From 84ab6d08b8f1633696ea389fd74789d0d3daaa7b Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 12 Apr 2024 10:27:21 +0200 Subject: [PATCH 0001/1532] feat(routing): make assignment mode auto when all dossiers are reaffected to defaut groupe. so that all dossiers can be re routed --- .../administrateurs/groupe_instructeurs_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/administrateurs/groupe_instructeurs_controller.rb b/app/controllers/administrateurs/groupe_instructeurs_controller.rb index 1a52105d9..318e502a6 100644 --- a/app/controllers/administrateurs/groupe_instructeurs_controller.rb +++ b/app/controllers/administrateurs/groupe_instructeurs_controller.rb @@ -212,7 +212,7 @@ module Administrateurs def reaffecter_all_dossiers_to_defaut_groupe procedure.groupe_instructeurs_but_defaut.each do |gi| gi.dossiers.find_each do |dossier| - dossier.assign_to_groupe_instructeur(procedure.defaut_groupe_instructeur, DossierAssignment.modes.fetch(:manual), current_administrateur) + dossier.assign_to_groupe_instructeur(procedure.defaut_groupe_instructeur, DossierAssignment.modes.fetch(:auto), current_administrateur) end end end From 21b6c687612e4fb905df89026f1d3b936f406c25 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 12 Apr 2024 10:28:31 +0200 Subject: [PATCH 0002/1532] fix re_routing rake tasks --- lib/tasks/re_routing_dossiers.rake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tasks/re_routing_dossiers.rake b/lib/tasks/re_routing_dossiers.rake index 93925f090..78133ffeb 100644 --- a/lib/tasks/re_routing_dossiers.rake +++ b/lib/tasks/re_routing_dossiers.rake @@ -18,7 +18,7 @@ namespace :re_routing_dossiers do dossiers.each do |dossier| RoutingEngine.compute(dossier, assignment_mode:) - rake_puts "Dossier #{args[:dossier_id]} routed to groupe instructeur #{dossier.groupe_instructeur.label}" + rake_puts "Dossier #{dossier.id} routed to groupe instructeur #{dossier.groupe_instructeur.label}" progress.inc end From 3349d729120b20acd8f94d2f4e7340f32e00167c Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 12 Apr 2024 10:37:26 +0200 Subject: [PATCH 0003/1532] task(routing): add a new task to reset forced_groupe_instructeur value --- lib/tasks/re_routing_dossiers.rake | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/tasks/re_routing_dossiers.rake b/lib/tasks/re_routing_dossiers.rake index 78133ffeb..c2f77b04f 100644 --- a/lib/tasks/re_routing_dossiers.rake +++ b/lib/tasks/re_routing_dossiers.rake @@ -24,4 +24,25 @@ namespace :re_routing_dossiers do end progress.finish end + + desc <<~EOD + Given an procedure id in argument, reset value of forced_groupe_instructeur to false for all dossiers en_construction. + ex: rails re_routing_dossiers:reset_forced_groupe_instructeur\[85869\] + EOD + task :reset_forced_groupe_instructeur, [:procedure_id] => :environment do |_t, args| + procedure = Procedure.find_by(id: args[:procedure_id]) + + dossiers = procedure.dossiers.state_en_construction + + progress = ProgressReport.new(dossiers.count) + + dossiers.each do |dossier| + if dossier.update(forced_groupe_instructeur: false) + rake_puts "Dossier #{dossier.id} updated with forced_groupe_instructeur to false" + + progress.inc + end + end + progress.finish + end end From 6644f8157fb941258601ef4579ee76353a8779fa Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 18 Apr 2024 10:21:32 +0200 Subject: [PATCH 0004/1532] fix(dossier): always use user_email_for instead of user.email --- app/mailers/resend_attestation_mailer.rb | 2 +- app/models/dossier_transfer.rb | 2 +- app/views/manager/dossiers/transfer_edit.html.erb | 2 +- .../notification_mailer/send_notification_for_tiers.html.haml | 2 +- app/views/users/dossiers/_dossiers_list.html.haml | 4 ++-- app/views/users/dossiers/papertrail.pdf.prawn | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/mailers/resend_attestation_mailer.rb b/app/mailers/resend_attestation_mailer.rb index 395eb8062..342fdc08e 100644 --- a/app/mailers/resend_attestation_mailer.rb +++ b/app/mailers/resend_attestation_mailer.rb @@ -2,7 +2,7 @@ class ResendAttestationMailer < ApplicationMailer include Rails.application.routes.url_helpers def resend_attestation(dossier) - to = dossier.user.email + to = dossier.user_email_for(:notification) subject = "Nouvelle attestation pour votre dossier nº #{dossier.id}" mail(to: to, subject: subject, body: body(dossier)) diff --git a/app/models/dossier_transfer.rb b/app/models/dossier_transfer.rb index 0783247fa..83e165cf7 100644 --- a/app/models/dossier_transfer.rb +++ b/app/models/dossier_transfer.rb @@ -28,7 +28,7 @@ class DossierTransfer < ApplicationRecord DossierTransferLog.create(transfer.dossiers.map do |dossier| { dossier: dossier, - from: dossier.user.email, + from: dossier.user_email_for(:notification), from_support: transfer.from_support, to: transfer.email } diff --git a/app/views/manager/dossiers/transfer_edit.html.erb b/app/views/manager/dossiers/transfer_edit.html.erb index 7fcb26555..ab45a4722 100644 --- a/app/views/manager/dossiers/transfer_edit.html.erb +++ b/app/views/manager/dossiers/transfer_edit.html.erb @@ -12,7 +12,7 @@
User
- <%= link_to @dossier.user.email, manager_user_path(@dossier.user) %> + <%= link_to @dossier.user_email_for(:notification), manager_user_path(@dossier.user) %>
Text summary
diff --git a/app/views/notification_mailer/send_notification_for_tiers.html.haml b/app/views/notification_mailer/send_notification_for_tiers.html.haml index 83c73e69d..565c80cc7 100644 --- a/app/views/notification_mailer/send_notification_for_tiers.html.haml +++ b/app/views/notification_mailer/send_notification_for_tiers.html.haml @@ -22,7 +22,7 @@ %p = t("layouts.mailers.for_tiers.second_part") - = "#{mail_to(@dossier.user.email)}." + = "#{mail_to(@dossier.user_email_for(:notification))}." %p = t(:best_regards, scope: [:views, :shared, :greetings]) diff --git a/app/views/users/dossiers/_dossiers_list.html.haml b/app/views/users/dossiers/_dossiers_list.html.haml index 8ab0cfa5f..556f6afc6 100644 --- a/app/views/users/dossiers/_dossiers_list.html.haml +++ b/app/views/users/dossiers/_dossiers_list.html.haml @@ -79,9 +79,9 @@ - c.with_body do %p - if dossier.transfer.from_support? - = t('views.users.dossiers.transfers.receiver_demande_en_cours_from_support', id: dossier.id, email: dossier.user.email) + = t('views.users.dossiers.transfers.receiver_demande_en_cours_from_support', id: dossier.id, email: dossier.user_email_for(:notification)) - else - = t('views.users.dossiers.transfers.receiver_demande_en_cours', id: dossier.id, email: dossier.user.email) + = t('views.users.dossiers.transfers.receiver_demande_en_cours', id: dossier.id, email: dossier.user_email_for(:notification)) %p = link_to t('views.users.dossiers.transfers.accept'), transfer_path(dossier.transfer), class: "fr-link fr-mr-1w", method: :put = link_to t('views.users.dossiers.transfers.reject'), transfer_path(dossier.transfer), class: "fr-link", method: :delete diff --git a/app/views/users/dossiers/papertrail.pdf.prawn b/app/views/users/dossiers/papertrail.pdf.prawn index 898163eac..705183dd3 100644 --- a/app/views/users/dossiers/papertrail.pdf.prawn +++ b/app/views/users/dossiers/papertrail.pdf.prawn @@ -52,7 +52,7 @@ prawn_document(margin: [top_margin, right_margin, bottom_margin, left_margin], p pdf.fill_color grey pdf.text "#{Individual.human_attribute_name(:prenom)} : #{@dossier.individual.prenom}", size: 10, character_spacing: -0.2, align: :justify pdf.text "#{Individual.human_attribute_name(:nom)} : #{@dossier.individual.nom.upcase}", size: 10, character_spacing: -0.2, align: :justify - pdf.text "#{User.human_attribute_name(:email)} : #{@dossier.user.email}", size: 10, character_spacing: -0.2, align: :justify + pdf.text "#{User.human_attribute_name(:email)} : #{@dossier.user_email_for(:display)}", size: 10, character_spacing: -0.2, align: :justify end end @@ -61,7 +61,7 @@ prawn_document(margin: [top_margin, right_margin, bottom_margin, left_margin], p pdf.fill_color grey pdf.text "Dénomination : " + raison_sociale_or_name(@dossier.etablissement), size: 10, character_spacing: -0.2, align: :justify pdf.text "SIRET : " + @dossier.etablissement.siret, size: 10, character_spacing: -0.2, align: :justify - pdf.text "#{User.human_attribute_name(:email)} : #{@dossier.user.email}", size: 10, character_spacing: -0.2, align: :justify + pdf.text "#{User.human_attribute_name(:email)} : #{@dossier.user_email_for(:display)}", size: 10, character_spacing: -0.2, align: :justify end end From 63aecdd85a4e21f2974401c92786aa05bda42fb0 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 18 Apr 2024 16:03:31 +0200 Subject: [PATCH 0005/1532] chore(sva): always enable card but form submit needs feature flag --- .../card/sva_svr_component/sva_svr_component.en.yml | 6 ++++-- .../card/sva_svr_component/sva_svr_component.fr.yml | 2 +- .../card/sva_svr_component/sva_svr_component.html.haml | 2 +- app/components/procedure/sva_svr_form_component.rb | 1 + .../sva_svr_form_component.html.haml | 9 ++++++++- app/views/administrateurs/procedures/show.html.haml | 2 +- 6 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/components/procedure/card/sva_svr_component/sva_svr_component.en.yml b/app/components/procedure/card/sva_svr_component/sva_svr_component.en.yml index f9aa71a10..8a4aa581f 100644 --- a/app/components/procedure/card/sva_svr_component/sva_svr_component.en.yml +++ b/app/components/procedure/card/sva_svr_component/sva_svr_component.en.yml @@ -1,4 +1,6 @@ --- en: - ready: "Configuré" - needs_configuration: "À configurer" + title: "Silence Vaut Accord ou Rejet" + subtitle: "Accept or Refuse a file after a deadline" + ready: "Configured" + disabled: "Disabled" diff --git a/app/components/procedure/card/sva_svr_component/sva_svr_component.fr.yml b/app/components/procedure/card/sva_svr_component/sva_svr_component.fr.yml index 18e6e9728..8ed547ab5 100644 --- a/app/components/procedure/card/sva_svr_component/sva_svr_component.fr.yml +++ b/app/components/procedure/card/sva_svr_component/sva_svr_component.fr.yml @@ -3,4 +3,4 @@ fr: title: "Silence Vaut Accord ou Rejet" subtitle: "Accepter ou Refuser un dossier après un délai" ready: "Configuré" - needs_configuration: "À configurer" + disabled: "Désactivé" diff --git a/app/components/procedure/card/sva_svr_component/sva_svr_component.html.haml b/app/components/procedure/card/sva_svr_component/sva_svr_component.html.haml index 2b4297749..e250e9153 100644 --- a/app/components/procedure/card/sva_svr_component/sva_svr_component.html.haml +++ b/app/components/procedure/card/sva_svr_component/sva_svr_component.html.haml @@ -4,7 +4,7 @@ - if @procedure.sva_svr_enabled? %p.fr-badge.fr-badge--success= t('.ready') - else - %p.fr-badge.fr-badge--info= t('.needs_configuration') + %p.fr-badge= t('.disabled') %h3.fr-h6.fr-mt-10v= t('.title') %p.fr-tile-subtitle= t('.subtitle') %p.fr-btn.fr-btn--tertiary= t('views.shared.actions.edit') diff --git a/app/components/procedure/sva_svr_form_component.rb b/app/components/procedure/sva_svr_form_component.rb index e39d740e6..f357a90ce 100644 --- a/app/components/procedure/sva_svr_form_component.rb +++ b/app/components/procedure/sva_svr_form_component.rb @@ -10,6 +10,7 @@ class Procedure::SVASVRFormComponent < ApplicationComponent def form_disabled? return false if procedure.brouillon? + return true if !procedure.feature_enabled?(:sva) procedure.sva_svr_enabled? end diff --git a/app/components/procedure/sva_svr_form_component/sva_svr_form_component.html.haml b/app/components/procedure/sva_svr_form_component/sva_svr_form_component.html.haml index fbd8287ba..121f939f8 100644 --- a/app/components/procedure/sva_svr_form_component/sva_svr_form_component.html.haml +++ b/app/components/procedure/sva_svr_form_component/sva_svr_form_component.html.haml @@ -1,5 +1,12 @@ = form_for [procedure, configuration], url: admin_procedure_sva_svr_path(procedure), method: :put do |f| - - if procedure.publiee? && !procedure.sva_svr_enabled? + - if !procedure.feature_enabled?(:sva) + .fr-alert.fr-alert--info.fr-alert--sm.fr-my-8w + %p + Pour activer le paramétrage de cette fonctionnalité, contactez-nous sur + = link_to CONTACT_EMAIL, "mailto:#{CONTACT_EMAIL}", **helpers.external_link_attributes + en indiquant votre numéro de démarche (#{@procedure.id}) et le cadre d’application du SVA/SVR. + + - elsif procedure.publiee? && !procedure.sva_svr_enabled? .fr-alert.fr-alert--info.fr-alert--sm.fr-mb-4w %p= t('.notice_new_files_only') diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml index 16ef212bf..2bd30c89c 100644 --- a/app/views/administrateurs/procedures/show.html.haml +++ b/app/views/administrateurs/procedures/show.html.haml @@ -85,7 +85,7 @@ = render Procedure::Card::AnnotationsComponent.new(procedure: @procedure) = render Procedure::Card::APIEntrepriseComponent.new(procedure: @procedure) = render Procedure::Card::APIParticulierComponent.new(procedure: @procedure) - = render Procedure::Card::SVASVRComponent.new(procedure: @procedure) if @procedure.sva_svr_enabled? || @procedure.feature_enabled?(:sva) + = render Procedure::Card::SVASVRComponent.new(procedure: @procedure) = render Procedure::Card::MonAvisComponent.new(procedure: @procedure) = render Procedure::Card::DossierSubmittedMessageComponent.new(procedure: @procedure) = render Procedure::Card::ChorusComponent.new(procedure: @procedure) From 8f8b63ac6acd8f9ce80276b1060dfae674e6b840 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 18 Apr 2024 11:18:53 +0200 Subject: [PATCH 0006/1532] fix: load the dols day by day --- app/jobs/cron/operations_signature_job.rb | 16 ++++---- .../cron/operations_signature_job_spec.rb | 40 +++++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 spec/jobs/cron/operations_signature_job_spec.rb diff --git a/app/jobs/cron/operations_signature_job.rb b/app/jobs/cron/operations_signature_job.rb index 605cea333..5decb0a8f 100644 --- a/app/jobs/cron/operations_signature_job.rb +++ b/app/jobs/cron/operations_signature_job.rb @@ -2,14 +2,16 @@ class Cron::OperationsSignatureJob < Cron::CronJob self.schedule_expression = "every day at 6 am" def perform(*args) + start_date = DossierOperationLog.where(bill_signature: nil).order(:executed_at).pick(:executed_at).beginning_of_day last_midnight = Time.zone.today.beginning_of_day - operations_by_day = BillSignatureService.grouped_unsigned_operation_until(last_midnight) - operations_by_day.each do |day, operations| - begin - BillSignatureService.sign_operations(operations, day) - rescue - raise # let errors show up on Sentry and delayed_jobs - end + + while start_date < last_midnight + operations = DossierOperationLog + .where(executed_at: start_date...start_date.tomorrow, bill_signature: nil) + + BillSignatureService.sign_operations(operations, start_date) if operations.present? + + start_date = start_date.tomorrow end end end diff --git a/spec/jobs/cron/operations_signature_job_spec.rb b/spec/jobs/cron/operations_signature_job_spec.rb new file mode 100644 index 000000000..94ce99f82 --- /dev/null +++ b/spec/jobs/cron/operations_signature_job_spec.rb @@ -0,0 +1,40 @@ +RSpec.describe Cron::OperationsSignatureJob, type: :job do + describe 'perform' do + subject { Cron::OperationsSignatureJob.perform_now } + + let(:today) { Time.zone.parse('2018-01-05 00:06:00') } + + before do + travel_to(today) + allow(BillSignatureService).to receive(:sign_operations) + end + + context "with dol without signature executed_at two_days_ago" do + let(:two_days_ago_00_30) { Time.zone.parse('2018-01-03 00:30:00') } + let(:two_days_ago_00_00) { Time.zone.parse('2018-01-03 00:00:00') } + + let(:one_day_ago_00_30) { Time.zone.parse('2018-01-04 00:30:00') } + let(:one_day_ago_00_00) { Time.zone.parse('2018-01-04 00:00:00') } + + let!(:dol_1) { create(:dossier_operation_log, executed_at: two_days_ago_00_30) } + let!(:dol_2) { create(:dossier_operation_log, executed_at: one_day_ago_00_30) } + + before { subject } + + it do + expect(BillSignatureService).to have_received(:sign_operations).exactly(2).times + expect(BillSignatureService).to have_received(:sign_operations).with([dol_1], two_days_ago_00_00) + expect(BillSignatureService).to have_received(:sign_operations).with([dol_2], one_day_ago_00_00) + end + end + + context "with dol without signature executed_at today past midnight" do + let(:today_00_01) { Time.zone.parse('2018-01-05 00:00:01') } + let!(:dol) { create(:dossier_operation_log, executed_at: today_00_01) } + + before { subject } + + it { expect(BillSignatureService).not_to have_received(:sign_operations) } + end + end +end From a10a59b2657ebda6af0d40d307514493ef1be64e Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 18 Apr 2024 11:19:51 +0200 Subject: [PATCH 0007/1532] perf: only load id digest --- app/jobs/cron/operations_signature_job.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/jobs/cron/operations_signature_job.rb b/app/jobs/cron/operations_signature_job.rb index 5decb0a8f..6985ac040 100644 --- a/app/jobs/cron/operations_signature_job.rb +++ b/app/jobs/cron/operations_signature_job.rb @@ -7,6 +7,7 @@ class Cron::OperationsSignatureJob < Cron::CronJob while start_date < last_midnight operations = DossierOperationLog + .select(:id, :digest) .where(executed_at: start_date...start_date.tomorrow, bill_signature: nil) BillSignatureService.sign_operations(operations, start_date) if operations.present? From 2745be5672a093311daa1032735259f531aa2ba7 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 18 Apr 2024 11:28:38 +0200 Subject: [PATCH 0008/1532] chore: remove old code --- app/services/bill_signature_service.rb | 8 ----- spec/services/bill_signature_service_spec.rb | 34 -------------------- 2 files changed, 42 deletions(-) diff --git a/app/services/bill_signature_service.rb b/app/services/bill_signature_service.rb index 7badb3399..685355e7b 100644 --- a/app/services/bill_signature_service.rb +++ b/app/services/bill_signature_service.rb @@ -1,12 +1,4 @@ class BillSignatureService - def self.grouped_unsigned_operation_until(date) - date = date.in_time_zone - unsigned_operations = DossierOperationLog - .where(bill_signature: nil) - .where('executed_at < ?', date) - unsigned_operations.group_by { |e| e.executed_at.to_date } - end - def self.sign_operations(operations, day) return unless Certigna::API.enabled? bill = BillSignature.build_with_operations(operations, day) diff --git a/spec/services/bill_signature_service_spec.rb b/spec/services/bill_signature_service_spec.rb index e9a0866ac..7f4f8c081 100644 --- a/spec/services/bill_signature_service_spec.rb +++ b/spec/services/bill_signature_service_spec.rb @@ -1,38 +1,4 @@ describe BillSignatureService do - describe ".grouped_unsigned_operation_until" do - subject { BillSignatureService.grouped_unsigned_operation_until(date).length } - - let(:date) { Time.zone.now.beginning_of_day } - - context "when operations of several days need to be signed" do - before do - create :dossier_operation_log, executed_at: 3.days.ago - create :dossier_operation_log, executed_at: 2.days.ago - create :dossier_operation_log, executed_at: 1.day.ago - end - - it { is_expected.to eq 3 } - end - - context "when operations on a single day need to be signed" do - before do - create :dossier_operation_log, executed_at: 1.day.ago - create :dossier_operation_log, executed_at: 1.day.ago - end - - it { is_expected.to eq 1 } - end - - context "when there are no operations to be signed" do - before do - create :dossier_operation_log, created_at: 1.day.ago, bill_signature: build(:bill_signature, :with_signature, :with_serialized) - create :dossier_operation_log, created_at: 1.day.from_now - end - - it { is_expected.to eq 0 } - end - end - describe ".sign_operations" do let(:date) { Date.today } From 45fa700561d2dd27d5f9d6a42f10be427861559a Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 19 Apr 2024 10:59:04 +0200 Subject: [PATCH 0009/1532] fix: choose dolist sender depending on the mail domain --- app/lib/dolist/api.rb | 10 ++++++---- config/env.example.optional | 1 + config/secrets.yml | 2 ++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/lib/dolist/api.rb b/app/lib/dolist/api.rb index 87e401a0a..cc0d44bae 100644 --- a/app/lib/dolist/api.rb +++ b/app/lib/dolist/api.rb @@ -190,9 +190,11 @@ module Dolist format(base, account_id: account_id) end - def sender_id - Rails.cache.fetch("dolist_api_sender_id", expires_in: 1.hour) do - senders.dig("ItemList", 0, "Sender", "ID") + def sender_id(domain) + if domain == "demarches.gouv.fr" + Rails.application.secrets.dolist[:gouv_sender_id] + else + Rails.application.secrets.dolist[:default_sender_id] end end @@ -267,7 +269,7 @@ module Dolist "Message": { "Name": mail['X-Dolist-Message-Name'].value, "Subject": mail.subject, - "SenderID": sender_id, + "SenderID": sender_id(mail.from_address.domain), "ForceHttp": false, # ForceHttp : force le tracking http non sécurisé (True/False). "Format": "html", "DisableOpenTracking": true, # DisableOpenTracking : désactivation du tracking d'ouverture (True/False). diff --git a/config/env.example.optional b/config/env.example.optional index df8621781..691b1b50e 100644 --- a/config/env.example.optional +++ b/config/env.example.optional @@ -137,6 +137,7 @@ MATOMO_IFRAME_URL="https://matomo.example.org/index.php?module=CoreAdminHome&act # DOLIST_ACCOUNT_ID="" # DOLIST_NO_REPLY_EMAIL="" # DOLIST_API_KEY="" +# DOLIST_DEFAULT_SENDER_ID="" # SMTP Provider: SIB (Brevo) # SENDINBLUE_SMTP_ADDRESS="" diff --git a/config/secrets.yml b/config/secrets.yml index efb986716..61cd28651 100644 --- a/config/secrets.yml +++ b/config/secrets.yml @@ -32,6 +32,8 @@ defaults: &defaults password: <%= ENV['DOLIST_PASSWORD'] %> account_id: <%= ENV['DOLIST_ACCOUNT_ID'] %> api_key: <%= ENV['DOLIST_API_KEY'] %> + default_sender_id: <%= ENV['DOLIST_DEFAULT_SENDER_ID'] || 1 %> + gouv_sender_id: <%= ENV['DOLIST_GOUV_SENDER_ID'] || 1 %> api_entreprise: key: <%= ENV['API_ENTREPRISE_KEY'] %> mailtrap: From 3dbb2266d23ce556758c08097ccf81588717679b Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 4 Apr 2024 16:17:04 +0200 Subject: [PATCH 0010/1532] chore(yabeda): add graphql metrics --- Gemfile | 1 + Gemfile.lock | 4 ++++ app/graphql/api/v2/schema.rb | 1 + 3 files changed, 6 insertions(+) diff --git a/Gemfile b/Gemfile index 03173c548..3fc9a385c 100644 --- a/Gemfile +++ b/Gemfile @@ -103,6 +103,7 @@ gem 'view_component' gem 'vite_rails' gem 'warden' gem 'webrick', require: false +gem 'yabeda-graphql' gem 'yabeda-prometheus' gem 'yabeda-sidekiq' gem 'zipline' diff --git a/Gemfile.lock b/Gemfile.lock index 9894c3dc0..6460eb6fc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -838,6 +838,9 @@ GEM anyway_config (>= 1.0, < 3) concurrent-ruby dry-initializer + yabeda-graphql (0.2.3) + graphql (>= 1.9, < 3) + yabeda (~> 0.2) yabeda-prometheus (0.9.1) prometheus-client (>= 3.0, < 5.0) rack @@ -997,6 +1000,7 @@ DEPENDENCIES web-console webmock webrick + yabeda-graphql yabeda-prometheus yabeda-sidekiq zipline diff --git a/app/graphql/api/v2/schema.rb b/app/graphql/api/v2/schema.rb index 6ff534a27..43741e10e 100644 --- a/app/graphql/api/v2/schema.rb +++ b/app/graphql/api/v2/schema.rb @@ -147,6 +147,7 @@ class API::V2::Schema < GraphQL::Schema use Timeout, max_seconds: 30 use GraphQL::Batch use GraphQL::Backtrace + use Yabeda::GraphQL if Rails.env.development? class LogQueryDepth < GraphQL::Analysis::AST::QueryDepth From fcbd3d94d1074f6bad9f6d989913755ea7687f19 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 19 Apr 2024 18:43:58 +0200 Subject: [PATCH 0011/1532] chore(yabeda): use webrick and check enabled flag --- README.md | 2 ++ config.ru | 4 ++++ config/env.example.optional | 2 +- config/initializers/prometheus_metrics.rb | 3 +++ 4 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 config/initializers/prometheus_metrics.rb diff --git a/README.md b/README.md index 9fedb71e5..834c11af7 100644 --- a/README.md +++ b/README.md @@ -192,3 +192,5 @@ La compatibilité est testée par Browserstack.
[ Date: Fri, 19 Apr 2024 19:00:55 +0200 Subject: [PATCH 0012/1532] chore(yabeda): add yabeda rails --- Gemfile | 2 ++ Gemfile.lock | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/Gemfile b/Gemfile index 3fc9a385c..9eab2f8cc 100644 --- a/Gemfile +++ b/Gemfile @@ -105,6 +105,8 @@ gem 'warden' gem 'webrick', require: false gem 'yabeda-graphql' gem 'yabeda-prometheus' +gem 'yabeda-puma-plugin' +gem 'yabeda-rails' gem 'yabeda-sidekiq' gem 'zipline' gem 'zxcvbn-ruby', require: 'zxcvbn' diff --git a/Gemfile.lock b/Gemfile.lock index 6460eb6fc..87f885794 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -845,6 +845,11 @@ GEM prometheus-client (>= 3.0, < 5.0) rack yabeda (~> 0.10) + yabeda-rails (0.9.0) + activesupport + anyway_config (>= 1.3, < 3) + railties + yabeda (~> 0.8) yabeda-sidekiq (0.12.0) anyway_config (>= 1.3, < 3) sidekiq @@ -1002,6 +1007,7 @@ DEPENDENCIES webrick yabeda-graphql yabeda-prometheus + yabeda-rails yabeda-sidekiq zipline zxcvbn-ruby From c5264973fc6e8653f429221d77a19c8ec18afbf3 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 19 Apr 2024 19:03:55 +0200 Subject: [PATCH 0013/1532] chore(yabeda): add yabeda puma --- Gemfile.lock | 5 +++++ config/puma.rb | 3 +++ 2 files changed, 8 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index 87f885794..e785eaead 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -845,6 +845,10 @@ GEM prometheus-client (>= 3.0, < 5.0) rack yabeda (~> 0.10) + yabeda-puma-plugin (0.7.1) + json + puma + yabeda (~> 0.5) yabeda-rails (0.9.0) activesupport anyway_config (>= 1.3, < 3) @@ -1007,6 +1011,7 @@ DEPENDENCIES webrick yabeda-graphql yabeda-prometheus + yabeda-puma-plugin yabeda-rails yabeda-sidekiq zipline diff --git a/config/puma.rb b/config/puma.rb index 8b9bb8681..470a8760d 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -43,3 +43,6 @@ end # Allow puma to be restarted by `rails restart` command. plugin :tmp_restart + +activate_control_app +plugin :yabeda From f02cb19fd4c042c49a332b1466b539caa21ff8a2 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 8 Apr 2024 10:23:07 +0200 Subject: [PATCH 0014/1532] feat(gallery): display pieces jointes in a gallery --- app/assets/images/apercu-indisponible.png | Bin 0 -> 9794 bytes app/assets/images/pdf-placeholder.png | Bin 0 -> 6912 bytes app/assets/stylesheets/gallery.scss | 66 ++++++++++++++++++ app/assets/stylesheets/icons.scss | 8 +++ .../instructeurs/dossiers_controller.rb | 7 ++ .../controllers/lightbox_controller.ts | 36 ++++++++++ .../dossiers/_header_bottom.html.haml | 4 ++ .../dossiers/pieces_jointes.html.haml | 29 ++++++++ config/locales/en.yml | 1 + config/locales/fr.yml | 1 + config/routes.rb | 1 + package.json | 1 + .../instructeurs/dossiers_controller_spec.rb | 25 +++++++ 13 files changed, 179 insertions(+) create mode 100644 app/assets/images/apercu-indisponible.png create mode 100644 app/assets/images/pdf-placeholder.png create mode 100644 app/assets/stylesheets/gallery.scss create mode 100644 app/javascript/controllers/lightbox_controller.ts create mode 100644 app/views/instructeurs/dossiers/pieces_jointes.html.haml diff --git a/app/assets/images/apercu-indisponible.png b/app/assets/images/apercu-indisponible.png new file mode 100644 index 0000000000000000000000000000000000000000..11d399fdac14eb080e60113a948d5bf90e99f17a GIT binary patch literal 9794 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 'dossiers#print' get 'telecharger_pjs' => 'dossiers#telecharger_pjs' get 'reaffectation' + get 'pieces_jointes' post 'reaffecter' end end diff --git a/package.json b/package.json index 0a2b30333..4186d3462 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "highcharts": "^10.3.3", "intersection-observer": "^0.12.2", "is-hotkey": "^0.2.0", + "lightgallery": "^2.7.2", "maplibre-gl": "^1.15.2", "match-sorter": "^6.3.4", "patch-package": "^7.0.0", diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index f72f52003..108c675ba 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -1372,4 +1372,29 @@ describe Instructeurs::DossiersController, type: :controller do it { expect(subject).to have_http_status(:ok) } end + + describe '#pieces_jointes' do + let(:procedure) { create(:procedure, :published, types_de_champ_public: [{ type: :piece_justificative }], instructeurs:) } + let(:dossier) { create(:dossier, :en_construction, :with_populated_champs, procedure: procedure) } + + before do + dossier.champs.first.piece_justificative_file.attach( + io: StringIO.new("image file"), + filename: "image.jpeg", + content_type: "image/jpeg", + # we don't want to run virus scanner on this file + metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE } + ) + get :pieces_jointes, params: { + procedure_id: procedure.id, + dossier_id: dossier.id + } + end + + it do + expect(response.body).to include('Télécharger le fichier toto.txt') + expect(response.body).to include('Télécharger le fichier image.jpeg') + expect(response.body).to include('Visualiser') + end + end end From 7c6c3608c93f13b93b9749c166b6721cc2be8f0d Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 5 Apr 2024 14:25:28 +0200 Subject: [PATCH 0015/1532] feat(gallery): use authorized content types --- .../dossiers/pieces_jointes.html.haml | 13 +++++++--- .../initializers/authorized_content_types.rb | 24 ++++++++++++------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml index 0384fbc6b..ce6cf923b 100644 --- a/app/views/instructeurs/dossiers/pieces_jointes.html.haml +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -8,8 +8,8 @@ - champ.piece_justificative_file.each do |attachment| .gallery-item - blob = attachment.blob - - if blob.content_type == 'application/pdf' - = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: "application/pdf", title: "#{champ.libelle} -- #{blob.filename}" do + - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) + = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do .thumbnail = image_tag("pdf-placeholder.png") .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } @@ -18,7 +18,7 @@ = champ.libelle = render Attachment::ShowComponent.new(attachment: attachment, new_tab: true) - - else + - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail = image_tag(blob.url) @@ -27,3 +27,10 @@ .champ-libelle = champ.libelle = render Attachment::ShowComponent.new(attachment: attachment, new_tab: true) + + - else + .thumbnail + = image_tag('apercu-indisponible.png') + .champ-libelle + = champ.libelle + = render Attachment::ShowComponent.new(attachment: attachment, new_tab: true) diff --git a/config/initializers/authorized_content_types.rb b/config/initializers/authorized_content_types.rb index fe5410266..e5af1c74f 100644 --- a/config/initializers/authorized_content_types.rb +++ b/config/initializers/authorized_content_types.rb @@ -1,15 +1,25 @@ -AUTHORIZED_CONTENT_TYPES = [ - # multimedia +AUTHORIZED_PDF_TYPES = [ + 'application/pdf', # text x 4628654 + 'application/x-pdf', # text x 30 + 'image/pdf', # text x 23 + 'text/pdf' # text x 12 +] + +AUTHORIZED_IMAGE_TYPES = [ 'image/jpeg', # multimedia x 1467465 'image/png', # multimedia x 126662 'image/tiff', # multimedia x 3985 'image/bmp', # multimedia x 3656 - 'video/mp4', # multimedia x 2075 'image/webp', # multimedia x 529 - 'video/quicktime', # multimedia x 486 'image/gif', # multimedia x 463 + 'image/vnd.dwg' # multimedia x 137 auto desk +] + +AUTHORIZED_CONTENT_TYPES = AUTHORIZED_IMAGE_TYPES + AUTHORIZED_PDF_TYPES + [ + # multimedia + 'video/mp4', # multimedia x 2075 + 'video/quicktime', # multimedia x 486 'video/3gpp', # multimedia x 216 - 'image/vnd.dwg', # multimedia x 137 auto desk 'audio/mpeg', # multimedia x 26 'video/x-ms-wm', # multimedia x 15 video microsoft ? 'audio/mp4', # audio .mp4, .m4a @@ -45,7 +55,6 @@ AUTHORIZED_CONTENT_TYPES = [ 'text/xml', # program x 10 # text / sheet / presentation - 'application/pdf', # text x 4628654 'application/vnd.ms-excel', # text x 166674 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', # text x 103879 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # text x 86336 @@ -69,18 +78,15 @@ AUTHORIZED_CONTENT_TYPES = [ 'application/vnd.ms-word.document.macroenabled.12', # text x 61 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', # text x 59 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', # text x 32 - 'application/x-pdf', # text x 30 'application/kswps', # inconnu x 26 , text ? 'application/x-iwork-numbers-sffnumbers', # text x 25 'text/rtf', # text x 25 - 'image/pdf', # text x 23 'application/vnd.ms-xpsdocument', # text x 23 'application/vnd.ms-excel.sheet.binary.macroenabled.12', # text x 21 'application/vnd.ms-powerpoint.presentation.macroenabled.12', # text x 15 'application/x-msword', # text x 15 'application/vnd.oasis.opendocument.spreadsheet-template', # text x 14 'application/vnd.oasis.opendocument.text-master', # text x 12 - 'text/pdf', # text x 12 'application/x-abiword', # text x 11 'application/x-iwork-keynote-sffnumbers', # text x 11 'application/x-iwork-keynote-sffkey', # text x 10 From 29dea52a7e840ce6f2964881be658e7af6845bb6 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 8 Apr 2024 10:13:05 +0200 Subject: [PATCH 0016/1532] chore: add truncate option to show and download component --- app/components/attachment/show_component.rb | 5 +-- .../show_component/show_component.html.haml | 2 +- app/components/dsfr/download_component.rb | 4 ++- .../download_component.html.haml | 2 +- .../dossiers/pieces_jointes.html.haml | 34 +++++++++---------- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/app/components/attachment/show_component.rb b/app/components/attachment/show_component.rb index 4bc8416e7..ca109617a 100644 --- a/app/components/attachment/show_component.rb +++ b/app/components/attachment/show_component.rb @@ -1,10 +1,11 @@ class Attachment::ShowComponent < ApplicationComponent - def initialize(attachment:, new_tab: false) + def initialize(attachment:, new_tab: false, truncate: false) @attachment = attachment @new_tab = new_tab + @truncate = truncate end - attr_reader :attachment, :new_tab + attr_reader :attachment, :new_tab, :truncate def should_display_link? (attachment.virus_scanner.safe? || !attachment.virus_scanner.started?) && !attachment.watermark_pending? diff --git a/app/components/attachment/show_component/show_component.html.haml b/app/components/attachment/show_component/show_component.html.haml index 49b9768df..f8bd81f44 100644 --- a/app/components/attachment/show_component/show_component.html.haml +++ b/app/components/attachment/show_component/show_component.html.haml @@ -1,6 +1,6 @@ %div{ id: dom_id(attachment, :show), class: class_names("attachment-error": error?, "fr-mb-2w": !should_display_link?) } - if should_display_link? - = render Dsfr::DownloadComponent.new(attachment: attachment, virus_not_analyzed: !attachment.virus_scanner.started?, new_tab: new_tab) + = render Dsfr::DownloadComponent.new(attachment: attachment, virus_not_analyzed: !attachment.virus_scanner.started?, new_tab: new_tab, truncate: truncate) - else .attachment-filename.fr-mb-1w.fr-mr-1w= attachment.filename.to_s diff --git a/app/components/dsfr/download_component.rb b/app/components/dsfr/download_component.rb index ef1e8862f..326c0865b 100644 --- a/app/components/dsfr/download_component.rb +++ b/app/components/dsfr/download_component.rb @@ -5,14 +5,16 @@ class Dsfr::DownloadComponent < ApplicationComponent attr_reader :ephemeral_link attr_reader :virus_not_analyzed attr_reader :new_tab + attr_reader :truncate - def initialize(attachment:, name: nil, url: nil, ephemeral_link: false, virus_not_analyzed: false, new_tab: false) + def initialize(attachment:, name: nil, url: nil, ephemeral_link: false, virus_not_analyzed: false, new_tab: false, truncate: false) @attachment = attachment @name = name || attachment.filename.to_s @url = url @ephemeral_link = ephemeral_link @virus_not_analyzed = virus_not_analyzed @new_tab = new_tab + @truncate = truncate end def title diff --git a/app/components/dsfr/download_component/download_component.html.haml b/app/components/dsfr/download_component/download_component.html.haml index 71d53624a..cd30a9f57 100644 --- a/app/components/dsfr/download_component/download_component.html.haml +++ b/app/components/dsfr/download_component/download_component.html.haml @@ -1,7 +1,7 @@ .fr-download %p = link_to url, {class: "fr-download__link", title: title}.merge(new_tab ? { target: '_blank' } : { download: '' }) do - = name + = truncate ? name.truncate(20) : name %span.fr-download__detail = helpers.download_details(attachment) - if ephemeral_link diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml index ce6cf923b..0575dcd4b 100644 --- a/app/views/instructeurs/dossiers/pieces_jointes.html.haml +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -15,22 +15,22 @@ .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } Visualiser .champ-libelle - = champ.libelle - = render Attachment::ShowComponent.new(attachment: attachment, new_tab: true) + = champ.libelle.truncate(25) + = render Attachment::ShowComponent.new(attachment: attachment, new_tab: true, truncate: true) - - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) - = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) + = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + .thumbnail + = image_tag(blob.url) + .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } + Visualiser + .champ-libelle + = champ.libelle.truncate(25) + = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) + + - else .thumbnail - = image_tag(blob.url) - .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } - Visualiser - .champ-libelle - = champ.libelle - = render Attachment::ShowComponent.new(attachment: attachment, new_tab: true) - - - else - .thumbnail - = image_tag('apercu-indisponible.png') - .champ-libelle - = champ.libelle - = render Attachment::ShowComponent.new(attachment: attachment, new_tab: true) + = image_tag('apercu-indisponible.png') + .champ-libelle + = champ.libelle.truncate(25) + = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) From 321d198f64b7a9dc511ed44a4da41614cdca87cc Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 8 Apr 2024 10:22:45 +0200 Subject: [PATCH 0017/1532] feat(gallery): add gallery to demande page --- app/assets/stylesheets/gallery.scss | 41 +++++++++++++++---- .../dossiers/pieces_jointes.html.haml | 32 ++++++++------- .../piece_justificative/_show.html.haml | 21 ++++++++-- app/views/shared/dossiers/_demande.html.haml | 2 +- 4 files changed, 71 insertions(+), 25 deletions(-) diff --git a/app/assets/stylesheets/gallery.scss b/app/assets/stylesheets/gallery.scss index 720505ca1..059a67128 100644 --- a/app/assets/stylesheets/gallery.scss +++ b/app/assets/stylesheets/gallery.scss @@ -1,11 +1,4 @@ .gallery { - display: flex; - flex-wrap: wrap; - - .gallery-item { - display: block; - } - a { background-image: none; } @@ -50,6 +43,40 @@ } } +.gallery-pieces-jointes { + display: flex; + flex-wrap: wrap; + + .gallery-item { + margin: 1.5rem 2rem; + } + + img { + height: 200px; + width: 200px; + } +} + +.gallery-demande { + img { + height: 150px; + width: 150px; + } + + .gallery-item { + margin-bottom: 2rem; + } + + .fr-download { + margin-bottom: 0.5rem; + } + + .thumbnail { + width: fit-content; + margin-bottom: 1rem; + } +} + .lg-has-iframe { width: 80% !important; margin-top: 50px; diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml index 0575dcd4b..e4ef2686a 100644 --- a/app/views/instructeurs/dossiers/pieces_jointes.html.haml +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -3,20 +3,24 @@ = render partial: "header", locals: { dossier: @dossier } .fr-container - .gallery{ "data-controller": "lightbox"} - - @champs_with_pieces_jointes.each do |champ| - - champ.piece_justificative_file.each do |attachment| - .gallery-item - - blob = attachment.blob - - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) - = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do - .thumbnail - = image_tag("pdf-placeholder.png") - .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } - Visualiser - .champ-libelle - = champ.libelle.truncate(25) - = render Attachment::ShowComponent.new(attachment: attachment, new_tab: true, truncate: true) + - if @champs_with_pieces_jointes.map(&:piece_justificative_file).flatten.none? + .empty-text + Ce dossier ne contient pas de pièces jointes + - else + .gallery.gallery-pieces-jointes{ "data-controller": "lightbox" } + - @champs_with_pieces_jointes.each do |champ| + - champ.piece_justificative_file.each do |attachment| + .gallery-item + - blob = attachment.blob + - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) + = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do + .thumbnail + = image_tag("pdf-placeholder.png") + .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } + Visualiser + .champ-libelle + = champ.libelle.truncate(25) + = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do diff --git a/app/views/shared/champs/piece_justificative/_show.html.haml b/app/views/shared/champs/piece_justificative/_show.html.haml index dd81dde84..9098e88fb 100644 --- a/app/views/shared/champs/piece_justificative/_show.html.haml +++ b/app/views/shared/champs/piece_justificative/_show.html.haml @@ -1,4 +1,19 @@ .fr-downloads-group - %ul - - champ.piece_justificative_file.attachments.each do |attachment| - %li= render Attachment::ShowComponent.new(attachment:, new_tab: true) + - champ.piece_justificative_file.attachments.each do |attachment| + %ul + %li= render Attachment::ShowComponent.new(attachment:, new_tab: true, truncate: true) + .gallery-item + - blob = attachment.blob + - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) + = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do + .thumbnail + = image_tag("pdf-placeholder.png") + .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } + = 'Visualiser' + + - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) + = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + .thumbnail + = image_tag(blob.url) + .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } + = 'Visualiser' diff --git a/app/views/shared/dossiers/_demande.html.haml b/app/views/shared/dossiers/_demande.html.haml index 4a7e9d1d9..6c6e6d601 100644 --- a/app/views/shared/dossiers/_demande.html.haml +++ b/app/views/shared/dossiers/_demande.html.haml @@ -2,7 +2,7 @@ - content_for(:notice_info) do = render partial: "shared/dossiers/france_connect_informations_notice", locals: { user_information: dossier.user.france_connect_informations.first } -.fr-container.counter-start-header-section.dossier-show{ class: class_names("dossier-show-instructeur" => profile =="instructeur") } +.fr-container.counter-start-header-section.dossier-show.gallery.gallery-demande{ class: class_names("dossier-show-instructeur" => profile =="instructeur"), "data-controller": "lightbox" } .fr-grid-row.fr-grid-row--center .fr-col-12.fr-col-xl-8 - if profile == 'instructeur' && dossier.termine_and_accuse_lecture? From a0b172566dd79f0eb71116ba9e9a905ec71cfe0b Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Mon, 15 Apr 2024 17:24:18 +0200 Subject: [PATCH 0018/1532] feat(gallery): small ui adjustments --- app/assets/images/apercu-indisponible.png | Bin 9794 -> 5870 bytes app/assets/images/pdf-placeholder.png | Bin 6912 -> 3581 bytes app/assets/stylesheets/gallery.scss | 10 ++++------ 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/assets/images/apercu-indisponible.png b/app/assets/images/apercu-indisponible.png index 11d399fdac14eb080e60113a948d5bf90e99f17a..83ed22706fb540ec63bd502381ffd732d2a51b09 100644 GIT binary patch literal 5870 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 Date: Sat, 20 Apr 2024 18:49:27 +0200 Subject: [PATCH 0019/1532] chore(sidekiq): move more jobs to sidekiq --- config/initializers/transition_to_sidekiq.rb | 28 ++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/config/initializers/transition_to_sidekiq.rb b/config/initializers/transition_to_sidekiq.rb index a9d34c7b3..419385827 100644 --- a/config/initializers/transition_to_sidekiq.rb +++ b/config/initializers/transition_to_sidekiq.rb @@ -63,5 +63,33 @@ if Rails.env.production? && SIDEKIQ_ENABLED class DossierOperationLogMoveToColdStorageBatchJob < ApplicationJob self.queue_adapter = :sidekiq end + + class BatchOperationEnqueueAllJob < ApplicationJob + self.queue_adapter = :sidekiq + end + + class BatchOperationProcessOneJob < ApplicationJob + self.queue_adapter = :sidekiq + end + + class TitreIdentiteWatermarkJob < ApplicationJob + self.queue_adapter = :sidekiq + end + + class AdminUpdateDefaultZonesJob < ApplicationJob + self.queue_adapter = :sidekiq + end + + class ProcessStalledDeclarativeDossierJob < ApplicationJob + self.queue_adapter = :sidekiq + end + + class ResetExpiringDossiersJob < ApplicationJob + self.queue_adapter = :sidekiq + end + + class SendClosingNotificationJob < ApplicationJob + self.queue_adapter = :sidekiq + end end end From cd808864b68e2ec604017d63778da5bd7bbbc445 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 22 Apr 2024 11:59:28 +0200 Subject: [PATCH 0020/1532] chore(spec): fix missing s in path --- .../{concern => concerns}/champ_conditional_concern_spec.rb | 0 spec/models/{concern => concerns}/dossier_clone_concern_spec.rb | 0 .../{concern => concerns}/dossier_correctable_concern_spec.rb | 0 .../{concern => concerns}/dossier_prefillable_concern_spec.rb | 0 spec/models/{concern => concerns}/dossier_rebase_concern_spec.rb | 0 .../{concern => concerns}/dossier_searchable_concern_spec.rb | 0 .../models/{concern => concerns}/dossier_sections_concern_spec.rb | 0 .../{concern => concerns}/email_sanitizable_concern_spec.rb | 0 .../{concern => concerns}/initiation_procedure_concern_spec.rb | 0 spec/models/{concern => concerns}/mail_template_concern_spec.rb | 0 spec/models/{concern => concerns}/procedure_stats_concern_spec.rb | 0 .../rna_champ_association_fetchable_concern_spec.rb | 0 .../siret_champ_etablissement_fetchable_concern_spec.rb | 0 .../{concern => concerns}/tags_substitution_concern_spec.rb | 0 spec/models/{concern => concerns}/treeable_concern_spec.rb | 0 15 files changed, 0 insertions(+), 0 deletions(-) rename spec/models/{concern => concerns}/champ_conditional_concern_spec.rb (100%) rename spec/models/{concern => concerns}/dossier_clone_concern_spec.rb (100%) rename spec/models/{concern => concerns}/dossier_correctable_concern_spec.rb (100%) rename spec/models/{concern => concerns}/dossier_prefillable_concern_spec.rb (100%) rename spec/models/{concern => concerns}/dossier_rebase_concern_spec.rb (100%) rename spec/models/{concern => concerns}/dossier_searchable_concern_spec.rb (100%) rename spec/models/{concern => concerns}/dossier_sections_concern_spec.rb (100%) rename spec/models/{concern => concerns}/email_sanitizable_concern_spec.rb (100%) rename spec/models/{concern => concerns}/initiation_procedure_concern_spec.rb (100%) rename spec/models/{concern => concerns}/mail_template_concern_spec.rb (100%) rename spec/models/{concern => concerns}/procedure_stats_concern_spec.rb (100%) rename spec/models/{concern => concerns}/rna_champ_association_fetchable_concern_spec.rb (100%) rename spec/models/{concern => concerns}/siret_champ_etablissement_fetchable_concern_spec.rb (100%) rename spec/models/{concern => concerns}/tags_substitution_concern_spec.rb (100%) rename spec/models/{concern => concerns}/treeable_concern_spec.rb (100%) diff --git a/spec/models/concern/champ_conditional_concern_spec.rb b/spec/models/concerns/champ_conditional_concern_spec.rb similarity index 100% rename from spec/models/concern/champ_conditional_concern_spec.rb rename to spec/models/concerns/champ_conditional_concern_spec.rb diff --git a/spec/models/concern/dossier_clone_concern_spec.rb b/spec/models/concerns/dossier_clone_concern_spec.rb similarity index 100% rename from spec/models/concern/dossier_clone_concern_spec.rb rename to spec/models/concerns/dossier_clone_concern_spec.rb diff --git a/spec/models/concern/dossier_correctable_concern_spec.rb b/spec/models/concerns/dossier_correctable_concern_spec.rb similarity index 100% rename from spec/models/concern/dossier_correctable_concern_spec.rb rename to spec/models/concerns/dossier_correctable_concern_spec.rb diff --git a/spec/models/concern/dossier_prefillable_concern_spec.rb b/spec/models/concerns/dossier_prefillable_concern_spec.rb similarity index 100% rename from spec/models/concern/dossier_prefillable_concern_spec.rb rename to spec/models/concerns/dossier_prefillable_concern_spec.rb diff --git a/spec/models/concern/dossier_rebase_concern_spec.rb b/spec/models/concerns/dossier_rebase_concern_spec.rb similarity index 100% rename from spec/models/concern/dossier_rebase_concern_spec.rb rename to spec/models/concerns/dossier_rebase_concern_spec.rb diff --git a/spec/models/concern/dossier_searchable_concern_spec.rb b/spec/models/concerns/dossier_searchable_concern_spec.rb similarity index 100% rename from spec/models/concern/dossier_searchable_concern_spec.rb rename to spec/models/concerns/dossier_searchable_concern_spec.rb diff --git a/spec/models/concern/dossier_sections_concern_spec.rb b/spec/models/concerns/dossier_sections_concern_spec.rb similarity index 100% rename from spec/models/concern/dossier_sections_concern_spec.rb rename to spec/models/concerns/dossier_sections_concern_spec.rb diff --git a/spec/models/concern/email_sanitizable_concern_spec.rb b/spec/models/concerns/email_sanitizable_concern_spec.rb similarity index 100% rename from spec/models/concern/email_sanitizable_concern_spec.rb rename to spec/models/concerns/email_sanitizable_concern_spec.rb diff --git a/spec/models/concern/initiation_procedure_concern_spec.rb b/spec/models/concerns/initiation_procedure_concern_spec.rb similarity index 100% rename from spec/models/concern/initiation_procedure_concern_spec.rb rename to spec/models/concerns/initiation_procedure_concern_spec.rb diff --git a/spec/models/concern/mail_template_concern_spec.rb b/spec/models/concerns/mail_template_concern_spec.rb similarity index 100% rename from spec/models/concern/mail_template_concern_spec.rb rename to spec/models/concerns/mail_template_concern_spec.rb diff --git a/spec/models/concern/procedure_stats_concern_spec.rb b/spec/models/concerns/procedure_stats_concern_spec.rb similarity index 100% rename from spec/models/concern/procedure_stats_concern_spec.rb rename to spec/models/concerns/procedure_stats_concern_spec.rb diff --git a/spec/models/concern/rna_champ_association_fetchable_concern_spec.rb b/spec/models/concerns/rna_champ_association_fetchable_concern_spec.rb similarity index 100% rename from spec/models/concern/rna_champ_association_fetchable_concern_spec.rb rename to spec/models/concerns/rna_champ_association_fetchable_concern_spec.rb diff --git a/spec/models/concern/siret_champ_etablissement_fetchable_concern_spec.rb b/spec/models/concerns/siret_champ_etablissement_fetchable_concern_spec.rb similarity index 100% rename from spec/models/concern/siret_champ_etablissement_fetchable_concern_spec.rb rename to spec/models/concerns/siret_champ_etablissement_fetchable_concern_spec.rb diff --git a/spec/models/concern/tags_substitution_concern_spec.rb b/spec/models/concerns/tags_substitution_concern_spec.rb similarity index 100% rename from spec/models/concern/tags_substitution_concern_spec.rb rename to spec/models/concerns/tags_substitution_concern_spec.rb diff --git a/spec/models/concern/treeable_concern_spec.rb b/spec/models/concerns/treeable_concern_spec.rb similarity index 100% rename from spec/models/concern/treeable_concern_spec.rb rename to spec/models/concerns/treeable_concern_spec.rb From c479d46b4739167da5256cbb5869b34c66c668f9 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 18 Apr 2024 10:09:42 +0200 Subject: [PATCH 0021/1532] refactor(champs): extract dossier champ related methods in to a concern --- app/models/concerns/dossier_champs_concern.rb | 56 +++++++++++++++++++ app/models/dossier.rb | 52 +---------------- 2 files changed, 57 insertions(+), 51 deletions(-) create mode 100644 app/models/concerns/dossier_champs_concern.rb diff --git a/app/models/concerns/dossier_champs_concern.rb b/app/models/concerns/dossier_champs_concern.rb new file mode 100644 index 000000000..9bd0cbd5c --- /dev/null +++ b/app/models/concerns/dossier_champs_concern.rb @@ -0,0 +1,56 @@ +module DossierChampsConcern + extend ActiveSupport::Concern + + def champs_for_revision(scope: nil, root: false) + champs_index = champs.group_by(&:stable_id) + # Due to some bad data we can have multiple copies of the same champ. Ignore extra copy. + .transform_values { _1.sort_by(&:id).uniq(&:row_id) } + + if scope.is_a?(TypeDeChamp) + revision + .children_of(scope) + .flat_map { champs_index[_1.stable_id] || [] } + .filter(&:child?) # TODO: remove once bad data (child champ without a row id) is cleaned + else + revision + .types_de_champ_for(scope:, root:) + .flat_map { champs_index[_1.stable_id] || [] } + end + end + + # Get all the champs values for the types de champ in the final list. + # Dossier might not have corresponding champ – display nil. + # To do so, we build a virtual champ when there is no value so we can call for_export with all indexes + def champs_for_export(types_de_champ, row_id = nil) + types_de_champ.flat_map do |type_de_champ| + champ = champ_for_export(type_de_champ, row_id) + type_de_champ.libelles_for_export.map do |(libelle, path)| + [libelle, champ&.for_export(path)] + end + end + end + + def project_champ(type_de_champ, row_id) + champ = champs_by_public_id[type_de_champ.public_id(row_id)] + if champ.nil? + type_de_champ.build_champ(dossier: self, row_id:) + else + champ + end + end + + private + + def champs_by_public_id + @champs_by_public_id ||= champs.sort_by(&:id).index_by(&:public_id) + end + + def champ_for_export(type_de_champ, row_id) + champ = champs_by_public_id[type_de_champ.public_id(row_id)] + if champ.blank? || !champ.visible? + nil + else + champ + end + end +end diff --git a/app/models/dossier.rb b/app/models/dossier.rb index d2aafd943..334d883a7 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -9,6 +9,7 @@ class Dossier < ApplicationRecord include DossierSearchableConcern include DossierSectionsConcern include DossierStateConcern + include DossierChampsConcern enum state: { brouillon: 'brouillon', @@ -1036,18 +1037,6 @@ class Dossier < ApplicationRecord columns + champs_for_export(types_de_champ) end - # Get all the champs values for the types de champ in the final list. - # Dossier might not have corresponding champ – display nil. - # To do so, we build a virtual champ when there is no value so we can call for_export with all indexes - def champs_for_export(types_de_champ, row_id = nil) - types_de_champ.flat_map do |type_de_champ| - champ = champ_for_export(type_de_champ, row_id) - type_de_champ.libelles_for_export.map do |(libelle, path)| - [libelle, champ&.for_export(path)] - end - end - end - def linked_dossiers_for(instructeur_or_expert) dossier_ids = champs_for_revision.filter(&:dossier_link?).filter_map(&:value) instructeur_or_expert.dossiers.where(id: dossier_ids) @@ -1141,45 +1130,10 @@ class Dossier < ApplicationRecord user.france_connected_with_one_identity? end - def champs_for_revision(scope: nil, root: false) - champs_index = champs.group_by(&:stable_id) - # Due to some bad data we can have multiple copies of the same champ. Ignore extra copy. - .transform_values { _1.sort_by(&:id).uniq(&:row_id) } - - if scope.is_a?(TypeDeChamp) - revision - .children_of(scope) - .flat_map { champs_index[_1.stable_id] || [] } - .filter(&:child?) # TODO: remove once bad data (child champ without a row id) is cleaned - else - revision - .types_de_champ_for(scope:, root:) - .flat_map { champs_index[_1.stable_id] || [] } - end - end - def has_annotations? revision.revision_types_de_champ_private.present? end - def project_champ(type_de_champ, row_id) - champ = champs_by_public_id[type_de_champ.public_id(row_id)] - if champ.nil? - type_de_champ.build_champ(dossier: self, row_id:) - else - champ - end - end - - def champ_for_export(type_de_champ, row_id) - champ = champs_by_public_id[type_de_champ.public_id(row_id)] - if champ.blank? || !champ.visible? - nil - else - champ - end - end - def hide_info_with_accuse_lecture? procedure.accuse_lecture? && termine? && accuse_lecture_agreement_at.blank? end @@ -1190,10 +1144,6 @@ class Dossier < ApplicationRecord private - def champs_by_public_id - @champs_by_public_id ||= champs.sort_by(&:id).index_by(&:public_id) - end - def create_missing_traitemets if en_construction_at.present? && traitements.en_construction.empty? self.traitements.passer_en_construction(processed_at: en_construction_at) From 0c7bc6b5556de6a3e02610d32cb405d1caece17c Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 27 Mar 2024 11:31:41 +0100 Subject: [PATCH 0022/1532] feat(dossier): add methods to upsert champ values --- app/models/concerns/dossier_champs_concern.rb | 75 +++++ .../concerns/dossier_prefillable_concern.rb | 4 - .../concern/dossier_champs_concern_spec.rb | 294 ++++++++++++++++++ spec/models/dossier_spec.rb | 35 --- 4 files changed, 369 insertions(+), 39 deletions(-) create mode 100644 spec/models/concern/dossier_champs_concern_spec.rb diff --git a/app/models/concerns/dossier_champs_concern.rb b/app/models/concerns/dossier_champs_concern.rb index 9bd0cbd5c..ab5a7545e 100644 --- a/app/models/concerns/dossier_champs_concern.rb +++ b/app/models/concerns/dossier_champs_concern.rb @@ -39,6 +39,46 @@ module DossierChampsConcern end end + def find_type_de_champ_by_stable_id(stable_id, scope = nil) + case scope + when :public + revision.types_de_champ.public_only + when :private + revision.types_de_champ.private_only + else + revision.types_de_champ + end.find_by!(stable_id:) + end + + def champs_for_prefill(stable_ids) + revision + .types_de_champ + .filter { _1.stable_id.in?(stable_ids) } + .filter { !revision.child?(_1) } + .map { champ_for_update(_1, nil) } + end + + def champ_for_update(type_de_champ, row_id) + champ, attributes = champ_with_attributes_for_update(type_de_champ, row_id) + champ.assign_attributes(attributes) + champ + end + + def update_champs_attributes(attributes, scope) + # TODO: remove after one deploy + if attributes.present? && attributes.values.filter { _1.key?(:with_public_id) }.empty? + assign_attributes("champs_#{scope}_all_attributes".to_sym => attributes) + @champs_by_public_id = nil + return + end + + champs_attributes = attributes.to_h.map do |public_id, attributes| + champ_attributes_by_public_id(public_id, attributes, scope) + end + + assign_attributes(champs_attributes:) + end + private def champs_by_public_id @@ -53,4 +93,39 @@ module DossierChampsConcern champ end end + + def champ_attributes_by_public_id(public_id, attributes, scope) + stable_id, row_id = public_id.split('-') + type_de_champ = find_type_de_champ_by_stable_id(stable_id, scope) + champ_with_attributes_for_update(type_de_champ, row_id).last.merge(attributes) + end + + def champ_with_attributes_for_update(type_de_champ, row_id) + attributes = type_de_champ.params_for_champ + # TODO: Once we have the right index in place, we should change this to use `create_or_find_by` instead of `find_or_create_by` + champ = champs + .create_with(type_de_champ:, **attributes) + .find_or_create_by!(stable_id: type_de_champ.stable_id, row_id:) + + attributes[:id] = champ.id + + # Needed when a revision change the champ type in this case, we reset the champ data + if champ.type != attributes[:type] + attributes[:value] = nil + attributes[:value_json] = nil + attributes[:external_id] = nil + attributes[:data] = nil + end + + parent = revision.parent_of(type_de_champ) + if parent.present? + attributes[:parent] = champs.find { _1.type_de_champ_id == parent.id } + else + attributes[:parent] = nil + end + + @champs_by_public_id = nil + + [champ, attributes] + end end diff --git a/app/models/concerns/dossier_prefillable_concern.rb b/app/models/concerns/dossier_prefillable_concern.rb index bbcfdc8e1..d0c21a2bf 100644 --- a/app/models/concerns/dossier_prefillable_concern.rb +++ b/app/models/concerns/dossier_prefillable_concern.rb @@ -13,8 +13,4 @@ module DossierPrefillableConcern assign_attributes(attributes) save(validate: false) end - - def find_champs_by_stable_ids(stable_ids) - champs.joins(:type_de_champ).where(types_de_champ: { stable_id: stable_ids.compact.uniq }) - end end diff --git a/spec/models/concern/dossier_champs_concern_spec.rb b/spec/models/concern/dossier_champs_concern_spec.rb new file mode 100644 index 000000000..f0d2a0357 --- /dev/null +++ b/spec/models/concern/dossier_champs_concern_spec.rb @@ -0,0 +1,294 @@ +RSpec.describe DossierChampsConcern do + let(:procedure) do + create(:procedure, types_de_champ_public:, types_de_champ_private:) + end + let(:types_de_champ_public) do + [ + { type: :text, libelle: "Un champ text", stable_id: 99 }, + { type: :text, libelle: "Un autre champ text", stable_id: 991 }, + { type: :yes_no, libelle: "Un champ yes no", stable_id: 992 }, + { type: :repetition, libelle: "Un champ répétable", stable_id: 993, mandatory: true, children: [{ type: :text, libelle: 'Nom', stable_id: 994 }] } + ] + end + let(:types_de_champ_private) do + [ + { type: :text, libelle: "Une annotation", stable_id: 995 } + ] + end + let(:dossier) { create(:dossier, procedure:) } + + describe "#find_type_de_champ_by_stable_id(public)" do + subject { dossier.find_type_de_champ_by_stable_id(992, :public) } + + it { is_expected.to be_truthy } + end + + describe "#find_type_de_champ_by_stable_id(private)" do + subject { dossier.find_type_de_champ_by_stable_id(995, :private) } + + it { is_expected.to be_truthy } + end + + describe "#project_champ" do + let(:type_de_champ_repetition) { dossier.find_type_de_champ_by_stable_id(993) } + let(:type_de_champ_public) { dossier.find_type_de_champ_by_stable_id(99) } + let(:type_de_champ_private) { dossier.find_type_de_champ_by_stable_id(995) } + let(:row_ids) { dossier.project_champ(type_de_champ_repetition, nil).row_ids } + + context "public champ" do + let(:row_id) { nil } + subject { dossier.project_champ(type_de_champ_public, row_id) } + + it { expect(subject.persisted?).to be_truthy } + + context "in repetition" do + let(:type_de_champ_public) { dossier.find_type_de_champ_by_stable_id(994) } + let(:row_id) { row_ids.first } + + it { + expect(subject.persisted?).to be_truthy + expect(subject.row_id).to eq(row_id) + expect(subject.parent_id).not_to be_nil + } + end + + context "missing champ" do + before { dossier; Champs::TextChamp.destroy_all } + + it { + expect(subject.new_record?).to be_truthy + expect(subject.is_a?(Champs::TextChamp)).to be_truthy + } + + context "in repetition" do + let(:type_de_champ_public) { dossier.find_type_de_champ_by_stable_id(994) } + let(:row_id) { row_ids.first } + + it { + expect(subject.new_record?).to be_truthy + expect(subject.is_a?(Champs::TextChamp)).to be_truthy + expect(subject.row_id).to eq(row_id) + } + end + end + end + + context "private champ" do + subject { dossier.project_champ(type_de_champ_private, nil) } + + it { expect(subject.persisted?).to be_truthy } + + context "missing champ" do + before { dossier; Champs::TextChamp.destroy_all } + + it { + expect(subject.new_record?).to be_truthy + expect(subject.is_a?(Champs::TextChamp)).to be_truthy + } + end + end + end + + describe "#champs_for_export" do + subject { dossier.champs_for_export(dossier.revision.types_de_champ_public) } + + it { expect(subject.size).to eq(4) } + it { expect(subject.first).to eq(["Un champ text", nil]) } + end + + describe "#champs_for_prefill" do + subject { dossier.champs_for_prefill([991, 995]) } + + it { + expect(subject.size).to eq(2) + expect(subject.map(&:libelle)).to eq(["Une annotation", "Un autre champ text"]) + expect(subject.all?(&:persisted?)).to be_truthy + } + + context "missing champ" do + before { dossier; Champs::TextChamp.destroy_all } + + it { + expect(subject.size).to eq(2) + expect(subject.map(&:libelle)).to eq(["Une annotation", "Un autre champ text"]) + expect(subject.all?(&:persisted?)).to be_truthy + } + end + end + + describe "#champ_for_update" do + let(:type_de_champ_repetition) { dossier.find_type_de_champ_by_stable_id(993) } + let(:type_de_champ_public) { dossier.find_type_de_champ_by_stable_id(99) } + let(:type_de_champ_private) { dossier.find_type_de_champ_by_stable_id(995) } + let(:row_ids) { dossier.project_champ(type_de_champ_repetition, nil).row_ids } + let(:row_id) { nil } + + context "public champ" do + subject { dossier.champ_for_update(type_de_champ_public, row_id) } + + it { + expect(subject.persisted?).to be_truthy + expect(subject.row_id).to eq(row_id) + } + + context "in repetition" do + let(:type_de_champ_public) { dossier.find_type_de_champ_by_stable_id(994) } + let(:row_id) { row_ids.first } + + it { + expect(subject.persisted?).to be_truthy + expect(subject.row_id).to eq(row_id) + expect(subject.parent_id).not_to be_nil + } + end + + context "missing champ" do + before { dossier; Champs::TextChamp.destroy_all } + + it { + expect(subject.persisted?).to be_truthy + expect(subject.is_a?(Champs::TextChamp)).to be_truthy + } + + context "in repetition" do + let(:type_de_champ_public) { dossier.find_type_de_champ_by_stable_id(994) } + let(:row_id) { row_ids.first } + + it { + expect(subject.persisted?).to be_truthy + expect(subject.is_a?(Champs::TextChamp)).to be_truthy + expect(subject.row_id).to eq(row_id) + expect(subject.parent_id).not_to be_nil + } + end + end + end + + context "private champ" do + subject { dossier.champ_for_update(type_de_champ_private, row_id) } + + it { + expect(subject.persisted?).to be_truthy + expect(subject.row_id).to eq(row_id) + } + end + end + + describe "#update_champs_attributes(public)" do + let(:type_de_champ_repetition) { dossier.find_type_de_champ_by_stable_id(993) } + let(:row_ids) { dossier.project_champ(type_de_champ_repetition, nil).row_ids } + let(:row_id) { row_ids.first } + + let(:attributes) do + { + "99" => { value: "Hello", with_public_id: true }, + "991" => { value: "World", with_public_id: true }, + "994-#{row_id}" => { value: "Greer", with_public_id: true } + } + end + + let(:champ_99) { dossier.project_champ(dossier.find_type_de_champ_by_stable_id(99), nil) } + let(:champ_991) { dossier.project_champ(dossier.find_type_de_champ_by_stable_id(991), nil) } + let(:champ_994) { dossier.project_champ(dossier.find_type_de_champ_by_stable_id(994), row_id) } + + subject { dossier.update_champs_attributes(attributes, :public) } + + it { + subject + expect(dossier.champs.any?(&:changed_for_autosave?)).to be_truthy + expect(champ_99.changed?).to be_truthy + expect(champ_991.changed?).to be_truthy + expect(champ_994.changed?).to be_truthy + expect(champ_99.value).to eq("Hello") + expect(champ_991.value).to eq("World") + expect(champ_994.value).to eq("Greer") + } + + context "missing champs" do + before { dossier; Champs::TextChamp.destroy_all; } + + it { + subject + expect(dossier.champs.any?(&:changed_for_autosave?)).to be_truthy + expect(champ_99.changed?).to be_truthy + expect(champ_991.changed?).to be_truthy + expect(champ_994.changed?).to be_truthy + expect(champ_99.value).to eq("Hello") + expect(champ_991.value).to eq("World") + expect(champ_994.value).to eq("Greer") + } + end + + context 'legacy attributes' do + let(:attributes) do + { + champ_99.id => { value: "Hello", id: champ_99.id }, + champ_991.id => { value: "World", id: champ_991.id }, + champ_994.id => { value: "Greer", id: champ_994.id } + } + end + + let(:champ_99_updated) { dossier.project_champ(dossier.find_type_de_champ_by_stable_id(99), nil) } + let(:champ_991_updated) { dossier.project_champ(dossier.find_type_de_champ_by_stable_id(991), nil) } + let(:champ_994_updated) { dossier.project_champ(dossier.find_type_de_champ_by_stable_id(994), row_id) } + + it { + subject + expect(dossier.champs_public_all.any?(&:changed_for_autosave?)).to be_truthy + dossier.save + dossier.reload + expect(champ_99_updated.value).to eq("Hello") + expect(champ_991_updated.value).to eq("World") + expect(champ_994_updated.value).to eq("Greer") + } + end + end + + describe "#update_champs_attributes(private)" do + let(:attributes) do + { + "995" => { value: "Hello", with_public_id: true } + } + end + + let(:annotation_995) { dossier.project_champ(dossier.find_type_de_champ_by_stable_id(995), nil) } + + subject { dossier.update_champs_attributes(attributes, :private) } + + it { + subject + expect(dossier.champs.any?(&:changed_for_autosave?)).to be_truthy + expect(annotation_995.changed?).to be_truthy + expect(annotation_995.value).to eq("Hello") + } + + context "missing champs" do + before { dossier; Champs::TextChamp.destroy_all; } + + it { + subject + expect(dossier.champs.any?(&:changed_for_autosave?)).to be_truthy + expect(annotation_995.changed?).to be_truthy + expect(annotation_995.value).to eq("Hello") + } + end + + context 'legacy attributes' do + let(:attributes) do + { + annotation_995.id => { value: "Hello", id: annotation_995.id } + } + end + + let(:annotation_995_updated) { dossier.project_champ(dossier.find_type_de_champ_by_stable_id(995), nil) } + + it { + subject + expect(dossier.champs_private_all.any?(&:changed_for_autosave?)).to be_truthy + dossier.save + dossier.reload + expect(annotation_995_updated.value).to eq("Hello") + } + end + end +end diff --git a/spec/models/dossier_spec.rb b/spec/models/dossier_spec.rb index 8a05debe4..f1fbe3ab5 100644 --- a/spec/models/dossier_spec.rb +++ b/spec/models/dossier_spec.rb @@ -2183,41 +2183,6 @@ describe Dossier, type: :model do end end - describe '#find_champs_by_stable_ids' do - let(:procedure) { create(:procedure, :published) } - let(:dossier) { create(:dossier, :brouillon, procedure: procedure) } - - subject { dossier.find_champs_by_stable_ids(stable_ids) } - - context 'when stable_ids is empty' do - let(:stable_ids) { [] } - - it { expect(subject).to match([]) } - end - - context 'when stable_ids contains nil or blank values' do - let(:stable_ids) { [nil, ""] } - - it { expect(subject).to match([]) } - end - - context 'when stable_ids contains present values' do - context 'when the dossier has no champ with the given stable ids' do - let(:stable_ids) { ['My Neighbor Totoro', 'Miyazaki'] } - - it { expect(subject).to match([]) } - end - - context 'when the dossier has champs with the given stable ids' do - let!(:type_de_champ_1) { create(:type_de_champ_text, procedure: procedure) } - let!(:type_de_champ_2) { create(:type_de_champ_textarea, procedure: procedure) } - let(:stable_ids) { [type_de_champ_1.stable_id, type_de_champ_2.stable_id] } - - it { expect(subject).to match_array(dossier.champs_public.joins(:type_de_champ).where(types_de_champ: { stable_id: stable_ids })) } - end - end - end - describe 'BatchOperation' do subject { build(:dossier) } it { is_expected.to belong_to(:batch_operation).optional } From 25e009df78c4d3f5348d1a7df1da5c049f81eb4e Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 27 Mar 2024 11:35:22 +0100 Subject: [PATCH 0023/1532] refactor(prefill): use champ_for_update method --- app/models/prefill_champs.rb | 2 +- .../types_de_champ/prefill_repetition_type_de_champ.rb | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/models/prefill_champs.rb b/app/models/prefill_champs.rb index f013c96d4..12d9c8ca0 100644 --- a/app/models/prefill_champs.rb +++ b/app/models/prefill_champs.rb @@ -23,7 +23,7 @@ class PrefillChamps .to_h dossier - .find_champs_by_stable_ids(value_by_stable_id.keys) + .champs_for_prefill(value_by_stable_id.keys) .map { |champ| [champ, value_by_stable_id[champ.stable_id]] } .map { |champ, value| PrefillValue.new(champ:, value:, dossier:) } end diff --git a/app/models/types_de_champ/prefill_repetition_type_de_champ.rb b/app/models/types_de_champ/prefill_repetition_type_de_champ.rb index 7ff8076ed..e90457e4b 100644 --- a/app/models/types_de_champ/prefill_repetition_type_de_champ.rb +++ b/app/models/types_de_champ/prefill_repetition_type_de_champ.rb @@ -54,14 +54,17 @@ class TypesDeChamp::PrefillRepetitionTypeDeChamp < TypesDeChamp::PrefillTypeDeCh def to_assignable_attributes return unless repetition.is_a?(Hash) - row = champ.rows[index] || champ.add_row(champ.dossier_revision) + row = champ.rows[index] || champ.add_row(revision) + row_id = row.first.row_id repetition.map do |key, value| next unless key.is_a?(String) && key.starts_with?("champ_") - subchamp = row.find { |champ| champ.stable_id == Champ.stable_id_from_typed_id(key) } - next unless subchamp + stable_id = Champ.stable_id_from_typed_id(key) + type_de_champ = revision.types_de_champ.find { _1.stable_id == stable_id } + next unless type_de_champ + subchamp = champ.dossier.champ_for_update(type_de_champ, row_id) TypesDeChamp::PrefillTypeDeChamp.build(subchamp.type_de_champ, revision).to_assignable_attributes(subchamp, value) end.compact end From fbd48abc9c58994ade094786ac097b7413c1da32 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 27 Mar 2024 11:35:42 +0100 Subject: [PATCH 0024/1532] refactor(graphql): use champ_for_update method --- app/graphql/mutations/dossier_modifier_annotation.rb | 8 +++++--- .../dossier_modifier_annotation_ajouter_ligne.rb | 10 ++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/graphql/mutations/dossier_modifier_annotation.rb b/app/graphql/mutations/dossier_modifier_annotation.rb index 46f8bea3e..30dbd6f7e 100644 --- a/app/graphql/mutations/dossier_modifier_annotation.rb +++ b/app/graphql/mutations/dossier_modifier_annotation.rb @@ -39,10 +39,12 @@ module Mutations def find_annotation(dossier, annotation_id) stable_id, row_id = Champ.decode_typed_id(annotation_id) + type_de_champ = dossier.revision.types_de_champ + .private_only + .find_by(type_champ: annotation_type_champ, stable_id:) - Champ.joins(:type_de_champ).find_by(type_de_champ: { - type_champ: annotation_type_champ, stable_id:, private: true - }, private: true, row_id:, dossier:) + return nil if type_de_champ.nil? + dossier.champ_for_update(type_de_champ, row_id) end def annotation_type_champ diff --git a/app/graphql/mutations/dossier_modifier_annotation_ajouter_ligne.rb b/app/graphql/mutations/dossier_modifier_annotation_ajouter_ligne.rb index ef146581a..260cb2734 100644 --- a/app/graphql/mutations/dossier_modifier_annotation_ajouter_ligne.rb +++ b/app/graphql/mutations/dossier_modifier_annotation_ajouter_ligne.rb @@ -26,11 +26,13 @@ module Mutations private def find_annotation(dossier, annotation_id) - stable_id, row_id = Champ.decode_typed_id(annotation_id) + stable_id, _row_id = Champ.decode_typed_id(annotation_id) + type_de_champ = dossier.revision.types_de_champ + .private_only + .find_by(type_champ: TypeDeChamp.type_champs.fetch(:repetition), stable_id:) - Champ.joins(:type_de_champ).find_by(type_de_champ: { - type_champ: TypeDeChamp.type_champs.fetch(:repetition), stable_id:, private: true - }, private: true, row_id:, dossier:) + return nil if type_de_champ.nil? + dossier.project_champ(type_de_champ, nil) end end end From ee57d005077eba7b3d28f4bc86108a964f108194 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 27 Mar 2024 11:37:30 +0100 Subject: [PATCH 0025/1532] refactor(champs): add new champ find strategy --- app/controllers/champs/champ_controller.rb | 22 ++++++ app/policies/dossier_policy.rb | 39 +++++++++++ spec/policies/dossier_policy_spec.rb | 79 ++++++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 app/controllers/champs/champ_controller.rb create mode 100644 app/policies/dossier_policy.rb create mode 100644 spec/policies/dossier_policy_spec.rb diff --git a/app/controllers/champs/champ_controller.rb b/app/controllers/champs/champ_controller.rb new file mode 100644 index 000000000..4322055be --- /dev/null +++ b/app/controllers/champs/champ_controller.rb @@ -0,0 +1,22 @@ +class Champs::ChampController < ApplicationController + before_action :authenticate_logged_user! + before_action :set_champ + + private + + def find_champ + if params[:champ_id].present? + policy_scope(Champ) + .includes(:type_de_champ, dossier: :champs) + .find(params[:champ_id]) + else + dossier = policy_scope(Dossier).includes(:champs, revision: [:types_de_champ]).find(params[:dossier_id]) + type_de_champ = dossier.revision.types_de_champ.find_by!(stable_id: params[:stable_id]) + dossier.champ_for_update(type_de_champ, params[:row_id]) + end + end + + def set_champ + @champ = find_champ + end +end diff --git a/app/policies/dossier_policy.rb b/app/policies/dossier_policy.rb new file mode 100644 index 000000000..c50b43f0f --- /dev/null +++ b/app/policies/dossier_policy.rb @@ -0,0 +1,39 @@ +class DossierPolicy < ApplicationPolicy + # Scope for WRITING to a dossier. + # + # (If the need for a scope to READ a dossier emerges, we can implement another scope + # in this file, following this example: https://github.com/varvet/pundit/issues/368#issuecomment-196111115) + class Scope < ApplicationScope + def resolve + if user.blank? + return scope.none + end + + # The join must be the same for all elements of the WHERE clause. + # + # NB: here we want to do `.left_outer_joins(:invites, { :groupe_instructeur: :instructeurs })`, + # but for some reasons ActiveRecord <= 5.2 generates bogus SQL. Hence the manual version of it below. + joined_scope = scope + .joins('LEFT OUTER JOIN invites ON invites.dossier_id = dossiers.id OR invites.dossier_id = dossiers.editing_fork_origin_id') + .joins('LEFT OUTER JOIN groupe_instructeurs ON groupe_instructeurs.id = dossiers.groupe_instructeur_id') + .joins('LEFT OUTER JOIN assign_tos ON assign_tos.groupe_instructeur_id = groupe_instructeurs.id') + .joins('LEFT OUTER JOIN instructeurs ON instructeurs.id = assign_tos.instructeur_id') + + # Users can access public champs on their own dossiers. + resolved_scope = joined_scope.where(user_id: user.id) + + # Invited users can access public champs on dossiers they are invited to + invite_clause = joined_scope.where('invites.user_id': user.id) + resolved_scope = resolved_scope.or(invite_clause) + + if instructeur.present? + # Additionnaly, instructeurs can access private champs + # on dossiers they are allowed to instruct. + instructeur_clause = joined_scope.where('instructeurs.id': instructeur.id) + resolved_scope = resolved_scope.or(instructeur_clause) + end + + resolved_scope.or(joined_scope.where(for_procedure_preview: true)) + end + end +end diff --git a/spec/policies/dossier_policy_spec.rb b/spec/policies/dossier_policy_spec.rb new file mode 100644 index 000000000..dc0a25aca --- /dev/null +++ b/spec/policies/dossier_policy_spec.rb @@ -0,0 +1,79 @@ +describe DossierPolicy do + let(:procedure) { create(:procedure, :with_type_de_champ, :with_type_de_champ_private) } + let(:dossier) { create(:dossier, procedure: procedure, user: dossier_owner) } + let(:dossier_owner) { create(:user) } + + let(:signed_in_user) { create(:user) } + let(:account) { { user: signed_in_user } } + + subject { Pundit.policy_scope(account, Dossier) } + + shared_examples_for 'they can access dossier' do + it { expect(subject.find_by(id: dossier.id)).to eq(dossier) } + end + + shared_examples_for 'they can’t access dossier' do + it { expect(subject.find_by(id: dossier.id)).to eq(nil) } + end + + context 'when an user only has user rights' do + context 'as the dossier owner' do + let(:signed_in_user) { dossier_owner } + + it_behaves_like 'they can access dossier' + end + + context 'as a person invited on the dossier' do + let(:invite) { create(:invite, :with_user, dossier: dossier) } + let(:signed_in_user) { invite.user } + + it_behaves_like 'they can access dossier' + end + + context 'as another user' do + let(:signed_in_user) { create(:user) } + + it_behaves_like 'they can’t access dossier' + end + end + + context 'when the user also has instruction rights' do + let(:instructeur) { create(:instructeur, user: signed_in_user) } + let(:account) { { user: signed_in_user, instructeur: instructeur } } + + context 'as the dossier instructeur and owner' do + let(:signed_in_user) { dossier_owner } + before { instructeur.assign_to_procedure(dossier.procedure) } + + it_behaves_like 'they can access dossier' + end + + context 'as the dossier instructeur (but not owner)' do + let(:signed_in_user) { create(:user) } + before { instructeur.assign_to_procedure(dossier.procedure) } + + it_behaves_like 'they can access dossier' + end + + context 'as an instructeur not assigned to the procedure' do + let(:signed_in_user) { create(:user) } + + it_behaves_like 'they can’t access dossier' + end + end + + context 'when the champ is on a forked dossier' do + let(:signed_in_user) { dossier_owner } + let(:origin) { create(:dossier, procedure: procedure, user: dossier_owner) } + let(:dossier) { origin.find_or_create_editing_fork(dossier_owner) } + + it_behaves_like 'they can access dossier' + + context 'when the user is invited on the origin dossier' do + let(:invite) { create(:invite, :with_user, dossier: origin) } + let(:signed_in_user) { invite.user } + + it_behaves_like 'they can access dossier' + end + end +end From 1021a31f7bd635bbcbc722b8bdae1429a7672cae Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 27 Mar 2024 11:39:15 +0100 Subject: [PATCH 0026/1532] refactor(dossier): use update_champs_private and update_champs_public methods --- .../concerns/turbo_champs_concern.rb | 9 +- .../instructeurs/dossiers_controller.rb | 49 ++++++++--- app/controllers/users/dossiers_controller.rb | 31 +++++-- app/models/champ.rb | 3 + .../instructeurs/dossiers_controller_spec.rb | 87 +++++++++++++++---- .../users/dossiers_controller_spec.rb | 28 +++--- 6 files changed, 155 insertions(+), 52 deletions(-) diff --git a/app/controllers/concerns/turbo_champs_concern.rb b/app/controllers/concerns/turbo_champs_concern.rb index af5be0438..1a8fc819f 100644 --- a/app/controllers/concerns/turbo_champs_concern.rb +++ b/app/controllers/concerns/turbo_champs_concern.rb @@ -4,9 +4,14 @@ module TurboChampsConcern private def champs_to_turbo_update(params, champs) - champ_ids = params.keys.map(&:to_i) + to_update = if params.values.filter { _1.key?(:with_public_id) }.empty? + champ_ids = params.keys.map(&:to_i) + champs.filter { _1.id.in?(champ_ids) } + else + champ_public_ids = params.keys + champs.filter { _1.public_id.in?(champ_public_ids) } + end.filter { _1.refresh_after_update? || _1.forked_with_changes? } - to_update = champs.filter { _1.id.in?(champ_ids) && (_1.refresh_after_update? || _1.forked_with_changes?) } to_show, to_hide = champs.filter(&:conditional?) .partition(&:visible?) .map { champs_to_one_selector(_1 - to_update) } diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index 51c45ebe6..212a6c8b0 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -273,8 +273,8 @@ module Instructeurs end def update_annotations - dossier_with_champs.assign_attributes(champs_private_params) - if dossier.champs_private_all.any?(&:changed?) + dossier_with_champs.update_champs_attributes(champs_private_attributes_params, :private) + if dossier.champs.any?(&:changed_for_autosave?) || dossier.champs_private_all.any?(&:changed_for_autosave?) # TODO remove second condition after one deploy dossier.last_champ_private_updated_at = Time.zone.now end @@ -282,7 +282,7 @@ module Instructeurs respond_to do |format| format.turbo_stream do - @to_show, @to_hide, @to_update = champs_to_turbo_update(champs_private_params.fetch(:champs_private_all_attributes), dossier.champs_private_all) + @to_show, @to_hide, @to_update = champs_to_turbo_update(champs_private_attributes_params, dossier.champs.filter(&:private?)) end end end @@ -294,7 +294,12 @@ module Instructeurs def annotation @dossier = dossier_with_champs(pj_template: false) - annotation = @dossier.champs_private_all.find(params[:annotation_id]) + annotation = if params[:with_public_id].present? + type_de_champ = @dossier.find_type_de_champ_by_stable_id(params[:annotation_id], :private) + @dossier.project_champ(type_de_champ, params[:row_id]) + else + @dossier.champs_private_all.find(params[:annotation_id]) + end respond_to do |format| format.turbo_stream do @@ -392,12 +397,36 @@ module Instructeurs end def champs_private_params - champs_params = params.require(:dossier).permit(champs_private_attributes: [ - :id, :value, :primary_value, :secondary_value, :piece_justificative_file, :value_other, :external_id, :numero_allocataire, :code_postal, :code_departement, value: [], - champs_attributes: [:id, :_destroy, :value, :primary_value, :secondary_value, :piece_justificative_file, :value_other, :external_id, :numero_allocataire, :code_postal, :code_departement, :feature, value: []] - ]) - champs_params[:champs_private_all_attributes] = champs_params.delete(:champs_private_attributes) || {} - champs_params + champ_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, + :accreditation_number, + :accreditation_birthdate, + :feature, + :with_public_id, + value: [] + ] + # Strong attributes do not support records (indexed hash); they only support hashes with + # static keys. We create a static hash based on the available keys. + public_ids = params.dig(:dossier, :champs_private_attributes)&.keys || [] + champs_private_attributes = public_ids.map { [_1, champ_attributes] }.to_h + params.require(:dossier).permit(champs_private_attributes:) + end + + def champs_private_attributes_params + champs_private_params.fetch(:champs_private_attributes) end def mark_demande_as_read diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index 00234d794..06dd6d742 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -284,7 +284,7 @@ module Users end format.turbo_stream do - @to_show, @to_hide, @to_update = champs_to_turbo_update(champs_public_params.fetch(:champs_public_all_attributes), dossier.champs_public_all) + @to_show, @to_hide, @to_update = champs_to_turbo_update(champs_public_attributes_params, dossier.champs.filter(&:public?)) render :update, layout: false end end @@ -298,7 +298,7 @@ module Users respond_to do |format| format.turbo_stream do - @to_show, @to_hide, @to_update = champs_to_turbo_update(champs_public_params.fetch(:champs_public_all_attributes), dossier.champs_public_all) + @to_show, @to_hide, @to_update = champs_to_turbo_update(champs_public_attributes_params, dossier.champs.filter(&:public?)) render :update, layout: false end end @@ -310,7 +310,12 @@ module Users def champ @dossier = dossier_with_champs(pj_template: false) - champ = @dossier.champs_public_all.find(params[:champ_id]) + champ = if params[:with_public_id].present? + type_de_champ = @dossier.find_type_de_champ_by_stable_id(params[:champ_id], :public) + @dossier.project_champ(type_de_champ, params[:row_id]) + else + @dossier.champs_public_all.find(params[:champ_id]) + end respond_to do |format| format.turbo_stream do @@ -478,7 +483,7 @@ module Users end def champs_public_params - champs_params = params.require(:dossier).permit(champs_public_attributes: [ + champ_attributes = [ :id, :value, :value_other, @@ -496,10 +501,18 @@ module Users :accreditation_number, :accreditation_birthdate, :feature, + :with_public_id, value: [] - ]) - champs_params[:champs_public_all_attributes] = champs_params.delete(:champs_public_attributes) || {} - champs_params + ] + # Strong attributes do not support records (indexed hash); they only support hashes with + # static keys. We create a static hash based on the available keys. + public_ids = params.dig(:dossier, :champs_public_attributes)&.keys || [] + champs_public_attributes = public_ids.map { [_1, champ_attributes] }.to_h + params.require(:dossier).permit(champs_public_attributes:) + end + + def champs_public_attributes_params + champs_public_params.fetch(:champs_public_attributes) end def dossier_scope @@ -532,8 +545,8 @@ module Users end def update_dossier_and_compute_errors - @dossier.assign_attributes(champs_public_params) - if @dossier.champs_public_all.any?(&:changed_for_autosave?) + @dossier.update_champs_attributes(champs_public_attributes_params, :public) + if @dossier.champs.any?(&:changed_for_autosave?) || @dossier.champs_public_all.any?(&:changed_for_autosave?) # TODO remove second condition after one deploy @dossier.last_champ_updated_at = Time.zone.now end diff --git a/app/models/champ.rb b/app/models/champ.rb index b3672d9f0..fc7ee4aa1 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -2,6 +2,9 @@ class Champ < ApplicationRecord include ChampConditionalConcern include ChampsValidateConcern + # TODO: remove after one deploy + attr_writer :with_public_id + belongs_to :dossier, inverse_of: false, touch: true, optional: false belongs_to :type_de_champ, inverse_of: :champ, optional: false belongs_to :parent, class_name: 'Champ', optional: true diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index 108c675ba..f4f065948 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -1005,25 +1005,25 @@ describe Instructeurs::DossiersController, type: :controller do dossier_id: dossier.id, dossier: { champs_private_attributes: { - '0': { - id: champ_multiple_drop_down_list.id, + champ_multiple_drop_down_list.public_id => { + with_public_id: true, value: ['', 'val1', 'val2'] }, - '1': { - id: champ_datetime.id, + champ_datetime.public_id => { + with_public_id: true, value: '2019-12-21T13:17' }, - '2': { - id: champ_linked_drop_down_list.id, + champ_linked_drop_down_list.public_id => { + with_public_id: true, primary_value: 'primary', secondary_value: 'secondary' }, - '3': { - id: champ_repetition.champs.first.id, + champ_repetition.champs.first.public_id => { + with_public_id: true, value: 'text' }, - '4': { - id: champ_drop_down_list.id, + champ_drop_down_list.public_id => { + with_public_id: true, value: '__other__', value_other: 'other value' } @@ -1074,12 +1074,11 @@ describe Instructeurs::DossiersController, type: :controller do end end - context 'with invalid champs_public (DecimalNumberChamp)' do - let(:types_de_champ_public) do - [ - { type: :decimal_number } - ] - end + after do + Timecop.return + end + + context "with new values for champs_private (legacy)" do let(:params) do { procedure_id: procedure.id, @@ -1095,9 +1094,63 @@ describe Instructeurs::DossiersController, type: :controller do } end + it 'update champs_private' do + patch :update_annotations, params: params, format: :turbo_stream + champ_datetime.reload + expect(champ_datetime.value).to eq(Time.zone.parse('2024-03-30T07:03:00').iso8601) + end + end + + context "without new values for champs_private" do + let(:params) do + { + procedure_id: procedure.id, + dossier_id: dossier.id, + dossier: { + champs_private_attributes: {}, + champs_public_attributes: { + champ_multiple_drop_down_list.public_id => { + with_public_id: true, + value: ['', 'val1', 'val2'] + } + } + } + } + end + + it { + expect(dossier.reload.last_champ_private_updated_at).to eq(nil) + expect(response).to have_http_status(200) + } + end + + context "with invalid champs_public (DecimalNumberChamp)" do + let(:types_de_champ_public) do + [ + { type: :decimal_number } + ] + end + + let(:champ_decimal_number) { dossier.champs_public.first } + + let(:params) do + { + procedure_id: procedure.id, + dossier_id: dossier.id, + dossier: { + champs_private_attributes: { + champ_datetime.public_id => { + with_public_id: true, + value: '2024-03-30T07:03' + } + } + } + } + end + it 'update champs_private' do too_long_float = '3.1415' - dossier.champs_public.first.update_column(:value, too_long_float) + champ_decimal_number.update_column(:value, too_long_float) patch :update_annotations, params: params, format: :turbo_stream champ_datetime.reload expect(champ_datetime.value).to eq(Time.zone.parse('2024-03-30T07:03:00').iso8601) diff --git a/spec/controllers/users/dossiers_controller_spec.rb b/spec/controllers/users/dossiers_controller_spec.rb index 5253ba54c..ad99422a4 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -674,12 +674,12 @@ describe Users::DossiersController, type: :controller do dossier: { groupe_instructeur_id: dossier.groupe_instructeur_id, champs_public_attributes: { - first_champ.id => { - id: first_champ.id, + first_champ.public_id => { + with_public_id: true, value: value }, - piece_justificative_champ.id => { - id: piece_justificative_champ.id, + piece_justificative_champ.public_id => { + with_public_id: true, piece_justificative_file: file } } @@ -719,7 +719,7 @@ describe Users::DossiersController, type: :controller do { id: dossier.id, dossier: { - champs_public_attributes: { first_champ.id => { id: first_champ.id } } + champs_public_attributes: { first_champ.public_id => { with_public_id: true } } } } end @@ -747,7 +747,7 @@ describe Users::DossiersController, type: :controller do { id: dossier.id, dossier: { - champs_public_attributes: { first_champ.id => { id: first_champ.id, value: value } } + champs_public_attributes: { first_champ.public_id => { with_public_id: true, value: value } } } } end @@ -790,12 +790,12 @@ describe Users::DossiersController, type: :controller do dossier: { groupe_instructeur_id: dossier.groupe_instructeur_id, champs_public_attributes: { - first_champ.id => { - id: first_champ.id, + first_champ.public_id => { + with_public_id: true, value: value }, - piece_justificative_champ.id => { - id: piece_justificative_champ.id, + piece_justificative_champ.public_id => { + with_public_id: true, piece_justificative_file: file } } @@ -855,8 +855,8 @@ describe Users::DossiersController, type: :controller do id: dossier.id, dossier: { champs_public_attributes: { - piece_justificative_champ.id => { - id: piece_justificative_champ.id, + piece_justificative_champ.public_id => { + with_public_id: true, piece_justificative_file: file } } @@ -951,8 +951,8 @@ describe Users::DossiersController, type: :controller do id: dossier.id, dossier: { champs_public_attributes: { - first_champ.id => { - id: first_champ.id, + first_champ.public_id => { + with_public_id: true, value: value } } From 741712141a28665ee6d1a62eb32e297ecae8de79 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 15 Apr 2024 15:06:05 +0200 Subject: [PATCH 0027/1532] refactor(champs): add new champs controllers urls with stable_id and row_id --- app/controllers/application_controller.rb | 16 -------------- app/controllers/champs/carte_controller.rb | 20 ++++++----------- app/controllers/champs/champ_controller.rb | 8 +++++-- app/controllers/champs/options_controller.rb | 13 +++++------ .../champs/piece_justificative_controller.rb | 9 +------- .../champs/repetition_controller.rb | 22 +++++-------------- app/controllers/champs/rna_controller.rb | 8 +++---- app/controllers/champs/siret_controller.rb | 9 ++++---- app/models/champs/repetition_champ.rb | 9 ++++++++ .../repetition/remove.turbo_stream.haml | 8 ++----- config/routes.rb | 16 ++++++++++++-- .../champs/repetition_controller_spec.rb | 5 +++-- .../controllers/champs/rna_controller_spec.rb | 4 ++-- .../champs/siret_controller_spec.rb | 7 +++--- 14 files changed, 66 insertions(+), 88 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7584990cb..9b0a6ffef 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -430,22 +430,6 @@ class ApplicationController < ActionController::Base controller_instance.try(:nav_bar_profile) end - # Extract a value from params based on the "path" - # - # params: { dossiers: { champs_public_attributes: { 1234 => { value: "hello" } } } } - # - # Usage: read_param_value("dossiers[champs_public_attributes][1234]", "value") - def read_param_value(path, name) - parts = path.split(/\[|\]\[|\]/) + [name] - parts.reduce(params) do |value, part| - if part.to_i != 0 - value[part.to_i] || value[part] - else - value[part] - end - end - end - def cast_bool(value) ActiveRecord::Type::Boolean.new.deserialize(value) end diff --git a/app/controllers/champs/carte_controller.rb b/app/controllers/champs/carte_controller.rb index dee8e5853..d2fe38e77 100644 --- a/app/controllers/champs/carte_controller.rb +++ b/app/controllers/champs/carte_controller.rb @@ -1,19 +1,15 @@ -class Champs::CarteController < ApplicationController - before_action :authenticate_logged_user! - +class Champs::CarteController < Champs::ChampController def index - @champ = policy_scope(Champ).find(params[:champ_id]) @focus = params[:focus].present? end def create - champ = policy_scope(Champ).find(params[:champ_id]) geo_area = if params_source == GeoArea.sources.fetch(:cadastre) - champ.geo_areas.find_by("properties->>'id' = :id", id: create_params_feature[:properties][:id]) + @champ.geo_areas.find_by("properties->>'id' = :id", id: create_params_feature[:properties][:id]) end if geo_area.nil? - geo_area = champ.geo_areas.build(source: params_source, properties: {}) + geo_area = @champ.geo_areas.build(source: params_source, properties: {}) if save_feature(geo_area, create_params_feature) render json: { feature: geo_area.to_feature }, status: :created @@ -26,8 +22,7 @@ class Champs::CarteController < ApplicationController end def update - champ = policy_scope(Champ).find(params[:champ_id]) - geo_area = champ.geo_areas.find(params[:id]) + geo_area = @champ.geo_areas.find(params[:id]) if save_feature(geo_area, update_params_feature) head :no_content @@ -37,9 +32,8 @@ class Champs::CarteController < ApplicationController end def destroy - champ = policy_scope(Champ).find(params[:champ_id]) - champ.geo_areas.find(params[:id]).destroy! - champ.touch + @champ.geo_areas.find(params[:id]).destroy! + @champ.touch head :no_content end @@ -82,7 +76,7 @@ class Champs::CarteController < ApplicationController geo_area.properties.merge!(feature[:properties]) end if geo_area.save - geo_area.champ.touch + @champ.touch true end end diff --git a/app/controllers/champs/champ_controller.rb b/app/controllers/champs/champ_controller.rb index 4322055be..5c03ee863 100644 --- a/app/controllers/champs/champ_controller.rb +++ b/app/controllers/champs/champ_controller.rb @@ -11,11 +11,15 @@ class Champs::ChampController < ApplicationController .find(params[:champ_id]) else dossier = policy_scope(Dossier).includes(:champs, revision: [:types_de_champ]).find(params[:dossier_id]) - type_de_champ = dossier.revision.types_de_champ.find_by!(stable_id: params[:stable_id]) - dossier.champ_for_update(type_de_champ, params[:row_id]) + type_de_champ = dossier.find_type_de_champ_by_stable_id(params[:stable_id]) + dossier.champ_for_update(type_de_champ, params_row_id) end end + def params_row_id + params[:row_id] + end + def set_champ @champ = find_champ end diff --git a/app/controllers/champs/options_controller.rb b/app/controllers/champs/options_controller.rb index 0338700aa..909a4f4be 100644 --- a/app/controllers/champs/options_controller.rb +++ b/app/controllers/champs/options_controller.rb @@ -1,13 +1,10 @@ -class Champs::OptionsController < ApplicationController +class Champs::OptionsController < Champs::ChampController include TurboChampsConcern - before_action :authenticate_logged_user! - def remove - champ = policy_scope(Champ).includes(:champs).find(params[:champ_id]) - champ.remove_option([params[:option]].compact, true) - champs = champ.private? ? champ.dossier.champs_private_all : champ.dossier.champs_public_all - @dossier = champ.private? ? nil : champ.dossier - @to_show, @to_hide, @to_update = champs_to_turbo_update({ params[:champ_id] => true }, champs) + @champ.remove_option([params[:option]].compact, true) + @dossier = @champ.private? ? nil : @champ.dossier + champs_attributes = params[:champ_id].present? ? { @champ.id => true } : { @champ.public_id => { with_public_id: true } } + @to_show, @to_hide, @to_update = champs_to_turbo_update(champs_attributes, @champ.dossier.champs) end end diff --git a/app/controllers/champs/piece_justificative_controller.rb b/app/controllers/champs/piece_justificative_controller.rb index 20d865492..43d1b8134 100644 --- a/app/controllers/champs/piece_justificative_controller.rb +++ b/app/controllers/champs/piece_justificative_controller.rb @@ -1,7 +1,4 @@ -class Champs::PieceJustificativeController < ApplicationController - before_action :authenticate_logged_user! - before_action :set_champ - +class Champs::PieceJustificativeController < Champs::ChampController def show respond_to do |format| format.turbo_stream @@ -23,10 +20,6 @@ class Champs::PieceJustificativeController < ApplicationController private - def set_champ - @champ = policy_scope(Champ).find(params[:champ_id]) - end - def attach_piece_justificative save_succeed = nil diff --git a/app/controllers/champs/repetition_controller.rb b/app/controllers/champs/repetition_controller.rb index 998dc00cb..6a09dd275 100644 --- a/app/controllers/champs/repetition_controller.rb +++ b/app/controllers/champs/repetition_controller.rb @@ -1,8 +1,5 @@ -class Champs::RepetitionController < ApplicationController - before_action :authenticate_logged_user! - +class Champs::RepetitionController < Champs::ChampController def add - @champ = find_champ row = @champ.add_row(@champ.dossier.revision) @first_champ_id = row.map(&:focusable_input_id).compact.first @row_id = row.first&.row_id @@ -10,21 +7,14 @@ class Champs::RepetitionController < ApplicationController end def remove - @champ = find_champ - @champ.champs.where(row_id: params[:row_id]).destroy_all - @champ.reload - @row_id = params[:row_id] + @champ.remove_row(params[:row_id]) + @to_remove = "safe-row-selector-#{params[:row_id]}" + @to_focus = @champ.focusable_input_id || helpers.dom_id(@champ, :create_repetition) end private - def find_champ - if params[:champ_id].present? - policy_scope(Champ).includes(:champs).find(params[:champ_id]) - else - policy_scope(Champ) - .includes(:champs, :type_de_champ) - .find_by!(dossier_id: params[:dossier_id], type_de_champ: { stable_id: params[:stable_id] }) - end + def params_row_id + nil end end diff --git a/app/controllers/champs/rna_controller.rb b/app/controllers/champs/rna_controller.rb index a8f49f7bd..e96ab6671 100644 --- a/app/controllers/champs/rna_controller.rb +++ b/app/controllers/champs/rna_controller.rb @@ -1,9 +1,7 @@ -class Champs::RNAController < ApplicationController - before_action :authenticate_logged_user! - +class Champs::RNAController < Champs::ChampController def show - @champ = policy_scope(Champ).find(params[:champ_id]) - rna = read_param_value(@champ.input_name, 'value') + champs_attributes = params.dig(:dossier, :champs_public_attributes) || params.dig(:dossier, :champs_private_attributes) + rna = champs_attributes.values.first[:value] unless @champ.fetch_association!(rna) @error = @champ.association_fetch_error_key diff --git a/app/controllers/champs/siret_controller.rb b/app/controllers/champs/siret_controller.rb index 23e4ab2ee..83d98f60c 100644 --- a/app/controllers/champs/siret_controller.rb +++ b/app/controllers/champs/siret_controller.rb @@ -1,10 +1,9 @@ -class Champs::SiretController < ApplicationController - before_action :authenticate_logged_user! - +class Champs::SiretController < Champs::ChampController def show - @champ = policy_scope(Champ).find(params[:champ_id]) + champs_attributes = params.dig(:dossier, :champs_public_attributes) || params.dig(:dossier, :champs_private_attributes) + siret = champs_attributes.values.first[:value] - if @champ.fetch_etablissement!(read_param_value(@champ.input_name, 'value'), current_user) + if @champ.fetch_etablissement!(siret, current_user) @siret = @champ.etablissement.siret else @siret = @champ.etablissement_fetch_error_key diff --git a/app/models/champs/repetition_champ.rb b/app/models/champs/repetition_champ.rb index 6bb189c49..7035488db 100644 --- a/app/models/champs/repetition_champ.rb +++ b/app/models/champs/repetition_champ.rb @@ -25,6 +25,15 @@ class Champs::RepetitionChamp < Champ added_champs end + def remove_row(row_id) + dossier.champs.where(row_id:).destroy_all + dossier.champs.reload + end + + def focusable_input_id + rows.last&.first&.focusable_input_id + end + def blank? champs.empty? end diff --git a/app/views/champs/repetition/remove.turbo_stream.haml b/app/views/champs/repetition/remove.turbo_stream.haml index 5fb0fe0be..d7be234a4 100644 --- a/app/views/champs/repetition/remove.turbo_stream.haml +++ b/app/views/champs/repetition/remove.turbo_stream.haml @@ -1,6 +1,2 @@ -= turbo_stream.remove "safe-row-selector-#{@row_id}" - -- if @champ.rows.size > 0 && @champ.rows.last&.first&.present? - = turbo_stream.focus @champ.rows.last&.first.focusable_input_id -- else - = turbo_stream.focus dom_id(@champ, :create_repetition) += turbo_stream.remove @to_remove += turbo_stream.focus @to_focus diff --git a/config/routes.rb b/config/routes.rb index 0ab8203f2..caf5a3358 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -195,9 +195,21 @@ Rails.application.routes.draw do namespace :champs do post ':dossier_id/:stable_id/repetition', to: 'repetition#add', as: :repetition delete ':dossier_id/:stable_id/repetition', to: 'repetition#remove' - post ':champ_id/repetition', to: 'repetition#add' - delete ':champ_id/repetition', to: 'repetition#remove' + get ':dossier_id/:stable_id/siret', to: 'siret#show' + get ':dossier_id/:stable_id/rna', to: 'rna#show' + delete ':dossier_id/:stable_id/options', to: 'options#remove' + + get ':dossier_id/:stable_id/carte/features', to: 'carte#index' + post ':dossier_id/:stable_id/carte/features', to: 'carte#create' + patch ':dossier_id/:stable_id/carte/features/:id', to: 'carte#update' + delete ':dossier_id/:stable_id/carte/features/:id', to: 'carte#destroy' + + get ':dossier_id/:stable_id/piece_justificative', to: 'piece_justificative#show' + put ':dossier_id/:stable_id/piece_justificative', to: 'piece_justificative#update' + get ':dossier_id/:stable_id/piece_justificative/template', to: 'piece_justificative#template' + + # TODO: remove after migration is ower get ':champ_id/siret', to: 'siret#show', as: :siret get ':champ_id/rna', to: 'rna#show', as: :rna delete ':champ_id/options', to: 'options#remove', as: :options diff --git a/spec/controllers/champs/repetition_controller_spec.rb b/spec/controllers/champs/repetition_controller_spec.rb index 2f6fed04e..3b80b5c32 100644 --- a/spec/controllers/champs/repetition_controller_spec.rb +++ b/spec/controllers/champs/repetition_controller_spec.rb @@ -5,8 +5,9 @@ describe Champs::RepetitionController, type: :controller do before { sign_in dossier.user } it 'removes repetition' do - rows, repetitions = dossier.champs.partition { _1.parent_id.present? } - expect { delete :remove, params: { champ_id: repetitions.first.id, row_id: rows.first.row_id }, format: :turbo_stream } + rows, repetitions = dossier.champs.partition(&:child?) + repetition = repetitions.first + expect { delete :remove, params: { dossier_id: repetition.dossier, stable_id: repetition.stable_id, row_id: rows.first.row_id }, format: :turbo_stream } .to change { dossier.reload.champs.size }.from(3).to(1) end end diff --git a/spec/controllers/champs/rna_controller_spec.rb b/spec/controllers/champs/rna_controller_spec.rb index c85d49685..1c738f794 100644 --- a/spec/controllers/champs/rna_controller_spec.rb +++ b/spec/controllers/champs/rna_controller_spec.rb @@ -7,8 +7,8 @@ describe Champs::RNAController, type: :controller do let(:champ) { dossier.champs_public.first } let(:champs_public_attributes) do - champ_attributes = [] - champ_attributes[champ.id] = { value: rna } + champ_attributes = {} + champ_attributes[champ.public_id] = { value: rna } champ_attributes end let(:params) do diff --git a/spec/controllers/champs/siret_controller_spec.rb b/spec/controllers/champs/siret_controller_spec.rb index 67a1ac8de..4e5212e36 100644 --- a/spec/controllers/champs/siret_controller_spec.rb +++ b/spec/controllers/champs/siret_controller_spec.rb @@ -7,13 +7,14 @@ describe Champs::SiretController, type: :controller do let(:champ) { dossier.champs_public.first } let(:champs_public_attributes) do - champ_attributes = [] - champ_attributes[champ.id] = { value: siret } + champ_attributes = {} + champ_attributes[champ.public_id] = { value: siret } champ_attributes end let(:params) do { - champ_id: champ.id, + dossier_id: champ.dossier_id, + stable_id: champ.stable_id, dossier: { champs_public_attributes: champs_public_attributes } From fa569c8bb433ca88f0b386d025b79e5a929ff266 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 22 Apr 2024 15:02:35 +0200 Subject: [PATCH 0028/1532] ci: enable simplecov --- .gitignore | 1 + .simplecov | 23 +++++++++++++++++++++++ Gemfile | 2 ++ Gemfile.lock | 12 ++++++++++++ spec/spec_helper.rb | 4 ++++ 5 files changed, 42 insertions(+) create mode 100644 .simplecov diff --git a/.gitignore b/.gitignore index 180beb90d..638c56b9e 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ yarn-debug.log* /public/assets /spec/support/spec_config.local.rb /config/initializers/config.local.rb +/coverage # Local Netlify folder .netlify diff --git a/.simplecov b/.simplecov new file mode 100644 index 000000000..3c98b20b7 --- /dev/null +++ b/.simplecov @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +SimpleCov.start "rails" do + enable_coverage :branch + + command_name "RSpec process #{Process.pid}" + + formatter SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::SimpleFormatter, + SimpleCov::Formatter::HTMLFormatter + ]) + + add_filter "/channels/" # not used + groups.delete("Channels") + + add_group "Components", "app/components" + add_group "API", ["app/graphql", "app/serializers"] + add_group "Manager", ["app/dashboards", "app/fields"] + add_group "Models", ["app/models", "app/validators"] + add_group "Policies", "app/policies" + add_group "Services", "app/services" + add_group "Tasks", ["app/tasks", "lib/tasks"] +end diff --git a/Gemfile b/Gemfile index 9eab2f8cc..aa3a5edb2 100644 --- a/Gemfile +++ b/Gemfile @@ -125,6 +125,8 @@ group :test do gem 'selenium-devtools' gem 'selenium-webdriver' gem 'shoulda-matchers', require: false + gem 'simplecov', require: false + gem 'simplecov-cobertura', require: false gem 'timecop' gem 'vcr' gem 'webmock' diff --git a/Gemfile.lock b/Gemfile.lock index e785eaead..47d1af494 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -223,6 +223,7 @@ GEM diff-lcs (1.5.1) discard (1.3.0) activerecord (>= 4.2, < 8) + docile (1.4.0) dotenv (2.8.1) dotenv-rails (2.8.1) dotenv (= 2.8.1) @@ -728,6 +729,15 @@ GEM simple_xlsx_reader (1.0.4) nokogiri rubyzip + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-cobertura (2.1.0) + rexml + simplecov (~> 0.19) + simplecov-html (0.12.3) + simplecov_json_formatter (0.1.4) simpleidn (0.2.1) unf (~> 0.1.4) sinatra (3.2.0) @@ -991,6 +1001,8 @@ DEPENDENCIES sidekiq sidekiq-cron simple_xlsx_reader + simplecov + simplecov-cobertura skylight spreadsheet_architect spring diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 30c87e9ae..ae50174c7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -16,6 +16,10 @@ # users commonly want. # # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +# +# +require 'simplecov' # see config in .simplecov file + require 'rspec/retry' SECURE_PASSWORD = 'my-s3cure-p4ssword' From 2bf53abcf67df9b5aff7c0143248488d32958f27 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 22 Apr 2024 15:00:40 +0200 Subject: [PATCH 0029/1532] ci: upload coverage reports to codecov --- .github/workflows/ci.yml | 15 +++++++++++++++ .simplecov | 13 +++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afcd4303a..20335c718 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,11 @@ jobs: run: | bun run test + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + unit_tests: name: Unit tests runs-on: ubuntu-latest @@ -100,6 +105,11 @@ jobs: name: rspec-results-${{ github.job }}-${{ strategy.job-index }} path: tmp/rspec_${{ github.job }}_${{ strategy.job-index }}.junit.xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + system_tests: name: System tests runs-on: ubuntu-latest @@ -144,6 +154,11 @@ jobs: name: rspec-results-${{ github.job }}-${{ strategy.job-index }} path: tmp/rspec_${{ github.job }}_${{ strategy.job-index }}.junit.xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + save_test_reports: name: Save test reports needs: [unit_tests, system_tests] diff --git a/.simplecov b/.simplecov index 3c98b20b7..547890773 100644 --- a/.simplecov +++ b/.simplecov @@ -5,10 +5,15 @@ SimpleCov.start "rails" do command_name "RSpec process #{Process.pid}" - formatter SimpleCov::Formatter::MultiFormatter.new([ - SimpleCov::Formatter::SimpleFormatter, - SimpleCov::Formatter::HTMLFormatter - ]) + if ENV["CI"] # codecov compatibility + require 'simplecov-cobertura' + formatter SimpleCov::Formatter::CoberturaFormatter + else + formatter SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::SimpleFormatter, + SimpleCov::Formatter::HTMLFormatter + ]) + end add_filter "/channels/" # not used groups.delete("Channels") From 25bf996377315f26d9d714f221cf15cc5675881f Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 22 Apr 2024 17:57:44 +0200 Subject: [PATCH 0030/1532] ci: codecov only informational --- codecov.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..a7dc5d4ec --- /dev/null +++ b/codecov.yml @@ -0,0 +1,10 @@ +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true +comment: + require_changes: true From 95bb09a1f811ed296a3e5dd79f04a7c25704359c Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 22 Apr 2024 14:22:52 +0200 Subject: [PATCH 0031/1532] refactor(champs): do not depend on attributes hash key in old code --- app/controllers/champs/options_controller.rb | 2 +- .../concerns/turbo_champs_concern.rb | 2 +- app/models/champ.rb | 6 ++-- .../champs/options_controller_spec.rb | 30 +++++++++++++++++++ spec/models/champ_spec.rb | 8 ++--- 5 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 spec/controllers/champs/options_controller_spec.rb diff --git a/app/controllers/champs/options_controller.rb b/app/controllers/champs/options_controller.rb index 909a4f4be..4c864a244 100644 --- a/app/controllers/champs/options_controller.rb +++ b/app/controllers/champs/options_controller.rb @@ -4,7 +4,7 @@ class Champs::OptionsController < Champs::ChampController def remove @champ.remove_option([params[:option]].compact, true) @dossier = @champ.private? ? nil : @champ.dossier - champs_attributes = params[:champ_id].present? ? { @champ.id => true } : { @champ.public_id => { with_public_id: true } } + champs_attributes = { @champ.public_id => params[:champ_id].present? ? { id: @champ.id } : { with_public_id: true } } @to_show, @to_hide, @to_update = champs_to_turbo_update(champs_attributes, @champ.dossier.champs) end end diff --git a/app/controllers/concerns/turbo_champs_concern.rb b/app/controllers/concerns/turbo_champs_concern.rb index 1a8fc819f..34e683e97 100644 --- a/app/controllers/concerns/turbo_champs_concern.rb +++ b/app/controllers/concerns/turbo_champs_concern.rb @@ -5,7 +5,7 @@ module TurboChampsConcern def champs_to_turbo_update(params, champs) to_update = if params.values.filter { _1.key?(:with_public_id) }.empty? - champ_ids = params.keys.map(&:to_i) + champ_ids = params.values.map { _1[:id] }.compact.map(&:to_i) champs.filter { _1.id.in?(champ_ids) } else champ_public_ids = params.keys diff --git a/app/models/champ.rb b/app/models/champ.rb index fc7ee4aa1..7fa02202b 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -177,13 +177,13 @@ class Champ < ApplicationRecord # However the field index makes it difficult to render a single field, independent from the ordering of the others. # # Luckily, this is only used to make the name unique, but the actual value is ignored when Rails parses nested - # attributes. So instead of the field index, this method uses the champ id; which gives us an independent and + # attributes. So instead of the field index, this method uses the champ public_id; which gives us an independent and # predictable input name. def input_name if private? - "dossier[champs_private_attributes][#{id}]" + "dossier[champs_private_attributes][#{public_id}]" else - "dossier[champs_public_attributes][#{id}]" + "dossier[champs_public_attributes][#{public_id}]" end end diff --git a/spec/controllers/champs/options_controller_spec.rb b/spec/controllers/champs/options_controller_spec.rb new file mode 100644 index 000000000..3b2e2466f --- /dev/null +++ b/spec/controllers/champs/options_controller_spec.rb @@ -0,0 +1,30 @@ +describe Champs::OptionsController, type: :controller do + let(:user) { create(:user) } + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :multiple_drop_down_list }]) } + + describe '#remove' do + let(:dossier) { create(:dossier, user:, procedure:) } + let(:champ) { dossier.champs.first } + + before { + sign_in user + champ.update(value: ['toto', 'tata'].to_json) + } + + context 'with stable_id' do + subject { delete :remove, params: { dossier_id: dossier, stable_id: champ.stable_id, option: 'tata' }, format: :turbo_stream } + + it 'remove option' do + expect { subject }.to change { champ.reload.selected_options.size }.from(2).to(1) + end + end + + context 'with champ_id' do + subject { delete :remove, params: { champ_id: champ.id, option: 'tata' }, format: :turbo_stream } + + it 'remove option' do + expect { subject }.to change { champ.reload.selected_options.size }.from(2).to(1) + end + end + end +end diff --git a/spec/models/champ_spec.rb b/spec/models/champ_spec.rb index a23d4be63..bf22d18dc 100644 --- a/spec/models/champ_spec.rb +++ b/spec/models/champ_spec.rb @@ -537,21 +537,21 @@ describe Champ do describe "#input_name" do let(:champ) { create(:champ_text) } - it { expect(champ.input_name).to eq "dossier[champs_public_attributes][#{champ.id}]" } + it { expect(champ.input_name).to eq "dossier[champs_public_attributes][#{champ.public_id}]" } context "when private" do let(:champ) { create(:champ_text, private: true) } - it { expect(champ.input_name).to eq "dossier[champs_private_attributes][#{champ.id}]" } + it { expect(champ.input_name).to eq "dossier[champs_private_attributes][#{champ.public_id}]" } end context "when has parent" do let(:champ) { create(:champ_text, parent: create(:champ_text)) } - it { expect(champ.input_name).to eq "dossier[champs_public_attributes][#{champ.id}]" } + it { expect(champ.input_name).to eq "dossier[champs_public_attributes][#{champ.public_id}]" } end context "when has private parent" do let(:champ) { create(:champ_text, private: true, parent: create(:champ_text, private: true)) } - it { expect(champ.input_name).to eq "dossier[champs_private_attributes][#{champ.id}]" } + it { expect(champ.input_name).to eq "dossier[champs_private_attributes][#{champ.public_id}]" } end end From 6ad619609806f68ef2134145f0bb3cf609289ea1 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 22 Apr 2024 20:16:07 +0200 Subject: [PATCH 0032/1532] fix(gallery): allow pdf iframes in the PJ gallery --- config/initializers/content_security_policy.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index d213d1dfc..d07dbf05a 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -32,9 +32,12 @@ Rails.application.config.content_security_policy do |policy| connect_whitelist << Rails.application.secrets.matomo[:host] if Rails.application.secrets.matomo[:enabled] policy.connect_src(:self, *connect_whitelist) - # Frames: allow Matomo's iframe on the /suivi page + # Frames: allow some iframes frame_whitelist = [] + # allow Matomo's iframe on the /suivi page frame_whitelist << URI(MATOMO_IFRAME_URL).host if Rails.application.secrets.matomo[:enabled] + # allow pdf iframes in the PJ gallery + frame_whitelist << URI(DS_PROXY_URL).host if DS_PROXY_URL.present? policy.frame_src(:self, *frame_whitelist) # Everything else: allow us From 388470f1868f283857ce04a7e1f0ccc4be230654 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 22 Apr 2024 20:55:13 +0200 Subject: [PATCH 0033/1532] fix(gallery): add a feature flag on gallery demande --- .../piece_justificative/_show.html.haml | 37 +++++++++++-------- app/views/shared/dossiers/_demande.html.haml | 2 +- config/initializers/flipper.rb | 1 + 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/app/views/shared/champs/piece_justificative/_show.html.haml b/app/views/shared/champs/piece_justificative/_show.html.haml index 9098e88fb..4d36de1d5 100644 --- a/app/views/shared/champs/piece_justificative/_show.html.haml +++ b/app/views/shared/champs/piece_justificative/_show.html.haml @@ -1,19 +1,24 @@ .fr-downloads-group - - champ.piece_justificative_file.attachments.each do |attachment| + - if !feature_enabled?(:gallery_demande) %ul - %li= render Attachment::ShowComponent.new(attachment:, new_tab: true, truncate: true) - .gallery-item - - blob = attachment.blob - - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) - = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do - .thumbnail - = image_tag("pdf-placeholder.png") - .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } - = 'Visualiser' + - champ.piece_justificative_file.attachments.each do |attachment| + %li= render Attachment::ShowComponent.new(attachment:, new_tab: true) + - else + - champ.piece_justificative_file.attachments.each do |attachment| + %ul + %li= render Attachment::ShowComponent.new(attachment:, new_tab: true, truncate: true) + .gallery-item + - blob = attachment.blob + - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) + = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do + .thumbnail + = image_tag("pdf-placeholder.png") + .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } + = 'Visualiser' - - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) - = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do - .thumbnail - = image_tag(blob.url) - .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } - = 'Visualiser' + - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) + = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + .thumbnail + = image_tag(blob.url) + .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } + = 'Visualiser' diff --git a/app/views/shared/dossiers/_demande.html.haml b/app/views/shared/dossiers/_demande.html.haml index 6c6e6d601..3cdd95d2e 100644 --- a/app/views/shared/dossiers/_demande.html.haml +++ b/app/views/shared/dossiers/_demande.html.haml @@ -2,7 +2,7 @@ - content_for(:notice_info) do = render partial: "shared/dossiers/france_connect_informations_notice", locals: { user_information: dossier.user.france_connect_informations.first } -.fr-container.counter-start-header-section.dossier-show.gallery.gallery-demande{ class: class_names("dossier-show-instructeur" => profile =="instructeur"), "data-controller": "lightbox" } +.fr-container.counter-start-header-section.dossier-show{ class: class_names('gallery': feature_enabled?(:gallery_demande), 'gallery-demande': feature_enabled?(:gallery_demande), "dossier-show-instructeur" => profile =="instructeur"), "data-controller": "lightbox" } .fr-grid-row.fr-grid-row--center .fr-col-12.fr-col-xl-8 - if profile == 'instructeur' && dossier.termine_and_accuse_lecture? diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb index 3d3851c9e..9b22801b9 100644 --- a/config/initializers/flipper.rb +++ b/config/initializers/flipper.rb @@ -26,6 +26,7 @@ features = [ :engagement_juridique_type_de_champ, :export_order_by_revision, :expression_reguliere_type_de_champ, + :gallery_demande, :groupe_instructeur_api_hack, :hide_instructeur_email, :sva, From 1a0db08eeff4adeffb8517224728d526b1249d29 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 22 Apr 2024 22:00:01 +0200 Subject: [PATCH 0034/1532] chore(npm): update lock file --- bun.lockb | Bin 548972 -> 549348 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/bun.lockb b/bun.lockb index 1270c43db627142dc5a0201423b57db9637dc88b..f15253d9243a6905b2330e94ff12229cd4579dbd 100755 GIT binary patch delta 97714 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 eL@fV>{LqVgx6um_?KrmMoUg^ZO5kM|Z~n literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/administrateur-procedure-close-message.png b/app/assets/images/faq/administrateur-procedure-close-message.png new file mode 100644 index 0000000000000000000000000000000000000000..ee6b2b5923a23620dd2d35f316289cd53ba77e87 GIT binary patch literal 13919 zcmb7r1yEei((mH#4vV`xEKbk_g1Zw4Zi~CS2M7)!B*ER?g1atm3n4fmxbsN<_q*S_ zuUaD=Vv$latBG$?NOu@bK`mvN9VRo5jV&(a}*NA|hg9 zVh#=tTU*C@4HWK1N4J zH#9W(`}>QFi{IYfK0Q4N2ngKY->a*u7Zem69UbxV^7i-lcXoEBrl#)i@7L7SbaZsk z($Xd*Bq%E@v#_w(+1V8p6-7oyhJ}UY=H{B3n(prIuCA^c7#O&_yKii4n46pD=jW@c zs-B&l{rvg!>gp;sHny*?&)M0TjEv0E((>!qua1t6A3l7Dii&b|bqx*wN#5Sx&CSii!ou_O^FBU4DJdz|*4E#?eVd(~O-xMm z^z=M9IDkMPU0q%4>+61gel0C6BO@aj85y;;wG|Z=US3`RoFo+7G=BaPeEe)MxVN%$ zLsqt$m9-!saJaB=g^vDNk+cSc&9(YlN}JD!|oY^Aszn_*$n3aEsJP`ot7u94MST za?u0HJAR8^zuQ|A5dZD_@K-3hF~3OL$Yv=j!dAgn#HQt!(aK`TF0`VpV8#00h@yNA9XpMU5b)S@{Y(`!*qGYcm zh`Hcw8^;IqUG`G|-8$OYHIe^>eR_ci@g0S`IVRA9;SVy^%<5?2C)MxPkWI*v3!yVMn;^+N_Jdsx0gV@F zWXj(o>q3Yz70@Y9>CiVz$drv4!l<{ogGkT6M}y6Rt-zuSRE@`4pf8coxs4@wg;lUX z95qM|RmD{K`|s>&ET>AGgx7@K?G7!_w{mO%;~a!OFqdhUq*FVC$94kQ#Mq8?eQ)HR zf+)$cQ?u)#4Cpo4wz`0Y9rm<*1E-lk8o4_Xw4RJ?FR=}3D(qfkdGX53^%>(dDfAdx8J_7dr&hHK*c(hWylP`~92p6sj3`8MK&($3?4>NlO$sy{C25D<6} z2P>M$+(GXh?76eMkZ}IDC5fL##w`X1&E!1_j(<=3C4sD!I zqU&gungBqBjZke8{u2>O7bO9v79J0Y7@ci;x<!%Nzr+ZZu9-I6a)GI3L~-yM4<4)%4K_ww9AoRRGvaN z(GSy^t&FLM=CRnK2Vq3sDbAi3B>^t+==yP&Ri?vs zpf@Hl(S|#N&#Ed%`^%uTLu!6YN}*CJ4t>BuS*eBT?{+o+t1nrtHT(FV#aPU)_}&#& z`wZ18cl%a6Sf^BToqsL8q-wBaeGVy4F)?=5ZZS*OX!MV~M&R0-xUet)h9pm+hT7D; zN-Zw!bBz=K6Lmncf_!_@)j6lr&6L67w~}R2lv%06nJI z1D2rz#f$S3+&gY$+5AtTR;0x5OwHP^zGmU?rX(XMvvM*;-@@OFG@OsGYzh_yd+-kX zGNK8}i~-!|#k|p&v;|6EJ?L?j+f+jqunjTJ%n&JJpZrPa-NlkcXpq|7wcAUw3$bFM zbU!{inu^LSHi(xjdl9W{ZASGPP|ttZlKCp=p(ZHA;uA_mRK?TXY$3T`NO#ueF2o%& zilhcle9>3Z$<6F|EcQ{SUkz-8d?tSIc$4Ri1x%xqI@4@X&iT$JDMj{`j$@0$4fD-K zhv?>%W0;Net9h4KVZrAJ$gv1P`ybJ%-z*B?SO62FuG~!I-WVl*)8Plqu&&Q3%|^;b zcWkbII86MrRbX%-08R5cPOUi|JhpGMpbS8YsQ#drEmj8i~~SER@W@FR?k?`O`ss+b_V+vhNW>+#U|^mLB%1#-L5Ixu6J-&Ku&-o$|P2_D%G#HZ@yH zYkdChfg?3VxH@ys-dkQozEn2Ry@5PmXd;YL8b%5`bq$hjWcfX1$mo?QNoz+;Oxw#r zBW|#7m^ty)h@U=z6tVDSGbU|lCdM^oSJsOGRf!*E0~?A(Xz_8-rgd|ME*wJ?DDV4Q zOw_`>?W$gM#$u&0m|TNtLK`a*2qDot?8{dq60WWqEFdO`0jcZuWVh^L7xujof3l}~?fL>~$gzL>zFUvz z^xZm^=?z@qbo0$a{d&)!ML5?IWmp$*#ck95MDb>FZbg>vMF(blo4gu_`x|cIAzAHth|wq(GIM|OAKsib|IpOtQ@{;1&&%MueJ-J>>!F|yA(l*M} zUh+FzUhyDMAJXxoimKM~49YC<;}$?1s9$q-USU_t7>_JBBeK3M{oRC z*d!FZ3;US@?Wx!e|B%Ioo~k+2>T6&K!sJ__CaUEJ^vJb{qQEG)u zUxw{C@PM_`YPxt!h@{c%oky$5r;h6D>q0)*;Q;CB%H9GrORdUX|D6jR5zrR|{A(XF zYA=EiN$EZqQW)f85pi;(n9O)vK9<2!6UvAV0SQSOu~`Dl-FQ48GrUzz^|Z+NJG~m4 z%Z0~i>xpBN)?n))f>a*laU~ftvo&5V)+A85wmR#y@yG`}p$=yQtfarM6}W;|Imq=I{%_3{2q{$zWbDWvcDy277YWMT}#4 z`TDUUN1hL$B;beC?U}>@9TxSfZA`*F>-#U8Z#+Gv`RG9Eh$D_Ob}^Tu?$59b^pGPR zqPdxGIYayLD^a|}K`FKzXEtk`Uz4e$d)1CdN2>H4%w~}hr&T;IN~9w>8q@hZf2n?2 zIJj+9{T)@)W0PVFpj4PY-e&pfN13_8(HYC1?TVs0(>C<>=Wf<`$F57b>3s_nRk^4> zH2ij8bMy^p**;C-w1uavP4Pth6FWJX-t%wvgJgCtd)Z17Ig7#B`TWz>=H)y4594{o zS7dgSHDrg(`h*wXx3qOQI-=S_%)L< z&$6)!IPk;gtVacU3d%?;(^&*r5!N{7QnvA2&Tu4IvfRM&@ z?_@9+QbwLSnLzHHJ&beza_7tJoO_lG%-NC4J8P^w+)h>#+$Qd6?6L|&oC8APBX_*d zF+*83C1y`P4fw)!+J8~GW1~q64c)wu_FU1@9m_jBNp4eNibvpYXmwuU>B!`dh&Y??6(0Fo`0h5Ci4fFZ=m*BU25J(aVOk+$?&f0sBQ2FH>(kmAHil5r$2Z`7lV@ksw)sHCCSbRL7zrw7XV@#K zWR*KH{H{iUG95k)tfGkZ=)}(sY!#LWW7B*jF1_I|^v9(o8=7OZwYTAiHagLl+zXGw zu_J@%NlOM758>=w@B5{0Meb~GD_Q~ySTprdg)4lt3uVw8u`i;{= zOA9@(WLLi*@*9;oiipcz_v)*`2&MUhvnf@zb;pn+ML_CN@QfymR!D>S9qgI-X)aqE zYZ%iXSXnG^fi>blpkAZzHV;iG3^GHk;l)EOjWllL*X7F1K3}d!xxEn5wQ~4D*~_RJ z3He`L8A92K=t#RXM$wIEOP+^#c-)yNjGO}cfyni-Ikj`z zN$yI|w5?*nNU8SaXU82wdTj`0s>i+IWuqC{U{iITQ(0QjN({VJ)mrHTtO@J!W%8S^ zJ^|x{+U0lim=pG$cYYhKj$aVKbIXboZq$&&JVIIEXRqaFZccGoPxmjv=_eyKY&w_{ zs@8+wRld@^e+ss|)b=g0=HiY(nnX9U@$<24IuJWM|ebC}YR?x@f zf-Lx=lkC?{k1RdTYGP)hA01NF_AblCThA*Dsl_N3aOT{Dnm>GYv_pNqY+4Z3upnASPCqtFI@Jm*d7kekPfS7)Cx}g=j}v)q9lP>Q__~YYw<_|OnBQ>kv;QlD zWtn@dd$%Y!YHWBlH%-R2!R|O(T|e^{l;nHbYCcERU$+V+T}K$=wH4cpP3ESc-O2J{L+U`Y@a5E~O2DU9~-gTv#vo4%&v{u$&g?4*Xr zRq`2`2cX(|fNed3BTM|&qf|i+RXK}amKQH2!9safGw{|)U{IL&lFRwm^_ks?4~&@4 zgl$C!mOsVy<+=n7s6~a`*us&dzRfLiV@~4Z?)*k^K#2aq$>6Ja~kTNrL{e$ag3AN$|_K+#I!A%sFN7# zRJ#PU$%ye5qqTr{Bj8>*cEf7|OgmIIRw`snM+fR?r8F&E9;o?-WJK)w;R zu{{|S4qI?a`9T5*zTR=!lW?uj){ z=*L1-XTUuq^BAs9eJ*a);RJ`{RQl--85k~iD1X+Ym{ODq1g_jcmf|zfoZQo)! zISjgbk_mER7%5H`95moeX;Qw^WHM@+#*omNoIS5zx!KZxN;sF=-V+Eg(kB5#?z=9W zoowSDv%LewOD5*%bLKMAg3>GGYIGCiRwfjlS?NI2TOW{yyCWY>0~70FNG;1JKP-u5--SA2Z;x`L2L#@U9+BA;4l?AHBeM}U; z{ZhA#O>>b=k=>xid(5$QSalKI$ox!RthjJ?r6@hyY5!!c7H_&yugjH4qznkt@wU$tGa>>W2qM7kSXU|O3oWa@wG^3m;*qVV&M#-CBMkq>feF1NgeJ`5J?KUV)N zzvz+*sngx;iStG?oHJD#hqJGMwfYH&n<31q$*47L+KYAL7?oVDN4`=qRdnyEkgI+q z5P%5eK25;c^*s&DvwedFF;mfeh z;af$A7$=sGJz7xZW$P~~s+*UeEEr=vzO3$zXkBTYE$md=&a?rfERith#Iv}Ivg{Al z%YOA2mJJm*LcJz))D>ebv0(O5ZHmu!|KxcgGz3Z+I7XmV@5;>Kjuf05dw!I@;IvB<>Yg5gomct#)2(2`S8eDA?~Qco z#p=Dj1Df}o(~GTHakP6SPti`bFihvO6)x_{C9`cvcrfeM$KB&G6{qe>%UbWw+Zv0K(sDx z860g3c8c_kM#!;vw0aE|;Gm6zh;wNl!s=S0v0cIqrYY*2_m65581yY5`t1BR*)l9y z7=ibs@|wKC|Ec*1p;W$bErnT|$#m*+*CajNnj%Yu88Z3Or71F0c-8r-*eES+T32Is z0!{u5*I}R7i8;_k_f29^T<_JI%{PpDhbfT}QDXsAh1>5D#%VWI2)l3lVax{Mz6cZO zN?u`7eO;Ao7`tJ?Ru7xylLbX(PFc&>@3PJfqnJNPAH@R|xEbt%UY+Xg=$Em6}|L zGpUWvRj9rm&&!$=SZ4P|r^YPn^i#yFh&L6(aBg$)0P+Y=`9Gqb|T-@byX61w6q~vQb@oE#KM@Y1LLtY zerSIzAyAPc}|!c3~UF@;W;pAKzR1Z-cQ_jUPAazY4hLaEo`>Q z)6d|0vKEawZM8NSd3IUb7hHZ-q zc%S#f(j(Ix1Nq0HV>yGC{G-g~-H5)vRM6Xo1fpI>*<#!$s%#u!gPo{o1)82qjb`Kiw^xiPh6g4dViM{Y3V zc$e1;-wOI^VY^qw_+1-cFCJ37JyqMuF1=I;Z5X^fGIbBmzbKRhY+NAqAKpI{9Z2dm zIP4GaAL_&A-^wq%e^LKW9|-7uGii2Y6}vme&dclq-Tj3exoMPT6~8bglyIIce5lbL zQ^($MyAi0Mb9l}v9^{oFpEO|7ZiJg-)X?pOOn_CbXBoTeXJDbO;s4`T@c?z}#J6Go zZ#o<2*_Lxb3$rUO0?!62Hq`IhZvZ~b6#X2%FKvNcXXhg51LCC&lX{6fHXHRfWR?&h zTfjE66-K>>%R{Ah;E+?s1m>ruv>qp(&)Ik5NT#O|Z@^LqA9QSxXhe5sM(KXh2SyrL zyqv6@RZ#bk-G>Av{`=KNV`x286wpUWmg9yKud-w2!5UGaSDbhKWm-jp5}m3UX;9F# zPorRDyh&@*s35?9K(|h(;}=C1`=W!NGkf45p;2U%pq7e%Ut^m18;F>aX+}9jgVt1h z8D7qyeg^QgcVd267nYADf~Sh)^@?OS9-r0N$dJk8=6}#-U;6z zd?vQiS(BWMqo_ct6?=^+euQ-IYu&84JM6UN3!zKtNNBR%<>n*o#jc2S@+{4DmV<-% z_3DH!>C=!)XrXQD2z_pv5aCCgo6nuU{pi1#X@56HIumT)Y=0HgQE?}Pj+?6bl`d-9d6~?gM>Rw@(W~~1uoY;HGqACL?wtTcq zQk|niq|Nd(XtVWP+D|Ce>O0!;75W|_P3j>3RQsltcMuuH$`I$wFbEe{0Z*912Nr;C zsjRs3RavW{tcaRp&;!f4<*$Vu%AZSpo(co2&8(H6DeW`}7VLxSikpUH-vR?J@;f$m zk%~c+QY8U@4~%|L^%0DmE#bIu{P$W8jP!L8lhU5a<&<2G%ErK@XqWkCcvCvU%|C*- zDFt;qpqMu0(P_bCXj3=3+mZ6Of%6@8;4k4a{M0YRd5a3G37ov!=s*GPaNxfOe!UVz z82P&Epv&5n`rXu}DO~~pvGmO!gkb$U3ZxX$X8aJV)GS@2D7h+HfG=%~lL>wKv{d%xc{S)`2}ScSkflB7LQt3&6lC=c}G$?-nr$TSfvJR3dj1aoX9E4z7ufrTSF71@BUS6{0%! z2DRlf$8x^;d|dZgjWr(!ZYk=e_F3LChgVoFo`5Y-NZ1w=_7f-f9K*7-Ppk;SxT6KLTp zk?IXfIcFl;A%M&#YXr^BLm7Gox48t6iq;NXwi(wW&$8wEGg7v>;I+s^@+Kh+O32lX z{k&pd5cLH|(7V8k_eGGi<`$R^*rVH*#XlM+^9jdMX#HeLBkzpxSuLl*!1Io_t=d`# z-bIG6N{frodW9;TupAzbRdcw=wlV-gyq9)rrBM7n$D}6!_DYi9{UF$4O?C& z28C;(YITKiv8A;22ftF2x2xbU>jbH&eZk5Le5qv!%63Mef@Q6O0HrxB)3Y~AE6y9N zvqyS9OHS$VgVQZ%lV%1-q+AclNNi0jTWUY)A`7bmNqGum2b|(;jG)~a(ZA7)G_1gN zvDRfqBe2R50DAc|#i z_9ELG;wTB`axKBS4WoEpps!wojZZr}r*^Y=gVoroUAl4m>#YOn7cK)^ocw)eJTR zN3BF28c$UCLIb5bBhpNMwVPdS@aK}Z`x!lO_n?$`@0FrI8w4cn>e=Q!J1|c=&GtX% z%$naUSUHt0rB}sDFo5=Hur-xj;$3y#0p9v^1#MFNb-)3$)AszNj~8#pr(0QMdfh@A zTCGqG7B8K(F_+dex+E$vO&F+Tf<0PJC{eJMo1PTidy0M6a zfdsC8a7Ps%D?@QMy?Bi(5Ha{Jiq+r!SdIK@@axe(=P`b!Kc8dcS?dXkT|D5def7<@ z>34s_!`l86$oDVUpzv^pD3PC)7>+sigQtv{3)&HzC3NZ}u!0*d-jtr$e0Q#(jBkwg z(zu+HFLLvHbUxD8Y?Ez=Y-$RaKGj;I$1FK{iUhMF2f#25ykjKOrG#l7eT2BQ+`d)v zgy?Yi(z5nfn4o07GM}U5QLnVF-4l7A|Al2|-JU%uA+Hnxq?%s2b8M8Vi>0mDasFAp zBNXA>3##ehS{UnlR7z%DJo!6<1k#lRx->51=Uab1a7*VgnDF<2RqUaMF#Y`c7{P&6 zJ~;abL9VmKw6aLi$~F`*$jt+t^T)-yytMOlf}k zqAu5 zgX!n_oRjUPfuW#+o!=a8RsR5+iIOsm)mGQ&ZR8s*pg<{!k>jfhwCYE#bO(vF<%Qgu zZs@HI(r>v?*W%7^%I7O%%o5#^H02S&%&G4TT1f7{S$Z`_{5y0Y_luX!1h4@i1rqce zAi)F_J4jt(Vwb|1Krq1K%6*QHV;m8xDCz`3HUzbHEzgL1%Eo-V;=Hzi=444997jb! z+asNod#~Qodr14zz58vX_fI#<$@%ixJue~zQxp0?NQ71jQV9Xy_k!!7q~l;#%=*;u zB;9Vyv67$WCH`w9XxbH))a$n32M^+AMPh(F4DFU392!b!5{Y^F@u8?muLX8ox4Z0$ zv^hV@!aGv~|L{E4O>N3o&xzS?sGqY*1FM4$35AcPYN{T{>z zqcGRJG-J!6XG~oVUXCJChCG)S!H)caBAYLx9N1|4mHoFVS{3`+K zQK=BskC3=WNvn<$nXZkOTMs6l12fEiOS0`tn{35K^?uWUsQIB1ywA|!O);!aqb^nR zO!n$IEFjK?i13RYr}QXxKbA%V7qYQn1f>r24kkRM{8wzjNVN0bI2@2=tMnYf(FQ{y z0$m;SO`O;RETV+&68mr1YgmI=ebpNZ3u}bdj+cs-Xs^qiejJ5VuwYsVTt#EX9nIf7 z!_81+Xhe4Z6W7R5zi;K8kJ9FiY9h<2JfxP?=&qkslzRlQ;)JauU(M&;O8G76@FqS-WjhU{xJ-i6HUXw1@xjeF<+$? z49~_5XOEut!>uXexwCf8rs;t?v4M{YlarkA0aAkfH*-QS7y{UEc6yqPDqOIAh-JQg zFmddm;+mTErAd}GRl2=o6MuGbHGPC{^t{{f4f6tnjGqIAUqxx>=0pv}{b_n%{F4LzSfBFk@)E-4-emsu}0N3I+#BYM~FI`!VJ7cFPdw4ayt^$mCleK3M3bU_A)=0Mnpc- z{w@vwu=${vcJ@%FZu67~9Dg~npK-q~|1@#;j=+A-`zIptf5HfV5y5{%DgHlE!pjGK zh`W0&O%lEYA4FpeZ*aD{C~{u&lxcq9+C)fOqVXX4sixsC6ZPx`_7J=?a1CuMKV+S} zFU~UETohzChOV^)V+ZPkecUCw-!_b&F9##zwWqhs1>jIW)o;F-;&|%KC&Te4QTX|p zP%N=HuTssH+s4uj>AvjJB5~Mh_rJk{h|aqI_V@oD#^j*&w>o97Liep3N#g^gq9j=U z2F-mtvjh!W6JITBsUYlUPJg|WY(%obk~xH~ZFL{s_K%) zX!zx^jHYf`41rRi;!IkQdYeocA^IF?Kx&w>1Xh|Ybsr@mkalY4Rr3V}SFISB9Hs;| z?)fyf$>TD|c1DwO_+QcJe|eW4>qn2R551zann=yhhjSE!omj0Y!y zV2O(vA`jp#|Jv4f8*ysI+Vvcft;z9-*ep=(PcZ^ixv=2y&&P*UeR+gA@uSG*ilYEI z545h4-~vaO^;?>^B|z5#V^+M?;5Of4|L($HaS|V9nCIvC8WrFcwFFO(NvWTjH*-or zl1E;WoH%gHwzhs#E)IZiI`8r^!}GfDcw{)K4q^al{*f;(QEp)_znx#mc^TY|o@A5+ z2Pi_`y{M5W&Cj7w(L{2{e=h>W04Kf z&)fA|mnNK4aOY(DuRRfLpNGZUzZ&lB7ozAJRHX%JI%UAb-oiq~M3+$3v(|OI#zx9a z0Vc*}5@U`&4l}zG&rVduk+fjGs3EqK1n9Ze3$i8g>$%1pDFZe96;PhD9qAP|-3E=m z;s+|^?jU$%2T>+i9o~$OuBO_jybr^;6E>Gv-(jGD@0mz1kAG5Y|4y&Ly``S$~-pxIeP|((aP)xoQelit&Oh&$-84M*v#$)Dda(?pOpHD zCwc!nxA^ZQ<3s6+PUh#lm(1dn?8|_x|ICu_&umElh`{se*se#5Eav-ZFDIK11U5F| z-j0EWhbLuSI^jvfvif?SVB^9W3PMB2yZrdWr>5Ec z8hz=(8ou%NxVv1-wF=rd>C@`%G27!zjyPfWJuPBe>qx(!`1S4PChewZ=a1pCJy82urh*l!AGLsfDw8;(8nHlk9DBe_Sf9SJ#L{cuexl^ghM8?S3qZkmMZ#RHFe_gZKNloYGI=WzbBJGlbjSU{oJK+?9q z*_;5&xZ{nrEA9=D8hA|t*IZ+gR&c@w%w6COtK2+(6jFY?2{>gTtJ7Ton903#Z%whx z;rh}L$Z(yn-xSGP{y~)CXUerU%i1GcLe#Hh!~~dc(4klRa&;g((88p*>t+zY>k@D6 zKV#J=obpDT(A+P}5p*?Ya#wk4+Eem!nc#hzO=&tnEod5hCu}%c~D zH`{%6P2b-{4)V9x_-rg1S2*sdPZBCmf?6>VF<}ML4|7qY-V>!i9kxW8?>52h@Lp({ zLl>2hcDaN=$acwVvwp=9>L!R=7)aiVwc>gp8e(0E4X}*VnIUt>Qj&c?MMCZdxmO|^ z@Q*0yk>nXifRqLKixT8NO@7Wk&aPPKp9mg5a8gTpaffe)*f&e-ue=j|L`nC*QF@+&8ky KGL=%MA^!u@H>`pH literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/administrateur-procedure-close-replace.png b/app/assets/images/faq/administrateur-procedure-close-replace.png new file mode 100644 index 0000000000000000000000000000000000000000..1ef5f17e58e5d58d49d9c2b7d28b7aafdcbb66ed GIT binary patch literal 16128 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/administrateur-procedure-test-commencer.png b/app/assets/images/faq/administrateur-procedure-test-commencer.png new file mode 100644 index 0000000000000000000000000000000000000000..942523612cebb376e02360753051afa21d6b501e GIT binary patch literal 26102 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/administrateur-procedure-test-link.png b/app/assets/images/faq/administrateur-procedure-test-link.png new file mode 100644 index 0000000000000000000000000000000000000000..e505e7a45a495e22d97a9a7141a68a27db382ad2 GIT binary patch literal 21163 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 z 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/administrateur-procedure-test-usager.png b/app/assets/images/faq/administrateur-procedure-test-usager.png new file mode 100644 index 0000000000000000000000000000000000000000..290a24709064e864fc3da097650fe43e0ef5db4d GIT binary patch literal 32062 zcmafaWmp{DvhLu7;O?%$Nzeof?(S|OxXa)cU~qyv!5xCT4(=A*26qO6+vVG5@AKSy zew@30thKsVbxqA%Rc}4rs~e=OD20YXi~;}v&}5{=RRI75FaQACj12d7#!eYM8~}g= zD9fu$yuQ8yu;PtP+BY}%@;f^_U0q!~ zJiN+E$~QMR!^6W_Sy@+CSC0=5o}NGv5fKZEZj$VbQXNM$`56 zv#xHlCvd>mZ@8>%)y}SGaq)tStAv;+Q%tPt;og}cJ5qWK3-p63keB- z|89kkpOKfhl$JIZ9zN06w|9JewYPT;fk2?p$HPNt1_60CK6xGiSs@`sF)?`w5qT*Q zc^MIT1vyQNsATgO(Q;BMu(A1adS=ZBwvD0^NOjGKgY!yO&QePHq;J4Ne8SLo+bSBy zYC7iGu!u2Nk1kcsTK10*(97MX=K08|`}6bqPkc|ew~f4?&*$b3I=e4d*PsiFtrF6w z?R^Hucc-Tlo<5xlDpwnu*E_q@K_N#2gSUHoo8^@!BcqRhuWJ}t&!%T?u3sA( z+Sk^0Gc$iRH+L>BZkCo-&&(`URMbvRE!5Su3=K{B`UVaTjJLJ*g2AJmo&6gd`^zhk zKY!M9ath|>S4T%@dU}RZQZnP>lD4)EYigSE@``tMPPey@Cnn}AtLm+-|B`rkY5@Si zdl_+2bx)X+6$y_WNkYjjkJPlKsi;0QnZIdo4f=YnT1aJqcg8Q!8M{WSVOOpFmPY@x zU5vK~^ZAR;^vk8tzyrdiz}1YdN#SrJPt<;=t)0!HU>HhwtggIjdu%5__{JfwuT$jP z>KL`(O2-+a>n%AJQh>I^(@Ss|0lCnORxF4Q0A_?}RS5zgHhVt$_GiBxTPr>F^zE}x z2)E`=6>3Y60>bYa2=)zEgA9m9zuS+k%WU&dDJ)9~vm!gg0zM_jx{Md8H@)M};;AIch?ev* zt>U7H_O#J*6P1nDr6Pt}!2pQaYGzCexr$p^Sy>9^tVR<$Za9gdV-aprG@Zuf&O+Ll z(RYJCe(=7j(=`4z&s}Oi(JC-bbQ~qQx)d(y)A!SvE#{LH&DmcRz?62NThuk`V>kvk z_q`x=X(zW}q^LlHxn=iSJd%zYYopeVKLNjF;c|v#1?mk^P;h_;e81y|8XR{L3NoA} zokq`DlsW>dqot_*np9ip4D=gY-R(3!ERyeo17An{j?RP=mKTU5z;4DJWh-sJe)ZlG zQ|MkPMze92X5EZiTq55c1C@UVA%a=qc4JvlqvJF$28V!4ls;H&V&f?l5U9dmF-Y6om-QuV!rUw9{^=OWg5 zIc5Tc8{EUQ8VrYrhUBNL$KKFwv_T<0;?D>f2hS%#z#LJFQt_9xe{mhZko4^nkPoU@ zD1OQC48mB7fy)V6`4m_%JcTS2UCQ*dLg;m*-aygo&jn#6Z{0jS=^ZvnHK#9-uOl@M zRoK3Y9D{`K?di<5UYv(}Dw|wk`b9{LP3_i5kQy1fyStlEy$*?`@5@gGFurC^zJug^ zbNtrw+J?nWquUEI%gn6b`?egwR88u^bLrWhoAb&t6Hf za?EwPme{#2WN7S7WRg3ua?;Y%KVV|TX5iw7ZMg{i@cq&~&F{^8UNsy(8XVEXf-GcI zTGyPnjAjlzZxlKDP=$Keeec_;cJ|=qs3V7WS~whT3G7(-HQK7DqKQJKt-n-$E;DcF z&@jI55}{LOX zsVz#Y5{c6q;%N7JIDtq+?ZTZ*B!UTF#4KACc0*OVjU1PT4ch`8z-I?Fz=n;&xb3QA zS^XNZTP~$so_6}0)$#U;SHb9&zlRFJ8pPDc=Or* z<%ld-_h{G5vSi2FD7ZXo;qfwGs^8S5w4jYn5JFNqKKQ_-$yaPU9vHa~7%k)hhxmSbxs{a#1llb-UWSMqpN3>m!E%)bgkzoR`yUxBog+ZAnYjG@ax!n zUPIAZ5A(bFyxwpQJ$uKU@BFKpw+91X=Aifv6q6SjJ{-Hr9@CeZMv-=4a(En#VM<1Q zW@xgRjhgKfM4?H#+18eV&LaijK{kCKrZD#hZZ&_o4BHy{t922WU95o%GXGAf?#W&k z>=f8K9>6YI{F?#N3j#Gm&jf|pVZpaMDtw4f_|qp;_`s~<{?wu$2_)t=5v6fUfmp00 z=gvkIU()_02aTJ3Q3DVEs_}qOX*pB4zJ6Bc?KqPYigIU*Mw-#74+jf zp#sOYjDg*f1A$mMF?MNAiI2PmFAUji^9V5$hMI7uJO0*{dXi7sUWgnX_;3=|`Q?43 zQa;M3;-k4jJtkS!v`P{+uB8glJ|H@bv3& zBfs5#qC6wpvzseCOe}{=3@?y9%+nQ?81ZriIt2`wdI+*mvdt5G(Zy>L`j? zJSIfVvsfaq1=t@eqj{lT?`;e57_*ZQZ(ucBG|?rOH2nc_^g|%DMBwszLz(Ob)+K^0 z)j~-EqtdK2yeo3)m;aFS3ku~}wna0Bt|Gq}H}A%Z?YTjd?XI0*qin0WxBpfz!tWSW zRV}S?UNjGVIs+>#scchhw6VUy$XZm}ni_H^C%$(qA8fJmAy^+xsZNN|4o`^gzT-sk z2l#xGjcqPs94TpDwEZ;T9FV`(g#}FsW6jLd!RJ7P_{R&Wy$f12YpYW+o` z%3M`e{WsjBluT@54nAx~$rVXIKueuezpA35T}GXr+K&P-=|P~(n#amI*I{;@TvD0%~xLD_{ zo{eEr?S_?NGbxsG-gDT35Ko|H$H7|GrlTQthx~w#GAS)yD>Z0iqZxr!*g3o2;J8BP7cbwAkR92KJ>R`N(G;N~!ZQ z7D=E~go@7K=ijN+sWUX$nNW0aM6Rw72&|||7|IR38R7Cyew5_u>4ov&` zaY(GYH;DS5`0Lj`*k@`Sk#gydzFboWU+Dh;YFU|^j)u$OFPa>|n=2ENSck<%+%kf?~8?|3$WAEO73z`s! zxM!jy?QJG2KVkg|Vfp412`kl<(uX9mN~U7|cgK}!(sQTiDfJI?(j>h~w`2hSIJ(cT zA3Kjv8&a!sXmAT9BRoE5Qs+g-4`!{yNawRPcHw^@*yPu&&Uq^Q^@{A5`}{Vm@1Y*c|g;HMFrEMVd;ur~qq_|8S7!znG zI8HfXRc;`WV(J)OYhM6mGXtb}_{uOxx^-Q4AAk#U-~qJHDfOg51PLTSF*c+9b`_~9 zHsz;wC9UB%U9wlhNw6^L2)g-56ePaT+I)uX26NT4rK!~;_%3D#ld{^+G-@WPu#7~E zTZmL(d`^W!w!4RU^uwBji5qA6hs(giKwC>M)XjW7Bf!~r+mM{jRy|hliU2_*u2m#S zeZou3wUOHEr-NTEaE%mJsj&zLjg`C80@Dv^$#b!U=!?2%Kv*6asq1obyRkad`!Avr zqwuTljp1bXx&ADhaAy}hLWTo~v_G6kki=JwhO)Okqr#C6+uU8t7hs@FlFH9nWy@3R zzwFW1)Vk{3e9jTqFO;yLQr5s6Io~mclA{xrzF*BGC04nG<QiA@U9zsMqeOhXn~IU10S6{&uL?$DSR5CT6e)~byeX0F>qU(sZrAA)jt`oC*)xu5C{3EyavEoN16H zZ-2Kxb|d+PC!$y{ttY70lBS3#%qOgIe{bRgk=dV0JP&n}9$OX<&WW2yzQ}!hsk_{8 zL+e)9#%MGiirCow^m~~r2N%mF+zZ8(5uJuw2|xl%TP2VarV!G`eBnv-4B*2j@FMjZJ~%F((*`2%}o5 zp&-&((lV3O6ccp}b3<8K@|@PC%2~253-$U;S=omF9UAO4TV4@Qp7^5> z7JDVtLYAa#q-RYMtr8_GvUKk!i4OPh`3)@Olhr%@ zfbjG`0kh&wO19wWa5L%wZCaAja5I`-!n$_2Jrrt|_BPIH>!^N+i^$Lm@mHQ^1ajLV zJ2P2zJKefe0!zY-wOa)rSrhrRkf?uTu(0s zVhIb7swm5_&BWFKpMk58uKa5vy&W_B0gn_Xef%~S;2sQ?RHj1D#geTdORo{%;~HPt zjXl=9kvTYCLs5U2;|9c+?CF+Kbdph=ecNESMRkjKp-cCnms1nq$)UwphZg z3rj$kDM6r6P{KGzgh^^Hs%6@lY8a;G9GHx4qLCN_0_iQl#l`Jv#N9 zT{~LS#HcTnid^hKpG}@pm{*NZsHZqdACycO;%u_^8^?5efZ_)Sp%4f#eOQ}(^X&nH zJ(}<&!WZ{7*T;egi6|p=A@M-34!XZ2^e&j{F3xrLHHWP2uoCiRoA1e}Mi6_0G8%+U zbPG=;*^J7HG>XobW*o!~GhrePRFNZW+MJ9xpbwt&(kVTEj=SzrE00vSZmS`3HHego z-Say;Rg&7L1VoMhXy{AYs3tw@7<)v>Wn2EBvD7dOvH}o>!9ZOniV zfR@4n0GY|P=zx56c6@^*vjnSjQYN@=zWM}Le$Avb^K=$2bO5efPLaM$Q+gxYgVk@w zrjIB9*YUi``qGD1g$!!DwOa#8n1Cb!gEe)lBHx9A$`uJcb}npyU$}>X25v&*@u-k* zM5_%KIiTLnXtxXI?E#L6L83v7o13#~ywKsLB3!QYe9E!g(%*95`9H_I<{f&aD`kwT zjldKbWjuv>h2tuHSKnpl8O5WdvXV$#H=~=kl_^cPWf?UBx8FmiTvm0dJZt8yPpLBN zCIDCF$bz8}Q@7f+!jch*qJ&e4kK@+!idV01|MdUgnuT=%I~_>>t6DQ@5uOYR*2JZ@Qw@vbXoS zqXpibYB~7*Y!YgAI=(-hyh%ve@M(6so;Bgg)XB*_F@L@`iN`>9@VyDk#0HFEpskTu4} z(9%W$P~?VSu#b0+azQ-yGcqz(Ca+xgA+!!_?R-xRov-uPw72$XbzA+m$L5wmCBM3r zIhajcnrvnpAPJN=R#-rlZhn5Pfu-Z#Is6U-!`b}Yl09q*%D*D8q_ZA-Vvyla3{d2R z$c=^zcP9ET3BRlwjzV*2g|mEfb}p{oAo$0UUEjk5k}gz$B__0TsYAfqXn$C3LSvGOR|_PfQG4v+~7K0559rFgyhirD4t`l_E?wc-EQDRjy4<#A$j z;!jI^_5T_OZ;P9;fGEuu`0nq!lmAR$-zL>j_#!WeZZMP&L6AV zQ;evPI^(#ET8|Sg-gKsi@d7#AAf6!gr^-Lbkd-#DLkqFU$?*dIr|dF_Rxo2FT#agnI0X)^gO^w@vd&3X9cWs1U2(NcF>K z&sC2TeYl`bs`>XXPi%)w47rwd%{Xu0>^nMt#w2|FleO7^{nw_Z=vpVO6F>LKh!YBQ z_?=mCROxn;qL3-1The6J=>R#!e#UK3NqT7p(Ht{Wtd-QPhaxDMk?a`|`E(~D_ z=%{OV3+=?me~zR-v;SkzNxx8T#0!n>RfE=S_@FsvPkw}!aS1oJ&j4X0C(hIOwhdc^ zlOQ;f1b6BKqp;;v2`Md zEI*r1C^xWqvh7G2_9D-T8L!K}o*vq`QAhbuO}T%+-|RP?Z_fGoILfxwmmJ>h8|f8>Vo6%yoxPLBGKodn?5YY~gvHln^SqB^ zLzmw4_5v>hM=Sp79I+~Op2T>O<)f~dg*w_%Bt9`F1o{T?BR{37SAb}15zYq@UANim z1&*HM%=Y+wv{;Nd=}p12oa9kw=8H=^`cY^6ak=<+f>hq@h?5|>EoeulnEc)eK@c9%3(Vy~TB-e^azG>iA{lcJ;h zsk+aK5)01{3V35r4ajWaFmaKb8sx#HlBZT1TUyX= z)pTM}E_+1pEzzy)iizBkmZ|&GgS$$XI{KnK1Mv^nESD34 z>8BRTf;ksg{NJaF8vpX8>wC74pc23Cz5BOPE>0{`TQ#8?dmjcaWH{>I@YoZ% z2Rfk96NP!&5t7-$St8jXhAuJEzJqMx%9~_t8Hbjc5Vn;+3=2ArgPdP8H&2#JJqN1{ z?JI2#s8BJi2Y9}nc)P<+X++!T!T6s?4)2=8o++oeMUIPTgnmMyy&m-5`BLp&Zu5=5 z*xt?AWQ6n0qe@&*EIyk3UvhKNwjdR~de&F_J_=`5x3ewjA1B!oz+0a!p@}9_7WdJ- z)ij%m=_19sj)xhXwH?sduOmaDG!!AnQXrq2ng*z%0T)Q<%qJhbGbf*ERTs5%dPR9) ze2zso_fbWY6-4iwhx+Q|n2tFg^-E^X=%MLL5n7V?_`;jV=4d};vvn!c$Qe}*UE059 zs>*PK4(^?TI1_W!s%Gcsrl?1yd0UcEn_Fp<=;Fm!rB*2a&M>TW=|(PQ;I8g{6ly10 zYtEG~GaZ0nAmnW?nwroPt~H57J#0rXYuyy4>Tjq@u;%#&4S7-L-Gr~a8vmk{Y03HF z%u*}jgJwKoZTlZ6~NuY&eH`V5@k0I^OD|8{aXJ+t)(#W)S zpW^R`Tx9Kv=b(CS7GkEd3n5eci%LE zh~^MTh%IDsYe{}NGWYu*atNV%nhi zk4&!&=t%#X%kA)#SQ6YuFrY7x}o zN7)0%*pCO*%A`cmaYGWfNTYrP%VX-ip!}jNgC|Xdy&C(7H5GlnZVrG$rp**pVGKOk zRq0L<{G@6MnW0kPo>m}b^;iMtBbkQq!;CJM?j$EOxnhSRahs7)TxGb!a@g23!oGas zYX9uoYzOhMFpUDL(~Ixeg24^0>7ZcdxU#h7 zkUi<*-c@G9XnS5{DWJx@#PImaTr>U%?mMEyFZfzs_2h$2Ewe}^*(-K9;v4b8f2FOiHQ_@8UKlwH0(6Yw(Aa2Tp@%hB2-lA5*R{p`4SXQJ}@~!`O9k z2H&{1Bu!nJ$60NnFQZo?(5awFSEDIM%|m?e``V|vX~Uc&{@E`A&cMpk8qZp5ygsoe zv&y!NsV8+BYl;M%1j5lF7oiPL<+V|Uc~=WXL;MCudY4bB+Ur~25!4&Xcd{VrdZ-D{ zu-=5usvS*ZS}i2gVZ0N;x#^&N@6Omp9ZsO7@KTl5@|-bZNg6WLExtm6Dnm5+jzej8 z8<>V2^9}O6&SArIz9^h`T>Ql27%Iz zj*}^93yEZC@;0s;k|8niRQ}iq!V8d0xKK6$^L-f_awG!FV z>xhyTzJ`HpeM1`;v-q$?C5l!un?2D}CATW!^oaJ*h9fIH9X?x#r?dWrS~UH+dEUze zyH(B?e=hC6-|$s@eyKaiXz`DSs2&~s!f?n&66MBW*zb{Eh~~xdkc$r&SnESo{s;%X z_R#gN7#N!Il_8OQYPThp8V8pG_bCLHcY6_KOM@iD4WR{R@&5&XsC=fF)Lf0EQkG)1 zAW3$ir=ZGq0d;(4c`Ta+(u-KXRbY5>@toL)Z>FwT;haV#(q0+0qESMvYiz2Uipdb#Y56ED}lYWiL3Mm5fdcs{!OE04#-{aDMhq_^5cVroraCHUVA zD>9S*;ar1uH{?GwLj2NQ`~Z(*Ij4ye39G%-9OlNJo#b{jB4_inMYWJqU1KV`=K5W4Avuo^S3Zr z>fa9GFaRd;cTw|F$RY(WXU&nIYBR@_%WToR5koN>FWdovgnI=#v!H2?VQpSIQS`<7 zc>;EewPL-b=J5?w% z2<~6qn_$rk3xM`T@&A&MCjFl>wCMlQ7{~Xf_W#lNZ|M))@;bf*aeV5Tr;YuhkT|Et zlZ@`Mb)qkYzfZh^(OHUaXO^Pr3~f-qW~nF@{-dlv;zp)TQ(W`;xUgj6 z`NhfhpyfCnTf8(#?oSH;Qr`H9oN@4rL0&igGfZy*1C_=)$xG_0P!3dSv+L2$o>)NO*Gt6ZCU~>D^6v)V4?(*Is^!uXh&z z7Xd@v0@O;?`-)2VTHwuJN-W*{psZ_kV5GBc(g^ArtM8vmj~TZIjgPd3Oa&;G>W-bb ze9QI+9>2L@57fwwe3w~E)zhFo+Wjr2K8r{=0A?QJ@cSUgFQlxA!i9VRDG;$3yq;)#Ez zLt;*}1SIO6ah#m9NWEH!TI)p zV7Ww`bVM7)kTV#P=g691p=?A#E}oMQX&AE+tL2mZHO|m_1WA95cEE zgfGBtxJVo(RqNA6OvL<1^6t$FrzjvE>f{w^p*?aZRBcX`{K-_zeM%d9iXR+s6=->Eq~rR7Ea9e2Mv;0;^8Etk=!j>q=jMq%s< z05bz_m<(Vr-Qlto)+Avu0w++)NdO%1lvRh=`{t$N?|U|d7V^3cOm>L?`4xVi(;u8% z@GUY-x}7v2Ommyq)u^!9DuBWIfkq^5+2kN()F=ibo^KgHy8F-vS5MDAeZj&|=90@y z5G3J2_o)DZ>o^?0L+ao0RAAqB+0ksb7BbMVFM+?6f1Dd-;CZDKbjZQ6RDhfp7a875 zzs{*kzl-Y>(Lw^Uk@An| zx$8o7K!NTN5~B8!p^Xo_7BK?P4{-6LfYt#bhqj@fGb-6TR(spz(^jtAFplQJtU{8n z<%sH=KuTpUOVLGcuemNyCV~n@IG3>g5It!h?C|XAK2iA`714?JKDxS}ckS79P^jCh zEBOIiQ{NH=YaecHZGn|@@8Jn-*4U7)?Ue%6T4z-XlK|zHh_Hca1DtGV_L-B3fi;pi zFuX-N5A$(%F(d~|hM!F{Lip_Bd;idKd@_sYOUoj?J;XlOOt?+-?_U~PM2;+?4r%Yy zO5oe3V%{6LiEcwysf!55ElzCSSrCzJkB%D>IpP(n?02kT*M9#ru-O95wLL#XLe`IwtDsBgykK26NEA2`8)3hHiZjtsebv_D9{V-%ccG zIci?WzCG(7jEhk-q6NW!pF4jC`3mg>?g!cF-HG8!gV>7&AI!}nI!LeFa@-&@nQq;6 zro#v%-J}-!%`ISpz-fa(TS{b2|T}eRsl5&w~cC3{V_dM3lV#y+sBs^@MhO=4rFxy?nYU)3b^>KP1U% zUy`D}-=!7GKt_$b6a2Pu5Qfp^LU4O`=5~Pns&-20fY)E2$wxxBO%GD?y}M91`E(Qe zs*`Z%*();x3Yi=tj~~P!1+jy~NkDYuhRja93x)u<8#FPMahek{@lOvAS_m zW!t~4=mo56%c}vOTWyj&15i4X&j;5;pb_9_pSXP3&!+O3;cmc{4smBKx+>?TBcop% zCZ2GEY>%I8tTYQD;bOgbd_~DF3%V22r_8CNSrVwK>zJ2oV-<%$JrI=Mj{E zk8Ih*ImbN2*w{_S*c*6f+%lPV`I?{=0729DXw5fM)Ar!=&8AtLR2pXE?s(@)dDg3^ z;+k8!<2<|K?rhGMtt%I}DecdaqY+?GEiz<%G6H7hAy|G#d|BPU2+eE(3~vlRY><2> zE%g)hXL@OA!uRv^k?*S#8+1a6Ms26ijsLg(^<8&&H-iBLF3Yt3_iN^Dp$3H7JDnEi zypsW1N4c5%38ugnc3BIJGJ(!%PBkK-CI;+roy2w)9YP<4EUkfi4N)^S{-3 zNpJbD!3J&AK0!#iNLcD**N0FHi$DaOKJ4v+ze22vXP3(5o`5X5j>L~mZ4VUMv}|0v z>xr`DYmCtQUdGLJJS{g%SN7RGY$Mp2llyoX$ott|)2kItP%IH!=0?6-CPa``$1tbQ z)@}!pDF29KjBcHamR6SileIEea_0$Vt~uLxdZ+bcs&gHL5lztklA=}aNIGoLvIWQ? zoc&1f9E(pL~9nH&1&o5uAhs1|H2kS^?7SUyS#1Qv?0u zQ9j{O78icIQl=33K461U@2CL3whw z5AM48Byywz&3`2l!77epr}$@|PEg0=WT=($f&8~{61KNrxqL?Hgz}s>glbqA{1WLM z6tK^UDfT5h+q)cs-q5d=zwASDIMM}?i1RR6I$*+jcb0;$X1jAqIOM)8z=%3dR!Hbx zh-_ASTRP!JEfm?Vz%>hsK%Ck5xGHfRRH%?sN<=xMi0NEJ)j<0{8-iG^6X47HNSK3TgoWF4(<0X$Bt=wj9ynNY4(wYf6$ zFsK!e#Q)MEpl?N*uXb+wwHNx;XYJ40s9q0gqrT8BdAqY@M($}=VkGMvc&R^+d-kU8hlZNrpeBpft^9d?rHr**wm70|=OspyRC6rBFWz%wVB)B^Zy5HgK8oXNhuewgw0ucg~aer)a!sbe( z=kH>?6M+wBBi;-Wk2ojU+CrKXgb{zjYrzl!OOSRm>2^iIVDX~BP(r%0Esp`>qyQ`= z5irdE95LVaG5md35md1ntB zI}4Nj1FRNa0)khEuHR5Pu zGNTX8a@;`V*0c77M<)U7)@E$sQtS=Iv&%d5Z!na~-5#oP01*f>o?^nVIUc`<#ygh< zQ)f2VY3(yqR^mEg>bT`j(p-td43#m^q6ue3;q$Xz;`i!eI824=TgMQu`Avk^NKuWv z8x!o}kyiq`b@<@*HoB^&o0L3VuV0{`mC-^*@t|9aC(M(t-zh@3b*Ww14jX=7Gr?~4 zo4>Od(U6o;NcEFKPRh}mY8#J*~JXiz=Yphx%=i9AWE_^ z_z88XSLJBb7*lkDT$*+W`i>CWppRoCjC&*MN-Y?Wz_ZnZk zdyRY_cj21~F1>t}067Hqtb5bFw8Mi|!w?d-@ zO_pIh4E8|CiFOH*$>UXw5HPWC{a}00(fQ?uBqCd<#4eQNGC#Z;CEno?&TTM3b#YmQ zfM9-{>Gw}Id?9fu#7eDvpL_{fV0ph!BYg(t6v5EPH?tK#wK6+N>4U@yemLa&5G~nm zJ!%6M7gTQHSaK!&SYavUe6->{d0=5H*(x$*U}$DVSthM1<(>e9#J*PuL_llxdT1NV zu)i)u{r)u%3|&vDf;`f$|G8_p><{Er;6(yClE31kQ~ed~jYU*?XG{y*=QE+Ga_I?MKS&K;XI7#C*Mho=?XfIPfd{;&Y z-n6=5ctq}`Htm_DT^>8qdf!)d=Yk@Vd`D)ZS?ak=}8RpkS?n zE`rL&&i%aTxtzQo04N>s0qWpH7=QO1+D}dRB%XjT*VP>#!`r_9J2srbbidm8g2>>d zi7s0*)UOR8YL5HGKOWF*8iXL7+fR3EswkaVc*i-y!f8gJ)3$7mPku3WTi6^Ti1mV; zj!~<0^R);p9O?e^5OC+u0BwZDk7u~Kw?=%z~BkqS172xcU_*U}9sd;`5My52h zS2|Oa%|;FmH(C}LUR2y@i%zJySPCbWuTetHPn3%&mvWqUSLD8|$eyt&d9cS#eMZzS zGpHiJJuZP*k!&eyA<41HwxLXch-+B2pUQ7e=V#B_?Yd z9==7{>W=DP^EQ>5)QNo2C0?%QL!_^AG1~dHu=Kj)Gxo$HNzI7#taZCCU-?2=pd1(~ zrsQDx@AjWYuh+Q*pPfU%#Ub#XT5Mvb7c|Ml>>ZZ}U-$12I;9}Qn1KuukRbVm+S=mJp44 zModk)l}ar>=%ibHZ?V=k4bzAa>)GXma{q|kr6J#^ITw$nb&lKS zd|4aYPGRyAz(34kbLzgmUyhga&8@~vDUp3bM#W|TcC1GOwqf5tAr`?nok+kYJ-jTtc|Te+%l zxuhOe@ZU}+7-GpWW52$5{`m_-Ghc1=BS3Oqd1Jc1ff~OJ#{FE&S|5Fu3clCrOUS&nM|R*n|%d!Ov&oaY5-+fwPbM#g)xylyzz=4R~uC8>!ZHm+JPA^1xiJE0)kjtU6LK?8K` z(jATz@ZA(na*1Q3EZky)!KrL0bl@phy+=QO(8cP8^?pF4GQ4Cv0AyzofOv1f@x4nI zBC5HZML53pk(GcsJs3_jkq;?YhYW2*R1LySUlH0sm}gDWmH;t=H;Ert11MoeV)i+Z~98W^8&%?X;7vE}8Tb^T_2GJ%~^H>e4zm{`cjRA^<0a?Wb> zn)RfMW!~9wiBpW7%c`$>VjEeVUJ(DM6#SSfPVU`2+DIW&Oz9#o3>%Z7Tc6tDyW%^$ zkoNUqHEj*SW~%~ULAPG78QmT$I?wB@O2q9sR=EL)(`y4IU~i5Q1ex~J8tDg;%D)}3 z34hM%sxbI?E5*=IiNmc*X^h~4dpT^)s%0NBQB7N^o!T=vy|0WyqdNgn3Z3guKhvq0 z1&++bQ#X7&snbtjw}?_a4+3WEX*(G@opLq*Pit=(7FW!O|iF{hFu!sz=~GC1jw$&)@YxnVF|bA)Q;MWTEef85ko_1YtNcx#gAU zp%Vb=4M}(1o-kR)gfr|jsWq1C^2;BUi4Al?zs7&a__CH_HdCVLVO3QFK=E1jik|Wc ze?f5VMfda`X5)OD_WjI_P02_!Ljb zBXRH#4NXfsJ=pgm7HD{_ zO`=L6c82ki;y+J0gUFKhQluR_^`U9pIJ2`qh_H$vn;%F;ca(jzs66g>-oO|Kr)bF6 zm$aa(ey>%&i}#!%0*UeI6yRHTtUNl9(IMDq&YGQ${RTD1Hp#zQ6Nkk=jSK5-y#9Nb z*97%3J@8Z6e71r{Zcq&;?-#M}U!`=nqW36h85|QykJqUcA~A-DcL@wOJ=Je(ah@c$ z@WYbG(`m@?5NW_uz2qWcMc&UeSS6Xj+k^L3_wP&6)q;8N7FO~8DO_2v52&KhP)Ne_ z8pv;m70uzBxT$x3)99<0YDxaZE)g_^F5Ax+uce-Z*VurwiV*PDY6_w7PCL;oX5oD_ zp4L*CdB>WRcK$NBN`}6TJrI}Q+o>GRoR^_IBe0@L-Q^U@Ub_3CxXBcaU#zoPX zM;Yl3HmnluGRxVK_9LEwD{UIGG@<-~jTr{!Z^WT8$qMf`a^z+vr}P8((veYgKDS9j z_Xkz!fn5t)MgAPygOtAb8qgl&Z%!kXjSPigvs0-hWD`Y!5_|E`#I*^)8uVCtsX2~4 zsw_WPOLwBgX9~?ZstPsL9h0%LcSeIoUs}c?ked`qyG!If#JV$3>p?QxFS11h`q#?B zVtzAOW#KK+I>VQJrlVbwokZt-dSE$iWVzkccx@cSJTD9CCKC;jo*b=Q455|P9ms@D zGkMo+2a{4f{`f&ea9%<*icXtwU0SQ%RwlLdUhdNi>G2p!p?O6!L~|a)4NA|~v~X7U z_EZ7>q_6h-@mddLMgqX8r~fIVcp|$EmwIN|8$8qH5sXFZS6b%A83O2Qi}*{gzTYfQ z`zvJ5C+c+3YOgfJd+Jw}pEuz;-n5`l-D*ofzYIeK$jkX7JzG^=izp=V29K8hy@kZUrVBjV>qjYz$!`*IHY_NW9vApYzwPzg^vN*!aNj|;VN=n^D&n+9 z8?k01D}i^D^5R_c*V#b#OQks87s2f_e5p{+cfB+#riVsbYo;N-7%V^y zkadgG_HOzrElkR-|Bo0>NdJ*B6Jd@*ashs&V@V!CI-M4ZclAG9f#0;(Z9kD)yvh>! zeGx*VpNf=%yUBbF-TWf=%IGVUkAgEQT?COj@L3f%;osE0t)*> zYM@F0g?`^t?6nq(#I-o5EzaU)WpQ>&pGsBVXoEoXJONJug*;mw+C6V21eLkGw(yv! z07s+S3H@(A$`8g^h-sMt6%5s7+}`D!N|O#O>&0bIGMx!Thi}APBJFh0A297iha+!n z8we-0`+kKA7{ zaq7p{;r&D$KEeLk@^cTt^zuAqn&9u9xArHldEHKy@GpZ;r3*v|@r81qS2j>4N#r{@e<=9GP+@lkwgafube8FjC-PLnJEJvz4)^H#j8p{IMry-nN#}S@IJzWyXDv*E3Bp-aa`T z7*?3+(=OSb)w25f#VJ-9)~YEfM0lBbYWN5x-Zg&5GL}JiAjK>T|qrr_PAS zFu^;UG4D?#X~@*i;JepZ~J|QRW)))mX!N24lnj6@z17!x8^;{SV;35zaCGpP3V% zrl z8V#@0x81%@GhI5ZFWbUE1Te8|T~<4ovhR5wX!eJH&PgF+9?4+39ut5l$$_1%!8nLG zd9r>E&q03-&+WvmRS=1iTv1Oag`{ElllB~;8#`D(kV1&b+fZ(o|1t4Fuhk8&>)>YfE3#9+81+A+2()_r z5&FwV3*pDMuoJj7ByLbn&*e*W)Ix?!q$jm{-*-{;!Zh157&Dj2L)R9Vi3=U? zl3&yBnhCdR^Ku#k^HA`G`8=VehjgwkM^5DrfAf?kB|IA2OB~0f=CTy_WtvcykpFzc zRGM@Z(fAdd@t952GNnHe4oGh6_(jqaccUqXu|u+m4#7n%6ug{rQs3^ZJ*gKFTZMp7 z-xwnd|Iupw5RxsvowFnTW}A>=O@|0BN$k@%1_5UlEj1DxE7Hf>sgh%Y52}K&fnR z55l1w&_}WMc6w(+PbG7|zPX32Wvj;T=B(b>2)gy(R4^PnJ_1zjk$?JBA!A9Kc0D4? z4fa&y0&ld2ZU6-&WB$R!ELj-zdn2sSHg0NW$MITrzo8PY>I;K2#J}&VZWuQrhQsRSo(0UYd~etKJ1Rs=DO$4{j<8-D(b zi2Z>6NXx1l#P}`HaB%wo#N$7lGW*9~f7r3J!8fZb6z?LncTTGS#jhI0&-5jI{RUzp z42BYFj{Y-Mo*W>c_>PzU!msZdC(>(tMcavzRmiH6_DBAkbBq{=?vMS)WD3(m=9-}+ z->)|&3!T_($GkjtxCFj=ZSM7h`1n}zS#G}S&ot(TyUC{k2&~4M_4LS@^Xmce@PI+$#vpe?sWrPLuLUSA(z9u{3KLQT`6AeQ?aB6$5d=g$Ma1qt2Id9bc)&{+&tFA zmR=e?#Xag0FJdPleeT1vli_-i>OjN?ZKQdo2H2@b>kPYc^LiIbFDybyJufBPZMj~h z;``x`?*eb#34yskZ0NVgK8#GO=c(KTWt)Qx3!`Gt=Gfw^sl?A~-XtOkHv~94<_q7P zu2FbIIMbW6TJ5CQR0H1t6L&&F14>cef^<*yFa}dK<=Spar*jJ2QCw!bd z(?K0YY@gLk>PI*3G7^91Yq1DAkK{iS2JUJx)=XNCJ^9%b`iex$wkO=}<1*CJFmR-Y zoKS91lG5I$V?!WQbp#E zF{BW!(-USYDhz@Aps(eKsuI=ce$zouM#$7Ce!6m9WWEi^B)0ywb)3Ctg=3Dl;d~>p zFEYKProuOX!NplsPz1PJygcRtmFG>qmDB3iaKg6NPzP<&8&1qnWUbE zNDq7|9L?2E@5=N`|4qbwTD5Y8-u+OFcn2OPXj6PLZdSk4iZ%*&E+t*R0lEt(96=Wn zj9??-pN(0)ZE|_%FDZ>6zOmR6t;K7lP;1&XtV090;;O>GNZvW!N42_u@+&~R3aQ<4 z)N0n$%oUp#@qHwx+Pk`dGK{p9vV{a6o}?A;z+HF5fq8~i-7B&-QVV~Fn$+l$*AaT4Dh?vk~qkx<^pS4X;BSQD$*RDueB zIdg)9sI{R6Sg?64I8%Nq86Qp52N#fX)$;S#(1xk{}3Dqq{f0EN73$MK~3(;aB&f}p-7IN%tK&Uq4{c%k%oiERBtlr0g-zrnLfZ%H;4RuOess`Sg{ zV0O77yf%i{KzW~#>ks`|eTeoy-kqj@3&eqGl$D5))cb(lGAnL8EFfol9N9-9*s~A( zP^vm0gIsDuaeF7*9R}>3@lxpb16FL`-9Z^hbN{}Nc*}OJ7oCo`J(NY-wiQXkn43Sr zeI?ks=T|fBJSUdf&*UNc`TO7dW*?VLknq zhu5z5OLc)*OUZ#LHu-zI^LH@X_;kjS21w`z!5cH~sMjimTvqlaO4VB;!b%zgv!s=r zI$E#Pmc^4f#sY9$P?9##VM+tIDX$px>e$c6-)msGy!5pDH`$ff%6f*~EnK-E3hPe^ zys^`)UvxHbr{7y=uf=9=s7TXu{Ryzo4xFAIOT&tm+}lE&_!=5Vs5tAo29en%*HP)g z?(%!Ld982_Y#xs@n^}|-^5ew|2Mpt)S`K`RaR^sSkgm1tG|Jj8e7Qm!kxLgny9fZL zGA9j$NVIe=zh74LUm;F=H%h~3-lm4cOJaD^>sY&&EKUP8HDwFqJ-pL^q5voUd^jXD zHRr%FH*4LX&`JO!^rqaPZ+Im{%)_`b?y$=!-UQ0AtUq&%f0jzM5%TcAU!#P1RP%Pb9!FNeU<;K_ykp z$dxkebB|FqntLu9`aC!2SQGWPx5D2l6kchh9UA8Xly#_csKov4=N@K82jW>;L$I6H z{1C-xz%lu?-!&*Vh^5bMTNlj+&Se|)%gw(}iY9mox6JKMrd)k$ehDlX2jbbNnwlk&bfeZ7RCWeN=N`*MfnI&9Byhr z`lzim9m3u(&Mi|32IDl9ZtY{kjP~_qva1W4OG}HAxdTTVGY0g-58`Unx@7eXpDA#v zHiF3y5y;&Cwwr0n^V53XTjvES_g!pQkIj$nLnNm#=xDgB1zUa(ewy)t+dyABDROxi z6e^vnbpgAorT&=Z>&J_dKA^8AoaxwzjEVvyh`w_y;`=GQv{iX+s-FvDaEaMRKPUyA_dqUZP}(<=);D>y{Cp|a?6g7Qxso{pATAgu5@g;YoCmn_xFoTz#Tn zUu;<*%&fNuMl-08MwF6`C@fd^iknwItHjFew$Y#k)`pXslId?jn6X$*PhkyOCZ9hL zLQS#Qn3%9vh3hMAoDMftA$;F+^wu_bsTC_On%2mGW?El*rH<>Gh%{``AZJ;<1ZT(}6msFc8rIO7IvKPnwgOGTqd{Cn&26wKs{TXxfH>oA5nzyx@1wsB0|~26x+3 z*s0m~=#szvG-lMEsr#6dUzBwDr8)PJpa~e)QPv1b z)U@CErQ5$2()g@;1;YHqjD_hA82;64GSszCp|7q^d328h3yn|4=bbimel#LI`eqcC zUQS7Vw5`;1rL)aJ!eUvZM^V-r5rgCXJF@|SL=JSL(lbw2W3%z-UVF`{ebi@ z$h!+%P`=@+S0_efrR3_$%C2Qq|{ zO)99gB*LUevt;^hiY1*{ON&3?j|^32832SwD2Fh?zVUp|5lT6)11YxnhMS=34GOKoeVM0*Gs@$ z&rtL*h26{PRq&<_txG;&L1}k~IdoN_kCiMZG={D;WkH;%zHISkc^qLf@H?YVbz(3a z$ISHf_V8)0FaQxiK>_&Irzo*(s*LyzjU5~Sp=nEph(H@_BB)t`(Y!cGyiz1!SrT{w z4vT4kgQRq!6hN@-|Cy6eM?z>Ya3rBPsLy}lgg`2pWOShbxN*$?#^nU=!~%Cq`7dvm z|GR?<1tVbgIhOeUOG?hIR_%KM0DO4qH`9LpF&H&)Z65&laLM@}ZZu?cH^v#z>1d ztCYEGY3;1GNoJg!L)XO-a=WNwSL&gRO|?g=0S#}s2X~n7h-nI@+>NQNi}$5MS@4xt z1j*Idhx-squEq!<2tLijh>*c8=4s2CBVPXfds@@iLZQONDP}m^j%C~TI`EG}Pcbcj zWq(0~SAWu$^dD z6WQr_ogm~F#srhZeh^CFouIXvY+lTg=4btKPeEe`Tm7}ekdWCePO<|s7c0kMyK{xE9m39iWTsEEJ$>1Kz=T);wLD+~@K6= zdtej_*GoaOq-n>7JD2o+xv9UKL&MkI#`%tyuV=HxF8C^ZLTl=?CxV;5{`5xwx=?G| zELZ7zvSr>QFX7FHi$>PNP{{sb(Y@)gz&%Zb#pNf-SV`#Y;ZhZ8fV9e+B*VFt-KT3U zT^H-G#C7ExJr0xs3i-%u#ne6e)_q?~)RCiaHtTaHD*&#oy&?i#B^g-Lm(BB%JHnsP zgr(4{hdP@XV1|6cR`r>sb>u06B&MdJ=sdk#i%Yt!SaK!94tT+CzU~~1C;V6eE^&h` zz-Dot!j(k!l;9}a@;|DCQirVUy!C>u#+s62A@Wwu<>ncYmCa@h#GAY7HudGC+wY&& z=)DkDfbJh+^JgnJMVs|5BTJbq4|7cJCK&GoHO;7!nQbvu^g*nCQh`Yxt;?4aP5ULZ zSCOtV87LE?G@%ntlrBN<)=72F#Q=U4mpi{7%(lV z)yH&^HV!pqK#a`L@?=LMPfYRNi5kV3Z_Mat&T~?U(+fQ$e6hgdp3>=6Qyfp*xguim z+JSzwcqne?+skYlE4Bny-K}cyUUO}XbJW@U57K1IM}P#db7N1G*m%Kj0Y|L?R#g21 z*1?C-dcu-e4^kSg>f@v#A_>1z7n@~Z$wS=l;?Ek;BpRR&zJT`aK)`oIx0OI%oRH1} zEYDOa5mAyE@yTwLmuk#OmI`v;EM98jXIZkc$E!l$)f8KGAj~4;Y6wTlwE@d#hA%!T5Y~i(nUr<)%&Vaoh#G(f716R zhvP21qfNm;QExNAEy7wY0oRmj7}~$7NWf~Ml@a;!Wv59Kj~(R|n9}|5Kz<{(C#N-+ zwn-hn;=j~wY?)^_U@u-4$WPuj5KE^6ALvzXSiQM!=1%u>9PGxvDr@oWAGtc@b42%P zHfdA}4l$PGNEXF%m_A(2WSDr?d~Fv}ZF$9V=FT>^{eAC<`qKwX@|L9%MKl?wJJXvD zmg1RrIyYimtiWqXvl_9G>e$6Q3e8$Pc8)uxWhNot+49E=IRU>-NSedys zEM_vssT$tua#~#5ghXc_?flb33+Uy2g;al*U)Zkf|C&XEKiR-C4l&{GB<^f&mM2FbA6}7h~JXeAwOaDQhLh;rvRz_)%0CVSDpa${%pjI zdUwtoOp!1h&)p}#IT4fm((954O@YWFtO>^DmO8(-$EqeTdD(UZ#tX#t04bhak3-hj z?&|>Z53yg)eyh~Ni9nkk8DwJG*L>r}SbtnJnsEIkmp;B`J_7Vj+m@AMw@Wp>!0luj z$2nP?KT`*mkvH=O1JYf>+*>g${(2X8f+dt-lhE&GBl@}x#DV?hEGw4pYH^!@B=q~q zy%;lq0eDttn(=PiQQKp=h_~1xMLXhFKccZ87Y9?XTzP44uHaI@N>*3l#R+q8$xdj7 z$V2Qq*IzG7&SZ^vnOq$#5*HM*-=}6Sq@BzhA*fKQFZp08e}P)7ql1Iv-OPn4Tv0SE znxH*0F@KBcbL6)jX!O~G`X~F=-t25at-+2zh3JJFP}7rbgwFjNTr5!q(Gm8mOBN=m z^7>!5`(W^R7&fK<^>-)T{-pha^&DV&I4IcqIq*1jjNam1ehr}7y6vmqN(T=1+uGE4 z-a^iA1#Lv3p(-;iAJnzYJkaRnhY70B0SzACcDHOh)-{uW8ZhgE`(V)Imt^$V|Q6J|ki3EHnyy70(bUZi{XB zl>8o6)$pS$XbCjGUJz=hg+PWmJcNciCe@2+Ux+@U8*4K!t|ZiB>`ZnltR=Vk;GpH- z$`B|yM~ds&?5egc!%y=$Bt#PGtur;+W^B()dD#vQfp8Do5`{ohH^IQUK%}5)ayOiJ z7(GA3{ht@E&W(b$Oe1d;GSG!IjuVPXNlwFR$4M{YA^CZKK^vzo(8o;^-}uX~U@fm{ zpzI%i5DV}Vc1PPzRTpO`G3v0W5XD)-kIHb6a%@dQK%1MXxP)6alM$00F20=#^uoi& zoU5ZpRStdlEupRWG-XN`d2v?8{9Hp|G)yq4Q?dDZU!QW|XMT|~ja#hlVXsb2%-{e9}r5#_F zH@F-PTasE;&&YteOrT0IfMOlQlyx5{>Q2E|A5ujoDCJe~mV`J4N9djYns)ByW~{|g zMwxnzLsnuH+R^7Q*=A$@L8TS8q@ISptEIYS_K5%@xS`Oo*u^FtLS{8|hjdN++6LSX zLTe&b)?fxpis8mb(!d&}V2|*3P!*XfP@>pRoRoz&#m?37$8xv=em2;>iEh%!S#A|} zoCJp!DOAfv-(|dX=q0kyR0|}4)$2AeGxa$9A+Y*3kF^rE>6+-Hy_$m##2TElBiMk2 zbT*pUF9qfa!U_>QTKsgOE~6W)qf9)MAa|)fOBpe?UdPq20@L}YF^@H^7A$j2C!B~XzuiJ#<(`PU?m%7B92gxx2UP;w?iSK7=m=wc5MjcDbU;{y#Cjf&JMNd8h<*tMb5oWl6fV}_ zs2*;0JKVW}%CC;|s(HDU?cQT!qIXFH zHc@%qF%jR3WUu27H=Pi+q{P}3aH@3h}eNeef)dYr*#~~Kl>Ml(3soO zn?&nnr!CP+BqiNYkv!EmWwm>Su7lX#qEKkXDMccS-F9IyDUzn*Y@-+z^JqmbJ>)_L zTK>1XI1U|!0T>;Bmoqug8VL|l5|$Ro>%e6MhKYfYK+XQJ zSzLq~v9vc;Aq1K??lEpH@90EyplCqPdyF;wTUq-2c3d3ED7K~(SpiJQGHePSgopPq z%s+v$I}egE#6nLQIbf`AgBW8*f2Y(xl$ z#G4-uo~1V<`sQ;p<<^4ws>?kyaI#pZPS?*VG*t~WC>vZ0K2P9b7#O`O{?MuT1Orl){rJVv>$g} z3_xRN&im<vscar>Dnn$Z1&wwAlR0eR$SoYzA3Fa}fU$t3r!baryUK z2Ja3{V`TPN=7HHy>dbgZ4fkxaOC3WAl!5B7_;)e;_#cvIg2t)p)TxT30(q!23BE%+ z6c6P=2YeJ+!Kqkw;;K+;anf*=PP;GEE+1aj1SxeTLIU2uhotnq2kL)}QFhW|Uzz>t zC4>elnN4h%u2e34q{X}JnGqxS6J_bO_8GscV=ybv;R|)^9FASY^b(gJd*hDNd+LRv z@vLYMLA+F22qX#n7b6N>qD#Vgs#wasjRb3u-1vRb-{0w7c9LDFyh&;sAIJC;OGt14 zj+~iNG+jF|O)2plQ5-BHWuZ~H7c1VU`i%;AnjvthAw6&;f$ySDg{``p5`qwQ;L4px zFC(8;QHCjgK&pz0%HCTQqZBJZXJt`sGF#G60ILG{Y4xXc&Jv|e@#Ov4M^-J#?J06vvll1WpRn);Rm*VumV|j32JVgKD zt$TYbvx4|^CN~B8kv;IK-t+a!eu5I~Xr*qUJd1K3+9r1^_%5x+R}Yb zW=kMu?3qJE)Y~cKX3FuV7uxx&F`A@0)09T<*%EOHmq3gPKWGGw`g!&|?ALVi$Dq4+ z7Z`|AtTlFP)2kiLEUG0ZY>7HFUw>tFt!LYCtP7H6kUq@?o`D5B1PyCdb37|qEnTkyHkbk^pb zCy^9bDl-sSsSYi-#YNnV$gn(=6u+d+8aIeu;o6xcp~?tpil>2Q z%||f!P;|;vkk7F{wTypD<&$OG8YIp*U=aR18iOK`c)?wx7t=io2VX-v^e+$fBPOz~ zZSq)l0>kOM?4x!@;QS~)PExaLMZzg;abvv-RWZ8#-xNxL+oih*1+`JJhmxSHB)S|# zF??a_F4@)<+dDwZdWK5A$hi_2F)87|VeP@~)?;RuWb7x$Cs~v2x%n|9_U}CIAN^QA%FCf}QI(Q}^I`(^`~of#;uPEY0?=nK5#Iza<(hC2yYpBA7;6 zTd|P(thZZ(jgQH+T}d~Y0got3M+k05zdNSqw=^ir4OmUh*)N=J$RJ zt0x!6tH{}l`EE`>HC!M5mBzU>65(cnX!K_}ExC?M%BatkI3JhY@=m7zWP1IDe}bBF z>Z8x^Z;2D4a5ap}xx~po4tY7wZKIPajV3nZ*hC`PCZV?a$cN5cc4N@Jx!ntsyHs>hX9T|J0yKg51#$ z3|9ICOr>fjTDk}Wb!UIdv$`Ynf074ubyjECJYu*#t2usEW+qWpg8Ui&AuNaK_L#hc z+U3mPsUN@8tI_6^EkJ>r{x^v+Dm78Uf;!!V=cvdPL%PJ0m|La9%LZT$w=YPX#kHpiNLKvx&7m^o5mOW`B6|Fn^#@L0`%&_jGo^-!rt+Alv#Rw zSnMV4h;-TIABlhS`ciS~GYy#!yMEt@N~A&V1-JmV*Q^TdTo14`9ZPKB+|zrp;m-ne z4}3U?zYgKJcH~?)tiF>Vum%h5`>Z?5Ci>U&*Fos8$ai=yYrO23D&sZlH(jNcD(~!( z*RK7|a2f`xpp}CZ?Kki-9(WIu3!*#^(WqT@!>$YdfN`sPCBzI~mGJYhGwu5nfx2FR z7y&bf%bi@`bZ^}jx`Zv2%+h4srK^lop?}EP?Pa{+GG`16kZ&-yf!}S!y)^Xn<=s4y zf(z}?0kXgTa!LJ!w<;4Nl`M2s9ADb^#oJosoTA3xelEx#N{s8z4i91*Xn^r#_T`H> z^{!)DcqjA>nOA1xZpPxeaKp4T18|GP(bRj`NJ4+(@7wtHSI=uMc#*2T9P8$Tb%L#x z2n(tb$*g4>b3A%|pRrI<@g8XKVB4FpaBpAK;<30{o+?X_!>{HZGtzBgFd%)e3w}2dyzOVgdFYO=&~c=Vx233-3gZ z4UywiI9?I|_bejdvk1+QsMJiy-aU0)gR_nF!PB}&BkB9ra&!8P+Q`gvM25;?F26O+ zX>DR0I=xB0+y1k^AANpJUtp^Qa4b4~h|62hauJGi%g!b($aBqySjk9B7uG=z>1Cm( zezwwl+)RU1&-Y(OH*x;%x3k#ZN=f~^x7NHYeReIRzUWt3h?iMPa_n4jR;uU=G6%`M zH)7L^qc1uv^P5`)+;nC6=Wt0&^JT$&E5`{f81*q~>I{kXZ99(~8Et*P)ollS6B?uPO~s?GJ_6R_cFp)Yt;o4` zHladCf^gZPw0A{-cm7&tgUcbypwz^e)xf{q|EaHxlsPY^7D({KHmBV~`qPMl5ke3l z|M8Zn2k#99(N}-o0{q;2!t?FJCnN{|7%MsBq?zr&yp|v}$TU zS=MUJj#=sVt1`Avvo(h9vhNP`Ng{mfNEm)qII=vAIW5$4A&Ngkesj(DP%Im^v#ljJ zIM-x!6zkFscX#QQf5{L1J+!@ZpKXbFe)EHjiJk?x3U^5jZ-NnW~?`I zEPal)9At@IBMZe3zkcoNm;+U=F3r#emV<+G1EPpWq2hKB;vt38=@kCIX1h)hJ-)W6 z>+vMrzxz7U{)a$wU$;%EBC^6wd(Fwwp+CJbUNIxst}gvXp`R;9!#oymzi^)*<%-wh*vvRbuV2I1r4mSf_oxu0&eN&AvWCw#{C)p5Hy7|e+KS_i%{ z8)T5x39*#UkQ&3mtpr0__%9;P+?+=%laJtoX&+6iUTxBA8gJTh{^?t|xiom6_C;xv zK}Todg6(M9qaa$gT<%ltk=Dwk%;ov_q28J{(^*+rce~>m$hHhsPdFt(!vcZQ^xu4xYgNPzfmNyVbO0!-3;F=t{^R}&^Cnp$at(D758 zzlq&vh8*S^dvu_pa-;2@#{210O1<5nr_QXw9~|c?pH#)8Rmggwd1+^<2@ll9-*Qh7 z`mXjsvwx@}--Ih;0kzipLgIdMvQ*J#A!A`hzTofY_#mqYSw4b8*&HDX z8@pj{I!+daJW!rXIhNznzfXyZX^4IzVGX_3cQK&5zs*h4MS4+nT=!o;tJOv*6x&R* z9#OSzMApfli}4%iIUU?LgT^!~abeYSx}7Dz^TZ#hpl4uZMG2cijVBz~6j0i5ao0em z2aH{v3DUUdDA(SFfsO^p#}-n_VJi~>kS20iX*m%u0Mx*(DtZVSYeta1Gq|JjG4I|R zu|NH4Q*TfNwnx*YYuOdf!+EUPo2h2DIZyCwQic{Bivj+evcFLibfFQ&q}kB^y;%z5rkC= zLH)aaYwumLq)P?UlrC7+A}{^c*4L4uo9DBuAckH4$~_DLX|z6kof|qCb=NP_lHUgt zO2@=hy>64aNQ%5_k zx_Ym?N&BbDmh;~bbotDhRaP>J-z4C&-BOj=)p_IEycN7-S>nq|1LMWjo1do7^{p+= zYN0XQTI}(j{I&P}`yQ%6#p6G=jfq`D|-kkR{mf$eN2u$TODs|!|Acw6_%UA67r|ABK8AQ zE(`UWh=s;YkH?-9+l?h}#|tc&kfT|m;JyiOh3O~rWy{Ioh7|8sR@sHo)XiS%;no#$w*Fa0i*xT|gzU;Del*T*@)|Yu zp+)FJ{g0k2<>lgXyt7-vnp#E!zdd}#kM0xb1p)=ZCoCUPR1r)7$;j#Qzld#j)V%|0zDV zlKx!fpULKbT7jR&0XMmP4sr902|thc<$ntQ)#QKm^siczAcCJ_z?*+4{~tN6-%~BB zBr-r)_-zMu(~ENOqk!1StNJ(p+g2N-ENuZ_dwuaPQ7oG#k5CGpF{Q;7#LB*W`|*DO D&71Vb literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/administrateur-procedures-list-header.png b/app/assets/images/faq/administrateur-procedures-list-header.png new file mode 100644 index 0000000000000000000000000000000000000000..2feab43ac6d046f8c95f6e6b8943802e0747e8f5 GIT binary patch literal 11736 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 HvI_fOe1NSd literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/administrateur-procedures-list.png b/app/assets/images/faq/administrateur-procedures-list.png new file mode 100644 index 0000000000000000000000000000000000000000..b3a58d9c8204465bf1b2880d021bb0d52c0f5016 GIT binary patch literal 21280 zcma&NbyOTr5Forj5+bk!5-eeX5Zps>_XKy>#ogV5WO0JK1rN5k>*DTiOK@4-?KZ!= zf9~DA`@VVeW~!^Jx@x+sx@W3uLgZz?VZQ$G8UO%bN_-bp1OT2x0030^muN`L`T$)Q z0DuONmsS>gdU{%1TE4uzoSjFoa~9e=bX;BCA0A$bh?JR{ws?8<0RYh}E2o{Xje^2i zTABH*0GLMC7N?iX$

FF6I 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 QG6xXDD>w9tb?!a=7xc14Y5)KL literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/administrateur-profile-switch.png b/app/assets/images/faq/administrateur-profile-switch.png new file mode 100644 index 0000000000000000000000000000000000000000..148aed96d29b49720de55cba20813ba0743e472f GIT binary patch literal 23269 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 N>Q5n0N|Y?#{};NYn3n(m literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/administrateur-repetition-create.png b/app/assets/images/faq/administrateur-repetition-create.png new file mode 100644 index 0000000000000000000000000000000000000000..f86f5bc1eef8bd9f7a8d03c1c0104cb5704a4233 GIT binary patch literal 21996 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/administrateur-repetition-view-usager-empty.png b/app/assets/images/faq/administrateur-repetition-view-usager-empty.png new file mode 100644 index 0000000000000000000000000000000000000000..dcd32df085edb74f9f4e1e78ee01853d940dc9bf GIT binary patch literal 3452 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

FPJ?BFPEA_g2?i})Z(hh`? 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 Wzj_kZG_U@*SV~+@tWZS%%l`wia`XHE literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/administrateur-set-auto-close-date.png b/app/assets/images/faq/administrateur-set-auto-close-date.png new file mode 100644 index 0000000000000000000000000000000000000000..37dfd32e1d12ead50d17707469d2c2fd4a5ba1e0 GIT binary patch literal 10680 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

obw;Y+3g(wAXHH!waG|heSJSB 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/instructeur-accepter-add-justificatif.png b/app/assets/images/faq/instructeur-accepter-add-justificatif.png new file mode 100644 index 0000000000000000000000000000000000000000..aa46c42a560861892afa19c4385d25d33b8263c2 GIT binary patch literal 29197 zcmagE1z1~66et*qLxECSytG(>7Iz6yptzReZpGb$Q>?f$X|+WqtL{sI88K*7?g>XpO8YgLu{$cT~q zyE_TV%7cT;$H%9bm>5Dr!p+T1B_*Zw^z`%P?)mwXy}c_Y)&dLjw)69cqetv<=%gd6IlG)jr$NllExx&YXhs(?R&W_IG;~OY+9}h3(Jq@_IdEMV{(9^@y z)z$Ur=qMy4`2PMuQ&VeUe%{2yq^Dy;nSfaa~6bhJ|b6@j{X)vCj3!mz{O+-`M~_ zrX_c5vs*mUi{bj6$AA~HC`#?OuvJ@>Sm@>B)z~z$*UZAnqeVIt0%_^}?R9>#_12)2 zkT9j^>Q2z1vU~9m4nJP*D~TF>&>|${WQmHml7PdvwIk;MABsHW69IsjtnaaHYY!`p zEfjz_!jRqq=%JV~zzxk3O-V$|)lZP0!uD4#;UNEzkLI=R*Mi+X3mXA}a1k z2TMV&xflS6HD8Ml%n9|WluF8T8bQs`Eq9P7E5^Lq{WvE}x}^%ztMJ@46ac_ae-at+ z-2T6QmOdHN7;BlD_PheTY%R6Oqr3XgGQZ- z6IJUzp|a?Ihana-mFKMPaG22bvL`32^{WIMmiBwl&&1oOj^u}S39)<2zn9bt_-Ff* zSnZSTTLo9ulYCmzNr$CPxq5q(ER{skU-l<#kK>sFL_Wj7GjhPRY2V{6OL z?Whh7`xL6^T^!E()M-MAaB*Aoe(yjG2d(N3#2P&E{NH7bSR8F?6kcG;Pb>1&Z$l}T z(!H+-VoiFhi;#n92(J8`AX~V?RUX4MafZYo-9H2thb(}1L3{<<`jSeo2!SYce^B~Y`O4J zGTnuXL!)*O5nS%^@?LPUhT6Zzil`+D?c!|4{a|L;-s$jS!IYRw(XJ0>+xD$TCMEiU z*$HP9xr068q=VaOXqULM_|tLj|QNKiI+f2BGZ{WTN5JE-W3M0ggEK zYS$4y9`1^RQkZE7<8>rDITsQaTzD2x0t`I00S4`#03DAAfZqcRa>u(ts{)sd*DM%H52(^mu_O%h>F@#Xm% zMSi|=LG9H}6bd#v+Qf=1Jgw&J5Cc@b3;N!{=?K|{eenv`-N8`C0G-V@qAva?fu4yi z0Isif$T{&Je%9Gq_%WgEUg(41u$p9@Dt8kR3JTi0QIonYu;*3ZrCpmR^oS6$uCa^q zy+Cd|b!3=gTi0gdVzao5YCvwzAO(B-ccFSkRjDo%qj9XkIufKjxV_Mq;mTyw33hzKO?FZ zL>ougM{FNQu!FK}O?KZn@Nm_TVAa){?0KM@_fe1gD@)&>;vsKE$Y^dizetFOv>t06V5Ei$VYC*`KIVZrLgp$$IE0>hFMtevnHz z`oPNveQ~w@1me^P!ql3DDRo(R-A2TRV;rf9GJx4c;|#^x!AAdm@m5BB|G`^@^4ukn zD=(ao3`&oNl(@I6EVx60ZBIxyBHyuh5lQA;FgY*1ZI4yT%W-6qYals743dS4FV9-@ z8F3EU9ZW<*_=Md6GoAFHp$c?-jWcHCNzn!SFT9UcN|#kMIgOvjkooifG?lXJ1hjhT z`1Cob$lqt4C8#ScQ_a9*^(tInuto3dU!4hb!}#jTKf2@JyDv@U8p@1JWZOvAWk^Xs zjhKV^N?nRa<+pWlv*rlTqcOMVNzvxx9L{)4?^sLT5ec#M!(XhncQd-aQEI1%tX8<1 z@8j;Mbhs~@I|176&6iF5iud^D^4|EM?WGskL{HzZH_FrD01Ey_%vgNVMsY4o)&CRljcxlxXVje~moGYu=?Ehr&9Tdv{YKfSLhnWrq++%RQWYOHE zxZbFBq$Kd`i2=TZK!CDOdW>VD^{oG3oW&da$pmX9H7pWjSjD@)y5v;sGG4WyXhx-o zzN0}D)Sm&?)(VR4{Lv}IwtE#;UR-^wW5_U{!mvp!$Ue!u`;eM1q&Ul|zsy`ZtJlh0 zS)G|RSAv#LHsc)x;`1QMw$w3$NxpSY=z>fdSyoM?j8QX;P(SoiMl5o<^D}yeI(D1Y z;J^5eO+Qu`{QZ3DD}VX&8yogx$7@J^#gQ_ zzpQet`Js84;O~t^%*E76EfewJ@?ZGAGk4|E&=m0}xPASE?|nmeRb@&zizEM$Q)pJ_ z67|_@7uMMGtE=sBvSflY6r-6e9JeC^_3J^jLmkCQB8Scl6?_2Vz60=!!OzGrVn5qQ?g!a-*>gl#g_{K z+41$)VHCd_M7)AL6Y6EYlNayy8FDir{dK>*2A-+7+;94c?`3??VAi|@-(yS^KjZowJN4RJws2ak)+8wtHe1)a-Gh47J%TvYD=>BF)GvY0=IL0>7 z)3rO^FtDWbrl6TuGl5M}#}e|6>xc>2k=qNe(`;k56Gcp}h(mK+0YMh-!^rF}KZ#kO|t3(Q};e>+a`LVx_H+gIC9opMNf@)tRTJ;~`WbQnL1CP4wus7ZvpS zrjs@;Jh;#Xnf6FOek4A3I2{=)LD;MQEDjbK3leE%83aaTcX6}X$rw^xN`|Cf1C1#3 zX%r(AIHE~XLIT$rPI%Rx7HOB8kFpf3l7(TM`RewoMI@ZB-Qo z?-AH6lB+l=M1H)JOzQ-`?5F;Yy@mJ<;~CIFrGA0SIt71c4t}GD^_#l0fnGbSqTs{$ zb`YMXuq0~m$8m{M`aSU(n+RuZv>QmFJ^L;2 zLlzzLH*_q-W?>EqHq0TqVUVZ>C>|^;0TOU26a!pMxdhoSMW- zk2;-noqSU}pMh=ck2L~RT<3Vw1*ov`(w)bpchP4*X2B@wBKT8?=5Ey}5nMRUn(%Hg z^Sgx?g(4}bH(2h2+6A(-(8@ZB7-wf}$tFdZ1hcGaQ0KJh4e;;VO^YHl8#6_qy|by% z@$T0*QyQ7?LLaiwP=#(Cu_yj^{_)+;BRO1SPj`6&p_ol z6O@H*s%fU!h{;XFG^g<4LIbAdN$;O#mQ*+Su*9K+e+GQ(=)%^JP_u<7B?nCwOtcUt zxN}M%yAL^3n0#N;pFTPyGM~4#?DV40;@^IKAE5X7J8Asz zYi2sqq{z{$RNpPcbv~Tjkb#EKHJzg}e8uRq1^THFGW&KiN*ntw#jX=Ttk(JW_ z-GtUGw3w5QM(*4e#4I3h9mo#R}n z+%J~_p{=~_raE$5oF^1NlEtdovgd^E=JXtED1;g6@_pNc@W5{LS)yz{>a=x6T^v!$ zUVl?3=5%T~)>|0<{R|#ChOf;FT`Y5uwT4Q3Re2_Bk7vKQ&o|Huw%=RbAl7#b&o=~9 z-m;v&v#W-u_>U z@Ypd#2JdO*uZB~~KRg?5aZ~JgreDKTOTFViO1@3O$~bI(sst$;%||cyS}{Qc0WpAT#c<0V*jQ#5j%5Y&mEkhrQRgvZ~K_fhs(jA`RWXc5Tp? z`~62^`zy54=e`sLx*hj~Uk%Tf^Ksx8gvlwdJHdj<&yK*p`S=+z1lkKJu#7Hq_Lp9} zj-70f-=@Q608w+mSf1B-XQC_~<}hi129n8x=rWbkQ8Sv~}0bq0?x3^LEJvySF3PUp1)>I- zH~Esg5~G zv!e@k@q`O{TBLu}0A!LB+;!TdU)O_@~PRTq_S*h)z9UO zS}~2eX_al8dDiu#_-#j$rkTl**Ch(JwBFx>&E(bPIGhr1H6(LcK0}(D?yNf3ufjIL z4H7DK?cv;ZiRe!snlD!QpIOTd-aZwGz%*JpF1kkT#IiZ4Q#}vt3Gh&9e<;)4UwZOS zKoj0gFJ}}_zw)$~)J2MrMgYtfS94m6t{|_Dojj;@!lL$?#FrtdPIk}wRG7Q+6kIvx zN6UDTrtYn9i(~T_qNrQ0JEKR-#9tg$TFWZ3Hm}B0VToQOu(6GlHh1`LpH%Ech7HSv zMB(hF-TH;_Sj$q8jo?C&SzG@2$mvd3dDF!O1b7pC%<7C_o|_qtC^1`jHe=?KusK2J zjBe(qy`OAvIs{&(7Dp}%-1_PSj^>It*y?+^2jz54qzkxA-CxX4xT&V)+mGcYMvdxg z@eMyChW|NazKb@L`%wcqO~mqsUTr2pEXT!Pfz$2JJWK)KoGM5NxN{04KxuIUYYj*E$D`T18?Y zySGzB+D~5eESf#b_d6c)} zO`&9~vs7NRns#CYw-X1p#}pUYu8bsNRIQ|!C&YnvnJmt(yt#zog*^p;$3gJ;E$yUd zeu5iF@VG-q9>f%2p1_SWx1`h%>1@+6by%>d#kHh%^~XRW_i~(#<08x$8=0HG5M>nz zu+2fhUbogfae23PCP{mi+wKc;EH3>amTz#7ko4k8+^jcjzFQR; zJNK?B#P>tkQURa>B-je^M=c&+;`?>4xu<_-HsdTO^|;5}TdK^)c~sCAmEM@@Pyyh} zUjP|}`x`g9kINr|(I&3-B|%XOx8{cPj1_$LLddJ0`ivS=(*7<8Q!w9}9! zLSGULdR|F}?T2tyK34rUa8*2xD*7YWAy|Oa?(egUVysA6R+(}k+YPIuttGC@koR0H z<;0@p#e=qF19%m4iVnjE$>LWgP{@Y^5C9EM_yqQaDlrQz>}STgZu*@)MFx6@IviM? zzyIcJ<|GB)2@1#UxB|bUgLsQPYtmpXvoN_w(dXXj?X!va9iOAw!@k+-oxgu^vXzOx z3c2~L=y3kZPk0UlfDVtwhXA}B;7HHNk?kq`5N(6<*Oadg+CcAt%LH4?4m0JYLSwC< zr!|@eXJSrh^3TBjGZ^m}6RIznaFX^d6Ys%~<3p6V<-Et1_jm#H2u7YzOt}9IKVAo@ z!Yq>r4=wxT`^mK%)U%c@^Xf3Fr5$m)wOW{oH`BSGe6k~ba%!>;c^|lZiKjmHbTefq zV)|<6*3RvP*!`umVs9X#BjX>%bRkWcZUl6g!kCE+!prXjSgu2yVag}x^E_^j8+=(A ztF1Sni~BCn)mJ*UD(&Gd6PwA5J8`u6JI@o1e?Ywc0ZHy$+giHp4rN`4?mWr|fvXUw zo2}+IeD+?SCRg=1o&^soJeJRyKpMJH$Wt=_ee@yK7-EY2hHQ{ zqY_Oq0UXue6Wp=~;Ze^lH0lBwls^j)=JR158Yx|yNPKwrp>Y$)){Pz5u6HDzCGVu2 zdhkR1Xi4pVBJehBo#X4(tqX}YsjArrwKyu%m2Cm}-k7Fqj$uQ472TecRPKu} zSrZVZm#h?rMJe_aDHRBSM1iX01W6zxq&$v^t2>?FhDOsF{$BVs-*VtWsE<9nP{#3^J#L{`d8=u9bmsRfs zfHN@ypf$$P=8=iJPq~`2stmndTR(2c%+*w%yr5HFk&iK=Zt*hniQ(%mjqiut#f$@1 zA-!lFAaM2syvt0;+qRtl`1Wb>!lLG*qqaSnAL79MYm)e8@1@^qgzgW>b;eLHuAOt* zVuo9~yjpmkuxOva!>EO7egC{vVO~i;d4n3W0Dj3$s)Pz+5Uwy?PaMuzfe)g^va-If z)s4=fvcndXN!>;5vtpN@+1k-+ClEhni0qy?w`d*J5gN$qg>2IiP3XGR5l7qH{J!s& zvNvVbd+$mGMU$Q=kS=+-ViQ%d!JXCnO{GDMqH3KL69dk~34jvN!}Bw67U$3kht=k| zB{;(lJ=CX~7q3$D@nZ=+)7pi~CcBm1S0y-7;BgI)=Y_IEJFEMZg|5y__==DTuf&cj zcr}NH7_}N>OdF$dLLO0p+y6^?PkW{wD2avreel(7c%<`<3&Ou|LMPL?WU}>OAhS zT9j`r?qZ4V)8K^qU#)8TKV9-!XRA}HYfT~HNt0%BO6r` z7C%b^jI1bTy5W$lW8zE0!hriPB4wEEZ}GO-Id{ofn~{~OC3U6hD#&Wqoz==zTw;;U zxx#+W(GC89Go;cEOgt#^+AWu#aSw*?lsA8ZXI}2jmvenfBUgW0SXKl5Zu5L05iNrN z9UxS*e9cm;+_tge1GyTb7TDl$Pxarq?ymX2pqt1k4nl>JG;6Wp~pnLC%E(pk?rp%(&L*56WG`$eKO&Z|t|#3*Aa<W-7=Ye=6J|gtqsQ8(zgqyrHU^QOg~_9DeGuRB*{UQ15QXi;DqAvfqc&6lO5) zrfTG}1WxYU$6B^sWOV}oP;GpLNk-Wa#47OL9y-T2hD_1?^Y4JH2Gr+Z{m;zkCI)IMRbWnUN_n>b5cpI1Sv`HHa!e!OWwC$2SkOsAR zP?)8IuPO0wU~~sq%O7k4pH)vU+iQ|>Y((%bfzDKrM}dTBPT(gq{KrHSl-pOlldEq! zulUcuKI)((-)CY8I`Fjzb3j`z=K8+S@-2bLkAV8lU(c<3m4}ge?Eqnmtn}V^R&AHv z(Ran(-fx~^k{>~kFqlkK0Q9~{{>DzYIPc(?K@aQ11tkIXOyh<8iL2<;(dL_WlMk zR?XjjG{mr#DyOuQ_A5&>K1d9FRs0HxmdxCdl+JtQ%SPI6Q4FW6-zR<(_`9S2HhQS) zz^AW{y*A2r>_%cK#WogiGwwpK8)2F~G&&?)nMB|vky8v5BCOTY>}$rqmlUGBb=He_ zsCHj+X??9#BrFmlR-iRD!Fi{oX`uk?So(QC+$_%j!Ift~5lue&w&N#&tCaZ5QcBdW z(`LF%rL#k`t@pJz8EGG(90ciQVeZT0`3d-E{VdgrSSMO$aG-S-zk-fUy|RbUFw>9h0|HpgdTQK&)2}w1tFhV(98Sm3H1D=+B41WHs05TCeAK&}G-~ zPtcu@f0ZQS^IakncKJIa@cHEln1k_H@Ke+_b5Wi?D8H zC2Hz0m>C(ifk6W!bFe&uZM-Q^Ed{_y&)s5vCym}Z))rnC-Sxh2+y%Yx7EZBhZ6|@6 zek!hCJ0z3#jlFLlSvTB=v05Lh$Za6qh$tub@$9t6t+rv-sPuN*QN4J}%U zj@jR8&a>Qa6 zdK#Pf3|sgFA3vi@+UqLeve~{=F+>03nd%*^wBcP{6Z6p~XH3KL%^LUB59XwVwS(q139~|jK zTsG$oy|aTi9Tce+pIj>LFU&hpgimtl%iP2H@WK+etJXVHx6v3e++g{6XKWPWIHAgU?B{#Lp7hP{s0Btn2TVPjwa>HaTH|ujD6?- z#?HL0qL~BbSV4IGZ6pY_^t0{ou!sL}YqDcTZb`bmgTw*+^~vakN-lR6lM0uP;?f>R z9~Al*3_?}7ITy_*6v!LG0OBo)9y_Aohc3%Z|BInuX0qKkjb&Zh(=px zbTtPGLkzU88R1D`&JMTWV1qv#Lf;DrSZ_N54m(bMJkfuaeKW8I-SEnR)(BA+0Rlua z+@Me88~39yl2Vy4C!|KtI~G$~Wh)WGzDx>AsA*m!*0$)i2&aAi`*FGxaV-7LKP;zk znjgh!%~M2=i@LnsH-wgjW1)qCw^F4Aczn?x;P1#BDgL$iDF4g7r+pk0k-Q(Oc%oUa zaGND{-6VHk+l5Dxf8~QB^wJR~Oz|^=XLA66r(*P1biUh+gv0LPYu8wQ@*0|Oo|LTn zdoSWmh%Y}1U&R}zZ*P!*tiP~?LgEMLUi6!sPt7J5PpH?-7FO}AB~y# z%VBM(f5!GSU=Ob=FXE* zP;w7T_6HG1zOgja=*bzuT^cnAXx5>Ybfgd=p8-Xfwgk9IshiRA)wcs;r7S#}k8IDu~IGJ6r#d#GhxNnu~z7>v82FJ~n z5)jCV2EIh6AV6b7lA{XbSKPobYgKh0{5mRHMxm}5yOXME^TKhr#Yo4*;Pc{lZPy2S zzeXStZ0A$6>-lRnS{$JWjSR1k@j@DBKf=D$2uIAI-CP&1itYn{@|Ge##lk@g8(56e z9`tcPPz{P2*!9*Ycd7ED*X_|Xe-cCkZfm(#+%*B!kz~ad`pKDJoMa=fEs!(h$y{d~ zwqJGaW4wa1y|e_7yVGUMdA`dvr58sI)(*<22rhdQU-Gh$$2TYuRPwn`d_4@m3VGr| zvT4|9twys4jRb~YGI50~$JdsiX1ypBfa8cQ?MUIEGyoFwVGdh=yVXzThjHO=m0R^1 z*~kC$w}7`prKKWXsbtOul<^ddA}XHzzw7teNBjk6v_qC+E_sVia|9#gGj{{yBu!V@ z{Fz+mNaqhfuYWqA#lpyMQa7c0H?DClsUC4mMHl~a4yo~uz5#MZ=%d*Kno&10Gt+n2!l@D+0_NP8&}81YfH6Tydm)K*4lw;04y zxAAczM`LO+kezFJ*&4c(yt?Y37Y5CLuHnGjvwmT!KB7y*!(4|fvMPkNerCJYiDo8L z_W7EHNhm@(VW+8U=&i#uK@k{(pdNop_>FRP&PjttV8zaFb|S9NK}4&juibtc7s#kN zafwkU?L-WNwd$<3+^32?o|3(@NoTcwP`}*ksTJZGUs4XIiM-Rwcp=DO38m1Qh{)gb zJADl}zS8^2D|xHd5HwRJ%3DI4rtiM-sfKJ-hz$wo*xM*|RexyhQy$ayK(7|aJQg$t zp;*k*H(5C$eJ@e@t2uh=2dAU>Qg@}utJ5I)&wSCmpEiMgPLXpbuBb`iS}qwdf)%hN zmzHU;#*k%AFzPF)zcGmVm@xx}YWo&vnQ1a!p`+q8QaUIwd4j!Lgc4pQ z{B(U5ezT!?8Oh7N3DM{=U@+mgp`+->fkf{XpSJP*z{5W-;FsA%jl$%sxOo_6fnRNt zIeZ-em4~nK@{keFM)6zk?tFu0NHOAql1a1?L^R&<>RTl&G@kL$RWqIQ#i4a0*$kO1Fg= z#w7_cpw1u+sTr7(CTP^u3Gb76nQar~&^kfmh{Iiw(%AIVU{K<}xCLGZ&e^f4$#S%w z?0S?}JL6j7ca=JSDXN}uWn0oFI}-Ha=2#j|E|#vD7+u%m#La2Wm{Gc3ypZTj@DTUc zrKwOakf!E~ehxH>4ltNqY<_qf{a+t$mU)R()Ve=%E8Rl}Uv;g-<~aLbioGIsIh?n* znixp6Kt8fHx#}_<(Thbq>*W*(KYSpp3dM62tkARFku!czUGic0=W#kUIO33fDb==D zr@(Q^4D(2f^R_SSNP;z;m^@w8`k2f3u+8M%0IzmWgZAd*`@ z2T^JBMAOwW8*G~y!c@=Vh&j?LH0&KNu`lhGTf#6kyKQBviu`}FJds>5u?7+zvrN?} zG3rD7D9%f%(0%z#`#S%qwNkjst=x81`j1w-2@M*SZv+QCKjVpWD(W4KxEF9+BA!+0 ziHpMbOg}~t-C3>Pa+6F#+>BbN(q1@5`F~Ipa$vp5#m=4BJi}$;)D&m5_B=+?&|J+? zQ}t4k>8HN}PbmdZuBa^kQ0!sH^jab-$L{yz?6I`NHsAey(E@&2>8eMe$}EuQib zm&{8J*vWT0=8?SvU(F$ac2_=^5V$jE+a$0La!R8li`wA0{Ql7-aYS}A`U*4b-16jB zrUh179I+tEQ-X^<^rm|z^NQcQYiere{_wjIhS=)Qvn}QF!*@Xaxj!Fz61ABv&KReX z*gcNJjvZ32u7!nT`Hcst(N(s(x>+IorKheu(QL;X@59mCgx8wxHdD>4yo0h{8U1NG zi234jisy-9SW}{}ku3v@AhotSp>IWkY;K!(;Bb zH&4(m7||}b=_XbYcG!N~Cz{q>Ok_7vreOc6k3o)SJxnzMM6&UorE77JfItxpl7x>z z;HSMyp2;Z1ju!nDw!Mz%PKnOyN5xCSFO=DgFZ89H}uNq)vVqCWD7#- z_04^KggV+L86Q~IfnF1hz&7PMGkoMF7)B7r8n0w(Zz}jCoKLcSS!gPe9@8a$&*!_v ze6@dlljo$n6flKk-U4-K_u+|q(}*>)=*6{*IBFS6x!OBia0J%TqRC%){U*J<)Yd^c5MEe6x% zR1j&Ka`Mf6Pd(MzRg!Pk&|v5t5bwMb-kwR$*XJ!b>FRsk4clJJbDGira^&V%N=n># zy?TBV>VGWsbpCka6q432DYRw_-r4@Bc>c)<+<^I%c--sxbl{om@ta{);8wOQhIg8l17}!#>AQP< z&|C2DVM1&2+Hk$8Iu^KFyH{0Px)0i-KywiFI|4mYGL1e5ko?KFRW=fV?FVveY>u&6 zrK{(6%-HzbL3A+d|pMD(+QUboeA?+?drD+^6Fw1 zEr-g|NiOu%Fh$%(dLACb2@_fID1WlzKXI^F^TNnGxVCB7p4kN)yZ4;i$O2+|^e0H|K!J~!te(d<_hObw_jSjmsIl|hnhAb>Xy3MqW zioE{F{$7h6QX2?Ye#1UCi`8&$fGS0=-P2+d`{g$S{;b&@OC4VN$0G*cU=i!kVN0k3|MVU9>X2kH zAb5u+l=br;oTD2`jIUXtwCgoHbVY$RJK7IesdO&`Zyj)7W07C)f9ewY+kVYZ;`8U& zR5iEN+j^|KpvV)8rc9uUmZ@ZiOV=~Q`}2p-68rqGQc3|RUH5Iqa8|9VG{ARzMKSwFlQj#s9_FT!4GR8f_~4*sMZi;FqGP7|Bg z+HY%K7ki>kG8qX^>8~ji-Ns~CC#QMyDg!|Kexbiu{G7*n$fLz0)M6d%xQ(y}1 zyp`885af-6l*L$2JEF}zCJ=^t!Usf(WT8b}4@cf++xj&Sj(Dz*QQz7}w7Na<`S7NJ zPR7=;)oHhj{6XhFup|OALN^~@aS?EGwS%E(GuiAFD`H1Ccj887?WSk-LI=`uwX|~w zGWk1?neAN~y)-89^9q512vnb%^jPEio3iNj+u>aAH~-Z-*^Eg?7>(P^;yD&8DtCFL z!n%zg!FJx7(t1~ZBz8Z|vw}zvU5Ua`z7}>$L%^SGuZLnAu0&si3$1oXwvKX%t$+GS z!7ab^9SSs(HJS6{8|tCYs3l(pGS z=stNfrL;29ehR#<>Z#oXEAkY>RE-aWJO+F`wJ|L7`O> zzS>sB4wbid6-$Y|@xF5M!s$GtSN6yHu^}6L{VJ(hAB&XwvMOU_=wMo9;1yzk>%m>x z>!5*RV_S~`Y@6i+?tk>v?t-k8L2x>No}RKv>a**-ryeEbKf5BTr+U)txP|Hl7o`-faoJ+Yb(lo&r2 zP^r)-r0!k`dvCLmIDQeR%u_DILEJx%j41uV*wQd|kdu2tS1#eGFM93lW_u{*(sQI~ z9f<2AFFK-t>guAxuxx+xEwh&JrbmH?F1c84tXJ*8$I?@T5&h!FXL$G!7{4TyxMtVH z#a&uv*gZ8PGt*{cY5tZKag-uz)diV;*KoA+T4j-a+A=CMbeAnv{r74A(Pqkocp^K* za%)6vH|fW0*zK+Fm^-ua>KqcvPEKXswj7&L|P!Vro0YZ^QV;^A{}u&2>cmE!jv#I)Zn6 zDPqQ;_JcH|76i<$H7e1-Z&I1YT!Jtx&@8N*E~xv03?U+m;Em8(rNOPqM2zk9Ad&Be z93^U&-B|vicQs8`&>jV?W^iOWk&UWF!YrBAscij!q>?#(+<{u)NR;$i%>2zM+af85 z%*Xu2{IzD$+~IQqVmcySP)S=g+A6ZZoWVy$R=! z*;$+atb5ACn4(bDZL~3RUg=DrwS%-UK$Z(m*|`*K@-^(Q7#$nSJApl}8hfv|>2Wx= zCV(H{l{~SOH%T@d%8}oh3$-62zEuQJQ0|n?1xL4@zo47g`#x#{*zNaM8N89;`9Z;t z-gYzbT4kNg;eSxvXpLYhB4U#-9ZaFy$EzbLqRb6oRV)T~6X(^GdCTPDa76P5Y*3G2 z%ir-)4GKQ*k%Egph?kkE4<{lFhcvmwo;wWm*Vtub*q&Har)t<)(yEGBhgsb;1@w&F zf!FZz1TcwryGe46>%_Q?-;l&QX2a;xbp9OljXe0aV;}7m+z9#Q548`SnElYuB;=6>5?i9H+1RxT0L7@K-6&7Q{4&!P_EmdQvW3~E7YD6a^Md(w&G zO|#21LKnkvey5~yfHZGHBl zTIH@rj)rBmB-J$rSf!5cK~V~mv^fhz8eT*|VWJ)SE&I8!fV+yQVUT6_N$ zEl#PtpI>X#CKGDh7?bT&VHfu=TX?Ud^lNI2HdBVcrzChSU009&KEJut(A?k4x9Ff# zvN$+9gDxDs_{^ZfP>HLDC8qA%x;2xHs=E?PH*+@n`>%15O;CmkrZ#~!MDD60L4P10BD0s=qb zkY*9ydJ9Fn8WdG;fBy{5f6o+H4*N40c zL}teY~lov_xtpvxazevRX+^RK!hnV|zT zi#F?M7uq`PSlHc*EI^n{7}rAUQOyZ>F6JRY&uFkquBetea&G)A>}K_NY}wHB66HSx zlG?gNKGuiMI~Poefog8DBQ(z#pL4dgT)q?K_oYKY)%omJh28V{eg#F#uMuHIiUt2b z0-#f+mg2VAEA;UK?`&CLb`xpMGdW~8s|-J9fB&s7n*a88?hH$Cq2-y0=V~B3Hy@qh zi#L|X&(&T3xcW3lDxv!2ztRwx+W%QC^k3@u|0o?=g`}bzxi9xs^yC3Ce5jA+b`&H6 zcN)tp#GrD|i-*wr{t=mU15;hMS#kUacT8Ekl82Fb{%rI-6RtoCmBs!? zkv#?e0YT*XZ?`p-lKCaaRF|InliYHm5%JMWE!_!0qDamMS{E+YV%7VF97h9?XS&Mw z%Ey#nL|?mj-vJz)vYTw z*9}yzHnGGl)$q4F2T>D}tMKh#$xQ&_Jo2Y=RlYuJrg{S64=yDJqWBX*U{K)ZYqHmY z#iK)usnR}n$>d>~bkkLktmt>p@XS|nTu>)mFFn?ef=PYX>_q;as0fZ_Bka#bw!CA{ zMQf`N@EN8@uvYMQOV7}Y2 znwLo1H}|~X!PO2%D@Z$xr*wci!bVTmCotJq2e53?WFjO6rC`K@L7-Ol+=8lREifTD zu0RHyJVo@4>9BjJP6{VP(iukVsa7)f@>|dHdvD2qd^#cAy|AE8&*c(8cokyDSY$hA z&l^T@dWaJTm5mGQ{k5}NSVu=gbUWda9_87acdq987xM9fOPbV(pgPgg92lqTcgv+$ z$n8j~i|3}RlbA++!tv<5U2laJH5kQMpC*_S@LdP@c;JUnibJ3{T-}B4Ux~viu%3YI zKr}@73HIfa=cc&!zS-D zKN>De^rXVkt#5L253>_l)8)-(gSXTy{hxPu2q{{-$|A^lVH+}F8ou@hN>E_k!kIDz z5-6)r=c*V3>CnYi5HQTW)$O+G`iWS2_8YUsN`zWwV>V8_4T-!?+IynXd0nxKLcf>P zsGPJP^f&9Z-EAozb3AQ_ZRp}fA}&JxLpY);SAj&&*tiDo?gr>|x>IVM*-oyr!6CP`($bXsh20`6AcU$r&V2mfz1_w0}6Ow&^NpWVx4rP)i6@9u|9$r*G#woK4T*4{JXA+c33@a^h4!LhUgLoD8`BwNww_4YdD%OQhmHS z_rq)&^1IAyPqtbd5}Jo>OOKZ;o<5MupJe0YgpFnrNlme3`-5U1+AUyDIvO|t6;Y7Xw)eVu@N!|0eB2VOmDh|_g&R~jBWyB*6F z6nabP1h*|^$VFz#v=Cn7oXTW@caB6xbz_s7>8-B)M&fR5JvzYS*ovTk7uITGgcsv3 zzZ^9a`PjcQ;2%g4l-5oooE`Dcf%f?dyApMcJ8Uhy)vSiPbYm4P@PaBlD*k0wrjMIe49$ z$8#*H{B6u6uw#)^-g{<9>J*2r_qm7W80Mb|UY_ZU_Hyyk<42@W%HN)kf+I!>ZY5ew!PS-?z*Lgi;b`oE9ptIY_S!)J#%(P0)>fLB9q=vA z(6eAM`e|l*!P%Rn)}i0y<}Z);aAa_JyMIuC__J%2SN&g>K(F6Rb+v2{Y|K|dlYZSd zJY7$NCQ(p^b7k5n*g{fw(r6p9>GK_1LF7m>_jnu5%DVBJDv2d$4baHK`>%C*e9xW9 z(mB))kw&{SvdE!Yb&_d1ev;?XB&Z*g^IMsOwhU9}eN4*Vli5_0DUUB%4HA%qks<7= zufQDY`=5e+zVhn_TbU&Z+gZ|&#V)8t(mqf8DIBT#VywH9=Gn9EZnh>aqUYwGpDTcf zM(w-rCp=Z@-V-J}F*aBlG>o^+XP$Xh48N?I7?x?z)*V@2pWFRHz=Ua?-^HlfB-hO3 zudd#|2zr``U`xzREUxzC&TPrp!HmrH13_~HL{gf}91#rRw_h`|2ioDieDc-L=}aR` z80zvR%5+OZ<#p^OOxaW~tx{@+-gYJ#KTTBS;8f}pFZTTVj5Wi?j3A?tmA>XWdxKn# zR+h0@dj1IJ?ypdMQOjRWwHJkGm#8M*3xazOUs~fg<>nugBKU0dl06;(9d}8_`7bsq z%qUi$@)AZm(Z|&~2@PACm+#*!MVm*3!jg^KpDQkc32tw~>6xpYJ97#qFUl zUcwyPuhrOH#pt1KMz8Wb8u%y7kLYLb;~1@2s~cD0eh)&bSN#ooZ!cX10R9wj{=)Yn z{6WuE@E>zq0`1!r-DJJwT>fA!zc_zoFFId2_n$aH+l@Chxxxq@!&g-ag5g(m;+cK%8hqBO zH`#qXPvh?--9Pb$ma5Rsw?^Tmd@w^UUUiVjKVgdR^UC!GVv0Rx1TufL8UJb;CIS*2 z=|6a0+CGm$+B&qs|uO7D+2(b)tqzlB6~7@pV9>{g2bT-|tI3y)Xoc4@?w= zO6jc0dUML9pz$SVd9L-(FF^*2o9VZ|Lm4XdMmQY$JUN;y!`(m`-n)m0Iax46s&#j> z9>mgW`h4(AayW`yJ@ah+^Vvr?${2jeU0p)c1IEi0i^~%Qnd{)o_e`@(oY}|dk#2rU zy=XyiVYjo3`W|V`Rk738xX6n+p^R3WlSqQ$Qlw=gPw{}GvSq=@V_+-8CN&1z_bIav zCt`Q}yII?UtRN3~do?`blWZnp|+A%OzY-)1XNu zLaGqMjmcWx8+BLFt+gr5h)V4LFyWb5%VlS%qWW+@P&Fs$>XPxuHIy8lt~llBJK>9X>#Or{lJ66i<-p^Ssnn-996{b>F`nMXwi zC}`*!lT^UABoTvG){H3wLh@T9MN+;1H}9m;1EFyItL(<#{tKOmo(omHJw>i*de`)2 zWv7HpO^$sFtnoD5MuFmm%wA{nw_-U3sLjbqhK5!)XPF^iT!$yO0750RQD|>{^DLvO zs4>bURWe83V2w!}AEy(bhxPKH%aJWzcIyn!#M#t$b~t7BB|tlCAT^@JI%QMVH*t#+ z(WbM?uNEUA<)qU0+|Gm!@ZVuGIiaBWl-1>su(L29mgo3~H@IT6FC-Ku-7w^C5w8L& zKB%herebArF!u!K@=t5BM>2!)i*WbXX}vE6GGWew<)&pMxCbQrTIDvVd6dkt_Cz7T z6(P5BAndFKE$NRZLxAP`gogm<RBDl2Mo-p~w5Q^^xsVW?BOE`@{W-Qu9oYt<4t+?jayH?Nlc0W}o z3+if38cZ^;VzqHvZG&uMqsf!71$&Ay*DOK;S4`~3naSZv&bJg!{m=&2>E8LYO!B++ zolBmEW!n(9vh`K~OH}-!Y>41MaPuxsK&TyrhE;Ly?3W2FnWZlhI-xfJi?21*Ex+dH znk75#fOCnzVdF7kSK#VOH0&XsOHn(x>ool85vb=^G=ADPEh%B3vWqkX9}O7sGOSsI zNl7*Sre7rrrDW-*Q#@AU3Gs;PF&3$ZKwNGQ3ta=29Yz8?bB=xI3zkWr`9*(l zOghZt$$o~Z>RVko=PV<4OBrx+-mCXk2pQ+0ifuIFe4NZWzHlp@!{@e*UkXZ#3|FuG zrHuDc*8-Rom{1#~ys=Hn&-jPmo+(v%9GEP_dq1+Tcuv!&0WrQsrArSLzgr5D7zHqL zq;SzupaHrnZ&ZFayU(s#p-YZ0`^0eBT&HRQ{#?&$;}-*Y8vPO^@Q&+g%NFnAjYR3K zXz@&E8*#edT2G>CdG2@~8Y02r(mxrd;rN^D?}tNd5Z{MiWP*6*)Y4-ImOWobwSjts z8qJ0zP!1IQ^YT?Xur3ujrLK+EUg)qjpJnhZ(?r(CZ{i!-=i`q)KJx>pU?t}>%V&XV zro{9tlkc{rWj0Faf3AwA4>))Vp|(j4id#oZd!`mLzwSOxo=42t<xCeaQIN-?Z z1{q(nLO4Y3quMEWSW7%Wt9%)4k2+*mNdK1SFN5!)HmT@w5~L>e%U~rSPtPYe5DbZR z_-NY)t@nZ`S3^X}aZWC{bJL~KCw@K$N|B_QN^4S;KiWWHcom`~#XGznvaRJix0Kzb z{L89-pIx+H>QT}iv^3It1#48&h-=pHcyVh*`Sp2cHnLjWx}L^5-j14cBu{r7w$G%s z`*@_i!}74MH5A`{4o|Kc+N4mJqsKLqnM}>1Y7hU;VyZ+~c5n**GL5KJnj<_|*JC@g z@0W0ouksC?ljX~2N`(cMFHA2JXYcx*J}?#ArPHA)OpIenDje|J@N^4hNk=ikBv?k2p-Z2Wo2#rOn_Tke?tc@p;q=j0l4TfDNfV4QW=)hK7) z3*@07DarCwFI>Be1F+qzU?>sQ6*f?jes2yF}8o^AN)<%F4B*{}QS zIBtSOOR81)v<_5aozN%3-Z{O)_uk6efifDAnMZv%93Myc0Auwd=vheAIgyxM(h-Sw z9LH;%2qwSh%TYYuzIH12?RW(sliTGyeN?(LU75!Tf&|#;&JkJ`Zh$#- zMJq-iaT^XnOfjprA<6=c6LHUYH$DyPFbS07Hwp{FXk}Njc*Oa783*gWLwzs}qH5Oo z2f#E3;d(T9BtG1o5YCHMcmwj9_kuLHfeiBfq(@7YbQc1Eg#c(pPBrIV(Hi)o<=`4!Y^<0fn?m3&7g3>pt*~ng9V_Qr6pxfGfJl)!8Vw4YZyQDX%sX*W~+PJL8aDXkCu4BT4H} zYdiaxL;^^^+5^NQzIlO42Ox1Q5s$L{&u7PiH_HMy5OYO=*GEQKnf|^#(&299@dB;# zcjwb&1qo0Bqj7~m7r^I+_sjA24O6UbUrGmF9}D*J9fm`$I}I&&u&s23sG*>*OIe;; zow>iga^2P|Q3ah5_(?asxhTEbWP+LKw+?Pzi2{9x#9g_60jJ7A(?q7t;nWSlg$kTH zp15+P0#@~(b3_0Cqd@=n5hH*H{Ns!H7iWw97kNB{06DnYLjT8MB%s0X?Z)|w`O{As!rU0c9*(Py;Go#nIt+Tq?3*JdqCn*0JL(z8=c+q8ajTfPuNS|o4u;;~Ey>tEAStnV>A1w4A?osk z8*Wl~~?8Ty1z%UJucBAXyy+wL4xu3V1)#@(@MG6$-Cr=joC~wKfu&X znb23=2LhVu)>%7vjZAkYpE0N2Arra0jUVA%bWxB_{&c*)vr9Vu@srhRwI0e^QGaaA zb)wRv^(L&qPJ5`{xkC8N@#!7^uOD>?Mo`i;!qMDBQYzOOA-hf<)>D6d)JYh1Z}sSR z-tc{L=1cVO!JXjWv>B}?ws(b|G`fF#nd%2ptzqWwKYeYI8b*3O(U+Z~KGJJ}0G=v& zC!#oTy-JKt*m*<$QE=RS*>yOPsnt6v{SSNbYd7z~+kKX1=B~pF z#~^dNVxQr++buJ$buxG8*tgTpe<^v#_r|C|MD?kEfg93u{pD&@OwZjWuO3ctU>Dl_%u!`euqrq4?AU}AP- zh_RyvN|o9CaI2g6jcNDJ7x&-4eKXh2N6L&1J8zU&eJIC#?sH$plK*~>(k+X$z*B`> z+Z!@w`;7AYVhPX{WeJ}FDv9q^Dy>m&_VE@wRc|8Mkz%M~NefWk6T;jN^k3LX7W!Uu z$z=Pnw81c8f`AJf`E(vGesP4l>y%sZKF^~ z*{z#}q54#D`8^R)tI|y5(Bfg1xpndZn0<_?<~}<#t5F#d!dlm?odI>tY9z%$zp$b3 zMUuy*(&tnvVEk5jghM88HN`_zn#3a#FNf)u*DS7)vUnA@}l zc6pPHv%}h@gYmz$$EuPgPA5aW#n-FVW|)S-tgCL$Q#TMopER~GG{u5{Ff=gQWN}(@ z^Fi~@L_&Ob{#GqKXj-yYCJx;8?hJT(MDS_58y0cYprT#Z{fVzMgsY`7Y;!VPzt7Cf ztb`>i&(8>0saK!m07JvNw6q_a++cWMo&j$>e=ejcq=|kDQ?edJ-GWIJ7_t=gcq!`r zsFlG#FOjf5aOc;M#!eGj*WEn71O|Mrsdnjywx7HF^5-K|Y!1;e(;PC}9sii1%SD_p zp$1wWw&%lvSc0rOk#9A==G9 z>G@AEkYz@Acfa{7^ea@D@~o2dDh(-6a)IkhCSgk9IIP#`H<4b7u^3@SAVDc)YE3Lr zId=s79viSyBDy6jeej|*O_Nxd($*OtC~w~aIA(Infdr`PP7ri)K;o~YH?(B7pr1dn z`SuO&77oJqe}cclkPcAfiWux~_O&z;;TkBb$IW`^W9SCO3$JG z30}|?LCM#6lzM0-HDd-f-mai+oLeI6XbjwgvC4umRuA4pE>Mb4euYAUKK1AI=ReMS zqR29;%Quffnmr*o#m~hpRpqr0Naj4&wOHkPG74#TcxznS&fyAr-ZW7g!ozAR? z38FsvRo>^JzG`hI1*^ce=&*O(=;br~5G6*ra0-`_7}MdI7gP^vfo3N$wAm(yYQ1B4 z7hZuF)3yE)ACoLp_|*7Q|DbVUVyk*qN;7O%(pX(A~)E!jwSlh>90(NcWV}8=c+)?ew>xpdB$*r}>ldx9{}jQHxMY zoK>Zl(;uscDZ@nt5n}<<6s>)G9chg}rCaX;vs#06Q2LcL?U0?S39SdMi$+W1b}~;tf93VUui$LH%?wgA;S;fp?`#f^=bn79+sWF%T1iqVTM#0N zp3Z`~CS(U9IdcPwd0Fcl<6BNm@EBxXw!?v8bD*f%WX;KXG38!6HAWL2V`# zZ%52l)u~pTEQdA!h(e7Eh7womzKpO~0rdh)#<&S8_{TL+XO3$FW@QYR*VU=6zQzCVcPsz~gWAoM*+3 z-Idl|>D*NVK<9#iG6Dzz^pgZAN#b37@xMAR4SkRd^IPh z$mp9bcBI~7wi5f_xNuQnDSh~}0(OxM%4fkYI4osyHzs@M)Cw*%~UQ3CXghI_~-iGn2JaIS|)D+NE7U0{S>Ngy)aEJ>b#U}@e zeo29rCfjjixv*U2Mp&)F$Fkja5j1p^qt}I9hcI?KTX6ntc2~>WEDMbF@V1hH+H=g)2baqm$;|3e4;^m@h?fafF^Um1svZ;xU&2JdU zS{D90w&+># za5dVE^L@V62cQ zlC%Xz=0}DNChgSSuJ<=zZ|Xk(5ExZ2t+2#@)7vv_MQ*V$&5}RHV^}|g>#&FGiAqpE zNo{!T9Xf7~)v^xVzQ+@?chrzW;4_JzF}>g(IB!_yi{BznRVKf;l3uPbS5Q(ypb^>! z(k_+)aT{)Z(An(j5DIdUs<#i{>zqsSnsF>Au}jCtjPt^vVUD1OZHO1C%^&8<6;d0c zIDCYH<(l-wZ=@j#o|p3zJ1@<+0*)*M{jhfUBQ~{H?JTNhUrLy6_Kc^25g~9<%qh_S zU~RGzS9#^NDW2fQwO;cq3kaJbnp)Ma2i`+fRtBR`xBBr)zjwcdk*gI!Hl>~nGcmBY z@bZoKgXx)|ePMb-pL}Jc4T^PThi-T)Ov^g9XQ~wtQ?BmNOg7Lt>pHb^Q4goO-)UI; zgi!6XmARpVbg^Ij$h%?4Sm^u4DaxSBAZ?Z3Nzi~+LyE4)3gh=#`=L%$p@n+=$W4B} z)uY8Y4!9PqFjY*fi5fpCi+{H@ZBCVwuad7O4ey{7{R%ri8Zh|5nQZROgfTa3GVrGR#-`c3 z%KRgD^Uo0&WaXabvOEiW&y#XpgIFrcCt}gMjd6JNF{QAR;N4|J@8f>(OFPN>BYAr{ zribiVD^5G8;K$fr+HcL!v^;2Q< z)yusuH>!q-ry``c+p>x9pEX3x!X^L4WF4Nnv?9>^RfW5M2x&j#vUX08-(4D`kLoDT z-VIV5lE?%;z1Fe^gl}S|f_}vOazFk!f*Da>*UJ{jQeqx*jPnUDNp18c&UutW!WeTF zaed{LHsaJp$faq>l^B7}2$@OJ@_9UQH0qCF0L9n(ofhm0@ee;lI5dY-ns=oMJUE69 zfQvC8Iv0vtQbgIeun)9dZc@vL+L}@9m(Y2hNbuqLkJD5HDs}mlh13Ix1OV$OPzflH z)j}?>CXRqHF6Ka5_RpZm@(dH9g75s9PKq1U)bg1>$(hXpkMkUTlfYzv)Nh(&lMCXX zYuv=Q^p^L7BNLUdZ!swzio4s9UD8#HzrVWS&4#eXN8XPXW<1h|>3U~~_JqRqzlz0X z`29%|s=C|}(3ehbb!X)FKJ1pg6n>gYq8t?t^Hu`X95%%i?H^T9OpX7;VhJ9T7rle0 z1_~y?>|xIHnudT*pM%CP6tZ`|&fH%3Cepl9$*Nm41or3RJgb1S&N-6@x4KH7W*BFM zKzV8B2u!a{aat|3UJ61!yw@|A61dMQK8hm92J+qz?d`_Vp`zISh7!Iyq=Wxg0P)c#=!mp{9)^5uSo_3xM3dz#J!w*X!sQW z%cO5)1`(3-VY@J~nHY8y`X|sb`)Z2=HaTtwG`^;Rn70>PCas0}MH6zXameC2v+hKL zzHo@O0Hwuz_|G>8{A7bZMaaS>tuYnFRTn@hU!lmgE_2yc3YLwbJe%x-^D*cR6SwOI zk2PMNtiKvkaO=C1>T~jEd&ZmYS#kC`evO&mZj!NgN6t-xYI@(*1?PyJ_^!~)V*{^u zI$*p}Re#>y-94a0@v5b-$=zbl#*0DD3pWaJ{C0|Xm zjnFxzcYc|@xHULdI?7tO?J?fa6_WECS&u4mYHQ=BCEtoCq*-!YN9Z7Qv=KVe1AQgF z373DW`i|B6G~4QV=XfQPF2h}Oyk&RdSJ!!RB=iqya-E|sHESFnwkMvF3sY#WuW`PS z9$Y0ZCvWN{mL5?cl%)HyITlEkgKT>EP&yBQV`iX4*(~4=cU@v zd=uqy`)QC(Gvzk`eYwj0zcA~6!(_Sy|A4lCs{A)-ON;2cf@ZGSeF6W8*sh?R>%D6L zWFxHFe$H;yjLYTR6n1&=KSANY#EnRzXLyhz035}{n_M>P(Z}L0Nv4sr=XuBGw=N)BUetv?LuGB|iFj)or=~`TgTSnIYvx?(K&FaA z?hf_!xLN=PrxOzkj0g=RKIbvC*dy#=xZUe9}~5zQ>#+cfJ}Zn>XqGbDeME9P=X#aLp-*lm*RS<=MW(;>Q$pV9z?JFcCYPHCxLa>Cphd3w-JCH6dMPa>nY~b293y-pw=98LHG_|3Gd$u+&!?4>W_NHP= z`Dzsge9e%8wyEYj0LhsPbn)Y0aofrHotbvj5amtxX#UrZ_5P{Uf#;dyX5a+agirTv zso6gq%Mmu$amshI^2=%EiXb)~fD z{|E*uV4SYLSFxRcuGpQs_*J&lg5+$cK7#;f5kZl7r}!c$dhU0+MJs#Q>vls%8>t3r z(u8hcJ^ADHDg%$>*=`~eYfmpRhx*Csdh2y#NtwO{tK&vtMbJ@{_25BtW993P5?Q;q zLjfjjz{1?U>$hjs=O&X>nN$S`X}(pFk|Wkc;k;Up9av?PYQVQ-Nf@UzL6eNzq$R4z zmSYat_;$1k+V#o+YH^gBev~fzDgGEc0>`h9nkvbp6OUz;u>phx61dl8wsZZR>DChU7B6w2|pNi$?*%_-&vmvI9v0{4!X&i9wI`^7G}=g;ND z&&G0~qopqaAH+->`OfSPsV#;yFzZMMCshf#IJTkU-05jFjg)_ZH+BCBUjAcG6I!50 zM2Iw+JB6=e&sU>*agk0Sq>)pkyF@rgBEGu_C~k}& z$4%a_9jzycbLKL+khfor#cXad*#$IpRMICFF}=u_YMbjR@z*P@=H08{eN!T8Uq31@ z0-wd?Ek+iqCK4$XM~?kUuj3YUEIOHV_7LRS4B?m7LTu%X#=t*LkIa-qYy|iC-Uf;r zI65&EmF&o({2bj3E~v^v%8M^hMNZVr81x6Y0jNnE3w3lBb8?WZg4& znk8l}HsK&z(F31SxRD<{-;X(D#i9h;_~y~Op=R5xbz}4KZKWyea26_Lui7KYY6+-k z-pJV}M__=4cweRb%rDcq=6ykF&i4&7hZ1yEPB*b5?y9tNBm3M_4qPZ+ z%!Q*{>?)AA)$s^w8e~+QuygbYm{PwQdtcSzbhSWa2WqcsVi>zCDn)#^)nI3)YiSh@ z4aBPS#jod)!5Xw|3}8?O7SnzZLz((sJ6E$W3$!5wnsz%nbcvIy7}-q=Fw4ZY;c zuy03aVldZMvI@M4S6a`d^T&SUwh)r!1hkYUe&OInC3#yIMI8*6Ny3+YB<`#OS$n1tH>QhU zSaoASd$lG>GW9XZjJhG}9k**@G>m-2{)FG@t;3b8)lb6WbR04VlaGYM`BR}Cc6nl4 z`CdWT9bZf`ah+=5t_m z3>+K+oT{Rx6buG~gG>1H=j7nv>g43^;^Obs)zkI$^Uci*7FIg^ha{Jrfyc+kr>Cbw zBlXRXSlf(5XW{-Dc5Ub9_Zvm|x3_m~{u~4Z1bkmB=Wk%Qt6;;|3V})a8tmrgu*8T! zw{(A@K%Yo1ru*dOb~sU+jevmFCT`@pFPs27bnFb;;V8pFz^WKtw{&}Rwb+*LrX$V# zPY-Nj+8zld(_6Q&mzS5lh0&XvkuU-RX=A^LipJ4De-AsuWC;if2qeP>Uan4mg@0yb zWs4;sP|uxz{$a%?m^f|VncuQ{cl9gggJIR>T*=kNB@_}BZlR3(%9L zI=l3ZCu^%j3>`JO5fH@m-N1Sy4cE&-2bV8fTStP<4U;QpyAu_oH!vaLADv!FaB#Uw za0z@-V}5W6Pe&6z6bV<|)y0|#a9Z6G>J{xBt9;g(9i81(l|RCag>Ei(1_plB%^Y?P z?N%&4ruH8Y5P0kzzg%t(hiS9(X{Wg~9OHA8#~8&&Mn`Yg|45pGjl>DQf1m!VBA8F& zD>%ZESJt&P-PKTDXuCgGNrHdp{8WIEKuulUOs`0tGBLE{&`B&7o~+2SWVJUWT|gk7 zK(;dP_eGgQdeP_+99-2$xC8+uLq0pm#^!jXgAh;`xLs{$&A~L9>tK-fd+HcwS9Meq zplNAt{bzS)dGB&&dPdl<8#YwEw77)l*73a4t)QUD$LI6_gB|Se<$D-5IPn`B85iXj z{`do%y@j5ho@N5L)6>(AkB(Ye8tv@uY9NsH^>x2M|FN+#H#c__6cjN5AsQMQPIius zjg6ABVhITeV}2n40Rcfi!&m_UegPRL0f9IPetACOnPdS0$wVZi=x?0I+c70s}@ znX|K#e+tKEPcYckDhzf9n@+K^x{iyxgB>=&9;Rkr-`@@l-oxSu2m~1ICE?&yCgdc) zXnMdO&o#oofyYDa4QszMV!~vdF2Xp2dWAh9z&QZmmf*y4T;Ku(4&K1IP9wk_ z7GuH94#UBfCsD$w()Ga*;U-Z3AwH)6p$#zH2Do_k+)vE}Ypk9D! z1B~bZut3bd>E>tJt{mpd7$>9~O{q_Xv#@2haE0E`4%-ZN0q>g~nH^WW-rMd6iUOK8 zf5igT7`Y*6NFlwBzdxv_%WW3JBkDI}GLA*FsS(p7+oWaug>__P@mu&fibm#U@BMs6 zZCh>6SY7O#^b>V%SAKvc%*{z1?X~>~o~vpTcRs?wOzB&jg+k}hQ) zB$TLRQaCz(JLc@P`n+wgvgC;*bavDE`S&FEg?wA>rYW*T(i~tp$<^56qp9Ibw{9Ak zt*cn>G|h-XbzD=Ym@_`yRvtj7Lx`+NV)@&+Dy;bV%h2I3WehR`fA6>xFO2zeY)5aL z9AiR8W%#&NDw*E?Z!DTy+TRTmX-Wq09EV9Or|Y%{j^5$Yr7);__IsRodm5KuP-KC5 zfGJ8)#ucX%&FAQ$$V;VEqQm5hC0?aQZlEJ&ZndsTN$-Y{DeY0OaeV3~(JJ@tGge~# z1Sz^RX?VM^$-H(EhqhkbI0U=$APVY za0w6Z2kQfO<)Dt-qs;iuIIQPFZ!IX31`ud%xTl#m}{J zt@Zx7Rcz1DFyTvWP0jj8UEwOGZAbV8DXneA*4d!`Nla$0QgBvso4lM>m9$oW=p=Di zL8I71w(HT{7{)-djSs#aUCBl#bc%Mc!N{|Z$G+f}Sw4fLcGxOu4my+IYVE|=iaMM* zdqDIDyxjEN?fBZNwFp@6>)oRMss-U1+jYbIrEqZhlkUynqJ`F6O6bp}edd9O|QctPCrFw0WJYxa^%g!1e z9ZacCBkl@=xei&-n$VL`3MZ1!W#1-(wP0vpifnVWB49Fei20^F-u1S(4M1~LFc)SE zSpqfUYxwwB8anYMr)g?*Rm=^KMA7;3^p(i}s2U!z5`+}K@PU+yJAWb5!V?11;|d+c zGMvZ?;zo*Go#Pbx(YGbfl^;I*`TTUc2%Of!lbeV)y_uTZbolUchSDFP((Y!3k&g?f zA9+j*X**Bci4FtvA3aiwK_x*+bF`I8(7DQ_%AazcLvt3=0Pb&AGoQ4ak3DNd47GkY zbe-}iRIA+HRc30GapzJ~=2Xar^?g*e72)~&3#j|#be|sHnvVGa8Cjf;0ZQ(Hew@lc zX~Q*Nsp2LiqgiNLym#}~Dh5J!MLIg-xEFI8Z=NS`GKWh`$i-4cv!}eH&bz48Mxs)? z!J!b;NJxbRCQZXk+kn&E4TW&M6JYr*4&T|cXE@X(eofi?0fgo8s|V9kyqBzO<6HQk zH7|aN_pMWJ6e5FK7}fUPT3TPQA~>WXfFb)gc3&px?f86bF4Ifa_ z$*3}y33FNSLL=>N{Xphb-6w%a3tXf|Ue0HTnK(IL!T-$G&BvRlpYJ7QFPPXq?aLK! zo6UK;2x=U*s%&j4%vDgX_UhD7U(5A}^=ZlcqR^T*f-5QsRN-cPT{r^DhwIl)XXO&Pvhp%Hh0Gll1L4uoW#>atz!J*lvI zVD%wZX>V`sZe|*lRg!nSs?nE1=nJ&Db6|&PruW&1%6c4u4Oufxs%b}rn*955pa**V zM&aP+77l(~qTmk)xq5ptK!|c8zIWU>K0yYj>tCk`}fU=$}NNOdvla1GIXWn?vG*?6zh_&hmQ**I}8_uk^lX|{czVo&{ zJ@@MiB&k;?<+akVQ_;?1HJIqe0SY;PH~n(I7CPW;4K;kxyS5^O+B)fzg;UHbjLs`z zo#r}~NWel7tjjV$&*!(-Pf@%XU~--aY3UNKt$Yd2d6CGqwXJoRztSfK3JDE(gD;m+ zMf)PeLp;&m^~~+Rd|!_LQM>hnk=SS&ti%9kgmYQb(mE+6F3smy`w&+r#+Q&ad%lfN z0~{vaQrg27oF{!~A}YTeI}xT>vY z-zTO5(M*R&;fd^=?3*npM7y0$Z+Illbb&K;nFA;#)(=10T64g7@e1!>rZj%o+4gPO z??BOWoycAfi@kdfc+7H8IfyQIP!Wyuyc9}by!gk0LOKKo@jP5CO2pq@q8+Sc}wK1?bDvcOGnVR>}#b`n}<+dwxi?5UE3 zFVTf#xL>kD=&R6(JyYzz_}(Dgzx%*nY5%2!(O|2;+qQU+!|>e>;fFG5Whi2$3V!QR zIBr)$0o&(BNp}z3dTfH&6xA*^k>yQ|#@;7f!$KUd;g*PlPi~Q`%qS*D8_%gzIXI$f z!>jDq{Q>&4cj__tH*-c_F%8A@+5;*R^SnDEOwOS7;zuM=P;^Ce&s6*G8je=NZ-pUm zGBvT_r-Brl8hhan7Mg#VeuE)y0OYXt>EJwbKfH-IzG{p`63tM8GT*|K=Aow;VQxnq{NjnLqJIYnQN)!m%JD{JAG1v$A*0<*ta`|LrftR2XehM*x9_GHry_SvuFDLNTJ6-aVdSi3I1L z+pf)1nq$oN0;t9eA5c!Cnsr5RZ=fQ1jk#Ydi9any^ixekeB;#0&M&`Avid|VBram! zyWa#%<18vdb$#6WJK;&Lxi?MW|5V|e zJGy;{?q2t4D%+Kkxh1LTyWeT-FhfnvSFJy&x(D)CcUwz?jpcrZ&T|^mV{(MDQMSu! zWgoS4X#l?YEqvNhqt8szY(21NI=#KfjPC~;w2trcKJ({4P9xH0vnlFAaexR;!!KLH zT^5v;=tG>OzK|-HL|ODy@^D?uSQ?~UR_0TFGeG&r3dL6!f%F?KtyLU}lcJ247RaJu z$?!wG2Aa2-!NvqMV`jEsfE6f?7Rc9;jGbQi8L{xt{2@ogFz9w+eXoERxtj5+xQXQD zn1cCgeNOeUWp`^5OlH2pzhE(qPevfkhm_K6S7FI8@8V|)-}*;$T%bi)8x0=EEwH9X zjC8o;V*sEPJRJD*$QCMRwi4-%;Ds51h%2C7NHmtV6IWE#> zI@p3?OyScJIrY<-*z_*x*oi#e<5wjW7g!g*o_i-93mj#to1W$KR|!eM;Mt;akd*GR;7 zJ6-l==lTj6`GDg`WUz6U7qcw9dl_H;76~GpUx-6~WBTOm9l$R}=psuDE7Dei&L+t? z@Rfh6v=GPIQcs-QBmITal4?#pG(uSDEwG#Op}%FHFXUwgZ{}OA^Fu4D@gdQ#?yDK@fNmY&oQPx$gbxo%MEheA zrP8Dbcr!JiDUe@`(VUo_1DBL8VgBt;~YjU>(C@0!1T4+SiJVDbdUaeF-326YfmrgOqIU?9aRM zQ>l|cN7ab#X9@8Kr7zH6Uq#+JSPpxkET?@fH4}`HQMh6s5g)|j@%`b=V)^~5Ry;mC zxF*MT&{?FTS7i62^zRjtvDfdp6n)bFF&(U5_OTMnzE($Gt@sIQSADGn@S}=7CL=m6 z&dsUW+<5T|>$-3Ll7NuAhkVtke|-#8X)FCy$%X)gk@7uW?PLYoox9ZrfCDp9egGRt zA-ecXXYt$urcmv~0?tPVdLXBga~)_YRsKV%rIar9QG0Q3@Hf5bEtTks;llym5JaHa z+azkHCg1CNzTYGO8CZTAuPmjJ5)J0%JLk7aACJVkljiUFEZ2n_a={UQ_hZt*f3!7a zKFdKK^AIxSAYOFtJj{+%ZDEZ`3VET_N0~{DAtz-$?w_O_bPO7;5|=?5kC#@ira$BBJ*}0UHfarn;&=H z$>~3Vyoum;|3Pdfb+P?ZZnq#Kldvez5bCr1x!+xTzJ2^XYc;TB!FvLWso-vJh6D-eQfdNW#*}I@BF0S z|4p$62C4As>W8hqa&j`Xw9jRec3UMnlwj&ulV)NOT`{Pw2|N&i(|Q;*M01p(zv?mp zA5Y#sE2cYiXUYp&z3f`2zml?~{jEo1}@FSB4 zQdg=*zHh-^EjrO4%9;?ZV_Vmo&jwqSpl}0s)T^1c^xj5bqGQ0w&LhV^R)(q^r9Ta= zmTXcQH>;>=R;#86Z={!bH2^}o+S5bo2s`BE2`e$a}@e9xZ1L3&RC4{xh=P+o1&l7<%ABP!dF%az}@4Uao$DOCn z={3UxwGKnAAdw|_Qpz5!m}&4dz4tUsC)@m&7$m-fTPndo`ss($#Z(9F*e*Ea)&~Q$ zya4d4{zpH}@-}B?ch4mZm55K-umXpq1e^|1H0hH^HYu~lOFy;tt5WLeG}IKaByg{} zPjc#$J;x&MYk@*aeZ~m%VTRfDKe;M~RB9}GVRFJRBW%y(bH{xKyf(gfOdoVOAy(ia zwqg0q+rMpC629c6UpPhl?8}fNTgKrekNDf(kq!J+_Sh|!lk$@Th&pdz%R*44`~pd) zIzz=L@wv>P*80uZK)D2eb;iref0h9}weY*nNRy5i^E;XPmtpxAgkMRXU6o;9Z>Kk^ z-{AcUq4coB37DLOo2V{ zM2HbP<({~4L&v5acU61JXFD$<%_5r#nCmmxd7|)UQo!DHy?$A@fe%uWH`24V$Ce$n z%OMK`3**_a_~V$h4^!rL!f|#3vrqQ)Og(#$HzTO@M=*YhpL0KkLbv;h;^qrOpwb zEX|a3S??mLq*f26>XU}Y;D?`n>85Ek3c;_#MBe9v>6tqIoT|qZW=f>~9)wJPcF$5t`3TM065t+|l|xlN2k zs0tA$>tEF1;OLp~q&R7#X{2U~B|%Yzq~WE`vmC6|{gQK@#-k%+4~-cgDCjN zLoq{y69ay+_SJZd_!McUYp5ZPhzq*NF!U8-!qex6w@*2Gut(z3*pUgveR=18hTqQLLV-&*ii&`rhb)qBtPMhM)Shi|8 z{+fJdyy5BW_S}Jkd)4wh_YO#0fv$d?U&Y5}mG9^gr9MHD>wc4unkkk&3i`R4aoIFs zcn?Be)I9cR-nnv1Rk`+hMlWfhPUbID|sc#VJmp;>}C+c z(YZBc{EtrbNBMm90NkPdk+E_Wi`2A*%bRzrDd2P>3+u%?&EV(v7tTNurmyDiec^Kn zuJ(rf|1?^0Z8)?B#yy?0*cWFF;BG>Ps5Le;rnppU!y zSUYq7C6CU0qt>hiT8>wHweyH2zlv!J_s*|yf@N&mjMl-#`*_JQ+UW8|0ZtWICbysA znT^K4e+^jPZLRE2YI@yo7A~ri_(DI-*ZHr8jQeb;pMlv5pLn8|DYCaY(+%NXKd=ci zN#!7&7m>^|ZbB|+VL6%L4@d|oo5EVf+-8J7bAa0jW zCHrLY%X|;7Vqiy~BEn|X@g{f6xlXlSbcIpR@%YH;Si3GC{0~Zn9GYT9I*)eAQ>SEH zMr9HbtU9tLAh*?^)z7s$3z-JH5Y*g8k!s*!y;s72jj>`l@T38h!j&H=`;0H5u9Wfj zkF0tbl^!0g2;sOHW=Xmy)1_0T_PQeJe?Yn!NPlg0T|49O=0PK(9|91IARmh{!HmQlnFsC)wqXzf~SugNYzLntI z6W^gyXw$!ngT*gF`{Mf-f%9w1RSB>zh~HVW=;K_T!F}&p7i`!5vVOhZ9f{QzXsM26JzN4s!lah;bc-$r6kNJ zBq{$zg}TDlm5q6`&7z_rty*RSk}^utIN06Y~y}wws!XHw@$Pc z){&8>l<}?($--TH^nF{r+G0y3b(#P#@}Uc{6JghiY&1aIE9C&-vyxslMf0ck-iwrm|fs5sQ@XWNxOz zx(|gB@m;ekXVNa>^=cR&kIt{+mC80@!Qhl(t=P0jyTinkWUUaJR-qFw6P^I@2gI8G zvc&lKkCpNIrilt*+9Vr}lpd338Olo1rz8O^1swKB@OG8+J&RR^GTpTi?B%V@couj8 zODbvJ@^ds8qOAJuHLFhM78J)n%vfP}n53mE7ju||Zw3c+?d)3BD|4nodG`*2`dm{T zz;K*&px0Z9g?N&FQA>j7sICC3qbw(54s~~ z&=8wm(&;9_vPf}Od8d#&u~5B~(=>IAq?k3aYK6Ni>f@cN6$YBSETe!3+T$CyS)iy^C!~A zFsiaiwJ`KkblNUIIx1Q*v7Y9mwM1L16I^d1a5j zu3rjylO)Pux+Y%5(8M{2(U;65XHmAw!$z$2TuVmx+grUZh+rw97io7{{Y6!0Y;XNU zGEwP3j;S@vuqR7pRt2YZwYNMK>9`OjtB1Zj4TsnE9BvGFq&1;l8D+JV|7tny4gtaS z73pi6^g;ZPmgni|%pxO0CZ+=a!{mDX+{rx{72NfDtmC!>lT)-@b?AICwm(*+zb6pi3eI%Ov z{S5h0Ksywkc+HeyRqJ3lu|Y)*R=WUb7vkd+fw?@pZ9pJu43{!wyyz6wd#Rf z`E5!?a0q6Mg9}EiGOOlFWXC``8vY@kZ_M>nnMdlqlt7)8MkmjK>HgLyaS^)WC!GG& zhmG=`QI|)RnCUUfsAJe=PE||H{;c&+RcEL&Gf%fYWP7)uOVLYl;CS zrHG7cSd7dk#~dTzBigtbsg-}K5zN6__?hp9lU>sV{v|t*gfGF_x!Psa9t;{M$H(sF z*dF@PNJ$pC?2B~=s25{I-mcBgmay|QyJ5|yLyFAz5kV@xpGf_u6AUr_Z{Z& z53=uD8gk>9Ri62=yW>eW64igk+wRta+P$57{M@%XP;5j^75knW(J8$!P4p{Oo{EObtS_f2Z-DjybV>OWprRc<(I zKXqO6jsM|hTyM7>-?3)$jOp9nd=c?1jKxbjCP8(rKUO)GjKSdTyNR5;mC-MN{}{x; zBk)a{Da)`81ZVoi!JZKr$nJks@nhIRGy$x>9!FNUK?(V)8Y|0;zB`_fva{4GgnPp* zfj*k+)VNJV7{LjA9{Khb$bt;`UUD{VluT0=jtQ*8T;p^R^@k*WZN!TOKg9%;nDVY6 z{Nry6ey(Gr8g-5Owdk@T7*ru8+@KmLQMaU5jNo|sUR!-mOfka9wTLP_w2UDIUg=?! zDopL9#e-MMhu~6)Q~*$E(NN?RMpF;S8{*ql88A{kk$>7Kg-H5HD{(Za-)z0FviCzW z9S+*nXdzfPog}_EmC4{+$cG(e?w^~o-yrC0L>HC4c8SBdpE!D`UM(?X4zMH=|z?J;&+IXi0%l^=rmKgjo|Jc?bOb4h5Bm zXu04FUjH`UFr+@WSTOUopUMF!j{8FxG}Ps6qb-I6pQwjpo4#kM!S7rf7Gq|X@@=@1 zEDQGZOV_J|b>^e#5_bvKz*1gWSMO7n-QAMGa1{TVu{d9C{BEzp-ZYB1Z1>Fsmoy6bmz;|xbU zV_WdL>B4q`Yz^ekTx_lFce{w*P;i4_fw2zq;1gxkV!tPe3c$%@9wRXYDTUH-8L6S} zHEqC*K7uQU<~){bfF_x*#H=lQWHoq&*zBVR&oXh508z4Az*uyc$Kb@f(yS_|9G2B0 z(`LG3b;_OYL2bAoG%Smaw!mqNYqxWXzmZ_bad=54fc3=m$PZfgdw-;+9%9t21;3ow zcGD3kc@*k(?T5~vUD(0v#ZX#5+;#GZ>kq>oNdl-38uq5R?;=8;+6wG61*Rw_C{*@_ z7ap7qf>j=lEeA8AqliG+x# zYu53WZ~wv$;yTZNL3s`D{bcWp)D}NLraQ@MPj&?!{%}Nsk)B-e5^s`FnkAqXgS^F42?hE37t!gd-tv>5NX^4qT1Ph%RS<+@xw66J;(B@X@4J!(AmEp;rGQ61V_M(*Bdu4 zm`$-gpZ8pSw%QrqJ(D1t4@R#C8%fjM^yi7sI3*`agWHh5sMc|HJpcS^tOcwd4OwmnMF0&U6Fgi$Y3d`Ctc6KaMCz zC%=qROjW>stc6-^JD2Ngl7Kg=2;6f&iu#&_VWMXWfZ!+r;mr`uatOqL6_G{RagmJu z-~9ZmBl194W>sn0m2-0tOs1nk8|MkzR$0n7P!ZpJFrH@>%CJY){E}5ql8atjo30*| z-Hhdm!YmTI_BH~+tYqB3@n9o!W#*R(L<4xFr9!3#{RGn}_PDU75LE?4l0JB7drELG z$hEPUoK#>LK{@VHEZ@F?V$vUdSnHq@UF1jG;Bn(d73_My+JsJ#uE1Q#cL{`s8)nWJ7%LJ^hJ;WaLxjIb+LR z>W3%+U6e%z(9zH6N~a+!qBl!(h<8yq;@yo<##N`(Op>nZM8_F3uVzk5%>xvdR-Ksi zk4P2z`ue+)06SnF(pTpA6ksPm36Gre^)giRmqKq@l0&!I z{Qi)-uR*?1e@4JHW?<*rgc8$C`|1ZE35x*>Efnm!hVSU;n1KasO$Oy^EX8%@D3%G) zC^i1w7QH|8G(PxxGLvx6w`~+M7hJwdkS3QbF5j1-n@scIj@F1TzzwOVraxmZg$;g5|@yW zuF{hsm=S#lHK@VSn|!+;;eENG_liCDnEU4}9%BAV#18WY-#TH28M3k>gp-RkZpcAe zkVbDriTaBi-d2v{*dNuoQi2SOoq1~VAygcc0QOixE~ksou~6i;ja4!OwdvY8D!H%b zeLH{i6P<>TLWnwQ3(=4B|~R-82b(!6|@v?Q*B8?y>VoO&O!{4Pqq%av`CIswuC zK+9>E@p=qxniK3zt!sTjt)G1OC7sKQsOsZ2x+Q;NJ-U1Il8lpqT-uakApzpu1v7zB z*V)JGkG5!+=zF&w7oF&dA=LINBOS00aW~%rBg`|FPHfVrC|vT@0NvlJg2*kf8br^2 zsNuKJpOCrA?h}1vs<=siOIk*zBM_)xAz?C%`xdgLdLrQ4VQ##ed2HbAdu>~wW+r{J zCU&aApu^(njYD1~d|vqO3S8BuW^UyIGJm{T>|^6_qpx z9!7ohrWZ$BpI98}x5Ss4K5iN%SvNSVWbZY6OS^KV?5{MabraVI$%3PEpvjRT`6v7+ zb_kAao{2(;6k(!DLTKOQJ0Qr^BXw6RyK)rCuz#?O*1 zP6B4$>%ON34*mYAwAPQS8sZ7apM3Q7jL@OBM)L`Ny6wP*3}+fpnFkWQNd|Xz?dEw1 zw=x46REJ@f0fuIe%f=N@oHx{gUvU{xWP?e=C2mM!T!L~hU8Ry!nK4a1K?#0xew!>+ zSi|)E1XMb9;~C3;PoHr^ScySqx%F{-YikR{Z2?g>$d(pn`nFXJG*<7O>9JAh_!FzD zJ;WN9q&i*2?AoA0hffw(s}6b3#cel#bdE}Mw6nDxnwzqyrLcGe=Kg8}wo@}ecFR@u zf5Y3xA|Q5Q`&go)o||Hz#R?fn!;{2je1HhZH4s3|bGcE3tXl#kCAievX9Hb4RTL-LZORLG3X9<+3`?DSVe}K zDGpC4TD|S`Xj;~_GuWv2qMP>gV4u-CiM|%g{Va&EUPDAZq=DYInjnC(oAv_9`@6{i z=1Y=Do$=rG+md9kT!P5li4(7Gfg6_g=qia&T(&uxAao}31fxsHIq9dZxn6&E>mHUZ zmC5grbZo63wXT4m1r`7L%0FiqNLktr$QG!smx0%=yMooj zQ{HSB=>O~H2>jUenY!}NNi;Z9oa_-dW8u}T;eptC7Wl&>(wTfiEiEm4!0{toMHMzf zJW&IXf}3fZ#-vp@#a+GlC<2A41(VfF=#NnD`6PTE7Xi=GVS$Aq5u(p-CGWwk2I0HX zy(m^0f9Bp&LxbtTXqdupRgUq(FP=g2P)B}`n#oJwzm9jY)lQT3cJ=QYLUSyh0j7|U z@rWkW1ympPG^w~+Gj=@g`TPhBPoCrf=G5pDYL|uPrj5i3=pmn?SiV zfsMi`gmeot$0lB#b%Ib(N=HrtzTfwyUv?IK(TOQ17zD5K{I`F4YIl~_UpVtyo1bpT zO!f?}yw^{qgpnu~zKpL4_bk-)!;^|k#`5fH*vz`QY!^4JhQ^!_tM%!!A4)f3ueLOu zxc#_!uGy{bvLDaj-vjv{X~^CjWD>K!P$;HY<+(JjPyFckF%mXTJZm(S}% zcv?4x@$%7EPOggq>*kek7mjG@D?9C8E25Rpz>~?Pf&j?n4A%N&9_$%w=P_LS;=uvm z_Ch`gVs0z!z4>zyTxmvcy_-f+Pa6ZfNHbcFF4wnmY(4C7_reEVpmd5f zZ>XJ1bJn20!}Ky$c-E*rR@zo6zuo3XVdZPNHy-Z~!haVp0kklDIUXt&8u9uDiw#Y^ z$!mm_Os|=n4OfEy(~Z%?T9;@nlKr(0thg3cLVY#&gxHmrTi#8an_}{dLvL?Cvh^vn z=`X{3uxtS7$kNTExJl2PREYRy&29?!`_vo5I(NpZqr|PGS;@G)=pp5cyqi0PWNtw* zL$fNM1s$!4hZ;MyI9mBM?yN)eXve;F%6zcnvTie&|A$b&-m-iO_ss1`W{$HWuxRc> zw4>o@7S^!=ysZ8(5jm<@FD_h5Jkn`7Jqo^FuT`%`%bUaxv+uE5+!THt{?v-Lop=)r zCsxgq7~p8BhQm7m*C0zDBonWLz$3O`i)!hF2d5LT+-y-)$WD7Ijw23(`P#pD`p>fj zlE-iFB4?)Yp22ouT|~P_%I16&B3-K!UUqIG-@20f<#95{J8@f^q%2VX&hJA1wnZ=U z3wLXV7`a!OD)p*sCE`TRX9Xoibjw@2WOBLBzixRaO;pP=7 zJF7gk2UAdeU_h3Ax@%2Z`S{Re#>a_**~6QvuwHnQX^ap#Q1d3TXd3w8@6!(cCnho* z^R9F~(c5IJBdXfkfm+C~h?Rx|kp$CJeT!I2#?Oc-?dI%8ep(bS!(Z`a_EK?JLJ`FI zH|2kXXLT37w2!e`Ayxb(`C`-_L1X$y2ktU7a||WL`P(5rE?+&NnoP2V!pUE)4&$ zH5HSeY~ATw2H<_^S1rqZF0yrdDNoTzP-va%`zvc&%qLe=7Ylcy>Exr9pg4GWzS$S% z4kg5}#g%C01wbHf$JE-n?oT$$)iP`ptIsVh`ukj?8r_|D$b1V9)mFBb00;9LoqEGY z9-dtCXk8-_LzY-pkd`4si`T*4nc(j&x{v`9)9qu|ykfS@(@^}!wV$&51QjMFW_vDP zp8=Y)U*|P9nm-JdthpIHN9pB>o_MZtI$v2%x|eX0u36^Z3qK4t)RCo+Z}|Mx=}Q~O zMMxW#7C?=`^1kXICM1Tv9Bt|s`d&hzDD$30yxqol>2EZvKTYVSdDi-O&p3$D)9K3* zPVum3tPr!8^Ghn{+UF^%%drCakFcE>80+)i`8;MB>CW-Hgz_=*XR?Frp3vDPi0$5U zdD_)8uq7QRV0jM%vuFOJXQYkl>=a>v25UqyB1sb`o-~eCXC~h2q>hg%TF%N5U)72V zXoG((xr4JK^+$&GrOpc2)w7GIbmrguR;JmEiUbLyhG9{Nbkq}AG)eD!Hk^w=V^?LK zFEGY;vZ#E5?rfTnD!un@E$W|6$JWIBD23hx1H1}@VG~S<6vVg)F;3-oflpy8cjNPH zFSaJdq#yAuFzv6J*WDkgzD3l`Mt=<+ZLdDMx2ts_cvRXH78f8_R6sUQN|Ju?C0ib9 z4}QP26||%G9J|?_Kt;L+Fh)H$wm2CY;%7nG31&*cS6eEV84o|SB2S&rdA($lL)|*- zunF%J(sruMu~GVb?RUg2DlztX<>Rm6W@5~&H<_-jUVB^|EL?PD@GVRh+@l$~hxS_V z4oF90efOD6pE>nZUa`kd6DcKSFZP}vzcraexFc*p*741-4#Mw4hW`z?)j^p$`qGu9 zsf4p5$PsoY=FfbL+R=l!+9U0eIhpD&3fRmi?{uTMTTM@-_t=Xg0zZX$Qw-2$=o;Sr zEV<58$BcKQ*iL%U5z)PtSEAI9J`y+uPXq7!_m*j?j|4X2;81(+U~VPvnudI&uzL7E z2g3mzpwEBiD4I7k{*j(72=_d=oV@KbTC6N|LY|d-=(J~2oUHQ*-4SqjgHM7YDxpY; zEE<(0Bgv32S2}lQ9;JvjF2@DuE^H{&^8`cUg5%go-whLenCHU|LbsY=P;02)@|RLp znusyHCf|_73;Z2-GIC(~@af&mMNHo-4%UFJ6qp|(#pB220C5ai{ z%L`VriVIpFU8Y4$R(~$1X0q5>_d|Hl7@@i)M4}z1WGX8!*aD`Nh@^pu6gt@ z%+?xzHPigBcF#p#kT>)h(CAl+TFa& zD?lV6=5G+ z?r%|I{gF@J%1o12(*-*_AhSvr9h~Uhi?yDlFh?RJcvzH?@SS6W3gO`=0v>Y4O%;g} zc_3bobHP8WBV~3n(ocgJZAV(ZtbR_o*(#C=(TtY=#svFpzQvBqpI1xuEoN7j{77UC z7n%Dr?b1vj?b1(XAq?vbY^2xV=c~H4fxm5Qt~TgL1)t=P#@K(c(d_?Tv*&hQAED!) z6%z}#-d533?vHDFb>8F#ZI+($x*B%@P`qO6oqPh`OUL*c*Z!Mm{O8ie|I-tX zu$Kl0=eYlu22Sk%sloq+sUu@rg8{xrRFYs@tmscOht>681O(NK! z$mag`^-YFS`0E94=}Z@HlbQD=@-Z#Py2Lu)6`9oBD}LtFhbuK#fwV&NZA-!T19TVTfw%dK&Xe9}=WEH@WM$A=&-HYN&b<}yNnh%{ zB=0rf+QEX52x8yu6^nj9vYF>8;@eV@4p98uN9*5z-XHVU zSwalVeZ>Xzq%2*b)C37WCD8KciB&_LjRb#u8cJi|l#s(YlG*HMX}m#qGZ8firjJvU zFy+ZZaX?*c-Ql0Hza1rK4FI#D_%F~vjxXVnN9LPoIF7&PxQdfz7#TZ7IpT*qK0I0u zqmH7MFLPQEn`nrB_C?f(mV4CmTxvC-=y;)ngtzTrBN0rPd^i9Dp}hd275Ps>tLZk} z{#SG4w^xV`1LywuT0zohOa8r@UXPY9)pNRy;?N`@A?ne%$>#n#Q9|O)u(ipVQY3iw zuNa*~D0-CvdEE9Aey{_D&!a>YDqz{{UW^b^e5C$-skCy~V=+c!*dJCoBxwf)68!|ab_K2Ws$kTXc3@=iml%%5+eY~Y*1r*psShzLW{#I4swp}+kF-xeYg@E$q?K3D)xA}cD ze6CBAcm1Vz0K7AP6)*UfSYUTKbfZqJn`0Zpx`cO-fs5Ol{8anG4h2}LDZ);3TXwFm zS4dg;Hh#N1s|{Be3pTte=(K14&|P|ZPV;?Uz2CQWHM^{PjcKjUV?h{umIGH&j?5GK z>8}wjCZfo<50ew1G53aq$xwk@#C^1aFyK zSbV|9_w3$+3Zz1BdcoZd)PoTTy$vKNv9dmqiErR5yah?}*(W0FVNSUyaW3#}yDYm`d`#Vbe=PTfaBQ{8CY2m+=@Tu}=Se$wj%1$IwvC3#9>e6_ z!usLMZ09@Iq!XwEdeAh%^E{9Ho8tk)9ChfKn%^ViaRz^^U$@-1)w~MIN{9O+9ufkb zdU^Y-`EfbWD&yn=$dj)1_|!E?&(}x%m=;WY+8iVeU`DF(Mpgl%0GN?0hfvc=NWOyi zpAQ!TqxxS$BG^Yo<6>b&M_zlfpaShuk^ubYQ+c5sRfZY8aT&D9 z{x&fZ8A4fC<1_nW02ONZ7{0eIGRvRRP*!mC{}poO(QIhlK8hGh=@43~HQZ83Yo^Ac zEoz9dLd-J}v|2+@G1F_FOKPZ^Dv~0>HLEd%m{Kv9wo1(!RZ8)Bcis2B_s4tdTkEXf zIeV}3&t7})b=KMEx6gt6B%e}R#enQyF3Kl_-h+~qb?Fgc>~s3bWM{?>6p)3y>9oHOf}+Upibc`w8CB{ zCN*m;sKI5tJk8=`Ox2s#iN#IFhEG=mBdb3DG-i!6_TPLC)LVPDg;wjSIa4!5&6;n- zJe&Q+>nZC&q%-bc@-7&GM(e^#8mnhijI+6Jf09|(LSUj>vhJ(@*!wOR3W8SKOJ!ow zO*;)Qr(9;b$3D2kyttz3g#`|{n7a*rY1MHcquYPp__;LR1*#3!KJEmK@SXDRPQdO> z9e=ciU1U|58oYcsK3NvS=5Z_R*;v=}n7|_zouEDXqV900w7_h8I9iaiO0X+zswR7@ zF^q5AP{k@)>N099gSA{=H$f_A4@7l|_rC&s_$AT;U|#KcrkUm9dzo$SuW)B)r7N;1 zDV&Gbi_hKtBUn+0mTp8id|aje&lF%U-Jw)SP7&qG*IRA>Sv|ltI5zJRwWg-1aTryi zB{hsm6T08aDXMt;UCEZ}{>o6{a$KEAOYUnIN2r2CtD1Kv9g9LX~W5Oe1H#|0tzXqmS!_{5}XFr@<~zQmW>H zb|k~XuMrohGfNvjUcBa5bHm_Hj@_4>26f>#GK$dcn+bj5e2dFNf)2o-_)DFzv=@AG z#qIZCilarZ3qoqpC#v^x45WKv8Muz_G}{^9+4YhaWEKKFbmMZ*Jqhs75o-&_p5%@* zhVtp)iTh>{WC?|pru1R?UI3y;^;UJvZorrozF_uN;E#2CPMzImm| z4K7TsPHS-=FFl`D_uKDSHAS!U`76O%H^Qlj3~>Sl9awkRk0lV{bs(91VQA;UM?HE(&(cYqI~vMpz6JmK z;iSZx@hHz?KUG1(#SJ@T4v$5dkj|Yc5J`&m4BI1`U zz*h55XZ0io$|+3(sPztcqhmCWJ3a`<- zKYeUJyhwUgWxwBGR{^AQmL2>>Otx=XQx8VX6pa3n&u{Q7-M@IJ<#A|>MN@~Lxa`_= zwi{P^oob!3sP91ta9VMkkvH4Hd!dNcxKwckcriF>>8IVy(~0&AH<-`BpjCcRY#*Bn zW{&9!obVN(+2@6_NnRZx?x&`64l;dBqON^{mEcfkX)}PJljnw8k;_-m2#x;%td0o3 z5A*jiTnhB{c)jXVop<1RQCMV?DIpVgAopgjVfLPHt^w_<*_pQk#b3D_*lbp?%^ghA zzZrF}ph|jLMAvbou~A4Q4r zI4_+jR6s2M{)8AGfm~SkV7QAUle9m6F6EfTey)$UpkLt>5J4Y@ilEO$xww)(C0whB zz5cvHb8uzQ`j6h z%3--@c;dQsPKrDJlVY-Dw8W2QeU?c8iX%KAhFL{J(C?+sg`3uc`! z=LFnDKFc(JiZJ7m)JsI0$EOI*P1d=i^oOLvySC~R6y~RY=2;?VV0H0H;CgQrgwu?| zfpFXYhIyhAk$s|N_?hp}g7>oe^#02*K&Cl73j}bTvpQvHxXvA9f;8ZPrsV0xRa?Cb zfCFRPmhJZ^$G;@R|DUVYe^fgV%|AYIR;Zi$$N=zS*1u-dI7DIsRQI55Qx6Oa!#uK; zfv1vZcAdZ#lPRHA0vHf}`_A{IB=NO=B4I2CRzq_}IQ~v3ycYJbh`~Ykm0$nK)(Czp zN0xrzbNWOwllT|}Sa3uxpKv+NSqEzG9xtEN7ZJ2it}p`wXd^ zcVg`a6)0X_FlzQs>@@Q3wRqsulPR}5RV%ex&?s*kwz-;SIg0mNTwk3hSu6%7A4=Gp z57Jb9k%%B~fJQO<+QKy?ud?6WjYPLKGUfn1DoI|g0;}0Z%deq-U4dI!Dq-a|ntZ%x zKqB~=7R8la7CPa$W#M4>9L4aK8_Ctkj<`Gt2-hyA=gRH3`0EQy7wl={AZsrm#m4Qo ztnJW)R@9=9moE$vjuT$L_(!GQT1bxtkMyu;ExOvKZRRTab&#?2^#;F1vs3GIC`DS9 z7Bl6$S{08|cFEHc>?yA0_28{k67hH5?(n>D>DROa-MO(I(qh7V+3fZ_nJ7O%_+hLC z&=4^RGv|9g+&#-*W3|N$sr;$fu8(_YR3(m7!Wn5z6^`3JqfA5IIF2Mdz9e$nQ*a2ZpYDSMOSPFdPvODJd~ATX$7-PIHr~cUb=BQk<}cRxzNWBI{9y=kU|Q-X^zd78 z3fb2-Z8zUS)g8)WGl4&B?CBew!`RAtnAp+mc{7(n$CiuY>FXU)=UR&VbmdG>!+Q3I z`Si~0+pss*(>oEC7PL(3)@mocCN%83&lB@*er&1lkq-}$fBhJVKYQvXAo`fozi#;HfS z0-&e89AVW+?IMJ&BLGbb40{`iB0`|8GF#s({sg;yHV{U53Rc^Q_wYzY=`0khl-83qHewk&Q43?}NHe-Hs>}! zQ~XtPfIVf-cPqhngW1Ep=r&9CJpESOb5b&)qbUYCAhFJj+Z;ik%2Enad9`HrEJe8x zqJfJ6OD^=ei+FmH@AxJI2-2?m^>tkey3k<9j7c`xn!!{UcCB*QP+5jedv{Zu-Z$u7_)Ng zk_SeaWgLrmUUI{J6{RmPeKJxqL6bZTs71M9)B7tT+igx#DGWMJF4x3zmwV znO7B<@}s64RFA6@VOZ=BrHxHgKtO%B>o5&p7?V=$Yzi!1vFSDR((&qArT#h!6SfK2 z5#7W!NL~+OCkD&-E`NmIR5g&T|8wVwy%C{#r7&ln>}t8>qf!F9Lq#Btyv~!$;9~xl zSZ^&@!D28z9N{xx$x+F0mO#E+Z#)0+c0PL_GeC@(-r7J&$s-@)R}?k*ZOVzht_vh* zn9X->yNrodEAB%N7=_tOcrf;^>DeT|nt86H%V7DB3V4*Jc+HdZuT_{EIHWo6USZTu znxi)hh^s>wrHDQ|U$b%%4zn^8PxGA%tO2gev5v4}n;1Cr)!XOt(oFG4C+hP+t4BXNxtodD%06zl*5Aw* z#gP)rKqtl;63)tYnrc%9rx+03894J;tDI^j*of>nCo(A{QivMR$fub{dj=vidcmYQ zVC*rSoR^ncq1KzN#B(C;!~o@2MI8DR^0?J1n=9&4_b~>vQ3g*^Lt`fu4NGb;Dw zmx7T1w6XT{CaJX>)X?m!3e$VMkB4sWVa(_AMMdnWxJk;74|}V}v*)d!ivLK_as#o# z+!FMhazT=+o^=D`tlJvE<8Em=&bkhWQN&VwW@7Gk{da|Z)~%LDSwk}po$bUZp+ zoYecso^wn?J3Tr=T%6SZ|M=gku|q78j=t{ark{__|NqCWgvbAm$@u^N<*#*}2qv71 zwcUa_kso8u=Kufy|J9K?IyyQ!T6vwg-Su!_ezxfU<*sFs#O&$QulBsF9{`cKx4Vu^ zk^kYoXCjkOsP6B`yN8Cx%I*JHLzbM_|L)V-?f>eMWsO9F#qj_C`1Jifjmh!w@9^Qt zoNPckMs~gL|NsB||L(ntXi>X(J3no!#(|Om0H3Pj|Glo?_y7O?^78KP_W%H{#{d8N z{Qo*|L@`e7??d`r>xoezKEncI*&I)oMASR$MygJ?bJVa zxZm>sS!1CFf7FBkk)>6V8bP=KRlswl+3U-oK{`4>P-T0b$?E+7Ws0o+>dT{iP&-Ck z`Tzgr#GZLsNIOGK*X{p_ti{ifJJkRG|NQgex{XC$eewJM{`J?doO)T zoWl3nsH}-)TSq!NI&A#s!=TLZ%;WXcn_4GXwQKg*nE|M>R#$Bb`7JJqaszUu$~`Ol3*I$Myz z{9K|NqvTq+vW|tKk3q`l`|9@4mxU0FgzW>;@pX|M$h9Q##qCS~^3HnL|49 zj+jY`<9mC?vVWyIB$4_2^RBMk0CD5v!O#UH&i~leZUB)4p6&nu z#P-b3BXr~Y-rjv(xc~sv{ps$U0Fhym=l|~LnQFq_v&Pw?xd2VkX@}o$pX+`%D#xnRyVw>9RjbUEaA|i@) z+4d>;GGaUWH>q{oQpA&DY0uz`i0x?Jwf45Ib2}{eYl!XYvp1ewd#xWd)V-NRE}VDffN_iO(EQ~l%Lyy^A6B#GWW^9q}}uOrNpY<&Fy z{2c-UeXuoV&>X@R6l@g|Y5*W?(`Fb6wk=z?Vc7N^FdZSub~|_N4v)YEXvWFgT`VZH z@b8KXkH|=}Z(FfghAk>OCN^%*-pbO#S% z>+q37Bf_=@Bc`u}qsM><7vLEuX?KaB&~n9#?f9zIfcnR38MYGuCv8u`By?IjGkw_R zTWEHCXBadOu&oioBaCHEIJ=Foz?yJw<9p}z$q52#y`lfYMd6YQ;U)gEaP|uSgsZ!Q zg_Kl$d8GxXlgE4w|HcXG>o8eI1h|CSAWt$|%d2w&JGemdk?ctwR&DyVPXI z_D=1va`Q6sZxsM&3yY-UoI3=uyZfa~w*n}U zP!~(f%8N28+$w4O8UARhs%ykr9{^pQGs%X+%tcaueXNx=OU6eViZGVW%WZ5b_QtQX zq`A7u%DQF0SkX!+qY=@&4caeWK{E$BN|{egM$)D-Ug$pp?vfiJl6&ttHA$Iwv%y|M zHT&Tda}{Y?kjZeh(OIl*H5e|g=Rs^ps<>Z(OT||^i|BOrVADMiT-_o-`|x~gjQgWf zWPvrS++E!shk87Dn&=pPoUrp?nP$PW;LXU%*(VgN3=<1dNq8Dk~ z%CP#=9%{l zesHf2M@ppHwgtKm2P?~S45(IpSlbp8+wsxI&b2W)mRW8%Fq<3Ev5Ljz*qVY zX7Mzt{bIY|;o0)#R{%lF)*=h6xkfl8mr?9r7mWS7(60%Q2*r@kU<`bkxQ7IqDPX(l z31MGu=4I3f8_!63uV6}#2;d^`!1R&r3QDa6l-Yk)E;g|CMj1>G2vWWaK>xs}_+z(2 zlDP^S-@G4Q0J;yuBTm|q?r#R73QkMt#OvFr0$N5-+TxGkfAI>5=S7Ef08Wf5_zvGe zRQ)&^`#!OW-$2kxQ(1jl*mr8jM8^Y;oOXAetA!$~xKGX@qsda+D z$|Y;!ZcYK)8bSwPuMv}D>~oDkl;~#&oD^(G77>PV;RKAFC~T;$D;IOtl(0ob8(wT5 zXGlb%Bl&`9btO;1zL}iFwD`>%5F=d_HY_nj&3z=E!=^q!yHl)NUd3UJMPcT(V494M zYQG}|lFak^fzCUDun`Q)ZEJlvZ9hJ5#1;LU{qRA1a-TFAN&y>lxr-YC6z?Tx0Rr`j zkzn)sA&MpJdK~ihB8QC+owT*%(X$6z?tSvyH;29Wl&@$fr!KQQ~Dvm z&hcT>K73RP<7&(@N@!cYg0rS9qu&fzWm6=GCI}mU)=JpqlU-S1jsBz2U}K{{Hi~v+ zp8T9CYa(_QASf&FedS&xZRcQ`oB9AKia_;g@avkooH}i^rEAm5vG< z%ZS9iuxfi8`dh@b4hX-m;8(e(2L$^J3uCc^NE6uP%4Y%_uVM<=fYzz5<6wBEcB7S1 zdGxzWr8v)6rn(qxsP5)I0Asl(D{2qudoNz$KDhyk==Y@*Kkjm<5oPcAWt5a)G%`vV zo%J`{xj+6C{uKat)WpkOw?l{kYvO72Pdtrcm0fXuE$gSJQ6+37Q1~T)zu7SGPeJbk zqr%2B0zCE&J|DkrnpYo3fXn3SLwx{u=Jw*?o7bJq$x7JdJ-rOuH>$xmJ0se~TTgy$ zj8m3T=zkUWd34~b@LhF-AK2J#0F>GA1A>~4Hf&E;AJBW!1A^Wsy0GU~fdRJMqJVdu z-_!S^?WcGiAZvGLNXQxY$qxuLD(B7Zp703SB{RaxDDR~Q1l+;JxBd8m0ycg?_^vtW zvqD=mA_wsU!tBGQl$o+Q7c!gvr7jo0)a9@tCMnqvxIcqmL~z*T+X@*rD6f$^O347Z z0#Lw4?-ug$W+FGw8Lv)8yZWG`czYt&_R)FdY9SN%aRfFpBcmc2h_vN+gHcV^?jH~d zyv!%P`p8M;OBCmPFvKM{qJWKGeE>8RNpXELZ2an@q808nIv3~UT`c86imQ*m*trAD z3Suw_!0H$N2Z7z3t_KhHG6f2hQq4Sk-w9@zfMoZ?*a8k*R~_^D(;52s)j5j={yP`( z1bAXZfX9;rcovSa`QXLa#KqW>Ft$AWSOx$900000;F(vy>c74En$P>B3Up=cu5)^i ze5z1aR@Yg*@~I+SnRVXGrwVlCu7o?CD$tc#88e$I(3MpwE14?LmC=>bBbX}Cm0dYI zm@3efu5VxLT>)fcNf@5beb5iv%w9qH=x7G&9gnn2DGEdw=?N=;4T-Rk1&~$|aKc!bjbPVcXq7NaiR?yuHBt zA`UyROajstTyCl%@RVmstjO8&;!TaGooV9z6B1pVW{CpUY+S(JsCmBBMu-xvY}5lsrCsh#WsWu&+&f)Zs-)0=o9`N2^R?C_GVTpSjhub}}5CS{Mzkc314CLRD<;f^y51|J*v14Ob&C2(e)gRisUh05y-_{1gmg z{oh*)=ne?GfjB@{@G*>yR(|()z6H;C{@j%fv-u10A6ezr{W!xQ9>s>Jb63)a4OruS zKx_4VDaW6q3DJC<0L7+b6{52^&*#~~lETinvy`(E`mDAl zDrJ-tn{X5xpxCO7Xj#%`T(EvO!0R#?83RdAbf3=-Y?BCUv7VSy*`AMBKVg;LLyQx} zb|HG;NYL5}U2_49V{LAub;LF{iklybO|EQ4ViTw|ip|rVKYeFqBOjAi-F<*~>Aw`4 zp4C5Uhkj*)+LcYx)~;+58KD9F$_8K^N^Yp@*nn=PXM+D=bx#y<>wOEalRZ#m7^J;+ zX7JG<*5(00FE%#2o8SF7=MXDfO5|)cVv{*rKBp)T5G7W&^isuconRht{M(=nXFw*+ z@}>XuV@dZOcGP7x)DgxK0i8=r+6y6XUZ=|B5s;-{b0XH|-_ zGvu6)p9HmH6Q@yDmb7)JQOAHhjY>}*(=0pDCc1niQV4I*AJ^n{N~zCByhR(uM0}B6 zDE-U_0DC~B905OJ_9$f>Yx7d~Lz({W>3L(UTHvy*Ay0$mZ)}$xYcpy4(02U_veH{= zId1zeWBs?CJ0=1EfI%SU-!&#!{w@*GrGQ8LpGQ0*Mj}SSlfsh?V&P)p3NH#TsPegM sO3F&g4dxfjFBq0FECT=l0002M00V6we0`@?6Yz@I&!q^|Ns2domx6O?6-70I$)gF|Ns2y z$e(jeLpwS;Mq8WH|Nrvdsj@&Pk&3{r|_Ugvb9V7?Qklk^m%}ggB8Q zX3OXQ|NsBhkvcj$Iyzc;ow(cfhh~MP(e7Gkh_&YbCx%IyE`|LT-on^%R}@Bjb(_V+)D#`Ey<@Zr#$Y(P3gjJ@yw zOk8yT?!Ss>P`h_JKWwnaf{_6LpsC{j|Nr3k|Nj60uEziW{rml8WxYF+(EsDCe8Bqu z(UX(W|D|x6Ibp8<@8SR$n5xX;n@={8$oBt0cemK~|6pRB1%TLw0FkCr zkpMWX09C+fIFW3r-RsMsLOMD?P-S+X&+7dDWs0i*>(HZoP&!9k`Tzg_|M=p=qj_0K zJ3~#`?*E9TvWm3Z(2_jT|NsB|_SLhH^Zft+_SwOXVrWo6Z=AyV*`}Gal}mHPbu+~3z;s^w4s zk|jp1rMuY1(C0jP%j)pxj#)bB=--84KRQQs`NxcJLp$g5|Nr^TkwZFpuI*fo!Rh$_ z&EoQ_WjpD=iE+C2q+vWjUybwW-#&4m?!LrnMTbM1+yEG{#DJOe|Ndq=JOB2@pi?^6 zrCY>_J3;`HVY2fAapKLz)777|&XAlo0Fg?H;d^_=sf4iZ)!Uo^kpKX~{@2y@%+LS; z)Ykw1o+Oa~OVs+_-*Nzw|LWyXlIP^Hz9V_$dtJB^T-sUy!>W0>Y?I_#VbTC3%vyQc zdtJZ7le!2r#0!q$-DL)-p(>O1E zyal>iv-GBB9`m@AkM$O>hpyKBPFA-=+U7N03tb%(V-|jKBugBxeRJ;4?&8i13vn47 z-iCOPCAcgXPmmZA$33_^#NFN9^|}2oSKUK*-|VrG56g!-nND|A^$+@+sha*p{kD=g zRvfI)e}VBZJFU+8qTb%Y(aMsOvkoT6TwL7%+&w(Kyy?iQ(|B>^^XEy@_r7GLG5Gnz zWNFH%A%>~`FecMan>yWh#>`n_gAbQ$*;{=JmiFxrd^mfKEPDk6P9#?vyP#m07}Gd~ zgaU-QhDQX_kyWSh;_5F(hq;mTtmn;N0HblWaM5CP`*MtA8~k)%FIgHDO*WtzKV0r7 zlFB~%Ms>Ro6GM8eFFp}m#hT)l#V5cAm{X#gP2X1<%>*EolOmZ-$%u%^ApvTxP?bqe>i5q-iMlSbLNoAj_URNu=TuEP8UkR>O0jzdj10P^( z<#jgTYJ!c6wXTdijUQJADK?rs-Pcc*vKEtLXG6$dKPe|GH%&sDLgzPZls5SiG*UN9 zSzFi>9hVxVynK8m6&MT2iEb^V{OGrBFPQob)vw=%Nzya6?I3lKWh|JwW7fi*()3-K z?6;|Jv$n(1|Ra_~*zTL@1#U-WqhcX=amRA&2?y3T)uBnyl%JvYN>XYT- zhAL>3(Ka?Ux6~FTHnb}K>-Z_OwRd=h1Om9Xdy;I_cGt?4oy!~?Su(!bRZDznTzPj- zUBF_1#@@aj2giBI{fQ&R5rxPB12EW|0N|4{G|Y68WSBNzpn<$|m0SolF}v5J2M49# zBBS;0!w@?y8R~FEM0m;}`>Ei%PVU5)68Bf(RtNe#y-=x5xH2D0z^a@uoBN?YRoSt{$%-JJG9SEb)Ef`hq&yvc%AZ1YV@o9k-+Q;93 z;0h1fH3Pt<@>SOLDmB zVO+JH%iGf&hK+96(3W{Y14`eEDdLvN4vr$3vU>H}_2#f$RV2>t z@{ODB?YPn?AGq}=^X(h0cWzKB+ioAY6}N0Czsvb9)+Mbs(}~HvNV3#n6<1s(7lI~c zuim@3|7=T7o?~;Dc9+R?6hoS`Z>R1twc-dx+z)=>(I1XK*4f?oqj4v;RODTyx0fH@ zia3`wY@_eNM~|hM!}Cr&!6r)b$75$hgLGHaL7bm##Cdb%Q)F)qz_Z^yjp%qJJ;C6Y z0U8xoJBDH2iQ~Kuj&Q{?t|7BqFy+i>;6~nTg`>-r)P~daM(Y!b)nVZ*`gXa}7vSnO zqTrHihMg7%SLei5f%*2C=c`}r!s(aSR#mN)r=a^QoTq<(SHHkNLfFPF)Sp)koB#}r zt$IDw1k^Ay#(?>rgCkEyS8;>^N?dg#9u)3+92zkJZsQHinvW7L+|0N0p5vEU2G>cF z(Nz^!TqPF*XkykigGBfHQ?WEe1Xnl4V+_R5f1zPZ7xB{PN(wfPV z#OfUzV&|pe%0OE$oW(Kaggp(^KRuK>s=1nlTrIH1)f5U58oF`A0Q7u+u3*v6pRD-u zi^D2s*0pZC|lv9_8G4bmEG z?<+M|_-<1*_~sdZ&jg%&H&@luwl{%wdyhIu!TMZarLKc7uQ`;`)2H^HT=8R7b0xm5 znrk}l=Ud~7t(JH9>qwk9QiUtX_VDzs0^%K3S9Zz+cTCbAU;W~)(D^Q|xR4L$s;#29 z{vQ>8by=XY##~wZ#GV!$8aO8Us^v|Z|__O(wzVHXG z&=D8H#r1*t*(50|5Ug^=Ga^`##+=l}W4vcDaaYTG27L-P%r5;m4nG)l%~f&5x?@}y zT)ouZIQ;+TgI*Q#1l_vJIsg-Rxb*FiR`^zGPf zJ-iinQ^ghU+h6x)G~aPXA;O9G?Y8BrS_8r~7Q^wkDqGx(a;@rn(N&a)yi$lrE|S?- zKj%tZ4G6B_PKP|yL>9>Rwwf#YAzz8t0Ob{)cu!&No?je)i7Dm7a`T>Gh$9j9hs)Ay=-8T=9@xv4MX-00000004lye=Cnt-+l)>wOM(j znk{x=R358Fut(3M^W@7$fQ;mf;`nQy zO3#J#R;~yj2b*Z4LKf0mWxIB@u5;i(rzW=0vktAe3jnY<+$sRaT__22?E#M1BQWKF zY;KciZg-RH>}Hc~_V&8TZmzxdz4`KXCz4cB=O&mxb~4{LZ)TXE_x|t2m97>wIv;qB zZ%QMr`2VlqY*MXHR<3zX+DUAbEqKKOrNK_-8 zI9WbH@59@auP@Ne)leW1SjJOp?wR>tJ1fs(r{`)*v$yArD%L{$5zZL2EB3ZF5nx|NIZ%}Pnp+Zlk>n7;X#4pAynb_UW-)aL>0zqmZ5Mq8;5!QwBS@Dxd7zurJ4opTZwS8N$`zgBY@et9@o?;@f1Iu42bjR$S$$?FG#LSVYog6s+P#j;SV9 zq7-B=bh)a9EGKCVA*nW5FgsjH91a4cnh_NB&VHHr4qXCY9&si4O1M(rR1W+rTv548 zSZ0SF;Yw8}H&@QO{K2b=tF`sSd!!<|6*nw}h5m?&9zUv62PX!}O-XtxO;%!KK0|Ty4F3mF5B5 zqdgpUuB?UBltBEI>~#FqVXiO>)#0)6>ay}zGG_<`dUo$oKwGnLet@_+m^0+Vj4Pdz zy=qLScjzsL-E#(tTMSip=s{1rSoL8af#tbx_vZ{U7ZtAD{b2hsfE`8Qsxj^~ccrf& zJ*uvxhLJ0y>Ib9Uozl4K2M=@Q+&8frb@hX-gV`rHkZH?gdxfaxqPS-aq1Y>aNp3b} zE-GOV02~iKw*;KB=?DM-002ovPDHLkV1fj(wl)9& literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/instructeur-filtres-dropdown.png b/app/assets/images/faq/instructeur-filtres-dropdown.png new file mode 100644 index 0000000000000000000000000000000000000000..610fe2ee1aae98ddb48ed5e9c53feace04c3b64e GIT binary patch literal 8878 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 Wi*&}F0$yIzDJ3Q^S}Cj_@V@{%Ov`To literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/instructeur-filtres-or.png b/app/assets/images/faq/instructeur-filtres-or.png new file mode 100644 index 0000000000000000000000000000000000000000..bca3a8876b1478889e3e4753d0e997a88b0d13f9 GIT binary patch literal 4331 zcmZWsc{J4F_x^~i6)GY`MJW_1%Zy$2r6I~160&5*I$~_uBN|KAX)M{YWf>I4zGcb2 zM8?>c!H{LFpP#NI001=r(>726LGa|{WO{lU0MINfNt>Gojg9j#SjFYbDO6OkGBO2{lKFgm zSY@Rm2Z#F6k&Rn-GF@DnYHMdjMRSbJzGE=M+1X>s$%C}C@mX1;GsGQmxxx(sajPJK z;99vW1maqLiYS9v69^Ppy+*2XmxY+54jq6;%bggwjxq!SQ8r5&2f@Enxg~1C=nIlx z!|K)+bFcuA9M=zm?M1=updd$VOxFef*_)DM4C2+3jRebBx)@b2wZ2=z;2X(?#SJi|+i94BZG^R%D<*coC)kcK3bdx}R z`Lwh#aBX0G4P3#zPJWIWBZJ`n&XG(^*I)A1&fEazjUq%Pylw*oN78aekHGc1_ja%v zFKByUEC67$XHUSBsgmM9d#8i!ERr_%0HClF1P_itXu=>kQw;XUNe6d<nnZAgbp3|5P8$G-tgvR);FSIhTpwXyI0YUZ7WU0<;G5ObmfZ0a zfV%?RYqfqJn4QQC zmoa05l!|zWvH`#}@HE0d`f1>)gZ}4OO6x8fN}ru@*)>xYkW=NucO5oTSO`0w{%L+` z*pfMv9AS`j)mjO)|94x>Bz{=n#yvx0$+uCoo4Cb6cxORvOQP03=_P-739sCkIMZ$= z`d0K4-S+OwVbZ$I+Uxm^Ou3T?{acC2(qm)92jZb!-QC?5a~SitICOIiHqzqYO6_k9 z&xe}&*AJiG+&zJDoNNx>C&w&-2l6O?KViX^aqDg0{o6;E$_$JZbIU4AaY4~G8`Jc* zv2o>00R^cwzp2&CIg;7%exF~wt71+UMjanrSD+7$p5wZF?$#athl0W~H}sp~eTRq- zggrmN`j{*(x&vxm9;VLhX2jZ5FePd1_KN|fy)K)2QY!-;QRHLH$E0}eKy?kBpy+8W zUMcz>T4t)axz$A}_c1anrx$gn^+xlt0c>e@gYEpMZtbn9y`5Zcd?6`qvKF-z!kDR+ z!ie2}7<>RV|vrANz!xDD@oMlV)QM?+aC1T`zA@nrB8Ftyq`?3x@KIBWnlEp z_af^YE|@ADlSRVdkcM+~yh|ar?@z#evemagYHd;7OxO}pA!=B0hN99OUlT1?6A4x# z?UvQXn5O`dhvUCBgptb;-&nqf`(_f!G*+7zQPm>aMw-#SxfJROCY*Ttd%C@1>*ouZM)1F(FB%YUzbo}M)Pz*tEy4*l|2GO(BlsR*;R6~dl=}`{dRK?}! z3*YZ;y=mJ!=719Tt$Fyf(y8cbfDmp%!WoCt;xw_(Hrh1+V^q*oSWWj~b7RqbUF8SFa} zkY*~!0~#bZr68n%w}W+P$+sIanpD`n?S6!ON=dnL?wq4CKjjveOsJtGZz0@XHU)96 zS}@t3C-kr;y>Im4sza*4E`C(=_|ryUa~MCjJk_+pHA#{AxtCC_1h=02o|Qo#(?5*9 z3iVIx_Bv9;?X1Z_mm}gX#|Y-qFFXHC!dnT(nfqQ#efWd;PmRc>xfy}gdox_#q+*pj z%}4x~^>^>%P48Bl5ZXE9`to!Kj(!tY%R)c<|w4>T@k;^I>46ujhEw z%@rwfm(Q?MaF~t#wwtYw4i#@?ES*w|9sKj3(I0-RFKawDzfO#g4&*_&{ox5L-=P9Kat#Cv%1th(B_Ww< z`zwpo7ZH9+&B|v4l?E2Xsm{klckS(PF|#vLo~`G=V};kQPRGf{j;kz{i79V3^9ip> znJM0}Kd6e4&nEE@+iIs-x_rqW@0&MvxwStr`idYgl^8>k5aj~aZL<>riHT+I;wn+E zn&NI@;yvwT+O)zk_2FZy0gYjo0=QD$p~&I>5)GNzYiZ@U*UNI>RStV(Jd=Z8hD4 zWq6)BtUYQ@VAA=G@{Lx7gNoq+cp;fjfyKz%Mj6Fr(6FfWnkubTEF{ABK>yH1YzW4H z&2glBe0bwwTnJZR*&?~Ifgq3B?HN_O+*UZ9&WwMjgjQcuI%&*y($;OY=gQySesX0< zZ>}Sr1{wZ3;SmsQ14Oc2|Z&+WBXz=(^0*c!c+=F8*-*#Em zn*|a&^T*yHqyDf5(UqWQ`FoS3P{Kd0{v+i}r9ww8lEwnGGwvq-Xx6Y>5T{TE&e5rj z|JaxonoyHcPu25J9;S90>xqkL4tt{E9G=3miMlxbYLC3ET$ih4w<5Rp-~c~Ix-+<* zrB!*Afi?4UQ+w|V2Hq0xQA4NS(G4px>x|`uEwnmt^DG?HUaJr;kG}Su94~(syR3xwGVf_FbtL{ynB$s%P7a zl!TOBgSBr`ymB7Ci?NXvK=IAvJO%?kms;D|CziC`%WL)(o)x3O7S%(pXCqzJge4YU zwA^98%?y0uzC#pL8<$|2ciOYR@>GK1OLK)|=xqY&E&Q7^AZ$}QZYIuvwp1?kBv|5@ zM&LmUH0Aqja(x%%UJ4e5&+^2xY)d2Q+-Z6D=n+!VR=P8MmrO-WEFU=IjA9xkeRG=% z)O5e|@a6}{jM8)C)xH*}h1!w~N=6o(I^zcK2}$)%^}KK^vl&|`7$x!2A@vbPS_R@$ zD&OTW1!%@-S-o+#E5%EKD4PVARnLLh&@*8ZMi$d`tej|A*Ua2mI^12^vIQkA+L+k0 zH*(D-gxI(#w5Ps5bY!H=y|Z4dyo7X?w&W{gy)V@bU_(?Um|FYzlh@O)=-A$}iF*ra zwJoKcQM>YW?{VNoxhWnZxv@xRrWjJDU~)l|^7?*OZ8Z&)f4#ymsO&Yt0sXAhG;69n z2xl@`%Bla-YpbYHB6_HEq=fKg>TlrWVV}UGz~t6DI1|mpZ_XDW_z?+B_V6=uBM#S{ z@3z*WE?_4e8rgr%iL4uDCwYEnh2xT_=QUKc;~?le;Z!v$K^#JDrv8PyhdG z%QWuuabErI(xUgDB4NR97c=w2ZI8+Pf6#Mnc+vOgyC&{}UDLldE>_V<9j{-BAtBPw zdW`I*$V|aIafd{eT?`Jn;JUT;)h6F{<=V`gr zlG_T4gDI1X1u{jvIXslnQR8 zew?xh(pOf=(FotZW%$@Q;(*7e5jhL&aQ-9-_&Mb&JaG<#z~hgJ#1acLo&+M7YeQ9` zjn45K(O5cU#*l$ya?y?W3#`0X^hw6jbCNr>HO4XTIgX$7)1eH zPWnDSwIyEo((W81E9ienZR-TmvSO&!M6CIu%OsX}RUMnoVPCoydwj}U@Y;*V!M7rz zd*>D_we(Ophx_SYhxG(^!s3m&zgVzZ9&9HD)9^}X1!m!2%a6X)ee5eiGU++7_R5uZ z)y!E6Bby^(fJJ2-$ES}2!cRTk*~OwgK8dyPQ1RAu z$V(Bk^29&u;-@#Cq?YGjW1kAH>JvxNh{}%owXrs~Zz^73O?GWSx-wUPmBzy;-7)nN zOrbs8jf?A|9|;r1G`#n{J5K&Wt~4Yb@$n?w<*lW}m~`6*vM zZy4$2K=cgu3m>m~`{j2j(TBPi{VcMcruW~UU4hG6U_N0w$wcb9O;Xlo8-a)NLl%3E zcLz0cdsnQhY-DK{_2ziFXAhI`>-3pgT!zre3B%7g^}yz5Z{9e(rg9tg$NoxEl_kZF)vl@g`-S6=`}N4Bl6N^9Gmpsi9_$q;u)-~j253-VX+Ekc zeqZ*uu@ANpa<=J=M#2|HvHTkAZo2&lFtDJA=5g2DdfTyvT%dW7)!)p=Y;#Yvb7#Q(}rj2WB zdlgj+B*22H>En!y>4k*_K|#Uz_;^JHrLFC)tKEfzYA0uxZbItV_O7jsjl=957(ET; zcHe(us!jvlUF&gM+ICay?i%ih!R<+BkJE-s@*c>iNZ04`48 zz|~^MZzV=tMrnu2gSnbG7cFu6f4odWDh>e!6Ps60d+^4~%l;r-Trpj*vEAprra*~* zA4mjsJ$lpOwjV!d&O8-Zaq|5g*LF^;Te-*8D_XgFcErUUCPqUbiJW_$?IbYe$GR!;`PdHCF#hxYcw;-~lb&%^lsE^eRO zlpOHzSYJOsN7c?f?NlG1J)xkWSn}{}6d7>=IUsC6Hdf0p9v&_pF+mREEt zL!wFFB-Op1OCahgCeD-3S1OE)k%Lw&H&2h^vdxo%C}e*0YPsbG!T<`_hi7)nGkcy# z?luw@`ab+nhy5S~p92X9pl?{dQhL>+lPG#D1Gx>1ywJwIg2{UA}xYwrRd;QBg@rRdAoq zd(%Vk5BSymlY;rH)?a3XNT8o^g;4;RABGv!49__}*BYbj^Fbpc+1oldoXvgQGbf2b zZ?D_-`8FmwenoBJ>YC=}I6;oXl@n9cMvg=L@u0C>h#!VfF2n=>3mfcY8#XIveUNK> zstBLxxdw-5QBCG)ceRTd=9p}c&(eHg+&T|IoUB~?d3aLkY&I9Q#L_GWf9V%62~)xd zmeyzu)fS&i{haeAJ)D}?EIZw<-l?wHqmGlIs$lm*QW1>UJzZ`!G7WG3@YCVx)W0|f zQwRmZGf)x)jni`Dl3CZ6qZ;XfI5(wSO#mCN{Ef&TXvaj(VcNvT5#=3HTHCkmO7N|s z#mA1P^My>8_ozstzrwbN-;()_@=p`B=Dz!k8-1LpB6eD6JOcg%8sQhElB2 zG&(xHI|7WiEwb|*IwI#ouF>s|%G{bjhXX9ZZK_h^WB4y1Dgd?Uf?qxJ3{@`Ohcz{D zD18k@V1{lBwA0z{mXeJ=I<=*DQzo&sO+N2O)@;{`0`!(B09$8>UXKuf-U$2uDfRy| z)kpAKZd~@3b0LJ)mh1}LUNtr1;|9Y4;08W0yt0MB zb$b=BYx3w6ZE4TXO9F#Fj{QAjXm5}Id7p_cPDGs#W^GvK()?NjolQw#)*MbLUA3BG zG{&coWpps)T7%%QnWgUer=aVHD{fwH?jPC&Q%;IocmqjnIl~qyOe5SQ#LodNkZ*0v z;gDiHhsW$wv$)Il*Y0asGSMUPh5L$1!io;=b3fVnpktO>g|k9)okT+U-1P#}Zin03 zLr!?m9ohS0oo*jp*T(lD!8ARgp;Yu0*5F}-Ii^I2EH6P65#=+E2JG?pdH2h8M#lK@ zvW6CHjd`@NNjt5_x~OZj#I==5$57=ULw&O{T5>fdwJNfMTvSw4AHXU#&F}z(1v}L9yNY#s zsd`E2GqfsdJ3eB|IwR^5_RDQj=dcy%*64@5h!+nVn(tCfv=V~n6p6ZEszEKY~5xkB2v0BjJmYx1QSa-5eDSuZTnHsn#X z2EPw2p%nj<&->@IxYaY(PO_4hYr1^09cwFfVYB~Tl#)%_f&sLvk^}@t5)~7r@`iA1 z8=k-gzMJq|nujwUX;FsC$m*8(Cqf7;u(QqL5ysD~rb}$0t@*i=Gu9U0Bgfwo_z8*5tY|Fa2#G!puTdl~# zMs`BI0Wiuz&%dz&%eYI-O(-iEDJdS&kNn3E<)=cC1pyx)e_ZG0<{rt4(ee8vK5F== zNS?Lid%zIVj2|jp#H*nuPziPE3TzLUb}dE%wvoM;X?PfBk#k9dZ zi}qWJYY4x4XD>%`Iq%*slhDXj^&fruKy&m??EV(dUtaSH0%sJEHiL}~8NAk8`Cj@f z-o<4yUktysmTC&%;0O8UjO`L%_EGe8? zOkE%99m=$j9U=Vk-EL{VlCQ_)=(O0X-(QhtwB_01)d@_E!bmM=}KwqJ50$@y7X#SdaGw2zQ&Ok+5ihUlg6 znKit?wus@jq2yI8@>p7ka$&-e31JCq_@!-_4#ZOGNs3zSGExxvj+q)*p)CI)EayI# z^y)P4IHe010s#@Dozbwe=2`mtXw{ zIHu!+ixu3k0%Lb(9;7qBO7vsJ`;TQdbQwKye!oTJe*ohkn?N6EH?z!B#e=&s5-|iA zKU2!$+(T+iL0~T&$4(z+aMREO@JrdDBX}VVYnr$XcuSj`s*frjKaP7^j&rlFNkRtP-L^{QkWyDa{NZY9O;sJ@GC z&li;16`&1tALWN|+7xsFrB>lZIq^rJOY4z&Qq?3~0x}aq%M}tP<5dq7^Zi+P@xGLC zl*`{Y=4-#kz7x~p2FHYaIz6gbqY`igDxvNCye8UAH~vBt0XrFUZrPjdeWKqGlbAvz zzWvtTPctqSsghh=v9zSNIkre`1Ur?Q;Y5Ky3c`Z0G!z&`r!15k1dwfCi?Qel$0s=z z+)(K)Zf>zOEobR$`1xe%d+#Z)Ga1F}SIE)9v#|N&xjkiqpo2;(`6>p?v@H<|=U{Vr3~lm5L;`FqTN% z*PXOgw>Z%OR-;cjm$FAcKAHAoi%mNz<1NPBw>BM@sJAm!#v9WVnixJ#ZIu!buHbv= zMx(I6wd~!xQA(lJ(Jq>UAL!|*&dYPiZz=FCj=S$a%~)F3jmVx`X_deZ&h_R~gNJDA zC*|^Gvfq6wj;^EY6>?qXB5Ekd?9BQOd|=Y-nsX&3)Qy(3sy!ehPK!}jT_x9-YfK2M z8Ra!^Ni7t}J4>o4; z553N+mee2VVcr9#FE5&4`Os`QfVh$x^{Be1F1f!`d}FC*#-GoOq;g6He~|ggzqY(+ z?Hsj2&Hj9kbGMrux@!JAmykvF2jP2)P|LF$s1)PuYh9#U;LuDATboq^% ztl<(KFpdl*NiUyfB~kZ{U*jf0hb+-fN5T||hsM(|PghP9Du25N;hZ7+5~w$!n~!f( zj>ZJqBB@0e%absQHn-eH8i@3#;hn}ACobGw3FAJ^b$Pzk&|OWs;=)kin1#Wk(<_1* z?FxYFdH*gR^X`rob&aTbVshZvZE$-jh@<^@5hsK7nb8XgF7|hcGW$n&Eutu2-7t3= zn6g`NCq(kEQL2wI_t{4X*97XX?bU=Eghw1eyjV`#;AE&cXQ?V7if`U(Ng}O;@tnt= zPDsP=mYY@{)-YL=$+95d`rb64^HLXg?+eO&dbW1%5U%J>C0>q}Yc-JrN#DIHM(hTr2S;i^bR&3$XYwhJ{T~GY1iQLa3u!s zG&6L^+u88_nHyAxeEjwb_W@{tgb+w6x{G1{3|(j#JNgp`Sq%;J`q-^AqNEiORdwQY z?Dzv(b|jj&efT>Al4jS$ zm))ZD{pnXG_Nmw?&IK3R?n`MMX($8rBsUK)1No&YVnEN9V43eZ(pH$XzADRbAko#d zqV}S!t0vxs9E@G74ke0=6}*&qoD@Cr8$vvlbReS<%)#Nq8A>O${}v((*ea#^{7Okr%WYn=cmFl~>k0W+ zc$O*+C8p)b#$zK81uil^h&Bv7(;W*9?0m!4w>trpEV1Zxq}8(ExCNEohi!C4Ri7#w zo_i3+3ah8-A1jcQvMl1!t|(->;=<`+kIoat zJ$$DY_oac2@{r8=3Ok}%;UtzTmdEn)^KyH`vkPyQx>I3;HADN$6c=?0t=VT{tkkK= zTJgV-QX=p8ZdHc`$8%|l*RQt)Z-o<7o-knPZ84q`4)ir|4CV|-;+xiqy&;N2Yz!+z zotW_^g#BIG)tL!2ui&ALJp;h3>@V~eqRGv@BnB}3w>J(n)-0B(b`ZiM83VFAdiUD0 z_w=~p<3wQ2QuTYUx(|HCeEf zEtmbpcG904c|&rdx?fk{@9m2Zyz4Mw*YJ;=W8w9lk;7>3T7j>14`}Ky8Ecl$<_z^< zL04Z_>OX6wqecc*iF+W#G`h|WdFbEJ zR*Gx7M(Y?+$p#D6qOuD20nt7a$cMZ=U3VF{UI!muW=#Dev6)6?mYxDE- z_V)7$3R<&=hw3s>(gkbVfvwA{Id~>Y$UmT%f*HpB(oqH?UtoEPXudy-)zFA^y;Z)- zEGa)?5aa7(=VMZkvmFvao4{31zkOImi1d+>T=B@^v5ka4F%e_v?aO+E5&Sl4f4*&0 zYDoCTzK6>8yO0hBEFlQ4 zu^uhU@H<~1atKBu7L;(8Blw)%Jh%SdDc^(M801@oOv`jwHOyQcZ_HwObK+g*0o(jB zA8;FQY`^(1o6?@ZaWr9SQv7s~dY)8#kisqeS)2bD^Fw@M^3w_}_wmygX1G^Ca^ZzQ zcqed(WMFw>r#K=(Y}sQTt`)=(!7qi_)7EfBc9bkwrk{K*ZL3ET5YX_x?BOH4`{|7) zq(=W~H_QF%ce1@FGkwFVWn*awnZO zS{|q0SpwStGA?qv}_A{3Chb+1ZF?$Uvj9L;swO;W5GXurf6kD!@E2 zQVBu_#SBal%NljnmWy|#O78E~JuqXpJ9!~8qv^VjFV5~0@^%J(U#^QyKr#t<4aYvY z?>C@ZLiSFe^4!)io_o*6GN?>Er@HDYu$F>vv#lNd4n~p6>hmx2WoS_-A`+4NV z6wbdkFkfceD0MZ88$eIK`m%yOViZ_y1)2|Y5B0eKFZ*TEuyWAg@ z0BSDlU~Ga0HkzgwVja5gcJ80J8`)MzKZhIJxHfEO3RB5g99Vbd&YT^=y0yX z7?U2bBOq*96=gp_VT)8$C(43{2%h<``ed{VvEvB|+L})o!+QJy8BNN|NN zeZ%(Nrn7wJ~-Yl7M}!v0rp=Q_Gm1tZ=#y!henA4h=^x98nx>vr0B zbtO+D?TgIM6GEQ6S&dSS22Uz^3CM;r+&H z(D#@~Y*2{wR2c?bW^6`WG4WQP(fV@jM`2H7iQ|d78Z+<^D-Gg&kO?^mIeTRaCaNlkrEgyw0D*-huBKMu9i`y3ajNLhz@YQDDZL)0FI4 z0R);iC~JOE0a?<$U}h8vv#c4rx0kVPo$E-r%d2)und<_5|#@s~LL~>sBt;1d6u=+oNtOVUYo8dR) z4nU)^9Y5EzrXf6jV-ckr`tIkKv$xU8X@yuzLUY_WOIK=zhj_eWnl?O{@Quk5ZQ4r$pgr8kozo<-dc^DHgQzo2rSOMAd$;#&3sfjOD%l z#}^MO0Nmju@=^it=CQt*H{mFm%s_B@NLt<@G?7)G9{Y2CF13RFw5ZrmZPmYAjI2%a zg43#!?!_U_oYu=oFWiVANzUFeSv%6bk*Mf6A$z9tUjs%SyFp*eqD(sbB~p&9B5xI{ zn!%mTv5bRQXOe%A}5!x7NAn8 zfBK;{`^LDJ#N)|AgL^~e++nA{VB`AaUz>fc*R6}ce~n=Od}km3rHrW7iDPbR;VL)~ zm%C85tAEmHaB~=Xz#^S+XF3ND(>gD!8rm&0UORLE0Ma0)P>t^2_Y-3IHD}{kWfqbo ziWifW6IV5pGIh76b6-c+njpnv$sG;ifN$^>-dhdoixC*Xb>msyxTNT>RL2asJzuJO zzhps`BP9D+(L*O^H50->f;>d>mx>Di+8O{bC3O4bX}=&B;mL7Wrjt@WRVBN~EDcFq z4mvWFj!voJP|UJ4`{|e<(f6|#`RY5EPWE!9el;bB^A_pF58hT|J3Q%MeIOYS#v6wN zeYd@^_`|Z^G{Z+##2(krH{(YT!Fm~0zDwNY9oLoPjarFw;)nlKI@iva?x}DgYJQie zeso|iIOZ8;3jR-C)6BOqCQ1#=-L9dF)So`bfsGh1{cEjaI9_OY(L&3CxfKMM1=^zI z4UvS51&g_|^AqJ=bvNgeU#2@Y8_j`-MgRa5Nk`zoedw`vN%%R5F*N>iDFX5+DsG-@ z_S_g2iIfVGH0+t=RhHfF&`I5%|LL9pLwLb^<#y`fQS(VoC;7+E`dStW_#cnsDVuYe zg4o|^Sa^kHyWv`U8Xts~ji`$EjG+G@@CM~vZydwGmP>m-;#_Gy!C`m=CHkuk{&7a2 z8*xG)hb(pC1e`MYchuqgCfC>h`q988=gDbF-@^qd1Z{h87KL>8upK;Pygv$QYp^jt zSYMS@3RhSAJ7aBl%?d{R$6*h5V`pfI+un+;egL^hA{c(alpzLBk{Bc z+JW=T%a2e0VQ}@t#NHqQzb_s?v?E<}ndVW^c9WjpXo~BzSr9i&R(I1T*`8|0R*9*Msrcw1AZAvq$xN3Y9Ng>YIQpYlbZnQT0%l(Mc{{~8Mfu{tYVh(X z_#YGFf6U;2TIEGgPy@pt>AO)+ii!V#K`3B@TP8pV4Qa`69|Ji4$5fz7dJ|5M9%oVF z()7jC^q;Z-VrbDzIgU7+a5hgr8@Hg65(c(&c{CvasJsd-iI=Su#i&chxeY7;q|Ds_ zEw2fxJU#GRDTL0C0HpR_;~=DI4g@?DS^(fuFx|b0VPv(DWX1ma`KbB2F%>Tic^i1Y zYlr-__E7Xrk!2?aFc>g=kCK_Kz1u9%`+bQ+yPUMGSV@pQOqB$i&2%c2#F|P?*v;%9 zYK=~a2j2S^bm@tz!_kNRi=jPSj2FBkte~?eG=7+ei1ed7-gi=R2%(Ib=0H%12UsWMgL7*2Pn=0*qEch*axlf~wNJJ7 zw*E39pdA9;!k?V!bDaorB%>#J8P3Z1cT>k?d^aG)-*cqFTO&t{_~#Xe2H_}+T)l7l+5XT`4~C=CahzOk(@sF~+w*+fTZvWh2vlSBh?~i`u7kzlwF# zZPb-+xUJM^QUq@uR8=W7TLzPOU?_ocqt7^%Agc(xqb0mE)L>e{=Ck$+lt4dMh zHy6Bn6a{qForJ$3Mfg~Nfex)YJ0$JgZ<)h_SN>vGAnJP-K;4(#UuZL1u0E>_v*{mX zmY8C*SJ7Wa*Z&PKa&i!I&hKv7NW`pjAwEsQynlu7ZFx;bmX@z>K6_&>YjK|59Nq_C z?Ni!uh&aX4c`do$Q0WC3VphtKjL{!=<_J#-J|J>T zOLTj>HWshyYUjf+p9qH$ue7bfu0)CHiZW;;uVnB$P!=0+egGcEhhAWwoC->T!_RUv%y=-1UZOB7+kP8ePidH6L z^(y0%d6wI?#$LO;$81&?w@L|M<}$nz<}_(R7O976A15Y$WwL%;AKwFAxJwhaN9B#~ zldcyqy^87`k=$%$9iRvE{&Ad3*0rc`d4G82Rx^DRmZbvs@EO0Zsc}uAp|_s3Ht)u* zuG7qZPUOERLT_1A(tR%~{%s(oElh7agZ&k!miHuTq1m$b@Jvga{He*K?yX6u<)b-R z{2)?i{Kwyz%~;`Z53=Ko%+*!WyGLcuKwC zb?@b%^K^tAJmu@kpM()40SoH|w(~KmhQdYQ((05)(WzKEmFc4doD_EFd~SmT6|y1| zugH`0C7)3~3S=A`o?#GYlmAt4+Z0`ycxk`~`ijBvN<%Zz-zJ~mcskZvS_L$uHAnbftklG|8^9qqWTKuZ^Qw5-dL!yy3P?q^EWRi9+gP>N=xI!Fn;5(H!MP=&8K-(#{5oj1B2a)x8E zhe^m+d9^cJiNaCL?J^9Ky z{+d`FKCxto=8$qy9TyCH^lTDuI#mzWsaT>=)fIx7L64&1Dgwklx`zLfe)clM{Xabx?Pyb5qEI$p0-}^(C{|?g9}jB zJpr}I7l;v<%>>8Ql{P0O-z*7Bo}|&`AJQXDJsVK5W2q!q9@DFw;s2Uuf3jifv92(B zc+{o*QV`t5g~VuetpX@p%t?m>^;bTQg>y65bgsr$KwQ4$+@_oFpOp8ixyM{-w)qkP zX~ zB2(OBy4`3#xb}tbCeNirtk@cE9&>0ZPfnwX6CLF5yCZTnwBE!a`FihUXR(nNLVk@( zK8ITqGMZlUQIIbZDUH2Co!&;(a%RDclM+LvwibJ>U$ppaR@u=)Ozg_LgU5g!`;@sTd6S*!RC>qxX_Dh|ZPo>7=^<-?l< zg;wro?r@1c0=jcJFUTwpvw5B^O`$vk#}Kor6D0gCMiSI~sPSOyd%4IyMp3wPEGP$5 z?PC3^2^=z4%QG-C85fba49^O`YQuI5;jb58v8C`iI1IMOFU4eZ)JA(idt{C~VoTga z*5E>C9AM2wmi_vgWatd8PNX#XieSr%SpYB8i?KbJzF4^^fM1btW=oh|fv;~?cwQi7 zlUfKZ$;NM~K?ykIH!1rNrzFEt>M!uG-fFW0X!6@-L>(|^w5-V5BZjNUZKEU zI>;&o|C3#}aQnu}y3_c2j<(<8_lKHkd3j74sW4a!-=~YWn1NGYJ}a|a-ihon+~#R{ zdKzQUm(e`VtEEsFEB`oBDD`-iL8_%35Eu1uD!qCuT4>C~hxQ~Z6Y?ioJkl~&-53Hj z`fwf8OHgxP$b06Z{-xsNyu8A(W@t+y4E7k%#5)>6`wo$-85iFcfZdQ*cz@W%IJJ~K zqoaph`;Al%MbHadjCGfv;yoCR+OmhU~H4eprMNKcZLHV4nrZ$b9gCz(63@fbW zR1Kb4_y7P?6dc9~0bh&`Alf(hi|GO8HUIAryfVzugmo6e`HsDaq$H`7D0;ID;;g013ZUNwzaYf8FdBUl`XW~v}f z8sAh%S<_bztt@VuOseM~v{$c~`w~(J7OE}W|1}ok4K|RrjCAflEsoW}?S$4|4;_aJ{HfEeu=Y6Fcs;IEs?99gBIp0DJ4%f%;__DZk5 z*V38Zb6cJxOp^354fdgM(l?VHs3e7L$rT5{3EPHG@|KUH{MPZtBpqr-gs;t}MyiH$ z`}dkXv1idIXj^B_Ouay!Tem#3TE0xBVL4yU9xl)W-$^qM0(<#}dy7Uk;u9(EdoLD{ z8|d&Y4d?EJsSQFrXA`99AlyZo6^x#a7dRh3N@Gd8YLX@JKkW;|K&++Gj+>ub`uJZt zkr$8R9dp@Spyi;v9}-dZxSC{fcxIZo>aD8(6!87T+LI$sE5_c6XWp^w{Wh z$YJRR>FW3L)N)K5(f2IQ@#|VVsK#`UG!?PftUfrnH(j* zG%K+subi>DM1~3MZ$_GLLNbX5-c!dCzYR=I@s(>S7EdisP9ZfC?ViVtPsJ?uOr}_~JNjnQ+ zO4Hb|`Tb=#o+`%Lxs3Jr_xx_#z;*V88?!1b#-VE;bDRZ@oj$cx=GM|b{_2A-1Vizg zlp!Gj1kesZL=5e>uZV__8hNoxSMWSYx<#43#@bP=V+-445P1-NRYSWOaWJs7yB$E< z^VTW)w3_4|WpPnkP{(iW6SH@9+gsB~ywM)+v!`RlkiqHn<6`H-M182JLkug5a|jCk zOu|fo^znjC$*~*G!mPkgbb{!ABd4cP|GeE%gHlO~2C0`zHzDlfjeMYBw%i60@t z!7n7fRzUqCtta-Kb{WEPV;(Y3xx(5ii&Eoa`-3n0Vp zodFFo2=7uzeB}H;=ZxjtBp|VqbH07|z)U`%dB=6Q5Z6TiGFIBTlW}>bmV`Nw=25n= z+j@BzOWYzWR=zG)&ThPM^1DdeI$_{H9Uy;=A`Raoj!9aBKe*^=@G$>vcE9ylX$pF1 zLu0~*No*b`CuimLJylFzpCg92IfTniG_@cFG>^Vp34Vn|+3--Ekezt=XIN)OdfLdt z2Ho~!njA@vyhdbGxogv$p~c!wrIgX!MHK&WoU{Xv{Nf?}VbZnMa=zMS`Gtn4~f|}MDLY*aLW-UDdBxl<7<0bW9N{h9G4vz4VI)FmU#c>xVKJt zVF6WKmQ~;pcrMe0v|)2G%g}Xi$P|UYbL`??{QeS87AxA=IglSNCwRsgvSCes$Lv`- z=kMqD8tzwq=Rs(?E_U-Zz3WfnoEIeVutvH=hNcKxh=)>d&Xe)w=C0$ylq~T>|FvzlkGuxm{fG&M{;tk4>1tO9eN?=_OF6op4T3r}$~kN$Q62Rzuuw!+H+ z!IbddM$MGa3r6|xl@0+w{b7^i@jR{{Iom zzgj9$eQ`Vghdv0xQ|=A7wZ6^Q4|6@G13r|rc@<+ar%C<@RMz|H-PVot*;!hkvF$_r zYXl@8!|w_V9a6>#Dtm1Y32+lryhTI~?tq;9j@hxcF^NyJr+32cmfECvi?6(m<>nNZhO`0&RBY*>N55`0+5JnYHH66z&p96pV8DiReR5{amcsGRq%j_@0k-_=9F%hv{oUC8jJ0 zGG@0%nw^`#SL&Ph+QGCnduoYBcl96_&i6chO(pW&&Ar5tgXd9bTxOciosp$J$qs}uQRcNwvFdKI}{xGF71;D$~CD-pZ;8@FdDEcz}p z#HJ96lm(&w=tBywAY@Za4AMLk4L$DFD^YK;!8$&*wL55{_j7Aun&6s?J-Qddo_n5^kc zH9cL@4rMtFf5;h8e8ggn?ytHt2))-lw+t=GC`X!CcqB<9q64`$MK&4VM;hipUF7=9 zfAO314`SOO7W|TAJ7~;;Jk6@{KeI$!)L5TFoluu7?=1>7XUBs$Z+`0X}f6nTqB980B%%va?g9$;T{$ zV&9!u28#!^+3^RwPmQX}0;d5xrh;`eaCFphpbx2x7woW{pqJmu?!e#!`8AqXy~eix zUgXRuJB}o@dlR+s)VP*U1CWXxOgQxxpa2i%{gaAU)HmH;+wkxWJ%kI3NQ7~<@VmV@ z`RhBu6l4Ggh=yx~SXn9VhYSb|FSP@J;UCC53@U%*$482*W4<{BjjbJ0L0NW=YS24> zZn`7lwILH}@g~*M--*?a^&giS(`({|tm-!wWI@=C-;+wZ zS)fTU(ShmmgwOhK{&?4n3qmxGY&p4Jxe!F6?CN8h_z*^svE7z6?|y>9ytikqUE|on zQS#5Lk%PNA=7cK6=7g%NuS({=!Y+bjN^uy7Jx<%{gqJZOretK4!%w66d%9?oI0-S$zou* z(hI)O)MAL^CKPr$Y06ExnXEVt#F2$S>+|KTpj1Uq>J`e-jE_n*CU}Nl<=;KPa)4p? zsORlO>>HV2{%k9$qBwJCd5k&Ks$Al!3ZVR6@CCLhs^+iP0j!DV~C+& zN=f=Fb;O`I+F`gt97i^pzu(-FC3=3=uG0$z0HvIRlwX}JuFKVJ3rb~P$Hg#r5&T2exo z)8`DxeUy{+Yu%dGwxz{G-8ndk7XBRdgJFWEnOK&o_PRak)9h&kZnYNN`L1E!Wa=YU zc*=r=mXOonpeCDt%GDl_W*^jk)mCS^Je+90rI;Y9^bxf}|Lf!m z*c}(;^)Roo$Q-Da8RXO_$3{-#zbO)09s_wne)nclF;@fSMM0mJ<7&>6Ya;z=WV!&{ z+|YwQxNMu;4&Hqvs+K)p-tyf;`qpF5p@D&Z@LU&sTIS%|NZd0qM(aPX_@~qg+6}jV zetT+iX+X^EIWO&wM0Bc`EU*}WHd)n`A z^p(EZE%xQGNYG6{G(4LQ)U30kue0w%$S90Y`n)U4Qk%5dV+kygoRoVYo@QW2)9g8Q zcaJ4Wl&iJ_!@;7WR&RVrQSLyY11Ey)tBR^cHbln2=7F5ZW|nBH3*|-|1EH*T;;e7C z{T4@7um1_y@+T4DS}a;$fj^HV!Wge1g!+%8APx;uaDm4^6e#TEPe1k`c5r zqxy}}S6QN|Nlouhl*7k0bJouaF&)SWbT4W1q9MO^$}gq2V5}XXeFMd0xjxmW-hURB}$0O Kh!hLy`TRd3=`3Ub literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/instructeur-procedure-notifications.png b/app/assets/images/faq/instructeur-procedure-notifications.png new file mode 100644 index 0000000000000000000000000000000000000000..ea56143c23adc560d0fefd6557b2d75555211a16 GIT binary patch literal 25740 zcmb@s1z20%*6*8ABT%HliW3})v}kb%P=dP@in|wgE5VC96n8Jh9g4fVyHngDH{I{H z^L_i=d(J&)Ju}Hz>zR3`&ozJJKZ4|B#L!R(Pyhe`nmANM0RVUkL42%{pCX=g|4b@H z+yLaHltkfh_}<>$Re#iF7UyZulgFdUjPxmd+V6sbxn^c92M3q;_i)GXhUw|Ulat$^ zK$x(U>iYVAM@O%>hwJtAHGDM2#ijG&;_>|ap}&7kL-$izP1(c6(m|D-yh4?ZPJMg( zCN6H|*4A&g#9tW|Gd6bid3kx)SLfle3CXG72L=XaXJ@S~%#DnVzSvq^oSaArbE~Q< zadUHTm+C)EWx(&xZJq2UMn>R6aSsbcyj;=M`-^11DS$~0vQ{mh>-0Dk8&*ix8Cllu;*FLq*@*M48!Sh;#zdgZjHIt3IDY*l)v*ci{|+EhiE_jTn)JDF$N z*=wOl$p(T+6pSjp9cqvr9S_FE^&)J5`VCjTT0pQ%Bbat82k(e7J<1 zH*h#EE-p9JyxfNS^bs+(*PMj7Jj!u!_;!gtZg~IwTaoy2IK1}}-W|e^OQ?yZ^K

WlpJf!n@p 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/instructeur-procedure-show.png b/app/assets/images/faq/instructeur-procedure-show.png new file mode 100644 index 0000000000000000000000000000000000000000..837cc39c72537cdf4c970b5dda90410fbf675560 GIT binary patch literal 76422 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

ewY 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/sign-in-page.png b/app/assets/images/faq/sign-in-page.png new file mode 100644 index 0000000000000000000000000000000000000000..c5500c9b8c0747cd0f8b42d99e3be64c00e317da GIT binary patch literal 39124 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 z

`) et une balise fermante (ex : `

`). Notez que la balise fermante inclut une barre oblique `/` (slash) après le `<`. + +### Exemples de mise en forme : + +- **Mettre un texte en italique** + - Balise ouvrante : `` + - Balise fermante : `` + - Exemple : « `Mon texte en italique` » affiche « Mon texte en *italique* » + +- **Souligner un texte** + - Balise ouvrante : `` + - Balise fermante : `` + - Exemple : `« Mon texte souligné »` affiche « Mon texte souligné » + +- **Mettre un texte en gras** + - Balise ouvrante : `` + - Balise fermante : `` + - Exemple : `« Mon texte en gras »` donne « Mon texte en **gras** » + +- **Faire un paragraphe** + - Balise ouvrante : `

` + - Balise fermante : `

` + - Exemple : `

Mon paragraphe

`. + + Généralement vous n’aurez pas besoin de créer un paragraphe, car un saut de ligne vide en créé toujours un nouveau. + +## Aperçu du texte avec ces balises + +Exemple dans la description de la démarche : + +![Démonstration de texte qui contient des balises HTML de mises en forme](faq/administrateur-example-markup.png) + +Rendu côté usager : + +![Aperçu du même texte dans la description de la démarche lisible par l’usager](faq/administrateur-example-markup-preview.png) diff --git a/doc/faqs/administrateur/comment-modifier-une-demarche-deja-publiee.fr.md b/doc/faqs/administrateur/comment-modifier-une-demarche-deja-publiee.fr.md new file mode 100644 index 000000000..99f09dba9 --- /dev/null +++ b/doc/faqs/administrateur/comment-modifier-une-demarche-deja-publiee.fr.md @@ -0,0 +1,31 @@ +--- +category: "administrateur" +subcategory: "general" +slug: "comment-modifier-une-demarche-deja-publiee" +locale: "fr" +keywords: "modifier démarche, publication, mise à jour formulaire, brouillon" +title: "Comment modifier une démarche déjà publiée ?" +--- + +# Comment modifier une démarche déjà publiée ? + +La plupart des caractéristiques de la démarche peuvent être modifiées après la publication, comme : + +- le nom et la description +- le formulaire en lui-même +- la délivrance et le contenu de l’attestation +- la plupart des réglages, à l’exception du chemin (l’url) et les réglages liées au *Silence Vaut Accord* ou *Silence Vaut Rejet* + + +## Modification du formulaire + +Vous disposez des mêmes options que lors de la création d’un formulaire. Pour valider les changements sur votre formulaire, cliquez simplement sur le bouton **« Publier »**. + +Les modifications apportées au formulaire s’appliqueront uniquement sur : + +- les dossiers en *« brouillon »* et les futurs dossiers (c’est-à-dire ceux déposés après la publication de vos modifications) +- les dossiers en *« en construction »* et *« en instruction »* compatibles (par exemple : si vous supprimez un champ ou modifiez les annotations privées) +- les autres dossiers en *« en construction »* au moment où l’usager modifier son dossier + +**Les dossiers *terminés* ne sont pas modifiés.** + diff --git a/doc/faqs/administrateur/comment-obtenir-un-compte-administrateur.fr.md b/doc/faqs/administrateur/comment-obtenir-un-compte-administrateur.fr.md new file mode 100644 index 000000000..dbcc58031 --- /dev/null +++ b/doc/faqs/administrateur/comment-obtenir-un-compte-administrateur.fr.md @@ -0,0 +1,18 @@ +--- +category: "administrateur" +subcategory: "general" +slug: "comment-obtenir-un-compte-administrateur" +locale: "fr" +keywords: "compte administrateur, demande, organisme public, sécurité" +title: "Comment obtenir un compte administrateur ?" +--- + +# Comment obtenir un compte administrateur ? + +Pour obtenir un compte administrateur, il suffit d’en faire la demande en déposant un dossier via le lien suivant : [Demande d'inscription à demarches.gouv.fr](https://demarches.gouv.fr/commencer/demande-d-inscription-a-demarches-simplifiees) + +Le profil administrateur est le seul profil réservé strictement aux agents publics. Nous vous demandons donc de bien vouloir **faire la demande avec votre adresse mail professionnelle**. + +👉 **Attention**, la création de compte administrateur est **réservée uniquement aux organismes publics**. Elle ne concerne ni les particuliers, ni les entreprises, ni les associations (sauf celles reconnues d'utilité publique), ni les personnes souhaitant remplir un dossier ou faire une démarche en ligne. Ce compte vous permettra de dématérialiser des démarches papier sur demarches-simplifiees.fr, vous pourrez ensuite les diffuser en ligne auprès de vos usagers (sur votre site web, par exemple). + +Une fois votre demande acceptée, vous recevrez un email vous invitant à créer votre mot de passe. Pour des raisons de sécurité, celui-ci doit atteindre une certaine complexité (nous aussi on aurait préféré vous laisser choisir « chouchou1234 » mais, malheureusement, nous devons protéger vos usagers !). diff --git a/doc/faqs/administrateur/faire-tester-une-demarche-par-un-collegue.fr.md b/doc/faqs/administrateur/faire-tester-une-demarche-par-un-collegue.fr.md new file mode 100644 index 000000000..4cc2b1523 --- /dev/null +++ b/doc/faqs/administrateur/faire-tester-une-demarche-par-un-collegue.fr.md @@ -0,0 +1,23 @@ +--- +category: "administrateur" +subcategory: "procedure_test" +slug: "faire-tester-une-demarche-par-un-collegue" +locale: "fr" +keywords: "démarche en test, lien URL, accès démarche, erreur message" +title: "Faire tester une démarche par un collègue" +--- + +# Faire tester une démarche par un(e) collègue + +Si vous êtes administrateur, vous pouvez partager le lien de votre démarche en test à un collègue. Il n’a pas besoin d’être administrateur pour tester le formulaire, mais doit avoir un compte sur demarches.gouv.fr. + +**Attention : tout dossier déposé, même instruit, sera supprimé une fois la démarche publiée. Ne communiquez pas ce lien à des vrais usagers !** + +## Message: *« La demarche n’existe pas »* + +Si la personne qui teste voit un bandeau avec le message *« La démarche n’existe pas »* après s’être identifié(e), cela indique généralement un problème avec le lien. + +Vérifiez les points suivants : + +- **Connexion requise** : Pour accéder à une démarche de test, l’usager doit d’abord être connecté à demarches.gouv.fr. La création d’un compte est donc indispensable. +- **Vérification du lien** : Assurez-vous que le lien transmis est correct. Un lien erroné conduira à l’affichage du message d’erreur mentionné. diff --git a/doc/faqs/administrateur/guide-de-bonnes-pratiques-pour-tester-une-demarche.fr.md b/doc/faqs/administrateur/guide-de-bonnes-pratiques-pour-tester-une-demarche.fr.md new file mode 100644 index 000000000..f94edf36e --- /dev/null +++ b/doc/faqs/administrateur/guide-de-bonnes-pratiques-pour-tester-une-demarche.fr.md @@ -0,0 +1,104 @@ +--- +category: "administrateur" +subcategory: "procedure_test" +slug: "guide-de-bonnes-pratiques-pour-tester-une-demarche" +locale: "fr" +keywords: "test démarche, étapes test, dépôt dossier, instruction, fonctionnalités secondaires" +title: "Guide de bonnes pratiques pour tester une démarche" +--- + +# Guide de bonnes pratiques pour tester une démarche + +Tester une démarche est nécessaire avant toute publication. En effet, il s’agit d’une étape essentielle pour déployer un formulaire de qualité, cela permet notamment de : + +- corriger les erreurs de votre formulaire et de le transmettre à votre délégué à la protection des données +- vérifier que le processus d’instruction envisagé correspond à vos besoins ainsi que toutes les fonctionnalités associées (emails automatiques, attestations, annotations privées, etc…) +- préparer votre service et vos usagers à l’utilisation de demarches.gouv.fr. + + +## Déroulé du test + +Le test d’une démarche se déroule en trois étapes : + +1. le test de la partie usager (dépôt de dossiers) +2. le test de la partie instructeur (instruction du dossier) +3. le test des fonctionnalités secondaires + +Vous pouvez faire tester la partie usager (étape 1) et instructeur (étape 2) par des collaborateurs. Cependant, nous vous recommandons fortement de les tester vous-même une première fois. + +**Vous pouvez effectuer toutes les modifications que vous souhaitez sur votre démarche pendant cette phase de test.** + +Bien évidemment, avant de tester la démarche, il faut l’avoir créé. Pour cela, vous pouvez vous aider de [notre guide de la dématérialisation réussie via demarches.gouv.fr](https://456404736-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-L7_aKvpAJdAIEfxHudA%2Fuploads%2FGJm7S7LVjHPKVlMCE36e%2FGuide%20des%20bonnes%20pratiques%20démarches-simplifiees.pdf?alt=media&token=228e63c7-a168-4656-9cda-3f53a10645c2). Vous pouvez également consulter la documentation [Comment créer une nouvelle démarche](https://doc.demarches-simplifiees.fr/tutoriels/tutoriel-administrateur) + +## Étape 1 : Déposer un dossier de test côté usager + +Vous devez commencer par cette étape afin de tester le parcours d’un usager pour déposer un dossier. De plus, sans dossier déposé, vous ne pourrez pas tester les fonctionnalités relatives à l’instruction. + +1. Utilisez le bouton **« Tester la démarche »** (ou suivez le lien de la démarche), accessibles depuis votre interface administrateur. Toute personne ayant connaissance du lien pourra remplir des dossiers test sur votre démarche qui seront supprimés plus tard. + ![Bouton Tester la démarche depuis la tableau de bord de la démarche](faq/administrateur-procedure-test-button.png) + ![Affichage du lien de test de la démarche](faq/administrateur-procedure-test-link.png) + +2. Commencez à remplir votre dossier en suivant le bouton **« Commencer la démarche »**. + ![Page d’accueil usager de la démarche](faq/administrateur-procedure-test-commencer.png) + +3. Après avoir complété le dossier et éventuellement testé les fonctionnalités dédiées à l'interface usagers (invitation à compléter un dossier…), cliquez sur **« Déposer le dossier »** pour finaliser le dépôt et voir le message de confirmation de dépôt. + ![Test de la démarche par l’usager](faq/administrateur-procedure-test-usager.png) + +Une fois votre dossier de test déposé, un message de confirmation de dépôt sera affiché : + +![Message de confirmation de dépôt d’un dossier](faq/administrateur-procedure-test-thanks.png) + +N’hésitez pas à [transmettre le lien vers la démarche test à vos collègues](/faq/administrateur/faire-tester-une-demarche-par-un-collegue) afin qu’ils puissent la tester et vous transmettre leur retour. Gardez à l’esprit que **tous les dossiers déposés pendant le test seront supprimés** lorsque la démarche sera modifiée ou publiée. + +## Étape 2 : Instruire le dossier de test + +Vous avez déposé à l’étape précédente un premier dossier. Rendez-vous désormais dans la partie instructeur. + +Passez à l’interface instructeur via le lien [https://demarches.gouv.fr/procedures](/procedures) ou en changeant de profil depuis le menu en haut à droite en cliquant sur votre adresse email. + +![Menu pour passer instructeur](faq/administrateur-profile-switch.png) + +⚠️ **Vous devez être instructeur de la démarche pour accéder aux dossiers déposés**. Par défaut, en tant que créateur de la démarche (administrateur), vous êtes instructeur de celle-ci. Si vous ne testez pas vous-même cette partie, ajoutez votre collaborateur en tant qu’instructeur. + +Vous arrivez alors sur l’interface instructeur. Il vous suffit de cliquer sur l’onglet **« En test »** puis suivez le lien de votre démarche avec un dossier **« à suivre »** . + +![Page d’accueil instructeur de la démarche](faq/administrateur-procedures-list.png) + +Trouvez le dossier à suivre, puis testez l’instruction du dossier. + +![Dossier à suivre](faq/administrateur-test-instruction-dossiers-list.png) + +Voici le tutoriel pour instruire un dossier en tant qu’instructeur : [Tutoriel Instructeur](https://doc.demarches-simplifiees.fr/tutoriels/tutoriel-instructeur) + +## Étape 3 : Tester les fonctionnalités secondaires + +Vous pourrez ici tester différents éléments secondaires : + +- Demande d’**avis externe** (partie instruction) . Pour plus d’information, vous pouvez consulter [notre tutoriel expert invité](https://doc.demarches-simplifiees.fr/tutoriels/tutoriel-expert-invite) +- **Vérifiez les e-mails** d’accusé de réception, de passage en instruction, d’acceptation, de refus et de classement sans suite (partie usager) +- Testez la **messagerie du dossier** en envoyant un message à l’usager. Si vous souhaitez anonymiser l’adresse mail des instructeurs dans la messagerie, vous pouvez [nous contacter à l’adresse contact@demarches-simplifiees.fr](mailto:contact@demarches-simplifiees.fr) +- Si l’**attestation automatique d’acceptation** et la partie annotations privées ont été paramétrées, vérifiez qu’il n’y a pas d’erreur + +En cas d’erreur et/ou en fonction des retours de vos collègues suite à la phase de test, vous pouvez modifier la démarche depuis votre profil administrateur. + +**⚠️ Attention, suite aux modifications, tous les dossiers seront supprimés.** + + +## Fin du test : Passage en production + +Après avoir minutieusement testé votre démarche, il est temps de la rendre accessible à tous en la publiant. + +1. **Accédez au tableau de bord administrateur** de votre démarche et cliquez sur le bouton **« Publier »** situé en haut à droite. + ![Bouton Publier](faq/administrateur-procedure-test-publish.png) + +2. **Personnalisez le lien** du formulaire pour le diffuser plus facilement à vos usagers. Cette étape vous permet de simplifier l’accès à votre démarche. + +3. **Indiquez le lien de diffusion** de votre démarche, comme le site internet de votre organisme, pour faciliter la communication auprès des usagers. + +4. **Prenez connaissance des mentions RGPD** avant la publication pour assurer la conformité de votre démarche avec le **Règlement Général sur la Protection des Données**. + +5. **Finalisez la publication** en cliquant sur le bouton **« Publier »** situé en bas de l’écran. + +Félicitations, vous êtes désormais administrateur d’une démarche publiée sur demarches.gouv.fr ! + +⚠️ **N’oubliez pas de diffuser le lien** de votre démarche auprès de vos usagers, accessible depuis votre interface administrateur. Ce lien est différent du lien de la démarche test. diff --git a/doc/faqs/administrateur/je-dois-confirmer-mon-compte-a-chaque-connexion.fr.md b/doc/faqs/administrateur/je-dois-confirmer-mon-compte-a-chaque-connexion.fr.md new file mode 100644 index 000000000..394e644c7 --- /dev/null +++ b/doc/faqs/administrateur/je-dois-confirmer-mon-compte-a-chaque-connexion.fr.md @@ -0,0 +1,36 @@ +--- +category: "administrateur" +subcategory: "general" +slug: "je-dois-confirmer-mon-compte-a-chaque-connexion" +locale: "fr" +keywords: "authentification, navigateur, configuration, cookies, sécurité compte" +title: "Je dois confirmer mon compte à chaque connexion" +--- + +# Je dois confirmer mon compte à chaque connexion + +Afin de sécuriser votre compte, demarches.gouv.fr vous demande tous les mois d’authentifier votre navigateur. Il vous faut alors cliquer sur le lien de confirmation envoyé par email. + +Ce processus peut parfois vous être demandé à chaque connexion. Nous avons identifié deux raisons possibles : + +- Une mauvaise configuration de votre navigateur. +- Le navigateur authentifié n’est pas celui que vous utilisez. + +**Le lien reçu** par email est **valide une semaine** et peut être **utilisé plusieurs fois**. Vous pouvez donc probablement le réutiliser pour authentifier votre navigateur sans attendre un nouvel email. + +## Mauvaise configuration de votre navigateur + +Ce problème apparaît lorsque votre navigateur est configuré de manière très sécurisée et efface les données provenant de demarches.gouv.fr à chaque fermeture. + +**Solution :** Pour corriger ce problème, configurez votre navigateur pour accepter les cookies du domaine demarches.gouv.fr : + +- Pour Firefox [https://support.mozilla.org/fr/kb/sites-disent-cookies-bloques-les-debloquer](https://support.mozilla.org/fr/kb/sites-disent-cookies-bloques-les-debloquer), +- Pour Chrome [https://support.google.com/accounts/answer/61416?co=GENIE.Platform%3DDesktop&hl=fr](https://support.google.com/accounts/answer/61416?co=GENIE.Platform%3DDesktop&hl=fr). + +Si vous n’avez pas les droits suffisants pour modifier cette configuration, contactez votre support informatique et mettez-nous en copie : contact@demarches.gouv.fr + +## Le navigateur authentifié n’est pas celui que vous utilisez + +Il est possible que lorsque vous cliquez sur le lien de l’email, celui-ci ouvre le navigateur par défaut, souvent Internet Explorer, alors que vous utilisez un autre navigateur, comme Firefox, pour accéder à demarches.gouv.fr. Le lendemain, lorsque vous ouvrez Firefox, le navigateur n’est toujours pas authentifié et vous devez à nouveau cliquer sur le lien de connexion. + +**Solution :** Copiez le lien de l’email et ouvrez-le avec le navigateur que vous utilisez habituellement pour aller sur demarches.gouv.fr. diff --git a/doc/faqs/administrateur/je-ne-trouve-pas-ma-demarche-dans-le-catalogue-de-demarches.fr.md b/doc/faqs/administrateur/je-ne-trouve-pas-ma-demarche-dans-le-catalogue-de-demarches.fr.md new file mode 100644 index 000000000..e84b306e0 --- /dev/null +++ b/doc/faqs/administrateur/je-ne-trouve-pas-ma-demarche-dans-le-catalogue-de-demarches.fr.md @@ -0,0 +1,35 @@ +--- +category: "administrateur" +subcategory: "create_procedure" +slug: "je-ne-trouve-pas-ma-demarche-dans-le-catalogue-de-demarches" +locale: "fr" +keywords: "catalogue démarches, cloner démarche, partager démarche, critères répertoriation" +title: "Je ne trouve pas ma démarche dans le catalogue de démarches" +--- + +# Je ne trouve pas ma démarche dans le catalogue de démarches + +Si vous avez publié votre démarche et qu’un collègue souhaite la cloner mais ne la trouve pas sous « _Nouvelle démarche_ » dans le catalogue _Créer une nouvelle démarche à partir d’une démarche existante_, voici quelques étapes à suivre : + +1. Utiliser la fonction de recherche du navigateur (Ctrl + F) pour chercher par mot clé plutôt que par le titre complet de la démarche et vérifier sa présence. +2. Si la démarche n’est pas trouvée, cela peut être dû au critère de répertoriation qui requiert que 30 dossiers soient déposés sur la démarche pour figurer dans le catalogue. + +## Partager la démarche +Dans cette situation, vous pouvez partager directement votre démarche avec votre collègue : + +- Allez sur votre profil administrateur. +- Cliquez sur votre démarche. +- Accédez à l’onglet publication. +- Suivez le bouton « _Envoyer une copie_ » en haut à droite. +- Entrez l’adresse email de votre collègue. + +Votre collègue trouvera alors une copie de votre démarche dans ses démarches « En test ». + +![Capture d’écran du bouton Envoyer une copie de ma démarche](faq/administrateur-button-copy-procedure.png) + +## Recherche dans « Toutes les démarches » + +Vous pouvez également rechercher la démarche depuis la page « _Toutes les démarches_ », où il est possible de filtrer les démarches par zones, statut, SIRET ou tags, y compris celles avec moins de 30 dossiers déposés. + +![Capture d’écran du lien vers la page Toute les démarches](faq/administrateur-link-all-procedures.png) +![Capture d’écran de la page Toutes les démarches](faq/administrateur-all-procedures.png) diff --git a/doc/faqs/administrateur/les-blocs-repetables.fr.md b/doc/faqs/administrateur/les-blocs-repetables.fr.md new file mode 100644 index 000000000..bb28f9b3e --- /dev/null +++ b/doc/faqs/administrateur/les-blocs-repetables.fr.md @@ -0,0 +1,39 @@ +--- +category: "administrateur" +subcategory: "general" +slug: "les-blocs-repetables" +locale: "fr" +keywords: "blocs répétables, champs dynamiques, saisie multiple, configuration champ" +title: "Les blocs répétables" +--- + +# Les blocs répétables + +Les blocs répétables sont une fonctionnalité qui permet à l’usager de saisir un certain nombre de champs autant de fois qu’il le souhaite. + + +## Exemple d’utilisation + +Imaginez une situation où l’usager doit saisir plusieurs fois une commune. L’administrateur prévoit une seule fois ce « bloc » d’informations (champ commune), et l’usager peut ensuite le saisir autant de fois que nécessaire. + + +## Comment configurer un bloc répétable ? + +1. Sélectionnez comme type de champ le **« Bloc répétable »** et ajoutez son libellé. Dans notre exemple ce sera *Localisation*. + ![Exemple de choix du champ Bloc répétable](faq/administrateur-list-champs-repetition.png) + +2. Renseignez les différents champs à inclure au sein de ce bloc répétable en cliquant sur **« Ajouter un champ »** à l’intérieur du bloc. Vous pouvez ajouter tous les types de champ (texte, zone de texte, entier, etc…) + Dans l’exemple d’une liste de communes, l’administrateur ajoute donc le champ « Commune » mais d’autres champs pourraient être ajoutés. + ![Exemple de configuration d’une répétition](faq/administrateur-repetition-create.png) + +## Comment fonctionne-t-il pour l’usager ? + +- L’usager voit le champ répétable, vide par défaut, et peut cliquer sur **« Ajouter un élément »** pour saisir une nouvelle instance du bloc. + ![Vue usager vide du bloc répétable](faq/administrateur-repetition-view-usager-empty.png) + +- Après avoir ajouté un élément, il peut continuer à ajouter de nouvelles valeurs aussi souvent que nécessaire. + ![Vue usager remplie avec 2 communes du bloc répétable](faq/administrateur-repetition-view-usager-fill.png) + +- Si nécessaire, l’usager a la possibilité de supprimer un élément précédemment ajouté. + +Les blocs répétables simplifient grandement la saisie de données multiples. diff --git a/doc/faqs/administrateur/nommer-un-nouvel-administrateur-des-demarches-les-bonnes-pratiques.fr.md b/doc/faqs/administrateur/nommer-un-nouvel-administrateur-des-demarches-les-bonnes-pratiques.fr.md new file mode 100644 index 000000000..73f6def6e --- /dev/null +++ b/doc/faqs/administrateur/nommer-un-nouvel-administrateur-des-demarches-les-bonnes-pratiques.fr.md @@ -0,0 +1,20 @@ +--- +category: "administrateur" +subcategory: "administrateur_departure" +slug: "nommer-un-nouvel-administrateur-des-demarches-les-bonnes-pratiques" +locale: "fr" +keywords: "nouvel administrateur, compte administrateur, transmission, suppression compte" +title: "Nommer un nouvel administrateur des démarches" +--- + +# Nommer un nouvel administrateur des démarches + +Pour assurer une transition fluide et maintenir l’intégrité de vos démarches, voici quelques étapes à suivre lors de la nomination d’un nouvel administrateur : + +1. **Identifier le nouvel administrateur** au sein de votre service qui prendra en charge des démarches. Si cette personne n’a pas encore de compte administrateur, elle peut en demander un via le lien : [Demande d’inscription à demarches.gouv.fr](https://demarches.gouv.fr/demandes/new). + +2. **Ajouter ce nouvel administrateur à toutes vos démarches**. Consultez notre tutoriel dédié pour vous guider dans ce processus : [Plusieurs administrateurs sur une même démarche](/faq/administrateur/ajouter-plusieurs-administrateurs-sur-une-meme-demarche). + +3. **Demander la suppression de votre compte** à [contact@demarches-simplifiees.fr](mailto:contact@demarches-simplifiees.fr), à condition qu’aucun dossier ou démarche ne soit rattaché à votre compte. Si nécessaire, pour votre nouvelle fonction, demandez l’ouverture d’un nouveau compte administrateur avec notre nouvel email via le formulaire : [Demande d’inscription à demarches.gouv.fr](https://demarches.gouv.fr/demandes/new). + +Ces étapes assurent une gestion continue et efficace des démarches, tout en facilitant le passage de responsabilités entre administrateurs. diff --git a/doc/faqs/administrateur/qu-est-ce-qu-un-administrateur.fr.md b/doc/faqs/administrateur/qu-est-ce-qu-un-administrateur.fr.md new file mode 100644 index 000000000..2017ea8db --- /dev/null +++ b/doc/faqs/administrateur/qu-est-ce-qu-un-administrateur.fr.md @@ -0,0 +1,16 @@ +--- +category: "administrateur" +subcategory: "general" +slug: "qu-est-ce-qu-un-administrateur" +locale: "fr" +keywords: "administrateur, gestion démarche, nommer instructeurs, profil utilisateur" +title: "Qu’est-ce qu’un administrateur ?" +--- + +# Qu’est-ce qu’un administrateur ? + +Un administrateur est en charge de la construction du formulaire et de la gestion de la démarche en général. Il a la responsabilité de nommer les instructeurs et est automatiquement considéré comme instructeur, ce qui signifie qu’il a accès aux dossiers et peut les instruire. + +Pour passer de l’action administrative à l’instruction des dossiers, l’administrateur doit changer de profil pour adopter celui d’instructeur. Cela montre la flexibilité du rôle d’administrateur, qui peut également endosser les rôles d’instructeur, d’usager, ou encore d’expert-invité selon les besoins de la démarche. + +Pour plus d’informations concernant le profil administrateur, vous pouvez consulter notre documentation : [Tutoriel Administrateur](https://doc.demarches-simplifiees.fr/tutoriels/tutoriel-administrateur) diff --git a/doc/faqs/instructeur/a-quoi-correspondent-les-differentes-categories-de-dossiers.fr.md b/doc/faqs/instructeur/a-quoi-correspondent-les-differentes-categories-de-dossiers.fr.md new file mode 100644 index 000000000..4a7068258 --- /dev/null +++ b/doc/faqs/instructeur/a-quoi-correspondent-les-differentes-categories-de-dossiers.fr.md @@ -0,0 +1,23 @@ +--- +category: "instructeur" +subcategory: "instruction" +slug: "a-quoi-correspondent-les-differentes-categories-de-dossiers" +locale: "fr" +keywords: "catégories dossiers, à suivre, suivi, traités, supprimé récemment, archivés" +title: "À quoi correspondent les différentes catégories de dossiers ?" +--- + +# À quoi correspondent les différentes catégories de dossiers ? + +Pour chaque démarche, les dossiers sont répartis dans plusieurs onglets : + +- **À suivre** : regroupe l’ensemble des dossiers qui ne sont suivis par aucun instructeur. +- **Suivis** : n’affiche que les dossiers que *vous* suivez. Elle ne prend donc pas en compte les dossiers suivis par les autres instructeurs de la démarche. +- **Traités** : regroupe les dossiers dont le statut est *accepté*, *refusé* ou *classé sans suite* et qui n’ont pas été archivés. +- **Supprimé récemment** : regroupe l’ensemble des dossiers non archivés, terminés et supprimés par les instructeurs de la démarche. +- **Expirant** : les dossiers *en construction* ou *traités*, (archivés ou non), dont le délai d’expiration approche (moins d’un mois). À l’issue de ce délai, le dossier sera supprimé de la plateforme. +- **Archivés** : regroupe l’ensemble des dossiers archivés. Les instructeurs ne peuvent plus y répondre, et les demandeurs ne peuvent plus les modifier. La messagerie est désactivée. Ces dossiers seront supprimés lorsque leur délai de conservation sur demarches.gouv.fr sera expiré. + +Notez qu’**un dossier peut être suivi par plusieurs instructeurs**. Vous pourrez donc retrouver les dossiers que vous ne suivez pas dans l’onglet **au total**. La somme des onglets *à suivre*, *suivis* et *traités* n’est donc pas nécessairement égale au nombre affiché *au total*. + +![Différents onglets répartissant les dossiers d’une démarche.](faq/instructeur-procedure-show.png) diff --git a/doc/faqs/instructeur/comment-ajouter-un-justificatif-lors-de-l-acceptation-d-un-dossier.fr.md b/doc/faqs/instructeur/comment-ajouter-un-justificatif-lors-de-l-acceptation-d-un-dossier.fr.md new file mode 100644 index 000000000..942ccc13d --- /dev/null +++ b/doc/faqs/instructeur/comment-ajouter-un-justificatif-lors-de-l-acceptation-d-un-dossier.fr.md @@ -0,0 +1,22 @@ +--- +category: "instructeur" +subcategory: "instruction" +slug: "comment-ajouter-un-justificatif-lors-de-l-acceptation-d-un-dossier" +locale: "fr" +keywords: "ajouter justificatif, acceptation dossier, lien attestation, documentation administrateur" +title: "Comment ajouter un justificatif lors de l’acceptation d’un dossier" +--- + +# Comment ajouter un justificatif lors de l’acceptation d’un dossier + +Lorsque vous acceptez, refusez ou classez sans suite un dossier, il est possible de joindre un document après avoir indiqué la raison derrière ce choix. + +![L’interface de l’instructeur pour accepter, refuser ou classer sans suite un dossier.](faq/instructeur-accepter-add-justificatif.png) + +## Note importante + +Pour que l’usager reçoive par email un lien lui permettant d’accéder au document en question, il est nécessaire d’inclure la balise `--lien attestation--` dans l’email d’acceptation. Cette opération requiert des droits d’administrateur. Vous pouvez trouver [dans la documentation comment procéder à cette opération](https://doc.demarches-simplifiees.fr/tutoriels/tutoriel-administrateur#les-e-mails-automatiques). Ce lien de téléchargement est disponible directement dans l’email **uniquement** en cas d’acceptation du dossier. + +Dans tous les cas, le document sera accessible dans l’interface de l’usager, qui pourra le télécharger, comme illustré ci-dessous : + +![L’interface de l’usager pour accéder à l’attestation de dépôt d’un dossier](faq/usager-dossier-accepte-summary.png) diff --git a/doc/faqs/instructeur/comment-filtrer-la-liste-des-dossiers.fr.md b/doc/faqs/instructeur/comment-filtrer-la-liste-des-dossiers.fr.md new file mode 100644 index 000000000..40804127c --- /dev/null +++ b/doc/faqs/instructeur/comment-filtrer-la-liste-des-dossiers.fr.md @@ -0,0 +1,52 @@ +--- +category: "instructeur" +subcategory: "instruction" +slug: "comment-filtrer-la-liste-des-dossiers" +locale: "fr" +keywords: "filtre dossiers, instruction, personnaliser tableau, sélection filtre" +title: "Comment filtrer la liste des dossiers ?" +--- + +# Comment filtrer la liste des dossiers ? + +En tant qu’Instructeur, vous pouvez appliquer des filtres sur la liste des dossiers, pour n’afficher que certaines valeurs et ainsi vous **faciliter l’instruction des dossiers**. + +## Appliquer un filtre + +Pour appliquer un filtre à une liste de dossiers : + +1. Affichez la liste des dossiers de la démarche. +2. Cliquez sur le bouton **« Sélectionner un filtre »**. +3. Sélectionnez le nom du champ sur lequel vous souhaitez appliquer un filtre en cliquant sur l’espace à droite de *colonne*. +4. Sélectionnez ensuite la valeur à filtrer. +5. Cliquez enfin sur **« Ajouter le filtre »**. + +![Capture d’écran de l’interface de saisie d’un filtre](faq/instructeur-filtres-dropdown.png) + +![Capture d’écran de toutes les colonnes filtres](faq/instructeur-filtres-list.png) + +## Appliquer plusieurs filtres + +Vous pouvez appliquer plusieurs filtres à une même liste. Quand vous appliquez des filtres sur des **types de champs différents**, les dossiers affichés sont ceux qui correspondent à **tous les filtres**. + +![Capture d’écran récapitulant une combinaison de filtres différents addifitifs](faq/instructeur-filtres-and.png) + +Quand vous appliquez des filtres sur les **mêmes types de champs**, les dossiers affichés sont ceux qui correspondent à **l’une ou l’autre des valeurs**. + +![Capture d’écran récapitulant une combinaison de filtres sur une même colonne](faq/instructeur-filtres-and.png) + +## Appliquer un filtre à une date + +Pour appliquer un filtre sur une colonne de date, rentrez la date au format « JJ/MM/AAAA ». + +![Capture d’écran illustrant un filtre sur une date](faq/instructeur-filtres-date.png) + +## Conseils sur les filtres + +- Les filtres s’appliquent indépendamment des majuscules et des minuscules. Par exemple, un filtre avec la valeur « domicile » renverra les dossiers pour « **Domicile** à Paris » et « Sans **domicile** ». +- Les filtres s’appliquent indifféremment au début, au milieu et à la fin des mots. Par exemple, un filtre avec la valeur « Pierre » renverra les dossiers pour « **Pierre**-Olivier » et « Jean-**Pierre** ». +- Les filtres sont individuels : vous pouvez appliquer des filtres sur votre compte sans que cela affecte les autres instructeurs. Ils sont enregistrés et restent présents lorsque vous fermez la page. + +En complément, vous pouvez également utiliser le bouton **« Personnaliser le tableau »** afin d’avoir une idée des informations du dossier avant de l’instruire. + +![Capture d'écran de l’interface de personnalisation du tableau](faq/instructeur-dossiers-list-header.png) diff --git a/doc/faqs/instructeur/comment-recevoir-un-email-chaque-fois-qu-un-dossier-est-depose.fr.md b/doc/faqs/instructeur/comment-recevoir-un-email-chaque-fois-qu-un-dossier-est-depose.fr.md new file mode 100644 index 000000000..9dfc06835 --- /dev/null +++ b/doc/faqs/instructeur/comment-recevoir-un-email-chaque-fois-qu-un-dossier-est-depose.fr.md @@ -0,0 +1,28 @@ +--- +category: "instructeur" +subcategory: "instruction" +slug: "comment-recevoir-run-email-chaque-fois-qu-un-dossier-est-depose" +locale: "fr" +keywords: "notification, suivi dossier, alerte dépôt dossier" +title: "Comment recevoir un email chaque fois qu’un dossier est déposé ?" +--- + +# Comment recevoir un email chaque fois qu’un dossier est déposé ? + +Il suffit de configurer vos notifications pour recevoir un email. En effet, pour vous aider à mieux suivre l’instruction de vos dossiers, plusieurs types de mails automatiques sont disponibles selon vos besoins : + +- Notification à chaque dossier déposé +- Notification à chaque message déposé +- Notification quotidienne +- Notification hebdomadaire +- Notification pour chaque avis rendu par un expert + +Pour paramétrer vos notifications en tant qu’instructeur, vous devez cliquer sur la démarche pour laquelle vous souhaitez recevoir l’email, puis cliquer sur **« Gestion des notifications »** : + +![Écran d’en-tête d’une démarche affichant le lien vers la page de gestion des notifications](faq/instructeur-procedure-header.png) + +Sélectionnez les notifications que vous souhaitez recevoir par email et cliquez sur le bouton **« Enregistrer »** : + +![Capture d’écran de la page de gestion des notifications](faq/instructeur-procedure-notifications.png) + +**Il est nécessaire de répéter cette opération pour chaque démarche pour laquelle vous souhaitez recevoir des notifications par email.** diff --git a/doc/faqs/instructeur/comment-repasser-un-dossier-en-instruction.fr.md b/doc/faqs/instructeur/comment-repasser-un-dossier-en-instruction.fr.md new file mode 100644 index 000000000..fa7776217 --- /dev/null +++ b/doc/faqs/instructeur/comment-repasser-un-dossier-en-instruction.fr.md @@ -0,0 +1,22 @@ +--- +category: "instructeur" +subcategory: "instruction" +slug: "comment-repasser-un-dossier-en-instruction" +locale: "fr" +keywords: "repasser instruction, dossier clôt, droits usager, demande correction" +title: "Comment repasser un dossier en instruction ?" +--- + +# Comment repasser un dossier en instruction ? + +Une fois un dossier clôturé, vous avez la possibilité de le repasser en instruction, notamment si vous souhaitez que l’usager vous apporte des éléments complémentaires. + +Toutefois, **l’acceptation d’un dossier génère des droits pour l’usager**. De ce fait, **votre responsabilité peut être engagée en cas de retour sur des droits qui ont été octroyés**. + +## Repasser un dossier en instruction + +Pour cela, cliquez sur le bouton relatif au statut du dossier (*Accepter*, *Refuser* ou *Classer sans suite*), puis sur **Repasser le dossier en construction**. L’usager recevra alors un email suivant le modèle ci-dessous. + +![Email reçu par un usager lorsque son dossier repasse en instruction, mentionnant que la précédente décision sur ce dossier est caduque.](faq/usager-email-dossier-repasser-instruction.png) + +Par ailleurs, vous pouvez désormais demander à l’usager de corriger son dossier et lui indiquer les informations à modifier/compléter en cliquant sur le bouton **Demande de correction** depuis l’interface instructeur. diff --git a/doc/faqs/instructeur/quels-sont-les-navigateurs-supportes.fr.md b/doc/faqs/instructeur/quels-sont-les-navigateurs-supportes.fr.md new file mode 100644 index 000000000..8585bd46a --- /dev/null +++ b/doc/faqs/instructeur/quels-sont-les-navigateurs-supportes.fr.md @@ -0,0 +1,32 @@ +--- +category: "instructeur" +subcategory: "instruction" +slug: "quels-sont-les-navigateurs-supportes" +locale: "fr" +keywords: "navigateur, compatibilité, mise à jour, carte" +title: "Quels sont les navigateurs supportés ?" +--- + +# Quels sont les navigateurs supportés ? + +Nous vous conseillons d’utiliser les navigateurs suivants, **idéalement dans leur dernière version disponible**. + +- Firefox (minimum 67) +- Chrome (minimum 79) +- Edge (minimum 79 et sans mode de compatibilité) +- Safari (minimum 12) +- Safari iOS (minimum 12) +- Samsung browser (minimum 12) + +**Mettez toujours à jour votre navigateur** notamment pour être protégé par leurs derniers correctifs de sécurité. + +Il est possible que le site marche à peu près sur des navigateurs plus anciens, +néanmoins nous n’assurons pas un support pour ceux-ci et ne garantissons pas que l’ensemble des pages soient fonctionnelles. + +## Les cartes ne s’affichent pas + +Si vous utilisez une ancienne version de Firefox, il est probable que le **webGL** soit désactivé. Pour l’activer : + +1. Entrez **about:config** dans la barre d’adresse (comme s’il s’agissait d'un site web, sans http devant). +2. Recherchez **webgl.force-enabled** et changez la valeur à **true** +3. Recherchez **webgl.disabled** et et assurez que la valeur est à **false** diff --git a/doc/faqs/usager/comment-deposer-un-autre-dossier-pour-une-meme-demarche.fr.md b/doc/faqs/usager/comment-deposer-un-autre-dossier-pour-une-meme-demarche.fr.md new file mode 100644 index 000000000..c4526e2f8 --- /dev/null +++ b/doc/faqs/usager/comment-deposer-un-autre-dossier-pour-une-meme-demarche.fr.md @@ -0,0 +1,14 @@ +--- +category: "usager" +subcategory: "fill_dossier" +slug: "comment-deposer-un-autre-dossier-pour-une-meme-demarche" +locale: "fr" +keywords: "nouveau dossier, même démarche, commencer, espace usager" +title: "Comment déposer un autre dossier pour une même démarche ?" +--- + +# Comment déposer un autre dossier pour une même démarche ? + +Pour commencer un nouveau dossier sur une démarche que vous avez déjà réalisée, cliquez sur le bouton **« Actions »** du dossier correspondant à la démarche que vous souhaitez faire. Sélectionnez enfin le bouton **« Commencer un autre dossier vide »**. + +![Image montrant le lien pour Commencer un autre dossier vide](faq/usager-dossier-actions-menu-start-new.png) diff --git a/doc/faqs/usager/comment-dupliquer-un-dossier-deja-depose.fr.md b/doc/faqs/usager/comment-dupliquer-un-dossier-deja-depose.fr.md new file mode 100644 index 000000000..f227a1f15 --- /dev/null +++ b/doc/faqs/usager/comment-dupliquer-un-dossier-deja-depose.fr.md @@ -0,0 +1,28 @@ +--- +category: "usager" +subcategory: "fill_dossier" +slug: "comment-dupliquer-un-dossier-deja-depose" +locale: "fr" +keywords: "dupliquer, dossier existant, préremplir, dépôt rapide" +title: "Comment dupliquer un dossier déjà déposé ?" +--- + +# Comment dupliquer un dossier déjà déposé ? + +En tant qu’usager, vous avez la possibilité de dupliquer un dossier existant si la démarche n’a pas été clôturée. **Cette action permet de préremplir de automatiquement votre dossier afin de le déposer rapidement !** + +Pour cela, il vous suffit de cliquer sur le bouton **« Dupliquer ce dossier »** depuis le menu déroulant **« Actions »** situé à droite du dossier concerné : + +![Image illustrant le menu Dupliquer ce dossier](faq/usager-dossier-actions-menu-clone.png) + +Votre nouveau dossier sera alors automatiquement prérempli avec les informations et les pièces justificatives déjà transmises lors du dépôt de votre précédent dossier. + +![Image montrant le dossier dupliqué en brouillon](faq/usager-dossier-cloned-draft.png) + +Vous avez la possibilité de : + +- Consulter le dossier +- Vérifier si les informations sont toujours correctes +- Et si besoin, modifier les informations + +Après avoir vérifié et/ou complété le dossier, vous pouvez cliquer sur le bouton **« Déposer le dossier »**. diff --git a/doc/faqs/usager/comment-trouver-ma-demarche.fr.md b/doc/faqs/usager/comment-trouver-ma-demarche.fr.md new file mode 100644 index 000000000..1f70bb682 --- /dev/null +++ b/doc/faqs/usager/comment-trouver-ma-demarche.fr.md @@ -0,0 +1,63 @@ +--- +category: "usager" +subcategory: "fill_dossier" +slug: "comment-trouver-ma-demarche" +locale: "fr" +keywords: "lien démarche, démarches courantes, service-public, espace usager, nouveau dossier" +title: "Comment trouver ma démarche ?" +--- + +# Comment trouver ma démarche ? + +## 1. Trouver le lien de votre démarche + +Pour déposer un dossier sur demarches.gouv.fr, il est nécessaire de disposer du lien de la démarche qui vous intéresse. Il ressemble à un lien de cette forme : + + https://demarches.gouv.fr/commencer/xxxxxxxxxxxxxx + +ou + + https://www.demarches-simplifiees.fr/commencer/xxxxxxxxxxxxxx + + +Ce lien vous est communiqué par l’administration compétente pour votre démarche – généralement sur son site internet, ou par email. + +## 2. Les démarches les plus courantes + +Vous trouverez ci-dessous la liste des démarches les plus courantes dématérialisées sur demarches.gouv.fr : + +- [Démarches relatives au permis de conduire](https://doc.demarches-simplifiees.fr/listes-des-demarches/demarches-relatives-au-permis-de-conduire) +- [Démarches relatives au transporteur](https://doc.demarches-simplifiees.fr/listes-des-demarches/demarches-relatives-au-transporteur) +- [Démarches relative à l’inscription au service de restauration](https://doc.demarches-simplifiees.fr/listes-des-demarches/demarches-relative-a-linscription-au-service-de-restauration) +- [Démarches relatives aux cartes professionnelles de chauffeurs de voiture de tourisme (VTC)](https://doc.demarches-simplifiees.fr/listes-des-demarches/demarches-relatives-aux-cartes-professionnelles-de-chauffeurs-de-voiture-de-tourisme-vtc) +- [Démarches relatives aux étrangers résidant en France](https://doc.demarches-simplifiees.fr/listes-des-demarches/demarches-relatives-aux-titres-de-sejour-pour-les-etrangers) +- [Démarches relatives aux médailles d’honneur](https://doc.demarches-simplifiees.fr/listes-des-demarches/demarches-relatives-aux-medailles-dhonneur) +- [Démarche relative à la consultation du domaine](https://demarches.gouv.fr/commencer/consultation-du-domaine) + +## 3. Je ne trouve pas le lien pour ma démarche + +Si l’administration en charge de votre démarche n’a pas choisi d’utiliser demarches.gouv.fr, vous pouvez vous rendre sur [Service-public.fr](https://www.service-public.fr), qui référence la plupart des démarches administratives. + +**Pour être accompagné par un agent ou trouver des lieux d’inclusion numérique**, consultez également : + +- [Service-public.fr](https://www.service-public.fr), site public de renseignement administratif +- [Cartographie de l’inclusion numérique](https://cartographie.societenumerique.gouv.fr/orientation/besoin) permettant d’orienter les usagers vers les lieux d’inclusion numériques +- [France services](https://www.france-services.gouv.fr/demarches-et-services) qui peut vous aider dans l’accomplissement de vos démarches en ligne. + +## 4. Compléter un dossier déjà créé + +Pour compléter un dossier, [connectez-vous sur votre espace demarches.gouv.fr](/users/sign_in) en renseignant l’adresse email et le mot de passe utilisés lors de la création de votre dossier. + +## 5. Je veux déposer un nouveau dossier + +Pour commencer un nouveau dossier sur une démarche déjà réalisée, [connectez-vous](/users/sign_in) et cliquez sur **« Actions »** puis **« Commencer un autre dossier vide »**. + +![Image montrant le lien pour Commencer un autre dossier vide](faq/usager-dossier-actions-menu-start-new.png) + +### Si le bouton "Commencer un autre dossier vide" n’est pas affiché cela signifie que la démarche a été clôturée. + +Pour connaitre le nouveau lien vers la démarche en ligne, nous vous invitons à contacter le service en charge de la démarche. Vous trouverez les informations de contact en bas du formulaire dans la partie **« Poser une questions sur la démarche »** (en cliquant sur le numéro de dossier). + +![Image montrant comment trouver les informations de contact d’une démarche](faq/usager-procedure-close-focus-contact.png) + + diff --git a/doc/faqs/usager/je-veux-contacter-le-service-en-charge-de-ma-demarche.fr.md b/doc/faqs/usager/je-veux-contacter-le-service-en-charge-de-ma-demarche.fr.md new file mode 100644 index 000000000..569ec6bd1 --- /dev/null +++ b/doc/faqs/usager/je-veux-contacter-le-service-en-charge-de-ma-demarche.fr.md @@ -0,0 +1,29 @@ +--- +category: "usager" +subcategory: "dossier_state" +slug: "je-veux-contacter-le-service-en-charge-de-ma-demarche" +locale: "fr" +keywords: "contact service, messagerie dossier, démarche administrative" +title: "Je veux contacter le service en charge de ma démarche" +--- + +# Je veux contacter le service en charge de ma démarche + +**L’équipe de demarches.gouv.fr ne peut pas vous renseigner sur l’avancée du traitement de votre dossier**. + +Pour contacter les services en charge de votre démarche, vous pouvez : + +1. Passer par la **messagerie du dossier** afin de contacter les services compétents + + Cela vous permet de communiquer directement avec l’équipe en charge de votre dossier à travers la plateforme. + + Pour cela, suivez le lien **« Messagerie »** depuis le dossier concerné, et envoyez-leur un message. + + ![Vue de l’interface de messagerie avec le service traitant un dossier](faq/usager-messagerie.png) + +2. Contacter **les services compétents aux contacts renseignés pour la démarche** + + Si des contacts d’adresse email ou numéro de téléphone sont spécifiés pour la démarche que vous suivez, vous pouvez les utiliser pour obtenir des renseignements supplémentaires ou pour toute question spécifique concernant votre dossier. + Ces informations se retrouvent dans le pied de page du dossier. + + ![Coordonnées de contact du service traitant un dossier](faq/usager-footer-contact.png) diff --git a/doc/faqs/usager/je-veux-enregistrer-mon-formulaire-pour-le-reprendre-plus-tard.fr.md b/doc/faqs/usager/je-veux-enregistrer-mon-formulaire-pour-le-reprendre-plus-tard.fr.md new file mode 100644 index 000000000..56c76ecb4 --- /dev/null +++ b/doc/faqs/usager/je-veux-enregistrer-mon-formulaire-pour-le-reprendre-plus-tard.fr.md @@ -0,0 +1,31 @@ +--- +category: "usager" +subcategory: "fill_dossier" +slug: "je-veux-enregistrer-mon-formulaire-pour-le-reprendre-plus-tard" +locale: "fr" +keywords: "enregistrer formulaire, reprendre formulaire, brouillon, France Connect" +title: "Je veux enregistrer mon formulaire pour le reprendre plus tard" +--- + +# Je veux enregistrer mon formulaire pour le reprendre plus tard + +## Comment enregistrer mon formulaire ? + +Lorsque vous remplissez un formulaire sur demarches.gouv.fr, les informations que vous saisissez sont **enregistrées automatiquement**. + +Si vous souhaitez terminer de remplir le formulaire plus tard, **il suffit de fermer la page du formulaire**. Lorsque vous retournerez sur demarches.gouv.fr, vous pourrez reprendre votre démarche là où vous l’avez laissée. + +![Le formulaire est enregistré automatiquement](faq/usager-form-footer-submit.png) + +## Comment reprendre mon formulaire plus tard ? + +Si vous avez déjà commencé à remplir une démarche, vous pouvez retrouver votre dossier déjà rempli. Pour cela : + +1. Connectez-vous à demarches.gouv.fr, utilisez votre identifiant et votre mot de passe, ou bien FranceConnect. + ![La page de connexion de demarches.gouv.fr](faq/sign-in-page.png) + +2. Dans [la liste de vos dossiers](/dossiers), **cliquez sur le dossier en brouillon** que vous souhaitez reprendre. Vous pouvez également faire une **recherche par numéro de dossier, mots-clés ou en filtrant par démarches**. + ![La liste de mes dossiers](faq/usager-dossiers-list.png) + +Vous pouvez alors reprendre votre formulaire là où vous l’aviez laissé en cliquant sur le bouton **« Continuer à remplir »**. Une fois que vous avez rempli le formulaire, cliquez sur **« Déposer le dossier »** pour l’envoyer à l’administration. + diff --git a/doc/faqs/usager/je-veux-savoir-ou-en-est-l-instruction-de-ma-demarche.fr.md b/doc/faqs/usager/je-veux-savoir-ou-en-est-l-instruction-de-ma-demarche.fr.md new file mode 100644 index 000000000..5c43bb470 --- /dev/null +++ b/doc/faqs/usager/je-veux-savoir-ou-en-est-l-instruction-de-ma-demarche.fr.md @@ -0,0 +1,28 @@ +--- +category: "usager" +subcategory: "dossier_state" +slug: "je-veux-savoir-ou-en-est-l-instruction-de-ma-demarche" +locale: "fr" +keywords: "instruction démarche, messagerie, dossier, services compétents, contact" +title: "Je veux savoir où en est l’instruction de ma démarche" +--- + +# Je veux savoir où en est l’instruction de ma démarche + +**L’équipe de demarches.gouv.fr n’est pas en mesure de vous renseigner sur l’avancée du traitement de votre dossier**. En effet, nous nous occupons uniquement de la partie technique de la plateforme. + +Pour contacter les services en charge de votre démarche, vous pouvez : + +1. Passer par la **messagerie du dossier** afin de contacter les services compétents + + Pour cela, il vous suffit de cliquer sur le lien **« Messagerie »** depuis le dossier concerné. + + ![Vue de l’interface de messagerie avec le service traitant un dossier](faq/usager-messagerie.png) + + N’oubliez pas de cliquer sur le bouton **« Envoyer le message »**. + +2. Contacter les **services compétents aux contacts renseignés pour la démarche** + + La messagerie peut être désactivée lorsque le dossier est archivé ou fait l’objet d’une décision finale. Dans ce cas, vous pouvez contacter le service grâce aux informations de contact situées en bas du formulaire. + + ![Coordonnées de contact du service traitant un dossier](faq/usager-footer-contact.png) diff --git a/doc/faqs/usager/modification-de-l-identite-du-demandeur-d-un-dossier.fr.md b/doc/faqs/usager/modification-de-l-identite-du-demandeur-d-un-dossier.fr.md new file mode 100644 index 000000000..8326e8daa --- /dev/null +++ b/doc/faqs/usager/modification-de-l-identite-du-demandeur-d-un-dossier.fr.md @@ -0,0 +1,40 @@ +--- +category: "usager" +subcategory: "fill_dossier" +slug: "modification-de-l-identite-du-demandeur-d-un-dossier" +locale: "fr" +keywords: "modification identité, dossier brouillon, dossier construction, changer identité" +title: "Modification de l’identité du demandeur d’un dossier" +--- + +# Modification de l’identité du demandeur d’un dossier + +En tant qu’usager, vous avez la possibilité de modifier l’identité associée au dossier, lorsqu’il s’agit d’un dossier en **brouillon** mais également lorsque le dossier est en **construction**. + +## Le dossier est en "brouillon" + +Pour modifier votre dossier en brouillon, il suffit de cliquer sur le dossier concerné puis de cliquer sur le bouton _« Mon identité »_ situé à droite de l’écran comme ci-dessous : + +![Image montrant où cliquer pour modifier l’identité d’un dossier en brouillon](faq/usager-edit-identity-brouillon-1.png) + +Vous pourrez ensuite modifier votre identité depuis le bouton _« Modifier l’identité »_ : + +![Image montrant le bouton pour modifier l’identité](faq/usager-edit-identity-brouillon-2.png) + +## Le dossier est en "construction" + +Une fois votre dossier déposé, il devient _en construction_ (tant que l’administration ne traite votre dossier), et vous avez la possibilité de modifier votre identité. + +Pour cela, cliquez sur le bouton _« Modifier le dossier »_ depuis la liste de vos dossiers. + +![Image illustrant où cliquer pour modifier un dossier en construction](faq/usager-edit-identity-construction-1.png) + +Après avoir cliqué sur l’onglet _« Demande »_, suivez le lien _« Modifier l’identité »_ comme ci-dessous : + +![Image montrant où modifier l’identité dans un dossier en construction](faq/usager-edit-identity-construction-2.png) + +## Le dossier a un autre statut + +Si le dossier est en déjà _en instruction_, vous ne pouvez plus modifier son identité. Échangez avec l’administration via la messagerie si nécessaire. + +Information : pour certaines démarches, un dossier déposé ne passe pas par le statut _"en construction"_ et il n’est alors pas possible de modifier l’identité sans contacter l’administration. diff --git a/doc/faqs/usager/mon-dossier-a-ete-depose-par-un-tiers-et-je-souhaite-y-acceder.fr.md b/doc/faqs/usager/mon-dossier-a-ete-depose-par-un-tiers-et-je-souhaite-y-acceder.fr.md new file mode 100644 index 000000000..5c22dd8ee --- /dev/null +++ b/doc/faqs/usager/mon-dossier-a-ete-depose-par-un-tiers-et-je-souhaite-y-acceder.fr.md @@ -0,0 +1,21 @@ +--- +category: "usager" +subcategory: "find_dossier" +slug: "mon-dossier-a-ete-depose-par-un-tiers-et-je-souhaite-y-acceder" +locale: "fr" +keywords: "dossier, tiers, accès dossier, transfert dossier, action dossier" +title: "Mon dossier a été déposé par un tiers et je souhaite y accéder" + +--- + +# Mon dossier a été déposé par un tiers et je souhaite y accéder + +Vous devez **contacter la personne qui a déposé le dossier** et lui demander de vous le transférer. + +Pour cela, la personne doit cliquer sur le bouton **« Action »** (ou _« Autres actions »_), situé à droite du dossier depuis son interface : + +![Image illustration le bouton Autres actions avec le menu de transfert de dossier](faq/usager-dossier-actions-menu-transfer.png) + +Et ensuite cliquer sur **« Transférer le dossier »** en indiquant votre adresse mail. + +![Image illustration l’interface de transfert de dossier vers un autre compte](faq/usager-transfer-dossier.png) diff --git a/doc/faqs/usager/quels-sont-les-navigateurs-supportes.fr.md b/doc/faqs/usager/quels-sont-les-navigateurs-supportes.fr.md new file mode 100644 index 000000000..934150d90 --- /dev/null +++ b/doc/faqs/usager/quels-sont-les-navigateurs-supportes.fr.md @@ -0,0 +1,22 @@ +--- +category: "usager" +subcategory: "dossier_technical_issue" +slug: "quels-sont-les-navigateurs-supportes" +locale: "fr" +keywords: "navigateur, compatibilité, mise à jour" +title: "Quels sont les navigateurs supportés ?" +--- + +# Quels sont les navigateurs supportés ? + +Nous vous conseillons d’utiliser les navigateurs suivants, **idéalement dans leur dernière version disponible**. + +- Firefox (minimum 67) +- Chrome (minimum 79) +- Edge (minimum 79 et sans mode de compatibilité) +- Safari (minimum 12) +- Safari iOS (minimum 12) +- Samsung browser (minimum 12) + +**Mettez toujours à jour votre navigateur** notamment pour être protégé par leurs derniers correctifs de sécurité. + From d05aee0c6709450fcbbc648c616801dd7f9fdadc Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 16 Apr 2024 12:24:39 +0200 Subject: [PATCH 0168/1532] feat(faq): variabilize application name, host, contact_email --- app/controllers/faq_controller.rb | 10 ++++++++- app/services/faqs_loader_service.rb | 21 +++++++++++++++---- ...dministrateurs-sur-une-meme-demarche.fr.md | 2 +- ...ent-obtenir-un-compte-administrateur.fr.md | 2 +- ...-tester-une-demarche-par-un-collegue.fr.md | 4 ++-- ...s-pratiques-pour-tester-une-demarche.fr.md | 10 ++++----- ...firmer-mon-compte-a-chaque-connexion.fr.md | 12 +++++------ ...-n-ont-pas-recu-d-email-d-invitation.fr.md | 4 ++-- ...r-des-demarches-les-bonnes-pratiques.fr.md | 4 ++-- ...s-differentes-categories-de-dossiers.fr.md | 2 +- ...firmer-mon-compte-a-chaque-connexion.fr.md | 12 +++++------ ...x-dossiers-que-je-souhaite-instruire.fr.md | 2 +- .../usager/comment-trouver-ma-demarche.fr.md | 18 +++++++--------- .../erreur-siret-lors-depot-de-dossier.fr.md | 10 ++++----- ...n-dossier-mais-je-ne-le-retrouve-pas.fr.md | 2 +- .../j-ai-un-autre-probleme-technique.fr.md | 2 +- ...une-demande-car-je-n-ai-pas-de-SIRET.fr.md | 2 +- .../usager/je-ne-recois-pas-d-email.fr.md | 2 +- ...ives-aux-installations-de-combustion.fr.md | 2 +- ...re-a-l-epreuve-du-permis-de-conduire.fr.md | 2 +- ...e-obtenir-un-duplicata-cerfa-02-neph.fr.md | 4 ++-- .../je-veux-changer-mon-adresse-email.fr.md | 12 +++++------ .../je-veux-changer-mon-mot-de-passe.fr.md | 4 ++-- ...-le-service-en-charge-de-ma-demarche.fr.md | 2 +- ...rmulaire-pour-le-reprendre-plus-tard.fr.md | 8 +++---- ...-en-est-l-instruction-de-ma-demarche.fr.md | 2 +- 26 files changed, 87 insertions(+), 70 deletions(-) diff --git a/app/controllers/faq_controller.rb b/app/controllers/faq_controller.rb index 6c28f2725..deafe5984 100644 --- a/app/controllers/faq_controller.rb +++ b/app/controllers/faq_controller.rb @@ -16,7 +16,15 @@ class FAQController < ApplicationController private def loader_service - @loader_service ||= FAQsLoaderService.new + @loader_service ||= begin + substitutions = { + application_base_url: Current.application_base_url, + application_name: Current.application_name, + contact_email: Current.contact_email + } + + FAQsLoaderService.new(substitutions) + end end def load_faq_data diff --git a/app/services/faqs_loader_service.rb b/app/services/faqs_loader_service.rb index 19acd97fb..102e843fb 100644 --- a/app/services/faqs_loader_service.rb +++ b/app/services/faqs_loader_service.rb @@ -4,8 +4,12 @@ class FAQsLoaderService PATH = Rails.root.join('doc', 'faqs').freeze ORDER = ['usager', 'instructeur', 'administrateur'].freeze - def initialize - @faqs_by_path ||= Rails.cache.fetch("faqs_data", expires_in: 1.day) do + attr_reader :substitutions + + def initialize(substitutions) + @substitutions = substitutions + + @faqs_by_path ||= Rails.cache.fetch(["faqs_data", substitutions], expires_in: 1.day) do load_faqs end end @@ -13,7 +17,7 @@ class FAQsLoaderService def find(path) file_path = @faqs_by_path.fetch(path).fetch(:file_path) - FrontMatterParser::Parser.parse_file(file_path) + parse_with_substitutions(file_path) end def faqs_for_category(category) @@ -36,7 +40,7 @@ class FAQsLoaderService def load_faqs Dir.glob("#{PATH}/**/*.md").each_with_object({}) do |file_path, faqs_by_path| - parsed = FrontMatterParser::Parser.parse_file(file_path) + parsed = parse_with_substitutions(file_path) front_matter = parsed.front_matter.symbolize_keys faq_data = front_matter.slice(:slug, :title, :category, :subcategory, :locale, :keywords).merge(file_path: file_path) @@ -45,4 +49,13 @@ class FAQsLoaderService faqs_by_path[path] = faq_data end end + + # Substitute all string before front matter parser so metadata are also substituted. + # using standard ruby formatting, ie => `%{my_var} % { my_var: 'value' }` + # We have to escape % chars not used for substitutions, ie. not preceeded by { + def parse_with_substitutions(file_path) + substituted_content = File.read(file_path).gsub(/%(?!{)/, '%%') % substitutions + + FrontMatterParser::Parser.new(:md).call(substituted_content) + end end diff --git a/doc/faqs/administrateur/ajouter-plusieurs-administrateurs-sur-une-meme-demarche.fr.md b/doc/faqs/administrateur/ajouter-plusieurs-administrateurs-sur-une-meme-demarche.fr.md index b9564c3bf..8c2120f3d 100644 --- a/doc/faqs/administrateur/ajouter-plusieurs-administrateurs-sur-une-meme-demarche.fr.md +++ b/doc/faqs/administrateur/ajouter-plusieurs-administrateurs-sur-une-meme-demarche.fr.md @@ -17,7 +17,7 @@ Il est possible d’avoir plusieurs administrateurs sur une même démarche. Cel ## Comment ajouter un autre administrateur sur votre démarche ? -1. Si les personnes en question n’ont pas encore de compte administrateur, **faites une [demande de compte administrateur](https://demarches.gouv.fr/demandes/new)** et attendez que le compte soit approuvé. +1. Si les personnes en question n’ont pas encore de compte administrateur, **faites une [demande de compte administrateur](%{application_base_url}/demandes/new)** et attendez que le compte soit approuvé. 2. Connectez-vous en tant qu’Administrateur et **allez sur le tableau de bord de la démarche**. 3. Cliquez sur l’onglet **« Administrateurs »** de la démarche. 4. **Ajoutez l’adresse email** du nouvel administrateur à la liste. diff --git a/doc/faqs/administrateur/comment-obtenir-un-compte-administrateur.fr.md b/doc/faqs/administrateur/comment-obtenir-un-compte-administrateur.fr.md index dbcc58031..25099ab68 100644 --- a/doc/faqs/administrateur/comment-obtenir-un-compte-administrateur.fr.md +++ b/doc/faqs/administrateur/comment-obtenir-un-compte-administrateur.fr.md @@ -9,7 +9,7 @@ title: "Comment obtenir un compte administrateur ?" # Comment obtenir un compte administrateur ? -Pour obtenir un compte administrateur, il suffit d’en faire la demande en déposant un dossier via le lien suivant : [Demande d'inscription à demarches.gouv.fr](https://demarches.gouv.fr/commencer/demande-d-inscription-a-demarches-simplifiees) +Pour obtenir un compte administrateur, il suffit d’en faire la demande en déposant un dossier via le lien suivant : [Demande d'inscription à %{application_name}](%{application_base_url}/commencer/demande-d-inscription-a-demarches-simplifiees) Le profil administrateur est le seul profil réservé strictement aux agents publics. Nous vous demandons donc de bien vouloir **faire la demande avec votre adresse mail professionnelle**. diff --git a/doc/faqs/administrateur/faire-tester-une-demarche-par-un-collegue.fr.md b/doc/faqs/administrateur/faire-tester-une-demarche-par-un-collegue.fr.md index 4cc2b1523..4afd8de48 100644 --- a/doc/faqs/administrateur/faire-tester-une-demarche-par-un-collegue.fr.md +++ b/doc/faqs/administrateur/faire-tester-une-demarche-par-un-collegue.fr.md @@ -9,7 +9,7 @@ title: "Faire tester une démarche par un collègue" # Faire tester une démarche par un(e) collègue -Si vous êtes administrateur, vous pouvez partager le lien de votre démarche en test à un collègue. Il n’a pas besoin d’être administrateur pour tester le formulaire, mais doit avoir un compte sur demarches.gouv.fr. +Si vous êtes administrateur, vous pouvez partager le lien de votre démarche en test à un collègue. Il n’a pas besoin d’être administrateur pour tester le formulaire, mais doit avoir un compte sur %{application_name}. **Attention : tout dossier déposé, même instruit, sera supprimé une fois la démarche publiée. Ne communiquez pas ce lien à des vrais usagers !** @@ -19,5 +19,5 @@ Si la personne qui teste voit un bandeau avec le message *« La démarche n’e Vérifiez les points suivants : -- **Connexion requise** : Pour accéder à une démarche de test, l’usager doit d’abord être connecté à demarches.gouv.fr. La création d’un compte est donc indispensable. +- **Connexion requise** : Pour accéder à une démarche de test, l’usager doit d’abord être connecté à %{application_name}. La création d’un compte est donc indispensable. - **Vérification du lien** : Assurez-vous que le lien transmis est correct. Un lien erroné conduira à l’affichage du message d’erreur mentionné. diff --git a/doc/faqs/administrateur/guide-de-bonnes-pratiques-pour-tester-une-demarche.fr.md b/doc/faqs/administrateur/guide-de-bonnes-pratiques-pour-tester-une-demarche.fr.md index f94edf36e..6e00f34d6 100644 --- a/doc/faqs/administrateur/guide-de-bonnes-pratiques-pour-tester-une-demarche.fr.md +++ b/doc/faqs/administrateur/guide-de-bonnes-pratiques-pour-tester-une-demarche.fr.md @@ -13,7 +13,7 @@ Tester une démarche est nécessaire avant toute publication. En effet, il s’a - corriger les erreurs de votre formulaire et de le transmettre à votre délégué à la protection des données - vérifier que le processus d’instruction envisagé correspond à vos besoins ainsi que toutes les fonctionnalités associées (emails automatiques, attestations, annotations privées, etc…) -- préparer votre service et vos usagers à l’utilisation de demarches.gouv.fr. +- préparer votre service et vos usagers à l’utilisation de %{application_name}. ## Déroulé du test @@ -28,7 +28,7 @@ Vous pouvez faire tester la partie usager (étape 1) et instructeur (étape 2) p **Vous pouvez effectuer toutes les modifications que vous souhaitez sur votre démarche pendant cette phase de test.** -Bien évidemment, avant de tester la démarche, il faut l’avoir créé. Pour cela, vous pouvez vous aider de [notre guide de la dématérialisation réussie via demarches.gouv.fr](https://456404736-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-L7_aKvpAJdAIEfxHudA%2Fuploads%2FGJm7S7LVjHPKVlMCE36e%2FGuide%20des%20bonnes%20pratiques%20démarches-simplifiees.pdf?alt=media&token=228e63c7-a168-4656-9cda-3f53a10645c2). Vous pouvez également consulter la documentation [Comment créer une nouvelle démarche](https://doc.demarches-simplifiees.fr/tutoriels/tutoriel-administrateur) +Bien évidemment, avant de tester la démarche, il faut l’avoir créé. Pour cela, vous pouvez vous aider de [notre guide de la dématérialisation réussie via %{application_name}](https://456404736-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-L7_aKvpAJdAIEfxHudA%2Fuploads%2FGJm7S7LVjHPKVlMCE36e%2FGuide%20des%20bonnes%20pratiques%20démarches-simplifiees.pdf?alt=media&token=228e63c7-a168-4656-9cda-3f53a10645c2). Vous pouvez également consulter la documentation [Comment créer une nouvelle démarche](https://doc.demarches-simplifiees.fr/tutoriels/tutoriel-administrateur) ## Étape 1 : Déposer un dossier de test côté usager @@ -54,7 +54,7 @@ N’hésitez pas à [transmettre le lien vers la démarche test à vos collègue Vous avez déposé à l’étape précédente un premier dossier. Rendez-vous désormais dans la partie instructeur. -Passez à l’interface instructeur via le lien [https://demarches.gouv.fr/procedures](/procedures) ou en changeant de profil depuis le menu en haut à droite en cliquant sur votre adresse email. +Passez à l’interface instructeur via le lien [%{application_base_url}/procedures](/procedures) ou en changeant de profil depuis le menu en haut à droite en cliquant sur votre adresse email. ![Menu pour passer instructeur](faq/administrateur-profile-switch.png) @@ -76,7 +76,7 @@ Vous pourrez ici tester différents éléments secondaires : - Demande d’**avis externe** (partie instruction) . Pour plus d’information, vous pouvez consulter [notre tutoriel expert invité](https://doc.demarches-simplifiees.fr/tutoriels/tutoriel-expert-invite) - **Vérifiez les e-mails** d’accusé de réception, de passage en instruction, d’acceptation, de refus et de classement sans suite (partie usager) -- Testez la **messagerie du dossier** en envoyant un message à l’usager. Si vous souhaitez anonymiser l’adresse mail des instructeurs dans la messagerie, vous pouvez [nous contacter à l’adresse contact@demarches-simplifiees.fr](mailto:contact@demarches-simplifiees.fr) +- Testez la **messagerie du dossier** en envoyant un message à l’usager. Si vous souhaitez anonymiser l’adresse mail des instructeurs dans la messagerie, vous pouvez [nous contacter à l’adresse %{contact_email}](mailto:%{contact_email}) - Si l’**attestation automatique d’acceptation** et la partie annotations privées ont été paramétrées, vérifiez qu’il n’y a pas d’erreur En cas d’erreur et/ou en fonction des retours de vos collègues suite à la phase de test, vous pouvez modifier la démarche depuis votre profil administrateur. @@ -99,6 +99,6 @@ Après avoir minutieusement testé votre démarche, il est temps de la rendre ac 5. **Finalisez la publication** en cliquant sur le bouton **« Publier »** situé en bas de l’écran. -Félicitations, vous êtes désormais administrateur d’une démarche publiée sur demarches.gouv.fr ! +Félicitations, vous êtes désormais administrateur d’une démarche publiée sur %{application_name} ! ⚠️ **N’oubliez pas de diffuser le lien** de votre démarche auprès de vos usagers, accessible depuis votre interface administrateur. Ce lien est différent du lien de la démarche test. diff --git a/doc/faqs/administrateur/je-dois-confirmer-mon-compte-a-chaque-connexion.fr.md b/doc/faqs/administrateur/je-dois-confirmer-mon-compte-a-chaque-connexion.fr.md index 394e644c7..cb1232330 100644 --- a/doc/faqs/administrateur/je-dois-confirmer-mon-compte-a-chaque-connexion.fr.md +++ b/doc/faqs/administrateur/je-dois-confirmer-mon-compte-a-chaque-connexion.fr.md @@ -9,7 +9,7 @@ title: "Je dois confirmer mon compte à chaque connexion" # Je dois confirmer mon compte à chaque connexion -Afin de sécuriser votre compte, demarches.gouv.fr vous demande tous les mois d’authentifier votre navigateur. Il vous faut alors cliquer sur le lien de confirmation envoyé par email. +Afin de sécuriser votre compte, %{application_name} vous demande tous les mois d’authentifier votre navigateur. Il vous faut alors cliquer sur le lien de confirmation envoyé par email. Ce processus peut parfois vous être demandé à chaque connexion. Nous avons identifié deux raisons possibles : @@ -20,17 +20,17 @@ Ce processus peut parfois vous être demandé à chaque connexion. Nous avons id ## Mauvaise configuration de votre navigateur -Ce problème apparaît lorsque votre navigateur est configuré de manière très sécurisée et efface les données provenant de demarches.gouv.fr à chaque fermeture. +Ce problème apparaît lorsque votre navigateur est configuré de manière très sécurisée et efface les données provenant de %{application_name} à chaque fermeture. -**Solution :** Pour corriger ce problème, configurez votre navigateur pour accepter les cookies du domaine demarches.gouv.fr : +**Solution :** Pour corriger ce problème, configurez votre navigateur pour accepter les cookies du domaine %{application_name} : - Pour Firefox [https://support.mozilla.org/fr/kb/sites-disent-cookies-bloques-les-debloquer](https://support.mozilla.org/fr/kb/sites-disent-cookies-bloques-les-debloquer), - Pour Chrome [https://support.google.com/accounts/answer/61416?co=GENIE.Platform%3DDesktop&hl=fr](https://support.google.com/accounts/answer/61416?co=GENIE.Platform%3DDesktop&hl=fr). -Si vous n’avez pas les droits suffisants pour modifier cette configuration, contactez votre support informatique et mettez-nous en copie : contact@demarches.gouv.fr +Si vous n’avez pas les droits suffisants pour modifier cette configuration, contactez votre support informatique et mettez-nous en copie : %{contact_email} ## Le navigateur authentifié n’est pas celui que vous utilisez -Il est possible que lorsque vous cliquez sur le lien de l’email, celui-ci ouvre le navigateur par défaut, souvent Internet Explorer, alors que vous utilisez un autre navigateur, comme Firefox, pour accéder à demarches.gouv.fr. Le lendemain, lorsque vous ouvrez Firefox, le navigateur n’est toujours pas authentifié et vous devez à nouveau cliquer sur le lien de connexion. +Il est possible que lorsque vous cliquez sur le lien de l’email, celui-ci ouvre le navigateur par défaut, souvent Internet Explorer, alors que vous utilisez un autre navigateur, comme Firefox, pour accéder à %{application_name}. Le lendemain, lorsque vous ouvrez Firefox, le navigateur n’est toujours pas authentifié et vous devez à nouveau cliquer sur le lien de connexion. -**Solution :** Copiez le lien de l’email et ouvrez-le avec le navigateur que vous utilisez habituellement pour aller sur demarches.gouv.fr. +**Solution :** Copiez le lien de l’email et ouvrez-le avec le navigateur que vous utilisez habituellement pour aller sur %{application_name}. diff --git a/doc/faqs/administrateur/les-instructeurs-n-ont-pas-recu-d-email-d-invitation.fr.md b/doc/faqs/administrateur/les-instructeurs-n-ont-pas-recu-d-email-d-invitation.fr.md index 063eed6af..e7bd80e21 100644 --- a/doc/faqs/administrateur/les-instructeurs-n-ont-pas-recu-d-email-d-invitation.fr.md +++ b/doc/faqs/administrateur/les-instructeurs-n-ont-pas-recu-d-email-d-invitation.fr.md @@ -10,11 +10,11 @@ title: "Les instructeurs n’ont pas reçu d’email d’invitation" # Les instructeurs n’ont pas reçu d’email d’invitation En tant qu’administrateur, si après avoir nommé des instructeurs sur votre -démarche ces derniers ne reçoivent pas d’email d’invitation à se connecter à demarches.gouv.fr, +démarche ces derniers ne reçoivent pas d’email d’invitation à se connecter à %{application_name}, alors vous vous trouvez peut-être dans la situation suivante : - Vous avez fait une erreur dans la saisie des adresses email en nommant les instructeurs. - Les instructeurs sont déjà nommés sur une autre démarche. Leur compte étant déjà validé, ils ne reçoivent plus de nouvel email. -- Vous ne vous trouvez dans aucune des situations mentionnées ? Nous vous prions de nous [contacter par email](mailto:contact@demarches-simplifiees.fr). +- Vous ne vous trouvez dans aucune des situations mentionnées ? Nous vous prions de nous [contacter par email](mailto:%{contact_email}). diff --git a/doc/faqs/administrateur/nommer-un-nouvel-administrateur-des-demarches-les-bonnes-pratiques.fr.md b/doc/faqs/administrateur/nommer-un-nouvel-administrateur-des-demarches-les-bonnes-pratiques.fr.md index 73f6def6e..4e4ee3c25 100644 --- a/doc/faqs/administrateur/nommer-un-nouvel-administrateur-des-demarches-les-bonnes-pratiques.fr.md +++ b/doc/faqs/administrateur/nommer-un-nouvel-administrateur-des-demarches-les-bonnes-pratiques.fr.md @@ -11,10 +11,10 @@ title: "Nommer un nouvel administrateur des démarches" Pour assurer une transition fluide et maintenir l’intégrité de vos démarches, voici quelques étapes à suivre lors de la nomination d’un nouvel administrateur : -1. **Identifier le nouvel administrateur** au sein de votre service qui prendra en charge des démarches. Si cette personne n’a pas encore de compte administrateur, elle peut en demander un via le lien : [Demande d’inscription à demarches.gouv.fr](https://demarches.gouv.fr/demandes/new). +1. **Identifier le nouvel administrateur** au sein de votre service qui prendra en charge des démarches. Si cette personne n’a pas encore de compte administrateur, elle peut en demander un via le lien : [Demande d’inscription à %{application_name}](%{application_base_url}/demandes/new). 2. **Ajouter ce nouvel administrateur à toutes vos démarches**. Consultez notre tutoriel dédié pour vous guider dans ce processus : [Plusieurs administrateurs sur une même démarche](/faq/administrateur/ajouter-plusieurs-administrateurs-sur-une-meme-demarche). -3. **Demander la suppression de votre compte** à [contact@demarches-simplifiees.fr](mailto:contact@demarches-simplifiees.fr), à condition qu’aucun dossier ou démarche ne soit rattaché à votre compte. Si nécessaire, pour votre nouvelle fonction, demandez l’ouverture d’un nouveau compte administrateur avec notre nouvel email via le formulaire : [Demande d’inscription à demarches.gouv.fr](https://demarches.gouv.fr/demandes/new). +3. **Demander la suppression de votre compte** à [%{contact_email}](mailto:%{contact_email}), à condition qu’aucun dossier ou démarche ne soit rattaché à votre compte. Si nécessaire, pour votre nouvelle fonction, demandez l’ouverture d’un nouveau compte administrateur avec notre nouvel email via le formulaire : [Demande d’inscription à %{application_name}](%{application_base_url}/demandes/new). Ces étapes assurent une gestion continue et efficace des démarches, tout en facilitant le passage de responsabilités entre administrateurs. diff --git a/doc/faqs/instructeur/a-quoi-correspondent-les-differentes-categories-de-dossiers.fr.md b/doc/faqs/instructeur/a-quoi-correspondent-les-differentes-categories-de-dossiers.fr.md index 4a7068258..45d9efaa4 100644 --- a/doc/faqs/instructeur/a-quoi-correspondent-les-differentes-categories-de-dossiers.fr.md +++ b/doc/faqs/instructeur/a-quoi-correspondent-les-differentes-categories-de-dossiers.fr.md @@ -16,7 +16,7 @@ Pour chaque démarche, les dossiers sont répartis dans plusieurs onglets : - **Traités** : regroupe les dossiers dont le statut est *accepté*, *refusé* ou *classé sans suite* et qui n’ont pas été archivés. - **Supprimé récemment** : regroupe l’ensemble des dossiers non archivés, terminés et supprimés par les instructeurs de la démarche. - **Expirant** : les dossiers *en construction* ou *traités*, (archivés ou non), dont le délai d’expiration approche (moins d’un mois). À l’issue de ce délai, le dossier sera supprimé de la plateforme. -- **Archivés** : regroupe l’ensemble des dossiers archivés. Les instructeurs ne peuvent plus y répondre, et les demandeurs ne peuvent plus les modifier. La messagerie est désactivée. Ces dossiers seront supprimés lorsque leur délai de conservation sur demarches.gouv.fr sera expiré. +- **Archivés** : regroupe l’ensemble des dossiers archivés. Les instructeurs ne peuvent plus y répondre, et les demandeurs ne peuvent plus les modifier. La messagerie est désactivée. Ces dossiers seront supprimés lorsque leur délai de conservation sur %{application_name} sera expiré. Notez qu’**un dossier peut être suivi par plusieurs instructeurs**. Vous pourrez donc retrouver les dossiers que vous ne suivez pas dans l’onglet **au total**. La somme des onglets *à suivre*, *suivis* et *traités* n’est donc pas nécessairement égale au nombre affiché *au total*. diff --git a/doc/faqs/instructeur/je-dois-confirmer-mon-compte-a-chaque-connexion.fr.md b/doc/faqs/instructeur/je-dois-confirmer-mon-compte-a-chaque-connexion.fr.md index 2fc7b044c..cfcc96f2f 100644 --- a/doc/faqs/instructeur/je-dois-confirmer-mon-compte-a-chaque-connexion.fr.md +++ b/doc/faqs/instructeur/je-dois-confirmer-mon-compte-a-chaque-connexion.fr.md @@ -9,7 +9,7 @@ title: "Je dois confirmer mon compte à chaque connexion" # Je dois confirmer mon compte à chaque connexion -Afin de sécuriser votre compte, demarches.gouv.fr vous demande tous les mois d’authentifier votre navigateur. Il vous faut alors cliquer sur le lien de confirmation envoyé par email. +Afin de sécuriser votre compte, %{application_name} vous demande tous les mois d’authentifier votre navigateur. Il vous faut alors cliquer sur le lien de confirmation envoyé par email. Ce processus peut parfois vous être demandé à chaque connexion, nous avons identifié deux raisons possibles : @@ -20,17 +20,17 @@ Finalement, le lien reçu par email est valide une semaine et peut-être utilis ## Mauvaise configuration de notre navigateur -Ce problème apparaît lorsque votre navigateur est configuré de manière très sécurisée et efface les données provenant de demarches.gouv.fr à chaque fermeture. +Ce problème apparaît lorsque votre navigateur est configuré de manière très sécurisée et efface les données provenant de %{application_name} à chaque fermeture. -Solution : Pour corriger ce problème, configurez votre navigateur pour accepter les cookies du domaine demarches.gouv.fr : +Solution : Pour corriger ce problème, configurez votre navigateur pour accepter les cookies du domaine %{application_name} : - pour Firefox [https://support.mozilla.org/fr/kb/sites-disent-cookies-bloques-les-debloquer](https://support.mozilla.org/fr/kb/sites-disent-cookies-bloques-les-debloquer), - pour Chrome [https://support.google.com/accounts/answer/61416?co=GENIE.Platform%3DDesktop&hl=fr](https://support.google.com/accounts/answer/61416?co=GENIE.Platform%3DDesktop&hl=fr). -Si vous n’avez pas les droits suffisant pour modifier cette configuration, contactez votre support informatique en nous mettant en copie : contact@demarches-simplifiees.fr +Si vous n’avez pas les droits suffisant pour modifier cette configuration, contactez votre support informatique en nous mettant en copie : %{contact_email} ## Le navigateur authentifié n’est pas celui que vous utilisez -Il est possible que lorsque vous cliquez sur le lien de l’email, celui-ci ouvre le navigateur par défaut, la plupart du temps Internet Explorer. Or, le navigateur que vous utilisez pour aller sur demarches.gouv.fr est, par exemple, Firefox. Donc, le lendemain, lorsque vous ouvrez Firefox, le navigateur n’est toujours pas authentifié et vous devez à nouveau cliquer sur le lien de connexion. +Il est possible que lorsque vous cliquez sur le lien de l’email, celui-ci ouvre le navigateur par défaut, la plupart du temps Internet Explorer. Or, le navigateur que vous utilisez pour aller sur %{application_name} est, par exemple, Firefox. Donc, le lendemain, lorsque vous ouvrez Firefox, le navigateur n’est toujours pas authentifié et vous devez à nouveau cliquer sur le lien de connexion. -Solution : copiez le lien de l’email et ouvrez-le avec le navigateur que vous utilisez habituellement pour aller sur demarches.gouv.fr. +Solution : copiez le lien de l’email et ouvrez-le avec le navigateur que vous utilisez habituellement pour aller sur %{application_name}. diff --git a/doc/faqs/instructeur/je-n-arrive-pas-a-acceder-aux-dossiers-que-je-souhaite-instruire.fr.md b/doc/faqs/instructeur/je-n-arrive-pas-a-acceder-aux-dossiers-que-je-souhaite-instruire.fr.md index 3db88ca52..ada17f5ab 100644 --- a/doc/faqs/instructeur/je-n-arrive-pas-a-acceder-aux-dossiers-que-je-souhaite-instruire.fr.md +++ b/doc/faqs/instructeur/je-n-arrive-pas-a-acceder-aux-dossiers-que-je-souhaite-instruire.fr.md @@ -17,7 +17,7 @@ lui envoyer un email pour lui demander l’accès. ## Vous ne savez pas qui est l’administrateur de la démarche ? -Si c’est le cas, envoyez un email à [contact@demarches.gouv.fr](mailto:contact@demarches.gouv.fr), +Si c’est le cas, envoyez un email à [%{contact_email}](mailto:%{contact_email}), en décrivant précisément la démarche à laquelle vous voulez être affecté (intitulé, organisation, région, ou département…) afin que nous puissions retrouver diff --git a/doc/faqs/usager/comment-trouver-ma-demarche.fr.md b/doc/faqs/usager/comment-trouver-ma-demarche.fr.md index 1f70bb682..49fff79ae 100644 --- a/doc/faqs/usager/comment-trouver-ma-demarche.fr.md +++ b/doc/faqs/usager/comment-trouver-ma-demarche.fr.md @@ -11,20 +11,16 @@ title: "Comment trouver ma démarche ?" ## 1. Trouver le lien de votre démarche -Pour déposer un dossier sur demarches.gouv.fr, il est nécessaire de disposer du lien de la démarche qui vous intéresse. Il ressemble à un lien de cette forme : +Pour déposer un dossier sur %{application_name}, il est nécessaire de disposer du lien de la démarche qui vous intéresse. Il ressemble à un lien de cette forme : - https://demarches.gouv.fr/commencer/xxxxxxxxxxxxxx - -ou - - https://www.demarches-simplifiees.fr/commencer/xxxxxxxxxxxxxx + %{application_base_url}/commencer/xxxxxxxxxxxxxx Ce lien vous est communiqué par l’administration compétente pour votre démarche – généralement sur son site internet, ou par email. ## 2. Les démarches les plus courantes -Vous trouverez ci-dessous la liste des démarches les plus courantes dématérialisées sur demarches.gouv.fr : +Vous trouverez ci-dessous la liste des démarches les plus courantes dématérialisées sur %{application_name} : - [Démarches relatives au permis de conduire](https://doc.demarches-simplifiees.fr/listes-des-demarches/demarches-relatives-au-permis-de-conduire) - [Démarches relatives au transporteur](https://doc.demarches-simplifiees.fr/listes-des-demarches/demarches-relatives-au-transporteur) @@ -32,13 +28,13 @@ Vous trouverez ci-dessous la liste des démarches les plus courantes dématéria - [Démarches relatives aux cartes professionnelles de chauffeurs de voiture de tourisme (VTC)](https://doc.demarches-simplifiees.fr/listes-des-demarches/demarches-relatives-aux-cartes-professionnelles-de-chauffeurs-de-voiture-de-tourisme-vtc) - [Démarches relatives aux étrangers résidant en France](https://doc.demarches-simplifiees.fr/listes-des-demarches/demarches-relatives-aux-titres-de-sejour-pour-les-etrangers) - [Démarches relatives aux médailles d’honneur](https://doc.demarches-simplifiees.fr/listes-des-demarches/demarches-relatives-aux-medailles-dhonneur) -- [Démarche relative à la consultation du domaine](https://demarches.gouv.fr/commencer/consultation-du-domaine) +- [Démarche relative à la consultation du domaine](%{application_base_url}/commencer/consultation-du-domaine) ## 3. Je ne trouve pas le lien pour ma démarche -Si l’administration en charge de votre démarche n’a pas choisi d’utiliser demarches.gouv.fr, vous pouvez vous rendre sur [Service-public.fr](https://www.service-public.fr), qui référence la plupart des démarches administratives. +Si l’administration en charge de votre démarche n’a pas choisi d’utiliser %{application_name}, vous pouvez vous rendre sur [Service-public.fr](https://www.service-public.fr), qui référence la plupart des démarches administratives. -**Pour être accompagné par un agent ou trouver des lieux d’inclusion numérique**, consultez également : +**Pour être accompagné par un agent ou trouver des lieux d’inclusion numérique**, consultez également : - [Service-public.fr](https://www.service-public.fr), site public de renseignement administratif - [Cartographie de l’inclusion numérique](https://cartographie.societenumerique.gouv.fr/orientation/besoin) permettant d’orienter les usagers vers les lieux d’inclusion numériques @@ -46,7 +42,7 @@ Si l’administration en charge de votre démarche n’a pas choisi d’utiliser ## 4. Compléter un dossier déjà créé -Pour compléter un dossier, [connectez-vous sur votre espace demarches.gouv.fr](/users/sign_in) en renseignant l’adresse email et le mot de passe utilisés lors de la création de votre dossier. +Pour compléter un dossier, [connectez-vous sur votre espace %{application_name}](/users/sign_in) en renseignant l’adresse email et le mot de passe utilisés lors de la création de votre dossier. ## 5. Je veux déposer un nouveau dossier diff --git a/doc/faqs/usager/erreur-siret-lors-depot-de-dossier.fr.md b/doc/faqs/usager/erreur-siret-lors-depot-de-dossier.fr.md index d14b3af1e..dbd9d0bcb 100644 --- a/doc/faqs/usager/erreur-siret-lors-depot-de-dossier.fr.md +++ b/doc/faqs/usager/erreur-siret-lors-depot-de-dossier.fr.md @@ -1,20 +1,20 @@ --- -title: "Erreur SIRET lors d’un dépôt de dossier sur demarches.gouv.fr" +title: "Erreur SIRET lors d’un dépôt de dossier sur %{application_name}" category: usager subcategory: dossier_technical_issue -slug: "erreur-siret-lors-d-un-depot-de-dossier-sur-demarches-gouv-fr" +slug: "erreur-siret-lors-d-un-depot-de-dossier" locale: "fr" keywords: "erreur SIRET, dépôt de dossier, numéro SIRET, identification entreprise, URSSAF, INSEE, entreprise.data.gouv.fr" --- -# Erreur SIRET lors d’un dépôt de dossier sur demarches.gouv.fr +# Erreur SIRET lors d’un dépôt de dossier sur %{application_name} -Cet article s’adresse exclusivement aux utilisateurs de demarches.gouv.fr, +Cet article s’adresse exclusivement aux utilisateurs de %{application_name}, rencontrant un problème relatif à l’identification par numéro de SIRET lors d’un dépôt de dossiers. Si votre problème n’est pas relatif à l’utilisation de la plateforme -demarches.gouv.fr, vous pouvez consulter la page suivante : +%{application_name}, vous pouvez consulter la page suivante : [Service-public.fr pour signaler un problème](https://www.service-public.fr/professionnels-entreprises/vosdroits/R17969/signaler-un-probleme) Si vous rencontrez le message « Erreur SIRET » lorsque vous identifiez votre diff --git a/doc/faqs/usager/j-ai-depose-moi-meme-mon-dossier-mais-je-ne-le-retrouve-pas.fr.md b/doc/faqs/usager/j-ai-depose-moi-meme-mon-dossier-mais-je-ne-le-retrouve-pas.fr.md index 6a5e304ad..6cb71d7ab 100644 --- a/doc/faqs/usager/j-ai-depose-moi-meme-mon-dossier-mais-je-ne-le-retrouve-pas.fr.md +++ b/doc/faqs/usager/j-ai-depose-moi-meme-mon-dossier-mais-je-ne-le-retrouve-pas.fr.md @@ -15,7 +15,7 @@ Il se peut que vous ayez déposé votre dossier en utilisant une autre adresse e Si c'est le cas, vous avez 3 solutions pour le retrouver : - Recherchez parmi les emails reçus lors de la création de votre compte ou lors -du dépôt de votre dossier sur demarches.gouv.fr. L'adresse email associée à +du dépôt de votre dossier sur %{application_name}. L'adresse email associée à votre dossier est mentionnée dans l'entête de ces emails. - Consultez le corps de l'attestation de dépôt du dossier, où l'adresse email utilisée est également indiquée. diff --git a/doc/faqs/usager/j-ai-un-autre-probleme-technique.fr.md b/doc/faqs/usager/j-ai-un-autre-probleme-technique.fr.md index 9dc3b1554..83b89de78 100644 --- a/doc/faqs/usager/j-ai-un-autre-probleme-technique.fr.md +++ b/doc/faqs/usager/j-ai-un-autre-probleme-technique.fr.md @@ -16,7 +16,7 @@ Par exemple, un problème pour vous connecter ; ou une erreur au moment d’enre Utilisez notre [page de contact](/contact) pour écrire au support technique. Nous ferons de notre mieux pour vous répondre le plus rapidement possible. -**Pour être clair : le support technique ne s’occupe que des questions techniques liées à l’utilisation de demarches.gouv.fr**. +**Pour être clair : le support technique ne s’occupe que des questions techniques liées à l’utilisation de %{application_name}**. Il ne pourra pas répondre aux questions concernant votre dossier ou le traitement de votre demande. Pour une question administrative, contactez plutôt l’administration en charge de votre dossier, diff --git a/doc/faqs/usager/je-ne-peux-pas-faire-une-demande-car-je-n-ai-pas-de-SIRET.fr.md b/doc/faqs/usager/je-ne-peux-pas-faire-une-demande-car-je-n-ai-pas-de-SIRET.fr.md index 6a9e29681..344896e6d 100644 --- a/doc/faqs/usager/je-ne-peux-pas-faire-une-demande-car-je-n-ai-pas-de-SIRET.fr.md +++ b/doc/faqs/usager/je-ne-peux-pas-faire-une-demande-car-je-n-ai-pas-de-SIRET.fr.md @@ -17,7 +17,7 @@ vous devriez alors le trouver ainsi que le numéro SIRET associé. ## Si vous êtes une association Vous avez sans doute un numéro au Registre National des Associations, mais pas forcément de numéro SIRET. -Il n’est pour l’instant malheureusement pas possible d’utiliser demarches.gouv.fr avec uniquement un numéro de RNA. +Il n’est pour l’instant malheureusement pas possible d’utiliser %{application_name} avec uniquement un numéro de RNA. La demande d’un numéro de SIRET à l’INSEE peut se faire assez simplement par email, en envoyant un message à *sirene-associations@insee.fr*. La procédure est décrite en détail sur cette page, avec un exemple de message-type : [https://www.service-public.fr/associations/vosdroits/F1926](https://www.service-public.fr/associations/vosdroits/F1926) diff --git a/doc/faqs/usager/je-ne-recois-pas-d-email.fr.md b/doc/faqs/usager/je-ne-recois-pas-d-email.fr.md index 2262875c4..f1f3cf20b 100644 --- a/doc/faqs/usager/je-ne-recois-pas-d-email.fr.md +++ b/doc/faqs/usager/je-ne-recois-pas-d-email.fr.md @@ -13,5 +13,5 @@ Si vous ne recevez pas d’email, vous vous trouvez peut-être dans la situation - **Le mail est arrivé dans vos courriers indésirables.** Avez-vous vérifié dedans ? - **Votre compte est associé à une autre adresse email.** Avez-vous bien vérifié qu'il s'agit de la bonne adresse mail de dépôt de dossier ? - **Vous avez fait une erreur dans la saisie de votre adresse email.** Vous pouvez [créer à nouveau un compte](/users/sign_up), avec la bonne adresse. -- **Vous utilisez un outil de gestion des spams** (type MailInBlack) qui empêche la réception des emails. Il faut donc autoriser la réception des emails depuis demarches.gouv.fr. +- **Vous utilisez un outil de gestion des spams** (type MailInBlack) qui empêche la réception des emails. Il faut donc autoriser la réception des emails depuis %{application_name}. - **Vous ne vous trouvez dans aucune des situations mentionnées** auquel cas nous vous prions de [nous contacter par email](/contact). diff --git a/doc/faqs/usager/je-souhaite-declarer-les-informations-relatives-aux-installations-de-combustion.fr.md b/doc/faqs/usager/je-souhaite-declarer-les-informations-relatives-aux-installations-de-combustion.fr.md index 8219a67cf..9c68102df 100644 --- a/doc/faqs/usager/je-souhaite-declarer-les-informations-relatives-aux-installations-de-combustion.fr.md +++ b/doc/faqs/usager/je-souhaite-declarer-les-informations-relatives-aux-installations-de-combustion.fr.md @@ -13,4 +13,4 @@ Dans le cadre de l'arrêté du 2 janvier 2019 - JO du 18 janvier 2019 - précisa les modalités de recueil de données relatives aux installations de combustion moyennes, vous pouvez remplir la démarche suivante : -[https://demarches.gouv.fr/commencer/installations-de-combustion-moyennes-mcp-recueil-d](/commencer/installations-de-combustion-moyennes-mcp-recueil-d) +[%{application_base_url}/commencer/installations-de-combustion-moyennes-mcp-recueil-d](/commencer/installations-de-combustion-moyennes-mcp-recueil-d) diff --git a/doc/faqs/usager/je-souhaite-m-inscrire-a-l-epreuve-du-permis-de-conduire.fr.md b/doc/faqs/usager/je-souhaite-m-inscrire-a-l-epreuve-du-permis-de-conduire.fr.md index dcdb584f9..3e3befc7c 100644 --- a/doc/faqs/usager/je-souhaite-m-inscrire-a-l-epreuve-du-permis-de-conduire.fr.md +++ b/doc/faqs/usager/je-souhaite-m-inscrire-a-l-epreuve-du-permis-de-conduire.fr.md @@ -9,7 +9,7 @@ title: "Je souhaite m’inscrire à l’épreuve du permis de conduire" # Je souhaite m’inscrire à l’épreuve du permis de conduire -Pour vous inscrire à l’épreuve pratique du permis de conduire, rendez-vous sur la page dédiée où vous trouverez les départements qui permettent d’utiliser demarches.gouv.fr pour vous inscrire. +Pour vous inscrire à l’épreuve pratique du permis de conduire, rendez-vous sur la page dédiée où vous trouverez les départements qui permettent d’utiliser %{application_name} pour vous inscrire. Par ailleurs, comme chaque administration choisit d’utiliser cette plateforme ou non, il n’est pas obligatoire que la démarche recherchée soit dématérialisée sur notre site. diff --git a/doc/faqs/usager/je-souhaite-obtenir-un-duplicata-cerfa-02-neph.fr.md b/doc/faqs/usager/je-souhaite-obtenir-un-duplicata-cerfa-02-neph.fr.md index 3e72746a3..71f79a708 100644 --- a/doc/faqs/usager/je-souhaite-obtenir-un-duplicata-cerfa-02-neph.fr.md +++ b/doc/faqs/usager/je-souhaite-obtenir-un-duplicata-cerfa-02-neph.fr.md @@ -9,11 +9,11 @@ title: "Je souhaite obtenir un duplicata CERFA 02 - NEPH" # Je souhaite obtenir un duplicata CERFA 02 - NEPH -Pour obtenir un duplicata du CERFA 02, rendez-vous sur la page dédiée où vous trouverez les départements qui permettent d’utiliser demarches.gouv.fr pour réactiver son numéro NEPH. +Pour obtenir un duplicata du CERFA 02, rendez-vous sur la page dédiée où vous trouverez les départements qui permettent d’utiliser %{application_name} pour réactiver son numéro NEPH. Pour savoir comment remplir votre démarche, vous pouvez consulter le tutoriel usager. -Comme chaque administration choisit d’utiliser cette plateforme ou non, **il n’est pas obligatoire que la démarche recherchée soit dématérialisée sur demarches.gouv.fr**. +Comme chaque administration choisit d’utiliser cette plateforme ou non, **il n’est pas obligatoire que la démarche recherchée soit dématérialisée sur %{application_name}**. En ce sens, nous vous invitons à contacter votre préfecture ou à consulter leur site internet. *Comme chaque administration choisit d’utiliser cette plateforme ou non, il n’est pas obligatoire que la démarche recherchée soit dématérialisée sur notre site.* diff --git a/doc/faqs/usager/je-veux-changer-mon-adresse-email.fr.md b/doc/faqs/usager/je-veux-changer-mon-adresse-email.fr.md index eb0ab61b9..ba4e68ed3 100644 --- a/doc/faqs/usager/je-veux-changer-mon-adresse-email.fr.md +++ b/doc/faqs/usager/je-veux-changer-mon-adresse-email.fr.md @@ -10,21 +10,21 @@ title: "Je veux changer mon adresse email" # Je veux changer mon adresse email -Si vous disposez d’un compte usager sur demarches.gouv.fr, il est possible de changer l’adresse email associée à celui-ci. +Si vous disposez d’un compte usager sur %{application_name}, il est possible de changer l’adresse email associée à celui-ci. -**Attention** : pour des raisons de sécurité, les comptes instructeur et administrateur sur demarches.gouv.fr doivent nous contacter à contact@demarches.gouv.fr pour demander ce changement. +**Attention** : pour des raisons de sécurité, les comptes instructeur et administrateur sur %{application_name} doivent nous contacter à %{contact_email} pour demander ce changement. -Cette adresse correspond à l’identifiant avec lequel vous vous connectez à demarches.gouv.fr. C’est également à cette adresse que nous envoyons les messages concernant l’avancement de votre dossier. +Cette adresse correspond à l’identifiant avec lequel vous vous connectez à %{application_name}. C’est également à cette adresse que nous envoyons les messages concernant l’avancement de votre dossier. ## Changer mon adresse email Pour changer l’adresse email associée à votre compte, suivez les étapes suivantes : -1. [Connectez-vous](/users/sign_in) à votre compte sur demarches.gouv.fr ; +1. [Connectez-vous](/users/sign_in) à votre compte sur %{application_name} ; 2. Cliquez sur le menu contenant votre adresse email en haut à droite de la page, puis sur _« Voir mon profil »_, ou [suivez directement ce lien si vous êtes déjà connecté(e)](/profil). ![Menu Usager avec lien Voir mon profil](faq/usager-dropdown.png) -3. Dans l’encadré _« Coordonnées »_, renseignez la nouvelle adresse email que vous souhaitez utiliser. Puis cliquez sur _« Changer mon adresse »_. **Attention** : Cette adresse ne doit pas être déjà utilisée par un autre compte sur demarches.gouv.fr. +3. Dans l’encadré _« Coordonnées »_, renseignez la nouvelle adresse email que vous souhaitez utiliser. Puis cliquez sur _« Changer mon adresse »_. **Attention** : Cette adresse ne doit pas être déjà utilisée par un autre compte sur %{application_name}. ![Section Coordonées avec formulaire de modification d’email](faq/usager-edit-email.png) 4. Ouvrez la boîte email de votre nouvelle adresse, et cliquez sur le lien de confirmation que nous vous avons envoyé.
@@ -32,7 +32,7 @@ Pour changer l’adresse email associée à votre compte, suivez les étapes sui ## Si l’adresse est déjà utilisée par un autre compte -La nouvelle adresse email ne doit pas être déjà utilisée par un compte existant sur demarches.gouv.fr. +La nouvelle adresse email ne doit pas être déjà utilisée par un compte existant sur %{application_name}. **Si la nouvelle adresse est déjà utilisée, vous recevrez un email vous informant que le changement d’adresse ne peut pas être pris en compte.** diff --git a/doc/faqs/usager/je-veux-changer-mon-mot-de-passe.fr.md b/doc/faqs/usager/je-veux-changer-mon-mot-de-passe.fr.md index e36837714..0688d7cc9 100644 --- a/doc/faqs/usager/je-veux-changer-mon-mot-de-passe.fr.md +++ b/doc/faqs/usager/je-veux-changer-mon-mot-de-passe.fr.md @@ -14,7 +14,7 @@ Si vous souhaitez modifier le mot de passe de votre compte, vous pouvez demander Pour cela : 1. Ouvrez la page de [réinitialisation de mot de passe](/users/password/new) -2. Indiquez l’adresse email de votre compte demarches.gouv.fr +2. Indiquez l’adresse email de votre compte %{application_name} 3. Vous recevrez par email un lien pour réinitialiser votre mot de passe. 4. Cliquez sur ce lien, et rentrez le nouveau mot de passe que vous souhaitez utiliser. @@ -24,4 +24,4 @@ Vérifiez que le message ne se trouve pas dans les spams ou indésirables. L’email peut mettre quelques minutes avant que vous le receviez. Réitérez la demande éventuellement. Si ce n’est pas le cas, vous pouvez nous contacter par [notre formulaire de contact](/contact) -ou par email à l’adresse *contact@demarches-simplifiees.fr*. +ou par email à l’adresse *%{contact_email}*. diff --git a/doc/faqs/usager/je-veux-contacter-le-service-en-charge-de-ma-demarche.fr.md b/doc/faqs/usager/je-veux-contacter-le-service-en-charge-de-ma-demarche.fr.md index 569ec6bd1..c44ed5bb0 100644 --- a/doc/faqs/usager/je-veux-contacter-le-service-en-charge-de-ma-demarche.fr.md +++ b/doc/faqs/usager/je-veux-contacter-le-service-en-charge-de-ma-demarche.fr.md @@ -9,7 +9,7 @@ title: "Je veux contacter le service en charge de ma démarche" # Je veux contacter le service en charge de ma démarche -**L’équipe de demarches.gouv.fr ne peut pas vous renseigner sur l’avancée du traitement de votre dossier**. +**L’équipe de %{application_name} ne peut pas vous renseigner sur l’avancée du traitement de votre dossier**. Pour contacter les services en charge de votre démarche, vous pouvez : diff --git a/doc/faqs/usager/je-veux-enregistrer-mon-formulaire-pour-le-reprendre-plus-tard.fr.md b/doc/faqs/usager/je-veux-enregistrer-mon-formulaire-pour-le-reprendre-plus-tard.fr.md index 56c76ecb4..0b78f84c3 100644 --- a/doc/faqs/usager/je-veux-enregistrer-mon-formulaire-pour-le-reprendre-plus-tard.fr.md +++ b/doc/faqs/usager/je-veux-enregistrer-mon-formulaire-pour-le-reprendre-plus-tard.fr.md @@ -11,9 +11,9 @@ title: "Je veux enregistrer mon formulaire pour le reprendre plus tard" ## Comment enregistrer mon formulaire ? -Lorsque vous remplissez un formulaire sur demarches.gouv.fr, les informations que vous saisissez sont **enregistrées automatiquement**. +Lorsque vous remplissez un formulaire sur %{application_name}, les informations que vous saisissez sont **enregistrées automatiquement**. -Si vous souhaitez terminer de remplir le formulaire plus tard, **il suffit de fermer la page du formulaire**. Lorsque vous retournerez sur demarches.gouv.fr, vous pourrez reprendre votre démarche là où vous l’avez laissée. +Si vous souhaitez terminer de remplir le formulaire plus tard, **il suffit de fermer la page du formulaire**. Lorsque vous retournerez sur %{application_name}, vous pourrez reprendre votre démarche là où vous l’avez laissée. ![Le formulaire est enregistré automatiquement](faq/usager-form-footer-submit.png) @@ -21,8 +21,8 @@ Si vous souhaitez terminer de remplir le formulaire plus tard, **il suffit de fe Si vous avez déjà commencé à remplir une démarche, vous pouvez retrouver votre dossier déjà rempli. Pour cela : -1. Connectez-vous à demarches.gouv.fr, utilisez votre identifiant et votre mot de passe, ou bien FranceConnect. - ![La page de connexion de demarches.gouv.fr](faq/sign-in-page.png) +1. Connectez-vous à %{application_name}, utilisez votre identifiant et votre mot de passe, ou bien FranceConnect. + ![La page de connexion de %{application_name}](faq/sign-in-page.png) 2. Dans [la liste de vos dossiers](/dossiers), **cliquez sur le dossier en brouillon** que vous souhaitez reprendre. Vous pouvez également faire une **recherche par numéro de dossier, mots-clés ou en filtrant par démarches**. ![La liste de mes dossiers](faq/usager-dossiers-list.png) diff --git a/doc/faqs/usager/je-veux-savoir-ou-en-est-l-instruction-de-ma-demarche.fr.md b/doc/faqs/usager/je-veux-savoir-ou-en-est-l-instruction-de-ma-demarche.fr.md index 5c43bb470..f20e51d1c 100644 --- a/doc/faqs/usager/je-veux-savoir-ou-en-est-l-instruction-de-ma-demarche.fr.md +++ b/doc/faqs/usager/je-veux-savoir-ou-en-est-l-instruction-de-ma-demarche.fr.md @@ -9,7 +9,7 @@ title: "Je veux savoir où en est l’instruction de ma démarche" # Je veux savoir où en est l’instruction de ma démarche -**L’équipe de demarches.gouv.fr n’est pas en mesure de vous renseigner sur l’avancée du traitement de votre dossier**. En effet, nous nous occupons uniquement de la partie technique de la plateforme. +**L’équipe de %{application_name} n’est pas en mesure de vous renseigner sur l’avancée du traitement de votre dossier**. En effet, nous nous occupons uniquement de la partie technique de la plateforme. Pour contacter les services en charge de votre démarche, vous pouvez : From 21507cf524d571730180ecb6843848c42a7cc3d6 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 17 Apr 2024 23:09:54 +0200 Subject: [PATCH 0169/1532] test(faqs): service & controller specs --- spec/controllers/faq_controller_spec.rb | 65 +++++++++++++++++ spec/services/faqs_loader_service_spec.rb | 86 +++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 spec/controllers/faq_controller_spec.rb create mode 100644 spec/services/faqs_loader_service_spec.rb diff --git a/spec/controllers/faq_controller_spec.rb b/spec/controllers/faq_controller_spec.rb new file mode 100644 index 000000000..4d1daf1b9 --- /dev/null +++ b/spec/controllers/faq_controller_spec.rb @@ -0,0 +1,65 @@ +RSpec.describe FAQController, type: :controller do + describe "GET #index" do + render_views + + it "displays titles and render links for all entries" do + get :index + + # Usager + expect(response.body).to include("Gestion de mon compte") + expect(response.body).to include("Je veux changer mon adresse email") + expect(response.body).to include(faq_path(category: "usager", slug: "je-veux-changer-mon-adresse-email")) + + # Instructeur + expect(response.body).to include("Je dois confirmer mon compte à chaque connexion") + + # Instructeur + expect(response.body).to include("Les blocs répétables") + end + + context "with invalid subcategory" do + before do + service = instance_double(FAQsLoaderService, all: faqs) + allow(FAQsLoaderService).to receive(:new).and_return(service) + end + + let(:faqs) do + { + 'usager' => { + 'oops' => [{ category: 'usager', subcategory: 'oops', title: 'FAQ Title 1', slug: 'faq1' }] + } + } + end + + it "fails so we can't make a typo and publish non translated subcategories" do + expect { get :index }.to raise_error(ActionView::Template::Error) + end + end + end + + describe "GET #show" do + before do + allow(Current).to receive(:application_name).and_return('demarches.gouv.fr') + end + + render_views + + context "when the FAQ exists" do + it "renders the show template with the FAQ content and metadata" do + get :show, params: { category: 'usager', slug: 'je-veux-changer-mon-adresse-email' } + expect(response.body).to include('Si vous disposez d’un compte usager sur demarches.gouv.fr') + + # link to siblings + expect(response.body).to include(faq_path(category: 'usager', slug: 'je-veux-changer-mon-mot-de-passe')) + end + end + + context "when the FAQ does not exist" do + it "raises a routing error for a missing FAQ" do + expect { + get :show, params: { category: 'nonexistent', slug: 'nofaq' } + }.to raise_error(ActionController::RoutingError) + end + end + end +end diff --git a/spec/services/faqs_loader_service_spec.rb b/spec/services/faqs_loader_service_spec.rb new file mode 100644 index 000000000..ce9ad6c95 --- /dev/null +++ b/spec/services/faqs_loader_service_spec.rb @@ -0,0 +1,86 @@ +require 'rails_helper' + +RSpec.describe FAQsLoaderService do + let(:substitutions) { { application_name: "demarches.gouv.fr", application_base_url: APPLICATION_BASE_URL, contact_email: CONTACT_EMAIL } } + let(:service) { FAQsLoaderService.new(substitutions) } + + context "behavior with stubbed markdown files" do + before do + allow(Dir).to receive(:glob).and_return(['path/to/faq1.md', 'path/to/faq2.md']) + + # Mock File.read calls to fake md files + # but call original otherwise (rspec or debuggning uses File.read to load some files) + allow(File).to receive(:read).and_wrap_original do |original_method, *args| + case args.first + when 'path/to/faq1.md' + <<~MD + --- + title: FAQ1 + slug: faq1 + category: usager + subcategory: account + --- + Welcome to %{application_name} + MD + when 'path/to/faq2.md' + <<~MD + --- + title: FAQ2 + slug: faq2 + category: admin + subcategory: general + --- + This is for %{application_base_url} + MD + else + original_method.call(*args) + end + end + end + + describe '#find' do + it 'returns a file with variable substitutions' do + expect(service.find('usager/faq1').content).to include('Welcome to demarches.gouv.fr') + end + end + + describe '#all' do + it 'returns all FAQs' do + expect(service.all).to eq({ + "usager" => { "account" => [{ category: "usager", file_path: "path/to/faq1.md", slug: "faq1", subcategory: "account", title: "FAQ1" }] }, + "admin" => { "general" => [{ category: "admin", file_path: "path/to/faq2.md", slug: "faq2", subcategory: "general", title: "FAQ2" }] } + }) + end + end + + describe '#faqs_for_category' do + it 'returns FAQs grouped by subcategory for a given category' do + result = service.faqs_for_category('usager') + expect(result).to eq({ + 'account' => [{ category: 'usager', subcategory: 'account', title: 'FAQ1', slug: 'faq1', file_path: 'path/to/faq1.md' }] + }) + end + end + + describe 'caching' do + it 'works', caching: true do + 2.times { + service = FAQsLoaderService.new(substitutions) + service.all + expect(Dir).to have_received(:glob).once + } + + # depends on substitutions + service = FAQsLoaderService.new(substitutions.merge(application_name: "other name")) + service.all + expect(Dir).to have_received(:glob).twice + end + end + end + + context "with actual files" do + it 'load, perform substitutions and returns all FAQs' do + expect(service.all.keys).to match_array(["administrateur", "instructeur", "usager"]) + end + end +end From 0581c67d6cd618c3f73729436fff10837860b210 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 22 Apr 2024 16:45:06 +0200 Subject: [PATCH 0170/1532] refactor: extract ApplicationVersion version from sentry --- app/controllers/application_controller.rb | 2 +- config/initializers/application_version.rb | 19 +++++++++++++++++++ config/initializers/sentry.rb | 14 +------------- 3 files changed, 21 insertions(+), 14 deletions(-) create mode 100644 config/initializers/application_version.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9b0a6ffef..32d8c088f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -339,7 +339,7 @@ class ApplicationController < ActionController::Base environment: sentry[:environment], browser: { modern: BrowserSupport.supported?(browser) }, user: sentry_user, - release: SentryRelease.current + release: ApplicationVersion.current } end diff --git a/config/initializers/application_version.rb b/config/initializers/application_version.rb new file mode 100644 index 000000000..c615edc05 --- /dev/null +++ b/config/initializers/application_version.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ApplicationVersion + @@current = nil + + # Detect the current release version, which helps Sentry identifying the current release + # or can be used as cache key when for some contents susceptible to change between releases. + # + # The deploy process can write a "version" file at root + # containing a string identifying the release, like the sha256 commit used by its release. + # It defaults to a random string if the file is not found (so each restart will behave like a new version) + def self.current + @@current ||= begin + version = Rails.root.join('version') + version.readable? ? version.read.strip : SecureRandom.hex + end + @@current.presence + end +end diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb index 473efa285..6273b0f13 100644 --- a/config/initializers/sentry.rb +++ b/config/initializers/sentry.rb @@ -1,15 +1,3 @@ -class SentryRelease - @@current = nil - - def self.current - @@current ||= begin - version = Rails.root.join('version') - version.readable? ? version.read.strip : '' - end - @@current.presence - end -end - Sentry.init do |config| secrets = Rails.application.secrets.sentry @@ -19,7 +7,7 @@ Sentry.init do |config| config.dsn = secrets[:enabled] ? secrets[:rails_client_key] : nil config.send_default_pii = false - config.release = SentryRelease.current + config.release = ApplicationVersion.current config.environment = secrets[:environment] || Rails.env config.enabled_environments = ['production', secrets[:environment].presence].compact config.breadcrumbs_logger = [:active_support_logger] From b0503b4f282c59de7e74acc42ce1119c7f274c51 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 22 Apr 2024 17:31:18 +0200 Subject: [PATCH 0171/1532] chore(faq): cache depends on app version --- app/services/faqs_loader_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/faqs_loader_service.rb b/app/services/faqs_loader_service.rb index 102e843fb..443efe7d2 100644 --- a/app/services/faqs_loader_service.rb +++ b/app/services/faqs_loader_service.rb @@ -9,7 +9,7 @@ class FAQsLoaderService def initialize(substitutions) @substitutions = substitutions - @faqs_by_path ||= Rails.cache.fetch(["faqs_data", substitutions], expires_in: 1.day) do + @faqs_by_path ||= Rails.cache.fetch(["faqs_data", ApplicationVersion.current, substitutions], expires_in: 1.week) do load_faqs end end From 8d99e28da542635dc3860cdc3ff165a92cecbf5a Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 22 Apr 2024 19:59:56 +0200 Subject: [PATCH 0172/1532] chore(faq): cache I/O on showing faq --- app/services/faqs_loader_service.rb | 6 ++-- spec/services/faqs_loader_service_spec.rb | 39 ++++++++++++++++------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/app/services/faqs_loader_service.rb b/app/services/faqs_loader_service.rb index 443efe7d2..563a05775 100644 --- a/app/services/faqs_loader_service.rb +++ b/app/services/faqs_loader_service.rb @@ -15,9 +15,11 @@ class FAQsLoaderService end def find(path) - file_path = @faqs_by_path.fetch(path).fetch(:file_path) + Rails.cache.fetch(["faq", path, ApplicationVersion.current, substitutions], expires_in: 1.week) do + file_path = @faqs_by_path.fetch(path).fetch(:file_path) - parse_with_substitutions(file_path) + parse_with_substitutions(file_path) + end end def faqs_for_category(category) diff --git a/spec/services/faqs_loader_service_spec.rb b/spec/services/faqs_loader_service_spec.rb index ce9ad6c95..42ab8a95d 100644 --- a/spec/services/faqs_loader_service_spec.rb +++ b/spec/services/faqs_loader_service_spec.rb @@ -42,6 +42,23 @@ RSpec.describe FAQsLoaderService do it 'returns a file with variable substitutions' do expect(service.find('usager/faq1').content).to include('Welcome to demarches.gouv.fr') end + + it 'caches file readings', caching: true do + service # this load paths, and create a first hit on file + expect(File).to have_received(:read).with('path/to/faq1.md').exactly(1).times + + 2.times { + service.find('usager/faq1') + expect(File).to have_received(:read).with('path/to/faq1.md').exactly(2).times + } + + # depends on substitutions and re-hit files + service = FAQsLoaderService.new(substitutions.merge(application_name: "other name")) + expect(File).to have_received(:read).with('path/to/faq1.md').exactly(3).times + + service.find('usager/faq1') + expect(File).to have_received(:read).with('path/to/faq1.md').exactly(4).times + end end describe '#all' do @@ -51,19 +68,8 @@ RSpec.describe FAQsLoaderService do "admin" => { "general" => [{ category: "admin", file_path: "path/to/faq2.md", slug: "faq2", subcategory: "general", title: "FAQ2" }] } }) end - end - describe '#faqs_for_category' do - it 'returns FAQs grouped by subcategory for a given category' do - result = service.faqs_for_category('usager') - expect(result).to eq({ - 'account' => [{ category: 'usager', subcategory: 'account', title: 'FAQ1', slug: 'faq1', file_path: 'path/to/faq1.md' }] - }) - end - end - - describe 'caching' do - it 'works', caching: true do + it 'caches file readings', caching: true do 2.times { service = FAQsLoaderService.new(substitutions) service.all @@ -76,6 +82,15 @@ RSpec.describe FAQsLoaderService do expect(Dir).to have_received(:glob).twice end end + + describe '#faqs_for_category' do + it 'returns FAQs grouped by subcategory for a given category' do + result = service.faqs_for_category('usager') + expect(result).to eq({ + 'account' => [{ category: 'usager', subcategory: 'account', title: 'FAQ1', slug: 'faq1', file_path: 'path/to/faq1.md' }] + }) + end + end end context "with actual files" do From 965afbd18c514765a379323468fc6516cf0a99ee Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 17:09:08 +0200 Subject: [PATCH 0173/1532] fix(brakeman): false positive params not rendered --- config/brakeman.ignore | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 404123563..bf9b76294 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -15,7 +15,7 @@ "type": "controller", "class": "Users::DossiersController", "method": "merci", - "line": 302, + "line": 309, "file": "app/controllers/users/dossiers_controller.rb", "rendered": { "name": "users/dossiers/merci", @@ -67,6 +67,40 @@ ], "note": "" }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 2, + "fingerprint": "a7d18cc3434b4428a884f1217791f9a9db67839e73fb499f1ccd0f686f08eccc", + "check_name": "CrossSiteScripting", + "message": "Unescaped parameter value", + "file": "app/views/faq/show.html.haml", + "line": 12, + "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", + "code": "Redcarpet::Markdown.new(Redcarpet::TrustedRenderer.new(view_context), :autolink => true).render(loader_service.find(\"#{params[:category]}/#{params[:slug]}\").content)", + "render_path": [ + { + "type": "controller", + "class": "FAQController", + "method": "show", + "line": 14, + "file": "app/controllers/faq_controller.rb", + "rendered": { + "name": "faq/show", + "file": "app/views/faq/show.html.haml" + } + } + ], + "location": { + "type": "template", + "template": "faq/show" + }, + "user_input": "params[:category]", + "confidence": "Weak", + "cwe_id": [ + 79 + ], + "note": "Theses params are not rendered" + }, { "warning_type": "SQL Injection", "warning_code": 0, @@ -153,7 +187,7 @@ "check_name": "CrossSiteScripting", "message": "Unescaped model attribute", "file": "app/views/notification_mailer/send_notification_for_tiers.html.haml", - "line": 29, + "line": 31, "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", "code": "Current.application_name.gsub(\".\", \"⁠.\")", "render_path": null, @@ -169,6 +203,6 @@ "note": "Current is not a model" } ], - "updated": "2024-03-27 17:15:54 +0100", + "updated": "2024-04-23 18:27:12 +0200", "brakeman_version": "6.1.2" } From 64de0cb1466b0c8281f84480113a910b09994a88 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 24 Apr 2024 15:50:42 +0200 Subject: [PATCH 0174/1532] refactor(faq): update links to new internal links --- app/views/administrateurs/_breadcrumbs.html.haml | 2 +- .../procedures/_publication_form.html.haml | 2 +- app/views/layouts/_header.haml | 2 +- .../layouts/commencer/_no_procedure.html.haml | 2 +- app/views/root/landing.html.haml | 4 ++-- .../shared/champs/siret/_etablissement.html.haml | 2 +- .../accessibility_statement.html.haml | 4 ++-- app/views/users/dossiers/_dossiers_list.html.haml | 2 +- app/views/users/sessions/link_sent.html.haml | 2 +- config/locales/en.yml | 6 +++--- config/locales/fr.yml | 6 +++--- config/locales/links.en.yml | 15 +++++++-------- config/locales/links.fr.yml | 15 +++++++-------- .../views/administrateurs/procedures/en.yml | 2 +- .../views/administrateurs/procedures/fr.yml | 2 +- config/locales/views/layouts/_breadcrumb.en.yml | 2 +- config/locales/views/layouts/_breadcrumb.fr.yml | 2 +- .../erreur-siret-lors-depot-de-dossier.fr.md | 2 +- public/500.html | 6 ++---- public/502.html | 6 ++---- public/503.html | 6 ++---- public/504.html | 6 ++---- 22 files changed, 44 insertions(+), 54 deletions(-) diff --git a/app/views/administrateurs/_breadcrumbs.html.haml b/app/views/administrateurs/_breadcrumbs.html.haml index 95c4295ac..d19911f36 100644 --- a/app/views/administrateurs/_breadcrumbs.html.haml +++ b/app/views/administrateurs/_breadcrumbs.html.haml @@ -35,7 +35,7 @@ - else %p.fr-mb-1w = t('more_info_on_test', scope: [:layouts, :breadcrumb]) - = link_to t('go_to_FAQ', scope: [:layouts, :breadcrumb]), t("url_FAQ", scope: [:layouts, :breadcrumb]), title: new_tab_suffix(t('go_to_FAQ', scope: [:layouts, :breadcrumb])), **external_link_attributes + = link_to t('go_to_FAQ', scope: [:layouts, :breadcrumb]), t("url_FAQ", scope: [:layouts, :breadcrumb]), title: new_tab_suffix(t('go_to_FAQ', scope: [:layouts, :breadcrumb])) .flex %span.fr-badge.fr-badge--new.fr-mr-1w = t('draft', scope: [:layouts, :breadcrumb]) diff --git a/app/views/administrateurs/procedures/_publication_form.html.haml b/app/views/administrateurs/procedures/_publication_form.html.haml index 94ff52d42..85a8f083c 100644 --- a/app/views/administrateurs/procedures/_publication_form.html.haml +++ b/app/views/administrateurs/procedures/_publication_form.html.haml @@ -16,7 +16,7 @@ - c.with_body do %p = t('.faq_test_alert') - = link_to t('.faq_test_alert_link'), t('.faq_test_alert_link_url'), **external_link_attributes + = link_to t('.faq_test_alert_link'), t('.faq_test_alert_link_url') = render partial: 'publication_form_inputs', locals: { procedure: procedure, closed_procedures: @closed_procedures, form: f } = render Dsfr::CalloutComponent.new(title: t('.dpd_title'), heading_level: 'h2') do |c| - c.with_body do diff --git a/app/views/layouts/_header.haml b/app/views/layouts/_header.haml index 4048831e0..75e2f9b14 100644 --- a/app/views/layouts/_header.haml +++ b/app/views/layouts/_header.haml @@ -52,7 +52,7 @@ = render partial: 'shared/help/help_dropdown_instructeur' - else // NB: on mobile in order to have links correctly aligned, we need a left icon - = link_to t('help'), t("links.common.faq.url"), class: 'fr-btn dropdown-button', title: new_tab_suffix(t('help')), **external_link_attributes + = link_to t('help'), t("links.common.faq.url"), class: 'fr-btn dropdown-button', title: t('help') diff --git a/app/views/layouts/commencer/_no_procedure.html.haml b/app/views/layouts/commencer/_no_procedure.html.haml index 36fb66cae..d579efb3e 100644 --- a/app/views/layouts/commencer/_no_procedure.html.haml +++ b/app/views/layouts/commencer/_no_procedure.html.haml @@ -7,4 +7,4 @@ %p= t('.line3') %hr %p.small-simple= t('.are_you_new', app_name: Current.application_name) - = link_to t('views.users.sessions.new.find_procedure'), t("links.common.faq.comment_trouver_ma_demarche_url"), title: new_tab_suffix(t('views.users.sessions.new.find_procedure')), class: "fr-btn fr-btn--secondary", **external_link_attributes + = link_to t('views.users.sessions.new.find_procedure'), t("links.common.faq.comment_trouver_ma_demarche_url"), title: new_tab_suffix(t('views.users.sessions.new.find_procedure')), class: "fr-btn fr-btn--secondary" diff --git a/app/views/root/landing.html.haml b/app/views/root/landing.html.haml index 851a8bb47..7d32d4689 100644 --- a/app/views/root/landing.html.haml +++ b/app/views/root/landing.html.haml @@ -23,7 +23,7 @@ %h2= t(".have_a_procedure") %p.fr-h5= t(".fill_procedure") - = link_to t(".how_to_find_procedure"), t("links.common.faq.comment_trouver_ma_demarche_url"), class: "fr-btn fr-btn--lg fr-mr-1w fr-mb-2w", title: new_tab_suffix(t(".how_to_find_procedure")), **external_link_attributes + = link_to t(".how_to_find_procedure"), t("links.common.faq.comment_trouver_ma_demarche_url"), class: "fr-btn fr-btn--lg fr-mr-1w fr-mb-2w", title: new_tab_suffix(t(".how_to_find_procedure")) = link_to t("views.users.sessions.new.connection"), new_user_session_path, class: "fr-btn fr-btn--secondary fr-btn--lg" .fr-py-6w @@ -51,7 +51,7 @@ %h2= t(".question") %p.fr-h5= t(".answer_in_faq") %div - = link_to t(".online_help"), t("links.common.faq.url"), class: "fr-btn fr-btn--lg", title: new_tab_suffix(t(".online_help")), **external_link_attributes + = link_to t(".online_help"), t("links.common.faq.url"), class: "fr-btn fr-btn--lg", title: t(".online_help") .fr-py-6w .container diff --git a/app/views/shared/champs/siret/_etablissement.html.haml b/app/views/shared/champs/siret/_etablissement.html.haml index efaaa901c..6aec8510c 100644 --- a/app/views/shared/champs/siret/_etablissement.html.haml +++ b/app/views/shared/champs/siret/_etablissement.html.haml @@ -8,7 +8,7 @@ - when :not_found %p.fr-error-text Nous n’avons pas trouvé d’établissement correspondant à ce numéro de SIRET. - = link_to('Plus d’informations', t("links.common.faq.erreur_siret_url"), **external_link_attributes) + = link_to('Plus d’informations', t("links.common.faq.erreur_siret_url")) - when :network_error %p.fr-error-text= t('errors.messages.siret_network_error') diff --git a/app/views/static_pages/accessibility_statement.html.haml b/app/views/static_pages/accessibility_statement.html.haml index 7859f8554..1676164cf 100644 --- a/app/views/static_pages/accessibility_statement.html.haml +++ b/app/views/static_pages/accessibility_statement.html.haml @@ -95,10 +95,10 @@ = t("views.accessibility_statement.preparation.page_six") %li = link_to t("views.accessibility_statement.preparation.page_seven.label"), t("views.accessibility_statement.preparation.page_seven.url"), - title: t("views.accessibility_statement.preparation.page_seven.title"), **external_link_attributes + title: t("views.accessibility_statement.preparation.page_seven.title") %li = link_to t("views.accessibility_statement.preparation.page_eight.label"), t("views.accessibility_statement.preparation.page_eight.url"), - title: t("views.accessibility_statement.preparation.page_eight.title"), **external_link_attributes + title: t("views.accessibility_statement.preparation.page_eight.title") %li = t("views.accessibility_statement.preparation.page_nine") %li diff --git a/app/views/users/dossiers/_dossiers_list.html.haml b/app/views/users/dossiers/_dossiers_list.html.haml index 877ab6f96..465003f57 100644 --- a/app/views/users/dossiers/_dossiers_list.html.haml +++ b/app/views/users/dossiers/_dossiers_list.html.haml @@ -131,4 +131,4 @@ %p.empty-text-details = t('views.users.dossiers.dossiers_list.no_result_text_html', app_base: Current.application_base_url) %p - = link_to t("root.landing.how_to_find_procedure"), t("links.common.faq.comment_trouver_ma_demarche_url"), class: "fr-btn fr-btn--lg fr-mr-1w fr-mb-2w", **external_link_attributes + = link_to t("root.landing.how_to_find_procedure"), t("links.common.faq.comment_trouver_ma_demarche_url"), class: "fr-btn fr-btn--lg fr-mr-1w fr-mb-2w" diff --git a/app/views/users/sessions/link_sent.html.haml b/app/views/users/sessions/link_sent.html.haml index b0cbd891f..66ef59192 100644 --- a/app/views/users/sessions/link_sent.html.haml +++ b/app/views/users/sessions/link_sent.html.haml @@ -24,6 +24,6 @@ %section %p.fr-mt-3w - Si vous voyez cette page trop souvent, consultez notre aide : #{link_to t("links.common.faq.confirmer_compte_chaque_connexion_url"), t("links.common.faq.confirmer_compte_chaque_connexion_url"), **external_link_attributes} + Si vous voyez cette page trop souvent, #{link_to "consultez notre aide", t("links.common.faq.confirmer_compte_chaque_connexion_url")} %p.fr-mt-3w = t('views.users.shared.contact_us_if_any_trouble_html', href: contact_admin_url) diff --git a/config/locales/en.yml b/config/locales/en.yml index 153bb9ae5..b8413b1eb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -174,12 +174,12 @@ en: page_six: "Home page - User search results" page_seven: label: "FAQ" - url: "https://faq.demarches-simplifiees.fr/" + url: "/faq" title: "FAQ - new tab" page_eight: label: "FAQ - User (submission of a file)" - url: "https://faq.demarches-simplifiees.fr/collection/17-usager-depot-dun-dossier" - title: "FAQ - User (submission of a file) - new tab" + url: "/faq#accordion-usager-0" + title: "FAQ - User (submission of a file)" page_nine: "Documentation - Presentation" page_ten: "Documentation - Start-up" page_eleven: "Documentation - General" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index f7643b183..7378f6ff7 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -170,12 +170,12 @@ fr: page_six: "Accueil connecté - Résultats de recherche usager" page_seven: label: "FAQ" - url: "https://faq.demarches-simplifiees.fr/" + url: "/faq" title: "FAQ - nouvel onglet" page_eight: label: "FAQ - Usager (dépôt d'un dossier)" - url: "https://faq.demarches-simplifiees.fr/collection/17-usager-depot-dun-dossier" - title: "FAQ - Usager (dépôt d'un dossier) - nouvel onglet" + url: "/faq#accordion-usager-0" + title: "FAQ - Usager (dépôt d'un dossier)" page_nine: "Documentation - Présentation" page_ten: "Documentation - Démarrage" page_eleven: "Documentation - Généralités" diff --git a/config/locales/links.en.yml b/config/locales/links.en.yml index a825f368b..b7697d43a 100644 --- a/config/locales/links.en.yml +++ b/config/locales/links.en.yml @@ -6,14 +6,13 @@ en: faq: label: "FAQ" title: "Frequently Asked Questions" - url: "https://faq.demarches-simplifiees.fr" - autosave_url: "https://faq.demarches-simplifiees.fr/article/77-enregistrer-mon-formulaire-pour-le-reprendre-plus-tard?preview=5ec28ca1042863474d1aee00" - comment_trouver_ma_demarche_url: "https://faq.demarches-simplifiees.fr/article/59-comment-trouver-ma-demarche" - confirmer_compte_chaque_connexion_url: "https://faq.demarches-simplifiees.fr/article/34-je-dois-confirmer-mon-compte-a-chaque-connexion" - contacter_service_en_charge_url: "https://faq.demarches-simplifiees.fr/article/12-contacter-le-service-en-charge-de-ma-demarche" - email_non_recu_url: "https://faq.demarches-simplifiees.fr/article/79-je-ne-recois-pas-demail" - erreur_siret_url: "https://faq.demarches-simplifiees.fr/article/4-erreur-siret" - ou_en_est_mon_dossier_url: "https://faq.demarches-simplifiees.fr/article/11-je-veux-savoir-ou-en-est-linstruction-de-ma-demarche" + url: "/faq" + autosave_url: "/faq/usager/je-veux-enregistrer-mon-formulaire-pour-le-reprendre-plus-tard" + comment_trouver_ma_demarche_url: "/faq/usager/comment-trouver-ma-demarche" + confirmer_compte_chaque_connexion_url: "/faq/instructeur/je-dois-confirmer-mon-compte-a-chaque-connexion" + contacter_service_en_charge_url: "/faq/usager/je-veux-contacter-le-service-en-charge-de-ma-demarche" + erreur_siret_url: "/faq/usager/erreur-siret-lors-d-un-depot-de-dossier" + ou_en_est_mon_dossier_url: "/faq/usager/je-veux-savoir-ou-en-est-l-instruction-de-ma-demarche" footer: top_labels: hidden_title: Useful links diff --git a/config/locales/links.fr.yml b/config/locales/links.fr.yml index 4c20d26e0..1923776f9 100644 --- a/config/locales/links.fr.yml +++ b/config/locales/links.fr.yml @@ -8,14 +8,13 @@ fr: faq: label: "FAQ" title: "Foire aux Questions" - url: "https://faq.demarches-simplifiees.fr" - autosave_url: "https://faq.demarches-simplifiees.fr/article/77-enregistrer-mon-formulaire-pour-le-reprendre-plus-tard?preview=5ec28ca1042863474d1aee00" - comment_trouver_ma_demarche_url: "https://faq.demarches-simplifiees.fr/article/59-comment-trouver-ma-demarche" - confirmer_compte_chaque_connexion_url: "https://faq.demarches-simplifiees.fr/article/34-je-dois-confirmer-mon-compte-a-chaque-connexion" - contacter_service_en_charge_url: "https://faq.demarches-simplifiees.fr/article/12-contacter-le-service-en-charge-de-ma-demarche" - email_non_recu_url: "https://faq.demarches-simplifiees.fr/article/79-je-ne-recois-pas-demail" - erreur_siret_url: "https://faq.demarches-simplifiees.fr/article/4-erreur-siret" - ou_en_est_mon_dossier_url: "https://faq.demarches-simplifiees.fr/article/11-je-veux-savoir-ou-en-est-linstruction-de-ma-demarche" + url: "/faq" + autosave_url: "/faq/usager/je-veux-enregistrer-mon-formulaire-pour-le-reprendre-plus-tard" + comment_trouver_ma_demarche_url: "/faq/usager/comment-trouver-ma-demarche" + confirmer_compte_chaque_connexion_url: "/faq/instructeur/je-dois-confirmer-mon-compte-a-chaque-connexion" + contacter_service_en_charge_url: "/faq/usager/je-veux-contacter-le-service-en-charge-de-ma-demarche" + erreur_siret_url: "/faq/usager/erreur-siret-lors-d-un-depot-de-dossier" + ou_en_est_mon_dossier_url: "/faq/usager/je-veux-savoir-ou-en-est-l-instruction-de-ma-demarche" footer: top_labels: hidden_title: Liens pratiques diff --git a/config/locales/views/administrateurs/procedures/en.yml b/config/locales/views/administrateurs/procedures/en.yml index 8279cb808..db0bf6b5a 100644 --- a/config/locales/views/administrateurs/procedures/en.yml +++ b/config/locales/views/administrateurs/procedures/en.yml @@ -58,7 +58,7 @@ en: publication_form: faq_test_alert: Have you thought about testing your procedure before publishing it? To help you in this test phase, you can faq_test_alert_link: consult our best practices guide. - faq_test_alert_link_url: "https://faq.demarches-simplifiees.fr/category/49-comment-tester-ma-demarche" + faq_test_alert_link_url: "/faq#accordion-administrateur-2" draft_changed_procedure_alert: "Publish a new version of your procedure. The following changes will be applied:" dpd_title: Before publishing dpd_part_1: Have you thought about informing your Personal Data Protection Officer (DPO). diff --git a/config/locales/views/administrateurs/procedures/fr.yml b/config/locales/views/administrateurs/procedures/fr.yml index 930d08247..0fcc5ad96 100644 --- a/config/locales/views/administrateurs/procedures/fr.yml +++ b/config/locales/views/administrateurs/procedures/fr.yml @@ -58,7 +58,7 @@ fr: publication_form: faq_test_alert: Avez-vous bien pensé à tester votre démarche avant de la publier ? Pour vous aider dans cette phase de test, vous pouvez faq_test_alert_link: consulter notre guide de bonnes pratiques. - faq_test_alert_link_url: "https://faq.demarches-simplifiees.fr/category/49-comment-tester-ma-demarche" + faq_test_alert_link_url: "/faq#accordion-administrateur-2" draft_changed_procedure_alert: "Publiez une nouvelle version de votre démarche. Les modifications suivantes seront appliquées :" dpd_title: Avant de publier dpd_part_1: Avez-vous bien pensé à informer votre Délégué à la Protection des Données personnelles (DPD). diff --git a/config/locales/views/layouts/_breadcrumb.en.yml b/config/locales/views/layouts/_breadcrumb.en.yml index 03328421e..8efbc086e 100644 --- a/config/locales/views/layouts/_breadcrumb.en.yml +++ b/config/locales/views/layouts/_breadcrumb.en.yml @@ -15,5 +15,5 @@ en: draft: "Draft" more_info_on_test: "For more information on test stage" go_to_FAQ: "read FAQ" - url_FAQ: "https://faq.demarches-simplifiees.fr/category/49-comment-tester-ma-demarche" + url_FAQ: "/faq#accordion-administrateur-2" faq: Frequently Asked Questions diff --git a/config/locales/views/layouts/_breadcrumb.fr.yml b/config/locales/views/layouts/_breadcrumb.fr.yml index f67542e4e..14f4fa15f 100644 --- a/config/locales/views/layouts/_breadcrumb.fr.yml +++ b/config/locales/views/layouts/_breadcrumb.fr.yml @@ -15,5 +15,5 @@ fr: draft: "En test" more_info_on_test: "Pour plus d’information sur la phase de test" go_to_FAQ: "consulter la FAQ" - url_FAQ: "https://faq.demarches-simplifiees.fr/category/49-comment-tester-ma-demarche" + url_FAQ: "/faq#accordion-administrateur-2" faq: Foire aux Questions diff --git a/doc/faqs/usager/erreur-siret-lors-depot-de-dossier.fr.md b/doc/faqs/usager/erreur-siret-lors-depot-de-dossier.fr.md index dbd9d0bcb..86c0816df 100644 --- a/doc/faqs/usager/erreur-siret-lors-depot-de-dossier.fr.md +++ b/doc/faqs/usager/erreur-siret-lors-depot-de-dossier.fr.md @@ -1,5 +1,5 @@ --- -title: "Erreur SIRET lors d’un dépôt de dossier sur %{application_name}" +title: "Erreur SIRET lors d’un dépôt de dossier" category: usager subcategory: dossier_technical_issue slug: "erreur-siret-lors-d-un-depot-de-dossier" diff --git a/public/500.html b/public/500.html index 778e9f022..cf28f520e 100644 --- a/public/500.html +++ b/public/500.html @@ -2110,10 +2110,8 @@
Aide diff --git a/public/502.html b/public/502.html index f4bea681f..9bfc1779c 100644 --- a/public/502.html +++ b/public/502.html @@ -2109,10 +2109,8 @@ Aide diff --git a/public/503.html b/public/503.html index 5e6dfebb4..4e44ea7c6 100644 --- a/public/503.html +++ b/public/503.html @@ -2109,10 +2109,8 @@ Aide diff --git a/public/504.html b/public/504.html index 90dd2745d..df8ee4e92 100644 --- a/public/504.html +++ b/public/504.html @@ -2109,10 +2109,8 @@ Aide From a41ba205d0646f7d821f8296e2f09c9c449fef75 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 16 May 2024 09:34:56 +0200 Subject: [PATCH 0175/1532] chore(faq): translate english interface (not faq content) --- app/views/faq/index.html.haml | 8 +++--- app/views/faq/show.html.haml | 1 + config/locales/faqs.en.yml | 46 +++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 config/locales/faqs.en.yml diff --git a/app/views/faq/index.html.haml b/app/views/faq/index.html.haml index a2026b203..d467388d0 100644 --- a/app/views/faq/index.html.haml +++ b/app/views/faq/index.html.haml @@ -7,18 +7,18 @@ %h1= t('.title', app_name: Current.application_name) - @faqs.each do |category, subcategories| - %h2= t(:name, scope: [:faq, :categories, category], raise: true) - %p= t(:description, scope: [:faq, :categories, category], raise: true) + %h2= t(:name, scope: [:faq, :categories, category], raise: true) # i18n-tasks-use t("faq.categories.#{category}.name") + %p= t(:description, scope: [:faq, :categories, category], raise: true) # i18n-tasks-use t("faq.categories.#{category}.description") .fr-accordions-group.fr-mb-6w - subcategories.each_with_index do |(subcategory, faqs), index| %section.fr-accordion %h3.fr-accordion__title %button.fr-accordion__btn{ 'aria-expanded': "false", 'aria-controls': "accordion-#{category}-#{index}" } - = t(:name, scope: [:faq, :subcategories, subcategory], raise: true) + = t(:name, scope: [:faq, :subcategories, subcategory], raise: true) # i18n-tasks-use t("faq.subcategories.#{subcategory}.name") .fr-collapse{ id: "accordion-#{category}-#{index}" } - - description = t(:description, scope: [:faq, :subcategories, subcategory], default: nil) + - description = t(:description, scope: [:faq, :subcategories, subcategory], default: nil) # i18n-tasks-use t("faq.subcategories.#{subcategory}.description") %p= description if description.present? %ul diff --git a/app/views/faq/show.html.haml b/app/views/faq/show.html.haml index b2ff97df6..45cfb50fb 100644 --- a/app/views/faq/show.html.haml +++ b/app/views/faq/show.html.haml @@ -6,6 +6,7 @@ .fr-col-12.fr-col-md-4 = render partial: "sidebar", locals: { faqs: @siblings, current: @metadata } .fr-col-12.fr-col-md-8 + -# i18n-tasks-use t("faq.categories.#{@metadata[:category]}.short_name") = render partial: "breadcrumb", locals: { faq_title: "#{t(:short_name, scope: [:faq, :categories, @metadata[:category]])} : #{@metadata[:title]}" } .markdown-content diff --git a/config/locales/faqs.en.yml b/config/locales/faqs.en.yml new file mode 100644 index 000000000..8721ff136 --- /dev/null +++ b/config/locales/faqs.en.yml @@ -0,0 +1,46 @@ +en: + faq: + index: + meta_title: "Frequently Asked Questions" + title: Frequently Asked Questions (FAQ) of %{app_name} + sidebar_button: In this FAQ + categories: + usager: + short_name: User + name: User (file application) + description: Help users submit and follow up on files, including resolving common problems. + instructeur: + short_name: Instructor + name: Instructor (file processing) + description: For instructors on accessing and managing files. + administrateur: + short_name: Administrator + name: Administrator (form creation) + description: Information for administrators on configuring the platform or creating procedures. + subcategories: + dossier_technical_issue: + name: I'm encountering a technical problem with my file + find_dossier: + name: I want to find my file + description: What if I can't find my file? + fill_dossier: + name: I want to make a + dossier_state: + name: I want to follow the progress of my application + account: + name: Manage my account + instructeur_account: + name: Login to my account + instruction: + name: Instruction + create_procedure: + name: I want to create an online procedure + description: How do you create a new procedure? + general: + name: General + administrateur_departure: + name: I'm leaving my job + description: Good practices to anticipate before my departure + procedure_test: + name: How to test my form + description: How do you test a new procedure? From 2d57ec4056954c469b1c74a7be53e60198228345 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 16 May 2024 16:18:35 +0200 Subject: [PATCH 0176/1532] fix(accuse-lecture): don't crash on dossier acceptance when procedure is still draft --- app/services/mail_template_presenter_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/mail_template_presenter_service.rb b/app/services/mail_template_presenter_service.rb index b0e9db3bb..f816f3a05 100644 --- a/app/services/mail_template_presenter_service.rb +++ b/app/services/mail_template_presenter_service.rb @@ -4,7 +4,7 @@ class MailTemplatePresenterService def self.create_commentaire_for_state(dossier, state) if dossier.procedure.accuse_lecture? && Dossier::TERMINE.include?(state) - CommentaireService.create!(CONTACT_EMAIL, dossier, body: I18n.t('layouts.mailers.accuse_lecture.commentaire_html', service: dossier.procedure.service.nom)) + CommentaireService.create!(CONTACT_EMAIL, dossier, body: I18n.t('layouts.mailers.accuse_lecture.commentaire_html', service: dossier.procedure.service&.nom)) else service = new(dossier, state) body = ["

[#{service.safe_subject}]

", service.safe_body].join('') From 3d52601155aa87fcf64f6645987cab769ff7279a Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 15 May 2024 23:29:45 +0200 Subject: [PATCH 0177/1532] fix(carte): gracefully ignore invalid year and kind params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empêche d'envoyer une date invalide à PG et polluer les logs. --- app/controllers/carte_controller.rb | 9 +++++- app/models/map_filter.rb | 37 ++++++++--------------- app/views/carte/show.html.erb | 2 +- spec/controllers/carte_controller_spec.rb | 8 +++++ spec/models/map_filter_spec.rb | 4 +-- 5 files changed, 31 insertions(+), 29 deletions(-) diff --git a/app/controllers/carte_controller.rb b/app/controllers/carte_controller.rb index 1b9c95f1e..2cfebb98b 100644 --- a/app/controllers/carte_controller.rb +++ b/app/controllers/carte_controller.rb @@ -1,6 +1,13 @@ class CarteController < ApplicationController def show - @map_filter = MapFilter.new(params) + @map_filter = MapFilter.new(params.fetch(:map_filter, {}).permit(:kind, :year)) + @map_filter.validate + + # Reset to default params in case of invalid params injection + @map_filter.kind = MapFilter.new.kind if @map_filter.errors.key?(:kind) + @map_filter.year = MapFilter.new.year if @map_filter.errors.key?(:year) + @map_filter.errors.clear + @map_filter.stats = stats end diff --git a/app/models/map_filter.rb b/app/models/map_filter.rb index 06837bdb1..3b8d33e0e 100644 --- a/app/models/map_filter.rb +++ b/app/models/map_filter.rb @@ -1,34 +1,21 @@ class MapFilter - # https://api.rubyonrails.org/v7.1.1/classes/ActiveModel/Errors.html - - include ActiveModel::Conversion - extend ActiveModel::Translation - extend ActiveModel::Naming + include ActiveModel::Model + include ActiveModel::Attributes LEGEND = { - nb_demarches: { 'nothing': -1, 'small': 20, 'medium': 50, 'large': 100, 'xlarge': 500 }, - nb_dossiers: { 'nothing': -1, 'small': 500, 'medium': 2000, 'large': 10000, 'xlarge': 50000 } - } + "nb_demarches" => { 'nothing': -1, 'small': 20, 'medium': 50, 'large': 100, 'xlarge': 500 }, + "nb_dossiers" => { 'nothing': -1, 'small': 500, 'medium': 2000, 'large': 10000, 'xlarge': 50000 } + }.freeze + + YEARS_INTERVAL = 2018..Date.current.year attr_accessor :stats - attr_reader :errors - def initialize(params) - @params = params[:map_filter]&.permit(:kind, :year) || {} - @errors = ActiveModel::Errors.new(self) - end + attribute :year, :integer + validates :year, numericality: { only_integer: true, greater_than_or_equal_to: YEARS_INTERVAL.begin, less_than_or_equal_to: YEARS_INTERVAL.end } - def persisted? - false - end - - def kind - @params[:kind]&.to_sym || :nb_demarches - end - - def year - @params[:year].presence - end + attribute :kind, default: "nb_demarches" + validates :kind, inclusion: { in: LEGEND.keys } def kind_buttons LEGEND.keys.map do @@ -41,7 +28,7 @@ class MapFilter end def css_class_for_departement(departement) - if kind == :nb_demarches + if kind == "nb_demarches" kind_legend_keys.reverse.find do nb_demarches_for_departement(departement) > LEGEND[kind][_1] end diff --git a/app/views/carte/show.html.erb b/app/views/carte/show.html.erb index 04d1aed9b..4847c96d2 100644 --- a/app/views/carte/show.html.erb +++ b/app/views/carte/show.html.erb @@ -249,7 +249,7 @@ <% end %>
<%= map_form.label :year, class: 'fr-label' %> - <%= map_form.select(:year, (2018..Date.current.year).to_a.reverse, { include_blank: t(:from_beginning, scope: 'activemodel.attributes.map_filter') }, { class: "fr-select" }) %> + <%= map_form.select(:year, MapFilter::YEARS_INTERVAL.to_a.reverse, { include_blank: t(:from_beginning, scope: 'activemodel.attributes.map_filter') }, { class: "fr-select" }) %>
<%= map_form.submit(name: nil, class: 'hidden', data: { autosubmit_target: 'submitter' } ) %> <% end %> diff --git a/spec/controllers/carte_controller_spec.rb b/spec/controllers/carte_controller_spec.rb index 941fd081b..a90ed9648 100644 --- a/spec/controllers/carte_controller_spec.rb +++ b/spec/controllers/carte_controller_spec.rb @@ -18,5 +18,13 @@ describe CarteController do get :show, params: { map_filter: { year: 2020 } } expect(subject.stats['75']).to eq({ nb_demarches: 1, nb_dossiers: 20 }) end + + it 'gracefully ignore invalid params' do + get :show, params: { map_filter: { year: "not!" } } + expect(subject.stats['75']).to eq({ nb_demarches: 2, nb_dossiers: 50 }) + + get :show, params: { map_filter: { kind: "nimp" } } + expect(subject.stats['75']).to eq({ nb_demarches: 2, nb_dossiers: 50 }) + end end end diff --git a/spec/models/map_filter_spec.rb b/spec/models/map_filter_spec.rb index adb5d6d36..5279c45eb 100644 --- a/spec/models/map_filter_spec.rb +++ b/spec/models/map_filter_spec.rb @@ -6,7 +6,7 @@ describe MapFilter do end describe 'css_class_for_departement' do - let(:params) { { kind: :nb_demarches } } + let(:params) { { kind: "nb_demarches" } } context 'for nb_demarches' do it 'return class css' do expect(map_filter.css_class_for_departement('63')).to eq :medium @@ -14,7 +14,7 @@ describe MapFilter do end context 'fr nb_dossiers' do - let(:params) { { kind: :nb_dossiers } } + let(:params) { { kind: "nb_dossiers" } } it 'return class css' do expect(map_filter.css_class_for_departement('63')).to eq :medium end From 0c9006e9536c55a072a6727af508475dcd490814 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 16 May 2024 20:27:31 +0200 Subject: [PATCH 0178/1532] ci: agressive timeout because system tests sometimes hang forever --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20335c718..11e97ca81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,7 @@ jobs: unit_tests: name: Unit tests runs-on: ubuntu-latest + timeout-minutes: 20 env: RUBY_YJIT_ENABLE: "1" services: @@ -113,6 +114,7 @@ jobs: system_tests: name: System tests runs-on: ubuntu-latest + timeout-minutes: 20 env: RUBY_YJIT_ENABLE: "1" services: From d65bc883cbf0864f37234efb379f9d9872368fd7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 May 2024 21:48:44 +0000 Subject: [PATCH 0179/1532] chore(deps): bump rexml from 3.2.6 to 3.2.7 Bumps [rexml](https://github.com/ruby/rexml) from 3.2.6 to 3.2.7. - [Release notes](https://github.com/ruby/rexml/releases) - [Changelog](https://github.com/ruby/rexml/blob/master/NEWS.md) - [Commits](https://github.com/ruby/rexml/compare/v3.2.6...v3.2.7) --- updated-dependencies: - dependency-name: rexml dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2e54015de..87e3bcb90 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -595,7 +595,8 @@ GEM responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.2.6) + rexml (3.2.7) + strscan (>= 3.0.9) rodf (1.2.0) builder (>= 3.0) rubyzip (>= 1.0) @@ -767,6 +768,7 @@ GEM stringio (3.1.0) strong_migrations (1.8.0) activerecord (>= 5.2) + strscan (3.1.0) swd (2.0.3) activesupport (>= 3) attr_required (>= 0.0.5) From 96238da249d36358b060630c7e065eaf366b947d Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Mon, 22 Apr 2024 15:12:49 +0200 Subject: [PATCH 0180/1532] add service to rotate jpg attachment automatically --- .../attachment_auto_rotate_concern.rb | 22 +++++++++++++ app/services/auto_rotate_service.rb | 30 ++++++++++++++++++ config/initializers/active_storage.rb | 1 + spec/fixtures/files/image-rotated.jpg | Bin 0 -> 15562 bytes spec/services/auto_rotate_service_spec.rb | 15 +++++++++ 5 files changed, 68 insertions(+) create mode 100644 app/models/concerns/attachment_auto_rotate_concern.rb create mode 100644 app/services/auto_rotate_service.rb create mode 100644 spec/fixtures/files/image-rotated.jpg create mode 100644 spec/services/auto_rotate_service_spec.rb diff --git a/app/models/concerns/attachment_auto_rotate_concern.rb b/app/models/concerns/attachment_auto_rotate_concern.rb new file mode 100644 index 000000000..9fba8a9ab --- /dev/null +++ b/app/models/concerns/attachment_auto_rotate_concern.rb @@ -0,0 +1,22 @@ +module AttachmentAutoRotateConcern + extend ActiveSupport::Concern + + included do + after_create_commit :auto_rotate + end + + private + + def auto_rotate + return if blob.nil? + return if ["image/jpeg", "image/jpg"].exclude?(blob.content_type) + + blob.open do |file| + Tempfile.create(["rotated", File.extname(file)]) do |output| + processed = AutoRotateService.new.process(file, output) + blob.upload(processed) # also update checksum & byte_size accordingly + blob.save! + end + end + end +end diff --git a/app/services/auto_rotate_service.rb b/app/services/auto_rotate_service.rb new file mode 100644 index 000000000..39813c8c5 --- /dev/null +++ b/app/services/auto_rotate_service.rb @@ -0,0 +1,30 @@ +class AutoRotateService + def process(file, output) + auto_rotate_image(file, output) + output + end + + private + + def auto_rotate_image(file, output) + image = MiniMagick::Image.new(file.to_path) + + case image["%[orientation]"] + when 'LeftBottom' + rotate_image(file, output, 90) + when 'BottomRight' + rotate_image(file, output, 180) + when 'RightTop' + rotate_image(file, output, 270) + end + end + + def rotate_image(file, output, degree) + MiniMagick::Tool::Convert.new do |convert| + convert << file.to_path + convert.rotate(degree) + convert.auto_orient + convert << output.to_path + end + end +end diff --git a/config/initializers/active_storage.rb b/config/initializers/active_storage.rb index 042333ae3..c20df224f 100644 --- a/config/initializers/active_storage.rb +++ b/config/initializers/active_storage.rb @@ -17,6 +17,7 @@ end ActiveSupport.on_load(:active_storage_attachment) do include AttachmentTitreIdentiteWatermarkConcern include AttachmentVirusScannerConcern + include AttachmentAutoRotateConcern end Rails.application.reloader.to_prepare do diff --git a/spec/fixtures/files/image-rotated.jpg b/spec/fixtures/files/image-rotated.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2f58be2c8b76259a0a96ae033cc28e8e524d841d GIT binary patch literal 15562 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 L|C^VGHUB>VinQH` literal 0 HcmV?d00001 diff --git a/spec/services/auto_rotate_service_spec.rb b/spec/services/auto_rotate_service_spec.rb new file mode 100644 index 000000000..9895be7d6 --- /dev/null +++ b/spec/services/auto_rotate_service_spec.rb @@ -0,0 +1,15 @@ +RSpec.describe AutoRotateService do + let(:image) { file_fixture("image-rotated.jpg") } + let(:auto_rotate_service) { AutoRotateService.new } + + describe '#process' do + it 'returns a tempfile if auto_rotate succeeds' do + Tempfile.create do |output| + auto_rotate_service.process(image, output) + expect(MiniMagick::Image.new(image.to_path)["%[orientation]"]).to eq('LeftBottom') + expect(MiniMagick::Image.new(output.to_path)["%[orientation]"]).to eq('TopLeft') + expect(output.size).to be_between(image.size / 1.2, image.size) + end + end + end +end From fce2c03015990b845c5c2193094b675af085a3be Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Mon, 22 Apr 2024 16:05:26 +0200 Subject: [PATCH 0181/1532] fix spec changing fake image file extension --- spec/components/attachment/multiple_component_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/components/attachment/multiple_component_spec.rb b/spec/components/attachment/multiple_component_spec.rb index 9f4d02549..26dfacd2c 100644 --- a/spec/components/attachment/multiple_component_spec.rb +++ b/spec/components/attachment/multiple_component_spec.rb @@ -105,7 +105,7 @@ RSpec.describe Attachment::MultipleComponent, type: :component do attached_file.attach( io: StringIO.new("x" * 2), filename: "me.jpg", - content_type: "image/jpeg", + content_type: "image/png", metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE } ) champ.save! From b4a7b4bfbdc7293562708b21a6ffdda56d1b5e4f Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Mon, 29 Apr 2024 16:53:21 +0200 Subject: [PATCH 0182/1532] create a job for all image treatments --- app/jobs/image_processor_job.rb | 59 +++++++++++++++++++ app/jobs/titre_identite_watermark_job.rb | 28 --------- .../attachment_auto_rotate_concern.rb | 22 ------- .../attachment_image_processor_concern.rb | 21 +++++++ ...ern.rb => blob_image_processor_concern.rb} | 8 +-- app/services/auto_rotate_service.rb | 6 +- config/initializers/active_storage.rb | 5 +- ...ob_spec.rb => image_processor_job_spec.rb} | 2 +- 8 files changed, 91 insertions(+), 60 deletions(-) create mode 100644 app/jobs/image_processor_job.rb delete mode 100644 app/jobs/titre_identite_watermark_job.rb delete mode 100644 app/models/concerns/attachment_auto_rotate_concern.rb create mode 100644 app/models/concerns/attachment_image_processor_concern.rb rename app/models/concerns/{blob_titre_identite_watermark_concern.rb => blob_image_processor_concern.rb} (58%) rename spec/jobs/{titre_identite_watermark_job_spec.rb => image_processor_job_spec.rb} (97%) diff --git a/app/jobs/image_processor_job.rb b/app/jobs/image_processor_job.rb new file mode 100644 index 000000000..6fa027e93 --- /dev/null +++ b/app/jobs/image_processor_job.rb @@ -0,0 +1,59 @@ +class ImageProcessorJob < ApplicationJob + class FileNotScannedYetError < StandardError + end + + # If by the time the job runs the blob has been deleted, ignore the error + discard_on ActiveRecord::RecordNotFound + # If the file is deleted during the scan, ignore the error + discard_on ActiveStorage::FileNotFoundError + # If the file is not analyzed or scanned for viruses yet, retry later + # (to avoid modifying the file while it is being scanned). + retry_on FileNotScannedYetError, wait: :exponentially_longer, attempts: 10 + + def perform(blob) + return if blob.nil? + raise FileNotScannedYetError if blob.virus_scanner.pending? + return if ActiveStorage::Attachment.find_by(blob_id: blob.id).record_type == "ActiveStorage::VariantRecord" + + auto_rotate(blob) if ["image/jpeg", "image/jpg"].include?(blob.content_type) + create_variants(blob) if blob.variant_required? + add_watermark(blob) if blob.watermark_pending? + end + + private + + def auto_rotate(blob) + blob.open do |file| + Tempfile.create(["rotated", File.extname(file)]) do |output| + processed = AutoRotateService.new.process(file, output) + return if processed.blank? + + blob.upload(processed) # also update checksum & byte_size accordingly + blob.save! + end + end + end + + def create_variants(blob) + blob.attachments.each do |attachment| + next unless attachment&.representable? + attachment.representation(resize_to_limit: [300, 300]).processed + attachment.representation(resize_to_limit: [400, 400]).processed + end + end + + def add_watermark(blob) + return if blob.watermark_done? + + blob.open do |file| + Tempfile.create(["watermarked", File.extname(file)]) do |output| + processed = WatermarkService.new.process(file, output) + return if processed.blank? + + blob.upload(processed) # also update checksum & byte_size accordingly + blob.watermarked_at = Time.current + blob.save! + end + end + end +end diff --git a/app/jobs/titre_identite_watermark_job.rb b/app/jobs/titre_identite_watermark_job.rb deleted file mode 100644 index 1b261e291..000000000 --- a/app/jobs/titre_identite_watermark_job.rb +++ /dev/null @@ -1,28 +0,0 @@ -class TitreIdentiteWatermarkJob < ApplicationJob - class FileNotScannedYetError < StandardError - end - - # If by the time the job runs the blob has been deleted, ignore the error - discard_on ActiveRecord::RecordNotFound - # If the file is deleted during the scan, ignore the error - discard_on ActiveStorage::FileNotFoundError - # If the file is not analyzed or scanned for viruses yet, retry later - # (to avoid modifying the file while it is being scanned). - retry_on FileNotScannedYetError, wait: :exponentially_longer, attempts: 10 - - def perform(blob) - return if blob.watermark_done? - raise FileNotScannedYetError if blob.virus_scanner.pending? - - blob.open do |file| - Tempfile.create(["watermarked", File.extname(file)]) do |output| - processed = WatermarkService.new.process(file, output) - return if processed.blank? - - blob.upload(processed) # also update checksum & byte_size accordingly - blob.watermarked_at = Time.current - blob.save! - end - end - end -end diff --git a/app/models/concerns/attachment_auto_rotate_concern.rb b/app/models/concerns/attachment_auto_rotate_concern.rb deleted file mode 100644 index 9fba8a9ab..000000000 --- a/app/models/concerns/attachment_auto_rotate_concern.rb +++ /dev/null @@ -1,22 +0,0 @@ -module AttachmentAutoRotateConcern - extend ActiveSupport::Concern - - included do - after_create_commit :auto_rotate - end - - private - - def auto_rotate - return if blob.nil? - return if ["image/jpeg", "image/jpg"].exclude?(blob.content_type) - - blob.open do |file| - Tempfile.create(["rotated", File.extname(file)]) do |output| - processed = AutoRotateService.new.process(file, output) - blob.upload(processed) # also update checksum & byte_size accordingly - blob.save! - end - end - end -end diff --git a/app/models/concerns/attachment_image_processor_concern.rb b/app/models/concerns/attachment_image_processor_concern.rb new file mode 100644 index 000000000..d1c44609c --- /dev/null +++ b/app/models/concerns/attachment_image_processor_concern.rb @@ -0,0 +1,21 @@ +# Run a virus scan on all attachments after they are analyzed. +# +# We're using a class extension to ensure that all attachments get scanned, +# regardless on how they were created. This could be an ActiveStorage::Analyzer, +# but as of Rails 6.1 only the first matching analyzer is ever run on +# a blob (and we may want to analyze the dimension of a picture as well +# as scanning it). +module AttachmentImageProcessorConcern + extend ActiveSupport::Concern + + included do + after_create_commit :process_image + end + + private + + def process_image + return if blob.nil? + ImageProcessorJob.perform_later(blob) + end +end diff --git a/app/models/concerns/blob_titre_identite_watermark_concern.rb b/app/models/concerns/blob_image_processor_concern.rb similarity index 58% rename from app/models/concerns/blob_titre_identite_watermark_concern.rb rename to app/models/concerns/blob_image_processor_concern.rb index 152c512f9..46b56b955 100644 --- a/app/models/concerns/blob_titre_identite_watermark_concern.rb +++ b/app/models/concerns/blob_image_processor_concern.rb @@ -1,4 +1,4 @@ -module BlobTitreIdentiteWatermarkConcern +module BlobImageProcessorConcern def watermark_pending? watermark_required? && !watermark_done? end @@ -7,10 +7,8 @@ module BlobTitreIdentiteWatermarkConcern watermarked_at.present? end - def watermark_later - if watermark_pending? - TitreIdentiteWatermarkJob.perform_later(self) - end + def variant_required? + attachments.any? { _1.record.class == Champs::TitreIdentiteChamp || _1.record.class == Champs::PieceJustificativeChamp } end private diff --git a/app/services/auto_rotate_service.rb b/app/services/auto_rotate_service.rb index 39813c8c5..9cec29a95 100644 --- a/app/services/auto_rotate_service.rb +++ b/app/services/auto_rotate_service.rb @@ -1,7 +1,6 @@ class AutoRotateService def process(file, output) auto_rotate_image(file, output) - output end private @@ -9,6 +8,8 @@ class AutoRotateService def auto_rotate_image(file, output) image = MiniMagick::Image.new(file.to_path) + return nil if !image.valid? + case image["%[orientation]"] when 'LeftBottom' rotate_image(file, output, 90) @@ -16,6 +17,8 @@ class AutoRotateService rotate_image(file, output, 180) when 'RightTop' rotate_image(file, output, 270) + else + nil end end @@ -26,5 +29,6 @@ class AutoRotateService convert.auto_orient convert << output.to_path end + output end end diff --git a/config/initializers/active_storage.rb b/config/initializers/active_storage.rb index c20df224f..5598525ad 100644 --- a/config/initializers/active_storage.rb +++ b/config/initializers/active_storage.rb @@ -4,7 +4,7 @@ Rails.application.config.active_storage.analyzers.delete ActiveStorage::Analyzer Rails.application.config.active_storage.analyzers.delete ActiveStorage::Analyzer::VideoAnalyzer ActiveSupport.on_load(:active_storage_blob) do - include BlobTitreIdentiteWatermarkConcern + include BlobImageProcessorConcern include BlobVirusScannerConcern include BlobSignedIdConcern @@ -15,9 +15,8 @@ ActiveSupport.on_load(:active_storage_blob) do end ActiveSupport.on_load(:active_storage_attachment) do - include AttachmentTitreIdentiteWatermarkConcern + include AttachmentImageProcessorConcern include AttachmentVirusScannerConcern - include AttachmentAutoRotateConcern end Rails.application.reloader.to_prepare do diff --git a/spec/jobs/titre_identite_watermark_job_spec.rb b/spec/jobs/image_processor_job_spec.rb similarity index 97% rename from spec/jobs/titre_identite_watermark_job_spec.rb rename to spec/jobs/image_processor_job_spec.rb index ebb9497cc..e337a825f 100644 --- a/spec/jobs/titre_identite_watermark_job_spec.rb +++ b/spec/jobs/image_processor_job_spec.rb @@ -1,4 +1,4 @@ -describe TitreIdentiteWatermarkJob, type: :job do +describe ImageProcessorJob, type: :job do let(:blob) do ActiveStorage::Blob.create_and_upload!(io: StringIO.new("toto"), filename: "toto.png") end From 93c37611079c3d72a3494e7929cecf4abd41644c Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Tue, 7 May 2024 16:27:08 +0200 Subject: [PATCH 0183/1532] add specs --- app/jobs/image_processor_job.rb | 2 +- .../attachment_image_processor_concern.rb | 2 + .../instructeurs/dossiers_controller_spec.rb | 10 +- spec/fixtures/files/image-no-exif.jpg | Bin 0 -> 13240 bytes spec/fixtures/files/image-no-rotation.jpg | Bin 0 -> 15562 bytes spec/jobs/image_processor_job_spec.rb | 123 ++++++++++++++---- spec/services/auto_rotate_service_spec.rb | 22 +++- 7 files changed, 129 insertions(+), 30 deletions(-) create mode 100644 spec/fixtures/files/image-no-exif.jpg create mode 100644 spec/fixtures/files/image-no-rotation.jpg diff --git a/app/jobs/image_processor_job.rb b/app/jobs/image_processor_job.rb index 6fa027e93..1c37040fe 100644 --- a/app/jobs/image_processor_job.rb +++ b/app/jobs/image_processor_job.rb @@ -13,7 +13,7 @@ class ImageProcessorJob < ApplicationJob def perform(blob) return if blob.nil? raise FileNotScannedYetError if blob.virus_scanner.pending? - return if ActiveStorage::Attachment.find_by(blob_id: blob.id).record_type == "ActiveStorage::VariantRecord" + return if ActiveStorage::Attachment.find_by(blob_id: blob.id)&.record_type == "ActiveStorage::VariantRecord" auto_rotate(blob) if ["image/jpeg", "image/jpg"].include?(blob.content_type) create_variants(blob) if blob.variant_required? diff --git a/app/models/concerns/attachment_image_processor_concern.rb b/app/models/concerns/attachment_image_processor_concern.rb index d1c44609c..459e54d76 100644 --- a/app/models/concerns/attachment_image_processor_concern.rb +++ b/app/models/concerns/attachment_image_processor_concern.rb @@ -16,6 +16,8 @@ module AttachmentImageProcessorConcern def process_image return if blob.nil? + return if blob.attachments.any? { _1.record_type == "Export" } + ImageProcessorJob.perform_later(blob) end end diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index f4f065948..27a02ae7f 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -1429,13 +1429,13 @@ describe Instructeurs::DossiersController, type: :controller do describe '#pieces_jointes' do let(:procedure) { create(:procedure, :published, types_de_champ_public: [{ type: :piece_justificative }], instructeurs:) } let(:dossier) { create(:dossier, :en_construction, :with_populated_champs, procedure: procedure) } + let(:path) { 'spec/fixtures/files/logo_test_procedure.png' } before do dossier.champs.first.piece_justificative_file.attach( - io: StringIO.new("image file"), - filename: "image.jpeg", - content_type: "image/jpeg", - # we don't want to run virus scanner on this file + io: File.open(path), + filename: "logo_test_procedure.png", + content_type: "image/png", metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE } ) get :pieces_jointes, params: { @@ -1446,7 +1446,7 @@ describe Instructeurs::DossiersController, type: :controller do it do expect(response.body).to include('Télécharger le fichier toto.txt') - expect(response.body).to include('Télécharger le fichier image.jpeg') + expect(response.body).to include('Télécharger le fichier logo_test_procedure.png') expect(response.body).to include('Visualiser') end end diff --git a/spec/fixtures/files/image-no-exif.jpg b/spec/fixtures/files/image-no-exif.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4ec032c2b42702a40367058d6f4c15e33a6201e9 GIT binary patch literal 13240 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 G{(k`CXWfAS literal 0 HcmV?d00001 diff --git a/spec/jobs/image_processor_job_spec.rb b/spec/jobs/image_processor_job_spec.rb index e337a825f..9c520babd 100644 --- a/spec/jobs/image_processor_job_spec.rb +++ b/spec/jobs/image_processor_job_spec.rb @@ -3,26 +3,27 @@ describe ImageProcessorJob, type: :job do ActiveStorage::Blob.create_and_upload!(io: StringIO.new("toto"), filename: "toto.png") end + let(:blob_jpg) do + ActiveStorage::Blob.create_and_upload!(io: StringIO.new("toto"), filename: "toto.jpg") + end + + let(:attachment) { ActiveStorage::Attachment.new(name: "test", blob: blob) } let(:antivirus_pending) { false } let(:watermark_service) { instance_double("WatermarkService") } + let(:auto_rotate_service) { instance_double("AutoRotateService") } before do virus_scanner_mock = instance_double("ActiveStorage::VirusScanner", pending?: antivirus_pending) + allow(blob).to receive(:attachments).and_return([attachment]) + allow(blob).to receive(:virus_scanner).and_return(virus_scanner_mock) + allow(blob_jpg).to receive(:virus_scanner).and_return(virus_scanner_mock) allow(WatermarkService).to receive(:new).and_return(watermark_service) allow(watermark_service).to receive(:process).and_return(true) - end - context "when watermark is already done" do - before do - allow(blob).to receive(:watermark_done?).and_return(true) - end - - it "does not process the blob" do - expect(watermark_service).not_to receive(:process) - described_class.perform_now(blob) - end + allow(AutoRotateService).to receive(:new).and_return(auto_rotate_service) + allow(auto_rotate_service).to receive(:process).and_return(true) end context "when the blob is not scanned yet" do @@ -33,24 +34,102 @@ describe ImageProcessorJob, type: :job do end end - context "when the blob is ready to be watermarked" do - let(:watermarked_file) { Tempfile.new("watermarked.png") } + describe 'autorotate' do + context "when image is not a jpg" do + let(:rotated_file) { Tempfile.new("rotated.png") } - before do - allow(watermarked_file).to receive(:size).and_return(100) + before do + allow(rotated_file).to receive(:size).and_return(100) + end + + it "it does not process autorotate" do + expect(auto_rotate_service).not_to receive(:process) + described_class.perform_now(blob) + end end - it "processes the blob with watermark" do - expect(watermark_service).to receive(:process).and_return(watermarked_file) + context "when image is a jpg " do + let(:rotated_file) { Tempfile.new("rotated.jpg") } - expect { - described_class.perform_now(blob) - }.to change { - blob.reload.checksum + before do + allow(rotated_file).to receive(:size).and_return(100) + end + + it "it processes autorotate" do + expect(auto_rotate_service).to receive(:process).and_return(rotated_file) + described_class.perform_now(blob_jpg) + end + end + end + + describe 'create representation' do + let(:file) { fixture_file_upload('spec/fixtures/files/logo_test_procedure.png', 'image/png') } + let(:blob_info) do + { + filename: file.original_filename, + byte_size: file.size, + checksum: Digest::SHA256.file(file.path), + content_type: file.content_type, + # we don't want to run virus scanner on this file + metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE } } + end - expect(blob.byte_size).to eq(100) - expect(blob.watermarked_at).to be_present + let(:blob) do + blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_info) + blob.upload(file) + blob + end + + context "when representation is not required" do + it "it does not create blob representation" do + expect { described_class.perform_now(blob) }.not_to change { ActiveStorage::VariantRecord.count } + end + end + + context "when representation is required" do + before do + allow(blob).to receive(:variant_required?).and_return(true) + end + + it "it creates blob representation" do + expect { described_class.perform_now(blob) }.to change { ActiveStorage::VariantRecord.count }.by(2) + end + end + end + + describe 'watermark' do + context "when watermark is already done" do + before do + allow(blob).to receive(:watermark_done?).and_return(true) + end + + it "does not process the watermark" do + expect(watermark_service).not_to receive(:process) + described_class.perform_now(blob) + end + end + + context "when the blob is ready to be watermarked" do + let(:watermarked_file) { Tempfile.new("watermarked.png") } + + before do + allow(watermarked_file).to receive(:size).and_return(100) + allow(blob).to receive(:watermark_pending?).and_return(true) + end + + it "processes the blob with watermark" do + expect(watermark_service).to receive(:process).and_return(watermarked_file) + + expect { + described_class.perform_now(blob) + }.to change { + blob.reload.checksum + } + + expect(blob.byte_size).to eq(100) + expect(blob.watermarked_at).to be_present + end end end end diff --git a/spec/services/auto_rotate_service_spec.rb b/spec/services/auto_rotate_service_spec.rb index 9895be7d6..f8a007f76 100644 --- a/spec/services/auto_rotate_service_spec.rb +++ b/spec/services/auto_rotate_service_spec.rb @@ -1,14 +1,32 @@ RSpec.describe AutoRotateService do let(:image) { file_fixture("image-rotated.jpg") } + let(:image_no_exif) { file_fixture("image-no-exif.jpg") } + let(:image_no_rotation) { file_fixture("image-no-rotation.jpg") } let(:auto_rotate_service) { AutoRotateService.new } describe '#process' do it 'returns a tempfile if auto_rotate succeeds' do Tempfile.create do |output| - auto_rotate_service.process(image, output) + result = auto_rotate_service.process(image, output) expect(MiniMagick::Image.new(image.to_path)["%[orientation]"]).to eq('LeftBottom') expect(MiniMagick::Image.new(output.to_path)["%[orientation]"]).to eq('TopLeft') - expect(output.size).to be_between(image.size / 1.2, image.size) + expect(result.size).to be_between(image.size / 1.2, image.size) + end + end + + it 'returns nil if image does not need to be return' do + Tempfile.create do |output| + result = auto_rotate_service.process(image_no_rotation, output) + expect(MiniMagick::Image.new(image_no_rotation.to_path)["%[orientation]"]).to eq('TopLeft') + expect(result).to eq nil + end + end + + it 'returns nil if no exif info on image' do + Tempfile.create do |output| + result = auto_rotate_service.process(image_no_exif, output) + expect(MiniMagick::Image.new(image_no_exif.to_path)["%[orientation]"]).to eq('Undefined') + expect(result).to eq nil end end end From affb1820d801ed74cfae13e5f3ef5b6935de8779 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Tue, 7 May 2024 16:41:20 +0200 Subject: [PATCH 0184/1532] add PDF preview in view for gallery --- app/assets/images/apercu-indisponible.png | Bin 5467 -> 5193 bytes app/assets/images/pdf-placeholder.png | Bin 3581 -> 0 bytes app/jobs/image_processor_job.rb | 5 ++--- .../champs/piece_justificative_champ.rb | 4 +--- app/models/champs/titre_identite_champ.rb | 4 +--- .../concerns/blob_image_processor_concern.rb | 2 +- .../dossiers/pieces_jointes.html.haml | 6 +++--- .../piece_justificative/_show.html.haml | 6 +++--- spec/jobs/image_processor_job_spec.rb | 4 ++-- 9 files changed, 13 insertions(+), 18 deletions(-) delete mode 100644 app/assets/images/pdf-placeholder.png diff --git a/app/assets/images/apercu-indisponible.png b/app/assets/images/apercu-indisponible.png index 6bebd9f59de194d5d5eab6847ac31e31c5d5a4d4..e6c56638a09003ae21f4e970de493cdce4097c94 100644 GIT binary patch literal 5193 zcmeHLX*64HyN;=Eq!Se#)YJi0L#tZzT>4T|OHnGcMbnz6m$$FL->J83 z{yYql0097i!xrYIb^rih*8Tt<;6>uQG`{jK!hz<_AppQ}x&6UsVRwF=7vu}E`_lw~ z#Y-;nZUmvm*2VxpE%M;b10euFY{tUW_)Y{Lby^>Jesto%?2i}SUykQyeMptMf6iIs zNII%ftVSS3HW7F>E&-kn=Q|~uDSIVBD78a0wbsi=nIBbS4irg2#+K{<>ECsDFe#AjK ztBV`UCU)&Lg0k(yxw|aeF77U^ELkQlBGChLFzsnuBxB*C5u%qn5VtkpzE{^O@zg`! zNb4Po42Ls6Q4v650$}d#-YyMpt1Na_QkUVjKFf&JFu1;7)>!Ar^fcNYdVNumIK!T4^O5iEp8pKQxY4S$15~62a z&-3vU(?#Zrfg;U+T{NA|;?3F%i)!)0{{`nCy5HyGkI?mLP5#rb47fWCVO2CBL7+8= z(i2)e-5zNlA1L?hBme&|=#%qjL0kLK+DF90wpFEO5O4QB@(|R_=F3c0RsJ8CA@G*r z&fHN2)#O-0k+8^-<=S3pbhG59e)2xDz-mdWKXzB4hV^7^ExwQ4)6GF1?D{pNk)NuojimuQILij;=Ktmv}iI~I&N#uA?1VF8Vq~~my*`F0*1}>Nd3S9Y3eoM_ z`h3Z;i}nZ-#~v=UK^m}sO%h_*OJ$X025ft^o`Cs7I^urR4!t+r#nAxm^`5lmg2z#v zT?!#122y!y?!8s_G6%7otA(6i!#%a>R@ll;7j?2HMt388taNf&(jA+}!Q=6r1zHx0 zHUS;XvB;vA(LYiZFlBF|yla7b+XT3qI0jtITTb}!>45gy+Je)X{=UjrWnzEdZp$FL z!(-XAPmem%A~*UJXpa|kDZTut_ zzC)35>Vh-S$SCGk#+aY?1{1WjNikymyTz7E6^dojbDKf^qJ!jw z#3jARs`9Uf`HlE^=x$HxOehGy+~z8Xe^hhJ4%89+B5cVwJ_5_G$8WJ$s)?Nn5F-+P zs5jBzX#7PT#dP$T*J|RaKM;#F=kU^;*^2s&Dgwr9bavfq@+#&Q=j;MSYbp1c?}Zfe>+d|UZJM@lnisdy^Di`@UAf{M5NuO4|w?j{8 z(KGIPB3)s<(W1BIXm%|jXTEe$*~isp@69Czc-gl{yBnzW_e@FJ{eifFc1WHaN8P^F zvmiKzF|4gxv%f>yXl!j3_=Va9@8=!XsMERN(kWvmUYAydJ+y%0j@BPQ_c+@Hs`olr z?`gUhdMgw)T^;X2wu1U`4&MteRm!KhE+^g$!m}NlredNQ6Hx@OzP-rp`8>j>2+5U` zWc%UQMtKtvwyuNQIh83>gu)-KmI=~O%KONiA$-Jf_a-5lZfPVorKkRB+8hdgQk5I`C#I|MsFGSG!wHR94coQv!z%W;oO;55!&Eo^;caZ>`Zv`8veeXJV|$ z+NO_xbv?o-mfV(~p{>D>cxqt`mL{D-&fZ<(d8p6gKp(IBn&HC#Az&KAuD44Dyz=T7 zBuw3j`X~+cl-HXpu}`Ao6o%S6TsDUm)iLW^6r?T3*TbNYTIFZOc>e3I zPZi)+*y&B{Yjw7Z11VLDS_`2~Lt#*OV3Z&*Z6=mAJvwdASQN9XA;)p3qbj8o!1{CR zCVFPmHa?H@4>4OSTYct;UlEEmpF0*Ymvq8WNRx_+jV-QE!66Ve*kl^wQTpO!Pb2`6Tum_HGDsi5yFqq zJ+IYsFQB)c1kMM)Y~TBG4rv7umwuut&?lVsdK+`HlQk1PoaY{=tInvIYUvv@poH3j)v|`$5AxBVR)P76OO2f{GZC1cH<~B< z7+#vlNvFw3Y9kR}{jRR#TAV|#9AB;$@9P>zHyD7pxv!e=6(Fie|GE}H+67BwnD?3h#HaT7=Et3~(`Axo|0CUpZw+W~M6shqN} z+`IU?+#WUD_GoonBb`}0?x1ORD$VN|2je$2f`3c`gg{zCtH;ke@sTon$01IXvu);i z4}5hOMl+l=rQ=nV@flCcNC)0h68?~Wsk4kNQm*b%KL%oLm$Y5!JZb!?Hf9I5!u(L4^9=y0KRUSv4@7Go~zI9 zlvu9v{>qp(NwMhde`J>OdlJ{0Y70Dm;rfCq6F87`8VTw++xqNhgRqFI87QRZ@G)76 zlmYDY(vHSV^nCXpGOoLw*x0R>XcZlfKWX~WW#}P92xVf^j2Q$q(i8X)d7${26ZVFV z$GN|Qc6%$!bwXwu4?gl;JoyIxjyieGsLq<{Mv+*xj1TmWxqR74@PuNLub#lnRp1Vj z=x$3&=eru_G@9PT1al0izR`gjSz#>?`wzP-1S$t#Ejz zq>vj+>B-QYu|L{9D)z(D>7H}1w*o}ZlImI_iL6?&Bf4!~AA=D|sKsLyFfR?b2|ULO zntqWpGYWbjQb6)-mleV-z;1S%8~2SHgO;|epTd;ZI+qV=P%$EFw~qmIdeQ(T>; zH+|9ex9l(YOm9rk`#qN|nC9J9+3%II^c=dd1v?#UMW54h{4r7e?U?L7ozVuR(l-)h zr%eZw3x1`$2(!$945YF(7RWpPcfzfczU<(>fnR{j!L_dhyk#85E8~&S_R&pDTf)Xy z^IXVLA$&E$JNT3JQ5LVL6Xw1oPOdJB6P9>wNdI~L5YJI}8q@qs%`>3in1N(F5@RAl?1*!jOBXvP(Tn_~ zcad~=xZi!W4O`dg5iz1ct}HenR|j1}>JpOya~G6_fPpQCiUs~#*Jr9{;JemeE$?P? pnhzd~KMuM3?`F{dc@u2gy&!ly!(Lq|g4g*2SeV(EVomNR{u8#TD=Pp1 literal 5467 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 diff --git a/app/assets/images/pdf-placeholder.png b/app/assets/images/pdf-placeholder.png deleted file mode 100644 index 58da75f9f728e70eb30af89f205a263ddc1aa1bb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3581 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 { validate_champ_value? || validation_context == :prefill } validates :piece_justificative_file, diff --git a/app/models/champs/titre_identite_champ.rb b/app/models/champs/titre_identite_champ.rb index 667feca7e..6d95f4d43 100644 --- a/app/models/champs/titre_identite_champ.rb +++ b/app/models/champs/titre_identite_champ.rb @@ -2,9 +2,7 @@ class Champs::TitreIdentiteChamp < Champ FILE_MAX_SIZE = 20.megabytes ACCEPTED_FORMATS = ['image/png', 'image/jpeg'] - has_many_attached :piece_justificative_file do |attachable| - attachable.variant :medium, resize: '400x400' - end + has_many_attached :piece_justificative_file # TODO: if: -> { validate_champ_value? || validation_context == :prefill } validates :piece_justificative_file, content_type: ACCEPTED_FORMATS, size: { less_than: FILE_MAX_SIZE } diff --git a/app/models/concerns/blob_image_processor_concern.rb b/app/models/concerns/blob_image_processor_concern.rb index 46b56b955..3b92ad4e4 100644 --- a/app/models/concerns/blob_image_processor_concern.rb +++ b/app/models/concerns/blob_image_processor_concern.rb @@ -7,7 +7,7 @@ module BlobImageProcessorConcern watermarked_at.present? end - def variant_required? + def representation_required? attachments.any? { _1.record.class == Champs::TitreIdentiteChamp || _1.record.class == Champs::PieceJustificativeChamp } end diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml index a0b1816a1..1d08707a3 100644 --- a/app/views/instructeurs/dossiers/pieces_jointes.html.haml +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -5,13 +5,13 @@ .fr-container .gallery.gallery-pieces-jointes{ "data-controller": "lightbox" } - @champs_with_pieces_jointes.each do |champ| - - champ.piece_justificative_file.each do |attachment| + - champ.piece_justificative_file.with_all_variant_records.each do |attachment| .gallery-item - blob = attachment.blob - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do .thumbnail - = image_tag("pdf-placeholder.png") + = image_tag(attachment.representation(resize_to_limit: [400, 400]).processed.url, loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } Visualiser .champ-libelle @@ -21,7 +21,7 @@ - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail - = image_tag(attachment.variant(:medium), loading: :lazy) + = image_tag(attachment.representation(resize_to_limit: [400, 400]).processed.url, loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } Visualiser .champ-libelle diff --git a/app/views/shared/champs/piece_justificative/_show.html.haml b/app/views/shared/champs/piece_justificative/_show.html.haml index 50218e54b..670e9e011 100644 --- a/app/views/shared/champs/piece_justificative/_show.html.haml +++ b/app/views/shared/champs/piece_justificative/_show.html.haml @@ -5,20 +5,20 @@ %li= render Attachment::ShowComponent.new(attachment:, new_tab: true) - else .gallery-items-list - - champ.piece_justificative_file.attachments.each do |attachment| + - champ.piece_justificative_file.attachments.with_all_variant_records.each do |attachment| .gallery-item - blob = attachment.blob - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do .thumbnail - = image_tag("pdf-placeholder.png") + = image_tag(attachment.representation(resize_to_limit: [400, 400]).processed.url, loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } = 'Visualiser' - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail - = image_tag(attachment.variant(:medium), loading: :lazy) + = image_tag(attachment.representation(resize_to_limit: [400, 400]).processed.url, loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } = 'Visualiser' - else diff --git a/spec/jobs/image_processor_job_spec.rb b/spec/jobs/image_processor_job_spec.rb index 9c520babd..e32999273 100644 --- a/spec/jobs/image_processor_job_spec.rb +++ b/spec/jobs/image_processor_job_spec.rb @@ -89,11 +89,11 @@ describe ImageProcessorJob, type: :job do context "when representation is required" do before do - allow(blob).to receive(:variant_required?).and_return(true) + allow(blob).to receive(:representation_required?).and_return(true) end it "it creates blob representation" do - expect { described_class.perform_now(blob) }.to change { ActiveStorage::VariantRecord.count }.by(2) + expect { described_class.perform_now(blob) }.to change { ActiveStorage::VariantRecord.count }.by(1) end end end From 7cfd9becc8817cb8183e10d1649e594c09210e23 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Thu, 16 May 2024 12:54:09 +0200 Subject: [PATCH 0185/1532] avoid performing job if attachment is created on existed blob --- app/models/concerns/attachment_image_processor_concern.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/attachment_image_processor_concern.rb b/app/models/concerns/attachment_image_processor_concern.rb index 459e54d76..25d4a3a09 100644 --- a/app/models/concerns/attachment_image_processor_concern.rb +++ b/app/models/concerns/attachment_image_processor_concern.rb @@ -16,7 +16,8 @@ module AttachmentImageProcessorConcern def process_image return if blob.nil? - return if blob.attachments.any? { _1.record_type == "Export" } + return if blob.attachments.size > 1 + return if blob.attachments.last.record_type == "Export" ImageProcessorJob.perform_later(blob) end From 602cf9290148e6cfafd20dd823e1f9889b33b249 Mon Sep 17 00:00:00 2001 From: Christian Lautier <15379878+maatinito@users.noreply.github.com> Date: Tue, 23 Apr 2024 09:39:33 -1000 Subject: [PATCH 0186/1532] Champ editor: Move champ header to footer --- .../stylesheets/procedure_champs_editor.scss | 33 ++++++++--------- .../champ_component/champ_component.html.haml | 36 +++++++++---------- .../select_champ_position_component.rb | 2 +- .../select_champ_position_component.html.haml | 1 - 4 files changed, 33 insertions(+), 39 deletions(-) diff --git a/app/assets/stylesheets/procedure_champs_editor.scss b/app/assets/stylesheets/procedure_champs_editor.scss index 6f1db8ac1..0423a6faf 100644 --- a/app/assets/stylesheets/procedure_champs_editor.scss +++ b/app/assets/stylesheets/procedure_champs_editor.scss @@ -21,11 +21,19 @@ .type-de-champ-container { width: 100%; border: 1px solid var(--border-default-grey); + padding-top: 12px; + border-left-width: 4px; border-radius: 5px; - margin-bottom: $default-padding; + margin-bottom: 3 * $default-spacer; box-shadow: 0px 2px 4px -4px; } + &.type-header-section { + .type-de-champ-container { + border-left: 4px solid var(--background-action-high-blue-france); + } + } + .handle { cursor: grab; @@ -49,31 +57,18 @@ display: none; } - .head { - select { - margin-bottom: 0px; - } - } - - &.type-header-section { - .head { - background-color: var(--background-contrast-blue-cumulus); - } - } - .flex { &.flex-gap { column-gap: $default-spacer * 2; } &.section { - margin-bottom: 8px; - padding: $default-spacer / 2 $default-spacer * 2; - } + padding: $default-spacer $default-spacer * 2; - &.head { - border-bottom: 1px solid var(--border-default-grey); - padding: $default-spacer / 2 $default-spacer; // due to no-outline button horizontal padding, don't add twice the padding so it's aligned with section + &.footer { + padding: 1.5 * $default-spacer $default-spacer * 2; + border-top: 1px solid var(--border-default-grey); + } } } diff --git a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml index 26f4c5882..980bd064c 100644 --- a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml +++ b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml @@ -1,25 +1,10 @@ -%li.type-de-champ.flex.column.justify-start.fr-mb-6w{ html_options } +%li.type-de-champ.flex.column.justify-start.fr-mb-5v{ html_options } .type-de-champ-container - .flex.justify-between.section.head - .position.flex.align-center= (@coordinate.position + 1).to_s - %button.fr-btn.fr-btn--tertiary-no-outline.fr-icon-arrow-up-line.move-up{ move_button_options(:up) } - %button.fr-btn.fr-btn--tertiary-no-outline.fr-icon-arrow-down-line.move-down{ move_button_options(:down) } - = render TypesDeChampEditor::SelectChampPositionComponent.new(revision:, coordinate:) - - .flex.right - - if coordinate.used_by_routing_rules? - %span - utilisé pour - = link_to('le routage', admin_procedure_groupe_instructeurs_path(revision.procedure_id, anchor: 'routing-rules')) - - else - = button_to type_de_champ_path, class: 'fr-btn fr-btn--tertiary-no-outline fr-icon-delete-line', title: "Supprimer le champ", method: :delete, form: { data: { turbo_confirm: 'Êtes vous sûr de vouloir supprimer ce champ ?' } } do - %span.sr-only Supprimer - - if @errors.present? .types-de-champ-errors = @errors - .flex.justify-start.section + .flex.justify-start.section.head = form_for(type_de_champ, form_options) do |form| .flex.justify-start.flex-gap .flex.justify-start.width-33 @@ -132,7 +117,7 @@ = form.select :character_limit, options_for_character_limit, {}, { id: dom_id(type_de_champ, :character_limit), class: 'fr-select' } - if type_de_champ.block? - .flex.justify-start.section.fr-ml-1w.fr-mb-2w + .flex.justify-start.section.fr-ml-1w .editor-block.flex-grow.cell = render TypesDeChampEditor::BlockComponent.new(block: coordinate, coordinates: coordinate.revision_types_de_champ, upper_coordinates: @upper_coordinates) .type-de-champ-add-button{ id: dom_id(coordinate, :type_de_champ_add_button), class: class_names(hidden: !coordinate.empty?) } @@ -140,5 +125,20 @@ = render(Conditions::ChampsConditionsComponent.new(tdc: type_de_champ, upper_tdcs: @upper_coordinates.map(&:type_de_champ), procedure_id: procedure.id)) + .flex.justify-between.section.footer + .position.flex.align-center= (@coordinate.position + 1).to_s + %button.fr-btn.fr-btn--tertiary-no-outline.fr-icon-arrow-up-line.move-up{ move_button_options(:up) } + %button.fr-btn.fr-btn--tertiary-no-outline.fr-icon-arrow-down-line.move-down{ move_button_options(:down) } + = render TypesDeChampEditor::SelectChampPositionComponent.new(revision:, coordinate:) + + .flex.right + - if coordinate.used_by_routing_rules? + %span + utilisé pour + = link_to('le routage', admin_procedure_groupe_instructeurs_path(revision.procedure_id, anchor: 'routing-rules')) + - else + = button_to type_de_champ_path, class: 'fr-btn fr-btn--tertiary-no-outline fr-icon-delete-line', title: "Supprimer le champ", method: :delete, form: { data: { turbo_confirm: 'Êtes vous sûr de vouloir supprimer ce champ ?' } } do + %span.sr-only Supprimer + .type-de-champ-add-button{ class: class_names(root: !coordinate.child?, flex: true) } = render TypesDeChampEditor::AddChampButtonComponent.new(revision: coordinate.revision, parent: coordinate&.parent, is_annotation: coordinate.private?, after_stable_id: type_de_champ.stable_id) diff --git a/app/components/types_de_champ_editor/select_champ_position_component.rb b/app/components/types_de_champ_editor/select_champ_position_component.rb index 22b06c806..898c48556 100644 --- a/app/components/types_de_champ_editor/select_champ_position_component.rb +++ b/app/components/types_de_champ_editor/select_champ_position_component.rb @@ -5,7 +5,7 @@ class TypesDeChampEditor::SelectChampPositionComponent < ApplicationComponent end def options - [["Sélectionner une option", @coordinate.stable_id]] + [["Déplacer le champ après", @coordinate.stable_id]] end def describedby_id diff --git a/app/components/types_de_champ_editor/select_champ_position_component/select_champ_position_component.html.haml b/app/components/types_de_champ_editor/select_champ_position_component/select_champ_position_component.html.haml index 0c7f0314a..41a651644 100644 --- a/app/components/types_de_champ_editor/select_champ_position_component/select_champ_position_component.html.haml +++ b/app/components/types_de_champ_editor/select_champ_position_component/select_champ_position_component.html.haml @@ -1,3 +1,2 @@ = form_with(url: move_and_morph_admin_procedure_type_de_champ_path(@coordinate.revision.procedure, @coordinate.type_de_champ.stable_id), class: 'fr-ml-3w flex', method: :patch, data: { turbo: true }) do |f| - = label_tag :target_stable_id, "Déplacer le champ après ", for: describedby_id, class: 'flex align-center flex-no-shrink fr-mr-3w fr-label' = select_tag :target_stable_id, options_for_select(options), id: describedby_id, class: 'fr-select', data: { 'select-champ-position-template-target': 'select', selected: @coordinate.stable_id } From 4ea601de79fffeb1080a6f4aff974f7ea3ea22ab Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 21 May 2024 09:50:27 +0200 Subject: [PATCH 0187/1532] fix(champs.pj.clone): stop cloning private piece_justificative_file when user clone his dossier --- app/models/champ.rb | 2 +- spec/models/champ_spec.rb | 40 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/app/models/champ.rb b/app/models/champ.rb index cbff3b54a..92fbca7d0 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -238,7 +238,7 @@ class Champ < ApplicationRecord kopy.write_attribute(:stable_id, original.stable_id) kopy.write_attribute(:stream, 'main') end - ClonePiecesJustificativesService.clone_attachments(original, kopy) + ClonePiecesJustificativesService.clone_attachments(original, kopy) if fork || !private? end end diff --git a/spec/models/champ_spec.rb b/spec/models/champ_spec.rb index 229b440a4..edde59295 100644 --- a/spec/models/champ_spec.rb +++ b/spec/models/champ_spec.rb @@ -569,4 +569,44 @@ describe Champ do it { expect(ActionView::RecordIdentifier.dom_id(champ.type_de_champ)).to eq("type_de_champ_#{champ.type_de_champ.id}") } it { expect(ActionView::RecordIdentifier.dom_class(champ)).to eq("champ") } end + + describe 'clone' do + subject { champ.clone(fork) } + + context 'when champ public' do + let(:champ) { create(:champ_piece_justificative, private: false) } + + context 'when fork' do + let(:fork) { true } + it do + expect(subject.piece_justificative_file).to be_attached + end + end + + context 'when not fork' do + let(:fork) { false } + it do + expect(subject.piece_justificative_file).to be_attached + end + end + end + + context 'champ private' do + let(:champ) { create(:champ_piece_justificative, private: true) } + + context 'when fork' do + let(:fork) { true } + it do + expect(subject.piece_justificative_file).to be_attached + end + end + + context 'when not fork' do + let(:fork) { false } + it do + expect(subject.piece_justificative_file).not_to be_attached + end + end + end + end end From 684cbe77458447b65bd435ab04bcda9a17680a1c Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 21 May 2024 11:53:07 +0200 Subject: [PATCH 0188/1532] chore(bundle): rails 7.0.8.1 => 7.0.8.3 --- Gemfile.lock | 122 +++++++++++++++++++++++++-------------------------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 87e3bcb90..51281c712 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -12,47 +12,47 @@ GEM aasm (5.5.0) concurrent-ruby (~> 1.0) acsv (0.0.1) - actioncable (7.0.8.1) - actionpack (= 7.0.8.1) - activesupport (= 7.0.8.1) + actioncable (7.0.8.3) + actionpack (= 7.0.8.3) + activesupport (= 7.0.8.3) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.8.1) - actionpack (= 7.0.8.1) - activejob (= 7.0.8.1) - activerecord (= 7.0.8.1) - activestorage (= 7.0.8.1) - activesupport (= 7.0.8.1) + actionmailbox (7.0.8.3) + actionpack (= 7.0.8.3) + activejob (= 7.0.8.3) + activerecord (= 7.0.8.3) + activestorage (= 7.0.8.3) + activesupport (= 7.0.8.3) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.8.1) - actionpack (= 7.0.8.1) - actionview (= 7.0.8.1) - activejob (= 7.0.8.1) - activesupport (= 7.0.8.1) + actionmailer (7.0.8.3) + actionpack (= 7.0.8.3) + actionview (= 7.0.8.3) + activejob (= 7.0.8.3) + activesupport (= 7.0.8.3) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.8.1) - actionview (= 7.0.8.1) - activesupport (= 7.0.8.1) + actionpack (7.0.8.3) + actionview (= 7.0.8.3) + activesupport (= 7.0.8.3) rack (~> 2.0, >= 2.2.4) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.8.1) - actionpack (= 7.0.8.1) - activerecord (= 7.0.8.1) - activestorage (= 7.0.8.1) - activesupport (= 7.0.8.1) + actiontext (7.0.8.3) + actionpack (= 7.0.8.3) + activerecord (= 7.0.8.3) + activestorage (= 7.0.8.3) + activesupport (= 7.0.8.3) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.8.1) - activesupport (= 7.0.8.1) + actionview (7.0.8.3) + activesupport (= 7.0.8.3) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -67,26 +67,26 @@ GEM activemodel (>= 5.2.0) activestorage (>= 5.2.0) activesupport (>= 5.2.0) - activejob (7.0.8.1) - activesupport (= 7.0.8.1) + activejob (7.0.8.3) + activesupport (= 7.0.8.3) globalid (>= 0.3.6) - activemodel (7.0.8.1) - activesupport (= 7.0.8.1) - activerecord (7.0.8.1) - activemodel (= 7.0.8.1) - activesupport (= 7.0.8.1) - activestorage (7.0.8.1) - actionpack (= 7.0.8.1) - activejob (= 7.0.8.1) - activerecord (= 7.0.8.1) - activesupport (= 7.0.8.1) + activemodel (7.0.8.3) + activesupport (= 7.0.8.3) + activerecord (7.0.8.3) + activemodel (= 7.0.8.3) + activesupport (= 7.0.8.3) + activestorage (7.0.8.3) + actionpack (= 7.0.8.3) + activejob (= 7.0.8.3) + activerecord (= 7.0.8.3) + activesupport (= 7.0.8.3) marcel (~> 1.0) mini_mime (>= 1.1.0) activestorage-openstack (1.6.0) fog-openstack (>= 1.0.9) marcel rails (>= 5.2.2) - activesupport (7.0.8.1) + activesupport (7.0.8.3) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -329,7 +329,7 @@ GEM highline (3.0.1) htmlentities (4.3.4) http_accept_language (2.1.1) - i18n (1.14.4) + i18n (1.14.5) concurrent-ruby (~> 1.0) i18n-tasks (1.0.13) activesupport (>= 4.0.2) @@ -427,7 +427,7 @@ GEM job-iteration (>= 1.3.6) railties (>= 6.0) zeitwerk (>= 2.6.2) - marcel (1.0.2) + marcel (1.0.4) matrix (0.4.2) memory_profiler (1.0.1) method_source (1.1.0) @@ -439,23 +439,23 @@ GEM mini_magick (4.12.0) mini_mime (1.1.5) mini_portile2 (2.8.6) - minitest (5.22.3) + minitest (5.23.0) msgpack (1.7.2) multi_json (1.15.0) mustermann (3.0.0) ruby2_keywords (~> 0.0.1) net-http (0.4.1) uri - net-imap (0.4.10) + net-imap (0.4.11) date net-protocol net-pop (0.1.2) net-protocol net-protocol (0.2.2) timeout - net-smtp (0.4.0.1) + net-smtp (0.5.0) net-protocol - nio4r (2.7.1) + nio4r (2.7.3) nokogiri (1.16.5) mini_portile2 (~> 2.8.2) racc (~> 1.4) @@ -508,7 +508,7 @@ GEM pundit (2.3.1) activesupport (>= 3.0.0) raabro (1.4.0) - racc (1.7.3) + racc (1.8.0) rack (2.2.9) rack-attack (6.7.0) rack (>= 1.0, < 4) @@ -531,20 +531,20 @@ GEM rack_session_access (0.2.0) builder (>= 2.0.0) rack (>= 1.0.0) - rails (7.0.8.1) - actioncable (= 7.0.8.1) - actionmailbox (= 7.0.8.1) - actionmailer (= 7.0.8.1) - actionpack (= 7.0.8.1) - actiontext (= 7.0.8.1) - actionview (= 7.0.8.1) - activejob (= 7.0.8.1) - activemodel (= 7.0.8.1) - activerecord (= 7.0.8.1) - activestorage (= 7.0.8.1) - activesupport (= 7.0.8.1) + rails (7.0.8.3) + actioncable (= 7.0.8.3) + actionmailbox (= 7.0.8.3) + actionmailer (= 7.0.8.3) + actionpack (= 7.0.8.3) + actiontext (= 7.0.8.3) + actionview (= 7.0.8.3) + activejob (= 7.0.8.3) + activemodel (= 7.0.8.3) + activerecord (= 7.0.8.3) + activestorage (= 7.0.8.3) + activesupport (= 7.0.8.3) bundler (>= 1.15.0) - railties (= 7.0.8.1) + railties (= 7.0.8.3) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -567,9 +567,9 @@ GEM rails-pg-extras (5.3.1) rails ruby-pg-extras (= 5.3.1) - railties (7.0.8.1) - actionpack (= 7.0.8.1) - activesupport (= 7.0.8.1) + railties (7.0.8.3) + actionpack (= 7.0.8.3) + activesupport (= 7.0.8.3) method_source rake (>= 12.2) thor (~> 1.0) @@ -872,7 +872,7 @@ GEM anyway_config (>= 1.3, < 3) sidekiq yabeda (~> 0.6) - zeitwerk (2.6.13) + zeitwerk (2.6.14) zip_tricks (5.6.0) zipline (1.5.0) actionpack (>= 6.0, < 8.0) From 29ef5b313ce14445900647959845e5e604d87e36 Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 21 May 2024 14:21:21 +0200 Subject: [PATCH 0189/1532] fix(data): clean Champs::PieceJustificativeChamp for annotations that had been cloned --- app/models/champ.rb | 1 + ...hamps_private_piece_justificatives_task.rb | 34 ++++++++++++ ..._private_piece_justificatives_task_spec.rb | 54 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 app/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task.rb create mode 100644 spec/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task_spec.rb diff --git a/app/models/champ.rb b/app/models/champ.rb index 92fbca7d0..2cb0bb4ec 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -39,6 +39,7 @@ class Champ < ApplicationRecord :departement?, :region?, :textarea?, + :piece_justificative?, :titre_identite?, :header_section?, :checkbox?, diff --git a/app/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task.rb b/app/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task.rb new file mode 100644 index 000000000..ad32ece93 --- /dev/null +++ b/app/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Maintenance + class BackfillClonedChampsPrivatePieceJustificativesTask < MaintenanceTasks::Task + def collection + Dossier.en_brouillon.where.not(parent_dossier_id: nil) + end + + def process(cloned_dossier) + cloned_dossier.champs_private + .filter { checkable_pj?(_1, cloned_dossier) } + .map do |cloned_champ| + parent_champ = cloned_dossier.parent_dossier + .champs_private + .find { _1.stable_id == cloned_champ.stable_id } + + next if !parent_champ + + parent_blob_ids = parent_champ.piece_justificative_file.map(&:blob_id) + cloned_blob_ids = cloned_champ.piece_justificative_file.map(&:blob_id) + + if parent_blob_ids.sort == cloned_blob_ids.sort + cloned_champ.piece_justificative_file.detach + end + end + end + + def checkable_pj?(champ, dossier) + return false if champ.type != "Champs::PieceJustificativeChamp" + return false if !champ.piece_justificative_file.attached? + true + end + end +end diff --git a/spec/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task_spec.rb b/spec/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task_spec.rb new file mode 100644 index 000000000..2029c1991 --- /dev/null +++ b/spec/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "rails_helper" + +module Maintenance + RSpec.describe BackfillClonedChampsPrivatePieceJustificativesTask do + describe "#process" do + let(:procedure) { create(:procedure, types_de_champ_private:) } + let(:types_de_champ_private) { [{ type: :piece_justificative }, { type: :text }] } + + let(:parent_dossier) { create(:dossier, procedure:) } + let(:cloned_dossier) { create(:dossier, procedure:) } + + let(:parent_champ_pj) { parent_dossier.champs_private.find(&:piece_justificative?) } + let(:cloned_champ_pj) { cloned_dossier.champs_private.find(&:piece_justificative?) } + + before do + cloned_dossier.update(parent_dossier:) # used on factorie, does not seed private_champs.. + parent_champ_pj.piece_justificative_file.attach( + io: StringIO.new("x" * 2), + filename: "me.jpg", + content_type: "image/png", + metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE } + ) + end + + subject { described_class.process(cloned_dossier) } + + context 'when dossier and parent have the same pjs' do + it 'detaches sames blob between parent_dossier and dossier' do + cloned_champ_pj.piece_justificative_file.attach(parent_champ_pj.piece_justificative_file.first.blob) + + subject + expect(cloned_champ_pj.reload.piece_justificative_file.attached?).to be_falsey + end + end + + context 'when dossier and parent have different pjs' do + it 'keeps different blobs between parent_dossier and dossier' do + cloned_champ_pj.piece_justificative_file.attach( + io: StringIO.new("x" * 2), + filename: "me.jpg", + content_type: "image/png", + metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE } + ) + + described_class.process(cloned_dossier) + subject + expect(cloned_champ_pj.reload.piece_justificative_file.attached?).to be_truthy + end + end + end + end +end From dd81baabe26744b2c1178e2f9087c991a9b69444 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Fri, 26 Apr 2024 19:54:37 +0200 Subject: [PATCH 0190/1532] chore(ts): improuve some types --- .../MapEditor/components/CadastreLayer.tsx | 2 +- .../MapEditor/components/DrawLayer.tsx | 55 +++++++++++++------ .../MapReader/components/GeoJSONLayer.tsx | 10 ++-- .../components/shared/maplibre/hooks.ts | 14 +++-- 4 files changed, 53 insertions(+), 28 deletions(-) diff --git a/app/javascript/components/MapEditor/components/CadastreLayer.tsx b/app/javascript/components/MapEditor/components/CadastreLayer.tsx index f2f0b7086..c8203d032 100644 --- a/app/javascript/components/MapEditor/components/CadastreLayer.tsx +++ b/app/javascript/components/MapEditor/components/CadastreLayer.tsx @@ -87,7 +87,7 @@ export function CadastreLayer({ }); const onHighlight = useCallback( - ({ detail }) => { + ({ detail }: CustomEvent<{ cid: string; highlight: boolean }>) => { highlightFeature(detail.cid, detail.highlight); }, [highlightFeature] diff --git a/app/javascript/components/MapEditor/components/DrawLayer.tsx b/app/javascript/components/MapEditor/components/DrawLayer.tsx index 82f6223ef..63d29767c 100644 --- a/app/javascript/components/MapEditor/components/DrawLayer.tsx +++ b/app/javascript/components/MapEditor/components/DrawLayer.tsx @@ -1,7 +1,7 @@ import { useCallback, useRef, useEffect } from 'react'; -import type { LngLatBoundsLike } from 'maplibre-gl'; +import type { LngLatBoundsLike, LngLatLike } from 'maplibre-gl'; import DrawControl from '@mapbox/mapbox-gl-draw'; -import type { FeatureCollection } from 'geojson'; +import type { FeatureCollection, Feature, Point } from 'geojson'; import { useMapLibre } from '../../shared/maplibre/MapLibre'; import { @@ -78,15 +78,24 @@ export function DrawLayer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [map, enabled]); - const onSetId = useCallback(({ detail }) => { - drawRef.current?.setFeatureProperty(detail.lid, 'id', detail.id); - }, []); - const onAddFeature = useCallback(({ detail }) => { - drawRef.current?.add(detail.feature); - }, []); - const onDeleteFature = useCallback(({ detail }) => { - drawRef.current?.delete(detail.id); - }, []); + const onSetId = useCallback( + ({ detail }: CustomEvent<{ lid: string; id: string }>) => { + drawRef.current?.setFeatureProperty(detail.lid, 'id', detail.id); + }, + [] + ); + const onAddFeature = useCallback( + ({ detail }: CustomEvent<{ feature: Feature }>) => { + drawRef.current?.add(detail.feature); + }, + [] + ); + const onDeleteFature = useCallback( + ({ detail }: CustomEvent<{ id: string }>) => { + drawRef.current?.delete(detail.id); + }, + [] + ); useMapEvent('draw.create', createFeatures); useMapEvent('draw.update', updateFeatures); @@ -122,7 +131,7 @@ function useExternalEvents( const flyTo = useFlyTo(); const onFeatureFocus = useCallback( - ({ detail }) => { + ({ detail }: CustomEvent<{ id: string; bbox: LngLatBoundsLike }>) => { const { id, bbox } = detail; if (id) { const feature = findFeature(featureCollection, id); @@ -137,16 +146,26 @@ function useExternalEvents( ); const onZoomFocus = useCallback( - ({ detail }) => { + ({ + detail + }: CustomEvent<{ + feature: Feature; + featureCollection: FeatureCollection; + }>) => { if (detail.feature && detail.featureCollection == featureCollection) { - flyTo(17, detail.feature.geometry.coordinates); + flyTo(17, detail.feature.geometry.coordinates as LngLatLike); } }, [flyTo, featureCollection] ); const onFeatureCreate = useCallback( - ({ detail }) => { + ({ + detail + }: CustomEvent<{ + feature: Feature; + featureCollection: FeatureCollection; + }>) => { const { feature } = detail; const { geometry, properties } = feature; if ( @@ -164,7 +183,9 @@ function useExternalEvents( ); const onFeatureUpdate = useCallback( - ({ detail }) => { + ({ + detail + }: CustomEvent<{ id: string; properties: Feature['properties'] }>) => { const { id, properties } = detail; const feature = findFeature(featureCollection, id); @@ -177,7 +198,7 @@ function useExternalEvents( ); const onFeatureDelete = useCallback( - ({ detail }) => { + ({ detail }: CustomEvent<{ id: string }>) => { const { id } = detail; const feature = findFeature(featureCollection, id); diff --git a/app/javascript/components/MapReader/components/GeoJSONLayer.tsx b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx index 50024e16a..add1dca03 100644 --- a/app/javascript/components/MapReader/components/GeoJSONLayer.tsx +++ b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo } from 'react'; -import { Popup, LngLatBoundsLike } from 'maplibre-gl'; -import type { Feature, FeatureCollection } from 'geojson'; +import { Popup, LngLatBoundsLike, LngLatLike } from 'maplibre-gl'; +import type { Feature, FeatureCollection, Point } from 'geojson'; import { useMapLibre } from '../../shared/maplibre/MapLibre'; import { @@ -102,7 +102,7 @@ function useExternalEvents(featureCollection: FeatureCollection) { const fitBounds = useFitBounds(); const flyTo = useFlyTo(); const onFeatureFocus = useCallback( - ({ detail }) => { + ({ detail }: CustomEvent<{ id: string }>) => { const { id } = detail; const feature = findFeature(featureCollection, id); if (feature) { @@ -112,10 +112,10 @@ function useExternalEvents(featureCollection: FeatureCollection) { [featureCollection, fitBounds] ); const onZoomFocus = useCallback( - ({ detail }) => { + ({ detail }: CustomEvent<{ feature: Feature }>) => { const { feature } = detail; if (feature) { - flyTo(17, feature.geometry.coordinates); + flyTo(17, feature.geometry.coordinates as LngLatLike); } }, [flyTo] diff --git a/app/javascript/components/shared/maplibre/hooks.ts b/app/javascript/components/shared/maplibre/hooks.ts index 9b5acb875..84f0542a1 100644 --- a/app/javascript/components/shared/maplibre/hooks.ts +++ b/app/javascript/components/shared/maplibre/hooks.ts @@ -9,7 +9,8 @@ import type { LngLatBoundsLike, LngLat, MapLayerEventType, - Style + Style, + LngLatLike } from 'maplibre-gl'; import type { Feature, Geometry } from 'geojson'; @@ -39,17 +40,20 @@ export function useFitBoundsNoFly() { export function useFlyTo() { const map = useMapLibre(); return useCallback( - (zoom: number, center: [number, number]) => { + (zoom: number, center: LngLatLike) => { map.flyTo({ zoom, center }); }, [map] ); } -export function useEvent(eventName: string, callback: EventListener) { +export function useEvent( + eventName: string, + callback: (event: CustomEvent) => void +) { return useEffect(() => { - addEventListener(eventName, callback); - return () => removeEventListener(eventName, callback); + addEventListener(eventName, callback as EventListener); + return () => removeEventListener(eventName, callback as EventListener); }, [eventName, callback]); } From ff98fd351f400281fbbb28aaf2b54e487e754c64 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 22 May 2024 09:21:25 +0200 Subject: [PATCH 0191/1532] chore(bun): add interactive update command --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b42172e48..5cb3a13e7 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,8 @@ "graphql:doc:build": "RAILS_ENV=production bin/rake graphql:schema:idl && spectaql spectaql_config.yml", "postinstall": "patch-package", "test": "vitest", - "coverage": "vitest run --coverage" + "coverage": "vitest run --coverage", + "up": "bunx npm-check-updates --root --format group -i" }, "resolutions": { "string-width": "4.2.2", From 47fe6ccf6c95af64e2adbc06f18abc9772a30019 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 22 May 2024 13:57:47 +0200 Subject: [PATCH 0192/1532] fix(gallery): keep variants but rollback pdf previews --- app/assets/images/pdf-placeholder.png | Bin 0 -> 3581 bytes .../dossiers/pieces_jointes.html.haml | 4 ++-- .../champs/piece_justificative/_show.html.haml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 app/assets/images/pdf-placeholder.png diff --git a/app/assets/images/pdf-placeholder.png b/app/assets/images/pdf-placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..58da75f9f728e70eb30af89f205a263ddc1aa1bb GIT binary patch literal 3581 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 Date: Wed, 22 May 2024 16:43:07 +0200 Subject: [PATCH 0193/1532] chore(sidekiq): transition ImageProcessorJob to sidekiq --- config/initializers/transition_to_sidekiq.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/initializers/transition_to_sidekiq.rb b/config/initializers/transition_to_sidekiq.rb index a4c8e961a..3b5360b6a 100644 --- a/config/initializers/transition_to_sidekiq.rb +++ b/config/initializers/transition_to_sidekiq.rb @@ -91,5 +91,9 @@ if Rails.env.production? && SIDEKIQ_ENABLED class SendClosingNotificationJob < ApplicationJob self.queue_adapter = :sidekiq end + + class ImageProcessorJob < ApplicationJob + self.queue_adapter = :sidekiq + end end end From 93ad0f4bda3ab72b0ef5c8e8b334b376e0753275 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Sat, 2 Mar 2024 22:41:05 +0100 Subject: [PATCH 0194/1532] [tiptap] convert tiptap json to path --- app/services/tiptap_service.rb | 21 +++++++++++++++++++++ spec/services/tiptap_service_spec.rb | 16 ++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/app/services/tiptap_service.rb b/app/services/tiptap_service.rb index ce372d39f..5d0cb4325 100644 --- a/app/services/tiptap_service.rb +++ b/app/services/tiptap_service.rb @@ -5,6 +5,12 @@ class TiptapService children(node[:content], substitutions, 0) end + def to_path(node, substitutions = {}) + return '' if node.nil? + + children_path(node[:content], substitutions) + end + # NOTE: node must be deep symbolized keys def used_tags_and_libelle_for(node, tags = Set.new) case node @@ -25,6 +31,21 @@ class TiptapService @body_started = false end + def children_path(content, substitutions) + content.map { node_to_path(_1, substitutions) }.join + end + + def node_to_path(node, substitutions) + case node + in type: 'paragraph', content: + children_path(content, substitutions) + in type: 'text', text:, **rest + text.strip + in type: 'mention', attrs: { id: }, **rest + substitutions.fetch(id) { "--#{id}--" } + end + end + def children(content, substitutions, level) content.map { node_to_html(_1, substitutions, level) }.join end diff --git a/spec/services/tiptap_service_spec.rb b/spec/services/tiptap_service_spec.rb index 14196fb53..da47220f2 100644 --- a/spec/services/tiptap_service_spec.rb +++ b/spec/services/tiptap_service_spec.rb @@ -192,4 +192,20 @@ RSpec.describe TiptapService do expect(described_class.new.used_tags_and_libelle_for(json)).to eq(Set.new([['name', 'Nom']])) end end + + describe '.to_path' do + let(:substitutions) { { "dossier_number" => "42" } } + let(:json) do + { + "content" => [ + { "type" => "paragraph", "content" => [{ "text" => "export_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " .pdf", "type" => "text" }] } + ] + + }.deep_symbolize_keys + end + + it 'returns path' do + expect(described_class.new.to_path(json, substitutions)).to eq("export_42.pdf") + end + end end From 474eb18016d50a41e6289fa26402d5a83dd9deda Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Sat, 2 Mar 2024 21:47:48 +0100 Subject: [PATCH 0195/1532] add export template migration --- db/migrate/20240130154452_create_export_templates.rb | 12 ++++++++++++ db/schema.rb | 11 +++++++++++ 2 files changed, 23 insertions(+) create mode 100644 db/migrate/20240130154452_create_export_templates.rb diff --git a/db/migrate/20240130154452_create_export_templates.rb b/db/migrate/20240130154452_create_export_templates.rb new file mode 100644 index 000000000..f1306af2f --- /dev/null +++ b/db/migrate/20240130154452_create_export_templates.rb @@ -0,0 +1,12 @@ +class CreateExportTemplates < ActiveRecord::Migration[7.0] + def change + create_table :export_templates do |t| + t.string :name, null: false + t.string :kind, null: false + t.jsonb :content, default: {} + t.belongs_to :groupe_instructeur, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index b936bf6b3..054c32caa 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -593,6 +593,16 @@ ActiveRecord::Schema[7.0].define(version: 2024_04_17_053843) do t.index ["procedure_id"], name: "index_experts_procedures_on_procedure_id" end + create_table "export_templates", force: :cascade do |t| + t.jsonb "content", default: {} + t.datetime "created_at", null: false + t.bigint "groupe_instructeur_id", null: false + t.string "kind", null: false + t.string "name", null: false + t.datetime "updated_at", null: false + t.index ["groupe_instructeur_id"], name: "index_export_templates_on_groupe_instructeur_id" + end + create_table "exports", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.integer "dossiers_count" @@ -1224,6 +1234,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_04_17_053843) do add_foreign_key "experts", "users" add_foreign_key "experts_procedures", "experts" add_foreign_key "experts_procedures", "procedures" + add_foreign_key "export_templates", "groupe_instructeurs" add_foreign_key "exports", "instructeurs" add_foreign_key "france_connect_informations", "users" add_foreign_key "geo_areas", "champs" From d1c3b84ea217bc524faf4b7b5d5ef172360176ee Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Sat, 2 Mar 2024 22:13:09 +0100 Subject: [PATCH 0196/1532] add export template model --- app/models/export_template.rb | 125 ++++++++++++++++++++++++++++ app/models/groupe_instructeur.rb | 1 + app/models/instructeur.rb | 1 + app/models/procedure.rb | 1 + spec/factories/export_template.rb | 34 ++++++++ spec/models/export_template_spec.rb | 76 +++++++++++++++++ 6 files changed, 238 insertions(+) create mode 100644 app/models/export_template.rb create mode 100644 spec/factories/export_template.rb create mode 100644 spec/models/export_template_spec.rb diff --git a/app/models/export_template.rb b/app/models/export_template.rb new file mode 100644 index 000000000..30897d081 --- /dev/null +++ b/app/models/export_template.rb @@ -0,0 +1,125 @@ +class ExportTemplate < ApplicationRecord + include TagsSubstitutionConcern + + belongs_to :groupe_instructeur + has_one :procedure, through: :groupe_instructeur + + DOSSIER_STATE = Dossier.states.fetch(:en_construction) + + def tiptap_default_dossier_directory=(body) + self.content["default_dossier_directory"] = JSON.parse(body) + end + + def tiptap_default_dossier_directory + tiptap_content("default_dossier_directory") + end + + def tiptap_pdf_name=(body) + self.content["pdf_name"] = JSON.parse(body) + end + + def tiptap_pdf_name + tiptap_content("pdf_name") + end + + def attachment_and_path(dossier, attachment, index: 0, row_index: nil) + [ + attachment, + path(dossier, attachment, index, row_index) + ] + end + + def tiptap_convert(dossier, param) + if content[param]["content"]&.first&.[]("content") + render_attributes_for(content[param], dossier) + end + end + + def tiptap_convert_pj(dossier, pj_stable_id) + if content_for_pj_id(pj_stable_id)["content"]&.first["content"] + render_attributes_for(content_for_pj_id(pj_stable_id), dossier) + end + end + + def render_attributes_for(content_for, dossier) + tiptap = TiptapService.new + used_tags = tiptap.used_tags_and_libelle_for(content_for.deep_symbolize_keys) + substitutions = tags_substitutions(used_tags, dossier, escape: false) + tiptap.to_path(content_for.deep_symbolize_keys, substitutions) + end + + + def folder(dossier) + render_attributes_for(content["default_dossier_directory"], dossier) + end + + def export_path(dossier) + File.join(folder(dossier), export_filename(dossier)) + end + + def export_filename(dossier) + "#{render_attributes_for(content["pdf_name"], dossier)}.pdf" + end + + private + + def tiptap_content(key) + content[key]&.to_json + end + + def tiptap_json(prefix) + { + "type" => "doc", + "content" => [ + { "type" => "paragraph", "content" => [{ "text" => prefix, "type" => "text" }, { "type" => "mention", "attrs" => DOSSIER_ID_TAG.stringify_keys }] } + ] + } + end + + def content_for_pj_id(stable_id) + content_for_stable_id = content["pjs"].find { _1.symbolize_keys[:stable_id] == stable_id.to_s } + content_for_stable_id.symbolize_keys.fetch(:path) + end + + + def path(dossier, attachment, index, row_index) + if attachment.name == 'pdf_export_for_instructeur' + return export_path(dossier) + end + + dir_path = case attachment.record_type + when 'Dossier' + 'dossier' + when 'Commentaire' + 'messagerie' + when 'Avis' + 'avis' + else + # for attachment + return attachment_path(dossier, attachment, index, row_index) + end + + File.join(folder(dossier), dir_path, attachment.filename.to_s) + end + + def attachment_path(dossier, attachment, index, row_index) + type_de_champ_id = dossier.champs.find(attachment.record_id).type_de_champ_id + stable_id = TypeDeChamp.find(type_de_champ_id).stable_id + tiptap_pj = content["pjs"].find { |pj| pj["stable_id"] == stable_id.to_s } + if tiptap_pj + File.join(folder(dossier), tiptap_convert_pj(dossier, stable_id) + suffix(attachment, index, row_index)) + else + File.join(folder(dossier), "erreur_renommage", attachment.filename.to_s) + end + end + + def suffix(attachment, index, row_index) + suffix = "" + if index >= 1 && !row_index.nil? + suffix += "-#{index + 1}" + suffix += "-#{row_index + 1}" if row_index + end + + suffix + attachment.filename.extension_with_delimiter + end +end diff --git a/app/models/groupe_instructeur.rb b/app/models/groupe_instructeur.rb index b0b165b1e..bd9aad659 100644 --- a/app/models/groupe_instructeur.rb +++ b/app/models/groupe_instructeur.rb @@ -9,6 +9,7 @@ class GroupeInstructeur < ApplicationRecord has_many :batch_operations, through: :dossiers, source: :batch_operations has_many :assignments, class_name: 'DossierAssignment', dependent: :nullify, inverse_of: :groupe_instructeur has_many :previous_assignments, class_name: 'DossierAssignment', dependent: :nullify, inverse_of: :previous_groupe_instructeur + has_many :export_templates has_and_belongs_to_many :exports, dependent: :destroy has_one :defaut_procedure, -> { with_discarded }, class_name: 'Procedure', foreign_key: :defaut_groupe_instructeur_id, dependent: :nullify, inverse_of: :defaut_groupe_instructeur diff --git a/app/models/instructeur.rb b/app/models/instructeur.rb index 707e4da98..0b724d337 100644 --- a/app/models/instructeur.rb +++ b/app/models/instructeur.rb @@ -14,6 +14,7 @@ class Instructeur < ApplicationRecord has_many :batch_operations, dependent: :nullify has_many :assign_to_with_email_notifications, -> { with_email_notifications }, class_name: 'AssignTo', inverse_of: :instructeur has_many :groupe_instructeur_with_email_notifications, through: :assign_to_with_email_notifications, source: :groupe_instructeur + has_many :export_templates, through: :groupe_instructeurs has_many :commentaires, inverse_of: :instructeur, dependent: :nullify has_many :dossiers, -> { state_not_brouillon }, through: :unordered_groupe_instructeurs diff --git a/app/models/procedure.rb b/app/models/procedure.rb index fe7005599..aa0c5695c 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -153,6 +153,7 @@ class Procedure < ApplicationRecord has_many :administrateurs, through: :administrateurs_procedures, after_remove: -> (procedure, _admin) { procedure.validate! } has_many :groupe_instructeurs, -> { order(:label) }, inverse_of: :procedure, dependent: :destroy has_many :instructeurs, through: :groupe_instructeurs + has_many :export_templates, through: :groupe_instructeurs has_many :active_groupe_instructeurs, -> { active }, class_name: 'GroupeInstructeur', inverse_of: false has_many :closed_groupe_instructeurs, -> { closed }, class_name: 'GroupeInstructeur', inverse_of: false diff --git a/spec/factories/export_template.rb b/spec/factories/export_template.rb new file mode 100644 index 000000000..0f4e8d882 --- /dev/null +++ b/spec/factories/export_template.rb @@ -0,0 +1,34 @@ +FactoryBot.define do + factory :export_template do + name { "Mon export" } + groupe_instructeur + content { + { + "pdf_name" => + { + "type" => "doc", + "content" => [ + { "type" => "paragraph", "content" => [{ "text" => "export_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_id", "label" => "id dossier" } }, { "text" => " .pdf", "type" => "text" }] } + ] + }, + "default_dossier_directory" => + { + "type" => "doc", + "content" => + [ + { + "type" => "paragraph", + "content" => + [ + { "text" => "dossier_", "type" => "text" }, + { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, + { "text" => " ", "type" => "text" } + ] + } + ] + } + } +} + kind { "zip" } + end +end diff --git a/spec/models/export_template_spec.rb b/spec/models/export_template_spec.rb new file mode 100644 index 000000000..32a3330d5 --- /dev/null +++ b/spec/models/export_template_spec.rb @@ -0,0 +1,76 @@ +describe ExportTemplate do + let(:groupe_instructeur) { create(:groupe_instructeur, procedure:) } + let(:export_template) { build(:export_template, groupe_instructeur:, content:) } + let(:procedure) { create(:procedure_with_dossiers) } + let(:content) do + { + "pdf_name" => { + "type" => "doc", + "content" => [ + { "type" => "paragraph", "content" => [{ "text" => "mon_export_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] } + ] + }, + "default_dossier_directory" => { + "type" => "doc", + "content" => [ + { "type" => "paragraph", "content" => [{ "text" => "DOSSIER_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] } + ] + }, + "pjs" => + [ + {path: {"type"=>"doc", "content"=>[{"type"=>"paragraph", "content"=>[{"type"=>"mention", "attrs"=>{"id"=>"dossier_number", "label"=>"numéro du dossier"}}, {"text"=>" _justif", "type"=>"text"}]}]}, stable_id: "3"}, + { path: + {"type"=>"doc", "content"=>[{"type"=>"paragraph", "content"=>[{"text"=>"cni_", "type"=>"text"}, {"type"=>"mention", "attrs"=>{"id"=>"dossier_number", "label"=>"numéro du dossier"}}, {"text"=>" ", "type"=>"text"}]}]}, + stable_id: "5"}, + { path: {"type"=>"doc", "content"=>[{"type"=>"paragraph", "content"=>[{"text"=>"pj_repet_", "type"=>"text"}, {"type"=>"mention", "attrs"=>{"id"=>"dossier_number", "label"=>"numéro du dossier"}}, {"text"=>" ", "type"=>"text"}]}]}, + stable_id: "10"} + ] + } + end + + describe 'new' do + let(:export_template) { build(:export_template, groupe_instructeur: groupe_instructeur) } + let(:procedure) { create(:procedure, types_de_champ_public:) } + let(:types_de_champ_public) do + [ + { type: :integer_number, stable_id: 900 }, + { type: :piece_justificative, libelle: "Justificatif de domicile", mandatory: true, stable_id: 910 } + ] + end + end + + describe '#tiptap_default_dossier_directory' do + it 'returns tiptap_default_dossier_directory from content' do + expect(export_template.tiptap_default_dossier_directory).to eq({ + "type" => "doc", + "content" => [ + { "type" => "paragraph", "content" => [{ "text" => "DOSSIER_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] } + ] + }.to_json) + end + end + + describe '#tiptap_pdf_name' do + it 'returns tiptap_pdf_name from content' do + expect(export_template.tiptap_pdf_name).to eq({ + "type" => "doc", + "content" => [ + { "type" => "paragraph", "content" => [{ "text" => "mon_export_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] } + ] + }.to_json) + end + end + + describe '#attachment_and_path' do + let(:dossier) { create(:dossier) } + + context 'for export pdf' do + let(:attachment) { double("attachment") } + + it 'gives absolute filename for export of specific dossier' do + allow(attachment).to receive(:name).and_return('pdf_export_for_instructeur') + expect(export_template.attachment_and_path(dossier, attachment)).to eq([attachment, "DOSSIER_#{dossier.id}/mon_export_#{dossier.id}.pdf"]) + end + end + end +end From 24922605cdfaff2a43dc7dad15df42d2110d5b6d Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Sun, 3 Mar 2024 10:03:12 +0100 Subject: [PATCH 0197/1532] add pj for export template --- app/models/export_template.rb | 9 +++++ spec/models/export_template_spec.rb | 62 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/app/models/export_template.rb b/app/models/export_template.rb index 30897d081..eaa753415 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -22,6 +22,15 @@ class ExportTemplate < ApplicationRecord tiptap_content("pdf_name") end + def content_for_pj(pj) + content_for_pj_id(pj.stable_id)&.to_json + end + + def content_for_pj_id(stable_id) + content_for_stable_id = content["pjs"].find { _1.symbolize_keys[:stable_id] == stable_id.to_s } + content_for_stable_id.symbolize_keys.fetch(:path) + end + def attachment_and_path(dossier, attachment, index: 0, row_index: nil) [ attachment, diff --git a/spec/models/export_template_spec.rb b/spec/models/export_template_spec.rb index 32a3330d5..9a2a29aaf 100644 --- a/spec/models/export_template_spec.rb +++ b/spec/models/export_template_spec.rb @@ -61,6 +61,22 @@ describe ExportTemplate do end end + describe '#content_for_pj' do + let(:type_de_champ_pj) { create(:type_de_champ_piece_justificative, stable_id: 3, libelle: 'Justificatif de domicile', procedure:) } + let(:champ_pj) { create(:champ_piece_justificative, type_de_champ: type_de_champ_pj) } + + let(:attachment) { ActiveStorage::Attachment.new(name: 'pj', record: champ_pj, blob: ActiveStorage::Blob.new(filename: "superpj.png")) } + + it 'returns tiptap content for pj' do + expect(export_template.content_for_pj(type_de_champ_pj)).to eq({ + "type"=>"doc", + "content"=> [ + {"type"=>"paragraph", "content"=>[{"type"=>"mention", "attrs"=>{"id"=>"dossier_number", "label"=>"numéro du dossier"}}, {"text"=>" _justif", "type"=>"text"}]} + ] + }.to_json) + end + end + describe '#attachment_and_path' do let(:dossier) { create(:dossier) } @@ -72,5 +88,51 @@ describe ExportTemplate do expect(export_template.attachment_and_path(dossier, attachment)).to eq([attachment, "DOSSIER_#{dossier.id}/mon_export_#{dossier.id}.pdf"]) end end + + context 'for pj' do + let(:dossier) { procedure.dossiers.first } + let(:type_de_champ_pj) { create(:type_de_champ_piece_justificative, stable_id: 3, procedure:) } + let(:champ_pj) { create(:champ_piece_justificative, type_de_champ: type_de_champ_pj) } + + let(:attachment) { ActiveStorage::Attachment.new(name: 'pj', record: champ_pj, blob: ActiveStorage::Blob.new(filename: "superpj.png")) } + + before do + dossier.champs_public << champ_pj + end + it 'returns pj and custom name for pj' do + expect(export_template.attachment_and_path(dossier, attachment)).to eq([attachment, "DOSSIER_#{dossier.id}/#{dossier.id}_justif.png"]) + end + end + context 'pj repetable' do + let(:procedure) do + create(:procedure_with_dossiers, :for_individual, types_de_champ_public: [{ type: :repetition, mandatory: true, children: [{ libelle: 'sub type de champ' }] }]) + end + let(:type_de_champ_repetition) do + repetition = draft.types_de_champ_public.repetition.first + repetition.update(stable_id: 3333) + repetition + end + let(:draft) { procedure.draft_revision } + let(:dossier) { procedure.dossiers.first } + + let(:type_de_champ_pj) do + draft.add_type_de_champ({ + type_champ: TypeDeChamp.type_champs.fetch(:piece_justificative), + libelle: "pj repet", + stable_id: 10, + parent_stable_id: type_de_champ_repetition.stable_id + }) + end + let(:champ_pj) { create(:champ_piece_justificative, type_de_champ: type_de_champ_pj) } + + let(:attachment) { ActiveStorage::Attachment.new(name: 'pj', record: champ_pj, blob: ActiveStorage::Blob.new(filename: "superpj.png")) } + + before do + dossier.champs_public << champ_pj + end + it 'rename repetable pj' do + expect(export_template.attachment_and_path(dossier, attachment)).to eq([attachment, "DOSSIER_#{dossier.id}/pj_repet_#{dossier.id}.png"]) + end + end end end From 25ab2420fec0a69d4f6424021df7a81fe9ef0af0 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Sun, 3 Mar 2024 10:11:26 +0100 Subject: [PATCH 0198/1532] validate export template --- app/models/export_template.rb | 1 + app/validators/export_template_validator.rb | 54 +++++++++ spec/models/export_template_spec.rb | 119 ++++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 app/validators/export_template_validator.rb diff --git a/app/models/export_template.rb b/app/models/export_template.rb index eaa753415..5345116bb 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -3,6 +3,7 @@ class ExportTemplate < ApplicationRecord belongs_to :groupe_instructeur has_one :procedure, through: :groupe_instructeur + validates_with ExportTemplateValidator DOSSIER_STATE = Dossier.states.fetch(:en_construction) diff --git a/app/validators/export_template_validator.rb b/app/validators/export_template_validator.rb new file mode 100644 index 000000000..1f3475040 --- /dev/null +++ b/app/validators/export_template_validator.rb @@ -0,0 +1,54 @@ +class ExportTemplateValidator < ActiveModel::Validator + def validate(record) + validate_default_dossier_directory(record) + validate_pdf_name(record) + validate_pjs(record) + end + + private + + def validate_default_dossier_directory(record) + mention = attribute_content_mention(record, :default_dossier_directory) + if mention&.fetch("id", nil) != "dossier_number" + record.errors.add :tiptap_default_dossier_directory, :dossier_number_mandatory + end + end + + def validate_pdf_name(record) + if attribute_content_text(record, :pdf_name).blank? && attribute_content_mention(record, :pdf_name).blank? + record.errors.add :tiptap_pdf_name, :blank + end + end + + def attribute_content_text(record, attribute) + attribute_content(record, attribute)&.find { |elem| elem["type"] == "text" }&.fetch("text", nil) + end + + def attribute_content_mention(record, attribute) + attribute_content(record, attribute)&.find { |elem| elem["type"] == "mention" }&.fetch("attrs", nil) + end + + def attribute_content(record, attribute) + content = record.content[attribute.to_s]&.fetch("content", nil) + if content.is_a?(Array) + content.first&.fetch("content", nil) + end + end + + def validate_pjs(record) + record.content["pjs"]&.each do |pj| + pj_sym = pj.symbolize_keys + libelle = record.groupe_instructeur.procedure.pieces_jointes_exportables_list.find { _1.stable_id.to_s == pj_sym[:stable_id] }&.libelle&.to_sym + validate_content(record, pj_sym[:path], libelle) + end + end + + def validate_content(record, attribute_content, attribute) + if attribute_content.nil? || attribute_content["content"].nil? || + attribute_content["content"].first.nil? || + attribute_content["content"].first["content"].nil? || + (attribute_content["content"].first["content"].find { |elem| elem["text"].blank? } && attribute_content["content"].first["content"].find { |elem| elem["type"] == "mention" }["attrs"].blank?) + record.errors.add attribute, I18n.t(:blank, scope: 'errors.messages') + end + end +end diff --git a/spec/models/export_template_spec.rb b/spec/models/export_template_spec.rb index 9a2a29aaf..d8df04760 100644 --- a/spec/models/export_template_spec.rb +++ b/spec/models/export_template_spec.rb @@ -135,4 +135,123 @@ describe ExportTemplate do end end end + + describe '#valid?' do + let(:subject) { build(:export_template, content:) } + let(:ddd_text) { "DoSSIER" } + let(:mention) { { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } } } + let(:ddd_mention) { mention } + let(:pdf_text) { "export" } + let(:pdf_mention) { mention } + let(:pj_text) { "_pj" } + let(:pj_mention) { mention } + let(:content) do + { + "pdf_name" => { + "type" => "doc", + "content" => [ + { "type" => "paragraph", "content" => [{ "text" => pdf_text, "type" => "text" }, pdf_mention] } + ] + }, + "default_dossier_directory" => { + "type" => "doc", + "content" => [ + { "type" => "paragraph", "content" => [{ "text" => ddd_text, "type" => "text" }, ddd_mention] } + ] + }, + "pjs" => + [ + { path: { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [pj_mention, { "text" => pj_text, "type" => "text" }] }] }, stable_id: "3" } + ] + } + end + + context 'with valid default dossier directory' do + it 'has no error for default_dossier_directory' do + expect(subject.valid?).to be_truthy + expect(subject.errors[:default_dossier_directory]).not_to be_present + end + end + + context 'with no ddd text' do + let(:ddd_text) { " " } + context 'with mention' do + let(:ddd_mention) { { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } } } + it 'has no error for default_dossier_directory' do + expect(subject.valid?).to be_truthy + expect(subject.errors[:default_dossier_directory]).not_to be_present + end + end + + context 'without mention' do + let(:ddd_mention) { { "type" => "mention", "attrs" => {} } } + it "add error for default_dossier_directory" do + expect(subject.valid?).to be_falsey + expect(subject.errors[:default_dossier_directory]).to be_present + end + end + + context 'with mention but without numéro de dossier' do + let(:ddd_mention) { { "type" => "mention", "attrs" => { "id" => 'dossier_service_name', "label" => "nom du service" } } } + it "add error for default_dossier_directory" do + expect(subject.valid?).to be_falsey + expect(subject.errors[:default_dossier_directory]).to be_present + end + end + end + + context 'with valid pdf name' do + it 'has no error for pdf name' do + expect(subject.valid?).to be_truthy + expect(subject.errors[:pdf_name]).not_to be_present + end + end + + context 'with pdf text and without mention' do + let(:pdf_text) { "export" } + let(:pdf_mention) { { "type" => "mention", "attrs" => {} } } + + it "add no error" do + expect(subject.valid?).to be_truthy + end + end + + context 'with no pdf text' do + let(:pdf_text) { " " } + + context 'with mention' do + it 'has no error for default_dossier_directory' do + expect(subject.valid?).to be_truthy + expect(subject.errors[:default_dossier_directory]).not_to be_present + end + end + + context 'without mention' do + let(:pdf_mention) { { "type" => "mention", "attrs" => {} } } + it "add error for pdf name" do + expect(subject.valid?).to be_falsey + expect(subject.errors[:pdf_name]).to be_present + end + end + end + + context 'with no pj text' do + let(:pj_text) { " " } + + context 'with mention' do + it 'has no error for pj' do + expect(subject.valid?).to be_truthy + expect(subject.errors[:pj_3]).not_to be_present + end + end + + context 'without mention' do + let(:pj_mention) { { "type" => "mention", "attrs" => {} } } + it "add error for pj" do + expect(subject.valid?).to be_falsey + expect(subject.errors[:pj_3]).to be_present + end + end + end + end end From bbb6309b4f67f81cdff5ea2c4a92a091434965aa Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Sun, 3 Mar 2024 10:27:48 +0100 Subject: [PATCH 0199/1532] procedure: add pieces_jointes_exportables_list --- app/models/procedure.rb | 26 ++++++++++++++++++++++---- app/models/procedure_revision.rb | 1 + spec/models/procedure_spec.rb | 26 +++++++++++++++++++++++--- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index aa0c5695c..02b1a3d15 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -992,6 +992,12 @@ class Procedure < ApplicationRecord end end + def pieces_jointes_exportables_list + pieces_jointes_list(with_private: true, with_titre_identite: false, with_repetition_parent: false) do |base_scope| + base_scope + end.flatten + end + def pieces_jointes_list_with_conditionnal pieces_jointes_list do |base_scope| base_scope.where.not(types_de_champ: { condition: nil }) @@ -1025,15 +1031,27 @@ class Procedure < ApplicationRecord private - def pieces_jointes_list - scope = yield active_revision.revision_types_de_champ_public + def pieces_jointes_list(with_private: false, with_titre_identite: true, with_repetition_parent: true) + types_de_champ = with_private ? + active_revision.revision_types_de_champ_private_and_public : + active_revision.revision_types_de_champ_public + + type_champs = ['repetition', 'piece_justificative'] + type_champs << 'titre_identite' if with_titre_identite + + scope = yield types_de_champ .includes(:type_de_champ, revision_types_de_champ: :type_de_champ) - .where(types_de_champ: { type_champ: ['repetition', 'piece_justificative', 'titre_identite'] }) + .where(types_de_champ: { type_champ: [type_champs] }) scope.each_with_object([]) do |rtdc, list| if rtdc.type_de_champ.repetition? rtdc.revision_types_de_champ.each do |rtdc_in_repetition| - list << [rtdc_in_repetition.type_de_champ, rtdc.type_de_champ] if rtdc_in_repetition.type_de_champ.piece_justificative? + if rtdc_in_repetition.type_de_champ.piece_justificative? + to_add = [] + to_add << rtdc_in_repetition.type_de_champ + to_add << rtdc.type_de_champ if with_repetition_parent + list << to_add + end end else list << [rtdc.type_de_champ] diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index c8daa1bec..8326c6533 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -8,6 +8,7 @@ class ProcedureRevision < ApplicationRecord has_many :revision_types_de_champ, -> { order(:position, :id) }, class_name: 'ProcedureRevisionTypeDeChamp', foreign_key: :revision_id, dependent: :destroy, inverse_of: :revision has_many :revision_types_de_champ_public, -> { root.public_only.ordered }, class_name: 'ProcedureRevisionTypeDeChamp', foreign_key: :revision_id, dependent: :destroy, inverse_of: :revision has_many :revision_types_de_champ_private, -> { root.private_only.ordered }, class_name: 'ProcedureRevisionTypeDeChamp', foreign_key: :revision_id, dependent: :destroy, inverse_of: :revision + has_many :revision_types_de_champ_private_and_public, -> { root.ordered }, class_name: 'ProcedureRevisionTypeDeChamp', foreign_key: :revision_id, dependent: :destroy, inverse_of: :revision has_many :types_de_champ, through: :revision_types_de_champ, source: :type_de_champ has_many :types_de_champ_public, through: :revision_types_de_champ_public, source: :type_de_champ has_many :types_de_champ_private, through: :revision_types_de_champ_private, source: :type_de_champ diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 01f937995..8b036cddc 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -1748,13 +1748,23 @@ describe Procedure do describe '#pieces_jointes_list' do include Logic - let(:procedure) { create(:procedure, types_de_champ_public:) } + let(:procedure) { create(:procedure, types_de_champ_public:, types_de_champ_private:) } let(:types_de_champ_public) do [ { type: :integer_number, stable_id: 900 }, { type: :piece_justificative, libelle: "PJ", mandatory: true, stable_id: 910 }, { type: :piece_justificative, libelle: "PJ-cond", mandatory: true, stable_id: 911, condition: ds_eq(champ_value(900), constant(1)) }, - { type: :repetition, libelle: "Répétition", stable_id: 920, children: [{ type: :piece_justificative, libelle: "PJ2", stable_id: 921 }] } + { type: :repetition, libelle: "Répétition", stable_id: 920, children: [{ type: :piece_justificative, libelle: "PJ2", stable_id: 921 }] }, + { type: :titre_identite, libelle: "CNI", mandatory: true, stable_id: 930 } + ] + end + + let(:types_de_champ_private) do + [ + { type: :integer_number, stable_id: 950 }, + { type: :piece_justificative, libelle: "PJ", mandatory: true, stable_id: 960 }, + { type: :piece_justificative, libelle: "PJ-cond", mandatory: true, stable_id: 961, condition: ds_eq(champ_value(900), constant(1)) }, + { type: :repetition, libelle: "Répétition", stable_id: 970, children: [{ type: :piece_justificative, libelle: "PJ2", stable_id: 971 }] } ] end @@ -1762,14 +1772,24 @@ describe Procedure do let(:pjcond) { procedure.active_revision.types_de_champ.find { _1.stable_id == 911 } } let(:repetition) { procedure.active_revision.types_de_champ.find { _1.stable_id == 920 } } let(:pj2) { procedure.active_revision.types_de_champ.find { _1.stable_id == 921 } } + let(:pj3) { procedure.active_revision.types_de_champ.find { _1.stable_id == 930 } } + + let(:pj5) { procedure.active_revision.types_de_champ.find { _1.stable_id == 960 } } + let(:pjcond2) { procedure.active_revision.types_de_champ.find { _1.stable_id == 961 } } + let(:repetition2) { procedure.active_revision.types_de_champ.find { _1.stable_id == 970 } } + let(:pj6) { procedure.active_revision.types_de_champ.find { _1.stable_id == 971 } } it "returns the list of pieces jointes without conditional" do - expect(procedure.pieces_jointes_list_without_conditionnal).to match_array([[pj1], [pj2, repetition]]) + expect(procedure.pieces_jointes_list_without_conditionnal).to match_array([[pj1], [pj2, repetition], [pj3]]) end it "returns the list of pieces jointes having conditional" do expect(procedure.pieces_jointes_list_with_conditionnal).to match_array([[pjcond]]) end + + it "returns the list of pieces jointes with private, without parent repetition, without titre identite" do + expect(procedure.pieces_jointes_exportables_list).to match_array([pj1, pj2, pjcond, pj5, pjcond2, pj6]) + end end describe "#attestation_template" do From dbf46b1f029b56964a491cb104ea71540efac612 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Sun, 3 Mar 2024 10:22:58 +0100 Subject: [PATCH 0200/1532] extract DOSSIER_ID_TAG --- .../concerns/tags_substitution_concern.rb | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/models/concerns/tags_substitution_concern.rb b/app/models/concerns/tags_substitution_concern.rb index 8bdc51705..8b0e50b1c 100644 --- a/app/models/concerns/tags_substitution_concern.rb +++ b/app/models/concerns/tags_substitution_concern.rb @@ -61,6 +61,15 @@ module TagsSubstitutionConcern end end + DOSSIER_ID_TAG = { + id: 'dossier_number', + label: 'numéro du dossier', + libelle: 'numéro du dossier', + description: '', + target: :id, + available_for_states: Dossier::SOUMIS + } + DOSSIER_TAGS = [ { id: 'dossier_motivation', @@ -98,13 +107,6 @@ module TagsSubstitutionConcern lambda: -> (d) { d.procedure.libelle }, available_for_states: Dossier::SOUMIS }, - { - id: 'dossier_number', - libelle: 'numéro du dossier', - description: '', - target: :id, - available_for_states: Dossier::SOUMIS - }, { id: 'dossier_service_name', libelle: 'nom du service', @@ -112,7 +114,7 @@ module TagsSubstitutionConcern lambda: -> (d) { d.procedure.organisation_name || '' }, available_for_states: Dossier::SOUMIS } - ] + ].push(DOSSIER_ID_TAG) DOSSIER_TAGS_FOR_MAIL = [ { From a248eba6415112db1e51a3c4b37f901be41f00a7 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Sun, 3 Mar 2024 10:19:02 +0100 Subject: [PATCH 0201/1532] export template: set default values --- app/models/export_template.rb | 10 ++++++++++ spec/models/export_template_spec.rb | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/app/models/export_template.rb b/app/models/export_template.rb index 5345116bb..7016edac7 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -7,6 +7,16 @@ class ExportTemplate < ApplicationRecord DOSSIER_STATE = Dossier.states.fetch(:en_construction) + def set_default_values + content["default_dossier_directory"] = tiptap_json("dossier-") + content["pdf_name"] = tiptap_json("export_") + + content["pjs"] = [] + procedure.pieces_jointes_exportables_list.each do |pj| + content["pjs"] << { "stable_id" => pj.stable_id.to_s, "path" => tiptap_json("#{pj.libelle.parameterize}-") } + end + end + def tiptap_default_dossier_directory=(body) self.content["default_dossier_directory"] = JSON.parse(body) end diff --git a/spec/models/export_template_spec.rb b/spec/models/export_template_spec.rb index d8df04760..2aa74744d 100644 --- a/spec/models/export_template_spec.rb +++ b/spec/models/export_template_spec.rb @@ -37,6 +37,31 @@ describe ExportTemplate do { type: :piece_justificative, libelle: "Justificatif de domicile", mandatory: true, stable_id: 910 } ] end + it 'set default values' do + export_template.set_default_values + expect(export_template.content).to eq({ + "pdf_name" => { + "type" => "doc", + "content" => [ + { "type" => "paragraph", "content" => [{ "text" => "export_", "type" => "text" }, { "type" => "mention", "attrs" => ExportTemplate::DOSSIER_ID_TAG.stringify_keys }] } + ] + }, + "default_dossier_directory" => { + "type" => "doc", + "content" => [ + { "type" => "paragraph", "content" => [{ "text" => "dossier-", "type" => "text" }, { "type" => "mention", "attrs" => ExportTemplate::DOSSIER_ID_TAG.stringify_keys }] } + ] + }, + "pjs" => + [ + + { + "stable_id" => "910", + "path" => { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "text" => "justificatif-de-domicile-", "type" => "text" }, { "type" => "mention", "attrs" => ExportTemplate::DOSSIER_ID_TAG.stringify_keys }] }] } + } + ] + }) + end end describe '#tiptap_default_dossier_directory' do From 95c308dc51796e5d08110373e584689048a42a85 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Sun, 3 Mar 2024 11:13:07 +0100 Subject: [PATCH 0202/1532] add specific tags --- app/models/export_template.rb | 4 ++++ .../concerns/tags_substitution_concern_spec.rb | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/app/models/export_template.rb b/app/models/export_template.rb index 7016edac7..667b82b7e 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -81,6 +81,10 @@ class ExportTemplate < ApplicationRecord "#{render_attributes_for(content["pdf_name"], dossier)}.pdf" end + def specific_tags + tags_categorized.slice(:individual, :etablissement, :dossier).values.flatten + end + private def tiptap_content(key) diff --git a/spec/models/concerns/tags_substitution_concern_spec.rb b/spec/models/concerns/tags_substitution_concern_spec.rb index e50ecfd70..a11f991f7 100644 --- a/spec/models/concerns/tags_substitution_concern_spec.rb +++ b/spec/models/concerns/tags_substitution_concern_spec.rb @@ -604,6 +604,24 @@ describe TagsSubstitutionConcern, type: :model do end end + describe 'some_tags' do + context 'for entreprise procedure' do + let(:for_individual) { false } + it do + tags = template_concern.some_tags + expect(tags.map { _1[:id] }).to eq ["entreprise_siren", "entreprise_numero_tva_intracommunautaire", "entreprise_siret_siege_social", "entreprise_raison_sociale", "entreprise_adresse", "dossier_motivation", "dossier_depose_at", "dossier_en_instruction_at", "dossier_processed_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number"] + end + end + + context 'for individual procedure' do + let(:for_individual) { true } + it do + tags = template_concern.some_tags + expect(tags.map { _1[:id] }).to eq ["individual_gender", "individual_last_name", "individual_first_name", "dossier_motivation", "dossier_depose_at", "dossier_en_instruction_at", "dossier_processed_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number"] + end + end + end + describe 'parser' do it do tokens = TagsSubstitutionConcern::TagsParser.parse("hello world --public--, --numéro du dossier--, un test--yolo-- encore du text\n---\n encore du text --- et encore du text\n--tag--") From f5813b4e55c6d946ff393b80ad0ef8951e60c63d Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Sun, 3 Mar 2024 11:30:41 +0100 Subject: [PATCH 0203/1532] create and update export templates --- .../export_templates_controller.rb | 82 ++++++++++++ app/models/export_template.rb | 8 +- .../export_templates/_form.html.haml | 59 +++++++++ .../export_templates/edit.html.haml | 7 + .../export_templates/new.html.haml | 6 + .../procedures/export_templates/fr.yml | 8 ++ config/routes.rb | 1 + .../export_templates_controller_spec.rb | 120 ++++++++++++++++++ 8 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 app/controllers/instructeurs/export_templates_controller.rb create mode 100644 app/views/instructeurs/export_templates/_form.html.haml create mode 100644 app/views/instructeurs/export_templates/edit.html.haml create mode 100644 app/views/instructeurs/export_templates/new.html.haml create mode 100644 config/locales/views/instructeurs/procedures/export_templates/fr.yml create mode 100644 spec/controllers/instructeurs/export_templates_controller_spec.rb diff --git a/app/controllers/instructeurs/export_templates_controller.rb b/app/controllers/instructeurs/export_templates_controller.rb new file mode 100644 index 000000000..4b12a848b --- /dev/null +++ b/app/controllers/instructeurs/export_templates_controller.rb @@ -0,0 +1,82 @@ +module Instructeurs + class ExportTemplatesController < InstructeurController + before_action :set_procedure + before_action :set_groupe_instructeur, only: [:create, :update] + before_action :set_export_template, only: [:edit, :update, :destroy] + before_action :set_groupe_instructeurs + before_action :set_all_pj + + def new + @export_template = ExportTemplate.new(kind: 'zip', groupe_instructeur: @groupe_instructeurs.first) + @export_template.set_default_values + end + + def create + @export_template = @groupe_instructeur.export_templates.build(export_template_params) + @export_template.assign_pj_names(pj_params) + if @export_template.save + redirect_to exports_instructeur_procedure_path(procedure: @procedure), notice: "Le modèle d'export #{@export_template.name} a bien été créé" + else + flash[:alert] = @export_template.errors.full_messages + render :new + end + end + + def edit + end + + def update + @export_template.assign_attributes(export_template_params) + @export_template.groupe_instructeur = @groupe_instructeur + @export_template.assign_pj_names(pj_params) + if @export_template.save + redirect_to exports_instructeur_procedure_path(procedure: @procedure), notice: "Le modèle d'export #{@export_template.name} a bien été modifié" + else + flash[:alert] = @export_template.errors.full_messages + render :edit + end + end + + private + + def export_template_params + params.require(:export_template).permit(*export_params) + end + + def set_procedure + @procedure = current_instructeur.procedures.find params[:procedure_id] + Sentry.configure_scope do |scope| + scope.set_tags(procedure: @procedure.id) + end + end + + def set_export_template + @export_template = current_instructeur.export_templates.find(params[:id]) + end + + def set_groupe_instructeur + @groupe_instructeur = @procedure.groupe_instructeurs.find(params.require(:export_template)[:groupe_instructeur_id]) + end + + def set_groupe_instructeurs + @groupe_instructeurs = current_instructeur.groupe_instructeurs.where(procedure: @procedure) + end + + def set_all_pj + @all_pj ||= @procedure.pieces_jointes_exportables_list + end + + def export_params + [:name, :kind, :tiptap_default_dossier_directory, :tiptap_pdf_name] + end + + def pj_params + @procedure = current_instructeur.procedures.find params[:procedure_id] + pj_params = [] + @all_pj.each do |pj| + pj_params << "tiptap_pj_#{pj.stable_id}".to_sym + end + params.require(:export_template).permit(*pj_params) + end + end +end diff --git a/app/models/export_template.rb b/app/models/export_template.rb index 667b82b7e..2a7282fdc 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -37,9 +37,11 @@ class ExportTemplate < ApplicationRecord content_for_pj_id(pj.stable_id)&.to_json end - def content_for_pj_id(stable_id) - content_for_stable_id = content["pjs"].find { _1.symbolize_keys[:stable_id] == stable_id.to_s } - content_for_stable_id.symbolize_keys.fetch(:path) + def assign_pj_names(pj_params) + self.content["pjs"] = [] + pj_params.each do |pj_param| + self.content["pjs"] << { stable_id: pj_param[0].delete_prefix("tiptap_pj_"), path: JSON.parse(pj_param[1]) } + end end def attachment_and_path(dossier, attachment, index: 0, row_index: nil) diff --git a/app/views/instructeurs/export_templates/_form.html.haml b/app/views/instructeurs/export_templates/_form.html.haml new file mode 100644 index 000000000..1257f7f0d --- /dev/null +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -0,0 +1,59 @@ +.fr-grid-row.fr-grid-row--gutters + .fr-col-12.fr-col-md-8 + = form_with url: form_url, model: @export_template, local: true do |f| + = render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field) do |c| + - c.with_hint do + Indiquez le nom à utiliser pour ce modèle d'export + + - if groupe_instructeurs.many? + .fr-input-group + = f.label :groupe_instructeur_id, class: 'fr-label' do + = f.object.class.human_attribute_name(:groupe_instructeur_id) + = render EditableChamp::AsteriskMandatoryComponent.new + %span.fr-hint-text + Avec quel groupe instructeur souhaitez-vous partager ce modèle d'export ? + = f.collection_select :groupe_instructeur_id, groupe_instructeurs, :id, :label, {}, class: 'fr-select' + - else + = f.hidden_field :groupe_instructeur_id + + = f.hidden_field :kind + + .fr-input-group{ data: { controller: 'tiptap' } } + = f.label :tiptap_default_dossier_directory, class: "fr-label" + .editor.mt-2{ data: { tiptap_target: 'editor' } } + = f.hidden_field :tiptap_default_dossier_directory, data: { tiptap_target: 'input' } + %ul.mt-2.flex.wrap.flex-gap-1 + - @export_template.specific_tags.each do |tag| + %li.fr-badge.fr-badge--sm{ role: 'button', title: tag[:description], data: { action: 'click->tiptap#insertTag', tiptap_target: 'tag', tag_id: tag[:id], tag_label: tag[:libelle] } } + = tag[:libelle] + + .fr-input-group{ data: { controller: 'tiptap' } } + = f.label :tiptap_pdf_name, class: "fr-label" + .editor.mt-2{ data: { tiptap_target: 'editor' } } + = f.hidden_field :tiptap_pdf_name, data: { tiptap_target: 'input' } + %ul.mt-2.flex.wrap.flex-gap-1 + - @export_template.specific_tags.each do |tag| + %li.fr-badge.fr-badge--sm{ role: 'button', title: tag[:description], data: { action: 'click->tiptap#insertTag', tiptap_target: 'tag', tag_id: tag[:id], tag_label: tag[:libelle] } } + = tag[:libelle] + + - if @all_pj.any? + %h3 Pieces justificatives + + - @all_pj.each do |pj| + .fr-input-group{ data: { controller: 'tiptap' } } + = label_tag pj.libelle, nil, name: field_name(:export_template, "tiptap_pj_#{pj.stable_id}"), class: "fr-label" + .editor.mt-2{ data: { tiptap_target: 'editor' } } + = hidden_field_tag field_name(:export_template, "tiptap_pj_#{pj.stable_id}"), "#{@export_template.content_for_pj(pj)}" , data: { tiptap_target: 'input' } + %ul.mt-2.flex.wrap.flex-gap-1 + - @export_template.specific_tags.each do |tag| + %li.fr-badge.fr-badge--sm{ role: 'button', title: tag[:description], data: { action: 'click->tiptap#insertTag', tiptap_target: 'tag', tag_id: tag[:id], tag_label: tag[:libelle] } } + = tag[:libelle] + + + .fixed-footer + .fr-container + %ul.fr-btns-group.fr-btns-group--inline-md + %li + = f.submit "Enregistrer", class: "fr-btn" + %li + = link_to "Annuler", instructeur_procedure_path(@procedure), class: "fr-btn fr-btn--secondary" diff --git a/app/views/instructeurs/export_templates/edit.html.haml b/app/views/instructeurs/export_templates/edit.html.haml new file mode 100644 index 000000000..bd4fc02b4 --- /dev/null +++ b/app/views/instructeurs/export_templates/edit.html.haml @@ -0,0 +1,7 @@ += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [[@procedure.libelle.truncate_words(10), instructeur_procedure_path(@procedure)], + [t('.title')]] } +.fr-container + %h1 Mise à jour modèle d'export + + = render partial: 'form', locals: { form_url: instructeur_export_template_path(@procedure, @export_template), groupe_instructeurs: @groupe_instructeurs } diff --git a/app/views/instructeurs/export_templates/new.html.haml b/app/views/instructeurs/export_templates/new.html.haml new file mode 100644 index 000000000..eeff6baa9 --- /dev/null +++ b/app/views/instructeurs/export_templates/new.html.haml @@ -0,0 +1,6 @@ += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [[@procedure.libelle.truncate_words(10), instructeur_procedure_path(@procedure)], + [t('.title')]] } +.fr-container + %h1 Nouveau modèle d'export + = render partial: 'form', locals: { form_url: instructeur_export_templates_path, groupe_instructeurs: @groupe_instructeurs } diff --git a/config/locales/views/instructeurs/procedures/export_templates/fr.yml b/config/locales/views/instructeurs/procedures/export_templates/fr.yml new file mode 100644 index 000000000..6f570152e --- /dev/null +++ b/config/locales/views/instructeurs/procedures/export_templates/fr.yml @@ -0,0 +1,8 @@ +fr: + instructeurs: + export_templates: + new: + title: Nouveau modèle d'export + edit: + title: Modèle d'export + diff --git a/config/routes.rb b/config/routes.rb index cb068da33..a9af9ac9e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -450,6 +450,7 @@ Rails.application.routes.draw do resources :procedures, only: [:index, :show], param: :procedure_id do member do resources :archives, only: [:index, :create] + resources :export_templates, only: [:new, :create, :edit, :update] resources :groupes, only: [:index, :show], controller: 'groupe_instructeurs' do resource :contact_information diff --git a/spec/controllers/instructeurs/export_templates_controller_spec.rb b/spec/controllers/instructeurs/export_templates_controller_spec.rb new file mode 100644 index 000000000..83adb32ac --- /dev/null +++ b/spec/controllers/instructeurs/export_templates_controller_spec.rb @@ -0,0 +1,120 @@ +describe Instructeurs::ExportTemplatesController, type: :controller do + before { sign_in(instructeur.user) } + let(:tiptap_pdf_name) { + { + "type" => "doc", + "content" => [ + { "type" => "paragraph", "content" => [{ "text" => "mon_export_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] } + ] + }.to_json + } + + let(:export_template_params) do + { + name: "coucou", + kind: "zip", + groupe_instructeur_id: groupe_instructeur.id, + tiptap_pdf_name: tiptap_pdf_name, + tiptap_default_dossier_directory: { + "type" => "doc", + "content" => [ + { "type" => "paragraph", "content" => [{ "text" => "DOSSIER_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] } + ] + }.to_json, + "pjs" => + [ + { path: { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " _justif", "type" => "text" }] }] }, stable_id: "3" }, + { + path: + { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "text" => "cni_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] }] }, + stable_id: "5" + }, + { + path: { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "text" => "pj_repet_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] }] }, + stable_id: "10" + } + ] + } + end + + let(:instructeur) { create(:instructeur) } + let(:procedure) { create(:procedure, instructeurs: [instructeur]) } + let(:groupe_instructeur) { procedure.defaut_groupe_instructeur } + + describe '#create' do + let(:subject) { post :create, params: { procedure_id: procedure.id, export_template: export_template_params } } + + context 'with valid params' do + it 'redirect to some page' do + subject + expect(response).to redirect_to(exports_instructeur_procedure_path(procedure:)) + expect(flash.notice).to eq "Le modèle d'export coucou a bien été créé" + end + end + + context 'with invalid params' do + let(:tiptap_pdf_name) { { content: "invalid" }.to_json } + it 'display error notification' do + subject + expect(flash.alert).to be_present + end + end + + context 'with procedure not accessible by current instructeur' do + let(:another_procedure) { create(:procedure) } + let(:subject) { post :create, params: { procedure_id: another_procedure.id, export_template: export_template_params } } + it 'raise exception' do + expect { subject }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + + describe '#edit' do + let(:export_template) { create(:export_template, groupe_instructeur:) } + let(:subject) { get :edit, params: { procedure_id: procedure.id, id: export_template.id } } + + it 'render edit' do + subject + expect(response).to render_template(:edit) + end + + context "with export_template not accessible by current instructeur" do + let(:another_groupe_instructeur) { create(:groupe_instructeur) } + let(:export_template) { create(:export_template, groupe_instructeur: another_groupe_instructeur) } + + it 'raise exception' do + expect { subject }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + + describe '#update' do + let(:export_template) { create(:export_template, groupe_instructeur:) } + let(:tiptap_pdf_name) { + { + "type" => "doc", + "content" => [ + { "type" => "paragraph", "content" => [{ "text" => "exPort_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] } + ] + }.to_json + } + + let(:subject) { put :update, params: { procedure_id: procedure.id, id: export_template.id, export_template: export_template_params } } + + context 'with valid params' do + it 'redirect to some page' do + subject + expect(response).to redirect_to(exports_instructeur_procedure_path(procedure:)) + expect(flash.notice).to eq "Le modèle d'export coucou a bien été modifié" + end + end + + context 'with invalid params' do + let(:tiptap_pdf_name) { { content: "invalid" }.to_json } + it 'display error notification' do + subject + expect(flash.alert).to be_present + end + end + end +end From a12d6b4af0b5781c2882e80356451263ac490a23 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Sun, 3 Mar 2024 11:56:08 +0100 Subject: [PATCH 0204/1532] preview content export --- app/assets/stylesheets/exports.scss | 71 +++++++++++++++++++ .../export_templates_controller.rb | 8 +++ .../export_templates/_form.html.haml | 20 +++++- .../preview.turbo_stream.haml | 2 + config/routes.rb | 6 +- 5 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 app/assets/stylesheets/exports.scss create mode 100644 app/views/instructeurs/export_templates/preview.turbo_stream.haml diff --git a/app/assets/stylesheets/exports.scss b/app/assets/stylesheets/exports.scss new file mode 100644 index 000000000..ff734fea2 --- /dev/null +++ b/app/assets/stylesheets/exports.scss @@ -0,0 +1,71 @@ +@import "constants"; + +.export-template-preview { + // From https://codepen.io/myramoki/pen/xZJjrr + .tree { + margin-left: 0; + } + + .tree, + .tree ul { + padding: 0; + list-style: none; + position: relative; + } + + .tree ul { + margin: 0 0 0 0.5em; // (indentation/2) + } + + .tree:before, + .tree ul:before { + content: ""; + display: block; + width: 0; + position: absolute; + top: 0; + bottom: 0; + left: 4px; + border-left: 1px dashed; + } + + ul.tree:before { + border-left: none; + } + + .tree li { + margin: 0; + padding: 0 1.5em; // indentation + .5em + line-height: 2em; // default list item's `line-height` + position: relative; + } + + .tree > li { + padding-left: 0; // Don't indent first level + } + + .tree li:before { + content: ""; + display: block; + width: 10px; // same with indentation + height: 0; + border-top: 1px dashed; + margin-top: -1px; // border top width + position: absolute; + top: 1em; // (line-height/2) + left: 4px; + } + + ul.tree > li:before { + border-top: none; + } + + .tree li:last-child:before { + background: var( + --background-alt-blue-france + ); // same with body background + height: auto; + top: 1em; // (line-height/2) + bottom: 0; + } +} diff --git a/app/controllers/instructeurs/export_templates_controller.rb b/app/controllers/instructeurs/export_templates_controller.rb index 4b12a848b..e04cf8152 100644 --- a/app/controllers/instructeurs/export_templates_controller.rb +++ b/app/controllers/instructeurs/export_templates_controller.rb @@ -37,6 +37,14 @@ module Instructeurs end end + def preview + param = params.require(:export_template).keys.first + @preview_param = param.delete_prefix("tiptap_") + hash = JSON.parse(params[:export_template][param]).deep_symbolize_keys + export_template = ExportTemplate.new(kind: 'zip', groupe_instructeur: @groupe_instructeurs.first) + @preview_value = export_template.render_attributes_for(hash, @procedure.dossier_for_preview(current_instructeur)) + end + private def export_template_params diff --git a/app/views/instructeurs/export_templates/_form.html.haml b/app/views/instructeurs/export_templates/_form.html.haml index 1257f7f0d..4ae51c1d5 100644 --- a/app/views/instructeurs/export_templates/_form.html.haml +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -21,7 +21,7 @@ .fr-input-group{ data: { controller: 'tiptap' } } = f.label :tiptap_default_dossier_directory, class: "fr-label" .editor.mt-2{ data: { tiptap_target: 'editor' } } - = f.hidden_field :tiptap_default_dossier_directory, data: { tiptap_target: 'input' } + = f.hidden_field :tiptap_default_dossier_directory, data: { tiptap_target: 'input', controller: 'turbo-input', turbo_input_url_value: preview_instructeur_export_templates_path } %ul.mt-2.flex.wrap.flex-gap-1 - @export_template.specific_tags.each do |tag| %li.fr-badge.fr-badge--sm{ role: 'button', title: tag[:description], data: { action: 'click->tiptap#insertTag', tiptap_target: 'tag', tag_id: tag[:id], tag_label: tag[:libelle] } } @@ -30,7 +30,7 @@ .fr-input-group{ data: { controller: 'tiptap' } } = f.label :tiptap_pdf_name, class: "fr-label" .editor.mt-2{ data: { tiptap_target: 'editor' } } - = f.hidden_field :tiptap_pdf_name, data: { tiptap_target: 'input' } + = f.hidden_field :tiptap_pdf_name, data: { tiptap_target: 'input', controller: 'turbo-input', turbo_input_url_value: preview_instructeur_export_templates_path } %ul.mt-2.flex.wrap.flex-gap-1 - @export_template.specific_tags.each do |tag| %li.fr-badge.fr-badge--sm{ role: 'button', title: tag[:description], data: { action: 'click->tiptap#insertTag', tiptap_target: 'tag', tag_id: tag[:id], tag_label: tag[:libelle] } } @@ -43,7 +43,7 @@ .fr-input-group{ data: { controller: 'tiptap' } } = label_tag pj.libelle, nil, name: field_name(:export_template, "tiptap_pj_#{pj.stable_id}"), class: "fr-label" .editor.mt-2{ data: { tiptap_target: 'editor' } } - = hidden_field_tag field_name(:export_template, "tiptap_pj_#{pj.stable_id}"), "#{@export_template.content_for_pj(pj)}" , data: { tiptap_target: 'input' } + = hidden_field_tag field_name(:export_template, "tiptap_pj_#{pj.stable_id}"), "#{@export_template.content_for_pj(pj)}" , data: { tiptap_target: 'input', controller: 'turbo-input', turbo_input_url_value: preview_instructeur_export_templates_path } %ul.mt-2.flex.wrap.flex-gap-1 - @export_template.specific_tags.each do |tag| %li.fr-badge.fr-badge--sm{ role: 'button', title: tag[:description], data: { action: 'click->tiptap#insertTag', tiptap_target: 'tag', tag_id: tag[:id], tag_label: tag[:libelle] } } @@ -57,3 +57,17 @@ = f.submit "Enregistrer", class: "fr-btn" %li = link_to "Annuler", instructeur_procedure_path(@procedure), class: "fr-btn fr-btn--secondary" + - if @export_template.sample_dossier + .fr-col-12.fr-col-md-4.fr-background-alt--blue-france + .export-template-preview.fr-p-2w.sticky--top + %h2.fr-h4 Aperçu + %ul.tree.fr-text--sm + %li= DownloadableFileService::EXPORT_DIRNAME + %li + %ul + %li + %span#preview_default_dossier_directory= @export_template.tiptap_convert(sample_dossier, "default_dossier_directory") + %ul + %li#preview_pdf_name= @export_template.tiptap_convert(sample_dossier, "pdf_name") + - @procedure.pieces_jointes_exportables_list.each do |pj| + %li{id: "preview_pj_#{pj.stable_id}"}= @export_template.tiptap_convert_pj(sample_dossier, pj.stable_id) diff --git a/app/views/instructeurs/export_templates/preview.turbo_stream.haml b/app/views/instructeurs/export_templates/preview.turbo_stream.haml new file mode 100644 index 000000000..f6c1e5468 --- /dev/null +++ b/app/views/instructeurs/export_templates/preview.turbo_stream.haml @@ -0,0 +1,2 @@ += turbo_stream.update "preview_#{@preview_param}" do + = @preview_value diff --git a/config/routes.rb b/config/routes.rb index a9af9ac9e..461575d5b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -450,7 +450,11 @@ Rails.application.routes.draw do resources :procedures, only: [:index, :show], param: :procedure_id do member do resources :archives, only: [:index, :create] - resources :export_templates, only: [:new, :create, :edit, :update] + resources :export_templates, only: [:new, :create, :edit, :update] do + collection do + get 'preview' + end + end resources :groupes, only: [:index, :show], controller: 'groupe_instructeurs' do resource :contact_information From 7661b8b1b2859a77296ba64016f5ff57a0f0ace5 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 8 Mar 2024 10:11:11 +0100 Subject: [PATCH 0205/1532] add export_template to exports --- app/models/export.rb | 1 + db/migrate/20240131094915_add_template_to_exports.rb | 6 ++++++ db/migrate/20240131095645_add_export_template_fk.rb | 5 +++++ db/migrate/20240131100329_validate_export_template_fk.rb | 5 +++++ db/schema.rb | 3 +++ 5 files changed, 20 insertions(+) create mode 100644 db/migrate/20240131094915_add_template_to_exports.rb create mode 100644 db/migrate/20240131095645_add_export_template_fk.rb create mode 100644 db/migrate/20240131100329_validate_export_template_fk.rb diff --git a/app/models/export.rb b/app/models/export.rb index 3a7a1ac34..66832d5e1 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -31,6 +31,7 @@ class Export < ApplicationRecord belongs_to :procedure_presentation, optional: true belongs_to :instructeur, optional: true belongs_to :user_profile, polymorphic: true, optional: true + belongs_to :export_template, optional: true has_one_attached :file diff --git a/db/migrate/20240131094915_add_template_to_exports.rb b/db/migrate/20240131094915_add_template_to_exports.rb new file mode 100644 index 000000000..397f053b7 --- /dev/null +++ b/db/migrate/20240131094915_add_template_to_exports.rb @@ -0,0 +1,6 @@ +class AddTemplateToExports < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + def change + add_reference :exports, :export_template, null: true, index: { algorithm: :concurrently } + end +end diff --git a/db/migrate/20240131095645_add_export_template_fk.rb b/db/migrate/20240131095645_add_export_template_fk.rb new file mode 100644 index 000000000..3e90045bc --- /dev/null +++ b/db/migrate/20240131095645_add_export_template_fk.rb @@ -0,0 +1,5 @@ +class AddExportTemplateFk < ActiveRecord::Migration[7.0] + def change + add_foreign_key :exports, :export_templates, validate: false + end +end diff --git a/db/migrate/20240131100329_validate_export_template_fk.rb b/db/migrate/20240131100329_validate_export_template_fk.rb new file mode 100644 index 000000000..08180880d --- /dev/null +++ b/db/migrate/20240131100329_validate_export_template_fk.rb @@ -0,0 +1,5 @@ +class ValidateExportTemplateFk < ActiveRecord::Migration[7.0] + def change + validate_foreign_key :exports, :export_templates + end +end diff --git a/db/schema.rb b/db/schema.rb index 054c32caa..22ee0febd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -606,6 +606,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_04_17_053843) do create_table "exports", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.integer "dossiers_count" + t.bigint "export_template_id" t.string "format", null: false t.bigint "instructeur_id" t.string "job_status", default: "pending", null: false @@ -617,6 +618,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_04_17_053843) do t.datetime "updated_at", precision: nil, null: false t.bigint "user_profile_id" t.string "user_profile_type" + t.index ["export_template_id"], name: "index_exports_on_export_template_id" t.index ["instructeur_id"], name: "index_exports_on_instructeur_id" t.index ["key"], name: "index_exports_on_key" t.index ["procedure_presentation_id"], name: "index_exports_on_procedure_presentation_id" @@ -1235,6 +1237,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_04_17_053843) do add_foreign_key "experts_procedures", "experts" add_foreign_key "experts_procedures", "procedures" add_foreign_key "export_templates", "groupe_instructeurs" + add_foreign_key "exports", "export_templates" add_foreign_key "exports", "instructeurs" add_foreign_key "france_connect_informations", "users" add_foreign_key "geo_areas", "champs" From 7a397526302c4ea872cdb03579e50b9306f2ad95 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 22 Mar 2024 10:05:47 +0100 Subject: [PATCH 0206/1532] rename root directory in zip export --- app/services/downloadable_file_service.rb | 5 +- .../procedure_archive_service_spec.rb | 50 +++++++++---------- .../services/procedure_export_service_spec.rb | 11 ++-- 3 files changed, 34 insertions(+), 32 deletions(-) diff --git a/app/services/downloadable_file_service.rb b/app/services/downloadable_file_service.rb index 97909578b..c6d87e5b1 100644 --- a/app/services/downloadable_file_service.rb +++ b/app/services/downloadable_file_service.rb @@ -1,9 +1,10 @@ class DownloadableFileService ARCHIVE_CREATION_DIR = ENV.fetch('ARCHIVE_CREATION_DIR') { '/tmp' } + EXPORT_DIRNAME = 'export' def self.download_and_zip(procedure, attachments, filename, &block) Dir.mktmpdir(nil, ARCHIVE_CREATION_DIR) do |tmp_dir| - export_dir = File.join(tmp_dir, filename) + export_dir = File.join(tmp_dir, EXPORT_DIRNAME) zip_path = File.join(ARCHIVE_CREATION_DIR, "#{filename}.zip") begin @@ -15,7 +16,7 @@ class DownloadableFileService Dir.chdir(tmp_dir) do File.delete(zip_path) if File.exist?(zip_path) - system 'zip', '-0', '-r', zip_path, filename + system 'zip', '-0', '-r', zip_path, EXPORT_DIRNAME end yield(zip_path) ensure diff --git a/spec/services/procedure_archive_service_spec.rb b/spec/services/procedure_archive_service_spec.rb index 8edd88065..40b8caa9f 100644 --- a/spec/services/procedure_archive_service_spec.rb +++ b/spec/services/procedure_archive_service_spec.rb @@ -33,11 +33,11 @@ describe ProcedureArchiveService do files = ZipTricks::FileReader.read_zip_structure(io: f) structure = [ - "#{service.send(:zip_root_folder, archive)}/", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/pieces_justificatives/", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/pieces_justificatives/attestation-dossier--05-03-2021-00-00-#{dossier.attestation.pdf.id % 10000}.pdf", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/export-#{dossier.id}-05-03-2021-00-00-#{dossier.id % 10000}.pdf" + "export/", + "export/dossier-#{dossier.id}/", + "export/dossier-#{dossier.id}/pieces_justificatives/", + "export/dossier-#{dossier.id}/pieces_justificatives/attestation-dossier--05-03-2021-00-00-#{dossier.attestation.pdf.id % 10000}.pdf", + "export/dossier-#{dossier.id}/export-#{dossier.id}-05-03-2021-00-00-#{dossier.id % 10000}.pdf" ] expect(files.map(&:filename)).to match_array(structure) end @@ -53,11 +53,11 @@ describe ProcedureArchiveService do archive.file.open do |f| files = ZipTricks::FileReader.read_zip_structure(io: f) structure = [ - "#{service.send(:zip_root_folder, archive)}/", - "#{service.send(:zip_root_folder, archive)}/-LISTE-DES-FICHIERS-EN-ERREURS.txt", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/pieces_justificatives/", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/export-#{dossier.id}-05-03-2021-00-00-#{dossier.id % 10000}.pdf" + "export/", + "export/-LISTE-DES-FICHIERS-EN-ERREURS.txt", + "export/dossier-#{dossier.id}/", + "export/dossier-#{dossier.id}/pieces_justificatives/", + "export/dossier-#{dossier.id}/export-#{dossier.id}-05-03-2021-00-00-#{dossier.id % 10000}.pdf" ] expect(files.map(&:filename)).to match_array(structure) end @@ -100,12 +100,12 @@ describe ProcedureArchiveService do archive.file.open do |f| zip_entries = ZipTricks::FileReader.read_zip_structure(io: f) structure = [ - "#{service.send(:zip_root_folder, archive)}/", - "#{service.send(:zip_root_folder, archive)}/-LISTE-DES-FICHIERS-EN-ERREURS.txt", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/export-dossier-05-03-2020-00-00-1.pdf", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/pieces_justificatives/", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/export-#{dossier.id}-05-03-2021-00-00-#{dossier.id % 10000}.pdf" + "export/", + "export/-LISTE-DES-FICHIERS-EN-ERREURS.txt", + "export/dossier-#{dossier.id}/", + "export/dossier-#{dossier.id}/export-dossier-05-03-2020-00-00-1.pdf", + "export/dossier-#{dossier.id}/pieces_justificatives/", + "export/dossier-#{dossier.id}/export-#{dossier.id}-05-03-2021-00-00-#{dossier.id % 10000}.pdf" ] expect(zip_entries.map(&:filename)).to match_array(structure) zip_entries.map do |entry| @@ -134,15 +134,15 @@ describe ProcedureArchiveService do archive.file.open do |f| files = ZipTricks::FileReader.read_zip_structure(io: f) structure = [ - "#{service.send(:zip_root_folder, archive)}/", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/pieces_justificatives/", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/pieces_justificatives/attestation-dossier--05-03-2020-00-00-#{dossier.attestation.pdf.id % 10000}.pdf", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/export-#{dossier.id}-05-03-2020-00-00-#{dossier.id % 10000}.pdf", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier_2020.id}/", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier_2020.id}/export-#{dossier_2020.id}-05-03-2020-00-00-#{dossier_2020.id % 10000}.pdf", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier_2020.id}/pieces_justificatives/", - "#{service.send(:zip_root_folder, archive)}/dossier-#{dossier_2020.id}/pieces_justificatives/attestation-dossier--05-03-2020-00-00-#{dossier_2020.attestation.pdf.id % 10000}.pdf" + "export/", + "export/dossier-#{dossier.id}/", + "export/dossier-#{dossier.id}/pieces_justificatives/", + "export/dossier-#{dossier.id}/pieces_justificatives/attestation-dossier--05-03-2020-00-00-#{dossier.attestation.pdf.id % 10000}.pdf", + "export/dossier-#{dossier.id}/export-#{dossier.id}-05-03-2020-00-00-#{dossier.id % 10000}.pdf", + "export/dossier-#{dossier_2020.id}/", + "export/dossier-#{dossier_2020.id}/export-#{dossier_2020.id}-05-03-2020-00-00-#{dossier_2020.id % 10000}.pdf", + "export/dossier-#{dossier_2020.id}/pieces_justificatives/", + "export/dossier-#{dossier_2020.id}/pieces_justificatives/attestation-dossier--05-03-2020-00-00-#{dossier_2020.attestation.pdf.id % 10000}.pdf" ] expect(files.map(&:filename)).to match_array(structure) end diff --git a/spec/services/procedure_export_service_spec.rb b/spec/services/procedure_export_service_spec.rb index 7f783eafd..aa2fb3733 100644 --- a/spec/services/procedure_export_service_spec.rb +++ b/spec/services/procedure_export_service_spec.rb @@ -540,12 +540,13 @@ describe ProcedureExportService do File.write('tmp.zip', subject.download, mode: 'wb') File.open('tmp.zip') do |fd| files = ZipTricks::FileReader.read_zip_structure(io: fd) + base_fn = 'export' structure = [ - "#{service.send(:base_filename)}/", - "#{service.send(:base_filename)}/dossier-#{dossier.id}/", - "#{service.send(:base_filename)}/dossier-#{dossier.id}/pieces_justificatives/", - "#{service.send(:base_filename)}/dossier-#{dossier.id}/#{ActiveStorage::DownloadableFile.timestamped_filename(ActiveStorage::Attachment.where(record_type: "Champ").first)}", - "#{service.send(:base_filename)}/dossier-#{dossier.id}/#{ActiveStorage::DownloadableFile.timestamped_filename(dossier_exports.first.first)}" + "#{base_fn}/", + "#{base_fn}/dossier-#{dossier.id}/", + "#{base_fn}/dossier-#{dossier.id}/pieces_justificatives/", + "#{base_fn}/dossier-#{dossier.id}/#{ActiveStorage::DownloadableFile.timestamped_filename(ActiveStorage::Attachment.where(record_type: "Champ").first)}", + "#{base_fn}/dossier-#{dossier.id}/#{ActiveStorage::DownloadableFile.timestamped_filename(dossier_exports.first.first)}" ] expect(files.size).to eq(structure.size) expect(files.map(&:filename)).to match_array(structure) From 357c07456cfd0d578bd3a0c8e4bcb03635dede43 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 8 Mar 2024 17:14:21 +0100 Subject: [PATCH 0207/1532] generate export with export_template --- app/controllers/api/v2/dossiers_controller.rb | 2 +- .../instructeurs/dossiers_controller.rb | 2 +- .../instructeurs/procedures_controller.rb | 7 +- app/controllers/users/dossiers_controller.rb | 2 +- app/lib/active_storage/downloadable_file.rb | 4 +- .../parallel_download_queue.rb | 2 + app/models/champ.rb | 8 ++ app/models/export.rb | 5 +- app/services/pieces_justificatives_service.rb | 18 +++- app/services/procedure_export_service.rb | 5 +- .../experts/avis_controller_spec.rb | 2 +- .../instructeurs/dossiers_controller_spec.rb | 2 +- .../procedures_controller_spec.rb | 12 +++ .../users/dossiers_controller_spec.rb | 2 +- spec/models/export_spec.rb | 8 ++ .../pieces_justificatives_service_spec.rb | 12 ++- .../services/procedure_export_service_spec.rb | 86 +++++++++++++------ 17 files changed, 131 insertions(+), 48 deletions(-) diff --git a/app/controllers/api/v2/dossiers_controller.rb b/app/controllers/api/v2/dossiers_controller.rb index 0612aaf53..3c627bd36 100644 --- a/app/controllers/api/v2/dossiers_controller.rb +++ b/app/controllers/api/v2/dossiers_controller.rb @@ -2,7 +2,7 @@ class API::V2::DossiersController < API::V2::BaseController before_action :ensure_dossier_present def pdf - @acls = PiecesJustificativesService.new(user_profile: Administrateur.new).acl_for_dossier_export(dossier.procedure) + @acls = PiecesJustificativesService.new(user_profile: Administrateur.new, export_template: nil).acl_for_dossier_export(dossier.procedure) render(template: 'dossiers/show', formats: [:pdf]) end diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index 04085f7c2..7ab9a717d 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -45,7 +45,7 @@ module Instructeurs @is_dossier_in_batch_operation = dossier.batch_operation.present? respond_to do |format| format.pdf do - @acls = PiecesJustificativesService.new(user_profile: current_instructeur).acl_for_dossier_export(dossier.procedure) + @acls = PiecesJustificativesService.new(user_profile: current_instructeur, export_template: nil).acl_for_dossier_export(dossier.procedure) render(template: 'dossiers/show', formats: [:pdf]) end format.all diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index 8caf08fee..1559dca8c 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -324,13 +324,18 @@ module Instructeurs end def export_format - @export_format ||= params[:export_format] + @export_format ||= params[:export_format].presence || export_template&.kind + end + + def export_template + @export_template ||= ExportTemplate.find(params[:export_template_id]) if params[:export_template_id].present? end def export_options @export_options ||= { time_span_type: params[:time_span_type], statut: params[:statut], + export_template:, procedure_presentation: params[:statut].present? ? procedure_presentation : nil }.compact end diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index 6176ce7f3..db49503c7 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -88,7 +88,7 @@ module Users end def show - pj_service = PiecesJustificativesService.new(user_profile: current_user) + pj_service = PiecesJustificativesService.new(user_profile: current_user, export_template: nil) respond_to do |format| format.pdf do @dossier = dossier_with_champs(pj_template: false) diff --git a/app/lib/active_storage/downloadable_file.rb b/app/lib/active_storage/downloadable_file.rb index b89c20997..57e919ff3 100644 --- a/app/lib/active_storage/downloadable_file.rb +++ b/app/lib/active_storage/downloadable_file.rb @@ -1,8 +1,8 @@ require 'fog/openstack' class ActiveStorage::DownloadableFile - def self.create_list_from_dossiers(dossiers:, user_profile:) - pj_service = PiecesJustificativesService.new(user_profile:) + def self.create_list_from_dossiers(dossiers:, user_profile:, export_template: nil) + pj_service = PiecesJustificativesService.new(user_profile:, export_template:) pj_service.generate_dossiers_export(dossiers) + pj_service.liste_documents(dossiers) end diff --git a/app/lib/download_manager/parallel_download_queue.rb b/app/lib/download_manager/parallel_download_queue.rb index 16dcbd762..35a6b8695 100644 --- a/app/lib/download_manager/parallel_download_queue.rb +++ b/app/lib/download_manager/parallel_download_queue.rb @@ -12,6 +12,8 @@ module DownloadManager end def download_all + # TODO: arriver à enelver ce parametrage d'ActiveStorage + ActiveStorage::Current.url_options = { host: ENV.fetch("APP_HOST") } hydra = Typhoeus::Hydra.new(max_concurrency: DOWNLOAD_MAX_PARALLEL) attachments.each do |attachment, path| diff --git a/app/models/champ.rb b/app/models/champ.rb index 2cb0bb4ec..6d3f02f11 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -91,6 +91,14 @@ class Champ < ApplicationRecord parent_id.present? end + def stable_id_with_row + [row_id, stable_id].compact + end + + def row_index + Champ.where(parent:).pluck(:row_id).sort.index(:id) + end + # used for the `required` html attribute # check visibility to avoid hidden required input # which prevent the form from being sent. diff --git a/app/models/export.rb b/app/models/export.rb index 66832d5e1..d9b29d409 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -67,9 +67,10 @@ class Export < ApplicationRecord procedure_presentation_id.present? end - def self.find_or_create_fresh_export(format, groupe_instructeurs, user_profile, time_span_type: time_span_types.fetch(:everything), statut: statuts.fetch(:tous), procedure_presentation: nil) + def self.find_or_create_fresh_export(format, groupe_instructeurs, user_profile, time_span_type: time_span_types.fetch(:everything), statut: statuts.fetch(:tous), procedure_presentation: nil, export_template: nil) attributes = { format:, + export_template:, time_span_type:, statut:, key: generate_cache_key(groupe_instructeurs.map(&:id), procedure_presentation) @@ -148,7 +149,7 @@ class Export < ApplicationRecord end def blob - service = ProcedureExportService.new(procedure, dossiers_for_export, user_profile) + service = ProcedureExportService.new(procedure, dossiers_for_export, user_profile, export_template) case format.to_sym when :csv diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 2fece0c0b..22040d4e0 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -1,6 +1,7 @@ class PiecesJustificativesService - def initialize(user_profile:) + def initialize(user_profile:, export_template:) @user_profile = user_profile + @export_template = export_template end def liste_documents(dossiers) @@ -58,7 +59,11 @@ class PiecesJustificativesService created_at: dossier.updated_at ) - pdfs << ActiveStorage::DownloadableFile.pj_and_path(dossier.id, a) + if @export_template + pdfs << @export_template.attachment_and_path(dossier, a) + else + pdfs << ActiveStorage::DownloadableFile.pj_and_path(dossier.id, a) + end end pdfs @@ -153,9 +158,14 @@ class PiecesJustificativesService .includes(:blob) .where(record_type: "Champ", record_id: champ_id_dossier_id.keys) .filter { |a| safe_attachment(a) } - .map do |a| + .map do |a, _i| dossier_id = champ_id_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + pj_index = Champ.find(a.record_id).piece_justificative_file.blobs.map(&:id).index(a.blob_id) + if @export_template + @export_template.attachment_and_path(Dossier.find(dossier_id), a, index: pj_index, row_index: a.record.row_index) + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end diff --git a/app/services/procedure_export_service.rb b/app/services/procedure_export_service.rb index 75eab89e8..5503a8a14 100644 --- a/app/services/procedure_export_service.rb +++ b/app/services/procedure_export_service.rb @@ -1,10 +1,11 @@ class ProcedureExportService attr_reader :procedure, :dossiers - def initialize(procedure, dossiers, user_profile) + def initialize(procedure, dossiers, user_profile, export_template) @procedure = procedure @dossiers = dossiers @user_profile = user_profile + @export_template = export_template end def to_csv @@ -36,7 +37,7 @@ class ProcedureExportService end def to_zip - attachments = ActiveStorage::DownloadableFile.create_list_from_dossiers(dossiers:, user_profile: @user_profile) + attachments = ActiveStorage::DownloadableFile.create_list_from_dossiers(dossiers:, user_profile: @user_profile, export_template: @export_template) DownloadableFileService.download_and_zip(procedure, attachments, base_filename) do |zip_filepath| ArchiveUploader.new(procedure: procedure, filename: filename(:zip), filepath: zip_filepath).blob diff --git a/spec/controllers/experts/avis_controller_spec.rb b/spec/controllers/experts/avis_controller_spec.rb index 7a4029eed..e58b4d2bb 100644 --- a/spec/controllers/experts/avis_controller_spec.rb +++ b/spec/controllers/experts/avis_controller_spec.rb @@ -121,7 +121,7 @@ describe Experts::AvisController, type: :controller do context 'with a valid avis' do it do service = instance_double(PiecesJustificativesService) - expect(PiecesJustificativesService).to receive(:new).with(user_profile: expert).and_return(service) + expect(PiecesJustificativesService).to receive(:new).with(user_profile: expert, export_template: nil).and_return(service) expect(service).to receive(:generate_dossiers_export).with(Dossier.where(id: dossier)).and_return([]) expect(service).to receive(:liste_documents).with(Dossier.where(id: dossier)).and_return([]) is_expected.to have_http_status(:success) diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index 8b99ff44f..8add60a04 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -936,7 +936,7 @@ describe Instructeurs::DossiersController, type: :controller do subject end - it { expect(assigns(:acls)).to eq(PiecesJustificativesService.new(user_profile: instructeur).acl_for_dossier_export(dossier.procedure)) } + it { expect(assigns(:acls)).to eq(PiecesJustificativesService.new(user_profile: instructeur, export_template: nil).acl_for_dossier_export(dossier.procedure)) } it { expect(assigns(:is_dossier_in_batch_operation)).to eq(false) } it { expect(response).to render_template 'dossiers/show' } diff --git a/spec/controllers/instructeurs/procedures_controller_spec.rb b/spec/controllers/instructeurs/procedures_controller_spec.rb index ca0e1a4a9..3f34c3e53 100644 --- a/spec/controllers/instructeurs/procedures_controller_spec.rb +++ b/spec/controllers/instructeurs/procedures_controller_spec.rb @@ -736,6 +736,18 @@ describe Instructeurs::ProceduresController, type: :controller do end it { expect { subject }.to change { Export.where(user_profile: instructeur).count }.by(1) } + + context 'with an export template' do + let(:export_template) { create(:export_template) } + subject do + get :download_export, params: { export_template_id: export_template.id, procedure_id: procedure.id } + end + + it 'displays an notice' do + is_expected.to redirect_to(exports_instructeur_procedure_url(procedure)) + expect(flash.notice).to be_present + end + end end context 'when the export is not ready' do diff --git a/spec/controllers/users/dossiers_controller_spec.rb b/spec/controllers/users/dossiers_controller_spec.rb index e0fd3501e..fb1a28e53 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -1142,7 +1142,7 @@ describe Users::DossiersController, type: :controller do end context 'when the dossier has been submitted' do - it { expect(assigns(:acls)).to eq(PiecesJustificativesService.new(user_profile: user).acl_for_dossier_export(dossier.procedure)) } + it { expect(assigns(:acls)).to eq(PiecesJustificativesService.new(user_profile: user, export_template: nil).acl_for_dossier_export(dossier.procedure)) } it { expect(response).to render_template('dossiers/show') } end end diff --git a/spec/models/export_spec.rb b/spec/models/export_spec.rb index 5e7e1eda3..5b2a8ae99 100644 --- a/spec/models/export_spec.rb +++ b/spec/models/export_spec.rb @@ -109,6 +109,14 @@ RSpec.describe Export, type: :model do end end + context 'with export template' do + let(:export_template) { build(:export_template) } + it 'creates new export' do + expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, export_template: export_template, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) } + .to change { Export.count }.by(1) + end + end + context 'with existing matching export' do def find_or_create = Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index 7a212912e..771cb8e96 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -2,8 +2,9 @@ describe PiecesJustificativesService do describe '.liste_documents' do let(:dossier) { create(:dossier, procedure: procedure) } let(:dossiers) { Dossier.where(id: dossier.id) } + let(:export_template) { nil } subject do - PiecesJustificativesService.new(user_profile:).liste_documents(dossiers).map(&:first) + PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:first) end context 'no acl' do @@ -19,6 +20,11 @@ describe PiecesJustificativesService do end it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) } + + context 'with export_template' do + let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur).tap(&:set_default_values) } + it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) } + end end context 'with a multiple attachments' do @@ -303,7 +309,7 @@ describe PiecesJustificativesService do let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :piece_justificative }] }]) } let(:dossier) { create(:dossier, :with_populated_champs, procedure: procedure) } let(:dossiers) { Dossier.where(id: dossier.id) } - subject { PiecesJustificativesService.new(user_profile:).generate_dossiers_export(dossiers) } + subject { PiecesJustificativesService.new(user_profile:, export_template: nil).generate_dossiers_export(dossiers) } it "doesn't update dossier" do expect { subject }.not_to change { dossier.updated_at } @@ -315,7 +321,7 @@ describe PiecesJustificativesService do let!(:not_confidentiel_avis) { create(:avis, :not_confidentiel, dossier: dossier) } let!(:expert_avis) { create(:avis, :confidentiel, dossier: dossier, expert: user_profile) } - subject { PiecesJustificativesService.new(user_profile:).generate_dossiers_export(dossiers) } + subject { PiecesJustificativesService.new(user_profile:, export_template: nil).generate_dossiers_export(dossiers) } it "includes avis not confidentiel as well as expert's avis" do expect_any_instance_of(Dossier).to receive(:avis_for_expert).with(user_profile).and_return([]) subject diff --git a/spec/services/procedure_export_service_spec.rb b/spec/services/procedure_export_service_spec.rb index aa2fb3733..9109124f4 100644 --- a/spec/services/procedure_export_service_spec.rb +++ b/spec/services/procedure_export_service_spec.rb @@ -2,8 +2,9 @@ require 'csv' describe ProcedureExportService do let(:instructeur) { create(:instructeur) } - let(:procedure) { create(:procedure, :published, :for_individual, :with_all_champs, instructeurs: [instructeur]) } - let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur) } + let(:procedure) { create(:procedure, :published, :for_individual, :with_all_champs) } + let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) } + let(:export_template) { nil } describe 'to_xlsx' do subject do @@ -243,7 +244,7 @@ describe ProcedureExportService do context 'as csv' do subject do - ProcedureExportService.new(procedure, procedure.dossiers, instructeur) + ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) .to_csv .open { |f| CSV.read(f.path) } end @@ -519,39 +520,68 @@ describe ProcedureExportService do end end - context 'generate_dossiers_export' do + describe 'generate_dossiers_export' do it 'include_infos_administration (so it includes avis, champs privés)' do - expect(ActiveStorage::DownloadableFile).to receive(:create_list_from_dossiers).with(dossiers: anything, user_profile: instructeur).and_return([]) + expect(ActiveStorage::DownloadableFile).to receive(:create_list_from_dossiers).with(dossiers: anything, user_profile: instructeur, export_template:).and_return([]) subject end - end - context 'with files (and http calls)' do - let!(:dossier) { create(:dossier, :accepte, :with_populated_champs, :with_individual, procedure: procedure) } - let(:dossier_exports) { PiecesJustificativesService.new(user_profile: instructeur).generate_dossiers_export(Dossier.where(id: dossier)) } - before do - allow_any_instance_of(ActiveStorage::Attachment).to receive(:url).and_return("https://opengraph.githubassets.com/d0e7862b24d8026a3c03516d865b28151eb3859029c6c6c2e86605891fbdcd7a/socketry/async-io") + context 'with export_template' do + let!(:dossier) { create(:dossier, :accepte, :with_populated_champs, :with_individual, procedure: procedure) } + let(:dossier_exports) { PiecesJustificativesService.new(user_profile: instructeur, export_template:).generate_dossiers_export(Dossier.where(id: dossier)) } + let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur).tap(&:set_default_values) } + before do + allow_any_instance_of(ActiveStorage::Attachment).to receive(:url).and_return("https://opengraph.githubassets.com/d0e7862b24d8026a3c03516d865b28151eb3859029c6c6c2e86605891fbdcd7a/socketry/async-io") + end + + it 'returns a blob with custom filenames' do + VCR.use_cassette('archive/new_file_to_get_200') do + subject + File.write('tmp.zip', subject.download, mode: 'wb') + File.open('tmp.zip') do |fd| + files = ZipTricks::FileReader.read_zip_structure(io: fd) + base_fn = "export" + structure = [ + "#{base_fn}/", + "#{base_fn}/dossier-#{dossier.id}/", + "#{base_fn}/dossier-#{dossier.id}/piece_justificative-#{dossier.id}.txt", + "#{base_fn}/dossier-#{dossier.id}/export_#{dossier.id}.pdf" + ] + expect(files.size).to eq(structure.size) + expect(files.map(&:filename)).to match_array(structure) + end + FileUtils.remove_entry_secure('tmp.zip') + end + end end - it 'returns a blob with valid files' do - VCR.use_cassette('archive/new_file_to_get_200') do - subject + context 'with files (and http calls)' do + let!(:dossier) { create(:dossier, :accepte, :with_populated_champs, :with_individual, procedure: procedure) } + let(:dossier_exports) { PiecesJustificativesService.new(user_profile: instructeur, export_template: nil).generate_dossiers_export(Dossier.where(id: dossier)) } + before do + allow_any_instance_of(ActiveStorage::Attachment).to receive(:url).and_return("https://opengraph.githubassets.com/d0e7862b24d8026a3c03516d865b28151eb3859029c6c6c2e86605891fbdcd7a/socketry/async-io") + end - File.write('tmp.zip', subject.download, mode: 'wb') - File.open('tmp.zip') do |fd| - files = ZipTricks::FileReader.read_zip_structure(io: fd) - base_fn = 'export' - structure = [ - "#{base_fn}/", - "#{base_fn}/dossier-#{dossier.id}/", - "#{base_fn}/dossier-#{dossier.id}/pieces_justificatives/", - "#{base_fn}/dossier-#{dossier.id}/#{ActiveStorage::DownloadableFile.timestamped_filename(ActiveStorage::Attachment.where(record_type: "Champ").first)}", - "#{base_fn}/dossier-#{dossier.id}/#{ActiveStorage::DownloadableFile.timestamped_filename(dossier_exports.first.first)}" - ] - expect(files.size).to eq(structure.size) - expect(files.map(&:filename)).to match_array(structure) + it 'returns a blob with valid files' do + VCR.use_cassette('archive/new_file_to_get_200') do + subject + + File.write('tmp.zip', subject.download, mode: 'wb') + File.open('tmp.zip') do |fd| + files = ZipTricks::FileReader.read_zip_structure(io: fd) + base_fn = 'export' + structure = [ + "#{base_fn}/", + "#{base_fn}/dossier-#{dossier.id}/", + "#{base_fn}/dossier-#{dossier.id}/pieces_justificatives/", + "#{base_fn}/dossier-#{dossier.id}/#{ActiveStorage::DownloadableFile.timestamped_filename(ActiveStorage::Attachment.where(record_type: "Champ").first)}", + "#{base_fn}/dossier-#{dossier.id}/#{ActiveStorage::DownloadableFile.timestamped_filename(dossier_exports.first.first)}" + ] + expect(files.size).to eq(structure.size) + expect(files.map(&:filename)).to match_array(structure) + end + FileUtils.remove_entry_secure('tmp.zip') end - FileUtils.remove_entry_secure('tmp.zip') end end end From 2a4bfdd40be75b0d44b0dbb59314e63997817831 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 8 Mar 2024 17:15:47 +0100 Subject: [PATCH 0208/1532] can use export template from export_dropdown_component --- app/components/dossiers/export_dropdown_component.rb | 10 ++++++++-- .../export_dropdown_component.html.haml | 4 ++++ .../administrateurs/exports/download.turbo_stream.haml | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/components/dossiers/export_dropdown_component.rb b/app/components/dossiers/export_dropdown_component.rb index 098dae369..bf67f688e 100644 --- a/app/components/dossiers/export_dropdown_component.rb +++ b/app/components/dossiers/export_dropdown_component.rb @@ -3,6 +3,7 @@ class Dossiers::ExportDropdownComponent < ApplicationComponent def initialize(procedure:, statut: nil, count: nil, class_btn: nil, export_url: nil) @procedure = procedure + @export_templates = procedure.export_templates @statut = statut @count = count @class_btn = class_btn @@ -21,10 +22,15 @@ class Dossiers::ExportDropdownComponent < ApplicationComponent item.fetch(:format) != :json || @procedure.active_revision.carte? end - def download_export_path(export_format:, no_progress_notification: nil) + def download_export_path(export_format: nil, export_template_id: nil, no_progress_notification: nil) @export_url.call(@procedure, - export_format: export_format, + export_format:, + export_template_id:, statut: @statut, no_progress_notification: no_progress_notification) end + + def export_templates + @export_templates + end end diff --git a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml index 12e064ae0..398a9571d 100644 --- a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml +++ b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml @@ -14,3 +14,7 @@ - menu.with_item do = link_to download_export_path(export_format: format), role: 'menuitem', data: { turbo_method: :post, turbo: true } do = t(".everything_#{format}_html") + - export_templates.each do |export_template| + - menu.with_item do + = link_to download_export_path(export_template_id: export_template.id), role: 'menuitem', data: { turbo_method: :post, turbo: true } do + = "Exporter à partir du modèle #{export_template.name}" diff --git a/app/views/administrateurs/exports/download.turbo_stream.haml b/app/views/administrateurs/exports/download.turbo_stream.haml index 6db447783..e88340126 100644 --- a/app/views/administrateurs/exports/download.turbo_stream.haml +++ b/app/views/administrateurs/exports/download.turbo_stream.haml @@ -1,4 +1,4 @@ -# not renderable as administrateur flagged as manager, so render it anyway - if @can_download_dossiers = turbo_stream.update_all '.procedure-actions' do - = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, count: @dossiers_count, export_url: method(:admin_procedure_exports_path)) + = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_templates: current_instructeur.export_templates, count: @dossiers_count, export_url: method(:admin_procedure_exports_path)) From fd9335f12989dfa6fc8086bd12d7511c52babba5 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Mon, 11 Mar 2024 15:56:17 +0100 Subject: [PATCH 0209/1532] style editor tags --- .../tags_button_list_component.html.haml | 19 ++++++++++--------- .../export_templates/_form.html.haml | 16 +++------------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/app/components/tags_button_list_component/tags_button_list_component.html.haml b/app/components/tags_button_list_component/tags_button_list_component.html.haml index 74f66a55d..ee52a1c09 100644 --- a/app/components/tags_button_list_component/tags_button_list_component.html.haml +++ b/app/components/tags_button_list_component/tags_button_list_component.html.haml @@ -1,14 +1,15 @@ - each_category do |category, tags, can_toggle_nullable| - .flex - %p.fr-label.fr-text--sm.fr-text--bold.fr-mb-1w= t(category, scope: ".categories") + - if category.present? + .flex + %p.fr-label.fr-text--sm.fr-text--bold.fr-mb-1w= t(category, scope: ".categories") - - if can_toggle_nullable - .fr-fieldset__element.fr-ml-4w - .fr-checkbox-group.fr-checkbox-group--sm - = check_box_tag("show_maybe_null", 1, false, data: { "no-autosubmit" => true, action: "change->attestation#toggleMaybeNull"}) - = label_tag "show_maybe_null", for: :show_maybe_null do - Voir les champs facultatifs - %span.hidden.fr-hint-text Un champ non rempli restera vide dans l’attestation. + - if can_toggle_nullable + .fr-fieldset__element.fr-ml-4w + .fr-checkbox-group.fr-checkbox-group--sm + = check_box_tag("show_maybe_null", 1, false, data: { "no-autosubmit" => true, action: "change->attestation#toggleMaybeNull"}) + = label_tag "show_maybe_null", for: :show_maybe_null do + Voir les champs facultatifs + %span.hidden.fr-hint-text Un champ non rempli restera vide dans l’attestation. %ul.fr-tags-group{ data: { category: category } } - tags.each do |tag| diff --git a/app/views/instructeurs/export_templates/_form.html.haml b/app/views/instructeurs/export_templates/_form.html.haml index 4ae51c1d5..2acdb655f 100644 --- a/app/views/instructeurs/export_templates/_form.html.haml +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -22,19 +22,13 @@ = f.label :tiptap_default_dossier_directory, class: "fr-label" .editor.mt-2{ data: { tiptap_target: 'editor' } } = f.hidden_field :tiptap_default_dossier_directory, data: { tiptap_target: 'input', controller: 'turbo-input', turbo_input_url_value: preview_instructeur_export_templates_path } - %ul.mt-2.flex.wrap.flex-gap-1 - - @export_template.specific_tags.each do |tag| - %li.fr-badge.fr-badge--sm{ role: 'button', title: tag[:description], data: { action: 'click->tiptap#insertTag', tiptap_target: 'tag', tag_id: tag[:id], tag_label: tag[:libelle] } } - = tag[:libelle] + .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.specific_tags }) .fr-input-group{ data: { controller: 'tiptap' } } = f.label :tiptap_pdf_name, class: "fr-label" .editor.mt-2{ data: { tiptap_target: 'editor' } } = f.hidden_field :tiptap_pdf_name, data: { tiptap_target: 'input', controller: 'turbo-input', turbo_input_url_value: preview_instructeur_export_templates_path } - %ul.mt-2.flex.wrap.flex-gap-1 - - @export_template.specific_tags.each do |tag| - %li.fr-badge.fr-badge--sm{ role: 'button', title: tag[:description], data: { action: 'click->tiptap#insertTag', tiptap_target: 'tag', tag_id: tag[:id], tag_label: tag[:libelle] } } - = tag[:libelle] + .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.specific_tags }) - if @all_pj.any? %h3 Pieces justificatives @@ -44,11 +38,7 @@ = label_tag pj.libelle, nil, name: field_name(:export_template, "tiptap_pj_#{pj.stable_id}"), class: "fr-label" .editor.mt-2{ data: { tiptap_target: 'editor' } } = hidden_field_tag field_name(:export_template, "tiptap_pj_#{pj.stable_id}"), "#{@export_template.content_for_pj(pj)}" , data: { tiptap_target: 'input', controller: 'turbo-input', turbo_input_url_value: preview_instructeur_export_templates_path } - %ul.mt-2.flex.wrap.flex-gap-1 - - @export_template.specific_tags.each do |tag| - %li.fr-badge.fr-badge--sm{ role: 'button', title: tag[:description], data: { action: 'click->tiptap#insertTag', tiptap_target: 'tag', tag_id: tag[:id], tag_label: tag[:libelle] } } - = tag[:libelle] - + .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.specific_tags }) .fixed-footer .fr-container From 5aac2ecdc45e4065c618e225538049c925af1388 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Mon, 11 Mar 2024 16:20:00 +0100 Subject: [PATCH 0210/1532] rename editor css class Co-authored-by: Colin Darie --- .../stylesheets/attestation_template_2_edit.scss | 13 +------------ app/assets/stylesheets/tiptap_editor.scss | 14 ++++++++++++++ .../attestation_template_v2s/edit.html.haml | 2 +- .../instructeurs/export_templates/_form.html.haml | 6 +++--- 4 files changed, 19 insertions(+), 16 deletions(-) create mode 100644 app/assets/stylesheets/tiptap_editor.scss diff --git a/app/assets/stylesheets/attestation_template_2_edit.scss b/app/assets/stylesheets/attestation_template_2_edit.scss index b726e9a8d..74a0a5684 100644 --- a/app/assets/stylesheets/attestation_template_2_edit.scss +++ b/app/assets/stylesheets/attestation_template_2_edit.scss @@ -20,7 +20,7 @@ min-height: 400px; } - .editor { + .tiptap-editor { // Visual zones .header .flex-1, h1 { @@ -63,17 +63,6 @@ li p { margin-bottom: 0; } - - // Tags - .fr-menu__list { - max-height: 500px; - } - - .fr-tag:not(.fr-menu .fr-tag) { - // style span rendered by tiptap like a button/link tag - color: var(--text-action-high-blue-france); - background-color: var(--background-action-low-blue-france); - } } // scss-lint:disable SelectorFormat diff --git a/app/assets/stylesheets/tiptap_editor.scss b/app/assets/stylesheets/tiptap_editor.scss new file mode 100644 index 000000000..9682989e5 --- /dev/null +++ b/app/assets/stylesheets/tiptap_editor.scss @@ -0,0 +1,14 @@ +@import "constants"; + +.tiptap-editor { + // Tags + .fr-menu__list { + max-height: 500px; + } + + .fr-tag:not(.fr-menu .fr-tag) { + // style span rendered by tiptap like a button/link tag + color: var(--text-action-high-blue-france); + background-color: var(--background-action-low-blue-france); + } +} diff --git a/app/views/administrateurs/attestation_template_v2s/edit.html.haml b/app/views/administrateurs/attestation_template_v2s/edit.html.haml index 308dca5e4..090247519 100644 --- a/app/views/administrateurs/attestation_template_v2s/edit.html.haml +++ b/app/views/administrateurs/attestation_template_v2s/edit.html.haml @@ -77,7 +77,7 @@ %button.fr-btn.fr-btn--secondary.fr-btn--sm{ type: 'button', title: label, class: icon == :hidden ? "hidden" : "fr-icon-#{icon}", data: { action: 'click->tiptap#menuButton', tiptap_target: 'button', tiptap_action: action } } = label - #editor.editor{ data: { tiptap_target: 'editor' }, aria: { describedby: dom_id(f.object, "json-body-messages")} } + #editor.tiptap-editor{ data: { tiptap_target: 'editor' }, aria: { describedby: dom_id(f.object, "json-body-messages")} } = f.hidden_field :tiptap_body, data: { tiptap_target: 'input' } .fr-error-text{ id: dom_id(f.object, "json-body-messages"), class: class_names("hidden" => !f.object.errors.include?(:json_body)) } diff --git a/app/views/instructeurs/export_templates/_form.html.haml b/app/views/instructeurs/export_templates/_form.html.haml index 2acdb655f..30dde872f 100644 --- a/app/views/instructeurs/export_templates/_form.html.haml +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -20,13 +20,13 @@ .fr-input-group{ data: { controller: 'tiptap' } } = f.label :tiptap_default_dossier_directory, class: "fr-label" - .editor.mt-2{ data: { tiptap_target: 'editor' } } + .tiptap-editor.fr-mt-1w{ data: { tiptap_target: 'editor' } } = f.hidden_field :tiptap_default_dossier_directory, data: { tiptap_target: 'input', controller: 'turbo-input', turbo_input_url_value: preview_instructeur_export_templates_path } .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.specific_tags }) .fr-input-group{ data: { controller: 'tiptap' } } = f.label :tiptap_pdf_name, class: "fr-label" - .editor.mt-2{ data: { tiptap_target: 'editor' } } + .tiptap-editor.fr-mt-1w{ data: { tiptap_target: 'editor' } } = f.hidden_field :tiptap_pdf_name, data: { tiptap_target: 'input', controller: 'turbo-input', turbo_input_url_value: preview_instructeur_export_templates_path } .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.specific_tags }) @@ -36,7 +36,7 @@ - @all_pj.each do |pj| .fr-input-group{ data: { controller: 'tiptap' } } = label_tag pj.libelle, nil, name: field_name(:export_template, "tiptap_pj_#{pj.stable_id}"), class: "fr-label" - .editor.mt-2{ data: { tiptap_target: 'editor' } } + .tiptap-editor.fr-mt-1w{ data: { tiptap_target: 'editor' } } = hidden_field_tag field_name(:export_template, "tiptap_pj_#{pj.stable_id}"), "#{@export_template.content_for_pj(pj)}" , data: { tiptap_target: 'input', controller: 'turbo-input', turbo_input_url_value: preview_instructeur_export_templates_path } .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.specific_tags }) From 4a79ecf301ec6cd1bc527d0f0e247c68edd8090e Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 12 Mar 2024 13:44:22 +0100 Subject: [PATCH 0211/1532] add new export template link --- .../export_dropdown_component.html.haml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml index 398a9571d..2583997c4 100644 --- a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml +++ b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml @@ -18,3 +18,6 @@ - menu.with_item do = link_to download_export_path(export_template_id: export_template.id), role: 'menuitem', data: { turbo_method: :post, turbo: true } do = "Exporter à partir du modèle #{export_template.name}" + - menu.with_item do + = link_to new_instructeur_export_template_path(procedure_id: params[:procedure_id]), role: 'menuitem' do + Ajouter un modèle d'export From be0c0311c5a9407367784901b9b80984ed282f11 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 12 Mar 2024 15:39:29 +0100 Subject: [PATCH 0212/1532] use export template for admin archives --- app/controllers/administrateurs/exports_controller.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/controllers/administrateurs/exports_controller.rb b/app/controllers/administrateurs/exports_controller.rb index 6e1d55305..c7cd0a751 100644 --- a/app/controllers/administrateurs/exports_controller.rb +++ b/app/controllers/administrateurs/exports_controller.rb @@ -34,7 +34,11 @@ module Administrateurs private def export_format - @export_format ||= params[:export_format] + @export_format ||= params[:export_format].presence || export_template&.kind + end + + def export_template + @export_template ||= ExportTemplate.find(params[:export_template_id]) if params[:export_template_id].present? end def export_options From 93f1fd5ebf81a19d52f13218af57b6fbffb4da38 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 12 Mar 2024 16:21:15 +0100 Subject: [PATCH 0213/1532] add export template lists --- .../instructeurs/procedures_controller.rb | 1 + .../instructeurs/procedures/exports.html.haml | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index 1559dca8c..386114576 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -245,6 +245,7 @@ module Instructeurs def exports @procedure = procedure @exports = Export.for_groupe_instructeurs(groupe_instructeur_ids).ante_chronological + @export_templates = current_instructeur.export_templates_for(@procedure).includes(:groupe_instructeur) cookies.encrypted[cookies_export_key] = { value: DateTime.current, expires: Export::MAX_DUREE_GENERATION + Export::MAX_DUREE_CONSERVATION_EXPORT diff --git a/app/views/instructeurs/procedures/exports.html.haml b/app/views/instructeurs/procedures/exports.html.haml index ed2f67fa8..67bd7e42c 100644 --- a/app/views/instructeurs/procedures/exports.html.haml +++ b/app/views/instructeurs/procedures/exports.html.haml @@ -22,3 +22,25 @@ - else = t('.no_export_html', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i ) + + .fr-table.fr-mt-5w + %table + %caption Liste des modèles d'export + %thead + %tr + %th{ scope: 'col' } Nom du modèle + %th{ scope: 'col' } Nom du modèle + %th{ scope: 'col' }= "Groupe instructeur" if @procedure.groupe_instructeurs.many? + %tbody + - @export_templates.each do |export_template| + %tr + %td= link_to export_template.name, edit_instructeur_export_template_path(export_template, procedure_id: @procedure.id) + %td= export_template.groupe_instructeur.label if @procedure.groupe_instructeurs.many? + - @export_templates.each do |export_template| + %tr + %td= link_to export_template.name, edit_instructeur_export_template_path(export_template, procedure_id: @procedure.id) + %td= export_template.groupe_instructeur.label + + %p + = link_to new_instructeur_export_template_path(procedure_id: params[:procedure_id]), class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line' do + Ajouter un modèle d'export From 2c28d97f3f7834d4d4237086f03c5bf018c60ff9 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 12 Mar 2024 17:28:43 +0100 Subject: [PATCH 0214/1532] destroy export_template --- .../instructeurs/export_templates_controller.rb | 8 ++++++++ app/models/export_template.rb | 1 + .../instructeurs/export_templates/_form.html.haml | 6 +++++- config/routes.rb | 2 +- .../export_templates_controller_spec.rb | 13 +++++++++++++ 5 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/controllers/instructeurs/export_templates_controller.rb b/app/controllers/instructeurs/export_templates_controller.rb index e04cf8152..9065b2d21 100644 --- a/app/controllers/instructeurs/export_templates_controller.rb +++ b/app/controllers/instructeurs/export_templates_controller.rb @@ -37,6 +37,14 @@ module Instructeurs end end + def destroy + if @export_template.destroy + redirect_to exports_instructeur_procedure_path(procedure: @procedure), notice: "Le modèle d'export #{@export_template.name} a bien été supprimé" + else + redirect_to exports_instructeur_procedure_path(procedure: @procedure), alert: "Le modèle d'export #{@export_template.name} n'a pu être supprimé" + end + end + def preview param = params.require(:export_template).keys.first @preview_param = param.delete_prefix("tiptap_") diff --git a/app/models/export_template.rb b/app/models/export_template.rb index 2a7282fdc..8dc01aa85 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -3,6 +3,7 @@ class ExportTemplate < ApplicationRecord belongs_to :groupe_instructeur has_one :procedure, through: :groupe_instructeur + has_many :exports, dependent: :nullify validates_with ExportTemplateValidator DOSSIER_STATE = Dossier.states.fetch(:en_construction) diff --git a/app/views/instructeurs/export_templates/_form.html.haml b/app/views/instructeurs/export_templates/_form.html.haml index 30dde872f..057120f28 100644 --- a/app/views/instructeurs/export_templates/_form.html.haml +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -47,7 +47,11 @@ = f.submit "Enregistrer", class: "fr-btn" %li = link_to "Annuler", instructeur_procedure_path(@procedure), class: "fr-btn fr-btn--secondary" - - if @export_template.sample_dossier + - if @export_template.persisted? + %li + = link_to "Supprimer", instructeur_export_template_path(@export_template, procedure_id: @procedure.id), method: :delete, data: { confirm: "Voulez-vous vraiment supprimer ce modèle ? Il sera supprimé pour tous les instructeurs du groupe"}, class: "fr-btn fr-btn--secondary" + - sample_dossier = @procedure.dossier_for_preview(current_instructeur) + - if sample_dossier .fr-col-12.fr-col-md-4.fr-background-alt--blue-france .export-template-preview.fr-p-2w.sticky--top %h2.fr-h4 Aperçu diff --git a/config/routes.rb b/config/routes.rb index 461575d5b..3905bb24f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -450,7 +450,7 @@ Rails.application.routes.draw do resources :procedures, only: [:index, :show], param: :procedure_id do member do resources :archives, only: [:index, :create] - resources :export_templates, only: [:new, :create, :edit, :update] do + resources :export_templates, only: [:new, :create, :edit, :update, :destroy] do collection do get 'preview' end diff --git a/spec/controllers/instructeurs/export_templates_controller_spec.rb b/spec/controllers/instructeurs/export_templates_controller_spec.rb index 83adb32ac..8b8d73b82 100644 --- a/spec/controllers/instructeurs/export_templates_controller_spec.rb +++ b/spec/controllers/instructeurs/export_templates_controller_spec.rb @@ -117,4 +117,17 @@ describe Instructeurs::ExportTemplatesController, type: :controller do end end end + + describe '#destroy' do + let(:export_template) { create(:export_template, groupe_instructeur:) } + let(:subject) { delete :destroy, params: { procedure_id: procedure.id, id: export_template.id } } + + context 'with valid params' do + it 'redirect to some page' do + subject + expect(response).to redirect_to(exports_instructeur_procedure_path(procedure:)) + expect(flash.notice).to eq "Le modèle d'export Mon export a bien été supprimé" + end + end + end end From 43c862ed4dc4c1391b1cf0ee857ed7af9be1868e Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Wed, 13 Mar 2024 13:51:22 +0100 Subject: [PATCH 0215/1532] list export templates for groupes instructeur of current instructeur --- .../dossiers/export_dropdown_component.rb | 4 ++-- .../export_dropdown_component.html.haml | 14 ++++++++------ app/models/instructeur.rb | 4 ++++ .../exports/download.turbo_stream.haml | 2 +- .../procedures/deleted_dossiers.html.haml | 2 +- .../procedures/download_export.turbo_stream.haml | 4 ++-- app/views/instructeurs/procedures/show.html.haml | 4 ++-- 7 files changed, 20 insertions(+), 14 deletions(-) diff --git a/app/components/dossiers/export_dropdown_component.rb b/app/components/dossiers/export_dropdown_component.rb index bf67f688e..91a3de116 100644 --- a/app/components/dossiers/export_dropdown_component.rb +++ b/app/components/dossiers/export_dropdown_component.rb @@ -1,9 +1,9 @@ class Dossiers::ExportDropdownComponent < ApplicationComponent include ApplicationHelper - def initialize(procedure:, statut: nil, count: nil, class_btn: nil, export_url: nil) + def initialize(procedure:, export_templates: nil, statut: nil, count: nil, class_btn: nil, export_url: nil) @procedure = procedure - @export_templates = procedure.export_templates + @export_templates = export_templates @statut = statut @count = count @class_btn = class_btn diff --git a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml index 2583997c4..dbbdbcb07 100644 --- a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml +++ b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml @@ -14,10 +14,12 @@ - menu.with_item do = link_to download_export_path(export_format: format), role: 'menuitem', data: { turbo_method: :post, turbo: true } do = t(".everything_#{format}_html") - - export_templates.each do |export_template| + + - if export_templates.present? + - export_templates.each do |export_template| + - menu.with_item do + = link_to download_export_path(export_template_id: export_template.id), role: 'menuitem', data: { turbo_method: :post, turbo: true } do + = "Exporter à partir du modèle #{export_template.name}" - menu.with_item do - = link_to download_export_path(export_template_id: export_template.id), role: 'menuitem', data: { turbo_method: :post, turbo: true } do - = "Exporter à partir du modèle #{export_template.name}" - - menu.with_item do - = link_to new_instructeur_export_template_path(procedure_id: params[:procedure_id]), role: 'menuitem' do - Ajouter un modèle d'export + = link_to new_instructeur_export_template_path(procedure_id: params[:procedure_id]), role: 'menuitem' do + Ajouter un modèle d'export diff --git a/app/models/instructeur.rb b/app/models/instructeur.rb index 0b724d337..5933df7b7 100644 --- a/app/models/instructeur.rb +++ b/app/models/instructeur.rb @@ -303,6 +303,10 @@ class Instructeur < ApplicationRecord agent_connect_information.order(updated_at: :desc).first end + def export_templates_for(procedure) + procedure.export_templates.where(groupe_instructeur: groupe_instructeurs).order(:name) + end + private def annotations_hash(demande, annotations_privees, avis, messagerie) diff --git a/app/views/administrateurs/exports/download.turbo_stream.haml b/app/views/administrateurs/exports/download.turbo_stream.haml index e88340126..6db447783 100644 --- a/app/views/administrateurs/exports/download.turbo_stream.haml +++ b/app/views/administrateurs/exports/download.turbo_stream.haml @@ -1,4 +1,4 @@ -# not renderable as administrateur flagged as manager, so render it anyway - if @can_download_dossiers = turbo_stream.update_all '.procedure-actions' do - = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_templates: current_instructeur.export_templates, count: @dossiers_count, export_url: method(:admin_procedure_exports_path)) + = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, count: @dossiers_count, export_url: method(:admin_procedure_exports_path)) diff --git a/app/views/instructeurs/procedures/deleted_dossiers.html.haml b/app/views/instructeurs/procedures/deleted_dossiers.html.haml index b3b31961f..7482f95f9 100644 --- a/app/views/instructeurs/procedures/deleted_dossiers.html.haml +++ b/app/views/instructeurs/procedures/deleted_dossiers.html.haml @@ -11,7 +11,7 @@ .procedure-actions - if @can_download_dossiers - = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_export_instructeur_procedure_path)) + = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_templates: current_instructeur.export_templates_for(@procedure), export_url: method(:download_export_instructeur_procedure_path)) .fr-container.flex= render partial: "tabs", locals: { procedure: @procedure, statut: @statut, diff --git a/app/views/instructeurs/procedures/download_export.turbo_stream.haml b/app/views/instructeurs/procedures/download_export.turbo_stream.haml index b841c65a0..c6e47b799 100644 --- a/app/views/instructeurs/procedures/download_export.turbo_stream.haml +++ b/app/views/instructeurs/procedures/download_export.turbo_stream.haml @@ -2,10 +2,10 @@ - if @can_download_dossiers - if @statut.nil? = turbo_stream.update_all '.procedure-actions' do - = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_export_instructeur_procedure_path)) + = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_templates: current_instructeur.export_templates_for(@procedure), export_url: method(:download_export_instructeur_procedure_path)) - else = turbo_stream.update_all '.dossiers-export' do - = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, statut: @statut, count: @dossiers_count, export_url: method(:download_export_instructeur_procedure_path)) + = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_templates: current_instructeur.export_templates_for(@procedure), statut: @statut, count: @dossiers_count, export_url: method(:download_export_instructeur_procedure_path)) = turbo_stream.update "last-export-alert" do = render partial: "last_export_alert", locals: { export: @last_export, statut: @statut } diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index a067ef265..e3ec7a5a0 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -11,7 +11,7 @@ .procedure-actions - if @can_download_dossiers - = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_export_instructeur_procedure_path)) + = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_templates: current_instructeur.export_templates_for(@procedure), export_url: method(:download_export_instructeur_procedure_path)) .fr-container.flex= render partial: "tabs", locals: { procedure: @procedure, statut: @statut, @@ -72,7 +72,7 @@ - if @dossiers_count > 0 %span.dossiers-export - = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, statut: @statut, count: @dossiers_count, class_btn: 'fr-btn--tertiary', export_url: method(:download_export_instructeur_procedure_path)) + = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_templates: current_instructeur.export_templates_for(@procedure), statut: @statut, count: @dossiers_count, class_btn: 'fr-btn--tertiary', export_url: method(:download_export_instructeur_procedure_path)) - if @filtered_sorted_paginated_ids.present? || @current_filters.count > 0 = render partial: "dossiers_filter_tags", locals: { procedure: @procedure, procedure_presentation: @procedure_presentation, current_filters: @current_filters, statut: @statut } From 9f715e84d51ae4334665d040befda6a20be3c30a Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 15 Mar 2024 18:20:38 +0100 Subject: [PATCH 0216/1532] add i18n for export template --- .../export_templates/_form.html.haml | 17 ++++++++++++----- config/locales/models/export_templates/en.yml | 17 +++++++++++++++++ config/locales/models/export_templates/fr.yml | 17 +++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 config/locales/models/export_templates/en.yml create mode 100644 config/locales/models/export_templates/fr.yml diff --git a/app/views/instructeurs/export_templates/_form.html.haml b/app/views/instructeurs/export_templates/_form.html.haml index 057120f28..b0f7676fd 100644 --- a/app/views/instructeurs/export_templates/_form.html.haml +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -1,9 +1,7 @@ .fr-grid-row.fr-grid-row--gutters .fr-col-12.fr-col-md-8 = form_with url: form_url, model: @export_template, local: true do |f| - = render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field) do |c| - - c.with_hint do - Indiquez le nom à utiliser pour ce modèle d'export + = render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field) - if groupe_instructeurs.many? .fr-input-group @@ -19,13 +17,22 @@ = f.hidden_field :kind .fr-input-group{ data: { controller: 'tiptap' } } - = f.label :tiptap_default_dossier_directory, class: "fr-label" + = f.label :tiptap_default_dossier_directory, class: "fr-label" do + = f.object.class.human_attribute_name(:tiptap_default_dossier_directory) + = render EditableChamp::AsteriskMandatoryComponent.new + %span.fr-hint-text + = t('activerecord.attributes.export_template.hints.tiptap_default_dossier_directory') + .tiptap-editor.fr-mt-1w{ data: { tiptap_target: 'editor' } } = f.hidden_field :tiptap_default_dossier_directory, data: { tiptap_target: 'input', controller: 'turbo-input', turbo_input_url_value: preview_instructeur_export_templates_path } .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.specific_tags }) .fr-input-group{ data: { controller: 'tiptap' } } - = f.label :tiptap_pdf_name, class: "fr-label" + = f.label :tiptap_pdf_name, class: "fr-label" do + = f.object.class.human_attribute_name(:tiptap_pdf_name) + = render EditableChamp::AsteriskMandatoryComponent.new + %span.fr-hint-text + = t('activerecord.attributes.export_template.hints.tiptap_pdf_name') .tiptap-editor.fr-mt-1w{ data: { tiptap_target: 'editor' } } = f.hidden_field :tiptap_pdf_name, data: { tiptap_target: 'input', controller: 'turbo-input', turbo_input_url_value: preview_instructeur_export_templates_path } .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.specific_tags }) diff --git a/config/locales/models/export_templates/en.yml b/config/locales/models/export_templates/en.yml new file mode 100644 index 000000000..e6cd08856 --- /dev/null +++ b/config/locales/models/export_templates/en.yml @@ -0,0 +1,17 @@ +en: + activerecord: + models: + export_template: Export template + attributes: + export_template: + hints: + name: "The name will be visible by you and the other instructors" + tiptap_default_dossier_directory: "How would you like to name the directory containing the documents of a folder?" + tiptap_pdf_name: "How would you like to name the pdf file containing all the user's answers?" + name: "Template's name" + tiptap_default_dossier_directory: "Directory's name for pdf format" + tiptap_pdf_name: "Export's filename" + errors: + models: + export_template: + dossier_number_mandatory: "must contain dossier's number" diff --git a/config/locales/models/export_templates/fr.yml b/config/locales/models/export_templates/fr.yml new file mode 100644 index 000000000..60852aee0 --- /dev/null +++ b/config/locales/models/export_templates/fr.yml @@ -0,0 +1,17 @@ +fr: + activerecord: + models: + export_template: "Modèle d'export" + attributes: + export_template: + hints: + name: "Le nom sera visible par vous et les autres instructeurs pour générer un export" + tiptap_default_dossier_directory: "Comment souhaitez-vous nommer le répertoire contenant les documents d'un dossier ?" + tiptap_pdf_name: "Comment souhaitez-vous nommer le fichier pdf qui contient toutes les réponses de l'usager ?" + name: "Nom du modèle" + tiptap_default_dossier_directory: Nom du répertoire + tiptap_pdf_name: "Nom du dossier au format pdf" + errors: + models: + export_template: + dossier_number_mandatory: doit contenir le numéro du dossier From 8e8057ddd31cc99eab2e6905b781a40b46b7474d Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Wed, 20 Mar 2024 16:02:34 +0100 Subject: [PATCH 0217/1532] add some specs to export template --- .../tags_substitution_concern_spec.rb | 18 ----- spec/models/export_template_spec.rb | 78 ++++++++++++------- 2 files changed, 49 insertions(+), 47 deletions(-) diff --git a/spec/models/concerns/tags_substitution_concern_spec.rb b/spec/models/concerns/tags_substitution_concern_spec.rb index a11f991f7..e50ecfd70 100644 --- a/spec/models/concerns/tags_substitution_concern_spec.rb +++ b/spec/models/concerns/tags_substitution_concern_spec.rb @@ -604,24 +604,6 @@ describe TagsSubstitutionConcern, type: :model do end end - describe 'some_tags' do - context 'for entreprise procedure' do - let(:for_individual) { false } - it do - tags = template_concern.some_tags - expect(tags.map { _1[:id] }).to eq ["entreprise_siren", "entreprise_numero_tva_intracommunautaire", "entreprise_siret_siege_social", "entreprise_raison_sociale", "entreprise_adresse", "dossier_motivation", "dossier_depose_at", "dossier_en_instruction_at", "dossier_processed_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number"] - end - end - - context 'for individual procedure' do - let(:for_individual) { true } - it do - tags = template_concern.some_tags - expect(tags.map { _1[:id] }).to eq ["individual_gender", "individual_last_name", "individual_first_name", "dossier_motivation", "dossier_depose_at", "dossier_en_instruction_at", "dossier_processed_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number"] - end - end - end - describe 'parser' do it do tokens = TagsSubstitutionConcern::TagsParser.parse("hello world --public--, --numéro du dossier--, un test--yolo-- encore du text\n---\n encore du text --- et encore du text\n--tag--") diff --git a/spec/models/export_template_spec.rb b/spec/models/export_template_spec.rb index 2aa74744d..cb8d343ed 100644 --- a/spec/models/export_template_spec.rb +++ b/spec/models/export_template_spec.rb @@ -1,7 +1,15 @@ describe ExportTemplate do let(:groupe_instructeur) { create(:groupe_instructeur, procedure:) } - let(:export_template) { build(:export_template, groupe_instructeur:, content:) } - let(:procedure) { create(:procedure_with_dossiers) } + let(:export_template) { create(:export_template, groupe_instructeur:, content:) } + let(:procedure) { create(:procedure_with_dossiers, types_de_champ_public:, for_individual:) } + let(:dossier) { procedure.dossiers.first } + let(:for_individual) { false } + let(:types_de_champ_public) do + [ + { type: :piece_justificative, libelle: "Justificatif de domicile", mandatory: true, stable_id: 3 }, + { type: :titre_identite, libelle: "CNI", mandatory: true, stable_id: 5 } + ] + end let(:content) do { "pdf_name" => { @@ -30,13 +38,6 @@ describe ExportTemplate do describe 'new' do let(:export_template) { build(:export_template, groupe_instructeur: groupe_instructeur) } - let(:procedure) { create(:procedure, types_de_champ_public:) } - let(:types_de_champ_public) do - [ - { type: :integer_number, stable_id: 900 }, - { type: :piece_justificative, libelle: "Justificatif de domicile", mandatory: true, stable_id: 910 } - ] - end it 'set default values' do export_template.set_default_values expect(export_template.content).to eq({ @@ -56,7 +57,7 @@ describe ExportTemplate do [ { - "stable_id" => "910", + "stable_id" => "3", "path" => { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "text" => "justificatif-de-domicile-", "type" => "text" }, { "type" => "mention", "attrs" => ExportTemplate::DOSSIER_ID_TAG.stringify_keys }] }] } } ] @@ -161,8 +162,18 @@ describe ExportTemplate do end end + describe '#tiptap_convert' do + it 'convert default dossier directory' do + expect(export_template.tiptap_convert(procedure.dossiers.first, "default_dossier_directory")).to eq "DOSSIER_#{dossier.id}" + end + + it 'convert pdf_name' do + expect(export_template.tiptap_convert(procedure.dossiers.first, "pdf_name")).to eq "mon_export_#{dossier.id}" + end + end + describe '#valid?' do - let(:subject) { build(:export_template, content:) } + let(:subject) { build(:export_template, groupe_instructeur:, content:) } let(:ddd_text) { "DoSSIER" } let(:mention) { { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } } } let(:ddd_mention) { mention } @@ -194,7 +205,6 @@ describe ExportTemplate do context 'with valid default dossier directory' do it 'has no error for default_dossier_directory' do expect(subject.valid?).to be_truthy - expect(subject.errors[:default_dossier_directory]).not_to be_present end end @@ -204,23 +214,15 @@ describe ExportTemplate do let(:ddd_mention) { { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } } } it 'has no error for default_dossier_directory' do expect(subject.valid?).to be_truthy - expect(subject.errors[:default_dossier_directory]).not_to be_present end end - context 'without mention' do - let(:ddd_mention) { { "type" => "mention", "attrs" => {} } } - it "add error for default_dossier_directory" do - expect(subject.valid?).to be_falsey - expect(subject.errors[:default_dossier_directory]).to be_present - end - end - - context 'with mention but without numéro de dossier' do + context 'without numéro de dossier' do let(:ddd_mention) { { "type" => "mention", "attrs" => { "id" => 'dossier_service_name', "label" => "nom du service" } } } - it "add error for default_dossier_directory" do + it "add error for tiptap_default_dossier_directory" do expect(subject.valid?).to be_falsey - expect(subject.errors[:default_dossier_directory]).to be_present + expect(subject.errors[:tiptap_default_dossier_directory]).to be_present + expect(subject.errors.full_messages).to include "Le champ « Nom du répertoire » doit contenir le numéro du dossier" end end end @@ -228,7 +230,7 @@ describe ExportTemplate do context 'with valid pdf name' do it 'has no error for pdf name' do expect(subject.valid?).to be_truthy - expect(subject.errors[:pdf_name]).not_to be_present + expect(subject.errors[:tiptap_pdf_name]).not_to be_present end end @@ -247,7 +249,7 @@ describe ExportTemplate do context 'with mention' do it 'has no error for default_dossier_directory' do expect(subject.valid?).to be_truthy - expect(subject.errors[:default_dossier_directory]).not_to be_present + expect(subject.errors[:tiptap_pdf_name]).not_to be_present end end @@ -255,18 +257,18 @@ describe ExportTemplate do let(:pdf_mention) { { "type" => "mention", "attrs" => {} } } it "add error for pdf name" do expect(subject.valid?).to be_falsey - expect(subject.errors[:pdf_name]).to be_present + expect(subject.errors.full_messages).to include "Le champ « Nom de l'export » doit être rempli" end end end context 'with no pj text' do + # let!(:type_de_champ_pj) { create(:type_de_champ_piece_justificative, stable_id: 3, libelle: 'Justificatif de domicile', procedure:) } let(:pj_text) { " " } context 'with mention' do it 'has no error for pj' do expect(subject.valid?).to be_truthy - expect(subject.errors[:pj_3]).not_to be_present end end @@ -274,9 +276,27 @@ describe ExportTemplate do let(:pj_mention) { { "type" => "mention", "attrs" => {} } } it "add error for pj" do expect(subject.valid?).to be_falsey - expect(subject.errors[:pj_3]).to be_present + expect(subject.errors.full_messages).to include "Le champ « Justificatif de domicile » doit être rempli" end end end end + + describe 'specific_tags' do + context 'for entreprise procedure' do + let(:for_individual) { false } + it do + tags = export_template.specific_tags + expect(tags.map { _1[:id] }).to eq ["entreprise_siren", "entreprise_numero_tva_intracommunautaire", "entreprise_siret_siege_social", "entreprise_raison_sociale", "entreprise_adresse", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur"] + end + end + + context 'for individual procedure' do + let(:for_individual) { true } + it do + tags = export_template.specific_tags + expect(tags.map { _1[:id] }).to eq ["individual_gender", "individual_last_name", "individual_first_name", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur"] + end + end + end end From aeb4bd2ff19b51cf58e83df86118620a701144c6 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 19 Mar 2024 11:03:29 +0100 Subject: [PATCH 0218/1532] add original-filename tag --- app/models/export_template.rb | 19 +++++++--- .../export_templates/_form.html.haml | 2 +- spec/models/export_template_spec.rb | 35 +++++++++++++------ 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/app/models/export_template.rb b/app/models/export_template.rb index 8dc01aa85..b90a81a0e 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -58,16 +58,17 @@ class ExportTemplate < ApplicationRecord end end - def tiptap_convert_pj(dossier, pj_stable_id) - if content_for_pj_id(pj_stable_id)["content"]&.first["content"] - render_attributes_for(content_for_pj_id(pj_stable_id), dossier) + def tiptap_convert_pj(dossier, pj_stable_id, attachment = nil) + if content_for_pj_id(pj_stable_id)["content"]&.first&.[]("content") + render_attributes_for(content_for_pj_id(pj_stable_id), dossier, attachment) end end - def render_attributes_for(content_for, dossier) + def render_attributes_for(content_for, dossier, attachment = nil) tiptap = TiptapService.new used_tags = tiptap.used_tags_and_libelle_for(content_for.deep_symbolize_keys) substitutions = tags_substitutions(used_tags, dossier, escape: false) + substitutions['original-filename'] = attachment.filename.base if attachment tiptap.to_path(content_for.deep_symbolize_keys, substitutions) end @@ -88,6 +89,14 @@ class ExportTemplate < ApplicationRecord tags_categorized.slice(:individual, :etablissement, :dossier).values.flatten end + def tags_for_pj + specific_tags.push({ + libelle: 'nom original du fichier', + id: 'original-filename', + maybe_null: false + }) + end + private def tiptap_content(key) @@ -134,7 +143,7 @@ class ExportTemplate < ApplicationRecord stable_id = TypeDeChamp.find(type_de_champ_id).stable_id tiptap_pj = content["pjs"].find { |pj| pj["stable_id"] == stable_id.to_s } if tiptap_pj - File.join(folder(dossier), tiptap_convert_pj(dossier, stable_id) + suffix(attachment, index, row_index)) + File.join(folder(dossier), tiptap_convert_pj(dossier, stable_id, attachment) + suffix(attachment, index, row_index)) else File.join(folder(dossier), "erreur_renommage", attachment.filename.to_s) end diff --git a/app/views/instructeurs/export_templates/_form.html.haml b/app/views/instructeurs/export_templates/_form.html.haml index b0f7676fd..9f76d9aee 100644 --- a/app/views/instructeurs/export_templates/_form.html.haml +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -45,7 +45,7 @@ = label_tag pj.libelle, nil, name: field_name(:export_template, "tiptap_pj_#{pj.stable_id}"), class: "fr-label" .tiptap-editor.fr-mt-1w{ data: { tiptap_target: 'editor' } } = hidden_field_tag field_name(:export_template, "tiptap_pj_#{pj.stable_id}"), "#{@export_template.content_for_pj(pj)}" , data: { tiptap_target: 'input', controller: 'turbo-input', turbo_input_url_value: preview_instructeur_export_templates_path } - .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.specific_tags }) + .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.tags_for_pj }) .fixed-footer .fr-container diff --git a/spec/models/export_template_spec.rb b/spec/models/export_template_spec.rb index cb8d343ed..6d314f136 100644 --- a/spec/models/export_template_spec.rb +++ b/spec/models/export_template_spec.rb @@ -26,12 +26,16 @@ describe ExportTemplate do }, "pjs" => [ - {path: {"type"=>"doc", "content"=>[{"type"=>"paragraph", "content"=>[{"type"=>"mention", "attrs"=>{"id"=>"dossier_number", "label"=>"numéro du dossier"}}, {"text"=>" _justif", "type"=>"text"}]}]}, stable_id: "3"}, - { path: - {"type"=>"doc", "content"=>[{"type"=>"paragraph", "content"=>[{"text"=>"cni_", "type"=>"text"}, {"type"=>"mention", "attrs"=>{"id"=>"dossier_number", "label"=>"numéro du dossier"}}, {"text"=>" ", "type"=>"text"}]}]}, - stable_id: "5"}, - { path: {"type"=>"doc", "content"=>[{"type"=>"paragraph", "content"=>[{"text"=>"pj_repet_", "type"=>"text"}, {"type"=>"mention", "attrs"=>{"id"=>"dossier_number", "label"=>"numéro du dossier"}}, {"text"=>" ", "type"=>"text"}]}]}, - stable_id: "10"} + { path: { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "type" => "mention", "attrs" => { "id" => "original-filename", "label" => "nom original du fichier" } }, { "text" => " _justif", "type" => "text" }] }] }, stable_id: "3" }, + { + path: + { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "text" => "cni_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] }] }, + stable_id: "5" + }, + { + path: { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "text" => "pj_repet_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] }] }, + stable_id: "10" + } ] } end @@ -95,9 +99,9 @@ describe ExportTemplate do it 'returns tiptap content for pj' do expect(export_template.content_for_pj(type_de_champ_pj)).to eq({ - "type"=>"doc", - "content"=> [ - {"type"=>"paragraph", "content"=>[{"type"=>"mention", "attrs"=>{"id"=>"dossier_number", "label"=>"numéro du dossier"}}, {"text"=>" _justif", "type"=>"text"}]} + "type" => "doc", + "content" => [ + { "type" => "paragraph", "content" => [{ "type" => "mention", "attrs" => { "id" => "original-filename", "label" => "nom original du fichier" } }, { "text" => " _justif", "type" => "text" }] } ] }.to_json) end @@ -126,7 +130,7 @@ describe ExportTemplate do dossier.champs_public << champ_pj end it 'returns pj and custom name for pj' do - expect(export_template.attachment_and_path(dossier, attachment)).to eq([attachment, "DOSSIER_#{dossier.id}/#{dossier.id}_justif.png"]) + expect(export_template.attachment_and_path(dossier, attachment)).to eq([attachment, "DOSSIER_#{dossier.id}/superpj_justif.png"]) end end context 'pj repetable' do @@ -172,6 +176,17 @@ describe ExportTemplate do end end + describe '#tiptap_convert_pj' do + let(:type_de_champ_pj) { create(:type_de_champ_piece_justificative, stable_id: 3, libelle: 'Justificatif de domicile', procedure:) } + let(:champ_pj) { create(:champ_piece_justificative, type_de_champ: type_de_champ_pj) } + let(:attachment) { ActiveStorage::Attachment.new(name: 'pj', record: champ_pj, blob: ActiveStorage::Blob.new(filename: "superpj.png")) } + + it 'convert pj' do + attachment + expect(export_template.tiptap_convert_pj(dossier, type_de_champ_pj.stable_id, attachment)).to eq "superpj_justif" + end + end + describe '#valid?' do let(:subject) { build(:export_template, groupe_instructeur:, content:) } let(:ddd_text) { "DoSSIER" } From 565f6f44e59e2cd9304727088ef9e1bf4c3525cf Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Wed, 20 Mar 2024 16:34:46 +0100 Subject: [PATCH 0219/1532] make private some methods --- app/models/export_template.rb | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/app/models/export_template.rb b/app/models/export_template.rb index b90a81a0e..52b983f7f 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -72,19 +72,6 @@ class ExportTemplate < ApplicationRecord tiptap.to_path(content_for.deep_symbolize_keys, substitutions) end - - def folder(dossier) - render_attributes_for(content["default_dossier_directory"], dossier) - end - - def export_path(dossier) - File.join(folder(dossier), export_filename(dossier)) - end - - def export_filename(dossier) - "#{render_attributes_for(content["pdf_name"], dossier)}.pdf" - end - def specific_tags tags_categorized.slice(:individual, :etablissement, :dossier).values.flatten end @@ -117,6 +104,17 @@ class ExportTemplate < ApplicationRecord content_for_stable_id.symbolize_keys.fetch(:path) end + def folder(dossier) + render_attributes_for(content["default_dossier_directory"], dossier) + end + + def export_path(dossier) + File.join(folder(dossier), export_filename(dossier)) + end + + def export_filename(dossier) + "#{render_attributes_for(content["pdf_name"], dossier)}.pdf" + end def path(dossier, attachment, index, row_index) if attachment.name == 'pdf_export_for_instructeur' From 4e1552a9ebdf0c390b3591b0e55fb8cf0085c87f Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Thu, 21 Mar 2024 11:39:16 +0100 Subject: [PATCH 0220/1532] add sample messagerie in preview --- app/views/instructeurs/export_templates/_form.html.haml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/views/instructeurs/export_templates/_form.html.haml b/app/views/instructeurs/export_templates/_form.html.haml index 9f76d9aee..7ac7704c6 100644 --- a/app/views/instructeurs/export_templates/_form.html.haml +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -71,4 +71,9 @@ %ul %li#preview_pdf_name= @export_template.tiptap_convert(sample_dossier, "pdf_name") - @procedure.pieces_jointes_exportables_list.each do |pj| - %li{id: "preview_pj_#{pj.stable_id}"}= @export_template.tiptap_convert_pj(sample_dossier, pj.stable_id) + %li{ id: "preview_pj_#{pj.stable_id}" }= @export_template.tiptap_convert_pj(sample_dossier, pj.stable_id) + %ul + %li + %span messagerie + %ul + %li un-autre-fichier From 40d7b81e1653e1b353119064574636626931d992 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 26 Mar 2024 10:56:22 +0100 Subject: [PATCH 0221/1532] add some explanations for export template --- .../export_templates/_form.html.haml | 12 ++++++++++++ .../instructeurs/procedures/exports.html.haml | 19 +++++++++---------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/app/views/instructeurs/export_templates/_form.html.haml b/app/views/instructeurs/export_templates/_form.html.haml index 7ac7704c6..9be0a9ba0 100644 --- a/app/views/instructeurs/export_templates/_form.html.haml +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -1,6 +1,18 @@ .fr-grid-row.fr-grid-row--gutters .fr-col-12.fr-col-md-8 = form_with url: form_url, model: @export_template, local: true do |f| + #export_template-edit.fr-my-4w{ data: { controller: 'tiptap', tiptap_insert_after_tag_value: ' ' } } + .fr-mb-6w + = render Dsfr::AlertComponent.new(state: :info, title: "Nouvel éditeur de modèle d'export", heading_level: 'h3') do |c| + - c.with_body do + Cette page permet d'éditer un modèle d'export et ainsi personnaliser le contenu des exports (pour l'instant, + uniquement au format zip). Ainsi, vous pouvez notamment normaliser le nom des pièces jointes. + Essayez-le et donnez-nous votre avis + en nous envoyant un email à #{mail_to(CONTACT_EMAIL, subject: "Editeur de modèle d'export")}. + .fr-highlight + %p.fr-text--sm + N'incluez pas les extensions de fichier (.pdf, .jpg, …) dans les noms de pièces jointes et de fichiers. + = render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field) - if groupe_instructeurs.many? diff --git a/app/views/instructeurs/procedures/exports.html.haml b/app/views/instructeurs/procedures/exports.html.haml index 67bd7e42c..005eec45f 100644 --- a/app/views/instructeurs/procedures/exports.html.haml +++ b/app/views/instructeurs/procedures/exports.html.haml @@ -23,12 +23,15 @@ - else = t('.no_export_html', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i ) - .fr-table.fr-mt-5w - %table - %caption Liste des modèles d'export - %thead - %tr - %th{ scope: 'col' } Nom du modèle + %h2.fr-mb-1w.fr-mt-8w + Liste des modèles d'export + %p.fr-hint-text + Un modèle d'export permet de personnaliser le nom des fichiers (pour un export au format Zip) + - if @export_templates.any? + .fr-table.fr-table--no-caption.fr-mt-5w + %table + %thead + %tr %th{ scope: 'col' } Nom du modèle %th{ scope: 'col' }= "Groupe instructeur" if @procedure.groupe_instructeurs.many? %tbody @@ -36,10 +39,6 @@ %tr %td= link_to export_template.name, edit_instructeur_export_template_path(export_template, procedure_id: @procedure.id) %td= export_template.groupe_instructeur.label if @procedure.groupe_instructeurs.many? - - @export_templates.each do |export_template| - %tr - %td= link_to export_template.name, edit_instructeur_export_template_path(export_template, procedure_id: @procedure.id) - %td= export_template.groupe_instructeur.label %p = link_to new_instructeur_export_template_path(procedure_id: params[:procedure_id]), class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line' do From 213522f23f32a323c1689a5833bc79d5cf8be760 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 29 Mar 2024 13:58:36 +0100 Subject: [PATCH 0222/1532] remove useless tiptap controller --- app/views/instructeurs/export_templates/_form.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/instructeurs/export_templates/_form.html.haml b/app/views/instructeurs/export_templates/_form.html.haml index 9be0a9ba0..686e9df1b 100644 --- a/app/views/instructeurs/export_templates/_form.html.haml +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -1,7 +1,7 @@ .fr-grid-row.fr-grid-row--gutters .fr-col-12.fr-col-md-8 = form_with url: form_url, model: @export_template, local: true do |f| - #export_template-edit.fr-my-4w{ data: { controller: 'tiptap', tiptap_insert_after_tag_value: ' ' } } + #export_template-edit.fr-my-4w .fr-mb-6w = render Dsfr::AlertComponent.new(state: :info, title: "Nouvel éditeur de modèle d'export", heading_level: 'h3') do |c| - c.with_body do From cec73e07a58378a7394e336b36a5535f76c31417 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 5 Apr 2024 11:02:17 +0200 Subject: [PATCH 0223/1532] move preview to be aligned with form --- .../export_templates/_form.html.haml | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/app/views/instructeurs/export_templates/_form.html.haml b/app/views/instructeurs/export_templates/_form.html.haml index 686e9df1b..8363e50e2 100644 --- a/app/views/instructeurs/export_templates/_form.html.haml +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -1,17 +1,17 @@ +#export_template-edit.fr-my-4w + .fr-mb-6w + = render Dsfr::AlertComponent.new(state: :info, title: "Nouvel éditeur de modèle d'export", heading_level: 'h3') do |c| + - c.with_body do + Cette page permet d'éditer un modèle d'export et ainsi personnaliser le contenu des exports (pour l'instant, + uniquement au format zip). Ainsi, vous pouvez notamment normaliser le nom des pièces jointes. + Essayez-le et donnez-nous votre avis + en nous envoyant un email à #{mail_to(CONTACT_EMAIL, subject: "Editeur de modèle d'export")}. + .fr-highlight + %p.fr-text--sm + N'incluez pas les extensions de fichier (.pdf, .jpg, …) dans les noms de pièces jointes et de fichiers. .fr-grid-row.fr-grid-row--gutters .fr-col-12.fr-col-md-8 = form_with url: form_url, model: @export_template, local: true do |f| - #export_template-edit.fr-my-4w - .fr-mb-6w - = render Dsfr::AlertComponent.new(state: :info, title: "Nouvel éditeur de modèle d'export", heading_level: 'h3') do |c| - - c.with_body do - Cette page permet d'éditer un modèle d'export et ainsi personnaliser le contenu des exports (pour l'instant, - uniquement au format zip). Ainsi, vous pouvez notamment normaliser le nom des pièces jointes. - Essayez-le et donnez-nous votre avis - en nous envoyant un email à #{mail_to(CONTACT_EMAIL, subject: "Editeur de modèle d'export")}. - .fr-highlight - %p.fr-text--sm - N'incluez pas les extensions de fichier (.pdf, .jpg, …) dans les noms de pièces jointes et de fichiers. = render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field) From e235131c4cc9f2803c3c9ca037e77f0ecedce711 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 9 Apr 2024 15:12:21 +0200 Subject: [PATCH 0224/1532] add export template feature flag --- .../export_dropdown_component.html.haml | 1 + .../instructeurs/procedures/exports.html.haml | 37 ++++++++++--------- config/initializers/flipper.rb | 1 + 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml index dbbdbcb07..fbb499483 100644 --- a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml +++ b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml @@ -20,6 +20,7 @@ - menu.with_item do = link_to download_export_path(export_template_id: export_template.id), role: 'menuitem', data: { turbo_method: :post, turbo: true } do = "Exporter à partir du modèle #{export_template.name}" + - if feature_enabled?(:export_template) - menu.with_item do = link_to new_instructeur_export_template_path(procedure_id: params[:procedure_id]), role: 'menuitem' do Ajouter un modèle d'export diff --git a/app/views/instructeurs/procedures/exports.html.haml b/app/views/instructeurs/procedures/exports.html.haml index 005eec45f..0986a977a 100644 --- a/app/views/instructeurs/procedures/exports.html.haml +++ b/app/views/instructeurs/procedures/exports.html.haml @@ -23,23 +23,24 @@ - else = t('.no_export_html', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i ) - %h2.fr-mb-1w.fr-mt-8w - Liste des modèles d'export - %p.fr-hint-text - Un modèle d'export permet de personnaliser le nom des fichiers (pour un export au format Zip) - - if @export_templates.any? - .fr-table.fr-table--no-caption.fr-mt-5w - %table - %thead - %tr - %th{ scope: 'col' } Nom du modèle - %th{ scope: 'col' }= "Groupe instructeur" if @procedure.groupe_instructeurs.many? - %tbody - - @export_templates.each do |export_template| + - if feature_enabled?(:export_template) + %h2.fr-mb-1w.fr-mt-8w + Liste des modèles d'export + %p.fr-hint-text + Un modèle d'export permet de personnaliser le nom des fichiers (pour un export au format Zip) + - if @export_templates.any? + .fr-table.fr-table--no-caption.fr-mt-5w + %table + %thead %tr - %td= link_to export_template.name, edit_instructeur_export_template_path(export_template, procedure_id: @procedure.id) - %td= export_template.groupe_instructeur.label if @procedure.groupe_instructeurs.many? + %th{ scope: 'col' } Nom du modèle + %th{ scope: 'col' }= "Groupe instructeur" if @procedure.groupe_instructeurs.many? + %tbody + - @export_templates.each do |export_template| + %tr + %td= link_to export_template.name, edit_instructeur_export_template_path(export_template, procedure_id: @procedure.id) + %td= export_template.groupe_instructeur.label if @procedure.groupe_instructeurs.many? - %p - = link_to new_instructeur_export_template_path(procedure_id: params[:procedure_id]), class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line' do - Ajouter un modèle d'export + %p + = link_to new_instructeur_export_template_path(procedure_id: params[:procedure_id]), class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line' do + Ajouter un modèle d'export diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb index 7eedd85e7..765a98bfe 100644 --- a/config/initializers/flipper.rb +++ b/config/initializers/flipper.rb @@ -25,6 +25,7 @@ features = [ :dossier_pdf_vide, :engagement_juridique_type_de_champ, :export_order_by_revision, + :export_template, :expression_reguliere_type_de_champ, :gallery_demande, :groupe_instructeur_api_hack, From 6445337be71694ad56b14f604ce579f7516c4682 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 26 Apr 2024 15:36:30 +0200 Subject: [PATCH 0225/1532] refactor(pj_list): extract pj list in a concern and simplify --- .../concerns/pieces_jointes_list_concern.rb | 39 ++++++++++++++ app/models/procedure.rb | 51 +------------------ app/models/procedure_revision.rb | 1 - .../shared/_procedure_description.html.haml | 30 ++++++----- .../pieces_jointes_list_concern_spec.rb | 50 ++++++++++++++++++ spec/models/procedure_spec.rb | 46 ----------------- .../_procedure_description.html.haml_spec.rb | 2 +- 7 files changed, 107 insertions(+), 112 deletions(-) create mode 100644 app/models/concerns/pieces_jointes_list_concern.rb create mode 100644 spec/models/concerns/pieces_jointes_list_concern_spec.rb diff --git a/app/models/concerns/pieces_jointes_list_concern.rb b/app/models/concerns/pieces_jointes_list_concern.rb new file mode 100644 index 000000000..263b01e9f --- /dev/null +++ b/app/models/concerns/pieces_jointes_list_concern.rb @@ -0,0 +1,39 @@ +module PiecesJointesListConcern + extend ActiveSupport::Concern + + included do + def public_wrapped_partionned_pjs + pieces_jointes_list(public_only: true, wrap_with_parent: true) + .partition { |(pj, _)| pj.condition.nil? } + end + + def pieces_jointes_exportables_list + pieces_jointes_list(exclude_titre_identite: true) + end + + private + + def pieces_jointes_list( + exclude_titre_identite: false, + public_only: false, + wrap_with_parent: false + ) + coordinates = active_revision.revision_types_de_champ + .includes(:type_de_champ, revision_types_de_champ: :type_de_champ) + + coordinates = coordinates.public_only if public_only + + type_champ = ['piece_justificative'] + type_champ << 'titre_identite' if !exclude_titre_identite + + coordinates = coordinates.where(types_de_champ: { type_champ: }) + + return coordinates.map(&:type_de_champ) if !wrap_with_parent + + # we want pj in the form of [[pj1], [pj2, repetition], [pj3, repetition]] + coordinates + .map { |c| c.child? ? [c, c.parent] : [c] } + .map { |a| a.map(&:type_de_champ) } + end + end +end diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 02b1a3d15..11cec13d8 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -5,6 +5,7 @@ class Procedure < ApplicationRecord include ProcedureGroupeInstructeurAPIHackConcern include ProcedureSVASVRConcern include ProcedureChorusConcern + include PiecesJointesListConcern include Discard::Model self.discard_column = :hidden_at @@ -982,28 +983,6 @@ class Procedure < ApplicationRecord end end - def pieces_jointes_list? - pieces_jointes_list_without_conditionnal.present? || pieces_jointes_list_with_conditionnal.present? - end - - def pieces_jointes_list_without_conditionnal - pieces_jointes_list do |base_scope| - base_scope.where(types_de_champ: { condition: nil }) - end - end - - def pieces_jointes_exportables_list - pieces_jointes_list(with_private: true, with_titre_identite: false, with_repetition_parent: false) do |base_scope| - base_scope - end.flatten - end - - def pieces_jointes_list_with_conditionnal - pieces_jointes_list do |base_scope| - base_scope.where.not(types_de_champ: { condition: nil }) - end - end - def toggle_routing update!(routing_enabled: self.groupe_instructeurs.active.many?) end @@ -1031,34 +1010,6 @@ class Procedure < ApplicationRecord private - def pieces_jointes_list(with_private: false, with_titre_identite: true, with_repetition_parent: true) - types_de_champ = with_private ? - active_revision.revision_types_de_champ_private_and_public : - active_revision.revision_types_de_champ_public - - type_champs = ['repetition', 'piece_justificative'] - type_champs << 'titre_identite' if with_titre_identite - - scope = yield types_de_champ - .includes(:type_de_champ, revision_types_de_champ: :type_de_champ) - .where(types_de_champ: { type_champ: [type_champs] }) - - scope.each_with_object([]) do |rtdc, list| - if rtdc.type_de_champ.repetition? - rtdc.revision_types_de_champ.each do |rtdc_in_repetition| - if rtdc_in_repetition.type_de_champ.piece_justificative? - to_add = [] - to_add << rtdc_in_repetition.type_de_champ - to_add << rtdc.type_de_champ if with_repetition_parent - list << to_add - end - end - else - list << [rtdc.type_de_champ] - end - end - end - def validate_auto_archive_on_in_the_future return if auto_archive_on.nil? return if auto_archive_on.future? diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index 8326c6533..c8daa1bec 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -8,7 +8,6 @@ class ProcedureRevision < ApplicationRecord has_many :revision_types_de_champ, -> { order(:position, :id) }, class_name: 'ProcedureRevisionTypeDeChamp', foreign_key: :revision_id, dependent: :destroy, inverse_of: :revision has_many :revision_types_de_champ_public, -> { root.public_only.ordered }, class_name: 'ProcedureRevisionTypeDeChamp', foreign_key: :revision_id, dependent: :destroy, inverse_of: :revision has_many :revision_types_de_champ_private, -> { root.private_only.ordered }, class_name: 'ProcedureRevisionTypeDeChamp', foreign_key: :revision_id, dependent: :destroy, inverse_of: :revision - has_many :revision_types_de_champ_private_and_public, -> { root.ordered }, class_name: 'ProcedureRevisionTypeDeChamp', foreign_key: :revision_id, dependent: :destroy, inverse_of: :revision has_many :types_de_champ, through: :revision_types_de_champ, source: :type_de_champ has_many :types_de_champ_public, through: :revision_types_de_champ_public, source: :type_de_champ has_many :types_de_champ_private, through: :revision_types_de_champ_private, source: :type_de_champ diff --git a/app/views/shared/_procedure_description.html.haml b/app/views/shared/_procedure_description.html.haml index 922d790c8..e1cce58eb 100644 --- a/app/views/shared/_procedure_description.html.haml +++ b/app/views/shared/_procedure_description.html.haml @@ -48,21 +48,23 @@ #accordion-116.fr-collapse = h render SimpleFormatComponent.new(procedure.description_pj, allow_a: true) - - elsif procedure.pieces_jointes_list? - %section.fr-accordion.pieces_jointes - %h2.fr-accordion__title - %button.fr-accordion__btn{ "aria-controls" => "accordion-116", "aria-expanded" => "false" } - = t('shared.procedure_description.pieces_jointes') - #accordion-116.fr-collapse - - if procedure.pieces_jointes_list_without_conditionnal.present? - %ul - = render partial: "shared/procedure_pieces_jointes_list", collection: procedure.pieces_jointes_list_without_conditionnal, as: :pj + - else + - pj_without_condition, pj_with_condition = procedure.public_wrapped_partionned_pjs + - if pj_without_condition.present? || pj_with_condition.present? + %section.fr-accordion.pieces_jointes + %h2.fr-accordion__title + %button.fr-accordion__btn{ "aria-controls" => "accordion-116", "aria-expanded" => "false" } + = t('shared.procedure_description.pieces_jointes') + #accordion-116.fr-collapse + - if pj_without_condition.present? + %ul + = render partial: "shared/procedure_pieces_jointes_list", collection: pj_without_condition, as: :pj - - if procedure.pieces_jointes_list_with_conditionnal.present? - %h3.fr-text--sm.fr-mb-0.fr-mt-2w - = t('shared.procedure_description.pieces_jointes_conditionnal_list_title') - %ul - = render partial: "shared/procedure_pieces_jointes_list", collection: procedure.pieces_jointes_list_with_conditionnal, as: :pj + - if pj_with_condition.present? + %h3.fr-text--sm.fr-mb-0.fr-mt-2w + = t('shared.procedure_description.pieces_jointes_conditionnal_list_title') + %ul + = render partial: "shared/procedure_pieces_jointes_list", collection: pj_with_condition, as: :pj - estimated_delay_component = Procedure::EstimatedDelayComponent.new(procedure: procedure) - if estimated_delay_component.render? diff --git a/spec/models/concerns/pieces_jointes_list_concern_spec.rb b/spec/models/concerns/pieces_jointes_list_concern_spec.rb new file mode 100644 index 000000000..bc4bdcd47 --- /dev/null +++ b/spec/models/concerns/pieces_jointes_list_concern_spec.rb @@ -0,0 +1,50 @@ +describe PiecesJointesListConcern do + describe '#pieces_jointes_list' do + include Logic + let(:procedure) { create(:procedure, types_de_champ_public:, types_de_champ_private:) } + let(:types_de_champ_public) do + [ + { type: :integer_number, stable_id: 900 }, + { type: :piece_justificative, libelle: "pj1", stable_id: 910 }, + { type: :piece_justificative, libelle: "pj-cond", stable_id: 911, condition: ds_eq(champ_value(900), constant(1)) }, + { type: :repetition, libelle: "Répétition", stable_id: 920, children: [{ type: :piece_justificative, libelle: "pj2", stable_id: 921 }] }, + { type: :titre_identite, libelle: "pj3", stable_id: 930 } + ] + end + + let(:types_de_champ_private) do + [ + { type: :integer_number, stable_id: 950 }, + { type: :piece_justificative, libelle: "pj5", stable_id: 960 }, + { type: :piece_justificative, libelle: "pj-cond2", stable_id: 961, condition: ds_eq(champ_value(900), constant(1)) }, + { type: :repetition, libelle: "Répétition2", stable_id: 970, children: [{ type: :piece_justificative, libelle: "pj6", stable_id: 971 }] } + ] + end + + let(:types_de_champ) { procedure.active_revision.types_de_champ } + def find_by_stable_id(stable_id) = types_de_champ.find { _1.stable_id == stable_id } + + let(:pj1) { find_by_stable_id(910) } + let(:pjcond) { find_by_stable_id(911) } + let(:repetition) { find_by_stable_id(920) } + let(:pj2) { find_by_stable_id(921) } + let(:pj3) { find_by_stable_id(930) } + + let(:pj5) { find_by_stable_id(960) } + let(:pjcond2) { find_by_stable_id(961) } + let(:repetition2) { find_by_stable_id(970) } + let(:pj6) { find_by_stable_id(971) } + + it "returns the list of pieces jointes without conditional" do + expect(procedure.public_wrapped_partionned_pjs.first).to match_array([[pj1], [pj2, repetition], [pj3]]) + end + + it "returns the list of pieces jointes having conditional" do + expect(procedure.public_wrapped_partionned_pjs.second).to match_array([[pjcond]]) + end + + it "returns the list of pieces jointes with private, without parent repetition, without titre identite" do + expect(procedure.pieces_jointes_exportables_list.map(&:libelle)).to match_array([pj1, pj2, pjcond, pj5, pjcond2, pj6].map(&:libelle)) + end + end +end diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 8b036cddc..0589985bb 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -1746,52 +1746,6 @@ describe Procedure do end end - describe '#pieces_jointes_list' do - include Logic - let(:procedure) { create(:procedure, types_de_champ_public:, types_de_champ_private:) } - let(:types_de_champ_public) do - [ - { type: :integer_number, stable_id: 900 }, - { type: :piece_justificative, libelle: "PJ", mandatory: true, stable_id: 910 }, - { type: :piece_justificative, libelle: "PJ-cond", mandatory: true, stable_id: 911, condition: ds_eq(champ_value(900), constant(1)) }, - { type: :repetition, libelle: "Répétition", stable_id: 920, children: [{ type: :piece_justificative, libelle: "PJ2", stable_id: 921 }] }, - { type: :titre_identite, libelle: "CNI", mandatory: true, stable_id: 930 } - ] - end - - let(:types_de_champ_private) do - [ - { type: :integer_number, stable_id: 950 }, - { type: :piece_justificative, libelle: "PJ", mandatory: true, stable_id: 960 }, - { type: :piece_justificative, libelle: "PJ-cond", mandatory: true, stable_id: 961, condition: ds_eq(champ_value(900), constant(1)) }, - { type: :repetition, libelle: "Répétition", stable_id: 970, children: [{ type: :piece_justificative, libelle: "PJ2", stable_id: 971 }] } - ] - end - - let(:pj1) { procedure.active_revision.types_de_champ.find { _1.stable_id == 910 } } - let(:pjcond) { procedure.active_revision.types_de_champ.find { _1.stable_id == 911 } } - let(:repetition) { procedure.active_revision.types_de_champ.find { _1.stable_id == 920 } } - let(:pj2) { procedure.active_revision.types_de_champ.find { _1.stable_id == 921 } } - let(:pj3) { procedure.active_revision.types_de_champ.find { _1.stable_id == 930 } } - - let(:pj5) { procedure.active_revision.types_de_champ.find { _1.stable_id == 960 } } - let(:pjcond2) { procedure.active_revision.types_de_champ.find { _1.stable_id == 961 } } - let(:repetition2) { procedure.active_revision.types_de_champ.find { _1.stable_id == 970 } } - let(:pj6) { procedure.active_revision.types_de_champ.find { _1.stable_id == 971 } } - - it "returns the list of pieces jointes without conditional" do - expect(procedure.pieces_jointes_list_without_conditionnal).to match_array([[pj1], [pj2, repetition], [pj3]]) - end - - it "returns the list of pieces jointes having conditional" do - expect(procedure.pieces_jointes_list_with_conditionnal).to match_array([[pjcond]]) - end - - it "returns the list of pieces jointes with private, without parent repetition, without titre identite" do - expect(procedure.pieces_jointes_exportables_list).to match_array([pj1, pj2, pjcond, pj5, pjcond2, pj6]) - end - end - describe "#attestation_template" do let(:procedure) { create(:procedure) } diff --git a/spec/views/shared/_procedure_description.html.haml_spec.rb b/spec/views/shared/_procedure_description.html.haml_spec.rb index 4bb722895..673584aae 100644 --- a/spec/views/shared/_procedure_description.html.haml_spec.rb +++ b/spec/views/shared/_procedure_description.html.haml_spec.rb @@ -110,7 +110,7 @@ describe 'shared/_procedure_description', type: :view do context 'caching', caching: true do it "works" do - expect(procedure).to receive(:pieces_jointes_list?).once + expect(procedure).to receive(:public_wrapped_partionned_pjs).once 2.times { render partial: 'shared/procedure_description', locals: { procedure: } } end From c8b3b4b45a91bb4e6b01ba9ca74aa8cc9fab498b Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 26 Apr 2024 15:04:34 +0200 Subject: [PATCH 0226/1532] refactor: renaming --- .../instructeurs/export_templates_controller.rb | 2 +- app/models/concerns/pieces_jointes_list_concern.rb | 8 ++++---- app/models/export_template.rb | 2 +- app/validators/export_template_validator.rb | 2 +- app/views/instructeurs/export_templates/_form.html.haml | 2 +- spec/models/concerns/pieces_jointes_list_concern_spec.rb | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/controllers/instructeurs/export_templates_controller.rb b/app/controllers/instructeurs/export_templates_controller.rb index 9065b2d21..cc41c0108 100644 --- a/app/controllers/instructeurs/export_templates_controller.rb +++ b/app/controllers/instructeurs/export_templates_controller.rb @@ -79,7 +79,7 @@ module Instructeurs end def set_all_pj - @all_pj ||= @procedure.pieces_jointes_exportables_list + @all_pj ||= @procedure.exportables_pieces_jointes end def export_params diff --git a/app/models/concerns/pieces_jointes_list_concern.rb b/app/models/concerns/pieces_jointes_list_concern.rb index 263b01e9f..05ba4b2da 100644 --- a/app/models/concerns/pieces_jointes_list_concern.rb +++ b/app/models/concerns/pieces_jointes_list_concern.rb @@ -3,17 +3,17 @@ module PiecesJointesListConcern included do def public_wrapped_partionned_pjs - pieces_jointes_list(public_only: true, wrap_with_parent: true) + pieces_jointes(public_only: true, wrap_with_parent: true) .partition { |(pj, _)| pj.condition.nil? } end - def pieces_jointes_exportables_list - pieces_jointes_list(exclude_titre_identite: true) + def exportables_pieces_jointes + pieces_jointes(exclude_titre_identite: true) end private - def pieces_jointes_list( + def pieces_jointes( exclude_titre_identite: false, public_only: false, wrap_with_parent: false diff --git a/app/models/export_template.rb b/app/models/export_template.rb index 52b983f7f..136a75999 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -13,7 +13,7 @@ class ExportTemplate < ApplicationRecord content["pdf_name"] = tiptap_json("export_") content["pjs"] = [] - procedure.pieces_jointes_exportables_list.each do |pj| + procedure.exportables_pieces_jointes.each do |pj| content["pjs"] << { "stable_id" => pj.stable_id.to_s, "path" => tiptap_json("#{pj.libelle.parameterize}-") } end end diff --git a/app/validators/export_template_validator.rb b/app/validators/export_template_validator.rb index 1f3475040..2a1158ed6 100644 --- a/app/validators/export_template_validator.rb +++ b/app/validators/export_template_validator.rb @@ -38,7 +38,7 @@ class ExportTemplateValidator < ActiveModel::Validator def validate_pjs(record) record.content["pjs"]&.each do |pj| pj_sym = pj.symbolize_keys - libelle = record.groupe_instructeur.procedure.pieces_jointes_exportables_list.find { _1.stable_id.to_s == pj_sym[:stable_id] }&.libelle&.to_sym + libelle = record.groupe_instructeur.procedure.exportables_pieces_jointes.find { _1.stable_id.to_s == pj_sym[:stable_id] }&.libelle&.to_sym validate_content(record, pj_sym[:path], libelle) end end diff --git a/app/views/instructeurs/export_templates/_form.html.haml b/app/views/instructeurs/export_templates/_form.html.haml index 8363e50e2..18a108ce7 100644 --- a/app/views/instructeurs/export_templates/_form.html.haml +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -82,7 +82,7 @@ %span#preview_default_dossier_directory= @export_template.tiptap_convert(sample_dossier, "default_dossier_directory") %ul %li#preview_pdf_name= @export_template.tiptap_convert(sample_dossier, "pdf_name") - - @procedure.pieces_jointes_exportables_list.each do |pj| + - @procedure.exportables_pieces_jointes.each do |pj| %li{ id: "preview_pj_#{pj.stable_id}" }= @export_template.tiptap_convert_pj(sample_dossier, pj.stable_id) %ul %li diff --git a/spec/models/concerns/pieces_jointes_list_concern_spec.rb b/spec/models/concerns/pieces_jointes_list_concern_spec.rb index bc4bdcd47..0356a52c9 100644 --- a/spec/models/concerns/pieces_jointes_list_concern_spec.rb +++ b/spec/models/concerns/pieces_jointes_list_concern_spec.rb @@ -44,7 +44,7 @@ describe PiecesJointesListConcern do end it "returns the list of pieces jointes with private, without parent repetition, without titre identite" do - expect(procedure.pieces_jointes_exportables_list.map(&:libelle)).to match_array([pj1, pj2, pjcond, pj5, pjcond2, pj6].map(&:libelle)) + expect(procedure.exportables_pieces_jointes.map(&:libelle)).to match_array([pj1, pj2, pjcond, pj5, pjcond2, pj6].map(&:libelle)) end end end From 2dffa9aaa26cc1624c783b73d555f5d95ebfe18d Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 29 Apr 2024 17:09:02 +0200 Subject: [PATCH 0227/1532] refactor: extract preview --- .../export_templates/_form.html.haml | 18 +----------------- .../export_templates/_preview.html.haml | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 17 deletions(-) create mode 100644 app/views/instructeurs/export_templates/_preview.html.haml diff --git a/app/views/instructeurs/export_templates/_form.html.haml b/app/views/instructeurs/export_templates/_form.html.haml index 18a108ce7..ceccdf3f7 100644 --- a/app/views/instructeurs/export_templates/_form.html.haml +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -72,20 +72,4 @@ - sample_dossier = @procedure.dossier_for_preview(current_instructeur) - if sample_dossier .fr-col-12.fr-col-md-4.fr-background-alt--blue-france - .export-template-preview.fr-p-2w.sticky--top - %h2.fr-h4 Aperçu - %ul.tree.fr-text--sm - %li= DownloadableFileService::EXPORT_DIRNAME - %li - %ul - %li - %span#preview_default_dossier_directory= @export_template.tiptap_convert(sample_dossier, "default_dossier_directory") - %ul - %li#preview_pdf_name= @export_template.tiptap_convert(sample_dossier, "pdf_name") - - @procedure.exportables_pieces_jointes.each do |pj| - %li{ id: "preview_pj_#{pj.stable_id}" }= @export_template.tiptap_convert_pj(sample_dossier, pj.stable_id) - %ul - %li - %span messagerie - %ul - %li un-autre-fichier + = render partial: 'preview', locals: { dossier: sample_dossier, export_template: @export_template, procedure: @procedure } diff --git a/app/views/instructeurs/export_templates/_preview.html.haml b/app/views/instructeurs/export_templates/_preview.html.haml new file mode 100644 index 000000000..d12f908ae --- /dev/null +++ b/app/views/instructeurs/export_templates/_preview.html.haml @@ -0,0 +1,17 @@ +#preview.export-template-preview.fr-p-2w.sticky--top + %h2.fr-h4 Aperçu + %ul.tree.fr-text--sm + %li= DownloadableFileService::EXPORT_DIRNAME + %li + %ul + %li + %span#preview_default_dossier_directory= export_template.tiptap_convert(dossier, "default_dossier_directory") + %ul + %li#preview_pdf_name= export_template.tiptap_convert(dossier, "pdf_name") + - procedure.exportables_pieces_jointes.each do |pj| + %li{ id: "preview_pj_#{pj.stable_id}" }= export_template.tiptap_convert_pj(dossier, pj.stable_id) + %ul + %li + %span messagerie + %ul + %li un-autre-fichier From 4a900d812176283ee617fab767d0abb2091fa526 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 30 Apr 2024 09:18:49 +0200 Subject: [PATCH 0228/1532] refactor(UI): add file extension and number to preview --- .../instructeurs/export_templates/_preview.html.haml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/views/instructeurs/export_templates/_preview.html.haml b/app/views/instructeurs/export_templates/_preview.html.haml index d12f908ae..124abc2c1 100644 --- a/app/views/instructeurs/export_templates/_preview.html.haml +++ b/app/views/instructeurs/export_templates/_preview.html.haml @@ -1,17 +1,17 @@ #preview.export-template-preview.fr-p-2w.sticky--top %h2.fr-h4 Aperçu %ul.tree.fr-text--sm - %li= DownloadableFileService::EXPORT_DIRNAME + %li #{DownloadableFileService::EXPORT_DIRNAME}/ %li %ul %li - %span#preview_default_dossier_directory= export_template.tiptap_convert(dossier, "default_dossier_directory") + %span#preview_default_dossier_directory #{export_template.tiptap_convert(dossier, "default_dossier_directory")}/ %ul - %li#preview_pdf_name= export_template.tiptap_convert(dossier, "pdf_name") + %li#preview_pdf_name #{export_template.tiptap_convert(dossier, "pdf_name")}.pdf - procedure.exportables_pieces_jointes.each do |pj| - %li{ id: "preview_pj_#{pj.stable_id}" }= export_template.tiptap_convert_pj(dossier, pj.stable_id) + %li{ id: "preview_pj_#{pj.stable_id}" } #{export_template.tiptap_convert_pj(dossier, pj.stable_id)}-1.jpg %ul %li - %span messagerie + %span messagerie/ %ul - %li un-autre-fichier + %li un-autre-fichier.png From c51792b936683d843c75c15b71dc09fe32dc186e Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 30 Apr 2024 09:34:06 +0200 Subject: [PATCH 0229/1532] refactor(UI): move extension warning near pjs --- app/views/instructeurs/export_templates/_form.html.haml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/views/instructeurs/export_templates/_form.html.haml b/app/views/instructeurs/export_templates/_form.html.haml index ceccdf3f7..74a29afd5 100644 --- a/app/views/instructeurs/export_templates/_form.html.haml +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -6,9 +6,7 @@ uniquement au format zip). Ainsi, vous pouvez notamment normaliser le nom des pièces jointes. Essayez-le et donnez-nous votre avis en nous envoyant un email à #{mail_to(CONTACT_EMAIL, subject: "Editeur de modèle d'export")}. - .fr-highlight - %p.fr-text--sm - N'incluez pas les extensions de fichier (.pdf, .jpg, …) dans les noms de pièces jointes et de fichiers. + .fr-grid-row.fr-grid-row--gutters .fr-col-12.fr-col-md-8 = form_with url: form_url, model: @export_template, local: true do |f| @@ -52,6 +50,10 @@ - if @all_pj.any? %h3 Pieces justificatives + .fr-highlight + %p.fr-text--sm + N'incluez pas les extensions de fichier (.pdf, .jpg, …) dans les noms de pièces jointes. + - @all_pj.each do |pj| .fr-input-group{ data: { controller: 'tiptap' } } = label_tag pj.libelle, nil, name: field_name(:export_template, "tiptap_pj_#{pj.stable_id}"), class: "fr-label" From 1b734aeaedb7c2c656f42dab8c5686d82c96d9eb Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 30 Apr 2024 00:01:45 +0200 Subject: [PATCH 0230/1532] refactor: simplify preview --- .../instructeurs/export_templates_controller.rb | 12 +++++++----- .../instructeurs/export_templates/_form.html.haml | 11 ++++++----- .../export_templates/preview.turbo_stream.haml | 2 -- 3 files changed, 13 insertions(+), 12 deletions(-) delete mode 100644 app/views/instructeurs/export_templates/preview.turbo_stream.haml diff --git a/app/controllers/instructeurs/export_templates_controller.rb b/app/controllers/instructeurs/export_templates_controller.rb index cc41c0108..64ef44a4e 100644 --- a/app/controllers/instructeurs/export_templates_controller.rb +++ b/app/controllers/instructeurs/export_templates_controller.rb @@ -46,11 +46,13 @@ module Instructeurs end def preview - param = params.require(:export_template).keys.first - @preview_param = param.delete_prefix("tiptap_") - hash = JSON.parse(params[:export_template][param]).deep_symbolize_keys - export_template = ExportTemplate.new(kind: 'zip', groupe_instructeur: @groupe_instructeurs.first) - @preview_value = export_template.render_attributes_for(hash, @procedure.dossier_for_preview(current_instructeur)) + set_groupe_instructeur + @export_template = @groupe_instructeur.export_templates.build(export_template_params) + @export_template.assign_pj_names(pj_params) + + @sample_dossier = @procedure.dossier_for_preview(current_instructeur) + + render turbo_stream: turbo_stream.replace('preview', partial: 'preview', locals: { export_template: @export_template, procedure: @procedure, dossier: @sample_dossier }) end private diff --git a/app/views/instructeurs/export_templates/_form.html.haml b/app/views/instructeurs/export_templates/_form.html.haml index 74a29afd5..00813d9b6 100644 --- a/app/views/instructeurs/export_templates/_form.html.haml +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -9,7 +9,7 @@ .fr-grid-row.fr-grid-row--gutters .fr-col-12.fr-col-md-8 - = form_with url: form_url, model: @export_template, local: true do |f| + = form_with url: form_url, model: @export_template, local: true, data: { turbo: 'true', controller: 'autosubmit' } do |f| = render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field) @@ -34,7 +34,7 @@ = t('activerecord.attributes.export_template.hints.tiptap_default_dossier_directory') .tiptap-editor.fr-mt-1w{ data: { tiptap_target: 'editor' } } - = f.hidden_field :tiptap_default_dossier_directory, data: { tiptap_target: 'input', controller: 'turbo-input', turbo_input_url_value: preview_instructeur_export_templates_path } + = f.hidden_field :tiptap_default_dossier_directory, data: { tiptap_target: 'input' } .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.specific_tags }) .fr-input-group{ data: { controller: 'tiptap' } } @@ -44,7 +44,7 @@ %span.fr-hint-text = t('activerecord.attributes.export_template.hints.tiptap_pdf_name') .tiptap-editor.fr-mt-1w{ data: { tiptap_target: 'editor' } } - = f.hidden_field :tiptap_pdf_name, data: { tiptap_target: 'input', controller: 'turbo-input', turbo_input_url_value: preview_instructeur_export_templates_path } + = f.hidden_field :tiptap_pdf_name, data: { tiptap_target: 'input' } .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.specific_tags }) - if @all_pj.any? @@ -58,14 +58,15 @@ .fr-input-group{ data: { controller: 'tiptap' } } = label_tag pj.libelle, nil, name: field_name(:export_template, "tiptap_pj_#{pj.stable_id}"), class: "fr-label" .tiptap-editor.fr-mt-1w{ data: { tiptap_target: 'editor' } } - = hidden_field_tag field_name(:export_template, "tiptap_pj_#{pj.stable_id}"), "#{@export_template.content_for_pj(pj)}" , data: { tiptap_target: 'input', controller: 'turbo-input', turbo_input_url_value: preview_instructeur_export_templates_path } + = hidden_field_tag field_name(:export_template, "tiptap_pj_#{pj.stable_id}"), "#{@export_template.content_for_pj(pj)}" , data: { tiptap_target: 'input' } .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.tags_for_pj }) .fixed-footer .fr-container %ul.fr-btns-group.fr-btns-group--inline-md %li - = f.submit "Enregistrer", class: "fr-btn" + %input.hidden{ type: 'submit', formaction: preview_instructeur_export_templates_path, data: { autosubmit_target: 'submitter' }, formnovalidate: 'true', formmethod: 'get' } + = f.button "Enregistrer", class: "fr-btn", data: { turbo: 'false' } %li = link_to "Annuler", instructeur_procedure_path(@procedure), class: "fr-btn fr-btn--secondary" - if @export_template.persisted? diff --git a/app/views/instructeurs/export_templates/preview.turbo_stream.haml b/app/views/instructeurs/export_templates/preview.turbo_stream.haml deleted file mode 100644 index f6c1e5468..000000000 --- a/app/views/instructeurs/export_templates/preview.turbo_stream.haml +++ /dev/null @@ -1,2 +0,0 @@ -= turbo_stream.update "preview_#{@preview_param}" do - = @preview_value From 6d757db20b2f36c236e1a1cf61e5bdbc86f8ca24 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 30 Apr 2024 19:03:27 +0200 Subject: [PATCH 0231/1532] fix: champ.row_index and test pjs_for_champs --- app/models/champ.rb | 4 +- .../pieces_justificatives_service_spec.rb | 88 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/app/models/champ.rb b/app/models/champ.rb index 6d3f02f11..a66b91e84 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -96,7 +96,9 @@ class Champ < ApplicationRecord end def row_index - Champ.where(parent:).pluck(:row_id).sort.index(:id) + return nil if parent_id.nil? + + Champ.where(parent_id:).pluck(:row_id).sort.index(row_id) end # used for the `required` html attribute diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index 771cb8e96..759a9c91e 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -1,4 +1,92 @@ describe PiecesJustificativesService do + describe 'pjs_for_champs' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :piece_justificative }, { type: :repetition, children: [{ type: :piece_justificative }] }]) } + let(:dossier) { create(:dossier, procedure: procedure) } + let(:dossiers) { Dossier.where(id: dossier.id) } + let(:witness) { create(:dossier, procedure: procedure) } + let(:export_template) { double('ExportTemplate') } + let(:pj_service) { PiecesJustificativesService.new(user_profile:, export_template:) } + let(:user_profile) { build(:administrateur) } + + def pj_champ(d) = d.champs_public.find_by(type: 'Champs::PieceJustificativeChamp') + def repetition(d) = d.champs.find_by(type: "Champs::RepetitionChamp") + def attachments(champ) = champ.piece_justificative_file.attachments + + before { attach_file_to_champ(pj_champ(witness)) } + + subject { pj_service.send(:pjs_for_champs, dossiers) } + + context 'without any attachment' do + it { expect(subject).to be_empty } + end + + context 'with a single attachment' do + let(:champ) { pj_champ(dossier) } + before { attach_file_to_champ(champ) } + + it do + expect(export_template).to receive(:attachment_and_path) + .with(dossier, attachments(pj_champ(dossier)).first, index: 0, row_index: nil, champ:) + subject + end + end + + context 'with multiple attachments' do + let(:champ) { pj_champ(dossier) } + + before do + attach_file_to_champ(champ) + attach_file_to_champ(champ) + end + + it do + expect(export_template).to receive(:attachment_and_path) + .with(dossier, attachments(pj_champ(dossier)).first, index: 0, row_index: nil, champ:) + + expect(export_template).to receive(:attachment_and_path) + .with(dossier, attachments(pj_champ(dossier)).second, index: 1, row_index: nil, champ:) + subject + end + end + + context 'with a repetition' do + let(:first_champ) { repetition(dossier).champs.first } + let(:second_champ) { repetition(dossier).champs.second } + + before do + repetition(dossier).add_row(dossier.revision) + attach_file_to_champ(first_champ) + attach_file_to_champ(first_champ) + + repetition(dossier).add_row(dossier.revision) + attach_file_to_champ(second_champ) + end + + it do + first_child_attachments = attachments(repetition(dossier).champs.first) + second_child_attachments = attachments(repetition(dossier).champs.second) + + expect(export_template).to receive(:attachment_and_path) + .with(dossier, first_child_attachments.first, index: 0, row_index: 0, champ: first_champ) + + expect(export_template).to receive(:attachment_and_path) + .with(dossier, first_child_attachments.second, index: 1, row_index: 0, champ: first_champ) + + expect(export_template).to receive(:attachment_and_path) + .with(dossier, second_child_attachments.first, index: 0, row_index: 1, champ: second_champ) + + count = 0 + + callback = lambda { |*_args| count += 1 } + ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do + subject + end + + expect(count).to eq(18) + end + end + end + describe '.liste_documents' do let(:dossier) { create(:dossier, procedure: procedure) } let(:dossiers) { Dossier.where(id: dossier.id) } From b65686783627afb20eec332b90176d089c8e7b8b Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 1 May 2024 21:23:41 +0200 Subject: [PATCH 0232/1532] refactor(pj_service): do not query for pj_index --- app/services/pieces_justificatives_service.rb | 17 ++++++++++------- .../pieces_justificatives_service_spec.rb | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 22040d4e0..bffbf6ea4 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -158,13 +158,16 @@ class PiecesJustificativesService .includes(:blob) .where(record_type: "Champ", record_id: champ_id_dossier_id.keys) .filter { |a| safe_attachment(a) } - .map do |a, _i| - dossier_id = champ_id_dossier_id[a.record_id] - pj_index = Champ.find(a.record_id).piece_justificative_file.blobs.map(&:id).index(a.blob_id) - if @export_template - @export_template.attachment_and_path(Dossier.find(dossier_id), a, index: pj_index, row_index: a.record.row_index) - else - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + .group_by(&:record_id) + .flat_map do |champ_id, attachments| + dossier_id = champ_id_dossier_id[champ_id] + + attachments.map.with_index do |attachment, index| + if @export_template + @export_template.attachment_and_path(Dossier.find(dossier_id), attachment, index: index, row_index: attachment.record.row_index) + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, attachment) + end end end end diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index 759a9c91e..56917eda7 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -82,7 +82,7 @@ describe PiecesJustificativesService do subject end - expect(count).to eq(18) + expect(count).to eq(10) end end end From 585810553f72827bd17175219fb8b6f491d1426d Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 1 May 2024 21:25:43 +0200 Subject: [PATCH 0233/1532] refactor(suffix): be consistent with index suffix --- app/models/export_template.rb | 7 ++----- spec/services/procedure_export_service_spec.rb | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/app/models/export_template.rb b/app/models/export_template.rb index 136a75999..4d9bc5f39 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -148,11 +148,8 @@ class ExportTemplate < ApplicationRecord end def suffix(attachment, index, row_index) - suffix = "" - if index >= 1 && !row_index.nil? - suffix += "-#{index + 1}" - suffix += "-#{row_index + 1}" if row_index - end + suffix = "-#{index + 1}" + suffix += "-#{row_index + 1}" if row_index.present? suffix + attachment.filename.extension_with_delimiter end diff --git a/spec/services/procedure_export_service_spec.rb b/spec/services/procedure_export_service_spec.rb index 9109124f4..b463675be 100644 --- a/spec/services/procedure_export_service_spec.rb +++ b/spec/services/procedure_export_service_spec.rb @@ -544,7 +544,7 @@ describe ProcedureExportService do structure = [ "#{base_fn}/", "#{base_fn}/dossier-#{dossier.id}/", - "#{base_fn}/dossier-#{dossier.id}/piece_justificative-#{dossier.id}.txt", + "#{base_fn}/dossier-#{dossier.id}/piece_justificative-#{dossier.id}-1.txt", "#{base_fn}/dossier-#{dossier.id}/export_#{dossier.id}.pdf" ] expect(files.size).to eq(structure.size) From fe5c655a52c93ab09c5cc0c1b7bd0dec143185a3 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 7 May 2024 09:25:47 +0200 Subject: [PATCH 0234/1532] spec(perf): count sql queries for 10 dossiers --- .../procedure_export_service_zip_spec.rb | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 spec/services/procedure_export_service_zip_spec.rb diff --git a/spec/services/procedure_export_service_zip_spec.rb b/spec/services/procedure_export_service_zip_spec.rb new file mode 100644 index 000000000..70ca5fc75 --- /dev/null +++ b/spec/services/procedure_export_service_zip_spec.rb @@ -0,0 +1,87 @@ +describe ProcedureExportService do + let(:instructeur) { create(:instructeur) } + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :piece_justificative, libelle: 'pj' }, { type: :repetition, children: [{ type: :piece_justificative }] }]) } + let(:dossiers) { create_list(:dossier, 10, procedure: procedure) } + let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur).tap(&:set_default_values) } + let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) } + + def pj_champ(d) = d.champs_public.find_by(type: 'Champs::PieceJustificativeChamp') + def repetition(d) = d.champs.find_by(type: "Champs::RepetitionChamp") + def attachments(champ) = champ.piece_justificative_file.attachments + + before do + dossiers.each do |dossier| + attach_file_to_champ(pj_champ(dossier)) + + repetition(dossier).add_row(dossier.revision) + attach_file_to_champ(repetition(dossier).champs.first) + attach_file_to_champ(repetition(dossier).champs.first) + + repetition(dossier).add_row(dossier.revision) + attach_file_to_champ(repetition(dossier).champs.second) + end + + allow_any_instance_of(ActiveStorage::Attachment).to receive(:url).and_return("https://opengraph.githubassets.com/d0e7862b24d8026a3c03516d865b28151eb3859029c6c6c2e86605891fbdcd7a/socketry/async-io") + end + + describe 'to_zip' do + subject { service.to_zip } + + describe 'generate_dossiers_export' do + context 'with export_template' do + let(:dossier_exports) { PiecesJustificativesService.new(user_profile: instructeur, export_template:).generate_dossiers_export(Dossier.where(id: dossier)) } + + it 'returns a blob with custom filenames' do + VCR.use_cassette('archive/new_file_to_get_200', allow_playback_repeats: true) do + sql_count = 0 + + callback = lambda { |*_args| sql_count += 1 } + ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do + subject + end + + expect(sql_count).to eq(474) + + dossier = dossiers.first + + File.write('tmp.zip', subject.download, mode: 'wb') + File.open('tmp.zip') do |fd| + files = ZipTricks::FileReader.read_zip_structure(io: fd) + base_fn = "export" + structure = [ + "export/", + "export/dossier-#{dossier.id}/", + "export/dossier-#{dossier.id}/export_#{dossier.id}.pdf", + "export/dossier-#{dossier.id}/pj-#{dossier.id}-1.png", + "export/dossier-#{dossier.id}/libelle-du-champ-2-#{dossier.id}-1-1.png", + "export/dossier-#{dossier.id}/libelle-du-champ-2-#{dossier.id}-2-1.png", + "export/dossier-#{dossier.id}/libelle-du-champ-2-#{dossier.id}-1-2.png" + ] + + expect(files.size).to eq(10 * 6 + 1) + expect(structure - files.map(&:filename)).to be_empty + end + FileUtils.remove_entry_secure('tmp.zip') + end + end + end + end + end + + def attach_file_to_champ(champ, safe = true) + attach_file(champ.piece_justificative_file, safe) + end + + def attach_file(attachable, safe = true) + to_be_attached = { + io: StringIO.new("toto"), + filename: "toto.png", content_type: "image/png" + } + + if safe + to_be_attached[:metadata] = { virus_scan_result: ActiveStorage::VirusScanner::SAFE } + end + + attachable.attach(to_be_attached) + end +end From 43fb1ddeb51fad9b609d55a42b3318814de70056 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 14 May 2024 16:37:55 +0200 Subject: [PATCH 0235/1532] refactor: remove target in tags --- .../concerns/tags_substitution_concern.rb | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/app/models/concerns/tags_substitution_concern.rb b/app/models/concerns/tags_substitution_concern.rb index 8b0e50b1c..7960e60fb 100644 --- a/app/models/concerns/tags_substitution_concern.rb +++ b/app/models/concerns/tags_substitution_concern.rb @@ -66,7 +66,7 @@ module TagsSubstitutionConcern label: 'numéro du dossier', libelle: 'numéro du dossier', description: '', - target: :id, + lambda: -> (d) { d.id }, available_for_states: Dossier::SOUMIS } @@ -154,21 +154,21 @@ module TagsSubstitutionConcern id: 'individual_gender', libelle: 'civilité', description: 'M., Mme', - target: :gender, + lambda: -> (d) { d.individual&.gender }, available_for_states: Dossier::SOUMIS }, { id: 'individual_last_name', libelle: 'nom', description: "nom de l'usager", - target: :nom, + lambda: -> (d) { d.individual&.nom }, available_for_states: Dossier::SOUMIS }, { id: 'individual_first_name', libelle: 'prénom', description: "prénom de l'usager", - target: :prenom, + lambda: -> (d) { d.individual&.prenom }, available_for_states: Dossier::SOUMIS } ] @@ -178,35 +178,35 @@ module TagsSubstitutionConcern id: 'entreprise_siren', libelle: 'SIREN', description: '', - target: :siren, + lambda: -> (d) { d.etablissement&.entreprise&.siren }, available_for_states: Dossier::SOUMIS }, { id: 'entreprise_numero_tva_intracommunautaire', libelle: 'numéro de TVA intracommunautaire', description: '', - target: :numero_tva_intracommunautaire, + lambda: -> (d) { d.etablissement&.entreprise&.numero_tva_intracommunautaire }, available_for_states: Dossier::SOUMIS }, { id: 'entreprise_siret_siege_social', libelle: 'SIRET du siège social', description: '', - target: :siret_siege_social, + lambda: -> (d) { d.etablissement&.entreprise&.siret_siege_social }, available_for_states: Dossier::SOUMIS }, { id: 'entreprise_raison_sociale', libelle: 'raison sociale', description: '', - target: :raison_sociale, + lambda: -> (d) { d.etablissement&.entreprise&.raison_sociale }, available_for_states: Dossier::SOUMIS }, { id: 'entreprise_adresse', libelle: 'adresse', description: '', - target: :inline_adresse, + lambda: -> (d) { d.etablissement&.entreprise&.inline_adresse }, available_for_states: Dossier::SOUMIS } ] @@ -399,12 +399,8 @@ module TagsSubstitutionConcern end.join('') end - def replace_tag(tag, data) - value = if tag.key?(:target) - data.public_send(tag[:target]) - else - instance_exec(data, &tag[:lambda]) - end + def replace_tag(tag, dossier) + value = instance_exec(dossier, &tag[:lambda]) if escape_unsafe_tags? && tag.fetch(:escapable, true) escape_once(value) @@ -457,8 +453,8 @@ module TagsSubstitutionConcern [champ_private_tags(dossier:), dossier], [dossier_tags, dossier], [ROUTAGE_TAGS, dossier], - [INDIVIDUAL_TAGS, dossier.individual], - [ENTREPRISE_TAGS, dossier.etablissement&.entreprise] + [INDIVIDUAL_TAGS, dossier], + [ENTREPRISE_TAGS, dossier] ] end end From 1c0bd3e0e53ed0e3d37b1c478c8986f220d4f943 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 14 May 2024 16:51:44 +0200 Subject: [PATCH 0236/1532] refactor: remove unused data --- .../concerns/tags_substitution_concern.rb | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/app/models/concerns/tags_substitution_concern.rb b/app/models/concerns/tags_substitution_concern.rb index 7960e60fb..a7088fbd5 100644 --- a/app/models/concerns/tags_substitution_concern.rb +++ b/app/models/concerns/tags_substitution_concern.rb @@ -283,20 +283,18 @@ module TagsSubstitutionConcern @escape_unsafe_tags = escape - flat_tags = tags_and_datas_list(dossier).each_with_object({}) do |(tags, data), result| - next if data.nil? - + flat_tags = tags_and_datas_list(dossier).each_with_object({}) do |tags, result| valid_tags = tags_for_dossier_state(tags) valid_tags.each do |tag| - result[tag[:id]] = [tag, data] + result[tag[:id]] = [tag, dossier] end end tags_and_libelles.each_with_object({}) do |(tag_id, libelle), substitutions| substitutions[tag_id] = case flat_tags[tag_id] - in tag, data - replace_tag(tag, data) + in tag, dossier + replace_tag(tag, dossier) else # champ not in dossier, for example during preview on draft revision libelle end @@ -372,8 +370,8 @@ module TagsSubstitutionConcern tokens = parse_tags(text) - tags_and_datas = tags_and_datas_list(dossier).filter_map do |(tags, data)| - data && [tags_for_dossier_state(tags).index_by { _1[:id] }, data] + tags_and_datas = tags_and_datas_list(dossier).filter_map do |tags| + dossier && [tags_for_dossier_state(tags).index_by { _1[:id] }, dossier] end tags_and_datas.reduce(tokens) do |tokens, (tags, data)| @@ -449,12 +447,12 @@ module TagsSubstitutionConcern def tags_and_datas_list(dossier) [ - [champ_public_tags(dossier:), dossier], - [champ_private_tags(dossier:), dossier], - [dossier_tags, dossier], - [ROUTAGE_TAGS, dossier], - [INDIVIDUAL_TAGS, dossier], - [ENTREPRISE_TAGS, dossier] + champ_public_tags(dossier:), + champ_private_tags(dossier:), + dossier_tags, + ROUTAGE_TAGS, + INDIVIDUAL_TAGS, + ENTREPRISE_TAGS ] end end From 3af1cee240b1903b3e9af6207e430ffbdba909ab Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 14 May 2024 17:11:12 +0200 Subject: [PATCH 0237/1532] refactor: simplify --- app/models/concerns/tags_substitution_concern.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/models/concerns/tags_substitution_concern.rb b/app/models/concerns/tags_substitution_concern.rb index a7088fbd5..8ddef3b03 100644 --- a/app/models/concerns/tags_substitution_concern.rb +++ b/app/models/concerns/tags_substitution_concern.rb @@ -287,14 +287,13 @@ module TagsSubstitutionConcern valid_tags = tags_for_dossier_state(tags) valid_tags.each do |tag| - result[tag[:id]] = [tag, dossier] + result[tag[:id]] = tag end end tags_and_libelles.each_with_object({}) do |(tag_id, libelle), substitutions| - substitutions[tag_id] = case flat_tags[tag_id] - in tag, dossier - replace_tag(tag, dossier) + substitutions[tag_id] = if flat_tags[tag_id].present? + replace_tag(flat_tags[tag_id], dossier) else # champ not in dossier, for example during preview on draft revision libelle end From e0c867f222906e1c902f5bf6a8cfac60d1d43cf1 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 14 May 2024 17:12:37 +0200 Subject: [PATCH 0238/1532] refactor: rename --- app/models/concerns/tags_substitution_concern.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/concerns/tags_substitution_concern.rb b/app/models/concerns/tags_substitution_concern.rb index 8ddef3b03..bb99cbe5c 100644 --- a/app/models/concerns/tags_substitution_concern.rb +++ b/app/models/concerns/tags_substitution_concern.rb @@ -283,7 +283,7 @@ module TagsSubstitutionConcern @escape_unsafe_tags = escape - flat_tags = tags_and_datas_list(dossier).each_with_object({}) do |tags, result| + flat_tags = available_tags(dossier).each_with_object({}) do |tags, result| valid_tags = tags_for_dossier_state(tags) valid_tags.each do |tag| @@ -369,7 +369,7 @@ module TagsSubstitutionConcern tokens = parse_tags(text) - tags_and_datas = tags_and_datas_list(dossier).filter_map do |tags| + tags_and_datas = available_tags(dossier).filter_map do |tags| dossier && [tags_for_dossier_state(tags).index_by { _1[:id] }, dossier] end @@ -444,7 +444,7 @@ module TagsSubstitutionConcern end end - def tags_and_datas_list(dossier) + def available_tags(dossier) [ champ_public_tags(dossier:), champ_private_tags(dossier:), From d60e7906e040ff3e49a5f04957cf4d2f312b92b8 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 14 May 2024 17:19:52 +0200 Subject: [PATCH 0239/1532] refactor: memoize flat_tags --- app/models/concerns/tags_substitution_concern.rb | 14 ++++++++------ spec/services/procedure_export_service_zip_spec.rb | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/models/concerns/tags_substitution_concern.rb b/app/models/concerns/tags_substitution_concern.rb index bb99cbe5c..e68dae815 100644 --- a/app/models/concerns/tags_substitution_concern.rb +++ b/app/models/concerns/tags_substitution_concern.rb @@ -283,17 +283,19 @@ module TagsSubstitutionConcern @escape_unsafe_tags = escape - flat_tags = available_tags(dossier).each_with_object({}) do |tags, result| - valid_tags = tags_for_dossier_state(tags) + if @flat_tags.nil? + @flat_tags = available_tags(dossier).each_with_object({}) do |tags, result| + valid_tags = tags_for_dossier_state(tags) - valid_tags.each do |tag| - result[tag[:id]] = tag + valid_tags.each do |tag| + result[tag[:id]] = tag + end end end tags_and_libelles.each_with_object({}) do |(tag_id, libelle), substitutions| - substitutions[tag_id] = if flat_tags[tag_id].present? - replace_tag(flat_tags[tag_id], dossier) + substitutions[tag_id] = if @flat_tags[tag_id].present? + replace_tag(@flat_tags[tag_id], dossier) else # champ not in dossier, for example during preview on draft revision libelle end diff --git a/spec/services/procedure_export_service_zip_spec.rb b/spec/services/procedure_export_service_zip_spec.rb index 70ca5fc75..85237c724 100644 --- a/spec/services/procedure_export_service_zip_spec.rb +++ b/spec/services/procedure_export_service_zip_spec.rb @@ -40,7 +40,7 @@ describe ProcedureExportService do subject end - expect(sql_count).to eq(474) + expect(sql_count).to eq(296) dossier = dossiers.first From 420520489df7f91efedf7b1257ec131113b849da Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 15 May 2024 09:30:38 +0200 Subject: [PATCH 0240/1532] refactor(tags_substitution): simplify --- app/models/concerns/tags_substitution_concern.rb | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/app/models/concerns/tags_substitution_concern.rb b/app/models/concerns/tags_substitution_concern.rb index e68dae815..c9b7262af 100644 --- a/app/models/concerns/tags_substitution_concern.rb +++ b/app/models/concerns/tags_substitution_concern.rb @@ -284,13 +284,10 @@ module TagsSubstitutionConcern @escape_unsafe_tags = escape if @flat_tags.nil? - @flat_tags = available_tags(dossier).each_with_object({}) do |tags, result| - valid_tags = tags_for_dossier_state(tags) - - valid_tags.each do |tag| - result[tag[:id]] = tag - end - end + @flat_tags = available_tags(dossier) + .flatten + .then { tags_for_dossier_state(_1) } + .index_by { _1[:id] } end tags_and_libelles.each_with_object({}) do |(tag_id, libelle), substitutions| From e8a175d3102af18cfb91250759f05cf8ec8cc3ea Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 15 May 2024 10:30:51 +0200 Subject: [PATCH 0241/1532] refactor: be explicite with memoization --- app/models/concerns/tags_substitution_concern.rb | 14 +++++++++----- app/models/export_template.rb | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/models/concerns/tags_substitution_concern.rb b/app/models/concerns/tags_substitution_concern.rb index c9b7262af..373ad3018 100644 --- a/app/models/concerns/tags_substitution_concern.rb +++ b/app/models/concerns/tags_substitution_concern.rb @@ -275,7 +275,7 @@ module TagsSubstitutionConcern used_tags_and_libelle_for(text).map { _1.first.nil? ? _1.second : _1.first } end - def tags_substitutions(tags_and_libelles, dossier, escape: true) + def tags_substitutions(tags_and_libelles, dossier, escape: true, memoize: false) # NOTE: # - tags_and_libelles est un simple Set de couples (tag_id, libelle) (pas la même structure que dans replace_tags) # - dans `replace_tags`, on fait référence à des tags avec ou sans id, mais pas ici, @@ -283,16 +283,20 @@ module TagsSubstitutionConcern @escape_unsafe_tags = escape - if @flat_tags.nil? - @flat_tags = available_tags(dossier) + flat_tags = if memoize && @flat_tags.present? + @flat_tags + else + available_tags(dossier) .flatten .then { tags_for_dossier_state(_1) } .index_by { _1[:id] } end + @flat_tags = flat_tags if memoize + tags_and_libelles.each_with_object({}) do |(tag_id, libelle), substitutions| - substitutions[tag_id] = if @flat_tags[tag_id].present? - replace_tag(@flat_tags[tag_id], dossier) + substitutions[tag_id] = if flat_tags[tag_id].present? + replace_tag(flat_tags[tag_id], dossier) else # champ not in dossier, for example during preview on draft revision libelle end diff --git a/app/models/export_template.rb b/app/models/export_template.rb index 4d9bc5f39..dfbb57f3f 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -67,7 +67,7 @@ class ExportTemplate < ApplicationRecord def render_attributes_for(content_for, dossier, attachment = nil) tiptap = TiptapService.new used_tags = tiptap.used_tags_and_libelle_for(content_for.deep_symbolize_keys) - substitutions = tags_substitutions(used_tags, dossier, escape: false) + substitutions = tags_substitutions(used_tags, dossier, escape: false, memoize: true) substitutions['original-filename'] = attachment.filename.base if attachment tiptap.to_path(content_for.deep_symbolize_keys, substitutions) end From 080bcd8628a8330443111163213d3e9b3466a81a Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 15 May 2024 10:49:27 +0200 Subject: [PATCH 0242/1532] refactor: DossierPreloader rename includes_for_dossier -> includes_for_champ --- app/lib/recovery/exporter.rb | 2 +- app/models/dossier_preloader.rb | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/lib/recovery/exporter.rb b/app/lib/recovery/exporter.rb index f414df28a..3b681608b 100644 --- a/app/lib/recovery/exporter.rb +++ b/app/lib/recovery/exporter.rb @@ -18,7 +18,7 @@ module Recovery etablissement: :exercices, revision: :procedure) @dossiers = DossierPreloader.new(dossier_with_data, - includes_for_dossier: [:geo_areas, etablissement: :exercices], + includes_for_champ: [:geo_areas, etablissement: :exercices], includes_for_etablissement: [:exercices]).all @file_path = file_path end diff --git a/app/models/dossier_preloader.rb b/app/models/dossier_preloader.rb index 2f541ad93..28cb4b769 100644 --- a/app/models/dossier_preloader.rb +++ b/app/models/dossier_preloader.rb @@ -1,10 +1,10 @@ class DossierPreloader DEFAULT_BATCH_SIZE = 2000 - def initialize(dossiers, includes_for_dossier: [], includes_for_etablissement: []) + def initialize(dossiers, includes_for_champ: [], includes_for_etablissement: []) @dossiers = dossiers @includes_for_etablissement = includes_for_etablissement - @includes_for_dossier = includes_for_dossier + @includes_for_champ = includes_for_champ end def in_batches(size = DEFAULT_BATCH_SIZE) @@ -37,7 +37,7 @@ class DossierPreloader end def load_dossiers(dossiers, pj_template: false) - to_include = @includes_for_dossier.dup + to_include = @includes_for_champ.dup to_include << [piece_justificative_file_attachments: :blob] if pj_template From fa7d5cfa33fee57675e4a16f036e374dd6955581 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 15 May 2024 15:06:38 +0200 Subject: [PATCH 0243/1532] fix test --- spec/models/export_template_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/models/export_template_spec.rb b/spec/models/export_template_spec.rb index 6d314f136..0316b8edf 100644 --- a/spec/models/export_template_spec.rb +++ b/spec/models/export_template_spec.rb @@ -130,7 +130,7 @@ describe ExportTemplate do dossier.champs_public << champ_pj end it 'returns pj and custom name for pj' do - expect(export_template.attachment_and_path(dossier, attachment)).to eq([attachment, "DOSSIER_#{dossier.id}/superpj_justif.png"]) + expect(export_template.attachment_and_path(dossier, attachment)).to eq([attachment, "DOSSIER_#{dossier.id}/superpj_justif-1.png"]) end end context 'pj repetable' do @@ -161,7 +161,7 @@ describe ExportTemplate do dossier.champs_public << champ_pj end it 'rename repetable pj' do - expect(export_template.attachment_and_path(dossier, attachment)).to eq([attachment, "DOSSIER_#{dossier.id}/pj_repet_#{dossier.id}.png"]) + expect(export_template.attachment_and_path(dossier, attachment)).to eq([attachment, "DOSSIER_#{dossier.id}/pj_repet_#{dossier.id}-1.png"]) end end end From 9effa9e030623a5ad1da20e6192b2118a56ada75 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 17 May 2024 16:47:30 +0200 Subject: [PATCH 0244/1532] perf(zip): preload_dossier earlier --- app/lib/active_storage/downloadable_file.rb | 6 +- app/services/pieces_justificatives_service.rb | 60 +++++++------------ 2 files changed, 25 insertions(+), 41 deletions(-) diff --git a/app/lib/active_storage/downloadable_file.rb b/app/lib/active_storage/downloadable_file.rb index 57e919ff3..15b796aa5 100644 --- a/app/lib/active_storage/downloadable_file.rb +++ b/app/lib/active_storage/downloadable_file.rb @@ -4,7 +4,11 @@ class ActiveStorage::DownloadableFile def self.create_list_from_dossiers(dossiers:, user_profile:, export_template: nil) pj_service = PiecesJustificativesService.new(user_profile:, export_template:) - pj_service.generate_dossiers_export(dossiers) + pj_service.liste_documents(dossiers) + dossiers = dossiers + .includes(:individual, :traitement, :etablissement, user: :france_connect_informations, avis: :expert, commentaires: [:instructeur, :expert], revision: [:revision_types_de_champ, :types_de_champ_public, :types_de_champ_private]) + + loaded_dossiers = DossierPreloader.new(dossiers).in_batches + pj_service.generate_dossiers_export(loaded_dossiers) + pj_service.liste_documents(loaded_dossiers) end def self.cleanup_list_from_dossier(files) diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index bffbf6ea4..4b83d3b28 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -7,22 +7,18 @@ class PiecesJustificativesService def liste_documents(dossiers) bill_ids = [] - docs = dossiers.in_batches.flat_map do |batch| - pjs = pjs_for_champs(batch) + - pjs_for_commentaires(batch) + - pjs_for_dossier(batch) + - pjs_for_avis(batch) + docs = pjs_for_champs(dossiers) + + pjs_for_commentaires(dossiers) + + pjs_for_dossier(dossiers) + + pjs_for_avis(dossiers) - if liste_documents_allows?(:with_bills) - # some bills are shared among operations - # so first, all the bill_ids are fetched - operation_logs, some_bill_ids = operation_logs_and_signature_ids(batch) + if liste_documents_allows?(:with_bills) + # some bills are shared among operations + # so first, all the bill_ids are fetched + operation_logs, some_bill_ids = operation_logs_and_signature_ids(dossiers) - pjs += operation_logs - bill_ids += some_bill_ids - end - - pjs + docs += operation_logs + bill_ids += some_bill_ids end if liste_documents_allows?(:with_bills) @@ -33,14 +29,12 @@ class PiecesJustificativesService docs end - def generate_dossiers_export(dossiers) + def generate_dossiers_export(dossiers) # TODO: renommer generate_dossier_export sans s return [] if dossiers.empty? pdfs = [] procedure = dossiers.first.procedure - dossiers = dossiers.includes(:individual, :traitement, :etablissement, user: :france_connect_informations, avis: :expert, commentaires: [:instructeur, :expert]) - dossiers = DossierPreloader.new(dossiers).in_batches dossiers.each do |dossier| dossier.association(:procedure).target = procedure @@ -50,7 +44,6 @@ class PiecesJustificativesService acls: acl_for_dossier_export(procedure), dossier: dossier }) - a = ActiveStorage::FakeAttachment.new( file: StringIO.new(pdf), filename: "export-#{dossier.id}.pdf", @@ -142,34 +135,21 @@ class PiecesJustificativesService end def pjs_for_champs(dossiers) - champs = Champ - .joins(:piece_justificative_file_attachments) - .where(type: "Champs::PieceJustificativeChamp", dossier: dossiers) + champs = dossiers.flat_map(&:champs).filter { _1.type == "Champs::PieceJustificativeChamp" } if !liste_documents_allows?(:with_champs_private) - champs = champs.where(private: false) + champs = champs.reject(&:private?) end - champ_id_dossier_id = champs - .pluck(:id, :dossier_id) - .to_h - - ActiveStorage::Attachment - .includes(:blob) - .where(record_type: "Champ", record_id: champ_id_dossier_id.keys) - .filter { |a| safe_attachment(a) } - .group_by(&:record_id) - .flat_map do |champ_id, attachments| - dossier_id = champ_id_dossier_id[champ_id] - - attachments.map.with_index do |attachment, index| - if @export_template - @export_template.attachment_and_path(Dossier.find(dossier_id), attachment, index: index, row_index: attachment.record.row_index) - else - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, attachment) - end + champs.flat_map do |champ| + champ.piece_justificative_file_attachments.map.with_index do |attachment, index| + if @export_template + @export_template.attachment_and_path(champ.dossier, attachment, index:, row_index: champ.row_index, champ:) + else + ActiveStorage::DownloadableFile.pj_and_path(champ.dossier_id, attachment) end end + end end def pjs_for_commentaires(dossiers) From a7e29c4ea63495a7a35a133ac6b540252ced151f Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 17 May 2024 17:00:51 +0200 Subject: [PATCH 0245/1532] perf(spec): new count ! --- spec/services/procedure_export_service_zip_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/services/procedure_export_service_zip_spec.rb b/spec/services/procedure_export_service_zip_spec.rb index 85237c724..71362ec1e 100644 --- a/spec/services/procedure_export_service_zip_spec.rb +++ b/spec/services/procedure_export_service_zip_spec.rb @@ -1,7 +1,7 @@ describe ProcedureExportService do let(:instructeur) { create(:instructeur) } let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :piece_justificative, libelle: 'pj' }, { type: :repetition, children: [{ type: :piece_justificative }] }]) } - let(:dossiers) { create_list(:dossier, 10, procedure: procedure) } + let(:dossiers) { create_list(:dossier, 100, procedure: procedure) } let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur).tap(&:set_default_values) } let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) } @@ -40,7 +40,7 @@ describe ProcedureExportService do subject end - expect(sql_count).to eq(296) + expect(sql_count).to eq(272) dossier = dossiers.first From ca12a56e6aba76fe32bf4cadfce340896d5c047e Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 17 May 2024 17:01:41 +0200 Subject: [PATCH 0246/1532] perf(zip): give champ to avoid seeking stable_id --- app/models/export_template.rb | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/app/models/export_template.rb b/app/models/export_template.rb index dfbb57f3f..f5ed164b9 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -45,10 +45,10 @@ class ExportTemplate < ApplicationRecord end end - def attachment_and_path(dossier, attachment, index: 0, row_index: nil) + def attachment_and_path(dossier, attachment, index: 0, row_index: nil, champ: nil) [ attachment, - path(dossier, attachment, index, row_index) + path(dossier, attachment, index, row_index, champ) ] end @@ -116,7 +116,7 @@ class ExportTemplate < ApplicationRecord "#{render_attributes_for(content["pdf_name"], dossier)}.pdf" end - def path(dossier, attachment, index, row_index) + def path(dossier, attachment, index, row_index, champ) if attachment.name == 'pdf_export_for_instructeur' return export_path(dossier) end @@ -130,15 +130,14 @@ class ExportTemplate < ApplicationRecord 'avis' else # for attachment - return attachment_path(dossier, attachment, index, row_index) + return attachment_path(dossier, attachment, index, row_index, champ) end File.join(folder(dossier), dir_path, attachment.filename.to_s) end - def attachment_path(dossier, attachment, index, row_index) - type_de_champ_id = dossier.champs.find(attachment.record_id).type_de_champ_id - stable_id = TypeDeChamp.find(type_de_champ_id).stable_id + def attachment_path(dossier, attachment, index, row_index, champ) + stable_id = champ.stable_id tiptap_pj = content["pjs"].find { |pj| pj["stable_id"] == stable_id.to_s } if tiptap_pj File.join(folder(dossier), tiptap_convert_pj(dossier, stable_id, attachment) + suffix(attachment, index, row_index)) From 6184b33a18efb7d1e53456dd7aa54457d9f333c6 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 17 May 2024 17:36:40 +0200 Subject: [PATCH 0247/1532] perf(preloader): preloader use batch for batches --- app/lib/active_storage/downloadable_file.rb | 9 +++++---- app/models/dossier_preloader.rb | 10 ++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/lib/active_storage/downloadable_file.rb b/app/lib/active_storage/downloadable_file.rb index 15b796aa5..e42c19b54 100644 --- a/app/lib/active_storage/downloadable_file.rb +++ b/app/lib/active_storage/downloadable_file.rb @@ -4,11 +4,12 @@ class ActiveStorage::DownloadableFile def self.create_list_from_dossiers(dossiers:, user_profile:, export_template: nil) pj_service = PiecesJustificativesService.new(user_profile:, export_template:) - dossiers = dossiers - .includes(:individual, :traitement, :etablissement, user: :france_connect_informations, avis: :expert, commentaires: [:instructeur, :expert], revision: [:revision_types_de_champ, :types_de_champ_public, :types_de_champ_private]) + files = [] + DossierPreloader.new(dossiers).in_batches_with_block do |loaded_dossiers| + files += pj_service.generate_dossiers_export(loaded_dossiers) + pj_service.liste_documents(loaded_dossiers) + end - loaded_dossiers = DossierPreloader.new(dossiers).in_batches - pj_service.generate_dossiers_export(loaded_dossiers) + pj_service.liste_documents(loaded_dossiers) + files end def self.cleanup_list_from_dossier(files) diff --git a/app/models/dossier_preloader.rb b/app/models/dossier_preloader.rb index 28cb4b769..c1a3e614e 100644 --- a/app/models/dossier_preloader.rb +++ b/app/models/dossier_preloader.rb @@ -13,6 +13,16 @@ class DossierPreloader dossiers end + def in_batches_with_block(size = DEFAULT_BATCH_SIZE, &block) + @dossiers.in_batches(of: size) do |batch| + data = Dossier.where(id: batch.ids).includes(:individual, :traitement, :etablissement, user: :france_connect_informations, avis: :expert, commentaires: [:instructeur, :expert], revision: :revision_types_de_champ) + + dossiers = data.to_a + load_dossiers(dossiers) + yield(dossiers) + end + end + def all(pj_template: false) dossiers = @dossiers.to_a load_dossiers(dossiers, pj_template:) From 8628ec162190d65fd715b9fbc94401e80affc937 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 17 May 2024 17:38:54 +0200 Subject: [PATCH 0248/1532] perf(spec): new record ! Co-authored-by: Christophe Robillard --- spec/services/procedure_export_service_zip_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/services/procedure_export_service_zip_spec.rb b/spec/services/procedure_export_service_zip_spec.rb index 71362ec1e..1baace907 100644 --- a/spec/services/procedure_export_service_zip_spec.rb +++ b/spec/services/procedure_export_service_zip_spec.rb @@ -1,7 +1,7 @@ describe ProcedureExportService do let(:instructeur) { create(:instructeur) } let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :piece_justificative, libelle: 'pj' }, { type: :repetition, children: [{ type: :piece_justificative }] }]) } - let(:dossiers) { create_list(:dossier, 100, procedure: procedure) } + let(:dossiers) { create_list(:dossier, 10, procedure: procedure) } let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur).tap(&:set_default_values) } let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) } @@ -40,7 +40,7 @@ describe ProcedureExportService do subject end - expect(sql_count).to eq(272) + expect(sql_count).to eq(89) dossier = dossiers.first From e38999efda23ca135c8a2bddbcd9844b75f1a4ae Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Sat, 18 May 2024 10:02:29 +0200 Subject: [PATCH 0249/1532] perf(pj service): compute row_id without extraneous requests --- app/models/champ.rb | 6 ------ app/services/pieces_justificatives_service.rb | 12 ++++++++++-- spec/services/procedure_export_service_zip_spec.rb | 4 ++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/models/champ.rb b/app/models/champ.rb index a66b91e84..74b5fd1e9 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -95,12 +95,6 @@ class Champ < ApplicationRecord [row_id, stable_id].compact end - def row_index - return nil if parent_id.nil? - - Champ.where(parent_id:).pluck(:row_id).sort.index(row_id) - end - # used for the `required` html attribute # check visibility to avoid hidden required input # which prevent the form from being sent. diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 4b83d3b28..612adba35 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -141,10 +141,18 @@ class PiecesJustificativesService champs = champs.reject(&:private?) end + champs_id_row_index = champs.filter { _1.row_id.present? }.group_by(&:dossier_id).values.each_with_object({}) do |champs_for_dossier, hash| + champs_for_dossier.group_by(&:stable_id).values.each do |champs_for_stable_id| + champs_for_stable_id.sort_by(&:row_id).each.with_index { |c, index| hash[c.id] = index } + end + end + champs.flat_map do |champ| - champ.piece_justificative_file_attachments.map.with_index do |attachment, index| + champ.piece_justificative_file_attachments.filter { |a| safe_attachment(a) }.map.with_index do |attachment, index| + row_index = champs_id_row_index[champ.id] + if @export_template - @export_template.attachment_and_path(champ.dossier, attachment, index:, row_index: champ.row_index, champ:) + @export_template.attachment_and_path(champ.dossier, attachment, index:, row_index:, champ:) else ActiveStorage::DownloadableFile.pj_and_path(champ.dossier_id, attachment) end diff --git a/spec/services/procedure_export_service_zip_spec.rb b/spec/services/procedure_export_service_zip_spec.rb index 1baace907..e50cce89f 100644 --- a/spec/services/procedure_export_service_zip_spec.rb +++ b/spec/services/procedure_export_service_zip_spec.rb @@ -40,7 +40,7 @@ describe ProcedureExportService do subject end - expect(sql_count).to eq(89) + expect(sql_count).to eq(58) dossier = dossiers.first @@ -58,7 +58,7 @@ describe ProcedureExportService do "export/dossier-#{dossier.id}/libelle-du-champ-2-#{dossier.id}-1-2.png" ] - expect(files.size).to eq(10 * 6 + 1) + expect(files.size).to eq(dossiers.count * 6 + 1) expect(structure - files.map(&:filename)).to be_empty end FileUtils.remove_entry_secure('tmp.zip') From d3b700326deb2ced2e0a1dbcc6d8f1e39a2ae9db Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 21 May 2024 17:05:34 +0200 Subject: [PATCH 0250/1532] spec: fix --- spec/models/export_template_spec.rb | 6 +++--- spec/services/procedure_export_service_zip_spec.rb | 11 +++++------ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/spec/models/export_template_spec.rb b/spec/models/export_template_spec.rb index 0316b8edf..f0602597a 100644 --- a/spec/models/export_template_spec.rb +++ b/spec/models/export_template_spec.rb @@ -130,7 +130,7 @@ describe ExportTemplate do dossier.champs_public << champ_pj end it 'returns pj and custom name for pj' do - expect(export_template.attachment_and_path(dossier, attachment)).to eq([attachment, "DOSSIER_#{dossier.id}/superpj_justif-1.png"]) + expect(export_template.attachment_and_path(dossier, attachment, champ: champ_pj)).to eq([attachment, "DOSSIER_#{dossier.id}/superpj_justif-1.png"]) end end context 'pj repetable' do @@ -161,7 +161,7 @@ describe ExportTemplate do dossier.champs_public << champ_pj end it 'rename repetable pj' do - expect(export_template.attachment_and_path(dossier, attachment)).to eq([attachment, "DOSSIER_#{dossier.id}/pj_repet_#{dossier.id}-1.png"]) + expect(export_template.attachment_and_path(dossier, attachment, champ: champ_pj)).to eq([attachment, "DOSSIER_#{dossier.id}/pj_repet_#{dossier.id}-1.png"]) end end end @@ -272,7 +272,7 @@ describe ExportTemplate do let(:pdf_mention) { { "type" => "mention", "attrs" => {} } } it "add error for pdf name" do expect(subject.valid?).to be_falsey - expect(subject.errors.full_messages).to include "Le champ « Nom de l'export » doit être rempli" + expect(subject.errors.full_messages).to include "Le champ « Nom du dossier au format pdf » doit être rempli" end end end diff --git a/spec/services/procedure_export_service_zip_spec.rb b/spec/services/procedure_export_service_zip_spec.rb index e50cce89f..0daced35e 100644 --- a/spec/services/procedure_export_service_zip_spec.rb +++ b/spec/services/procedure_export_service_zip_spec.rb @@ -1,6 +1,6 @@ describe ProcedureExportService do let(:instructeur) { create(:instructeur) } - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :piece_justificative, libelle: 'pj' }, { type: :repetition, children: [{ type: :piece_justificative }] }]) } + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :piece_justificative, libelle: 'pj' }, { type: :repetition, children: [{ type: :piece_justificative, libelle: 'repet_pj' }] }]) } let(:dossiers) { create_list(:dossier, 10, procedure: procedure) } let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur).tap(&:set_default_values) } let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) } @@ -40,22 +40,21 @@ describe ProcedureExportService do subject end - expect(sql_count).to eq(58) + expect(sql_count <= 58).to be_truthy dossier = dossiers.first File.write('tmp.zip', subject.download, mode: 'wb') File.open('tmp.zip') do |fd| files = ZipTricks::FileReader.read_zip_structure(io: fd) - base_fn = "export" structure = [ "export/", "export/dossier-#{dossier.id}/", "export/dossier-#{dossier.id}/export_#{dossier.id}.pdf", "export/dossier-#{dossier.id}/pj-#{dossier.id}-1.png", - "export/dossier-#{dossier.id}/libelle-du-champ-2-#{dossier.id}-1-1.png", - "export/dossier-#{dossier.id}/libelle-du-champ-2-#{dossier.id}-2-1.png", - "export/dossier-#{dossier.id}/libelle-du-champ-2-#{dossier.id}-1-2.png" + "export/dossier-#{dossier.id}/repet_pj-#{dossier.id}-1-1.png", + "export/dossier-#{dossier.id}/repet_pj-#{dossier.id}-2-1.png", + "export/dossier-#{dossier.id}/repet_pj-#{dossier.id}-1-2.png" ] expect(files.size).to eq(dossiers.count * 6 + 1) From 4fb03e3967d203743498f75940c8c05296fd732e Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Wed, 22 May 2024 12:23:38 +0200 Subject: [PATCH 0251/1532] fix: remove useless code --- app/lib/download_manager/parallel_download_queue.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/lib/download_manager/parallel_download_queue.rb b/app/lib/download_manager/parallel_download_queue.rb index 35a6b8695..16dcbd762 100644 --- a/app/lib/download_manager/parallel_download_queue.rb +++ b/app/lib/download_manager/parallel_download_queue.rb @@ -12,8 +12,6 @@ module DownloadManager end def download_all - # TODO: arriver à enelver ce parametrage d'ActiveStorage - ActiveStorage::Current.url_options = { host: ENV.fetch("APP_HOST") } hydra = Typhoeus::Hydra.new(max_concurrency: DOWNLOAD_MAX_PARALLEL) attachments.each do |attachment, path| From 0869168bd33160e221316988d739938809f41901 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 23 May 2024 09:59:09 +0200 Subject: [PATCH 0252/1532] spec: test champs_id_row_index --- app/services/pieces_justificatives_service.rb | 28 +++++++-- .../pieces_justificatives_service_spec.rb | 61 +++++++++++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 612adba35..0fdbc2be9 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -141,11 +141,7 @@ class PiecesJustificativesService champs = champs.reject(&:private?) end - champs_id_row_index = champs.filter { _1.row_id.present? }.group_by(&:dossier_id).values.each_with_object({}) do |champs_for_dossier, hash| - champs_for_dossier.group_by(&:stable_id).values.each do |champs_for_stable_id| - champs_for_stable_id.sort_by(&:row_id).each.with_index { |c, index| hash[c.id] = index } - end - end + champs_id_row_index = compute_champ_id_row_index(champs) champs.flat_map do |champ| champ.piece_justificative_file_attachments.filter { |a| safe_attachment(a) }.map.with_index do |attachment, index| @@ -301,4 +297,26 @@ class PiecesJustificativesService .blob .virus_scan_result == ActiveStorage::VirusScanner::SAFE end + + # given + # repet_0 (stable_id: r0) + # # row_0 + # # # pj_champ_0 (stable_id: 0) + # # row_1 + # # # pj_champ_1 (stable_id: 0) + # repet_1 (stable_id: r1) + # # row_0 + # # # pj_champ_2 (stable_id: 1) + # # # pj_champ_3 (stable_id: 2) + # # row_1 + # # # pj_champ_4 (stable_id: 1) + # # # pj_champ_5 (stable_id: 2) + # it returns { pj_0.id => 0, pj_1.id => 1, pj_2.id => 0, pj_3.id => 0, pj_4.id => 1, pj_5.id => 1 } + def compute_champ_id_row_index(champs) + champs.filter(&:child?).group_by(&:dossier_id).values.each_with_object({}) do |children_for_dossier, hash| + children_for_dossier.group_by(&:stable_id).values.each do |champs_for_stable_id| + champs_for_stable_id.sort_by(&:row_id).each.with_index { |c, index| hash[c.id] = index } + end + end + end end diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index 56917eda7..cef564981 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -417,6 +417,67 @@ describe PiecesJustificativesService do end end + describe '#compute_champ_id_row_index' do + let(:user_profile) { build(:administrateur) } + let(:types_de_champ_public) do + [ + { type: :repetition, children: [{ type: :piece_justificative }] }, + { type: :repetition, children: [{ type: :piece_justificative }, { type: :piece_justificative }] } + ] + end + + let(:procedure) { create(:procedure, types_de_champ_public:) } + let(:dossier_1) { create(:dossier, procedure:) } + let(:champs) { dossier_1.champs } + + def pj_champ(d) = d.champs_public.find_by(type: 'Champs::PieceJustificativeChamp') + def repetition(d, index:) = d.champs_public.filter(&:repetition?)[index] + + subject { PiecesJustificativesService.new(user_profile:, export_template: nil).send(:compute_champ_id_row_index, champs) } + + before do + pj_champ(dossier_1) + + # repet_0 (stable_id: r0) + # # row_0 + # # # pj_champ_0 (stable_id: 0) + # # row_1 + # # # pj_champ_1 (stable_id: 0) + # repet_1 (stable_id: r1) + # # row_0 + # # # pj_champ_2 (stable_id: 1) + # # # pj_champ_3 (stable_id: 2) + # # row_1 + # # # pj_champ_4 (stable_id: 1) + # # # pj_champ_5 (stable_id: 2) + + repet_0 = repetition(dossier_1, index: 0) + repet_1 = repetition(dossier_1, index: 1) + + repet_0.add_row(dossier_1.revision) + repet_0.add_row(dossier_1.revision) + + repet_1.add_row(dossier_1.revision) + repet_1.add_row(dossier_1.revision) + end + + it do + champs = dossier_1.champs_public + repet_0 = champs[0] + pj_0 = repet_0.rows.first.first + pj_1 = repet_0.rows.second.first + + repet_1 = champs[1] + pj_2 = repet_1.rows.first.first + pj_3 = repet_1.rows.first.second + + pj_4 = repet_1.rows.second.first + pj_5 = repet_1.rows.second.second + + is_expected.to eq({ pj_0.id => 0, pj_1.id => 1, pj_2.id => 0, pj_3.id => 0, pj_4.id => 1, pj_5.id => 1 }) + end + end + def attach_file_to_champ(champ, safe = true) attach_file(champ.piece_justificative_file, safe) end From ab4b201fcb3d3d008e05c8a034f0eeb6c8209451 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 23 May 2024 11:38:27 +0200 Subject: [PATCH 0253/1532] fix(toggle): render label in 1 line --- app/assets/stylesheets/dsfr.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/assets/stylesheets/dsfr.scss b/app/assets/stylesheets/dsfr.scss index 89e2e966b..11343be43 100644 --- a/app/assets/stylesheets/dsfr.scss +++ b/app/assets/stylesheets/dsfr.scss @@ -179,3 +179,10 @@ button.fr-tag-bug { border: initial; display: block; // Pour cette valeur spécifique, on récupère celle de .fr-label } + +// Fix toggles having labels on 2 lines +// From upstream https://github.com/GouvernementFR/dsfr/pull/928 +// Remove it when PR is merged (v1.12 ?) +.fr-toggle label[data-fr-unchecked-label][data-fr-checked-label]::before { + word-wrap: normal; +} From fa91987e3d0bf7a930a7442e19bf706e8f60fd19 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 23 May 2024 11:39:56 +0200 Subject: [PATCH 0254/1532] fix(toggle): class markup & label on left for attestation v2 --- .../release_note/form_component/form_component.html.haml | 2 +- .../administrateurs/attestation_template_v2s/edit.html.haml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/release_note/form_component/form_component.html.haml b/app/components/release_note/form_component/form_component.html.haml index 2a78072ff..94d74ff9c 100644 --- a/app/components/release_note/form_component/form_component.html.haml +++ b/app/components/release_note/form_component/form_component.html.haml @@ -6,7 +6,7 @@ .fr-fieldset__element .fr-toggle - = f.check_box :published, class: "fr-toggle-input", id: dom_id(release_note, :published) + = f.check_box :published, class: "fr-toggle__input", id: dom_id(release_note, :published) %label.fr-toggle__label{ for: dom_id(release_note, :published), data: { fr_checked_label: "Publié", fr_unchecked_label: "Brouillon" } } Publier diff --git a/app/views/administrateurs/attestation_template_v2s/edit.html.haml b/app/views/administrateurs/attestation_template_v2s/edit.html.haml index 308dca5e4..daa69b7c6 100644 --- a/app/views/administrateurs/attestation_template_v2s/edit.html.haml +++ b/app/views/administrateurs/attestation_template_v2s/edit.html.haml @@ -38,8 +38,8 @@ %h2.fr-h4 En-tête .fr-fieldset__element - .fr-toggle - = f.check_box :official_layout, class: "fr-toggle-input", id: dom_id(@attestation_template, :official_layout), data: { "attestation-target": "layoutToggle"} + .fr-toggle.fr-toggle--label-left + = f.check_box :official_layout, class: "fr-toggle__input", id: dom_id(@attestation_template, :official_layout), data: { "attestation-target": "layoutToggle"} %label.fr-toggle__label{ for: dom_id(@attestation_template, :official_layout), data: { fr_checked_label: "Activé", fr_unchecked_label: "Désactivé" } } Je souhaite générer une attestation à la charte de l’état (logo avec Marianne) From e866aced929a0a97927f3f821ab8c2afbeea49ce Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Mon, 13 May 2024 16:48:59 +0200 Subject: [PATCH 0255/1532] Remove empty

--- app/views/support/index.html.haml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/views/support/index.html.haml b/app/views/support/index.html.haml index eadc29c96..fb97ab53b 100644 --- a/app/views/support/index.html.haml +++ b/app/views/support/index.html.haml @@ -35,10 +35,9 @@ - if link.present? .fr-ml-3w.hidden{ id: "card-#{question_type}", "aria-hidden": true , data: { "support-target": "content" } } = render Dsfr::CalloutComponent.new(title: t('.our_answer')) do |c| - - c.with_body do - %p - -# i18n-tasks-use t("support.index.#{question_type}.answer_html") - = t('answer_html', scope: [:support, :index, question_type], base_url: Current.application_base_url, "link_#{question_type}": link) + - c.with_html_body do + -# i18n-tasks-use t("support.index.#{question_type}.answer_html") + = t('answer_html', scope: [:support, :index, question_type], base_url: Current.application_base_url, "link_#{question_type}": link) .fr-input-group = label_tag :dossier_id, t('file_number', scope: [:utils]), class: 'fr-label' From eaf9773e9eab11ea7c41a00165a1543e55a69562 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 23 May 2024 19:09:04 +0200 Subject: [PATCH 0256/1532] chore(job): DossierIndexSearchTerms in low_priority queue --- app/jobs/dossier_index_search_terms_job.rb | 2 ++ spec/mailers/instructeur_mailer_spec.rb | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/jobs/dossier_index_search_terms_job.rb b/app/jobs/dossier_index_search_terms_job.rb index 688171472..293d1c96a 100644 --- a/app/jobs/dossier_index_search_terms_job.rb +++ b/app/jobs/dossier_index_search_terms_job.rb @@ -1,4 +1,6 @@ class DossierIndexSearchTermsJob < ApplicationJob + queue_as :low_priority + discard_on ActiveRecord::RecordNotFound def perform(dossier) diff --git a/spec/mailers/instructeur_mailer_spec.rb b/spec/mailers/instructeur_mailer_spec.rb index e84dfde82..8fb916a28 100644 --- a/spec/mailers/instructeur_mailer_spec.rb +++ b/spec/mailers/instructeur_mailer_spec.rb @@ -12,7 +12,7 @@ RSpec.describe InstructeurMailer, type: :mailer do before { ENV['BULK_EMAIL_QUEUE'] = custom_queue } it 'enqueues email is custom queue for low priority delivery' do - expect { subject.deliver_later }.to have_enqueued_job.on_queue(custom_queue) + expect { subject.deliver_later }.to have_enqueued_job(PriorizedMailDeliveryJob).on_queue(custom_queue) end end end From 3617368a35a8c33506d6598d4e40d731b2bc539a Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 27 May 2024 09:56:43 +0200 Subject: [PATCH 0257/1532] fix(search): increase debounce delay because of too frequent brouillon updates --- app/models/concerns/dossier_searchable_concern.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/concerns/dossier_searchable_concern.rb b/app/models/concerns/dossier_searchable_concern.rb index 24457b68b..f7433337c 100644 --- a/app/models/concerns/dossier_searchable_concern.rb +++ b/app/models/concerns/dossier_searchable_concern.rb @@ -6,7 +6,7 @@ module DossierSearchableConcern included do after_commit :index_search_terms_later, if: -> { previously_new_record? || user_previously_changed? || mandataire_first_name_previously_changed? || mandataire_last_name_previously_changed? } - SEARCH_TERMS_DEBOUNCE = 30.seconds + SEARCH_TERMS_DEBOUNCE = 5.minutes kredis_flag :debounce_index_search_terms_flag From 1bca3c123bc7afb6b3dbbb7824357fec9d2e2d22 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 28 Mar 2024 12:42:29 +0100 Subject: [PATCH 0258/1532] chore(dossier): remove legacy update attributes --- .../editable_champ/carte_component.rb | 6 +-- .../editable_champ_component.rb | 12 +---- .../editable_champ_component.html.haml | 5 -- .../multiple_drop_down_list_component.rb | 6 +-- .../editable_champ/rna_component.rb | 6 +-- .../editable_champ/siret_component.rb | 6 +-- app/controllers/champs/champ_controller.rb | 12 ++--- app/controllers/champs/options_controller.rb | 3 +- .../concerns/turbo_champs_concern.rb | 9 +--- .../instructeurs/dossiers_controller.rb | 12 ++--- app/controllers/users/dossiers_controller.rb | 12 ++--- app/helpers/champ_helper.rb | 6 +-- app/models/champ.rb | 7 --- app/models/concerns/dossier_champs_concern.rb | 7 --- app/models/dossier.rb | 4 -- app/models/dossier_preloader.rb | 8 --- config/initializers/flipper.rb | 3 +- config/routes.rb | 14 ------ .../champs/carte_controller_spec.rb | 33 ++++++------ .../champs/options_controller_spec.rb | 8 --- .../controllers/champs/rna_controller_spec.rb | 2 +- .../champs/siret_controller_spec.rb | 2 +- .../instructeurs/dossiers_controller_spec.rb | 30 ----------- .../users/dossiers_controller_spec.rb | 24 +-------- .../concerns/dossier_champs_concern_spec.rb | 50 ++----------------- .../concerns/dossier_rebase_concern_spec.rb | 4 +- spec/models/dossier_preloader_spec.rb | 4 +- 27 files changed, 50 insertions(+), 245 deletions(-) diff --git a/app/components/editable_champ/carte_component.rb b/app/components/editable_champ/carte_component.rb index 792995b35..189fcb76a 100644 --- a/app/components/editable_champ/carte_component.rb +++ b/app/components/editable_champ/carte_component.rb @@ -11,10 +11,6 @@ class EditableChamp::CarteComponent < EditableChamp::EditableChampBaseComponent end def update_path - if Champ.update_by_stable_id? - champs_carte_features_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id) - else - champs_legacy_carte_features_path(@champ) - end + champs_carte_features_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id) end end diff --git a/app/components/editable_champ/editable_champ_component.rb b/app/components/editable_champ/editable_champ_component.rb index 8bc6f4cac..a529d7a8f 100644 --- a/app/components/editable_champ/editable_champ_component.rb +++ b/app/components/editable_champ/editable_champ_component.rb @@ -54,17 +54,9 @@ class EditableChamp::EditableChampComponent < ApplicationComponent def turbo_poll_url_value if @champ.private? - if Champ.update_by_stable_id? - annotation_instructeur_dossier_path(@champ.dossier.procedure, @champ.dossier, @champ.stable_id, row_id: @champ.row_id, with_public_id: true) - else - annotation_instructeur_dossier_path(@champ.dossier.procedure, @champ.dossier, @champ) - end + annotation_instructeur_dossier_path(@champ.dossier.procedure, @champ.dossier, @champ.stable_id, row_id: @champ.row_id, with_public_id: true) else - if Champ.update_by_stable_id? - champ_dossier_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id, with_public_id: true) - else - champ_dossier_path(@champ.dossier, @champ) - end + champ_dossier_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id, with_public_id: true) end end diff --git a/app/components/editable_champ/editable_champ_component/editable_champ_component.html.haml b/app/components/editable_champ/editable_champ_component/editable_champ_component.html.haml index 9608eded7..06ff21348 100644 --- a/app/components/editable_champ/editable_champ_component/editable_champ_component.html.haml +++ b/app/components/editable_champ/editable_champ_component/editable_champ_component.html.haml @@ -6,8 +6,3 @@ = render champ_component = render Dsfr::InputStatusMessageComponent.new(errors_on_attribute: champ_component.errors_on_attribute?, error_full_messages: champ_component.error_full_messages, describedby_id: @champ.describedby_id, champ: @champ) - - - if Champ.update_by_stable_id? - = @form.hidden_field :with_public_id, value: 'true' - - else - = @form.hidden_field :id, value: @champ.id diff --git a/app/components/editable_champ/multiple_drop_down_list_component.rb b/app/components/editable_champ/multiple_drop_down_list_component.rb index 9ba731618..55e3d4ba4 100644 --- a/app/components/editable_champ/multiple_drop_down_list_component.rb +++ b/app/components/editable_champ/multiple_drop_down_list_component.rb @@ -10,10 +10,6 @@ class EditableChamp::MultipleDropDownListComponent < EditableChamp::EditableCham end def update_path(option) - if Champ.update_by_stable_id? - champs_options_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id, option:) - else - champs_legacy_options_path(@champ, option:) - end + champs_options_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id, option:) end end diff --git a/app/components/editable_champ/rna_component.rb b/app/components/editable_champ/rna_component.rb index 09783147c..fd04cb278 100644 --- a/app/components/editable_champ/rna_component.rb +++ b/app/components/editable_champ/rna_component.rb @@ -4,10 +4,6 @@ class EditableChamp::RNAComponent < EditableChamp::EditableChampBaseComponent end def update_path - if Champ.update_by_stable_id? - champs_rna_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id) - else - champs_legacy_rna_path(@champ) - end + champs_rna_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id) end end diff --git a/app/components/editable_champ/siret_component.rb b/app/components/editable_champ/siret_component.rb index 800d6be5d..f97fbf50a 100644 --- a/app/components/editable_champ/siret_component.rb +++ b/app/components/editable_champ/siret_component.rb @@ -12,10 +12,6 @@ class EditableChamp::SiretComponent < EditableChamp::EditableChampBaseComponent end def update_path - if Champ.update_by_stable_id? - champs_siret_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id) - else - champs_legacy_siret_path(@champ) - end + champs_siret_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id) end end diff --git a/app/controllers/champs/champ_controller.rb b/app/controllers/champs/champ_controller.rb index 5c03ee863..73ecb3044 100644 --- a/app/controllers/champs/champ_controller.rb +++ b/app/controllers/champs/champ_controller.rb @@ -5,15 +5,9 @@ class Champs::ChampController < ApplicationController private def find_champ - if params[:champ_id].present? - policy_scope(Champ) - .includes(:type_de_champ, dossier: :champs) - .find(params[:champ_id]) - else - dossier = policy_scope(Dossier).includes(:champs, revision: [:types_de_champ]).find(params[:dossier_id]) - type_de_champ = dossier.find_type_de_champ_by_stable_id(params[:stable_id]) - dossier.champ_for_update(type_de_champ, params_row_id) - end + dossier = policy_scope(Dossier).includes(:champs, revision: [:types_de_champ]).find(params[:dossier_id]) + type_de_champ = dossier.find_type_de_champ_by_stable_id(params[:stable_id]) + dossier.champ_for_update(type_de_champ, params_row_id) end def params_row_id diff --git a/app/controllers/champs/options_controller.rb b/app/controllers/champs/options_controller.rb index cc878fc3e..61e53e05c 100644 --- a/app/controllers/champs/options_controller.rb +++ b/app/controllers/champs/options_controller.rb @@ -3,9 +3,8 @@ class Champs::OptionsController < Champs::ChampController def remove @champ.remove_option([params[:option]].compact, true) - @champ.reload @dossier = @champ.private? ? nil : @champ.dossier - champs_attributes = { @champ.public_id => params[:champ_id].present? ? { id: @champ.id } : { with_public_id: true } } + champs_attributes = { @champ.public_id => {} } @to_show, @to_hide, @to_update = champs_to_turbo_update(champs_attributes, @champ.dossier.champs) end end diff --git a/app/controllers/concerns/turbo_champs_concern.rb b/app/controllers/concerns/turbo_champs_concern.rb index 34e683e97..089edacbc 100644 --- a/app/controllers/concerns/turbo_champs_concern.rb +++ b/app/controllers/concerns/turbo_champs_concern.rb @@ -4,13 +4,8 @@ module TurboChampsConcern private def champs_to_turbo_update(params, champs) - to_update = if params.values.filter { _1.key?(:with_public_id) }.empty? - champ_ids = params.values.map { _1[:id] }.compact.map(&:to_i) - champs.filter { _1.id.in?(champ_ids) } - else - champ_public_ids = params.keys - champs.filter { _1.public_id.in?(champ_public_ids) } - end.filter { _1.refresh_after_update? || _1.forked_with_changes? } + to_update = champs.filter { _1.public_id.in?(params.keys) } + .filter { _1.refresh_after_update? || _1.forked_with_changes? } to_show, to_hide = champs.filter(&:conditional?) .partition(&:visible?) diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index 7ab9a717d..7657f9a48 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -275,7 +275,7 @@ module Instructeurs def update_annotations dossier_with_champs.update_champs_attributes(champs_private_attributes_params, :private) - if dossier.champs.any?(&:changed_for_autosave?) || dossier.champs_private_all.any?(&:changed_for_autosave?) # TODO remove second condition after one deploy + if dossier.champs.any?(&:changed_for_autosave?) dossier.last_champ_private_updated_at = Time.zone.now end @@ -296,13 +296,8 @@ module Instructeurs def annotation @dossier = dossier_with_champs(pj_template: false) - annotation_id_or_stable_id = params[:stable_id] - annotation = if params[:with_public_id].present? - type_de_champ = @dossier.find_type_de_champ_by_stable_id(annotation_id_or_stable_id, :private) - @dossier.project_champ(type_de_champ, params[:row_id]) - else - @dossier.champs_private_all.find(annotation_id_or_stable_id) - end + type_de_champ = @dossier.find_type_de_champ_by_stable_id(params[:stable_id], :private) + annotation = @dossier.project_champ(type_de_champ, params[:row_id]) respond_to do |format| format.turbo_stream do @@ -418,7 +413,6 @@ module Instructeurs :accreditation_number, :accreditation_birthdate, :feature, - :with_public_id, value: [] ] # Strong attributes do not support records (indexed hash); they only support hashes with diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index db49503c7..e6a64f2cd 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -319,13 +319,8 @@ module Users def champ @dossier = dossier_with_champs(pj_template: false) - champ_id_or_stable_id = params[:stable_id] - champ = if params[:with_public_id].present? - type_de_champ = @dossier.find_type_de_champ_by_stable_id(champ_id_or_stable_id, :public) - @dossier.project_champ(type_de_champ, params[:row_id]) - else - @dossier.champs_public_all.find(champ_id_or_stable_id) - end + type_de_champ = @dossier.find_type_de_champ_by_stable_id(params[:stable_id], :public) + champ = @dossier.project_champ(type_de_champ, params[:row_id]) respond_to do |format| format.turbo_stream do @@ -511,7 +506,6 @@ module Users :accreditation_number, :accreditation_birthdate, :feature, - :with_public_id, value: [] ] # Strong attributes do not support records (indexed hash); they only support hashes with @@ -556,7 +550,7 @@ module Users def update_dossier_and_compute_errors @dossier.update_champs_attributes(champs_public_attributes_params, :public) - if @dossier.champs.any?(&:changed_for_autosave?) || @dossier.champs_public_all.any?(&:changed_for_autosave?) # TODO remove second condition after one deploy + if @dossier.champs.any?(&:changed_for_autosave?) @dossier.last_champ_updated_at = Time.zone.now end diff --git a/app/helpers/champ_helper.rb b/app/helpers/champ_helper.rb index a79aada8d..ed359520e 100644 --- a/app/helpers/champ_helper.rb +++ b/app/helpers/champ_helper.rb @@ -9,11 +9,7 @@ module ChampHelper def auto_attach_url(object, params = {}) if object.is_a?(Champ) - if Champ.update_by_stable_id? - champs_piece_justificative_url(object.dossier, object.stable_id, params.merge(row_id: object.row_id)) - else - champs_legacy_piece_justificative_url(object.id, params) - end + champs_piece_justificative_url(object.dossier, object.stable_id, params.merge(row_id: object.row_id)) elsif object.is_a?(TypeDeChamp) && object.piece_justificative? piece_justificative_template_admin_procedure_type_de_champ_url(stable_id: object.stable_id, procedure_id: object.procedure.id, **params) elsif object.is_a?(TypeDeChamp) && object.explication? diff --git a/app/models/champ.rb b/app/models/champ.rb index 74b5fd1e9..6a05baea3 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -2,9 +2,6 @@ class Champ < ApplicationRecord include ChampConditionalConcern include ChampsValidateConcern - # TODO: remove after one deploy - attr_writer :with_public_id - belongs_to :dossier, inverse_of: false, touch: true, optional: false belongs_to :type_de_champ, inverse_of: :champ, optional: false belongs_to :parent, class_name: 'Champ', optional: true @@ -295,10 +292,6 @@ class Champ < ApplicationRecord self.value = value.delete("\u0000") end - def self.update_by_stable_id? - Flipper.enabled?(:champ_update_by_stable_id, Current.user) - end - class NotImplemented < ::StandardError def initialize(method) super(":#{method} not implemented") diff --git a/app/models/concerns/dossier_champs_concern.rb b/app/models/concerns/dossier_champs_concern.rb index 1e5f2bc3e..40c0692c8 100644 --- a/app/models/concerns/dossier_champs_concern.rb +++ b/app/models/concerns/dossier_champs_concern.rb @@ -65,13 +65,6 @@ module DossierChampsConcern end def update_champs_attributes(attributes, scope) - # TODO: remove after one deploy - if attributes.present? && attributes.values.filter { _1.key?(:with_public_id) }.empty? - assign_attributes("champs_#{scope}_all_attributes".to_sym => attributes) - @champs_by_public_id = nil - return - end - champs_attributes = attributes.to_h.map do |public_id, attributes| champ_attributes_by_public_id(public_id, attributes, scope) end diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 5d3c1a810..6fa255a64 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -49,8 +49,6 @@ class Dossier < ApplicationRecord has_many :champs_to_destroy, -> { order(:parent_id) }, class_name: 'Champ', inverse_of: false, dependent: :destroy has_many :champs_public, -> { root.public_only }, class_name: 'Champ', inverse_of: false has_many :champs_private, -> { root.private_only }, class_name: 'Champ', inverse_of: false - has_many :champs_public_all, -> { public_only }, class_name: 'Champ', inverse_of: false - has_many :champs_private_all, -> { private_only }, class_name: 'Champ', inverse_of: false has_many :prefilled_champs_public, -> { root.public_only.prefilled }, class_name: 'Champ', inverse_of: false has_many :commentaires, inverse_of: :dossier, dependent: :destroy @@ -145,8 +143,6 @@ class Dossier < ApplicationRecord accepts_nested_attributes_for :champs accepts_nested_attributes_for :champs_public accepts_nested_attributes_for :champs_private - accepts_nested_attributes_for :champs_public_all - accepts_nested_attributes_for :champs_private_all accepts_nested_attributes_for :individual include AASM diff --git a/app/models/dossier_preloader.rb b/app/models/dossier_preloader.rb index c1a3e614e..fd19a3ab1 100644 --- a/app/models/dossier_preloader.rb +++ b/app/models/dossier_preloader.rb @@ -93,8 +93,6 @@ class DossierPreloader champs_public, champs_private = champs.partition(&:public?) dossier.association(:champs).target = [] - dossier.association(:champs_public_all).target = [] - dossier.association(:champs_private_all).target = [] load_champs(dossier, :champs_public, champs_public, dossier, children_by_parent) load_champs(dossier, :champs_private, champs_private, dossier, children_by_parent) @@ -122,12 +120,6 @@ class DossierPreloader dossier.association(:champs).target += champs - if champs.first.public? - dossier.association(:champs_public_all).target += champs - else - dossier.association(:champs_private_all).target += champs - end - parent.association(name).target = champs .filter { positions[dossier.revision_id][_1.type_de_champ_id].present? } .sort_by { [_1.row_id, positions[dossier.revision_id][_1.type_de_champ_id]] } diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb index 765a98bfe..a027582b2 100644 --- a/config/initializers/flipper.rb +++ b/config/initializers/flipper.rb @@ -31,8 +31,7 @@ features = [ :groupe_instructeur_api_hack, :hide_instructeur_email, :sva, - :switch_domain, - :champ_update_by_stable_id + :switch_domain ] def database_exists? diff --git a/config/routes.rb b/config/routes.rb index 3905bb24f..dbe1d200a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -208,20 +208,6 @@ Rails.application.routes.draw do get ':dossier_id/:stable_id/piece_justificative', to: 'piece_justificative#show', as: :piece_justificative put ':dossier_id/:stable_id/piece_justificative', to: 'piece_justificative#update' get ':dossier_id/:stable_id/piece_justificative/template', to: 'piece_justificative#template', as: :piece_justificative_template - - # TODO: remove after migration is ower - get ':champ_id/siret', to: 'siret#show', as: :legacy_siret - get ':champ_id/rna', to: 'rna#show', as: :legacy_rna - delete ':champ_id/options', to: 'options#remove', as: :legacy_options - - get ':champ_id/carte/features', to: 'carte#index', as: :legacy_carte_features - post ':champ_id/carte/features', to: 'carte#create' - patch ':champ_id/carte/features/:id', to: 'carte#update' - delete ':champ_id/carte/features/:id', to: 'carte#destroy' - - get ':champ_id/piece_justificative', to: 'piece_justificative#show', as: :legacy_piece_justificative - put ':champ_id/piece_justificative', to: 'piece_justificative#update' - get ':champ_id/piece_justificative/template', to: 'piece_justificative#template' end resources :attachments, only: [:show, :destroy] diff --git a/spec/controllers/champs/carte_controller_spec.rb b/spec/controllers/champs/carte_controller_spec.rb index 0e0d86657..80d566593 100644 --- a/spec/controllers/champs/carte_controller_spec.rb +++ b/spec/controllers/champs/carte_controller_spec.rb @@ -1,30 +1,28 @@ describe Champs::CarteController, type: :controller do let(:user) { create(:user) } - let(:procedure) { create(:procedure, :published) } + let(:procedure) { create(:procedure, :published, types_de_champ_public: [{ type: :carte, options: { cadastres: true } }]) } let(:dossier) { create(:dossier, user: user, procedure: procedure) } let(:params) do { dossier: { champs_public_attributes: { - '1' => { value: value } + champ.public_id => { value: value } } }, position: '1', - champ_id: champ.id + dossier_id: champ.dossier_id, + stable_id: champ.stable_id } end - let(:champ) do - create(:type_de_champ_carte, options: { - cadastres: true - }).champ.create(dossier: dossier) - end + let(:champ) { dossier.champs.first } describe 'features' do let(:feature) { attributes_for(:geo_area, :polygon) } let(:geo_area) { create(:geo_area, :selection_utilisateur, :polygon, champ: champ) } let(:params) do { - champ_id: champ.id, + dossier_id: champ.dossier_id, + stable_id: champ.stable_id, feature: feature, source: GeoArea.sources.fetch(:selection_utilisateur) } @@ -49,7 +47,8 @@ describe Champs::CarteController, type: :controller do let(:feature) { attributes_for(:geo_area, :invalid_point) } let(:params) do { - champ_id: champ.id, + dossier_id: champ.dossier_id, + stable_id: champ.stable_id, feature: feature, source: GeoArea.sources.fetch(:selection_utilisateur) } @@ -62,7 +61,8 @@ describe Champs::CarteController, type: :controller do describe 'PATCH #update' do let(:params) do { - champ_id: champ.id, + dossier_id: champ.dossier_id, + stable_id: champ.stable_id, id: geo_area.id, feature: feature } @@ -101,7 +101,8 @@ describe Champs::CarteController, type: :controller do describe 'DELETE #destroy' do let(:params) do { - champ_id: champ.id, + dossier_id: champ.dossier_id, + stable_id: champ.stable_id, id: geo_area.id } end @@ -122,7 +123,10 @@ describe Champs::CarteController, type: :controller do context 'without focus' do let(:params) do - { champ_id: champ.id } + { + dossier_id: champ.dossier_id, + stable_id: champ.stable_id + } end it 'updates the list' do @@ -134,7 +138,8 @@ describe Champs::CarteController, type: :controller do context "update list and focus" do let(:params) do { - champ_id: champ.id, + dossier_id: champ.dossier_id, + stable_id: champ.stable_id, focus: true } end diff --git a/spec/controllers/champs/options_controller_spec.rb b/spec/controllers/champs/options_controller_spec.rb index 3b2e2466f..ca24b6805 100644 --- a/spec/controllers/champs/options_controller_spec.rb +++ b/spec/controllers/champs/options_controller_spec.rb @@ -18,13 +18,5 @@ describe Champs::OptionsController, type: :controller do expect { subject }.to change { champ.reload.selected_options.size }.from(2).to(1) end end - - context 'with champ_id' do - subject { delete :remove, params: { champ_id: champ.id, option: 'tata' }, format: :turbo_stream } - - it 'remove option' do - expect { subject }.to change { champ.reload.selected_options.size }.from(2).to(1) - end - end end end diff --git a/spec/controllers/champs/rna_controller_spec.rb b/spec/controllers/champs/rna_controller_spec.rb index a9275e843..449c34c68 100644 --- a/spec/controllers/champs/rna_controller_spec.rb +++ b/spec/controllers/champs/rna_controller_spec.rb @@ -121,7 +121,7 @@ describe Champs::RNAController, type: :controller do end context 'when user is not signed in' do - subject! { get :show, params: { champ_id: champ.id }, format: :turbo_stream } + subject! { get :show, params: { dossier_id: champ.dossier_id, stable_id: champ.stable_id }, format: :turbo_stream } it { expect(response.code).to redirect_to(new_user_session_path) } end diff --git a/spec/controllers/champs/siret_controller_spec.rb b/spec/controllers/champs/siret_controller_spec.rb index 4e5212e36..28b652ed1 100644 --- a/spec/controllers/champs/siret_controller_spec.rb +++ b/spec/controllers/champs/siret_controller_spec.rb @@ -151,7 +151,7 @@ describe Champs::SiretController, type: :controller do end context 'when user is not signed in' do - subject! { get :show, params: { champ_id: champ.id }, format: :turbo_stream } + subject! { get :show, params: { dossier_id: champ.dossier_id, stable_id: champ.stable_id }, format: :turbo_stream } it { expect(response).to redirect_to(new_user_session_path) } end diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index 8add60a04..3ee08fbf8 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -1006,24 +1006,19 @@ describe Instructeurs::DossiersController, type: :controller do dossier: { champs_private_attributes: { champ_multiple_drop_down_list.public_id => { - with_public_id: true, value: ['', 'val1', 'val2'] }, champ_datetime.public_id => { - with_public_id: true, value: '2019-12-21T13:17' }, champ_linked_drop_down_list.public_id => { - with_public_id: true, primary_value: 'primary', secondary_value: 'secondary' }, champ_repetition.champs.first.public_id => { - with_public_id: true, value: 'text' }, champ_drop_down_list.public_id => { - with_public_id: true, value: '__other__', value_other: 'other value' } @@ -1079,29 +1074,6 @@ describe Instructeurs::DossiersController, type: :controller do Timecop.return end - context "with new values for champs_private (legacy)" do - let(:params) do - { - procedure_id: procedure.id, - dossier_id: dossier.id, - dossier: { - champs_private_attributes: { - '0': { - id: champ_datetime.id, - value: '2024-03-30T07:03' - } - } - } - } - end - - it 'update champs_private' do - patch :update_annotations, params: params, format: :turbo_stream - champ_datetime.reload - expect(champ_datetime.value).to eq(Time.zone.parse('2024-03-30T07:03:00').iso8601) - end - end - context "without new values for champs_private" do let(:params) do { @@ -1111,7 +1083,6 @@ describe Instructeurs::DossiersController, type: :controller do champs_private_attributes: {}, champs_public_attributes: { champ_multiple_drop_down_list.public_id => { - with_public_id: true, value: ['', 'val1', 'val2'] } } @@ -1141,7 +1112,6 @@ describe Instructeurs::DossiersController, type: :controller do dossier: { champs_private_attributes: { champ_datetime.public_id => { - with_public_id: true, value: '2024-03-30T07:03' } } diff --git a/spec/controllers/users/dossiers_controller_spec.rb b/spec/controllers/users/dossiers_controller_spec.rb index fb1a28e53..8dd165c01 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -675,11 +675,9 @@ describe Users::DossiersController, type: :controller do groupe_instructeur_id: dossier.groupe_instructeur_id, champs_public_attributes: { first_champ.public_id => { - with_public_id: true, value: value }, piece_justificative_champ.public_id => { - with_public_id: true, piece_justificative_file: file } } @@ -713,22 +711,6 @@ describe Users::DossiersController, type: :controller do expect(dossier.reload.updated_at.year).to eq(2100) expect(dossier.reload.state).to eq(Dossier.states.fetch(:brouillon)) end - - context 'without new values for champs' do - let(:submit_payload) do - { - id: dossier.id, - dossier: { - champs_public_attributes: { first_champ.public_id => { with_public_id: true } } - } - } - end - - it "doesn't set last_champ_updated_at" do - subject - expect(dossier.reload.last_champ_updated_at).to eq(nil) - end - end end context 'when the user has an invitation but is not the owner' do @@ -747,7 +729,7 @@ describe Users::DossiersController, type: :controller do { id: dossier.id, dossier: { - champs_public_attributes: { first_champ.public_id => { with_public_id: true, value: value } } + champs_public_attributes: { first_champ.public_id => { value: value } } } } end @@ -801,11 +783,9 @@ describe Users::DossiersController, type: :controller do groupe_instructeur_id: dossier.groupe_instructeur_id, champs_public_attributes: { first_champ.public_id => { - with_public_id: true, value: value }, piece_justificative_champ.public_id => { - with_public_id: true, piece_justificative_file: file } } @@ -866,7 +846,6 @@ describe Users::DossiersController, type: :controller do dossier: { champs_public_attributes: { piece_justificative_champ.public_id => { - with_public_id: true, piece_justificative_file: file } } @@ -962,7 +941,6 @@ describe Users::DossiersController, type: :controller do dossier: { champs_public_attributes: { first_champ.public_id => { - with_public_id: true, value: value } } diff --git a/spec/models/concerns/dossier_champs_concern_spec.rb b/spec/models/concerns/dossier_champs_concern_spec.rb index f0d2a0357..a9e58ca77 100644 --- a/spec/models/concerns/dossier_champs_concern_spec.rb +++ b/spec/models/concerns/dossier_champs_concern_spec.rb @@ -181,9 +181,9 @@ RSpec.describe DossierChampsConcern do let(:attributes) do { - "99" => { value: "Hello", with_public_id: true }, - "991" => { value: "World", with_public_id: true }, - "994-#{row_id}" => { value: "Greer", with_public_id: true } + "99" => { value: "Hello" }, + "991" => { value: "World" }, + "994-#{row_id}" => { value: "Greer" } } end @@ -218,36 +218,12 @@ RSpec.describe DossierChampsConcern do expect(champ_994.value).to eq("Greer") } end - - context 'legacy attributes' do - let(:attributes) do - { - champ_99.id => { value: "Hello", id: champ_99.id }, - champ_991.id => { value: "World", id: champ_991.id }, - champ_994.id => { value: "Greer", id: champ_994.id } - } - end - - let(:champ_99_updated) { dossier.project_champ(dossier.find_type_de_champ_by_stable_id(99), nil) } - let(:champ_991_updated) { dossier.project_champ(dossier.find_type_de_champ_by_stable_id(991), nil) } - let(:champ_994_updated) { dossier.project_champ(dossier.find_type_de_champ_by_stable_id(994), row_id) } - - it { - subject - expect(dossier.champs_public_all.any?(&:changed_for_autosave?)).to be_truthy - dossier.save - dossier.reload - expect(champ_99_updated.value).to eq("Hello") - expect(champ_991_updated.value).to eq("World") - expect(champ_994_updated.value).to eq("Greer") - } - end end describe "#update_champs_attributes(private)" do let(:attributes) do { - "995" => { value: "Hello", with_public_id: true } + "995" => { value: "Hello" } } end @@ -272,23 +248,5 @@ RSpec.describe DossierChampsConcern do expect(annotation_995.value).to eq("Hello") } end - - context 'legacy attributes' do - let(:attributes) do - { - annotation_995.id => { value: "Hello", id: annotation_995.id } - } - end - - let(:annotation_995_updated) { dossier.project_champ(dossier.find_type_de_champ_by_stable_id(995), nil) } - - it { - subject - expect(dossier.champs_private_all.any?(&:changed_for_autosave?)).to be_truthy - dossier.save - dossier.reload - expect(annotation_995_updated.value).to eq("Hello") - } - end end end diff --git a/spec/models/concerns/dossier_rebase_concern_spec.rb b/spec/models/concerns/dossier_rebase_concern_spec.rb index 18ae80782..357329898 100644 --- a/spec/models/concerns/dossier_rebase_concern_spec.rb +++ b/spec/models/concerns/dossier_rebase_concern_spec.rb @@ -347,7 +347,7 @@ describe DossierRebaseConcern do it "updates the brouillon champs with the latest revision changes" do expect(dossier.revision).to eq(procedure.published_revision) expect(dossier.champs_public.size).to eq(5) - expect(dossier.champs_public_all.size).to eq(7) + expect(dossier.champs.count(&:public?)).to eq(7) expect(repetition_champ.rows.size).to eq(2) expect(repetition_champ.rows[0].size).to eq(1) expect(repetition_champ.rows[1].size).to eq(1) @@ -360,7 +360,7 @@ describe DossierRebaseConcern do expect(procedure.revisions.size).to eq(3) expect(dossier.revision).to eq(procedure.published_revision) expect(dossier.champs_public.size).to eq(6) - expect(dossier.champs_public_all.size).to eq(12) + expect(dossier.champs.count(&:public?)).to eq(12) expect(rebased_text_champ.value).to eq(text_champ.value) expect(rebased_text_champ.type_de_champ_id).not_to eq(text_champ.type_de_champ_id) expect(rebased_datetime_champ.type_champ).to eq(TypeDeChamp.type_champs.fetch(:date)) diff --git a/spec/models/dossier_preloader_spec.rb b/spec/models/dossier_preloader_spec.rb index b2b180451..8d6a8e575 100644 --- a/spec/models/dossier_preloader_spec.rb +++ b/spec/models/dossier_preloader_spec.rb @@ -29,13 +29,13 @@ describe DossierPreloader do expect(first_child.type).to eq('Champs::TextChamp') expect(repetition.id).not_to eq(first_child.id) expect(subject.champs.first.dossier).to eq(subject) - expect(subject.champs_public_all.first.dossier).to eq(subject) + expect(subject.champs.find(&:public?).dossier).to eq(subject) expect(subject.champs_public.first.dossier).to eq(subject) expect(subject.champs_public.first.type_de_champ.piece_justificative_template.attached?).to eq(false) expect(subject.champs.first.conditional?).to eq(false) - expect(subject.champs_public_all.first.conditional?).to eq(false) + expect(subject.champs.find(&:public?).conditional?).to eq(false) expect(subject.champs_public.first.conditional?).to eq(false) expect(first_child.parent).to eq(repetition) From a8398a71b106f7f6be6e7041941e02bb8cc90ae8 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Fri, 24 May 2024 17:28:33 +0200 Subject: [PATCH 0259/1532] feat(graphql): expose last_champ_updated_at and last_champ_private_updated_at on api --- app/graphql/api/v2/stored_query.rb | 2 ++ app/graphql/schema.graphql | 10 ++++++++++ app/graphql/types/dossier_type.rb | 11 +++++++++++ 3 files changed, 23 insertions(+) diff --git a/app/graphql/api/v2/stored_query.rb b/app/graphql/api/v2/stored_query.rb index e04896f81..e2b5c78c0 100644 --- a/app/graphql/api/v2/stored_query.rb +++ b/app/graphql/api/v2/stored_query.rb @@ -281,6 +281,8 @@ class API::V2::StoredQuery dateExpiration dateSuppressionParUsager dateDerniereCorrectionEnAttente @include(if: $includeCorrections) + dateDerniereModificationChamps + dateDerniereModificationAnnotations motivation motivationAttachment { ...FileFragment diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index ae37dadee..52d58d1ad 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -1418,6 +1418,16 @@ type Dossier { """ dateDerniereModification: ISO8601DateTime! + """ + Date de la dernière modification des annotations. + """ + dateDerniereModificationAnnotations: ISO8601DateTime! + + """ + Date de la dernière modification des champs. + """ + dateDerniereModificationChamps: ISO8601DateTime! + """ Date d’expiration. """ diff --git a/app/graphql/types/dossier_type.rb b/app/graphql/types/dossier_type.rb index 2aca07737..783b0e332 100644 --- a/app/graphql/types/dossier_type.rb +++ b/app/graphql/types/dossier_type.rb @@ -26,6 +26,9 @@ module Types field :date_traitement, GraphQL::Types::ISO8601DateTime, "Date du dernier traitement.", null: true, method: :processed_at field :date_derniere_modification, GraphQL::Types::ISO8601DateTime, "Date de la dernière modification.", null: false, method: :updated_at + field :date_derniere_modification_champs, GraphQL::Types::ISO8601DateTime, "Date de la dernière modification des champs.", null: false + field :date_derniere_modification_annotations, GraphQL::Types::ISO8601DateTime, "Date de la dernière modification des annotations.", null: false + field :date_suppression_par_usager, GraphQL::Types::ISO8601DateTime, "Date de la suppression par l’usager.", null: true, method: :hidden_by_user_at field :date_suppression_par_administration, GraphQL::Types::ISO8601DateTime, "Date de la suppression par l’administration.", null: true, method: :hidden_by_administration_at field :date_expiration, GraphQL::Types::ISO8601DateTime, "Date d’expiration.", null: true @@ -89,6 +92,14 @@ module Types Loaders::Association.for(object.class, :pending_correction).load(object).then { _1&.created_at } end + def date_derniere_modification_champs + object.last_champ_updated_at || object.created_at + end + + def date_derniere_modification_annotations + object.last_champ_private_updated_at || object.created_at + end + def connection_usager if object.user_deleted? :deleted From bc8e3c35dd9fc22ac815151d3618fba25c495f3c Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 24 May 2024 14:04:22 +0200 Subject: [PATCH 0260/1532] Feat(User): add email_verified_at column --- .../20240524120336_add_email_verified_at_column_to_users.rb | 5 +++++ db/schema.rb | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20240524120336_add_email_verified_at_column_to_users.rb diff --git a/db/migrate/20240524120336_add_email_verified_at_column_to_users.rb b/db/migrate/20240524120336_add_email_verified_at_column_to_users.rb new file mode 100644 index 000000000..176b9d2ab --- /dev/null +++ b/db/migrate/20240524120336_add_email_verified_at_column_to_users.rb @@ -0,0 +1,5 @@ +class AddEmailVerifiedAtColumnToUsers < ActiveRecord::Migration[7.0] + def change + add_column :users, :email_verified_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 22ee0febd..ff7db2e60 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[7.0].define(version: 2024_04_17_053843) do +ActiveRecord::Schema[7.0].define(version: 2024_05_24_120336) do # These are extensions that must be enabled in order to support this database enable_extension "pg_buffercache" enable_extension "pg_stat_statements" @@ -1137,6 +1137,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_04_17_053843) do t.datetime "current_sign_in_at", precision: nil t.string "current_sign_in_ip" t.string "email", default: "", null: false + t.datetime "email_verified_at" t.string "encrypted_password", default: "", null: false t.integer "failed_attempts", default: 0, null: false t.datetime "inactive_close_to_expiration_notice_sent_at" From 841c1cc8458dafdf56d22012e77489f7af4fe809 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 27 May 2024 09:59:48 +0200 Subject: [PATCH 0261/1532] Feat(user): verify user email during devise confirmation --- app/models/user.rb | 1 + spec/models/user_spec.rb | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/app/models/user.rb b/app/models/user.rb index 3a9aebfb2..f3e4f6b47 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -63,6 +63,7 @@ class User < ApplicationRecord # Callback provided by Devise def after_confirmation + update!(email_verified_at: Time.zone.now) link_invites! end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 336d78886..958346a47 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -16,6 +16,12 @@ describe User, type: :model do user.confirm expect(user.reload.invites.size).to eq(2) end + + it 'verifies its email' do + expect(user.email_verified_at).to be_nil + user.confirm + expect(user.email_verified_at).to be_present + end end describe '#owns?' do From fa06d17169464488ecb70baa54d623aa51b6e12e Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 27 May 2024 10:01:14 +0200 Subject: [PATCH 0262/1532] Feat(user): set email_verified_at when setting confirmed_at --- app/models/user.rb | 4 ++-- db/seeds.rb | 3 ++- spec/models/france_connect_information_spec.rb | 1 + spec/models/user_spec.rb | 14 ++++++++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index f3e4f6b47..d047ab1d1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -99,7 +99,7 @@ class User < ApplicationRecord def self.create_or_promote_to_instructeur(email, password, administrateurs: []) user = User - .create_with(password: password, confirmed_at: Time.zone.now) + .create_with(password: password, confirmed_at: Time.zone.now, email_verified_at: Time.zone.now) .find_or_create_by(email: email) if user.valid? @@ -138,7 +138,7 @@ class User < ApplicationRecord def self.create_or_promote_to_expert(email, password) user = User - .create_with(password: password, confirmed_at: Time.zone.now) + .create_with(password: password, confirmed_at: Time.zone.now, email_verified_at: Time.zone.now) .find_or_create_by(email: email) if user.valid? diff --git a/db/seeds.rb b/db/seeds.rb index 52f97d02f..1a021ad23 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -13,7 +13,8 @@ SuperAdmin.create!(email: default_user, password: default_password) user = User.create!( email: default_user, password: default_password, - confirmed_at: Time.zone.now + confirmed_at: Time.zone.now, + email_verified_at: Time.zone.now ) user.create_instructeur! user.create_administrateur! diff --git a/spec/models/france_connect_information_spec.rb b/spec/models/france_connect_information_spec.rb index 621c729b3..1dcb149cc 100644 --- a/spec/models/france_connect_information_spec.rb +++ b/spec/models/france_connect_information_spec.rb @@ -19,6 +19,7 @@ describe FranceConnectInformation, type: :model do it do subject expect(fci.user.email).to eq('a@email.com') + expect(fci.user.email_verified_at).to be_present end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 958346a47..409507d37 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -117,6 +117,7 @@ describe User, type: :model do user = subject expect(user.valid_password?(password)).to be true expect(user.confirmed_at).to be_present + expect(user.email_verified_at).to be_present expect(user.instructeur).to be_present end @@ -190,6 +191,7 @@ describe User, type: :model do user = subject expect(user.valid_password?(password)).to be true expect(user.confirmed_at).to be_present + expect(user.email_verified_at).to be_present expect(user.expert).to be_present end end @@ -220,6 +222,18 @@ describe User, type: :model do end end + describe '.create_or_promote_to_gestionnaire' do + let(:email) { 'inst1@gmail.com' } + let(:password) { 'un super password !' } + + subject { User.create_or_promote_to_gestionnaire(email, password) } + + it 'verifies its email' do + user = subject + expect(user.email_verified_at).to be_present + end + end + describe 'invite_administrateur!' do let(:super_admin) { create(:super_admin) } let(:administrateur) { create(:administrateur) } From dbf6459b4b124eb8bb77603faa288085a75976a2 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 27 May 2024 11:08:14 +0200 Subject: [PATCH 0263/1532] feat(User): add email_verified_at column to individual (in case of mandant) --- ...0527090508_add_email_verified_at_column_to_individuals.rb | 5 +++++ db/schema.rb | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20240527090508_add_email_verified_at_column_to_individuals.rb diff --git a/db/migrate/20240527090508_add_email_verified_at_column_to_individuals.rb b/db/migrate/20240527090508_add_email_verified_at_column_to_individuals.rb new file mode 100644 index 000000000..b77aea273 --- /dev/null +++ b/db/migrate/20240527090508_add_email_verified_at_column_to_individuals.rb @@ -0,0 +1,5 @@ +class AddEmailVerifiedAtColumnToIndividuals < ActiveRecord::Migration[7.0] + def change + add_column :individuals, :email_verified_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index ff7db2e60..74b41f6c7 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[7.0].define(version: 2024_05_24_120336) do +ActiveRecord::Schema[7.0].define(version: 2024_05_27_090508) do # These are extensions that must be enabled in order to support this database enable_extension "pg_buffercache" enable_extension "pg_stat_statements" @@ -752,6 +752,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_05_24_120336) do t.datetime "created_at", precision: nil t.integer "dossier_id" t.string "email" + t.datetime "email_verified_at" t.string "gender" t.string "nom" t.string "notification_method" From 1cf9535bea66b2e26573eeff8d0bd44acfbf9bf6 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 27 May 2024 11:22:52 +0200 Subject: [PATCH 0264/1532] feat(User): verify mandat email during creation --- app/controllers/users/dossiers_controller.rb | 2 ++ spec/controllers/users/dossiers_controller_spec.rb | 1 + 2 files changed, 3 insertions(+) diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index e6a64f2cd..592f16f2c 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -149,6 +149,8 @@ module Users @no_description = true if @dossier.update(dossier_params) && @dossier.individual.valid? + # TODO: remove this after proper mandat email validation + @dossier.individual.update!(email_verified_at: Time.zone.now) @dossier.update!(autorisation_donnees: true, identity_updated_at: Time.zone.now) flash.notice = t('.identity_saved') diff --git a/spec/controllers/users/dossiers_controller_spec.rb b/spec/controllers/users/dossiers_controller_spec.rb index 8dd165c01..8d3b702f9 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -211,6 +211,7 @@ describe Users::DossiersController, type: :controller do expect(individual.errors.full_messages).to be_empty expect(individual.notification_method).to eq('email') expect(individual.email).to eq('mickey@gmail.com') + expect(individual.email_verified_at).to be_present expect(response).to redirect_to(brouillon_dossier_path(dossier)) end From 555df3a6d82b3d6e3bc5f8a906fa89162bb5c8a1 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 27 May 2024 11:37:09 +0200 Subject: [PATCH 0265/1532] feat(User): add maintenance task to backfill email_verified_at --- ...prefill_individual_email_verified_at_task.rb | 17 +++++++++++++++++ .../prefill_user_email_verified_at_task.rb | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 app/tasks/maintenance/prefill_individual_email_verified_at_task.rb create mode 100644 app/tasks/maintenance/prefill_user_email_verified_at_task.rb diff --git a/app/tasks/maintenance/prefill_individual_email_verified_at_task.rb b/app/tasks/maintenance/prefill_individual_email_verified_at_task.rb new file mode 100644 index 000000000..fa6e045d2 --- /dev/null +++ b/app/tasks/maintenance/prefill_individual_email_verified_at_task.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# We are going to confirm the various email addresses of the users in the system. +# Individual model (mandant) needs their email_verified_at attribute to be set in order to receive emails. +# This task sets the email_verified_at attribute to the current time for all the individual to be backward compatible +# See https://github.com/demarches-simplifiees/demarches-simplifiees.fr/issues/10450 +module Maintenance + class PrefillIndividualEmailVerifiedAtTask < MaintenanceTasks::Task + def collection + Individual.in_batches + end + + def process(batch_of_individuals) + batch_of_individuals.update_all(email_verified_at: Time.zone.now) + end + end +end diff --git a/app/tasks/maintenance/prefill_user_email_verified_at_task.rb b/app/tasks/maintenance/prefill_user_email_verified_at_task.rb new file mode 100644 index 000000000..ad3ba6f5b --- /dev/null +++ b/app/tasks/maintenance/prefill_user_email_verified_at_task.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# We are going to confirm the various email addresses of the users in the system. +# User model needs their email_verified_at attribute to be set in order to receive emails. +# This task sets the email_verified_at attribute to the current time for all users to be backward compatible +# See https://github.com/demarches-simplifiees/demarches-simplifiees.fr/issues/10450 +module Maintenance + class PrefillUserEmailVerifiedAtTask < MaintenanceTasks::Task + def collection + User.in_batches + end + + def process(batch_of_users) + batch_of_users.update_all(email_verified_at: Time.zone.now) + end + end +end From 5846fb4417f33413b0176ccbb258173d7a89882b Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Mon, 27 May 2024 15:14:34 +0200 Subject: [PATCH 0266/1532] fix spec titles --- spec/services/pieces_justificatives_service_spec.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index cef564981..3f750829b 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -323,14 +323,14 @@ describe PiecesJustificativesService do context 'given an administrateur' do let(:user_profile) { build(:administrateur) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end end context 'given an instructeur' do let(:user_profile) { create(:instructeur) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end end @@ -346,7 +346,7 @@ describe PiecesJustificativesService do let(:experts_procedure) { create(:experts_procedure, expert: user_profile, procedure:) } let(:avis) { create(:avis, experts_procedure:, dossier: dossier, confidentiel: true) } let(:user_profile) { create(:expert) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end end @@ -370,21 +370,21 @@ describe PiecesJustificativesService do context 'given an administrateur' do let(:user_profile) { build(:administrateur) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end end context 'given an instructeur' do let(:user_profile) { create(:instructeur) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end end context 'given an expert' do let(:user_profile) { create(:expert) } - it "doesn't return confidentiel avis.piece_justificative_file" do + it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end end From 06cbb65d4e204542334741b3759d3038eaa72269 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 28 May 2024 10:06:30 +0200 Subject: [PATCH 0267/1532] spec: simplify export template factory --- spec/factories/export_template.rb | 42 ++++++++++++------- spec/models/export_template_spec.rb | 2 +- .../pieces_justificatives_service_spec.rb | 2 +- .../services/procedure_export_service_spec.rb | 2 +- .../procedure_export_service_zip_spec.rb | 2 +- 5 files changed, 31 insertions(+), 19 deletions(-) diff --git a/spec/factories/export_template.rb b/spec/factories/export_template.rb index 0f4e8d882..54ce224e5 100644 --- a/spec/factories/export_template.rb +++ b/spec/factories/export_template.rb @@ -11,24 +11,36 @@ FactoryBot.define do { "type" => "paragraph", "content" => [{ "text" => "export_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_id", "label" => "id dossier" } }, { "text" => " .pdf", "type" => "text" }] } ] }, - "default_dossier_directory" => - { - "type" => "doc", - "content" => - [ - { - "type" => "paragraph", - "content" => - [ - { "text" => "dossier_", "type" => "text" }, - { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, - { "text" => " ", "type" => "text" } - ] + "default_dossier_directory" => { + "type" => "doc", + "content" => + [ + { + "type" => "paragraph", + "content" => + [ + { "text" => "dossier_", "type" => "text" }, + { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, + { "text" => " ", "type" => "text" } + ] + } + ] } - ] - } } } kind { "zip" } + + to_create do |export_template, _context| + export_template.set_default_values + export_template.save + end + + trait :with_custom_content do + to_create do |export_template, context| + export_template.set_default_values + export_template.content = context.content + export_template.save + end + end end end diff --git a/spec/models/export_template_spec.rb b/spec/models/export_template_spec.rb index f0602597a..529de503b 100644 --- a/spec/models/export_template_spec.rb +++ b/spec/models/export_template_spec.rb @@ -1,6 +1,6 @@ describe ExportTemplate do let(:groupe_instructeur) { create(:groupe_instructeur, procedure:) } - let(:export_template) { create(:export_template, groupe_instructeur:, content:) } + let(:export_template) { create(:export_template, :with_custom_content, groupe_instructeur:, content:) } let(:procedure) { create(:procedure_with_dossiers, types_de_champ_public:, for_individual:) } let(:dossier) { procedure.dossiers.first } let(:for_individual) { false } diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index 3f750829b..bdfe28911 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -110,7 +110,7 @@ describe PiecesJustificativesService do it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) } context 'with export_template' do - let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur).tap(&:set_default_values) } + let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur) } it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) } end end diff --git a/spec/services/procedure_export_service_spec.rb b/spec/services/procedure_export_service_spec.rb index b463675be..0c03cfd98 100644 --- a/spec/services/procedure_export_service_spec.rb +++ b/spec/services/procedure_export_service_spec.rb @@ -529,7 +529,7 @@ describe ProcedureExportService do context 'with export_template' do let!(:dossier) { create(:dossier, :accepte, :with_populated_champs, :with_individual, procedure: procedure) } let(:dossier_exports) { PiecesJustificativesService.new(user_profile: instructeur, export_template:).generate_dossiers_export(Dossier.where(id: dossier)) } - let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur).tap(&:set_default_values) } + let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur) } before do allow_any_instance_of(ActiveStorage::Attachment).to receive(:url).and_return("https://opengraph.githubassets.com/d0e7862b24d8026a3c03516d865b28151eb3859029c6c6c2e86605891fbdcd7a/socketry/async-io") end diff --git a/spec/services/procedure_export_service_zip_spec.rb b/spec/services/procedure_export_service_zip_spec.rb index 0daced35e..14e5ceaad 100644 --- a/spec/services/procedure_export_service_zip_spec.rb +++ b/spec/services/procedure_export_service_zip_spec.rb @@ -2,7 +2,7 @@ describe ProcedureExportService do let(:instructeur) { create(:instructeur) } let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :piece_justificative, libelle: 'pj' }, { type: :repetition, children: [{ type: :piece_justificative, libelle: 'repet_pj' }] }]) } let(:dossiers) { create_list(:dossier, 10, procedure: procedure) } - let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur).tap(&:set_default_values) } + let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur) } let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) } def pj_champ(d) = d.champs_public.find_by(type: 'Champs::PieceJustificativeChamp') From ce6ebf35896fb0eac6a94c9215758f47d9413204 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 28 May 2024 11:06:31 +0200 Subject: [PATCH 0268/1532] fix dossier directory for commentaire when export with export template --- app/models/export_template.rb | 4 ++-- app/services/pieces_justificatives_service.rb | 7 +++++- spec/factories/export_template.rb | 22 +++++++++++++++++++ .../pieces_justificatives_service_spec.rb | 7 ++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/app/models/export_template.rb b/app/models/export_template.rb index f5ed164b9..3a9bf9c5c 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -48,7 +48,7 @@ class ExportTemplate < ApplicationRecord def attachment_and_path(dossier, attachment, index: 0, row_index: nil, champ: nil) [ attachment, - path(dossier, attachment, index, row_index, champ) + path(dossier, attachment, index:, row_index:, champ:) ] end @@ -116,7 +116,7 @@ class ExportTemplate < ApplicationRecord "#{render_attributes_for(content["pdf_name"], dossier)}.pdf" end - def path(dossier, attachment, index, row_index, champ) + def path(dossier, attachment, index: 0, row_index: nil, champ: nil) if attachment.name == 'pdf_export_for_instructeur' return export_path(dossier) end diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 0fdbc2be9..8f95adc44 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -169,7 +169,12 @@ class PiecesJustificativesService .filter { |a| safe_attachment(a) } .map do |a| dossier_id = commentaire_id_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + @export_template.attachment_and_path(dossier, a) + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end diff --git a/spec/factories/export_template.rb b/spec/factories/export_template.rb index 54ce224e5..d62754115 100644 --- a/spec/factories/export_template.rb +++ b/spec/factories/export_template.rb @@ -42,5 +42,27 @@ FactoryBot.define do export_template.save end end + + trait :with_custom_ddd_prefix do + transient do + ddd_prefix { 'dossier_' } + end + + to_create do |export_template, context| + export_template.set_default_values + export_template.content["default_dossier_directory"]["content"] = [ + { + "type" => "paragraph", + "content" => + [ + { "text" => context.ddd_prefix, "type" => "text" }, + { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, + { "text" => " ", "type" => "text" } + ] + } + ] + export_template.save + end + end end end diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index bdfe28911..9d6e69fd1 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -164,6 +164,13 @@ describe PiecesJustificativesService do end it { expect(subject).to match_array(dossier.commentaires.first.piece_jointe.attachments) } + + context 'with export_template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + it 'uses specific name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/messagerie")).to be true + end + end end context 'with a pj not safe on a commentaire' do From 08c079ca0b33497c656899921a7a400eb8e276ce Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 28 May 2024 11:10:54 +0200 Subject: [PATCH 0269/1532] fix dossier directory for avis when export with export template --- app/services/pieces_justificatives_service.rb | 7 ++++++- spec/services/pieces_justificatives_service_spec.rb | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 8f95adc44..64807d6b5 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -246,7 +246,12 @@ class PiecesJustificativesService .filter { |a| safe_attachment(a) } .map do |a| dossier_id = avis_ids_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + @export_template.attachment_and_path(dossier, a) + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index 9d6e69fd1..770d67e02 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -387,6 +387,13 @@ describe PiecesJustificativesService do it "return confidentiel avis.piece_justificative_file" do expect(subject.size).to eq(2) end + + context 'with export_template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + it 'uses specific name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/avis")).to be true + end + end end context 'given an expert' do From 4232cc98c75046e9e7171dab241541df74cac855 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 28 May 2024 11:28:13 +0200 Subject: [PATCH 0270/1532] fix dossier directory for motivation when export with export template --- app/services/pieces_justificatives_service.rb | 7 ++++++- spec/services/pieces_justificatives_service_spec.rb | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 64807d6b5..75210b024 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -206,7 +206,12 @@ class PiecesJustificativesService .filter { |a| safe_attachment(a) } .map do |a| dossier_id = a.record_id - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + @export_template.attachment_and_path(dossier, a) + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index 770d67e02..f0a6a094a 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -187,6 +187,13 @@ describe PiecesJustificativesService do let!(:witness) { create(:dossier, :with_justificatif) } it { expect(subject).to match_array(dossier.justificatif_motivation.attachment) } + + context 'with export_template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + it 'uses specific name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/dossier")).to be true + end + end end context 'with a motivation not safe' do From 2267ec98cf8e154a15b7c9a523908635aef5a60e Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 28 May 2024 11:35:49 +0200 Subject: [PATCH 0271/1532] fix dossier directory for attestation when export with export template --- app/models/export_template.rb | 2 ++ app/services/pieces_justificatives_service.rb | 7 ++++++- spec/services/pieces_justificatives_service_spec.rb | 10 ++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/models/export_template.rb b/app/models/export_template.rb index 3a9bf9c5c..6555af9c9 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -128,6 +128,8 @@ class ExportTemplate < ApplicationRecord 'messagerie' when 'Avis' 'avis' + when 'Attestation' + 'pieces_justificatives' else # for attachment return attachment_path(dossier, attachment, index, row_index, champ) diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 75210b024..871aa6f68 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -227,7 +227,12 @@ class PiecesJustificativesService .where(record_type: "Attestation", record_id: attestation_id_dossier_id.keys) .map do |a| dossier_id = attestation_id_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + @export_template.attachment_and_path(dossier, a) + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index f0a6a094a..c817e9047 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -209,6 +209,16 @@ describe PiecesJustificativesService do let!(:witness) { create(:dossier, :with_attestation) } it { expect(subject).to match_array(dossier.attestation.pdf.attachment) } + it 'uses default name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template: nil).liste_documents(dossiers).map(&:second)[0].starts_with?("dossier-#{dossier.id}/pieces_justificatives")).to be true + end + + context 'with export_template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + it 'uses specific name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/pieces_justificatives")).to be true + end + end end context 'with an etablissement' do From d9f7b6d1df894d755c76131234bafc270e282ffa Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 28 May 2024 11:40:25 +0200 Subject: [PATCH 0272/1532] fix dossier directory for etablissement when export with export template --- app/models/export_template.rb | 2 +- app/services/pieces_justificatives_service.rb | 7 ++++++- spec/services/pieces_justificatives_service_spec.rb | 11 +++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/models/export_template.rb b/app/models/export_template.rb index 6555af9c9..febadd05a 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -128,7 +128,7 @@ class ExportTemplate < ApplicationRecord 'messagerie' when 'Avis' 'avis' - when 'Attestation' + when 'Attestation', 'Etablissement' 'pieces_justificatives' else # for attachment diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 871aa6f68..92d53b088 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -195,7 +195,12 @@ class PiecesJustificativesService .where(record_type: "Etablissement", record_id: etablissement_id_dossier_id.keys) .map do |a| dossier_id = etablissement_id_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + if @export_template + dossier = dossiers.find { _1.id == dossier_id } + @export_template.attachment_and_path(dossier, a) + else + ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + end end end diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index c817e9047..0057b0b0f 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -236,6 +236,17 @@ describe PiecesJustificativesService do end it { expect(subject).to match_array([attestation_sociale.attachment, attestation_fiscale.attachment]) } + + it 'uses default name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template: nil).liste_documents(dossiers).map(&:second)[0].starts_with?("dossier-#{dossier.id}/pieces_justificatives")).to be true + end + + context 'with export_template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + it 'uses specific name for dossier directory' do + expect(PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:second)[0].starts_with?("DOSSIER-#{dossier.id}/pieces_justificatives")).to be true + end + end end end From bc4deb1fc2d047ff5e18547822c50626648aadf8 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 24 May 2024 14:55:34 +0200 Subject: [PATCH 0273/1532] add specs to export templates controller --- .../export_templates_controller_spec.rb | 62 ++++++++++++++----- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/spec/controllers/instructeurs/export_templates_controller_spec.rb b/spec/controllers/instructeurs/export_templates_controller_spec.rb index 8b8d73b82..36c9f665e 100644 --- a/spec/controllers/instructeurs/export_templates_controller_spec.rb +++ b/spec/controllers/instructeurs/export_templates_controller_spec.rb @@ -21,26 +21,45 @@ describe Instructeurs::ExportTemplatesController, type: :controller do { "type" => "paragraph", "content" => [{ "text" => "DOSSIER_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] } ] }.to_json, - "pjs" => - [ - { path: { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " _justif", "type" => "text" }] }] }, stable_id: "3" }, - { - path: - { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "text" => "cni_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] }] }, - stable_id: "5" - }, - { - path: { "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "text" => "pj_repet_", "type" => "text" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, { "text" => " ", "type" => "text" }] }] }, - stable_id: "10" - } - ] + tiptap_pj_3: { + "type" => "doc", + "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "avis-commission-" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] }] + }.to_json, + tiptap_pj_5: { + + "type" => "doc", + "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "avis-commission-" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] }] + }.to_json, + tiptap_pj_10: { + + "type" => "doc", + "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "avis-commission-" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] }] + }.to_json } end let(:instructeur) { create(:instructeur) } - let(:procedure) { create(:procedure, instructeurs: [instructeur]) } + let(:procedure) do + create( + :procedure, instructeurs: [instructeur], + types_de_champ_public: [ + { type: :piece_justificative, libelle: "pj1", stable_id: 3 }, + { type: :piece_justificative, libelle: "pj2", stable_id: 5 }, + { type: :piece_justificative, libelle: "pj3", stable_id: 10 } + ] + ) + end let(:groupe_instructeur) { procedure.defaut_groupe_instructeur } + describe '#new' do + let(:subject) { get :new, params: { procedure_id: procedure.id } } + + it do + subject + expect(assigns(:export_template)).to be_present + end + end + describe '#create' do let(:subject) { post :create, params: { procedure_id: procedure.id, export_template: export_template_params } } @@ -130,4 +149,19 @@ describe Instructeurs::ExportTemplatesController, type: :controller do end end end + + describe '#preview' do + render_views + + let(:export_template) { create(:export_template, groupe_instructeur:) } + + let(:subject) { get :preview, params: { procedure_id: procedure.id, id: export_template.id, export_template: export_template_params }, format: :turbo_stream } + + it '' do + dossier = create(:dossier, procedure: procedure, for_procedure_preview: true) + subject + expect(response.body).to include "DOSSIER_#{dossier.id}" + expect(response.body).to include "mon_export_#{dossier.id}.pdf" + end + end end From c0a95ab5256d9d05a2e1144c9ccc8d077fc51655 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 28 May 2024 18:24:45 +0200 Subject: [PATCH 0274/1532] add specs to pieces_justificatives_service --- .../pieces_justificatives_service_spec.rb | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index 0057b0b0f..341cb33ad 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -439,7 +439,8 @@ describe PiecesJustificativesService do let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :piece_justificative }] }]) } let(:dossier) { create(:dossier, :with_populated_champs, procedure: procedure) } let(:dossiers) { Dossier.where(id: dossier.id) } - subject { PiecesJustificativesService.new(user_profile:, export_template: nil).generate_dossiers_export(dossiers) } + let(:export_template) { nil } + subject { PiecesJustificativesService.new(user_profile:, export_template:).generate_dossiers_export(dossiers) } it "doesn't update dossier" do expect { subject }.not_to change { dossier.updated_at } @@ -451,11 +452,24 @@ describe PiecesJustificativesService do let!(:not_confidentiel_avis) { create(:avis, :not_confidentiel, dossier: dossier) } let!(:expert_avis) { create(:avis, :confidentiel, dossier: dossier, expert: user_profile) } - subject { PiecesJustificativesService.new(user_profile:, export_template: nil).generate_dossiers_export(dossiers) } + subject { PiecesJustificativesService.new(user_profile:, export_template:).generate_dossiers_export(dossiers) } it "includes avis not confidentiel as well as expert's avis" do expect_any_instance_of(Dossier).to receive(:avis_for_expert).with(user_profile).and_return([]) subject end + + it 'gives default name to export pdf file' do + expect(subject.first.second.starts_with?("dossier-#{dossier.id}/export-#{dossier.id}")).to eq true + end + end + + context 'with export template' do + let(:export_template) { create(:export_template, :with_custom_ddd_prefix, ddd_prefix: "DOSSIER-", groupe_instructeur: procedure.defaut_groupe_instructeur) } + subject { PiecesJustificativesService.new(user_profile:, export_template:).generate_dossiers_export(dossiers) } + + it 'gives custom name to export pdf file' do + expect(subject.first.second).to eq "DOSSIER-#{dossier.id}/export_#{dossier.id}.pdf" + end end end From d61203e57ceb03dfa47f51772c0cfc05f03b31d9 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 28 May 2024 19:14:57 +0200 Subject: [PATCH 0275/1532] remove dead code --- app/models/champ.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/models/champ.rb b/app/models/champ.rb index 74b5fd1e9..2cb0bb4ec 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -91,10 +91,6 @@ class Champ < ApplicationRecord parent_id.present? end - def stable_id_with_row - [row_id, stable_id].compact - end - # used for the `required` html attribute # check visibility to avoid hidden required input # which prevent the form from being sent. From 4d34d6d4c5fa0b738911dd7e43c8b0e0dbfcd0de Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 24 Apr 2024 10:27:46 +0200 Subject: [PATCH 0276/1532] chore(gallery): add lightgallery license key --- README.md | 2 ++ app/javascript/controllers/lightbox_controller.ts | 5 ++++- config/env.example.optional | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 834c11af7..69c3015cc 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ Pour faire tourner sidekiq, vous aurez besoin de : - redis +- lightgallery : une license a été souscrite pour soutenir le projet, mais elle n'est pas obligatoire si la librairie est utilisée dans le cadre d'une application open source. + #### Développement - rbenv : voir https://github.com/rbenv/rbenv-installer#rbenv-installer--doctor-scripts diff --git a/app/javascript/controllers/lightbox_controller.ts b/app/javascript/controllers/lightbox_controller.ts index 49e53ecd0..1c671fa16 100644 --- a/app/javascript/controllers/lightbox_controller.ts +++ b/app/javascript/controllers/lightbox_controller.ts @@ -19,7 +19,10 @@ export default class extends Controller { zoomFromOrigin: false, allowMediaOverlap: true, toggleThumb: true, - selector: '.gallery-link' + selector: '.gallery-link', + // license key is not mandatory for open source projects but we purchased + // an organization license to show our support (see https://www.lightgalleryjs.com/license/) + licenseKey: import.meta.env.VITE_LIGHTGALLERY_LICENSE_KEY }; const gallery = document.querySelector('.gallery'); diff --git a/config/env.example.optional b/config/env.example.optional index c79ce23bb..109b73fc7 100644 --- a/config/env.example.optional +++ b/config/env.example.optional @@ -272,3 +272,6 @@ CRON_JOBS_DISABLED="" # disable SIDEKIQ_RELIABLE_FETCH # SKIP_RELIABLE_FETCH="true" + +# optional license key for lightgallery +VITE_LIGHTGALLERY_LICENSE_KEY = "" From b06934b8d3d5b0000f385ab1ce2e2b6705416446 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 29 May 2024 11:03:33 +0200 Subject: [PATCH 0277/1532] fix(instructeur): supprimes_recemment should include archived dossiers --- app/models/dossier.rb | 2 +- spec/factories/dossier.rb | 5 +++++ spec/models/dossier_spec.rb | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 6fa255a64..dbfce2464 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -420,7 +420,7 @@ class Dossier < ApplicationRecord when 'tous' visible_by_administration.all_state when 'supprimes_recemment' - hidden_by_administration.termine + hidden_by_administration.state_termine when 'archives' visible_by_administration.archived when 'expirant' diff --git a/spec/factories/dossier.rb b/spec/factories/dossier.rb index bf387fe6c..1ce2ebbf0 100644 --- a/spec/factories/dossier.rb +++ b/spec/factories/dossier.rb @@ -114,6 +114,11 @@ FactoryBot.define do hidden_at { Time.zone.now } end + trait :hidden_by_administration do + hidden_by_administration_at { 1.day.ago } + hidden_by_reason { DeletedDossier.reasons.fetch(:instructeur_request) } + end + trait :with_dossier_link do after(:create) do |dossier, _evaluator| # create linked dossier diff --git a/spec/models/dossier_spec.rb b/spec/models/dossier_spec.rb index 68c7df9e5..f52242650 100644 --- a/spec/models/dossier_spec.rb +++ b/spec/models/dossier_spec.rb @@ -23,6 +23,43 @@ describe Dossier, type: :model do it { expect(Dossier.brouillons_recently_updated).to eq([dossier_en_brouillon_2, dossier_en_brouillon]) } end + + describe 'by_statut' do + let(:procedure) { create(:procedure) } + let(:dossier_en_construction) { create(:dossier, :en_construction, procedure:) } + let(:dossier_en_instruction) { create(:dossier, :en_instruction, procedure:) } + let(:dossier_accepte) { create(:dossier, :accepte, procedure:) } + let(:dossier_refuse) { create(:dossier, :refuse, procedure:) } + let(:dossier_accepte_archive) { create(:dossier, :accepte, :archived, procedure:) } + let(:dossier_accepte_deleted) { create(:dossier, :accepte, :hidden_by_administration, procedure:) } + let(:dossier_accepte_archive_deleted) { create(:dossier, :accepte, :archived, :hidden_by_administration, procedure:) } + + let!(:dossiers) { [dossier_en_construction, dossier_en_instruction, dossier_accepte, dossier_refuse] } + + context 'tous' do + it do + expect(procedure.dossiers.by_statut('tous')).to match_array(dossiers - [dossier_accepte_archive, dossier_accepte_archive_deleted]) + end + end + + context 'a-suivre' do + it do + expect(procedure.dossiers.by_statut('a-suivre')).to match_array([dossier_en_construction, dossier_en_instruction]) + end + end + + context 'supprimes_recemment' do + it do + expect(procedure.dossiers.by_statut('supprimes_recemment')).to match_array([dossier_accepte_deleted, dossier_accepte_archive_deleted]) + end + end + + context 'archives' do + it do + expect(procedure.dossiers.by_statut('archives')).to match_array([dossier_accepte_archive]) + end + end + end end describe 'validations' do From 5154231ccfcf842f1330198ffa7bbda8e61ddfe0 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 29 May 2024 11:31:58 +0200 Subject: [PATCH 0278/1532] fix(dossier): batch operations to termine dossier should send emails --- app/models/batch_operation.rb | 8 ++++---- spec/models/batch_operation_spec.rb | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/app/models/batch_operation.rb b/app/models/batch_operation.rb index 07e3c113d..f6abad9b1 100644 --- a/app/models/batch_operation.rb +++ b/app/models/batch_operation.rb @@ -82,13 +82,13 @@ class BatchOperation < ApplicationRecord when BatchOperation.operations.fetch(:desarchiver) dossier.desarchiver! when BatchOperation.operations.fetch(:passer_en_instruction) - dossier.passer_en_instruction(instructeur: instructeur) + dossier.passer_en_instruction!(instructeur: instructeur) when BatchOperation.operations.fetch(:accepter) - dossier.accepter(instructeur: instructeur, motivation: motivation, justificatif: justificatif_motivation) + dossier.accepter!(instructeur: instructeur, motivation: motivation, justificatif: justificatif_motivation) when BatchOperation.operations.fetch(:refuser) - dossier.refuser(instructeur: instructeur, motivation: motivation, justificatif: justificatif_motivation) + dossier.refuser!(instructeur: instructeur, motivation: motivation, justificatif: justificatif_motivation) when BatchOperation.operations.fetch(:classer_sans_suite) - dossier.classer_sans_suite(instructeur: instructeur, motivation: motivation, justificatif: justificatif_motivation) + dossier.classer_sans_suite!(instructeur: instructeur, motivation: motivation, justificatif: justificatif_motivation) when BatchOperation.operations.fetch(:follow) instructeur.follow(dossier) when BatchOperation.operations.fetch(:repousser_expiration) diff --git a/spec/models/batch_operation_spec.rb b/spec/models/batch_operation_spec.rb index 8120c122c..da24e7b38 100644 --- a/spec/models/batch_operation_spec.rb +++ b/spec/models/batch_operation_spec.rb @@ -233,6 +233,26 @@ describe BatchOperation, type: :model do end end + describe '#process_one' do + let(:dossier) { create(:dossier, :en_instruction, :with_individual) } + subject { create(:batch_operation, operation, instructeur: create(:instructeur)) } + + context 'accepter' do + let(:operation) { :accepter } + it { expect { subject.process_one(dossier) }.to have_enqueued_job.on_queue(Rails.application.config.action_mailer.deliver_later_queue_name) } + end + + context 'refuser' do + let(:operation) { :refuser } + it { expect { subject.process_one(dossier) }.to have_enqueued_job.on_queue(Rails.application.config.action_mailer.deliver_later_queue_name) } + end + + context 'classer_sans_suite' do + let(:operation) { :classer_sans_suite } + it { expect { subject.process_one(dossier) }.to have_enqueued_job.on_queue(Rails.application.config.action_mailer.deliver_later_queue_name) } + end + end + describe 'stale' do let(:finished_at) { 6.hours.ago } let(:staled_batch_operation) { create(:batch_operation, operation: :archiver, finished_at: 2.days.ago, updated_at: 2.days.ago) } From eb9aad27c34ce0b49052193fb10e3c27a804efbd Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 29 May 2024 13:07:03 +0200 Subject: [PATCH 0279/1532] fix(dossier): handle missing siret information when dossier passe en instruction --- app/models/concerns/dossier_state_concern.rb | 1 - app/services/serializer_service.rb | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/dossier_state_concern.rb b/app/models/concerns/dossier_state_concern.rb index e90902066..fae8b5f91 100644 --- a/app/models/concerns/dossier_state_concern.rb +++ b/app/models/concerns/dossier_state_concern.rb @@ -62,7 +62,6 @@ module DossierStateConcern MailTemplatePresenterService.create_commentaire_for_state(self, Dossier.states.fetch(:en_instruction)) if procedure.sva_svr_enabled? - # TODO: handle serialization errors when SIRET demandeur was not completed log_automatic_dossier_operation(:passer_en_instruction, self) else log_automatic_dossier_operation(:passer_en_instruction) diff --git a/app/services/serializer_service.rb b/app/services/serializer_service.rb index 6f97841c1..04ee431ea 100644 --- a/app/services/serializer_service.rb +++ b/app/services/serializer_service.rb @@ -164,6 +164,7 @@ class SerializerService demandeur { ...PersonnePhysiqueFragment ...PersonneMoraleFragment + ...PersonneMoraleIncompleteFragment } motivation motivationAttachment { @@ -309,6 +310,10 @@ class SerializerService } } + fragment PersonneMoraleIncompleteFragment on PersonneMoraleIncomplete { + siret + } + fragment AddressFragment on Address { label type From 47aa3b7d4a0bec5f81caa6080c5bc807e563467f Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Tue, 21 May 2024 09:56:50 +0000 Subject: [PATCH 0280/1532] Affichage des zones directement dans le tableau et suppression du nombre d'administrateurs --- .../procedures/_detail.html.haml | 28 ++++----- .../procedures/_informations.html.haml | 4 +- .../administrateurs/procedures/all.html.haml | 4 +- app/views/layouts/all.html.haml | 57 ++++++++++--------- 4 files changed, 48 insertions(+), 45 deletions(-) diff --git a/app/views/administrateurs/procedures/_detail.html.haml b/app/views/administrateurs/procedures/_detail.html.haml index b2e89c4b7..798bb15a6 100644 --- a/app/views/administrateurs/procedures/_detail.html.haml +++ b/app/views/administrateurs/procedures/_detail.html.haml @@ -5,15 +5,23 @@ - params = show_detail ? {} : { show_detail: true } = button_to detail_admin_procedure_path(procedure["id"]), method: :post, params:, title:, class: [icon, "fr-icon--sm fr-mr-1w fr-mb-1w fr-text-action-high--blue-france fr-btn fr-btn--tertiary-no-outline" ] do = title - %td - if procedure.template - %p.fr-badge.fr-badge--info.fr-badge--sm= "Modèle DS" - %br + %p.fr-badge.fr-badge--info.fr-badge--sm= "Modèle" + %abbr{ title: APPLICATION_NAME }= acronymize(APPLICATION_NAME) = procedure.libelle %td= procedure.id %td= procedure.estimated_dossiers_count - %td= procedure.administrateurs.count + %td + - if procedure.respond_to?(:parsed_latest_zone_labels) + - procedure.parsed_latest_zone_labels.uniq.each do |zone_label| + %span.mb-2= zone_label + .mb-2 + - else + - procedure.zones.uniq.each do |zone| + %span= zone.current_label + .mb-1 + %td= t procedure.aasm_state, scope: 'activerecord.attributes.procedure.aasm_state' %td= l(procedure.published_at, format: :message_date_without_time) if procedure.published_at %td @@ -21,16 +29,10 @@ = link_to('Cloner', admin_procedure_clone_path(procedure.id, from_new_from_existing: true), 'data-method' => :put, class: 'fr-btn fr-btn--tertiary fr-btn--sm') - - - if show_detail %tr.procedure{ id: "procedure_detail_#{procedure.id}" } %td.fr-highlight--beige-gris-galet{ colspan: '8' } .fr-container - .fr-grid-row - .fr-col-6 - - procedure.zones.uniq.each do |zone| - = zone.label_at(procedure.published_or_created_at) - .fr-col-6 - - procedure.administrateurs.uniq.each do |admin| - = admin.email + .fr-col-6 + - procedure.administrateurs.uniq.each do |admin| + = admin.email diff --git a/app/views/administrateurs/procedures/_informations.html.haml b/app/views/administrateurs/procedures/_informations.html.haml index fbc56387d..a3d361a52 100644 --- a/app/views/administrateurs/procedures/_informations.html.haml +++ b/app/views/administrateurs/procedures/_informations.html.haml @@ -120,8 +120,8 @@ %use.fr-artwork-major{ href: image_path("pictograms/buildings/school.svg#artwork-major") } .fr-fieldset__element - = f.label :tags, 'Associez les tags à la démarche', class: 'fr-label' - %p.fr-hint-text Les tags sont des mots ou des expressions que vous attribuez aux démarches pour décrire leur contenu et pour les retrouver. Les tags sont partagés avec la communauté, ce qui vous permet de voir les tags attribués aux démarches créées par les autres administrateurs. + = f.label :tags, 'Associez des thématiques à la démarche', class: 'fr-label' + %p.fr-hint-text Par des mots ou des expressions que vous attribuez aux démarches pour décrire leur contenu et pour les retrouver. Les tags sont partagés avec la communauté, ce qui vous permet de voir les tags attribués aux démarches créées par les autres administrateurs. = hidden_field_tag 'procedure[tags]', JSON.generate(@procedure.tags) = react_component("ComboMultiple", id: "procedure_tags_combo", diff --git a/app/views/administrateurs/procedures/all.html.haml b/app/views/administrateurs/procedures/all.html.haml index 43069b757..224e59fc6 100644 --- a/app/views/administrateurs/procedures/all.html.haml +++ b/app/views/administrateurs/procedures/all.html.haml @@ -57,8 +57,8 @@ %th{ scope: 'col' } %th{ scope: 'col' } Démarche %th{ scope: 'col' } № - %th{ scope: 'col' } Dossiers - %th{ scope: 'col' } Administrateurs + %th{ scope: 'col' } Nombre de dossiers + %th{ scope: 'col' } Zones %th{ scope: 'col' } Statut %th{ scope: 'col' } Date %th{ scope: 'col' } Action diff --git a/app/views/layouts/all.html.haml b/app/views/layouts/all.html.haml index 2b785e13f..44d69dacf 100644 --- a/app/views/layouts/all.html.haml +++ b/app/views/layouts/all.html.haml @@ -1,6 +1,5 @@ - content_for(:main_navigation) do = render 'administrateurs/main_navigation' - - content_for :content do .fr-container %h1.fr-my-4w Toutes les démarches @@ -25,16 +24,16 @@ = link_to all_admin_procedures_path(zone_ids: current_administrateur.zones), { data: { turbo: 'false' } } do %span.fr-icon-arrow-go-back-line Réinitialiser %ul + %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } .fr-mb-1w %button{ 'data-action': 'expand#toggle' } %span.fr-icon-add-line.fr-icon--sm.fr-mr-1w.fr-text-action-high--blue-france{ 'aria-hidden': 'true', 'data-expand-target': 'icon' } - Mes zones - .fr-ml-1w{ 'data-expand-target': 'content' } - = f.collection_check_boxes :zone_ids, @filter.admin_zones, :id, :current_label, include_hidden: false do |b| - .fr-checkbox-group.fr-ml-2w.fr-py-1w - = b.check_box(checked: @filter.zone_filtered?(b.value)) - = b.label(class: 'fr-label') { b.text } + Démarches modèles + .fr-ml-1w.hidden{ 'data-expand-target': 'content' } + .fr-checkbox-group.fr-ml-2w.fr-py-1w + = f.check_box :template, class: 'fr-input' + = f.label :template, 'Modèle DS', class: 'fr-label' %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } .fr-mb-1w %button{ 'data-action': 'expand#toggle' } @@ -45,6 +44,16 @@ .fr-checkbox-group.fr-ml-2w.fr-py-1w = b.check_box(checked: @filter.zone_filtered?(b.value)) = b.label(class: 'fr-label') { b.text } + %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } + .fr-mb-1w + %button{ 'data-action': 'expand#toggle' } + %span.fr-icon-add-line.fr-icon--sm.fr-mr-1w.fr-text-action-high--blue-france{ 'aria-hidden': 'true', 'data-expand-target': 'icon' } + Mes zones + .fr-ml-1w.hidden{ 'data-expand-target': 'content' } + = f.collection_check_boxes :zone_ids, @filter.admin_zones, :id, :current_label, include_hidden: false do |b| + .fr-checkbox-group.fr-ml-2w.fr-py-1w + = b.check_box(checked: @filter.zone_filtered?(b.value)) + = b.label(class: 'fr-label') { b.text } %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } .fr-mb-1w %button{ 'data-action': 'expand#toggle' } @@ -65,6 +74,16 @@ { selected: @filter.service_departement, include_blank: ''}, id: "service_dep_select", class: 'fr-select' + %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } + .fr-mb-1w + %button{ 'data-action': 'expand#toggle' } + %span.fr-icon-add-line.fr-icon--sm.fr-mr-1w.fr-text-action-high--blue-france{ 'aria-hidden': 'true', 'data-expand-target': 'icon' } + Type d'usager + .fr-ml-1w.hidden{ 'data-expand-target': 'content' } + = f.collection_check_boxes :kind_usagers, ['individual', 'personne_morale'], :to_s, :to_s, include_hidden: false do |b| + .fr-checkbox-group.fr-ml-2w.fr-py-1w + = b.check_box(checked: @filter.kind_usager_filtered?(b.value)) + = b.label(class: 'fr-label') { t b.text, scope: 'activerecord.attributes.procedure.kind_usager' } %li.fr-py-2w{ 'data-controller': "expand" } .fr-mb-1w.fr-pl-2w %button{ 'data-action': 'click->expand#toggle' } @@ -86,39 +105,21 @@ = b.check_box(checked: @filter.status_filtered?(b.value)) = b.label(class: 'fr-label') { t b.text, scope: 'activerecord.attributes.procedure.aasm_state' } + %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } .fr-mb-1w %button{ 'data-action': 'expand#toggle' } %span.fr-icon-add-line.fr-icon--sm.fr-mr-1w.fr-text-action-high--blue-france{ 'aria-hidden': 'true', 'data-expand-target': 'icon' } - Type d'usager - .fr-ml-1w.hidden{ 'data-expand-target': 'content' } - = f.collection_check_boxes :kind_usagers, ['individual', 'personne_morale'], :to_s, :to_s, include_hidden: false do |b| - .fr-checkbox-group.fr-ml-2w.fr-py-1w - = b.check_box(checked: @filter.kind_usager_filtered?(b.value)) - = b.label(class: 'fr-label') { t b.text, scope: 'activerecord.attributes.procedure.kind_usager' } - %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } - .fr-mb-1w - %button{ 'data-action': 'expand#toggle' } - %span.fr-icon-add-line.fr-icon--sm.fr-mr-1w.fr-text-action-high--blue-france{ 'aria-hidden': 'true', 'data-expand-target': 'icon' } - Tags + Thématique .fr-ml-1w.hidden{ 'data-expand-target': 'content' } %div - = f.search_field :tags, placeholder: 'Choisissez un tag', list: 'tags_list', class: 'fr-input', data: { no_autosubmit: 'input', turbo_force: :server }, multiple: true + = f.search_field :tags, placeholder: 'Choisissez un thème', list: 'tags_list', class: 'fr-input', data: { no_autosubmit: 'input', turbo_force: :server }, multiple: true %datalist#tags_list - Procedure.tags.each do |tag| %option{ value: tag } - if @filter.tags.present? - @filter.tags.each do |tag| = f.hidden_field :tags, value: tag, multiple: true, id: "tag-#{tag.tr(' ', '_')}" - %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } - .fr-mb-1w - %button{ 'data-action': 'expand#toggle' } - %span.fr-icon-add-line.fr-icon--sm.fr-mr-1w.fr-text-action-high--blue-france{ 'aria-hidden': 'true', 'data-expand-target': 'icon' } - Démarches modèles - .fr-ml-1w.hidden{ 'data-expand-target': 'content' } - .fr-checkbox-group.fr-ml-2w.fr-py-1w - = f.check_box :template, class: 'fr-input' - = f.label :template, 'Modèle DS', class: 'fr-label' .fr-col-9 = yield(:results) From 7281f3ec1af1ba526a02b7ff57aa09c455acc460 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Tue, 21 May 2024 09:57:43 +0000 Subject: [PATCH 0281/1532] SQL pour renvoyer les zones dans ProcedureDetail --- app/controllers/administrateurs/procedures_controller.rb | 3 ++- app/models/procedure_detail.rb | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/controllers/administrateurs/procedures_controller.rb b/app/controllers/administrateurs/procedures_controller.rb index 9b1b2b1b9..4dde5c6c2 100644 --- a/app/controllers/administrateurs/procedures_controller.rb +++ b/app/controllers/administrateurs/procedures_controller.rb @@ -482,7 +482,8 @@ module Administrateurs procedures_result = procedures_result.where('unaccent(libelle) ILIKE unaccent(?)', "%#{filter.libelle}%") if filter.libelle.present? procedures_sql = procedures_result.to_sql - sql = "select id, libelle, published_at, aasm_state, estimated_dossiers_count, template, count(administrateurs_procedures.administrateur_id) as admin_count from administrateurs_procedures inner join procedures on procedures.id = administrateurs_procedures.procedure_id where procedures.id in (#{procedures_sql}) group by procedures.id order by published_at desc" + sql = "select procedures.id, libelle, published_at, aasm_state, estimated_dossiers_count, template, array_agg(distinct latest_labels.name) filter (where latest_labels.name is not null) as latest_zone_labels from administrateurs_procedures inner join procedures on procedures.id = administrateurs_procedures.procedure_id left join procedures_zones ON procedures.id = procedures_zones.procedure_id left join zones ON zones.id = procedures_zones.zone_id left join (select zone_id, name from zone_labels where (zone_id, designated_on) in (select zone_id, max(designated_on) from zone_labels group by zone_id)) as latest_labels on zones.id = latest_labels.zone_id + where procedures.id in (#{procedures_sql}) group by procedures.id order by published_at desc" ActiveRecord::Base.connection.execute(sql) end diff --git a/app/models/procedure_detail.rb b/app/models/procedure_detail.rb index c590787b3..ebe6c3f6d 100644 --- a/app/models/procedure_detail.rb +++ b/app/models/procedure_detail.rb @@ -1,4 +1,4 @@ -ProcedureDetail = Struct.new(:id, :libelle, :published_at, :aasm_state, :estimated_dossiers_count, :admin_count, :template, keyword_init: true) do +ProcedureDetail = Struct.new(:id, :libelle, :published_at, :aasm_state, :estimated_dossiers_count, :admin_count, :template, :latest_zone_labels, keyword_init: true) do include SpreadsheetArchitect def spreadsheet_columns @@ -12,4 +12,11 @@ ProcedureDetail = Struct.new(:id, :libelle, :published_at, :aasm_state, :estimat def administrateurs AdministrateursCounter.new(admin_count) end + + def parsed_latest_zone_labels + # Replace curly braces with square brackets to make it a valid JSON array + JSON.parse(latest_zone_labels.tr('{', '[').tr('}', ']')) + rescue JSON::ParserError + [] + end end From db0b32a99a9792bbcf764a3c7f614450bb8f2f20 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Wed, 29 May 2024 09:46:18 +0000 Subject: [PATCH 0282/1532] =?UTF-8?q?Test=20la=20m=C3=A9thode=20parsed=5Fl?= =?UTF-8?q?atest=5Fzone=5Flabels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../procedures_controller_spec.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/spec/controllers/administrateurs/procedures_controller_spec.rb b/spec/controllers/administrateurs/procedures_controller_spec.rb index 72d2484e0..1193b769f 100644 --- a/spec/controllers/administrateurs/procedures_controller_spec.rb +++ b/spec/controllers/administrateurs/procedures_controller_spec.rb @@ -91,6 +91,10 @@ describe Administrateurs::ProceduresController, type: :controller do let!(:draft_procedure) { create(:procedure) } let!(:published_procedure) { create(:procedure_with_dossiers, :published, dossiers_count: 2) } let!(:closed_procedure) { create(:procedure, :closed) } + let!(:procedure_detail_draft) { ProcedureDetail.new(id: draft_procedure.id, latest_zone_labels: '{ "zone1", "zone2" }') } + let!(:procedure_detail_published) { ProcedureDetail.new(id: published_procedure.id, latest_zone_labels: '{ "zone3", "zone4" }') } + let!(:procedure_detail_closed) { ProcedureDetail.new(id: closed_procedure.id, latest_zone_labels: '{ "zone5", "zone6" }') } + subject { get :all } it { expect(subject.status).to eq(200) } @@ -116,6 +120,19 @@ describe Administrateurs::ProceduresController, type: :controller do expect(assigns(:procedures).any? { |p| p.id == draft_procedure.id }).to be_falsey end + context 'with parsed latest zone labels' do + it 'parses the latest zone labels correctly' do + expect(procedure_detail_draft.parsed_latest_zone_labels).to eq(["zone1", "zone2"]) + expect(procedure_detail_published.parsed_latest_zone_labels).to eq(["zone3", "zone4"]) + expect(procedure_detail_closed.parsed_latest_zone_labels).to eq(["zone5", "zone6"]) + end + + it 'returns an empty array for invalid JSON' do + procedure_detail_draft.latest_zone_labels = '{ invalid json }' + expect(procedure_detail_draft.parsed_latest_zone_labels).to eq([]) + end + end + context 'for default admin zones' do let(:zone1) { create(:zone) } let(:zone2) { create(:zone) } From acf6579aa4b45a07ca63f38f4de01d811c351a04 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Wed, 29 May 2024 11:08:46 +0200 Subject: [PATCH 0283/1532] add missing specs to export template --- spec/models/export_template_spec.rb | 42 +++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/spec/models/export_template_spec.rb b/spec/models/export_template_spec.rb index 529de503b..443959bc1 100644 --- a/spec/models/export_template_spec.rb +++ b/spec/models/export_template_spec.rb @@ -69,6 +69,22 @@ describe ExportTemplate do end end + describe '#assign_pj_names' do + let(:pj_params) do + { + "tiptap_pj_1" => { + "type" => "doc", "content" => [{ "type" => "paragraph", "content" => [{ "type" => "text", "text" => "avis-commission-" }, { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }] }] + }.to_json + } + end + it 'values content from pj params' do + export_template.assign_pj_names(pj_params) + expect(export_template.content["pjs"]).to eq [ + { :path => { "content" => [{ "content" => [{ "text" => "avis-commission-", "type" => "text" }, { "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" }, "type" => "mention" }], "type" => "paragraph" }], "type" => "doc" }, :stable_id => "1" } + ] + end + end + describe '#tiptap_default_dossier_directory' do it 'returns tiptap_default_dossier_directory from content' do expect(export_template.tiptap_default_dossier_directory).to eq({ @@ -297,21 +313,37 @@ describe ExportTemplate do end end - describe 'specific_tags' do - context 'for entreprise procedure' do - let(:for_individual) { false } + context 'for entreprise procedure' do + let(:for_individual) { false } + describe 'specific_tags' do it do tags = export_template.specific_tags expect(tags.map { _1[:id] }).to eq ["entreprise_siren", "entreprise_numero_tva_intracommunautaire", "entreprise_siret_siege_social", "entreprise_raison_sociale", "entreprise_adresse", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur"] end end - context 'for individual procedure' do - let(:for_individual) { true } + describe 'tags_for_pj' do + it do + tags = export_template.tags_for_pj + expect(tags.map { _1[:id] }).to eq ["entreprise_siren", "entreprise_numero_tva_intracommunautaire", "entreprise_siret_siege_social", "entreprise_raison_sociale", "entreprise_adresse", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur", "original-filename"] + end + end + end + + context 'for individual procedure' do + let(:for_individual) { true } + describe 'specific_tags' do it do tags = export_template.specific_tags expect(tags.map { _1[:id] }).to eq ["individual_gender", "individual_last_name", "individual_first_name", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur"] end end + + describe 'tags_for_pj' do + it do + tags = export_template.tags_for_pj + expect(tags.map { _1[:id] }).to eq ["individual_gender", "individual_last_name", "individual_first_name", "dossier_depose_at", "dossier_procedure_libelle", "dossier_service_name", "dossier_number", "dossier_groupe_instructeur", "original-filename"] + end + end end end From 0ed166f51008d2b03989238fb406b5a1a3eccedb Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Wed, 29 May 2024 14:30:20 +0200 Subject: [PATCH 0284/1532] export_template feature flag scoped by procedure --- .../export_dropdown_component.html.haml | 16 ++++++++-------- .../instructeurs/procedures/exports.html.haml | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml index fbb499483..fd29214f2 100644 --- a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml +++ b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml @@ -15,12 +15,12 @@ = link_to download_export_path(export_format: format), role: 'menuitem', data: { turbo_method: :post, turbo: true } do = t(".everything_#{format}_html") - - if export_templates.present? - - export_templates.each do |export_template| + - if @procedure.feature_enabled?(:export_template) + - if export_templates.present? + - export_templates.each do |export_template| + - menu.with_item do + = link_to download_export_path(export_template_id: export_template.id), role: 'menuitem', data: { turbo_method: :post, turbo: true } do + = "Exporter à partir du modèle #{export_template.name}" - menu.with_item do - = link_to download_export_path(export_template_id: export_template.id), role: 'menuitem', data: { turbo_method: :post, turbo: true } do - = "Exporter à partir du modèle #{export_template.name}" - - if feature_enabled?(:export_template) - - menu.with_item do - = link_to new_instructeur_export_template_path(procedure_id: params[:procedure_id]), role: 'menuitem' do - Ajouter un modèle d'export + = link_to new_instructeur_export_template_path(procedure_id: params[:procedure_id]), role: 'menuitem' do + Ajouter un modèle d'export diff --git a/app/views/instructeurs/procedures/exports.html.haml b/app/views/instructeurs/procedures/exports.html.haml index 0986a977a..793a7d960 100644 --- a/app/views/instructeurs/procedures/exports.html.haml +++ b/app/views/instructeurs/procedures/exports.html.haml @@ -23,7 +23,7 @@ - else = t('.no_export_html', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i ) - - if feature_enabled?(:export_template) + - if @procedure.feature_enabled?(:export_template) %h2.fr-mb-1w.fr-mt-8w Liste des modèles d'export %p.fr-hint-text From fc90648c79e618f914f2f83989ddf411c1db12cf Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Wed, 29 May 2024 15:42:49 +0200 Subject: [PATCH 0285/1532] fix: regenerate export from export template --- app/components/dossiers/export_link_component.rb | 3 ++- .../export_link_component/export_link_component.html.haml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/components/dossiers/export_link_component.rb b/app/components/dossiers/export_link_component.rb index 0fe2967aa..647d08a4b 100644 --- a/app/components/dossiers/export_link_component.rb +++ b/app/components/dossiers/export_link_component.rb @@ -11,9 +11,10 @@ class Dossiers::ExportLinkComponent < ApplicationComponent @export_url = export_url end - def download_export_path(export_format:, statut:, no_progress_notification: nil) + def download_export_path(export_format:, statut:, export_template_id: nil, no_progress_notification: nil) @export_url.call(@procedure, export_format: export_format, + export_template_id:, statut: statut, no_progress_notification: no_progress_notification) end diff --git a/app/components/dossiers/export_link_component/export_link_component.html.haml b/app/components/dossiers/export_link_component/export_link_component.html.haml index 0ed8d34a2..6f217d5ae 100644 --- a/app/components/dossiers/export_link_component/export_link_component.html.haml +++ b/app/components/dossiers/export_link_component/export_link_component.html.haml @@ -14,4 +14,4 @@ = export_button(export) - if export.failed? - = button_to refresh_button_options(export)[:title], download_export_path(export_format: export.format, statut: export.statut), refresh_button_options(export) + = button_to refresh_button_options(export)[:title], download_export_path(export_template_id: export.export_template&.id, export_format: export.format, statut: export.statut), refresh_button_options(export) From 704cd60e0437aeaa92b64fa5ca6fb79ce57014cb Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 29 May 2024 15:56:49 +0200 Subject: [PATCH 0286/1532] chore(task): run commune code fix on all champs of a procedure --- .../maintenance/backfill_commune_code_from_name_task.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/tasks/maintenance/backfill_commune_code_from_name_task.rb b/app/tasks/maintenance/backfill_commune_code_from_name_task.rb index 8c50b075e..922c3a377 100644 --- a/app/tasks/maintenance/backfill_commune_code_from_name_task.rb +++ b/app/tasks/maintenance/backfill_commune_code_from_name_task.rb @@ -2,11 +2,12 @@ module Maintenance class BackfillCommuneCodeFromNameTask < MaintenanceTasks::Task - attribute :champ_ids, :string - validates :champ_ids, presence: true + attribute :procedure_id, :string + validates :procedure_id, presence: true def collection - Champ.where(id: champ_ids.split(',').map(&:strip).map(&:to_i)) + procedure = Procedure.find(procedure_id.strip.to_i) + Champs::CommuneChamp.where(dossier_id: procedure.dossiers.not_brouillon) end def process(champ) From 9f504dbefd7cdc5adfd349b4075ff6b5f67261d8 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Wed, 29 May 2024 16:09:13 +0200 Subject: [PATCH 0287/1532] precise export template source for zip exports --- .../export_link_component/export_link_component.html.haml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/components/dossiers/export_link_component/export_link_component.html.haml b/app/components/dossiers/export_link_component/export_link_component.html.haml index 6f217d5ae..32d631e33 100644 --- a/app/components/dossiers/export_link_component/export_link_component.html.haml +++ b/app/components/dossiers/export_link_component/export_link_component.html.haml @@ -7,6 +7,9 @@ = export_title(export) %span.fr-text-mention--grey.fr-mb-1w = time_info(export) + - if export.export_template + %span.fr-tag.fr-tag--sm.fr-ml-1w + = export.export_template.name .fr-ml-auto = badge(export) From 823ee11d846cd11ae493bac68fa7f1b4b36cd737 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Wed, 29 May 2024 17:03:09 +0200 Subject: [PATCH 0288/1532] fix(asset cards): background none for welcome card admin in darkmode --- app/assets/stylesheets/card.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/assets/stylesheets/card.scss b/app/assets/stylesheets/card.scss index 82b6f7cf1..f2143d2ec 100644 --- a/app/assets/stylesheets/card.scss +++ b/app/assets/stylesheets/card.scss @@ -5,6 +5,9 @@ [data-fr-theme="dark"] .card { background: none; border: 1px solid var(--background-action-low-blue-france); + &.feedback { + background: none; + } } .card { From bd94c575d71df8f9f4201f095a61aa12ae03205b Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Wed, 29 May 2024 17:35:40 +0200 Subject: [PATCH 0289/1532] linter check --- app/assets/stylesheets/card.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/card.scss b/app/assets/stylesheets/card.scss index f2143d2ec..e3646961f 100644 --- a/app/assets/stylesheets/card.scss +++ b/app/assets/stylesheets/card.scss @@ -5,6 +5,7 @@ [data-fr-theme="dark"] .card { background: none; border: 1px solid var(--background-action-low-blue-france); + &.feedback { background: none; } From bcf3c0ff3414583362391ef952bc37f30df01c7d Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 30 May 2024 10:24:52 +0200 Subject: [PATCH 0290/1532] fix(jobs): fix default bulk_email_queue name --- app/jobs/priorized_mail_delivery_job.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/jobs/priorized_mail_delivery_job.rb b/app/jobs/priorized_mail_delivery_job.rb index 8ebe5549c..4b598bf43 100644 --- a/app/jobs/priorized_mail_delivery_job.rb +++ b/app/jobs/priorized_mail_delivery_job.rb @@ -11,6 +11,6 @@ class PriorizedMailDeliveryJob < ActionMailer::MailDeliveryJob end def custom_queue - ENV.fetch('BULK_EMAIL_QUEUE') { Rails.application.config.action_mailer.deliver_later_queue_name } + ENV.fetch('BULK_EMAIL_QUEUE') { Rails.application.config.action_mailer.deliver_later_queue_name.to_s } end end From 43c3d706acfcf13009e09724c96712a839901ebb Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 30 May 2024 12:25:32 +0200 Subject: [PATCH 0291/1532] chore(js): lazy load lightbox and tiptap --- .../controllers/{ => lazy}/lightbox_controller.ts | 4 ++-- .../controllers/{ => lazy}/tiptap_controller.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) rename app/javascript/controllers/{ => lazy}/lightbox_controller.ts (92%) rename app/javascript/controllers/{ => lazy}/tiptap_controller.ts (92%) diff --git a/app/javascript/controllers/lightbox_controller.ts b/app/javascript/controllers/lazy/lightbox_controller.ts similarity index 92% rename from app/javascript/controllers/lightbox_controller.ts rename to app/javascript/controllers/lazy/lightbox_controller.ts index 1c671fa16..2820c8a35 100644 --- a/app/javascript/controllers/lightbox_controller.ts +++ b/app/javascript/controllers/lazy/lightbox_controller.ts @@ -1,4 +1,4 @@ -import { Controller } from '@hotwired/stimulus'; +import { ApplicationController } from '../application_controller'; import lightGallery from 'lightgallery'; import { LightGallery } from 'lightgallery/lightgallery'; import lgThumbnail from 'lightgallery/plugins/thumbnail'; @@ -7,7 +7,7 @@ import lgRotate from 'lightgallery/plugins/rotate'; import lgHash from 'lightgallery/plugins/hash'; import 'lightgallery/css/lightgallery-bundle.css'; -export default class extends Controller { +export default class extends ApplicationController { lightGallery?: LightGallery; connect(): void { diff --git a/app/javascript/controllers/tiptap_controller.ts b/app/javascript/controllers/lazy/tiptap_controller.ts similarity index 92% rename from app/javascript/controllers/tiptap_controller.ts rename to app/javascript/controllers/lazy/tiptap_controller.ts index caf7c4a66..e80d2e599 100644 --- a/app/javascript/controllers/tiptap_controller.ts +++ b/app/javascript/controllers/lazy/tiptap_controller.ts @@ -2,10 +2,10 @@ import { Editor, type JSONContent } from '@tiptap/core'; import { isButtonElement, isHTMLElement } from '@coldwired/utils'; import { z } from 'zod'; -import { ApplicationController } from './application_controller'; -import { getAction } from '../shared/tiptap/actions'; -import { tagSchema, type TagSchema } from '../shared/tiptap/tags'; -import { createEditor } from '../shared/tiptap/editor'; +import { ApplicationController } from '../application_controller'; +import { getAction } from '../../shared/tiptap/actions'; +import { tagSchema, type TagSchema } from '../../shared/tiptap/tags'; +import { createEditor } from '../../shared/tiptap/editor'; export class TiptapController extends ApplicationController { static targets = ['editor', 'input', 'button', 'tag']; From 14294af5dcd30d13f6b47ff745eb67f84016af97 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 30 May 2024 11:30:52 +0200 Subject: [PATCH 0292/1532] tech: block dubious emails address --- .../20240530090353_block_dubious_email.rake | 27 +++++++++ ...20240530090353_block_dubious_email_spec.rb | 57 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 lib/tasks/deployment/20240530090353_block_dubious_email.rake create mode 100644 spec/lib/tasks/deployment/20240530090353_block_dubious_email_spec.rb diff --git a/lib/tasks/deployment/20240530090353_block_dubious_email.rake b/lib/tasks/deployment/20240530090353_block_dubious_email.rake new file mode 100644 index 000000000..95f3696b4 --- /dev/null +++ b/lib/tasks/deployment/20240530090353_block_dubious_email.rake @@ -0,0 +1,27 @@ +namespace :after_party do + desc 'Deployment task: block_dubious_email' + task block_dubious_email: :environment do + User + .where.associated(:instructeur) + .where(created_at: ..3.months.ago) + .where(last_sign_in_at: nil) + .update_all(email_verified_at: nil) + + User + .where.associated(:expert) + .where(created_at: ..3.months.ago) + .where(last_sign_in_at: nil) + .update_all(email_verified_at: nil) + + # rubocop:disable DS/Unscoped + User + .unscoped + .where.missing(:instructeur, :expert) + .where(confirmed_at: nil) + .update_all(email_verified_at: nil) + # rubocop:enable DS/Unscoped + + AfterParty::TaskRecord + .create version: AfterParty::TaskRecorder.new(__FILE__).timestamp + end +end diff --git a/spec/lib/tasks/deployment/20240530090353_block_dubious_email_spec.rb b/spec/lib/tasks/deployment/20240530090353_block_dubious_email_spec.rb new file mode 100644 index 000000000..796b13162 --- /dev/null +++ b/spec/lib/tasks/deployment/20240530090353_block_dubious_email_spec.rb @@ -0,0 +1,57 @@ +describe '20240530090353_block_dubious_email' do + let(:rake_task) { Rake::Task['after_party:block_dubious_email'] } + let(:now) { Time.current } + let(:confirmed_user) { create(:user, email_verified_at: now, created_at: 1.year.ago) } + let(:unconfirmed_user) { create(:user, email_verified_at: now, created_at: 1.year.ago, confirmed_at: nil) } + + let(:never_seen_instructeur) do + instructeur = create(:instructeur) + instructeur.user.update!(email_verified_at: now, created_at: 1.year.ago) + instructeur.user + end + let(:seen_instructeur) do + instructeur = create(:instructeur) + instructeur.user.update!( + email_verified_at: now, + created_at: 1.year.ago, + last_sign_in_at: now + ) + instructeur.user + end + + let(:young_never_seen_instructeur) do + instructeur = create(:instructeur) + instructeur.user.update!( + email_verified_at: now, + created_at: 2.months.ago, + confirmed_at: nil + ) + instructeur.user + end + + subject(:run_task) { rake_task.invoke } + + after { rake_task.reenable } + + it 'block_dubious_email' do + people = [ + confirmed_user, + unconfirmed_user, + never_seen_instructeur, + young_never_seen_instructeur, + seen_instructeur + ] + + expect(people.map(&:email_verified_at)).to all(be_present) + + run_task + people.each(&:reload) + + expect(confirmed_user.email_verified_at).to be_present + expect(seen_instructeur.email_verified_at).to be_present + expect(young_never_seen_instructeur.email_verified_at).to be_present + + expect(never_seen_instructeur.email_verified_at).to be_nil + expect(unconfirmed_user.email_verified_at).to be_nil + end +end From eb70d63892ba22dee33d4b345bc42dbe28094eb9 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Thu, 30 May 2024 13:47:01 +0200 Subject: [PATCH 0293/1532] remove css fix --- app/assets/stylesheets/card.scss | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/assets/stylesheets/card.scss b/app/assets/stylesheets/card.scss index e3646961f..82b6f7cf1 100644 --- a/app/assets/stylesheets/card.scss +++ b/app/assets/stylesheets/card.scss @@ -5,10 +5,6 @@ [data-fr-theme="dark"] .card { background: none; border: 1px solid var(--background-action-low-blue-france); - - &.feedback { - background: none; - } } .card { From 9634cce8cd769a63036cd56da721f89205954d28 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Wed, 22 May 2024 14:55:28 +0200 Subject: [PATCH 0294/1532] page presentation --- app/views/administrateurs/procedures/edit.html.haml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/administrateurs/procedures/edit.html.haml b/app/views/administrateurs/procedures/edit.html.haml index 137932e3a..f79244721 100644 --- a/app/views/administrateurs/procedures/edit.html.haml +++ b/app/views/administrateurs/procedures/edit.html.haml @@ -3,7 +3,7 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_back_path(@procedure)], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['Description']] } + ['Présentation']] } = render NestedForms::FormOwnerComponent.new = form_for @procedure, @@ -12,7 +12,7 @@ .fr-container .fr-grid-row .fr-col-12.fr-col-offset-md-2.fr-col-md-8 - %h1.fr-h2 Description + %h1.fr-h2 Présentation = render partial: 'administrateurs/procedures/informations', locals: { f: f } @@ -23,6 +23,6 @@ .fr-col-12.fr-col-offset-md-2.fr-col-md-8 %ul.fr-btns-group.fr-btns-group--inline-md %li - = f.button 'Enregistrer', class: 'fr-btn' + = link_to "Annuler et revenir à l'écran de gestion", admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Êtes-vous sûr de vouloir annuler les modifications effectuées ?'} %li - = link_to 'Annuler', admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Êtes-vous sûr de vouloir annuler les modifications effectuées ?'} + = f.button 'Enregistrer', class: 'fr-btn' From 10ac3fe693147181cf8efaffa217412fa5a2a276 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Wed, 22 May 2024 15:09:37 +0200 Subject: [PATCH 0295/1532] page champs formulaire --- app/views/administrateurs/procedures/champs.html.haml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/views/administrateurs/procedures/champs.html.haml b/app/views/administrateurs/procedures/champs.html.haml index d927556a1..95ddf2860 100644 --- a/app/views/administrateurs/procedures/champs.html.haml +++ b/app/views/administrateurs/procedures/champs.html.haml @@ -1,10 +1,10 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_back_path(@procedure)], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['Configuration des champs']], preview: @procedure.draft_revision.valid? } + ['Champs du formulaire']], preview: @procedure.draft_revision.valid? } .fr-container - %h1 Configuration des champs + %h1.fr-h2 Champs du formulaire = render NestedForms::FormOwnerComponent.new .fr-grid-row = render partial: 'champs_summary' @@ -15,12 +15,13 @@ .fixed-footer .fr-container .flex - %ul.fr-btns-group.fr-btns-group--inline-md + %ul.fr-btns-group.fr-btns-group--inline-md.fr-ml-0 %li - = link_to t('continue', scope: [:layouts, :breadcrumb]), admin_procedure_path(@procedure), title: t('continue_title', scope: [:layouts, :breadcrumb]), class: 'fr-btn' + = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w fr-mr-2w' do + Revenir à l'écran de gestion - if @procedure.draft_revision.revision_types_de_champ_public.count > 0 %li - = link_to t('preview', scope: [:layouts, :breadcrumb]), apercu_admin_procedure_path(@procedure), target: "_blank", rel: "noopener", class: 'fr-btn fr-btn--secondary' + = link_to t('preview', scope: [:layouts, :breadcrumb]), apercu_admin_procedure_path(@procedure), target: "_blank", rel: "noopener", class: 'fr-link fr-mb-2w' .fr-ml-auto #autosave-notice.hidden = render TypesDeChampEditor::EstimatedFillDurationComponent.new(revision: @procedure.draft_revision, is_annotation: false) From 1fe59c165f51d74e23e88f125056f35cc19f876f Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Wed, 22 May 2024 16:12:48 +0200 Subject: [PATCH 0296/1532] page service --- .../administrateurs/services/_form.html.haml | 4 +- .../administrateurs/services/edit.html.haml | 17 ++--- .../administrateurs/services/index.html.haml | 63 ++++++++++--------- 3 files changed, 46 insertions(+), 38 deletions(-) diff --git a/app/views/administrateurs/services/_form.html.haml b/app/views/administrateurs/services/_form.html.haml index c3afc1522..ec4a7233a 100644 --- a/app/views/administrateurs/services/_form.html.haml +++ b/app/views/administrateurs/services/_form.html.haml @@ -39,6 +39,6 @@ .fr-container %ul.fr-btns-group.fr-btns-group--inline-md %li - = f.submit "Enregistrer", class: "fr-btn" + = link_to "Annuler et revenir à l'écran de gestion", admin_procedure_path(id: @procedure.id), class: "fr-btn fr-btn--secondary" %li - = link_to "Annuler et revenir à la page de suivi", admin_procedure_path(id: @procedure.id), class: "fr-btn fr-btn--secondary" + = f.submit "Enregistrer", class: "fr-btn" diff --git a/app/views/administrateurs/services/edit.html.haml b/app/views/administrateurs/services/edit.html.haml index 186294bfc..0b056372c 100644 --- a/app/views/administrateurs/services/edit.html.haml +++ b/app/views/administrateurs/services/edit.html.haml @@ -5,21 +5,22 @@ ['Modifier le service']] } -.container +.fr-container + .flex.justify-between.align-center.fr-mb-3w + = link_to "Liste de tous les services", admin_services_path(procedure_id: @procedure.id), class: "fr-link fr-icon-arrow-left-line fr-link--icon-left" + = link_to "+ Nouveau service", new_admin_service_path(procedure_id: @procedure.id), class: "fr-btn" + + %h1.fr-h2 + Modifier le service + - other_services = @service.procedures.reject {|procedure| procedure.id == @procedure.id } - if other_services.count > 1 - = render Dsfr::AlertComponent.new(state: :warning, title: "Modifier ce service impactera la ou les démarches qui sont rattachée/s") do |c| + = render Dsfr::AlertComponent.new(state: :warning, title: "Modifier ce service impactera la ou les démarches qui sont rattachée/s", extra_class_names: 'fr-mb-3w') do |c| - c.with_body do %ul - other_services.each do |proc| %li= "#{proc.libelle} (N° #{proc.id})" %p.mt-3 Si vous souhaitez modifier uniquement les informations pour ce service, créez un nouveau service puis associez-le à la démarche - %p.mt-3 - = link_to "+ Nouveau service", new_admin_service_path(procedure_id: @procedure.id), class: "fr-btn" - - - %h1.mt-2 Modifier le service - = render partial: 'form', locals: { service: @service, procedure_id: @procedure.id } diff --git a/app/views/administrateurs/services/index.html.haml b/app/views/administrateurs/services/index.html.haml index b0fb75517..a837c14f6 100644 --- a/app/views/administrateurs/services/index.html.haml +++ b/app/views/administrateurs/services/index.html.haml @@ -1,34 +1,41 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['Choix du service']] } + ['Service']] } -#services-index.container - %h1.fr-h1 Liste des Services - %h2.fr-h4 La démarche “#{@procedure.libelle}” peut être affectée aux services dans la liste ci-dessous +#services-index.fr-container + %h1.fr-h2 Service - %table.fr-table.width-100.mt-3 - %thead - %tr - %th{ scope: "col" } - Nom - %th.change{ scope: "col" } - = link_to "Nouveau service", new_admin_service_path(procedure_id: @procedure.id), class: "fr-btn fr-btn--secondary" - - %tbody - - @services.each do |service| + .fr-table.fr-table--layout-fixed + %table + %caption Liste des services pouvant être affectés à la démarche + %thead %tr - %td - = service.nom - %td.change - - if @procedure.service == service - %strong.mr-2 (Assigné) - - else - = button_to "Assigner", add_to_procedure_admin_services_path(procedure: { id: @procedure.id, service_id: service.id, }), method: :patch, class: 'link mr-2', form_class: 'inline' - = link_to('Modifier', edit_admin_service_path(service, procedure_id: @procedure.id), class: 'link my-2') - - if @procedure.service != service - = link_to 'Supprimer', - admin_service_path(service, procedure_id: @procedure.id), - method: :delete, - data: { confirm: "Confirmez vous la suppression de #{service.nom}" }, - class: 'btn btn-link ml-2' + %th{ scope: "col" } + Nom + %th.change{ scope: "col" } + = link_to "Nouveau service", new_admin_service_path(procedure_id: @procedure.id), class: "fr-btn fr-btn--secondary" + + %tbody + - @services.each do |service| + %tr + %td + = service.nom + %td.change + - if @procedure.service == service + %strong.mr-2 (Assigné) + - else + = button_to "Assigner", add_to_procedure_admin_services_path(procedure: { id: @procedure.id, service_id: service.id, }), method: :patch, class: 'link mr-2', form_class: 'inline' + = link_to('Modifier', edit_admin_service_path(service, procedure_id: @procedure.id), class: 'link my-2') + - if @procedure.service != service + = link_to 'Supprimer', + admin_service_path(service, procedure_id: @procedure.id), + method: :delete, + data: { confirm: "Confirmez vous la suppression de #{service.nom}" }, + class: 'btn btn-link ml-2' + +.padded-fixed-footer + .fixed-footer.fr-pb-2w + .fr-container + = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left' do + Revenir à l'écran de gestion From 18585ce4222f3ba89855f9d9f2f45b2939554046 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Wed, 22 May 2024 16:16:45 +0200 Subject: [PATCH 0297/1532] page administrateurs --- .../procedure_administrateurs/index.html.haml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/views/administrateurs/procedure_administrateurs/index.html.haml b/app/views/administrateurs/procedure_administrateurs/index.html.haml index 8ad25f4e1..0545d0f43 100644 --- a/app/views/administrateurs/procedure_administrateurs/index.html.haml +++ b/app/views/administrateurs/procedure_administrateurs/index.html.haml @@ -4,10 +4,14 @@ ['Administrateurs']], preview: false } .fr-container - %h1 Gérer les administrateurs de « #{@procedure.libelle} » + %h1.fr-h2 Administrateurs - .fr-table.fr-table--bordered + .fr-mb-4w + = render 'add_admin_form', procedure: @procedure, disabled_as_super_admin: administrateur_as_manager? + + .fr-table.fr-table--bordered.fr-table--layout-fixed %table + %caption Liste des administrateurs %thead %th= 'Adresse email' %th= 'Enregistré le' @@ -16,5 +20,8 @@ %tbody#administrateurs = render(Procedure::ProcedureAdministrateurs::AdministrateurComponent.with_collection(@procedure.administrateurs.order('users.email'), procedure: @procedure)) - .fr-mt-4w - = render 'add_admin_form', procedure: @procedure, disabled_as_super_admin: administrateur_as_manager? +.padded-fixed-footer + .fixed-footer.fr-pb-2w + .fr-container + = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left' do + Revenir à l'écran de gestion From 659e4ffb617c40c919e7551f704afe1937837d06 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Wed, 22 May 2024 16:24:17 +0200 Subject: [PATCH 0298/1532] page instructeurs --- .../instructeurs_management_component.html.haml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml index b3d9c2100..5b22e5325 100644 --- a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml +++ b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml @@ -1,5 +1,5 @@ - content_for(:title, 'Instructeurs') -%h1 Gestion des instructeurs +%h1.fr-h2 Instructeurs = render partial: 'administrateurs/groupe_instructeurs/import_export', locals: { procedure: @procedure } @@ -10,3 +10,9 @@ instructeurs: @instructeurs, available_instructeur_emails: @available_instructeur_emails, disabled_as_super_admin: @disabled_as_super_admin } + +.padded-fixed-footer + .fixed-footer.fr-pb-2w + .fr-container + = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left' do + Revenir à l'écran de gestion From 1cec1b91c1654792812cb6cba0658ffa365020ca Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Wed, 22 May 2024 16:31:42 +0200 Subject: [PATCH 0299/1532] page modifications historique --- .../modifications_component.fr.yml | 4 ++-- .../modifications_component.html.haml | 2 +- .../procedures/modifications.html.haml | 14 ++++++++++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/components/procedure/card/modifications_component/modifications_component.fr.yml b/app/components/procedure/card/modifications_component/modifications_component.fr.yml index e46fc4f3c..676fc64cd 100644 --- a/app/components/procedure/card/modifications_component/modifications_component.fr.yml +++ b/app/components/procedure/card/modifications_component/modifications_component.fr.yml @@ -1,5 +1,5 @@ --- fr: title: - one: Modification - other: Modifications + one: Modification du formulaire + other: Modifications du formulaire diff --git a/app/components/procedure/card/modifications_component/modifications_component.html.haml b/app/components/procedure/card/modifications_component/modifications_component.html.haml index 7ee37782a..9dc203c17 100644 --- a/app/components/procedure/card/modifications_component/modifications_component.html.haml +++ b/app/components/procedure/card/modifications_component/modifications_component.html.haml @@ -8,5 +8,5 @@ %h3.fr-h6 = t('.title', count: @procedure.revisions_count) - %p.fr-tile-subtitle Historique des modifications du formulaire + %p.fr-tile-subtitle Historique des modifications apportées au formulaire %p.fr-btn.fr-btn--tertiary Voir diff --git a/app/views/administrateurs/procedures/modifications.html.haml b/app/views/administrateurs/procedures/modifications.html.haml index a3775ada7..6c890f8fe 100644 --- a/app/views/administrateurs/procedures/modifications.html.haml +++ b/app/views/administrateurs/procedures/modifications.html.haml @@ -1,12 +1,12 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_back_path(@procedure)], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['Modifications']] } -.container - %h1.page-title + ['Historique des modifications du formulaire']] } +.fr-container + %h1.fr-h2 Historique des modifications du formulaire -.container +.fr-container - previous_revision = nil - @procedure.revisions.each do |revision| - if previous_revision.present? && !revision.draft? @@ -30,3 +30,9 @@ %p= t('.dossiers_en_instruction', count: dossiers_en_instruction_count) = render Procedure::RevisionChangesComponent.new changes:, previous_revision: - previous_revision = revision + +.padded-fixed-footer + .fixed-footer.fr-pb-2w + .fr-container + = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left' do + Revenir à l'écran de gestion From 45fbbf774da0311754cfe40f0e8ef5cbee37e12e Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Thu, 23 May 2024 16:52:53 +0200 Subject: [PATCH 0300/1532] page zones --- .../procedures/zones.html.haml | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/app/views/administrateurs/procedures/zones.html.haml b/app/views/administrateurs/procedures/zones.html.haml index e8b6e91eb..a0f5989e0 100644 --- a/app/views/administrateurs/procedures/zones.html.haml +++ b/app/views/administrateurs/procedures/zones.html.haml @@ -4,12 +4,12 @@ locals: { steps: [['Démarches', admin_procedures_back_path(@procedure)], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], ['Description']] } -.container - = form_for @procedure, - url: url_for({ controller: 'administrateurs/procedures', action: :update, id: @procedure.id }), - html: { multipart: true } do |f| += form_for @procedure, + url: url_for({ controller: 'administrateurs/procedures', action: :update, id: @procedure.id }), + html: { multipart: true } do |f| - %h1.page-title Zones + .fr-container + %h1.fr-h2 Zones - if Rails.application.config.ds_zonage_enabled %fieldset.fr-fieldset{ aria: { labelledby: "zones-legend"} } @@ -25,7 +25,11 @@ = b.check_box = b.label class: "fr-label" - .procedure-form__actions.sticky--bottom - .actions-right - = link_to 'Annuler', admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--tertiary fr-mr-2w', data: { confirm: 'Êtes-vous sûr de vouloir annuler les modifications effectuées ?'} - = f.button 'Enregistrer', class: 'fr-btn fr-btn--primary' + .padded-fixed-footer + .fixed-footer + .fr-container + %ul.fr-btns-group.fr-btns-group--inline-md + %li + = link_to "Annuler et revenir à l'écran de gestion", admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Êtes-vous sûr de vouloir annuler les modifications effectuées ?'} + %li + = f.button 'Enregistrer', class: 'fr-btn' From 233d6ee823dfa9e1580363f1a7477c88ffba8c76 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Thu, 23 May 2024 17:06:20 +0200 Subject: [PATCH 0301/1532] page avis externes --- .../experts_procedures/index.html.haml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/views/administrateurs/experts_procedures/index.html.haml b/app/views/administrateurs/experts_procedures/index.html.haml index 886aa061f..55995ed69 100644 --- a/app/views/administrateurs/experts_procedures/index.html.haml +++ b/app/views/administrateurs/experts_procedures/index.html.haml @@ -1,13 +1,13 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['Liste des experts']] } + ['Avis externes']] } -.container - %h1.page-title.mt-2= t('.titles.main', libelle: @procedure.libelle) - - .container.groupe-instructeur +.fr-container + %h1.fr-h2 + Avis externes + .groupe-instructeur .card .card-title= t('.titles.allow_invite_experts') %p= t('.descriptions.allow_invite_experts') @@ -107,3 +107,9 @@ .blank-tab %h2.empty-text Aucun expert invité pour le moment. %p.empty-text-details Les instructeurs de cette démarche n’ont pas encore fait appel aux experts. + +.padded-fixed-footer + .fixed-footer.fr-pb-2w + .fr-container + = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left' do + Revenir à l'écran de gestion From 918b1312db0241eb2510d873cd54057552da55cb Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Thu, 23 May 2024 17:09:09 +0200 Subject: [PATCH 0302/1532] page confi emails --- app/views/administrateurs/mail_templates/index.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/administrateurs/mail_templates/index.html.haml b/app/views/administrateurs/mail_templates/index.html.haml index 2aa43246d..7a6640439 100644 --- a/app/views/administrateurs/mail_templates/index.html.haml +++ b/app/views/administrateurs/mail_templates/index.html.haml @@ -6,7 +6,7 @@ .fr-container .fr-grid-row.fr-grid-row--gutters .fr-col-12 - %h1 Configuration des emails + %h1.fr-h2 Configuration des emails - if @procedure.accuse_lecture? = render Dsfr::AlertComponent.new(state: :info, size: :sm) do |c| - c.with_body do From 125c4a45fc47e39748af2732e62609544a8a3659 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Thu, 23 May 2024 17:24:09 +0200 Subject: [PATCH 0303/1532] page annotations --- .../administrateurs/procedures/annotations.html.haml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/views/administrateurs/procedures/annotations.html.haml b/app/views/administrateurs/procedures/annotations.html.haml index 05d198cf6..a903ae638 100644 --- a/app/views/administrateurs/procedures/annotations.html.haml +++ b/app/views/administrateurs/procedures/annotations.html.haml @@ -1,10 +1,10 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_back_path(@procedure)], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['Configuration des annotations privées']], preview: true } + ['Annotations privées']], preview: true } .fr-container - %h1 Configuration des annotations privées + %h1.fr-h2 Annotations privées = render NestedForms::FormOwnerComponent.new .fr-grid-row .fr-col @@ -13,9 +13,9 @@ .padded-fixed-footer .fixed-footer .fr-container - %ul.fr-btns-group.fr-btns-group--inline-md + %ul.fr-btns-group.fr-btns-group--inline-md.fr-ml-0 %li - = link_to t('continue_annotations', scope: [:layouts, :breadcrumb]), admin_procedure_path(@procedure), title: t('continue_annotations', scope: [:layouts, :breadcrumb]), class: 'fr-btn' + = link_to "Revenir à l'écran de gestion", admin_procedure_path(@procedure), title: t('continue_annotations', scope: [:layouts, :breadcrumb]), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w fr-mr-2w' - if @procedure.draft_revision.revision_types_de_champ_private.count > 0 %li - = link_to t('preview_annotations', scope: [:layouts, :breadcrumb]), apercu_admin_procedure_path(@procedure, params: {tab: 'annotations-privees'}), target: "_blank", rel: "noopener", class: 'fr-btn fr-btn--secondary' + = link_to t('preview_annotations', scope: [:layouts, :breadcrumb]), apercu_admin_procedure_path(@procedure, params: {tab: 'annotations-privees'}), target: "_blank", rel: "noopener", class: 'fr-link fr-mb-2w' From 06c784eb039c0579c288a2ff7ee8d90288df66ae Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Mon, 27 May 2024 11:13:21 +0200 Subject: [PATCH 0304/1532] page api token --- .../administrateurs/procedures_controller.rb | 4 ++-- .../procedures/jeton.html.haml | 22 ++++++++++++------- config/locales/fr.yml | 1 - 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/app/controllers/administrateurs/procedures_controller.rb b/app/controllers/administrateurs/procedures_controller.rb index 4dde5c6c2..99a1a946e 100644 --- a/app/controllers/administrateurs/procedures_controller.rb +++ b/app/controllers/administrateurs/procedures_controller.rb @@ -298,8 +298,8 @@ module Administrateurs APIEntreprise::PrivilegesAdapter.new(token).valid? && @procedure.save - redirect_to jeton_admin_procedure_path(procedure_id: params[:procedure_id]), - notice: 'Le jeton a bien été mis à jour' + flash.notice = 'Le jeton a bien été mis à jour' + redirect_to admin_procedure_path(id: @procedure.id) else flash.now.alert = "Mise à jour impossible : le jeton n’est pas valide" diff --git a/app/views/administrateurs/procedures/jeton.html.haml b/app/views/administrateurs/procedures/jeton.html.haml index 0062ac47b..31d076d23 100644 --- a/app/views/administrateurs/procedures/jeton.html.haml +++ b/app/views/administrateurs/procedures/jeton.html.haml @@ -1,15 +1,13 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_back_path(@procedure)], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['Jeton']] } + ['Jeton Entreprise']] } -.container - %h1.page-title - Configurer le jeton API Entreprise +.fr-container + %h1.fr-h2 Jeton Entreprise -.container - %h1 - = form_with model: @procedure, url: url_for({ controller: 'administrateurs/procedures', action: :update_jeton }) do |f| += form_with model: @procedure, url: url_for({ controller: 'administrateurs/procedures', action: :update_jeton }) do |f| + .fr-container = render Dsfr::AlertComponent.new(state: :info, size: :sm, extra_class_names: 'fr-mb-2w') do |c| - c.with_body do %p @@ -23,4 +21,12 @@ .fr-input-group = f.label :api_entreprise_token, "Jeton", class: 'fr-label' = f.password_field :api_entreprise_token, value: @procedure.read_attribute(:api_entreprise_token), class: 'fr-input' - = f.button 'Enregistrer', class: 'fr-btn' + + .padded-fixed-footer + .fixed-footer + .fr-container + %ul.fr-btns-group.fr-btns-group--inline-md + %li + = link_to "Annuler et revenir à l'écran de gestion", admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Êtes-vous sûr de vouloir annuler les modifications effectuées ?'} + %li + = f.button 'Enregistrer', class: 'fr-btn' diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 7378f6ff7..619f5f4f4 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -893,7 +893,6 @@ fr: configure_api_particulier_token: "Configurer le jeton API particulier" jeton_particulier: show: - configure_token: "Configurer le jeton API Particulier" api_particulier_description_html: "%{app_name} utilise API Particulier qui permet d’accéder aux données familiales (CAF), aux données fiscales (DGFiP), au statut pôle-emploi et au statut étudiant d’un citoyen.
Renseignez ici le jeton API Particulier propre à votre démarche." token_description: "Il doit contenir au minimum 15 caractères." update: From 6f0cf19f0d6815f9c3266cbc911d36e8c4a3ef32 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Mon, 27 May 2024 15:08:28 +0200 Subject: [PATCH 0305/1532] page SVA SVR --- .../sva_svr_form_component.en.yml | 2 +- .../sva_svr_form_component.fr.yml | 2 +- .../sva_svr_form_component.html.haml | 69 ++++++++++--------- .../administrateurs/sva_svr/edit.html.haml | 6 +- 4 files changed, 43 insertions(+), 36 deletions(-) diff --git a/app/components/procedure/sva_svr_form_component/sva_svr_form_component.en.yml b/app/components/procedure/sva_svr_form_component/sva_svr_form_component.en.yml index f168f6503..0e285fc49 100644 --- a/app/components/procedure/sva_svr_form_component/sva_svr_form_component.en.yml +++ b/app/components/procedure/sva_svr_form_component/sva_svr_form_component.en.yml @@ -11,7 +11,7 @@ en: When an instructor asks for a file to be corrected, the countdown of the delay is interrupted. The delay resumes when the applicant resubmits their file stating that they have made the requested corrections. If the file has been declared incomplete, the delay will be reset, regardless of the configuration below. - submit: Apply SVA/SVR configuration + submit: Save cancel: Cancel decision_buttons: disabled: "Disabled" diff --git a/app/components/procedure/sva_svr_form_component/sva_svr_form_component.fr.yml b/app/components/procedure/sva_svr_form_component/sva_svr_form_component.fr.yml index 9a0316585..afb32dea3 100644 --- a/app/components/procedure/sva_svr_form_component/sva_svr_form_component.fr.yml +++ b/app/components/procedure/sva_svr_form_component/sva_svr_form_component.fr.yml @@ -11,7 +11,7 @@ fr: Lorsqu’un instructeur demande de corriger un dossier, le décompte du délai est interrompu. Le délai reprend lorsque le demandeur redépose son dossier en déclarant avoir effectué les corrections demandées. Si le dossier avait été déclaré incomplet, le délai sera réinitialisé, quelle que soit la configuration ci-dessous. - submit: Appliquer la configuration SVA/SVR + submit: Enregistrer cancel: Annuler decision_buttons: disabled: "Désactivé" diff --git a/app/components/procedure/sva_svr_form_component/sva_svr_form_component.html.haml b/app/components/procedure/sva_svr_form_component/sva_svr_form_component.html.haml index 121f939f8..1eac6cb13 100644 --- a/app/components/procedure/sva_svr_form_component/sva_svr_form_component.html.haml +++ b/app/components/procedure/sva_svr_form_component/sva_svr_form_component.html.haml @@ -1,40 +1,47 @@ = form_for [procedure, configuration], url: admin_procedure_sva_svr_path(procedure), method: :put do |f| - - if !procedure.feature_enabled?(:sva) - .fr-alert.fr-alert--info.fr-alert--sm.fr-my-8w - %p - Pour activer le paramétrage de cette fonctionnalité, contactez-nous sur - = link_to CONTACT_EMAIL, "mailto:#{CONTACT_EMAIL}", **helpers.external_link_attributes - en indiquant votre numéro de démarche (#{@procedure.id}) et le cadre d’application du SVA/SVR. + .fr-container + - if !procedure.feature_enabled?(:sva) + .fr-alert.fr-alert--info.fr-alert--sm.fr-mb-5w + %p + Pour activer le paramétrage de cette fonctionnalité, contactez-nous sur + = link_to CONTACT_EMAIL, "mailto:#{CONTACT_EMAIL}", **helpers.external_link_attributes + en indiquant votre numéro de démarche (#{@procedure.id}) et le cadre d’application du SVA/SVR. - - elsif procedure.publiee? && !procedure.sva_svr_enabled? - .fr-alert.fr-alert--info.fr-alert--sm.fr-mb-4w - %p= t('.notice_new_files_only') + - elsif procedure.publiee? && !procedure.sva_svr_enabled? + .fr-alert.fr-alert--info.fr-alert--sm.fr-mb-4w + %p= t('.notice_new_files_only') - - if procedure.publiee? && procedure.sva_svr_enabled? - .fr-alert.fr-alert--warning.fr-alert--sm.fr-mb-4w - %p= t('.notice_edit_denied') + - if procedure.publiee? && procedure.sva_svr_enabled? + .fr-alert.fr-alert--warning.fr-alert--sm.fr-mb-4w + %p= t('.notice_edit_denied') - %fieldset.fr-fieldset - %legend.fr-fieldset__legend= t(".rule") - = render Dsfr::RadioButtonListComponent.new(form: f, target: :decision, buttons: decision_buttons, error: configuration.errors[:decision].first) + %fieldset.fr-fieldset + %legend.fr-fieldset__legend= t(".rule") + = render Dsfr::RadioButtonListComponent.new(form: f, target: :decision, buttons: decision_buttons, error: configuration.errors[:decision].first) - %fieldset.fr-fieldset - %legend.fr-fieldset__legend= t(".delay") - .fr-fieldset__element.fr-fieldset__element--inline - .fr-input-group - = f.number_field :period, class: 'fr-input', disabled: form_disabled? - .fr-fieldset__element.fr-fieldset__element--inline - .fr-select-group - = f.select :unit, options_for_select(SVASVRConfiguration.unit_options.map { [t(_1, scope: ".unit_labels"), _1] }, selected: configuration.unit), {}, class: 'fr-select', disabled: form_disabled? + %fieldset.fr-fieldset + %legend.fr-fieldset__legend= t(".delay") + .fr-fieldset__element.fr-fieldset__element--inline + .fr-input-group + = f.number_field :period, class: 'fr-input', disabled: form_disabled? + .fr-fieldset__element.fr-fieldset__element--inline + .fr-select-group + = f.select :unit, options_for_select(SVASVRConfiguration.unit_options.map { [t(_1, scope: ".unit_labels"), _1] }, selected: configuration.unit), {}, class: 'fr-select', disabled: form_disabled? - %fieldset.fr-fieldset - %legend.fr-fieldset__legend - = t(".resume_method") + %fieldset.fr-fieldset + %legend.fr-fieldset__legend + = t(".resume_method") - %span.fr-hint-text - = t(".resume_intro") + %span.fr-hint-text + = t(".resume_intro") - = render Dsfr::RadioButtonListComponent.new(form: f, target: :resume, buttons: resume_buttons) + = render Dsfr::RadioButtonListComponent.new(form: f, target: :resume, buttons: resume_buttons) - = f.submit t(".submit"), class: "fr-btn", disabled: form_disabled? - = link_to t(".cancel"), admin_procedure_path(procedure.id), class: "fr-btn fr-btn--secondary fr-ml-2w" + .padded-fixed-footer + .fixed-footer + .fr-container + %ul.fr-btns-group.fr-btns-group--inline-md + %li + = link_to "Annuler et revenir à l'écran de gestion", admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Êtes-vous sûr de vouloir annuler les modifications effectuées ?'} + %li + = f.submit t(".submit"), class: "fr-btn", disabled: form_disabled? diff --git a/app/views/administrateurs/sva_svr/edit.html.haml b/app/views/administrateurs/sva_svr/edit.html.haml index 715938671..2c026b42b 100644 --- a/app/views/administrateurs/sva_svr/edit.html.haml +++ b/app/views/administrateurs/sva_svr/edit.html.haml @@ -1,10 +1,10 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_path], ["#{@procedure.libelle.truncate_words(10)}", admin_procedure_path(@procedure)], - ["Configuration SVA/SVR"]] } + ["Silence Vaut Accord ou Rejet"]] } .fr-container.fr-my-5w - %h1.fr-h1 Règle du Silence Vaut Accord ou Silence Vaut Rejet + %h1.fr-h2 Silence Vaut Accord ou Rejet = render Dsfr::CalloutComponent.new(title: "Fonctionnement du SVA/SVR") do |c| - c.with_body do @@ -40,4 +40,4 @@ = link_to("Liste des démarches encadrées par ce principe", "https://www.service-public.fr/demarches-silence-vaut-accord", class: "fr-link", title: new_tab_suffix("Rechercher les démarches avec SVA sur service-public.fr"), **external_link_attributes) - = render Procedure::SVASVRFormComponent.new(procedure: @procedure, configuration: @configuration) += render Procedure::SVASVRFormComponent.new(procedure: @procedure, configuration: @configuration) From ba8b05ed6a0a51ca13d1be178601f76bfc879867 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Mon, 27 May 2024 15:32:31 +0200 Subject: [PATCH 0306/1532] page MonAvis --- .../mon_avis_component.fr.yml | 2 +- .../administrateurs/procedures_controller.rb | 3 ++- .../procedures/_monavis.html.haml | 2 +- .../procedures/monavis.html.haml | 21 ++++++++++++------- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/app/components/procedure/card/mon_avis_component/mon_avis_component.fr.yml b/app/components/procedure/card/mon_avis_component/mon_avis_component.fr.yml index bdf8cabad..ca2c03824 100644 --- a/app/components/procedure/card/mon_avis_component/mon_avis_component.fr.yml +++ b/app/components/procedure/card/mon_avis_component/mon_avis_component.fr.yml @@ -1,3 +1,3 @@ --- fr: - title: MonAvis + title: Bouton « MonAvis » diff --git a/app/controllers/administrateurs/procedures_controller.rb b/app/controllers/administrateurs/procedures_controller.rb index 99a1a946e..1c68058ea 100644 --- a/app/controllers/administrateurs/procedures_controller.rb +++ b/app/controllers/administrateurs/procedures_controller.rb @@ -270,10 +270,11 @@ module Administrateurs def update_monavis if !@procedure.update(procedure_params) flash.now.alert = @procedure.errors.full_messages + render 'monavis' else flash.notice = 'le champ MonAvis a bien été mis à jour' + redirect_to admin_procedure_path(id: @procedure.id) end - render 'monavis' end def accuse_lecture diff --git a/app/views/administrateurs/procedures/_monavis.html.haml b/app/views/administrateurs/procedures/_monavis.html.haml index 499f22d0e..62ac9d711 100644 --- a/app/views/administrateurs/procedures/_monavis.html.haml +++ b/app/views/administrateurs/procedures/_monavis.html.haml @@ -14,5 +14,5 @@ Une fois en possession du code généré sur le site MonAvis, vous pouvez le coller dans le champ ci-dessous : .fr-input-group - = f.label :monavis_embed, "Mon avis", class: "fr-label" + = f.label :monavis_embed, "Code généré sur le site MonAvis", class: "fr-label" = f.text_area :monavis_embed, rows: '6', placeholder: 'Je donne mon avis', class: 'fr-input' diff --git a/app/views/administrateurs/procedures/monavis.html.haml b/app/views/administrateurs/procedures/monavis.html.haml index 2cbc3cce3..1b8189d6b 100644 --- a/app/views/administrateurs/procedures/monavis.html.haml +++ b/app/views/administrateurs/procedures/monavis.html.haml @@ -3,12 +3,19 @@ [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], ['MonAvis']] } -.container - %h1.page-title - Insérer un lien vers « MonAvis » +.fr-container + %h1.fr-h2 + Bouton « MonAvis » -.container - %h1 - = form_for @procedure, url: url_for({ controller: 'administrateurs/procedures', action: :update_monavis }), html: { class: 'form', multipart: true } do |f| += form_for @procedure, url: url_for({ controller: 'administrateurs/procedures', action: :update_monavis }), html: { class: 'form', multipart: true } do |f| + .fr-container = render partial: 'monavis', locals: { f: f } - = f.button 'Enregistrer', class: 'fr-btn' + + .padded-fixed-footer + .fixed-footer + .fr-container + %ul.fr-btns-group.fr-btns-group--inline-md + %li + = link_to "Annuler et revenir à l'écran de gestion", admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Êtes-vous sûr de vouloir annuler les modifications effectuées ?'} + %li + = f.button 'Enregistrer', class: 'fr-btn' From 0df4b480fbe8078a0d205d6c1db143d59de2fc85 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Mon, 27 May 2024 15:53:43 +0200 Subject: [PATCH 0307/1532] page fin de depot --- .../dossier_submitted_messages/edit.html.haml | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/app/views/administrateurs/dossier_submitted_messages/edit.html.haml b/app/views/administrateurs/dossier_submitted_messages/edit.html.haml index 08ce1b7cd..ed794cd73 100644 --- a/app/views/administrateurs/dossier_submitted_messages/edit.html.haml +++ b/app/views/administrateurs/dossier_submitted_messages/edit.html.haml @@ -3,31 +3,38 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['Fin de dépot']] } + ['Fin de dépôt']] } -.procedure-form - .procedure-form__columns.container - = form_for @dossier_submitted_message, - url: url_for({ controller: 'administrateurs/dossier_submitted_messages', action: :update, id: @procedure.id }), - html: { class: 'form procedure-form__column--form fr-background-alt--blue-france' } do |f| += form_for @dossier_submitted_message, + url: url_for({ controller: 'administrateurs/dossier_submitted_messages', action: :update, id: @procedure.id }), + html: { class: 'form' } do |f| - %h1.page-title - Fin du dépot - %p.notice - L'utilisateur se verra afficher ce message une fois le dossier envoyé + .procedure-form + .procedure-form__columns.container + .procedure-form__column--form.fr-background-alt--blue-france.fr-pt-5w + %h1.fr-h2 + Fin de dépôt + %p.notice + L'utilisateur se verra afficher ce message une fois le dossier envoyé - = render partial: 'administrateurs/dossier_submitted_messages/informations', locals: { f: f } + = render partial: 'administrateurs/dossier_submitted_messages/informations', locals: { f: f } - .procedure-form__actions - .actions-left - = f.submit 'Enregistrer', class: 'fr-btn send' - .procedure-form__column--preview - %h3 - .procedure-form__preview-title - Aperçu - .notice - Cet aperçu est mis à jour après chaque sauvegarde. + .procedure-form__column--preview + %h3 + .procedure-form__preview-title + Aperçu + .notice + Cet aperçu est mis à jour après chaque sauvegarde. - .procedure-preview - = render partial: 'users/dossiers/merci', locals: { procedure: @procedure, dossier: nil} + .procedure-preview + = render partial: 'users/dossiers/merci', locals: { procedure: @procedure, dossier: nil} + + .padded-fixed-footer + .fixed-footer + .fr-container + %ul.fr-btns-group.fr-btns-group--inline-md + %li + = link_to "Annuler et revenir à l'écran de gestion", admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Êtes-vous sûr de vouloir annuler les modifications effectuées ?'} + %li + = f.button 'Enregistrer', class: 'fr-btn' From 123a038c19ebf157033d6867b70ab6adaa80689f Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Mon, 27 May 2024 16:03:06 +0200 Subject: [PATCH 0308/1532] page accuse lecture --- .../administrateurs/procedures/accuse_lecture.html.haml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/views/administrateurs/procedures/accuse_lecture.html.haml b/app/views/administrateurs/procedures/accuse_lecture.html.haml index 42465f148..65b1e5143 100644 --- a/app/views/administrateurs/procedures/accuse_lecture.html.haml +++ b/app/views/administrateurs/procedures/accuse_lecture.html.haml @@ -6,7 +6,7 @@ .fr-container .fr-grid-row .fr-col-12.fr-col-offset-md-2.fr-col-md-8 - %h1.page-title + %h1.fr-h2 Accusé de lecture = render Dsfr::CalloutComponent.new(title: nil) do |c| @@ -37,7 +37,6 @@ .fixed-footer .fr-container .fr-grid-row - .fr-col-12.fr-col-offset-md-2.fr-col-md-8 - %ul.fr-btns-group.fr-btns-group--inline-md - %li - = link_to 'Enregistrer et revenir à la page de suivi', admin_procedure_path(id: @procedure), class: 'fr-btn' + .fr-col-12.fr-col-offset-md-2.fr-col-md-8.fr-pb-2w + = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w' do + Revenir à l'écran de gestion From 2659d0f966a25d06404891ba9f5e2ce86a22c16a Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Mon, 27 May 2024 17:56:05 +0200 Subject: [PATCH 0309/1532] create component for footer --- .../procedure/fixed_footer_component.rb | 10 ++++++++++ .../fixed_footer_component.html.fr.yml | 4 ++++ .../fixed_footer_component.html.haml | 20 +++++++++++++++++++ ...nstructeurs_management_component.html.haml | 7 ++----- .../sva_svr_form_component.html.haml | 9 +-------- .../dossier_submitted_messages/edit.html.haml | 9 +-------- .../experts_procedures/index.html.haml | 6 +----- .../mail_templates/index.html.haml | 8 +------- .../procedure_administrateurs/index.html.haml | 6 +----- .../procedures/accuse_lecture.html.haml | 8 +------- .../administrateurs/procedures/edit.html.haml | 11 +--------- .../procedures/jeton.html.haml | 9 +-------- .../procedures/modifications.html.haml | 6 +----- .../procedures/monavis.html.haml | 9 +-------- .../procedures/zones.html.haml | 9 +-------- .../administrateurs/services/_form.html.haml | 9 +-------- .../administrateurs/services/index.html.haml | 6 +----- 17 files changed, 49 insertions(+), 97 deletions(-) create mode 100644 app/components/procedure/fixed_footer_component.rb create mode 100644 app/components/procedure/fixed_footer_component/fixed_footer_component.html.fr.yml create mode 100644 app/components/procedure/fixed_footer_component/fixed_footer_component.html.haml diff --git a/app/components/procedure/fixed_footer_component.rb b/app/components/procedure/fixed_footer_component.rb new file mode 100644 index 000000000..b67548bca --- /dev/null +++ b/app/components/procedure/fixed_footer_component.rb @@ -0,0 +1,10 @@ +class Procedure::FixedFooterComponent < ApplicationComponent + def initialize(procedure:, form: nil, is_form_disabled: nil, extra_class_names: nil) + @procedure = procedure + @form = form + @is_form_disabled = is_form_disabled + @extra_class_names = extra_class_names + end + + attr_reader :form, :is_form_disabled, :extra_class_names +end diff --git a/app/components/procedure/fixed_footer_component/fixed_footer_component.html.fr.yml b/app/components/procedure/fixed_footer_component/fixed_footer_component.html.fr.yml new file mode 100644 index 000000000..0c67a97f1 --- /dev/null +++ b/app/components/procedure/fixed_footer_component/fixed_footer_component.html.fr.yml @@ -0,0 +1,4 @@ +fr: + back: Revenir à l'écran de gestion + submit: Enregistrer + cancel: Annuler et revenir à l'écran de gestion diff --git a/app/components/procedure/fixed_footer_component/fixed_footer_component.html.haml b/app/components/procedure/fixed_footer_component/fixed_footer_component.html.haml new file mode 100644 index 000000000..2116e38ff --- /dev/null +++ b/app/components/procedure/fixed_footer_component/fixed_footer_component.html.haml @@ -0,0 +1,20 @@ +- if form + .padded-fixed-footer + .fixed-footer + .fr-container + .fr-grid-row + %div{ class: "fr-col-12 #{extra_class_names}" } + %ul.fr-btns-group.fr-btns-group--inline-md + %li + = link_to t('.cancel'), admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Si vous avez fait des modifications elles ne seront pas sauvegardées.'} + %li + = form.submit t(".submit"), class: "fr-btn", disabled: is_form_disabled + +- else + .padded-fixed-footer + .fixed-footer + .fr-container + .fr-grid-row + %div{ class: "fr-col-12 fr-pb-2w #{extra_class_names}" } + = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w' do + = t('.back') diff --git a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml index 5b22e5325..3b538bdb1 100644 --- a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml +++ b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml @@ -11,8 +11,5 @@ available_instructeur_emails: @available_instructeur_emails, disabled_as_super_admin: @disabled_as_super_admin } -.padded-fixed-footer - .fixed-footer.fr-pb-2w - .fr-container - = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left' do - Revenir à l'écran de gestion + += render Procedure::FixedFooterComponent.new(procedure: @procedure) diff --git a/app/components/procedure/sva_svr_form_component/sva_svr_form_component.html.haml b/app/components/procedure/sva_svr_form_component/sva_svr_form_component.html.haml index 1eac6cb13..85719eb9a 100644 --- a/app/components/procedure/sva_svr_form_component/sva_svr_form_component.html.haml +++ b/app/components/procedure/sva_svr_form_component/sva_svr_form_component.html.haml @@ -37,11 +37,4 @@ = render Dsfr::RadioButtonListComponent.new(form: f, target: :resume, buttons: resume_buttons) - .padded-fixed-footer - .fixed-footer - .fr-container - %ul.fr-btns-group.fr-btns-group--inline-md - %li - = link_to "Annuler et revenir à l'écran de gestion", admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Êtes-vous sûr de vouloir annuler les modifications effectuées ?'} - %li - = f.submit t(".submit"), class: "fr-btn", disabled: form_disabled? + = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f, is_form_disabled: form_disabled?) diff --git a/app/views/administrateurs/dossier_submitted_messages/edit.html.haml b/app/views/administrateurs/dossier_submitted_messages/edit.html.haml index ed794cd73..dc289c291 100644 --- a/app/views/administrateurs/dossier_submitted_messages/edit.html.haml +++ b/app/views/administrateurs/dossier_submitted_messages/edit.html.haml @@ -30,11 +30,4 @@ .procedure-preview = render partial: 'users/dossiers/merci', locals: { procedure: @procedure, dossier: nil} - .padded-fixed-footer - .fixed-footer - .fr-container - %ul.fr-btns-group.fr-btns-group--inline-md - %li - = link_to "Annuler et revenir à l'écran de gestion", admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Êtes-vous sûr de vouloir annuler les modifications effectuées ?'} - %li - = f.button 'Enregistrer', class: 'fr-btn' + = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f) diff --git a/app/views/administrateurs/experts_procedures/index.html.haml b/app/views/administrateurs/experts_procedures/index.html.haml index 55995ed69..e1d6b6d88 100644 --- a/app/views/administrateurs/experts_procedures/index.html.haml +++ b/app/views/administrateurs/experts_procedures/index.html.haml @@ -108,8 +108,4 @@ %h2.empty-text Aucun expert invité pour le moment. %p.empty-text-details Les instructeurs de cette démarche n’ont pas encore fait appel aux experts. -.padded-fixed-footer - .fixed-footer.fr-pb-2w - .fr-container - = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left' do - Revenir à l'écran de gestion += render Procedure::FixedFooterComponent.new(procedure: @procedure) diff --git a/app/views/administrateurs/mail_templates/index.html.haml b/app/views/administrateurs/mail_templates/index.html.haml index 7a6640439..9fd744726 100644 --- a/app/views/administrateurs/mail_templates/index.html.haml +++ b/app/views/administrateurs/mail_templates/index.html.haml @@ -18,10 +18,4 @@ = render Procedure::EmailTemplateCardComponent.new(email_template: mail_template) -.padded-fixed-footer - .fixed-footer - .fr-container - .fr-grid-row - .fr-col-12.fr-pb-2w - = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w' do - Revenir à la démarche += render Procedure::FixedFooterComponent.new(procedure: @procedure) diff --git a/app/views/administrateurs/procedure_administrateurs/index.html.haml b/app/views/administrateurs/procedure_administrateurs/index.html.haml index 0545d0f43..3e9bf37aa 100644 --- a/app/views/administrateurs/procedure_administrateurs/index.html.haml +++ b/app/views/administrateurs/procedure_administrateurs/index.html.haml @@ -20,8 +20,4 @@ %tbody#administrateurs = render(Procedure::ProcedureAdministrateurs::AdministrateurComponent.with_collection(@procedure.administrateurs.order('users.email'), procedure: @procedure)) -.padded-fixed-footer - .fixed-footer.fr-pb-2w - .fr-container - = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left' do - Revenir à l'écran de gestion += render Procedure::FixedFooterComponent.new(procedure: @procedure) diff --git a/app/views/administrateurs/procedures/accuse_lecture.html.haml b/app/views/administrateurs/procedures/accuse_lecture.html.haml index 65b1e5143..90be126cc 100644 --- a/app/views/administrateurs/procedures/accuse_lecture.html.haml +++ b/app/views/administrateurs/procedures/accuse_lecture.html.haml @@ -33,10 +33,4 @@ hint: "L’accusé de lecture est à activer uniquement pour les démarches avec voies de recours car il complexifie l’accès à la décision finale pour les usagers", opt: {"checked" => @procedure.accuse_lecture}) -.padded-fixed-footer - .fixed-footer - .fr-container - .fr-grid-row - .fr-col-12.fr-col-offset-md-2.fr-col-md-8.fr-pb-2w - = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w' do - Revenir à l'écran de gestion += render Procedure::FixedFooterComponent.new(procedure: @procedure, extra_class_names: 'fr-col-offset-md-2 fr-col-md-8' ) diff --git a/app/views/administrateurs/procedures/edit.html.haml b/app/views/administrateurs/procedures/edit.html.haml index f79244721..95164e8e6 100644 --- a/app/views/administrateurs/procedures/edit.html.haml +++ b/app/views/administrateurs/procedures/edit.html.haml @@ -16,13 +16,4 @@ = render partial: 'administrateurs/procedures/informations', locals: { f: f } - .padded-fixed-footer - .fixed-footer - .fr-container - .fr-grid-row - .fr-col-12.fr-col-offset-md-2.fr-col-md-8 - %ul.fr-btns-group.fr-btns-group--inline-md - %li - = link_to "Annuler et revenir à l'écran de gestion", admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Êtes-vous sûr de vouloir annuler les modifications effectuées ?'} - %li - = f.button 'Enregistrer', class: 'fr-btn' + = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f, extra_class_names: 'fr-col-offset-md-2 fr-col-md-8') diff --git a/app/views/administrateurs/procedures/jeton.html.haml b/app/views/administrateurs/procedures/jeton.html.haml index 31d076d23..35d92ab90 100644 --- a/app/views/administrateurs/procedures/jeton.html.haml +++ b/app/views/administrateurs/procedures/jeton.html.haml @@ -22,11 +22,4 @@ = f.label :api_entreprise_token, "Jeton", class: 'fr-label' = f.password_field :api_entreprise_token, value: @procedure.read_attribute(:api_entreprise_token), class: 'fr-input' - .padded-fixed-footer - .fixed-footer - .fr-container - %ul.fr-btns-group.fr-btns-group--inline-md - %li - = link_to "Annuler et revenir à l'écran de gestion", admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Êtes-vous sûr de vouloir annuler les modifications effectuées ?'} - %li - = f.button 'Enregistrer', class: 'fr-btn' + = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f) diff --git a/app/views/administrateurs/procedures/modifications.html.haml b/app/views/administrateurs/procedures/modifications.html.haml index 6c890f8fe..ea9e9c32b 100644 --- a/app/views/administrateurs/procedures/modifications.html.haml +++ b/app/views/administrateurs/procedures/modifications.html.haml @@ -31,8 +31,4 @@ = render Procedure::RevisionChangesComponent.new changes:, previous_revision: - previous_revision = revision -.padded-fixed-footer - .fixed-footer.fr-pb-2w - .fr-container - = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left' do - Revenir à l'écran de gestion += render Procedure::FixedFooterComponent.new(procedure: @procedure) diff --git a/app/views/administrateurs/procedures/monavis.html.haml b/app/views/administrateurs/procedures/monavis.html.haml index 1b8189d6b..ee5e98fe9 100644 --- a/app/views/administrateurs/procedures/monavis.html.haml +++ b/app/views/administrateurs/procedures/monavis.html.haml @@ -11,11 +11,4 @@ .fr-container = render partial: 'monavis', locals: { f: f } - .padded-fixed-footer - .fixed-footer - .fr-container - %ul.fr-btns-group.fr-btns-group--inline-md - %li - = link_to "Annuler et revenir à l'écran de gestion", admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Êtes-vous sûr de vouloir annuler les modifications effectuées ?'} - %li - = f.button 'Enregistrer', class: 'fr-btn' + = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f) diff --git a/app/views/administrateurs/procedures/zones.html.haml b/app/views/administrateurs/procedures/zones.html.haml index a0f5989e0..f30e5c737 100644 --- a/app/views/administrateurs/procedures/zones.html.haml +++ b/app/views/administrateurs/procedures/zones.html.haml @@ -25,11 +25,4 @@ = b.check_box = b.label class: "fr-label" - .padded-fixed-footer - .fixed-footer - .fr-container - %ul.fr-btns-group.fr-btns-group--inline-md - %li - = link_to "Annuler et revenir à l'écran de gestion", admin_procedure_path(id: @procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Êtes-vous sûr de vouloir annuler les modifications effectuées ?'} - %li - = f.button 'Enregistrer', class: 'fr-btn' + = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f) diff --git a/app/views/administrateurs/services/_form.html.haml b/app/views/administrateurs/services/_form.html.haml index ec4a7233a..9c6040508 100644 --- a/app/views/administrateurs/services/_form.html.haml +++ b/app/views/administrateurs/services/_form.html.haml @@ -34,11 +34,4 @@ - if procedure_id.present? = hidden_field_tag :procedure_id, procedure_id - .padded-fixed-footer - .fixed-footer - .fr-container - %ul.fr-btns-group.fr-btns-group--inline-md - %li - = link_to "Annuler et revenir à l'écran de gestion", admin_procedure_path(id: @procedure.id), class: "fr-btn fr-btn--secondary" - %li - = f.submit "Enregistrer", class: "fr-btn" + = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f) diff --git a/app/views/administrateurs/services/index.html.haml b/app/views/administrateurs/services/index.html.haml index a837c14f6..123e76536 100644 --- a/app/views/administrateurs/services/index.html.haml +++ b/app/views/administrateurs/services/index.html.haml @@ -34,8 +34,4 @@ data: { confirm: "Confirmez vous la suppression de #{service.nom}" }, class: 'btn btn-link ml-2' -.padded-fixed-footer - .fixed-footer.fr-pb-2w - .fr-container - = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left' do - Revenir à l'écran de gestion += render Procedure::FixedFooterComponent.new(procedure: @procedure) From 291271f04b718cfcd01a4265ad1d4809972af1d0 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Tue, 28 May 2024 10:58:03 +0200 Subject: [PATCH 0310/1532] fix linter and specs --- .../administrateurs/jeton_particulier/show.html.haml | 11 +++++------ config/locales/views/layouts/_breadcrumb.en.yml | 2 -- config/locales/views/layouts/_breadcrumb.fr.yml | 2 -- .../administrateurs/procedures_controller_spec.rb | 2 +- .../administrateurs/procedure_administrateurs_spec.rb | 2 +- .../procedure_groupe_instructeur_spec.rb | 2 +- 6 files changed, 8 insertions(+), 13 deletions(-) diff --git a/app/views/administrateurs/jeton_particulier/show.html.haml b/app/views/administrateurs/jeton_particulier/show.html.haml index 7527313eb..f0ded6bdb 100644 --- a/app/views/administrateurs/jeton_particulier/show.html.haml +++ b/app/views/administrateurs/jeton_particulier/show.html.haml @@ -2,14 +2,13 @@ locals: { steps: [['Démarches', admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], [Procedure.human_attribute_name(:jeton_api_particulier), admin_procedure_api_particulier_path(@procedure)], - ['Jeton']] } + ['Jeton Particulier']] } -.container - %h1.page-title - = t('.configure_token') +.fr-container + %h1.fr-h2 + Jeton Particulier -.container - %h1 +.fr-container = form_with model: @procedure, url: admin_procedure_api_particulier_jeton_path, local: true do |f| = render Dsfr::AlertComponent.new(state: :info, size: :sm, extra_class_names: 'fr-mb-2w') do |c| - c.with_body do diff --git a/config/locales/views/layouts/_breadcrumb.en.yml b/config/locales/views/layouts/_breadcrumb.en.yml index 8efbc086e..f0a4ff07d 100644 --- a/config/locales/views/layouts/_breadcrumb.en.yml +++ b/config/locales/views/layouts/_breadcrumb.en.yml @@ -6,9 +6,7 @@ en: show: Show breadcrumb preview: "Preview the form" preview_annotations: "Preview annotations" - continue: "Validate form" continue_annotations: "Validate annotations" - continue_title: "You can comeback using this link" since: "since %{date}" closed: "Closed" published: "Published" diff --git a/config/locales/views/layouts/_breadcrumb.fr.yml b/config/locales/views/layouts/_breadcrumb.fr.yml index 14f4fa15f..9605fb9ef 100644 --- a/config/locales/views/layouts/_breadcrumb.fr.yml +++ b/config/locales/views/layouts/_breadcrumb.fr.yml @@ -6,9 +6,7 @@ fr: show: "Voir le fil d’Ariane" preview: "Prévisualiser le formulaire" preview_annotations: "Prévisualiser les annotations" - continue: "Valider le formulaire" continue_annotations: "Valider les annotations" - continue_title: "Vous pourrez revenir ici par la suite" since: "depuis le %{date}" closed: "Close" published: "Publiée" diff --git a/spec/controllers/administrateurs/procedures_controller_spec.rb b/spec/controllers/administrateurs/procedures_controller_spec.rb index 1193b769f..e19155b82 100644 --- a/spec/controllers/administrateurs/procedures_controller_spec.rb +++ b/spec/controllers/administrateurs/procedures_controller_spec.rb @@ -977,7 +977,7 @@ describe Administrateurs::ProceduresController, type: :controller do end it { expect(flash[:notice]).to be_present } - it { expect(response.body).to include "MonAvis" } + it { expect(response).to redirect_to(admin_procedure_path(procedure.id)) } end context 'when the embed code is not valid' do diff --git a/spec/system/administrateurs/procedure_administrateurs_spec.rb b/spec/system/administrateurs/procedure_administrateurs_spec.rb index 8d6d3bea6..420c439b3 100644 --- a/spec/system/administrateurs/procedure_administrateurs_spec.rb +++ b/spec/system/administrateurs/procedure_administrateurs_spec.rb @@ -14,7 +14,7 @@ describe 'Administrateurs can manage administrateurs', js: true do scenario 'card is clickable' do visit admin_procedure_path(procedure) find('#administrateurs').click - expect(page).to have_css("h1", text: "Gérer les administrateurs de « #{procedure.libelle} »") + expect(page).to have_css("h1", text: "Administrateurs") end context 'as admin not flagged from manager' do diff --git a/spec/system/administrateurs/procedure_groupe_instructeur_spec.rb b/spec/system/administrateurs/procedure_groupe_instructeur_spec.rb index c5ac2c33a..6dd065aae 100644 --- a/spec/system/administrateurs/procedure_groupe_instructeur_spec.rb +++ b/spec/system/administrateurs/procedure_groupe_instructeur_spec.rb @@ -17,7 +17,7 @@ describe 'Manage procedure instructeurs', js: true do scenario 'it works' do visit admin_procedure_path(procedure) find('#groupe-instructeurs').click - expect(page).to have_css("h1", text: "Gestion des instructeurs") + expect(page).to have_css("h1", text: "Instructeurs") end end From 4446d6d62abe8471af34a9c54e8fa516509b40fa Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Thu, 30 May 2024 16:33:25 +0200 Subject: [PATCH 0311/1532] remove footer for admin pages with sticky footer --- .../fixed_footer_component/fixed_footer_component.html.haml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/components/procedure/fixed_footer_component/fixed_footer_component.html.haml b/app/components/procedure/fixed_footer_component/fixed_footer_component.html.haml index 2116e38ff..c9ebaaa0f 100644 --- a/app/components/procedure/fixed_footer_component/fixed_footer_component.html.haml +++ b/app/components/procedure/fixed_footer_component/fixed_footer_component.html.haml @@ -1,3 +1,6 @@ +- content_for(:footer) do +   + - if form .padded-fixed-footer .fixed-footer From 1bc7cfd9176c616d76998e22547385688dcad208 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Tue, 28 May 2024 14:56:32 +0200 Subject: [PATCH 0312/1532] remove card historique des modifications --- .../procedure/card/modifications_component.rb | 9 --------- .../modifications_component.fr.yml | 5 ----- .../modifications_component.html.haml | 12 ------------ .../administrateurs/procedures/champs.html.haml | 6 +++++- .../procedures/modifications.html.haml | 3 +++ app/views/administrateurs/procedures/show.html.haml | 1 - 6 files changed, 8 insertions(+), 28 deletions(-) delete mode 100644 app/components/procedure/card/modifications_component.rb delete mode 100644 app/components/procedure/card/modifications_component/modifications_component.fr.yml delete mode 100644 app/components/procedure/card/modifications_component/modifications_component.html.haml diff --git a/app/components/procedure/card/modifications_component.rb b/app/components/procedure/card/modifications_component.rb deleted file mode 100644 index 35b90a624..000000000 --- a/app/components/procedure/card/modifications_component.rb +++ /dev/null @@ -1,9 +0,0 @@ -class Procedure::Card::ModificationsComponent < ApplicationComponent - def initialize(procedure:) - @procedure = procedure - end - - def render? - @procedure.revised? - end -end diff --git a/app/components/procedure/card/modifications_component/modifications_component.fr.yml b/app/components/procedure/card/modifications_component/modifications_component.fr.yml deleted file mode 100644 index 676fc64cd..000000000 --- a/app/components/procedure/card/modifications_component/modifications_component.fr.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -fr: - title: - one: Modification du formulaire - other: Modifications du formulaire diff --git a/app/components/procedure/card/modifications_component/modifications_component.html.haml b/app/components/procedure/card/modifications_component/modifications_component.html.haml deleted file mode 100644 index 9dc203c17..000000000 --- a/app/components/procedure/card/modifications_component/modifications_component.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -.fr-col-6.fr-col-md-4.fr-col-lg-3 - = link_to modifications_admin_procedure_path(@procedure), id: 'modifications', class: 'fr-tile fr-enlarge-link' do - .fr-tile__body.flex.column.align-center.justify-between - %p.fr-badge.fr-badge--success Activée - %div - .line-count.fr-my-1w - %p.fr-tag= @procedure.revisions_count - %h3.fr-h6 - = t('.title', count: @procedure.revisions_count) - - %p.fr-tile-subtitle Historique des modifications apportées au formulaire - %p.fr-btn.fr-btn--tertiary Voir diff --git a/app/views/administrateurs/procedures/champs.html.haml b/app/views/administrateurs/procedures/champs.html.haml index 95ddf2860..1d397ebb0 100644 --- a/app/views/administrateurs/procedures/champs.html.haml +++ b/app/views/administrateurs/procedures/champs.html.haml @@ -4,7 +4,11 @@ ['Champs du formulaire']], preview: @procedure.draft_revision.valid? } .fr-container - %h1.fr-h2 Champs du formulaire + .flex.justify-between.align-center.fr-mb-3w + %h1.fr-h2 Champs du formulaire + - if @procedure.revised? + = link_to "Voir l'historique des modifications du formulaire", modifications_admin_procedure_path(@procedure), class: 'fr-link' + = render NestedForms::FormOwnerComponent.new .fr-grid-row = render partial: 'champs_summary' diff --git a/app/views/administrateurs/procedures/modifications.html.haml b/app/views/administrateurs/procedures/modifications.html.haml index ea9e9c32b..978fee30f 100644 --- a/app/views/administrateurs/procedures/modifications.html.haml +++ b/app/views/administrateurs/procedures/modifications.html.haml @@ -1,8 +1,11 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_back_path(@procedure)], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], + ['Champs du formulaire', champs_admin_procedure_path(@procedure)], ['Historique des modifications du formulaire']] } .fr-container + .fr-mb-3w + = link_to "Champs du formulaire", champs_admin_procedure_path(@procedure), class: "fr-link fr-icon-arrow-left-line fr-link--icon-left" %h1.fr-h2 Historique des modifications du formulaire diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml index a4c1f16d3..c510fa022 100644 --- a/app/views/administrateurs/procedures/show.html.haml +++ b/app/views/administrateurs/procedures/show.html.haml @@ -75,7 +75,6 @@ = render Procedure::Card::ServiceComponent.new(procedure: @procedure, administrateur: current_administrateur) = render Procedure::Card::AdministrateursComponent.new(procedure: @procedure) = render Procedure::Card::InstructeursComponent.new(procedure: @procedure) - = render Procedure::Card::ModificationsComponent.new(procedure: @procedure) %h3.fr-h6 Pour aller plus loin .fr-grid-row.fr-grid-row--gutters.fr-mb-5w From ee203d1afc198c71c1f6eba9ded8be88ddabd738 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Tue, 28 May 2024 16:40:15 +0200 Subject: [PATCH 0313/1532] reorganize page config expert invites --- app/components/dsfr/toggle_component.rb | 5 +- .../toggle_component.html.haml | 2 +- .../experts_procedures/index.html.haml | 170 +++++++++--------- .../administrateurs/experts_procedures/fr.yml | 5 +- .../index.html.haml_spec.rb | 4 +- 5 files changed, 100 insertions(+), 86 deletions(-) diff --git a/app/components/dsfr/toggle_component.rb b/app/components/dsfr/toggle_component.rb index 20c328e9b..f0a4114b0 100644 --- a/app/components/dsfr/toggle_component.rb +++ b/app/components/dsfr/toggle_component.rb @@ -1,5 +1,5 @@ class Dsfr::ToggleComponent < ApplicationComponent - def initialize(form:, target:, title:, disabled: nil, hint: nil, toggle_labels: { checked: 'Activé', unchecked: 'Désactivé' }, opt: nil) + def initialize(form:, target:, title:, disabled: nil, hint: nil, toggle_labels: { checked: 'Activé', unchecked: 'Désactivé' }, opt: nil, extra_class_names: nil) @form = form @target = target @title = title @@ -7,7 +7,8 @@ class Dsfr::ToggleComponent < ApplicationComponent @disabled = disabled @toggle_labels = toggle_labels @opt = opt + @extra_class_names = extra_class_names end - attr_reader :toggle_labels + attr_reader :toggle_labels, :extra_class_names end diff --git a/app/components/dsfr/toggle_component/toggle_component.html.haml b/app/components/dsfr/toggle_component/toggle_component.html.haml index 7769a3724..18bde573d 100644 --- a/app/components/dsfr/toggle_component/toggle_component.html.haml +++ b/app/components/dsfr/toggle_component/toggle_component.html.haml @@ -1,4 +1,4 @@ -.fr-toggle.fr-toggle--label-left +%div{ class: "fr-toggle fr-toggle--label-left #{extra_class_names}" } = @form.check_box @target, class: 'fr-toggle__input', disabled: @disabled, data: @opt = @form.label @target, diff --git a/app/views/administrateurs/experts_procedures/index.html.haml b/app/views/administrateurs/experts_procedures/index.html.haml index e1d6b6d88..dc8909f82 100644 --- a/app/views/administrateurs/experts_procedures/index.html.haml +++ b/app/views/administrateurs/experts_procedures/index.html.haml @@ -7,102 +7,112 @@ %h1.fr-h2 Avis externes - .groupe-instructeur - .card - .card-title= t('.titles.allow_invite_experts') - %p= t('.descriptions.allow_invite_experts') + = render Dsfr::CalloutComponent.new(title: nil) do |c| + - c.with_body do + Pendant l'instruction d'un dossier, les instructeurs peuvent demander leur avis à un ou plusieurs experts. + %p + = link_to('Comment gérer les avis externes', t('.experts_doc.url'), + title: t('.experts_doc.title'), + **external_link_attributes) + + %ul.fr-toggle__list + %li = form_for @procedure, method: :put, url: allow_expert_review_admin_procedure_path(@procedure), - html: { class: 'form procedure-form__column--form no-background' } do |f| - %label.toggle-switch{ data: { controller: 'autosubmit' } } - = f.check_box :allow_expert_review, class: 'toggle-switch-checkbox' - %span.toggle-switch-control.round - %span.toggle-switch-label.on - %span.toggle-switch-label.off + data: { controller: 'autosubmit', turbo: 'true' } do |f| + + = render Dsfr::ToggleComponent.new(form: f, + target: :allow_expert_review, + title: t('.titles.allow_invite_experts'), + hint: t('.descriptions.allow_invite_experts'), + disabled: false, + extra_class_names: 'fr-toggle--border-bottom') - if @procedure.allow_expert_review? - .card - .card-title= t('.titles.manage_procedure_experts') - %p= t('.descriptions.manage_procedure_experts') + %li + = form_for @procedure, + method: :put, + url: allow_expert_messaging_admin_procedure_path(@procedure), + data: { controller: 'autosubmit', turbo: 'true' } do |f| + + = render Dsfr::ToggleComponent.new(form: f, + target: :allow_expert_messaging, + title: t('.titles.allow_expert_messaging'), + hint: t('.descriptions.allow_expert_messaging'), + disabled: false, + extra_class_names: 'fr-toggle--border-bottom') + + %li = form_for @procedure, method: :put, url: experts_require_administrateur_invitation_admin_procedure_path(@procedure), - html: { class: 'form procedure-form__column--form no-background' } do |f| - %label.toggle-switch{ data: { controller: 'autosubmit' } } - = f.check_box :experts_require_administrateur_invitation, class: 'toggle-switch-checkbox' - %span.toggle-switch-control.round - %span.toggle-switch-label.on - %span.toggle-switch-label.off + data: { controller: 'autosubmit', turbo: 'true' } do |f| - .card - .card-title= t('.titles.allow_expert_messaging') - %p= t('.descriptions.allow_expert_messaging') - = form_for @procedure, - method: :put, - url: allow_expert_messaging_admin_procedure_path(@procedure), - html: { class: 'form procedure-form__column--form no-background' } do |f| - %label.toggle-switch{ data: { controller: 'autosubmit' } } - = f.check_box :allow_expert_messaging, class: 'toggle-switch-checkbox' - %span.toggle-switch-control.round - %span.toggle-switch-label.on - %span.toggle-switch-label.off + = render Dsfr::ToggleComponent.new(form: f, + target: :experts_require_administrateur_invitation, + title: t('.titles.manage_procedure_experts'), + hint: t('.descriptions.manage_procedure_experts'), + disabled: false) - - if @procedure.experts_require_administrateur_invitation? - .card - .card-title Affecter des experts à la démarche - = form_for :experts_procedure, - url: admin_procedure_experts_path(@procedure), - html: { class: 'form' } do |f| - .instructeur-wrapper - %p Pendant l'instruction d’un dossier, les instructeurs peuvent demander leur avis à un ou plusieurs experts. - %p#experts-emails Entrez les adresses email des experts que vous souhaitez affecter à cette démarche - = hidden_field_tag :emails, nil - = react_component("ComboMultiple", - options: [], - selected: [], disabled: [], - group: '.instructeur-wrapper', - name: 'emails', - label: 'Emails', - describedby: 'experts-emails', - acceptNewValues: true) + - if @procedure.experts_require_administrateur_invitation? + .card + = form_for :experts_procedure, + url: admin_procedure_experts_path(@procedure), + html: { class: 'form' } do |f| + + .instructeur-wrapper + %p#experts-emails Entrez les adresses emails des experts que vous souhaitez ajouter à la liste prédéfinie + = hidden_field_tag :emails, nil + = react_component("ComboMultiple", + options: [], + selected: [], disabled: [], + group: '.instructeur-wrapper', + name: 'emails', + label: 'Emails', + describedby: 'experts-emails', + acceptNewValues: true) + + = f.submit 'Ajouter à la liste', class: 'fr-btn' - = f.submit 'Affecter à la démarche', class: 'button primary send' - if @experts_procedure.present? - %table.table.mt-2 - %thead - %tr - %th Liste des experts - %th Nombre d’avis - - if @procedure.experts_require_administrateur_invitation - %th Notifier des décisions sur les dossiers - %tbody - - @experts_procedure.each do |expert_procedure| + .fr-table.fr-table--no-caption.fr-table--layout-fixed.fr-mt-3w + %table + %thead %tr - %td - = dsfr_icon('fr-icon-user-fill') - = expert_procedure.expert.email - %td.text-center - = expert_procedure.avis.count + %th Liste des experts + %th Nombre d’avis - if @procedure.experts_require_administrateur_invitation + %th Notifier des décisions sur les dossiers + - if @procedure.experts_require_administrateur_invitation + %th Action + %tbody + - @experts_procedure.each do |expert_procedure| + %tr + %td + = dsfr_icon('fr-icon-user-fill') + = expert_procedure.expert.email %td.text-center - = form_for expert_procedure, - url: admin_procedure_expert_path(id: expert_procedure), - method: :put, - data: { turbo: true }, - html: { class: 'form procedure-form__column--form no-background' } do |f| - %label.toggle-switch{ data: { controller: 'autosubmit' } } - = f.check_box :allow_decision_access, class: 'toggle-switch-checkbox' - %span.toggle-switch-control.round - %span.toggle-switch-label.on - %span.toggle-switch-label.off - - if @procedure.experts_require_administrateur_invitation - %td.actions= button_to 'retirer', - admin_procedure_expert_path(id: expert_procedure, procedure: @procedure), - method: :delete, - data: { confirm: "Êtes-vous sûr de vouloir révoquer l'expert « #{expert_procedure.expert.email} » de la démarche #{expert_procedure.procedure.libelle} ? Les instructeurs ne pourront plus lui demander d’avis" }, - class: 'button' + = expert_procedure.avis.count + - if @procedure.experts_require_administrateur_invitation + %td.text-center + = form_for expert_procedure, + url: admin_procedure_expert_path(id: expert_procedure), + method: :put, + data: { turbo: true }, + html: { class: 'form procedure-form__column--form no-background' } do |f| + %label.toggle-switch{ data: { controller: 'autosubmit' } } + = f.check_box :allow_decision_access, class: 'toggle-switch-checkbox' + %span.toggle-switch-control.round + %span.toggle-switch-label.on + %span.toggle-switch-label.off + - if @procedure.experts_require_administrateur_invitation + %td.actions= button_to 'retirer', + admin_procedure_expert_path(id: expert_procedure, procedure: @procedure), + method: :delete, + data: { confirm: "Êtes-vous sûr de vouloir révoquer l'expert « #{expert_procedure.expert.email} » de la démarche #{expert_procedure.procedure.libelle} ? Les instructeurs ne pourront plus lui demander d’avis" }, + class: 'fr-btn fr-btn--secondary' - else .blank-tab %h2.empty-text Aucun expert invité pour le moment. diff --git a/config/locales/views/administrateurs/experts_procedures/fr.yml b/config/locales/views/administrateurs/experts_procedures/fr.yml index 62db8096c..673b0a609 100644 --- a/config/locales/views/administrateurs/experts_procedures/fr.yml +++ b/config/locales/views/administrateurs/experts_procedures/fr.yml @@ -6,8 +6,11 @@ fr: main: Experts invités sur %{libelle} allow_invite_experts: "Autoriser les instructeurs à solliciter des experts invités" allow_expert_messaging: "Autoriser les experts à accéder à la messagerie usager" - manage_procedure_experts: "Gérer les experts invités de la démarche" + manage_procedure_experts: "Gérer les experts invités de la démarche avec une liste prédéfinie" descriptions: allow_invite_experts : Lorsque cette fonctionnalité est active, les instructeurs peuvent solliciter les experts allow_expert_messaging: Lorsque cette fonctionnalité est active, les experts peuvent demander des informations aux usagers manage_procedure_experts: Lorsque cette fonctionnalité est active, les instructeurs peuvent uniquement solliciter les experts de votre liste + experts_doc: + title: Avis externes documentation + url: 'https://app.gitbook.com/o/-L7_aClyGhmMzzsqtO4_/s/-L7_aKvpAJdAIEfxHudA/tutoriels/tutoriel-administrateur/~/comments?context=post&node=ff7bd481c1994d6aa56817b7237b9e59#id-12.-la-gestion-des-avis-experts-invites-de-votre-demarche' diff --git a/spec/views/administrateurs/experts_procedures/index.html.haml_spec.rb b/spec/views/administrateurs/experts_procedures/index.html.haml_spec.rb index fcc3aa25c..18b91611f 100644 --- a/spec/views/administrateurs/experts_procedures/index.html.haml_spec.rb +++ b/spec/views/administrateurs/experts_procedures/index.html.haml_spec.rb @@ -40,7 +40,7 @@ describe 'administrateurs/experts_procedures/index', type: :view do context 'when the experts_require_administrateur_invitation is false' do it 'authorize instructors to invite any expert' do - expect(rendered).not_to have_content "Affecter des experts à la démarche" + expect(rendered).not_to have_content "Entrez les adresses emails des experts que vous souhaitez ajouter à la liste prédéfinie" end end @@ -50,7 +50,7 @@ describe 'administrateurs/experts_procedures/index', type: :view do subject end it 'does not authorize instructors to invite any expert but only those presents in admin list' do - expect(rendered).to have_content "Affecter des experts à la démarche" + expect(rendered).to have_content "Entrez les adresses emails des experts que vous souhaitez ajouter à la liste prédéfinie" end end end From ab54b60489b8705645e483a3def39f7282ce1131 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Thu, 30 May 2024 18:17:08 +0200 Subject: [PATCH 0314/1532] add dsfr callout --- app/assets/stylesheets/card_admin.scss | 4 ++ .../procedures/new_from_existing.html.haml | 52 +++++++++---------- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/app/assets/stylesheets/card_admin.scss b/app/assets/stylesheets/card_admin.scss index 8f678d3aa..5001efce2 100644 --- a/app/assets/stylesheets/card_admin.scss +++ b/app/assets/stylesheets/card_admin.scss @@ -4,3 +4,7 @@ .fr-tile-subtitle { min-height: 7rem; } + +.card-welcome { + margin: 30px auto; +} diff --git a/app/views/administrateurs/procedures/new_from_existing.html.haml b/app/views/administrateurs/procedures/new_from_existing.html.haml index 76ccd4178..d5f4adf58 100644 --- a/app/views/administrateurs/procedures/new_from_existing.html.haml +++ b/app/views/administrateurs/procedures/new_from_existing.html.haml @@ -1,31 +1,31 @@ .container - if current_administrateur.procedures.brouillons.count == 0 - .card.feedback - .card-title - Bienvenue, - %br - vous allez pouvoir créer une première démarche de test. - Celle-ci sera visible uniquement par vous et ne sera publiée nulle part, alors pas de crainte à avoir. - %br - %br - Besoin d’aide ? - %br - > Vous pouvez - = link_to "visionner cette vidéo", - "https://vimeo.com/261478872", - target: "_blank" - %br - > Vous pouvez lire notre - = link_to "documentation en ligne", - ADMINISTRATEUR_TUTORIAL_URL, - target: "_blank" - - %br - > Vous pouvez enfin - = link_to "prendre un rendez-vous téléphonique avec nous", - CALENDLY_URL, - target: "_blank" - + .card-welcome + = render Dsfr::CalloutComponent.new(title: nil, icon: "fr-icon-information-line") do |c| + - c.with_html_body do + %p + Bienvenue, + %br + vous allez pouvoir créer une première démarche de test. + Celle-ci sera visible uniquement par vous et ne sera publiée nulle part, alors pas de crainte à avoir. + %br + %br + Besoin d’aide ? + %br + > Vous pouvez + = link_to "visionner cette vidéo", + "https://vimeo.com/261478872", + target: "_blank" + %br + > Vous pouvez lire notre + = link_to "documentation en ligne", + ADMINISTRATEUR_TUTORIAL_URL, + target: "_blank" + %br + > Vous pouvez enfin + = link_to "prendre un rendez-vous téléphonique avec nous", + CALENDLY_URL, + target: "_blank" :javascript document.addEventListener("DOMContentLoaded", function() { $crisp.push(["do", "trigger:run", ["admin-signup"]]); From 60c1eb51c508ee55d8f821453d5dc3ad92bd5269 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 30 May 2024 22:18:57 +0200 Subject: [PATCH 0315/1532] fix(css): do not inline all DSFR svg --- vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vite.config.ts b/vite.config.ts index 30cff164b..8fee5f60c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -42,7 +42,7 @@ if (shouldBuildLegacy()) { export default defineConfig({ resolve: { alias: { '@utils': '/shared/utils.ts' } }, - build: { sourcemap: true }, + build: { sourcemap: true, assetsInlineLimit: 0 }, plugins }); From a278aac92fc187488013285d15760c48adb996f6 Mon Sep 17 00:00:00 2001 From: mfo Date: Fri, 31 May 2024 08:15:26 +0200 Subject: [PATCH 0316/1532] fix(spec): maj broken spec on main --- spec/models/batch_operation_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/models/batch_operation_spec.rb b/spec/models/batch_operation_spec.rb index da24e7b38..07346e116 100644 --- a/spec/models/batch_operation_spec.rb +++ b/spec/models/batch_operation_spec.rb @@ -239,17 +239,17 @@ describe BatchOperation, type: :model do context 'accepter' do let(:operation) { :accepter } - it { expect { subject.process_one(dossier) }.to have_enqueued_job.on_queue(Rails.application.config.action_mailer.deliver_later_queue_name) } + it { expect { subject.process_one(dossier) }.to have_enqueued_job(PriorizedMailDeliveryJob) } end context 'refuser' do let(:operation) { :refuser } - it { expect { subject.process_one(dossier) }.to have_enqueued_job.on_queue(Rails.application.config.action_mailer.deliver_later_queue_name) } + it { expect { subject.process_one(dossier) }.to have_enqueued_job(PriorizedMailDeliveryJob) } end context 'classer_sans_suite' do let(:operation) { :classer_sans_suite } - it { expect { subject.process_one(dossier) }.to have_enqueued_job.on_queue(Rails.application.config.action_mailer.deliver_later_queue_name) } + it { expect { subject.process_one(dossier) }.to have_enqueued_job(PriorizedMailDeliveryJob) } end end From d44822cc1cf519097e40bbfdab7559599df50721 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 4 Mar 2024 16:35:09 +0100 Subject: [PATCH 0317/1532] chore(js): remove vite legacy build --- app/helpers/application_helper.rb | 8 ---- app/helpers/vite_helper.rb | 35 --------------- app/javascript/entrypoints/application.js | 7 --- .../_outdated_browser_banner.html.haml | 4 +- app/views/layouts/application.html.haml | 7 --- bun.lockb | Bin 549348 -> 495684 bytes config/env.example.optional | 4 -- package.json | 9 +--- spec/system/outdated_browser_spec.rb | 2 +- vite.config.ts | 40 +----------------- 10 files changed, 4 insertions(+), 112 deletions(-) delete mode 100644 app/helpers/vite_helper.rb diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 71a35dafd..138c57bb6 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -126,14 +126,6 @@ module ApplicationHelper !BrowserSupport.supported?(browser) end - def vite_legacy? - if ENV['VITE_LEGACY'] == 'disabled' - false - else - Rails.env.production? || ENV['VITE_LEGACY'] == 'enabled' - end - end - def external_link_attributes { target: "_blank", rel: "noopener noreferrer" } end diff --git a/app/helpers/vite_helper.rb b/app/helpers/vite_helper.rb deleted file mode 100644 index dbea57046..000000000 --- a/app/helpers/vite_helper.rb +++ /dev/null @@ -1,35 +0,0 @@ -module ViteHelper - # This module is a port of code in @vitejs/plugin-legacy. We need it because ruby vite_plugin_legacy - # has ommited to implement this logic. Original code here: - # https://github.com/vitejs/vite/blob/722f5148ea494cdc15379d3a98dca0751131ca22/packages/plugin-legacy/src/index.ts#L408-L532 - - SAFARI_10_NO_MODULE_FIX = "!function(){var e=document,t=e.createElement('script');if(!('noModule'in t)&&'onbeforeload'in t){var n=!1;e.addEventListener('beforeload',(function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute('nomodule')||!n)return;e.preventDefault()}),!0),t.type='module',t.src='.',e.head.appendChild(t),t.remove()}}();" - - LEGACY_POLYFILL_ID = 'vite-legacy-polyfill' - LEGACY_ENTRY_ID = 'vite-legacy-entry' - SYSTEM_JS_INLINE_CODE = "document.querySelectorAll('script[data-legacy-entry]').forEach((e) => System.import(e.getAttribute('data-src')))" - - DETECT_MODERN_BROWSER_VARNAME = '__vite_is_modern_browser' - DETECT_MODERN_BROWSER_CODE = "try{import.meta.url;import('_').catch(()=>1);}catch(e){}window.#{DETECT_MODERN_BROWSER_VARNAME}=true;" - DYNAMIC_FALLBACK_INLINE_CODE = "!function(){if(window.#{DETECT_MODERN_BROWSER_VARNAME})return;console.warn('vite: loading legacy build because dynamic import or import.meta.url is unsupported, syntax error above should be ignored');var e=document.getElementById('#{LEGACY_POLYFILL_ID}'),n=document.createElement('script');n.src=e.src,n.onload=function(){#{SYSTEM_JS_INLINE_CODE}},document.body.appendChild(n)}();" - - def vite_legacy_javascript_tag(name, asset_type: :javascript) - legacy_name = name.sub(/(\..+)|$/, '-legacy\1') - src = vite_asset_path(legacy_name, type: :virtual) - javascript_include_tag(src, nomodule: true, 'data-legacy-entry': true, 'data-src': src) - end - - def vite_legacy_polyfill_tag - safe_join [ - javascript_tag(SAFARI_10_NO_MODULE_FIX, type: :module, nonce: true), - javascript_include_tag(vite_asset_path('legacy-polyfills', type: :virtual), nomodule: true, id: LEGACY_POLYFILL_ID) - ] - end - - def vite_legacy_fallback_tag - safe_join [ - javascript_tag(DETECT_MODERN_BROWSER_CODE, type: :module, nonce: true), - javascript_tag(DYNAMIC_FALLBACK_INLINE_CODE, type: :module, nonce: true) - ] - end -end diff --git a/app/javascript/entrypoints/application.js b/app/javascript/entrypoints/application.js index 0ceb3b061..867cea059 100644 --- a/app/javascript/entrypoints/application.js +++ b/app/javascript/entrypoints/application.js @@ -48,12 +48,5 @@ Turbo.session.drive = false; // Expose globals window.DS = window.DS || DS; -// enable legacy mode of DSFR when vite is not detectde as modern browser -window.addEventListener('load', function () { - if (!window.__vite_is_modern_browser) { - window.dsfr.internals.legacy.setLegacy(); - } -}); - import('../shared/track/matomo'); import('../shared/track/sentry'); diff --git a/app/views/layouts/_outdated_browser_banner.html.haml b/app/views/layouts/_outdated_browser_banner.html.haml index cbf9e91cd..c3605fd12 100644 --- a/app/views/layouts/_outdated_browser_banner.html.haml +++ b/app/views/layouts/_outdated_browser_banner.html.haml @@ -1,9 +1,7 @@ - if show_outdated_browser_banner? = render Dsfr::AlertComponent.new(state: :warning, title: "Navigateur trop ancien", heading_level: :h2) do |c| - c.with_body do - Votre navigateur internet, #{browser.name} #{browser.version}, est malheureusement trop ancien. Il ne sera plus compatible avec #{APPLICATION_NAME} à partir du  - %strong - 1 juin 2024. + Votre navigateur internet, #{browser.name} #{browser.version}, est malheureusement trop ancien. Il n’est plus compatible avec #{APPLICATION_NAME}. %br Veuillez installer un navigateur plus récent en suivant le lien suivant : %br diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index aba53cdd3..36755078f 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -22,13 +22,6 @@ - if administrateur_signed_in? = vite_javascript_tag 'track-admin' - - if vite_legacy? - = vite_legacy_polyfill_tag - = vite_legacy_javascript_tag 'application' - - if administrateur_signed_in? - = vite_legacy_javascript_tag 'track-admin' - = vite_legacy_fallback_tag - = preload_link_tag(asset_url("Marianne-Regular.woff2")) = preload_link_tag(asset_url("Spectral-Regular.ttf")) diff --git a/bun.lockb b/bun.lockb index f15253d9243a6905b2330e94ff12229cd4579dbd..e44c7900991f0d6391e1bc9a82bea46854df15b0 100755 GIT binary patch delta 100332 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%0D?%tO; zrgJG(3D-LVb9L!x)#^X``eAefS+|pUER}n^nVNBlT&HNJ9$n%r%x`W#`-9?Tol$IGQJwSXmt7tNLLWd^-OE$yee=7pfTVb$Kr)j}ErHTdfpMx5D5YV#ErHTyD|@**Mf z6*aNu!TgKcmX~7aS(e>A%>4J+hix)b*<7uaioHVFeiE2k6~4-)&K})Ejl9YzaI%$J zd=>Lit<{yQmg1o+TigC{BdS@)2JM@7Xl+VWyyJ&P>0(p6I%I6a`y=G%rz;uKF-@#s z)W1er$J?m!*I3Cg($czey=F;fUfEi?T(>m+7RY^5#C2-czny*Yrd#dCO$uFfO=_!I zTLVAWPOZL9vdLW39b%gMwpSHyP(k}T*lRk)mvkkztvET-RzX2>_i?6!ns$RW$CB6j z8)W1zmfXli?dzaMzqh#g24YmaqdoT2D|2exZq~h_^f+#RWccl<+TS8aKTSWcm_&6_ zRc?}lVV&%);ZWwHdrr1*eM8c)jbbL%Nk!iz2hp9>#+#O~;Gd>t8(*SGLq?t>t}i#< zDAs^E9_@v!e9yCx-sF(c*nGA_lqwRK-IfJz zk&Vk_pgB=@Rs0SE$S>{<7-CX)S4;13jG*r7+8xUgnZB*O%YK|I`L{mpeVAcN#>3qQ zAFPY07uLacK-)M}u!oAeM>Mi8$UU++t*3qD%9i({b#1(l^}ffz!hD|?i+ZX` z_c=*?13kNQ@y%7+2^J#S=i@3YTA7|h@No#yfp;oW|h5oNA*b3wVwqy ztJKTkKFaq2qruHS_ET`@Sj0hF_klI>E3NqrQ|7)L1zXhM{H)ggwq*W2BqgZZ=Q1c(s1olO! zSL}PAB!)DB#Ag$?&U@f;RgTFs0BHhOBUI5xSo|};dj4Rt=O)ZtHd0+7SKcwaeIYm3 z+4FoY*U70(JksJSBtuA^2$k$HN8dM4mBdZ;`A_>5tL9D*i>XnklD0YslkbV90#t`N8)Ka{lv7d0L{i;9dd98;N$GRVMlhxonObY#fT!4~w;sAO8 zZ)Mq@|9JoO`46=jWO_4H9eXNQkzw``t6++o!#&)yxKeIu9gJ;R36Z)h6|YUUhWXPR zCq!Blt+wzmkU&O%p!&Gbby?YWP88HT_!)I1{6q&Bm3J5R_AEu(kVmcOb z)enl^m$C0@mnv8U+h(_>AF9{9c$jj2ZpmiU@V^*h^xatSV~!#1=jyNw&d>7BO~Ntc zj+JxXYq)(RP8olh8rAnrMzez~49Pe;WVqVOom}u(EM$T;yJ5<_1;PUvXKiKXesvll z(vGvV-qd^Ym~CZ=E44vo&l|2>Us|}`e_=@$x*jumHuKMhV1%5&LOR>d>%%9u8I{}0 zVBqh^;}9NXk5D6DP_1K!tA#J9R-GyDUuVjG=rHqxkt+Np)%#_neGn+Jqf6m(jT`on z?AuG2e3V-G%HnRJu<1vsr5NkOonRvGhAKg)U&zgD}U0- zGxs;5YfG~cBgq*1_|f{erOP`%vaKuXHbmPAdrg)T(dfCOi8|p>IciY1NtewIJd2Ue zZdyA=MPVVWYs%|5-J{G{bp$=Q_E>w`2gZCz8Pl*z1i6ugCZe_^L|R#)pw^adGyjx3 zT{$Wt-Nq{KHym{k7IHR^-u-L)l~O&uzgvtRt14j;{OdG+9YW4#NZipn_1%e9bV)a3 zliWw^wTNxw>{o(E=OSWO-RgIO5dGM1=y>J(mYf|PZ_j&ZxOwxbr_M~qvPpkt(-vm? z+`_|rd%}ck)^P7H6YSmepDpgo1a;)C#n(J;qIyQs=D8D9f_Jp5ANP#}KPk1+g`2jm z?<^%V{HJ8?2Q;EppQJ)9M63PYk|Ti;%^q0%+rIshmBJ|?z=$?`vI;XZMb^>H>n5xA zU&!U5$*TSb+{crZuanio`|D(Te_2{;ZSi}V6YitqaWx2}LfogQfu zACF<2)qkCUhxgiP_WJQ0ShHAaOZX{Uld?7T1Jl%RQlmyTjT*K!91pX-lstm5=RWu< z2}w`f{C3{^9v5mYlZv(to2Iuq)#*WNT4IQk3-v8_=Ts4CkgZ({cs+A5jnSYhZ9~m073a+#Q%~deMs;A zBrOkD?hZ{=$Dv*(cY6bniN+6Y%X>PO@?^YcwWjgL)Jr=2dsET<+Iu|~a)n&5u2JEg zE=Lx9w>U9N#WIlmfA9H^XQ?_?5->&pTDY8wRI}}a?m@3-8QQrk<5rppf!l2L#l;#P zYK&U{Y#ByK7t)Z)(5|WeyWjP5X6nGeZClV7K3mm~N2{8Jg)0_$x5SItrv z7oAnJ)$(}OZ05V%6gpcyzTG5#n5{m=vlcfy&#~zezC6CwS4G9QCNqpP7HNE--5|1-{qmAe*LZQ66KrJ>JdBv3z-^MJvwggBsDYEX2HuV zyxu{GJ0X$r{GS%+Up9x1%Pb&viE1lxPwKeuiw&K#aC*m<+Jf$PUmv&KoL+<4WU4sY z7Q(EH_n4Qc&BXOivee#nFNVg1r0BiD{><3+YFV14>Y1eOkA++tA3txJ|JjKL%>K!{ zEsfHI$iOh->AI40uZM2?e%yLXRcJEew#OnF7N)w-61N!g+1_()&#wCsB7xw=i?mCn2l%&}TRo{)&dtf1To1u8W-_PuhooKHgIk&?I zku&Cz{_2?)(_bVYM6Zs{S)rD@F<`I6Lhb>IpV>M2);5n7Smd#<4g9r2eIj-5eOSod zV2g6C+;fc`5~Wk;4*0|hcvT3!~OT&ad%PmjVop|&P=ECp5Cm#*eUp%>=p zStxhSjb_`?Y!@lIsSi!-TXX%fS=cOX9qK*Um)zIpxIu>M0n?5Y1C{-+4`FmKOT`w;lBSDiVpXPry-{e9oS zSmm9X#$O2w>Bu{yT;?2Dc#)^S#v8%SV^t-w=!}It6uiz$oreXPG%y0Yp*#EHm-}Cf~l)P8%u$QXVsw{a64cfvZG;YRZ3^r}sp+@^s z?v&l^!&A=X{Sv1wup^DM^B`Vn3^X0wp<>fmc~C_fNvN5BUeApXyJd;=JAY+3R)^RR zTD+#Q|IZ|ikY8s!%`SV}n^iT`J^W%kUJf;i6tGL>NXrQ-v`a;#!~M5K{MQ-xF#qe4 z>O6SwA-6J&R5)dFZyPe48HJHsd+t2d!F1LVp+>QPT_Htw+h49Zz1TA1kEN4&e5But z``2gkUl+4@hX0doUhonPOddONb)8L!%+zu&P0+X1$Dfn-4SUrSQa7`{?wpa)_4r=(o4CIF zRr`$Se*4wXjMlK=4WuA*pIL9$cWtve$vCN9`)b3h{r0xF;_{+i7X}1>)@k#cs>}hE zD3i5&s4*G+XO$ZvWe(Y|z5!QGs5`kAv#LnWZEvl9{!{J9#LbvqxzG3CSMLA$fO0OW zH&@RyS>4koI&5#!|ICUJV*A^Ul#cpuI6QfmeMx59*Sk3+1fjJG$HS2nAATARUXqRQ&&@}0A<%qjGUHx!I3 z#Eqk>5@xALzjKSWJ=(Wx*V@B$^+o@Ntz^0>tJt|!R5q(yr0sP8j`fhFl9QBoCa)EJ zS_R28k>(=}=nEk-X5V>M?nZ^I)p$`xo-`80l<1f}Wc|`}Ap?5*^2UY{;zfunajMKd z5Hqe-nReepvJoO(HBCtW3a8r+h~!zg5h;iusfZojuWdRQ_PNXVkWfOToQ0idXJ7U% zl($%nIOPa&C8WccogFjfXx78++ZybT4_w?F{=im>*cMlgQ{UCEi^`VW>J#}rRyV?u z5SBDUn&efdeY)rN?MQzTA~Q^DxxF)&UU=H;d&mJoq=HTre7!8t{8CzQU_PoSx9J=q zlJ6an6|T3tx+$CxnOQOoYu&C(dtOa2CGGxTPD+(HuQfp$?j>$(d)FBQ#~tW72JNTKvybIH4kLaaXF=i23n{54}Y(5c)S{! zn~B@FQ|eM~u7@j5+23QRJmtxg!qLaSI5{wJk+4Q<@rQx4D9mvLc4N~_)tiHK_-3Mx3 zv3IFl3qoV>475K=vW@UE@}#Qt>xe(}#W-Ej<*L0KIM(xgc`C`$f@DLM^8I-Lc~z|q zru6n^=B_>mTk{5I!CX3Z=|^kl)OoO=Fy=Cg58<_upNEi;PM|#Jyi<#2E!sA1XNs&< z$nmgxCb#{1qPyyvy{lS7hcp>$$-)Cdx%3dl^v^#glE3LwIfAX8X>IqcgKyZQHg;Q@ zaD7;D`~9A+?;X6MJc6xF)xID)z~CF|7H2Q=*Y7Co8))zNS)N~c+9~C;7;>ja?gcmP z-Fx1|8ILT!^@|W9FIQ2xwS>qJbEZPc1rJAE&qIiGd_uMml8BIl&i6Bwt#IIwF01?n z@qQiDwMJm-5&y5fD}jpgSk}xa|Br%-A__VpsF*xqSY=d1zy%cq#1%9~AuuS4EP@-N z5aWI|E*MJGONizfulpKIV#FaCF+yYw!hw|y)CW1#p>udbr}iSI%+3KnYvS zzn?T_=DNuAA)*9!P^zHCo|wNKE6Bwc_WZslcD#HmsIeXhgDN;Nxlwo03gt)4e`0)u zr1eJ$Lzo#jWObm+mQQQQ98p2>UeL)CDkvHkPP91{v<$ek3oB@o9*ioepvy>gE;qRV zjmz5h{Be`bDb4xp07|bti7VuUO;Rc{I+>^dS{|P@E{Kx74iR!e4<0VAfTX76zKz zS8)gQ#|0O&cSfIJn{S<2uoIr#sG?DUfQSSHL)^l8M4RX~Zyncax_SjDF4FKSS_TMh zW))R3Tph=9wv~CV`M&!wrwB#@{2Wlew2CeVLOwp%lV^9NVtY)t0k|1Y*A#U)5LNKO zZ>WKV8xxIPRkW=;HdDnWl=ibK@(BV55lXnJAJTKc$kNMIbc*HQtSan*5o)* zpSJY}X^2clkP~9r?~}_xq+RaQ6PAyVT3iuv()PShKH*5kRSWGAzy+iJrTa7(sqW5w z-ud0f^H%g4yM-Niur7f;5ARb+Fr?cugyj;{y@$G5huvfog4S&Rpr8_+P6co&HWS*khT2SCmb^2u-j+-RyjWaB-;T*Yru6W6m*G{8uyf189v(1 z<xlAWY_m_;n$}NgbWbU=Uj64*w6&+?%fGX!?)QjZr$U#&pV(5EMa7pSkW_9 z*Gr>Ox_sNt)!BBoyPr~5fv9715vGn=J1`@%x74YjomQjVM4qCh{NBJd2j5O$4z&;Q z{64LRv(lQf8IFB%Wvlyc&)4Dt1~%`elqmkyZY`ZbUF~TtRrJQN{;VZkAJ!)f7Mnp6 zdaPWK`SqnQP=gHxD7UG{`!w`o&Z{1}uDBEY2e8`%RtT=n5fID){(a*ck#jDz%~x9U zWt-#e>rpYo9w2bsxzVdZ`|zBN0s@+?mniXQnNcum$n<+PbrENHr;pUhW}tu|`}I&I zqv}y$7SajsgQO5)JjM%ENF zT53*L!ln9JCu_PAjZ8P|!U!a|{dwgzM1%N6Fh2-aL`Ndi$C`W+aW4&n*Tb4JvhWj{ zZiqFlWmWj?Q%7sqDSiRfsT!%kroHY!E&~9nHYRS=XgwsFm4STG5ETzVtFAIV8(0Uh zK&JDPHHs5cS-N0Zk1^D%b+yRq?Bralsaq@oM+MxzXcbcM8xGw%>kEckt4XJxAYH%zv_#=0+HcOTRz$QeD$4Z1QwGDUcyC3!W=PyR`80FywKY(7xB;{arf zdqZT7hu6Emcma^Pi~+SK&Urh!g#Ku6;AsK0Vt9y3jPo0-rp=Ela$}1LW~;lwQa0k_ zn-QA3`Cwl6#-d#~ChZ$h@DSAP0f=U(`}M7<-(71paC;4915wfvCCz&V^qPG5%Zs7} z<}SGrg=S-TK>kco-MU%!zLGV4RWX6gF2!pDXh2)ss4x}}Aq1Gq4V{U_AfkyE9@at{@zIcd$NTAf(_v(?&C9W#E{Ub1=%=n@71;&w7W;S zl^hMZ#18p{ch4zS43W@)ZJ_}zXgc)Nq!AO1X?7FV;26TU*Pa~g^5~>-P*TlY{;LZg zUJ|zOd{w@vvLS>{{jBAX)DlPwPKyN?kL`87DQBe`a+0kjif0u zX#xlWFEcdY?wtupRr_G#D=3J#AnGg|Jzk*(QIH*7eFGl1iIU;FHdR!GQ?D;6yWx$nAoo zHCjNzp$m0eQIq*N zR^>5ApZ2?^dK_S{a0yj?3j}7WOhqN=)U|BKi8$7$?90-n!O?;U^o%=Jirx@<%rF_i zP0{X*->dY8#%y3uIEe-B5@)_l)AfCGO2v0tZbxd1c!d|`&SsP7|ehD!7Q z&tgM%!pN00CRGxHoK(k%KYl(TMD_ z0cl}L0knKc;iR*$o&afP>j|Gf)eSZJqLo;KSx_gfs<#jXUrkhvr81`S6|JmRu^^l# zx){bwbt~aAOvSc|P$*m+onAsq)RsxM)9`PVNpM*8-!L5in_}trQes&-9UK(G*A^81 zrO>^sC@F#=MuY2fvyn2}O0i@q1PT50Cl>mb#BOtrrCz8`6{ZeWfM5Y z_HV0KDC2xdJ6G~+o#)FprXEdu>(^`U#m?*!GCQ&MvD!-m=Sor9!@g7uKy92Kot-Ns z8{+->#$86YcfXm@VeQ9?2~keC(ow>8_EYO0Ise19{b?u>?@i3~r?h#nZSzIlyVjtC=n?g}Hu5|kCDu;XCC}Ag!Pzdk)(|**|?EwUHk^eblwDX0_i!K9#oe-h> zC;X{$9&GG6K(IZw#FO&AdX3Vb0)m|c0|LiV!SjLRDj?YDT#vEkNxAdKcVMjm06S_w zbf%ysV9%Y-v>1@ut^u@RJ{(Qd9JK)71_L!lYOqYj5CeBb#|4bp;O%+$9pDx>UR4}L z2AZ-!@-u7#BzsJv`=WK1{M;TPToQd%PEWo-2{X?*7u(vOO4LtB30pwG;_mN4m(jj1 zyeoIE%Q9cNcqcESIUo==%Ob4Sbfsns!G$_v9mIJ!!hzx!qA%hRA1xlx`r{pptgk0| z79v02lY9v2kAd`r!C|rAViD-A4Adw$yVg`XY(8?Si0!vA&coA6?auj`6Y6o*Uq94V zlwj1Rb*EX2z^@+w!Oni=kkmyVyB=U01Y%t8p~M~~9Zz=nsO(Bb?hAEwmLZfE-KrmU zi~Ji@$m1R~?r-P}L~SigABIplQr+_qz9TZ|+Mv8X4fe5j3dEa67#5esfW+$#gOTdm z_T=m8){8qoYqKv3PZX;$5I3u+WHChlZ9uTjSbsY}lf5p0?L-UKUK4d~7WS5ZSig>i zz=#r6u)y@;OVAnuBNi>edFOxkjTxu$fAr?ePG9w9lUWNQSzHHVSD4&1?ZZp_S0v^f z`g;lX;p-!ZO@%f{*eIPMqC0zlXu5QCxXu`n9F6e4skjJ%e?YA#!fArgpV z%sXPQ3GK@V>BXiNp@enBQd2&@W=$Ew`tvUDdF1l_vi#zGAPOh&>|Ra`N;(49$=z)u zLdJ(9;+&WGTTSUtxgb+l42Wicm=_*AVEu0kmwO#iDRd`og#&qh->RLXc{|>D!y5|yL zW#N5mOzpQC(k#Slvc)pqu2%Ny#Dms<`h2y;!4j-D)E~kv#OMo^UeP737V_~1lLA~( z!j2o$XBC89_@EPuAc7>Y=abao5i-rBuAGU4%FbFNqwsdfP;x1PY#WDal!(pSn~ncj zeD>WBRh+&7=9&d1P?79)t*TcjwFu$q7I*`TW%>1P^LcS|IK_VoN*lzI&jF-vu@rO& zX;>_6`xMU$B*k(ap_cRTlp1&r=SR5*q-bqw9GwCZT_Y_Isd(2t-_v8R@eTIm3uuRS z=g*f`x5jJZ3F0b>E<>w=8ePcTAr(NSKsmv^v!*_~U=}d+lH# zj41PVFCBg|qR*4x);H^q40aZLEqiu~eNyC@7ajQUBF)6!DX#!}l=a+jV{QnHOob}0LmmF+z_J0&$M)R;6@0c3%s-cu&B zcm1&>aLvti4ekMZh`-|!GBT#?lhWyjRdQ<@?1z8y_sTMP?vfk(RJWd#lANs1GG9(8W0EmDd%8Y3ebU4PI`o~SBkxk#x-@T>JV->`x*0-2Bir*?bQc+i6E7~f1QRr=il{eic2gNG)NEmn6eG;t{pZWl5Is>Ci41FE{f^vq*q$3ogmE9ey`-sx&-wIuAYViWawZ18Smh8@?&IzOQW=jk~lXA(L?(URZ z(vgj_YiYL+WiL%>aFN_uE-f#SXKHE8G1;Lsr&NwNGVwz|)d-tLrIoQZvutdwmdeUR mw_7Sx_gHIJDv7sgZW}6cV~|@GZ{us5FEaKZ1OI8w&3^$oSX0*k delta 135453 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

)%AwGW3|>w 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 diff --git a/config/env.example.optional b/config/env.example.optional index 109b73fc7..b9cc395e9 100644 --- a/config/env.example.optional +++ b/config/env.example.optional @@ -154,10 +154,6 @@ DOLIST_API_BALANCING_VALUE="50" # Used only by a migration to choose your default regarding procedure archive dossiers after duree_conservation_dossiers_dans_ds # DEFAULT_PROCEDURE_EXPIRES_WHEN_TERMINE_ENABLED=true -# Enable vite legacy build (IE11). Legacy build is used in production (except if set to "disabled"). -# You might want to enable it in other environements for testing. Build time will be greatly impacted. -VITE_LEGACY="" - # around july 2022, we changed the duree_conservation_dossiers_dans_ds, allow instances to choose their own duration NEW_MAX_DUREE_CONSERVATION=12 diff --git a/package.json b/package.json index 5cb3a13e7..54142b700 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "@reach/combobox": "^0.17.0", "@reach/slider": "^0.17.0", "@sentry/browser": "7.107.0", - "@stimulus/polyfills": "^2.0.0", "@tiptap/core": "^2.2.4", "@tiptap/extension-bold": "^2.2.4", "@tiptap/extension-bullet-list": "^2.2.4", @@ -47,13 +46,11 @@ "core-js": "^3.31.0", "date-fns": "^2.30.0", "debounce": "^1.2.1", - "dom4": "^2.1.6", "email-butler": "^1.0.13", "geojson": "^0.5.0", "graphiql": "^3.1.1", "graphql": "^16.8.1", "highcharts": "^10.3.3", - "intersection-observer": "^0.12.2", "is-hotkey": "^0.2.0", "lightgallery": "^2.7.2", "maplibre-gl": "^1.15.2", @@ -70,10 +67,7 @@ "tiny-invariant": "^1.3.3", "tippy.js": "^6.3.7", "trix": "^1.2.3", - "turbo-polyfills": "^0.5.0", "use-debounce": "^9.0.4", - "whatwg-fetch": "^3.6.20", - "yet-another-abortcontroller-polyfill": "^0.0.4", "zod": "^3.20.2" }, "devDependencies": { @@ -94,7 +88,6 @@ "@types/sortablejs": "^1.15.8", "@typescript-eslint/eslint-plugin": "^6.13.1", "@typescript-eslint/parser": "^6.13.1", - "@vitejs/plugin-legacy": "^5.2.0", "@vitejs/plugin-react": "^4.2.0", "autoprefixer": "^10.4.19", "axe-core": "^4.8.4", @@ -111,7 +104,7 @@ "vite": "^5.0.12", "vite-plugin-full-reload": "^1.1.0", "vite-plugin-ruby": "^5.0.0", - "vitest": "^0.34.6" + "vitest": "^1.3.1" }, "scripts": { "clean": "del tmp public/graphql && bin/vite clobber", diff --git a/spec/system/outdated_browser_spec.rb b/spec/system/outdated_browser_spec.rb index 0284a01bd..a2f5c1721 100644 --- a/spec/system/outdated_browser_spec.rb +++ b/spec/system/outdated_browser_spec.rb @@ -7,7 +7,7 @@ describe 'Outdated browsers support:' do scenario 'a banner is displayed' do visit new_user_session_path - expect(page).to have_content('1 juin 2024') + expect(page).to have_content('Il n’est plus compatible avec') expect(page).to have_content('Votre navigateur internet, Internet Explorer 10, est malheureusement trop ancien') end end diff --git a/vite.config.ts b/vite.config.ts index 8fee5f60c..036c8e0c8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,7 @@ import { defineConfig } from 'vite'; import ViteReact from '@vitejs/plugin-react'; -import ViteLegacy from '@vitejs/plugin-legacy'; -import FullReload from 'vite-plugin-full-reload'; import RubyPlugin from 'vite-plugin-ruby'; +import FullReload from 'vite-plugin-full-reload'; const plugins = [ RubyPlugin(), @@ -13,45 +12,8 @@ const plugins = [ ) ]; -if (shouldBuildLegacy()) { - plugins.push( - ViteLegacy({ - targets: [ - 'defaults', - 'Chrome >= 50', - 'Edge >= 14', - 'Firefox >= 50', - 'Opera >= 40', - 'Safari >= 8', - 'iOS >= 8', - 'IE >= 11' - ], - additionalLegacyPolyfills: [ - 'dom4', - 'core-js/stable', - '@stimulus/polyfills', - 'turbo-polyfills', - 'intersection-observer', - 'regenerator-runtime/runtime', - 'whatwg-fetch', - 'yet-another-abortcontroller-polyfill' - ] - }) - ); -} - export default defineConfig({ resolve: { alias: { '@utils': '/shared/utils.ts' } }, build: { sourcemap: true, assetsInlineLimit: 0 }, plugins }); - -function shouldBuildLegacy() { - if (process.env.VITE_LEGACY == 'disabled') { - return false; - } - return ( - process.env.RAILS_ENV == 'production' || - process.env.VITE_LEGACY == 'enabled' - ); -} From 607fbf52870cf132651c0c0b3c4e1d974df6189f Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 28 May 2024 11:08:34 +0200 Subject: [PATCH 0318/1532] feat(user): block unverified_email from being sent --- app/lib/balancer_delivery_method.rb | 15 ++++++ app/models/individual.rb | 2 + app/models/user.rb | 2 + spec/lib/balancer_delivery_method_spec.rb | 62 +++++++++++++++++++++-- 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/app/lib/balancer_delivery_method.rb b/app/lib/balancer_delivery_method.rb index 2f9774af0..2ed33ae91 100644 --- a/app/lib/balancer_delivery_method.rb +++ b/app/lib/balancer_delivery_method.rb @@ -14,6 +14,7 @@ # # Be sure to restart your server when you modify this file. class BalancerDeliveryMethod + BYPASS_UNVERIFIED_MAIL_PROTECTION = 'BYPASS_UNVERIFIED_MAIL_PROTECTION'.freeze FORCE_DELIVERY_METHOD_HEADER = 'X-deliver-with' # Allows configuring the random number generator used for selecting a delivery method, # mostly for testing purposes. @@ -24,6 +25,8 @@ class BalancerDeliveryMethod end def deliver!(mail) + return if prevent_delivery?(mail) + balanced_delivery_method = delivery_method(mail) ApplicationMailer.wrap_delivery_behavior(mail, balanced_delivery_method) @@ -40,6 +43,18 @@ class BalancerDeliveryMethod private + def prevent_delivery?(mail) + return false if mail[BYPASS_UNVERIFIED_MAIL_PROTECTION].present? + + user = User.find_by(email: mail.to.first) + return user.unverified_email? if user.present? + + individual = Individual.find_by(email: mail.to.first) + return individual.unverified_email? if individual.present? + + true + end + def force_delivery_method?(mail) @delivery_methods.keys.map(&:to_s).include?(mail[FORCE_DELIVERY_METHOD_HEADER]&.value) end diff --git a/app/models/individual.rb b/app/models/individual.rb index 812435242..fd9406391 100644 --- a/app/models/individual.rb +++ b/app/models/individual.rb @@ -29,4 +29,6 @@ class Individual < ApplicationRecord gender: fc_information.gender == 'female' ? GENDER_FEMALE : GENDER_MALE ) end + + def unverified_email? = !email_verified_at? end diff --git a/app/models/user.rb b/app/models/user.rb index d047ab1d1..576f65759 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -267,6 +267,8 @@ class User < ApplicationRecord super && blocked_at.nil? end + def unverified_email? = !email_verified_at? + private def does_not_merge_on_self diff --git a/spec/lib/balancer_delivery_method_spec.rb b/spec/lib/balancer_delivery_method_spec.rb index 7fb2495be..36c691195 100644 --- a/spec/lib/balancer_delivery_method_spec.rb +++ b/spec/lib/balancer_delivery_method_spec.rb @@ -1,7 +1,11 @@ RSpec.describe BalancerDeliveryMethod do class ExampleMailer < ApplicationMailer - def greet(name) - mail(to: "smtp_to", from: "smtp_from", body: "Hello #{name}") + def greet(name, bypass_unverified_mail_protection: true) + mail(to: name, from: "smtp_from", body: "Hello #{name}") + + if bypass_unverified_mail_protection + headers['BYPASS_UNVERIFIED_MAIL_PROTECTION'] = true + end end end @@ -9,7 +13,8 @@ RSpec.describe BalancerDeliveryMethod do before_action :set_x_deliver_with def greet(name) - mail(to: "smtp_to", from: "smtp_from", body: "Hello #{name}") + mail(to: name, from: "smtp_from", body: "Hello #{name}") + headers['BYPASS_UNVERIFIED_MAIL_PROTECTION'] = true end private @@ -145,6 +150,57 @@ RSpec.describe BalancerDeliveryMethod do end end + context 'when the email does not bypass unverified mail protection' do + let(:mail) { ExampleMailer.greet(email, bypass_unverified_mail_protection:) } + let(:bypass_unverified_mail_protection) { false } + + before do + ActionMailer::Base.balancer_settings = { mock_smtp: 10 } + mail.deliver_now + end + + context 'when the email belongs to a user' do + let(:email) { user.email } + let(:user) { create(:user, email: 'u@a.com', email_verified_at:) } + + context 'and the email is not verified' do + let(:email_verified_at) { nil } + + it { expect(mail).not_to have_been_delivered_using(MockSmtp) } + end + + context 'and the email is not verified but a bypass flag is added' do + let(:email_verified_at) { nil } + let(:bypass_unverified_mail_protection) { true } + + it { expect(mail).to have_been_delivered_using(MockSmtp) } + end + + context 'and the email is verified' do + let(:email_verified_at) { Time.current } + + it { expect(mail).to have_been_delivered_using(MockSmtp) } + end + end + + context 'when the email belongs to a individual' do + let(:email) { individual.email } + let(:individual) { create(:individual, email: 'u@a.com', email_verified_at:) } + + context 'and the email is not verified' do + let(:email_verified_at) { nil } + + it { expect(mail).not_to have_been_delivered_using(MockSmtp) } + end + + context 'and the email is verified' do + let(:email_verified_at) { Time.current } + + it { expect(mail).to have_been_delivered_using(MockSmtp) } + end + end + end + # Helpers def have_been_delivered_using(delivery_class) From 8104157da677999daa7b6a12f26a019832898db1 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 29 May 2024 09:52:54 +0200 Subject: [PATCH 0319/1532] feat(user): always allow devise mail --- app/mailers/concerns/balanced_delivery_concern.rb | 4 ++++ app/mailers/devise_user_mailer.rb | 2 ++ spec/lib/balancer_delivery_method_spec.rb | 11 +++++++---- spec/mailers/devise_user_mailer_spec.rb | 5 ++++- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/mailers/concerns/balanced_delivery_concern.rb b/app/mailers/concerns/balanced_delivery_concern.rb index 486aadac4..3bbeb6d7e 100644 --- a/app/mailers/concerns/balanced_delivery_concern.rb +++ b/app/mailers/concerns/balanced_delivery_concern.rb @@ -8,6 +8,10 @@ module BalancedDeliveryConcern self.class.critical_email?(action_name) end + def bypass_unverified_mail_protection! + headers[BalancerDeliveryMethod::BYPASS_UNVERIFIED_MAIL_PROTECTION] = true + end + private def forced_delivery_provider? diff --git a/app/mailers/devise_user_mailer.rb b/app/mailers/devise_user_mailer.rb index d9acb25c3..26cad7c03 100644 --- a/app/mailers/devise_user_mailer.rb +++ b/app/mailers/devise_user_mailer.rb @@ -34,6 +34,8 @@ class DeviseUserMailer < Devise::Mailer @procedure = opts[:procedure_after_confirmation] || nil @prefill_token = opts[:prefill_token] + bypass_unverified_mail_protection! + I18n.with_locale(record.locale) do super end diff --git a/spec/lib/balancer_delivery_method_spec.rb b/spec/lib/balancer_delivery_method_spec.rb index 36c691195..659adb46e 100644 --- a/spec/lib/balancer_delivery_method_spec.rb +++ b/spec/lib/balancer_delivery_method_spec.rb @@ -1,20 +1,23 @@ RSpec.describe BalancerDeliveryMethod do class ExampleMailer < ApplicationMailer + include BalancedDeliveryConcern + def greet(name, bypass_unverified_mail_protection: true) mail(to: name, from: "smtp_from", body: "Hello #{name}") - if bypass_unverified_mail_protection - headers['BYPASS_UNVERIFIED_MAIL_PROTECTION'] = true - end + bypass_unverified_mail_protection! if bypass_unverified_mail_protection end end class ImportantEmail < ApplicationMailer + include BalancedDeliveryConcern + before_action :set_x_deliver_with def greet(name) mail(to: name, from: "smtp_from", body: "Hello #{name}") - headers['BYPASS_UNVERIFIED_MAIL_PROTECTION'] = true + + bypass_unverified_mail_protection! end private diff --git a/spec/mailers/devise_user_mailer_spec.rb b/spec/mailers/devise_user_mailer_spec.rb index ed0e946ff..88fd2c21c 100644 --- a/spec/mailers/devise_user_mailer_spec.rb +++ b/spec/mailers/devise_user_mailer_spec.rb @@ -5,7 +5,10 @@ RSpec.describe DeviseUserMailer, type: :mailer do subject { described_class.confirmation_instructions(user, token, opts = {}) } context 'without SafeMailer configured' do - it { expect(subject[BalancerDeliveryMethod::FORCE_DELIVERY_METHOD_HEADER]&.value).to eq(nil) } + it do + expect(subject[BalancerDeliveryMethod::FORCE_DELIVERY_METHOD_HEADER]&.value).to eq(nil) + expect(subject[BalancerDeliveryMethod::BYPASS_UNVERIFIED_MAIL_PROTECTION]).to be_present + end end context 'with SafeMailer configured' do From 5d259ec47b2dd0ae49578366483e4103291954de Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 29 May 2024 10:22:44 +0200 Subject: [PATCH 0320/1532] refactor(user): rename invite! -> invite_instructeur! --- app/controllers/instructeurs/groupe_instructeurs_controller.rb | 2 +- app/controllers/manager/instructeurs_controller.rb | 2 +- app/models/groupe_instructeur.rb | 2 +- app/models/user.rb | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/controllers/instructeurs/groupe_instructeurs_controller.rb b/app/controllers/instructeurs/groupe_instructeurs_controller.rb index d7bedc485..1ad00f79a 100644 --- a/app/controllers/instructeurs/groupe_instructeurs_controller.rb +++ b/app/controllers/instructeurs/groupe_instructeurs_controller.rb @@ -65,7 +65,7 @@ module Instructeurs administrateurs: [procedure.administrateurs.first] ) - user.invite! if user.valid? + user.invite_instructeur! if user.valid? user.instructeur end diff --git a/app/controllers/manager/instructeurs_controller.rb b/app/controllers/manager/instructeurs_controller.rb index 2bfe85d9c..1cd9c0b9c 100644 --- a/app/controllers/manager/instructeurs_controller.rb +++ b/app/controllers/manager/instructeurs_controller.rb @@ -2,7 +2,7 @@ module Manager class InstructeursController < Manager::ApplicationController def reinvite instructeur = Instructeur.find(params[:id]) - instructeur.user.invite! + instructeur.user.invite_instructeur! flash[:notice] = "Instructeur réinvité." redirect_to manager_instructeur_path(instructeur) end diff --git a/app/models/groupe_instructeur.rb b/app/models/groupe_instructeur.rb index bd9aad659..f628b136e 100644 --- a/app/models/groupe_instructeur.rb +++ b/app/models/groupe_instructeur.rb @@ -58,7 +58,7 @@ class GroupeInstructeur < ApplicationRecord if not_found_emails.present? instructeurs_to_add += not_found_emails.map do |email| user = User.create_or_promote_to_instructeur(email, SecureRandom.hex, administrateurs: procedure.administrateurs) - user.invite! + user.invite_instructeur! user.instructeur end end diff --git a/app/models/user.rb b/app/models/user.rb index 576f65759..d98229788 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -79,7 +79,7 @@ class User < ApplicationRecord owns?(dossier) || invite?(dossier) end - def invite! + def invite_instructeur! UserMailer.invite_instructeur(self, set_reset_password_token).deliver_later end From 7c514e3585a4e6efa786e2c07609bc815e2e9c0d Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 29 May 2024 12:24:05 +0200 Subject: [PATCH 0321/1532] feat(user): always allow invitation mail --- app/mailers/administrateur_mailer.rb | 2 ++ app/mailers/administration_mailer.rb | 4 ++++ app/mailers/user_mailer.rb | 4 ++++ spec/mailers/administrateur_mailer_spec.rb | 5 ++++- spec/mailers/administration_mailer_spec.rb | 12 +++++++++--- spec/mailers/user_mailer_spec.rb | 13 +++++++++++++ 6 files changed, 36 insertions(+), 4 deletions(-) diff --git a/app/mailers/administrateur_mailer.rb b/app/mailers/administrateur_mailer.rb index 0ed0d6598..19df4f960 100644 --- a/app/mailers/administrateur_mailer.rb +++ b/app/mailers/administrateur_mailer.rb @@ -8,6 +8,8 @@ class AdministrateurMailer < ApplicationMailer @expiration_date = @user.reset_password_sent_at + Devise.reset_password_within @subject = "N'oubliez pas d’activer votre compte administrateur" + bypass_unverified_mail_protection! + mail(to: user.email, subject: @subject, reply_to: CONTACT_EMAIL) diff --git a/app/mailers/administration_mailer.rb b/app/mailers/administration_mailer.rb index baa26e321..7880eb8e3 100644 --- a/app/mailers/administration_mailer.rb +++ b/app/mailers/administration_mailer.rb @@ -8,6 +8,8 @@ class AdministrationMailer < ApplicationMailer @author_name = "Équipe de #{APPLICATION_NAME}" subject = "Activez votre compte administrateur" + bypass_unverified_mail_protection! + mail(to: user.email, subject: subject, reply_to: CONTACT_EMAIL) @@ -16,6 +18,8 @@ class AdministrationMailer < ApplicationMailer def refuse_admin(admin_email) subject = "Votre demande de compte a été refusée" + bypass_unverified_mail_protection! + mail(to: admin_email, subject: subject, reply_to: CONTACT_EMAIL) diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index f45892699..08d45509a 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -41,6 +41,8 @@ class UserMailer < ApplicationMailer configure_defaults_for_user(user) + bypass_unverified_mail_protection! + mail(to: user.email, subject: subject, reply_to: Current.contact_email) @@ -54,6 +56,8 @@ class UserMailer < ApplicationMailer configure_defaults_for_user(user) + bypass_unverified_mail_protection! + mail(to: user.email, subject: subject, reply_to: Current.contact_email) diff --git a/spec/mailers/administrateur_mailer_spec.rb b/spec/mailers/administrateur_mailer_spec.rb index a42edb2b3..baf023934 100644 --- a/spec/mailers/administrateur_mailer_spec.rb +++ b/spec/mailers/administrateur_mailer_spec.rb @@ -23,7 +23,10 @@ RSpec.describe AdministrateurMailer, type: :mailer do subject { described_class.activate_before_expiration(user, token) } context 'without SafeMailer configured' do - it { expect(subject[BalancerDeliveryMethod::FORCE_DELIVERY_METHOD_HEADER]&.value).to eq(nil) } + it do + expect(subject[BalancerDeliveryMethod::FORCE_DELIVERY_METHOD_HEADER]&.value).to eq(nil) + expect(subject['BYPASS_UNVERIFIED_MAIL_PROTECTION']).to be_present + end end context 'with SafeMailer configured' do diff --git a/spec/mailers/administration_mailer_spec.rb b/spec/mailers/administration_mailer_spec.rb index 42a416acf..e4dc16b2a 100644 --- a/spec/mailers/administration_mailer_spec.rb +++ b/spec/mailers/administration_mailer_spec.rb @@ -9,8 +9,11 @@ RSpec.describe AdministrationMailer, type: :mailer do it { expect(subject.subject).not_to be_empty } describe "when the user has not been activated" do - it { expect(subject.body).to include(admin_activate_path(token: token)) } - it { expect(subject.body).not_to include(edit_user_password_url(admin_user, reset_password_token: token)) } + it do + expect(subject.body).to include(admin_activate_path(token: token)) + expect(subject.body).not_to include(edit_user_password_url(admin_user, reset_password_token: token)) + expect(subject['BYPASS_UNVERIFIED_MAIL_PROTECTION']).to be_present + end end describe "when the user is already active" do @@ -25,6 +28,9 @@ RSpec.describe AdministrationMailer, type: :mailer do subject { described_class.refuse_admin(mail) } - it { expect(subject.subject).not_to be_empty } + it do + expect(subject.subject).not_to be_empty + expect(subject['BYPASS_UNVERIFIED_MAIL_PROTECTION']).to be_present + end end end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 7b6ef9641..0dc7a9212 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -168,4 +168,17 @@ RSpec.describe UserMailer, type: :mailer do end end end + + describe '.invite_instructeur' do + subject { described_class.invite_instructeur(user, "reset_token") } + + it { expect(subject['BYPASS_UNVERIFIED_MAIL_PROTECTION']).to be_present } + end + + describe '.invite_gestionnaire' do + let(:groupe_gestionnaire) { create(:groupe_gestionnaire) } + subject { described_class.invite_gestionnaire(user, "reset_token", groupe_gestionnaire) } + + it { expect(subject['BYPASS_UNVERIFIED_MAIL_PROTECTION']).to be_present } + end end From 819fa2cde2ff61b798619dce9aa12d88492aebf4 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 31 May 2024 14:33:40 +0200 Subject: [PATCH 0322/1532] feat(User): always allow reset_password_instructions --- app/mailers/devise_user_mailer.rb | 6 ++++++ spec/mailers/devise_user_mailer_spec.rb | 1 + 2 files changed, 7 insertions(+) diff --git a/app/mailers/devise_user_mailer.rb b/app/mailers/devise_user_mailer.rb index 26cad7c03..5e993daa0 100644 --- a/app/mailers/devise_user_mailer.rb +++ b/app/mailers/devise_user_mailer.rb @@ -41,6 +41,12 @@ class DeviseUserMailer < Devise::Mailer end end + def reset_password_instructions(record, token, opts = {}) + bypass_unverified_mail_protection! + + super + end + def self.critical_email?(action_name) true end diff --git a/spec/mailers/devise_user_mailer_spec.rb b/spec/mailers/devise_user_mailer_spec.rb index 88fd2c21c..2044da2f2 100644 --- a/spec/mailers/devise_user_mailer_spec.rb +++ b/spec/mailers/devise_user_mailer_spec.rb @@ -73,6 +73,7 @@ RSpec.describe DeviseUserMailer, type: :mailer do it "respect preferred domain" do expect(header_value("From", subject.message)).to include(CONTACT_EMAIL) expect(subject.message.to_s).to include("#{ENV.fetch("APP_HOST_LEGACY")}/users/password") + expect(subject[BalancerDeliveryMethod::BYPASS_UNVERIFIED_MAIL_PROTECTION]).to be_present end end From 72f7c1d632005b0bbd9a2a9aef67c68dd7ee7a3f Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 31 May 2024 16:30:22 +0200 Subject: [PATCH 0323/1532] Feat(SuperAdmin): super admin can unblock email --- app/controllers/manager/users_controller.rb | 7 +++++++ app/views/manager/users/show.html.erb | 8 ++++++++ config/routes.rb | 1 + 3 files changed, 16 insertions(+) diff --git a/app/controllers/manager/users_controller.rb b/app/controllers/manager/users_controller.rb index effa7043c..287b2f20d 100644 --- a/app/controllers/manager/users_controller.rb +++ b/app/controllers/manager/users_controller.rb @@ -23,6 +23,13 @@ module Manager end end + def unblock_mails + user = User.find(params[:id]) + user.update!(email_verified_at: Time.current) + flash[:notice] = "Les emails ont été débloqués." + redirect_to manager_user_path(user) + end + def resend_confirmation_instructions user = User.find(params[:id]) user.resend_confirmation_instructions diff --git a/app/views/manager/users/show.html.erb b/app/views/manager/users/show.html.erb index 41f353302..ac1881a35 100644 --- a/app/views/manager/users/show.html.erb +++ b/app/views/manager/users/show.html.erb @@ -26,6 +26,14 @@ as well as a link to its edit page.

+ <% if user.unverified_email? %> + <%= link_to( + "Débloquer mails", + [:unblock_mails, namespace, page.resource], + method: :post, + class: "button") %> + <% end %> + <%= link_to( "Modifier", edit_manager_user_path(page.resource), diff --git a/config/routes.rb b/config/routes.rb index dbe1d200a..c4973d1ae 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -50,6 +50,7 @@ Rails.application.routes.draw do delete 'delete', on: :member post 'resend_confirmation_instructions', on: :member post 'resend_reset_password_instructions', on: :member + post 'unblock_mails', on: :member put 'enable_feature', on: :member get 'emails', on: :member put 'unblock_email' From 8e093e88c2490c442b69a2ddf51a30b86d6c8d66 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Mon, 3 Jun 2024 07:20:56 +0000 Subject: [PATCH 0324/1532] =?UTF-8?q?Ajoute=20la=20m=C3=A9thode=20Acronomy?= =?UTF-8?q?ze=20comme=20helper=20pour=20=C3=AAtre=20utilis=C3=A9=20sur=20l?= =?UTF-8?q?a=20page=20toutes=20les=20d=C3=A9marches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/helpers/application_helper.rb | 9 +++++++++ spec/helpers/application_helper_spec.rb | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 138c57bb6..f5f859032 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,6 +1,8 @@ module ApplicationHelper APP_HOST = ENV['APP_HOST'] APP_HOST_LEGACY = ENV['APP_HOST_LEGACY'] + REGEXP_REPLACE_TRAILING_EXTENSION = /(\.\w+)+$/.freeze + REGEXP_REPLACE_WORD_SEPARATOR = /[\s_-]+/.freeze def app_host_legacy?(request) return false if APP_HOST_LEGACY.blank? @@ -145,4 +147,11 @@ module ApplicationHelper tag.span(class: class_names(classes, 'fr-icon--sm': sm, 'fr-mr-1v': mr), "aria-hidden" => true) end + + def acronymize(str) + str.gsub(REGEXP_REPLACE_TRAILING_EXTENSION, '') + .split(REGEXP_REPLACE_WORD_SEPARATOR) + .map { |word| word[0].upcase } + .join + end end diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index dc3998c9b..524fc6faf 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -112,4 +112,29 @@ describe ApplicationHelper do it { is_expected.to eq("") } end end + describe '#acronymize' do + it 'returns the acronym of a given string' do + expect(helper.acronymize('Application Name')).to eq('AN') + expect(helper.acronymize('Hello World')).to eq('HW') + expect(helper.acronymize('Demarches Simplifiees')).to eq('DS') + end + + it 'handles single word input' do + expect(helper.acronymize('Word')).to eq('W') + end + + it 'returns an empty string for empty input' do + expect(helper.acronymize('')).to eq('') + end + + it 'handles strings with extensions' do + expect(helper.acronymize('file_name.txt')).to eq('FN') + expect(helper.acronymize('example.pdf')).to eq('E') + end + + it 'handles strings with various word separators' do + expect(helper.acronymize('multi-word_string')).to eq('MWS') + expect(helper.acronymize('another_example-test')).to eq('AET') + end + end end From 010ebb1a23942abcbb17bc32a32246efab656860 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 30 May 2024 11:13:21 +0200 Subject: [PATCH 0325/1532] chore(npm): update build dependencies --- app/javascript/shared/track/sentry.ts | 7 +++--- bun.lockb | Bin 495684 -> 502268 bytes package.json | 34 +++++++++++++------------- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/app/javascript/shared/track/sentry.ts b/app/javascript/shared/track/sentry.ts index 1cbb3053b..14d880625 100644 --- a/app/javascript/shared/track/sentry.ts +++ b/app/javascript/shared/track/sentry.ts @@ -23,10 +23,9 @@ if (enabled && key) { ] }); - Sentry.configureScope((scope) => { - scope.setUser(user); - scope.setExtra('browser', browser.modern ? 'modern' : 'legacy'); - }); + const scope = Sentry.getCurrentScope(); + scope.setUser(user); + scope.setExtra('browser', browser.modern ? 'modern' : 'legacy'); // Register a way to explicitely capture messages from a different bundle. addEventListener('sentry:capture-exception', (event) => { diff --git a/bun.lockb b/bun.lockb index e44c7900991f0d6391e1bc9a82bea46854df15b0..9cc8b3bb356a97e0f908f76bd947b656fda4a8ab 100755 GIT binary patch delta 81730 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⋙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%0D+O|*6{!tzJ`1FmA>=q>!y*BuX?XL}a z#i!SX%pz;Dp*(7>_H09LQ6|}7V;4QJSBEH6i0b;7g@_-?hD@4j(KiMMahwUn!DlXY z=+mKBw>ATjg2|&L!5E%@GX*MtJ2ux8x84{WwPatvGgv2jzBR1OAYZC+@|&T9g+-{C zIvCB-`X}n3^_pzst)kM@0fMz;59)2Dk{vNSXYn916=>C->5hmX0aW1kKk+D54f8qG8{vAHVx zO5Xv0%Q;127Skk)Y!eueVxURXt!K|3o&KTNJ+fC+NKl7vP&S$hZPNaGu}!i*kx}jX z{Ue2_z~X)bQu6B2A)3W8x4UPr4n2OCMfL5`GwN?z{UdYq2n=BH%yK|&L59NopSI2} zCWS3?6Rgo z`@(rKwLCP%kJ$J$t!;!5ZHn~GG<`B+tS^cp@uiWN^vs>TGjr3<%iMGB@1A?_xo7Uj zZZbpK1)lsbPoRYQCh)G7q?HbSQfx)kg_0a9p3ulgPLDF23}N2Igrsuo-Ig)srd7u9 z^@u5-q;gTu8H{;?LYcn4gd)P33_A`Nl0t`~g+Xwcf~-;zF?=o~B$Y7az`KxCs9dxV zHZE3>Rcf6bZ!TgBYJNoe-RakV{T`}jooj&8O8FAQ_dGD=lT^->IkG-QR@tccG_pxg zP${}xikNSRN{qh36u=?Q<$RJ_(3Cl{DT=JJu1&`YK2qi0cPAvfW;r?8jQ#TpPwEHz zkHt@(;92|>tu*N|`ZkQH_5s}vqn(oNyhfN2JTKwyl$$uKHT{q?QC^(3OVXVbHqi7A zbklMQdnuI08f#A+nIhViLOC1hd(98rObQkB!bBO3rw}HsM}N;QnV@@}oZ>>&v^9=8 zx}QLtSEn}#?9%AF{kl#YKSn9-O=CCRNMq6Zy%`CO=I890xCeDsrUi*oTI>d_7wvc& z)OK1AKEbDzlo`^C>17xxE8YhR+wWPT`O~;hb4%QwOTX#G*6lNxdEhlX*Ox>|VY6)V~7FdFFO6ZZFP3$Lv6t$Kv!<~W!8TSnm#gk=41 z2e6h7k6@$goaF)D+)Rz&gbx9Ajbf8jTp2}L)cV&bR%KIGzk=bU>!@{%Lq~Y1o*s^& zUFv(gc%Y0N%4uH~l|kp6B1Yt!EVj{NmK%3Gi)Nb7aq8e*UUdciYnB`NXAbKE;xO_u zheqn{=7{0k21h4C&$B<6Lxq!ZtBhl9!#E78`J1C*dz&8CcG6>FXWZ;UCBH(858_KK zUCMJM*Txb1KZBEbsXv;3_FNL5=PXGV_ delta 76140 zcmeFad0dR``~N>PHJVGZMw=5fD!oyYS$&iy#g>ztYN{&ub5$@LAFwrlm} z>)XvAI#`--ypdJcrzGBK>)B-Y^)2`7<6`+EC%`+5fjcMTKl z`c)+oeej7v=IXHEK#$Z2iKGhb$wB^p6TN-Z+I+39LB0Xx z4WSpH)Un*Tn(@_JL^Yc)v>)Qx&ez*B(BF4LNFb(8vK*Y+SNjEf2Yai7s=&tBqhH1f z)jot$ex^)l-d7#yAB1ub7f2+nz{}@JB-NnX776_?fztSspftX#i}`LuMR$%9`WqbN z<4vo$85uO59eTpC5xAvPsIWb>3i$;E1&2>nOE#?(xCTlUYxAGPOC-jyzcHbs z&IuAp1K7C%!i-%H6hgBV>Zj1{y-Fg9YJtQxBpO58KxvKFtrkMY5H^L%k3>PMB?%RM zUL*M36LmlGslv0cDa7_csocf2LPw3)2^}qgUrq4yPzp(IJ?6g&5*q+$(^NsvOrWij zg%Q-=An@H#eef?(TKi-uP4!|ZHCSbnup~O7hJaItI4G4XL46INNl;_xY-l~`)np8t zHpv0e;nPB)=VxU?kL|Z=w$Ohz(FjTl zvL#K3n6aWg3rfv?a1w@HwwuYEYWjW5}oV zh}kc6CYOnmvYtqLv&Jnw=$TFqD?v3t9y_1WMiQ1Z@a4hSL1{=?LYtTmIZ}!T+eJTD}`L z)u*+`q8v?^Xh%tUocz6_j<72h2=*91-ebIv`ZT!yuYwZWQ^LG80;geV8yJBGXhChE z{5+l()};e%3K%V~0=tf6xFq_@8F9oOG~E(O25cJP1}M!V9$I(;2C21Ak#>0k{{W?t zIGhz4XbhV={?{4J(7Nqp4(Bzq>W*=1mF$Q(P0|qsXc2=v!qn7idm*c+m7q$|{v_&4QGL{* ze$?qZu&GHtPP~I8Cty>|n?Y%A8bGPZ&+jCX=Fnm&jr}f^hMEthF3^?M!zU<8qCLXn z5c%XV(c5o=dB{{rol?Qp7Zri*@97bY==?pPPoYBU(6TaN0Eh_MmlC}%Vtqay!NKYY z5_M2Wu(wYTG$2GB7%q8-{K}H{2)LlBfokmjJ*cP_D)RX%^l%Go8p+6Sg3UkNH0~z# zqw1Fnr<@c@PuV5#q^C>(l=iPPl=iFc4`KffgiX_L1*NXO1E-drLaBxI5-G15D8)-V zskGu8{hXqvD37`B9xX?L8hRiVDpKQW=@%@K{7(&!){*jyq`53@9Mz_hP`i;_(97rw ztw<+5Dc`HDPl%tM4gVKQTHZ4#+Ngeh#Y?>+WAdkSnD35SS9so_rQ78q*awtvREGYFf z6iR_H8cG#;c?9`S!}c2roBXv?Yz3RjHHT6M>xlkw#=-!!GpP$s1AC1bjiMgjLW1-x zlp0KgQpCnUX(T`2fU1N1eL~fey3>RR?u9_2j&~KcDU>QUgwl*DM6Mm6b_ui{4wXrx zct>q8X&tmNu>?~|Bi2r>wxD*3|2b3I5o&|%pY>{I=ogO?g5`-!i1xvz!U)o!G?jIm z2(~sjwXvWL#y!ZV8T;H+Sc+d23-y+W9BUt>#+HQodQ6o_l2DFjd|nh1v{oTd3JUGo z{c{GiGxL8p6WSTjc0{Y%`m_Vl26xo|6wTU#+6J{1Xs2Gg6x#Da>#yyoHtPR*4rpWH zDb5eXNYu}0*G9K??X*#<-A3ANqiyJas#x1Gv4NkvMq5r>zYyejL#)lv7Su-l1b@%q zKyN?w1(~o5)a`{B*$bQY`5GuK-5fC=(aZ<)IUK_jJ3--oo>OqwI^I!;4KFD9yL1u` zrv2on329m@>S1SLgKHmt`LJnU1*#|D^9YILWfvh|)m5nJ9&E}#3qLxsaG{-GK6T2Z zVcmrMGf)bqZBV+2ssjV*FbzAb6#Ns#d>#+t^>jG|VgeiV6^1`2y_2nL5vM@7%TG}1-WZlurv2c<`q zDU{x1`M3zPXn^ZIMUpl|i=CyC#;`}DJgJsvyZ-*D>+L4YZbv9}WIgg}mIk0)4T)x6 z%X%`m(R(+v>|52y(BX-*!SZI0QcELzKK?ae?8K3Auj;uDveOiG?XY)gw`i$RyX&U= zcHIh0_Nr-z{^z>cTQ%W_}?Tj?(_@7Iw>KdG_8FkY2A%0FZUMNDWr;8cO#Ep^Xy)8@0-4tb@t}= zzb~s^uhh3BbwHDa?T%!$>($BZs;0ovOcOnzgC=Z%s`2MF)gH{tI2-q3^7MI}MYnY? zhW594@p$Nq)rJS3*PDBIibgfaD@t}}-i(9=o&A>Gc98D5GH!mVNnvjO9=lH$4ef0= zeVAOdu6cCZHgnbEwNvZY@cq&Jf@a-w&tH6AZ^)Q-TCO)}`n0L7UiH%2YeNs4dta3Y z<`#F^5t4rQ<+6h(PIx!z7#n-+<@ta<=JSW>RGH}Zwbov}!R0b(Lyhg|Hk$OoioL3# zxl=U9{8XGxm_*V6t1Q#Rj8Mw`V71p+kGE7LA=L?-Nw3N}P!aAXNEZ;f#x`523ELjc z8Q?&pvU-{vZzb0k7Ujz{wnLO$1S|`|x~fvZP{ELDq)V0DOEIgGCdW_7wTO^NaEM0h zYI4$)+yq$U$!Tm|m5TkadcykI3E9U8P1eboGV|#gi&HiVpXm}wFUq&|k#%6{t)!eD zh|8}Mw>VAsq8?nO84^i1P#sN7mXfoAWeZD6ZE%sWsEtY*+X+f88&)4!5>3oBrA!*B zu{dKR>k+AmfczDy$vR`h70ndJsiU!VS8@$-oodHdEp=9M9wzZ`y zMYPZfOoFpg?gp!`rhJGcw;rj1e3Mk>B`jgya>inEVua4=X`}&4ZXzt2VI7({`6^g# zHQ7#F zu4)W=JswscP4-wzZVOUWCuYG_DK8?y`2~uvD7J0xMl4I%%?L@Co-pozF*!%bWu&Kt80gbS>ma6zynDLKQ1!j9qBm+J>B5P6s%?1oHO z)JqAIMo5+1-bKO?bonqTgGJS#7Z_~!#lplQ9>*zVMT<3A*KFjy<1i9U`CpduSxBMt ziI&_xr23Oio-J>?R3hp3FMJNT?SFFa6gc%6Lv>Qh8!eMa>^0dg zmfR$y9KkX5)>5S`cbVo(fej~LE^J2Oph$#8Bf+Glt8`&>)7HUmY%KEO*SlqUe#jnsr+_sV5LY^fb8iw)0*~V8Z zvy0bQ+_8}_031!>FEd=J$+}}Bb6KhRa>s_-w^AZeq5&-S45jSFN=-ze4QI}T-dEE| zmndZcO!KACM!o}Z2)}`B8v6*PM#fIKF1- z5T(4$dhBw(7ja18$UxgykfI%hM@_tvvrZPq&s(yV8#GyuZDfZbLv6U`8-=M8jwf$e z_I%B@qg4tRL-|Ui*er&dgqXm}hAQQQ$X}(@4=$w$N7UICYyhrMvGLs zFt8jL_X(ti@*OtZB9RQ|Q{G7Ni^3g6isBa0fUx4WY9fkkxCvW@dLk>aHK0gGZ=&TQ?ZDru@F>$wfL zFjbf@Y!*BxAHq_>MVCL8TW%8yNa$$eJYeOO72*p60m8dwy>@Wg%T1uUFkp*R=! z|J+zukiTH{`q`PwuxPGO##!iKS$+NX#K~MG`uxLhw7M8#o`qO#<>sKwxBNg)&!1|SU57zLX zjtp836{*~6w5mxYb-=Uxu|55K^ zP>27Z`Jf~IgSI{{kqr1Jm4zJFSd`mvPmxK1!iSB_=!7O~tc~2|1lrVCms|dxEl&ow z`4@fAV2y!m{(l&8R=K*4p?S;^^~)jrkf zK{52KCd?V)VgThb+6mVSX*D5N&B zR~J|WRBmyRrUUOtR}{%@k#DGlH%ErqU5?=5>7Y_dzi9932qiLy70~8 zQpqj7ECe{7fUw@dqVwBClar#9_qu|PYOHaGn~oHXj=yVjnXs${uO!^Oud*zC%)0$m zZ7kd3xiBAAFZc+@cb;hBv>&UK>tCa*N@Tnx*B2>zCZIiB&=X+MW)u2S2&*G3xkkEI zDXVv#Wf@>H#$0Fk_QXam`&%Lz#mWsZ1@1_?3dsY%XpI{Z$uNOVK+;`E<{;@UBwH6q zBpyO?5t5=q5t3?wI^M)D1PRF=N$;?TTA0}~z-~Y1rG24b z!3CkEhSgJ>C)-@eB5I?=Q&1}{<(k|TO7IUKg(oapOW{3v2WLFIKz?}_!lHTC)x`MV zEyz8=8&7)=B^Llo=n1YK+*Vi=aJcZhW4DqgUCTI=``SH5&wWo=gW$z~fWc+KvVg_! zM#TqMRNv1BrL6S>_N6YSZ2SX_#W)*o&4Zs|gsSesqQV^ij>_mE3Tw(SYlDyyD#aUE z`7*LJ+4rp!NOXWZ-sR%s54}gi83GFzfUdB5B2Rex7XqsjECv4#uLu_PSh&}9do1*k zf0Lu|fkk8fc}{YhK&U3{jnPWQR$<~7hj%kkp-}5Erg!b ze)`iwpNGJrgNa{9E&-M^EJP5d=8NcwylADo?=w2Pvu9dzGm+{HuR7$V*jyx>g}-|L z7Q~!yPx?0wjbdSE;|)1Z)*x6oS^X@zqe#(AVx@;GWv`1hStEKVo@;M~bS3f?tzV?OZy0{ay?6_G|QWK!(DbUt`7JuxQ1F(RWzI z>Aewy;^*yr2#7UY@Ht7eQV|F1X94y`ADa}U1M+dBa#3=hVbO4e5NiKcxEW!Ss+BsI z-%8kYOQ|(C;JtPQFmswxzn-%B?^%{P&Iic{ZB7j4)Jn8)?qZx_u&5!dTOyvGuxOk@ zvu|M0e#N{6DCI3aVtzE`;g<5LNcH6}v3gqX~>yxmG=rEq(J79_P zk9wZN!rQ_jmO9NoOW4;AQfv9>&v-r z4EZ8qmR&H<^MI*Om?MnsuxR1U1U^ms28*iWPX|u*RfsfB6N5`~2&_STQ}nUk30M}q zC!J4qzX|iGM{i@eaj+;4dj)6Af4;R_g?t((9}YTKze(7kZs=J1a-li&9!u#7YY3dM zc(+vw7^7j}RL)Z=zDp#7Vc;mim2M2I5wvJJd%t6ZR;Z})5A8cS8hF4DF~GI}(_&-e zU>W83<&Z%d>*1E%Frq*8utaCTMxYXk(q!vXFX#$QS< z3l{AxJfDUr4O+^ieAo3P_>DExz+e1z;&r6V(p+jSJFCMiR0#O`x>Dh-D2wrxs${xi zcCs$Bu*QRV0)FA6Cx4IIgp?iMwVc^Xc`+=!8xFOUcfs$Z@J}~+AX36-H9IIJ34*>QU%>S)>ZxLJF~n&*PGiqA9@#{Wzr`!Ri8w zj{q)?OC@9YIoOMo3*Te?20|Yz(Z?XN z9t~K;K(syslpbx>G&!sB84)Z#%9(8_W`Ha{%B@YbJ-|& z3xP6C8e<`TPV>(Rl1*;RvYezx6g!Yl6Re#Ye1YW_RYhVd979qZL(3D}(#1vY0fsn4 z#GXJ(SQP1Lm7>YdU~^Z>jyGWz!?E?MG-VM`xf6an2OBEhQfG5hex5mLGa+JdQja>w zS^u_iqs#o(Q!IqV-^6}?ad`b_J|gr# zr;#?||C_0l-7{tpu2`1+#F&NVkIy&}5IaLeqaNr}>-$Te7T-@0KO4;_-ENcuR_6>P!XPfKOM!SdUcE1o7ANzbjo`*%F zz@Ece8nx9PkT_TslVS0zMBkk9;l$6P#`>z20$D->biq)V|J?qF@!z`iYpAlJ<}Awt z)4<@3k82R;zb%Tq5FAej90`ru@!R^J9>2Gvh=fnXTLXCv7O4v03~6kfxk>6P=h)Xv?7s)V-_Q(#e5!WYb` zu=to|Ip$c17clt;7)_O|r3JI_MbO7t2$L&Z@2gl!CF6t&5R-AR=wZU&nz`?==rX56 z-z2IOR>Jl`rxxSc$FlrzUR(gAz5(KSQ3JoBMf(wVUwn~064ogG>(xC-(Lv2~u9A%~ z_c-I=)fZM5{y2|7N~nw0`3NlGIY?f*_*E@L5klPpDPd4Co+^cCV4bin_rRhG@PQvJ zMNi@GftA4rAiZEw5b@V?E=cJ~mv>a#!v)Q(naQ)+>NHGHz^^UKJbr^;Bi6Fw_@o@#KEAkEa@7p`$7sV`L zVI_9ptu!o(A6%=$l(GZ8*_RNkgk>Ir5h6CZ+r;EGuRmA+?lM9muk#!`&Uf*A++(ET#vpdc`nRg2xp{ z7BK@&RUPz?rsQrQJ$Wb2c(5?5`t%LH+zFPSru?p@!BM2>eoSvv>R{9HZ&bMYL$rOv z+!+MHqJu~9$w}aS|w4H_0t9y=I4K-3Au%n6rZYQW18jbki?0EB=;Oi z;Y7mMZd`+*!h;NNB%`-}(4!p^ARvHw-qPx`Z%h=f&%S@Dy87>*7@QFgq z86_7yQaBqcX~@cjMb!ybRTng=wRFNL)%yR}0~`2$cNr>q>D-?g}h=C&||;Gabbu<|F>qqlAr$JNyup z0>((b0G)R~V9_MwiGwHfc$0 z&mG4i|1e8K$_);9ACJ%e^~MORDp+G-(Rg`Fz8@Ag06sRCm5n+0WudfkRpY7{*L4|w zh)Ngle6m^OF=^eXN=V&-N#rhmQ1(4C{-MP0i=0#!_G8f|rQ}o5{x_xa&qTjs(GMC0 zERhf(_Cm}cr2;QSo3t)$9Uc4+S_wZWO93T6j^IC(cx91SRO0#~*Vhr+uK=i;iVpvK zHVwcaO6-Zje^H9e+Q^qfjYYqTN+YT#a#Bi~h;~J#B{Kyl|0ZHSTe?cB8&wme3kY?o zo0vn&!R`U25~PrK~32c#cWctCqW5K7I{Ub{3)WJkCp+=$uDJ7eVycv{^r%q5R*cD14X9HD0`-*Z-tZltB%T6V*d3KuQfw5N%Qlt%*?bpCocpvb{u`l>8<`X|K$HQaw>n@{1NV z28ve;2_?v34wR;Tp^(9=0c=J#D_kpW#K%ganEn4msohm#xr#~?zYd)Gn+&CHY=V;i zW+?uXq>7rRD;~f|@Q&$}ix(+Xum?*0&k*^)DOH>)`X7Rl>rwomhL1xj|AeR~MLi|z zXyQ}7VT?LdXZ9n*P%3kzeW3os0AXwCEB+cw_aK+ zio*G^h@L>{MM}wH{Gh5{K&k0hB7Y~^AE8wK8ez zHnl_tQmVL~XjfF?rXsJX)If8QS5%sl79uC50hvLGx03f38tNcA{F_q6UBvu~N)>gZ z3YmMdv@Y(c_)Q4Wt)ddQAUCKLlw54_gC?{O8UKUouryT5KPCH%ZlqMqQM5_x!JY)A zUQQA7{lxr=O5N}mIVsswMVplD0MV|XQG9Di&?1up$H~RPvX>Ce?wqfVL9zNr|_H z66!$j8whkHOH}GmXVL!uL#h5QVtu4!cZE_rCQ31blrmJJb|+;Qwupy@jfi_d>Gf|) z#e0hRq%<+TMVplJ`#`DPzM|bv%qOLMdnm2=K+$%rB!&nQ{OjM88X6+zS5&$Kxr&^W z8XhIuq}1SO(IzF^U9^9x7$RelL6@gMXbWfplp0k7!P=^j{LnK=SN1)Vjj;JT0)KD&zUZiB77xOQPoRrF4g;K@Wp!kGPBE<56qIRNo6txSKI;awDODHvHEpl5Zy-3N=4oV&CD{4P6pA>%` z08xnqRnT8_7$E9EQ5~Vwks(m3aJXoX6!Tr6^dcp{QKF6!)kD+?P`a@DK&d@HC?Efn zpn?IS28%jP)CedwJX5q|pj5$ZkPihiV2ZokMgL{3U?T~0%(L+7Bh#8;rSiwgBHegcm~haxDw zNU4V}p;YlJC^h(2%>Qpn19=BO()Xf%fKq*>P?~`%SO>DJD}+ZB5<){Yp;WMzXd6Q5 zRZ*!UM&MLI187TVZzzq>9!mKGM0*I7{DzBsq-c*4?J-eEG={@W5yU`g#IvE)kvSrt z52Y6=HLy_BI4Jr31*L|TL+M3ITmz;0;-OS7LDbcvMy*AH3T_Z}ix*h4`c+iQZv;;DH5S!W z)Fz@oDRrnh)C0;V{;5<^OR>PeDRrognE&5Y!L0wrHG`__26yV0QY`jwN}I?E`J}d@ zA1OAj#9p-jn^Lz1!jHx`7)tgqj-O^e0rS5rg`BI{5Gi$J43s)P7D^S57xVv3X`mCt zd{Qc>=EQ5pBmnv}DpYi+sPry3TI8fuFhVkr5?LFq+G_Ftkd zi4rrGK&gV|qQ;93E5&?LD#%1l5P3zVDP1FSQmSvAs2iX(gDFs|Csov_ZAj2m?h-*d zlwSX)G}1j{J}KGzpj2^|Xdf2yNhvl?K&io#A}1yL6cpv7BxjJI50$Qq8TnA!=!K$v z7fP>+N*#VA@_$q6*b}k*Q_=6=wGG}c{a1$y`3IBtNN*eb`APzl`{yeO`f7qU+@G%` zDt;A#3E{t@pdJ3_D~Uf}N&NXrg3buM{(L3z=PL<(@JJUIyhtf9Dt;9~oNT(gbR+%q zmBgQ~B0+Ip4$9%tPddKabGw6QcH@_N?Urua;HqQV(ayH1 zk*93V^}@}b8x0+{rYv&r{LS9^R9%a_tQ6<*%a_+oX!P;=mNBpG4?ZcB_FU03e9=~u z%x8LDrg5D|KW*}H=wH{;J==|OY`SH2%>*8gEM^G@B@ zN1X39$fld^WQS+L@`LI?S!3zIsSTbbRqMZa)ruGC;Wul^UMyWadC!3tj&yQ*hV703C?a4u9UzZ+ zOb1Aq2H@)kaE{fO0bm~n5HbVc0?Q##N??CmfJ-bO5+EfUAU_h|3NxJv;1U5acP79! zc7=dqIzZPbfP5Ac1&~4D34t4|V>E!*41oA(fSc?A0i#F&yBL7mY*`FIZltUV8|Wh& zC@o~RvjD7)wkEI*J$0ec}qqoZvMKCb$&f_ zK*Wt9p}pVuXq;QtV`=kbb)$6O{#CZ+L&4HL&bJJgn#}M@H8Om)rCjf|f9(O|gO7iI z)n9Ljb86Dio|Cp#-ut-erKKGTw>URAmUU<#S8J;Im*9EFD)jGj#r}OizPabp7!POb znuo0C?Kr#Pm0t0f;jWj?Kf8YB=IzG^U#k}0c((mey}7F6#THGMKP-CP;CsUZt{>l) z#h$F{^rTFEmATKC8AY89etCBH(BMH{lip_=_4{zO_p@1^>qb`E+i%&zsypX+l&Uw4 zT7M^LZ{Gg0;RtZ5%cG5w za`fXpI&a#YH)`KQ*E^e{_OENS=&OCP+lWpTy7yJeo-dX;v)ow3kz<@}fUI1~R>gsY zEdVJe@>UP-ZZ&txB;XH0$Y#t+NJKDkw2M66<&=IxK%gy^>gq*l$fD(?+H} zdD~#_qq$?|>bq@tI%i{WlNwv+d<*S6sX^5ZP1&54721}vvcC|q1*n#}FF^?Fu+$|0 zv5Norw#KVt{M{dW>5JP(;9Y834yJ2_(b;m@Eg-XWq*J?EeC|K)`?*YXC}r zf@&;}K*|z$n6H3G4VFViT$Tb95~w8r#WH}ncmP8IWDu}k2~d|UScxKD%P}KMhU*vv z#xGCUdUI5#0S*msO*L(m8u6`gc#DFTx|1(W4{Q2y#gZ5A4v%d!_Tb%S*9@~}HL3kA z!S%0?`|4NG?Q`dL?Futu%(^pFYotLtNeu1OXT=0^2{^*pn!n;DuAXebrnGDN&th^0L_`}Y5)}j$R^N|accmI2>7l6XvH!KBqRWs zBmuNx-bn!Vi2xS}m^0(G0Hp*X*8;R>c?42c0hq4?=*Ys?0l2INC?wFCwOS9LSOXBZ z9-u3`Ng#uObuxgGEl39NN&+Y$(4BSP0AREhAZY`DB`YS7OTcj>fHh0p2oSaopqxMt z=CBFCY&}5QCIDMjMxcOz`(}XNEOj$LY_hB>tGY$jUuwr(w*aU%z$1GLJo+(iD?kwe z->m=+ER#UOMgWr(fC0=q1;Bn2zy$)1%s3UGlt5%Ez+jd~AZ0Uv`8EJ27QPL@WeY$d zfnluGb^ygzfVk}dBiKy>83e3%0E}b{b^v�F)4Lr9Xg%`A7vwN&^_hiqimc2{`Tq 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 Date: Mon, 13 May 2024 16:53:33 +0200 Subject: [PATCH 0326/1532] feat(champ): add updated_by column --- app/controllers/champs/champ_controller.rb | 2 +- .../instructeurs/dossiers_controller.rb | 2 +- app/controllers/users/dossiers_controller.rb | 2 +- .../mutations/dossier_modifier_annotation.rb | 2 +- app/models/concerns/dossier_champs_concern.rb | 17 +++++++++-------- .../prefill_repetition_type_de_champ.rb | 2 +- .../20240513140508_add_updated_by_to_champs.rb | 5 +++++ db/schema.rb | 1 + .../piece_justificative_controller_spec.rb | 3 +-- .../concerns/dossier_champs_concern_spec.rb | 8 ++++---- 10 files changed, 25 insertions(+), 19 deletions(-) create mode 100644 db/migrate/20240513140508_add_updated_by_to_champs.rb diff --git a/app/controllers/champs/champ_controller.rb b/app/controllers/champs/champ_controller.rb index 73ecb3044..adf03a778 100644 --- a/app/controllers/champs/champ_controller.rb +++ b/app/controllers/champs/champ_controller.rb @@ -7,7 +7,7 @@ class Champs::ChampController < ApplicationController def find_champ dossier = policy_scope(Dossier).includes(:champs, revision: [:types_de_champ]).find(params[:dossier_id]) type_de_champ = dossier.find_type_de_champ_by_stable_id(params[:stable_id]) - dossier.champ_for_update(type_de_champ, params_row_id) + dossier.champ_for_update(type_de_champ, params_row_id, updated_by: current_user.email) end def params_row_id diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index 7657f9a48..3fa84c917 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -274,7 +274,7 @@ module Instructeurs end def update_annotations - dossier_with_champs.update_champs_attributes(champs_private_attributes_params, :private) + dossier_with_champs.update_champs_attributes(champs_private_attributes_params, :private, updated_by: current_user.email) if dossier.champs.any?(&:changed_for_autosave?) dossier.last_champ_private_updated_at = Time.zone.now end diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index 592f16f2c..acdfd1332 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -551,7 +551,7 @@ module Users end def update_dossier_and_compute_errors - @dossier.update_champs_attributes(champs_public_attributes_params, :public) + @dossier.update_champs_attributes(champs_public_attributes_params, :public, updated_by: current_user.email) if @dossier.champs.any?(&:changed_for_autosave?) @dossier.last_champ_updated_at = Time.zone.now end diff --git a/app/graphql/mutations/dossier_modifier_annotation.rb b/app/graphql/mutations/dossier_modifier_annotation.rb index 30dbd6f7e..de467fa00 100644 --- a/app/graphql/mutations/dossier_modifier_annotation.rb +++ b/app/graphql/mutations/dossier_modifier_annotation.rb @@ -44,7 +44,7 @@ module Mutations .find_by(type_champ: annotation_type_champ, stable_id:) return nil if type_de_champ.nil? - dossier.champ_for_update(type_de_champ, row_id) + dossier.champ_for_update(type_de_champ, row_id, updated_by: current_administrateur.email) end def annotation_type_champ diff --git a/app/models/concerns/dossier_champs_concern.rb b/app/models/concerns/dossier_champs_concern.rb index 40c0692c8..a5c05a5d0 100644 --- a/app/models/concerns/dossier_champs_concern.rb +++ b/app/models/concerns/dossier_champs_concern.rb @@ -55,18 +55,18 @@ module DossierChampsConcern .types_de_champ .filter { _1.stable_id.in?(stable_ids) } .filter { !revision.child?(_1) } - .map { champ_for_update(_1, nil) } + .map { champ_for_update(_1, nil, updated_by: nil) } end - def champ_for_update(type_de_champ, row_id) - champ, attributes = champ_with_attributes_for_update(type_de_champ, row_id) + def champ_for_update(type_de_champ, row_id, updated_by:) + champ, attributes = champ_with_attributes_for_update(type_de_champ, row_id, updated_by:) champ.assign_attributes(attributes) champ end - def update_champs_attributes(attributes, scope) + def update_champs_attributes(attributes, scope, updated_by:) champs_attributes = attributes.to_h.map do |public_id, attributes| - champ_attributes_by_public_id(public_id, attributes, scope) + champ_attributes_by_public_id(public_id, attributes, scope, updated_by:) end assign_attributes(champs_attributes:) @@ -87,13 +87,13 @@ module DossierChampsConcern end end - def champ_attributes_by_public_id(public_id, attributes, scope) + def champ_attributes_by_public_id(public_id, attributes, scope, updated_by:) stable_id, row_id = public_id.split('-') type_de_champ = find_type_de_champ_by_stable_id(stable_id, scope) - champ_with_attributes_for_update(type_de_champ, row_id).last.merge(attributes) + champ_with_attributes_for_update(type_de_champ, row_id, updated_by:).last.merge(attributes) end - def champ_with_attributes_for_update(type_de_champ, row_id) + def champ_with_attributes_for_update(type_de_champ, row_id, updated_by:) attributes = type_de_champ.params_for_champ # TODO: Once we have the right index in place, we should change this to use `create_or_find_by` instead of `find_or_create_by` champ = champs @@ -101,6 +101,7 @@ module DossierChampsConcern .find_or_create_by!(stable_id: type_de_champ.stable_id, row_id:) attributes[:id] = champ.id + attributes[:updated_by] = updated_by # Needed when a revision change the champ type in this case, we reset the champ data if champ.type != attributes[:type] diff --git a/app/models/types_de_champ/prefill_repetition_type_de_champ.rb b/app/models/types_de_champ/prefill_repetition_type_de_champ.rb index e90457e4b..a61e7119d 100644 --- a/app/models/types_de_champ/prefill_repetition_type_de_champ.rb +++ b/app/models/types_de_champ/prefill_repetition_type_de_champ.rb @@ -64,7 +64,7 @@ class TypesDeChamp::PrefillRepetitionTypeDeChamp < TypesDeChamp::PrefillTypeDeCh type_de_champ = revision.types_de_champ.find { _1.stable_id == stable_id } next unless type_de_champ - subchamp = champ.dossier.champ_for_update(type_de_champ, row_id) + subchamp = champ.dossier.champ_for_update(type_de_champ, row_id, updated_by: nil) TypesDeChamp::PrefillTypeDeChamp.build(subchamp.type_de_champ, revision).to_assignable_attributes(subchamp, value) end.compact end diff --git a/db/migrate/20240513140508_add_updated_by_to_champs.rb b/db/migrate/20240513140508_add_updated_by_to_champs.rb new file mode 100644 index 000000000..d7621ab6b --- /dev/null +++ b/db/migrate/20240513140508_add_updated_by_to_champs.rb @@ -0,0 +1,5 @@ +class AddUpdatedByToChamps < ActiveRecord::Migration[7.0] + def change + add_column :champs, :updated_by, :text + end +end diff --git a/db/schema.rb b/db/schema.rb index 74b41f6c7..6f7fcfe7c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -263,6 +263,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_05_27_090508) do t.string "type" t.integer "type_de_champ_id" t.datetime "updated_at", precision: nil + t.text "updated_by" t.string "value" t.jsonb "value_json" t.index ["dossier_id"], name: "index_champs_on_dossier_id" diff --git a/spec/controllers/champs/piece_justificative_controller_spec.rb b/spec/controllers/champs/piece_justificative_controller_spec.rb index 0c0eba1e1..d63c4b77a 100644 --- a/spec/controllers/champs/piece_justificative_controller_spec.rb +++ b/spec/controllers/champs/piece_justificative_controller_spec.rb @@ -10,7 +10,6 @@ describe Champs::PieceJustificativeController, type: :controller do subject do put :update, params: { - position: '1', dossier_id: champ.dossier_id, stable_id: champ.stable_id, blob_signed_id: file @@ -49,7 +48,7 @@ describe Champs::PieceJustificativeController, type: :controller do # See https://github.com/betagouv/demarches-simplifiees.fr/issues/4926 before do champ - expect_any_instance_of(Champs::PieceJustificativeChamp).to receive(:save).twice.and_return(false) + expect_any_instance_of(Champs::PieceJustificativeChamp).to receive(:save).and_return(false) expect_any_instance_of(Champs::PieceJustificativeChamp).to receive(:errors) .and_return(double(full_messages: ['La pièce justificative n’est pas d’un type accepté'])) end diff --git a/spec/models/concerns/dossier_champs_concern_spec.rb b/spec/models/concerns/dossier_champs_concern_spec.rb index a9e58ca77..42d22a340 100644 --- a/spec/models/concerns/dossier_champs_concern_spec.rb +++ b/spec/models/concerns/dossier_champs_concern_spec.rb @@ -124,7 +124,7 @@ RSpec.describe DossierChampsConcern do let(:row_id) { nil } context "public champ" do - subject { dossier.champ_for_update(type_de_champ_public, row_id) } + subject { dossier.champ_for_update(type_de_champ_public, row_id, updated_by: dossier.user.email) } it { expect(subject.persisted?).to be_truthy @@ -165,7 +165,7 @@ RSpec.describe DossierChampsConcern do end context "private champ" do - subject { dossier.champ_for_update(type_de_champ_private, row_id) } + subject { dossier.champ_for_update(type_de_champ_private, row_id, updated_by: dossier.user.email) } it { expect(subject.persisted?).to be_truthy @@ -191,7 +191,7 @@ RSpec.describe DossierChampsConcern do let(:champ_991) { dossier.project_champ(dossier.find_type_de_champ_by_stable_id(991), nil) } let(:champ_994) { dossier.project_champ(dossier.find_type_de_champ_by_stable_id(994), row_id) } - subject { dossier.update_champs_attributes(attributes, :public) } + subject { dossier.update_champs_attributes(attributes, :public, updated_by: dossier.user.email) } it { subject @@ -229,7 +229,7 @@ RSpec.describe DossierChampsConcern do let(:annotation_995) { dossier.project_champ(dossier.find_type_de_champ_by_stable_id(995), nil) } - subject { dossier.update_champs_attributes(attributes, :private) } + subject { dossier.update_champs_attributes(attributes, :private, updated_by: dossier.user.email) } it { subject From ac23d5fb4138885187f44b16c2dc1c96a9d2fed2 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 4 Jun 2024 10:19:15 +0200 Subject: [PATCH 0327/1532] convert date with dash for export renaming --- .../concerns/tags_substitution_concern.rb | 3 ++- app/models/export_template.rb | 1 + spec/factories/export_template.rb | 20 +++++++++++++++++++ spec/models/export_template_spec.rb | 8 ++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/tags_substitution_concern.rb b/app/models/concerns/tags_substitution_concern.rb index 373ad3018..ae899dc04 100644 --- a/app/models/concerns/tags_substitution_concern.rb +++ b/app/models/concerns/tags_substitution_concern.rb @@ -307,7 +307,8 @@ module TagsSubstitutionConcern def format_date(date) if date.present? - date.strftime('%d/%m/%Y') + format = defined?(self.class::FORMAT_DATE) ? self.class::FORMAT_DATE : '%d/%m/%Y' + date.strftime(format) else '' end diff --git a/app/models/export_template.rb b/app/models/export_template.rb index febadd05a..550bb57cd 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -7,6 +7,7 @@ class ExportTemplate < ApplicationRecord validates_with ExportTemplateValidator DOSSIER_STATE = Dossier.states.fetch(:en_construction) + FORMAT_DATE = "%Y-%m-%d" def set_default_values content["default_dossier_directory"] = tiptap_json("dossier-") diff --git a/spec/factories/export_template.rb b/spec/factories/export_template.rb index d62754115..6785356af 100644 --- a/spec/factories/export_template.rb +++ b/spec/factories/export_template.rb @@ -64,5 +64,25 @@ FactoryBot.define do export_template.save end end + + trait :with_date_depot_for_export_pdf do + to_create do |export_template, _| + export_template.set_default_values + export_template.content["pdf_name"]["content"] = [ + { + "type" => "paragraph", + "content" => + [ + { "text" => "export_", "type" => "text" }, + { "type" => "mention", "attrs" => { "id" => "dossier_number", "label" => "numéro du dossier" } }, + { "text" => "-", "type" => "text" }, + { "type" => "mention", "attrs" => { "id" => "dossier_depose_at", "label" => "date de dépôt" } }, + { "text" => " ", "type" => "text" } + ] + } + ] + export_template.save + end + end end end diff --git a/spec/models/export_template_spec.rb b/spec/models/export_template_spec.rb index 443959bc1..d90de6744 100644 --- a/spec/models/export_template_spec.rb +++ b/spec/models/export_template_spec.rb @@ -190,6 +190,14 @@ describe ExportTemplate do it 'convert pdf_name' do expect(export_template.tiptap_convert(procedure.dossiers.first, "pdf_name")).to eq "mon_export_#{dossier.id}" end + + context 'for date' do + let(:export_template) { create(:export_template, :with_date_depot_for_export_pdf, groupe_instructeur:) } + let(:dossier) { create(:dossier, :en_construction, procedure:, depose_at: Date.parse("2024/03/30")) } + it 'convert date with dash' do + expect(export_template.tiptap_convert(dossier, "pdf_name")).to eq "export_#{dossier.id}-2024-03-30" + end + end end describe '#tiptap_convert_pj' do From bf3455bbf0ca9c778d6ad023d8a75c7649ac05d9 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 4 Jun 2024 10:43:37 +0200 Subject: [PATCH 0328/1532] fix(api): public api v1 should not inherit from api v1 --- app/controllers/api/public/v1/base_controller.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/public/v1/base_controller.rb b/app/controllers/api/public/v1/base_controller.rb index a60f9caeb..7353c83a3 100644 --- a/app/controllers/api/public/v1/base_controller.rb +++ b/app/controllers/api/public/v1/base_controller.rb @@ -1,8 +1,12 @@ -class API::Public::V1::BaseController < APIController +class API::Public::V1::BaseController < ApplicationController skip_forgery_protection before_action :check_content_type_is_json, if: -> { request.post? || request.patch? || request.put? } + before_action do + Current.browser = 'api' + end + protected def render_missing_param(param_name) From febcbf0d5a765b0fa822c694122698760fcf7a19 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Tue, 4 Jun 2024 13:45:59 +0200 Subject: [PATCH 0329/1532] using dsfr class for spacing --- app/assets/stylesheets/card_admin.scss | 4 -- .../procedures/new_from_existing.html.haml | 59 +++++++++---------- 2 files changed, 29 insertions(+), 34 deletions(-) diff --git a/app/assets/stylesheets/card_admin.scss b/app/assets/stylesheets/card_admin.scss index 5001efce2..8f678d3aa 100644 --- a/app/assets/stylesheets/card_admin.scss +++ b/app/assets/stylesheets/card_admin.scss @@ -4,7 +4,3 @@ .fr-tile-subtitle { min-height: 7rem; } - -.card-welcome { - margin: 30px auto; -} diff --git a/app/views/administrateurs/procedures/new_from_existing.html.haml b/app/views/administrateurs/procedures/new_from_existing.html.haml index d5f4adf58..228004b1d 100644 --- a/app/views/administrateurs/procedures/new_from_existing.html.haml +++ b/app/views/administrateurs/procedures/new_from_existing.html.haml @@ -1,35 +1,34 @@ .container - if current_administrateur.procedures.brouillons.count == 0 - .card-welcome - = render Dsfr::CalloutComponent.new(title: nil, icon: "fr-icon-information-line") do |c| - - c.with_html_body do - %p - Bienvenue, - %br - vous allez pouvoir créer une première démarche de test. - Celle-ci sera visible uniquement par vous et ne sera publiée nulle part, alors pas de crainte à avoir. - %br - %br - Besoin d’aide ? - %br - > Vous pouvez - = link_to "visionner cette vidéo", - "https://vimeo.com/261478872", - target: "_blank" - %br - > Vous pouvez lire notre - = link_to "documentation en ligne", - ADMINISTRATEUR_TUTORIAL_URL, - target: "_blank" - %br - > Vous pouvez enfin - = link_to "prendre un rendez-vous téléphonique avec nous", - CALENDLY_URL, - target: "_blank" - :javascript - document.addEventListener("DOMContentLoaded", function() { - $crisp.push(["do", "trigger:run", ["admin-signup"]]); - }); + = render Dsfr::CalloutComponent.new(title: nil, icon: "fr-icon-information-line", extra_class_names: 'fr-my-4w') do |c| + - c.with_html_body do + %p + Bienvenue, + %br + vous allez pouvoir créer une première démarche de test. + Celle-ci sera visible uniquement par vous et ne sera publiée nulle part, alors pas de crainte à avoir. + %br + %br + Besoin d’aide ? + %br + > Vous pouvez + = link_to "visionner cette vidéo", + "https://vimeo.com/261478872", + target: "_blank" + %br + > Vous pouvez lire notre + = link_to "documentation en ligne", + ADMINISTRATEUR_TUTORIAL_URL, + target: "_blank" + %br + > Vous pouvez enfin + = link_to "prendre un rendez-vous téléphonique avec nous", + CALENDLY_URL, + target: "_blank" + :javascript + document.addEventListener("DOMContentLoaded", function() { + $crisp.push(["do", "trigger:run", ["admin-signup"]]); + }); .form From ec269a568c074a1446f8c363e98da42d2c585ce3 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 3 Jun 2024 22:38:23 +0200 Subject: [PATCH 0330/1532] fix(mailer): fix delivery prevented with bcc --- app/lib/balancer_delivery_method.rb | 1 + spec/lib/balancer_delivery_method_spec.rb | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/lib/balancer_delivery_method.rb b/app/lib/balancer_delivery_method.rb index 2ed33ae91..4ad5d5728 100644 --- a/app/lib/balancer_delivery_method.rb +++ b/app/lib/balancer_delivery_method.rb @@ -45,6 +45,7 @@ class BalancerDeliveryMethod def prevent_delivery?(mail) return false if mail[BYPASS_UNVERIFIED_MAIL_PROTECTION].present? + return false if mail.to.blank? # bcc list user = User.find_by(email: mail.to.first) return user.unverified_email? if user.present? diff --git a/spec/lib/balancer_delivery_method_spec.rb b/spec/lib/balancer_delivery_method_spec.rb index 659adb46e..92cb80082 100644 --- a/spec/lib/balancer_delivery_method_spec.rb +++ b/spec/lib/balancer_delivery_method_spec.rb @@ -2,8 +2,8 @@ RSpec.describe BalancerDeliveryMethod do class ExampleMailer < ApplicationMailer include BalancedDeliveryConcern - def greet(name, bypass_unverified_mail_protection: true) - mail(to: name, from: "smtp_from", body: "Hello #{name}") + def greet(name, bypass_unverified_mail_protection: true, **mail_args) + mail(to: name, from: "smtp_from", body: "Hello #{name}", **mail_args) bypass_unverified_mail_protection! if bypass_unverified_mail_protection end @@ -202,6 +202,13 @@ RSpec.describe BalancerDeliveryMethod do it { expect(mail).to have_been_delivered_using(MockSmtp) } end end + + context 'when there are only bcc recipients' do + let(:bypass_unverified_mail_protection) { false } + let(:mail) { ExampleMailer.greet(nil, bypass_unverified_mail_protection: false, bcc: ["'u@a.com'"]) } + + it { expect(mail).to have_been_delivered_using(MockSmtp) } + end end # Helpers From f98517852f76354e9eb4f8d35c373462c272a8e3 Mon Sep 17 00:00:00 2001 From: mfo Date: Fri, 31 May 2024 08:20:45 +0200 Subject: [PATCH 0331/1532] feat(spec.procedure_administrateurs_spec): les navigation and cases --- .../procedure_administrateurs_spec.rb | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/spec/system/administrateurs/procedure_administrateurs_spec.rb b/spec/system/administrateurs/procedure_administrateurs_spec.rb index 420c439b3..b2111cd14 100644 --- a/spec/system/administrateurs/procedure_administrateurs_spec.rb +++ b/spec/system/administrateurs/procedure_administrateurs_spec.rb @@ -11,26 +11,17 @@ describe 'Administrateurs can manage administrateurs', js: true do login_as administrateur.user, scope: :user end - scenario 'card is clickable' do + scenario "card is clickable, and i can send invitation when i'm not a manager" do + another_administrateur = create(:administrateur) visit admin_procedure_path(procedure) find('#administrateurs').click expect(page).to have_css("h1", text: "Administrateurs") - end - context 'as admin not flagged from manager' do - let(:manager) { false } + fill_in('administrateur_email', with: another_administrateur.email) - scenario 'the administrator can add another administrator' do - another_administrateur = create(:administrateur) - visit admin_procedure_administrateurs_path(procedure) - - fill_in('administrateur_email', with: another_administrateur.email) - - click_on 'Ajouter comme administrateur' - - within('.alert-success') do - expect(page).to have_content(another_administrateur.email) - end + click_on 'Ajouter comme administrateur' + within('.alert-success') do + expect(page).to have_content(another_administrateur.email) end end From f14c88a54aee8bb8fefd3243ee876b8b40dc4a64 Mon Sep 17 00:00:00 2001 From: mfo Date: Fri, 31 May 2024 09:41:31 +0200 Subject: [PATCH 0332/1532] clean(spec): speed, avoid using with_all_champs factory. takes too much time when not needed --- app/models/dossier.rb | 1 - .../procedures_controller_spec.rb | 22 +- .../users/dossiers_controller_spec.rb | 5 +- ...0220705164551_remove_unused_champs_spec.rb | 22 -- spec/models/dossier_spec.rb | 20 +- .../services/procedure_export_service_spec.rb | 361 +++++++----------- spec/system/accessibilite/wcag_usager_spec.rb | 91 +++-- 7 files changed, 209 insertions(+), 313 deletions(-) delete mode 100644 spec/lib/tasks/deployment/20220705164551_remove_unused_champs_spec.rb diff --git a/app/models/dossier.rb b/app/models/dossier.rb index dbfce2464..123c7768c 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -1006,7 +1006,6 @@ class Dossier < ApplicationRecord else columns << ['Entreprise raison sociale', etablissement&.entreprise_raison_sociale] end - if procedure.chorusable? && procedure.chorus_configuration.complete? columns += [ ['Domaine Fonctionnel', procedure.chorus_configuration.domaine_fonctionnel&.fetch("code") { '' }], diff --git a/spec/controllers/administrateurs/procedures_controller_spec.rb b/spec/controllers/administrateurs/procedures_controller_spec.rb index e19155b82..0cb5bfa2d 100644 --- a/spec/controllers/administrateurs/procedures_controller_spec.rb +++ b/spec/controllers/administrateurs/procedures_controller_spec.rb @@ -16,24 +16,28 @@ describe Administrateurs::ProceduresController, type: :controller do let(:tags) { "[\"planete\",\"environnement\"]" } describe '#apercu' do - render_views - - let(:procedure) { create(:procedure, :with_all_champs) } - subject { get :apercu, params: { id: procedure.id } } before do sign_in(admin.user) end - it do - subject - expect(response).to have_http_status(:ok) - expect(procedure.dossiers.visible_by_user).to be_empty - expect(procedure.dossiers.for_procedure_preview).not_to be_empty + context 'all tdc can be rendered' do + render_views + + let(:procedure) { create(:procedure, :with_all_champs) } + + it do + subject + expect(response).to have_http_status(:ok) + expect(procedure.dossiers.visible_by_user).to be_empty + expect(procedure.dossiers.for_procedure_preview).not_to be_empty + end end context 'when the draft is invalid' do + let(:procedure) { create(:procedure) } + before do allow_any_instance_of(ProcedureRevision).to receive(:invalid?).and_return(true) end diff --git a/spec/controllers/users/dossiers_controller_spec.rb b/spec/controllers/users/dossiers_controller_spec.rb index 8d3b702f9..220bd6722 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -1489,15 +1489,18 @@ describe Users::DossiersController, type: :controller do end describe '#clone' do - let(:procedure) { create(:procedure, :with_all_champs) } let(:dossier) { create(:dossier, procedure: procedure) } subject { post :clone, params: { id: dossier.id } } context 'not signed in' do + let(:procedure) { create(:procedure) } + it { expect(subject).to redirect_to(new_user_session_path) } end context 'signed with user dossier' do + let(:procedure) { create(:procedure, :with_all_champs) } + before { sign_in dossier.user } it { expect(subject).to redirect_to(brouillon_dossier_path(Dossier.last)) } diff --git a/spec/lib/tasks/deployment/20220705164551_remove_unused_champs_spec.rb b/spec/lib/tasks/deployment/20220705164551_remove_unused_champs_spec.rb deleted file mode 100644 index 1a4fdf386..000000000 --- a/spec/lib/tasks/deployment/20220705164551_remove_unused_champs_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -describe '20220705164551_remove_unused_champs' do - let(:rake_task) { Rake::Task['after_party:remove_unused_champs'] } - let(:procedure) { create(:procedure, :with_all_champs) } - let(:dossier) { create(:dossier, :with_populated_champs, procedure: procedure) } - let(:champ_repetition) { dossier.champs_public.find(&:repetition?) } - - subject(:run_task) do - dossier - rake_task.invoke - end - - before { champ_repetition.champs.first.update(type_de_champ: create(:type_de_champ)) } - after { rake_task.reenable } - - describe 'remove_unused_champs' do - it "with bad champs" do - expect(Champ.where(dossier: dossier).count).to eq(44) - run_task - expect(Champ.where(dossier: dossier).count).to eq(43) - end - end -end diff --git a/spec/models/dossier_spec.rb b/spec/models/dossier_spec.rb index f52242650..92f0b0944 100644 --- a/spec/models/dossier_spec.rb +++ b/spec/models/dossier_spec.rb @@ -2112,25 +2112,19 @@ describe Dossier, type: :model do create(:attestation, dossier: dossier) end - it "can destroy dossier" do + it "can destroy dossier, reset demarche, logg context" do + json_message = nil + allow(Rails.logger).to receive(:info) { json_message ||= _1 } + expect(dossier.destroy).to be_truthy expect { dossier.reload }.to raise_error(ActiveRecord::RecordNotFound) - end - - it "can reset demarche" do - expect { dossier.procedure.reset! }.not_to raise_error - expect { dossier.reload }.to raise_error(ActiveRecord::RecordNotFound) - end - - it "call logger with context" do - json_message = nil - - allow(Rails.logger).to receive(:info) { json_message ||= _1 } - dossier.destroy expect(JSON.parse(json_message)).to a_hash_including( { message: "Dossier destroyed", dossier_id: dossier.id, procedure_id: procedure.id }.stringify_keys ) + + expect { dossier.procedure.reset! }.not_to raise_error + expect { dossier.reload }.to raise_error(ActiveRecord::RecordNotFound) end end diff --git a/spec/services/procedure_export_service_spec.rb b/spec/services/procedure_export_service_spec.rb index 0c03cfd98..30488f846 100644 --- a/spec/services/procedure_export_service_spec.rb +++ b/spec/services/procedure_export_service_spec.rb @@ -2,7 +2,6 @@ require 'csv' describe ProcedureExportService do let(:instructeur) { create(:instructeur) } - let(:procedure) { create(:procedure, :published, :for_individual, :with_all_champs) } let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) } let(:export_template) { nil } @@ -18,159 +17,166 @@ describe ProcedureExportService do let(:avis_sheet) { subject.sheets.third } let(:repetition_sheet) { subject.sheets.fourth } - before do - # change one tdc place to check if the header is ordered - tdc_first = procedure.active_revision.revision_types_de_champ_public.first - tdc_last = procedure.active_revision.revision_types_de_champ_public.last - - tdc_first.update(position: tdc_last.position + 1) - procedure.reload - end - describe 'sheets' do + let(:procedure) { create(:procedure) } + it 'should have a sheet for each record type' do expect(subject.sheets.map(&:name)).to eq(['Dossiers', 'Etablissements', 'Avis']) end end describe 'Dossiers sheet' do - let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } + context 'with all data for individual' do + let(:procedure) { create(:procedure, :published, :for_individual, :with_all_champs) } + let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } - let(:nominal_headers) do - [ - "ID", - "Email", - "FranceConnect ?", - "Civilité", - "Nom", - "Prénom", - "Dépôt pour un tiers", - "Nom du mandataire", - "Prénom du mandataire", - "Archivé", - "État du dossier", - "Dernière mise à jour le", - "Dernière mise à jour du dossier le", - "Déposé le", - "Passé en instruction le", - "Traité le", - "Motivation de la décision", - "Instructeurs", - "textarea", - "date", - "datetime", - "number", - "decimal_number", - "integer_number", - "checkbox", - "civilite", - "email", - "phone", - "address", - "yes_no", - "simple_drop_down_list", - "multiple_drop_down_list", - "linked_drop_down_list", - "communes", - "communes (Code INSEE)", - "communes (Département)", - "departements", - "departements (Code)", - "regions", - "regions (Code)", - "pays", - "pays (Code)", - "dossier_link", - "piece_justificative", - "rna", - "carte", - "titre_identite", - "iban", - "siret", - "annuaire_education", - "cnaf", - "dgfip", - "pole_emploi", - "mesri", - "text", - "epci", - "epci (Code)", - "epci (Département)", - "cojo", - "expression_reguliere", - "rnf", - "rnf (Nom)", - "rnf (Adresse)", - "rnf (Code INSEE Ville)", - "rnf (Département)", - "engagement_juridique" - ] - end + # before do + # # change one tdc place to check if the header is ordered + # tdc_first = procedure.active_revision.revision_types_de_champ_public.first + # tdc_last = procedure.active_revision.revision_types_de_champ_public.last - it 'should have headers' do - expect(dossiers_sheet.headers).to match_array(nominal_headers) - end + # tdc_first.update(position: tdc_last.position + 1) + # procedure.reload + # end - it 'should have data' do - expect(dossiers_sheet.data.size).to eq(1) - expect(etablissements_sheet.data.size).to eq(1) + let(:nominal_headers) do + [ + "ID", + "Email", + "FranceConnect ?", + "Civilité", + "Nom", + "Prénom", + "Dépôt pour un tiers", + "Nom du mandataire", + "Prénom du mandataire", + "Archivé", + "État du dossier", + "Dernière mise à jour le", + "Dernière mise à jour du dossier le", + "Déposé le", + "Passé en instruction le", + "Traité le", + "Motivation de la décision", + "Instructeurs", + "textarea", + "date", + "datetime", + "number", + "decimal_number", + "integer_number", + "checkbox", + "civilite", + "email", + "phone", + "address", + "simple_drop_down_list", + "multiple_drop_down_list", + "linked_drop_down_list", + "communes", + "communes (Code INSEE)", + "communes (Département)", + "departements", + "departements (Code)", + "regions", + "regions (Code)", + "pays", + "pays (Code)", + "dossier_link", + "piece_justificative", + "rna", + "carte", + "titre_identite", + "iban", + "siret", + "annuaire_education", + "cnaf", + "dgfip", + "pole_emploi", + "mesri", + "text", + "epci", + "epci (Code)", + "epci (Département)", + "cojo", + "expression_reguliere", + "rnf", + "rnf (Nom)", + "rnf (Adresse)", + "rnf (Code INSEE Ville)", + "rnf (Département)", + "engagement_juridique", + "yes_no" + ] + end - # SimpleXlsxReader is transforming datetimes in utc... It is only used in test so we just hack around. - offset = dossier.depose_at.utc_offset - depose_at = Time.zone.at(dossiers_sheet.data[0][13] - offset.seconds) - en_instruction_at = Time.zone.at(dossiers_sheet.data[0][14] - offset.seconds) - expect(dossiers_sheet.data.first.size).to eq(nominal_headers.size) - expect(depose_at).to eq(dossier.depose_at.round) - expect(en_instruction_at).to eq(dossier.en_instruction_at.round) + it 'should have data' do + expect(dossiers_sheet.headers).to match_array(nominal_headers) + + expect(dossiers_sheet.data.size).to eq(1) + expect(etablissements_sheet.data.size).to eq(1) + + # SimpleXlsxReader is transforming datetimes in utc... It is only used in test so we just hack around. + offset = dossier.depose_at.utc_offset + depose_at = Time.zone.at(dossiers_sheet.data[0][13] - offset.seconds) + en_instruction_at = Time.zone.at(dossiers_sheet.data[0][14] - offset.seconds) + expect(dossiers_sheet.data.first.size).to eq(nominal_headers.size) + expect(depose_at).to eq(dossier.depose_at.round) + expect(en_instruction_at).to eq(dossier.en_instruction_at.round) + end end context 'with a birthdate' do - before { procedure.update(ask_birthday: true) } + let(:procedure) { create(:procedure, :published, :for_individual, ask_birthday: true) } + let!(:dossier) { create(:dossier, :en_instruction, :with_individual, procedure:) } - let(:birthdate_headers) { nominal_headers.insert(nominal_headers.index('Archivé'), 'Date de naissance') } - - it { expect(dossiers_sheet.headers).to match_array(birthdate_headers) } - it { expect(dossiers_sheet.data[0][dossiers_sheet.headers.index('Date de naissance')]).to be_a(Date) } + it 'find date de naissance' do + expect(dossiers_sheet.headers).to include('Date de naissance') + expect(dossiers_sheet.data[0][dossiers_sheet.headers.index('Date de naissance')]).to be_a(Date) + end end context 'with a procedure routee' do - before { create(:groupe_instructeur, label: '2', procedure: procedure) } + let(:procedure) { create(:procedure, :published, :for_individual) } + let!(:dossier) { create(:dossier, :en_instruction, :with_individual, procedure:) } + before { create(:groupe_instructeur, label: '2', procedure:) } - let(:routee_headers) { nominal_headers.insert(nominal_headers.index('textarea'), 'Groupe instructeur') } - - it { expect(dossiers_sheet.headers).to match_array(routee_headers) } - it { expect(dossiers_sheet.data[0][dossiers_sheet.headers.index('Groupe instructeur')]).to eq('défaut') } + it 'find groupe instructeur data' do + expect(dossiers_sheet.headers).to include('Groupe instructeur') + expect(dossiers_sheet.data[0][dossiers_sheet.headers.index('Groupe instructeur')]).to eq('défaut') + end end context 'with a dossier having multiple pjs' do - let!(:dossier_2) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } + let(:procedure) { create(:procedure, :published, :for_individual, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :piece_justificative }] } + let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure:) } + let!(:dossier_2) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure:) } before do dossier_2.champs_public .find { _1.is_a? Champs::PieceJustificativeChamp } .piece_justificative_file .attach(io: StringIO.new("toto"), filename: "toto.txt", content_type: "text/plain") end - it { expect(dossiers_sheet.data.first.size).to eq(nominal_headers.size) } + it { expect(dossiers_sheet.data.first.size).to eq(19) } # default number of header when procedure has only one champ end context 'with procedure chorus' do - let(:procedure) { create(:procedure, :published, :for_individual, :filled_chorus, :with_all_champs) } - let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, procedure: procedure) } + before { expect_any_instance_of(Procedure).to receive(:chorusable?).and_return(true) } + let(:procedure) { create(:procedure, :published, :for_individual, :filled_chorus) } + let!(:dossier) { create(:dossier, :en_instruction, procedure: procedure) } it 'includes chorus headers' do - expected_headers = [ - 'Domaine Fonctionnel', - 'Référentiel De Programmation', - 'Centre De Coup' - ] - - expect(dossiers_sheet.headers).to match_array(nominal_headers) + expect(dossiers_sheet.headers).to include('Domaine Fonctionnel') + expect(dossiers_sheet.headers).to include('Référentiel De Programmation') + expect(dossiers_sheet.headers).to include('Centre De Coût') end end end describe 'Etablissement sheet' do - let(:procedure) { create(:procedure, :published, :with_all_champs) } + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :siret, libelle: 'siret' }] } let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_entreprise, procedure: procedure) } let(:dossier_etablissement) { etablissements_sheet.data[1] } @@ -191,54 +197,7 @@ describe ProcedureExportService do "Traité le", "Motivation de la décision", "Instructeurs", - "textarea", - "date", - "datetime", - "number", - "decimal_number", - "integer_number", - "checkbox", - "civilite", - "email", - "phone", - "address", - "yes_no", - "simple_drop_down_list", - "multiple_drop_down_list", - "linked_drop_down_list", - "communes", - "communes (Code INSEE)", - "communes (Département)", - "departements", - "departements (Code)", - "regions", - "regions (Code)", - "pays", - "pays (Code)", - "dossier_link", - "piece_justificative", - "rna", - "carte", - "titre_identite", - "iban", - "siret", - "annuaire_education", - "cnaf", - "dgfip", - "pole_emploi", - "mesri", - "text", - "epci", - "epci (Code)", - "epci (Département)", - "cojo", - "expression_reguliere", - "rnf", - "rnf (Nom)", - "rnf (Adresse)", - "rnf (Code INSEE Ville)", - "rnf (Département)", - "engagement_juridique" + 'siret' ] end @@ -294,54 +253,7 @@ describe ProcedureExportService do "Traité le", "Motivation de la décision", "Instructeurs", - "textarea", - "date", - "datetime", - "number", - "decimal_number", - "integer_number", - "checkbox", - "civilite", - "email", - "phone", - "address", - "yes_no", - "simple_drop_down_list", - "multiple_drop_down_list", - "linked_drop_down_list", - "communes", - "communes (Code INSEE)", - "communes (Département)", - "departements", - "departements (Code)", - "regions", - "regions (Code)", - "pays", - "pays (Code)", - "dossier_link", - "piece_justificative", - "rna", - "carte", - "titre_identite", - "iban", - "siret", - "annuaire_education", - "cnaf", - "dgfip", - "pole_emploi", - "mesri", - "text", - "epci", - "epci (Code)", - "epci (Département)", - "cojo", - "expression_reguliere", - "rnf", - "rnf (Nom)", - "rnf (Adresse)", - "rnf (Code INSEE Ville)", - "rnf (Département)", - "engagement_juridique" + 'siret' ] end @@ -352,7 +264,7 @@ describe ProcedureExportService do end end - it 'should have headers' do + it 'should have headers and data' do expect(dossiers_sheet.headers).to match_array(nominal_headers) expect(etablissements_sheet.headers).to eq([ @@ -391,9 +303,7 @@ describe ProcedureExportService do "Association date de déclaration", "Association date de publication" ]) - end - it 'should have data' do expect(etablissements_sheet.data.size).to eq(2) expect(dossier_etablissement[1]).to eq("Dossier") expect(champ_etablissement[1]).to eq("siret") @@ -401,10 +311,11 @@ describe ProcedureExportService do end describe 'Avis sheet' do + let(:procedure) { create(:procedure, :published, :for_individual) } let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } let!(:avis) { create(:avis, :with_answer, dossier: dossier) } - it 'should have headers' do + it 'should have headers and data' do expect(avis_sheet.headers).to eq([ "Dossier ID", "Introduction", @@ -416,14 +327,20 @@ describe ProcedureExportService do "Instructeur", "Expert" ]) - end - - it 'should have data' do expect(avis_sheet.data.size).to eq(1) end end describe 'Repetitions sheet' do + before do + # change one tdc place to check if the header is ordered + tdc_first = procedure.active_revision.revision_types_de_champ_public.first + tdc_last = procedure.active_revision.revision_types_de_champ_public.last + + tdc_first.update(position: tdc_last.position + 1) + procedure.reload + end + let(:procedure) { create(:procedure, :published, :for_individual, types_de_champ_public: [{ type: :repetition, children: [{ libelle: 'Nom' }, { libelle: 'Age' }] }]) } let!(:dossiers) do [ @@ -509,6 +426,9 @@ describe ProcedureExportService do end describe 'to_zip' do + let(:procedure) { create(:procedure, :published, :for_individual, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :piece_justificative, libelle: 'piece_justificative' }] } + subject { service.to_zip } context 'without files' do it 'does not raises in_batches' do @@ -588,6 +508,9 @@ describe ProcedureExportService do end describe 'to_geo_json' do + let(:procedure) { create(:procedure, :published, :for_individual, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :carte }] } + subject do service .to_geo_json diff --git a/spec/system/accessibilite/wcag_usager_spec.rb b/spec/system/accessibilite/wcag_usager_spec.rb index d8ad6fd10..16e6b23c2 100644 --- a/spec/system/accessibilite/wcag_usager_spec.rb +++ b/spec/system/accessibilite/wcag_usager_spec.rb @@ -1,33 +1,25 @@ describe 'wcag rules for usager', js: true do - let(:procedure) { create(:procedure, :published, :with_all_champs, :with_service, :for_individual) } + let(:procedure) { create(:procedure, :published, :with_service, :for_individual) } let(:password) { 'a very complicated password' } let(:litteraire_user) { create(:user, password: password) } - before do - procedure.active_revision.types_de_champ_public.find { |tdc| tdc.type_champ == TypeDeChamp.type_champs.fetch(:carte) }.destroy - end + def test_external_links_have_title_says_it_opens_in_a_new_tab + links = page.all("a[target=_blank]") + expect(links.count).to be_positive - shared_examples "external links have title says it opens in a new tab" do - it do - links = page.all("a[target=_blank]") - expect(links.count).to be_positive - - links.each do |link| - expect(link[:title]).to include("Nouvel onglet"), "link #{link[:href]} does not have title mentioning it opens in a new tab" - end + links.each do |link| + expect(link[:title]).to include("Nouvel onglet"), "link #{link[:href]} does not have title mentioning it opens in a new tab" end end - shared_examples "aria-label do not mix with title attribute" do - it do - elements = page.all("[aria-label][title]") - elements.each do |element| - expect(element[:title]).to be_blank, "path=#{path}, element title=\"#{element[:title]}\" mixes aria-label and title attributes" - end + def test_aria_label_do_not_mix_with_title_attribute + elements = page.all("[aria-label][title]") + elements.each do |element| + expect(element[:title]).to be_blank, "path=#{path}, element title=\"#{element[:title]}\" mixes aria-label and title attributes" end end - def expect_axe_clean_without_main_navigation + def test_expect_axe_clean_without_main_navigation # On page without main navigation content (like anonymous home page), # there are either a bug in axe, either dsfr markup is not conform to wcag2a. # There is no issue on pages having a child navigation. @@ -35,10 +27,6 @@ describe 'wcag rules for usager', js: true do expect(page).to be_axe_clean.within("#modal-header__menu").skipping("aria-prohibited-attr") end - shared_examples "axe clean without main navigation" do - it { expect_axe_clean_without_main_navigation } - end - context 'pages without the need to be logged in' do before do visit path @@ -46,16 +34,20 @@ describe 'wcag rules for usager', js: true do context 'homepage' do let(:path) { root_path } - it_behaves_like "axe clean without main navigation" - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + test_expect_axe_clean_without_main_navigation + end end context 'sign_up page' do let(:path) { new_user_registration_path } - it_behaves_like "axe clean without main navigation" - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + test_expect_axe_clean_without_main_navigation + end end scenario 'account confirmation page' do @@ -66,43 +58,51 @@ describe 'wcag rules for usager', js: true do perform_enqueued_jobs do click_button 'Créer un compte' - expect_axe_clean_without_main_navigation + test_expect_axe_clean_without_main_navigation end end context 'sign_up confirmation' do let(:path) { user_confirmation_path("user[email]" => "some@email.com") } - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + end end context 'sign_in page' do let(:path) { new_user_session_path } - it_behaves_like "axe clean without main navigation" - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + test_expect_axe_clean_without_main_navigation + end end context 'contact page' do let(:path) { contact_path } - it_behaves_like "axe clean without main navigation" - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + test_expect_axe_clean_without_main_navigation + end end context 'commencer page' do let(:path) { commencer_path(path: procedure.path) } - it_behaves_like "axe clean without main navigation" - it_behaves_like "external links have title says it opens in a new tab" - it_behaves_like "aria-label do not mix with title attribute" + it 'pass wcag tests' do + test_external_links_have_title_says_it_opens_in_a_new_tab + test_aria_label_do_not_mix_with_title_attribute + test_expect_axe_clean_without_main_navigation + end end scenario 'commencer page, help dropdown' do visit commencer_path(path: procedure.reload.path) page.find("#help-menu_button").click - expect_axe_clean_without_main_navigation + test_expect_axe_clean_without_main_navigation end end @@ -135,7 +135,7 @@ describe 'wcag rules for usager', js: true do end context "logged in, depot d'un dossier entreprise" do - let(:procedure) { create(:procedure, :with_all_champs, :with_service, :published) } + let(:procedure) { create(:procedure, :with_service, :published) } before do login_as litteraire_user, scope: :user @@ -163,11 +163,6 @@ describe 'wcag rules for usager', js: true do dossier visit dossiers_path expect(page).to be_axe_clean - end - - scenario 'liste des dossiers et actions sur le dossier' do - dossier - visit dossiers_path page.find("#actions_menu_dossier_#{dossier.id}_button").click expect(page).to be_axe_clean end From 9753a91db63d6fba6e01a987bda6721de500ae4c Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 4 Jun 2024 17:40:48 +0200 Subject: [PATCH 0333/1532] feat(graphql): messages can be discarded through api --- app/graphql/api/v2/stored_query.rb | 13 +++ .../dossier_passer_en_instruction.rb | 3 + .../mutations/dossier_supprimer_message.rb | 24 ++++++ app/graphql/schema.graphql | 35 ++++++++ app/graphql/types/message_type.rb | 5 ++ app/graphql/types/mutation_type.rb | 1 + app/models/dossier.rb | 6 +- .../graphql_controller_stored_queries_spec.rb | 83 +++++++++++++++++++ 8 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 app/graphql/mutations/dossier_supprimer_message.rb diff --git a/app/graphql/api/v2/stored_query.rb b/app/graphql/api/v2/stored_query.rb index e2b5c78c0..1c48a5bd1 100644 --- a/app/graphql/api/v2/stored_query.rb +++ b/app/graphql/api/v2/stored_query.rb @@ -819,6 +819,19 @@ class API::V2::StoredQuery } } + mutation dossierSupprimerMessage($input: DossierSupprimerMessageInput!) { + dossierSupprimerMessage(input: $input) { + message { + id + createdAt + discardedAt + } + errors { + message + } + } + } + mutation dossierModifierAnnotationText( $input: DossierModifierAnnotationTextInput! ) { diff --git a/app/graphql/mutations/dossier_passer_en_instruction.rb b/app/graphql/mutations/dossier_passer_en_instruction.rb index 1ece00ede..d9886b724 100644 --- a/app/graphql/mutations/dossier_passer_en_instruction.rb +++ b/app/graphql/mutations/dossier_passer_en_instruction.rb @@ -21,6 +21,9 @@ module Mutations if !dossier.en_construction? return false, { errors: ["Le dossier est déjà #{dossier_display_state(dossier, lower: true)}"] } end + if dossier.blocked_with_pending_correction? + return false, { errors: ["Le dossier est en attente de correction"] } + end dossier_authorized_for?(dossier, instructeur) end end diff --git a/app/graphql/mutations/dossier_supprimer_message.rb b/app/graphql/mutations/dossier_supprimer_message.rb new file mode 100644 index 000000000..bde6de51f --- /dev/null +++ b/app/graphql/mutations/dossier_supprimer_message.rb @@ -0,0 +1,24 @@ +module Mutations + class DossierSupprimerMessage < Mutations::BaseMutation + description "Supprimer un message." + + argument :message_id, ID, required: true, loads: Types::MessageType + argument :instructeur_id, ID, required: true, loads: Types::ProfileType + + field :message, Types::MessageType, null: true + field :errors, [Types::ValidationErrorType], null: true + + def resolve(message:, **args) + message.soft_delete! + + { message: } + end + + def authorized?(message:, instructeur:, **args) + if !message.soft_deletable?(instructeur) + return false, { errors: ["Le message ne peut pas être supprimé"] } + end + dossier_authorized_for?(message.dossier, instructeur) + end + end +end diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index 52d58d1ad..c422408fe 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -2192,6 +2192,30 @@ enum DossierState { sans_suite } +""" +Autogenerated input type of DossierSupprimerMessage +""" +input DossierSupprimerMessageInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + instructeurId: ID! + messageId: ID! +} + +""" +Autogenerated return type of DossierSupprimerMessage. +""" +type DossierSupprimerMessagePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + errors: [ValidationError!] + message: Message +} + type DropDownListChampDescriptor implements ChampDescriptor { """ Description des champs d’un bloc répétable. @@ -3084,6 +3108,7 @@ type Message { body: String! correction: Correction createdAt: ISO8601DateTime! + discardedAt: ISO8601DateTime email: String! id: ID! } @@ -3313,6 +3338,16 @@ type Mutation { input: DossierRepasserEnInstructionInput! ): DossierRepasserEnInstructionPayload + """ + Supprimer un message. + """ + dossierSupprimerMessage( + """ + Parameters for DossierSupprimerMessage + """ + input: DossierSupprimerMessageInput! + ): DossierSupprimerMessagePayload + """ Ajouter des instructeurs à un groupe instructeur. """ diff --git a/app/graphql/types/message_type.rb b/app/graphql/types/message_type.rb index 70622647b..788075bb2 100644 --- a/app/graphql/types/message_type.rb +++ b/app/graphql/types/message_type.rb @@ -4,6 +4,7 @@ module Types field :email, String, null: false field :body, String, null: false field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :discarded_at, GraphQL::Types::ISO8601DateTime, null: true field :attachment, Types::File, null: true, deprecation_reason: "Utilisez le champ `attachments` à la place.", extensions: [ { Extensions::Attachment => { attachments: :piece_jointe, as: :single } } ] @@ -19,5 +20,9 @@ module Types def correction Loaders::Association.for(object.class, :dossier_correction).load(object) end + + def self.authorized?(object, context) + context.authorized_demarche?(object.dossier.revision.procedure) + end end end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index ce2f8ac56..b9ad5a92c 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -3,6 +3,7 @@ module Types field :create_direct_upload, mutation: Mutations::CreateDirectUpload field :dossier_envoyer_message, mutation: Mutations::DossierEnvoyerMessage + field :dossier_supprimer_message, mutation: Mutations::DossierSupprimerMessage field :dossier_passer_en_instruction, mutation: Mutations::DossierPasserEnInstruction field :dossier_classer_sans_suite, mutation: Mutations::DossierClasserSansSuite field :dossier_refuser, mutation: Mutations::DossierRefuser diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 123c7768c..54c187169 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -558,8 +558,12 @@ class Dossier < ApplicationRecord false end + def blocked_with_pending_correction? + procedure.feature_enabled?(:blocking_pending_correction) && pending_correction? + end + def can_passer_en_instruction? - return false if procedure.feature_enabled?(:blocking_pending_correction) && pending_correction? + return false if blocked_with_pending_correction? true end diff --git a/spec/controllers/api/v2/graphql_controller_stored_queries_spec.rb b/spec/controllers/api/v2/graphql_controller_stored_queries_spec.rb index 6fa77c40b..a42d90210 100644 --- a/spec/controllers/api/v2/graphql_controller_stored_queries_spec.rb +++ b/spec/controllers/api/v2/graphql_controller_stored_queries_spec.rb @@ -912,6 +912,17 @@ describe API::V2::GraphqlController do expect(ActionMailer::Base.deliveries.size).to eq(0) } end + + context 'with pending corrections' do + before { Flipper.enable(:blocking_pending_correction, dossier.procedure) } + let!(:dossier_correction) { create(:dossier_correction, dossier:) } + + it { + expect(dossier.pending_correction?).to be_truthy + expect(gql_errors).to be_nil + expect(gql_data[:dossierPasserEnInstruction][:errors]).to eq([{ message: "Le dossier est en attente de correction" }]) + } + end end context 'dossierRepasserEnConstruction' do @@ -1332,5 +1343,77 @@ describe API::V2::GraphqlController do } end end + + context 'dossierEnvoyerMessage' do + let(:dossier) { create(:dossier, :en_construction, :with_individual, procedure:) } + let(:variables) { { input: { dossierId: dossier.to_typed_id, instructeurId: instructeur.to_typed_id, body: 'Hello World!' } } } + let(:operation_name) { 'dossierEnvoyerMessage' } + + it { + expect(gql_errors).to be_nil + expect(gql_data[:dossierEnvoyerMessage][:errors]).to be_nil + expect(gql_data[:dossierEnvoyerMessage][:message][:id]).to eq(dossier.commentaires.first.to_typed_id) + perform_enqueued_jobs + expect(ActionMailer::Base.deliveries.size).to eq(1) + } + end + + context 'dossierSupprimerMessage' do + let(:dossier) { create(:dossier, :en_construction, :with_individual, procedure:) } + let(:message) { create(:commentaire, dossier:, instructeur:) } + let(:dossier_correction) { create(:dossier_correction, dossier:, commentaire: message) } + let(:variables) { { input: { messageId: message.to_typed_id, instructeurId: instructeur.to_typed_id } } } + let(:operation_name) { 'dossierSupprimerMessage' } + + it { + expect(message.discarded?).to be_falsey + expect(gql_errors).to be_nil + expect(gql_data[:dossierSupprimerMessage][:errors]).to be_nil + expect(gql_data[:dossierSupprimerMessage][:message][:id]).to eq(message.to_typed_id) + expect(gql_data[:dossierSupprimerMessage][:message][:discardedAt]).not_to be_nil + expect(message.reload.discarded?).to be_truthy + } + + it { + expect(dossier_correction.commentaire.discarded?).to be_falsey + expect(dossier.pending_correction?).to be_truthy + expect(gql_errors).to be_nil + expect(gql_data[:dossierSupprimerMessage][:errors]).to be_nil + expect(gql_data[:dossierSupprimerMessage][:message][:id]).to eq(message.to_typed_id) + expect(gql_data[:dossierSupprimerMessage][:message][:discardedAt]).not_to be_nil + expect(message.reload.discarded?).to be_truthy + expect(dossier.pending_correction?).to be_falsey + } + + context 'when unauthorized' do + let(:dossier) { create(:dossier, :en_construction, :with_individual) } + + it { + expect(message.discarded?).to be_falsey + expect(gql_errors.first[:message]).to eq("An object of type Message was hidden due to permissions") + } + end + + context 'when from not the same instructeur' do + let(:other_instructeur) { create(:instructeur, followed_dossiers: dossiers) } + let(:variables) { { input: { messageId: message.to_typed_id, instructeurId: other_instructeur.to_typed_id } } } + + it { + expect(message.discarded?).to be_falsey + expect(gql_errors).to be_nil + expect(gql_data[:dossierSupprimerMessage][:errors]).to eq([{ message: "Le message ne peut pas être supprimé" }]) + } + end + + context 'when from usager' do + let(:message) { create(:commentaire, dossier:) } + + it { + expect(message.discarded?).to be_falsey + expect(gql_errors).to be_nil + expect(gql_data[:dossierSupprimerMessage][:errors]).to eq([{ message: "Le message ne peut pas être supprimé" }]) + } + end + end end end From ef3ca9839ba0ec99876967ce78d3254ec89ec34e Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 3 Jun 2024 07:44:27 +0200 Subject: [PATCH 0334/1532] feat(procedure.validation): extract validation context: types_de_champ_public_editor, types_de_champ_private_editor and publication [combining both contextes]. validate conditions, headers_sections, regexp on type_de_champ_private too. dry validation --- .../procedure/card/champs_component.rb | 5 +- app/components/procedure/errors_summary.rb | 48 +++++++++++ .../errors_summary/errors_summary.html.haml | 9 +++ .../publication_warning_component.rb | 39 --------- .../publication_warning_component.html.haml | 7 -- .../champ_component/champ_component.html.haml | 2 +- .../types_de_champ_editor/editor_component.rb | 8 ++ .../editor_component.html.haml | 2 +- .../types_de_champ_editor/errors_summary.rb | 38 --------- .../errors_summary/errors_summary.fr.yml | 12 --- .../errors_summary/errors_summary.html.haml | 15 ---- .../header_section_component.rb | 2 +- .../header_section_component.html.haml | 2 +- app/models/procedure.rb | 10 ++- app/models/procedure_revision.rb | 48 ----------- app/models/type_de_champ.rb | 8 +- .../types_de_champ/condition_validator.rb | 21 +++++ .../expression_reguliere_validator.rb | 11 +++ .../header_section_consistency_validator.rb | 29 +++++++ .../no_empty_block_validator.rb | 3 +- .../no_empty_drop_down_validator.rb | 3 +- .../conditions/_update.turbo_stream.haml | 2 +- .../procedures/_publication_form.html.haml | 2 +- .../procedures/champs.html.haml | 2 +- .../administrateurs/procedures/show.html.haml | 13 ++- .../types_de_champ/_insert.turbo_stream.haml | 4 +- config/locales/fr.yml | 2 - config/locales/models/procedure/en.yml | 22 +++++ config/locales/models/procedure/fr.yml | 22 +++++ config/locales/models/type_de_champ/fr.yml | 2 +- .../card/annotations_component_spec.rb | 30 +++++++ .../procedures/card/champs_component_spec.rb | 30 +++++++ .../procedures/errors_summary_spec.rb | 80 +++++++++++++++++++ .../editor_component_spec.rb | 26 ++++++ spec/models/procedure_revision_spec.rb | 35 ++++---- .../administrateurs/procedure_publish_spec.rb | 21 ++--- .../administrateurs/types_de_champ_spec.rb | 4 +- 37 files changed, 398 insertions(+), 221 deletions(-) create mode 100644 app/components/procedure/errors_summary.rb create mode 100644 app/components/procedure/errors_summary/errors_summary.html.haml delete mode 100644 app/components/procedure/publication_warning_component.rb delete mode 100644 app/components/procedure/publication_warning_component/publication_warning_component.html.haml delete mode 100644 app/components/types_de_champ_editor/errors_summary.rb delete mode 100644 app/components/types_de_champ_editor/errors_summary/errors_summary.fr.yml delete mode 100644 app/components/types_de_champ_editor/errors_summary/errors_summary.html.haml create mode 100644 app/validators/types_de_champ/condition_validator.rb create mode 100644 app/validators/types_de_champ/expression_reguliere_validator.rb create mode 100644 app/validators/types_de_champ/header_section_consistency_validator.rb create mode 100644 spec/components/procedures/card/annotations_component_spec.rb create mode 100644 spec/components/procedures/card/champs_component_spec.rb create mode 100644 spec/components/procedures/errors_summary_spec.rb create mode 100644 spec/components/types_de_champ_editor/editor_component_spec.rb diff --git a/app/components/procedure/card/champs_component.rb b/app/components/procedure/card/champs_component.rb index d45b2f666..38604c831 100644 --- a/app/components/procedure/card/champs_component.rb +++ b/app/components/procedure/card/champs_component.rb @@ -7,9 +7,6 @@ class Procedure::Card::ChampsComponent < ApplicationComponent private def error_messages - [ - @procedure.errors.messages_for(:draft_types_de_champ_public), - @procedure.errors.messages_for(:draft_revision) - ].flatten.to_sentence + @procedure.errors.messages_for(:draft_types_de_champ_public).to_sentence end end diff --git a/app/components/procedure/errors_summary.rb b/app/components/procedure/errors_summary.rb new file mode 100644 index 000000000..adce8fd64 --- /dev/null +++ b/app/components/procedure/errors_summary.rb @@ -0,0 +1,48 @@ +class Procedure::ErrorsSummary < ApplicationComponent + def initialize(procedure:, validation_context:) + @procedure = procedure + @validation_context = validation_context + end + + def title + case @validation_context + when :types_de_champ_private_editor + "Les annotations privées contiennent des erreurs" + when :types_de_champ_public_editor + "Les champs formulaire contiennent des erreurs" + when :publication + if @procedure.publiee? + "Des problèmes empêchent la publication des modifications" + else + "Des problèmes empêchent la publication de la démarche" + end + end + end + + def invalid? + @procedure.validate(@validation_context) + @procedure.errors.present? + end + + def error_messages + @procedure.errors.map do |error| + [error, error_correction_page(error)] + end + end + + def error_correction_page(error) + case error.attribute + when :draft_types_de_champ_public + tdc = error.options[:type_de_champ] + champs_admin_procedure_path(@procedure, anchor: dom_id(tdc.stable_self, :editor_error)) + when :draft_types_de_champ_private + tdc = error.options[:type_de_champ] + annotations_admin_procedure_path(@procedure, anchor: dom_id(tdc.stable_self, :editor_error)) + when :attestation_template + edit_admin_procedure_attestation_template_path(@procedure) + when :initiated_mail, :received_mail, :closed_mail, :refused_mail, :without_continuation_mail, :re_instructed_mail + klass = "Mails::#{error.attribute.to_s.classify}".constantize + edit_admin_procedure_mail_template_path(@procedure, klass.const_get(:SLUG)) + end + end +end diff --git a/app/components/procedure/errors_summary/errors_summary.html.haml b/app/components/procedure/errors_summary/errors_summary.html.haml new file mode 100644 index 000000000..72780bcd1 --- /dev/null +++ b/app/components/procedure/errors_summary/errors_summary.html.haml @@ -0,0 +1,9 @@ +#errors-summary + - if invalid? + = render Dsfr::AlertComponent.new(state: :error, title: , extra_class_names: 'fr-mb-2w') do |c| + - c.with_body do + - error_messages.each do |(error, path)| + %p.mt-2 + = error.full_message + - if path.present? + = "(#{link_to 'corriger', path, class: 'fr-link'})" diff --git a/app/components/procedure/publication_warning_component.rb b/app/components/procedure/publication_warning_component.rb deleted file mode 100644 index 7af808c3d..000000000 --- a/app/components/procedure/publication_warning_component.rb +++ /dev/null @@ -1,39 +0,0 @@ -class Procedure::PublicationWarningComponent < ApplicationComponent - def initialize(procedure:) - @procedure = procedure - end - - def title - return "Des problèmes empêchent la publication des modifications" if @procedure.publiee? - - "Des problèmes empêchent la publication de la démarche" - end - - private - - def render? - @procedure.validate(:publication) - @procedure.errors.delete(:path) - @procedure.errors.any? - end - - def error_messages - @procedure.errors - .to_hash(full_messages: true) - .map do |attribute, messages| - [messages, error_correction_page(attribute)] - end - end - - def error_correction_page(attribute) - case attribute - when :draft_revision - champs_admin_procedure_path(@procedure) - when :attestation_template - edit_admin_procedure_attestation_template_path(@procedure) - when :initiated_mail, :received_mail, :closed_mail, :refused_mail, :without_continuation_mail, :re_instructed_mail - klass = "Mails::#{attribute.to_s.classify}".constantize - edit_admin_procedure_mail_template_path(@procedure, klass.const_get(:SLUG)) - end - end -end diff --git a/app/components/procedure/publication_warning_component/publication_warning_component.html.haml b/app/components/procedure/publication_warning_component/publication_warning_component.html.haml deleted file mode 100644 index 7d8ff43b7..000000000 --- a/app/components/procedure/publication_warning_component/publication_warning_component.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -= render Dsfr::AlertComponent.new(state: :warning, title:) do |c| - - c.with_body do - - error_messages.each do |(messages, path)| - %p.mt-2 - = messages.to_sentence - - if path.present? - = "(#{link_to 'corriger', path, class: 'fr-link'})" diff --git a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml index 980bd064c..60cf82102 100644 --- a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml +++ b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml @@ -1,5 +1,5 @@ %li.type-de-champ.flex.column.justify-start.fr-mb-5v{ html_options } - .type-de-champ-container + .type-de-champ-container{ id: dom_id(type_de_champ.stable_self, :editor_error) } - if @errors.present? .types-de-champ-errors = @errors diff --git a/app/components/types_de_champ_editor/editor_component.rb b/app/components/types_de_champ_editor/editor_component.rb index 0ea2c76cb..288a2c668 100644 --- a/app/components/types_de_champ_editor/editor_component.rb +++ b/app/components/types_de_champ_editor/editor_component.rb @@ -17,4 +17,12 @@ class TypesDeChampEditor::EditorComponent < ApplicationComponent @revision.revision_types_de_champ_public end end + + def validation_context + if annotations? + :types_de_champ_private_editor + else + :types_de_champ_public_editor + end + end end diff --git a/app/components/types_de_champ_editor/editor_component/editor_component.html.haml b/app/components/types_de_champ_editor/editor_component/editor_component.html.haml index 71df85e46..d161d0ba2 100644 --- a/app/components/types_de_champ_editor/editor_component/editor_component.html.haml +++ b/app/components/types_de_champ_editor/editor_component/editor_component.html.haml @@ -1,6 +1,6 @@ .fr-pb-12w{ 'data-turbo': 'true', id: dom_id(@revision, :types_de_champ_editor) } .types-de-champ-editor.editor-root - = render TypesDeChampEditor::ErrorsSummary.new(revision: @revision) + = render Procedure::ErrorsSummary.new(procedure: @revision.procedure, validation_context:) = render TypesDeChampEditor::BlockComponent.new(block: @revision, coordinates: coordinates) #empty-coordinates{ hidden: coordinates.present? } = render TypesDeChampEditor::AddChampButtonComponent.new(revision: @revision, is_annotation: annotations?) diff --git a/app/components/types_de_champ_editor/errors_summary.rb b/app/components/types_de_champ_editor/errors_summary.rb deleted file mode 100644 index b5367eaf1..000000000 --- a/app/components/types_de_champ_editor/errors_summary.rb +++ /dev/null @@ -1,38 +0,0 @@ -class TypesDeChampEditor::ErrorsSummary < ApplicationComponent - def initialize(revision:) - @revision = revision - end - - def invalid? - @revision.invalid? - end - - def condition_errors? - @revision.errors.include?(:condition) - end - - def header_section_errors? - @revision.errors.include?(:header_section) - end - - def expression_reguliere_errors? - @revision.errors.include?(:expression_reguliere) - end - - private - - def errors_for(key) - @revision.errors.filter { _1.attribute == key } - end - - def error_message_for(key) - errors_for(key) - .map { |error| error.options[:type_de_champ] } - .map { |tdc| tag.li(tdc_anchor(tdc, key)) } - .then { |lis| tag.ul(lis.reduce(&:+)) } - end - - def tdc_anchor(tdc, key) - tag.a(tdc.libelle, href: champs_admin_procedure_path(@revision.procedure_id, anchor: dom_id(tdc.stable_self, key)), data: { turbo: false }) - end -end diff --git a/app/components/types_de_champ_editor/errors_summary/errors_summary.fr.yml b/app/components/types_de_champ_editor/errors_summary/errors_summary.fr.yml deleted file mode 100644 index 08d594931..000000000 --- a/app/components/types_de_champ_editor/errors_summary/errors_summary.fr.yml +++ /dev/null @@ -1,12 +0,0 @@ -fr: - fix_conditional: - one: 'La logique conditionnelle du champ suivant est invalide, veuillez la corriger :' - other: 'La logique conditionnelle des champs suivants sont invalides, veuillez les corriger :' - - fix_header_section: - one: 'Le titre de section suivant est invalide, veuillez le corriger :' - other: 'Les titres de section suivants sont invalides, veuillez les corriger :' - - fix_expressions_regulieres: - one: "L'expression régulière suivante est invalide, veuillez la corriger :" - other: 'Les expressions régulières suivantes sont invalides, veuillez les corriger :' diff --git a/app/components/types_de_champ_editor/errors_summary/errors_summary.html.haml b/app/components/types_de_champ_editor/errors_summary/errors_summary.html.haml deleted file mode 100644 index 46f599ce5..000000000 --- a/app/components/types_de_champ_editor/errors_summary/errors_summary.html.haml +++ /dev/null @@ -1,15 +0,0 @@ -#errors-summary - - if invalid? - = render Dsfr::AlertComponent.new(state: :warning, title: "Le formulaire contient des erreurs", extra_class_names: 'fr-mb-2w') do |c| - - c.with_body do - - if condition_errors? - %p= t('.fix_conditional', count: errors_for(:condition).size) - = error_message_for(:condition) - - - if header_section_errors? - %p= t('.fix_header_section', count: errors_for(:header_section).size) - = error_message_for(:header_section) - - - if expression_reguliere_errors? - %p= t('.fix_expressions_regulieres', count: errors_for(:expression_reguliere).size) - = error_message_for(:expression_reguliere) diff --git a/app/components/types_de_champ_editor/header_section_component.rb b/app/components/types_de_champ_editor/header_section_component.rb index 80271b8e1..39f372dd8 100644 --- a/app/components/types_de_champ_editor/header_section_component.rb +++ b/app/components/types_de_champ_editor/header_section_component.rb @@ -31,7 +31,7 @@ class TypesDeChampEditor::HeaderSectionComponent < ApplicationComponent end def errors? - !errors.empty? + errors.present? end def to_html_list(messages) diff --git a/app/components/types_de_champ_editor/header_section_component/header_section_component.html.haml b/app/components/types_de_champ_editor/header_section_component/header_section_component.html.haml index b40967b26..022c2830b 100644 --- a/app/components/types_de_champ_editor/header_section_component/header_section_component.html.haml +++ b/app/components/types_de_champ_editor/header_section_component/header_section_component.html.haml @@ -1,5 +1,5 @@ %div{ id: dom_id(@tdc.stable_self, :header_section) } - if errors? - .errors-summary= to_html_list(errors) + .errors-summary= errors = @form.label :header_section_level, "Niveau du titre", for: dom_id(@tdc, :header_section_level) = @form.select :header_section_level, header_section_options_for_select, {}, id: dom_id(@tdc, :header_section_level), class: 'fr-select width-33' diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 11cec13d8..af398a991 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -259,13 +259,19 @@ class Procedure < ApplicationRecord validates :lien_dpo, url: { no_local: true, allow_blank: true, accept_email: true } validates :draft_types_de_champ_public, + 'types_de_champ/condition': true, + 'types_de_champ/expression_reguliere': true, + 'types_de_champ/header_section_consistency': true, 'types_de_champ/no_empty_block': true, 'types_de_champ/no_empty_drop_down': true, - on: :publication + on: [:types_de_champ_public_editor, :publication] + validates :draft_types_de_champ_private, + 'types_de_champ/condition': true, + 'types_de_champ/header_section_consistency': true, 'types_de_champ/no_empty_block': true, 'types_de_champ/no_empty_drop_down': true, - on: :publication + on: [:types_de_champ_private_editor, :publication] validate :check_juridique, on: [:create, :publication] diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index c8daa1bec..fef3516d5 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -17,10 +17,6 @@ class ProcedureRevision < ApplicationRecord scope :ordered, -> { order(:created_at) } - validate :conditions_are_valid? - validate :header_sections_are_valid? - validate :expressions_regulieres_are_valid? - delegate :path, to: :procedure, prefix: true def build_champs_public @@ -453,48 +449,4 @@ class ProcedureRevision < ApplicationRecord coordinate.update!(type_de_champ: cloned_type_de_champ) cloned_type_de_champ end - - def conditions_are_valid? - public_tdcs = types_de_champ_public.to_a - .flat_map { _1.repetition? ? children_of(_1) : _1 } - - public_tdcs - .map.with_index - .filter_map { |tdc, i| tdc.condition? ? [tdc, i] : nil } - .map do |tdc, i| - [tdc, tdc.condition.errors(public_tdcs.take(i))] - end - .filter { |_tdc, errors| errors.present? } - .each { |tdc, message| errors.add(:condition, message, type_de_champ: tdc) } - end - - def header_sections_are_valid? - public_tdcs = types_de_champ_public.to_a - - root_tdcs_errors = errors_for_header_sections_order(public_tdcs) - repetition_tdcs_errors = public_tdcs - .filter_map { _1.repetition? ? children_of(_1) : nil } - .map { errors_for_header_sections_order(_1) } - - repetition_tdcs_errors + root_tdcs_errors - end - - def expressions_regulieres_are_valid? - types_de_champ_public.to_a - .flat_map { _1.repetition? ? children_of(_1) : _1 } - .each do |tdc| - if tdc.expression_reguliere? && tdc.invalid_regexp? - errors.add(:expression_reguliere, type_de_champ: tdc) - end - end - end - - def errors_for_header_sections_order(tdcs) - tdcs - .map.with_index - .filter_map { |tdc, i| tdc.header_section? ? [tdc, i] : nil } - .map { |tdc, i| [tdc, tdc.check_coherent_header_level(tdcs.take(i))] } - .filter { |_tdc, errors| errors.present? } - .each { |tdc, message| errors.add(:header_section, message, type_de_champ: tdc) } - end end diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index ea2de7468..10e415c05 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -505,15 +505,15 @@ class TypeDeChamp < ApplicationRecord end def check_coherent_header_level(upper_tdcs) - errs = [] previous_level = previous_section_level(upper_tdcs) - current_level = header_section_level_value.to_i + difference = current_level - previous_level if current_level > previous_level && difference != 1 - errs << I18n.t('activerecord.errors.type_de_champ.attributes.header_section_level.gap_error', level: current_level - previous_level - 1) + I18n.t('activerecord.errors.type_de_champ.attributes.header_section_level.gap_error', level: current_level - previous_level - 1) + else + nil end - errs end def current_section_level(revision) diff --git a/app/validators/types_de_champ/condition_validator.rb b/app/validators/types_de_champ/condition_validator.rb new file mode 100644 index 000000000..74b57c3d5 --- /dev/null +++ b/app/validators/types_de_champ/condition_validator.rb @@ -0,0 +1,21 @@ +class TypesDeChamp::ConditionValidator < ActiveModel::EachValidator + def validate_each(procedure, attribute, types_de_champ) + public_tdcs = types_de_champ.to_a + .flat_map { _1.repetition? ? procedure.draft_revision.children_of(_1) : _1 } + + public_tdcs + .map.with_index + .filter_map { |tdc, i| tdc.condition? ? [tdc, i] : nil } + .map do |tdc, i| + [tdc, tdc.condition.errors(public_tdcs.take(i))] + end + .filter { |_tdc, errors| errors.present? } + .each do |tdc, _error_hash| + procedure.errors.add( + attribute, + procedure.errors.generate_message(attribute, :invalid_condition, { value: tdc.libelle }), + type_de_champ: tdc + ) + end + end +end diff --git a/app/validators/types_de_champ/expression_reguliere_validator.rb b/app/validators/types_de_champ/expression_reguliere_validator.rb new file mode 100644 index 000000000..39262adeb --- /dev/null +++ b/app/validators/types_de_champ/expression_reguliere_validator.rb @@ -0,0 +1,11 @@ +class TypesDeChamp::ExpressionReguliereValidator < ActiveModel::EachValidator + def validate_each(procedure, attribute, types_de_champ) + types_de_champ.to_a + .flat_map { _1.repetition? ? procedure.draft_revision.children_of(_1) : _1 } + .each do |tdc| + if tdc.expression_reguliere? && tdc.invalid_regexp? + procedure.errors.add(:expression_reguliere, type_de_champ: tdc) + end + end + end +end diff --git a/app/validators/types_de_champ/header_section_consistency_validator.rb b/app/validators/types_de_champ/header_section_consistency_validator.rb new file mode 100644 index 000000000..062b27cf0 --- /dev/null +++ b/app/validators/types_de_champ/header_section_consistency_validator.rb @@ -0,0 +1,29 @@ +class TypesDeChamp::HeaderSectionConsistencyValidator < ActiveModel::EachValidator + def validate_each(procedure, attribute, types_de_champ) + public_tdcs = types_de_champ.to_a + + root_tdcs_errors = errors_for_header_sections_order(procedure, attribute, public_tdcs) + repetition_tdcs_errors = public_tdcs + .filter_map { _1.repetition? ? procedure.draft_revision.children_of(_1) : nil } + .map { errors_for_header_sections_order(procedure, attribute, _1) } + + repetition_tdcs_errors + root_tdcs_errors + end + + private + + def errors_for_header_sections_order(procedure, attribute, types_de_champ) + types_de_champ + .map.with_index + .filter_map { |tdc, i| tdc.header_section? ? [tdc, i] : nil } + .map { |tdc, i| [tdc, tdc.check_coherent_header_level(types_de_champ.take(i))] } + .filter { |_tdc, errors| errors.present? } + .each do |tdc, message| + procedure.errors.add( + attribute, + procedure.errors.generate_message(attribute, :inconsistent_header_section, { value: tdc.libelle, custom_message: message }), + type_de_champ: tdc + ) + end + end +end diff --git a/app/validators/types_de_champ/no_empty_block_validator.rb b/app/validators/types_de_champ/no_empty_block_validator.rb index e1ea17739..50356d6ab 100644 --- a/app/validators/types_de_champ/no_empty_block_validator.rb +++ b/app/validators/types_de_champ/no_empty_block_validator.rb @@ -11,7 +11,8 @@ class TypesDeChamp::NoEmptyBlockValidator < ActiveModel::EachValidator if procedure.draft_revision.children_of(parent).empty? procedure.errors.add( attribute, - procedure.errors.generate_message(attribute, :empty_repetition, { value: parent.libelle }) + procedure.errors.generate_message(attribute, :empty_repetition, { value: parent.libelle }), + type_de_champ: parent ) end end diff --git a/app/validators/types_de_champ/no_empty_drop_down_validator.rb b/app/validators/types_de_champ/no_empty_drop_down_validator.rb index bd8fa21dd..0be4e5406 100644 --- a/app/validators/types_de_champ/no_empty_drop_down_validator.rb +++ b/app/validators/types_de_champ/no_empty_drop_down_validator.rb @@ -11,7 +11,8 @@ class TypesDeChamp::NoEmptyDropDownValidator < ActiveModel::EachValidator if drop_down.drop_down_list_enabled_non_empty_options.empty? procedure.errors.add( attribute, - procedure.errors.generate_message(attribute, :empty_drop_down, { value: drop_down.libelle }) + procedure.errors.generate_message(attribute, :empty_drop_down, { value: drop_down.libelle }), + type_de_champ: drop_down ) end end diff --git a/app/views/administrateurs/conditions/_update.turbo_stream.haml b/app/views/administrateurs/conditions/_update.turbo_stream.haml index 3fba14ec8..028fb8c0e 100644 --- a/app/views/administrateurs/conditions/_update.turbo_stream.haml +++ b/app/views/administrateurs/conditions/_update.turbo_stream.haml @@ -4,7 +4,7 @@ ['Configuration des champs']], preview: @procedure.draft_revision.valid? }) -= turbo_stream.replace 'errors-summary', render(TypesDeChampEditor::ErrorsSummary.new(revision: @procedure.draft_revision)) += turbo_stream.replace 'errors-summary', render(Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: @tdc.public? ? :types_de_champ_public_editor : :types_de_champ_private_editor)) - rendered = render @condition_component diff --git a/app/views/administrateurs/procedures/_publication_form.html.haml b/app/views/administrateurs/procedures/_publication_form.html.haml index 85a8f083c..0c9cc8454 100644 --- a/app/views/administrateurs/procedures/_publication_form.html.haml +++ b/app/views/administrateurs/procedures/_publication_form.html.haml @@ -2,7 +2,7 @@ url: admin_procedure_publish_path(procedure_id: procedure.id), method: :put, html: { class: 'form' } do |f| - = render Procedure::PublicationWarningComponent.new(procedure: procedure) + = render Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: :publication) .mt-2 - if procedure.draft_changed? %p.mb-2= t('.draft_changed_procedure_alert') diff --git a/app/views/administrateurs/procedures/champs.html.haml b/app/views/administrateurs/procedures/champs.html.haml index 95ddf2860..4ec783ee1 100644 --- a/app/views/administrateurs/procedures/champs.html.haml +++ b/app/views/administrateurs/procedures/champs.html.haml @@ -9,7 +9,7 @@ .fr-grid-row = render partial: 'champs_summary' .fr-col - = render TypesDeChampEditor::EditorComponent.new(revision: @procedure.draft_revision) + = render TypesDeChampEditor::EditorComponent.new(revision: @procedure.draft_revision, is_annotation: false) .padded-fixed-footer .fixed-footer diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml index a4c1f16d3..0bd65ebf8 100644 --- a/app/views/administrateurs/procedures/show.html.haml +++ b/app/views/administrateurs/procedures/show.html.haml @@ -5,7 +5,7 @@ .fr-container.procedure-admin-container %ul.fr-btns-group.fr-btns-group--inline-sm.fr-btns-group--icon-left - - if @procedure.draft_revision.valid? + - if @procedure.validate(:publication) - if !@procedure.brouillon? = link_to 'Télécharger', admin_procedure_archives_path(@procedure), class: 'fr-btn fr-btn--tertiary fr-btn--icon-left fr-icon-download-line', id: "archive-procedure" @@ -27,15 +27,11 @@ = link_to 'Clore', admin_procedure_close_path(procedure_id: @procedure.id), class: 'fr-btn fr-btn--tertiary fr-btn--icon-left fr-icon-calendar-close-fill', id: "close-procedure-link" .fr-container - = render TypesDeChampEditor::ErrorsSummary.new(revision: @procedure.draft_revision) - -- if @procedure.draft_changed? - .fr-container + - if @procedure.draft_changed? = render Dsfr::CalloutComponent.new(title: t(:has_changes, scope: [:administrateurs, :revision_changes]), icon: "fr-fi-information-line") do |c| - c.with_body do = render Procedure::RevisionChangesComponent.new changes: @procedure.revision_changes, previous_revision: @procedure.published_revision - - = render Procedure::PublicationWarningComponent.new(procedure: @procedure) + = render Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: :publication) - c.with_bottom do %ul.fr-mt-2w.fr-btns-group.fr-btns-group--inline @@ -44,6 +40,9 @@ - else %li= button_to 'Publier les modifications', admin_procedure_publication_path(@procedure), class: 'fr-btn', id: 'publish-procedure-link', data: { disable_with: "Publication..." }, disabled: !@procedure.draft_revision.valid? || @procedure.errors.present?, method: :get %li= button_to "Réinitialiser les modifications", admin_procedure_reset_draft_path(@procedure), class: 'fr-btn fr-btn--secondary fr-mr-2w', data: { confirm: 'Êtes-vous sûr de vouloir réinitialiser les modifications ?' }, method: :put + - else + = render Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: :publication) + - if !@procedure.procedure_expires_when_termine_enabled? = render partial: 'administrateurs/procedures/suggest_expires_when_termine', locals: { procedure: @procedure } diff --git a/app/views/administrateurs/types_de_champ/_insert.turbo_stream.haml b/app/views/administrateurs/types_de_champ/_insert.turbo_stream.haml index 9421b594a..ad121dedd 100644 --- a/app/views/administrateurs/types_de_champ/_insert.turbo_stream.haml +++ b/app/views/administrateurs/types_de_champ/_insert.turbo_stream.haml @@ -10,9 +10,9 @@ locals: { steps: [['Démarches', admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], ['Configuration des champs']], - preview: @procedure.draft_revision.valid? }) + preview: @procedure.validate(@coordinate&.private? ? :types_de_champ_private_editor : :types_de_champ_public_editor) }) -= turbo_stream.replace 'errors-summary', render(TypesDeChampEditor::ErrorsSummary.new(revision: @procedure.draft_revision)) += turbo_stream.replace 'errors-summary', render(Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: @coordinate&.private? ? :types_de_champ_private_editor : :types_de_champ_public_editor)) = turbo_stream.replace 'summary', render(partial: 'administrateurs/procedures/champs_summary') diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 619f5f4f4..07486886c 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -739,8 +739,6 @@ fr: evil_regexp: L'expression régulière que vous avez entrée est potentiellement dangereuse et pourrait entraîner des problèmes de performance mismatch_regexp: L'exemple doit correspondre à l'expression régulière fournie syntax_error_regexp: La syntaxe de l'expression régulière n'est pas valide - empty_repetition: '« %{value} » doit comporter au moins un champ répétable' - empty_drop_down: '« %{value} » doit comporter au moins un choix sélectionnable' # procedure_not_draft: "Cette démarche n’est maintenant plus en brouillon." cadastres_empty: one: "Aucune parcelle cadastrale sur la zone sélectionnée" diff --git a/config/locales/models/procedure/en.yml b/config/locales/models/procedure/en.yml index 2f1d1c8b8..55a9c1ddd 100644 --- a/config/locales/models/procedure/en.yml +++ b/config/locales/models/procedure/en.yml @@ -72,8 +72,30 @@ en: invalid: 'invalid format' draft_types_de_champ_public: format: 'Public field %{message}' + invalid_condition: "« %{value} » have an invalid logic" + empty_repetition: '« %{value} » requires at least one field' + empty_drop_down: '« %{value} » requires at least one option' + inconsistent_header_section: "« %{value} » %{custom_message}" draft_types_de_champ_private: format: 'Private field %{message}' + invalid_condition: "« %{value} » have an invalid logic" + empty_repetition: '« %{value} » requires at least one field' + empty_drop_down: '« %{value} » requires at least one option' + inconsistent_header_section: "« %{value} » %{custom_message}" + attestation_template: + format: "%{attribute} %{message}" + initiated_mail: + format: "%{attribute} %{message}" + received_mail: + format: "%{attribute} %{message}" + closed_mail: + format: "%{attribute} %{message}" + refused_mail: + format: "%{attribute} %{message}" + without_continuation_mail: + format: "%{attribute} %{message}" + re_instructed_mail: + format: "%{attribute} %{message}" lien_dpo: invalid_uri_or_email: "Fill in with an email or a link" sva_svr: diff --git a/config/locales/models/procedure/fr.yml b/config/locales/models/procedure/fr.yml index 78e2bcf26..4a0fdeca5 100644 --- a/config/locales/models/procedure/fr.yml +++ b/config/locales/models/procedure/fr.yml @@ -78,8 +78,30 @@ fr: invalid: 'n’a pas le bon format' draft_types_de_champ_public: format: 'Le champ %{message}' + invalid_condition: "« %{value} » a une logique conditionnelle invalide" + empty_repetition: '« %{value} » doit comporter au moins un champ répétable' + empty_drop_down: '« %{value} » doit comporter au moins un choix sélectionnable' + inconsistent_header_section: "« %{value} » %{custom_message}" draft_types_de_champ_private: format: 'L’annotation privée %{message}' + invalid_condition: "« %{value} » a une logique conditionnelle invalide" + empty_repetition: '« %{value} » doit comporter au moins un champ répétable' + empty_drop_down: '« %{value} » doit comporter au moins un choix sélectionnable' + inconsistent_header_section: "« %{value} » %{custom_message}" + attestation_template: + format: "%{attribute} %{message}" + initiated_mail: + format: "%{attribute} %{message}" + received_mail: + format: "%{attribute} %{message}" + closed_mail: + format: "%{attribute} %{message}" + refused_mail: + format: "%{attribute} %{message}" + without_continuation_mail: + format: "%{attribute} %{message}" + re_instructed_mail: + format: "%{attribute} %{message}" lien_dpo: invalid_uri_or_email: "Veuillez saisir un mail ou un lien" auto_archive_on: diff --git a/config/locales/models/type_de_champ/fr.yml b/config/locales/models/type_de_champ/fr.yml index a6ea09511..71de8c159 100644 --- a/config/locales/models/type_de_champ/fr.yml +++ b/config/locales/models/type_de_champ/fr.yml @@ -61,4 +61,4 @@ fr: type_de_champ: attributes: header_section_level: - gap_error: "Un titre de section avec le niveau %{level} est manquant." + gap_error: "devrait être précédé d'un titre de niveau %{level}" diff --git a/spec/components/procedures/card/annotations_component_spec.rb b/spec/components/procedures/card/annotations_component_spec.rb new file mode 100644 index 000000000..478937124 --- /dev/null +++ b/spec/components/procedures/card/annotations_component_spec.rb @@ -0,0 +1,30 @@ +describe Procedure::Card::AnnotationsComponent, type: :component do + describe 'render' do + let(:procedure) { create(:procedure, id: 1, types_de_champ_private:, types_de_champ_public:) } + let(:types_de_champ_private) { [] } + let(:types_de_champ_public) { [] } + before { procedure.validate(:publication) } + subject { render_inline(described_class.new(procedure: procedure)) } + + context 'when no errors' do + it 'does not render' do + expect(subject).to have_selector('.fr-badge--info', text: 'À configurer') + end + end + + context 'when errors on types_de_champs_public' do + let(:types_de_champ_public) { [{ type: :drop_down_list, options: [] }] } + it 'does not render' do + expect(subject).to have_selector('.fr-badge--info', text: 'À configurer') + end + end + + context 'when errors on types_de_champs_private' do + let(:types_de_champ_private) { [{ type: :drop_down_list, options: [] }] } + + it 'render the template' do + expect(subject).to have_selector('.fr-badge--error', text: 'À modifier') + end + end + end +end diff --git a/spec/components/procedures/card/champs_component_spec.rb b/spec/components/procedures/card/champs_component_spec.rb new file mode 100644 index 000000000..61a5d5762 --- /dev/null +++ b/spec/components/procedures/card/champs_component_spec.rb @@ -0,0 +1,30 @@ +describe Procedure::Card::ChampsComponent, type: :component do + describe 'render' do + let(:procedure) { create(:procedure, id: 1, types_de_champ_private:, types_de_champ_public:) } + let(:types_de_champ_private) { [] } + let(:types_de_champ_public) { [] } + before { procedure.validate(:publication) } + subject { render_inline(described_class.new(procedure: procedure)) } + + context 'when no errors' do + it 'does not render' do + expect(subject).to have_selector('.fr-badge--warning', text: 'À faire') + end + end + + context 'when errors on types_de_champs_public' do + let(:types_de_champ_public) { [{ type: :drop_down_list, options: [] }] } + it 'does not render' do + expect(subject).to have_selector('.fr-badge--error', text: 'À modifier') + end + end + + context 'when errors on types_de_champs_private' do + let(:types_de_champ_private) { [{ type: :drop_down_list, options: [] }] } + + it 'render the template' do + expect(subject).to have_selector('.fr-badge--warning', text: 'À faire') + end + end + end +end diff --git a/spec/components/procedures/errors_summary_spec.rb b/spec/components/procedures/errors_summary_spec.rb new file mode 100644 index 000000000..4c3ef6337 --- /dev/null +++ b/spec/components/procedures/errors_summary_spec.rb @@ -0,0 +1,80 @@ +describe Procedure::ErrorsSummary, type: :component do + subject { render_inline(described_class.new(procedure:, validation_context:)) } + + describe 'validations context' do + let(:procedure) { create(:procedure, types_de_champ_private:, types_de_champ_public:) } + let(:types_de_champ_private) { [{ type: :drop_down_list, options: [], libelle: 'private' }] } + let(:types_de_champ_public) { [{ type: :drop_down_list, options: [], libelle: 'public' }] } + + before { subject } + + context 'when :publication' do + let(:validation_context) { :publication } + + it 'shows errors for public and private tdc' do + expect(page).to have_text("Le champ « public » doit comporter au moins un choix sélectionnable") + expect(page).to have_text("L’annotation privée « private » doit comporter au moins un choix sélectionnable") + end + end + + context 'when :types_de_champ_public_editor' do + let(:validation_context) { :types_de_champ_public_editor } + + it 'shows errors for public only tdc' do + expect(page).to have_text("Le champ « public » doit comporter au moins un choix sélectionnable") + expect(page).not_to have_text("L’annotation privée « private » doit comporter au moins un choix sélectionnable") + end + end + + context 'when :types_de_champ_private_editor' do + let(:validation_context) { :types_de_champ_private_editor } + + it 'shows errors for private only tdc' do + expect(page).not_to have_text("Le champ « public » doit comporter au moins un choix sélectionnable") + expect(page).to have_text("L’annotation privée « private » doit comporter au moins un choix sélectionnable") + end + end + end + + describe 'render all kind of champs errors' do + include Logic + + let(:procedure) do + create(:procedure, id: 1, types_de_champ_public: [ + { libelle: 'repetition requires children', type: :repetition, children: [] }, + { libelle: 'drop down list requires options', type: :drop_down_list, options: [] }, + { libelle: 'invalid condition', type: :text, condition: ds_eq(constant(true), constant(1)) }, + { libelle: 'header sections must have consistent order', type: :header_section, level: 2 } + ]) + end + + let(:validation_context) { :types_de_champ_public_editor } + + before { subject } + + it 'renders all errors on champ' do + expect(page).to have_text("Le champ « drop down list requires options » doit comporter au moins un choix sélectionnable") + expect(page).to have_text("Le champ « repetition requires children » doit comporter au moins un champ répétable") + expect(page).to have_text("Le champ « invalid condition » a une logique conditionnelle invalide") + expect(page).to have_text("Le champ « header sections must have consistent order » devrait être précédé d'un titre de niveau 1") + # TODO, test attestation_template, initiated_mail, :received_mail, :closed_mail, :refused_mail, :without_continuation_mail, :re_instructed_mail + end + end + + describe 'render error for other kind of associated objects' do + let(:validation_context) { :publication } + let(:procedure) { create(:procedure, attestation_template:, initiated_mail:) } + let(:attestation_template) { build(:attestation_template) } + let(:initiated_mail) { build(:initiated_mail) } + + before do + [:attestation_template, :initiated_mail].map { procedure.send(_1).update_column(:body, '--invalidtag--') } + subject + end + + it 'render error nicely' do + expect(page).to have_text("Le modèle d’attestation n'est pas valide") + expect(page).to have_text("L’email de notification de passage de dossier en instruction n'est pas valide") + end + end +end diff --git a/spec/components/types_de_champ_editor/editor_component_spec.rb b/spec/components/types_de_champ_editor/editor_component_spec.rb new file mode 100644 index 000000000..7b4a19e46 --- /dev/null +++ b/spec/components/types_de_champ_editor/editor_component_spec.rb @@ -0,0 +1,26 @@ +describe TypesDeChampEditor::EditorComponent, type: :component do + let(:revision) { procedure.draft_revision } + let(:procedure) { create(:procedure, id: 1, types_de_champ_private:, types_de_champ_public:) } + + let(:types_de_champ_private) { [{ type: :drop_down_list, options: [], libelle: 'private' }] } + let(:types_de_champ_public) { [{ type: :drop_down_list, options: [], libelle: 'public' }] } + + describe 'render' do + subject { render_inline(described_class.new(revision:, is_annotation:)) } + context 'types_de_champ_public' do + let(:is_annotation) { false } + it 'does not render private champs errors' do + expect(subject).not_to have_text("« private » doit comporter au moins un choix sélectionnable") + expect(subject).to have_text("« public » doit comporter au moins un choix sélectionnable") + end + end + + context 'types_de_champ_private' do + let(:is_annotation) { true } + it 'does not render public champs errors' do + expect(subject).to have_text("« private » doit comporter au moins un choix sélectionnable") + expect(subject).not_to have_text("« public » doit comporter au moins un choix sélectionnable") + end + end + end +end diff --git a/spec/models/procedure_revision_spec.rb b/spec/models/procedure_revision_spec.rb index 9d3ea5924..4c7a17ba3 100644 --- a/spec/models/procedure_revision_spec.rb +++ b/spec/models/procedure_revision_spec.rb @@ -828,23 +828,22 @@ describe ProcedureRevision do describe 'conditions_are_valid' do include Logic - def first_champ = procedure.draft_revision.types_de_champ_public.first - - def second_champ = procedure.draft_revision.types_de_champ_public.second - - let(:procedure) do - create(:procedure).tap do |p| - p.draft_revision.add_type_de_champ(type_champ: :integer_number, libelle: 'l1') - p.draft_revision.add_type_de_champ(type_champ: :integer_number, libelle: 'l2') - end + let(:procedure) { create(:procedure, types_de_champ_public:) } + let(:types_de_champ_public) do + [ + { type: :integer_number, libelle: 'l1' }, + { type: :integer_number, libelle: 'l2' } + ] end + def first_champ = procedure.draft_revision.types_de_champ_public.first + def second_champ = procedure.draft_revision.types_de_champ_public.second let(:draft_revision) { procedure.draft_revision } let(:condition) { nil } subject do - draft_revision.save - draft_revision.errors + procedure.validate(:publication) + procedure.errors end context 'when a champ has a valid condition (type)' do @@ -865,7 +864,7 @@ describe ProcedureRevision do before { second_champ.update(condition: condition) } let(:condition) { ds_eq(constant(true), constant(1)) } - it { expect(subject.first.attribute).to eq(:condition) } + it { expect(subject.first.attribute).to eq(:draft_types_de_champ_public) } end context 'when a champ has an invalid condition: needed tdc is down in the forms' do @@ -876,7 +875,7 @@ describe ProcedureRevision do first_champ.update(condition: need_second_champ) end - it { expect(subject.first.attribute).to eq(:condition) } + it { expect(subject.first.attribute).to eq(:draft_types_de_champ_public) } end context 'with a repetition' do @@ -904,7 +903,7 @@ describe ProcedureRevision do context 'when a champ belongs to a repetition' do let(:condition) { ds_eq(champ_value(-1), constant(1)) } - it { expect(subject.first.attribute).to eq(:condition) } + it { expect(subject.first.attribute).to eq(:draft_types_de_champ_public) } end end end @@ -918,8 +917,8 @@ describe ProcedureRevision do let(:draft_revision) { procedure.draft_revision } subject do - draft_revision.save - draft_revision.errors + procedure.validate(:publication) + procedure.errors end it 'find error' do @@ -936,8 +935,8 @@ describe ProcedureRevision do let(:draft_revision) { procedure.draft_revision } subject do - draft_revision.save - draft_revision.errors + procedure.validate(:publication) + procedure.errors end context "When no regexp and no example" do diff --git a/spec/system/administrateurs/procedure_publish_spec.rb b/spec/system/administrateurs/procedure_publish_spec.rb index f1109ec4c..10818971c 100644 --- a/spec/system/administrateurs/procedure_publish_spec.rb +++ b/spec/system/administrateurs/procedure_publish_spec.rb @@ -44,13 +44,10 @@ describe 'Publishing a procedure', js: true do end context 'when a procedure isn’t published yet' do - before do - visit admin_procedures_path(statut: "brouillons") - click_on procedure.libelle - find('#publish-procedure-link').click - end - scenario 'an admin can publish it' do + visit admin_procedure_path(procedure) + find('#publish-procedure-link').click + expect(find_field('procedure_path').value).to eq procedure.path fill_in 'lien_site_web', with: 'http://some.website' within('form') { click_on 'Publier' } @@ -72,10 +69,13 @@ describe 'Publishing a procedure', js: true do end scenario 'an error message prevents the publication' do - expect(page).to have_content('Des problèmes empêchent la publication de la démarche') - expect(page).to have_content("Le champ « Enfants » doit comporter au moins un champ répétable") - expect(page).to have_content("L’annotation privée « Civilité » doit comporter au moins un choix sélectionnable") + visit admin_procedure_path(procedure) + expect(page).to have_content('Des problèmes empêchent la publication de la démarche') + expect(page).to have_content("« Enfants » doit comporter au moins un champ répétable") + expect(page).to have_content("« Civilité » doit comporter au moins un choix sélectionnable") + + visit admin_procedure_publication_path(procedure) expect(find_field('procedure_path').value).to eq procedure.path fill_in 'lien_site_web', with: 'http://some.website' @@ -85,8 +85,9 @@ describe 'Publishing a procedure', js: true do context 'when the procedure has the same path as another procedure from another admin ' do scenario 'an error message prevents the publication' do - expect(find_field('procedure_path').value).to eq procedure.path + visit admin_procedure_publication_path(procedure) fill_in 'procedure_path', with: other_procedure.path + expect(page).to have_content 'vous devez la modifier afin de pouvoir publier votre démarche' fill_in 'lien_site_web', with: 'http://some.website' diff --git a/spec/system/administrateurs/types_de_champ_spec.rb b/spec/system/administrateurs/types_de_champ_spec.rb index 8cbba47a7..28023d9b8 100644 --- a/spec/system/administrateurs/types_de_champ_spec.rb +++ b/spec/system/administrateurs/types_de_champ_spec.rb @@ -228,9 +228,7 @@ describe 'As an administrateur I can edit types de champ', js: true do click_on 'Supprimer' end end - - expect(page).to have_content("Le formulaire contient des erreurs") - expect(page).to have_content("Le titre de section suivant est invalide, veuillez le corriger :") + expect(page).to have_content("devrait être précédé d'un titre de niveau 1") end end From 728a28134cc302d0587bbbc7a8f742e952ee369a Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 5 Jun 2024 09:40:54 +0200 Subject: [PATCH 0335/1532] chore(bundle): rails 7.0.8.3 => 7.0.8.4 --- Gemfile.lock | 116 +++++++++++++++++++++++++-------------------------- 1 file changed, 58 insertions(+), 58 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 51281c712..a0a99d358 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -12,47 +12,47 @@ GEM aasm (5.5.0) concurrent-ruby (~> 1.0) acsv (0.0.1) - actioncable (7.0.8.3) - actionpack (= 7.0.8.3) - activesupport (= 7.0.8.3) + actioncable (7.0.8.4) + actionpack (= 7.0.8.4) + activesupport (= 7.0.8.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.8.3) - actionpack (= 7.0.8.3) - activejob (= 7.0.8.3) - activerecord (= 7.0.8.3) - activestorage (= 7.0.8.3) - activesupport (= 7.0.8.3) + actionmailbox (7.0.8.4) + actionpack (= 7.0.8.4) + activejob (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.8.3) - actionpack (= 7.0.8.3) - actionview (= 7.0.8.3) - activejob (= 7.0.8.3) - activesupport (= 7.0.8.3) + actionmailer (7.0.8.4) + actionpack (= 7.0.8.4) + actionview (= 7.0.8.4) + activejob (= 7.0.8.4) + activesupport (= 7.0.8.4) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.8.3) - actionview (= 7.0.8.3) - activesupport (= 7.0.8.3) + actionpack (7.0.8.4) + actionview (= 7.0.8.4) + activesupport (= 7.0.8.4) rack (~> 2.0, >= 2.2.4) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.8.3) - actionpack (= 7.0.8.3) - activerecord (= 7.0.8.3) - activestorage (= 7.0.8.3) - activesupport (= 7.0.8.3) + actiontext (7.0.8.4) + actionpack (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.8.3) - activesupport (= 7.0.8.3) + actionview (7.0.8.4) + activesupport (= 7.0.8.4) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -67,26 +67,26 @@ GEM activemodel (>= 5.2.0) activestorage (>= 5.2.0) activesupport (>= 5.2.0) - activejob (7.0.8.3) - activesupport (= 7.0.8.3) + activejob (7.0.8.4) + activesupport (= 7.0.8.4) globalid (>= 0.3.6) - activemodel (7.0.8.3) - activesupport (= 7.0.8.3) - activerecord (7.0.8.3) - activemodel (= 7.0.8.3) - activesupport (= 7.0.8.3) - activestorage (7.0.8.3) - actionpack (= 7.0.8.3) - activejob (= 7.0.8.3) - activerecord (= 7.0.8.3) - activesupport (= 7.0.8.3) + activemodel (7.0.8.4) + activesupport (= 7.0.8.4) + activerecord (7.0.8.4) + activemodel (= 7.0.8.4) + activesupport (= 7.0.8.4) + activestorage (7.0.8.4) + actionpack (= 7.0.8.4) + activejob (= 7.0.8.4) + activerecord (= 7.0.8.4) + activesupport (= 7.0.8.4) marcel (~> 1.0) mini_mime (>= 1.1.0) activestorage-openstack (1.6.0) fog-openstack (>= 1.0.9) marcel rails (>= 5.2.2) - activesupport (7.0.8.3) + activesupport (7.0.8.4) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -174,7 +174,7 @@ GEM clamav-client (3.2.0) coercible (1.0.0) descendants_tracker (~> 0.0.1) - concurrent-ruby (1.2.3) + concurrent-ruby (1.3.1) connection_pool (2.4.1) content_disposition (1.0.0) crack (1.0.0) @@ -438,15 +438,15 @@ GEM rake mini_magick (4.12.0) mini_mime (1.1.5) - mini_portile2 (2.8.6) - minitest (5.23.0) + mini_portile2 (2.8.7) + minitest (5.23.1) msgpack (1.7.2) multi_json (1.15.0) mustermann (3.0.0) ruby2_keywords (~> 0.0.1) net-http (0.4.1) uri - net-imap (0.4.11) + net-imap (0.4.12) date net-protocol net-pop (0.1.2) @@ -531,20 +531,20 @@ GEM rack_session_access (0.2.0) builder (>= 2.0.0) rack (>= 1.0.0) - rails (7.0.8.3) - actioncable (= 7.0.8.3) - actionmailbox (= 7.0.8.3) - actionmailer (= 7.0.8.3) - actionpack (= 7.0.8.3) - actiontext (= 7.0.8.3) - actionview (= 7.0.8.3) - activejob (= 7.0.8.3) - activemodel (= 7.0.8.3) - activerecord (= 7.0.8.3) - activestorage (= 7.0.8.3) - activesupport (= 7.0.8.3) + rails (7.0.8.4) + actioncable (= 7.0.8.4) + actionmailbox (= 7.0.8.4) + actionmailer (= 7.0.8.4) + actionpack (= 7.0.8.4) + actiontext (= 7.0.8.4) + actionview (= 7.0.8.4) + activejob (= 7.0.8.4) + activemodel (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) bundler (>= 1.15.0) - railties (= 7.0.8.3) + railties (= 7.0.8.4) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -567,9 +567,9 @@ GEM rails-pg-extras (5.3.1) rails ruby-pg-extras (= 5.3.1) - railties (7.0.8.3) - actionpack (= 7.0.8.3) - activesupport (= 7.0.8.3) + railties (7.0.8.4) + actionpack (= 7.0.8.4) + activesupport (= 7.0.8.4) method_source rake (>= 12.2) thor (~> 1.0) @@ -872,7 +872,7 @@ GEM anyway_config (>= 1.3, < 3) sidekiq yabeda (~> 0.6) - zeitwerk (2.6.14) + zeitwerk (2.6.15) zip_tricks (5.6.0) zipline (1.5.0) actionpack (>= 6.0, < 8.0) From cb5ba455ebaf621a6caebff9138f9ff6f9f541fb Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Wed, 5 Jun 2024 11:52:18 +0000 Subject: [PATCH 0336/1532] =?UTF-8?q?Fixe=20bug=20sur=20toutes=20les=20d?= =?UTF-8?q?=C3=A9marches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/procedure_detail.rb | 2 +- .../procedures/_detail.html.haml | 2 +- .../procedures_controller_spec.rb | 16 ---------- spec/models/procedure_spec.rb | 31 +++++++++++++++++++ 4 files changed, 33 insertions(+), 18 deletions(-) diff --git a/app/models/procedure_detail.rb b/app/models/procedure_detail.rb index ebe6c3f6d..2ae20f20f 100644 --- a/app/models/procedure_detail.rb +++ b/app/models/procedure_detail.rb @@ -14,7 +14,7 @@ ProcedureDetail = Struct.new(:id, :libelle, :published_at, :aasm_state, :estimat end def parsed_latest_zone_labels - # Replace curly braces with square brackets to make it a valid JSON array + return [] if latest_zone_labels.nil? || latest_zone_labels.strip.empty? JSON.parse(latest_zone_labels.tr('{', '[').tr('}', ']')) rescue JSON::ParserError [] diff --git a/app/views/administrateurs/procedures/_detail.html.haml b/app/views/administrateurs/procedures/_detail.html.haml index 798bb15a6..99b8b1410 100644 --- a/app/views/administrateurs/procedures/_detail.html.haml +++ b/app/views/administrateurs/procedures/_detail.html.haml @@ -31,7 +31,7 @@ - if show_detail %tr.procedure{ id: "procedure_detail_#{procedure.id}" } - %td.fr-highlight--beige-gris-galet{ colspan: '8' } + %td.fr-highlight--green-emeraude{ colspan: '8' } .fr-container .fr-col-6 - procedure.administrateurs.uniq.each do |admin| diff --git a/spec/controllers/administrateurs/procedures_controller_spec.rb b/spec/controllers/administrateurs/procedures_controller_spec.rb index 0cb5bfa2d..116db94d1 100644 --- a/spec/controllers/administrateurs/procedures_controller_spec.rb +++ b/spec/controllers/administrateurs/procedures_controller_spec.rb @@ -95,9 +95,6 @@ describe Administrateurs::ProceduresController, type: :controller do let!(:draft_procedure) { create(:procedure) } let!(:published_procedure) { create(:procedure_with_dossiers, :published, dossiers_count: 2) } let!(:closed_procedure) { create(:procedure, :closed) } - let!(:procedure_detail_draft) { ProcedureDetail.new(id: draft_procedure.id, latest_zone_labels: '{ "zone1", "zone2" }') } - let!(:procedure_detail_published) { ProcedureDetail.new(id: published_procedure.id, latest_zone_labels: '{ "zone3", "zone4" }') } - let!(:procedure_detail_closed) { ProcedureDetail.new(id: closed_procedure.id, latest_zone_labels: '{ "zone5", "zone6" }') } subject { get :all } @@ -124,19 +121,6 @@ describe Administrateurs::ProceduresController, type: :controller do expect(assigns(:procedures).any? { |p| p.id == draft_procedure.id }).to be_falsey end - context 'with parsed latest zone labels' do - it 'parses the latest zone labels correctly' do - expect(procedure_detail_draft.parsed_latest_zone_labels).to eq(["zone1", "zone2"]) - expect(procedure_detail_published.parsed_latest_zone_labels).to eq(["zone3", "zone4"]) - expect(procedure_detail_closed.parsed_latest_zone_labels).to eq(["zone5", "zone6"]) - end - - it 'returns an empty array for invalid JSON' do - procedure_detail_draft.latest_zone_labels = '{ invalid json }' - expect(procedure_detail_draft.parsed_latest_zone_labels).to eq([]) - end - end - context 'for default admin zones' do let(:zone1) { create(:zone) } let(:zone2) { create(:zone) } diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 0589985bb..39ebc055f 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -1776,6 +1776,37 @@ describe Procedure do end end + describe "#parsed_latest_zone_labels" do + let!(:draft_procedure) { create(:procedure) } + let!(:published_procedure) { create(:procedure_with_dossiers, :published, dossiers_count: 2) } + let!(:closed_procedure) { create(:procedure, :closed) } + let!(:procedure_detail_draft) { ProcedureDetail.new(id: draft_procedure.id, latest_zone_labels: '{ "zone1", "zone2" }') } + let!(:procedure_detail_published) { ProcedureDetail.new(id: published_procedure.id, latest_zone_labels: '{ "zone3", "zone4" }') } + let!(:procedure_detail_closed) { ProcedureDetail.new(id: closed_procedure.id, latest_zone_labels: '{ "zone5", "zone6" }') } + context 'with parsed latest zone labels' do + it 'parses the latest zone labels correctly' do + expect(procedure_detail_draft.parsed_latest_zone_labels).to eq(["zone1", "zone2"]) + expect(procedure_detail_published.parsed_latest_zone_labels).to eq(["zone3", "zone4"]) + expect(procedure_detail_closed.parsed_latest_zone_labels).to eq(["zone5", "zone6"]) + end + + it 'returns an empty array for invalid JSON' do + procedure_detail_draft.latest_zone_labels = '{ invalid json }' + expect(procedure_detail_draft.parsed_latest_zone_labels).to eq([]) + end + + it 'returns an empty array when latest_zone_labels is nil' do + procedure_detail_draft.latest_zone_labels = nil + expect(procedure_detail_draft.parsed_latest_zone_labels).to eq([]) + end + + it 'returns an empty array when latest_zone_labels is empty' do + procedure_detail_draft.latest_zone_labels = '' + expect(procedure_detail_draft.parsed_latest_zone_labels).to eq([]) + end + end + end + private def create_dossier_with_pj_of_size(size, procedure) From 73fe247c3d72a17c09e3f83028e3b886a281a191 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 14:21:58 +0200 Subject: [PATCH 0337/1532] feat(avis): ensure consistent ordering, use ASC ordering to give back most recent avis first --- app/models/dossier.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 54c187169..e609323bb 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -59,7 +59,7 @@ class Dossier < ApplicationRecord has_many :previous_follows, -> { inactive }, class_name: 'Follow', inverse_of: :dossier has_many :followers_instructeurs, through: :follows, source: :instructeur has_many :previous_followers_instructeurs, -> { distinct }, through: :previous_follows, source: :instructeur - has_many :avis, inverse_of: :dossier, dependent: :destroy + has_many :avis, -> { order(:created_at) }, inverse_of: :dossier, dependent: :destroy has_many :experts, through: :avis has_many :traitements, -> { order(:processed_at) }, inverse_of: :dossier, dependent: :destroy do def passer_en_construction(instructeur: nil, processed_at: Time.zone.now) From ed496a45b4d44aba4fc493fc6cbfc0498547c50f Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Thu, 6 Jun 2024 11:53:08 +0200 Subject: [PATCH 0338/1532] ajout du texte introductif du bouton JDMA dans merci --- app/assets/stylesheets/merci.scss | 7 ------- app/views/users/dossiers/_merci.html.haml | 6 +++++- config/locales/en.yml | 2 ++ config/locales/fr.yml | 2 ++ 4 files changed, 9 insertions(+), 8 deletions(-) delete mode 100644 app/assets/stylesheets/merci.scss diff --git a/app/assets/stylesheets/merci.scss b/app/assets/stylesheets/merci.scss deleted file mode 100644 index b73c2d8e6..000000000 --- a/app/assets/stylesheets/merci.scss +++ /dev/null @@ -1,7 +0,0 @@ -@import "constants"; - -.merci .monavis { - img { - margin-top: 2 * $default-padding; - } -} diff --git a/app/views/users/dossiers/_merci.html.haml b/app/views/users/dossiers/_merci.html.haml index 383f502d9..12bd195f6 100644 --- a/app/views/users/dossiers/_merci.html.haml +++ b/app/views/users/dossiers/_merci.html.haml @@ -20,7 +20,11 @@ .flex.column.align-center = link_to t('views.users.dossiers.merci.acces_dossier'), dossier ? dossier_path(dossier) : "#dossier" , class: 'fr-btn fr-btn--xl fr-mt-5w' - = link_to t('views.users.dossiers.merci.submit_dossier'), commencer_url(procedure.path), class: 'fr-btn fr-btn--secondary fr-mt-3w' + = link_to t('views.users.dossiers.merci.submit_dossier'), commencer_url(procedure.path), class: 'fr-btn fr-btn--secondary fr-mt-3w fr-mb-2w' .monavis + %p.fr-mt-5w.fr-mb-1w + %strong= t('views.users.dossiers.merci.jdma_l1') + %p= t('views.users.dossiers.merci.jdma_l2') + != procedure.monavis_embed diff --git a/config/locales/en.yml b/config/locales/en.yml index b8413b1eb..5787289e6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -446,6 +446,8 @@ en: dossier_edit_l4: talk with an instructor. acces_dossier: Access your file submit_dossier: Submit an other file + jdma_l1: Help us improve this service! + jdma_l2: Give us your feedback, it only takes 2 minutes. show: header: summary: "Summary" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 619f5f4f4..52100dc78 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -449,6 +449,8 @@ fr: dossier_edit_l4: échanger avec un instructeur. acces_dossier: Accéder à votre dossier submit_dossier: Déposer un autre dossier + jdma_l1: Aidez-nous à améliorer ce service ! + jdma_l2: Donnez-nous votre avis, cela ne prend que 2 minutes. show: header: summary: "Résumé" From f3795ebc9817903597836df6dc0bf99164611f75 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 24 May 2024 09:46:45 +0200 Subject: [PATCH 0339/1532] feat(gallery): add pdf previews --- app/views/instructeurs/dossiers/pieces_jointes.html.haml | 6 +++--- app/views/shared/champs/piece_justificative/_show.html.haml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml index 58a867ccd..e3480d617 100644 --- a/app/views/instructeurs/dossiers/pieces_jointes.html.haml +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -8,17 +8,17 @@ - champ.piece_justificative_file.with_all_variant_records.each do |attachment| .gallery-item - blob = attachment.blob - - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) + - if blob.previewable? && blob.content_type.in?(AUTHORIZED_PDF_TYPES) = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do .thumbnail - = image_tag("pdf-placeholder.png") + = image_tag(attachment.preview(resize_to_limit: [400, 400]).processed.url, loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } Visualiser .champ-libelle = champ.libelle.truncate(25) = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) - - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) + - elsif blob.variable? && blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail = image_tag(attachment.variant(resize_to_limit: [400, 400]).processed.url, loading: :lazy) diff --git a/app/views/shared/champs/piece_justificative/_show.html.haml b/app/views/shared/champs/piece_justificative/_show.html.haml index 734aeadd8..b32f05595 100644 --- a/app/views/shared/champs/piece_justificative/_show.html.haml +++ b/app/views/shared/champs/piece_justificative/_show.html.haml @@ -8,14 +8,14 @@ - champ.piece_justificative_file.attachments.with_all_variant_records.each do |attachment| .gallery-item - blob = attachment.blob - - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) + - if blob.previewable? && blob.content_type.in?(AUTHORIZED_PDF_TYPES) = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do .thumbnail - = image_tag("pdf-placeholder.png") + = image_tag(attachment.preview(resize_to_limit: [400, 400]).processed.url, loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } = 'Visualiser' - - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) + - elsif blob.variable? && blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail = image_tag(attachment.variant(resize_to_limit: [400, 400]).processed.url, loading: :lazy) From 05ad5dcbd67c44ae2663b37d2bc8bf6217b1501b Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 24 May 2024 09:58:19 +0200 Subject: [PATCH 0340/1532] feat(gallery): display large variant for rare image types --- app/jobs/image_processor_job.rb | 3 ++ .../dossiers/pieces_jointes.html.haml | 3 +- .../piece_justificative/_show.html.haml | 3 +- .../initializers/authorized_content_types.rb | 4 +++ spec/fixtures/files/pencil.tiff | Bin 0 -> 15224 bytes spec/jobs/image_processor_job_spec.rb | 29 +++++++++++++----- 6 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 spec/fixtures/files/pencil.tiff diff --git a/app/jobs/image_processor_job.rb b/app/jobs/image_processor_job.rb index 8017d2b35..798ba86a1 100644 --- a/app/jobs/image_processor_job.rb +++ b/app/jobs/image_processor_job.rb @@ -38,6 +38,9 @@ class ImageProcessorJob < ApplicationJob blob.attachments.each do |attachment| next unless attachment&.representable? attachment.representation(resize_to_limit: [400, 400]).processed + if attachment.blob.content_type.in?(RARE_IMAGE_TYPES) + attachment.variant(resize_to_limit: [2000, 2000]).processed + end end end diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml index e3480d617..eb67f850c 100644 --- a/app/views/instructeurs/dossiers/pieces_jointes.html.haml +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -19,7 +19,8 @@ = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) - elsif blob.variable? && blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) - = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + - blob_url = blob.content_type.in?(RARE_IMAGE_TYPES) ? attachment.variant(resize_to_limit: [2000, 2000]).processed.url : blob.url + = link_to image_url(blob_url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail = image_tag(attachment.variant(resize_to_limit: [400, 400]).processed.url, loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } diff --git a/app/views/shared/champs/piece_justificative/_show.html.haml b/app/views/shared/champs/piece_justificative/_show.html.haml index b32f05595..1624f86e1 100644 --- a/app/views/shared/champs/piece_justificative/_show.html.haml +++ b/app/views/shared/champs/piece_justificative/_show.html.haml @@ -16,7 +16,8 @@ = 'Visualiser' - elsif blob.variable? && blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) - = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + - blob_url = blob.content_type.in?(RARE_IMAGE_TYPES) ? attachment.variant(resize_to_limit: [2000, 2000]).processed.url : blob.url + = link_to image_url(blob_url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail = image_tag(attachment.variant(resize_to_limit: [400, 400]).processed.url, loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } diff --git a/config/initializers/authorized_content_types.rb b/config/initializers/authorized_content_types.rb index e5af1c74f..eaa16fbd9 100644 --- a/config/initializers/authorized_content_types.rb +++ b/config/initializers/authorized_content_types.rb @@ -15,6 +15,10 @@ AUTHORIZED_IMAGE_TYPES = [ 'image/vnd.dwg' # multimedia x 137 auto desk ] +RARE_IMAGE_TYPES = [ + 'image/tiff' # multimedia x 3985 +] + AUTHORIZED_CONTENT_TYPES = AUTHORIZED_IMAGE_TYPES + AUTHORIZED_PDF_TYPES + [ # multimedia 'video/mp4', # multimedia x 2075 diff --git a/spec/fixtures/files/pencil.tiff b/spec/fixtures/files/pencil.tiff new file mode 100644 index 0000000000000000000000000000000000000000..67af5a81aa3db48f070524d1b52c588491b74861 GIT binary patch literal 15224 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 bn3?@sFux%Kd*|f9^ Date: Wed, 29 May 2024 10:54:38 +0200 Subject: [PATCH 0341/1532] refactor(gallery): add helper methods --- app/helpers/gallery_helper.rb | 21 +++++++++++++++++++ .../dossiers/pieces_jointes.html.haml | 11 +++++----- .../piece_justificative/_show.html.haml | 11 +++++----- 3 files changed, 31 insertions(+), 12 deletions(-) create mode 100644 app/helpers/gallery_helper.rb diff --git a/app/helpers/gallery_helper.rb b/app/helpers/gallery_helper.rb new file mode 100644 index 000000000..67068d480 --- /dev/null +++ b/app/helpers/gallery_helper.rb @@ -0,0 +1,21 @@ +module GalleryHelper + def displayable_pdf?(blob) + blob.previewable? && blob.content_type.in?(AUTHORIZED_PDF_TYPES) + end + + def displayable_image?(blob) + blob.variable? && blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) + end + + def preview_url_for(attachment) + attachment.preview(resize_to_limit: [400, 400]).processed.url + end + + def variant_url_for(attachment) + attachment.variant(resize_to_limit: [400, 400]).processed.url + end + + def blob_url(attachment) + attachment.blob.content_type.in?(RARE_IMAGE_TYPES) ? attachment.variant(resize_to_limit: [2000, 2000]).processed.url : attachment.blob.url + end +end diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml index eb67f850c..d770433d4 100644 --- a/app/views/instructeurs/dossiers/pieces_jointes.html.haml +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -8,21 +8,20 @@ - champ.piece_justificative_file.with_all_variant_records.each do |attachment| .gallery-item - blob = attachment.blob - - if blob.previewable? && blob.content_type.in?(AUTHORIZED_PDF_TYPES) + - if displayable_pdf?(blob) = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do .thumbnail - = image_tag(attachment.preview(resize_to_limit: [400, 400]).processed.url, loading: :lazy) + = image_tag(preview_url_for(attachment), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } Visualiser .champ-libelle = champ.libelle.truncate(25) = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) - - elsif blob.variable? && blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) - - blob_url = blob.content_type.in?(RARE_IMAGE_TYPES) ? attachment.variant(resize_to_limit: [2000, 2000]).processed.url : blob.url - = link_to image_url(blob_url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + - elsif displayable_image?(blob) + = link_to image_url(blob_url(attachment)), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail - = image_tag(attachment.variant(resize_to_limit: [400, 400]).processed.url, loading: :lazy) + = image_tag(variant_url_for(attachment), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } Visualiser .champ-libelle diff --git a/app/views/shared/champs/piece_justificative/_show.html.haml b/app/views/shared/champs/piece_justificative/_show.html.haml index 1624f86e1..abb301872 100644 --- a/app/views/shared/champs/piece_justificative/_show.html.haml +++ b/app/views/shared/champs/piece_justificative/_show.html.haml @@ -8,18 +8,17 @@ - champ.piece_justificative_file.attachments.with_all_variant_records.each do |attachment| .gallery-item - blob = attachment.blob - - if blob.previewable? && blob.content_type.in?(AUTHORIZED_PDF_TYPES) + - if displayable_pdf?(blob) = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do .thumbnail - = image_tag(attachment.preview(resize_to_limit: [400, 400]).processed.url, loading: :lazy) + = image_tag(preview_url_for(attachment), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } = 'Visualiser' - - elsif blob.variable? && blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) - - blob_url = blob.content_type.in?(RARE_IMAGE_TYPES) ? attachment.variant(resize_to_limit: [2000, 2000]).processed.url : blob.url - = link_to image_url(blob_url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + - elsif displayable_image?(blob) + = link_to image_url(blob_url(attachment)), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail - = image_tag(attachment.variant(resize_to_limit: [400, 400]).processed.url, loading: :lazy) + = image_tag(variant_url_for(attachment), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } = 'Visualiser' - else From ffc0ddc446775bde5702a5932fa500dd75941340 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 29 May 2024 11:15:40 +0200 Subject: [PATCH 0342/1532] chore(gallery): add activestorage error catching in front --- app/helpers/gallery_helper.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/helpers/gallery_helper.rb b/app/helpers/gallery_helper.rb index 67068d480..98f7ffc1e 100644 --- a/app/helpers/gallery_helper.rb +++ b/app/helpers/gallery_helper.rb @@ -9,13 +9,19 @@ module GalleryHelper def preview_url_for(attachment) attachment.preview(resize_to_limit: [400, 400]).processed.url + rescue ActiveStorage::Error + 'pdf-placeholder.png' end def variant_url_for(attachment) attachment.variant(resize_to_limit: [400, 400]).processed.url + rescue ActiveStorage::Error + 'apercu-indisponible.png' end def blob_url(attachment) attachment.blob.content_type.in?(RARE_IMAGE_TYPES) ? attachment.variant(resize_to_limit: [2000, 2000]).processed.url : attachment.blob.url + rescue ActiveStorage::Error + attachment.blob.url end end From f6e54a540bcaef229cb28c5bbfeb1236dbc6a1f4 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 29 May 2024 16:08:23 +0200 Subject: [PATCH 0343/1532] chore(gallery): add activestorage error catching in job --- app/jobs/image_processor_job.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/jobs/image_processor_job.rb b/app/jobs/image_processor_job.rb index 798ba86a1..ee914a925 100644 --- a/app/jobs/image_processor_job.rb +++ b/app/jobs/image_processor_job.rb @@ -10,6 +10,10 @@ class ImageProcessorJob < ApplicationJob # (to avoid modifying the file while it is being scanned). retry_on FileNotScannedYetError, wait: :exponentially_longer, attempts: 10 + rescue_from ActiveStorage::PreviewError do + retry_or_discard + end + def perform(blob) return if blob.nil? raise FileNotScannedYetError if blob.virus_scanner.pending? @@ -58,4 +62,14 @@ class ImageProcessorJob < ApplicationJob end end end + + def retry_or_discard + if executions < max_attempts + retry_job wait: 5.minutes + end + end + + def max_attempts + 3 + end end From bb3e53a6fee66c521d1c6af2bc7329ddc2f1cac5 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 6 Jun 2024 11:52:25 +0200 Subject: [PATCH 0344/1532] chore(job): log request_id enqueueing a job --- app/lib/active_job/application_log_subscriber.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/lib/active_job/application_log_subscriber.rb b/app/lib/active_job/application_log_subscriber.rb index 23d31f072..a24b293b7 100644 --- a/app/lib/active_job/application_log_subscriber.rb +++ b/app/lib/active_job/application_log_subscriber.rb @@ -33,6 +33,7 @@ class ActiveJob::ApplicationLogSubscriber < ::ActiveJob::LogSubscriber def process_event(event, type) data = extract_metadata(event) data.merge!(extract_exception(event)) + data[:request_id] = Current.request_id if Current.request_id.present? case type when 'enqueue_at' From 48c092a74a3c339c307cf846690541de1a91d937 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 5 Jun 2024 11:56:29 +0200 Subject: [PATCH 0345/1532] test(gallery): test gallery helper --- .github/workflows/ci.yml | 3 +- spec/helpers/gallery_helper_spec.rb | 57 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 spec/helpers/gallery_helper_spec.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11e97ca81..89d52a61d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,8 @@ jobs: - name: Install build dependancies # - fonts pickable by ImageMagick # - rust for YJIT support - run: sudo apt-get update && sudo apt-get install -y gsfonts rustc redis-server + # - poppler-utils for pdf previews + run: sudo apt-get update && sudo apt-get install -y gsfonts rustc redis-server poppler-utils - name: Setup the app runtime and dependencies uses: ./.github/actions/ci-setup-rails diff --git a/spec/helpers/gallery_helper_spec.rb b/spec/helpers/gallery_helper_spec.rb new file mode 100644 index 000000000..41469798c --- /dev/null +++ b/spec/helpers/gallery_helper_spec.rb @@ -0,0 +1,57 @@ +RSpec.describe GalleryHelper, type: :helper do + let(:procedure) { create(:procedure_with_dossiers) } + let(:type_de_champ_pj) { create(:type_de_champ_piece_justificative, stable_id: 3, libelle: 'Justificatif de domicile', procedure:) } + let(:champ_pj) { create(:champ_piece_justificative, type_de_champ: type_de_champ_pj) } + let(:blob_info) do + { + filename: file.original_filename, + byte_size: file.size, + checksum: Digest::SHA256.file(file.path), + content_type: file.content_type, + # we don't want to run virus scanner on this file + metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE } + } + end + let(:blob) do + blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_info) + blob.upload(file) + blob + end + let(:attachment) { ActiveStorage::Attachment.create(name: "test", blob: blob, record: champ_pj) } + + describe ".variant_url_for" do + subject { variant_url_for(attachment) } + + context "when attachment can be represented with a variant" do + let(:file) { fixture_file_upload('spec/fixtures/files/logo_test_procedure.png', 'image/png') } + + it { expect { subject }.to change { ActiveStorage::VariantRecord.count }.by(1) } + it { is_expected.not_to eq("apercu-indisponible.png") } + end + + context "when attachment cannot be represented with a variant" do + let(:file) { fixture_file_upload('spec/fixtures/files/instructeurs-file.csv', 'text/csv') } + + it { expect { subject }.not_to change { ActiveStorage::VariantRecord.count } } + it { is_expected.to eq("apercu-indisponible.png") } + end + end + + describe ".preview_url_for" do + subject { preview_url_for(attachment) } + + context "when attachment can be represented with a preview" do + let(:file) { fixture_file_upload('spec/fixtures/files/RIB.pdf', 'application/pdf') } + + it { expect { subject }.to change { ActiveStorage::VariantRecord.count }.by(1) } + it { is_expected.not_to eq("pdf-placeholder.png") } + end + + context "when attachment cannot be represented with a preview" do + let(:file) { fixture_file_upload('spec/fixtures/files/instructeurs-file.csv', 'text/csv') } + + it { expect { subject }.not_to change { ActiveStorage::VariantRecord.count } } + it { is_expected.to eq("pdf-placeholder.png") } + end + end +end From e1002beacac8e35280523070e701d47f3bc42f05 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Thu, 11 Apr 2024 09:20:44 +0200 Subject: [PATCH 0346/1532] Met le conteneur au DSFR pour assurer la responsive sur la page contact --- app/views/support/index.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/support/index.html.haml b/app/views/support/index.html.haml index fb97ab53b..99fee2678 100644 --- a/app/views/support/index.html.haml +++ b/app/views/support/index.html.haml @@ -3,8 +3,8 @@ = render partial: "root/footer" #contact-form - .container - %h1.new-h1 + .fr-container + %h1 = t('.contact') = form_tag contact_path, method: :post, multipart: true, class: 'fr-form-group', data: {controller: :support } do From 2ecaee6fe283ad6ca64f24160c603023edfc31f4 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 6 Jun 2024 16:28:35 +0200 Subject: [PATCH 0347/1532] fix(graphql): use null_session forgery protection on graphql controller to allow open data requests --- app/controllers/api/v2/base_controller.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/v2/base_controller.rb b/app/controllers/api/v2/base_controller.rb index 3c7d44e09..f247e2a12 100644 --- a/app/controllers/api/v2/base_controller.rb +++ b/app/controllers/api/v2/base_controller.rb @@ -1,5 +1,11 @@ class API::V2::BaseController < ApplicationController - skip_forgery_protection if: -> { request.headers.key?('HTTP_AUTHORIZATION') } + # This controller is used for API v2 through api endpoint (/api/v2/graphql) + # and through the web interface (/graphql). When used through the web interface, + # we use connected administrateur to authenticate the request. We want CSRF protection + # for the web interface, but not for the API endpoint. :null_session means that when the + # request is not CSRF protected, we will not raise an exception, + # but we will provide the controller with an empty session. + protect_from_forgery with: :null_session skip_before_action :setup_tracking before_action :authenticate_from_token before_action :ensure_authorized_network, if: -> { @api_token.present? } From ae103e049c1baa713d3c333e867842b178ed3a93 Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 6 Jun 2024 17:35:02 +0200 Subject: [PATCH 0348/1532] feat(DossiersController#merci): add download link --- app/views/users/dossiers/_merci.html.haml | 46 ++++++++++++----------- config/locales/en.yml | 1 + config/locales/fr.yml | 1 + 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/app/views/users/dossiers/_merci.html.haml b/app/views/users/dossiers/_merci.html.haml index 383f502d9..b9bb097c6 100644 --- a/app/views/users/dossiers/_merci.html.haml +++ b/app/views/users/dossiers/_merci.html.haml @@ -1,26 +1,30 @@ .merci.text-center.mb-7 - .container - = image_tag('user/envoi-dossier.svg', alt: '', class: 'mt-8') - %h1.mt-4.mb-3.mx-0= t('views.users.dossiers.merci.thanks') - %h2.send.m-2.text-lg - = t('views.users.dossiers.merci.dossier_send_l1') - %strong= procedure.libelle - = t('views.users.dossiers.merci.dossier_send_l2') - %p.m-2 - = t('views.users.dossiers.merci.dossier_acces_l1') - %strong= t('views.users.dossiers.merci.dossier_acces_l2') - %p.m-2 - = t('views.users.dossiers.merci.dossier_edit_l1') - - if !dossier&.read_only? && !procedure.declarative_accepte? && !procedure.sva_svr_enabled? - %strong= t('views.users.dossiers.merci.dossier_edit_l2') - = t('views.users.dossiers.merci.dossier_edit_l3') - %strong= t('views.users.dossiers.merci.dossier_edit_l4') - - if procedure.active_dossier_submitted_message - %p.m-2= procedure.active_dossier_submitted_message.message_on_submit_by_usager + .fr-container + .fr-grid-row.fr-col-offset-md-2.fr-col-md-8 + .fr-col-12 + = image_tag('user/envoi-dossier.svg', alt: '', class: 'mt-8') + %h1.fr-mt-4w.fr-mb-3w.mx-0= t('views.users.dossiers.merci.thanks') + %h2.send.fr-m-2w.text-lg + = t('views.users.dossiers.merci.dossier_send_l1') + %strong= procedure.libelle + = t('views.users.dossiers.merci.dossier_send_l2') + %p.fr-m-2w + = t('views.users.dossiers.merci.dossier_acces_l1') + %strong= t('views.users.dossiers.merci.dossier_acces_l2') + %p.fr-m-2w + = t('views.users.dossiers.merci.dossier_edit_l1') + - if !dossier&.read_only? && !procedure.declarative_accepte? && !procedure.sva_svr_enabled? + %strong= t('views.users.dossiers.merci.dossier_edit_l2') + = t('views.users.dossiers.merci.dossier_edit_l3') + %strong= t('views.users.dossiers.merci.dossier_edit_l4') + - if procedure.active_dossier_submitted_message + %p.fr-m-2= procedure.active_dossier_submitted_message.message_on_submit_by_usager + %p.justify-center.flex.fr-mb-5w.fr-mt-2w + = link_to "#{t('views.users.dossiers.merci.download_dossier')} (PDF)", dossier_path(dossier, format: :pdf), download: "Mon dossier", target: "_blank", rel: "noopener", title: t('views.users.dossiers.show.header.print_dossier'), class: 'fr-btn fr-btn--secondary fr-mx-2w fr-btn--icon-left fr-icon-download-line' + = link_to t('views.users.dossiers.merci.acces_dossier'), dossier ? dossier_path(dossier) : "#dossier" , class: 'fr-btn fr-mx-2w' - .flex.column.align-center - = link_to t('views.users.dossiers.merci.acces_dossier'), dossier ? dossier_path(dossier) : "#dossier" , class: 'fr-btn fr-btn--xl fr-mt-5w' - = link_to t('views.users.dossiers.merci.submit_dossier'), commencer_url(procedure.path), class: 'fr-btn fr-btn--secondary fr-mt-3w' + %hr.fr-hr + = link_to t('views.users.dossiers.merci.submit_dossier'), commencer_url(procedure.path), class: 'fr-btn fr-btn--secondary fr-mt-2w' .monavis != procedure.monavis_embed diff --git a/config/locales/en.yml b/config/locales/en.yml index b8413b1eb..17b7328d0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -444,6 +444,7 @@ en: dossier_edit_l2: edit it dossier_edit_l3: and dossier_edit_l4: talk with an instructor. + download_dossier: Download your file acces_dossier: Access your file submit_dossier: Submit an other file show: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 07486886c..227f89a6e 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -447,6 +447,7 @@ fr: dossier_edit_l2: le modifier dossier_edit_l3: et dossier_edit_l4: échanger avec un instructeur. + download_dossier: Télécharger mon dossier acces_dossier: Accéder à votre dossier submit_dossier: Déposer un autre dossier show: From f106e558c058a9ca48f93e9599bf4448e2b5bfa7 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Fri, 7 Jun 2024 12:25:30 +0200 Subject: [PATCH 0349/1532] pour le site nb_source=site --- app/views/users/dossiers/_merci.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/users/dossiers/_merci.html.haml b/app/views/users/dossiers/_merci.html.haml index 12bd195f6..9081ee58a 100644 --- a/app/views/users/dossiers/_merci.html.haml +++ b/app/views/users/dossiers/_merci.html.haml @@ -27,4 +27,4 @@ %strong= t('views.users.dossiers.merci.jdma_l1') %p= t('views.users.dossiers.merci.jdma_l2') - != procedure.monavis_embed + != procedure.monavis_embed.gsub('nd_source=button', 'nd_source=site') From 0f1c1302a9dff0a354d69809fcf41e587a1ab2f1 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Fri, 7 Jun 2024 12:32:55 +0200 Subject: [PATCH 0350/1532] =?UTF-8?q?on=20affiche=20uniquement=20si=20JDMA?= =?UTF-8?q?=20activ=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/users/dossiers/_merci.html.haml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/views/users/dossiers/_merci.html.haml b/app/views/users/dossiers/_merci.html.haml index 9081ee58a..955f7fdf8 100644 --- a/app/views/users/dossiers/_merci.html.haml +++ b/app/views/users/dossiers/_merci.html.haml @@ -22,9 +22,9 @@ = link_to t('views.users.dossiers.merci.acces_dossier'), dossier ? dossier_path(dossier) : "#dossier" , class: 'fr-btn fr-btn--xl fr-mt-5w' = link_to t('views.users.dossiers.merci.submit_dossier'), commencer_url(procedure.path), class: 'fr-btn fr-btn--secondary fr-mt-3w fr-mb-2w' - .monavis - %p.fr-mt-5w.fr-mb-1w - %strong= t('views.users.dossiers.merci.jdma_l1') - %p= t('views.users.dossiers.merci.jdma_l2') - - != procedure.monavis_embed.gsub('nd_source=button', 'nd_source=site') + - if procedure.monavis_embed + .monavis + %p.fr-mt-5w.fr-mb-1w + %strong= t('views.users.dossiers.merci.jdma_l1') + %p= t('views.users.dossiers.merci.jdma_l2') + != procedure.monavis_embed.gsub('nd_source=button', 'nd_source=site') From a9b56459c83eb2611e309839d899a16e612349cf Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Fri, 7 Jun 2024 12:52:27 +0200 Subject: [PATCH 0351/1532] on fait ouvrir dans un nouvel onglet --- app/views/users/dossiers/_merci.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/users/dossiers/_merci.html.haml b/app/views/users/dossiers/_merci.html.haml index 955f7fdf8..c25957a48 100644 --- a/app/views/users/dossiers/_merci.html.haml +++ b/app/views/users/dossiers/_merci.html.haml @@ -27,4 +27,4 @@ %p.fr-mt-5w.fr-mb-1w %strong= t('views.users.dossiers.merci.jdma_l1') %p= t('views.users.dossiers.merci.jdma_l2') - != procedure.monavis_embed.gsub('nd_source=button', 'nd_source=site') + != procedure.monavis_embed.gsub('nd_source=button', 'nd_source=site').gsub(' Date: Fri, 7 Jun 2024 06:23:52 +0200 Subject: [PATCH 0352/1532] feat(EmailChecker.check): add class to search for typo in email addresses --- Gemfile | 1 + Gemfile.lock | 2 + app/lib/email_checker.rb | 649 +++++++++++++++++++++++++++++++++ spec/lib/email_checker_spec.rb | 36 ++ 4 files changed, 688 insertions(+) create mode 100644 app/lib/email_checker.rb create mode 100644 spec/lib/email_checker_spec.rb diff --git a/Gemfile b/Gemfile index 8d78db84d..9a9ff1600 100644 --- a/Gemfile +++ b/Gemfile @@ -95,6 +95,7 @@ gem 'sidekiq' gem 'sidekiq-cron' gem 'skylight' gem 'spreadsheet_architect' +gem 'string-similarity' gem 'strong_migrations' # lint database migrations gem 'sys-proctable' gem 'turbo-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 51281c712..98bde51e9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -765,6 +765,7 @@ GEM activesupport (>= 5.2) sprockets (>= 3.0.0) stackprof (0.2.26) + string-similarity (2.1.0) stringio (3.1.0) strong_migrations (1.8.0) activerecord (>= 5.2) @@ -1013,6 +1014,7 @@ DEPENDENCIES spring spring-commands-rspec stackprof + string-similarity strong_migrations sys-proctable timecop diff --git a/app/lib/email_checker.rb b/app/lib/email_checker.rb new file mode 100644 index 000000000..97fa9d803 --- /dev/null +++ b/app/lib/email_checker.rb @@ -0,0 +1,649 @@ +class EmailChecker + KNOWN_DOMAINS = [ + 'gmail.com', + 'hotmail.fr', + 'orange.fr', + 'yahoo.fr', + 'hotmail.com', + 'outlook.fr', + 'wanadoo.fr', + 'free.fr', + 'yahoo.com', + 'icloud.com', + 'laposte.net', + 'live.fr', + 'sfr.fr', + 'outlook.com', + 'neuf.fr', + 'aol.com', + 'bbox.fr', + 'msn.com', + 'me.com', + 'gmx.fr', + 'protonmail.com', + 'club-internet.fr', + 'live.com', + 'ymail.com', + 'ars.sante.fr', + 'mail.ru', + 'cegetel.net', + 'numericable.fr', + 'aliceadsl.fr', + 'comcast.net', + 'assurance-maladie.fr', + 'mac.com', + 'naver.com', + 'airbus.com', + 'justice.fr', + 'pole-emploi.fr', + 'educagri.fr', + 'aphp.fr', + 'netcourrier.com', + 'dbmail.com', + 'aol.fr', + 'qq.com', + 'hotmail.co.uk', + 'yahoo.co.uk', + 'proxima-mail.fr', + 'yahoo.com.br', + 'sciencespo.fr', + 'gmx.com', + 'etu.univ-st-etienne.fr', + 'yahoo.ca', + '163.com', + 'francetravail.fr', + 'mail.pf', + 'nantesmetropole.fr', + 'hotmail.it', + 'sbcglobal.net', + 'noos.fr', + 'ird.fr', + 'safrangroup.com', + 'croix-rouge.fr', + 'eiffage.com', + 'veolia.com', + 'notaires.fr', + 'nordnet.fr', + 'videotron.ca', + 'paris.fr', + 'lilo.org', + 'mfr.asso.fr', + 'yopmail.com', + 'ukr.net', + 'onf.fr', + 'stellantis.com', + '9online.fr', + 'atmp50.fr', + 'engie.com', + 'libertysurf.fr', + 'mailo.com', + 'auchan.fr', + 'verizon.net', + 'rocketmail.com', + 'mpsa.com', + 'entrepreneur.fr', + 'googlemail.com', + 'arcelormittal.com', + 'groupe-sos.org', + 'proton.me', + 'att.net', + 'pm.me', + 'orange.com', + 'abv.bg', + 'yahoo.es', + 'creditmutuel.fr', + 'yandex.ru', + 'essec.edu', + 'urssaf.fr', + 'bpifrance.fr', + 'uol.com.br', + 'suez.com', + 'univ-st-etienne.fr', + 'korian.fr', + 'developpement-durable.gouv.fr', + 'modernisation.gouv.fr', + 'social.gouv.fr', + 'emploi.gouv.fr', + 'agriculture.gouv.fr', + 'intradef.gouv.fr', + 'interieur.gouv.fr', + 'oise.gouv.fr', + 'direccte.gouv.fr', + 'culture.gouv.fr', + 'pas-de-calais.gouv.fr', + 'finances.gouv.fr', + 'drieets.gouv.fr', + 'drjscs.gouv.fr', + 'sg.social.gouv.fr', + 'martinique.pref.gouv.fr', + 'beta.gouv.fr', + 'dieccte.gouv.fr', + 'cotes-darmor.gouv.fr', + 'vosges.gouv.fr', + 'developppement-durable.gouv.fr', + 'mayenne.gouv.fr', + 'aviation-civile.gouv.fr', + 'data.gouv.fr', + 'recherche.gouv.fr', + 'sante.gouv.fr', + 'paris-idf.gouv.fr', + 'guyane.gouv.fr', + 'douane.finances.gouv.fr', + 'cget.gouv.fr', + 'herault.gouv.fr', + 'loire-atlantique.gouv.fr', + 'manche.gouv.fr', + 'seine-maritime.gouv.fr', + 'dgccrf.finances.gouv.fr', + 'tarn-et-garonne.gouv.fr', + 'dila.gouv.fr', + 'diplomatie.gouv.fr', + 'haut-rhin.gouv.fr', + 'nord.gouv.fr', + 'bouches-du-rhone.gouv.fr', + 'alpes-de-haute-provence.gouv.fr', + 'hautes-alpes.gouv.fr', + 'alpes-maritimes.gouv.fr', + 'var.gouv.fr', + 'vaucluse.gouv.fr', + 'rhone.gouv.fr', + 'occitanie.gouv.fr', + 'ille-et-vilaine.gouv.fr', + 'finistere.gouv.fr', + 'aisne.gouv.fr', + 'indre.gouv.fr', + 'yvelines.gouv.fr', + 'bas-rhin.gouv.fr', + 'landes.gouv.fr', + 'haute-marne.gouv.fr', + 'correze.gouv.fr', + 'val-doise.gouv.fr', + 'seine-et-marne.gouv.fr', + 'essonne.gouv.fr', + 'calvados.gouv.fr', + 'charente-maritime.gouv.fr', + 'corse-du-sud.gouv.fr', + 'gironde.gouv.fr', + 'haute-corse.gouv.fr', + 'morbihan.gouv.fr', + 'pyrenees-atlantiques.gouv.fr', + 'pyrenees-orientales.gouv.fr', + 'somme.gouv.fr', + 'vendee.gouv.fr', + 'dgtresor.gouv.fr', + 'marne.gouv.fr', + 'auvergne-rhone-alpes.gouv.fr', + 'meurthe-et-moselle.gouv.fr', + 'pm.gouv.fr', + 'oncfs.gouv.fr', + 'orne.gouv.fr', + 'charente.gouv.fr', + 'travail.gouv.fr', + 'gard.gouv.fr', + 'maine-et-loire.gouv.fr', + 'moselle.gouv.fr', + 'outre-mer.gouv.fr', + 'jscs.gouv.fr', + 'haute-garonne.gouv.fr', + 'vienne.gouv.fr', + 'dordogne.gouv.fr', + 'eure.gouv.fr', + 'meuse.gouv.fr', + 'savoie.gouv.fr', + 'doubs.gouv.fr', + 'bfc.gouv.fr', + 'education.gouv.fr', + 'ariege.gouv.fr', + 'normandie.gouv.fr', + 'gendarmerie.interieur.gouv.fr', + 'ain.gouv.fr', + 'ardennes.gouv.fr', + 'drome.gouv.fr', + 'bretagne.gouv.fr', + 'paca.gouv.fr', + 'haute-saone.gouv.fr', + 'lot.gouv.fr', + 'dgfip.finances.gouv.fr', + 'aveyron.gouv.fr', + 'gers.gouv.fr', + 'tarn.gouv.fr', + 'aude.gouv.fr', + 'lozere.gouv.fr', + 'hautes-pyrenees.gouv.fr', + 'jeunesse-sports.gouv.fr', + 'alpes.maritimes.gouv.fr', + 'dreets.gouv.fr', + 'justice.gouv.fr', + 'sports.gouv.fr', + 'nouvelle-aquitaine.gouv.fr', + 'jura.gouv.fr', + 'haute-savoie.gouv.fr', + 'creuse.gouv.fr', + 'creps-poitiers.sports.gouv.fr', + 'equipement-agriculture.gouv.fr', + 'ira-metz.gouv.fr', + 'loire.gouv.fr', + 'defense.gouv.fr', + 'paris.gouv.fr', + 'ensm.sports.gouv.fr', + 'isere.gouv.fr', + 'haute-loire.gouv.fr', + 'cantal.gouv.fr', + 'lot-et-garonne.gouv.fr', + 'reunion.pref.gouv.fr', + 'loiret.gouv.fr', + 'indre-et-loire.gouv.fr', + 'eleve.ira-metz.gouv.fr', + 'deux-sevres.gouv.fr', + 'inao.gouv.fr', + 'franceconnect.gouv.fr', + 'essone.gouv.fr', + 'workinfrance.beta.gouv.fr', + 'seine-saint-denis.gouv.fr', + 'val-de-marne.gouv.fr', + 'morbihan.pref.gouv.fr', + 'externes.justice.gouv.fr', + 'haute-vienne.gouv.fr', + 'territoire-de-belfort.gouv.fr', + 'creps-reunion.sports.gouv.fr', + 'creps-centre.sports.gouv.fr', + 'creps-rhonealpes.sports.gouv.fr', + 'creps-montpellier.sports.gouv.fr', + 'nord.pref.gouv.fr', + 'charente-maritime.pref.gouv.fr', + 'cher.gouv.fr', + 'cote-dor.gouv.fr', + 'ssi.gouv.fr', + 'ira.gouv.fr', + 'pays-de-la-loire.gouv.fr', + 'loir-et-cher.gouv.fr', + 'saone-et-loire.gouv.fr', + 'enseignementsup.gouv.fr', + 'eure-et-loir.gouv.fr', + 'yonne.gouv.fr', + 'guadeloupe.pref.gouv.fr', + 'centre-val-de-loire.gouv.fr', + 'entreprise.api.gouv.fr', + 'grand-est.gouv.fr', + 'sarthe.gouv.fr', + 'sarthe.pref.gouv.fr', + 'puy-de-dome.gouv.fr', + 'externes.sante.gouv.fr', + 'allier.gouv.fr', + 'aube.gouv.fr', + 'nievre.gouv.fr', + 'ardeche.gouv.fr', + 'api.gouv.fr', + 'hauts-de-seine.gouv.fr', + 'hauts-de-france.gouv.fr', + 'temp-beta.gouv.fr', + 'def.gouv.fr', + 'particulier.api.gouv.fr', + 'ira-lille.gouv.fr', + 'haute-saone.pref.gouv.fr', + 'yvelines.pref.gouv.fr', + 'sgg.pm.gouv.fr', + 'anah.gouv.fr', + 'corse.gouv.fr', + 'mayenne.pref.gouv.fr', + 'cote-dor.pref.gouv.fr', + 'guyane.pref.gouv.fr', + 'ira-nantes.gouv.fr', + 'igas.gouv.fr', + 'tarn.pref.gouv.fr', + 'martinique.gouv.fr', + 'creps-paca.sports.gouv.fr', + 'ofb.gouv.fr', + 'loir-et-cher.pref.gouv.fr', + 'indre-et-loire.pref.gouv.fr', + 'polynesie-francaise.pref.gouv.fr', + 'scl.finances.gouv.fr', + 'numerique.gouv.fr', + 'cantal.pref.gouv.fr', + 'territoire-de-belfort.pref.gouv.fr', + 'creps-wattignies.sports.gouv.fr', + 'vienne.pref.gouv.fr', + 'ardennes.pref.gouv.fr', + 'creps-strasbourg.sports.gouv.fr', + 'creps-dijon.sports.gouv.fr', + 'ara.gouv.fr', + 'sgdsn.gouv.fr', + 'pays-de-la-loire.pref.gouv.fr', + 'anct.gouv.fr', + 'creps-pap.sports.gouv.fr', + 'sgae.gouv.fr', + 'esnm.sports.gouv.fr', + 'nouvelle-caledonie.gouv.fr', + 'deets.gouv.fr', + 'mayotte.gouv.fr', + 'creps-bordeaux.sports.gouv.fr', + 'civs.gouv.fr', + 'iga.interieur.gouv.fr', + 'cab.travail.gouv.fr', + 'ira-bastia.gouv.fr', + 'ira-lyon.gouv.fr', + 'creps-lorraine.sports.gouv.fr', + 'dihal.gouv.fr', + 'ofpra.gouv.fr', + 'mayotte.pref.gouv.fr', + 'strategie.gouv.fr', + 'territoires.gouv.fr', + 'dgcl.gouv.fr', + 'doubs.pref.gouv.fr', + 'service-civique.gouv.fr', + 'maine-et-loire.pref.gouv.fr', + 'envsn.sports.gouv.fr', + 'wallis-et-futuna.pref.gouv.fr', + 'gendarmerie.defense.gouv.fr', + 'anlci.gouv.fr', + 'cabinets.finances.gouv.fr', + 'seine-maritime.pref.gouv.fr', + 'promo46.ira-metz.gouv.fr', + 'aisne.pref.gouv.fr', + 'sportsdenature.gouv.fr', + 'loire-atlantique.pref.gouv.fr', + 'aude.pref.gouv.fr', + 'premier-ministre.gouv.fr', + 'igf.finances.gouv.fr', + 'eleves.ira-bastia.gouv.fr', + 'igesr.gouv.fr', + 'alpc.gouv.fr', + 'externes.emploi.gouv.fr', + 'prestataire.finances.gouv.fr', + 'gironde.pref.gouv.fr', + 'premar-atlantique.gouv.fr', + 'creps-toulouse.sports.gouv.fr', + 'guadeloupe.gouv.fr', + 'cybermalveillance.gouv.fr', + 'dicod.defense.gouv.fr', + 'creps-vichy.sports.gouv.fr', + 'aft.gouv.fr', + 'equipement.gouv.fr', + 'academie.defense.gouv.fr', + 'aube.pref.gouv.fr', + 'seine-et-marne.pref.gouv.fr', + 'pyrenees-orientales.pref.gouv.fr', + 'haute-garonne.pref.gouv.fr', + 'haut-rhin.pref.gouv.fr', + 'seine-saint-denis.pref.gouv.fr', + 'dcstep.gouv.fr', + 'promo47.ira-metz.gouv.fr', + 'trackdechets.beta.gouv.fr', + 'val-de-marne.pref.gouv.fr', + 'fabrique.social.gouv.fr', + 'agrasc.gouv.fr', + 'indre.pref.gouv.fr', + 'tarn-et-garonne.pref.gouv.fr', + 'corse.pref.gouv.fr', + 'bas-rhin.pref.gouv.fr', + 'inclusion.beta.gouv.fr', + 'hauts-de-seine.pref.gouv.fr', + 'loiret.pref.gouv.fr', + 'essonne.pref.gouv.fr', + 'territoires-industrie.gouv.fr', + 'spm975.gouv.fr', + 'saint-barth-saint-martin.gouv.fr', + 'judiciaire.interieur.gouv.fr', + 'mer.gouv.fr', + 'premar-manche.gouv.fr', + 'haute-normandie.pref.gouv.fr', + 'prestataire.modernisation.gouv.fr', + 'covoiturage.beta.gouv.fr', + 'promo48.ira-metz.gouv.fr', + 'france-services.gouv.fr', + 'ddets.gouv.fr', + 'afa.gouv.fr', + 'externes.social.gouv.fr', + 'vosges.pref.gouv.fr', + 'reunion.gouv.fr', + 'rhone.pref.gouv.fr', + 'alpes-maritimes.pref.gouv.fr', + 'gard.pref.gouv.fr', + 'oise.pref.gouv.fr', + 'creps-reims.sports.gouv.fr', + 'bouches-du-rhone.pref.gouv.fr', + 'esante.gouv.fr', + 'rhone-alpes.pref.gouv.fr', + 'finistere.pref.gouv.fr', + 'ops-bss.defense.gouv.fr', + 'orne.pref.gouv.fr', + 'transformation.gouv.fr', + 'cbcm.social.gouv.fr', + 'recosante.beta.gouv.fr', + 'pas-de-calais.pref.gouv.fr', + 'promo49.ira-metz.gouv.fr', + 'paca.pref.gouv.fr', + 'meurthe-et-moselle.pref.gouv.fr', + 'externes.sg.social.gouv.fr', + 'puy-de-dome.pref.gouv.fr', + 'academie.def.gouv.fr', + 'tarn.gouv.frd81intranet.ddcspp.tarn.gouv.fr', + 'agriculture-equipement.gouv.fr', + 'creps-idf.sports.gouv.fr', + 'eleve.ira-nantes.gouv.fr', + 'cohesion-territoires.gouv.fr', + 'ariege.pref.gouv.fr', + 'pyrenees-atlantiques.pref.gouv.fr', + 'hautes-pyrenees.pref.gouv.fr', + 'lot-et-garonne.pref.gouv.fr', + 'loire.pref.gouv.fr', + 'info-routiere.gouv.fr', + 'diges.gouv.fr', + 'insp.gouv.fr', + 'creps-pdl.sports.gouv.fr', + 'ddc.social.gouv.fr', + 'eleve.insp.gouv.fr', + 'val-doise.pref.gouv.fr', + 'montsaintmichel.gouv.fr', + 'st-cyr.terre-net.defense.gouv.fr', + '.finances.gouv.fr', + 'logement.gouv.fr', + 'cotes-darmor.pref.gouv.fr', + 'marne.pref.gouv.fr', + 'herault.pref.gouv.fr', + 'viennne.gouv.fr', + 'landes.pref.gouv.fr', + 'moselle.pref.gouv.fr', + 'saone-et-loire.pref.gouv.fr', + 'bmpm.gouv.fr', + 'ecologie-territoires.gouv.fr', + 'nievre.pref.gouv.fr', + 'hautes-pyrénées.gouv.fr', + 'gic.gouv.fr', + 'industrie.gouv.fr', + 'lot.pref.gouv.fr', + 'plan.gouv.fr', + 'internet.gouv.fr', + 'mesads.beta.gouv.fr', + 'gers.pref.gouv.fr', + 'dordogne.pref.gouv.fr', + 'somme.pref.gouv.fr', + 'datasubvention.beta.gouv.fr', + 'anc.gouv.fr', + 'premar-mediterranee.gouv.fr', + 'ille-et-vilaine.pref.gouv.fr', + 'eure-et-loir.pref.gouv.fr', + 'prestataires.pm.gouv.fr', + 'snu.gouv.fr', + 'code.gouv.fr', + 'alsace.pref.gouv.fr', + 'haute-vienne.pref.gouv.fr', + 'yonne.pref.gouv.fr', + 'bretagne.pref.gouv.fr', + 'mastere.insp.gouv.fr', + 'cada.pm.gouv.fr', + 'creuse.pref.gouv.fr', + 'ecologie.gouv.fr', + 'midi-pyrenees.pref.gouv.fr', + 'promo54.ira-metz.gouv.fr', + 'var.pref.gouv.fr', + 'alpes-de-haute-provence.pref.gouv.fr', + 'mail.numerique.gouv.fr', + 'france-identite.gouv.fr', + 'transport.data.gouv.fr', + 'allier.pref.gouv.fr', + 'dilhal.gouv.fr', + 'ardeche.pref.gouv.fr', + 'haute-corse.pref.gouv.fr', + 'intérieur.gouv.fr', + 'ddfip.gouv.fr', + 'calvados.pref.gouv.fr', + 'territoir-de-belfort.gouv.fr', + 'nor.gouv.fr', + 'creps-occitanie.sports.gouv.fr', + 'developpement-durabe.gouv.fr', + 'educ.nat.gouv.fr', + 'developpement-duable.gouv.fr', + 'dgfip.finanes.gouv.fr', + 'loire-atlantqieu.gouv.fr', + 'promo55.ira-metz.gouv.fr', + 'haute-saône.gouv.fr', + 'developpement.durable.gouv.fr', + 'dreet.gouv.fr', + 'miprof.gouv.fr', + 'pref.guyane.gouv.fr', + 'developpement.gouv.fr', + 'gendamrerie.interieur.gouv.fr', + 'pyrenees-atlantique.gouv.fr', + 'apprentissage.beta.gouv.fr', + 'yveliens.gouv.fr', + 'justiice.gouv.fr', + 'cutlure.gouv.fr', + 'aidantsconnect.beta.gouv.fr', + 'developpement-durbale.gouv.fr', + 'sine-et-marne.gouv.fr', + 'sociale.gouv.fr', + 'develeoppement-durable.gouv.fr', + 'draaf.gouv.fr', + 'drets.gouv.fr', + 'ancli.gouv.fr', + 'finistrere.gouv.fr', + 'bourgogne.pref.gouv.fr', + 'ac-polynesie.pf', + 'ac-lille.fr', + 'ac-nantes.fr', + 'ac-martinique.fr', + 'ac-creteil.fr', + 'ac-toulouse.fr', + 'ac-amiensfr', + 'ac-amiens.fr', + 'ac-rennes.fr', + 'ac-strasbourg.fr', + 'ac-lyon.fr', + 'ac-versailles.fr', + 'ac-audit.fr', + 'ac-rouen.fr', + 'ac-reunion.fr', + 'ac-poitiers.fr', + 'ac-caen.fr', + 'ac-montpellier.fr', + 'ac-paris.fr', + 'ac-besancon.fr', + 'ac-nancy-metz.fr', + 'ac-aix-marseille.fr', + 'ac-grenoble.fr', + 'ac-corse.fr', + 'ac-nice.fr', + 'ac-orleans-tours.fr', + 'ac-guadeloupe.fr', + 'ac-reims.fr', + 'ac-mayotte.fr', + 'ac-clermont.fr', + 'ac-bordeaux.fr', + 'ac-limoges.fr', + 'ac-normandie.fr', + 'ac-dijon.fr', + 'ac-guyane.fr', + 'ac-transports.fr', + 'ac-arpajonnais.com', + 'ac-cned.fr', + 'ac-nettoyage.com', + 'ac-architectes.fr', + 'ac-ajaccio.corsica', + 'ac-noumea.nc', + 'ac-spm.fr', + 'ac-versailes.fr', + 'ac-polynesie.fr', + 'ac-experts.fr', + 'ac-creteil.com', + 'ac-smart-relocation.com', + 'ac-ec.pro', + 'ac-sas.fr', + 'ac-derma.de', + 'ac-or.com', + 'ac-baugeois.fr', + 'ac-5.ru', + 'ac-arles.fr', + 'ac-holding.net', + 'ac-mb.fr', + 'ac-wf.wf', + 'ac-brest-finistere.fr', + 'ac-leman.com', + 'ac-darboussier.fr', + 'ac-si.fr', + 'ac-bordeau.fr', + 'ac-gatinais.com', + 'ac-cheminots.fr', + 'ac-seyssinet.com', + 'ac-cannes.fr', + 'ac-prev.com', + 'ac-sologne.fr', + 'ac-rennes', + 'ac-courbevoie.com', + 'ac-ce.fr', + 'ac-architecte.fr', + 'ac-tions.org', + 'ac-pm.fr', + 'ac-avocats.com', + 'ac-talents-rh.com', + 'ac-louis.com', + 'ac-internet.fr', + 'ac-toulouse.com', + 'ac-escial.fr', + 'ac-environnement.com', + 'ac-academie.fr', + 'ac-poiters.fr', + 'ac-bordeux.fr', + 'ac-verseilles.fr', + 'ac-ais-marseille.fr', + 'ac-horizon.fr', + 'ac-bordeaux.ft', + 'ac-toulouses.fr', + 'ac-toulous.fr' + ].freeze + + def self.check(email:) + return { success: false } if email.blank? + + parsed_email = Mail::Address.new(email) + return { success: false } if parsed_email.domain.blank? + + return { success: true } if KNOWN_DOMAINS.any? { _1 == parsed_email.domain } + + similar_domains = closest_domains(domain: parsed_email.domain) + return { success: true } if similar_domains.empty? + + { success: true, email_suggestions: email_suggestions(parsed_email:, similar_domains:) } + end + + private + + def self.closest_domains(domain:) + KNOWN_DOMAINS.filter do |known_domain| + close_by_distance_of(domain, known_domain, distance: 1) || + with_same_chars_and_close_by_distance_of(domain, known_domain, distance: 2) + end + end + + def self.close_by_distance_of(a, b, distance:) + String::Similarity.levenshtein_distance(a, b) == distance + end + + def self.with_same_chars_and_close_by_distance_of(a, b, distance:) + close_by_distance_of(a, b, distance: 2) && a.chars.sort == b.chars.sort + end + + def self.email_suggestions(parsed_email:, similar_domains:) + similar_domains.map { Mail::Address.new("#{parsed_email.local}@#{_1}").to_s } + end +end diff --git a/spec/lib/email_checker_spec.rb b/spec/lib/email_checker_spec.rb new file mode 100644 index 000000000..cfcf73bfa --- /dev/null +++ b/spec/lib/email_checker_spec.rb @@ -0,0 +1,36 @@ +describe EmailChecker do + describe 'check' do + subject { described_class } + + it 'works with identified use cases' do + expect(subject.check(email: nil)).to eq({ success: false }) + expect(subject.check(email: '')).to eq({ success: false }) + expect(subject.check(email: 'panpan')).to eq({ success: false }) + + # allow same domain + expect(subject.check(email: "martin@orange.fr")).to eq({ success: true }) + # find difference of 1 lev distance + expect(subject.check(email: "martin@orane.fr")).to eq({ success: true, email_suggestions: ['martin@orange.fr'] }) + # find difference of 2 lev distance, only with same chars + expect(subject.check(email: "martin@oragne.fr")).to eq({ success: true, email_suggestions: ['martin@orange.fr'] }) + # ignore unknown domain + expect(subject.check(email: "martin@ore.fr")).to eq({ success: true }) + end + + it 'passes through real use cases, with levenshtein_distance 1' do + expect(subject.check(email: "martin@asn.com")).to eq({ success: true, email_suggestions: ['martin@msn.com'] }) + expect(subject.check(email: "martin@gamail.com")).to eq({ success: true, email_suggestions: ['martin@gmail.com'] }) + expect(subject.check(email: "martin@glail.com")).to eq({ success: true, email_suggestions: ['martin@gmail.com'] }) + expect(subject.check(email: "martin@gmail.coml")).to eq({ success: true, email_suggestions: ['martin@gmail.com'] }) + expect(subject.check(email: "martin@gmail.con")).to eq({ success: true, email_suggestions: ['martin@gmail.com'] }) + expect(subject.check(email: "martin@hotmil.fr")).to eq({ success: true, email_suggestions: ['martin@hotmail.fr'] }) + expect(subject.check(email: "martin@mail.com")).to eq({ success: true, email_suggestions: ["martin@gmail.com", "martin@ymail.com", "martin@mailo.com"] }) + expect(subject.check(email: "martin@msc.com")).to eq({ success: true, email_suggestions: ["martin@msn.com", "martin@mac.com"] }) + expect(subject.check(email: "martin@ymail.com")).to eq({ success: true }) + end + + it 'passes through real use cases, with levenshtein_distance 2, must share all chars' do + expect(subject.check(email: "martin@oise.fr")).to eq({ success: true }) # could be live.fr + end + end +end From 66eb3dc821d924f9a3143398ad2263b3b54c2485 Mon Sep 17 00:00:00 2001 From: mfo Date: Fri, 7 Jun 2024 10:06:40 +0200 Subject: [PATCH 0353/1532] feat(email_check): change strategy to check email, dropping email_buttler package and using a custom EmailChecker --- app/components/dsfr/input_component.rb | 2 +- app/controllers/email_checker_controller.rb | 5 +++ .../controllers/email_input_controller.ts | 31 +++++++++++--- bun.lockb | Bin 502268 -> 501940 bytes config/routes.rb | 1 + package.json | 1 - .../email_checker_controller_spec.rb | 39 ++++++++++++++++++ 7 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 app/controllers/email_checker_controller.rb create mode 100644 spec/controllers/email_checker_controller_spec.rb diff --git a/app/components/dsfr/input_component.rb b/app/components/dsfr/input_component.rb index 3ee07149b..367ed74b0 100644 --- a/app/components/dsfr/input_component.rb +++ b/app/components/dsfr/input_component.rb @@ -32,7 +32,7 @@ class Dsfr::InputComponent < ApplicationComponent }.merge(input_group_error_class_names)) } if email? - opts[:data] = { controller: 'email-input' } + opts[:data] = { controller: 'email-input', email_input_url_value: show_email_suggestions_path } end opts end diff --git a/app/controllers/email_checker_controller.rb b/app/controllers/email_checker_controller.rb new file mode 100644 index 000000000..b794b4d7a --- /dev/null +++ b/app/controllers/email_checker_controller.rb @@ -0,0 +1,5 @@ +class EmailCheckerController < ApplicationController + def show + render json: EmailChecker.check(email: params[:email]) + end +end diff --git a/app/javascript/controllers/email_input_controller.ts b/app/javascript/controllers/email_input_controller.ts index 8eed97fa9..8b64a7e92 100644 --- a/app/javascript/controllers/email_input_controller.ts +++ b/app/javascript/controllers/email_input_controller.ts @@ -1,18 +1,39 @@ -import { suggest } from 'email-butler'; +import { httpRequest } from '@utils'; import { show, hide } from '@utils'; import { ApplicationController } from './application_controller'; +type checkEmailResponse = { + success: boolean; + email_suggestions: string[]; +}; + export class EmailInputController extends ApplicationController { static targets = ['ariaRegion', 'suggestion', 'input']; + static values = { + url: String + }; + + declare readonly urlValue: string; + declare readonly ariaRegionTarget: HTMLElement; declare readonly suggestionTarget: HTMLElement; declare readonly inputTarget: HTMLInputElement; - checkEmail() { - const suggestion = suggest(this.inputTarget.value); - if (suggestion && suggestion.full) { - this.suggestionTarget.innerHTML = suggestion.full; + async checkEmail() { + if (!this.inputTarget.value) { + return; + } + + const url = new URL(this.urlValue, document.baseURI); + url.searchParams.append('email', this.inputTarget.value); + + const data: checkEmailResponse | null = await httpRequest( + url.toString() + ).json(); + + if (data && data.email_suggestions && data.email_suggestions.length > 0) { + this.suggestionTarget.innerHTML = data.email_suggestions[0]; show(this.ariaRegionTarget); this.ariaRegionTarget.setAttribute('aria-live', 'assertive'); } diff --git a/bun.lockb b/bun.lockb index 9cc8b3bb356a97e0f908f76bd947b656fda4a8ab..7c777a1117455aa446b2e741a83ac6e465d43f38 100755 GIT binary patch delta 86005 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 Pg5Km5G8Gr5*!I5wC7i}^ delta 86386 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 diff --git a/config/routes.rb b/config/routes.rb index c4973d1ae..8b5f2152e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -161,6 +161,7 @@ Rails.application.routes.draw do end get 'password_complexity' => 'password_complexity#show', as: 'show_password_complexity' + get 'check_email' => 'email_checker#show', as: 'show_email_suggestions' resources :targeted_user_links, only: [:show] diff --git a/package.json b/package.json index 8d5144609..edad7ebc6 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "core-js": "^3.37.1", "date-fns": "^2.30.0", "debounce": "^1.2.1", - "email-butler": "^1.0.13", "geojson": "^0.5.0", "graphiql": "^3.2.3", "graphql": "^16.8.1", diff --git a/spec/controllers/email_checker_controller_spec.rb b/spec/controllers/email_checker_controller_spec.rb new file mode 100644 index 000000000..4572c2cd4 --- /dev/null +++ b/spec/controllers/email_checker_controller_spec.rb @@ -0,0 +1,39 @@ +describe EmailCheckerController, type: :controller do + describe '#show' do + render_views + before { get :show, format: :json, params: params } + let(:body) { JSON.parse(response.body, symbolize_names: true) } + + context 'valid email' do + let(:params) { { email: 'martin@orange.fr' } } + it do + expect(response).to have_http_status(:success) + expect(body).to eq({ success: true }) + end + end + + context 'email with typo' do + let(:params) { { email: 'martin@orane.fr' } } + it do + expect(response).to have_http_status(:success) + expect(body).to eq({ success: true, email_suggestions: ['martin@orange.fr'] }) + end + end + + context 'empty' do + let(:params) { { email: '' } } + it do + expect(response).to have_http_status(:success) + expect(body).to eq({ success: false }) + end + end + + context 'notanemail' do + let(:params) { { email: 'clarkkent' } } + it do + expect(response).to have_http_status(:success) + expect(body).to eq({ success: false }) + end + end + end +end From d7a19bd421201eaf554e35f9904d745892e15c0f Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Fri, 7 Jun 2024 18:20:28 +0200 Subject: [PATCH 0354/1532] ajout du JDMA au mail de depot de dossier (avec nb_source=email) --- app/mailers/notification_mailer.rb | 7 +++++++ app/views/layouts/mailers/_jdma.html.haml | 10 ++++++++++ .../notification_mailer/send_notification.html.haml | 3 +++ 3 files changed, 20 insertions(+) create mode 100644 app/views/layouts/mailers/_jdma.html.haml diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 4459e741d..276c48911 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -8,6 +8,7 @@ class NotificationMailer < ApplicationMailer before_action :set_dossier, except: [:send_notification_for_tiers, :send_accuse_lecture_notification] before_action :set_services_publics_plus, only: :send_notification + before_action :set_jdma, only: :send_notification helper ServiceHelper helper MailerHelper @@ -88,6 +89,12 @@ class NotificationMailer < ApplicationMailer @services_publics_plus_url = ENV['SERVICES_PUBLICS_PLUS_URL'].presence end + def set_jdma + return unless params[:state] == Dossier.states.fetch(:en_construction) + + @jdma_html = @dossier.procedure.monavis_embed.presence + end + def set_dossier @dossier = params[:dossier] configure_defaults_for_user(@dossier.user) diff --git a/app/views/layouts/mailers/_jdma.html.haml b/app/views/layouts/mailers/_jdma.html.haml new file mode 100644 index 000000000..e88cd4dc2 --- /dev/null +++ b/app/views/layouts/mailers/_jdma.html.haml @@ -0,0 +1,10 @@ += vertical_margin(50) + +%div{ align: "center" } + %p + %strong Aidez-nous à améliorer ce service ! + %br + Donnez-nous votre avis, cela ne prend que 2 minutes. + != @jdma_html.gsub('nd_source=button', 'nd_source=email').gsub(' Date: Mon, 10 Jun 2024 09:35:29 +0200 Subject: [PATCH 0355/1532] bug(TypesDeChamp::ConditionValidator): should allow to use types_de_champ_public on condition for types_de_champ_private --- spec/models/procedure_spec.rb | 59 +++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 0589985bb..080612253 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -352,24 +352,12 @@ describe Procedure do end describe 'draft_types_de_champ validations' do - let(:repetition) { repetition = procedure.draft_revision.types_de_champ_public.find(&:repetition?) } - let(:text_field) { build(:type_de_champ_text) } - let(:invalid_repetition_error_message) { 'Le champ « Enfants » doit comporter au moins un champ répétable' } - - let(:drop_down) { build(:type_de_champ_drop_down_list, :without_selectable_values, libelle: 'Civilité') } - let(:invalid_drop_down_error_message) { 'Le champ « Civilité » doit comporter au moins un choix sélectionnable' } - - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :text }, { type: :integer_number }] }]) } - let(:draft) { procedure.draft_revision } - - before do - draft.revision_types_de_champ.create(type_de_champ: drop_down, position: 100) - - repetition.update(libelle: 'Enfants') - draft.children_of(repetition).destroy_all - end + let(:procedure) { create(:procedure, types_de_champ_public:, types_de_champ_private:) } context 'on a draft procedure' do + let(:types_de_champ_private) { [] } + let(:types_de_champ_public) { [{ type: :repetition, libelle: 'Enfants', children: [] }] } + it 'doesn’t validate the types de champs' do procedure.validate expect(procedure.errors[:draft_types_de_champ_public]).not_to be_present @@ -377,12 +365,22 @@ describe Procedure do end context 'when validating for publication' do + let(:types_de_champ_public) do + [ + { type: :repetition, libelle: 'Enfants', children: [] }, + { type: :drop_down_list, libelle: 'Civilité', options: [] } + ] + end + let(:types_de_champ_private) { [] } + let(:invalid_repetition_error_message) { 'Le champ « Enfants » doit comporter au moins un champ répétable' } + let(:invalid_drop_down_error_message) { 'Le champ « Civilité » doit comporter au moins un choix sélectionnable' } + it 'validates that no repetition type de champ is empty' do procedure.validate(:publication) expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).to include(invalid_repetition_error_message) new_draft = procedure.draft_revision - + repetition = procedure.draft_revision.types_de_champ_public.find(&:repetition?) parent_coordinate = new_draft.revision_types_de_champ.find_by(type_de_champ: repetition) new_draft.revision_types_de_champ.create(type_de_champ: create(:type_de_champ), position: 0, parent: parent_coordinate) @@ -394,6 +392,7 @@ describe Procedure do procedure.validate(:publication) expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).to include(invalid_drop_down_error_message) + drop_down = procedure.draft_revision.types_de_champ_public.find(&:drop_down_list?) drop_down.update!(drop_down_list_value: "--title--\r\nsome value") procedure.reload.validate(:publication) expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).not_to include(invalid_drop_down_error_message) @@ -401,10 +400,13 @@ describe Procedure do end context 'when the champ is private' do - before do - repetition.update(private: true) - drop_down.update(private: true) + let(:types_de_champ_private) do + [ + { type: :repetition, libelle: 'Enfants', children: [] }, + { type: :drop_down_list, libelle: 'Civilité', options: [] } + ] end + let(:types_de_champ_public) { [] } let(:invalid_repetition_error_message) { 'L’annotation privée « Enfants » doit comporter au moins un champ répétable' } let(:invalid_drop_down_error_message) { 'L’annotation privée « Civilité » doit comporter au moins un choix sélectionnable' } @@ -418,6 +420,23 @@ describe Procedure do procedure.validate(:publication) expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to include(invalid_drop_down_error_message) end + + it 'validates that types de champ private condition works types de champ public and private' do + end + end + + context 'when condition on champ private use public champ' do + include Logic + let(:types_de_champ_private) { [{ type: :text, condition: ds_eq(champ_value(1), constant(2)) }] } + let(:types_de_champ_public) { [{ type: :number, stable_id: 1 }] } + + it 'validate without context' do + expect(procedure.validate).to be_truthy + end + + it 'validate with types_de_champ_private_editor' do + expect(procedure.validate(:types_de_champ_private_editor)).to be_falsey + end end end From 0983f35dfdcb16681ae9a0e303ff426d5bb5b772 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Mon, 10 Jun 2024 11:09:55 +0200 Subject: [PATCH 0356/1532] ajout d'un helper pour la source --- app/mailers/notification_mailer.rb | 2 +- app/models/procedure.rb | 4 ++++ app/views/layouts/mailers/_jdma.html.haml | 2 +- app/views/users/dossiers/_merci.html.haml | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 276c48911..9b0d94493 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -92,7 +92,7 @@ class NotificationMailer < ApplicationMailer def set_jdma return unless params[:state] == Dossier.states.fetch(:en_construction) - @jdma_html = @dossier.procedure.monavis_embed.presence + @jdma_html = @dossier.procedure.monavis_embed_html_source("email").presence end def set_dossier diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 11cec13d8..337970e89 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -1008,6 +1008,10 @@ class Procedure < ApplicationRecord update!(closing_reason: nil, closing_details: nil, replaced_by_procedure_id: nil, closing_notification_brouillon: false, closing_notification_en_cours: false) end + def monavis_embed_html_source(source) + monavis_embed.gsub('nd_source=button', "nd_source=#{source}").gsub(' Date: Mon, 10 Jun 2024 09:57:34 +0200 Subject: [PATCH 0357/1532] fix(TypesDeChamp::ConditionValidator): allow to use types_de_champ_public on condition for types_de_champ_private --- .../types_de_champ/condition_validator.rb | 35 ++++++++++--------- spec/models/procedure_spec.rb | 35 +++++++++++++------ .../procedure_export_service_zip_spec.rb | 3 +- 3 files changed, 45 insertions(+), 28 deletions(-) diff --git a/app/validators/types_de_champ/condition_validator.rb b/app/validators/types_de_champ/condition_validator.rb index 74b57c3d5..65e1e887a 100644 --- a/app/validators/types_de_champ/condition_validator.rb +++ b/app/validators/types_de_champ/condition_validator.rb @@ -1,21 +1,24 @@ class TypesDeChamp::ConditionValidator < ActiveModel::EachValidator def validate_each(procedure, attribute, types_de_champ) - public_tdcs = types_de_champ.to_a - .flat_map { _1.repetition? ? procedure.draft_revision.children_of(_1) : _1 } + return if types_de_champ.empty? - public_tdcs - .map.with_index - .filter_map { |tdc, i| tdc.condition? ? [tdc, i] : nil } - .map do |tdc, i| - [tdc, tdc.condition.errors(public_tdcs.take(i))] - end - .filter { |_tdc, errors| errors.present? } - .each do |tdc, _error_hash| - procedure.errors.add( - attribute, - procedure.errors.generate_message(attribute, :invalid_condition, { value: tdc.libelle }), - type_de_champ: tdc - ) - end + tdcs = if attribute == :draft_types_de_champ_private + procedure.draft_revision.types_de_champ_for + else + procedure.draft_revision.types_de_champ_for(scope: :public) + end + + tdcs.each_with_index do |tdc, i| + next unless tdc.condition? + + errors = tdc.condition.errors(tdcs.take(i)) + next if errors.blank? + + procedure.errors.add( + attribute, + procedure.errors.generate_message(attribute, :invalid_condition, { value: tdc.libelle }), + type_de_champ: tdc + ) + end end end diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 080612253..7c4c8c1e6 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -211,7 +211,7 @@ describe Procedure do it { is_expected.to allow_value('text').on(:publication).for(:cadre_juridique) } context 'with deliberation' do - let(:procedure) { build(:procedure, cadre_juridique: nil) } + let(:procedure) { build(:procedure, cadre_juridique: nil, revisions: [build(:procedure_revision)]) } it { expect(procedure.valid?(:publication)).to eq(false) } @@ -420,22 +420,37 @@ describe Procedure do procedure.validate(:publication) expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to include(invalid_drop_down_error_message) end - - it 'validates that types de champ private condition works types de champ public and private' do - end end context 'when condition on champ private use public champ' do include Logic - let(:types_de_champ_private) { [{ type: :text, condition: ds_eq(champ_value(1), constant(2)) }] } - let(:types_de_champ_public) { [{ type: :number, stable_id: 1 }] } - + let(:types_de_champ_public) { [{ type: :decimal_number, stable_id: 1 }] } + let(:types_de_champ_private) { [{ type: :text, condition: ds_eq(champ_value(1), constant(2)), stable_id: 2 }] } it 'validate without context' do - expect(procedure.validate).to be_truthy + procedure.validate + expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to be_empty end - it 'validate with types_de_champ_private_editor' do - expect(procedure.validate(:types_de_champ_private_editor)).to be_falsey + it 'validate allows condition' do + procedure.validate(:types_de_champ_private_editor) + expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to be_empty + end + end + + context 'when condition on champ public use private champ' do + include Logic + let(:types_de_champ_public) { [{ type: :text, libelle: 'condition', condition: ds_eq(champ_value(1), constant(2)), stable_id: 2 }] } + let(:types_de_champ_private) { [{ type: :decimal_number, stable_id: 1 }] } + let(:error_on_condition) { "Le champ « condition » a une logique conditionnelle invalide" } + + it 'validate without context' do + procedure.validate + expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).to be_empty + end + + it 'validate prevent condition' do + procedure.validate(:types_de_champ_public_editor) + expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).to include(error_on_condition) end end end diff --git a/spec/services/procedure_export_service_zip_spec.rb b/spec/services/procedure_export_service_zip_spec.rb index 14e5ceaad..e4daaf3ee 100644 --- a/spec/services/procedure_export_service_zip_spec.rb +++ b/spec/services/procedure_export_service_zip_spec.rb @@ -39,8 +39,7 @@ describe ProcedureExportService do ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do subject end - - expect(sql_count <= 58).to be_truthy + expect(sql_count <= 62).to be_truthy dossier = dossiers.first From 1ee667af755543eaf384925baae718f34e3d8edc Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Mon, 10 Jun 2024 11:26:37 +0200 Subject: [PATCH 0358/1532] fix(brakeman): maj avec le nouvel appel dans la vue --- config/brakeman.ignore | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config/brakeman.ignore b/config/brakeman.ignore index bf9b76294..7dd6545da 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -3,19 +3,19 @@ { "warning_type": "Cross-Site Scripting", "warning_code": 2, - "fingerprint": "1b805585567775589825c0eda58cb84c074fc760d0a7afb101c023a51427f2b5", + "fingerprint": "26f504696b074d18ef3f5568dc8f6a46d1283a67fe37822498fa25d0409664ab", "check_name": "CrossSiteScripting", "message": "Unescaped model attribute", "file": "app/views/users/dossiers/_merci.html.haml", - "line": 26, + "line": 30, "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", - "code": "current_user.dossiers.includes(:procedure).find(params[:id]).procedure.monavis_embed", + "code": "current_user.dossiers.includes(:procedure).find(params[:id]).procedure.monavis_embed_html_source(\"site\")", "render_path": [ { "type": "controller", "class": "Users::DossiersController", "method": "merci", - "line": 309, + "line": 320, "file": "app/controllers/users/dossiers_controller.rb", "rendered": { "name": "users/dossiers/merci", @@ -74,7 +74,7 @@ "check_name": "CrossSiteScripting", "message": "Unescaped parameter value", "file": "app/views/faq/show.html.haml", - "line": 12, + "line": 13, "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", "code": "Redcarpet::Markdown.new(Redcarpet::TrustedRenderer.new(view_context), :autolink => true).render(loader_service.find(\"#{params[:category]}/#{params[:slug]}\").content)", "render_path": [ @@ -203,6 +203,6 @@ "note": "Current is not a model" } ], - "updated": "2024-04-23 18:27:12 +0200", + "updated": "2024-06-10 11:21:19 +0200", "brakeman_version": "6.1.2" } From 915aec6894d0ddee5a19d10f05e654f00716f7a4 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 10 Jun 2024 13:06:31 +0200 Subject: [PATCH 0359/1532] chore(apiv1): fix regression with attachment url in commentaire --- app/serializers/commentaire_serializer.rb | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/serializers/commentaire_serializer.rb b/app/serializers/commentaire_serializer.rb index 2941765e2..94f652147 100644 --- a/app/serializers/commentaire_serializer.rb +++ b/app/serializers/commentaire_serializer.rb @@ -2,9 +2,18 @@ class CommentaireSerializer < ActiveModel::Serializer attributes :email, :body, :created_at, - :piece_jointe_attachments + :piece_jointe_attachments, + :attachment def created_at object.created_at&.in_time_zone('UTC') end + + def attachment + piece_jointe = object.piece_jointe_attachments.first + + if piece_jointe&.virus_scanner&.safe? + Rails.application.routes.url_helpers.url_for(piece_jointe) + end + end end From ef2a02197147b3ecc7fc957dfce70833df0bebdd Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Mon, 10 Jun 2024 13:06:54 +0200 Subject: [PATCH 0360/1532] fix condition si monavis est nil --- .DS_Store | Bin 0 -> 8196 bytes app/mailers/notification_mailer.rb | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..765f57dc5914c412cd52c3818b1a7cf891808d37 GIT binary patch literal 8196 zcmeHMT~8ZF6un~;yq19)wUKyc@<=d)K??%$+%N?~G?nM5;0Fd`z@LL=g_Q z)e25Ig`e{=QwrwJJy-=kWryt~YvWsW1{-b!1%d)WfuKN8ASm$9PylN-m*|FdUq21! zpg>UIf2jb^4>cTY>&8yBm8kgct$0#WNNws->Y;x1P-UPp8qko|XoRa%N?eAzlfxKO3VZ_mA)Qd$WBlwfDnMn_p|037 zFkk2R`O{;~3p%Bj=*5xip>X73%QhDE4`}Q%h}HSUQs)EOrpNS%4rrhDs6kI~wTJHy z!9Jyq$7a=Rc4T_eg{PQ~sO#cIGaFw(@AFi$F-2gYr&klJVxyL!M+`+09$R>BVdVOv ztRBHvG;(VYt?U{~=wlasWL*=#<*buhvUFN-(buEydLby}@XFwg(b55WmFtN$au`MF zQ;tdwszP64oVIBfW7Wi&qjsjx3aU9Qt~<+%A_uhqMRlCEpbuY-Ss@yFePxZ_I2!fQ zQ_cj51+?i0j7pZ5dk(LJk&Ds(Lpq-IBKp`~mVwStFXM;!tVs--{Lnt#b}Hnd<8Oh! z#&~efnh^nuv+~U2v3j=Wd3YXUp5U1C;*+Dx!7kZYX(4;a&3K8mKP(+1LA#bB*r$23d*ORaD}!pE5BugF?r-gW(i@J}*MC=q;@qwIQdEj6 z(Ra<`?7TUON8@259z5r72U(W1r`K`&X|H?Us(!ScrK7l)b_YV}b-I9j`>dCCvhzkZ zPCG;4HuZz39F<$um5Ymw&(>FKTbrAgtF?=*PjLNUDl^J#5jT+`12fGAp51u$m zd7Zk>D0ug{Xsg4mUU4}u|DKP`a`;mX-pv*@Y^4Ibd-MDMcdIO*f&xK-f29IyZl}4^ zK+~sFs^1IVYu9kRz`=!a6K#cplWfOfWjhXg^@ky@Yk)GRZtO%`+(CKoF9NRrq2%9~ X_DUJn|9)8i<^2;P!&z7VgX{lalf>_= literal 0 HcmV?d00001 diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 9b0d94493..3e6064389 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -90,9 +90,9 @@ class NotificationMailer < ApplicationMailer end def set_jdma - return unless params[:state] == Dossier.states.fetch(:en_construction) + return unless params[:state] == Dossier.states.fetch(:en_construction) && @dossier.procedure.monavis_embed - @jdma_html = @dossier.procedure.monavis_embed_html_source("email").presence + @jdma_html = @dossier.procedure.monavis_embed_html_source("email") end def set_dossier From 1d4a8795c8c38384d8e670c21846de997dc22bf2 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 10 Jun 2024 14:28:50 +0200 Subject: [PATCH 0361/1532] fix(clipboard): if unsupported, don't hide element when button is on another target --- app/javascript/controllers/clipboard_controller.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/javascript/controllers/clipboard_controller.ts b/app/javascript/controllers/clipboard_controller.ts index d2b4ae1cf..cecdc5d93 100644 --- a/app/javascript/controllers/clipboard_controller.ts +++ b/app/javascript/controllers/clipboard_controller.ts @@ -17,7 +17,11 @@ export class ClipboardController extends Controller { connect(): void { // some extensions or browsers block clipboard if (!navigator.clipboard) { - this.element.classList.add('hidden'); + if (this.hasToHideTarget) { + this.toHideTarget.classList.add('hidden'); + } else { + this.element.classList.add('hidden'); + } } } From a8e382d0d07369977111ce5b70e36841d605946d Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Mon, 10 Jun 2024 14:55:51 +0200 Subject: [PATCH 0362/1532] fix(clone): le lien monavis est reinitialise lors du clonage --- app/models/procedure.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index af398a991..3d6c05b1f 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -559,6 +559,7 @@ class Procedure < ApplicationRecord procedure.closing_notification_brouillon = false procedure.closing_notification_en_cours = false procedure.template = false + procedure.monavis_embed = nil if !procedure.valid? procedure.errors.attribute_names.each do |attribute| From 603bb97679ec8e9f9a33eacb014062b6fbe1238c Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 10 Jun 2024 11:29:50 +0200 Subject: [PATCH 0363/1532] fix(gallery): catch StandardError --- app/helpers/gallery_helper.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/helpers/gallery_helper.rb b/app/helpers/gallery_helper.rb index 98f7ffc1e..1f9ddeeab 100644 --- a/app/helpers/gallery_helper.rb +++ b/app/helpers/gallery_helper.rb @@ -9,19 +9,19 @@ module GalleryHelper def preview_url_for(attachment) attachment.preview(resize_to_limit: [400, 400]).processed.url - rescue ActiveStorage::Error + rescue StandardError 'pdf-placeholder.png' end def variant_url_for(attachment) attachment.variant(resize_to_limit: [400, 400]).processed.url - rescue ActiveStorage::Error + rescue StandardError 'apercu-indisponible.png' end def blob_url(attachment) attachment.blob.content_type.in?(RARE_IMAGE_TYPES) ? attachment.variant(resize_to_limit: [2000, 2000]).processed.url : attachment.blob.url - rescue ActiveStorage::Error + rescue StandardError attachment.blob.url end end From 810d272be25e4094431eaf2be9218e62ff58264f Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:02:59 +0200 Subject: [PATCH 0364/1532] feat(procedure_revision): add ineligibilite columns --- ...409075536_add_transitions_rules_to_procedure_revisions.rb | 5 +++++ ...7_add_dossier_ineligble_message_to_procedure_revisions.rb | 5 +++++ ...dd_eligibilite_dossiers_enabled_to_procedure_revisions.rb | 5 +++++ db/schema.rb | 3 +++ 4 files changed, 18 insertions(+) create mode 100644 db/migrate/20240409075536_add_transitions_rules_to_procedure_revisions.rb create mode 100644 db/migrate/20240514075727_add_dossier_ineligble_message_to_procedure_revisions.rb create mode 100644 db/migrate/20240516095601_add_eligibilite_dossiers_enabled_to_procedure_revisions.rb diff --git a/db/migrate/20240409075536_add_transitions_rules_to_procedure_revisions.rb b/db/migrate/20240409075536_add_transitions_rules_to_procedure_revisions.rb new file mode 100644 index 000000000..e2a654783 --- /dev/null +++ b/db/migrate/20240409075536_add_transitions_rules_to_procedure_revisions.rb @@ -0,0 +1,5 @@ +class AddTransitionsRulesToProcedureRevisions < ActiveRecord::Migration[7.0] + def change + add_column :procedure_revisions, :ineligibilite_rules, :jsonb + end +end diff --git a/db/migrate/20240514075727_add_dossier_ineligble_message_to_procedure_revisions.rb b/db/migrate/20240514075727_add_dossier_ineligble_message_to_procedure_revisions.rb new file mode 100644 index 000000000..bf8464f8c --- /dev/null +++ b/db/migrate/20240514075727_add_dossier_ineligble_message_to_procedure_revisions.rb @@ -0,0 +1,5 @@ +class AddDossierIneligbleMessageToProcedureRevisions < ActiveRecord::Migration[7.0] + def change + add_column :procedure_revisions, :ineligibilite_message, :text + end +end diff --git a/db/migrate/20240516095601_add_eligibilite_dossiers_enabled_to_procedure_revisions.rb b/db/migrate/20240516095601_add_eligibilite_dossiers_enabled_to_procedure_revisions.rb new file mode 100644 index 000000000..19ce1d243 --- /dev/null +++ b/db/migrate/20240516095601_add_eligibilite_dossiers_enabled_to_procedure_revisions.rb @@ -0,0 +1,5 @@ +class AddEligibiliteDossiersEnabledToProcedureRevisions < ActiveRecord::Migration[7.0] + def change + add_column :procedure_revisions, :ineligibilite_enabled, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 6f7fcfe7c..b33a10582 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -863,6 +863,9 @@ ActiveRecord::Schema[7.0].define(version: 2024_05_27_090508) do create_table "procedure_revisions", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.bigint "dossier_submitted_message_id" + t.boolean "ineligibilite_enabled", default: false, null: false + t.text "ineligibilite_message" + t.jsonb "ineligibilite_rules" t.bigint "procedure_id", null: false t.datetime "published_at", precision: nil t.datetime "updated_at", precision: nil, null: false From 12d23f1498208fc12e01b9447aac84a38a30798f Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:08:00 +0200 Subject: [PATCH 0365/1532] feat(Procedure::Cards::IneligibleDossier): add an ineligibilite dossier card to procedure dashboard --- .../conditions/conditions_component.rb | 2 +- .../card/ineligibilite_dossier_component.rb | 19 ++++++++++++++ .../ineligibilite_dossier_component.fr.yml | 8 ++++++ .../ineligibilite_dossier_component.html.haml | 15 +++++++++++ app/models/procedure_revision.rb | 4 +++ app/models/type_de_champ.rb | 4 +++ .../administrateurs/procedures/show.html.haml | 1 + .../card/ineligibilite_dossier_component.rb | 25 +++++++++++++++++++ 8 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 app/components/procedure/card/ineligibilite_dossier_component.rb create mode 100644 app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml create mode 100644 app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.html.haml create mode 100644 spec/components/procedures/card/ineligibilite_dossier_component.rb diff --git a/app/components/conditions/conditions_component.rb b/app/components/conditions/conditions_component.rb index 01f081a85..8817a1336 100644 --- a/app/components/conditions/conditions_component.rb +++ b/app/components/conditions/conditions_component.rb @@ -61,7 +61,7 @@ class Conditions::ConditionsComponent < ApplicationComponent def available_targets_for_select @source_tdcs - .filter { |tdc| ChampValue::MANAGED_TYPE_DE_CHAMP.values.include?(tdc.type_champ) } + .filter(&:conditionable?) .map { |tdc| [tdc.libelle, champ_value(tdc.stable_id).to_json] } end diff --git a/app/components/procedure/card/ineligibilite_dossier_component.rb b/app/components/procedure/card/ineligibilite_dossier_component.rb new file mode 100644 index 000000000..d69e06623 --- /dev/null +++ b/app/components/procedure/card/ineligibilite_dossier_component.rb @@ -0,0 +1,19 @@ +class Procedure::Card::IneligibiliteDossierComponent < ApplicationComponent + def initialize(procedure:) + @procedure = procedure + end + + def ready? + @procedure.draft_revision + .conditionable_types_de_champ + .present? + end + + def error? + !@procedure.draft_revision.validate(:ineligibilite_rules_editor) + end + + def completed? + @procedure.draft_revision.ineligibilite_enabled + end +end diff --git a/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml new file mode 100644 index 000000000..d65f0d535 --- /dev/null +++ b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml @@ -0,0 +1,8 @@ +--- +fr: + title: Inéligibilité des dossiers + state: + pending: Champs à configurer + ready: À configurer + completed: Activé + subtitle: Gérez vos critères d’inéligibilité en fonction des champs du formulaire diff --git a/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.html.haml b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.html.haml new file mode 100644 index 000000000..e82e64fad --- /dev/null +++ b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.html.haml @@ -0,0 +1,15 @@ +.fr-col-6.fr-col-md-4.fr-col-lg-3 + = link_to edit_admin_procedure_ineligibilite_rules_path(@procedure), class: 'fr-tile fr-enlarge-link' do + .fr-tile__body.flex.column.align-center.justify-between + - if !ready? + %p.fr-badge.fr-badge--warning= t('.state.pending') + - elsif error? + %p.fr-badge.fr-badge--error À modifier + - elsif !completed? + %p.fr-badge.fr-badge--info= t('.state.ready') + - else + %p.fr-badge.fr-badge--success= t('.state.completed') + %div + %h3.fr-h6.fr-mt-10v= t('.title') + %p.fr-tile-subtitle= t('.subtitle') + %p.fr-btn.fr-btn--tertiary= t('views.shared.actions.edit') diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index fef3516d5..157b4dd67 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -251,6 +251,10 @@ class ProcedureRevision < ApplicationRecord types_de_champ_public.filter(&:routable?) end + def conditionable_types_de_champ + types_de_champ_for(scope: :public).filter(&:conditionable?) + end + private def compute_estimated_fill_duration diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 10e415c05..cf2c321d8 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -657,6 +657,10 @@ class TypeDeChamp < ApplicationRecord type_champ.in?(ROUTABLE_TYPES) end + def conditionable? + Logic::ChampValue::MANAGED_TYPE_DE_CHAMP.values.include?(type_champ) + end + def invalid_regexp? self.errors.delete(:expression_reguliere) self.errors.delete(:expression_reguliere_exemple_text) diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml index 345eb2824..4d63d909b 100644 --- a/app/views/administrateurs/procedures/show.html.haml +++ b/app/views/administrateurs/procedures/show.html.haml @@ -71,6 +71,7 @@ = render Procedure::Card::PresentationComponent.new(procedure: @procedure) = render Procedure::Card::ZonesComponent.new(procedure: @procedure) if Rails.application.config.ds_zonage_enabled = render Procedure::Card::ChampsComponent.new(procedure: @procedure) + = render Procedure::Card::IneligibiliteDossierComponent.new(procedure: @procedure) = render Procedure::Card::ServiceComponent.new(procedure: @procedure, administrateur: current_administrateur) = render Procedure::Card::AdministrateursComponent.new(procedure: @procedure) = render Procedure::Card::InstructeursComponent.new(procedure: @procedure) diff --git a/spec/components/procedures/card/ineligibilite_dossier_component.rb b/spec/components/procedures/card/ineligibilite_dossier_component.rb new file mode 100644 index 000000000..433b59155 --- /dev/null +++ b/spec/components/procedures/card/ineligibilite_dossier_component.rb @@ -0,0 +1,25 @@ +describe Procedure::Card::IneligibiliteDossierComponent, type: :component do + describe 'render' do + subject do + render_inline(described_class.new(procedure: procedure)) + end + + context 'when none of types_de_champ_public supports conditional' do + let(:procedure) { create(:procedure, types_de_champ_public: []) } + + it 'render missing setup' do + subject + expect(page).to have_text('Champs manquant') + end + end + + context 'when at least one of types_de_champ_public support conditional' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :yes_no }]) } + + it 'render the template' do + subject + expect(page).to have_text('À configurer') + end + end + end +end From aca3e38859784c89a9c9f0fb7558622a479d06a3 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:25:10 +0200 Subject: [PATCH 0366/1532] feat(ProcedureRevision.ineligibilite_rules): add ineligibilite_rules management to procedure revision based on conditional logic --- .../ineligibilite_rules_component.rb | 34 +++ .../ineligibilite_rules_component.fr.yml | 6 + .../ineligibilite_rules_component.html.haml | 42 ++++ .../procedure/pending_republish_component.rb | 10 + .../pending_republish_component.fr.yml | 4 + .../pending_republish_component.html.haml | 3 + .../ineligibilite_rules_controller.rb | 74 ++++++ app/models/procedure_revision.rb | 12 + .../_update.turbo_stream.haml | 7 + .../add_row.turbo_stream.haml | 1 + .../change_targeted_champ.turbo_stream.haml | 1 + .../delete_row.turbo_stream.haml | 1 + .../destroy.turbo_stream.haml | 1 + .../ineligibilite_rules/edit.html.haml | 28 +++ .../update.turbo_stream.haml | 1 + config/env.example.optional | 3 + config/initializers/02_urls.rb | 1 + config/routes.rb | 8 + .../ineligibilite_rules_component_spec.rb | 64 +++++ .../pending_republish_component_spec.rb | 14 ++ .../ineligibilite_rules_controller_spec.rb | 231 ++++++++++++++++++ .../procedure_ineligibilite_spec.rb | 45 ++++ 22 files changed, 591 insertions(+) create mode 100644 app/components/conditions/ineligibilite_rules_component.rb create mode 100644 app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.fr.yml create mode 100644 app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml create mode 100644 app/components/procedure/pending_republish_component.rb create mode 100644 app/components/procedure/pending_republish_component/pending_republish_component.fr.yml create mode 100644 app/components/procedure/pending_republish_component/pending_republish_component.html.haml create mode 100644 app/controllers/administrateurs/ineligibilite_rules_controller.rb create mode 100644 app/views/administrateurs/ineligibilite_rules/_update.turbo_stream.haml create mode 100644 app/views/administrateurs/ineligibilite_rules/add_row.turbo_stream.haml create mode 100644 app/views/administrateurs/ineligibilite_rules/change_targeted_champ.turbo_stream.haml create mode 100644 app/views/administrateurs/ineligibilite_rules/delete_row.turbo_stream.haml create mode 100644 app/views/administrateurs/ineligibilite_rules/destroy.turbo_stream.haml create mode 100644 app/views/administrateurs/ineligibilite_rules/edit.html.haml create mode 100644 app/views/administrateurs/ineligibilite_rules/update.turbo_stream.haml create mode 100644 spec/components/conditions/ineligibilite_rules_component_spec.rb create mode 100644 spec/components/procedures/pending_republish_component_spec.rb create mode 100644 spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb create mode 100644 spec/system/administrateurs/procedure_ineligibilite_spec.rb diff --git a/app/components/conditions/ineligibilite_rules_component.rb b/app/components/conditions/ineligibilite_rules_component.rb new file mode 100644 index 000000000..a12ab262e --- /dev/null +++ b/app/components/conditions/ineligibilite_rules_component.rb @@ -0,0 +1,34 @@ +class Conditions::IneligibiliteRulesComponent < Conditions::ConditionsComponent + include Logic + + def initialize(draft_revision:) + @draft_revision = draft_revision + @published_revision = draft_revision.procedure.published_revision + @condition = draft_revision.ineligibilite_rules + @source_tdcs = draft_revision.types_de_champ_for(scope: :public) + end + + def pending_changes? + return false if !@published_revision + + !@published_revision.compare_ineligibilite_rules(@draft_revision).empty? + end + + private + + def input_prefix + 'procedure_revision[condition_form]' + end + + def input_id_for(name, row_index) + "#{@draft_revision.id}-#{name}-#{row_index}" + end + + def delete_condition_path(row_index) + delete_row_admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id, revision_id: @draft_revision.id, row_index:) + end + + def add_condition_path + add_row_admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id, revision_id: @draft_revision.id) + end +end diff --git a/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.fr.yml b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.fr.yml new file mode 100644 index 000000000..b646c3019 --- /dev/null +++ b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.fr.yml @@ -0,0 +1,6 @@ +--- +fr: + display_if: Bloquer si + select: Sélectionner + add_condition: Ajouter une règle d’inéligibilité + remove_a_row: Supprimer une règle diff --git a/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml new file mode 100644 index 000000000..547a2ad85 --- /dev/null +++ b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml @@ -0,0 +1,42 @@ +%div{ id: dom_id(@draft_revision, :ineligibilite_rules) } + = render Procedure::PendingRepublishComponent.new(procedure: @draft_revision.procedure, render_if: pending_changes?) + = render Conditions::ConditionsErrorsComponent.new(conditions: condition_per_row, source_tdcs: @source_tdcs) + %fieldset.fr-fieldset + %legend.fr-mx-1w.fr-label.fr-py-0.fr-mb-1w.fr-mt-2w + Règles d’inéligibilité + %span.fr-hint-text Vous pouvez utiliser 1 ou plusieurs critère pour bloquer le dépot + .fr-fieldset__element + = form_tag admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id), method: :patch, data: { turbo: true, controller: 'autosave' }, class: 'form width-100' do + .conditionnel.width-100 + %table.condition-table + %thead + %tr + %th.fr-pt-0.far-left + %th.fr-pt-0.target Champ Cible + %th.fr-pt-0.operator Opérateur + %th.fr-pt-0.value Valeur + %th.fr-pt-0.delete-column + %tbody + - rows.each.with_index do |(targeted_champ, operator_name, value), row_index| + %tr + %td.far-left= far_left_tag(row_index) + %td.target= left_operand_tag(targeted_champ, row_index) + %td.operator= operator_tag(operator_name, targeted_champ, row_index) + %td.value= right_operand_tag(targeted_champ, value, row_index, operator_name) + %td.delete-column= delete_condition_tag(row_index) + %tfoot + %tr + %td.text-right{ colspan: 5 }= add_condition_tag + + + + = form_for(@draft_revision, url: change_admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id)) do |f| + .fr-fieldset__element= render Dsfr::InputComponent.new(form: f, attribute: :ineligibilite_message, input_type: :text_area, opts: {rows: 5}) + .fr-fieldset__element + .fr-toggle + = f.check_box :ineligibilite_enabled, class: 'fr-toggle__input', data: @opt + = f.label :ineligibilite_enabled, "Inéligibilité des dossiers", data: { 'fr-checked-label': "Actif", 'fr-unchecked-label': "Inactif" }, class: 'fr-toggle__label' + %p.fr-hint-text Passer l’intérrupteur sur activé pour que les critères d’inéligibilité configurés s'appliquent + + + = render Procedure::FixedFooterComponent.new(procedure: @draft_revision.procedure, form: f, extra_class_names: 'fr-col-offset-md-2 fr-col-md-8') diff --git a/app/components/procedure/pending_republish_component.rb b/app/components/procedure/pending_republish_component.rb new file mode 100644 index 000000000..181eb6f5c --- /dev/null +++ b/app/components/procedure/pending_republish_component.rb @@ -0,0 +1,10 @@ +class Procedure::PendingRepublishComponent < ApplicationComponent + def initialize(procedure:, render_if:) + @procedure = procedure + @render_if = render_if + end + + def render? + @render_if + end +end diff --git a/app/components/procedure/pending_republish_component/pending_republish_component.fr.yml b/app/components/procedure/pending_republish_component/pending_republish_component.fr.yml new file mode 100644 index 000000000..eb941cdba --- /dev/null +++ b/app/components/procedure/pending_republish_component/pending_republish_component.fr.yml @@ -0,0 +1,4 @@ +--- +fr: + pending_republish_html: | + Ces modifications ne seront appliquées qu'à la prochaine publication. Vous pouvez vérifier puis publier les modifications sur l'écran de gestion de la démarche \ No newline at end of file diff --git a/app/components/procedure/pending_republish_component/pending_republish_component.html.haml b/app/components/procedure/pending_republish_component/pending_republish_component.html.haml new file mode 100644 index 000000000..eab7f62fc --- /dev/null +++ b/app/components/procedure/pending_republish_component/pending_republish_component.html.haml @@ -0,0 +1,3 @@ += render Dsfr::AlertComponent.new(state: :warning) do |c| + - c.with_body do + = t('.pending_republish_html', href: admin_procedure_path(@procedure.id)) diff --git a/app/controllers/administrateurs/ineligibilite_rules_controller.rb b/app/controllers/administrateurs/ineligibilite_rules_controller.rb new file mode 100644 index 000000000..41d6865f9 --- /dev/null +++ b/app/controllers/administrateurs/ineligibilite_rules_controller.rb @@ -0,0 +1,74 @@ +module Administrateurs + class IneligibiliteRulesController < AdministrateurController + before_action :retrieve_procedure + + def edit + end + + def change + if draft_revision.update(procedure_revision_params) + redirect_to edit_admin_procedure_ineligibilite_rules_path(@procedure) + else + flash[:alert] = draft_revision.errors.full_messages + render :edit + end + end + + def add_row + condition = Logic.add_empty_condition_to(draft_revision.ineligibilite_rules) + draft_revision.update!(ineligibilite_rules: condition) + @ineligibilite_rules_component = build_ineligibilite_rules_component + end + + def delete_row + condition = condition_form.delete_row(row_index).to_condition + draft_revision.update!(ineligibilite_rules: condition) + + @ineligibilite_rules_component = build_ineligibilite_rules_component + end + + def update + condition = condition_form.to_condition + draft_revision.update!(ineligibilite_rules: condition) + + @ineligibilite_rules_component = build_ineligibilite_rules_component + end + + def change_targeted_champ + condition = condition_form.change_champ(row_index).to_condition + draft_revision.update!(ineligibilite_rules: condition) + @ineligibilite_rules_component = build_ineligibilite_rules_component + end + + private + + def build_ineligibilite_rules_component + Conditions::IneligibiliteRulesComponent.new(draft_revision: draft_revision) + end + + def draft_revision + @procedure.draft_revision + end + + def condition_form + ConditionForm.new(ineligibilite_rules_params.merge(source_tdcs: draft_revision.types_de_champ_for(scope: :public))) + end + + def ineligibilite_rules_params + params + .require(:procedure_revision) + .require(:condition_form) + .permit(:top_operator_name, rows: [:targeted_champ, :operator_name, :value]) + end + + def row_index + params[:row_index].to_i + end + + def procedure_revision_params + params + .require(:procedure_revision) + .permit(:ineligibilite_message, :ineligibilite_enabled) + end + end +end diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index 157b4dd67..2b56ecf80 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -1,4 +1,5 @@ class ProcedureRevision < ApplicationRecord + include Logic self.implicit_order_column = :created_at belongs_to :procedure, -> { with_discarded }, inverse_of: :revisions, optional: false belongs_to :dossier_submitted_message, inverse_of: :revisions, optional: true, dependent: :destroy @@ -17,8 +18,19 @@ class ProcedureRevision < ApplicationRecord scope :ordered, -> { order(:created_at) } + validates :ineligibilite_message, presence: true, if: -> { ineligibilite_enabled? } + delegate :path, to: :procedure, prefix: true + validate :ineligibilite_rules_are_valid?, + on: [:ineligibilite_rules_editor, :publication] + validates :ineligibilite_message, + presence: true, + if: -> { ineligibilite_enabled? }, + on: [:ineligibilite_rules_editor, :publication] + + serialize :ineligibilite_rules, LogicSerializer + def build_champs_public # reload: it can be out of sync in test if some tdcs are added wihtout using add_tdc types_de_champ_public.reload.map(&:build_champ) diff --git a/app/views/administrateurs/ineligibilite_rules/_update.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/_update.turbo_stream.haml new file mode 100644 index 000000000..a0ace0eca --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/_update.turbo_stream.haml @@ -0,0 +1,7 @@ +- rendered = render @ineligibilite_rules_component + +- if rendered.present? + = turbo_stream.replace dom_id(@procedure.draft_revision, :ineligibilite_rules) do + - rendered +- else + = turbo_stream.remove dom_id(@procedure.draft_revision, :ineligibilite_rules) diff --git a/app/views/administrateurs/ineligibilite_rules/add_row.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/add_row.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/add_row.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/ineligibilite_rules/change_targeted_champ.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/change_targeted_champ.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/change_targeted_champ.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/ineligibilite_rules/delete_row.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/delete_row.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/delete_row.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/ineligibilite_rules/destroy.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/destroy.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/destroy.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/app/views/administrateurs/ineligibilite_rules/edit.html.haml b/app/views/administrateurs/ineligibilite_rules/edit.html.haml new file mode 100644 index 000000000..a76a30468 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/edit.html.haml @@ -0,0 +1,28 @@ += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [['Démarches', admin_procedures_path], + [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], + ['Inéligibilité des dossiers']] } + + +.fr-container + .fr-grid-row + .fr-col-12.fr-col-offset-md-2.fr-col-md-8 + %h1.fr-h1 Inéligibilité des dossiers + + = render Dsfr::AlertComponent.new(title: nil, size: :sm, state: :info, heading_level: 'h2', extra_class_names: 'fr-my-2w') do |c| + - c.with_body do + %p + Les dossiers répondant à vos critères d’inéligibilité ne pourront pas être déposés. Plus d’informations sur l’inéligibilité des dossiers dans la + = link_to('doc', ELIGIBILITE_URL, title: "Document sur l’inéligibilité des dossiers", **external_link_attributes) + + - if !@procedure.draft_revision.conditionable_types_de_champ.present? + %p.fr-mt-2w.fr-mb-2w + Pour configurer l’inéligibilité des dossiers, votre formulaire doit comporter au moins un champ supportant les critères d’inéligibilité. Il vous faut donc ajouter au moins un des champs suivant à votre formulaire : + %ul + - Logic::ChampValue::MANAGED_TYPE_DE_CHAMP.values.each do + %li= "« #{t(_1, scope: [:activerecord, :attributes, :type_de_champ, :type_champs])} »" + %p.fr-mt-2w + = link_to 'Ajouter un champ supportant les critères d’inéligibilité', champs_admin_procedure_path(@procedure), class: 'fr-link fr-icon-arrow-right-line fr-link--icon-right' + = render Procedure::FixedFooterComponent.new(procedure: @procedure) + - else + = render Conditions::IneligibiliteRulesComponent.new(draft_revision: @procedure.draft_revision) diff --git a/app/views/administrateurs/ineligibilite_rules/update.turbo_stream.haml b/app/views/administrateurs/ineligibilite_rules/update.turbo_stream.haml new file mode 100644 index 000000000..8f9900e50 --- /dev/null +++ b/app/views/administrateurs/ineligibilite_rules/update.turbo_stream.haml @@ -0,0 +1 @@ += render partial: 'update' diff --git a/config/env.example.optional b/config/env.example.optional index b9cc395e9..79710f04e 100644 --- a/config/env.example.optional +++ b/config/env.example.optional @@ -61,6 +61,9 @@ DS_ENV="staging" # Instance customization: URL of the Routage documentation # ROUTAGE_URL="" # +# Instance customization: URL of the EligibiliteDossier documentation +# ELIGIBILITE_URL="" +# # Instance customization: URL of the accessibility statement # ACCESSIBILITE_URL="" diff --git a/config/initializers/02_urls.rb b/config/initializers/02_urls.rb index d6e031dae..c29ea1d5d 100644 --- a/config/initializers/02_urls.rb +++ b/config/initializers/02_urls.rb @@ -37,6 +37,7 @@ CGU_URL = ENV.fetch("CGU_URL", [DOC_URL, "cgu"].join("/")) MENTIONS_LEGALES_URL = ENV.fetch("MENTIONS_LEGALES_URL", "/mentions-legales") ACCESSIBILITE_URL = ENV.fetch("ACCESSIBILITE_URL", "/declaration-accessibilite") ROUTAGE_URL = ENV.fetch("ROUTAGE_URL", [DOC_URL, "/pour-aller-plus-loin/routage"].join("/")) +ELIGIBILITE_URL = ENV.fetch("ELIGIBILITE_URL", [DOC_URL, "/pour-aller-plus-loin/eligibilite-des-dossiers"].join("/")) API_DOC_URL = [DOC_URL, "api-graphql"].join("/") WEBHOOK_DOC_URL = [DOC_URL, "pour-aller-plus-loin", "webhook"].join("/") WEBHOOK_ALTERNATIVE_DOC_URL = [DOC_URL, "api-graphql", "cas-dusages-exemple-dimplementation", "synchroniser-les-dossiers-modifies-sur-ma-demarche"].join("/") diff --git a/config/routes.rb b/config/routes.rb index c4973d1ae..38bd26cc9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -607,6 +607,14 @@ Rails.application.routes.draw do delete :delete_row, on: :member end + resource :ineligibilite_rules, only: [:edit, :update, :destroy], param: :revision_id do + patch :change_targeted_champ, on: :member + patch :update_all_rows, on: :member + patch :add_row, on: :member + delete :delete_row, on: :member + patch :change + end + patch :update_defaut_groupe_instructeur, controller: 'routing_rules', as: :update_defaut_groupe_instructeur put 'clone' diff --git a/spec/components/conditions/ineligibilite_rules_component_spec.rb b/spec/components/conditions/ineligibilite_rules_component_spec.rb new file mode 100644 index 000000000..c678c5ace --- /dev/null +++ b/spec/components/conditions/ineligibilite_rules_component_spec.rb @@ -0,0 +1,64 @@ +describe Conditions::IneligibiliteRulesComponent, type: :component do + include Logic + let(:procedure) { create(:procedure) } + let(:component) { described_class.new(draft_revision: procedure.draft_revision) } + + describe 'render' do + let(:ineligibilite_message) { 'ok' } + let(:ineligibilite_enabled) { true } + before do + procedure.draft_revision.update(ineligibilite_rules:, ineligibilite_message:, ineligibilite_enabled:) + end + context 'when ineligibilite_rules are valid' do + let(:ineligibilite_rules) { ds_eq(constant(true), constant(true)) } + it 'does not render error' do + render_inline(component) + expect(page).not_to have_selector('.errors-summary') + end + end + context 'when ineligibilite_rules are invalid' do + let(:ineligibilite_rules) { ds_eq(constant(true), constant(1)) } + it 'does not render error' do + render_inline(component) + expect(page).to have_selector('.errors-summary') + end + end + end + + describe '#pending_changes' do + context 'when procedure is published' do + it 'detect changes when setup changes' do + expect(component.pending_changes?).to be_falsey + + procedure.draft_revision.ineligibilite_message = 'changed' + expect(component.pending_changes?).to be_falsey + + procedure.reload + procedure.draft_revision.ineligibilite_enabled = true + expect(component.pending_changes?).to be_falsey + + procedure.reload + procedure.draft_revision.ineligibilite_rules = {} + expect(component.pending_changes?).to be_falsey + end + end + + context 'when procedure is published' do + let(:procedure) { create(:procedure, :published) } + it 'detect changes when setup changes' do + expect(component.pending_changes?).to be_falsey + + procedure.draft_revision.ineligibilite_message = 'changed' + expect(component.pending_changes?).to be_truthy + + procedure.reload + procedure.draft_revision.ineligibilite_enabled = true + expect(component.pending_changes?).to be_truthy + + procedure.reload + procedure.draft_revision.ineligibilite_rules = {} + expect(component.pending_changes?).to be_truthy + end + end + end +end diff --git a/spec/components/procedures/pending_republish_component_spec.rb b/spec/components/procedures/pending_republish_component_spec.rb new file mode 100644 index 000000000..a5e301a20 --- /dev/null +++ b/spec/components/procedures/pending_republish_component_spec.rb @@ -0,0 +1,14 @@ +describe Procedure::PendingRepublishComponent, type: :component do + subject { render_inline(described_class.new(render_if:, procedure: build(:procedure, id: 1))) } + let(:page) { subject } + describe 'render_if' do + context 'when false' do + let(:render_if) { false } + it { expect(page).not_to have_text('Ces modifications ne seront appliquées') } + end + context 'when true' do + let(:render_if) { true } + it { expect(page).to have_text('Ces modifications ne seront appliquées') } + end + end +end diff --git a/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb b/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb new file mode 100644 index 000000000..5c8f94628 --- /dev/null +++ b/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb @@ -0,0 +1,231 @@ +describe Administrateurs::IneligibiliteRulesController, type: :controller do + include Logic + let(:user) { create(:user) } + let(:admin) { create(:administrateur, user: create(:user)) } + let(:procedure) { create(:procedure, administrateurs: [admin], types_de_champ_public:) } + let(:types_de_champ_public) { [] } + + describe 'condition management' do + before { sign_in(admin.user) } + + let(:default_params) do + { + procedure_id: procedure.id, + revision_id: procedure.draft_revision.id + } + end + + describe '#add_row' do + subject { post :add_row, params: default_params, format: :turbo_stream } + + context 'without any row' do + it 'creates an empty condition' do + expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules } + .from(nil) + .to(empty_operator(empty, empty)) + end + end + + context 'with row' do + before do + procedure.draft_revision.ineligibilite_rules = empty_operator(empty, empty) + procedure.draft_revision.save! + end + + it 'add one more creates an empty condition' do + expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules } + .from(empty_operator(empty, empty)) + .to(ds_and([ + empty_operator(empty, empty), + empty_operator(empty, empty) + ])) + end + end + end + + describe 'delete_row' do + let(:condition_form) do + { + top_operator_name: Logic::And.name, + rows: [ + { + targeted_champ: empty.to_json, + operator_name: Logic::EmptyOperator, + value: empty.to_json + }, + { + targeted_champ: empty.to_json, + operator_name: Logic::EmptyOperator, + value: empty.to_json + } + ] + } + end + let(:initial_condition) do + ds_and([ + empty_operator(empty, empty), + empty_operator(empty, empty) + ]) + end + + subject { delete :delete_row, params: default_params.merge(row_index: 0, procedure_revision: { condition_form: }), format: :turbo_stream } + it 'remove condition' do + procedure.draft_revision.update(ineligibilite_rules: initial_condition) + + expect { subject } + .to change { procedure.draft_revision.reload.ineligibilite_rules } + .from(initial_condition) + .to(empty_operator(empty, empty)) + end + end + + context 'simple tdc' do + let(:types_de_champ_public) { [{ type: :yes_no }] } + let(:yes_no_tdc) { procedure.draft_revision.types_de_champ_for(scope: :public).first } + let(:targeted_champ) { champ_value(yes_no_tdc.stable_id).to_json } + + describe '#change_targeted_champ' do + let(:condition_form) do + { + rows: [ + { + targeted_champ: targeted_champ, + operator_name: Logic::Eq.name, + value: constant(true).to_json + } + ] + } + end + subject { patch :change_targeted_champ, params: default_params.merge(procedure_revision: { condition_form: }), format: :turbo_stream } + it 'update condition' do + expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules } + .from(nil) + .to(ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) + end + end + + describe '#update' do + let(:value) { constant(true).to_json } + let(:operator_name) { Logic::Eq.name } + let(:condition_form) do + { + rows: [ + { + targeted_champ: targeted_champ, + operator_name: operator_name, + value: value + } + ] + } + end + subject { patch :update, params: default_params.merge(procedure_revision: { condition_form: condition_form }), format: :turbo_stream } + it 'updates condition' do + expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules } + .from(nil) + .to(ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) + end + end + end + + context 'repetition tdc' do + let(:types_de_champ_public) { [{ type: :repetition, children: [{ type: :yes_no }] }] } + let(:yes_no_tdc) { procedure.draft_revision.types_de_champ_for(scope: :public).find { _1.type_champ == 'yes_no' } } + let(:targeted_champ) { champ_value(yes_no_tdc.stable_id).to_json } + let(:condition_form) do + { + rows: [ + { + targeted_champ: targeted_champ, + operator_name: Logic::Eq.name, + value: constant(true).to_json + } + ] + } + end + subject { patch :change_targeted_champ, params: default_params.merge(procedure_revision: { condition_form: }), format: :turbo_stream } + describe "#update" do + it 'update condition' do + expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules } + .from(nil) + .to(ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) + end + end + + describe '#change_targeted_champ' do + let(:condition_form) do + { + rows: [ + { + targeted_champ: targeted_champ, + operator_name: Logic::Eq.name, + value: constant(true).to_json + } + ] + } + end + subject { patch :change_targeted_champ, params: default_params.merge(procedure_revision: { condition_form: }), format: :turbo_stream } + it 'update condition' do + expect { subject }.to change { procedure.draft_revision.reload.ineligibilite_rules } + .from(nil) + .to(ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) + end + end + end + end + + describe '#edit' do + subject { get :edit, params: { procedure_id: procedure.id } } + + context 'when user is not signed in' do + it { is_expected.to redirect_to(new_user_session_path) } + end + + context 'when user is signed in but not admin of procedure' do + before { sign_in(user) } + it { is_expected.to redirect_to(new_user_session_path) } + end + + context 'when user is signed as admin' do + before do + sign_in(admin.user) + subject + end + + it { is_expected.to have_http_status(200) } + + context 'rendered without tdc' do + let(:types_de_champ_public) { [] } + render_views + + it { expect(response.body).to have_link("Ajouter un champ supportant les critères d’inéligibilité") } + end + + context 'rendered with tdc' do + let(:types_de_champ_public) { [{ type: :yes_no }] } + render_views + + it { expect(response.body).not_to have_link("Ajouter un champ supportant les critères d’inéligibilité") } + end + end + end + + describe 'change' do + let(:params) do + { + procedure_id: procedure.id, + procedure_revision: { + ineligibilite_message: 'panpan', + ineligibilite_enabled: '1' + } + } + end + before { sign_in(admin.user) } + it 'works' do + patch :change, params: params + draft_revision = procedure.reload.draft_revision + expect(draft_revision.ineligibilite_message).to eq('panpan') + expect(draft_revision.ineligibilite_enabled).to eq(true) + expect(response).to redirect_to(edit_admin_procedure_ineligibilite_rules_path(procedure)) + end + end +end diff --git a/spec/system/administrateurs/procedure_ineligibilite_spec.rb b/spec/system/administrateurs/procedure_ineligibilite_spec.rb new file mode 100644 index 000000000..9db80cf59 --- /dev/null +++ b/spec/system/administrateurs/procedure_ineligibilite_spec.rb @@ -0,0 +1,45 @@ +describe 'Administrateurs can edit procedures', js: true do + include Logic + + let(:procedure) { create(:procedure, administrateurs: [create(:administrateur)]) } + before do + login_as procedure.administrateurs.first.user, scope: :user + end + + scenario 'setup eligibilite' do + # explain no champ compatible + visit admin_procedure_path(procedure) + expect(page).to have_content("Champs à configurer") + + # explain which champs are compatible + visit edit_admin_procedure_ineligibilite_rules_path(procedure) + expect(page).to have_content("Inéligibilité des dossiers") + expect(page).to have_content("Pour configurer l’inéligibilité des dossiers, votre formulaire doit comporter au moins un champ supportant les critères d’inéligibilité. Il vous faut donc ajouter au moins un des champs suivant à votre formulaire : ") + click_on "Ajouter un champ supportant les critères d’inéligibilité" + + # setup a compatible champ + expect(page).to have_content('Champs du formulaire') + click_on 'Ajouter un champ' + select "Oui/Non" + fill_in "Libellé du champ", with: "Un champ oui non" + click_on "Revenir à l'écran de gestion" + procedure.reload + first_tdc = procedure.draft_revision.types_de_champ.first + # back to procedure dashboard, explain you can set it up now + expect(page).to have_content('À configurer') + visit edit_admin_procedure_ineligibilite_rules_path(procedure) + + # setup rules and stuffs + expect(page).to have_content("Inéligibilité des dossiers") + fill_in "Message d’inéligibilité", with: "vous n'etes pas eligible" + find('label', text: 'Inéligibilité des dossiers').click + click_on "Ajouter une règle d’inéligibilité" + all('select').first.select 'Un champ oui non' + click_on 'Enregistrer' + + # rules are setup + wait_until { procedure.reload.draft_revision.ineligibilite_enabled == true } + expect(procedure.draft_revision.ineligibilite_message).to eq("vous n'etes pas eligible") + expect(procedure.draft_revision.ineligibilite_rules).to eq(ds_eq(champ_value(first_tdc.stable_id), constant(true))) + end +end From 5de4ce889f4f7f8717388852e73bc5e1978f98ec Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:30:33 +0200 Subject: [PATCH 0367/1532] feat(ProcedureRevision.ineligibilites_rules): keep track of changes and show it to admin for republication --- .../procedure/revision_changes_component.rb | 12 +- .../revision_changes_component.fr.yml | 7 + .../revision_changes_component.html.haml | 6 +- app/models/concerns/dossier_rebase_concern.rb | 2 +- app/models/procedure.rb | 10 +- app/models/procedure_revision.rb | 35 +- app/models/procedure_revision_change.rb | 74 ++- .../procedures/_publication_form.html.haml | 2 +- .../procedures/modifications.html.haml | 3 +- .../administrateurs/procedures/show.html.haml | 2 +- spec/models/procedure_revision_spec.rb | 563 +++++++++++------- 11 files changed, 458 insertions(+), 258 deletions(-) diff --git a/app/components/procedure/revision_changes_component.rb b/app/components/procedure/revision_changes_component.rb index e266f13e2..af786e6bc 100644 --- a/app/components/procedure/revision_changes_component.rb +++ b/app/components/procedure/revision_changes_component.rb @@ -1,9 +1,13 @@ class Procedure::RevisionChangesComponent < ApplicationComponent - def initialize(changes:, previous_revision:) - @changes = changes + def initialize(new_revision:, previous_revision:) @previous_revision = previous_revision - @public_move_changes, @private_move_changes = changes.filter { _1.op == :move }.partition { !_1.private? } - @delete_champ_warning = !total_dossiers.zero? && !@changes.all?(&:can_rebase?) + @new_revision = new_revision + + @tdc_changes = previous_revision.compare_types_de_champ(new_revision) + @public_move_changes, @private_move_changes = @tdc_changes.filter { _1.op == :move }.partition { !_1.private? } + @delete_champ_warning = !total_dossiers.zero? && !@tdc_changes.all?(&:can_rebase?) + + @ineligibilite_rules_changes = previous_revision.compare_ineligibilite_rules(new_revision) end private diff --git a/app/components/procedure/revision_changes_component/revision_changes_component.fr.yml b/app/components/procedure/revision_changes_component/revision_changes_component.fr.yml index 10009ce1e..3228c76a8 100644 --- a/app/components/procedure/revision_changes_component/revision_changes_component.fr.yml +++ b/app/components/procedure/revision_changes_component/revision_changes_component.fr.yml @@ -80,3 +80,10 @@ fr: update_expression_reguliere_exemple_text: L’exemple d’expression régulière de l’annotation privée « %{label} » a été modifiée. Le nouvel exemple est « %{to} ». remove_expression_reguliere_error_message: Le message d’erreur de l’expression régulière de l’annotation privée « %{label} » a été supprimé. update_expression_reguliere_error_message: Le message d’erreur de l’expression régulière de l’annotation privée « %{label} » a été modifiée. Le nouveau message est « %{to} ». + ineligibilite_rules: + add: La condition d’inéligibilité « %{new_condition} » a été ajoutée. + remove: La condition d’inéligibilité « %{previous_condition} » a été supprimée + update: La conditon d’inéligibilité « %{previous_condition} » a été changée pour « %{new_condition} » + enabled: "L’inéligibilité des dossiers a été activée" + disabled: "L’inéligibilité des dossiers a été désactivée" + message_updated: "Le message d’inéligibilité a été changé pour « %{ineligibilite_message} »" \ No newline at end of file diff --git a/app/components/procedure/revision_changes_component/revision_changes_component.html.haml b/app/components/procedure/revision_changes_component/revision_changes_component.html.haml index ba19a0dd9..ed7f550c8 100644 --- a/app/components/procedure/revision_changes_component/revision_changes_component.html.haml +++ b/app/components/procedure/revision_changes_component/revision_changes_component.html.haml @@ -2,7 +2,7 @@ - list.with_empty do = t('.no_changes') - - @changes.each do |change| + - @tdc_changes.each do |change| - prefix = change.private? ? 'private' : 'public' - case change.op - when :add @@ -176,3 +176,7 @@ - list.with_item do .fr-alert.fr-alert--warning.fr-mt-1v = t(".invalid_routing_rules_alert") + + - @ineligibilite_rules_changes.each do |change| + - list.with_item do + = t(".ineligibilite_rules.#{change.op}", **change.i18n_params) diff --git a/app/models/concerns/dossier_rebase_concern.rb b/app/models/concerns/dossier_rebase_concern.rb index dd6395dc2..49807793c 100644 --- a/app/models/concerns/dossier_rebase_concern.rb +++ b/app/models/concerns/dossier_rebase_concern.rb @@ -22,7 +22,7 @@ module DossierRebaseConcern end def pending_changes - procedure.published_revision.present? ? revision.compare(procedure.published_revision) : [] + procedure.published_revision.present? ? revision.compare_types_de_champ(procedure.published_revision) : [] end def can_rebase_mandatory_change?(stable_id) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index af398a991..21f272372 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -431,11 +431,15 @@ class Procedure < ApplicationRecord def draft_changed? preload_draft_and_published_revisions - !brouillon? && published_revision.different_from?(draft_revision) && revision_changes.present? + !brouillon? && (types_de_champ_revision_changes.present? || ineligibilite_rules_revision_changes.present?) end - def revision_changes - published_revision.compare(draft_revision) + def types_de_champ_revision_changes + published_revision.compare_types_de_champ(draft_revision) + end + + def ineligibilite_rules_revision_changes + published_revision.compare_ineligibilite_rules(draft_revision) end def preload_draft_and_published_revisions diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index 2b56ecf80..a3e16f592 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -148,16 +148,18 @@ class ProcedureRevision < ApplicationRecord !draft? end - def different_from?(revision) - revision_types_de_champ != revision.revision_types_de_champ - end - - def compare(revision) + def compare_types_de_champ(revision) changes = [] changes += compare_revision_types_de_champ(revision_types_de_champ, revision.revision_types_de_champ) changes end + def compare_ineligibilite_rules(revision) + changes = [] + changes += compare_revision_ineligibilite_rules(revision) + changes + end + def dossier_for_preview(user) dossier = Dossier .create_with(autorisation_donnees: true) @@ -334,6 +336,29 @@ class ProcedureRevision < ApplicationRecord end end + def compare_revision_ineligibilite_rules(new_revision) + from_ineligibilite_rules = ineligibilite_rules + to_ineligibilite_rules = new_revision.ineligibilite_rules + changes = [] + + if from_ineligibilite_rules.present? && to_ineligibilite_rules.blank? + changes << ProcedureRevisionChange::RemoveEligibiliteRuleChange + end + if from_ineligibilite_rules.blank? && to_ineligibilite_rules.present? + changes << ProcedureRevisionChange::AddEligibiliteRuleChange + end + if from_ineligibilite_rules != to_ineligibilite_rules + changes << ProcedureRevisionChange::UpdateEligibiliteRuleChange + end + if ineligibilite_message != new_revision.ineligibilite_message + changes << ProcedureRevisionChange::UpdateEligibiliteMessageChange + end + if ineligibilite_enabled != new_revision.ineligibilite_enabled + changes << (new_revision.ineligibilite_enabled ? ProcedureRevisionChange::EligibiliteEnabledChange : ProcedureRevisionChange::EligibiliteDisabledChange) + end + changes.map { _1.new(self, new_revision) } + end + def compare_type_de_champ(from_type_de_champ, to_type_de_champ, from_coordinates, to_coordinates) changes = [] if from_type_de_champ.type_champ != to_type_de_champ.type_champ diff --git a/app/models/procedure_revision_change.rb b/app/models/procedure_revision_change.rb index fc412cc26..7d99f0fd2 100644 --- a/app/models/procedure_revision_change.rb +++ b/app/models/procedure_revision_change.rb @@ -1,17 +1,19 @@ class ProcedureRevisionChange - attr_reader :type_de_champ - def initialize(type_de_champ) - @type_de_champ = type_de_champ + class TypeDeChange + attr_reader :type_de_champ + def initialize(type_de_champ) + @type_de_champ = type_de_champ + end + + def label = @type_de_champ.libelle + def stable_id = @type_de_champ.stable_id + def private? = @type_de_champ.private? + def child? = @type_de_champ.child? + + def to_h = { op:, stable_id:, label:, private: private? } end - def label = @type_de_champ.libelle - def stable_id = @type_de_champ.stable_id - def private? = @type_de_champ.private? - def child? = @type_de_champ.child? - - def to_h = { op:, stable_id:, label:, private: private? } - - class AddChamp < ProcedureRevisionChange + class AddChamp < TypeDeChange def initialize(type_de_champ) super(type_de_champ) end @@ -23,7 +25,7 @@ class ProcedureRevisionChange def to_h = super.merge(mandatory: mandatory?) end - class RemoveChamp < ProcedureRevisionChange + class RemoveChamp < TypeDeChange def initialize(type_de_champ) super(type_de_champ) end @@ -32,7 +34,7 @@ class ProcedureRevisionChange def can_rebase?(dossier = nil) = true end - class MoveChamp < ProcedureRevisionChange + class MoveChamp < TypeDeChange attr_reader :from, :to def initialize(type_de_champ, from, to) @@ -46,7 +48,7 @@ class ProcedureRevisionChange def to_h = super.merge(from:, to:) end - class UpdateChamp < ProcedureRevisionChange + class UpdateChamp < TypeDeChange attr_reader :attribute, :from, :to def initialize(type_de_champ, attribute, from, to) @@ -75,4 +77,48 @@ class ProcedureRevisionChange end end end + + class EligibiliteRulesChange + attr_reader :previous_revision, :new_revision + def initialize(previous_revision, new_revision) + @previous_revision = previous_revision + @new_revision = new_revision + @previous_ineligibilite_rules = @previous_revision.ineligibilite_rules + @new_ineligibilite_rules = @new_revision.ineligibilite_rules + end + + def i18n_params + { + previous_condition: @previous_ineligibilite_rules&.to_s(previous_revision.types_de_champ.filter { @previous_ineligibilite_rules.sources.include? _1.stable_id }), + new_condition: @new_ineligibilite_rules&.to_s(new_revision.types_de_champ.filter { @new_ineligibilite_rules.sources.include? _1.stable_id }) + } + end + end + + class AddEligibiliteRuleChange < EligibiliteRulesChange + def op = :add + end + + class RemoveEligibiliteRuleChange < EligibiliteRulesChange + def op = :remove + end + + class UpdateEligibiliteRuleChange < EligibiliteRulesChange + def op = :update + end + + class EligibiliteEnabledChange < EligibiliteRulesChange + def op = :enabled + def i18n_params = {} + end + + class EligibiliteDisabledChange < EligibiliteRulesChange + def op = :disabled + def i18n_params = {} + end + + class UpdateEligibiliteMessageChange < EligibiliteRulesChange + def op = :message_updated + def i18n_params = { ineligibilite_message: @new_revision.ineligibilite_message } + end end diff --git a/app/views/administrateurs/procedures/_publication_form.html.haml b/app/views/administrateurs/procedures/_publication_form.html.haml index 0c9cc8454..d8d96870e 100644 --- a/app/views/administrateurs/procedures/_publication_form.html.haml +++ b/app/views/administrateurs/procedures/_publication_form.html.haml @@ -8,7 +8,7 @@ %p.mb-2= t('.draft_changed_procedure_alert') = render Dsfr::AlertComponent.new(state: :info, size: :sm, extra_class_names: 'fr-mb-2w') do |c| - c.with_body do - = render Procedure::RevisionChangesComponent.new changes: procedure.revision_changes, previous_revision: procedure.published_revision + = render Procedure::RevisionChangesComponent.new new_revision: procedure.draft_revision, previous_revision: procedure.published_revision - if procedure.close? = render partial: 'publication_form_inputs', locals: { procedure: procedure, closed_procedures: @closed_procedures, form: f } - elsif @procedure.brouillon? && @procedure.missing_steps.empty? diff --git a/app/views/administrateurs/procedures/modifications.html.haml b/app/views/administrateurs/procedures/modifications.html.haml index 978fee30f..73b8673bd 100644 --- a/app/views/administrateurs/procedures/modifications.html.haml +++ b/app/views/administrateurs/procedures/modifications.html.haml @@ -13,7 +13,6 @@ - previous_revision = nil - @procedure.revisions.each do |revision| - if previous_revision.present? && !revision.draft? - - changes = previous_revision.compare(revision) - dossiers = revision.dossiers.visible_by_administration - dossiers_en_construction_count = dossiers.state_en_construction.count - dossiers_en_instruction_count = dossiers.state_en_instruction.count @@ -31,7 +30,7 @@ %p= t('.dossiers_en_construction', count: dossiers_en_construction_count) - elsif !dossiers_en_instruction_count.zero? %p= t('.dossiers_en_instruction', count: dossiers_en_instruction_count) - = render Procedure::RevisionChangesComponent.new changes:, previous_revision: + = render Procedure::RevisionChangesComponent.new new_revision: revision, previous_revision: - previous_revision = revision = render Procedure::FixedFooterComponent.new(procedure: @procedure) diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml index 4d63d909b..4463a86cf 100644 --- a/app/views/administrateurs/procedures/show.html.haml +++ b/app/views/administrateurs/procedures/show.html.haml @@ -30,8 +30,8 @@ - if @procedure.draft_changed? = render Dsfr::CalloutComponent.new(title: t(:has_changes, scope: [:administrateurs, :revision_changes]), icon: "fr-fi-information-line") do |c| - c.with_body do - = render Procedure::RevisionChangesComponent.new changes: @procedure.revision_changes, previous_revision: @procedure.published_revision = render Procedure::ErrorsSummary.new(procedure: @procedure, validation_context: :publication) + = render Procedure::RevisionChangesComponent.new new_revision: @procedure.draft_revision, previous_revision: @procedure.published_revision - c.with_bottom do %ul.fr-mt-2w.fr-btns-group.fr-btns-group--inline diff --git a/spec/models/procedure_revision_spec.rb b/spec/models/procedure_revision_spec.rb index 4c7a17ba3..434d82e44 100644 --- a/spec/models/procedure_revision_spec.rb +++ b/spec/models/procedure_revision_spec.rb @@ -347,306 +347,417 @@ describe ProcedureRevision do end end - describe '#compare' do + describe '#compare_types_de_champ' do include Logic - - let(:first_tdc) { draft.types_de_champ_public.first } - let(:second_tdc) { draft.types_de_champ_public.second } let(:new_draft) { procedure.create_new_revision } + subject { procedure.active_revision.compare_types_de_champ(new_draft.reload).map(&:to_h) } - subject { procedure.active_revision.compare(new_draft.reload).map(&:to_h) } + describe 'when tdcs changes' do + let(:first_tdc) { draft.types_de_champ_public.first } + let(:second_tdc) { draft.types_de_champ_public.second } - context 'with a procedure with 2 tdcs' do - let(:procedure) do - create(:procedure, types_de_champ_public: [ - { type: :integer_number, libelle: 'l1' }, - { type: :text, libelle: 'l2' } - ]) + context 'with a procedure with 2 tdcs' do + let(:procedure) do + create(:procedure, types_de_champ_public: [ + { type: :integer_number, libelle: 'l1' }, + { type: :text, libelle: 'l2' } + ]) + end + + context 'when a condition is added' do + before do + second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) + second.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(3))) + end + + it do + is_expected.to eq([ + { + attribute: :condition, + from: nil, + label: "l2", + op: :update, + private: false, + stable_id: second_tdc.stable_id, + to: "(l1 == 3)" + } + ]) + end + end + + context 'when a condition is removed' do + before do + second_tdc.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(2))) + draft.reload + + second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) + second.update(condition: nil) + end + + it do + is_expected.to eq([ + { + attribute: :condition, + from: "(l1 == 2)", + label: "l2", + op: :update, + private: false, + stable_id: second_tdc.stable_id, + to: nil + } + ]) + end + end + + context 'when a condition is changed' do + before do + second_tdc.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(2))) + draft.reload + + second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) + second.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(3))) + end + + it do + is_expected.to eq([ + { + attribute: :condition, + from: "(l1 == 2)", + label: "l2", + op: :update, + private: false, + stable_id: second_tdc.stable_id, + to: "(l1 == 3)" + } + ]) + end + end end - context 'when a condition is added' do - before do - second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) - second.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(3))) + context 'when a type de champ is added' do + let(:procedure) { create(:procedure) } + let(:new_tdc) do + new_draft.add_type_de_champ( + type_champ: TypeDeChamp.type_champs.fetch(:text), + libelle: "Un champ text" + ) end + before { new_tdc } + it do is_expected.to eq([ { - attribute: :condition, - from: nil, - label: "l2", - op: :update, + op: :add, + label: "Un champ text", private: false, - stable_id: second_tdc.stable_id, - to: "(l1 == 3)" + mandatory: false, + stable_id: new_tdc.stable_id } ]) end end - context 'when a condition is removed' do - before do - second_tdc.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(2))) - draft.reload + context 'when a type de champ is changed' do + context 'when libelle, description, and mandatory are changed' do + let(:procedure) { create(:procedure, :with_type_de_champ) } - second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) - second.update(condition: nil) + before do + updated_tdc = new_draft.find_and_ensure_exclusive_use(first_tdc.stable_id) + + updated_tdc.update(libelle: 'modifier le libelle', description: 'une description', mandatory: !updated_tdc.mandatory) + end + + it do + is_expected.to eq([ + { + op: :update, + attribute: :libelle, + label: first_tdc.libelle, + private: false, + from: first_tdc.libelle, + to: "modifier le libelle", + stable_id: first_tdc.stable_id + }, + { + op: :update, + attribute: :description, + label: first_tdc.libelle, + private: false, + from: first_tdc.description, + to: "une description", + stable_id: first_tdc.stable_id + }, + { + op: :update, + attribute: :mandatory, + label: first_tdc.libelle, + private: false, + from: false, + to: true, + stable_id: first_tdc.stable_id + } + ]) + end + end + + context 'when collapsible_explanation_enabled and collapsible_explanation_text are changed' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :explication }]) } + + before do + updated_tdc = new_draft.find_and_ensure_exclusive_use(first_tdc.stable_id) + + updated_tdc.update(collapsible_explanation_enabled: "1", collapsible_explanation_text: 'afficher au clique') + end + it do + is_expected.to eq([ + { + op: :update, + attribute: :collapsible_explanation_enabled, + label: first_tdc.libelle, + private: first_tdc.private?, + from: false, + to: true, + stable_id: first_tdc.stable_id + }, + { + op: :update, + attribute: :collapsible_explanation_text, + label: first_tdc.libelle, + private: first_tdc.private?, + from: nil, + to: 'afficher au clique', + stable_id: first_tdc.stable_id + } + ]) + end + end + end + + context 'when a type de champ is moved' do + let(:procedure) { create(:procedure, types_de_champ_public: Array.new(3) { { type: :text } }) } + let(:new_draft_second_tdc) { new_draft.types_de_champ_public.second } + let(:new_draft_third_tdc) { new_draft.types_de_champ_public.third } + + before do + new_draft_second_tdc + new_draft_third_tdc + new_draft.move_type_de_champ(new_draft_second_tdc.stable_id, 2) end it do is_expected.to eq([ { - attribute: :condition, - from: "(l1 == 2)", - label: "l2", - op: :update, + op: :move, + label: new_draft_third_tdc.libelle, private: false, - stable_id: second_tdc.stable_id, - to: nil - } - ]) - end - end - - context 'when a condition is changed' do - before do - second_tdc.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(2))) - draft.reload - - second = new_draft.find_and_ensure_exclusive_use(second_tdc.stable_id) - second.update(condition: ds_eq(champ_value(first_tdc.stable_id), constant(3))) - end - - it do - is_expected.to eq([ + from: 2, + to: 1, + stable_id: new_draft_third_tdc.stable_id + }, { - attribute: :condition, - from: "(l1 == 2)", - label: "l2", - op: :update, + op: :move, + label: new_draft_second_tdc.libelle, private: false, - stable_id: second_tdc.stable_id, - to: "(l1 == 3)" + from: 1, + to: 2, + stable_id: new_draft_second_tdc.stable_id } ]) end end - end - context 'when a type de champ is added' do - let(:procedure) { create(:procedure) } - let(:new_tdc) do - new_draft.add_type_de_champ( - type_champ: TypeDeChamp.type_champs.fetch(:text), - libelle: "Un champ text" - ) - end - - before { new_tdc } - - it do - is_expected.to eq([ - { - op: :add, - label: "Un champ text", - private: false, - mandatory: false, - stable_id: new_tdc.stable_id - } - ]) - end - end - - context 'when a type de champ is changed' do - context 'when libelle, description, and mandatory are changed' do + context 'when a type de champ is removed' do let(:procedure) { create(:procedure, :with_type_de_champ) } before do - updated_tdc = new_draft.find_and_ensure_exclusive_use(first_tdc.stable_id) - - updated_tdc.update(libelle: 'modifier le libelle', description: 'une description', mandatory: !updated_tdc.mandatory) + new_draft.remove_type_de_champ(first_tdc.stable_id) end it do is_expected.to eq([ { - op: :update, - attribute: :libelle, + op: :remove, label: first_tdc.libelle, private: false, - from: first_tdc.libelle, - to: "modifier le libelle", - stable_id: first_tdc.stable_id - }, - { - op: :update, - attribute: :description, - label: first_tdc.libelle, - private: false, - from: first_tdc.description, - to: "une description", - stable_id: first_tdc.stable_id - }, - { - op: :update, - attribute: :mandatory, - label: first_tdc.libelle, - private: false, - from: false, - to: true, stable_id: first_tdc.stable_id } ]) end end - context 'when collapsible_explanation_enabled and collapsible_explanation_text are changed' do - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :explication }]) } + context 'when a child type de champ is transformed into a drop_down_list' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :text, libelle: 'sub type de champ' }, { type: :integer_number }] }]) } before do - updated_tdc = new_draft.find_and_ensure_exclusive_use(first_tdc.stable_id) - - updated_tdc.update(collapsible_explanation_enabled: "1", collapsible_explanation_text: 'afficher au clique') + child = new_draft.children_of(new_draft.types_de_champ_public.last).first + new_draft.find_and_ensure_exclusive_use(child.stable_id).update(type_champ: :drop_down_list, drop_down_options: ['one', 'two']) end + it do is_expected.to eq([ { op: :update, - attribute: :collapsible_explanation_enabled, - label: first_tdc.libelle, - private: first_tdc.private?, - from: false, - to: true, - stable_id: first_tdc.stable_id + attribute: :type_champ, + label: "sub type de champ", + private: false, + from: "text", + to: "drop_down_list", + stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id }, { op: :update, - attribute: :collapsible_explanation_text, - label: first_tdc.libelle, - private: first_tdc.private?, - from: nil, - to: 'afficher au clique', - stable_id: first_tdc.stable_id + attribute: :drop_down_options, + label: "sub type de champ", + private: false, + from: [], + to: ["one", "two"], + stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id + } + ]) + end + end + + context 'when a child type de champ is transformed into a map' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :text, libelle: 'sub type de champ' }, { type: :integer_number }] }]) } + + before do + child = new_draft.children_of(new_draft.types_de_champ_public.last).first + new_draft.find_and_ensure_exclusive_use(child.stable_id).update(type_champ: :carte, options: { cadastres: true, znieff: true }) + end + + it do + is_expected.to eq([ + { + op: :update, + attribute: :type_champ, + label: "sub type de champ", + private: false, + from: "text", + to: "carte", + stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id + }, + { + op: :update, + attribute: :carte_layers, + label: "sub type de champ", + private: false, + from: [], + to: [:cadastres, :znieff], + stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id } ]) end end end + end - context 'when a type de champ is moved' do - let(:procedure) { create(:procedure, types_de_champ_public: Array.new(3) { { type: :text } }) } - let(:new_draft_second_tdc) { new_draft.types_de_champ_public.second } - let(:new_draft_third_tdc) { new_draft.types_de_champ_public.third } + describe 'compare_ineligibilite_rules' do + include Logic + let(:new_draft) { procedure.create_new_revision } + subject { procedure.active_revision.compare_ineligibilite_rules(new_draft.reload) } - before do - new_draft_second_tdc - new_draft_third_tdc - new_draft.move_type_de_champ(new_draft_second_tdc.stable_id, 2) + context 'when ineligibilite_rules changes' do + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :yes_no }] } + let(:yes_no_tdc) { new_draft.types_de_champ_public.first } + + context 'when nothing changed' do + it { is_expected.to be_empty } end - it do - is_expected.to eq([ - { - op: :move, - label: new_draft_third_tdc.libelle, - private: false, - from: 2, - to: 1, - stable_id: new_draft_third_tdc.stable_id - }, - { - op: :move, - label: new_draft_second_tdc.libelle, - private: false, - from: 1, - to: 2, - stable_id: new_draft_second_tdc.stable_id - } - ]) + context 'when ineligibilite_rules added' do + before do + new_draft.update!(ineligibilite_rules: ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::AddEligibiliteRuleChange)) } + end + + context 'when ineligibilite_rules removed' do + before do + procedure.published_revision.update!(ineligibilite_rules: ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::RemoveEligibiliteRuleChange)) } + end + + context 'when ineligibilite_rules changed' do + before do + procedure.published_revision.update!(ineligibilite_rules: ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) + new_draft.update!(ineligibilite_rules: ds_and([ + ds_eq(champ_value(yes_no_tdc.stable_id), constant(true)), + empty_operator(empty, empty) + ])) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::UpdateEligibiliteRuleChange)) } + end + + context 'when when ineligibilite_enabled changes from false to true' do + before do + procedure.published_revision.update!(ineligibilite_enabled: false, ineligibilite_message: :required) + new_draft.update!(ineligibilite_enabled: true, ineligibilite_message: :required) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::EligibiliteEnabledChange)) } + end + + context 'when ineligibilite_enabled changes from true to false' do + before do + procedure.published_revision.update!(ineligibilite_enabled: true, ineligibilite_message: :required) + new_draft.update!(ineligibilite_enabled: false, ineligibilite_message: :required) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::EligibiliteDisabledChange)) } + end + + context 'when ineligibilite_message changes' do + before do + procedure.published_revision.update!(ineligibilite_message: :a) + new_draft.update!(ineligibilite_message: :b) + end + + it { is_expected.to include(an_instance_of(ProcedureRevisionChange::UpdateEligibiliteMessageChange)) } end end + end - context 'when a type de champ is removed' do - let(:procedure) { create(:procedure, :with_type_de_champ) } - - before do - new_draft.remove_type_de_champ(first_tdc.stable_id) - end - - it do - is_expected.to eq([ - { - op: :remove, - label: first_tdc.libelle, - private: false, - stable_id: first_tdc.stable_id - } - ]) - end + describe 'ineligibilite_rules_are_valid?' do + include Logic + let(:procedure) { create(:procedure) } + let(:draft_revision) { procedure.draft_revision } + let(:ineligibilite_message) { 'ok' } + let(:ineligibilite_enabled) { true } + before do + procedure.draft_revision.update(ineligibilite_rules:, ineligibilite_message:, ineligibilite_enabled:) end - context 'when a child type de champ is transformed into a drop_down_list' do - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :text, libelle: 'sub type de champ' }, { type: :integer_number }] }]) } - - before do - child = new_draft.children_of(new_draft.types_de_champ_public.last).first - new_draft.find_and_ensure_exclusive_use(child.stable_id).update(type_champ: :drop_down_list, drop_down_options: ['one', 'two']) - end - - it do - is_expected.to eq([ - { - op: :update, - attribute: :type_champ, - label: "sub type de champ", - private: false, - from: "text", - to: "drop_down_list", - stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id - }, - { - op: :update, - attribute: :drop_down_options, - label: "sub type de champ", - private: false, - from: [], - to: ["one", "two"], - stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id - } - ]) + context 'when ineligibilite_rules are valid' do + let(:ineligibilite_rules) { ds_eq(constant(true), constant(true)) } + it 'is valid' do + expect(draft_revision.validate(:publication)).to be_truthy + expect(draft_revision.validate(:ineligibilite_rules_editor)).to be_truthy end end - - context 'when a child type de champ is transformed into a map' do - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :text, libelle: 'sub type de champ' }, { type: :integer_number }] }]) } - - before do - child = new_draft.children_of(new_draft.types_de_champ_public.last).first - new_draft.find_and_ensure_exclusive_use(child.stable_id).update(type_champ: :carte, options: { cadastres: true, znieff: true }) + context 'when ineligibilite_rules are invalid on simple champ' do + let(:ineligibilite_rules) { ds_eq(constant(true), constant(1)) } + it 'is invalid' do + expect(draft_revision.validate(:publication)).to be_falsey + expect(draft_revision.validate(:ineligibilite_rules_editor)).to be_falsey end - - it do - is_expected.to eq([ - { - op: :update, - attribute: :type_champ, - label: "sub type de champ", - private: false, - from: "text", - to: "carte", - stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id - }, - { - op: :update, - attribute: :carte_layers, - label: "sub type de champ", - private: false, - from: [], - to: [:cadastres, :znieff], - stable_id: new_draft.children_of(new_draft.types_de_champ_public.last).first.stable_id - } - ]) + end + context 'when ineligibilite_rules are invalid on repetition champ' do + let(:ineligibilite_rules) { ds_eq(constant(true), constant(1)) } + let(:procedure) { create(:procedure, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :repetition, children: [{ type: :integer_number }] }] } + let(:tdc_number) { draft_revision.types_de_champ_for(scope: :public).find { _1.type_champ == 'integer_number' } } + let(:ineligibilite_rules) do + ds_eq(champ_value(tdc_number.stable_id), constant(true)) + end + it 'is invalid' do + expect(draft_revision.validate(:publication)).to be_falsey + expect(draft_revision.validate(:ineligibilite_rules_editor)).to be_falsey end end end From 5644692448e922be87c98166cb857d0df95f0cc2 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:33:03 +0200 Subject: [PATCH 0368/1532] feat(Logic.computable?): add computable? to know if a ineligibilite_rules set is computable --- .../concerns/champ_conditional_concern.rb | 4 ++ app/models/dossier.rb | 4 ++ app/models/logic/and.rb | 7 +++ app/models/logic/binary_operator.rb | 9 ++++ app/models/logic/or.rb | 10 +++++ app/models/procedure_revision.rb | 13 ++++++ spec/models/logic/and_spec.rb | 36 ++++++++++++++++ spec/models/logic/binary_operator_spec.rb | 15 +++++++ spec/models/logic/or_spec.rb | 43 +++++++++++++++++++ 9 files changed, 141 insertions(+) diff --git a/app/models/concerns/champ_conditional_concern.rb b/app/models/concerns/champ_conditional_concern.rb index 9e6559be9..63001229d 100644 --- a/app/models/concerns/champ_conditional_concern.rb +++ b/app/models/concerns/champ_conditional_concern.rb @@ -21,6 +21,10 @@ module ChampConditionalConcern end end + def reset_visible # recompute after a dossier update + remove_instance_variable :@visible if instance_variable_defined? :@visible + end + private def champs_for_condition diff --git a/app/models/dossier.rb b/app/models/dossier.rb index e609323bb..14d9cd4f7 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -938,6 +938,10 @@ class Dossier < ApplicationRecord end end + def ineligibilite_rules_computable? + revision.ineligibilite_rules_computable?(champs_for_revision(scope: :public)) + end + def demander_un_avis!(avis) log_dossier_operation(avis.claimant, :demander_un_avis, avis) end diff --git a/app/models/logic/and.rb b/app/models/logic/and.rb index 51537235f..11d31a9c0 100644 --- a/app/models/logic/and.rb +++ b/app/models/logic/and.rb @@ -7,5 +7,12 @@ class Logic::And < Logic::NAryOperator @operands.map { |operand| operand.compute(champs) }.all? end + def computable?(champs = []) + return true if sources.blank? + + champs.filter { _1.stable_id.in?(sources) && _1.visible? } + .all? { _1.value.present? } + end + def to_s(type_de_champs) = "(#{@operands.map { |o| o.to_s(type_de_champs) }.join(' && ')})" end diff --git a/app/models/logic/binary_operator.rb b/app/models/logic/binary_operator.rb index 812fa0605..35f6ce1a7 100644 --- a/app/models/logic/binary_operator.rb +++ b/app/models/logic/binary_operator.rb @@ -42,6 +42,15 @@ class Logic::BinaryOperator < Logic::Term l&.send(operation, r) || false end + def computable?(champs = []) + return true if sources.blank? + + visible_champs_sources = champs.filter { _1.stable_id.in?(sources) && _1.visible? } + + return false if visible_champs_sources.size != sources.size + visible_champs_sources.all? { _1.value.present? } + end + def to_s(type_de_champs) = "(#{@left.to_s(type_de_champs)} #{operation} #{@right.to_s(type_de_champs)})" def ==(other) diff --git a/app/models/logic/or.rb b/app/models/logic/or.rb index a0e2dfeae..96a0fe133 100644 --- a/app/models/logic/or.rb +++ b/app/models/logic/or.rb @@ -7,5 +7,15 @@ class Logic::Or < Logic::NAryOperator @operands.map { |operand| operand.compute(champs) }.any? end + + def computable?(champs = []) + return true if sources.blank? + + visible_champs_sources = champs.filter { _1.stable_id.in?(sources) && _1.visible? } + + return false if visible_champs_sources.blank? + visible_champs_sources.all? { _1.value.present? } || compute(visible_champs_sources) + end + def to_s(type_de_champs = []) = "(#{@operands.map { |o| o.to_s(type_de_champs) }.join(' || ')})" end diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index a3e16f592..7e4f30860 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -269,6 +269,12 @@ class ProcedureRevision < ApplicationRecord types_de_champ_for(scope: :public).filter(&:conditionable?) end + def ineligibilite_rules_computable?(champs) + ineligibilite_enabled && ineligibilite_rules&.computable?(champs) + ensure + champs.map(&:reset_visible) # otherwise @visible is cached, then dossier can be updated. champs are not updated + end + private def compute_estimated_fill_duration @@ -483,6 +489,13 @@ class ProcedureRevision < ApplicationRecord changes end + def ineligibilite_rules_are_valid? + if ineligibilite_rules + ineligibilite_rules.errors(types_de_champ_for(scope: :public).to_a) + .each { errors.add(:ineligibilite_rules, :invalid) } + end + end + def replace_type_de_champ_by_clone(coordinate) cloned_type_de_champ = coordinate.type_de_champ.deep_clone do |original, kopy| ClonePiecesJustificativesService.clone_attachments(original, kopy) diff --git a/spec/models/logic/and_spec.rb b/spec/models/logic/and_spec.rb index 67f319acb..c0eefc8e8 100644 --- a/spec/models/logic/and_spec.rb +++ b/spec/models/logic/and_spec.rb @@ -6,6 +6,42 @@ describe Logic::And do it { expect(and_from([true, true, false]).compute).to be false } end + describe '#computable?' do + let(:champ_1) { create(:champ_integer_number, value: value_1) } + let(:champ_2) { create(:champ_integer_number, value: value_2) } + + let(:logic) do + ds_and([ + greater_than(champ_value(champ_1.stable_id), constant(1)), + less_than(champ_value(champ_2.stable_id), constant(10)) + ]) + end + + subject { logic.computable?([champ_1, champ_2]) } + + context "when none of champs.value are filled, and logic can't be computed" do + let(:value_1) { nil } + let(:value_2) { nil } + it { is_expected.to be_falsey } + end + context "when one champs has a value (that compute to false) the other has not, and logic keeps waiting for the 2nd value" do + let(:value_1) { 1 } + let(:value_2) { nil } + it { is_expected.to be_falsey } + end + context 'when all champs.value are filled, and logic can be computed' do + let(:value_1) { 1 } + let(:value_2) { 10 } + it { is_expected.to be_truthy } + end + context 'when one champs is not visible and the other has a value, and logic can be computed' do + let(:value_1) { 1 } + let(:value_2) { nil } + before { expect(champ_2).to receive(:visible?).and_return(false) } + it { is_expected.to be_truthy } + end + end + describe '#to_s' do it do expect(and_from([true, false, true]).to_s([])).to eq "(Oui && Non && Oui)" diff --git a/spec/models/logic/binary_operator_spec.rb b/spec/models/logic/binary_operator_spec.rb index e27c3b7bc..f816e81e7 100644 --- a/spec/models/logic/binary_operator_spec.rb +++ b/spec/models/logic/binary_operator_spec.rb @@ -28,6 +28,19 @@ describe Logic::BinaryOperator do it { expect(greater_than(constant(2), champ_value(champ.stable_id)).sources).to eq([champ.stable_id]) } it { expect(greater_than(champ_value(champ.stable_id), champ_value(champ2.stable_id)).sources).to eq([champ.stable_id, champ2.stable_id]) } end + + describe '#computable?' do + let(:champ) { create(:champ_integer_number, value: nil) } + + it 'computable?' do + expect(greater_than(champ_value(champ.stable_id), constant(1)).computable?([])).to be(false) + expect(greater_than(champ_value(champ.stable_id), constant(1)).computable?([champ])).to be(false) + allow(champ).to receive(:value).and_return(double(present?: true)) + expect(greater_than(champ_value(champ.stable_id), constant(1)).computable?([champ])).to be(true) + allow(champ).to receive(:visible?).and_return(false) + expect(greater_than(champ_value(champ.stable_id), constant(1)).computable?([champ])).to be(false) + end + end end describe Logic::GreaterThan do @@ -43,6 +56,8 @@ end describe Logic::GreaterThanEq do include Logic + let(:champ) { create(:champ_integer_number, value: nil) } + it 'computes' do expect(greater_than_eq(constant(0), constant(1)).compute).to be(false) expect(greater_than_eq(constant(1), constant(1)).compute).to be(true) diff --git a/spec/models/logic/or_spec.rb b/spec/models/logic/or_spec.rb index 1888587d2..82d5392fb 100644 --- a/spec/models/logic/or_spec.rb +++ b/spec/models/logic/or_spec.rb @@ -7,6 +7,49 @@ describe Logic::Or do it { expect(or_from([false, false, false]).compute).to be false } end + describe '#computable?' do + let(:champ_1) { create(:champ_integer_number, value: value_1) } + let(:champ_2) { create(:champ_integer_number, value: value_2) } + + let(:logic) do + ds_or([ + greater_than(champ_value(champ_1.stable_id), constant(1)), + less_than(champ_value(champ_2.stable_id), constant(10)) + ]) + end + + context 'with all champs' do + subject { logic.computable?([champ_1, champ_2]) } + + context "when none of champs.value are filled, or logic can't be computed" do + let(:value_1) { nil } + let(:value_2) { nil } + it { is_expected.to be_falsey } + end + context "when one champs has a value (that compute to false) the other has not, or logic keeps waiting for the 2nd value" do + let(:value_1) { 1 } + let(:value_2) { nil } + it { is_expected.to be_falsey } + end + context 'when all champs.value are filled, or logic can be computed' do + let(:value_1) { 1 } + let(:value_2) { 10 } + it { is_expected.to be_truthy } + end + context 'when one champs.value and his condition is true, or logic can be computed' do + let(:value_1) { 2 } + let(:value_2) { nil } + it { is_expected.to be_truthy } + end + context 'when one champs is not visible and the other has a value that fails, or logic can be computed' do + let(:value_1) { 1 } + let(:value_2) { nil } + before { expect(champ_2).to receive(:visible?).and_return(false) } + it { is_expected.to be_truthy } + end + end + end + describe '#to_s' do it { expect(or_from([true, false, true]).to_s).to eq "(Oui || Non || Oui)" } end From 2210db3b81a98be7cd6c5242316de9334cff3c69 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:34:17 +0200 Subject: [PATCH 0369/1532] feat(Dossier::EditFooterComponent): disable submit button when inligibilite_rules matches --- .../dossiers/edit_footer_component.rb | 21 ++++++-- .../edit_footer_component.en.yml | 1 + .../edit_footer_component.fr.yml | 1 + .../edit_footer_component.html.haml | 7 ++- app/models/dossier.rb | 8 ++- .../dossiers/edit_footer_component_spec.rb | 50 +++++++++++++++++++ 6 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 spec/components/dossiers/edit_footer_component_spec.rb diff --git a/app/components/dossiers/edit_footer_component.rb b/app/components/dossiers/edit_footer_component.rb index fca7fab45..ac77bbfea 100644 --- a/app/components/dossiers/edit_footer_component.rb +++ b/app/components/dossiers/edit_footer_component.rb @@ -1,4 +1,6 @@ class Dossiers::EditFooterComponent < ApplicationComponent + delegate :can_passer_en_construction?, :ineligibilite_rules_computable?, to: :@dossier + def initialize(dossier:, annotation:) @dossier = dossier @annotation = annotation @@ -14,24 +16,37 @@ class Dossiers::EditFooterComponent < ApplicationComponent @annotation.present? end + def disabled_submit_buttons_options + { + class: 'fr-text--sm fr-mb-0 fr-mr-2w', + data: { 'fr-opened': "true" }, + aria: { controls: 'modal-eligibilite-rules-dialog' } + } + end + def submit_draft_button_options { class: 'fr-btn fr-btn--sm', - disabled: !owner?, + disabled: !owner? || ineligibilite_rules_invalid?, method: :post, - data: { 'disable-with': t('.submitting'), controller: 'autosave-submit' } + data: { 'disable-with': t('.submitting'), controller: 'autosave-submit', turbo_force: :server } } end def submit_en_construction_button_options { class: 'fr-btn fr-btn--sm', + disabled: ineligibilite_rules_invalid?, method: :post, - data: { 'disable-with': t('.submitting'), controller: 'autosave-submit' }, + data: { 'disable-with': t('.submitting'), controller: 'autosave-submit', turbo_force: :server }, form: { id: "form-submit-en-construction" } } end + def ineligibilite_rules_invalid? + ineligibilite_rules_computable? && !can_passer_en_construction? + end + def render? !@dossier.for_procedure_preview? end diff --git a/app/components/dossiers/edit_footer_component/edit_footer_component.en.yml b/app/components/dossiers/edit_footer_component/edit_footer_component.en.yml index 098e6ec0b..b6de7d121 100644 --- a/app/components/dossiers/edit_footer_component/edit_footer_component.en.yml +++ b/app/components/dossiers/edit_footer_component/edit_footer_component.en.yml @@ -2,5 +2,6 @@ en: submit: Submit the file submit_changes: Submit file changes + submit_disabled: File submission disabled submitting: Submitting… invite_notice: You are invited to make amendments to this file but only the owner themselves can submit it. diff --git a/app/components/dossiers/edit_footer_component/edit_footer_component.fr.yml b/app/components/dossiers/edit_footer_component/edit_footer_component.fr.yml index 33937aed6..8ffd062db 100644 --- a/app/components/dossiers/edit_footer_component/edit_footer_component.fr.yml +++ b/app/components/dossiers/edit_footer_component/edit_footer_component.fr.yml @@ -2,5 +2,6 @@ fr: submit: Déposer le dossier submit_changes: Déposer les modifications + submit_disabled: Pourquoi je ne peux pas déposer mon dossier ? submitting: Envoi en cours… invite_notice: En tant qu’invité, vous pouvez remplir ce formulaire – mais le titulaire du dossier doit le déposer lui-même. diff --git a/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml b/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml index 77540bd16..fb4ab8fb1 100644 --- a/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml +++ b/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml @@ -3,8 +3,13 @@ = render Dossiers::AutosaveFooterComponent.new(dossier: @dossier, annotation: annotation?) - if !annotation? && @dossier.can_transition_to_en_construction? + - if ineligibilite_rules_invalid? + = link_to t('.submit_disabled'), "#", disabled_submit_buttons_options = button_to t('.submit'), brouillon_dossier_url(@dossier), submit_draft_button_options - - elsif @dossier.forked_with_changes? + + - if @dossier.forked_with_changes? + - if ineligibilite_rules_invalid? + = link_to t('.submit_disabled'), "#", disabled_submit_buttons_options = button_to t('.submit_changes'), modifier_dossier_url(@dossier.editing_fork_origin), submit_en_construction_button_options diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 14d9cd4f7..e83edfb97 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -156,7 +156,7 @@ class Dossier < ApplicationRecord state :sans_suite event :passer_en_construction, after: :after_passer_en_construction, after_commit: :after_commit_passer_en_construction do - transitions from: :brouillon, to: :en_construction + transitions from: :brouillon, to: :en_construction, guard: :can_passer_en_construction? end event :passer_en_instruction, after: :after_passer_en_instruction, after_commit: :after_commit_passer_en_instruction do @@ -562,6 +562,12 @@ class Dossier < ApplicationRecord procedure.feature_enabled?(:blocking_pending_correction) && pending_correction? end + def can_passer_en_construction? + return true if !revision.ineligibilite_enabled + + !revision.ineligibilite_rules.compute(champs_for_revision(scope: :public)) + end + def can_passer_en_instruction? return false if blocked_with_pending_correction? diff --git a/spec/components/dossiers/edit_footer_component_spec.rb b/spec/components/dossiers/edit_footer_component_spec.rb new file mode 100644 index 000000000..40e60802b --- /dev/null +++ b/spec/components/dossiers/edit_footer_component_spec.rb @@ -0,0 +1,50 @@ +RSpec.describe Dossiers::EditFooterComponent, type: :component do + let(:annotation) { false } + let(:component) { Dossiers::EditFooterComponent.new(dossier:, annotation:) } + + subject { render_inline(component).to_html } + + before { allow(component).to receive(:owner?).and_return(true) } + + context 'when brouillon' do + let(:dossier) { create(:dossier, :brouillon) } + + context 'when dossier can be submitted' do + before { allow(component).to receive(:ineligibilite_rules_invalid?).and_return(false) } + it 'renders submit button without disabled' do + expect(subject).to have_selector('button', text: 'Déposer le dossier') + end + end + + context 'when dossier can not be submitted' do + before { allow(component).to receive(:ineligibilite_rules_invalid?).and_return(true) } + it 'renders submit button with disabled' do + expect(subject).to have_selector('a', text: 'Pourquoi je ne peux pas déposer mon dossier ?') + expect(subject).to have_selector('button[disabled]', text: 'Déposer le dossier') + end + end + end + + context 'when en construction' do + let(:fork_origin) { create(:dossier, :en_construction) } + let(:dossier) { fork_origin.clone(fork: true) } + before { allow(dossier).to receive(:forked_with_changes?).and_return(true) } + + context 'when dossier can be submitted' do + before { allow(component).to receive(:ineligibilite_rules_invalid?).and_return(false) } + + it 'renders submit button without disabled' do + expect(subject).to have_selector('button', text: 'Déposer les modifications') + end + end + + context 'when dossier can not be submitted' do + before { allow(component).to receive(:ineligibilite_rules_invalid?).and_return(true) } + + it 'renders submit button with disabled' do + expect(subject).to have_selector('a', text: 'Pourquoi je ne peux pas déposer mon dossier ?') + expect(subject).to have_selector('button[disabled]', text: 'Déposer les modifications') + end + end + end +end From be5f5802375a78577ae095cbac87acac68694cd3 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:36:25 +0200 Subject: [PATCH 0370/1532] feat(Users/Dossiers#update): track changes live and pop modal when ineligibilite_rules matches --- .../invalid_ineligibilite_rules_component.rb | 16 ++++ ...valid_ineligibilite_rules_component.en.yml | 6 ++ ...valid_ineligibilite_rules_component.fr.yml | 5 + ...id_ineligibilite_rules_component.html.haml | 16 ++++ app/controllers/users/dossiers_controller.rb | 11 ++- .../ineligibilite_rules_match_controller.ts | 19 ++++ app/views/shared/dossiers/_edit.html.haml | 2 + .../users/dossiers/update.turbo_stream.haml | 7 ++ .../users/dossiers_controller_spec.rb | 92 +++++++++++++++---- .../shared/dossiers/_edit.html.haml_spec.rb | 14 +++ 10 files changed, 164 insertions(+), 24 deletions(-) create mode 100644 app/components/dossiers/invalid_ineligibilite_rules_component.rb create mode 100644 app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.en.yml create mode 100644 app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.fr.yml create mode 100644 app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.html.haml create mode 100644 app/javascript/controllers/ineligibilite_rules_match_controller.ts diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component.rb b/app/components/dossiers/invalid_ineligibilite_rules_component.rb new file mode 100644 index 000000000..fe45272f6 --- /dev/null +++ b/app/components/dossiers/invalid_ineligibilite_rules_component.rb @@ -0,0 +1,16 @@ +class Dossiers::InvalidIneligibiliteRulesComponent < ApplicationComponent + delegate :can_passer_en_construction?, :ineligibilite_rules_computable?, to: :@dossier + + def initialize(dossier:) + @dossier = dossier + @revision = dossier.revision + end + + def render? + ineligibilite_rules_computable? && !can_passer_en_construction? + end + + def error_message + @dossier.revision.ineligibilite_message + end +end diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.en.yml b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.en.yml new file mode 100644 index 000000000..1a377763c --- /dev/null +++ b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.en.yml @@ -0,0 +1,6 @@ +fr: + modal: + title: "Your file does not match submission criteria" + close: "Close" + close_alt: "Close this modal" + body: "The procedure « %{procedure_libelle} » have submission criteria, unfortunately your file does not match them. You can not submit your file" \ No newline at end of file diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.fr.yml b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.fr.yml new file mode 100644 index 000000000..d191f03d4 --- /dev/null +++ b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.fr.yml @@ -0,0 +1,5 @@ +fr: + modal: + title: "Vous ne pouvez pas déposer votre dossier" + close: "Fermer" + close_alt: "Fermer la fenêtre modale" \ No newline at end of file diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.html.haml b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.html.haml new file mode 100644 index 000000000..dd39925cd --- /dev/null +++ b/app/components/dossiers/invalid_ineligibilite_rules_component/invalid_ineligibilite_rules_component.html.haml @@ -0,0 +1,16 @@ +%div{ id: dom_id(@dossier, :ineligibilite_rules_broken), data: { controller: 'ineligibilite-rules-match', turbo_force: :server } } + %button.fr-sr-only{ aria: {controls: 'modal-eligibilite-rules-dialog' }, data: {'fr-opened': "false" } } + show modal + + %dialog.fr-modal{ "aria-labelledby" => "fr-modal-title-modal-1", role: "dialog", id: 'modal-eligibilite-rules-dialog', data: { 'ineligibilite-rules-match-target' => 'dialog' } } + .fr-container.fr-container--fluid.fr-container-md + .fr-grid-row.fr-grid-row--center + .fr-col-12.fr-col-md-8.fr-col-lg-6 + .fr-modal__body + .fr-modal__header + %button.fr-btn--close.fr-btn{ aria: { controls: 'modal-eligibilite-rules-dialog' }, title: t('.modal.close_alt') }= t('.modal.close') + .fr-modal__content + %h1#fr-modal-title-modal-1.fr-modal__title + %span.fr-icon-arrow-right-line.fr-icon--lg> + = t('.modal.title') + %p= error_message diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index acdfd1332..e686683ff 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -303,10 +303,13 @@ module Users def update @dossier = dossier.en_construction? ? dossier.find_editing_fork(dossier.user) : dossier @dossier = dossier_with_champs(pj_template: false) - @errors = update_dossier_and_compute_errors - - @dossier.index_search_terms_later if @errors.empty? - + @ineligibilite_rules_was_computable = @dossier.ineligibilite_rules_computable? + @can_passer_en_construction_was = @dossier.can_passer_en_construction? + update_dossier_and_compute_errors + @dossier.index_search_terms_later if @dossier.errors.empty? + @ineligibilite_rules_is_computable = @dossier.ineligibilite_rules_computable? + @can_passer_en_construction_is = @dossier.can_passer_en_construction? + @ineligibilite_rules_computable_changed = !@ineligibilite_rules_was_computable && @ineligibilite_rules_is_computable respond_to do |format| format.turbo_stream do @to_show, @to_hide, @to_update = champs_to_turbo_update(champs_public_attributes_params, dossier.champs.filter(&:public?)) diff --git a/app/javascript/controllers/ineligibilite_rules_match_controller.ts b/app/javascript/controllers/ineligibilite_rules_match_controller.ts new file mode 100644 index 000000000..5b47d79b5 --- /dev/null +++ b/app/javascript/controllers/ineligibilite_rules_match_controller.ts @@ -0,0 +1,19 @@ +import { ApplicationController } from './application_controller'; +declare interface modal { + disclose: () => void; +} +declare interface dsfr { + modal: modal; +} +declare const window: Window & + typeof globalThis & { dsfr: (elem: HTMLElement) => dsfr }; + +export class InvalidIneligibiliteRulesController extends ApplicationController { + static targets = ['dialog']; + + declare dialogTarget: HTMLElement; + + connect() { + setTimeout(() => window.dsfr(this.dialogTarget).modal.disclose(), 100); + } +} diff --git a/app/views/shared/dossiers/_edit.html.haml b/app/views/shared/dossiers/_edit.html.haml index d5fff3262..1962951e8 100644 --- a/app/views/shared/dossiers/_edit.html.haml +++ b/app/views/shared/dossiers/_edit.html.haml @@ -25,4 +25,6 @@ = render Dossiers::PendingCorrectionCheckboxComponent.new(dossier: dossier) + = render Dossiers::InvalidIneligibiliteRulesComponent.new(dossier: dossier) + = render Dossiers::EditFooterComponent.new(dossier: dossier_for_editing, annotation: false) diff --git a/app/views/users/dossiers/update.turbo_stream.haml b/app/views/users/dossiers/update.turbo_stream.haml index 91a898ab0..374291733 100644 --- a/app/views/users/dossiers/update.turbo_stream.haml +++ b/app/views/users/dossiers/update.turbo_stream.haml @@ -1 +1,8 @@ = render partial: 'shared/dossiers/update_champs', locals: { to_show: @to_show, to_hide: @to_hide, to_update: @to_update, dossier: @dossier } + +- if !params.key?(:validate) + - if @ineligibilite_rules_is_computable + = turbo_stream.remove(dom_id(@dossier, :ineligibilite_rules_broken)) + + - if (@ineligibilite_rules_computable_changed && !@can_passer_en_construction_is) || (@can_passer_en_construction_was && !@can_passer_en_construction_is) + = turbo_stream.append('contenu', render(Dossiers::InvalidIneligibiliteRulesComponent.new(dossier: @dossier))) diff --git a/spec/controllers/users/dossiers_controller_spec.rb b/spec/controllers/users/dossiers_controller_spec.rb index 220bd6722..1f78b2f4a 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -398,7 +398,9 @@ describe Users::DossiersController, type: :controller do describe '#submit_brouillon' do before { sign_in(user) } - let!(:dossier) { create(:dossier, user: user) } + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :text }] } + let!(:dossier) { create(:dossier, user:, procedure:) } let(:first_champ) { dossier.champs_public.first } let(:anchor_to_first_champ) { controller.helpers.link_to first_champ.libelle, brouillon_dossier_path(anchor: first_champ.labelledby_id), class: 'error-anchor' } let(:value) { 'beautiful value' } @@ -439,9 +441,9 @@ describe Users::DossiersController, type: :controller do render_views let(:error_message) { 'nop' } before do - expect_any_instance_of(Dossier).to receive(:validate).and_return(false) - expect_any_instance_of(Dossier).to receive(:errors).and_return( - [double(inner_error: double(base: first_champ), message: 'nop')] + allow_any_instance_of(Dossier).to receive(:validate).and_return(false) + allow_any_instance_of(Dossier).to receive(:errors).and_return( + [instance_double(ActiveModel::NestedError, inner_error: double(base: first_champ), message: 'nop')] ) subject end @@ -461,11 +463,8 @@ describe Users::DossiersController, type: :controller do render_views let(:value) { nil } - - before do - first_champ.type_de_champ.update(mandatory: true, libelle: 'l') - subject - end + let(:types_de_champ_public) { [{ type: :text, mandatory: true, libelle: 'l' }] } + before { subject } it { expect(response).to render_template(:brouillon) } it { expect(response.body).to have_link(first_champ.libelle, href: "##{first_champ.labelledby_id}") } @@ -548,8 +547,8 @@ describe Users::DossiersController, type: :controller do render_views before do - expect_any_instance_of(Dossier).to receive(:validate).and_return(false) - expect_any_instance_of(Dossier).to receive(:errors).and_return( + allow_any_instance_of(Dossier).to receive(:validate).and_return(false) + allow_any_instance_of(Dossier).to receive(:errors).and_return( [double(inner_error: double(base: first_champ), message: 'nop')] ) @@ -661,7 +660,8 @@ describe Users::DossiersController, type: :controller do describe '#update brouillon' do before { sign_in(user) } - let(:procedure) { create(:procedure, :published, types_de_champ_public: [{}, { type: :piece_justificative }]) } + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:types_de_champ_public) { [{}, { type: :piece_justificative }] } let(:dossier) { create(:dossier, user:, procedure:) } let(:first_champ) { dossier.champs_public.first } let(:piece_justificative_champ) { dossier.champs_public.last } @@ -754,13 +754,65 @@ describe Users::DossiersController, type: :controller do end end - it "debounce search terms indexation" do - # dossier creation trigger a first indexation and flag, - # so we we have to remove this flag - dossier.debounce_index_search_terms_flag.remove + context 'having ineligibilite_rules setup' do + include Logic + render_views - assert_enqueued_jobs(1, only: DossierIndexSearchTermsJob) do - 3.times { patch :update, params: payload, format: :turbo_stream } + let(:types_de_champ_public) { [{ type: :text }, { type: :integer_number }] } + let(:text_champ) { dossier.champs_public.first } + let(:number_champ) { dossier.champs_public.last } + let(:submit_payload) do + { + id: dossier.id, + dossier: { + groupe_instructeur_id: dossier.groupe_instructeur_id, + champs_public_attributes: { + text_champ.public_id => { + with_public_id: true, + value: "hello world" + }, + number_champ.public_id => { + with_public_id: true, + value: + } + } + } + } + end + let(:must_be_greater_than) { 10 } + + before do + procedure.published_revision.update( + ineligibilite_enabled: true, + ineligibilite_message: 'lol', + ineligibilite_rules: greater_than(champ_value(number_champ.stable_id), constant(must_be_greater_than)) + ) + procedure.published_revision.save! + end + render_views + + context 'when it pass from undefined to true' do + let(:value) { must_be_greater_than + 1 } + + it 'raises popup' do + subject + dossier.reload + expect(dossier.can_passer_en_construction?).to be_falsey + expect(assigns(:ineligibilite_rules_was_computable)).to eq(false) + expect(assigns(:ineligibilite_rules_is_computable)).to eq(true) + expect(response.body).to match(ActionView::RecordIdentifier.dom_id(dossier, :ineligibilite_rules_broken)) + end + end + context 'when it pass from undefined to false' do + let(:value) { must_be_greater_than - 1 } + it 'does nothing' do + subject + dossier.reload + expect(dossier.can_passer_en_construction?).to be_truthy + expect(assigns(:ineligibilite_rules_was_computable)).to eq(false) + expect(assigns(:ineligibilite_rules_is_computable)).to eq(true) + expect(response.body).not_to have_selector("##{ActionView::RecordIdentifier.dom_id(dossier, :ineligibilite_rules_broken)}") + end end end end @@ -868,8 +920,8 @@ describe Users::DossiersController, type: :controller do context 'classic error' do before do - expect_any_instance_of(Dossier).to receive(:save).and_return(false) - expect_any_instance_of(Dossier).to receive(:errors).and_return( + allow_any_instance_of(Dossier).to receive(:save).and_return(false) + allow_any_instance_of(Dossier).to receive(:errors).and_return( [message: 'nop', inner_error: double(base: first_champ)] ) subject diff --git a/spec/views/shared/dossiers/_edit.html.haml_spec.rb b/spec/views/shared/dossiers/_edit.html.haml_spec.rb index c242f3ec8..5183554d8 100644 --- a/spec/views/shared/dossiers/_edit.html.haml_spec.rb +++ b/spec/views/shared/dossiers/_edit.html.haml_spec.rb @@ -149,4 +149,18 @@ describe 'shared/dossiers/edit', type: :view do end end end + + context 'when dossier transitions rules are computable and passer_en_construction is false' do + let(:types_de_champ_public) { [] } + let(:dossier) { create(:dossier, procedure:) } + + before do + allow_any_instance_of(Dossiers::InvalidIneligibiliteRulesComponent).to receive(:ineligibilite_rules_computable?).and_return(true) + allow(dossier).to receive(:can_passer_en_construction?).and_return(false) + end + + it 'renders broken transitions rules dialog' do + expect(subject).to have_selector("##{ActionView::RecordIdentifier.dom_id(dossier, :ineligibilite_rules_broken)}") + end + end end From 178685b34b059c5cfeffd230a99f111d89e1f9d6 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:37:12 +0200 Subject: [PATCH 0371/1532] feat(TypeDeChampEditor): prevent to destroy a type de champ used by inligibilite rules --- .../champ_component/champ_component.html.haml | 6 +++++- app/models/procedure_revision_type_de_champ.rb | 4 ++++ .../types_de_champ_editor/champ_component_spec.rb | 11 +++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml index 60cf82102..d28769213 100644 --- a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml +++ b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml @@ -10,7 +10,7 @@ .flex.justify-start.width-33 .cell.flex.justify-start.column.flex-grow = form.label :type_champ, "Type de champ", for: dom_id(type_de_champ, :type_champ) - = form.select :type_champ, grouped_options_for_select(types_of_type_de_champ, type_de_champ.type_champ), {}, class: 'fr-select small-margin small inline width-100', id: dom_id(type_de_champ, :type_champ), disabled: coordinate.used_by_routing_rules? + = form.select :type_champ, grouped_options_for_select(types_of_type_de_champ, type_de_champ.type_champ), {}, class: 'fr-select small-margin small inline width-100', id: dom_id(type_de_champ, :type_champ), disabled: coordinate.used_by_routing_rules? || coordinate.used_by_ineligibilite_rules? .flex.column.justify-start.flex-grow .cell @@ -136,6 +136,10 @@ %span utilisé pour = link_to('le routage', admin_procedure_groupe_instructeurs_path(revision.procedure_id, anchor: 'routing-rules')) + - elsif coordinate.used_by_ineligibilite_rules? + %span + utilisé pour + = link_to('l’eligibilité des dossiers', edit_admin_procedure_ineligibilite_rules_path(revision.procedure_id)) - else = button_to type_de_champ_path, class: 'fr-btn fr-btn--tertiary-no-outline fr-icon-delete-line', title: "Supprimer le champ", method: :delete, form: { data: { turbo_confirm: 'Êtes vous sûr de vouloir supprimer ce champ ?' } } do %span.sr-only Supprimer diff --git a/app/models/procedure_revision_type_de_champ.rb b/app/models/procedure_revision_type_de_champ.rb index c4842da20..506e205f7 100644 --- a/app/models/procedure_revision_type_de_champ.rb +++ b/app/models/procedure_revision_type_de_champ.rb @@ -75,4 +75,8 @@ class ProcedureRevisionTypeDeChamp < ApplicationRecord def used_by_routing_rules? stable_id.in?(procedure.stable_ids_used_by_routing_rules) end + + def used_by_ineligibilite_rules? + revision.ineligibilite_enabled? && stable_id.in?(revision.ineligibilite_rules&.sources || []) + end end diff --git a/spec/components/types_de_champ_editor/champ_component_spec.rb b/spec/components/types_de_champ_editor/champ_component_spec.rb index 1e368f1ba..27b57472f 100644 --- a/spec/components/types_de_champ_editor/champ_component_spec.rb +++ b/spec/components/types_de_champ_editor/champ_component_spec.rb @@ -2,10 +2,12 @@ describe TypesDeChampEditor::ChampComponent, type: :component do describe 'render' do let(:component) { described_class.new(coordinate:, upper_coordinates: []) } let(:routing_rules_stable_ids) { [] } + let(:ineligibilite_rules_used?) { false } before do Flipper.enable_actor(:engagement_juridique_type_de_champ, procedure) allow_any_instance_of(Procedure).to receive(:stable_ids_used_by_routing_rules).and_return(routing_rules_stable_ids) + allow_any_instance_of(ProcedureRevisionTypeDeChamp).to receive(:used_by_ineligibilite_rules?).and_return(ineligibilite_rules_used?) render_inline(component) end @@ -29,6 +31,15 @@ describe TypesDeChampEditor::ChampComponent, type: :component do expect(page).to have_text(/utilisé pour\nle routage/) end end + + context 'drop down tdc used for ineligibilite_rules' do + let(:ineligibilite_rules_used?) { true } + + it do + expect(page).to have_css("select[disabled=\"disabled\"]") + expect(page).to have_text(/l’eligibilité des dossiers/) + end + end end describe 'tdc ej' do From c480bc00c381f4b1c108b862e028bd9e03fd40ba Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 17:40:35 +0200 Subject: [PATCH 0372/1532] feat(Users/Dossiers#submit_brouillon_or_en_construction): prevent transition to en_construction if ineligibilite_rules matches. pop error nicely --- app/controllers/users/dossiers_controller.rb | 20 +-- app/models/dossier.rb | 2 +- .../users/dossier_ineligibilite_spec.rb | 119 ++++++++++++++++++ 3 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 spec/system/users/dossier_ineligibilite_spec.rb diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index e686683ff..ebfa2cdb4 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -231,9 +231,9 @@ module Users def submit_brouillon @dossier = dossier_with_champs(pj_template: false) - @errors = submit_dossier_and_compute_errors + submit_dossier_and_compute_errors - if @errors.blank? + if @dossier.errors.blank? && @dossier.can_passer_en_construction? @dossier.passer_en_construction! @dossier.process_declarative! @dossier.process_sva_svr! @@ -278,9 +278,9 @@ module Users editing_fork_origin.resolve_pending_correction end - @errors = submit_dossier_and_compute_errors + submit_dossier_and_compute_errors - if @errors.blank? + if @dossier.errors.blank? && @dossier.can_passer_en_construction? editing_fork_origin.merge_fork(@dossier) editing_fork_origin.submit_en_construction! @@ -288,7 +288,6 @@ module Users else respond_to do |format| format.html do - @dossier = editing_fork_origin render :modifier end @@ -570,21 +569,14 @@ module Users def submit_dossier_and_compute_errors @dossier.validate(:champs_public_value) - - errors = @dossier.errors - @dossier.check_mandatory_and_visible_champs.each do |error_on_champ| - errors.import(error_on_champ) - end + @dossier.check_mandatory_and_visible_champs if @dossier.editing_fork_origin&.pending_correction? @dossier.editing_fork_origin.validate(:champs_public_value) @dossier.editing_fork_origin.errors.where(:pending_correction).each do |error| - errors.import(error) + @dossier.errors.import(error) end - end - - errors end def ensure_ownership! diff --git a/app/models/dossier.rb b/app/models/dossier.rb index e83edfb97..7a1c611a8 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -940,7 +940,7 @@ class Dossier < ApplicationRecord .filter(&:visible?) .filter(&:mandatory_blank?) .map do |champ| - champ.errors.add(:value, :missing) + errors.import(champ.errors.add(:value, :missing)) end end diff --git a/spec/system/users/dossier_ineligibilite_spec.rb b/spec/system/users/dossier_ineligibilite_spec.rb new file mode 100644 index 000000000..366dac780 --- /dev/null +++ b/spec/system/users/dossier_ineligibilite_spec.rb @@ -0,0 +1,119 @@ +require 'system/users/dossier_shared_examples.rb' + +describe 'Dossier Inéligibilité', js: true do + include Logic + + let(:user) { create(:user) } + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:dossier) { create(:dossier, procedure:, user:) } + + let(:published_revision) { procedure.published_revision } + let(:first_tdc) { published_revision.types_de_champ.first } + let(:second_tdc) { published_revision.types_de_champ.last } + let(:ineligibilite_message) { 'sry vous pouvez aps soumettre votre dossier' } + let(:eligibilite_params) { { ineligibilite_enabled: true, ineligibilite_message: } } + + before do + published_revision.update(eligibilite_params.merge(ineligibilite_rules:)) + login_as user, scope: :user + end + + context 'single condition' do + let(:types_de_champ_public) { [{ type: :yes_no }] } + let(:ineligibilite_rules) { ds_eq(champ_value(first_tdc.stable_id), constant(true)) } + + scenario 'can submit, can not submit, reload' do + visit brouillon_dossier_path(dossier) + # no error while dossier is empty + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) + expect(page).not_to have_content("Vous ne pouvez pas déposer votre dossier") + + # does raise error when dossier is filled with valid condition + find("label", text: "Non").click + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) + expect(page).not_to have_content("Vous ne pouvez pas déposer votre dossier") + + # raise error when dossier is filled with invalid condition + find("label", text: "Oui").click + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: true) + expect(page).to have_content("Vous ne pouvez pas déposer votre dossier") + + # reload page and see error because it was filled + visit brouillon_dossier_path(dossier) + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: true) + expect(page).to have_content("Vous ne pouvez pas déposer votre dossier") + + # modal is closable, and we can change our dossier response to be eligible + within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } + find("label", text: "Non").click + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) + + # it works, yay + click_on "Déposer le dossier" + wait_until { dossier.reload.en_construction? == true } + end + end + + context 'or condition' do + let(:types_de_champ_public) { [{ type: :yes_no, libelle: 'l1' }, { type: :drop_down_list, libelle: 'l2', options: ['Paris', 'Marseille'] }] } + let(:ineligibilite_rules) do + ds_or([ + ds_eq(champ_value(first_tdc.stable_id), constant(true)), + ds_eq(champ_value(second_tdc.stable_id), constant('Paris')) + ]) + end + + scenario 'can submit, can not submit, can edit, etc...' do + visit brouillon_dossier_path(dossier) + # no error while dossier is empty + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) + expect(page).not_to have_content("Vous ne pouvez pas déposer votre dossier") + + # only one condition is matches, cannot submit dossier and error message is clear + within "#champ-#{first_tdc.stable_id}" do + find("label", text: "Oui").click + end + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: true) + expect(page).to have_content("Vous ne pouvez pas déposer votre dossier") + within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } + + # only one condition does not matches, I can conitnue + within "#champ-#{first_tdc.stable_id}" do + find("label", text: "Non").click + end + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) + + # Now test dossier modification + click_on "Déposer le dossier" + click_on "Accéder à votre dossier" + click_on "Modifier le dossier" + + # one condition matches, means i'm blocked to send my file. + within "#champ-#{first_tdc.stable_id}" do + find("label", text: "Oui").click + end + expect(page).to have_selector(:button, text: "Déposer les modifications", disabled: true) + within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } + within "#champ-#{first_tdc.stable_id}" do + find("label", text: "Non").click + end + expect(page).to have_selector(:button, text: "Déposer les modifications", disabled: false) + + # second condition matches, means i'm blocked to send my file + within "#champ-#{second_tdc.stable_id}" do + find("label", text: 'Paris').click + end + expect(page).to have_selector(:button, text: "Déposer les modifications", disabled: true) + within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } + + # none of conditions matches, i can submit + within "#champ-#{second_tdc.stable_id}" do + find("label", text: 'Marseille').click + end + + # it works, yay + click_on "Déposer les modifications" + wait_until { dossier.reload.en_construction? == true } + end + end +end From e3a24d53ea080f54aa91986497a3615c2e680380 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 18:00:19 +0200 Subject: [PATCH 0373/1532] tech(refactor): procedure::error_summary and dossier::ErrorsFullMessagesComponent use same behaviour to compact/expand errors --- .../errors_full_messages_component.rb | 8 ++-- .../errors_full_messages_component.en.yml | 1 - .../errors_full_messages_component.fr.yml | 1 - .../errors_full_messages_component.html.haml | 17 ++----- app/components/expandable_error_list.rb | 9 ++++ .../expandable_error_list.html.en.yml | 3 ++ .../expandable_error_list.html.fr.yml | 3 ++ .../expandable_error_list.html.haml | 14 ++++++ app/components/procedure/errors_summary.rb | 18 +++++-- .../errors_summary/errors_summary.html.haml | 6 +-- app/views/shared/dossiers/_edit.html.haml | 2 +- config/locales/models/procedure/en.yml | 16 +++---- config/locales/models/procedure/fr.yml | 16 +++---- .../procedures/errors_summary_spec.rb | 47 ++++++++++++------- spec/models/procedure_spec.rb | 26 +++++----- .../administrateurs/procedure_publish_spec.rb | 6 +-- 16 files changed, 115 insertions(+), 78 deletions(-) create mode 100644 app/components/expandable_error_list.rb create mode 100644 app/components/expandable_error_list/expandable_error_list.html.en.yml create mode 100644 app/components/expandable_error_list/expandable_error_list.html.fr.yml create mode 100644 app/components/expandable_error_list/expandable_error_list.html.haml diff --git a/app/components/dossiers/errors_full_messages_component.rb b/app/components/dossiers/errors_full_messages_component.rb index fd8bafd94..207170e8c 100644 --- a/app/components/dossiers/errors_full_messages_component.rb +++ b/app/components/dossiers/errors_full_messages_component.rb @@ -3,17 +3,15 @@ class Dossiers::ErrorsFullMessagesComponent < ApplicationComponent ErrorDescriptor = Data.define(:anchor, :label, :error_message) - def initialize(dossier:, errors:) + def initialize(dossier:) @dossier = dossier - @errors = errors end def dedup_and_partitioned_errors - formated_errors = @errors.to_enum # ActiveModel::Errors.to_a is an alias to full_messages, we don't want that + @dossier.errors.to_enum # ActiveModel::Errors.to_a is an alias to full_messages, we don't want that .to_a # but enum.to_a gives back an array .uniq { |error| [error.inner_error.base] } # dedup cumulated errors from dossier.champs, dossier.champs_public, dossier.champs_private which run the validator one time per association .map { |error| to_error_descriptor(error) } - yield(Array(formated_errors[0..2]), Array(formated_errors[3..])) end def to_error_descriptor(error) @@ -27,6 +25,6 @@ class Dossiers::ErrorsFullMessagesComponent < ApplicationComponent end def render? - !@errors.empty? + !@dossier.errors.empty? end end diff --git a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml index 3fab8164d..0a595e80a 100644 --- a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml +++ b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml @@ -5,4 +5,3 @@ en: Your file has 1 error. Fix-it to continue : other: | Your file has %{count} errors. Fix-them to continue : - see_more: Show all errors diff --git a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml index 1fd0e7f8c..3d94f636f 100644 --- a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml +++ b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml @@ -5,4 +5,3 @@ fr: Votre dossier contient 1 champ en erreur. Corrigez-la pour poursuivre : other: | Votre dossier contient %{count} champs en erreurs. Corrigez-les pour poursuivre : - see_more: Afficher toutes les erreurs diff --git a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml index 58d76cb56..ada4150b5 100644 --- a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml +++ b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml @@ -1,15 +1,4 @@ .fr-alert.fr-alert--error.fr-mb-3w{ role: "alertdialog" } - - dedup_and_partitioned_errors do |head, tail| - %p#sumup-errors= t('.sumup_html', count: head.size + tail.size, url: head.first.anchor) - %ul.fr-mb-0#head-errors - - head.each do |error_descriptor| - %li - = link_to error_descriptor.label, error_descriptor.anchor, class: 'error-anchor' - = error_descriptor.error_message - - if tail.size > 0 - %button{ type: "button", "aria-controls": 'tail-errors', "aria-expanded": "false", class: "fr-btn fr-btn--sm fr-btn--tertiary-no-outline" }= t('.see_more') - %ul#tail-errors.fr-collapse.fr-mt-0 - - tail.each do |error_descriptor| - %li - = link_to error_descriptor.label, error_descriptor.anchor, class: 'error-anchor' - = "(#{error_descriptor.error_message})" + - if dedup_and_partitioned_errors.size > 0 + %p#sumup-errors= t('.sumup_html', count: dedup_and_partitioned_errors.size, url: dedup_and_partitioned_errors.first.anchor) + = render ExpandableErrorList.new(errors: dedup_and_partitioned_errors) diff --git a/app/components/expandable_error_list.rb b/app/components/expandable_error_list.rb new file mode 100644 index 000000000..43d5c9215 --- /dev/null +++ b/app/components/expandable_error_list.rb @@ -0,0 +1,9 @@ +class ExpandableErrorList < ApplicationComponent + def initialize(errors:) + @errors = errors + end + + def splitted_errors + yield(Array(@errors[0..2]), Array(@errors[3..])) + end +end diff --git a/app/components/expandable_error_list/expandable_error_list.html.en.yml b/app/components/expandable_error_list/expandable_error_list.html.en.yml new file mode 100644 index 000000000..b21ee7d8a --- /dev/null +++ b/app/components/expandable_error_list/expandable_error_list.html.en.yml @@ -0,0 +1,3 @@ +--- +en: + see_more: Show all errors diff --git a/app/components/expandable_error_list/expandable_error_list.html.fr.yml b/app/components/expandable_error_list/expandable_error_list.html.fr.yml new file mode 100644 index 000000000..755d13886 --- /dev/null +++ b/app/components/expandable_error_list/expandable_error_list.html.fr.yml @@ -0,0 +1,3 @@ +--- +fr: + see_more: Afficher toutes les erreurs diff --git a/app/components/expandable_error_list/expandable_error_list.html.haml b/app/components/expandable_error_list/expandable_error_list.html.haml new file mode 100644 index 000000000..1ab5221e5 --- /dev/null +++ b/app/components/expandable_error_list/expandable_error_list.html.haml @@ -0,0 +1,14 @@ +- splitted_errors do |head, tail| + %ul#head-errors.fr-mb-0 + - head.each do |error_descriptor| + %li + = link_to error_descriptor.label, error_descriptor.anchor, class: 'error-anchor' + = error_descriptor.error_message + + - if tail.size > 0 + %button.fr-mt-0.fr-btn.fr-btn--sm.fr-btn--tertiary-no-outline{ type: "button", "aria-controls": 'tail-errors', "aria-expanded": "false", class: "" }= t('see_more') + %ul#tail-errors.fr-collapse.fr-mt-0 + - tail.each do |error_descriptor| + %li + = link_to error_descriptor.label, error_descriptor.anchor, class: 'error-anchor' + = error_descriptor.error_message diff --git a/app/components/procedure/errors_summary.rb b/app/components/procedure/errors_summary.rb index adce8fd64..e185c81b3 100644 --- a/app/components/procedure/errors_summary.rb +++ b/app/components/procedure/errors_summary.rb @@ -1,4 +1,6 @@ class Procedure::ErrorsSummary < ApplicationComponent + ErrorDescriptor = Data.define(:anchor, :label, :error_message) + def initialize(procedure:, validation_context:) @procedure = procedure @validation_context = validation_context @@ -24,10 +26,8 @@ class Procedure::ErrorsSummary < ApplicationComponent @procedure.errors.present? end - def error_messages - @procedure.errors.map do |error| - [error, error_correction_page(error)] - end + def errors + @procedure.errors.map { to_error_descriptor(_1) } end def error_correction_page(error) @@ -45,4 +45,14 @@ class Procedure::ErrorsSummary < ApplicationComponent edit_admin_procedure_mail_template_path(@procedure, klass.const_get(:SLUG)) end end + + def to_error_descriptor(error) + libelle = case error.attribute + when :draft_types_de_champ_public, :draft_types_de_champ_private + error.options[:type_de_champ].libelle.truncate(200) + else + error.base.class.human_attribute_name(error.attribute) + end + ErrorDescriptor.new(error_correction_page(error), libelle, error.message) + end end diff --git a/app/components/procedure/errors_summary/errors_summary.html.haml b/app/components/procedure/errors_summary/errors_summary.html.haml index 72780bcd1..e5042916e 100644 --- a/app/components/procedure/errors_summary/errors_summary.html.haml +++ b/app/components/procedure/errors_summary/errors_summary.html.haml @@ -2,8 +2,4 @@ - if invalid? = render Dsfr::AlertComponent.new(state: :error, title: , extra_class_names: 'fr-mb-2w') do |c| - c.with_body do - - error_messages.each do |(error, path)| - %p.mt-2 - = error.full_message - - if path.present? - = "(#{link_to 'corriger', path, class: 'fr-link'})" + = render ExpandableErrorList.new(errors:) diff --git a/app/views/shared/dossiers/_edit.html.haml b/app/views/shared/dossiers/_edit.html.haml index 1962951e8..c797d35f2 100644 --- a/app/views/shared/dossiers/_edit.html.haml +++ b/app/views/shared/dossiers/_edit.html.haml @@ -10,7 +10,7 @@ = render NestedForms::FormOwnerComponent.new = form_for dossier_for_editing, url: brouillon_dossier_url(dossier), method: :patch, html: { id: 'dossier-edit-form', class: 'form', multipart: true, novalidate: 'novalidate' } do |f| - = render Dossiers::ErrorsFullMessagesComponent.new(dossier: @dossier, errors: @errors || []) + = render Dossiers::ErrorsFullMessagesComponent.new(dossier: dossier) %header.mb-6 .fr-highlight %p.fr-text--sm diff --git a/config/locales/models/procedure/en.yml b/config/locales/models/procedure/en.yml index 55a9c1ddd..34fc89e35 100644 --- a/config/locales/models/procedure/en.yml +++ b/config/locales/models/procedure/en.yml @@ -72,16 +72,16 @@ en: invalid: 'invalid format' draft_types_de_champ_public: format: 'Public field %{message}' - invalid_condition: "« %{value} » have an invalid logic" - empty_repetition: '« %{value} » requires at least one field' - empty_drop_down: '« %{value} » requires at least one option' - inconsistent_header_section: "« %{value} » %{custom_message}" + invalid_condition: "have an invalid logic" + empty_repetition: 'requires at least one field' + empty_drop_down: 'requires at least one option' + inconsistent_header_section: "%{custom_message}" draft_types_de_champ_private: format: 'Private field %{message}' - invalid_condition: "« %{value} » have an invalid logic" - empty_repetition: '« %{value} » requires at least one field' - empty_drop_down: '« %{value} » requires at least one option' - inconsistent_header_section: "« %{value} » %{custom_message}" + invalid_condition: "have an invalid logic" + empty_repetition: 'requires at least one field' + empty_drop_down: 'requires at least one option' + inconsistent_header_section: "%{custom_message}" attestation_template: format: "%{attribute} %{message}" initiated_mail: diff --git a/config/locales/models/procedure/fr.yml b/config/locales/models/procedure/fr.yml index 4a0fdeca5..85df92d73 100644 --- a/config/locales/models/procedure/fr.yml +++ b/config/locales/models/procedure/fr.yml @@ -78,16 +78,16 @@ fr: invalid: 'n’a pas le bon format' draft_types_de_champ_public: format: 'Le champ %{message}' - invalid_condition: "« %{value} » a une logique conditionnelle invalide" - empty_repetition: '« %{value} » doit comporter au moins un champ répétable' - empty_drop_down: '« %{value} » doit comporter au moins un choix sélectionnable' - inconsistent_header_section: "« %{value} » %{custom_message}" + invalid_condition: "a une logique conditionnelle invalide" + empty_repetition: 'doit comporter au moins un champ répétable' + empty_drop_down: 'doit comporter au moins un choix sélectionnable' + inconsistent_header_section: "%{custom_message}" draft_types_de_champ_private: format: 'L’annotation privée %{message}' - invalid_condition: "« %{value} » a une logique conditionnelle invalide" - empty_repetition: '« %{value} » doit comporter au moins un champ répétable' - empty_drop_down: '« %{value} » doit comporter au moins un choix sélectionnable' - inconsistent_header_section: "« %{value} » %{custom_message}" + invalid_condition: "a une logique conditionnelle invalide" + empty_repetition: 'doit comporter au moins un champ répétable' + empty_drop_down: 'doit comporter au moins un choix sélectionnable' + inconsistent_header_section: "%{custom_message}" attestation_template: format: "%{attribute} %{message}" initiated_mail: diff --git a/spec/components/procedures/errors_summary_spec.rb b/spec/components/procedures/errors_summary_spec.rb index 4c3ef6337..3e64cc790 100644 --- a/spec/components/procedures/errors_summary_spec.rb +++ b/spec/components/procedures/errors_summary_spec.rb @@ -11,27 +11,33 @@ describe Procedure::ErrorsSummary, type: :component do context 'when :publication' do let(:validation_context) { :publication } - it 'shows errors for public and private tdc' do - expect(page).to have_text("Le champ « public » doit comporter au moins un choix sélectionnable") - expect(page).to have_text("L’annotation privée « private » doit comporter au moins un choix sélectionnable") + it 'shows errors and links for public and private tdc' do + expect(page).to have_content("Erreur : Des problèmes empêchent la publication de la démarche") + expect(page).to have_selector("a", text: "public") + expect(page).to have_selector("a", text: "private") + expect(page).to have_text("doit comporter au moins un choix sélectionnable", count: 2) end end context 'when :types_de_champ_public_editor' do let(:validation_context) { :types_de_champ_public_editor } - it 'shows errors for public only tdc' do - expect(page).to have_text("Le champ « public » doit comporter au moins un choix sélectionnable") - expect(page).not_to have_text("L’annotation privée « private » doit comporter au moins un choix sélectionnable") + it 'shows errors and links for public only tdc' do + expect(page).to have_text("Erreur : Les champs formulaire contiennent des erreurs") + expect(page).to have_selector("a", text: "public") + expect(page).to have_text("doit comporter au moins un choix sélectionnable", count: 1) + expect(page).not_to have_selector("a", text: "private") end end context 'when :types_de_champ_private_editor' do let(:validation_context) { :types_de_champ_private_editor } - it 'shows errors for private only tdc' do - expect(page).not_to have_text("Le champ « public » doit comporter au moins un choix sélectionnable") - expect(page).to have_text("L’annotation privée « private » doit comporter au moins un choix sélectionnable") + it 'shows errors and links for private only tdc' do + expect(page).to have_text("Erreur : Les annotations privées contiennent des erreurs") + expect(page).to have_selector("a", text: "private") + expect(page).to have_text("doit comporter au moins un choix sélectionnable") + expect(page).not_to have_selector("a", text: "public") end end end @@ -52,12 +58,18 @@ describe Procedure::ErrorsSummary, type: :component do before { subject } - it 'renders all errors on champ' do - expect(page).to have_text("Le champ « drop down list requires options » doit comporter au moins un choix sélectionnable") - expect(page).to have_text("Le champ « repetition requires children » doit comporter au moins un champ répétable") - expect(page).to have_text("Le champ « invalid condition » a une logique conditionnelle invalide") - expect(page).to have_text("Le champ « header sections must have consistent order » devrait être précédé d'un titre de niveau 1") - # TODO, test attestation_template, initiated_mail, :received_mail, :closed_mail, :refused_mail, :without_continuation_mail, :re_instructed_mail + it 'renders all errors and links on champ' do + expect(page).to have_selector("a", text: "drop down list requires options") + expect(page).to have_content("doit comporter au moins un choix sélectionnable") + + expect(page).to have_selector("a", text: "repetition requires children") + expect(page).to have_content("doit comporter au moins un champ répétable") + + expect(page).to have_selector("a", text: "invalid condition") + expect(page).to have_content("a une logique conditionnelle invalide") + + expect(page).to have_selector("a", text: "header sections must have consistent order") + expect(page).to have_content("devrait être précédé d'un titre de niveau 1") end end @@ -73,8 +85,9 @@ describe Procedure::ErrorsSummary, type: :component do end it 'render error nicely' do - expect(page).to have_text("Le modèle d’attestation n'est pas valide") - expect(page).to have_text("L’email de notification de passage de dossier en instruction n'est pas valide") + expect(page).to have_selector("a", text: "Le modèle d’attestation") + expect(page).to have_selector("a", text: "L’email de notification de passage de dossier en instruction") + expect(page).to have_text("n'est pas valide", count: 2) end end end diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 2a20829ab..0fa94a425 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -372,12 +372,12 @@ describe Procedure do ] end let(:types_de_champ_private) { [] } - let(:invalid_repetition_error_message) { 'Le champ « Enfants » doit comporter au moins un champ répétable' } - let(:invalid_drop_down_error_message) { 'Le champ « Civilité » doit comporter au moins un choix sélectionnable' } + let(:invalid_repetition_error_message) { "doit comporter au moins un champ répétable" } + let(:invalid_drop_down_error_message) { "doit comporter au moins un choix sélectionnable" } it 'validates that no repetition type de champ is empty' do procedure.validate(:publication) - expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).to include(invalid_repetition_error_message) + expect(procedure.errors.messages_for(:draft_types_de_champ_public)).to include(invalid_repetition_error_message) new_draft = procedure.draft_revision repetition = procedure.draft_revision.types_de_champ_public.find(&:repetition?) @@ -385,17 +385,17 @@ describe Procedure do new_draft.revision_types_de_champ.create(type_de_champ: create(:type_de_champ), position: 0, parent: parent_coordinate) procedure.validate(:publication) - expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).not_to include(invalid_repetition_error_message) + expect(procedure.errors.messages_for(:draft_types_de_champ_public)).not_to include(invalid_repetition_error_message) end it 'validates that no drop-down type de champ is empty' do procedure.validate(:publication) - expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).to include(invalid_drop_down_error_message) + expect(procedure.errors.messages_for(:draft_types_de_champ_public)).to include(invalid_drop_down_error_message) drop_down = procedure.draft_revision.types_de_champ_public.find(&:drop_down_list?) drop_down.update!(drop_down_list_value: "--title--\r\nsome value") procedure.reload.validate(:publication) - expect(procedure.errors.full_messages_for(:draft_types_de_champ_public)).not_to include(invalid_drop_down_error_message) + expect(procedure.errors.messages_for(:draft_types_de_champ_public)).not_to include(invalid_drop_down_error_message) end end @@ -408,17 +408,21 @@ describe Procedure do end let(:types_de_champ_public) { [] } - let(:invalid_repetition_error_message) { 'L’annotation privée « Enfants » doit comporter au moins un champ répétable' } - let(:invalid_drop_down_error_message) { 'L’annotation privée « Civilité » doit comporter au moins un choix sélectionnable' } + let(:invalid_repetition_error_message) { "doit comporter au moins un champ répétable" } + let(:invalid_drop_down_error_message) { "doit comporter au moins un choix sélectionnable" } it 'validates that no repetition type de champ is empty' do procedure.validate(:publication) - expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to include(invalid_repetition_error_message) + expect(procedure.errors.messages_for(:draft_types_de_champ_private)).to include(invalid_repetition_error_message) + repetition = procedure.draft_revision.types_de_champ_private.find(&:repetition?) + expect(procedure.errors.to_enum.to_a.map { _1.options[:type_de_champ] }).to include(repetition) end it 'validates that no drop-down type de champ is empty' do procedure.validate(:publication) - expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to include(invalid_drop_down_error_message) + expect(procedure.errors.messages_for(:draft_types_de_champ_private)).to include(invalid_drop_down_error_message) + drop_down = procedure.draft_revision.types_de_champ_private.find(&:drop_down_list?) + expect(procedure.errors.to_enum.to_a.map { _1.options[:type_de_champ] }).to include(drop_down) end end @@ -441,7 +445,7 @@ describe Procedure do include Logic let(:types_de_champ_public) { [{ type: :text, libelle: 'condition', condition: ds_eq(champ_value(1), constant(2)), stable_id: 2 }] } let(:types_de_champ_private) { [{ type: :decimal_number, stable_id: 1 }] } - let(:error_on_condition) { "Le champ « condition » a une logique conditionnelle invalide" } + let(:error_on_condition) { "Le champ a une logique conditionnelle invalide" } it 'validate without context' do procedure.validate diff --git a/spec/system/administrateurs/procedure_publish_spec.rb b/spec/system/administrateurs/procedure_publish_spec.rb index 10818971c..dc1858358 100644 --- a/spec/system/administrateurs/procedure_publish_spec.rb +++ b/spec/system/administrateurs/procedure_publish_spec.rb @@ -72,8 +72,8 @@ describe 'Publishing a procedure', js: true do visit admin_procedure_path(procedure) expect(page).to have_content('Des problèmes empêchent la publication de la démarche') - expect(page).to have_content("« Enfants » doit comporter au moins un champ répétable") - expect(page).to have_content("« Civilité » doit comporter au moins un choix sélectionnable") + expect(page).to have_content("Enfants doit comporter au moins un champ répétable") + expect(page).to have_content("Civilité doit comporter au moins un choix sélectionnable") visit admin_procedure_publication_path(procedure) expect(find_field('procedure_path').value).to eq procedure.path @@ -195,7 +195,7 @@ describe 'Publishing a procedure', js: true do scenario 'an error message prevents the publication' do visit admin_procedure_path(procedure) expect(page).to have_content('Des problèmes empêchent la publication des modifications') - expect(page).to have_link('corriger', href: edit_admin_procedure_mail_template_path(procedure, Mails::InitiatedMail::SLUG)) + expect(page).to have_link(href: edit_admin_procedure_mail_template_path(procedure, Mails::InitiatedMail::SLUG)) expect(page).to have_button('Publier les modifications', disabled: true) end end From a0115767579cb447ba5b4efac25adb61fb4152bc Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 18:08:33 +0200 Subject: [PATCH 0374/1532] feat(procedure_revision.validates): ineligibilite_rules --- app/components/procedure/errors_summary.rb | 2 ++ app/models/procedure.rb | 9 ++++++++- app/models/procedure_revision.rb | 7 +++++++ config/locales/en.yml | 5 +++++ config/locales/fr.yml | 5 +++++ spec/components/procedures/errors_summary_spec.rb | 4 ++++ .../types_de_champ_editor/editor_component_spec.rb | 10 ++++++---- 7 files changed, 37 insertions(+), 5 deletions(-) diff --git a/app/components/procedure/errors_summary.rb b/app/components/procedure/errors_summary.rb index e185c81b3..bf41ab3da 100644 --- a/app/components/procedure/errors_summary.rb +++ b/app/components/procedure/errors_summary.rb @@ -32,6 +32,8 @@ class Procedure::ErrorsSummary < ApplicationComponent def error_correction_page(error) case error.attribute + when :ineligibilite_rules + edit_admin_procedure_ineligibilite_rules_path(@procedure) when :draft_types_de_champ_public tdc = error.options[:type_de_champ] champs_admin_procedure_path(@procedure, anchor: dom_id(tdc.stable_self, :editor_error)) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 21f272372..4e080831c 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -293,7 +293,7 @@ class Procedure < ApplicationRecord validates_with MonAvisEmbedValidator - validates_associated :draft_revision, on: :publication + validate :validates_associated_draft_revision_with_context validates_associated :initiated_mail, on: :publication validates_associated :received_mail, on: :publication validates_associated :closed_mail, on: :publication @@ -1020,6 +1020,13 @@ class Procedure < ApplicationRecord private + def validates_associated_draft_revision_with_context + return if draft_revision.blank? + return if draft_revision.validate(validation_context) + + draft_revision.errors.map { errors.import(_1) } + end + def validate_auto_archive_on_in_the_future return if auto_archive_on.nil? return if auto_archive_on.future? diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index 7e4f30860..bb7dbb43e 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -496,6 +496,13 @@ class ProcedureRevision < ApplicationRecord end end + def ineligibilite_rules_are_valid? + if ineligibilite_rules + ineligibilite_rules.errors(types_de_champ_for(scope: :public).to_a) + .each { errors.add(:ineligibilite_rules, :invalid) } + end + end + def replace_type_de_champ_by_clone(coordinate) cloned_type_de_champ = coordinate.type_de_champ.deep_clone do |original, kopy| ClonePiecesJustificativesService.clone_attachments(original, kopy) diff --git a/config/locales/en.yml b/config/locales/en.yml index 17b7328d0..a05c394b2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -606,6 +606,7 @@ en: otp_attempt: 'OTP code (only if you have already activated 2FA)' procedure: zone: This procedure is run by + ineligibilite_rules: "Eligibility rules" champs: value: Value default_mail_attributes: &default_mail_attributes @@ -667,6 +668,10 @@ en: path: taken: is already used for procedure. You cannot use it because it belongs to another administrator. invalid: is not valid. It must countain between 3 and 200 characters among a-z, 0-9, '_' and '-'. + procedure_revision: + attributes: + ineligibilite_rules: + invalid: are invalid "dossier/champs": format: "%{message}" attributes: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 227f89a6e..624e141d2 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -610,6 +610,7 @@ fr: otp_attempt: 'Code OTP (uniquement si vous avez déjà activé 2FA)' procedure: zone: La démarche est mise en œuvre par + ineligibilite_rules: "Les règles d’Inéligibilité" champs: value: Valeur du champ default_mail_attributes: &default_mail_attributes @@ -669,6 +670,10 @@ fr: path: taken: est déjà utilisé par une démarche. Vous ne pouvez pas l’utiliser car il appartient à un autre administrateur. invalid: n’est pas valide. Il doit comporter au moins 3 caractères, au plus 200 caractères et seuls les caractères a-z, 0-9, '_' et '-' sont autorisés. + procedure_revision: + attributes: + ineligibilite_rules: + invalid: ne sont pas valides "dossier/champs": format: "%{message}" attributes: diff --git a/spec/components/procedures/errors_summary_spec.rb b/spec/components/procedures/errors_summary_spec.rb index 3e64cc790..ebeb1096d 100644 --- a/spec/components/procedures/errors_summary_spec.rb +++ b/spec/components/procedures/errors_summary_spec.rb @@ -74,6 +74,8 @@ describe Procedure::ErrorsSummary, type: :component do end describe 'render error for other kind of associated objects' do + include Logic + let(:validation_context) { :publication } let(:procedure) { create(:procedure, attestation_template:, initiated_mail:) } let(:attestation_template) { build(:attestation_template) } @@ -81,10 +83,12 @@ describe Procedure::ErrorsSummary, type: :component do before do [:attestation_template, :initiated_mail].map { procedure.send(_1).update_column(:body, '--invalidtag--') } + procedure.draft_revision.update(ineligibilite_enabled: true, ineligibilite_rules: ds_eq(constant(true), constant(1)), ineligibilite_message: 'ko') subject end it 'render error nicely' do + expect(page).to have_selector("a", text: "Les règles d’inéligibilité") expect(page).to have_selector("a", text: "Le modèle d’attestation") expect(page).to have_selector("a", text: "L’email de notification de passage de dossier en instruction") expect(page).to have_text("n'est pas valide", count: 2) diff --git a/spec/components/types_de_champ_editor/editor_component_spec.rb b/spec/components/types_de_champ_editor/editor_component_spec.rb index 7b4a19e46..5b643995c 100644 --- a/spec/components/types_de_champ_editor/editor_component_spec.rb +++ b/spec/components/types_de_champ_editor/editor_component_spec.rb @@ -10,16 +10,18 @@ describe TypesDeChampEditor::EditorComponent, type: :component do context 'types_de_champ_public' do let(:is_annotation) { false } it 'does not render private champs errors' do - expect(subject).not_to have_text("« private » doit comporter au moins un choix sélectionnable") - expect(subject).to have_text("« public » doit comporter au moins un choix sélectionnable") + expect(subject).not_to have_text("private") + expect(subject).to have_selector("a", text: "public") + expect(subject).to have_text("doit comporter au moins un choix sélectionnable") end end context 'types_de_champ_private' do let(:is_annotation) { true } it 'does not render public champs errors' do - expect(subject).to have_text("« private » doit comporter au moins un choix sélectionnable") - expect(subject).not_to have_text("« public » doit comporter au moins un choix sélectionnable") + expect(subject).to have_selector("a", "private") + expect(subject).to have_text("doit comporter au moins un choix sélectionnable") + expect(subject).not_to have_text("public") end end end From f819da8921b5e1631a1b4cfb200222e52fa848f7 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 5 Jun 2024 19:16:41 +0200 Subject: [PATCH 0375/1532] tech(clean): simplify implementation of eligibilite rules, code, enhance wording and test coverage --- .../stylesheets/conditions_component.scss | 9 ++ .../ineligibilite_rules_component.html.haml | 53 ++++++----- .../dossiers/edit_footer_component.rb | 10 +-- .../edit_footer_component.html.haml | 4 +- .../invalid_ineligibilite_rules_component.rb | 4 +- .../card/ineligibilite_dossier_component.rb | 2 +- .../ineligibilite_dossier_component.fr.yml | 4 +- .../ineligibilite_dossier_component.html.haml | 4 +- app/controllers/users/dossiers_controller.rb | 3 - app/models/dossier.rb | 7 +- app/models/logic/and.rb | 7 -- app/models/logic/binary_operator.rb | 9 -- app/models/logic/or.rb | 10 --- app/models/procedure_revision.rb | 13 --- .../ineligibilite_rules/edit.html.haml | 6 +- .../users/dossiers/update.turbo_stream.haml | 7 +- config/locales/fr.yml | 2 +- config/locales/models/procedure/fr.yml | 2 +- .../locales/models/procedure_revision/fr.yml | 7 ++ .../dossiers/edit_footer_component_spec.rb | 8 +- .../editor_component_spec.rb | 2 +- .../ineligibilite_rules_controller_spec.rb | 4 +- .../users/dossiers_controller_spec.rb | 13 +-- spec/models/logic/and_spec.rb | 36 -------- spec/models/logic/binary_operator_spec.rb | 13 --- spec/models/logic/or_spec.rb | 43 --------- .../procedure_ineligibilite_spec.rb | 8 +- .../users/dossier_ineligibilite_spec.rb | 89 ++++++++++++++++--- .../shared/dossiers/_edit.html.haml_spec.rb | 1 - 29 files changed, 161 insertions(+), 219 deletions(-) create mode 100644 config/locales/models/procedure_revision/fr.yml diff --git a/app/assets/stylesheets/conditions_component.scss b/app/assets/stylesheets/conditions_component.scss index 055e9b4f9..59f8bf6b9 100644 --- a/app/assets/stylesheets/conditions_component.scss +++ b/app/assets/stylesheets/conditions_component.scss @@ -57,5 +57,14 @@ form.form > .conditionnel { select.alert { border-color: $dark-red; } + + &:first-child { + padding-left: 0; + } + + &:last-child { + text-align: right; + padding-right: 0; + } } } diff --git a/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml index 547a2ad85..313618167 100644 --- a/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml +++ b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml @@ -1,21 +1,30 @@ %div{ id: dom_id(@draft_revision, :ineligibilite_rules) } = render Procedure::PendingRepublishComponent.new(procedure: @draft_revision.procedure, render_if: pending_changes?) = render Conditions::ConditionsErrorsComponent.new(conditions: condition_per_row, source_tdcs: @source_tdcs) - %fieldset.fr-fieldset - %legend.fr-mx-1w.fr-label.fr-py-0.fr-mb-1w.fr-mt-2w - Règles d’inéligibilité - %span.fr-hint-text Vous pouvez utiliser 1 ou plusieurs critère pour bloquer le dépot + .fr-fieldset + = form_for(@draft_revision, url: change_admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id), html: { id: 'ineligibilite_form', class: 'width-100' }) do |f| + .fr-fieldset__element + .fr-toggle.fr-toggle--label-left + = f.check_box :ineligibilite_enabled, class: 'fr-toggle__input', data: @opt + = f.label :ineligibilite_enabled, "Bloquer le dépôt des dossiers répondant à des conditions d’inéligibilité", data: { 'fr-checked-label': "Activé", 'fr-unchecked-label': "Désactivé" }, class: 'fr-toggle__label' + + .fr-fieldset__element= render Dsfr::InputComponent.new(form: f, attribute: :ineligibilite_message, input_type: :text_area, opts: {rows: 5}) + + .fr-mx-1w.fr-label.fr-py-0.fr-mb-1w.fr-mt-2w + Conditions d’inéligibilité + %span.fr-hint-text Vous pouvez utiliser une ou plusieurs condtions pour bloquer le dépot. .fr-fieldset__element = form_tag admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id), method: :patch, data: { turbo: true, controller: 'autosave' }, class: 'form width-100' do .conditionnel.width-100 %table.condition-table - %thead - %tr - %th.fr-pt-0.far-left - %th.fr-pt-0.target Champ Cible - %th.fr-pt-0.operator Opérateur - %th.fr-pt-0.value Valeur - %th.fr-pt-0.delete-column + - if rows.size > 0 + %thead + %tr + %th.fr-pt-0.far-left + %th.fr-pt-0.target Champ Cible + %th.fr-pt-0.operator Opérateur + %th.fr-pt-0.value Valeur + %th.fr-pt-0.delete-column %tbody - rows.each.with_index do |(targeted_champ, operator_name, value), row_index| %tr @@ -28,15 +37,13 @@ %tr %td.text-right{ colspan: 5 }= add_condition_tag - - - = form_for(@draft_revision, url: change_admin_procedure_ineligibilite_rules_path(@draft_revision.procedure_id)) do |f| - .fr-fieldset__element= render Dsfr::InputComponent.new(form: f, attribute: :ineligibilite_message, input_type: :text_area, opts: {rows: 5}) - .fr-fieldset__element - .fr-toggle - = f.check_box :ineligibilite_enabled, class: 'fr-toggle__input', data: @opt - = f.label :ineligibilite_enabled, "Inéligibilité des dossiers", data: { 'fr-checked-label': "Actif", 'fr-unchecked-label': "Inactif" }, class: 'fr-toggle__label' - %p.fr-hint-text Passer l’intérrupteur sur activé pour que les critères d’inéligibilité configurés s'appliquent - - - = render Procedure::FixedFooterComponent.new(procedure: @draft_revision.procedure, form: f, extra_class_names: 'fr-col-offset-md-2 fr-col-md-8') + .padded-fixed-footer + .fixed-footer + .fr-container + .fr-grid-row.fr-col-offset-md-2.fr-col-md-8 + .fr-col-12 + %ul.fr-btns-group.fr-btns-group--inline-md + %li + = link_to "Annuler et revenir à l'écran de gestion", admin_procedure_path(id: @draft_revision.procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Si vous avez fait des modifications elles ne seront pas sauvegardées.'} + %li + = button_tag "Enregistrer", class: "fr-btn", form: 'ineligibilite_form' diff --git a/app/components/dossiers/edit_footer_component.rb b/app/components/dossiers/edit_footer_component.rb index ac77bbfea..5f5bb8980 100644 --- a/app/components/dossiers/edit_footer_component.rb +++ b/app/components/dossiers/edit_footer_component.rb @@ -1,5 +1,5 @@ class Dossiers::EditFooterComponent < ApplicationComponent - delegate :can_passer_en_construction?, :ineligibilite_rules_computable?, to: :@dossier + delegate :can_passer_en_construction?, to: :@dossier def initialize(dossier:, annotation:) @dossier = dossier @@ -27,7 +27,7 @@ class Dossiers::EditFooterComponent < ApplicationComponent def submit_draft_button_options { class: 'fr-btn fr-btn--sm', - disabled: !owner? || ineligibilite_rules_invalid?, + disabled: !owner? || !can_passer_en_construction?, method: :post, data: { 'disable-with': t('.submitting'), controller: 'autosave-submit', turbo_force: :server } } @@ -36,17 +36,13 @@ class Dossiers::EditFooterComponent < ApplicationComponent def submit_en_construction_button_options { class: 'fr-btn fr-btn--sm', - disabled: ineligibilite_rules_invalid?, + disabled: !can_passer_en_construction?, method: :post, data: { 'disable-with': t('.submitting'), controller: 'autosave-submit', turbo_force: :server }, form: { id: "form-submit-en-construction" } } end - def ineligibilite_rules_invalid? - ineligibilite_rules_computable? && !can_passer_en_construction? - end - def render? !@dossier.for_procedure_preview? end diff --git a/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml b/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml index fb4ab8fb1..2f0f59b2b 100644 --- a/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml +++ b/app/components/dossiers/edit_footer_component/edit_footer_component.html.haml @@ -3,12 +3,12 @@ = render Dossiers::AutosaveFooterComponent.new(dossier: @dossier, annotation: annotation?) - if !annotation? && @dossier.can_transition_to_en_construction? - - if ineligibilite_rules_invalid? + - if !can_passer_en_construction? = link_to t('.submit_disabled'), "#", disabled_submit_buttons_options = button_to t('.submit'), brouillon_dossier_url(@dossier), submit_draft_button_options - if @dossier.forked_with_changes? - - if ineligibilite_rules_invalid? + - if !can_passer_en_construction? = link_to t('.submit_disabled'), "#", disabled_submit_buttons_options = button_to t('.submit_changes'), modifier_dossier_url(@dossier.editing_fork_origin), submit_en_construction_button_options diff --git a/app/components/dossiers/invalid_ineligibilite_rules_component.rb b/app/components/dossiers/invalid_ineligibilite_rules_component.rb index fe45272f6..526bdbc94 100644 --- a/app/components/dossiers/invalid_ineligibilite_rules_component.rb +++ b/app/components/dossiers/invalid_ineligibilite_rules_component.rb @@ -1,5 +1,5 @@ class Dossiers::InvalidIneligibiliteRulesComponent < ApplicationComponent - delegate :can_passer_en_construction?, :ineligibilite_rules_computable?, to: :@dossier + delegate :can_passer_en_construction?, to: :@dossier def initialize(dossier:) @dossier = dossier @@ -7,7 +7,7 @@ class Dossiers::InvalidIneligibiliteRulesComponent < ApplicationComponent end def render? - ineligibilite_rules_computable? && !can_passer_en_construction? + !can_passer_en_construction? end def error_message diff --git a/app/components/procedure/card/ineligibilite_dossier_component.rb b/app/components/procedure/card/ineligibilite_dossier_component.rb index d69e06623..b1d371708 100644 --- a/app/components/procedure/card/ineligibilite_dossier_component.rb +++ b/app/components/procedure/card/ineligibilite_dossier_component.rb @@ -6,7 +6,7 @@ class Procedure::Card::IneligibiliteDossierComponent < ApplicationComponent def ready? @procedure.draft_revision .conditionable_types_de_champ - .present? + .present? && @procedure.draft_revision.ineligibilite_enabled end def error? diff --git a/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml index d65f0d535..6e78d7da6 100644 --- a/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml +++ b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.fr.yml @@ -2,7 +2,7 @@ fr: title: Inéligibilité des dossiers state: - pending: Champs à configurer + pending: Désactivé ready: À configurer completed: Activé - subtitle: Gérez vos critères d’inéligibilité en fonction des champs du formulaire + subtitle: Gérez vos conditions d’inéligibilité en fonction des champs du formulaire diff --git a/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.html.haml b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.html.haml index e82e64fad..aeced88e6 100644 --- a/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.html.haml +++ b/app/components/procedure/card/ineligibilite_dossier_component/ineligibilite_dossier_component.html.haml @@ -2,11 +2,9 @@ = link_to edit_admin_procedure_ineligibilite_rules_path(@procedure), class: 'fr-tile fr-enlarge-link' do .fr-tile__body.flex.column.align-center.justify-between - if !ready? - %p.fr-badge.fr-badge--warning= t('.state.pending') + %p.fr-badge.fr-badge= t('.state.pending') - elsif error? %p.fr-badge.fr-badge--error À modifier - - elsif !completed? - %p.fr-badge.fr-badge--info= t('.state.ready') - else %p.fr-badge.fr-badge--success= t('.state.completed') %div diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index ebfa2cdb4..1fac35eae 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -302,13 +302,10 @@ module Users def update @dossier = dossier.en_construction? ? dossier.find_editing_fork(dossier.user) : dossier @dossier = dossier_with_champs(pj_template: false) - @ineligibilite_rules_was_computable = @dossier.ineligibilite_rules_computable? @can_passer_en_construction_was = @dossier.can_passer_en_construction? update_dossier_and_compute_errors @dossier.index_search_terms_later if @dossier.errors.empty? - @ineligibilite_rules_is_computable = @dossier.ineligibilite_rules_computable? @can_passer_en_construction_is = @dossier.can_passer_en_construction? - @ineligibilite_rules_computable_changed = !@ineligibilite_rules_was_computable && @ineligibilite_rules_is_computable respond_to do |format| format.turbo_stream do @to_show, @to_hide, @to_update = champs_to_turbo_update(champs_public_attributes_params, dossier.champs.filter(&:public?)) diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 7a1c611a8..343baf06d 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -940,12 +940,9 @@ class Dossier < ApplicationRecord .filter(&:visible?) .filter(&:mandatory_blank?) .map do |champ| - errors.import(champ.errors.add(:value, :missing)) + champ.errors.add(:value, :missing) end - end - - def ineligibilite_rules_computable? - revision.ineligibilite_rules_computable?(champs_for_revision(scope: :public)) + .each { errors.import(_1) } end def demander_un_avis!(avis) diff --git a/app/models/logic/and.rb b/app/models/logic/and.rb index 11d31a9c0..51537235f 100644 --- a/app/models/logic/and.rb +++ b/app/models/logic/and.rb @@ -7,12 +7,5 @@ class Logic::And < Logic::NAryOperator @operands.map { |operand| operand.compute(champs) }.all? end - def computable?(champs = []) - return true if sources.blank? - - champs.filter { _1.stable_id.in?(sources) && _1.visible? } - .all? { _1.value.present? } - end - def to_s(type_de_champs) = "(#{@operands.map { |o| o.to_s(type_de_champs) }.join(' && ')})" end diff --git a/app/models/logic/binary_operator.rb b/app/models/logic/binary_operator.rb index 35f6ce1a7..812fa0605 100644 --- a/app/models/logic/binary_operator.rb +++ b/app/models/logic/binary_operator.rb @@ -42,15 +42,6 @@ class Logic::BinaryOperator < Logic::Term l&.send(operation, r) || false end - def computable?(champs = []) - return true if sources.blank? - - visible_champs_sources = champs.filter { _1.stable_id.in?(sources) && _1.visible? } - - return false if visible_champs_sources.size != sources.size - visible_champs_sources.all? { _1.value.present? } - end - def to_s(type_de_champs) = "(#{@left.to_s(type_de_champs)} #{operation} #{@right.to_s(type_de_champs)})" def ==(other) diff --git a/app/models/logic/or.rb b/app/models/logic/or.rb index 96a0fe133..a0e2dfeae 100644 --- a/app/models/logic/or.rb +++ b/app/models/logic/or.rb @@ -7,15 +7,5 @@ class Logic::Or < Logic::NAryOperator @operands.map { |operand| operand.compute(champs) }.any? end - - def computable?(champs = []) - return true if sources.blank? - - visible_champs_sources = champs.filter { _1.stable_id.in?(sources) && _1.visible? } - - return false if visible_champs_sources.blank? - visible_champs_sources.all? { _1.value.present? } || compute(visible_champs_sources) - end - def to_s(type_de_champs = []) = "(#{@operands.map { |o| o.to_s(type_de_champs) }.join(' || ')})" end diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index bb7dbb43e..0a27fec2c 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -269,12 +269,6 @@ class ProcedureRevision < ApplicationRecord types_de_champ_for(scope: :public).filter(&:conditionable?) end - def ineligibilite_rules_computable?(champs) - ineligibilite_enabled && ineligibilite_rules&.computable?(champs) - ensure - champs.map(&:reset_visible) # otherwise @visible is cached, then dossier can be updated. champs are not updated - end - private def compute_estimated_fill_duration @@ -496,13 +490,6 @@ class ProcedureRevision < ApplicationRecord end end - def ineligibilite_rules_are_valid? - if ineligibilite_rules - ineligibilite_rules.errors(types_de_champ_for(scope: :public).to_a) - .each { errors.add(:ineligibilite_rules, :invalid) } - end - end - def replace_type_de_champ_by_clone(coordinate) cloned_type_de_champ = coordinate.type_de_champ.deep_clone do |original, kopy| ClonePiecesJustificativesService.clone_attachments(original, kopy) diff --git a/app/views/administrateurs/ineligibilite_rules/edit.html.haml b/app/views/administrateurs/ineligibilite_rules/edit.html.haml index a76a30468..6eb91d12f 100644 --- a/app/views/administrateurs/ineligibilite_rules/edit.html.haml +++ b/app/views/administrateurs/ineligibilite_rules/edit.html.haml @@ -12,17 +12,17 @@ = render Dsfr::AlertComponent.new(title: nil, size: :sm, state: :info, heading_level: 'h2', extra_class_names: 'fr-my-2w') do |c| - c.with_body do %p - Les dossiers répondant à vos critères d’inéligibilité ne pourront pas être déposés. Plus d’informations sur l’inéligibilité des dossiers dans la + Les dossiers répondant à vos conditions d’inéligibilité ne pourront pas être déposés. Plus d’informations sur l’inéligibilité des dossiers dans la = link_to('doc', ELIGIBILITE_URL, title: "Document sur l’inéligibilité des dossiers", **external_link_attributes) - if !@procedure.draft_revision.conditionable_types_de_champ.present? %p.fr-mt-2w.fr-mb-2w - Pour configurer l’inéligibilité des dossiers, votre formulaire doit comporter au moins un champ supportant les critères d’inéligibilité. Il vous faut donc ajouter au moins un des champs suivant à votre formulaire : + Pour configurer l’inéligibilité des dossiers, votre formulaire doit comporter au moins un champ supportant les conditions d’inéligibilité. Il vous faut donc ajouter au moins un des champs suivant à votre formulaire : %ul - Logic::ChampValue::MANAGED_TYPE_DE_CHAMP.values.each do %li= "« #{t(_1, scope: [:activerecord, :attributes, :type_de_champ, :type_champs])} »" %p.fr-mt-2w - = link_to 'Ajouter un champ supportant les critères d’inéligibilité', champs_admin_procedure_path(@procedure), class: 'fr-link fr-icon-arrow-right-line fr-link--icon-right' + = link_to 'Ajouter un champ supportant les conditions d’inéligibilité', champs_admin_procedure_path(@procedure), class: 'fr-link fr-icon-arrow-right-line fr-link--icon-right' = render Procedure::FixedFooterComponent.new(procedure: @procedure) - else = render Conditions::IneligibiliteRulesComponent.new(draft_revision: @procedure.draft_revision) diff --git a/app/views/users/dossiers/update.turbo_stream.haml b/app/views/users/dossiers/update.turbo_stream.haml index 374291733..8224c1abd 100644 --- a/app/views/users/dossiers/update.turbo_stream.haml +++ b/app/views/users/dossiers/update.turbo_stream.haml @@ -1,8 +1,7 @@ = render partial: 'shared/dossiers/update_champs', locals: { to_show: @to_show, to_hide: @to_hide, to_update: @to_update, dossier: @dossier } - if !params.key?(:validate) - - if @ineligibilite_rules_is_computable - = turbo_stream.remove(dom_id(@dossier, :ineligibilite_rules_broken)) - - - if (@ineligibilite_rules_computable_changed && !@can_passer_en_construction_is) || (@can_passer_en_construction_was && !@can_passer_en_construction_is) + - if @can_passer_en_construction_was && !@can_passer_en_construction_is = turbo_stream.append('contenu', render(Dossiers::InvalidIneligibiliteRulesComponent.new(dossier: @dossier))) + - else @ineligibilite_rules_is_computable + = turbo_stream.remove(dom_id(@dossier, :ineligibilite_rules_broken)) diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 624e141d2..42896bd24 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -610,7 +610,7 @@ fr: otp_attempt: 'Code OTP (uniquement si vous avez déjà activé 2FA)' procedure: zone: La démarche est mise en œuvre par - ineligibilite_rules: "Les règles d’Inéligibilité" + ineligibilite_rules: "Les règles d’inéligibilité" champs: value: Valeur du champ default_mail_attributes: &default_mail_attributes diff --git a/config/locales/models/procedure/fr.yml b/config/locales/models/procedure/fr.yml index 85df92d73..5a9dfd9ab 100644 --- a/config/locales/models/procedure/fr.yml +++ b/config/locales/models/procedure/fr.yml @@ -8,7 +8,7 @@ fr: procedure: hints: description: Décrivez en quelques lignes le contexte, la finalité, etc. - description_target_audience: Décrivez en quelques lignes les destinataires finaux de la démarche, les critères d’éligibilité s’il y en a, les pré-requis, etc. + description_target_audience: Décrivez en quelques lignes les destinataires finaux de la démarche, les conditions d’éligibilité s’il y en a, les pré-requis, etc. description_pj: Décrivez la liste des pièces jointes à fournir s’il y en a lien_site_web: "Il s'agit de la page de votre site web où le lien sera diffusé. Ex: https://exemple.gouv.fr/page_informant_sur_ma_demarche" cadre_juridique: "Exemple: 'https://www.legifrance.gouv.fr/'" diff --git a/config/locales/models/procedure_revision/fr.yml b/config/locales/models/procedure_revision/fr.yml new file mode 100644 index 000000000..1665415aa --- /dev/null +++ b/config/locales/models/procedure_revision/fr.yml @@ -0,0 +1,7 @@ +fr: + activerecord: + attributes: + procedure_revision: + ineligibilite_message: Message d’inéligibilité + hints: + ineligibilite_message: "Ce message sera affiché à l’usager si son dossier est bloqué et lui expliquera la raison de son inéligibilité." diff --git a/spec/components/dossiers/edit_footer_component_spec.rb b/spec/components/dossiers/edit_footer_component_spec.rb index 40e60802b..4b8e1a77f 100644 --- a/spec/components/dossiers/edit_footer_component_spec.rb +++ b/spec/components/dossiers/edit_footer_component_spec.rb @@ -10,14 +10,14 @@ RSpec.describe Dossiers::EditFooterComponent, type: :component do let(:dossier) { create(:dossier, :brouillon) } context 'when dossier can be submitted' do - before { allow(component).to receive(:ineligibilite_rules_invalid?).and_return(false) } + before { allow(component).to receive(:can_passer_en_construction?).and_return(true) } it 'renders submit button without disabled' do expect(subject).to have_selector('button', text: 'Déposer le dossier') end end context 'when dossier can not be submitted' do - before { allow(component).to receive(:ineligibilite_rules_invalid?).and_return(true) } + before { allow(component).to receive(:can_passer_en_construction?).and_return(false) } it 'renders submit button with disabled' do expect(subject).to have_selector('a', text: 'Pourquoi je ne peux pas déposer mon dossier ?') expect(subject).to have_selector('button[disabled]', text: 'Déposer le dossier') @@ -31,7 +31,7 @@ RSpec.describe Dossiers::EditFooterComponent, type: :component do before { allow(dossier).to receive(:forked_with_changes?).and_return(true) } context 'when dossier can be submitted' do - before { allow(component).to receive(:ineligibilite_rules_invalid?).and_return(false) } + before { allow(component).to receive(:can_passer_en_construction?).and_return(true) } it 'renders submit button without disabled' do expect(subject).to have_selector('button', text: 'Déposer les modifications') @@ -39,7 +39,7 @@ RSpec.describe Dossiers::EditFooterComponent, type: :component do end context 'when dossier can not be submitted' do - before { allow(component).to receive(:ineligibilite_rules_invalid?).and_return(true) } + before { allow(component).to receive(:can_passer_en_construction?).and_return(false) } it 'renders submit button with disabled' do expect(subject).to have_selector('a', text: 'Pourquoi je ne peux pas déposer mon dossier ?') diff --git a/spec/components/types_de_champ_editor/editor_component_spec.rb b/spec/components/types_de_champ_editor/editor_component_spec.rb index 5b643995c..fb7094983 100644 --- a/spec/components/types_de_champ_editor/editor_component_spec.rb +++ b/spec/components/types_de_champ_editor/editor_component_spec.rb @@ -19,7 +19,7 @@ describe TypesDeChampEditor::EditorComponent, type: :component do context 'types_de_champ_private' do let(:is_annotation) { true } it 'does not render public champs errors' do - expect(subject).to have_selector("a", "private") + expect(subject).to have_selector("a", text: "private") expect(subject).to have_text("doit comporter au moins un choix sélectionnable") expect(subject).not_to have_text("public") end diff --git a/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb b/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb index 5c8f94628..2a76a054a 100644 --- a/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb +++ b/spec/controllers/administrateurs/ineligibilite_rules_controller_spec.rb @@ -197,14 +197,14 @@ describe Administrateurs::IneligibiliteRulesController, type: :controller do let(:types_de_champ_public) { [] } render_views - it { expect(response.body).to have_link("Ajouter un champ supportant les critères d’inéligibilité") } + it { expect(response.body).to have_link("Ajouter un champ supportant les conditions d’inéligibilité") } end context 'rendered with tdc' do let(:types_de_champ_public) { [{ type: :yes_no }] } render_views - it { expect(response.body).not_to have_link("Ajouter un champ supportant les critères d’inéligibilité") } + it { expect(response.body).not_to have_link("Ajouter un champ supportant les conditions d’inéligibilité") } end end end diff --git a/spec/controllers/users/dossiers_controller_spec.rb b/spec/controllers/users/dossiers_controller_spec.rb index 1f78b2f4a..2a38eb7d7 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -791,26 +791,27 @@ describe Users::DossiersController, type: :controller do end render_views - context 'when it pass from undefined to true' do + context 'when it switches from true to false' do let(:value) { must_be_greater_than + 1 } it 'raises popup' do subject dossier.reload expect(dossier.can_passer_en_construction?).to be_falsey - expect(assigns(:ineligibilite_rules_was_computable)).to eq(false) - expect(assigns(:ineligibilite_rules_is_computable)).to eq(true) + expect(assigns(:can_passer_en_construction_was)).to eq(true) + expect(assigns(:can_passer_en_construction_is)).to eq(false) expect(response.body).to match(ActionView::RecordIdentifier.dom_id(dossier, :ineligibilite_rules_broken)) end end - context 'when it pass from undefined to false' do + + context 'when it stays true' do let(:value) { must_be_greater_than - 1 } it 'does nothing' do subject dossier.reload expect(dossier.can_passer_en_construction?).to be_truthy - expect(assigns(:ineligibilite_rules_was_computable)).to eq(false) - expect(assigns(:ineligibilite_rules_is_computable)).to eq(true) + expect(assigns(:can_passer_en_construction_was)).to eq(true) + expect(assigns(:can_passer_en_construction_is)).to eq(true) expect(response.body).not_to have_selector("##{ActionView::RecordIdentifier.dom_id(dossier, :ineligibilite_rules_broken)}") end end diff --git a/spec/models/logic/and_spec.rb b/spec/models/logic/and_spec.rb index c0eefc8e8..67f319acb 100644 --- a/spec/models/logic/and_spec.rb +++ b/spec/models/logic/and_spec.rb @@ -6,42 +6,6 @@ describe Logic::And do it { expect(and_from([true, true, false]).compute).to be false } end - describe '#computable?' do - let(:champ_1) { create(:champ_integer_number, value: value_1) } - let(:champ_2) { create(:champ_integer_number, value: value_2) } - - let(:logic) do - ds_and([ - greater_than(champ_value(champ_1.stable_id), constant(1)), - less_than(champ_value(champ_2.stable_id), constant(10)) - ]) - end - - subject { logic.computable?([champ_1, champ_2]) } - - context "when none of champs.value are filled, and logic can't be computed" do - let(:value_1) { nil } - let(:value_2) { nil } - it { is_expected.to be_falsey } - end - context "when one champs has a value (that compute to false) the other has not, and logic keeps waiting for the 2nd value" do - let(:value_1) { 1 } - let(:value_2) { nil } - it { is_expected.to be_falsey } - end - context 'when all champs.value are filled, and logic can be computed' do - let(:value_1) { 1 } - let(:value_2) { 10 } - it { is_expected.to be_truthy } - end - context 'when one champs is not visible and the other has a value, and logic can be computed' do - let(:value_1) { 1 } - let(:value_2) { nil } - before { expect(champ_2).to receive(:visible?).and_return(false) } - it { is_expected.to be_truthy } - end - end - describe '#to_s' do it do expect(and_from([true, false, true]).to_s([])).to eq "(Oui && Non && Oui)" diff --git a/spec/models/logic/binary_operator_spec.rb b/spec/models/logic/binary_operator_spec.rb index f816e81e7..b7924ebc7 100644 --- a/spec/models/logic/binary_operator_spec.rb +++ b/spec/models/logic/binary_operator_spec.rb @@ -28,19 +28,6 @@ describe Logic::BinaryOperator do it { expect(greater_than(constant(2), champ_value(champ.stable_id)).sources).to eq([champ.stable_id]) } it { expect(greater_than(champ_value(champ.stable_id), champ_value(champ2.stable_id)).sources).to eq([champ.stable_id, champ2.stable_id]) } end - - describe '#computable?' do - let(:champ) { create(:champ_integer_number, value: nil) } - - it 'computable?' do - expect(greater_than(champ_value(champ.stable_id), constant(1)).computable?([])).to be(false) - expect(greater_than(champ_value(champ.stable_id), constant(1)).computable?([champ])).to be(false) - allow(champ).to receive(:value).and_return(double(present?: true)) - expect(greater_than(champ_value(champ.stable_id), constant(1)).computable?([champ])).to be(true) - allow(champ).to receive(:visible?).and_return(false) - expect(greater_than(champ_value(champ.stable_id), constant(1)).computable?([champ])).to be(false) - end - end end describe Logic::GreaterThan do diff --git a/spec/models/logic/or_spec.rb b/spec/models/logic/or_spec.rb index 82d5392fb..1888587d2 100644 --- a/spec/models/logic/or_spec.rb +++ b/spec/models/logic/or_spec.rb @@ -7,49 +7,6 @@ describe Logic::Or do it { expect(or_from([false, false, false]).compute).to be false } end - describe '#computable?' do - let(:champ_1) { create(:champ_integer_number, value: value_1) } - let(:champ_2) { create(:champ_integer_number, value: value_2) } - - let(:logic) do - ds_or([ - greater_than(champ_value(champ_1.stable_id), constant(1)), - less_than(champ_value(champ_2.stable_id), constant(10)) - ]) - end - - context 'with all champs' do - subject { logic.computable?([champ_1, champ_2]) } - - context "when none of champs.value are filled, or logic can't be computed" do - let(:value_1) { nil } - let(:value_2) { nil } - it { is_expected.to be_falsey } - end - context "when one champs has a value (that compute to false) the other has not, or logic keeps waiting for the 2nd value" do - let(:value_1) { 1 } - let(:value_2) { nil } - it { is_expected.to be_falsey } - end - context 'when all champs.value are filled, or logic can be computed' do - let(:value_1) { 1 } - let(:value_2) { 10 } - it { is_expected.to be_truthy } - end - context 'when one champs.value and his condition is true, or logic can be computed' do - let(:value_1) { 2 } - let(:value_2) { nil } - it { is_expected.to be_truthy } - end - context 'when one champs is not visible and the other has a value that fails, or logic can be computed' do - let(:value_1) { 1 } - let(:value_2) { nil } - before { expect(champ_2).to receive(:visible?).and_return(false) } - it { is_expected.to be_truthy } - end - end - end - describe '#to_s' do it { expect(or_from([true, false, true]).to_s).to eq "(Oui || Non || Oui)" } end diff --git a/spec/system/administrateurs/procedure_ineligibilite_spec.rb b/spec/system/administrateurs/procedure_ineligibilite_spec.rb index 9db80cf59..e93a6ea5d 100644 --- a/spec/system/administrateurs/procedure_ineligibilite_spec.rb +++ b/spec/system/administrateurs/procedure_ineligibilite_spec.rb @@ -9,13 +9,13 @@ describe 'Administrateurs can edit procedures', js: true do scenario 'setup eligibilite' do # explain no champ compatible visit admin_procedure_path(procedure) - expect(page).to have_content("Champs à configurer") + expect(page).to have_content("Désactivé") # explain which champs are compatible visit edit_admin_procedure_ineligibilite_rules_path(procedure) expect(page).to have_content("Inéligibilité des dossiers") - expect(page).to have_content("Pour configurer l’inéligibilité des dossiers, votre formulaire doit comporter au moins un champ supportant les critères d’inéligibilité. Il vous faut donc ajouter au moins un des champs suivant à votre formulaire : ") - click_on "Ajouter un champ supportant les critères d’inéligibilité" + expect(page).to have_content("Pour configurer l’inéligibilité des dossiers, votre formulaire doit comporter au moins un champ supportant les conditions d’inéligibilité. Il vous faut donc ajouter au moins un des champs suivant à votre formulaire : ") + click_on "Ajouter un champ supportant les conditions d’inéligibilité" # setup a compatible champ expect(page).to have_content('Champs du formulaire') @@ -32,7 +32,7 @@ describe 'Administrateurs can edit procedures', js: true do # setup rules and stuffs expect(page).to have_content("Inéligibilité des dossiers") fill_in "Message d’inéligibilité", with: "vous n'etes pas eligible" - find('label', text: 'Inéligibilité des dossiers').click + find('label', text: 'Bloquer le dépôt des dossiers répondant à des conditions d’inéligibilité').click click_on "Ajouter une règle d’inéligibilité" all('select').first.select 'Un champ oui non' click_on 'Enregistrer' diff --git a/spec/system/users/dossier_ineligibilite_spec.rb b/spec/system/users/dossier_ineligibilite_spec.rb index 366dac780..5bbb25c75 100644 --- a/spec/system/users/dossier_ineligibilite_spec.rb +++ b/spec/system/users/dossier_ineligibilite_spec.rb @@ -9,7 +9,7 @@ describe 'Dossier Inéligibilité', js: true do let(:published_revision) { procedure.published_revision } let(:first_tdc) { published_revision.types_de_champ.first } - let(:second_tdc) { published_revision.types_de_champ.last } + let(:second_tdc) { published_revision.types_de_champ.second } let(:ineligibilite_message) { 'sry vous pouvez aps soumettre votre dossier' } let(:eligibilite_params) { { ineligibilite_enabled: true, ineligibilite_message: } } @@ -18,8 +18,8 @@ describe 'Dossier Inéligibilité', js: true do login_as user, scope: :user end - context 'single condition' do - let(:types_de_champ_public) { [{ type: :yes_no }] } + describe 'ineligibilite_rules with a single BinaryOperator' do + let(:types_de_champ_public) { [{ type: :yes_no, stable_id: 1 }] } let(:ineligibilite_rules) { ds_eq(champ_value(first_tdc.stable_id), constant(true)) } scenario 'can submit, can not submit, reload' do @@ -28,24 +28,33 @@ describe 'Dossier Inéligibilité', js: true do expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) expect(page).not_to have_content("Vous ne pouvez pas déposer votre dossier") - # does raise error when dossier is filled with valid condition - find("label", text: "Non").click + # does raise error when dossier is filled with condition that does not match + within "#champ-1" do + find("label", text: "Non").click + end expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) expect(page).not_to have_content("Vous ne pouvez pas déposer votre dossier") - # raise error when dossier is filled with invalid condition - find("label", text: "Oui").click + # raise error when dossier is filled with condition that matches + within "#champ-1" do + find("label", text: "Oui").click + end expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: true) expect(page).to have_content("Vous ne pouvez pas déposer votre dossier") - # reload page and see error because it was filled + # reload page and see error visit brouillon_dossier_path(dossier) expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: true) expect(page).to have_content("Vous ne pouvez pas déposer votre dossier") # modal is closable, and we can change our dossier response to be eligible + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: true) within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } - find("label", text: "Non").click + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: false) + + within "#champ-1" do + find("label", text: "Non").click + end expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) # it works, yay @@ -54,7 +63,7 @@ describe 'Dossier Inéligibilité', js: true do end end - context 'or condition' do + describe 'ineligibilite_rules with a Or' do let(:types_de_champ_public) { [{ type: :yes_no, libelle: 'l1' }, { type: :drop_down_list, libelle: 'l2', options: ['Paris', 'Marseille'] }] } let(:ineligibilite_rules) do ds_or([ @@ -69,15 +78,17 @@ describe 'Dossier Inéligibilité', js: true do expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) expect(page).not_to have_content("Vous ne pouvez pas déposer votre dossier") - # only one condition is matches, cannot submit dossier and error message is clear + # first condition matches (so ineligible), cannot submit dossier and error message is clear within "#champ-#{first_tdc.stable_id}" do find("label", text: "Oui").click end expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: true) expect(page).to have_content("Vous ne pouvez pas déposer votre dossier") + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: true) within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: false) - # only one condition does not matches, I can conitnue + # first condition does not matches, I can conitnue within "#champ-#{first_tdc.stable_id}" do find("label", text: "Non").click end @@ -88,12 +99,15 @@ describe 'Dossier Inéligibilité', js: true do click_on "Accéder à votre dossier" click_on "Modifier le dossier" - # one condition matches, means i'm blocked to send my file. + # first matches, means i'm blocked to send my file. within "#champ-#{first_tdc.stable_id}" do find("label", text: "Oui").click end expect(page).to have_selector(:button, text: "Déposer les modifications", disabled: true) + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: true) within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: false) + within "#champ-#{first_tdc.stable_id}" do find("label", text: "Non").click end @@ -104,7 +118,56 @@ describe 'Dossier Inéligibilité', js: true do find("label", text: 'Paris').click end expect(page).to have_selector(:button, text: "Déposer les modifications", disabled: true) + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: true) within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: false) + + # none of conditions matches, i can submit + within "#champ-#{second_tdc.stable_id}" do + find("label", text: 'Marseille').click + end + + # it works, yay + click_on "Déposer les modifications" + wait_until { dossier.reload.en_construction? == true } + end + end + + describe 'ineligibilite_rules with a And and all visible champs' do + let(:types_de_champ_public) { [{ type: :yes_no, libelle: 'l1' }, { type: :drop_down_list, libelle: 'l2', options: ['Paris', 'Marseille'] }] } + let(:ineligibilite_rules) do + ds_and([ + ds_eq(champ_value(first_tdc.stable_id), constant(true)), + ds_eq(champ_value(second_tdc.stable_id), constant('Paris')) + ]) + end + + scenario 'can submit, can not submit, can edit, etc...' do + visit brouillon_dossier_path(dossier) + # no error while dossier is empty + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) + expect(page).not_to have_content("Vous ne pouvez pas déposer votre dossier") + + # only one condition is matches, can submit dossier + within "#champ-#{first_tdc.stable_id}" do + find("label", text: "Oui").click + end + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) + expect(page).not_to have_content("Vous ne pouvez pas déposer votre dossier") + + # Now test dossier modification + click_on "Déposer le dossier" + click_on "Accéder à votre dossier" + click_on "Modifier le dossier" + + # second condition matches, means i'm blocked to send my file + within "#champ-#{second_tdc.stable_id}" do + find("label", text: 'Paris').click + end + expect(page).to have_selector(:button, text: "Déposer les modifications", disabled: true) + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: true) + within("#modal-eligibilite-rules-dialog") { click_on "Fermer" } + expect(page).to have_selector("#modal-eligibilite-rules-dialog", visible: false) # none of conditions matches, i can submit within "#champ-#{second_tdc.stable_id}" do diff --git a/spec/views/shared/dossiers/_edit.html.haml_spec.rb b/spec/views/shared/dossiers/_edit.html.haml_spec.rb index 5183554d8..f6ce8f5bf 100644 --- a/spec/views/shared/dossiers/_edit.html.haml_spec.rb +++ b/spec/views/shared/dossiers/_edit.html.haml_spec.rb @@ -155,7 +155,6 @@ describe 'shared/dossiers/edit', type: :view do let(:dossier) { create(:dossier, procedure:) } before do - allow_any_instance_of(Dossiers::InvalidIneligibiliteRulesComponent).to receive(:ineligibilite_rules_computable?).and_return(true) allow(dossier).to receive(:can_passer_en_construction?).and_return(false) end From 8e3d45b0b1e0872fb2523b9ecad885854fcea0b0 Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 11 Jun 2024 10:17:27 +0200 Subject: [PATCH 0376/1532] review(pr): some enhancement, tx @colinux Co-Authored-By: Colin Darie --- app/controllers/email_checker_controller.rb | 2 +- .../controllers/email_input_controller.ts | 6 +++++- app/lib/email_checker.rb | 15 +++++++++------ spec/lib/email_checker_spec.rb | 2 +- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/app/controllers/email_checker_controller.rb b/app/controllers/email_checker_controller.rb index b794b4d7a..19cd0493b 100644 --- a/app/controllers/email_checker_controller.rb +++ b/app/controllers/email_checker_controller.rb @@ -1,5 +1,5 @@ class EmailCheckerController < ApplicationController def show - render json: EmailChecker.check(email: params[:email]) + render json: EmailChecker.new.check(email: params[:email]) end end diff --git a/app/javascript/controllers/email_input_controller.ts b/app/javascript/controllers/email_input_controller.ts index 8b64a7e92..f8442e1d3 100644 --- a/app/javascript/controllers/email_input_controller.ts +++ b/app/javascript/controllers/email_input_controller.ts @@ -21,7 +21,11 @@ export class EmailInputController extends ApplicationController { declare readonly inputTarget: HTMLInputElement; async checkEmail() { - if (!this.inputTarget.value) { + if ( + !this.inputTarget.value || + this.inputTarget.value.length < 5 || + !this.inputTarget.value.includes('@') + ) { return; } diff --git a/app/lib/email_checker.rb b/app/lib/email_checker.rb index 97fa9d803..c2cbe3536 100644 --- a/app/lib/email_checker.rb +++ b/app/lib/email_checker.rb @@ -1,4 +1,7 @@ class EmailChecker + # Extracted 100 most used domain on our users table [june 2024] + # + all .gouv.fr domain on our users table + # + all .ac-xxx on our users table KNOWN_DOMAINS = [ 'gmail.com', 'hotmail.fr', @@ -612,10 +615,10 @@ class EmailChecker 'ac-toulous.fr' ].freeze - def self.check(email:) + def check(email:) return { success: false } if email.blank? - parsed_email = Mail::Address.new(email) + parsed_email = Mail::Address.new(EmailSanitizableConcern::EmailSanitizer.sanitize(email)) return { success: false } if parsed_email.domain.blank? return { success: true } if KNOWN_DOMAINS.any? { _1 == parsed_email.domain } @@ -628,22 +631,22 @@ class EmailChecker private - def self.closest_domains(domain:) + def closest_domains(domain:) KNOWN_DOMAINS.filter do |known_domain| close_by_distance_of(domain, known_domain, distance: 1) || with_same_chars_and_close_by_distance_of(domain, known_domain, distance: 2) end end - def self.close_by_distance_of(a, b, distance:) + def close_by_distance_of(a, b, distance:) String::Similarity.levenshtein_distance(a, b) == distance end - def self.with_same_chars_and_close_by_distance_of(a, b, distance:) + def with_same_chars_and_close_by_distance_of(a, b, distance:) close_by_distance_of(a, b, distance: 2) && a.chars.sort == b.chars.sort end - def self.email_suggestions(parsed_email:, similar_domains:) + def email_suggestions(parsed_email:, similar_domains:) similar_domains.map { Mail::Address.new("#{parsed_email.local}@#{_1}").to_s } end end diff --git a/spec/lib/email_checker_spec.rb b/spec/lib/email_checker_spec.rb index cfcf73bfa..f9c35ea91 100644 --- a/spec/lib/email_checker_spec.rb +++ b/spec/lib/email_checker_spec.rb @@ -1,6 +1,6 @@ describe EmailChecker do describe 'check' do - subject { described_class } + subject { described_class.new } it 'works with identified use cases' do expect(subject.check(email: nil)).to eq({ success: false }) From 7553a5fc52f468f9951a49a2c8910aad7b5849b1 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Sat, 1 Jun 2024 23:07:56 +0200 Subject: [PATCH 0377/1532] test: don't trace coverage by default in local machines --- spec/spec_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ae50174c7..5ff703c1a 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -18,7 +18,7 @@ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration # # -require 'simplecov' # see config in .simplecov file +require 'simplecov' if ENV["CI"] || ENV["COVERAGE"] # see config in .simplecov file require 'rspec/retry' From 89cea04cfdb07c87b3208dbbc8d68613111b9df8 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 11 Jun 2024 14:48:13 +0200 Subject: [PATCH 0378/1532] chore(git): ignore system or local files --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 638c56b9e..a210a1d87 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ public/downloads doc/*.svg uploads/* .byebug_history +.DS_Store +*.swp +.envrc .env storage/ /node_modules From 8cb902821fda2437d8d2d4a8e5eb77d3fe7aa448 Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 11 Jun 2024 11:40:15 +0200 Subject: [PATCH 0379/1532] bug(draft_types_de_champ_private.condition): condition must be validated with upper_tdcs. considering that types_de_champ_private can have a condition using a types_de_champ_public, we have to include all types_de_champs_public plus only types_de_champs_private.upper_tdcs --- spec/models/procedure_spec.rb | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 0fa94a425..0b8b3f4ed 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -441,6 +441,33 @@ describe Procedure do end end + context 'when condition on champ private use public champ having a position higher than the champ private' do + include Logic + + let(:types_de_champ_public) do + [ + { type: :decimal_number, stable_id: 1 }, + { type: :decimal_number, stable_id: 2 } + ] + end + + let(:types_de_champ_private) do + [ + { type: :text, condition: ds_eq(champ_value(2), constant(2)), stable_id: 3 } + ] + end + + it 'validate without context' do + procedure.validate + expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to be_empty + end + + it 'validate allows condition' do + procedure.validate(:types_de_champ_private_editor) + expect(procedure.errors.full_messages_for(:draft_types_de_champ_private)).to be_empty + end + end + context 'when condition on champ public use private champ' do include Logic let(:types_de_champ_public) { [{ type: :text, libelle: 'condition', condition: ds_eq(champ_value(1), constant(2)), stable_id: 2 }] } From 06a870a083fe0ed22a10da5f629fef622cbe512d Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 11 Jun 2024 11:48:21 +0200 Subject: [PATCH 0380/1532] fix(draft_types_de_champ_private.condition): condition must be validated with upper_tdcs. considering that types_de_champ_private can have a condition using a types_de_champ_public, we have to include all types_de_champs_public plus only types_de_champs_private.upper_tdcs --- .../types_de_champ/condition_validator.rb | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/app/validators/types_de_champ/condition_validator.rb b/app/validators/types_de_champ/condition_validator.rb index 65e1e887a..86e23ff62 100644 --- a/app/validators/types_de_champ/condition_validator.rb +++ b/app/validators/types_de_champ/condition_validator.rb @@ -1,24 +1,34 @@ class TypesDeChamp::ConditionValidator < ActiveModel::EachValidator - def validate_each(procedure, attribute, types_de_champ) - return if types_de_champ.empty? + # condition are valid when + # tdc.condition.left is present in upper tdcs + # in case of types_de_champ_private, we should include types_de_champ_publics too + def validate_each(procedure, collection, tdcs) + return if tdcs.empty? - tdcs = if attribute == :draft_types_de_champ_private - procedure.draft_revision.types_de_champ_for - else - procedure.draft_revision.types_de_champ_for(scope: :public) - end - - tdcs.each_with_index do |tdc, i| + tdcs = tdcs_with_children(procedure, tdcs) + tdcs.each_with_index do |tdc, tdc_index| next unless tdc.condition? - errors = tdc.condition.errors(tdcs.take(i)) + upper_tdcs = [] + if collection == :draft_types_de_champ_private # in case of private tdc validation, we must include public tdcs + upper_tdcs += tdcs_with_children(procedure, procedure.draft_types_de_champ_public) + end + upper_tdcs += tdcs.take(tdc_index) # we take all upper_tdcs of current tdcs + + errors = tdc.condition.errors(upper_tdcs) next if errors.blank? procedure.errors.add( - attribute, - procedure.errors.generate_message(attribute, :invalid_condition, { value: tdc.libelle }), + collection, + procedure.errors.generate_message(collection, :invalid_condition, { value: tdc.libelle }), type_de_champ: tdc ) end end + + # find children in repetitions + def tdcs_with_children(procedure, tdcs) + tdcs.to_a + .flat_map { _1.repetition? ? procedure.draft_revision.children_of(_1) : _1 } + end end From c5fa25ee78f6f7f208389ec4f35ecb09a9f10891 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Tue, 11 Jun 2024 17:45:51 +0200 Subject: [PATCH 0381/1532] unless to if condition --- app/mailers/notification_mailer.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 9b0d94493..c319df7dd 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -90,9 +90,11 @@ class NotificationMailer < ApplicationMailer end def set_jdma - return unless params[:state] == Dossier.states.fetch(:en_construction) - - @jdma_html = @dossier.procedure.monavis_embed_html_source("email").presence + if params[:state] == Dossier.states.fetch(:en_construction) && @dossier.procedure.monavis_embed + @jdma_html = @dossier.procedure.monavis_embed_html_source("email") + else + return + end end def set_dossier From f504e7968d0088ce1d031b2ed9a8717271f6b770 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 12 Jun 2024 19:31:50 +0200 Subject: [PATCH 0382/1532] chore(redis): reduce connect timeout from 1s to 0.2s Prevent web workers from being stalled when Redis is down. --- config/environments/production.rb | 18 +++++++++++------- config/initializers/kredis.rb | 3 ++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/config/environments/production.rb b/config/environments/production.rb index 64ed28732..5008e6071 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -62,17 +62,21 @@ Rails.application.configure do # Use a different cache store in production. if ENV['REDIS_CACHE_URL'].present? - redis_options = { url: ENV['REDIS_CACHE_URL'] } - redis_options[:ssl] = (ENV['REDIS_CACHE_SSL'] == 'enabled') + redis_options = { + url: ENV['REDIS_CACHE_URL'], + connect_timeout: 0.2, + error_handler: -> (method:, returning:, exception:) { + Sentry.capture_exception exception, level: 'warning', + tags: { method: method, returning: returning } + } + } + + redis_options[:ssl] = ENV['REDIS_CACHE_SSL'] == 'enabled' + if ENV['REDIS_CACHE_SSL_VERIFY_NONE'] == 'enabled' redis_options[:ssl_params] = { verify_mode: OpenSSL::SSL::VERIFY_NONE } end - redis_options[:error_handler] = -> (method:, returning:, exception:) { - Sentry.capture_exception exception, level: 'warning', - tags: { method: method, returning: returning } - } - config.cache_store = :redis_cache_store, redis_options end diff --git a/config/initializers/kredis.rb b/config/initializers/kredis.rb index 878b8177a..90dd906b9 100644 --- a/config/initializers/kredis.rb +++ b/config/initializers/kredis.rb @@ -1,6 +1,7 @@ redis_volatile_options = { url: ENV['REDIS_CACHE_URL'], # will fallback to default redis url if empty, and won't fail if there is no redis server - ssl: ENV['REDIS_CACHE_SSL'] == 'enabled' + ssl: ENV['REDIS_CACHE_SSL'] == 'enabled', + connect_timeout: 0.2 } redis_volatile_options[:ssl_params] = { verify_mode: OpenSSL::SSL::VERIFY_NONE } if ENV['REDIS_CACHE_SSL_VERIFY_NONE'] == 'enabled' From 9d4113befb1aeaa1782fadfb1fa0aeba4ea8cf8a Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 11 Jun 2024 19:09:34 +0200 Subject: [PATCH 0383/1532] feat(helpscout): delete old conversations --- app/lib/helpscout/api.rb | 33 +++ ...helpscout_delete_old_conversations_task.rb | 57 +++++ .../helpscout_list_old_conversations.yml | 226 ++++++++++++++++++ ...cout_delete_old_conversations_task_spec.rb | 62 +++++ 4 files changed, 378 insertions(+) create mode 100644 app/tasks/maintenance/helpscout_delete_old_conversations_task.rb create mode 100644 spec/fixtures/cassettes/helpscout_list_old_conversations.yml create mode 100644 spec/tasks/maintenance/helpscout_delete_old_conversations_task_spec.rb diff --git a/app/lib/helpscout/api.rb b/app/lib/helpscout/api.rb index c0538c7a9..b16585240 100644 --- a/app/lib/helpscout/api.rb +++ b/app/lib/helpscout/api.rb @@ -7,6 +7,8 @@ class Helpscout::API PHONES = 'phones' OAUTH2_TOKEN = 'oauth2/token' + RATELIMIT_KEY = "helpscout-rate-limit-remaining" + def ready? required_secrets = [ Rails.application.secrets.helpscout[:mailbox_id], @@ -42,6 +44,30 @@ class Helpscout::API call_api(:post, CONVERSATIONS, body) end + def list_old_conversations(status, before, page: 1) + body = { + page:, + status:, # active, open, closed, pending, spam. "all" does not work + query: "( + modifiedAt:[* TO #{before.iso8601}] + )", + sortField: "modifiedAt", + sortOrder: "desc" + } + + response = call_api(:get, "#{CONVERSATIONS}?#{body.to_query}") + if !response.success? + raise StandardError, "Error while listing conversations: #{response.response_code} '#{response.body}'" + end + + body = parse_response_body(response) + [body[:_embedded][:conversations], body[:page]] + end + + def delete_conversation(conversation_id) + call_api(:delete, "#{CONVERSATIONS}/#{conversation_id}") + end + def add_phone_number(email, phone) query = CGI.escape("(email:#{email})") response = call_api(:get, "#{CUSTOMERS}?mailbox=#{user_support_mailbox_id}&query=#{query}") @@ -129,6 +155,13 @@ class Helpscout::API body: body.to_json, headers: headers }) + when :delete + Typhoeus.delete(url, { + body: body.to_json, + headers: headers + }) + end.tap do |response| + Rails.cache.write(RATELIMIT_KEY, response.headers["X-Ratelimit-Remaining-Minute"], expires_in: 1.minute) end end diff --git a/app/tasks/maintenance/helpscout_delete_old_conversations_task.rb b/app/tasks/maintenance/helpscout_delete_old_conversations_task.rb new file mode 100644 index 000000000..cbe388fb1 --- /dev/null +++ b/app/tasks/maintenance/helpscout_delete_old_conversations_task.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Maintenance + class HelpscoutDeleteOldConversationsTask < MaintenanceTasks::Task + # Delete Helpscout conversations not modified in the last 2 years, given a status. + # In order to delete all conversations, this task must be invoked 4 times + # for the 4 status: active, closed, spam, pending. + # Respects the Helpscount API rate limit (200 calls per minute). + + attribute :status, :string # active, closed, spam, or pending + validates :status, presence: true + + MODIFIED_BEFORE = 2.years.freeze + + throttle_on do + limit = Rails.cache.read(Helpscout::API::RATELIMIT_KEY) + limit.present? && limit == 0 + end + + def count + _conversations, pagination = api.list_old_conversations(status, modified_before) + + pagination[:totalElements] + end + + # Because conversations are deleted progressively, + # ignore cursor and always pick the first page + def enumerator_builder(cursor:) + Enumerator.new do |yielder| + loop do + conversations, pagination = api.list_old_conversations(status, modified_before) + conversations.each do |conversation| + yielder.yield(conversation[:id], nil) # don't care about cursor parameter + end + + # "number" is the current page (always 1 in our case) + # iterate until there are no remaining pages + break if pagination[:totalPages] == pagination[:number] + end + end + end + + def process(conversation_id) + @api.delete_conversation(conversation_id) + end + + private + + def api + @api ||= Helpscout::API.new + end + + def modified_before + MODIFIED_BEFORE.ago.utc.beginning_of_day + end + end +end diff --git a/spec/fixtures/cassettes/helpscout_list_old_conversations.yml b/spec/fixtures/cassettes/helpscout_list_old_conversations.yml new file mode 100644 index 000000000..e712fbb47 --- /dev/null +++ b/spec/fixtures/cassettes/helpscout_list_old_conversations.yml @@ -0,0 +1,226 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.helpscout.net/v2/oauth2/token + body: + encoding: UTF-8 + string: client_id=1234&client_secret=5678&grant_type=client_credentials + headers: + User-Agent: + - demarches-simplifiees.fr + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + Date: + - Tue, 11 Jun 2024 14:13:26 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '94' + Server: + - kong/0.14.1 + Cache-Control: + - no-store + Pragma: + - no-cache + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Location,Resource-Id + body: + encoding: UTF-8 + string: '{"token_type":"bearer","access_token":"redacted","expires_in":172800}' + recorded_at: Wed, 05 Jun 2024 00:00:00 GMT +- request: + method: get + uri: https://api.helpscout.net/v2/conversations?page=1&query=(%0A%20%20%20%20%20%20%20%20modifiedAt:%5B*%20TO%202022-06-05T00:00:00Z%5D%0A%20%20%20%20%20%20)&sortField=modifiedAt&sortOrder=desc&status=closed + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - demarches-simplifiees.fr + Authorization: + - Bearer redacted + Content-Type: + - application/json; charset=UTF-8 + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + Date: + - Tue, 11 Jun 2024 14:13:27 GMT + Content-Type: + - application/hal+json + X-Ratelimit-Limit-Minute: + - '200' + X-Ratelimit-Remaining-Minute: + - '199' + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Pragma: + - no-cache + Expires: + - '0' + X-Frame-Options: + - DENY + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Location,Resource-Id + Correlation-Id: + - a9ca7664-2711-4c36-a092-73203365b474#13211836 + X-Kong-Upstream-Latency: + - '640' + X-Kong-Proxy-Latency: + - '3' + Via: + - kong/0.14.1 + body: + encoding: UTF-8 + string: '{"_embedded":{"conversations":[{"id":1910642153,"number":1978770,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Re: + Demande de création de compte","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T23:34:03Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T23:34:04Z","userUpdatedAt":"2022-06-04T23:34:04Z","customerWaitingSince":{"time":"2022-06-04T23:34:04Z","friendly":"Jun + 5, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910642153"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533578452"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533578452"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910642153/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910642153/1978770"}},"assignee":{}},{"id":1910621183,"number":1978769,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Re: + Demande de création de compte","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T22:52:48Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T22:52:48Z","userUpdatedAt":"2022-06-04T22:52:48Z","customerWaitingSince":{"time":"2022-06-04T22:52:48Z","friendly":"Jun + 5, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910621183"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533578452"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533578452"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910621183/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910621183/1978769"}},"assignee":{}},{"id":1910573873,"number":1978767,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"Re: + Une demande de transfert de dossier vous est adressée","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-04T21:20:15Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T21:20:17Z","userUpdatedAt":"2022-06-04T21:20:15Z","customerWaitingSince":{"time":"2022-06-04T21:20:15Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910573873"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533568101"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533568101"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910573873/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910573873/1978767"}},"assignee":{}},{"id":1910547792,"number":1978766,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"Re: + Votre dossier nº 6988215 a été classé sans suite (Demande de premier titre + de séjour vie privée et familiale)","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-04T20:34:17Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T20:34:17Z","userUpdatedAt":"2022-06-04T20:34:17Z","customerWaitingSince":{"time":"2022-06-04T20:34:17Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910547792"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/485472353"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/485472353"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910547792/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910547792/1978766"}},"assignee":{}},{"id":1910474797,"number":1978763,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"Re: + Nouveau message pour votre dossier nº 5172954 « Demande d''un premier titre + de séjour -VIE PRIVEE ET FAMILIALE ------------- -CITOYEN UE ET FAMILLE -REFUGIE, + PROTECTION SUBSIDIAIRE, APATRIDE-- -VISITEUR-- Préfecture de Nanterre »","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-04T18:30:40Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T18:30:40Z","userUpdatedAt":"2022-06-04T18:30:40Z","customerWaitingSince":{"time":"2022-06-04T18:30:40Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910474797"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533546294"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533546294"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910474797/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910474797/1978763"}},"assignee":{}},{"id":1910462923,"number":1978761,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Faire + une nouvelle demande de titre séjour","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T18:11:21Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T18:11:21Z","userUpdatedAt":"2022-06-04T18:11:21Z","customerWaitingSince":{"time":"2022-06-04T18:11:21Z","friendly":"Jun + 4, ''22"},"source":{"type":"api","via":"customer"},"tags":[{"id":8922426,"color":"#A5B2BD","tag":"contact + form"},{"id":6885035,"color":"#A5B2BD","tag":"other"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910462923"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533543448"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533543448"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910462923/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910462923/1978761"}},"assignee":{}},{"id":1910327544,"number":1978755,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"reactivation + de numero NEPH","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T14:49:14Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T14:49:14Z","userUpdatedAt":"2022-06-04T14:49:14Z","customerWaitingSince":{"time":"2022-06-04T14:49:14Z","friendly":"Jun + 4, ''22"},"source":{"type":"api","via":"customer"},"tags":[{"id":8922426,"color":"#A5B2BD","tag":"contact + form"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910327544"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533513486"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533513486"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910327544/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910327544/1978755"}},"assignee":{}},{"id":1910270744,"number":1978750,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"[Free + Report] The State of Productivity 2022","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-04T13:18:23Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T13:18:23Z","userUpdatedAt":"2022-06-04T13:18:23Z","customerWaitingSince":{"time":"2022-06-04T13:18:23Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910270744"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/457241695"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/457241695"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910270744/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910270744/1978750"}},"assignee":{}},{"id":1910265845,"number":1978749,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Titre + de séjour , numéro étranger : 9914064788","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T13:09:52Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T13:09:52Z","userUpdatedAt":"2022-06-04T13:09:52Z","customerWaitingSince":{"time":"2022-06-04T13:09:52Z","friendly":"Jun + 4, ''22"},"source":{"type":"api","via":"customer"},"tags":[{"id":8922426,"color":"#A5B2BD","tag":"contact + form"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910265845"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533500019"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533500019"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910265845/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910265845/1978749"}},"assignee":{}},{"id":1910174669,"number":1978744,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"Re: + Votre dossier nº 6779552 a été accepté (CPAM 75 - CONVENTIONNEMENT DES TAXIS + PARISIENS)","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-04T10:17:58Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T10:17:58Z","userUpdatedAt":"2022-06-04T10:17:58Z","customerWaitingSince":{"time":"2022-06-04T10:17:58Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910174669"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533480456"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533480456"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910174669/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910174669/1978744"}},"assignee":{}},{"id":1910154851,"number":1978743,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"Re :Nouveau + message pour votre dossier nº 5528204 « ARSIF - Procédure d’autorisation d’exercice + des médecins à diplômes hors UE (PADHUE) »","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-04T09:35:53Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T09:35:53Z","userUpdatedAt":"2022-06-04T09:35:53Z","customerWaitingSince":{"time":"2022-06-04T09:35:53Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910154851"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533476412"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533476412"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910154851/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910154851/1978743"}},"assignee":{}},{"id":1910119695,"number":1978739,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Demande + de renseignements","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T08:18:32Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T08:18:33Z","userUpdatedAt":"2022-06-04T08:18:32Z","customerWaitingSince":{"time":"2022-06-04T08:18:33Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910119695"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/477844062"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/477844062"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910119695/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910119695/1978739"}},"assignee":{}},{"id":1910118535,"number":1978738,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"Re: + Demande de rendez-vous en vue du dépôt d''une demande de renouvellement de + la carte de séjour ou du visa long séjour valant titre de séjour","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-04T08:15:43Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T08:15:43Z","userUpdatedAt":"2022-06-04T08:15:43Z","customerWaitingSince":{"time":"2022-06-04T08:15:43Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910118535"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533468939"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533468939"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910118535/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910118535/1978738"}},"assignee":{}},{"id":1910073903,"number":1978735,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Renouvellement","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T06:45:33Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T06:45:33Z","userUpdatedAt":"2022-06-04T06:45:33Z","customerWaitingSince":{"time":"2022-06-04T06:45:33Z","friendly":"Jun + 4, ''22"},"source":{"type":"api","via":"customer"},"tags":[{"id":8922426,"color":"#A5B2BD","tag":"contact + form"},{"id":6885035,"color":"#A5B2BD","tag":"other"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910073903"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533460330"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533460330"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910073903/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910073903/1978735"}},"assignee":{}},{"id":1910073830,"number":1978734,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Renouvellement","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T06:45:25Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T06:45:25Z","userUpdatedAt":"2022-06-04T06:45:25Z","customerWaitingSince":{"time":"2022-06-04T06:45:25Z","friendly":"Jun + 4, ''22"},"source":{"type":"api","via":"customer"},"tags":[{"id":8922426,"color":"#A5B2BD","tag":"contact + form"},{"id":6885035,"color":"#A5B2BD","tag":"other"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910073830"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533460330"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533460330"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910073830/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910073830/1978734"}},"assignee":{}},{"id":1910049439,"number":1978733,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Renseignements","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T05:55:41Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T05:55:41Z","userUpdatedAt":"2022-06-04T05:55:41Z","customerWaitingSince":{"time":"2022-06-04T05:55:41Z","friendly":"Jun + 4, ''22"},"source":{"type":"api","via":"customer"},"tags":[{"id":8922426,"color":"#A5B2BD","tag":"contact + form"},{"id":6885035,"color":"#A5B2BD","tag":"other"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910049439"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533455039"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533455039"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910049439/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910049439/1978733"}},"assignee":{}},{"id":1909873707,"number":1978732,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"Re: + Votre dossier nº 8851246 a été accepté (DEMANDE D''AUTORISATION D''INSTRUCTION + DANS LA FAMILLE 2022/2023)","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-04T00:33:59Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T00:33:59Z","userUpdatedAt":"2022-06-04T00:33:59Z","customerWaitingSince":{"time":"2022-06-04T00:33:59Z","friendly":"Jun + 4, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1909873707"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533409205"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533409205"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1909873707/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1909873707/1978732"}},"assignee":{}},{"id":1909608573,"number":1978730,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Oui + c’est bon","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-03T19:34:20Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-03T19:34:21Z","userUpdatedAt":"2022-06-03T19:34:21Z","customerWaitingSince":{"time":"2022-06-03T19:34:21Z","friendly":"Jun + 3, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1909608573"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/472328447"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/472328447"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1909608573/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1909608573/1978730"}},"assignee":{}},{"id":1909571857,"number":1978729,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Montant + du timbre","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-03T19:01:30Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-03T19:01:30Z","userUpdatedAt":"2022-06-03T19:01:30Z","customerWaitingSince":{"time":"2022-06-03T19:01:30Z","friendly":"Jun + 3, ''22"},"source":{"type":"api","via":"customer"},"tags":[{"id":8922426,"color":"#A5B2BD","tag":"contact + form"},{"id":6885035,"color":"#A5B2BD","tag":"other"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1909571857"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533345249"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533345249"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1909571857/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1909571857/1978729"}},"assignee":{}},{"id":1909508850,"number":1978726,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"RE: + Un dossier en construction va bientôt être supprimé","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-03T18:07:27Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-03T18:07:27Z","userUpdatedAt":"2022-06-03T18:07:27Z","customerWaitingSince":{"time":"2022-06-03T18:07:27Z","friendly":"Jun + 3, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1909508850"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/420667968"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/420667968"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1909508850/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1909508850/1978726"}},"assignee":{}},{"id":1909460281,"number":1978724,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-03T17:29:05Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-03T17:29:06Z","userUpdatedAt":"2022-06-03T17:29:05Z","customerWaitingSince":{"time":"2022-06-03T17:29:05Z","friendly":"Jun + 3, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1909460281"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533255322"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533255322"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1909460281/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1909460281/1978724"}},"assignee":{}},{"id":1909359672,"number":1978719,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Prise + de rdv","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-03T16:02:00Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-03T16:02:00Z","userUpdatedAt":"2022-06-03T16:02:00Z","customerWaitingSince":{"time":"2022-06-03T16:02:00Z","friendly":"Jun + 3, ''22"},"source":{"type":"api","via":"customer"},"tags":[{"id":8922426,"color":"#A5B2BD","tag":"contact + form"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1909359672"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/435717422"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/435717422"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1909359672/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1909359672/1978719"}},"assignee":{}},{"id":1909341225,"number":1978718,"threads":1,"type":"email","folderId":3195939,"status":"closed","state":"published","subject":"Re: + Préfecture de la Marne, renouvellement récépissé/APS","preview":"","mailboxId":193013,"createdBy":{},"createdAt":"2022-06-03T15:47:20Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-03T15:47:20Z","userUpdatedAt":"2022-06-03T15:47:20Z","customerWaitingSince":{"time":"2022-06-03T15:47:20Z","friendly":"Jun + 3, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1909341225"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/193013"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/505891768"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/505891768"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1909341225/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1909341225/1978718"}},"assignee":{}},{"id":1907817899,"number":1978559,"threads":4,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Re: + DUREE DE CONSERVATION DES DONNEES","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-02T14:02:18Z","closedBy":577293,"closedByUser":{},"closedAt":"2022-06-03T14:53:13Z","userUpdatedAt":"2022-06-03T14:54:29Z","customerWaitingSince":{"time":"2022-06-03T14:53:13Z","friendly":"Jun + 3, ''22"},"source":{"type":"email","via":"customer"},"tags":[{"id":9822999,"color":"#A5B2BD","tag":"conservation + données"},{"id":3754718,"color":"#A5B2BD","tag":"webinaire"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1907817899"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/190044272"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/190044272"},"closedBy":{"href":"https://api.helpscout.net/v2/users/577293"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1907817899/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1907817899/1978559"}},"assignee":{}},{"id":1904794963,"number":1978181,"threads":3,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Re: + Webinaire tour de France DS - Etape occitanie le 9 juin 2022","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-05-31T14:20:10Z","closedBy":577293,"closedByUser":{},"closedAt":"2022-06-03T14:53:34Z","userUpdatedAt":"2022-06-03T14:53:45Z","customerWaitingSince":{"time":"2022-06-03T14:53:34Z","friendly":"Jun + 3, ''22"},"source":{"type":"web","via":"user"},"tags":[{"id":3754718,"color":"#A5B2BD","tag":"webinaire"}],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1904794963"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/172457074"},"createdByUser":{"href":"https://api.helpscout.net/v2/users/412221"},"closedBy":{"href":"https://api.helpscout.net/v2/users/577293"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1904794963/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1904794963/1978181"}},"assignee":{}}]},"_links":{"next":{"href":"https://api.helpscout.net/v2/conversations?query=(\n modifiedAt:[* + TO 2022-06-05T00:00:00Z]\n )\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed\u0026page=2"},"self":{"href":"https://api.helpscout.net/v2/conversations?page=1\u0026query=(\n modifiedAt:[* + TO 2022-06-05T00:00:00Z]\n )\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed"},"first":{"href":"https://api.helpscout.net/v2/conversations?query=(\n modifiedAt:[* + TO 2022-06-05T00:00:00Z]\n )\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed\u0026page=1"},"last":{"href":"https://api.helpscout.net/v2/conversations?query=(\n modifiedAt:[* + TO 2022-06-05T00:00:00Z]\n )\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed\u0026page=75678"},"page":{"href":"https://api.helpscout.net/v2/conversations?page=1\u0026query=(%0A%20%20%20%20%20%20%20%20modifiedAt:%5B*%20TO%202022-06-05T00:00:00Z%5D%0A%20%20%20%20%20%20)\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed"}},"page":{"size":25,"totalElements":1891943,"totalPages":2,"number":1}}' + recorded_at: Wed, 05 Jun 2024 00:00:00 GMT + +- request: + method: get + uri: https://api.helpscout.net/v2/conversations?page=1&query=(%0A%20%20%20%20%20%20%20%20modifiedAt:%5B*%20TO%202022-06-05T00:00:00Z%5D%0A%20%20%20%20%20%20)&sortField=modifiedAt&sortOrder=desc&status=closed + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - demarches-simplifiees.fr + Authorization: + - Bearer redacted + Content-Type: + - application/json; charset=UTF-8 + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + Date: + - Tue, 11 Jun 2024 14:13:28 GMT + Content-Type: + - application/hal+json + X-Ratelimit-Limit-Minute: + - '200' + X-Ratelimit-Remaining-Minute: + - '198' + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Pragma: + - no-cache + Expires: + - '0' + X-Frame-Options: + - DENY + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - Location,Resource-Id + Correlation-Id: + - a9ca7664-2711-4c36-a092-73203365b474#13211836 + X-Kong-Upstream-Latency: + - '640' + X-Kong-Proxy-Latency: + - '3' + Via: + - kong/0.14.1 + body: + encoding: UTF-8 + string: + '{"_embedded":{"conversations":[{"id":1000000,"number":1978770,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Re: + Demande de création de compte","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T23:34:03Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T23:34:04Z","userUpdatedAt":"2022-06-04T23:34:04Z","customerWaitingSince":{"time":"2022-06-04T23:34:04Z","friendly":"Jun + 5, ''22"},"source":{"type":"email","via":"customer"},"tags":[],"cc":[],"bcc":[],"primaryCustomer":{},"customFields":[],"_embedded":{"threads":[]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations/1910642153"},"mailbox":{"href":"https://api.helpscout.net/v2/mailboxes/125926"},"primaryCustomer":{"href":"https://api.helpscout.net/v2/customers/533578452"},"createdByCustomer":{"href":"https://api.helpscout.net/v2/customers/533578452"},"closedBy":{"href":"https://api.helpscout.net/v2/users/1"},"threads":{"href":"https://api.helpscout.net/v2/conversations/1910642153/threads"},"web":{"href":"https://secure.helpscout.net/conversation/1910642153/1978770"}},"assignee":{}},{"id":1910621183,"number":1978769,"threads":2,"type":"email","folderId":1653804,"status":"closed","state":"published","subject":"Re: + Demande de création de compte","preview":"","mailboxId":125926,"createdBy":{},"createdAt":"2022-06-04T22:52:48Z","closedBy":1,"closedByUser":{},"closedAt":"2022-06-04T22:52:48Z","userUpdatedAt":"2022-06-04T22:52:48Z","customerWaitingSince":{"time":"2022-06-04T22:52:48Z","friendly":"Jun + 5,"}}]},"_links":{"self":{"href":"https://api.helpscout.net/v2/conversations?page=1\u0026query=(\n modifiedAt:[* + TO 2022-06-05T00:00:00Z]\n )\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed"},"first":{"href":"https://api.helpscout.net/v2/conversations?query=(\n modifiedAt:[* + TO 2022-06-05T00:00:00Z]\n )\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed\u0026page=1"},"last":{"href":"https://api.helpscout.net/v2/conversations?query=(\n modifiedAt:[* + TO 2022-06-05T00:00:00Z]\n )\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed\u0026page=1"},"page":{"href":"https://api.helpscout.net/v2/conversations?page=2\u0026query=(%0A%20%20%20%20%20%20%20%20modifiedAt:%5B*%20TO%202022-06-05T00:00:00Z%5D%0A%20%20%20%20%20%20)\u0026sortField=modifiedAt\u0026sortOrder=desc\u0026status=closed"}},"page":{"size":25,"totalElements":1,"totalPages":1,"number":1}}' + recorded_at: Wed, 05 Jun 2024 00:00:00 GMT +recorded_with: VCR 6.2.0 diff --git a/spec/tasks/maintenance/helpscout_delete_old_conversations_task_spec.rb b/spec/tasks/maintenance/helpscout_delete_old_conversations_task_spec.rb new file mode 100644 index 000000000..494be20bf --- /dev/null +++ b/spec/tasks/maintenance/helpscout_delete_old_conversations_task_spec.rb @@ -0,0 +1,62 @@ +describe Maintenance::HelpscoutDeleteOldConversationsTask do + before do + mock_helpscout_secrets + travel_to DateTime.new(2024, 6, 5) + end + + subject do + described_class.new.tap { _1.status = "closed" } + end + + describe '#enumerator_builder' do + it "enumerates conversation ids" do + VCR.use_cassette("helpscout_list_old_conversations") do |c| + ids = subject.enumerator_builder(cursor: 0).to_a + # Warning: calling a enumerable method always reinvoke the enumerable ! + # So immediately convert in array and run expectations on it + + # anonymize when recorded cassettes + c.new_recorded_interactions.each do |interaction| + interaction.request.body = anonymize_request(interaction) + + body = anonymize_response(interaction) + interaction.response.body = body.to_json + end + + expect(ids.count).to eq(27) # 25 first page + 2 next page + expect(ids[0][0]).to eq(1910642153) + end + end + end + + def anonymize_response(interaction) + body = JSON.parse(interaction.response.body) + + Array(body.dig("_embedded", "conversations")).each do |conversation| + conversation["createdBy"] = {} + conversation["closedByUser"] = {} + conversation["primaryCustomer"] = {} + conversation["assignee"] = {} + conversation["preview"] = "" # this also removes binary string + conversation["cc"] = [] + end + + body["access_token"] = "redacted" if body.key?("access_token") + + body + end + + def anonymize_request(interaction) + body = interaction.request.body + + return body unless body.include?("client_secret") + + URI.decode_www_form(body).to_h.merge("client_id" => "1234", "client_secret" => "5678").to_query + end + + def mock_helpscout_secrets + Rails.application.secrets.helpscout[:mailbox_id] = '9999' + Rails.application.secrets.helpscout[:client_id] = '1234' + Rails.application.secrets.helpscout[:client_secret] = '5678' + end +end From 939bc37eabb9892e36f370e7b520b5caf09a8935 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 12 Jun 2024 16:08:05 +0200 Subject: [PATCH 0384/1532] test(vcr): automatically redact bearer tokens --- spec/support/vcr.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/spec/support/vcr.rb b/spec/support/vcr.rb index cd86fba50..8ad4ebd34 100644 --- a/spec/support/vcr.rb +++ b/spec/support/vcr.rb @@ -4,4 +4,13 @@ VCR.configure do |c| c.cassette_library_dir = 'spec/fixtures/cassettes' c.configure_rspec_metadata! c.ignore_hosts 'test.host', 'chromedriver.storage.googleapis.com' + + c.filter_sensitive_data('redacted') do |interaction| + auth = interaction.request.headers['Authorization']&.first + next if auth.nil? + + if (match = auth.match(/^Bearer\s+([^,\s]+)/)) + match.captures.first + end + end end From 44cb588c422cfe5e3e28772e029d99900cef7cde Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Thu, 13 Jun 2024 12:08:37 +0200 Subject: [PATCH 0385/1532] mise en page & traduction page session --- app/views/users/sessions/link_sent.html.haml | 37 ++++++++++---------- config/locales/en.yml | 3 ++ config/locales/fr.yml | 3 ++ 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/app/views/users/sessions/link_sent.html.haml b/app/views/users/sessions/link_sent.html.haml index 66ef59192..26b7e69e5 100644 --- a/app/views/users/sessions/link_sent.html.haml +++ b/app/views/users/sessions/link_sent.html.haml @@ -3,27 +3,26 @@ - content_for :footer do = render partial: 'root/footer' -.fr-container.fr-my-5w - .fr-grid-row - .fr-col-12.fr-col-offset-md-1.fr-col-md-7 - %h1.fr-mt-6w Encore une petite étape ! +.fr-container + .fr-col-12.fr-col-md-6.fr-col-offset-md-3 + %h1.fr-mt-6w.fr-h2.center + = t('views.confirmation.new.title') - %section - %p.fr-text--lead - Nous venons de vous envoyer un courriel sur votre boite email #{@email}. - Veuillez l’ouvrir et cliquer sur le lien de connexion sécurisée à #{Current.application_name}. + %p.center{ aria: { hidden: true } }= image_tag("user/confirmation-email.svg", alt: t('views.confirmation.new.image_alt')) - %p.fr-text--lead - Ce lien est valide une semaine et peut être réutilisé plusieurs fois. + = render Dsfr::AlertComponent.new(title: '', state: :info, heading_level: 'h2', extra_class_names: 'fr-mt-6w fr-mb-3w') do |c| + - c.with_body do + %p= t('views.users.sessions.link_sent.email_cta_html', email: @email) + %p= t('views.confirmation.new.email_guidelines_html') - %p.fr-text--sm.fr-text-mention--grey - Ce courriel peut mettre jusqu’à 15 minutes pour arriver. Si vous n’avez pas reçu de courriel (n’hésitez pas à vérifier dans les indésirables), cliquez sur le bouton ci-dessous. + %p.fr-text--sm.fr-text-mention--grey.fr-mb-1w + = t('views.confirmation.new.email_missing') - = button_to instructeurs_reset_link_sent_path, class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-mail-line', method: 'POST' do - Renvoyer le courriel + = button_to instructeurs_reset_link_sent_path, class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-mail-line', method: 'POST' do + = t('views.confirmation.new.resent') - %section - %p.fr-mt-3w - Si vous voyez cette page trop souvent, #{link_to "consultez notre aide", t("links.common.faq.confirmer_compte_chaque_connexion_url")} - %p.fr-mt-3w - = t('views.users.shared.contact_us_if_any_trouble_html', href: contact_admin_url) + %p.fr-text--sm.fr-text-mention--grey.fr-mt-3w + = t('views.users.sessions.link_sent.consult_help_page_html', href: t("links.common.faq.confirmer_compte_chaque_connexion_url")) + + %p.fr-text--sm.fr-text-mention--grey.fr-mt-3w.fr-mb-6w + = t('views.users.shared.contact_us_if_any_trouble_html', href: contact_admin_url) diff --git a/config/locales/en.yml b/config/locales/en.yml index a05c394b2..c4a9ea309 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -554,6 +554,9 @@ en: connect_with_agent_connect: Visit our dedicated page subtitle: "Sign in with my account" for_tiers_alert: If you are completing a forme for someone else, you must use your own credentials. + link_sent: + consult_help_page_html: If you're seeing this page too often, please consult our help page. + email_cta_html: "We have to validate your email address %{email}." passwords: edit: subtitle: Change password diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 42896bd24..24815f182 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -557,6 +557,9 @@ fr: connect_with_agent_connect: Accédez à notre page dédiée subtitle: "Se connecter avec son compte" for_tiers_alert: Si vous remplissez un dossier pour un tiers, vous devez utiliser vos propres identifiants. + link_sent: + consult_help_page_html: Si vous voyez cette page trop souvent, consultez notre aide. + email_cta_html: "Nous avons besoin de vérifier votre adresse électronique %{email}." passwords: edit: subtitle: Changement de mot de passe From d124127f10a064c9774eb70363ccfe011d6683a9 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Thu, 13 Jun 2024 12:35:02 +0200 Subject: [PATCH 0386/1532] ajustements de front avec la page confirmation --- app/views/users/confirmations/new.html.haml | 4 +++- app/views/users/sessions/link_sent.html.haml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/views/users/confirmations/new.html.haml b/app/views/users/confirmations/new.html.haml index 587531801..786c1aa89 100644 --- a/app/views/users/confirmations/new.html.haml +++ b/app/views/users/confirmations/new.html.haml @@ -17,7 +17,9 @@ %p= t('views.confirmation.new.email_cta_html', email: resource.email) %p= t('views.confirmation.new.email_guidelines_html') + %p.fr-text--sm.fr-text-mention--grey.fr-mb-1w + = t('views.confirmation.new.email_missing') + = form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { class: 'fr-mb-6w'}) do |f| - %legend.fr-hint-text.fr-mb-3w= t('views.confirmation.new.email_missing') = f.hidden_field :email = f.submit t('views.confirmation.new.resent'), class: 'fr-btn fr-btn--secondary' diff --git a/app/views/users/sessions/link_sent.html.haml b/app/views/users/sessions/link_sent.html.haml index 26b7e69e5..270ff697a 100644 --- a/app/views/users/sessions/link_sent.html.haml +++ b/app/views/users/sessions/link_sent.html.haml @@ -18,7 +18,7 @@ %p.fr-text--sm.fr-text-mention--grey.fr-mb-1w = t('views.confirmation.new.email_missing') - = button_to instructeurs_reset_link_sent_path, class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-mail-line', method: 'POST' do + = button_to instructeurs_reset_link_sent_path, class: 'fr-btn fr-btn--secondary', method: 'POST' do = t('views.confirmation.new.resent') %p.fr-text--sm.fr-text-mention--grey.fr-mt-3w From ae5937b22afaf6e2f85a8610141198098c6eeb6f Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 12 Jun 2024 16:44:46 +0200 Subject: [PATCH 0387/1532] chore(sidekiq): concise transition and avoid typos by not (re)opening classes --- config/initializers/transition_to_sidekiq.rb | 121 +++++-------------- 1 file changed, 27 insertions(+), 94 deletions(-) diff --git a/config/initializers/transition_to_sidekiq.rb b/config/initializers/transition_to_sidekiq.rb index 3b5360b6a..bfc07449b 100644 --- a/config/initializers/transition_to_sidekiq.rb +++ b/config/initializers/transition_to_sidekiq.rb @@ -1,99 +1,32 @@ if Rails.env.production? && SIDEKIQ_ENABLED ActiveSupport.on_load(:after_initialize) do - class ActiveStorage::PurgeJob < ActiveStorage::BaseJob - self.queue_adapter = :sidekiq - end - - class ActiveStorage::AnalyzeJob < ActiveStorage::BaseJob - self.queue_adapter = :sidekiq - end - - class VirusScannerJob - self.queue_adapter = :sidekiq - end - - class DossierRebaseJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class ProcedureExternalURLCheckJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class MaintenanceTasks::TaskJob - self.queue_adapter = :sidekiq - end - - class PriorizedMailDeliveryJob < ActionMailer::MailDeliveryJob - self.queue_adapter = :sidekiq - end - - class ProcedureSVASVRProcessDossierJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class WebHookJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class DestroyRecordLaterJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class ChampFetchExternalDataJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class DossierIndexSearchTermsJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class Migrations::BackfillStableIdJob - self.queue_adapter = :sidekiq - end - - class Cron::CronJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class APIEntreprise::Job < ApplicationJob - self.queue_adapter = :sidekiq - end - - class DossierOperationLogMoveToColdStorageBatchJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class BatchOperationEnqueueAllJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class BatchOperationProcessOneJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class TitreIdentiteWatermarkJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class AdminUpdateDefaultZonesJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class ProcessStalledDeclarativeDossierJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class ResetExpiringDossiersJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class SendClosingNotificationJob < ApplicationJob - self.queue_adapter = :sidekiq - end - - class ImageProcessorJob < ApplicationJob - self.queue_adapter = :sidekiq + [ + ActiveStorage::AnalyzeJob, + ActiveStorage::PurgeJob, + AdminUpdateDefaultZonesJob, + APIEntreprise::Job, + AdminUpdateDefaultZonesJob, + BatchOperationEnqueueAllJob, + BatchOperationProcessOneJob, + ChampFetchExternalDataJob, + Cron::CronJob, + DestroyRecordLaterJob, + DossierIndexSearchTermsJob, + DossierOperationLogMoveToColdStorageBatchJob, + DossierRebaseJob, + ImageProcessorJob, + MaintenanceTasks::TaskJob, + Migrations::BackfillStableIdJob, + PriorizedMailDeliveryJob, + ProcedureExternalURLCheckJob, + ProcedureSVASVRProcessDossierJob, + ProcessStalledDeclarativeDossierJob, + ResetExpiringDossiersJob, + SendClosingNotificationJob, + VirusScannerJob, + WebHookJob + ].each do |job_class| + job_class.queue_adapter = :sidekiq end end end From a1469b04fe10d1537449c6785fb530ff69baa47b Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 13 Jun 2024 13:27:32 +0200 Subject: [PATCH 0388/1532] refactor(support): create HS conversation in async and run virus scanner on attachments --- app/controllers/support_controller.rb | 41 ++++++------ app/jobs/helpscout_create_conversation_job.rb | 21 ++++++ app/lib/helpscout/api.rb | 14 ++-- app/lib/helpscout/form_adapter.rb | 4 +- config/initializers/transition_to_sidekiq.rb | 1 + spec/controllers/support_controller_spec.rb | 30 ++++++--- .../helpscout_create_conversation_job_spec.rb | 64 +++++++++++++++++++ spec/lib/helpscout/form_adapter_spec.rb | 2 +- 8 files changed, 138 insertions(+), 39 deletions(-) create mode 100644 app/jobs/helpscout_create_conversation_job.rb create mode 100644 spec/jobs/helpscout_create_conversation_job_spec.rb diff --git a/app/controllers/support_controller.rb b/app/controllers/support_controller.rb index 3951e2658..e2e536499 100644 --- a/app/controllers/support_controller.rb +++ b/app/controllers/support_controller.rb @@ -14,24 +14,16 @@ class SupportController < ApplicationController flash.notice = "Votre message a été envoyé sur la messagerie de votre dossier." redirect_to messagerie_dossier_path(dossier) - elsif create_conversation - flash.notice = "Votre message a été envoyé." + return + end - if params[:admin] - redirect_to root_path(formulaire_contact_admin_submitted: true) - else - redirect_to root_path(formulaire_contact_general_submitted: true) - end + create_conversation_later + flash.notice = "Votre message a été envoyé." + + if params[:admin] + redirect_to root_path(formulaire_contact_admin_submitted: true) else - flash.now.alert = "Une erreur est survenue. Vous pouvez nous contacter à #{helpers.mail_to(Current.contact_email)}." - - if params[:admin] - setup_context_admin - render :admin - else - setup_context - render :index - end + redirect_to root_path(formulaire_contact_general_submitted: true) end end @@ -48,17 +40,26 @@ class SupportController < ApplicationController @options = Helpscout::FormAdapter.admin_options end - def create_conversation - Helpscout::FormAdapter.new( + def create_conversation_later + if params[:piece_jointe] + blob = ActiveStorage::Blob.create_and_upload!( + io: params[:piece_jointe].tempfile, + filename: params[:piece_jointe].original_filename, + content_type: params[:piece_jointe].content_type, + identify: false + ).tap(&:scan_for_virus_later) + end + + HelpscoutCreateConversationJob.perform_later( + blob_id: blob&.id, subject: params[:subject], email: email, phone: params[:phone], text: params[:text], - file: params[:piece_jointe], dossier_id: dossier&.id, browser: browser_name, tags: tags - ).send_form + ) end def create_commentaire diff --git a/app/jobs/helpscout_create_conversation_job.rb b/app/jobs/helpscout_create_conversation_job.rb new file mode 100644 index 000000000..a8175d029 --- /dev/null +++ b/app/jobs/helpscout_create_conversation_job.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class HelpscoutCreateConversationJob < ApplicationJob + queue_as :default + + class FileNotScannedYetError < StandardError + end + + retry_on FileNotScannedYetError, wait: :exponentially_longer, attempts: 10 + + def perform(blob_id: nil, **args) + if blob_id.present? + blob = ActiveStorage::Blob.find(blob_id) + raise FileNotScannedYetError if blob.virus_scanner.pending? + + blob = nil unless blob.virus_scanner.safe? + end + + Helpscout::FormAdapter.new(**args, blob:).send_form + end +end diff --git a/app/lib/helpscout/api.rb b/app/lib/helpscout/api.rb index c0538c7a9..b2f72d85c 100644 --- a/app/lib/helpscout/api.rb +++ b/app/lib/helpscout/api.rb @@ -22,7 +22,7 @@ class Helpscout::API }) end - def create_conversation(email, subject, text, file) + def create_conversation(email, subject, text, blob) body = { subject: subject, customer: customer(email), @@ -34,7 +34,7 @@ class Helpscout::API type: 'customer', customer: customer(email), text: text, - attachments: attachments(file) + attachments: attachments(blob) } ] }.compact @@ -76,13 +76,13 @@ class Helpscout::API private - def attachments(file) - if file.present? + def attachments(blob) + if blob.present? [ { - fileName: file.original_filename, - mimeType: file.content_type, - data: Base64.strict_encode64(file.read) + fileName: blob.filename, + mimeType: blob.content_type, + data: Base64.strict_encode64(blob.download) } ] else diff --git a/app/lib/helpscout/form_adapter.rb b/app/lib/helpscout/form_adapter.rb index 03c168f08..4127e9dc2 100644 --- a/app/lib/helpscout/form_adapter.rb +++ b/app/lib/helpscout/form_adapter.rb @@ -66,7 +66,7 @@ class Helpscout::FormAdapter params[:email], params[:subject], params[:text], - params[:file] + params[:blob] ) if response.success? @@ -74,6 +74,8 @@ class Helpscout::FormAdapter @api.add_phone_number(params[:email], params[:phone]) end response.headers['Resource-ID'] + else + raise StandardError, "Error while creating conversation: #{response.response_code} '#{response.body}'" end end end diff --git a/config/initializers/transition_to_sidekiq.rb b/config/initializers/transition_to_sidekiq.rb index bfc07449b..28e9075e7 100644 --- a/config/initializers/transition_to_sidekiq.rb +++ b/config/initializers/transition_to_sidekiq.rb @@ -14,6 +14,7 @@ if Rails.env.production? && SIDEKIQ_ENABLED DossierIndexSearchTermsJob, DossierOperationLogMoveToColdStorageBatchJob, DossierRebaseJob, + HelpscoutCreateConversationJob, ImageProcessorJob, MaintenanceTasks::TaskJob, Migrations::BackfillStableIdJob, diff --git a/spec/controllers/support_controller_spec.rb b/spec/controllers/support_controller_spec.rb index d7275991d..281c7e4a3 100644 --- a/spec/controllers/support_controller_spec.rb +++ b/spec/controllers/support_controller_spec.rb @@ -58,9 +58,9 @@ describe SupportController, type: :controller do let(:params) { { subject: 'bonjour', text: 'un message' } } it 'creates a conversation on HelpScout' do - expect_any_instance_of(Helpscout::FormAdapter).to receive(:send_form).and_return(true) - - expect { subject }.to change(Commentaire, :count).by(0) + expect { subject }.to \ + change(Commentaire, :count).by(0).and \ + have_enqueued_job(HelpscoutCreateConversationJob).with(hash_including(params)) expect(flash[:notice]).to match('Votre message a été envoyé.') expect(response).to redirect_to root_path(formulaire_contact_general_submitted: true) @@ -80,9 +80,9 @@ describe SupportController, type: :controller do end it 'creates a conversation on HelpScout' do - expect_any_instance_of(Helpscout::FormAdapter).to receive(:send_form).and_return(true) - - expect { subject }.to change(Commentaire, :count).by(0) + expect { subject }.to \ + change(Commentaire, :count).by(0).and \ + have_enqueued_job(HelpscoutCreateConversationJob).with(hash_including(subject: 'bonjour', dossier_id: dossier.id)) expect(flash[:notice]).to match('Votre message a été envoyé.') expect(response).to redirect_to root_path(formulaire_contact_general_submitted: true) @@ -103,9 +103,8 @@ describe SupportController, type: :controller do end it 'posts the message to the dossier messagerie' do - expect_any_instance_of(Helpscout::FormAdapter).not_to receive(:send_form) - expect { subject }.to change(Commentaire, :count).by(1) + assert_no_enqueued_jobs(only: HelpscoutCreateConversationJob) expect(Commentaire.last.email).to eq(user.email) expect(Commentaire.last.dossier).to eq(dossier) @@ -159,10 +158,21 @@ describe SupportController, type: :controller do describe "when form is filled" do it "creates a conversation on HelpScout" do - expect_any_instance_of(Helpscout::FormAdapter).to receive(:send_form).and_return(true) - subject + expect { subject }.to have_enqueued_job(HelpscoutCreateConversationJob).with(hash_including(params.except(:admin))) expect(flash[:notice]).to match('Votre message a été envoyé.') end + + context "with a piece justificative" do + let(:logo) { fixture_file_upload('spec/fixtures/files/white.png', 'image/png') } + let(:params) { super().merge(piece_jointe: logo) } + + it "create blob and pass it to conversation job" do + expect { subject }.to \ + change(ActiveStorage::Blob, :count).by(1).and \ + have_enqueued_job(HelpscoutCreateConversationJob).with(hash_including(blob_id: Integer)).and \ + have_enqueued_job(VirusScannerJob) + end + end end describe "when invisible captcha is filled" do diff --git a/spec/jobs/helpscout_create_conversation_job_spec.rb b/spec/jobs/helpscout_create_conversation_job_spec.rb new file mode 100644 index 000000000..1220e538d --- /dev/null +++ b/spec/jobs/helpscout_create_conversation_job_spec.rb @@ -0,0 +1,64 @@ +require 'rails_helper' + +RSpec.describe HelpscoutCreateConversationJob, type: :job do + let(:args) { { email: 'sender@email.com' } } + + describe '#perform' do + context 'when blob_id is not present' do + it 'sends the form without a file' do + form_adapter = double('Helpscout::FormAdapter') + allow(Helpscout::FormAdapter).to receive(:new).with(hash_including(args.merge(blob: nil))).and_return(form_adapter) + expect(form_adapter).to receive(:send_form) + + described_class.perform_now(**args) + end + end + + context 'when blob_id is present' do + let(:blob) { + ActiveStorage::Blob.create_and_upload!(io: StringIO.new("toto"), filename: "toto.png") + } + + before do + allow(blob).to receive(:virus_scanner).and_return(double('VirusScanner', pending?: pending, safe?: safe)) + end + + context 'when the file has not been scanned yet' do + let(:pending) { true } + let(:safe) { false } + + it 'reenqueue job' do + expect { + described_class.perform_now(blob_id: blob.id, **args) + }.to have_enqueued_job(described_class).with(blob_id: blob.id, **args) + end + end + + context 'when the file is safe' do + let(:pending) { false } + let(:safe) { true } + + it 'downloads the file and sends the form' do + form_adapter = double('Helpscout::FormAdapter') + allow(Helpscout::FormAdapter).to receive(:new).with(hash_including(args.merge(blob:))).and_return(form_adapter) + allow(form_adapter).to receive(:send_form) + + described_class.perform_now(blob_id: blob.id, **args) + end + end + + context 'when the file is not safe' do + let(:pending) { false } + let(:safe) { false } + + it 'downloads the file and sends the form' do + form_adapter = double('Helpscout::FormAdapter') + allow(Helpscout::FormAdapter).to receive(:new).with(hash_including(args.merge(blob: nil))).and_return(form_adapter) + allow(form_adapter).to receive(:send_form) + + described_class.perform_now(blob_id: blob.id, **args) + end + end + end + end +end diff --git a/spec/lib/helpscout/form_adapter_spec.rb b/spec/lib/helpscout/form_adapter_spec.rb index d2e146cab..8eaa085a6 100644 --- a/spec/lib/helpscout/form_adapter_spec.rb +++ b/spec/lib/helpscout/form_adapter_spec.rb @@ -5,7 +5,7 @@ describe Helpscout::FormAdapter do context 'create_conversation' do before do allow(api).to receive(:create_conversation) - .and_return(double(success?: false)) + .and_return(double(success?: true, headers: {})) described_class.new(params, api).send_form end From 88c957806f13aaf7a66f9a60acbb36f25ebfbd29 Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Thu, 13 Jun 2024 14:01:05 +0200 Subject: [PATCH 0389/1532] fix link faq --- app/controllers/application_controller.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 32d8c088f..bc172fe47 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -312,7 +312,8 @@ class ApplicationController < ActionController::Base path == '/contact-admin' || path.start_with?('/connexion-par-jeton') || path.start_with?('/api/') || - path.start_with?('/lien-envoye') + path.start_with?('/lien-envoye') || + path.start_with?('/faq') false else From e283f2d8cd59b4b52f226e0ca66141ede9afe4c7 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 13 Jun 2024 14:12:00 +0200 Subject: [PATCH 0390/1532] chore(search): don't index on autosave --- app/controllers/users/dossiers_controller.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index 1fac35eae..f9e052268 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -304,7 +304,6 @@ module Users @dossier = dossier_with_champs(pj_template: false) @can_passer_en_construction_was = @dossier.can_passer_en_construction? update_dossier_and_compute_errors - @dossier.index_search_terms_later if @dossier.errors.empty? @can_passer_en_construction_is = @dossier.can_passer_en_construction? respond_to do |format| format.turbo_stream do From e6d761b915a951b4b808dab9c3b120109e1c1d94 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Fri, 14 Jun 2024 10:36:55 +0200 Subject: [PATCH 0391/1532] fix(dossier): see_more errors missing translation --- .../expandable_error_list/expandable_error_list.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/expandable_error_list/expandable_error_list.html.haml b/app/components/expandable_error_list/expandable_error_list.html.haml index 1ab5221e5..b9c958b31 100644 --- a/app/components/expandable_error_list/expandable_error_list.html.haml +++ b/app/components/expandable_error_list/expandable_error_list.html.haml @@ -6,7 +6,7 @@ = error_descriptor.error_message - if tail.size > 0 - %button.fr-mt-0.fr-btn.fr-btn--sm.fr-btn--tertiary-no-outline{ type: "button", "aria-controls": 'tail-errors', "aria-expanded": "false", class: "" }= t('see_more') + %button.fr-mt-0.fr-btn.fr-btn--sm.fr-btn--tertiary-no-outline{ type: "button", "aria-controls": 'tail-errors', "aria-expanded": "false", class: "" }= t('.see_more') %ul#tail-errors.fr-collapse.fr-mt-0 - tail.each do |error_descriptor| %li From 2e1e1c060b17bc05781d515f12bc122ee459f9de Mon Sep 17 00:00:00 2001 From: mfo Date: Fri, 14 Jun 2024 11:41:54 +0200 Subject: [PATCH 0392/1532] fix(dossier_submitted_message#edit): missing dossier for preview --- app/views/users/dossiers/_merci.html.haml | 2 +- .../dossier_submitted_messages_controller_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/users/dossiers/_merci.html.haml b/app/views/users/dossiers/_merci.html.haml index b9bb097c6..be9f3096c 100644 --- a/app/views/users/dossiers/_merci.html.haml +++ b/app/views/users/dossiers/_merci.html.haml @@ -20,7 +20,7 @@ - if procedure.active_dossier_submitted_message %p.fr-m-2= procedure.active_dossier_submitted_message.message_on_submit_by_usager %p.justify-center.flex.fr-mb-5w.fr-mt-2w - = link_to "#{t('views.users.dossiers.merci.download_dossier')} (PDF)", dossier_path(dossier, format: :pdf), download: "Mon dossier", target: "_blank", rel: "noopener", title: t('views.users.dossiers.show.header.print_dossier'), class: 'fr-btn fr-btn--secondary fr-mx-2w fr-btn--icon-left fr-icon-download-line' + = link_to "#{t('views.users.dossiers.merci.download_dossier')} (PDF)", dossier ? dossier_path(dossier, format: :pdf) : "#", download: "Mon dossier", target: "_blank", rel: "noopener", title: t('views.users.dossiers.show.header.print_dossier'), class: 'fr-btn fr-btn--secondary fr-mx-2w fr-btn--icon-left fr-icon-download-line' = link_to t('views.users.dossiers.merci.acces_dossier'), dossier ? dossier_path(dossier) : "#dossier" , class: 'fr-btn fr-mx-2w' %hr.fr-hr diff --git a/spec/controllers/administrateurs/dossier_submitted_messages_controller_spec.rb b/spec/controllers/administrateurs/dossier_submitted_messages_controller_spec.rb index 4bfb5babe..46f002f05 100644 --- a/spec/controllers/administrateurs/dossier_submitted_messages_controller_spec.rb +++ b/spec/controllers/administrateurs/dossier_submitted_messages_controller_spec.rb @@ -34,7 +34,7 @@ describe Administrateurs::DossierSubmittedMessagesController, type: :controller describe '#edit' do context 'when procedure is draft and have a DossierSubmittedMessage' do let(:procedure) { create(:procedure, :with_dossier_submitted_message, administrateur: administrateur) } - + render_views it 'assigns the existing DossierSubmittedMessage' do get(:edit, params: { procedure_id: procedure.id }) expect(response).to have_http_status(200) From 2dda5e44f9f158f3c6d27df712fbfddf90223e05 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Fri, 14 Jun 2024 11:34:41 +0200 Subject: [PATCH 0393/1532] chore(kredis): use default shared connection name, fixing dossier index debounce --- app/controllers/concerns/lockable_concern.rb | 2 +- config/initializers/kredis.rb | 6 +++--- spec/controllers/concerns/lockable_concern_spec.rb | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/controllers/concerns/lockable_concern.rb b/app/controllers/concerns/lockable_concern.rb index f6e59743b..880085426 100644 --- a/app/controllers/concerns/lockable_concern.rb +++ b/app/controllers/concerns/lockable_concern.rb @@ -3,7 +3,7 @@ module LockableConcern included do def lock_action(key) - lock = Kredis.flag(key, config: :volatile) + lock = Kredis.flag(key) head :locked and return if lock.marked? lock.mark(expires_in: 10.seconds) diff --git a/config/initializers/kredis.rb b/config/initializers/kredis.rb index 878b8177a..48a7a7a21 100644 --- a/config/initializers/kredis.rb +++ b/config/initializers/kredis.rb @@ -1,7 +1,7 @@ -redis_volatile_options = { +redis_shared_options = { url: ENV['REDIS_CACHE_URL'], # will fallback to default redis url if empty, and won't fail if there is no redis server ssl: ENV['REDIS_CACHE_SSL'] == 'enabled' } -redis_volatile_options[:ssl_params] = { verify_mode: OpenSSL::SSL::VERIFY_NONE } if ENV['REDIS_CACHE_SSL_VERIFY_NONE'] == 'enabled' +redis_shared_options[:ssl_params] = { verify_mode: OpenSSL::SSL::VERIFY_NONE } if ENV['REDIS_CACHE_SSL_VERIFY_NONE'] == 'enabled' -Kredis::Connections.connections[:volatile] = Redis.new(redis_volatile_options) +Kredis::Connections.connections[:shared] = Redis.new(redis_shared_options) diff --git a/spec/controllers/concerns/lockable_concern_spec.rb b/spec/controllers/concerns/lockable_concern_spec.rb index add57dc8d..dc47db17b 100644 --- a/spec/controllers/concerns/lockable_concern_spec.rb +++ b/spec/controllers/concerns/lockable_concern_spec.rb @@ -27,7 +27,7 @@ describe LockableConcern, type: :controller do context 'when there are concurrent requests' do it 'aborts the second request' do # Simulating the first request acquiring the lock - Kredis.flag(lock_key, config: :volatile).mark(expires_in: 3.seconds) + Kredis.flag(lock_key).mark(expires_in: 3.seconds) # Making the second request expect(subject).to have_http_status(:locked) @@ -36,7 +36,7 @@ describe LockableConcern, type: :controller do context 'when the lock expires' do it 'allows another request after expiration' do - Kredis.flag(lock_key, config: :volatile).mark(expires_in: 0.001.seconds) + Kredis.flag(lock_key).mark(expires_in: 0.001.seconds) sleep 0.002 expect(subject).to have_http_status(:ok) From 98782f8bf01328a37ea772e6a3b705344cd0a125 Mon Sep 17 00:00:00 2001 From: Benoit Queyron <72251526+Benoit-MINT@users.noreply.github.com> Date: Fri, 14 Jun 2024 17:37:05 +0200 Subject: [PATCH 0394/1532] suggestion de Colin Co-authored-by: Colin Darie --- app/mailers/notification_mailer.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index c319df7dd..e64a68ddc 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -92,11 +92,8 @@ class NotificationMailer < ApplicationMailer def set_jdma if params[:state] == Dossier.states.fetch(:en_construction) && @dossier.procedure.monavis_embed @jdma_html = @dossier.procedure.monavis_embed_html_source("email") - else - return end end - def set_dossier @dossier = params[:dossier] configure_defaults_for_user(@dossier.user) From cf787d322a274ff9f6d05250ebf4b4fa1d319289 Mon Sep 17 00:00:00 2001 From: Benoit Queyron <72251526+Benoit-MINT@users.noreply.github.com> Date: Fri, 14 Jun 2024 17:43:13 +0200 Subject: [PATCH 0395/1532] linter check --- app/mailers/notification_mailer.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index e64a68ddc..87d75fa1c 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -94,6 +94,7 @@ class NotificationMailer < ApplicationMailer @jdma_html = @dossier.procedure.monavis_embed_html_source("email") end end + def set_dossier @dossier = params[:dossier] configure_defaults_for_user(@dossier.user) From ee35dba37efdb00f2a19776989e41c478972aaeb Mon Sep 17 00:00:00 2001 From: Benoit Queyron Date: Fri, 14 Jun 2024 18:04:16 +0200 Subject: [PATCH 0396/1532] linter check --- app/mailers/notification_mailer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 87d75fa1c..8aa68de19 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -94,7 +94,7 @@ class NotificationMailer < ApplicationMailer @jdma_html = @dossier.procedure.monavis_embed_html_source("email") end end - + def set_dossier @dossier = params[:dossier] configure_defaults_for_user(@dossier.user) From 5a7316bc5b1c474fe5907752b3b3ad87739aaae4 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 17 Jun 2024 10:36:46 +0200 Subject: [PATCH 0397/1532] chore(css): import segmented controls --- app/javascript/entrypoints/main.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/javascript/entrypoints/main.css b/app/javascript/entrypoints/main.css index aa7dec85b..c6e48839d 100644 --- a/app/javascript/entrypoints/main.css +++ b/app/javascript/entrypoints/main.css @@ -24,6 +24,7 @@ @import '@gouvfr/dsfr/dist/component/modal/modal.css'; @import '@gouvfr/dsfr/dist/component/navigation/navigation.css'; @import '@gouvfr/dsfr/dist/component/notice/notice.css'; +@import '@gouvfr/dsfr/dist/component/segmented/segmented.css'; @import '@gouvfr/dsfr/dist/component/table/table.css'; @import '@gouvfr/dsfr/dist/component/tile/tile.css'; @import '@gouvfr/dsfr/dist/component/tag/tag.css'; From 266a7dbcdd20d40a93479f30101ce98bd5fe7004 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 17 Jun 2024 10:37:11 +0200 Subject: [PATCH 0398/1532] style(stats): better responsiveness & dark theme --- app/assets/stylesheets/stats.scss | 51 +------- .../controllers/lazy/chartkick_controller.ts | 77 ++++++----- app/views/stats/index.html.haml | 122 +++++++++--------- config/initializers/chartkick.rb | 16 ++- 4 files changed, 119 insertions(+), 147 deletions(-) diff --git a/app/assets/stylesheets/stats.scss b/app/assets/stylesheets/stats.scss index 01721ec38..9869fdf15 100644 --- a/app/assets/stylesheets/stats.scss +++ b/app/assets/stylesheets/stats.scss @@ -70,51 +70,8 @@ $stat-card-half-horizontal-spacing: 4 * $default-space; font-style: italic; } -$segmented-control-margin-top: $default-space; - -.segmented-control { - border-radius: 36px; - height: 36px; - line-height: 36px; - font-size: 0; - padding: 0; - display: inline-block; - margin-top: $segmented-control-margin-top; -} - $segmented-control-item-horizontal-padding: $default-space; -$segmented-control-item-border-radius: 2 * $default-space; - -.segmented-control-item { - display: inline-block; - font-size: 15px; - border: 2px solid $blue-france-700; - margin-right: -2px; - padding-top: var(--li-bottom); - padding-left: $segmented-control-item-horizontal-padding; - padding-right: $segmented-control-item-horizontal-padding; - color: $blue-france-700; - - &:first-of-type { - border-radius: $segmented-control-item-border-radius 0px 0px $segmented-control-item-border-radius; - } - - &:last-of-type { - border-radius: 0px $segmented-control-item-border-radius $segmented-control-item-border-radius 0px; - margin-right: 0; - } - - &:hover { - background-color: $blue-france-500; - color: #FFFFFF; - cursor: pointer; - } -} - -.segmented-control-item-active { - background-color: $blue-france-700; - color: #FFFFFF; -} +$segmented-control-item-border-radius: 0.25rem; .chart-container { margin-top: 36px; @@ -147,10 +104,10 @@ $big-number-card-padding: 2 * $segmented-control-item-border-radius; .big-number-card-number { display: block; text-align: center; - font-size: 80px; - line-height: 1em; + font-size: 4.5rem; + line-height: 1.5em; font-weight: bold; - color: $blue-france-500; + color: var(--text-title-blue-france); white-space: nowrap; } diff --git a/app/javascript/controllers/lazy/chartkick_controller.ts b/app/javascript/controllers/lazy/chartkick_controller.ts index 90758e10f..a5e84394c 100644 --- a/app/javascript/controllers/lazy/chartkick_controller.ts +++ b/app/javascript/controllers/lazy/chartkick_controller.ts @@ -1,44 +1,43 @@ import { Controller } from '@hotwired/stimulus'; -import { toggle, delegate } from '@utils'; -import Highcharts from 'highcharts'; import Chartkick from 'chartkick'; - -export class ChartkickController extends Controller { - async connect() { - delegate('click', '[data-toggle-chart]', (event) => - toggleChart(event as MouseEvent) - ); - } -} +import Highcharts from 'highcharts'; +import invariant from 'tiny-invariant'; Chartkick.use(Highcharts); -function reflow(nextChartId?: string) { - nextChartId && Chartkick.charts[nextChartId]?.getChartObject()?.reflow(); -} - -function toggleChart(event: MouseEvent) { - const nextSelectorItem = event.target as HTMLButtonElement, - chartClass = nextSelectorItem.dataset.toggleChart, - nextChart = chartClass - ? document.querySelector(chartClass) - : undefined, - nextChartId = nextChart?.children[0]?.id, - currentSelectorItem = nextSelectorItem.parentElement?.querySelector( - '.segmented-control-item-active' - ), - currentChart = - nextSelectorItem.parentElement?.parentElement?.querySelector( - '.chart:not(.hidden)' - ); - - // Change the current selector and the next selector states - currentSelectorItem?.classList.toggle('segmented-control-item-active'); - nextSelectorItem.classList.toggle('segmented-control-item-active'); - - // Hide the currently shown chart and show the new one - currentChart && toggle(currentChart); - nextChart && toggle(nextChart); - - // Reflow needed, see https://github.com/highcharts/highcharts/issues/1979 - reflow(nextChartId); + +export default class ChartkickController extends Controller { + static targets = ['chart']; + + declare readonly chartTargets: HTMLElement[]; + + toggleChart(event: Event) { + const target = event.currentTarget as HTMLInputElement; + const chartClass = target.dataset.toggleChart; + + invariant(chartClass, 'Missing data-toggle-chart attribute'); + + const nextChart = document.querySelector(chartClass); + const currentChart = this.chartTargets.find( + (chart) => !chart.classList.contains('hidden') + ); + + if (currentChart) { + currentChart.classList.add('hidden'); + } + + if (nextChart) { + nextChart.classList.remove('hidden'); + const nextChartId = nextChart.children[0]?.id; + this.reflow(nextChartId); + } + } + + reflow(chartId: string) { + if (chartId) { + const chart = Chartkick.charts[chartId]; + if (chart) { + chart.getChartObject()?.reflow(); + } + } + } } diff --git a/app/views/stats/index.html.haml b/app/views/stats/index.html.haml index d1712f63a..af8fc0bc1 100644 --- a/app/views/stats/index.html.haml +++ b/app/views/stats/index.html.haml @@ -2,71 +2,75 @@ - content_for :footer do = render partial: "root/footer" -.statistiques{ 'data-controller': 'chartkick' } - %h1.new-h1 Statistiques +.fr-container.fr-my-4w + %h1 Statistiques d’utilisation de la plateforme - .stat-cards - .stat-card.stat-card-half.big-number-card.pull-left - %span.big-number-card-title.long-title TOTAL DÉMARCHES DÉMAT. OU EN COURS DE DÉMAT. - %span.big-number-card-number - = number_with_delimiter(@procedures_numbers[:total]) - %span.big-number-card-detail - #{number_with_delimiter(@procedures_numbers[:last_30_days_count])} (#{@procedures_numbers[:evolution]} %) sur les 30 derniers jours - %span.big-number-card-detail - = link_to "Voir carte de déploiement", carte_path + .fr-grid-row.fr-grid-row--gutters + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout{ data: { controller: 'chartkick' } } + %h2.fr-callout__title Démarches dématérialisées (total) + %p.fr-callout__text.big-number-card-number.fr-mb-2w + %span.big-number-card-number= number_with_delimiter(@procedures_numbers[:total]) + %p.fr-callout__text.fr-text--md.text-center + #{number_with_delimiter(@procedures_numbers[:last_30_days_count])} (#{@procedures_numbers[:evolution]} %) sur les 30 derniers jours + %br + = link_to "Voir carte de déploiement", carte_path - .stat-card.stat-card-half.big-number-card.pull-left - %span.big-number-card-title TOTAL DOSSIERS DÉPOSÉS - %span.big-number-card-number - = number_with_delimiter(@dossiers_numbers[:total]) - %span.big-number-card-detail - #{number_with_delimiter(@dossiers_numbers[:last_30_days_count])} (#{@dossiers_numbers[:evolution]} %) sur les 30 derniers jours - %span.big-number-card-detail - = link_to "Voir carte de déploiement", carte_path(map_filter: { kind: :nb_dossiers }) + %fieldset.fr-segmented.fr-segmented--sm.pull-right.fr-mt-2w.fr-my-1w + .fr-segmented__elements + .fr-segmented__element + %input{ value: "1", checked: true, type: "radio", id: "segmented-procedures-1", name: "segmented-procedures", data: { action: 'chartkick#toggleChart', 'toggle-chart': '.monthly-procedures-chart' } } + %label.fr-label{ for: "segmented-procedures-1" } + Par mois + .fr-segmented__element + %input{ value: "2", type: "radio", id: "segmented-procedures-2", name: "segmented-procedures", data: { action: 'chartkick#toggleChart', 'toggle-chart': '.cumulative-procedures-chart' } } + %label.fr-label{ for: "segmented-procedures-2" } + Cumul + + .chart-container + .chart.monthly-procedures-chart{ data: { 'chartkick-target': 'chart' }} + = column_chart @procedures_in_the_last_4_months, library: Chartkick.options[:default_library_config] + .chart.cumulative-procedures-chart.hidden{ data: { 'chartkick-target': 'chart' } } + = area_chart @procedures_cumulative, library: Chartkick.options[:default_library_config] + + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout{ data: { controller: 'chartkick' } } + %h2.fr-callout__title Dossiers déposés (total) + %p.fr-callout__text.big-number-card-number.fr-mb-2w + = number_with_delimiter(@dossiers_numbers[:total]) + %p.fr-callout__text.fr-text--md.text-center + #{number_with_delimiter(@dossiers_numbers[:last_30_days_count])} (#{@dossiers_numbers[:evolution]} %) sur les 30 derniers jours + %br + = link_to "Voir carte de déploiement", carte_path(map_filter: { kind: :nb_dossiers }) + + %fieldset.fr-segmented.fr-segmented--sm.pull-right.fr-mt-2w.fr-my-1w + .fr-segmented__elements + .fr-segmented__element + %input{ value: "1", checked: true, type: "radio", id: "segmented-dossiers-1", name: "segmented-dossiers", data: { action: 'chartkick#toggleChart', 'toggle-chart': '.monthly-dossiers-chart' } } + %label.fr-label{ for: "segmented-dossiers-1" } + Par mois + .fr-segmented__element + %input{ value: "2", type: "radio", id: "segmented-dossiers-2", name: "segmented-dossiers", data: { action: 'chartkick#toggleChart', 'toggle-chart': '.cumulative-dossiers-chart' } } + %label.fr-label{ for: "segmented-dossiers-2" } + Cumul - .stat-card.stat-card-half.pull-left - %ul.segmented-control.pull-right - %li.segmented-control-item.segmented-control-item-active{ data: { 'toggle-chart': '.monthly-procedures-chart' } } - Par mois - %li.segmented-control-item{ data: { 'toggle-chart': '.cumulative-procedures-chart' } } - Cumul - %span.stat-card-title.pull-left Démarches dématérialisées - .clearfix + .chart-container + .chart.monthly-dossiers-chart{ data: { 'chartkick-target': 'chart' }} + = column_chart @dossiers_in_the_last_4_months, library: Chartkick.options[:default_library_config] + .chart.cumulative-dossiers-chart.hidden{ data: { 'chartkick-target': 'chart' } } + = area_chart @dossiers_cumulative, library: Chartkick.options[:default_library_config] - .chart-container - .chart.monthly-procedures-chart - = column_chart @procedures_in_the_last_4_months - .chart.cumulative-procedures-chart.hidden - = area_chart @procedures_cumulative + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout + %h2.fr-callout__title Répartition des dossiers - .stat-card.stat-card-half.pull-left - %ul.segmented-control.pull-right - %li.segmented-control-item.segmented-control-item-active{ data: { 'toggle-chart': '.monthly-dossiers-chart' } } - Par mois - %li.segmented-control-item{ data: { 'toggle-chart': '.cumulative-dossiers-chart' } } - Cumul - %span.stat-card-title.pull-left Dossiers déposés - .clearfix - - .chart-container - .chart.monthly-dossiers-chart - = column_chart @dossiers_in_the_last_4_months - .chart.cumulative-dossiers-chart.hidden - = area_chart @dossiers_cumulative - - .stat-card.stat-card-half.pull-left - %span.stat-card-title - Répartition des dossiers - - .chart-container - .chart - = pie_chart @dossiers_states_for_pie, - colors: ["#000091", "#7F7FC8", "#9A9AFF", "#00006D"] - - .clearfix + .chart-container + .chart + = pie_chart @dossiers_states_for_pie, library: Chartkick.options[:default_library_config], + colors: ["#000091", "#7F7FC8", "#9A9AFF", "#00006D"] - if super_admin_signed_in? - %h2.new-h2 Téléchargement + %h2.fr-h4 Téléchargement - = link_to "Télécharger les statistiques (CSV)", stats_download_path(format: :csv), class: 'fr-btn fr-btn-primary mb-4' + = link_to "Télécharger les statistiques (CSV)", stats_download_path(format: :csv), class: 'fr-btn fr-btn-primary fr-mb-4w' diff --git a/config/initializers/chartkick.rb b/config/initializers/chartkick.rb index 4f44c1a38..fe4d559ad 100644 --- a/config/initializers/chartkick.rb +++ b/config/initializers/chartkick.rb @@ -1,6 +1,18 @@ Chartkick.options = { content_for: :charts_js, - colors: ["#000091"], + colors: ["var(--background-action-high-blue-france)"], thousands: ' ', - decimal: ',' + decimal: ',', + default_library_config: { + chart: { backgroundColor: 'var(--background-contrast-grey)' }, + xAxis: { + lineColor: 'var(--border-action-high-grey)', + labels: { style: { color: "var(--text-default-grey)" } } + }, + yAxis: { + gridLineColor: 'var(--border-plain-grey)', + lineColor: 'var(--border-action-high-grey)', + labels: { style: { color: "var(--text-default-grey)" } } + } + } } From c288d340d8707d252bdacee05903c5b0b3749f9c Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 17 Jun 2024 11:53:01 +0200 Subject: [PATCH 0399/1532] style(stats): better responsive & dark theme for procedure stats --- app/assets/stylesheets/stats.scss | 97 -------------------- app/views/shared/procedures/_stats.html.haml | 89 ++++++++++-------- config/initializers/chartkick.rb | 34 +++++-- 3 files changed, 75 insertions(+), 145 deletions(-) diff --git a/app/assets/stylesheets/stats.scss b/app/assets/stylesheets/stats.scss index 9869fdf15..8c255c6f6 100644 --- a/app/assets/stylesheets/stats.scss +++ b/app/assets/stylesheets/stats.scss @@ -1,78 +1,8 @@ -@import "colors"; -@import "constants"; - -$dark-grey: #333333; -$light-grey: #999999; - -$default-space: 15px; - -$new-h1-margin-bottom: 4 * $default-space; -$new-h2-margin-bottom: 3 * $default-space; - -.new-h1, -.new-h2 { - color: $dark-grey; - text-align: center; - font-weight: bold; -} - .new-h1 { margin-bottom: 3.75rem; font-size: 2.5rem; } -.new-h2 { - margin-bottom: $new-h2-margin-bottom; - font-size: 36px; -} - -$statistiques-padding-top: $default-space * 2; - -.statistiques { - width: 1040px; - margin: 0 auto; - padding-top: $statistiques-padding-top; -} - -.stat-cards { - .stat-card:nth-of-type(even) { - margin-right: 0px; - } -} - -$stat-card-margin-bottom: 3 * $default-space; - -.stat-card { - padding: 15px; - margin-bottom: $stat-card-margin-bottom; - border-radius: 5px; - box-shadow: none; - border: 1px solid rgba(0, 0, 0, 0.15); -} - -$stat-card-half-horizontal-spacing: 4 * $default-space; - -.stat-card-half { - width: calc((100% - #{$stat-card-half-horizontal-spacing}) / 2); - margin-right: $stat-card-half-horizontal-spacing; -} - -.stat-card-title { - color: $dark-grey; - font-size: 26px; - font-weight: bold; - width: 200px; - text-transform: uppercase; -} - -.stat-card-details { - font-size: 13px; - font-style: italic; -} - -$segmented-control-item-horizontal-padding: $default-space; -$segmented-control-item-border-radius: 0.25rem; - .chart-container { margin-top: 36px; } @@ -81,26 +11,6 @@ $segmented-control-item-border-radius: 0.25rem; width: 100%; } -$big-number-card-padding: 2 * $segmented-control-item-border-radius; - -.big-number-card { - padding: $big-number-card-padding $segmented-control-item-horizontal-padding; -} - -.big-number-card-title { - display: block; - text-align: center; - margin: 0 auto; - margin-bottom: 20px; - color: $light-grey; - text-transform: uppercase; - - &.long-title { - margin-left: -30px; - margin-right: -30px; - } -} - .big-number-card-number { display: block; text-align: center; @@ -110,10 +20,3 @@ $big-number-card-padding: 2 * $segmented-control-item-border-radius; color: var(--text-title-blue-france); white-space: nowrap; } - -.big-number-card-detail { - display: block; - margin-top: $default-padding; - text-align: center; - color: $blue-france-500; -} diff --git a/app/views/shared/procedures/_stats.html.haml b/app/views/shared/procedures/_stats.html.haml index b39856c9e..1fe3affe0 100644 --- a/app/views/shared/procedures/_stats.html.haml +++ b/app/views/shared/procedures/_stats.html.haml @@ -1,45 +1,56 @@ -.statistiques{ 'data-controller': 'chartkick' } - %h1.new-h1= title - .stat-cards +.fr-container.fr-my-4w + %h1= title + .fr-grid-row.fr-grid-row--gutters - if @usual_traitement_time.present? - .stat-card.big-number-card - %span.big-number-card-title= t('.usual_processing_time') - = render Procedure::EstimatedDelayComponent.new(procedure: @procedure) + .fr-col-xs-12 + .fr-callout + %h2.fr-callout__title= t('.usual_processing_time') + = render Procedure::EstimatedDelayComponent.new(procedure: @procedure) - .stat-cards - .stat-card.stat-card-half.pull-left - %span.stat-card-title= t('.processing_time') - .stat-card-details= t('.since_procedure_creation') - .chart-container - .chart - - colors = %w(#C3D9FF #0069CC #1C7EC9) # from _colors.scss - = column_chart @usual_traitement_time_by_month, ytitle: t('.nb_days'), legend: "bottom", label: t('.processing_time_graph_description') + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout{ data: { controller: 'chartkick' } } + %h2.fr-callout__title= t('.processing_time') + %p.fr-callout__text.fr-text--md= t('.since_procedure_creation') - .stat-card.stat-card-half.pull-left - %span.stat-card-title= t('.status_evolution') - .stat-card-details= t('.status_evolution_details') - .chart-container - .chart - = area_chart @dossiers_funnel, ytitle: t('.dossiers_count'), label: t('.dossiers_count') + .chart-container + .chart-procedures-chart{ data: { 'chartkick-target': 'chart' }} + = column_chart @usual_traitement_time_by_month, + library: Chartkick.options[:default_library_config], + ytitle: t('.nb_days'), legend: "bottom", label: t('.processing_time_graph_description') - .stat-cards - .stat-card.stat-card-half.pull-left - %span.stat-card-title= t('.acceptance_rate') - .stat-card-details= t('.acceptance_rate_details') - .chart-container - .chart - = pie_chart @termines_states, - code: true, - colors: %w(#387EC3 #AE2C2B #FAD859), - label: t('.rate'), - suffix: '%', - library: { plotOptions: { pie: { dataLabels: { enabled: true, format: '{point.name} : {point.percentage: .1f}%' } } } } + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout + %h2.fr-callout__title= t('.status_evolution') + %p.fr-callout__text.fr-text--md= t('.status_evolution_details') + .chart-container + .chart + = area_chart @dossiers_funnel, + library: Chartkick.options[:default_library_config], + ytitle: t('.dossiers_count'), label: t('.dossiers_count') - .stat-card.stat-card-half.pull-left - %span.stat-card-title= t('.weekly_distribution') - .stat-card-details= t('.weekly_distribution_details') - .chart-container - .chart - = line_chart @termines_by_week, colors: ["#387EC3", "#AE2C2B", "#FAD859"], ytitle: t('.dossiers_count') -.clearfix + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout + %h2.fr-callout__title= t('.acceptance_rate') + %p.fr-callout__text.fr-text--md= t('.acceptance_rate_details') + + .chart-container + .chart + = pie_chart @termines_states, + library: Chartkick.options[:default_library_config], + code: true, + colors: ["var(--background-flat-success)", "var(--background-flat-error)", "#FAD859" ], + label: t('.rate'), + suffix: '%' + + .fr-col-xs-12.fr-col-sm-12.fr-col-lg-6 + .fr-callout + %h2.fr-callout__title= t('.weekly_distribution') + %p.fr-callout__text.fr-text--md= t('.weekly_distribution_details') + + .chart-container + .chart + = line_chart @termines_by_week, + library: Chartkick.options[:default_library_config], + colors: ["var(--background-flat-success)", "var(--background-flat-error)", "#FAD859" ], + ytitle: t('.dossiers_count') diff --git a/config/initializers/chartkick.rb b/config/initializers/chartkick.rb index fe4d559ad..68af7c151 100644 --- a/config/initializers/chartkick.rb +++ b/config/initializers/chartkick.rb @@ -5,14 +5,30 @@ Chartkick.options = { decimal: ',', default_library_config: { chart: { backgroundColor: 'var(--background-contrast-grey)' }, - xAxis: { - lineColor: 'var(--border-action-high-grey)', - labels: { style: { color: "var(--text-default-grey)" } } - }, - yAxis: { - gridLineColor: 'var(--border-plain-grey)', - lineColor: 'var(--border-action-high-grey)', - labels: { style: { color: "var(--text-default-grey)" } } - } + xAxis: { + lineColor: 'var(--border-action-high-grey)', + labels: { style: { color: "var(--text-default-grey)" } } + }, + yAxis: { + gridLineColor: 'var(--border-plain-grey)', + lineColor: 'var(--border-action-high-grey)', + labels: { style: { color: "var(--text-default-grey)" } } + }, + legend: { + itemStyle: { + color: "var(--text-default-grey)" + } + }, + plotOptions: { + pie: { + dataLabels: { + color: "var(--text-default-grey)", + enabled: true, format: '{point.name} : {point.percentage: .1f}%', + style: { + textOutline: 'none' + } + } + } + } } } From 225206425980fae4f7099d2db86ed4826d76b555 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 17 Jun 2024 11:57:52 +0200 Subject: [PATCH 0400/1532] style: cleanup stats.scss --- app/assets/stylesheets/stats.scss | 9 --------- app/views/shared/procedures/_stats.html.haml | 10 +++++----- app/views/stats/index.html.haml | 10 +++++----- app/views/support/admin.html.haml | 2 +- 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/app/assets/stylesheets/stats.scss b/app/assets/stylesheets/stats.scss index 8c255c6f6..759768e6e 100644 --- a/app/assets/stylesheets/stats.scss +++ b/app/assets/stylesheets/stats.scss @@ -1,12 +1,3 @@ -.new-h1 { - margin-bottom: 3.75rem; - font-size: 2.5rem; -} - -.chart-container { - margin-top: 36px; -} - .chart { width: 100%; } diff --git a/app/views/shared/procedures/_stats.html.haml b/app/views/shared/procedures/_stats.html.haml index 1fe3affe0..fd6fb1c85 100644 --- a/app/views/shared/procedures/_stats.html.haml +++ b/app/views/shared/procedures/_stats.html.haml @@ -12,8 +12,8 @@ %h2.fr-callout__title= t('.processing_time') %p.fr-callout__text.fr-text--md= t('.since_procedure_creation') - .chart-container - .chart-procedures-chart{ data: { 'chartkick-target': 'chart' }} + .fr-mt-4w + .chart-procedures-chart{ data: { 'chartkick-target': 'chart' } } = column_chart @usual_traitement_time_by_month, library: Chartkick.options[:default_library_config], ytitle: t('.nb_days'), legend: "bottom", label: t('.processing_time_graph_description') @@ -23,7 +23,7 @@ %h2.fr-callout__title= t('.status_evolution') %p.fr-callout__text.fr-text--md= t('.status_evolution_details') - .chart-container + .fr-mt-4w .chart = area_chart @dossiers_funnel, library: Chartkick.options[:default_library_config], @@ -34,7 +34,7 @@ %h2.fr-callout__title= t('.acceptance_rate') %p.fr-callout__text.fr-text--md= t('.acceptance_rate_details') - .chart-container + .fr-mt-4w .chart = pie_chart @termines_states, library: Chartkick.options[:default_library_config], @@ -48,7 +48,7 @@ %h2.fr-callout__title= t('.weekly_distribution') %p.fr-callout__text.fr-text--md= t('.weekly_distribution_details') - .chart-container + .fr-mt-4w .chart = line_chart @termines_by_week, library: Chartkick.options[:default_library_config], diff --git a/app/views/stats/index.html.haml b/app/views/stats/index.html.haml index af8fc0bc1..96c847e47 100644 --- a/app/views/stats/index.html.haml +++ b/app/views/stats/index.html.haml @@ -27,8 +27,8 @@ %label.fr-label{ for: "segmented-procedures-2" } Cumul - .chart-container - .chart.monthly-procedures-chart{ data: { 'chartkick-target': 'chart' }} + .fr-mt-4w + .chart.monthly-procedures-chart{ data: { 'chartkick-target': 'chart' } } = column_chart @procedures_in_the_last_4_months, library: Chartkick.options[:default_library_config] .chart.cumulative-procedures-chart.hidden{ data: { 'chartkick-target': 'chart' } } = area_chart @procedures_cumulative, library: Chartkick.options[:default_library_config] @@ -55,8 +55,8 @@ Cumul - .chart-container - .chart.monthly-dossiers-chart{ data: { 'chartkick-target': 'chart' }} + .fr-mt-4w + .chart.monthly-dossiers-chart{ data: { 'chartkick-target': 'chart' } } = column_chart @dossiers_in_the_last_4_months, library: Chartkick.options[:default_library_config] .chart.cumulative-dossiers-chart.hidden{ data: { 'chartkick-target': 'chart' } } = area_chart @dossiers_cumulative, library: Chartkick.options[:default_library_config] @@ -65,7 +65,7 @@ .fr-callout %h2.fr-callout__title Répartition des dossiers - .chart-container + .fr-mt-4w .chart = pie_chart @dossiers_states_for_pie, library: Chartkick.options[:default_library_config], colors: ["#000091", "#7F7FC8", "#9A9AFF", "#00006D"] diff --git a/app/views/support/admin.html.haml b/app/views/support/admin.html.haml index dff48a0cc..bfc845b32 100644 --- a/app/views/support/admin.html.haml +++ b/app/views/support/admin.html.haml @@ -2,7 +2,7 @@ #contact-form .container - %h1.new-h1 + %h1 = t('.contact_team') .description From d1e983ed97ff31310d7065ef2c27203ff43e2e68 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 17 Jun 2024 13:25:28 +0200 Subject: [PATCH 0401/1532] fix(EmailCheckerController): with partial email, should not raise error --- app/lib/email_checker.rb | 2 ++ spec/controllers/email_checker_controller_spec.rb | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/app/lib/email_checker.rb b/app/lib/email_checker.rb index c2cbe3536..e2d2d5222 100644 --- a/app/lib/email_checker.rb +++ b/app/lib/email_checker.rb @@ -627,6 +627,8 @@ class EmailChecker return { success: true } if similar_domains.empty? { success: true, email_suggestions: email_suggestions(parsed_email:, similar_domains:) } + rescue Mail::Field::IncompleteParseError + return { success: false } end private diff --git a/spec/controllers/email_checker_controller_spec.rb b/spec/controllers/email_checker_controller_spec.rb index 4572c2cd4..685bd5de9 100644 --- a/spec/controllers/email_checker_controller_spec.rb +++ b/spec/controllers/email_checker_controller_spec.rb @@ -35,5 +35,13 @@ describe EmailCheckerController, type: :controller do expect(body).to eq({ success: false }) end end + + context 'incomplete' do + let(:params) { { email: 'bikram.subedi81@' } } + it do + expect(response).to have_http_status(:success) + expect(body).to eq({ success: false }) + end + end end end From 8cbf4753ff40840bc2b3f37714300bd47fffbe25 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 17 Jun 2024 16:40:31 +0200 Subject: [PATCH 0402/1532] bug(ineligibilite_rules): caching champs.visible without re-validation afterward means we can skip conditions --- .../users/dossier_ineligibilite_spec.rb | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/spec/system/users/dossier_ineligibilite_spec.rb b/spec/system/users/dossier_ineligibilite_spec.rb index 5bbb25c75..9b9fa9de6 100644 --- a/spec/system/users/dossier_ineligibilite_spec.rb +++ b/spec/system/users/dossier_ineligibilite_spec.rb @@ -179,4 +179,27 @@ describe 'Dossier Inéligibilité', js: true do wait_until { dossier.reload.en_construction? == true } end end + + describe 'ineligibilite_rules does not mess with champs.visible' do + let(:types_de_champ_public) do + [ + { type: :yes_no, libelle: 'l1', stable_id: 1 }, + { type: :yes_no, libelle: 'l2', stable_id: 2, condition: ds_eq(champ_value(1), constant(false)) } + ] + end + let(:ineligibilite_rules) do + ds_eq(champ_value(2), constant(false)) + end + + scenario 'ineligibilite rules without validation on champ ensure to re-process cached champs.visible' do + visit brouillon_dossier_path(dossier) + expect(page).to have_selector(:button, text: "Déposer le dossier", disabled: false) + expect(page).not_to have_content("Vous ne pouvez pas déposer votre dossier") + + within "#champ-1" do + find("label", text: "Non").click + end + expect(page).to have_selector("#champ-2", visible: true) + end + end end From 31bf30830dd6eb8c0c8d6a634da1eadfcfc1c865 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Mon, 17 Jun 2024 14:46:49 +0200 Subject: [PATCH 0403/1532] Place the menu in the
diff --git a/app/views/manager/procedures/show.html.erb b/app/views/manager/procedures/show.html.erb index 82ff536cb..703c492d1 100644 --- a/app/views/manager/procedures/show.html.erb +++ b/app/views/manager/procedures/show.html.erb @@ -31,7 +31,7 @@ as well as a link to its edit page. t("administrate.actions.edit_resource", name: page.page_title), [:edit, namespace, page.resource], class: "button", - ) if accessible_action?(:edit, page.resource) %> + ) if accessible_action?(page.resource, :edit) %> <%= link_to 'Aperçu', apercu_admin_procedure_path(procedure), class: 'button' %> diff --git a/app/views/manager/published_procedures/index.html.erb b/app/views/manager/published_procedures/index.html.erb index e2339dae1..05754ba9e 100644 --- a/app/views/manager/published_procedures/index.html.erb +++ b/app/views/manager/published_procedures/index.html.erb @@ -48,7 +48,7 @@ It renders the `_table` partial to display details about the resources. ), [:new, namespace, page.resource_path.to_sym], class: "button", - ) if accessible_action?(:new, page.resource) && show_action?(:new, new_resource) %> + ) if accessible_action?(page.resource_name, :new) %> From 25977fd97df93c04aeed9305aabdd5614bc7036b Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 3 Oct 2024 13:13:07 +0200 Subject: [PATCH 1111/1532] =?UTF-8?q?ETQ=20int=C3=A9grateur=20d=E2=80=99AP?= =?UTF-8?q?I,=20je=20veux=20que=20le=20dossier=20soit=20remont=C3=A9=20com?= =?UTF-8?q?me=20modifi=C3=A9=20quand=20ses=20instructeurs=20changent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/follow.rb | 2 +- .../instructeurs/dossiers_controller_spec.rb | 51 +++++++++++++------ 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/app/models/follow.rb b/app/models/follow.rb index 75f9cb72b..c494eb47d 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -2,7 +2,7 @@ class Follow < ApplicationRecord belongs_to :instructeur, optional: false - belongs_to :dossier, optional: false + belongs_to :dossier, optional: false, touch: true validates :instructeur_id, uniqueness: { scope: [:dossier_id, :unfollowed_at] } diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index c9227d2ed..5985b19f4 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -61,40 +61,59 @@ describe Instructeurs::DossiersController, type: :controller do describe '#follow' do let(:batch_operation) {} - before do + + subject do batch_operation patch :follow, params: { procedure_id: procedure.id, dossier_id: dossier.id } end - it { expect(instructeur.followed_dossiers).to match([dossier]) } - it { expect(flash.notice).to eq('Dossier suivi') } - it { expect(response).to redirect_to(instructeur_procedure_path(dossier.procedure)) } + it do + subject + expect(instructeur.followed_dossiers).to match([dossier]) + expect(flash.notice).to eq('Dossier suivi') + expect(response).to redirect_to(instructeur_procedure_path(dossier.procedure)) + end + it { expect { subject }.to change { dossier.reload.updated_at } } context 'with dossier in batch_operation' do let(:batch_operation) { create(:batch_operation, operation: :archiver, dossiers: [dossier], instructeur: instructeur) } - it { expect(instructeur.followed_dossiers).to eq([]) } - it { expect(response).to redirect_to(instructeur_dossier_path(dossier.procedure, dossier)) } - it { expect(flash.alert).to eq("Votre action n'a pas été effectuée, ce dossier fait parti d'un traitement de masse.") } + + it do + subject + expect(instructeur.followed_dossiers).to eq([]) + expect(response).to redirect_to(instructeur_dossier_path(dossier.procedure, dossier)) + expect(flash.alert).to eq("Votre action n'a pas été effectuée, ce dossier fait parti d'un traitement de masse.") + end end end describe '#unfollow' do let(:batch_operation) {} - before do + before { instructeur.followed_dossiers << dossier } + + subject do batch_operation - instructeur.followed_dossiers << dossier patch :unfollow, params: { procedure_id: procedure.id, dossier_id: dossier.id } - instructeur.reload end - it { expect(instructeur.followed_dossiers).to match([]) } - it { expect(flash.notice).to eq("Vous ne suivez plus le dossier nº #{dossier.id}") } - it { expect(response).to redirect_to(instructeur_procedure_path(dossier.procedure)) } + it do + subject + expect(instructeur.followed_dossiers).to match([]) + expect(flash.notice).to eq("Vous ne suivez plus le dossier nº #{dossier.id}") + expect(response).to redirect_to(instructeur_procedure_path(dossier.procedure)) + end + + it { expect { subject }.to change { dossier.reload.updated_at } } + context 'with dossier in batch_operation' do let(:batch_operation) { create(:batch_operation, operation: :archiver, dossiers: [dossier], instructeur: instructeur) } - it { expect(instructeur.followed_dossiers).to eq([dossier]) } - it { expect(response).to redirect_to(instructeur_dossier_path(dossier.procedure, dossier)) } - it { expect(flash.alert).to eq("Votre action n'a pas été effectuée, ce dossier fait parti d'un traitement de masse.") } + + it do + subject + expect(instructeur.followed_dossiers).to eq([dossier]) + expect(response).to redirect_to(instructeur_dossier_path(dossier.procedure, dossier)) + expect(flash.alert).to eq("Votre action n'a pas été effectuée, ce dossier fait parti d'un traitement de masse.") + end end end From 8fdf5cbe80e1d5dce5cb9f68241529bec49483f6 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Fri, 27 Sep 2024 14:44:07 +0200 Subject: [PATCH 1112/1532] refactor(dossier): explicitly build default values - less callbacks, less magic --- .../api/public/v1/dossiers_controller.rb | 2 +- app/controllers/users/commencer_controller.rb | 2 +- app/controllers/users/dossiers_controller.rb | 2 +- app/models/dossier.rb | 54 +++++++++-------- app/models/procedure_revision.rb | 12 +--- spec/factories/dossier.rb | 58 ++++++++++--------- 6 files changed, 66 insertions(+), 64 deletions(-) diff --git a/app/controllers/api/public/v1/dossiers_controller.rb b/app/controllers/api/public/v1/dossiers_controller.rb index aac2bbbb3..571c00d30 100644 --- a/app/controllers/api/public/v1/dossiers_controller.rb +++ b/app/controllers/api/public/v1/dossiers_controller.rb @@ -9,7 +9,7 @@ class API::Public::V1::DossiersController < API::Public::V1::BaseController state: Dossier.states.fetch(:brouillon), prefilled: true ) - dossier.build_default_individual + dossier.build_default_values if dossier.save dossier.prefill!(PrefillChamps.new(dossier, params.to_unsafe_h).to_a, PrefillIdentity.new(dossier, params.to_unsafe_h).to_h) render json: serialize_dossier(dossier), status: :created diff --git a/app/controllers/users/commencer_controller.rb b/app/controllers/users/commencer_controller.rb index 7ab8c4c99..b47574a21 100644 --- a/app/controllers/users/commencer_controller.rb +++ b/app/controllers/users/commencer_controller.rb @@ -127,7 +127,7 @@ module Users state: Dossier.states.fetch(:brouillon), prefilled: true ) - @prefilled_dossier.build_default_individual + @prefilled_dossier.build_default_values if @prefilled_dossier.save @prefilled_dossier.prefill!(PrefillChamps.new(@prefilled_dossier, params.to_unsafe_h).to_a, PrefillIdentity.new(@prefilled_dossier, params.to_unsafe_h).to_h) end diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index de74eb5d1..ec60e2419 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -385,7 +385,7 @@ module Users user: current_user, state: Dossier.states.fetch(:brouillon) ) - dossier.build_default_individual + dossier.build_default_values dossier.save! DossierMailer.with(dossier:).notify_new_draft.deliver_later diff --git a/app/models/dossier.rb b/app/models/dossier.rb index dc8d97740..c6b5b2714 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -413,7 +413,6 @@ class Dossier < ApplicationRecord delegate :siret, :siren, to: :etablissement, allow_nil: true delegate :france_connected_with_one_identity?, to: :user, allow_nil: true - before_save :build_default_champs_for_new_dossier, if: Proc.new { revision_id_was.nil? && parent_dossier_id.nil? && editing_fork_origin_id.nil? } after_save :send_web_hook @@ -471,29 +470,9 @@ class Dossier < ApplicationRecord end end - def build_default_champs_for_new_dossier - revision.build_champs_public(self).each do |champ| - champs_public << champ - end - revision.build_champs_private(self).each do |champ| - champs_private << champ - end - champs_public.filter { _1.repetition? && _1.mandatory? }.each do |champ| - champ.add_row(updated_by: nil) - end - champs_private.filter(&:repetition?).each do |champ| - champ.add_row(updated_by: nil) - end - end - - def build_default_individual - if procedure.for_individual? && individual.blank? - self.individual = if france_connected_with_one_identity? - Individual.from_france_connect(user.france_connect_informations.first) - else - Individual.new - end - end + def build_default_values + build_default_individual + build_default_champs end def en_construction_ou_instruction? @@ -1176,6 +1155,33 @@ class Dossier < ApplicationRecord private + def build_default_champs + build_default_champs_for(revision.types_de_champ_public) if !champs.any?(&:public?) + build_default_champs_for(revision.types_de_champ_private) if !champs.any?(&:private?) + end + + def build_default_champs_for(types_de_champ) + self.champs << types_de_champ.flat_map do |type_de_champ| + if type_de_champ.repetition? && (type_de_champ.private? || type_de_champ.mandatory?) + row_id = ULID.generate + parent = type_de_champ.build_champ(dossier: self) + [parent] + revision.children_of(type_de_champ).map { _1.build_champ(dossier: self, parent:, row_id:) } + else + type_de_champ.build_champ(dossier: self) + end + end + end + + def build_default_individual + if procedure.for_individual? && individual.blank? + self.individual = if france_connected_with_one_identity? + Individual.from_france_connect(user.france_connect_informations.first) + else + Individual.new + end + end + end + def create_missing_traitemets if en_construction_at.present? && traitements.en_construction.empty? self.traitements.passer_en_construction(processed_at: en_construction_at) diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index 6bd3fe08a..0c8c8dc27 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -37,16 +37,6 @@ class ProcedureRevision < ApplicationRecord serialize :ineligibilite_rules, LogicSerializer - def build_champs_public(dossier) - # reload: it can be out of sync in test if some tdcs are added wihtout using add_tdc - types_de_champ_public.reload.map { _1.build_champ(dossier:) } - end - - def build_champs_private(dossier) - # reload: it can be out of sync in test if some tdcs are added wihtout using add_tdc - types_de_champ_private.reload.map { _1.build_champ(dossier:) } - end - def add_type_de_champ(params) parent_stable_id = params.delete(:parent_stable_id) parent_coordinate, _ = coordinate_and_tdc(parent_stable_id) @@ -172,7 +162,7 @@ class ProcedureRevision < ApplicationRecord .find_or_initialize_by(revision: self, user: user, for_procedure_preview: true, state: Dossier.states.fetch(:brouillon)) if dossier.new_record? - dossier.build_default_individual + dossier.build_default_values dossier.save! end diff --git a/spec/factories/dossier.rb b/spec/factories/dossier.rb index f599a410b..89ad244ea 100644 --- a/spec/factories/dossier.rb +++ b/spec/factories/dossier.rb @@ -11,6 +11,8 @@ FactoryBot.define do individual { association(:individual, :empty, dossier: instance, strategy: :build) if procedure.for_individual? } transient do + populate_champs { false } + populate_annotations { false } for_individual? { false } # For now a dossier must use a `create`d procedure, even if the dossier is only built (and not created). # This is because saving the dossier fails when the procedure has not been saved beforehand @@ -19,6 +21,34 @@ FactoryBot.define do procedure { create(:procedure, :published, :with_type_de_champ, :with_type_de_champ_private, for_individual: for_individual?) } end + after(:create) do |dossier, evaluator| + if evaluator.populate_champs + dossier.revision.types_de_champ_public.each do |type_de_champ| + value = if type_de_champ.simple_drop_down_list? + type_de_champ.drop_down_options.first + elsif type_de_champ.multiple_drop_down_list? + type_de_champ.drop_down_options.first(2).to_json + end + attrs = { stable_id: type_de_champ.stable_id, dossier:, value: }.compact + create(:"champ_do_not_use_#{type_de_champ.type_champ}", **attrs) + end + end + + if evaluator.populate_annotations + dossier.revision.types_de_champ_private.each do |type_de_champ| + value = if type_de_champ.simple_drop_down_list? + type_de_champ.drop_down_options.first + elsif type_de_champ.multiple_drop_down_list? + type_de_champ.drop_down_options.first(2).to_json + end + attrs = { stable_id: type_de_champ.stable_id, dossier:, private: true, value: }.compact + create(:"champ_do_not_use_#{type_de_champ.type_champ}", **attrs) + end + end + + dossier.build_default_values + end + trait :with_entreprise do transient do as_degraded_mode { false } @@ -259,35 +289,11 @@ FactoryBot.define do end trait :with_populated_champs do - after(:create) do |dossier, _evaluator| - dossier.champs_to_destroy.where(private: false).destroy_all - dossier.types_de_champ.each do |type_de_champ| - value = if type_de_champ.simple_drop_down_list? - type_de_champ.drop_down_options.first - elsif type_de_champ.multiple_drop_down_list? - type_de_champ.drop_down_options.first(2).to_json - end - attrs = { stable_id: type_de_champ.stable_id, dossier:, value: }.compact - create(:"champ_do_not_use_#{type_de_champ.type_champ}", **attrs) - end - dossier.reload - end + populate_champs { true } end trait :with_populated_annotations do - after(:create) do |dossier, _evaluator| - dossier.champs_to_destroy.where(private: true).destroy_all - dossier.types_de_champ_private.each do |type_de_champ| - value = if type_de_champ.simple_drop_down_list? - type_de_champ.drop_down_options.first - elsif type_de_champ.multiple_drop_down_list? - type_de_champ.drop_down_options.first(2).to_json - end - attrs = { stable_id: type_de_champ.stable_id, dossier:, private: true, value: }.compact - create(:"champ_do_not_use_#{type_de_champ.type_champ}", **attrs) - end - dossier.reload - end + populate_annotations { true } end trait :prefilled do From ecbf98514769911c0b3d6bb252a4854ed1018141 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Fri, 27 Sep 2024 14:45:06 +0200 Subject: [PATCH 1113/1532] refactor(dossier): champs_* -> project_champs_* --- app/components/dossiers/errors_full_messages_component.rb | 1 - app/dashboards/dossier_dashboard.rb | 4 ++-- app/models/dossier.rb | 4 ---- app/models/dossier_preloader.rb | 4 +--- ...ackfill_cloned_champs_private_piece_justificatives_task.rb | 4 ++-- 5 files changed, 5 insertions(+), 12 deletions(-) diff --git a/app/components/dossiers/errors_full_messages_component.rb b/app/components/dossiers/errors_full_messages_component.rb index 207170e8c..01710ed7f 100644 --- a/app/components/dossiers/errors_full_messages_component.rb +++ b/app/components/dossiers/errors_full_messages_component.rb @@ -10,7 +10,6 @@ class Dossiers::ErrorsFullMessagesComponent < ApplicationComponent def dedup_and_partitioned_errors @dossier.errors.to_enum # ActiveModel::Errors.to_a is an alias to full_messages, we don't want that .to_a # but enum.to_a gives back an array - .uniq { |error| [error.inner_error.base] } # dedup cumulated errors from dossier.champs, dossier.champs_public, dossier.champs_private which run the validator one time per association .map { |error| to_error_descriptor(error) } end diff --git a/app/dashboards/dossier_dashboard.rb b/app/dashboards/dossier_dashboard.rb index 1420dba52..f691a69f4 100644 --- a/app/dashboards/dossier_dashboard.rb +++ b/app/dashboards/dossier_dashboard.rb @@ -23,7 +23,7 @@ class DossierDashboard < Administrate::BaseDashboard en_construction_at: Field::DateTime, en_instruction_at: Field::DateTime, processed_at: Field::DateTime, - champs_public: ChampCollectionField, + project_champs_public: ChampCollectionField, groupe_instructeur: Field::BelongsTo }.freeze @@ -47,7 +47,7 @@ class DossierDashboard < Administrate::BaseDashboard :state, :procedure, :groupe_instructeur, - :champs_public, + :project_champs_public, :created_at, :updated_at, :hidden_by_user_at, diff --git a/app/models/dossier.rb b/app/models/dossier.rb index c6b5b2714..9a980ae60 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -49,8 +49,6 @@ class Dossier < ApplicationRecord # We have to remove champs in a particular order - champs with a reference to a parent have to be # removed first, otherwise we get a foreign key constraint error. has_many :champs_to_destroy, -> { order(:parent_id) }, class_name: 'Champ', inverse_of: false, dependent: :destroy - has_many :champs_public, -> { root.public_only }, class_name: 'Champ', inverse_of: false - has_many :champs_private, -> { root.private_only }, class_name: 'Champ', inverse_of: false has_many :commentaires, inverse_of: :dossier, dependent: :destroy has_many :preloaded_commentaires, -> { includes(:dossier_correction, piece_jointe_attachments: :blob) }, class_name: 'Commentaire', inverse_of: :dossier @@ -142,8 +140,6 @@ class Dossier < ApplicationRecord after_destroy_commit :log_destroy accepts_nested_attributes_for :champs - accepts_nested_attributes_for :champs_public - accepts_nested_attributes_for :champs_private accepts_nested_attributes_for :individual include AASM diff --git a/app/models/dossier_preloader.rb b/app/models/dossier_preloader.rb index 7b32e65f4..6882c23c9 100644 --- a/app/models/dossier_preloader.rb +++ b/app/models/dossier_preloader.rb @@ -39,7 +39,7 @@ class DossierPreloader def revisions(pj_template: false) @revisions ||= ProcedureRevision.where(id: @dossiers.pluck(:revision_id).uniq) - .includes(types_de_champ: pj_template ? { piece_justificative_template_attachment: :blob } : []) + .includes(types_de_champ_public: [], types_de_champ_private: [], types_de_champ: pj_template ? { piece_justificative_template_attachment: :blob } : []) .index_by(&:id) end @@ -80,8 +80,6 @@ class DossierPreloader dossier.association(:revision).target = revision end dossier.association(:champs).target = champs - dossier.association(:champs_public).target = dossier.project_champs_public - dossier.association(:champs_private).target = dossier.project_champs_private # remove once parent_id is deprecated champs_by_parent_id = champs.group_by(&:parent_id) diff --git a/app/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task.rb b/app/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task.rb index ad32ece93..23ad7af96 100644 --- a/app/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task.rb +++ b/app/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task.rb @@ -7,11 +7,11 @@ module Maintenance end def process(cloned_dossier) - cloned_dossier.champs_private + cloned_dossier.project_champs_private .filter { checkable_pj?(_1, cloned_dossier) } .map do |cloned_champ| parent_champ = cloned_dossier.parent_dossier - .champs_private + .project_champs_private .find { _1.stable_id == cloned_champ.stable_id } next if !parent_champ From 7a3926747376c31874bb4c55324e2642528b153f Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Fri, 27 Sep 2024 15:37:11 +0200 Subject: [PATCH 1114/1532] refactor(spec): champs_* -> project_champs_* --- ...nstruction_not_submitted_component_spec.rb | 2 +- .../editable_champ/section_component_spec.rb | 2 +- .../api/v2/graphql_controller_spec.rb | 28 +++---- .../piece_justificative_controller_spec.rb | 2 +- .../controllers/champs/rna_controller_spec.rb | 2 +- .../champs/siret_controller_spec.rb | 2 +- .../experts/avis_controller_spec.rb | 2 +- .../instructeurs/dossiers_controller_spec.rb | 12 +-- spec/controllers/recherche_controller_spec.rb | 16 ++-- .../users/dossiers_controller_spec.rb | 20 ++--- spec/graphql/dossier_spec.rb | 16 ++-- .../dossier_index_search_terms_job_spec.rb | 4 +- spec/lib/recovery/revision_life_cycle_spec.rb | 4 +- spec/models/attestation_template_spec.rb | 4 +- spec/models/champ_spec.rb | 18 ++--- .../concerns/dossier_champs_concern_spec.rb | 4 +- .../concerns/dossier_clone_concern_spec.rb | 39 +++++----- .../dossier_prefillable_concern_spec.rb | 20 ++--- .../concerns/dossier_rebase_concern_spec.rb | 73 ++++++++++--------- .../dossier_searchable_concern_spec.rb | 4 +- .../tags_substitution_concern_spec.rb | 22 +++--- spec/models/dossier_preloader_spec.rb | 14 ++-- spec/models/dossier_spec.rb | 32 +++----- spec/models/export_template_spec.rb | 2 +- spec/models/instructeur_spec.rb | 8 +- spec/models/procedure_presentation_spec.rb | 28 +++---- spec/policies/champ_policy_spec.rb | 4 +- .../dossier_projection_service_spec.rb | 22 +++--- .../pieces_justificatives_service_spec.rb | 14 ++-- .../services/procedure_export_service_spec.rb | 8 +- .../procedure_export_service_zip_spec.rb | 2 +- .../api_particulier/api_particulier_spec.rb | 8 +- spec/system/instructeurs/instruction_spec.rb | 2 +- .../instructeurs/procedure_sort_spec.rb | 2 +- .../routing/rules_full_scenario_spec.rb | 2 +- spec/system/users/brouillon_spec.rb | 2 +- spec/system/users/dropdown_spec.rb | 12 +-- spec/system/users/en_construction_spec.rb | 2 +- spec/system/users/invite_spec.rb | 2 +- spec/system/users/list_dossiers_spec.rb | 4 +- ..._private_piece_justificatives_task_spec.rb | 4 +- .../shared/dossiers/_champs.html.haml_spec.rb | 43 ++++++----- .../dossiers/_demande.html.haml_spec.rb | 4 +- .../shared/dossiers/_edit.html.haml_spec.rb | 2 +- 44 files changed, 254 insertions(+), 265 deletions(-) diff --git a/spec/components/dossiers/en_construction_not_submitted_component_spec.rb b/spec/components/dossiers/en_construction_not_submitted_component_spec.rb index c05377d6b..73122cf9d 100644 --- a/spec/components/dossiers/en_construction_not_submitted_component_spec.rb +++ b/spec/components/dossiers/en_construction_not_submitted_component_spec.rb @@ -21,7 +21,7 @@ RSpec.describe Dossiers::EnConstructionNotSubmittedComponent, type: :component d end context "with changes" do - before { fork.champs_public.first.update(value: "new value") } + before { fork.project_champs_public.first.update(value: "new value") } it "inform user" do expect(subject).to include("Des modifications n’ont pas encore été déposées") diff --git a/spec/components/editable_champ/section_component_spec.rb b/spec/components/editable_champ/section_component_spec.rb index cd821c247..d3398c0be 100644 --- a/spec/components/editable_champ/section_component_spec.rb +++ b/spec/components/editable_champ/section_component_spec.rb @@ -113,7 +113,7 @@ describe EditableChamp::SectionComponent, type: :component do end it 'contains as many text champ as repetition.rows' do - expect(page).to have_selector("fieldset fieldset input[type=text]", count: dossier.champs_public.find(&:repetition?).rows.size) + expect(page).to have_selector("fieldset fieldset input[type=text]", count: dossier.project_champs_public.find(&:repetition?).rows.size) end end diff --git a/spec/controllers/api/v2/graphql_controller_spec.rb b/spec/controllers/api/v2/graphql_controller_spec.rb index 35fbdbe18..6ceef733d 100644 --- a/spec/controllers/api/v2/graphql_controller_spec.rb +++ b/spec/controllers/api/v2/graphql_controller_spec.rb @@ -515,7 +515,7 @@ describe API::V2::GraphqlController do avis: [] ) - expected_champs = dossier.champs_public.map do |champ| + expected_champs = dossier.project_champs_public.map do |champ| { id: champ.to_typed_id, label: champ.libelle, @@ -546,7 +546,7 @@ describe API::V2::GraphqlController do end expect(gql_data[:dossier][:messages]).to match_array(expected_messages) - expect(gql_data[:dossier][:champs][0][:id]).to eq(dossier.champs_public[0].type_de_champ.to_typed_id) + expect(gql_data[:dossier][:champs][0][:id]).to eq(dossier.project_champs_public[0].type_de_champ.to_typed_id) end end @@ -687,8 +687,8 @@ describe API::V2::GraphqlController do context "champs" do let(:procedure) { create(:procedure, :published, :for_individual, administrateurs: [admin], types_de_champ_public: [{ type: :date }, { type: :datetime }]) } let(:dossier) { create(:dossier, :en_construction, procedure: procedure) } - let(:champ_date) { dossier.champs_public.first } - let(:champ_datetime) { dossier.champs_public.second } + let(:champ_date) { dossier.project_champs_public.first } + let(:champ_datetime) { dossier.project_champs_public.second } before do champ_date.update(value: '2019-07-10') @@ -1243,7 +1243,7 @@ describe API::V2::GraphqlController do "mutation { dossierModifierAnnotationText(input: { dossierId: \"#{dossier.to_typed_id}\", - annotationId: \"#{dossier.champs_private.find { |c| c.type == 'Champs::TextChamp' }.to_typed_id}\", + annotationId: \"#{dossier.project_champs_private.find { |c| c.type == 'Champs::TextChamp' }.to_typed_id}\", instructeurId: \"#{instructeur.to_typed_id}\", value: \"hello\" }) { @@ -1280,7 +1280,7 @@ describe API::V2::GraphqlController do "mutation { dossierModifierAnnotationCheckbox(input: { dossierId: \"#{dossier.to_typed_id}\", - annotationId: \"#{dossier.champs_private.find { |c| c.type_champ == 'checkbox' }.to_typed_id}\", + annotationId: \"#{dossier.project_champs_private.find { |c| c.type_champ == 'checkbox' }.to_typed_id}\", instructeurId: \"#{instructeur.to_typed_id}\", value: #{value} }) { @@ -1331,7 +1331,7 @@ describe API::V2::GraphqlController do "mutation { dossierModifierAnnotationCheckbox(input: { dossierId: \"#{dossier.to_typed_id}\", - annotationId: \"#{dossier.champs_private.find { |c| c.type_champ == 'yes_no' }.to_typed_id}\", + annotationId: \"#{dossier.project_champs_private.find { |c| c.type_champ == 'yes_no' }.to_typed_id}\", instructeurId: \"#{instructeur.to_typed_id}\", value: #{value} }) { @@ -1381,7 +1381,7 @@ describe API::V2::GraphqlController do "mutation { dossierModifierAnnotationDate(input: { dossierId: \"#{dossier.to_typed_id}\", - annotationId: \"#{dossier.champs_private.find { |c| c.type_champ == 'date' }.to_typed_id}\", + annotationId: \"#{dossier.project_champs_private.find { |c| c.type_champ == 'date' }.to_typed_id}\", instructeurId: \"#{instructeur.to_typed_id}\", value: \"#{1.day.from_now.to_date.iso8601}\" }) { @@ -1401,7 +1401,7 @@ describe API::V2::GraphqlController do expect(gql_data).to eq(dossierModifierAnnotationDate: { annotation: { - stringValue: dossier.reload.champs_private.find { |c| c.type_champ == 'date' }.to_s + stringValue: dossier.reload.project_champs_private.find { |c| c.type_champ == 'date' }.to_s }, errors: nil }) @@ -1416,7 +1416,7 @@ describe API::V2::GraphqlController do "mutation { dossierModifierAnnotationDatetime(input: { dossierId: \"#{dossier.to_typed_id}\", - annotationId: \"#{dossier.champs_private.find { |c| c.type_champ == 'datetime' }.to_typed_id}\", + annotationId: \"#{dossier.project_champs_private.find { |c| c.type_champ == 'datetime' }.to_typed_id}\", instructeurId: \"#{instructeur.to_typed_id}\", value: \"#{1.day.from_now.iso8601}\" }) { @@ -1436,7 +1436,7 @@ describe API::V2::GraphqlController do expect(gql_data).to eq(dossierModifierAnnotationDatetime: { annotation: { - stringValue: dossier.reload.champs_private.find { |c| c.type_champ == 'datetime' }.to_s + stringValue: dossier.reload.project_champs_private.find { |c| c.type_champ == 'datetime' }.to_s }, errors: nil }) @@ -1451,7 +1451,7 @@ describe API::V2::GraphqlController do "mutation { dossierModifierAnnotationDropDownList(input: { dossierId: \"#{dossier.to_typed_id}\", - annotationId: \"#{dossier.champs_private.find { |c| c.type_champ == 'drop_down_list' }.to_typed_id}\", + annotationId: \"#{dossier.project_champs_private.find { |c| c.type_champ == 'drop_down_list' }.to_typed_id}\", instructeurId: \"#{instructeur.to_typed_id}\", value: \"#{value}\" }) { @@ -1472,7 +1472,7 @@ describe API::V2::GraphqlController do expect(gql_data).to eq(dossierModifierAnnotationDropDownList: { annotation: { - stringValue: dossier.reload.champs_private.find { |c| c.type_champ == 'drop_down_list' }.to_s + stringValue: dossier.reload.project_champs_private.find { |c| c.type_champ == 'drop_down_list' }.to_s }, errors: nil }) @@ -1497,7 +1497,7 @@ describe API::V2::GraphqlController do "mutation { dossierModifierAnnotationIntegerNumber(input: { dossierId: \"#{dossier.to_typed_id}\", - annotationId: \"#{dossier.champs_private.find { |c| c.type_champ == 'integer_number' }.to_typed_id}\", + annotationId: \"#{dossier.project_champs_private.find { |c| c.type_champ == 'integer_number' }.to_typed_id}\", instructeurId: \"#{instructeur.to_typed_id}\", value: 42 }) { diff --git a/spec/controllers/champs/piece_justificative_controller_spec.rb b/spec/controllers/champs/piece_justificative_controller_spec.rb index fb3f4b905..90a43aba6 100644 --- a/spec/controllers/champs/piece_justificative_controller_spec.rb +++ b/spec/controllers/champs/piece_justificative_controller_spec.rb @@ -4,7 +4,7 @@ describe Champs::PieceJustificativeController, type: :controller do let(:user) { create(:user) } let(:procedure) { create(:procedure, :published, types_de_champ_public: [{ type: :piece_justificative }]) } let(:dossier) { create(:dossier, user: user, procedure: procedure) } - let(:champ) { dossier.champs_public.first } + let(:champ) { dossier.project_champs_public.first } describe '#update' do render_views diff --git a/spec/controllers/champs/rna_controller_spec.rb b/spec/controllers/champs/rna_controller_spec.rb index 67fead4fb..a4a09377b 100644 --- a/spec/controllers/champs/rna_controller_spec.rb +++ b/spec/controllers/champs/rna_controller_spec.rb @@ -6,7 +6,7 @@ describe Champs::RNAController, type: :controller do describe '#show' do let(:dossier) { create(:dossier, user: user, procedure: procedure) } - let(:champ) { dossier.champs_public.first } + let(:champ) { dossier.project_champs_public.first } let(:champs_public_attributes) do champ_attributes = {} diff --git a/spec/controllers/champs/siret_controller_spec.rb b/spec/controllers/champs/siret_controller_spec.rb index c0b134955..fdd483ac1 100644 --- a/spec/controllers/champs/siret_controller_spec.rb +++ b/spec/controllers/champs/siret_controller_spec.rb @@ -6,7 +6,7 @@ describe Champs::SiretController, type: :controller do describe '#show' do let(:dossier) { create(:dossier, user: user, procedure: procedure) } - let(:champ) { dossier.champs_public.first } + let(:champ) { dossier.project_champs_public.first } let(:champs_public_attributes) do champ_attributes = {} diff --git a/spec/controllers/experts/avis_controller_spec.rb b/spec/controllers/experts/avis_controller_spec.rb index f1435d431..ed84e994f 100644 --- a/spec/controllers/experts/avis_controller_spec.rb +++ b/spec/controllers/experts/avis_controller_spec.rb @@ -483,7 +483,7 @@ describe Experts::AvisController, type: :controller do context 'when the expert also shares the linked dossiers' do context 'and the expert can access the linked dossiers' do let(:created_avis) { create(:avis, dossier: dossier, claimant: claimant, email: "toto3@gmail.com") } - let(:linked_dossier) { Dossier.find_by(id: dossier.reload.champs_public.filter(&:dossier_link?).filter_map(&:value)) } + let(:linked_dossier) { Dossier.find_by(id: dossier.reload.project_champs_public.filter(&:dossier_link?).filter_map(&:value)) } let(:linked_avis) { create(:avis, dossier: linked_dossier, claimant: claimant) } let(:invite_linked_dossiers) { true } diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index c9227d2ed..ee8053be5 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -978,11 +978,11 @@ describe Instructeurs::DossiersController, type: :controller do let(:another_instructeur) { create(:instructeur) } let(:now) { Time.zone.parse('01/01/2100') } - let(:champ_multiple_drop_down_list) { dossier.champs_private.first } - let(:champ_linked_drop_down_list) { dossier.champs_private.second } - let(:champ_datetime) { dossier.champs_private.third } - let(:champ_repetition) { dossier.champs_private.fourth } - let(:champ_drop_down_list) { dossier.champs_private.fifth } + let(:champ_multiple_drop_down_list) { dossier.project_champs_private.first } + let(:champ_linked_drop_down_list) { dossier.project_champs_private.second } + let(:champ_datetime) { dossier.project_champs_private.third } + let(:champ_repetition) { dossier.project_champs_private.fourth } + let(:champ_drop_down_list) { dossier.project_champs_private.fifth } context 'when no invalid champs_public' do context "with new values for champs_private" do @@ -1106,7 +1106,7 @@ describe Instructeurs::DossiersController, type: :controller do ] end - let(:champ_decimal_number) { dossier.champs_public.first } + let(:champ_decimal_number) { dossier.project_champs_public.first } let(:params) do { diff --git a/spec/controllers/recherche_controller_spec.rb b/spec/controllers/recherche_controller_spec.rb index 0c155fa48..ec5c6910a 100644 --- a/spec/controllers/recherche_controller_spec.rb +++ b/spec/controllers/recherche_controller_spec.rb @@ -18,16 +18,16 @@ describe RechercheController, type: :controller do before do instructeur.assign_to_procedure(dossier.procedure) - dossier.champs_public[0].value = "Name of district A" - dossier.champs_public[1].value = "Name of city A" - dossier.champs_private[0].value = "Dossier A is complete" - dossier.champs_private[1].value = "Dossier A is valid" + dossier.project_champs_public[0].value = "Name of district A" + dossier.project_champs_public[1].value = "Name of city A" + dossier.project_champs_private[0].value = "Dossier A is complete" + dossier.project_champs_private[1].value = "Dossier A is valid" dossier.save! - dossier_with_expert.champs_public[0].value = "Name of district B" - dossier_with_expert.champs_public[1].value = "name of city B" - dossier_with_expert.champs_private[0].value = "Dossier B is incomplete" - dossier_with_expert.champs_private[1].value = "Dossier B is invalid" + dossier_with_expert.project_champs_public[0].value = "Name of district B" + dossier_with_expert.project_champs_public[1].value = "name of city B" + dossier_with_expert.project_champs_private[0].value = "Dossier B is incomplete" + dossier_with_expert.project_champs_private[1].value = "Dossier B is invalid" dossier_with_expert.save! perform_enqueued_jobs(only: DossierIndexSearchTermsJob) diff --git a/spec/controllers/users/dossiers_controller_spec.rb b/spec/controllers/users/dossiers_controller_spec.rb index 2e9bfadb0..68fe7a830 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -412,7 +412,7 @@ describe Users::DossiersController, type: :controller do let(:procedure) { create(:procedure, :published, types_de_champ_public:) } let(:types_de_champ_public) { [{ type: :text, mandatory: false }] } let!(:dossier) { create(:dossier, user:, procedure:) } - let(:first_champ) { dossier.champs_public.first } + let(:first_champ) { dossier.project_champs_public.first } let(:anchor_to_first_champ) { controller.helpers.link_to first_champ.libelle, brouillon_dossier_path(anchor: first_champ.labelledby_id), class: 'error-anchor' } let(:value) { 'beautiful value' } let(:now) { Time.zone.parse('01/01/2100') } @@ -529,7 +529,7 @@ describe Users::DossiersController, type: :controller do let(:procedure) { create(:procedure, :published, types_de_champ_public:) } let(:types_de_champ_public) { [{ type: :text, mandatory: false }] } let(:dossier) { create(:dossier, :en_construction, procedure:, user:) } - let(:first_champ) { dossier.owner_editing_fork.champs_public.first } + let(:first_champ) { dossier.owner_editing_fork.project_champs_public.first } let(:anchor_to_first_champ) { controller.helpers.link_to I18n.t('views.users.dossiers.fix_champ'), modifier_dossier_path(anchor: first_champ.labelledby_id), class: 'error-anchor' } let(:value) { 'beautiful value' } let(:now) { Time.zone.parse('01/01/2100') } @@ -677,8 +677,8 @@ describe Users::DossiersController, type: :controller do let(:procedure) { create(:procedure, :published, types_de_champ_public:) } let(:types_de_champ_public) { [{}, { type: :piece_justificative, mandatory: false }] } let(:dossier) { create(:dossier, user:, procedure:) } - let(:first_champ) { dossier.champs_public.first } - let(:piece_justificative_champ) { dossier.champs_public.last } + let(:first_champ) { dossier.project_champs_public.first } + let(:piece_justificative_champ) { dossier.project_champs_public.last } let(:value) { 'beautiful value' } let(:file) { fixture_file_upload('spec/fixtures/files/piece_justificative_0.pdf', 'application/pdf') } let(:now) { Time.zone.parse('01/01/2100') } @@ -773,8 +773,8 @@ describe Users::DossiersController, type: :controller do render_views let(:types_de_champ_public) { [{ type: :text }, { type: :integer_number }] } - let(:text_champ) { dossier.champs_public.first } - let(:number_champ) { dossier.champs_public.last } + let(:text_champ) { dossier.project_champs_public.first } + let(:number_champ) { dossier.project_champs_public.last } let(:submit_payload) do { id: dossier.id, @@ -837,9 +837,9 @@ describe Users::DossiersController, type: :controller do let(:procedure) { create(:procedure, :published, types_de_champ_public: [{}, { type: :piece_justificative }]) } let!(:dossier) { create(:dossier, :en_construction, user:, procedure:) } - let(:first_champ) { dossier.champs_public.first } + let(:first_champ) { dossier.project_champs_public.first } let(:anchor_to_first_champ) { controller.helpers.link_to I18n.t('views.users.dossiers.fix_champ'), brouillon_dossier_path(anchor: first_champ.labelledby_id), class: 'error-anchor' } - let(:piece_justificative_champ) { dossier.champs_public.last } + let(:piece_justificative_champ) { dossier.project_champs_public.last } let(:value) { 'beautiful value' } let(:file) { fixture_file_upload('spec/fixtures/files/piece_justificative_0.pdf', 'application/pdf') } let(:now) { Time.zone.parse('01/01/2100') } @@ -962,7 +962,7 @@ describe Users::DossiersController, type: :controller do before do first_champ.type_de_champ.update!(type_champ: :iban, mandatory: true, libelle: 'l') - dossier.champs_public.first.becomes!(Champs::IbanChamp).save! + dossier.project_champs_public.first.becomes!(Champs::IbanChamp).save! subject end @@ -1000,7 +1000,7 @@ describe Users::DossiersController, type: :controller do context 'when the champ is a phone number' do let(:procedure) { create(:procedure, :published, types_de_champ_public: [{ type: :phone }]) } let!(:dossier) { create(:dossier, :en_construction, user:, procedure:) } - let(:first_champ) { dossier.champs_public.first } + let(:first_champ) { dossier.project_champs_public.first } let(:now) { Time.zone.parse('01/01/2100') } let(:submit_payload) do diff --git a/spec/graphql/dossier_spec.rb b/spec/graphql/dossier_spec.rb index 01599b791..2c9e2d71a 100644 --- a/spec/graphql/dossier_spec.rb +++ b/spec/graphql/dossier_spec.rb @@ -71,8 +71,8 @@ RSpec.describe Types::DossierType, type: :graphql do end before do - dossier.champs_public.find { _1.type_champ == TypeDeChamp.type_champs.fetch(:address) }.update(data: address) - dossier.champs_public.find { _1.type_champ == TypeDeChamp.type_champs.fetch(:rna) }.update(data: rna) + dossier.project_champs_public.find { _1.type_champ == TypeDeChamp.type_champs.fetch(:address) }.update(data: address) + dossier.project_champs_public.find { _1.type_champ == TypeDeChamp.type_champs.fetch(:rna) }.update(data: rna) end it do @@ -82,7 +82,7 @@ RSpec.describe Types::DossierType, type: :graphql do expect(data[:dossier][:champs][1][:commune][:code]).to eq('75119') expect(data[:dossier][:champs][1][:commune][:postalCode]).to eq('75019') expect(data[:dossier][:champs][1][:departement][:code]).to eq('75') - expect(data[:dossier][:champs][2][:etablissement][:siret]).to eq dossier.champs_public[2].etablissement.siret + expect(data[:dossier][:champs][2][:etablissement][:siret]).to eq dossier.project_champs_public[2].etablissement.siret expect(data[:dossier][:champs][0][:id]).to eq(data[:dossier][:revision][:champDescriptors][0][:id]) expect(data[:dossier][:champs][1][:address][:cityName]).to eq('Paris 19e Arrondissement') @@ -99,7 +99,7 @@ RSpec.describe Types::DossierType, type: :graphql do end context 'when etablissement is in degraded mode' do - let(:etablissement) { dossier.champs_public.third.etablissement } + let(:etablissement) { dossier.project_champs_public.third.etablissement } before do etablissement.update(adresse: nil) end @@ -128,7 +128,7 @@ RSpec.describe Types::DossierType, type: :graphql do let(:dossier) { create(:dossier, :en_construction, :with_populated_champs, procedure:) } let(:query) { DOSSIER_WITH_SELECTED_CHAMP_QUERY } let(:variables) { { number: dossier.id, id: champ.to_typed_id } } - let(:champ) { dossier.champs_public.last } + let(:champ) { dossier.project_champs_public.last } context 'when champ exists' do it { @@ -155,7 +155,7 @@ RSpec.describe Types::DossierType, type: :graphql do let(:checkbox_value) { 'true' } before do - dossier.champs_public.first.update(value: checkbox_value) + dossier.project_champs_public.first.update(value: checkbox_value) end context 'when checkbox is true' do @@ -204,7 +204,7 @@ RSpec.describe Types::DossierType, type: :graphql do let(:variables) { { number: dossier.id } } before do - dossier.champs_public.first.update(value: linked_dossier.id) + dossier.project_champs_public.first.update(value: linked_dossier.id) end context 'en_construction' do @@ -233,7 +233,7 @@ RSpec.describe Types::DossierType, type: :graphql do let(:variables) { { number: dossier.id } } let(:rows) do - dossier.champs_public.first.rows.map do |champs| + dossier.project_champs_public.first.rows.map do |champs| { champs: champs.map { { id: _1.to_typed_id } } } end end diff --git a/spec/jobs/dossier_index_search_terms_job_spec.rb b/spec/jobs/dossier_index_search_terms_job_spec.rb index 872cf1a8a..523ac8cb9 100644 --- a/spec/jobs/dossier_index_search_terms_job_spec.rb +++ b/spec/jobs/dossier_index_search_terms_job_spec.rb @@ -10,8 +10,8 @@ RSpec.describe DossierIndexSearchTermsJob, type: :job do subject(:perform_job) { described_class.perform_now(dossier.reload) } before do - dossier.champs_public.first.update_column(:value, "un nouveau champ") - dossier.champs_private.first.update_column(:value, "private champ") + dossier.project_champs_public.first.update_column(:value, "un nouveau champ") + dossier.project_champs_private.first.update_column(:value, "private champ") end it "update search terms columns" do diff --git a/spec/lib/recovery/revision_life_cycle_spec.rb b/spec/lib/recovery/revision_life_cycle_spec.rb index f4227bfb8..9300d2740 100644 --- a/spec/lib/recovery/revision_life_cycle_spec.rb +++ b/spec/lib/recovery/revision_life_cycle_spec.rb @@ -29,11 +29,11 @@ describe 'Recovery::Revision::LifeCycle' do it do expect { DossierPreloader.load_one(dossier) }.not_to raise_error(ArgumentError) - expect(dossier.champs_public.size).to eq(1) + expect(dossier.project_champs_public.size).to eq(1) expect(dossier.champs.size).to eq(2) importer.load expect { DossierPreloader.load_one(dossier) }.not_to raise_error(ArgumentError) - expect(dossier.champs_public.size).to eq(2) + expect(dossier.project_champs_public.size).to eq(2) end end diff --git a/spec/models/attestation_template_spec.rb b/spec/models/attestation_template_spec.rb index 4343a9746..e998b54c1 100644 --- a/spec/models/attestation_template_spec.rb +++ b/spec/models/attestation_template_spec.rb @@ -75,11 +75,11 @@ describe AttestationTemplate, type: :model do end before do - dossier.champs_public + dossier.project_champs_public .find { |champ| champ.libelle == 'libelleA' } .update(value: 'libelle1') - dossier.champs_public + dossier.project_champs_public .find { |champ| champ.libelle == 'libelleB' } .update(value: 'libelle2') end diff --git a/spec/models/champ_spec.rb b/spec/models/champ_spec.rb index 6375ee4dd..016c7f2e0 100644 --- a/spec/models/champ_spec.rb +++ b/spec/models/champ_spec.rb @@ -86,8 +86,8 @@ describe Champ do let(:dossier) { create(:dossier) } it 'partition public and private' do - expect(dossier.champs_public.count).to eq(1) - expect(dossier.champs_private.count).to eq(1) + expect(dossier.project_champs_public.count).to eq(1) + expect(dossier.project_champs_private.count).to eq(1) end end @@ -97,7 +97,7 @@ describe Champ do context 'when a procedure has 2 revisions' do it 'does not duplicate the champs' do - expect(dossier.champs_public.count).to eq(1) + expect(dossier.project_champs_public.count).to eq(1) expect(procedure.revisions.count).to eq(2) end end @@ -111,7 +111,7 @@ describe Champ do before { procedure.publish } it 'does not duplicate the champs private' do - expect(dossier.champs_private.count).to eq(1) + expect(dossier.project_champs_private.count).to eq(1) expect(procedure.revisions.count).to eq(2) end end @@ -122,12 +122,12 @@ describe Champ do create(:procedure, types_de_champ_public: [{}, { type: :header_section }, { type: :repetition, mandatory: true, children: [{ type: :header_section }] }], types_de_champ_private: [{}, { type: :header_section }]) end let(:dossier) { create(:dossier, procedure: procedure) } - let(:public_champ) { dossier.champs_public.first } - let(:private_champ) { dossier.champs_private.first } - let(:champ_in_repetition) { dossier.champs_public.find(&:repetition?).champs.first } + let(:public_champ) { dossier.project_champs_public.first } + let(:private_champ) { dossier.project_champs_private.first } + let(:champ_in_repetition) { dossier.project_champs_public.find(&:repetition?).champs.first } let(:standalone_champ) { build(:champ, type_de_champ: build(:type_de_champ), dossier: build(:dossier)) } - let(:public_sections) { dossier.champs_public.filter(&:header_section?) } - let(:private_sections) { dossier.champs_private.filter(&:header_section?) } + let(:public_sections) { dossier.project_champs_public.filter(&:header_section?) } + let(:private_sections) { dossier.project_champs_private.filter(&:header_section?) } let(:sections_in_repetition) { dossier.champs.filter(&:child?).filter(&:header_section?) } it 'returns the sibling sections of a champ' do diff --git a/spec/models/concerns/dossier_champs_concern_spec.rb b/spec/models/concerns/dossier_champs_concern_spec.rb index a4168faab..e4177db5c 100644 --- a/spec/models/concerns/dossier_champs_concern_spec.rb +++ b/spec/models/concerns/dossier_champs_concern_spec.rb @@ -61,7 +61,7 @@ RSpec.describe DossierChampsConcern do end context "missing champ" do - before { dossier; Champs::TextChamp.destroy_all } + before { dossier.champs.where(type: 'Champs::TextChamp').destroy_all; dossier.reload } it { expect(subject.new_record?).to be_truthy @@ -94,7 +94,7 @@ RSpec.describe DossierChampsConcern do it { expect(subject.persisted?).to be_truthy } context "missing champ" do - before { dossier; Champs::TextChamp.destroy_all } + before { dossier.champs.where(type: 'Champs::TextChamp').destroy_all; dossier.reload } it { expect(subject.new_record?).to be_truthy diff --git a/spec/models/concerns/dossier_clone_concern_spec.rb b/spec/models/concerns/dossier_clone_concern_spec.rb index 7856bc140..e7e5f4869 100644 --- a/spec/models/concerns/dossier_clone_concern_spec.rb +++ b/spec/models/concerns/dossier_clone_concern_spec.rb @@ -120,15 +120,15 @@ RSpec.describe DossierCloneConcern do context 'public are duplicated' do it do - expect(new_dossier.champs_public.count).to eq(dossier.champs_public.count) - expect(new_dossier.champs_public.ids).not_to eq(dossier.champs_public.ids) + expect(new_dossier.project_champs_public.count).to eq(dossier.project_champs_public.count) + expect(new_dossier.project_champs_public.map(&:id)).not_to eq(dossier.project_champs_public.map(&:id)) end it 'keeps champs.values' do - original_first_champ = dossier.champs_public.first + original_first_champ = dossier.project_champs_public.first original_first_champ.update!(value: 'kthxbye') - expect(new_dossier.champs_public.first.value).to eq(original_first_champ.value) + expect(new_dossier.project_champs_public.first.value).to eq(original_first_champ.value) end context 'for Champs::Repetition with rows, original_champ.repetition and rows are duped' do @@ -192,26 +192,26 @@ RSpec.describe DossierCloneConcern do let(:types_de_champ_private) { [{}] } it 'reset champs private values' do - expect(new_dossier.champs_private.count).to eq(dossier.champs_private.count) - expect(new_dossier.champs_private.ids).not_to eq(dossier.champs_private.ids) - original_first_champs_private = dossier.champs_private.first + expect(new_dossier.project_champs_private.count).to eq(dossier.project_champs_private.count) + expect(new_dossier.project_champs_private.map(&:id)).not_to eq(dossier.project_champs_private.map(&:id)) + original_first_champs_private = dossier.project_champs_private.first original_first_champs_private.update!(value: 'kthxbye') - expect(new_dossier.champs_private.first.value).not_to eq(original_first_champs_private.value) - expect(new_dossier.champs_private.first.value).to eq(nil) + expect(new_dossier.project_champs_private.first.value).not_to eq(original_first_champs_private.value) + expect(new_dossier.project_champs_private.first.value).to eq(nil) end end end context "as a fork" do let(:new_dossier) { dossier.clone(fork: true) } - before { dossier.champs_public.reload } # we compare timestamps so we have to get the precision limit from the db } + before { dossier.project_champs_public } # we compare timestamps so we have to get the precision limit from the db } it do expect(new_dossier.editing_fork_origin).to eq(dossier) - expect(new_dossier.champs_public[0].id).not_to eq(dossier.champs_public[0].id) - expect(new_dossier.champs_public[0].created_at).to eq(dossier.champs_public[0].created_at) - expect(new_dossier.champs_public[0].updated_at).to eq(dossier.champs_public[0].updated_at) + expect(new_dossier.project_champs_public[0].id).not_to eq(dossier.project_champs_public[0].id) + expect(new_dossier.project_champs_public[0].created_at).to eq(dossier.project_champs_public[0].created_at) + expect(new_dossier.project_champs_public[0].updated_at).to eq(dossier.project_champs_public[0].updated_at) end context "piece justificative champ" do @@ -343,11 +343,11 @@ RSpec.describe DossierCloneConcern do dossier.debounce_index_search_terms_flag.remove end - it { expect { subject }.to change { dossier.reload.champs.size }.by(0) } - it { expect { subject }.not_to change { dossier.reload.champs.order(:created_at).reject { _1.stable_id.in?([99, 994]) }.map(&:value) } } + it { expect { subject }.to change { dossier.champs.size }.by(0) } + it { expect { subject }.not_to change { dossier.champs.order(:created_at).reject { _1.stable_id.in?([99, 994]) }.map(&:value) } } it { expect { subject }.to have_enqueued_job(DossierIndexSearchTermsJob).with(dossier) } - it { expect { subject }.to change { dossier.reload.champs.find { _1.stable_id == 99 }.value }.from('old value').to('new value') } - it { expect { subject }.to change { dossier.reload.champs.find { _1.stable_id == 994 }.value }.from('old value').to('new value in repetition') } + it { expect { subject }.to change { dossier.champs.find { _1.stable_id == 99 }.value }.from('old value').to('new value') } + it { expect { subject }.to change { dossier.champs.find { _1.stable_id == 994 }.value }.from('old value').to('new value in repetition') } it 'fork is hidden after merge' do subject @@ -386,11 +386,10 @@ RSpec.describe DossierCloneConcern do added_repetition_champ.update(value: "new value in repetition champ") dossier.reload super() - dossier.reload } - it { expect { subject }.to change { dossier.reload.champs.size }.by(1) } - it { expect { subject }.to change { dossier.reload.champs.order(:created_at).map(&:to_s) }.from(['old value', 'old value', 'Non', 'old value', 'old value']).to(['new value for updated champ', 'Non', 'old value', 'old value', 'new value for added champ', 'new value in repetition champ']) } + it { expect { subject }.to change { dossier.champs.size }.by(1) } + it { expect { subject }.to change { dossier.champs.order(:created_at).map(&:to_s) }.from(['old value', 'old value', 'Non', 'old value', 'old value']).to(['new value for updated champ', 'Non', 'old value', 'old value', 'new value for added champ', 'new value in repetition champ']) } it "dossier after merge should be on last published revision" do expect(dossier.revision_id).to eq(procedure.revisions.first.id) diff --git a/spec/models/concerns/dossier_prefillable_concern_spec.rb b/spec/models/concerns/dossier_prefillable_concern_spec.rb index f3b1fe2bb..2d0866fec 100644 --- a/spec/models/concerns/dossier_prefillable_concern_spec.rb +++ b/spec/models/concerns/dossier_prefillable_concern_spec.rb @@ -43,7 +43,7 @@ RSpec.describe DossierPrefillableConcern do end it "doesn't change champs_public" do - expect { fill }.not_to change { dossier.champs_public.to_a } + expect { fill }.not_to change { dossier.project_champs_public.to_a } end end @@ -71,12 +71,12 @@ RSpec.describe DossierPrefillableConcern do it "updates the champs with the new values and mark them as prefilled" do fill - expect(dossier.champs_public.first.value).to eq(value_1) - expect(dossier.champs_public.first.prefilled).to eq(true) - expect(dossier.champs_public.last.value).to eq(value_2) - expect(dossier.champs_public.last.prefilled).to eq(true) - expect(dossier.champs_private.first.value).to eq(value_3) - expect(dossier.champs_private.first.prefilled).to eq(true) + expect(dossier.project_champs_public.first.value).to eq(value_1) + expect(dossier.project_champs_public.first.prefilled).to eq(true) + expect(dossier.project_champs_public.last.value).to eq(value_2) + expect(dossier.project_champs_public.last.prefilled).to eq(true) + expect(dossier.project_champs_private.first.value).to eq(value_3) + expect(dossier.project_champs_private.first.prefilled).to eq(true) end end @@ -91,11 +91,11 @@ RSpec.describe DossierPrefillableConcern do it_behaves_like 'a dossier marked as prefilled' it "still updates the champ" do - expect { fill }.to change { dossier.champs_public.first.value }.from(nil).to(value) + expect { fill }.to change { dossier.project_champs_public.first.value }.from(nil).to(value) end it "still marks it as prefilled" do - expect { fill }.to change { dossier.champs_public.first.prefilled }.from(nil).to(true) + expect { fill }.to change { dossier.project_champs_public.first.prefilled }.from(nil).to(true) end end end @@ -115,7 +115,7 @@ RSpec.describe DossierPrefillableConcern do it "updates the champs with the new values and mark them as prefilled" do fill - expect(dossier.champs_public.first.value).to eq(value_1) + expect(dossier.project_champs_public.first.value).to eq(value_1) expect(dossier.individual).to be_nil # Fix #9486 end diff --git a/spec/models/concerns/dossier_rebase_concern_spec.rb b/spec/models/concerns/dossier_rebase_concern_spec.rb index d201f8191..5b4c1704a 100644 --- a/spec/models/concerns/dossier_rebase_concern_spec.rb +++ b/spec/models/concerns/dossier_rebase_concern_spec.rb @@ -293,19 +293,19 @@ describe DossierRebaseConcern do let(:datetime_type_de_champ) { types_de_champ.find { _1.stable_id == 103 } } let(:yes_no_type_de_champ) { types_de_champ.find { _1.stable_id == 104 } } - let(:text_champ) { dossier.champs_public.find { _1.stable_id == 1 } } - let(:repetition_champ) { dossier.champs_public.find { _1.stable_id == 101 } } - let(:datetime_champ) { dossier.champs_public.find { _1.stable_id == 103 } } + let(:text_champ) { dossier.project_champs_public.find { _1.stable_id == 1 } } + let(:repetition_champ) { dossier.project_champs_public.find { _1.stable_id == 101 } } + let(:datetime_champ) { dossier.project_champs_public.find { _1.stable_id == 103 } } - let(:rebased_text_champ) { dossier.champs_public.find { _1.stable_id == 1 } } - let(:rebased_repetition_champ) { dossier.champs_public.find { _1.stable_id == 101 } } - let(:rebased_datetime_champ) { dossier.champs_public.find { _1.stable_id == 103 } } - let(:rebased_number_champ) { dossier.champs_public.find { _1.stable_id == 105 } } + let(:rebased_text_champ) { dossier.project_champs_public.find { _1.stable_id == 1 } } + let(:rebased_repetition_champ) { dossier.project_champs_public.find { _1.stable_id == 101 } } + let(:rebased_datetime_champ) { dossier.project_champs_public.find { _1.stable_id == 103 } } + let(:rebased_number_champ) { dossier.project_champs_public.find { _1.stable_id == 105 } } - let(:rebased_new_repetition_champ) { dossier.champs_public.find { _1.libelle == "une autre repetition" } } + let(:rebased_new_repetition_champ) { dossier.project_champs_public.find { _1.libelle == "une autre repetition" } } let(:private_text_type_de_champ) { types_de_champ.find { _1.stable_id == 11 } } - let(:rebased_private_text_champ) { dossier.champs_private.find { _1.stable_id == 11 } } + let(:rebased_private_text_champ) { dossier.project_champs_private.find { _1.stable_id == 11 } } context "when revision is published" do before do @@ -345,16 +345,17 @@ describe DossierRebaseConcern do datetime_champ.update(value: Time.zone.now.to_s) text_champ.update(value: 'bonjour') + text_champ.type_de_champ # Add two rows then remove previous to last row in order to create a "hole" in the sequence repetition_champ.add_row(updated_by: 'test') repetition_champ.add_row(updated_by: 'test') - repetition_champ.champs.where(row_id: repetition_champ.rows[-2].first.row_id).destroy_all - repetition_champ.reload + repetition_champ.champs.where(row_id: repetition_champ.row_ids[-2]).destroy_all + dossier.reload end it "updates the brouillon champs with the latest revision changes" do expect(dossier.revision).to eq(procedure.published_revision) - expect(dossier.champs_public.size).to eq(5) + expect(dossier.project_champs_public.size).to eq(5) expect(dossier.champs.count(&:public?)).to eq(7) expect(repetition_champ.rows.size).to eq(2) expect(repetition_champ.rows[0].size).to eq(1) @@ -367,7 +368,7 @@ describe DossierRebaseConcern do expect(procedure.revisions.size).to eq(3) expect(dossier.revision).to eq(procedure.published_revision) - expect(dossier.champs_public.size).to eq(7) + expect(dossier.project_champs_public.size).to eq(7) expect(dossier.champs.count(&:public?)).to eq(13) expect(rebased_text_champ.value).to eq(text_champ.value) expect(rebased_text_champ.type_de_champ).not_to eq(text_champ.type_de_champ) @@ -404,7 +405,7 @@ describe DossierRebaseConcern do let(:dossier) { create(:dossier, :en_construction, procedure:) } it 'is noop' do - expect { subject }.not_to change { dossier.reload.champs_public[0].rebased_at } + expect { subject }.not_to change { dossier.reload.project_champs_public[0].rebased_at } expect { subject }.not_to change { dossier.updated_at } end end @@ -430,38 +431,38 @@ describe DossierRebaseConcern do context 'when a dropdown option is added' do before do - dossier.champs_public.first.update(value: 'v1') + dossier.project_champs_public.first.update(value: 'v1') stable_id = procedure.draft_revision.types_de_champ.find_by(libelle: 'l1') tdc_to_update = procedure.draft_revision.find_and_ensure_exclusive_use(stable_id) tdc_to_update.update(drop_down_options: ["option", "updated", "v1"]) end - it { expect { subject }.not_to change { dossier.champs_public.first.value } } + it { expect { subject }.not_to change { dossier.project_champs_public.first.value } } end context 'when a dropdown option is removed' do before do - dossier.champs_public.first.update(value: 'v1') + dossier.project_champs_public.first.update(value: 'v1') stable_id = procedure.draft_revision.types_de_champ.find_by(libelle: 'l1') tdc_to_update = procedure.draft_revision.find_and_ensure_exclusive_use(stable_id) tdc_to_update.update(drop_down_options: ["option", "updated"]) end - it { expect { subject }.to change { dossier.champs_public.first.value }.from('v1').to(nil) } + it { expect { subject }.to change { dossier.project_champs_public.first.value }.from('v1').to(nil) } end context 'when a dropdown unused option is removed' do before do - dossier.champs_public.first.update(value: 'v1') + dossier.project_champs_public.first.update(value: 'v1') stable_id = procedure.draft_revision.types_de_champ.find_by(libelle: 'l1') tdc_to_update = procedure.draft_revision.find_and_ensure_exclusive_use(stable_id) tdc_to_update.update(drop_down_options: ["v1", "updated"]) end - it { expect { subject }.not_to change { dossier.champs_public.first.value } } + it { expect { subject }.not_to change { dossier.project_champs_public.first.value } } end end @@ -476,38 +477,38 @@ describe DossierRebaseConcern do context 'when a dropdown option is added' do before do - dossier.champs_public.first.update(value: '["v1"]') + dossier.project_champs_public.first.update(value: '["v1"]') stable_id = procedure.draft_revision.types_de_champ.find_by(libelle: 'l1') tdc_to_update = procedure.draft_revision.find_and_ensure_exclusive_use(stable_id) tdc_to_update.update(drop_down_options: ["option", "updated", "v1"]) end - it { expect { subject }.not_to change { dossier.champs_public.first.value } } + it { expect { subject }.not_to change { dossier.project_champs_public.first.value } } end context 'when a dropdown option is removed' do before do - dossier.champs_public.first.update(value: '["v1", "option"]') + dossier.project_champs_public.first.update(value: '["v1", "option"]') stable_id = procedure.draft_revision.types_de_champ.find_by(libelle: 'l1') tdc_to_update = procedure.draft_revision.find_and_ensure_exclusive_use(stable_id) tdc_to_update.update(drop_down_options: ["option", "updated"]) end - it { expect { subject }.to change { dossier.champs_public.first.value }.from('["v1","option"]').to('["option"]') } + it { expect { subject }.to change { dossier.project_champs_public.first.value }.from('["v1","option"]').to('["option"]') } end context 'when a dropdown unused option is removed' do before do - dossier.champs_public.first.update(value: '["v1"]') + dossier.project_champs_public.first.update(value: '["v1"]') stable_id = procedure.draft_revision.types_de_champ.find_by(libelle: 'l1') tdc_to_update = procedure.draft_revision.find_and_ensure_exclusive_use(stable_id) tdc_to_update.update(drop_down_options: ["v1", "updated"]) end - it { expect { subject }.not_to change { dossier.champs_public.first.value } } + it { expect { subject }.not_to change { dossier.project_champs_public.first.value } } end end @@ -522,38 +523,38 @@ describe DossierRebaseConcern do context 'when a dropdown option is added' do before do - dossier.champs_public.first.update(value: '["v1",""]') + dossier.project_champs_public.first.update(value: '["v1",""]') stable_id = procedure.draft_revision.types_de_champ.find_by(libelle: 'l1') tdc_to_update = procedure.draft_revision.find_and_ensure_exclusive_use(stable_id) tdc_to_update.update(drop_down_options: ["--titre1--", "option", "v1", "updated", "--titre2--", "option2", "v2"]) end - it { expect { subject }.not_to change { dossier.champs_public.first.value } } + it { expect { subject }.not_to change { dossier.project_champs_public.first.value } } end context 'when a dropdown option is removed' do before do - dossier.champs_public.first.update(value: '["v1","option2"]') + dossier.project_champs_public.first.update(value: '["v1","option2"]') stable_id = procedure.draft_revision.types_de_champ.find_by(libelle: 'l1') tdc_to_update = procedure.draft_revision.find_and_ensure_exclusive_use(stable_id) tdc_to_update.update(drop_down_options: ["--titre1--", "option", "updated", "--titre2--", "option2", "v2"]) end - it { expect { subject }.to change { dossier.champs_public.first.value }.from('["v1","option2"]').to(nil) } + it { expect { subject }.to change { dossier.project_champs_public.first.value }.from('["v1","option2"]').to(nil) } end context 'when a dropdown unused option is removed' do before do - dossier.champs_public.first.update(value: '["v1",""]') + dossier.project_champs_public.first.update(value: '["v1",""]') stable_id = procedure.draft_revision.types_de_champ.find_by(libelle: 'l1') tdc_to_update = procedure.draft_revision.find_and_ensure_exclusive_use(stable_id) tdc_to_update.update(drop_down_options: ["--titre1--", "v1", "updated", "--titre2--", "option2", "v2"]) end - it { expect { subject }.not_to change { dossier.champs_public.first.value } } + it { expect { subject }.not_to change { dossier.project_champs_public.first.value } } end end @@ -568,14 +569,14 @@ describe DossierRebaseConcern do context 'and the cadastre are removed' do before do - dossier.champs_public.first.update(value: 'v1', geo_areas: [build(:geo_area, :cadastre)]) + dossier.project_champs_public.first.update(value: 'v1', geo_areas: [build(:geo_area, :cadastre)]) stable_id = procedure.draft_revision.types_de_champ.find_by(libelle: 'l1') tdc_to_update = procedure.draft_revision.find_and_ensure_exclusive_use(stable_id) tdc_to_update.update(cadastres: false) end - it { expect { subject }.to change { dossier.champs_public.first.cadastres.count }.from(1).to(0) } + it { expect { subject }.to change { dossier.project_champs_public.first.cadastres.count }.from(1).to(0) } end end @@ -626,7 +627,7 @@ describe DossierRebaseConcern do end context 'when the first tdc type is updated' do - def first_champ = dossier.champs_public.first + def first_champ = dossier.project_champs_public.first before do first_champ.update(value: 'v1', external_id: '123', geo_areas: [build(:geo_area)]) @@ -727,7 +728,7 @@ describe DossierRebaseConcern do parent.update(type_champ: :integer_number) end - it { expect { subject }.to change { dossier.champs_public.first.champs.count }.from(2).to(0) } + it { expect { subject }.to change { dossier.project_champs_public.first.champs.count }.from(2).to(0) } it { expect { subject }.to change { Champ.count }.from(3).to(1) } end end diff --git a/spec/models/concerns/dossier_searchable_concern_spec.rb b/spec/models/concerns/dossier_searchable_concern_spec.rb index d1b0c1114..62f198a03 100644 --- a/spec/models/concerns/dossier_searchable_concern_spec.rb +++ b/spec/models/concerns/dossier_searchable_concern_spec.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true describe DossierSearchableConcern do - let(:champ_public) { dossier.champs_public.first } - let(:champ_private) { dossier.champs_private.first } + let(:champ_public) { dossier.project_champs_public.first } + let(:champ_private) { dossier.project_champs_private.first } describe '#index_search_terms' do let(:etablissement) { dossier.etablissement } diff --git a/spec/models/concerns/tags_substitution_concern_spec.rb b/spec/models/concerns/tags_substitution_concern_spec.rb index 42b0d30a0..10e0368a5 100644 --- a/spec/models/concerns/tags_substitution_concern_spec.rb +++ b/spec/models/concerns/tags_substitution_concern_spec.rb @@ -169,11 +169,11 @@ describe TagsSubstitutionConcern, type: :model do context 'and their value in the dossier are not nil' do before do - dossier.champs_public + dossier.project_champs_public .find { |champ| champ.libelle == 'libelleA' } .update(value: 'libelle1') - dossier.champs_public + dossier.project_champs_public .find { |champ| champ.libelle == "libelle\xc2\xA0B".encode('utf-8') } .update(value: 'libelle2') end @@ -195,7 +195,7 @@ describe TagsSubstitutionConcern, type: :model do context 'and their value in the dossier are not nil' do before do - dossier.champs_public + dossier.project_champs_public .find { |champ| champ.libelle == "Intitulé de l'‘«\"évènement\"»’" } .update(value: 'ceci est mon évènement') end @@ -217,7 +217,7 @@ describe TagsSubstitutionConcern, type: :model do context 'and their value in the dossier are not nil' do before do - dossier.champs_public + dossier.project_champs_public .find { |champ| champ.libelle == "bon pote -- c'est top" } .update(value: 'ceci est mon évènement') end @@ -316,7 +316,7 @@ describe TagsSubstitutionConcern, type: :model do let(:template) { '--libelleA--' } context 'and its value in the dossier is not nil' do - before { dossier.champs_private.first.update(value: 'libelle1') } + before { dossier.project_champs_private.first.update(value: 'libelle1') } it { is_expected.to eq('libelle1') } end @@ -339,7 +339,7 @@ describe TagsSubstitutionConcern, type: :model do context 'champs publics are valid tags' do let(:types_de_champ_public) { [{ libelle: 'libelleA' }] } - before { dossier.champs_public.first.update(value: 'libelle1') } + before { dossier.project_champs_public.first.update(value: 'libelle1') } it { is_expected.to eq('libelle1') } end @@ -358,11 +358,11 @@ describe TagsSubstitutionConcern, type: :model do context 'and its value in the dossier are not nil' do before do - dossier.champs_public + dossier.project_champs_public .find { |champ| champ.type_champ == TypeDeChamp.type_champs.fetch(:date) } .update(value: '2017-04-15') - dossier.champs_public + dossier.project_champs_public .find { |champ| champ.type_champ == TypeDeChamp.type_champs.fetch(:datetime) } .update(value: '2017-09-13 09:00') end @@ -433,7 +433,7 @@ describe TagsSubstitutionConcern, type: :model do end context "match breaking and non breaking spaces" do - before { dossier.champs_public.first.update(value: 'valeur') } + before { dossier.project_champs_public.first.update(value: 'valeur') } shared_examples "treat all kinds of space as equivalent" do context 'and the champ has a non breaking space' do @@ -480,7 +480,7 @@ describe TagsSubstitutionConcern, type: :model do before do draft_type_de_champ.update(libelle: 'mon nouveau libellé') - dossier.champs_public.first.update(value: 'valeur') + dossier.project_champs_public.first.update(value: 'valeur') procedure.update!(draft_revision: procedure.create_new_revision, published_revision: procedure.draft_revision) end @@ -513,7 +513,7 @@ describe TagsSubstitutionConcern, type: :model do context 'in a champ' do let(:types_de_champ_public) { [{ libelle: 'libelleA' }] } - before { dossier.champs_public.first.update(value: 'hey anchor') } + before { dossier.project_champs_public.first.update(value: 'hey anchor') } it { is_expected.to eq('hey <a href="https://oops.com">anchor</a> --nom--') } end diff --git a/spec/models/dossier_preloader_spec.rb b/spec/models/dossier_preloader_spec.rb index 3fd7be78b..fb2fadda7 100644 --- a/spec/models/dossier_preloader_spec.rb +++ b/spec/models/dossier_preloader_spec.rb @@ -10,9 +10,9 @@ describe DossierPreloader do end let(:procedure) { create(:procedure, types_de_champ_public: types_de_champ) } let(:dossier) { create(:dossier, procedure: procedure) } - let(:repetition) { subject.champs_public.second } - let(:repetition_optional) { subject.champs_public.third } - let(:first_child) { subject.champs_public.second.champs.first } + let(:repetition) { subject.project_champs_public.second } + let(:repetition_optional) { subject.project_champs_public.third } + let(:first_child) { subject.project_champs_public.second.champs.first } describe 'all' do subject { DossierPreloader.load_one(dossier, pj_template: true) } @@ -25,20 +25,20 @@ describe DossierPreloader do callback = lambda { |*_args| count += 1 } ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do expect(subject.id).to eq(dossier.id) - expect(subject.champs_public.size).to eq(types_de_champ.size) + expect(subject.project_champs_public.size).to eq(types_de_champ.size) expect(subject.changed?).to be false expect(first_child.type).to eq('Champs::TextChamp') expect(repetition.id).not_to eq(first_child.id) expect(subject.champs.first.dossier).to eq(subject) expect(subject.champs.find(&:public?).dossier).to eq(subject) - expect(subject.champs_public.first.dossier).to eq(subject) + expect(subject.project_champs_public.first.dossier).to eq(subject) - expect(subject.champs_public.first.type_de_champ.piece_justificative_template.attached?).to eq(false) + expect(subject.project_champs_public.first.type_de_champ.piece_justificative_template.attached?).to eq(false) expect(subject.champs.first.conditional?).to eq(false) expect(subject.champs.find(&:public?).conditional?).to eq(false) - expect(subject.champs_public.first.conditional?).to eq(false) + expect(subject.project_champs_public.first.conditional?).to eq(false) expect(first_child.parent).to eq(repetition) expect(repetition.champs.first).to eq(first_child) diff --git a/spec/models/dossier_spec.rb b/spec/models/dossier_spec.rb index 26b623346..8deed619b 100644 --- a/spec/models/dossier_spec.rb +++ b/spec/models/dossier_spec.rb @@ -304,22 +304,12 @@ describe Dossier, type: :model do subject { dossier } - describe '#create' do - let(:procedure) { create(:procedure, :with_type_de_champ, :with_type_de_champ_private) } - let(:dossier) { create(:dossier, procedure: procedure, user: user) } - - it 'builds public and private champs' do - expect(dossier.champs_public.count).to eq(1) - expect(dossier.champs_private.count).to eq(1) - end - end - - describe '#build_default_individual' do + describe '#build_default_values' do let(:dossier) { build(:dossier, procedure: procedure, user: user) } subject do dossier.individual = nil - dossier.build_default_individual + dossier.build_default_values end context 'when the dossier belongs to a procedure for individuals' do @@ -867,7 +857,7 @@ describe Dossier, type: :model do it { is_expected.not_to eq(modif_date) } context 'when a champ is modified' do - before { dossier.champs_public.first.update_attribute('value', 'yop') } + before { dossier.project_champs_public.first.update_attribute('value', 'yop') } it { is_expected.to eq(modif_date) } end @@ -1709,14 +1699,14 @@ describe Dossier, type: :model do let(:expression_reguliere_error_message) { "Le champ doit être composé de lettres majuscules" } before do - champ = dossier.champs_public.first + champ = dossier.project_champs_public.first champ.value = expression_reguliere_exemple_text dossier.save(context: :champs_public_value) end it 'should have errors' do expect(dossier.errors).not_to be_empty - expect(dossier.errors.full_messages.join(',')).to include(dossier.champs_public.first.expression_reguliere_error_message) + expect(dossier.errors.full_messages.join(',')).to include(dossier.project_champs_public.first.expression_reguliere_error_message) end end @@ -1726,7 +1716,7 @@ describe Dossier, type: :model do let(:expression_reguliere_error_message) { "Le champ doit être composé de lettres majuscules" } before do - champ = dossier.champs_public.first + champ = dossier.project_champs_public.first champ.value = expression_reguliere_exemple_text dossier.save end @@ -2026,8 +2016,8 @@ describe Dossier, type: :model do let(:explication_type_de_champ) { procedure.active_revision.types_de_champ_public.find { |type_de_champ| type_de_champ.type_champ == TypeDeChamp.type_champs.fetch(:explication) } } let(:commune_type_de_champ) { procedure.active_revision.types_de_champ_public.find { |type_de_champ| type_de_champ.type_champ == TypeDeChamp.type_champs.fetch(:communes) } } let(:repetition_type_de_champ) { procedure.active_revision.types_de_champ_public.find { |type_de_champ| type_de_champ.type_champ == TypeDeChamp.type_champs.fetch(:repetition) } } - let(:repetition_champ) { dossier.champs_public.find(&:repetition?) } - let(:repetition_second_revision_champ) { dossier_second_revision.champs_public.find(&:repetition?) } + let(:repetition_champ) { dossier.project_champs_public.find(&:repetition?) } + let(:repetition_second_revision_champ) { dossier_second_revision.project_champs_public.find(&:repetition?) } let(:dossier) { create(:dossier, procedure: procedure) } let(:dossier_second_revision) { create(:dossier, procedure: procedure) } let(:dossier_champs_for_export) { dossier.champs_for_export(procedure.types_de_champ_for_procedure_export) } @@ -2087,14 +2077,14 @@ describe Dossier, type: :model do let(:dossier) { create(:dossier, procedure:) } let(:yes_no_tdc) { procedure.active_revision.types_de_champ_public.first } let(:text_tdc) { procedure.active_revision.types_de_champ_public.second } - let(:tdcs) { dossier.champs_public.map(&:type_de_champ) } + let(:tdcs) { dossier.project_champs_public.map(&:type_de_champ) } subject { dossier.champs_for_export(tdcs) } before do text_tdc.update(condition: ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) - yes_no, text = dossier.champs_public + yes_no, text = dossier.project_champs_public yes_no.update(value: yes_no_value) text.update(value: 'text') end @@ -2113,7 +2103,7 @@ describe Dossier, type: :model do context 'with another revision' do let(:tdc_from_another_revision) { create(:type_de_champ_communes, libelle: 'commune', condition: ds_eq(constant(true), constant(true))) } - let(:tdcs) { dossier.champs_public.map(&:type_de_champ) << tdc_from_another_revision } + let(:tdcs) { dossier.project_champs_public.map(&:type_de_champ) << tdc_from_another_revision } let(:yes_no_value) { 'true' } let(:expected) do diff --git a/spec/models/export_template_spec.rb b/spec/models/export_template_spec.rb index 8ef3e3c04..3cb3bfd26 100644 --- a/spec/models/export_template_spec.rb +++ b/spec/models/export_template_spec.rb @@ -50,7 +50,7 @@ describe ExportTemplate do end context 'for pj' do - let(:champ_pj) { dossier.champs_public.first } + let(:champ_pj) { dossier.project_champs_public.first } let(:export_template) { create(:export_template, groupe_instructeur:, pjs: [ExportItem.default(stable_id: 3, prefix: "justif", enabled: true)]) } let(:attachment) { ActiveStorage::Attachment.new(name: 'pj', record: champ_pj, blob: ActiveStorage::Blob.new(filename: "superpj.png")) } diff --git a/spec/models/instructeur_spec.rb b/spec/models/instructeur_spec.rb index b0b26f8bc..54f2a21b0 100644 --- a/spec/models/instructeur_spec.rb +++ b/spec/models/instructeur_spec.rb @@ -201,7 +201,7 @@ describe Instructeur, type: :model do context 'when there is a modification on public champs' do before { - dossier.champs_public.first.update(value: 'toto') + dossier.project_champs_public.first.update(value: 'toto') dossier.update(last_champ_updated_at: Time.zone.now) } @@ -223,7 +223,7 @@ describe Instructeur, type: :model do context 'when there is a modification on private champs' do before { - dossier.champs_private.first.update(value: 'toto') + dossier.project_champs_private.first.update(value: 'toto') dossier.update(last_champ_private_updated_at: Time.zone.now) } @@ -302,7 +302,7 @@ describe Instructeur, type: :model do it { expect(instructeur_on_procedure_2.notifications_for_groupe_instructeurs(gi_p2)[:en_cours]).to match([]) } context 'and there is a modification on private champs' do - before { dossier.champs_private.first.update_attribute('value', 'toto') } + before { dossier.project_champs_private.first.update_attribute('value', 'toto') } it { is_expected.to match([dossier.id]) } end @@ -317,7 +317,7 @@ describe Instructeur, type: :model do end context 'when there is a modification on public champs on a followed dossier from another procedure' do - before { dossier_on_procedure_2.champs_public.first.update_attribute('value', 'toto') } + before { dossier_on_procedure_2.project_champs_public.first.update_attribute('value', 'toto') } it { is_expected.to match([]) } end diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index 1759d8525..0ae5de859 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -146,8 +146,8 @@ describe ProcedurePresentation do let(:tartine_dossier) { create(:dossier, procedure: procedure) } before do - beurre_dossier.champs_public.first.update(value: 'beurre') - tartine_dossier.champs_public.first.update(value: 'tartine') + beurre_dossier.project_champs_public.first.update(value: 'beurre') + tartine_dossier.project_champs_public.first.update(value: 'tartine') end context 'asc' do @@ -176,8 +176,8 @@ describe ProcedurePresentation do nothing_dossier procedure.draft_revision.add_type_de_champ(tdc) procedure.publish_revision! - beurre_dossier.champs_public.last.update(value: 'beurre') - tartine_dossier.champs_public.last.update(value: 'tartine') + beurre_dossier.project_champs_public.last.update(value: 'beurre') + tartine_dossier.project_champs_public.last.update(value: 'tartine') end context 'asc' do @@ -201,8 +201,8 @@ describe ProcedurePresentation do let(:vin_dossier) { create(:dossier, procedure: procedure) } before do - biere_dossier.champs_private.first.update(value: 'biere') - vin_dossier.champs_private.first.update(value: 'vin') + biere_dossier.project_champs_private.first.update(value: 'biere') + vin_dossier.project_champs_private.first.update(value: 'vin') end context 'asc' do @@ -231,8 +231,8 @@ describe ProcedurePresentation do nothing_dossier procedure.draft_revision.add_type_de_champ(tdc) procedure.publish_revision! - biere_dossier.champs_private.last.update(value: 'biere') - vin_dossier.champs_private.last.update(value: 'vin') + biere_dossier.project_champs_private.last.update(value: 'biere') + vin_dossier.project_champs_private.last.update(value: 'vin') end context 'asc' do @@ -592,8 +592,8 @@ describe ProcedurePresentation do let(:value_column_searched) { ['postal_code'] } before do - kept_dossier.champs_public.find_by(stable_id: 1).update(value_json: { "postal_code" => value }) - create(:dossier, procedure: procedure).champs_public.find_by(stable_id: 1).update(value_json: { "postal_code" => "unknown" }) + kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "postal_code" => value }) + create(:dossier, procedure: procedure).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "postal_code" => "unknown" }) end it { is_expected.to contain_exactly(kept_dossier.id) } it 'describes column' do @@ -607,8 +607,8 @@ describe ProcedurePresentation do let(:value_column_searched) { ['departement_code'] } before do - kept_dossier.champs_public.find_by(stable_id: 1).update(value_json: { "departement_code" => value }) - create(:dossier, procedure: procedure).champs_public.find_by(stable_id: 1).update(value_json: { "departement_code" => "unknown" }) + kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "departement_code" => value }) + create(:dossier, procedure: procedure).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "departement_code" => "unknown" }) end it { is_expected.to contain_exactly(kept_dossier.id) } it 'describes column' do @@ -622,8 +622,8 @@ describe ProcedurePresentation do let(:value_column_searched) { ['region_name'] } before do - kept_dossier.champs_public.find_by(stable_id: 1).update(value_json: { "region_name" => value }) - create(:dossier, procedure: procedure).champs_public.find_by(stable_id: 1).update(value_json: { "region_name" => "unknown" }) + kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "region_name" => value }) + create(:dossier, procedure: procedure).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "region_name" => "unknown" }) end it { is_expected.to contain_exactly(kept_dossier.id) } it 'describes column' do diff --git a/spec/policies/champ_policy_spec.rb b/spec/policies/champ_policy_spec.rb index 4b86a4351..786956a2c 100644 --- a/spec/policies/champ_policy_spec.rb +++ b/spec/policies/champ_policy_spec.rb @@ -10,8 +10,8 @@ describe ChampPolicy do subject { Pundit.policy_scope(account, Champ) } - let(:champ) { dossier.champs_public.first } - let(:champ_private) { dossier.champs_private.first } + let(:champ) { dossier.project_champs_public.first } + let(:champ_private) { dossier.project_champs_private.first } shared_examples_for 'they can access a public champ' do it { expect(subject.find_by(id: champ.id)).to eq(champ) } diff --git a/spec/services/dossier_projection_service_spec.rb b/spec/services/dossier_projection_service_spec.rb index fe402d066..1b577cf40 100644 --- a/spec/services/dossier_projection_service_spec.rb +++ b/spec/services/dossier_projection_service_spec.rb @@ -21,10 +21,10 @@ describe DossierProjectionService do end before do - dossier_1.champs_public.first.update(value: 'champ_1') - dossier_1.champs_public.second.update(value: '["test"]') - dossier_2.champs_public.first.update(value: 'champ_2') - dossier_3.champs_public.first.destroy + dossier_1.project_champs_public.first.update(value: 'champ_1') + dossier_1.project_champs_public.second.update(value: '["test"]') + dossier_2.project_champs_public.first.update(value: 'champ_2') + dossier_3.project_champs_public.first.destroy end let(:result) { subject } @@ -65,7 +65,7 @@ describe DossierProjectionService do end before do - dossier.champs_public.first.update(code_postal: '63290', external_id: '63102') + dossier.project_champs_public.first.update(code_postal: '63290', external_id: '63102') end let(:result) { subject } @@ -185,7 +185,7 @@ describe DossierProjectionService do let(:dossier) { create(:dossier) } let(:column) { dossier.procedure.active_revision.types_de_champ_public.first.stable_id.to_s } - before { dossier.champs_public.first.update(value: 'kale') } + before { dossier.project_champs_public.first.update(value: 'kale') } it { is_expected.to eq('kale') } end @@ -195,7 +195,7 @@ describe DossierProjectionService do let(:dossier) { create(:dossier) } let(:column) { dossier.procedure.active_revision.types_de_champ_private.first.stable_id.to_s } - before { dossier.champs_private.first.update(value: 'quinoa') } + before { dossier.project_champs_private.first.update(value: 'quinoa') } it { is_expected.to eq('quinoa') } end @@ -206,7 +206,7 @@ describe DossierProjectionService do let(:dossier) { create(:dossier, procedure: procedure) } let(:column) { dossier.procedure.active_revision.types_de_champ_public.first.stable_id.to_s } - before { dossier.champs_public.first.update(value: 'true') } + before { dossier.project_champs_public.first.update(value: 'true') } it { is_expected.to eq('Oui') } end @@ -217,7 +217,7 @@ describe DossierProjectionService do let(:dossier) { create(:dossier, procedure: procedure) } let(:column) { dossier.procedure.active_revision.types_de_champ_public.first.stable_id.to_s } - before { dossier.champs_public.first.update(value: '18 a la bonne rue', data: { 'label' => '18 a la bonne rue', 'departement' => 'd' }) } + before { dossier.project_champs_public.first.update(value: '18 a la bonne rue', data: { 'label' => '18 a la bonne rue', 'departement' => 'd' }) } it { is_expected.to eq('18 a la bonne rue') } end @@ -236,7 +236,7 @@ describe DossierProjectionService do context 'when external id is set' do before do - dossier.champs_public.first.update(external_id: 'GB') + dossier.project_champs_public.first.update(external_id: 'GB') end it { is_expected.to eq('Royaume-Uni') } @@ -244,7 +244,7 @@ describe DossierProjectionService do context 'when no external id is set' do before do - dossier.champs_public.first.update(value: "qu'il est beau mon pays") + dossier.project_champs_public.first.update(value: "qu'il est beau mon pays") end it { is_expected.to eq("") } diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index 61661ece2..1c0979043 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -10,7 +10,7 @@ describe PiecesJustificativesService do let(:pj_service) { PiecesJustificativesService.new(user_profile:, export_template:) } let(:user_profile) { build(:administrateur) } - def pj_champ(d) = d.champs_public.find_by(type: 'Champs::PieceJustificativeChamp') + def pj_champ(d) = d.project_champs_public.find { _1.type == 'Champs::PieceJustificativeChamp' } def repetition(d) = d.champs.find_by(type: "Champs::RepetitionChamp") def attachments(champ) = champ.piece_justificative_file.attachments @@ -102,7 +102,7 @@ describe PiecesJustificativesService do let(:user_profile) { build(:administrateur) } let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :piece_justificative }]) } let(:witness) { create(:dossier, procedure: procedure) } - def pj_champ(d) = d.champs_public.find { |c| c.type == 'Champs::PieceJustificativeChamp' } + def pj_champ(d) = d.project_champs_public.find { |c| c.type == 'Champs::PieceJustificativeChamp' } context 'with a single attachment' do before do @@ -143,7 +143,7 @@ describe PiecesJustificativesService do let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :titre_identite }]) } let(:dossier) { create(:dossier, procedure: procedure) } - let(:champ_identite) { dossier.champs_public.find { |c| c.type == 'Champs::TitreIdentiteChamp' } } + let(:champ_identite) { dossier.project_champs_public.find { |c| c.type == 'Champs::TitreIdentiteChamp' } } before { attach_file_to_champ(champ_identite) } @@ -260,7 +260,7 @@ describe PiecesJustificativesService do let(:witness) { create(:dossier, procedure: procedure) } let!(:private_pj) { create(:type_de_champ_piece_justificative, procedure: procedure, private: true) } - def private_pj_champ(d) = d.champs_private.find { |c| c.type == 'Champs::PieceJustificativeChamp' } + def private_pj_champ(d) = d.project_champs_private.find { |c| c.type == 'Champs::PieceJustificativeChamp' } before do attach_file_to_champ(private_pj_champ(dossier)) @@ -503,8 +503,8 @@ describe PiecesJustificativesService do let(:dossier_1) { create(:dossier, procedure:) } let(:champs) { dossier_1.champs } - def pj_champ(d) = d.champs_public.find_by(type: 'Champs::PieceJustificativeChamp') - def repetition(d, index:) = d.champs_public.filter(&:repetition?)[index] + def pj_champ(d) = d.project_champs_public.find { _1.type == 'Champs::PieceJustificativeChamp' } + def repetition(d, index:) = d.project_champs_public.filter(&:repetition?)[index] subject { PiecesJustificativesService.new(user_profile:, export_template: nil).send(:compute_champ_id_row_index, champs) } @@ -535,7 +535,7 @@ describe PiecesJustificativesService do end it do - champs = dossier_1.champs_public + champs = dossier_1.project_champs_public repet_0 = champs[0] pj_0 = repet_0.rows.first.first pj_1 = repet_0.rows.second.first diff --git a/spec/services/procedure_export_service_spec.rb b/spec/services/procedure_export_service_spec.rb index 34cecb9f1..f44545c38 100644 --- a/spec/services/procedure_export_service_spec.rb +++ b/spec/services/procedure_export_service_spec.rb @@ -155,7 +155,7 @@ describe ProcedureExportService do let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure:) } let!(:dossier_2) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure:) } before do - dossier_2.champs_public + dossier_2.project_champs_public .find { _1.is_a? Champs::PieceJustificativeChamp } .piece_justificative_file .attach(io: StringIO.new("toto"), filename: "toto.txt", content_type: "text/plain") @@ -351,7 +351,7 @@ describe ProcedureExportService do create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) ] end - let(:champ_repetition) { dossiers.first.champs_public.find { |champ| champ.type_champ == 'repetition' } } + let(:champ_repetition) { dossiers.first.project_champs_public.find { |champ| champ.type_champ == 'repetition' } } it 'should have sheets' do expect(subject.sheets.map(&:name)).to eq(['Dossiers', 'Etablissements', 'Avis', champ_repetition.type_de_champ.libelle_for_export]) @@ -416,7 +416,7 @@ describe ProcedureExportService do context 'with empty repetition' do before do - dossiers.flat_map { |dossier| dossier.champs_public.filter(&:repetition?) }.each do |champ| + dossiers.flat_map { |dossier| dossier.project_champs_public.filter(&:repetition?) }.each do |champ| Champ.where(row_id: champ.row_ids).destroy_all end end @@ -520,7 +520,7 @@ describe ProcedureExportService do end let(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } - let(:champ_carte) { dossier.champs_public.find(&:carte?) } + let(:champ_carte) { dossier.project_champs_public.find(&:carte?) } let(:properties) { subject['features'].first['properties'] } before do diff --git a/spec/services/procedure_export_service_zip_spec.rb b/spec/services/procedure_export_service_zip_spec.rb index 696a62067..44602784e 100644 --- a/spec/services/procedure_export_service_zip_spec.rb +++ b/spec/services/procedure_export_service_zip_spec.rb @@ -7,7 +7,7 @@ describe ProcedureExportService do let(:export_template) { create(:export_template, :enabled_pjs, groupe_instructeur: procedure.defaut_groupe_instructeur) } let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) } - def pj_champ(d) = d.champs_public.find_by(type: 'Champs::PieceJustificativeChamp') + def pj_champ(d) = d.project_champs_public.find { _1.type == 'Champs::PieceJustificativeChamp' } def repetition(d) = d.champs.find_by(type: "Champs::RepetitionChamp") def attachments(champ) = champ.piece_justificative_file.attachments diff --git a/spec/system/api_particulier/api_particulier_spec.rb b/spec/system/api_particulier/api_particulier_spec.rb index 40b06aabb..a2ad90dcf 100644 --- a/spec/system/api_particulier/api_particulier_spec.rb +++ b/spec/system/api_particulier/api_particulier_spec.rb @@ -282,7 +282,7 @@ describe 'fetch API Particulier Data', js: true do fill_in 'Le code postal', with: 'wrong_code' dossier = Dossier.last - cnaf_champ = dossier.champs_public.find(&:cnaf?) + cnaf_champ = dossier.project_champs_public.find(&:cnaf?) wait_until { cnaf_champ.reload.code_postal == 'wrong_code' } @@ -342,7 +342,7 @@ describe 'fetch API Particulier Data', js: true do fill_in "Identifiant", with: 'wrong code' dossier = Dossier.last - pole_emploi_champ = dossier.champs_public.find(&:pole_emploi?) + pole_emploi_champ = dossier.project_champs_public.find(&:pole_emploi?) wait_until { pole_emploi_champ.reload.identifiant == 'wrong code' } @@ -418,7 +418,7 @@ describe 'fetch API Particulier Data', js: true do fill_in "INE", with: 'wrong code' dossier = Dossier.last - mesri_champ = dossier.champs_public.find(&:mesri?) + mesri_champ = dossier.project_champs_public.find(&:mesri?) wait_until { mesri_champ.reload.ine == 'wrong code' } clear_enqueued_jobs @@ -485,7 +485,7 @@ describe 'fetch API Particulier Data', js: true do fill_in "La référence d’avis d’imposition", with: 'wrong_code' dossier = Dossier.last - dgfip_champ = dossier.champs_public.find(&:dgfip?) + dgfip_champ = dossier.project_champs_public.find(&:dgfip?) wait_until { dgfip_champ.reload.reference_avis == 'wrong_code' } diff --git a/spec/system/instructeurs/instruction_spec.rb b/spec/system/instructeurs/instruction_spec.rb index d56c107b6..62390e084 100644 --- a/spec/system/instructeurs/instruction_spec.rb +++ b/spec/system/instructeurs/instruction_spec.rb @@ -214,7 +214,7 @@ describe 'Instructing a dossier:', js: true do context 'with dossiers having attached files' do let(:procedure) { create(:procedure, :published, types_de_champ_public: [{ type: :piece_justificative }], instructeurs: [instructeur]) } let(:dossier) { create(:dossier, :en_construction, procedure: procedure) } - let(:champ) { dossier.champs_public.first } + let(:champ) { dossier.project_champs_public.first } let(:path) { 'spec/fixtures/files/piece_justificative_0.pdf' } let(:commentaire) { create(:commentaire, instructeur: instructeur, dossier: dossier) } diff --git a/spec/system/instructeurs/procedure_sort_spec.rb b/spec/system/instructeurs/procedure_sort_spec.rb index 3f3c4342d..166b42105 100644 --- a/spec/system/instructeurs/procedure_sort_spec.rb +++ b/spec/system/instructeurs/procedure_sort_spec.rb @@ -10,7 +10,7 @@ describe "procedure sort", js: true do before do instructeur.follow(followed_dossier) instructeur.follow(followed_dossier_2) - followed_dossier.champs_public.first.update(value: '123') # touch the dossier + followed_dossier.project_champs_public.first.update(value: '123') # touch the dossier login_as(instructeur.user, scope: :user) visit instructeur_procedure_path(procedure, statut: "suivis") diff --git a/spec/system/routing/rules_full_scenario_spec.rb b/spec/system/routing/rules_full_scenario_spec.rb index 3a418560c..d688f2457 100644 --- a/spec/system/routing/rules_full_scenario_spec.rb +++ b/spec/system/routing/rules_full_scenario_spec.rb @@ -186,7 +186,7 @@ describe 'The routing with rules', js: true do click_on litteraire_user.dossiers.first.procedure.libelle click_on 'Modifier mon dossier' - fill_in litteraire_user.dossiers.first.champs_public.first.libelle, with: 'some value' + fill_in litteraire_user.dossiers.first.project_champs_public.first.libelle, with: 'some value' wait_for_autosave click_on 'Déposer les modifications' diff --git a/spec/system/users/brouillon_spec.rb b/spec/system/users/brouillon_spec.rb index c03ff3a07..ade916005 100644 --- a/spec/system/users/brouillon_spec.rb +++ b/spec/system/users/brouillon_spec.rb @@ -674,7 +674,7 @@ describe 'The user', js: true do end def champ_for(libelle) - champs = user_dossier.reload.champs_public + champs = user_dossier.reload.project_champs_public champ = champs.find { |c| c.libelle == libelle } champ.reload end diff --git a/spec/system/users/dropdown_spec.rb b/spec/system/users/dropdown_spec.rb index aaed8c34a..2a442f706 100644 --- a/spec/system/users/dropdown_spec.rb +++ b/spec/system/users/dropdown_spec.rb @@ -35,13 +35,13 @@ describe 'dropdown list with other option activated', js: true do choose I18n.t('shared.champs.drop_down_list.other') fill_in(I18n.t('shared.champs.drop_down_list.other_label'), with: "My choice") - wait_until { user_dossier.champs_public.first.value == "My choice" } - expect(user_dossier.champs_public.first.value).to eq("My choice") + wait_until { user_dossier.reload.project_champs_public.first.value == "My choice" } + expect(user_dossier.project_champs_public.first.value).to eq("My choice") choose "Secondary 1.1" - wait_until { user_dossier.champs_public.first.value == "Secondary 1.1" } - expect(user_dossier.champs_public.first.value).to eq("Secondary 1.1") + wait_until { user_dossier.reload.project_champs_public.first.value == "Secondary 1.1" } + expect(user_dossier.project_champs_public.first.value).to eq("Secondary 1.1") end end @@ -68,8 +68,8 @@ describe 'dropdown list with other option activated', js: true do select("Secondary 1.2") expect(page).to have_selector(".autosave-status.succeeded", visible: true) - wait_until { user_dossier.champs_public.first.value == "Secondary 1.2" } - expect(user_dossier.champs_public.first.value).to eq("Secondary 1.2") + wait_until { user_dossier.reload.project_champs_public.first.value == "Secondary 1.2" } + expect(user_dossier.project_champs_public.first.value).to eq("Secondary 1.2") end end diff --git a/spec/system/users/en_construction_spec.rb b/spec/system/users/en_construction_spec.rb index f34e8c993..ba9d06788 100644 --- a/spec/system/users/en_construction_spec.rb +++ b/spec/system/users/en_construction_spec.rb @@ -10,7 +10,7 @@ describe "Dossier en_construction", js: true do } let(:champ) { - dossier.find_editing_fork(dossier.user).champs_public.find { _1.stable_id == tdc.stable_id } + dossier.find_editing_fork(dossier.user).project_champs_public.find { _1.stable_id == tdc.stable_id } } scenario 'delete a non mandatory piece justificative' do diff --git a/spec/system/users/invite_spec.rb b/spec/system/users/invite_spec.rb index 95e1a08b6..4caa306ca 100644 --- a/spec/system/users/invite_spec.rb +++ b/spec/system/users/invite_spec.rb @@ -175,7 +175,7 @@ describe 'Invitations' do end it "can search something inside the dossier and it displays the dossier" do - page.find_by_id('q').set(dossier_2.champs_public.first.value) + page.find_by_id('q').set(dossier_2.project_champs_public.first.value) find('.fr-search-bar .fr-btn').click expect(current_path).to eq(dossiers_path) expect(page).to have_link(dossier.procedure.libelle) diff --git a/spec/system/users/list_dossiers_spec.rb b/spec/system/users/list_dossiers_spec.rb index 5d4cc3eac..dc3f5de5d 100644 --- a/spec/system/users/list_dossiers_spec.rb +++ b/spec/system/users/list_dossiers_spec.rb @@ -306,7 +306,7 @@ describe 'user access to the list of their dossiers', js: true do context "when user search for something inside the dossier" do before do - page.find_by_id('q').set(dossier_en_construction.champs_public.first.value) + page.find_by_id('q').set(dossier_en_construction.project_champs_public.first.value) end context 'when it matches multiple dossiers' do @@ -336,7 +336,7 @@ describe 'user access to the list of their dossiers', js: true do click_on 'Afficher' expect(page).not_to have_link(String(dossier_en_construction.id)) expect(page).not_to have_link(String(dossier_with_champs.id)) - expect(page).to have_content("Résultat de la recherche pour « #{dossier_en_construction.champs_public.first.value} » et pour la procédure « #{dossier_brouillon.procedure.libelle} » ") + expect(page).to have_content("Résultat de la recherche pour « #{dossier_en_construction.project_champs_public.first.value} » et pour la procédure « #{dossier_brouillon.procedure.libelle} » ") expect(page).to have_text("Aucun dossier") end end diff --git a/spec/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task_spec.rb b/spec/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task_spec.rb index 2029c1991..e34dcf848 100644 --- a/spec/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task_spec.rb +++ b/spec/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task_spec.rb @@ -11,8 +11,8 @@ module Maintenance let(:parent_dossier) { create(:dossier, procedure:) } let(:cloned_dossier) { create(:dossier, procedure:) } - let(:parent_champ_pj) { parent_dossier.champs_private.find(&:piece_justificative?) } - let(:cloned_champ_pj) { cloned_dossier.champs_private.find(&:piece_justificative?) } + let(:parent_champ_pj) { parent_dossier.project_champs_private.find(&:piece_justificative?) } + let(:cloned_champ_pj) { cloned_dossier.project_champs_private.find(&:piece_justificative?) } before do cloned_dossier.update(parent_dossier:) # used on factorie, does not seed private_champs.. diff --git a/spec/views/shared/dossiers/_champs.html.haml_spec.rb b/spec/views/shared/dossiers/_champs.html.haml_spec.rb index 93bfbebb5..6d250ceaa 100644 --- a/spec/views/shared/dossiers/_champs.html.haml_spec.rb +++ b/spec/views/shared/dossiers/_champs.html.haml_spec.rb @@ -21,12 +21,12 @@ describe 'shared/dossiers/champs', type: :view do context "there are some champs" do let(:types_de_champ_public) { [{ type: :checkbox }, { type: :header_section }, { type: :explication }, { type: :dossier_link }, { type: :textarea }, { type: :rna }] } - let(:champ1) { dossier.champs[0] } - let(:champ2) { dossier.champs[1] } - let(:champ3) { dossier.champs[2] } - let(:champ4) { dossier.champs[3] } - let(:champ5) { dossier.champs[4] } - let(:champ6) { dossier.champs[5] } + let(:champ1) { dossier.project_champs_public[0] } + let(:champ2) { dossier.project_champs_public[1] } + let(:champ3) { dossier.project_champs_public[2] } + let(:champ4) { dossier.project_champs_public[3] } + let(:champ5) { dossier.project_champs_public[4] } + let(:champ6) { dossier.project_champs_public[5] } before do champ1.update(value: 'true') @@ -57,8 +57,8 @@ describe 'shared/dossiers/champs', type: :view do context "with auto-link" do let(:types_de_champ_public) { [{ type: :text }, { type: :textarea }] } - let(:champ1) { dossier.champs[0] } - let(:champ2) { dossier.champs[1] } + let(:champ1) { dossier.project_champs_public.first } + let(:champ2) { dossier.project_champs_public.second } before do champ1.update(value: 'https://github.com/tchak') @@ -118,8 +118,8 @@ describe 'shared/dossiers/champs', type: :view do context "with seen_at" do let(:types_de_champ_public) { [{ type: :checkbox }] } - let(:dossier) { create(:dossier, :en_construction, :with_populated_champs, procedure:, depose_at: 1.day.ago) } - let(:champ1) { dossier.champs[0] } + let(:dossier) { create(:dossier, :en_construction, :with_populated_champs, procedure:, depose_at: 1.day.ago.change(usec: 0)) } + let(:champ1) { dossier.champs.first } context "with a demande_seen_at after champ updated_at" do let(:demande_seen_at) { champ1.updated_at + 1.hour } @@ -127,21 +127,20 @@ describe 'shared/dossiers/champs', type: :view do it { is_expected.not_to have_css(".fr-badge--new") } end - context "with champ updated_at at depose_at" do - let(:champ1) { dossier.champs[0] } - let(:demande_seen_at) { champ1.updated_at - 1.hour } - - before do - champ1.update(value: 'false', updated_at: dossier.depose_at) - end - - it { is_expected.not_to have_css(".fr-badge--new") } - end - - context "with a demande_seen_at after champ updated_at" do + context "with a demande_seen_at before champ updated_at" do let(:demande_seen_at) { champ1.updated_at - 1.hour } it { is_expected.to have_css(".fr-badge--new") } end + + context "with champ updated_at at depose_at" do + let(:demande_seen_at) { champ1.updated_at - 1.hour } + + before do + champ1.update_columns(value: 'false', updated_at: dossier.depose_at) + end + + it { is_expected.not_to have_css(".fr-badge--new") } + end end end diff --git a/spec/views/shared/dossiers/_demande.html.haml_spec.rb b/spec/views/shared/dossiers/_demande.html.haml_spec.rb index 8475cc469..5760764e2 100644 --- a/spec/views/shared/dossiers/_demande.html.haml_spec.rb +++ b/spec/views/shared/dossiers/_demande.html.haml_spec.rb @@ -48,7 +48,7 @@ describe 'shared/dossiers/demande', type: :view do let(:procedure) { create(:procedure, :published, :with_type_de_champ) } it 'renders the champs' do - dossier.champs_public.each do |champ| + dossier.project_champs_public.each do |champ| expect(subject).to include(champ.libelle) end end @@ -57,7 +57,7 @@ describe 'shared/dossiers/demande', type: :view do context 'when a champ is freshly build' do let(:procedure) { create(:procedure, :published, :with_type_de_champ) } before do - dossier.champs_public.first.destroy + dossier.project_champs_public.first.destroy end it 'renders without error' do diff --git a/spec/views/shared/dossiers/_edit.html.haml_spec.rb b/spec/views/shared/dossiers/_edit.html.haml_spec.rb index c974ecc0d..efccf5c7d 100644 --- a/spec/views/shared/dossiers/_edit.html.haml_spec.rb +++ b/spec/views/shared/dossiers/_edit.html.haml_spec.rb @@ -44,7 +44,7 @@ describe 'shared/dossiers/edit', type: :view do context 'with a single-value list' do let(:types_de_champ_public) { [{ type: :drop_down_list, options:, mandatory: }] } - let(:champ) { dossier.champs_public.first } + let(:champ) { dossier.project_champs_public.first } let(:type_de_champ) { champ.type_de_champ } let(:enabled_options) { type_de_champ.drop_down_options } let(:mandatory) { true } From 274e43c5e6494e004645f6a972ca28fdaea7998a Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Sat, 5 Oct 2024 22:22:04 +0200 Subject: [PATCH 1115/1532] fix(dossier): projected champs should have updated_at --- app/models/concerns/dossier_champs_concern.rb | 2 +- spec/models/concerns/dossier_champs_concern_spec.rb | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/dossier_champs_concern.rb b/app/models/concerns/dossier_champs_concern.rb index 5612b2dc9..f6e3f4b96 100644 --- a/app/models/concerns/dossier_champs_concern.rb +++ b/app/models/concerns/dossier_champs_concern.rb @@ -25,7 +25,7 @@ module DossierChampsConcern check_valid_row_id?(type_de_champ, row_id) champ = champs_by_public_id[type_de_champ.public_id(row_id)] if champ.nil? - type_de_champ.build_champ(dossier: self, row_id:) + type_de_champ.build_champ(dossier: self, row_id:, updated_at: depose_at || created_at) else champ end diff --git a/spec/models/concerns/dossier_champs_concern_spec.rb b/spec/models/concerns/dossier_champs_concern_spec.rb index e4177db5c..08106be52 100644 --- a/spec/models/concerns/dossier_champs_concern_spec.rb +++ b/spec/models/concerns/dossier_champs_concern_spec.rb @@ -66,6 +66,7 @@ RSpec.describe DossierChampsConcern do it { expect(subject.new_record?).to be_truthy expect(subject.is_a?(Champs::TextChamp)).to be_truthy + expect(subject.updated_at).not_to be_nil } context "in repetition" do @@ -76,6 +77,7 @@ RSpec.describe DossierChampsConcern do expect(subject.new_record?).to be_truthy expect(subject.is_a?(Champs::TextChamp)).to be_truthy expect(subject.row_id).to eq(row_id) + expect(subject.updated_at).not_to be_nil } context "invalid row_id" do @@ -99,6 +101,7 @@ RSpec.describe DossierChampsConcern do it { expect(subject.new_record?).to be_truthy expect(subject.is_a?(Champs::TextChamp)).to be_truthy + expect(subject.updated_at).not_to be_nil } end end From e37414342220bcdaa0e8f44e88cbf6e3b26f5882 Mon Sep 17 00:00:00 2001 From: benoitqueyron <72251526+Benoit-MINT@users.noreply.github.com> Date: Wed, 28 Aug 2024 09:33:00 +0200 Subject: [PATCH 1116/1532] front: ajustement de l'espacement du bloc des options --- .../champ_component/champ_component.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml index 4b2fd7062..8bb488d4a 100644 --- a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml +++ b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml @@ -67,7 +67,7 @@ - .flex.justify-start.fr-mt-1w + .flex.justify-start.fr-mt-1w.flex-gap - if type_de_champ.drop_down_list? .flex.column.justify-start.width-33 .cell From c6559577f1b837a5861b9147e3d3f0edc2db0895 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 20 Sep 2024 11:16:38 +0200 Subject: [PATCH 1117/1532] remove old comment --- app/models/column.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/models/column.rb b/app/models/column.rb index 583aa56aa..50ebeab7f 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -14,7 +14,6 @@ class Column @scope = scope @value_column = value_column @filterable = filterable - # We need this for backward compatibility @displayable = displayable end From 3c7521a42849aeae1cf0137951a50f06ee46a292 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 30 Sep 2024 20:48:54 +0200 Subject: [PATCH 1118/1532] extract dossier_id_column, notifications_column --- .../notified_toggle_component.html.haml | 2 +- app/models/concerns/columns_concern.rb | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml b/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml index 0fe1c9b20..84ff21cb9 100644 --- a/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml +++ b/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml @@ -1,4 +1,4 @@ -= form_tag update_sort_instructeur_procedure_path(procedure_id: @procedure.id, column_id: 'notifications/notifications', order: opposite_order), method: :get, data: { controller: 'autosubmit' } do += form_tag update_sort_instructeur_procedure_path(procedure_id: @procedure.id, column_id: @procedure.notifications_column.id, order: opposite_order), method: :get, data: { controller: 'autosubmit' } do .fr-fieldset__element.fr-m-0 .fr-checkbox-group.fr-checkbox-group--sm = check_box_tag :order, opposite_order, active? diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 3f8e11a52..efafecc18 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -14,8 +14,16 @@ module ColumnsConcern columns.concat(types_de_champ_columns) end + def dossier_id_column + Column.new(table: 'self', column: 'id', classname: 'number-col', type: :number) + end + + def notifications_column + Column.new(table: 'notifications', column: 'notifications', label: "notifications", filterable: false) + end + def dossier_columns - common = [Column.new(table: 'self', column: 'id', classname: 'number-col', type: :number), Column.new(table: 'notifications', column: 'notifications', label: "notifications", filterable: false)] + common = [dossier_id_column, notifications_column] dates = ['created_at', 'updated_at', 'depose_at', 'en_construction_at', 'en_instruction_at', 'processed_at'] .map { |column| Column.new(table: 'self', column:, type: :date) } From 3740a79219f48b23b4cdae6f1e7c0b994122f41f Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 7 Oct 2024 21:46:59 +0200 Subject: [PATCH 1119/1532] add procedure_id to column.id --- .../instructeurs/column_picker_component.rb | 2 +- app/models/column.rb | 3 +- .../concerns/addressable_column_concern.rb | 3 +- app/models/concerns/columns_concern.rb | 32 +++++++++---------- app/models/procedure_presentation.rb | 8 ++--- .../repetition_type_de_champ.rb | 4 +-- .../types_de_champ/type_de_champ_base.rb | 3 +- .../_dossiers_filter_tags.html.haml | 2 +- .../column_filter_component_spec.rb | 9 +++--- .../column_picker_component_spec.rb | 8 +++-- .../procedures_controller_spec.rb | 7 ++-- spec/models/concerns/columns_concern_spec.rb | 20 +++++++----- spec/models/export_spec.rb | 4 +-- spec/models/procedure_presentation_spec.rb | 9 ++++-- 14 files changed, 65 insertions(+), 49 deletions(-) diff --git a/app/components/instructeurs/column_picker_component.rb b/app/components/instructeurs/column_picker_component.rb index 3e27ce7ab..92cc524da 100644 --- a/app/components/instructeurs/column_picker_component.rb +++ b/app/components/instructeurs/column_picker_component.rb @@ -12,7 +12,7 @@ class Instructeurs::ColumnPickerComponent < ApplicationComponent def displayable_columns_for_select [ procedure.columns.filter(&:displayable).map { |column| [column.label, column.id] }, - procedure_presentation.displayed_fields.map { Column.new(**_1.deep_symbolize_keys).id } + procedure_presentation.displayed_fields.map { Column.new(**_1.deep_symbolize_keys.merge(procedure_id: procedure.id)).id } ] end end diff --git a/app/models/column.rb b/app/models/column.rb index 50ebeab7f..0add50b3a 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -5,7 +5,8 @@ class Column attr_reader :table, :column, :label, :classname, :type, :scope, :value_column, :filterable, :displayable - def initialize(table:, column:, label: nil, type: :text, value_column: :value, filterable: true, displayable: true, classname: '', scope: '') + def initialize(procedure_id:, table:, column:, label: nil, type: :text, value_column: :value, filterable: true, displayable: true, classname: '', scope: '') + @procedure_id = procedure_id @table = table @column = column @label = label || I18n.t(column, scope: [:activerecord, :attributes, :procedure_presentation, :fields, table]) diff --git a/app/models/concerns/addressable_column_concern.rb b/app/models/concerns/addressable_column_concern.rb index f6fbbed5d..b439d50bc 100644 --- a/app/models/concerns/addressable_column_concern.rb +++ b/app/models/concerns/addressable_column_concern.rb @@ -4,7 +4,7 @@ module AddressableColumnConcern extend ActiveSupport::Concern included do - def columns(displayable: true, prefix: nil) + def columns(procedure_id:, displayable: true, prefix: nil) super.concat([ ["code postal (5 chiffres)", ['postal_code'], :text], ["commune", ['city_name'], :text], @@ -12,6 +12,7 @@ module AddressableColumnConcern ["region", ['region_name'], :enum] ].map do |(label, value_column, type)| Columns::JSONPathColumn.new( + procedure_id:, table: Column::TYPE_DE_CHAMP_TABLE, column: stable_id, label: "#{libelle_with_prefix(prefix)} – #{label}", diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index efafecc18..772f5bc76 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -15,23 +15,23 @@ module ColumnsConcern end def dossier_id_column - Column.new(table: 'self', column: 'id', classname: 'number-col', type: :number) + Column.new(procedure_id: id, table: 'self', column: 'id', classname: 'number-col', type: :number) end def notifications_column - Column.new(table: 'notifications', column: 'notifications', label: "notifications", filterable: false) + Column.new(procedure_id: id, table: 'notifications', column: 'notifications', label: "notifications", filterable: false) end def dossier_columns common = [dossier_id_column, notifications_column] dates = ['created_at', 'updated_at', 'depose_at', 'en_construction_at', 'en_instruction_at', 'processed_at'] - .map { |column| Column.new(table: 'self', column:, type: :date) } + .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date) } non_displayable_dates = ['updated_since', 'depose_since', 'en_construction_since', 'en_instruction_since', 'processed_since'] - .map { |column| Column.new(table: 'self', column:, type: :date, displayable: false) } + .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date, displayable: false) } - states = [Column.new(table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false)] + states = [Column.new(procedure_id: id, table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false)] [common, dates, sva_svr_columns(for_filters: true), non_displayable_dates, states].flatten.compact end @@ -42,12 +42,12 @@ module ColumnsConcern scope = [:activerecord, :attributes, :procedure_presentation, :fields, :self] columns = [ - Column.new(table: 'self', column: 'sva_svr_decision_on', type: :date, + Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_on', type: :date, label: I18n.t("#{sva_svr_decision}_decision_on", scope:), classname: for_filters ? '' : 'sva-col') ] if for_filters - columns << Column.new(table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false, + columns << Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false, label: I18n.t("#{sva_svr_decision}_decision_before", scope:)) end @@ -58,30 +58,30 @@ module ColumnsConcern def standard_columns [ - Column.new(table: 'user', column: 'email'), - Column.new(table: 'followers_instructeurs', column: 'email'), - Column.new(table: 'groupe_instructeur', column: 'id', type: :enum), - Column.new(table: 'avis', column: 'question_answer', filterable: false) # not filterable ? + Column.new(procedure_id: id, table: 'user', column: 'email'), + Column.new(procedure_id: id, table: 'followers_instructeurs', column: 'email'), + Column.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum), + Column.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false) # not filterable ? ] end def individual_columns - ['nom', 'prenom', 'gender'].map { |column| Column.new(table: 'individual', column:) } + ['nom', 'prenom', 'gender'].map { |column| Column.new(procedure_id: id, table: 'individual', column:) } end def moral_columns etablissements = ['entreprise_siren', 'entreprise_forme_juridique', 'entreprise_nom_commercial', 'entreprise_raison_sociale', 'entreprise_siret_siege_social'] - .map { |column| Column.new(table: 'etablissement', column:) } + .map { |column| Column.new(procedure_id: id, table: 'etablissement', column:) } - etablissement_dates = ['entreprise_date_creation'].map { |column| Column.new(table: 'etablissement', column:, type: :date) } + etablissement_dates = ['entreprise_date_creation'].map { |column| Column.new(procedure_id: id, table: 'etablissement', column:, type: :date) } - other = ['siret', 'libelle_naf', 'code_postal'].map { |column| Column.new(table: 'etablissement', column:) } + other = ['siret', 'libelle_naf', 'code_postal'].map { |column| Column.new(procedure_id: id, table: 'etablissement', column:) } [etablissements, etablissement_dates, other].flatten end def types_de_champ_columns - all_revisions_types_de_champ.flat_map(&:columns) + all_revisions_types_de_champ.flat_map { _1.columns(procedure_id: id) } end end end diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 7ac5e26a1..d271c68ac 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -31,9 +31,9 @@ class ProcedurePresentation < ApplicationRecord def displayed_fields_for_headers [ - Column.new(table: 'self', column: 'id', classname: 'number-col'), - *displayed_fields.map { Column.new(**_1.deep_symbolize_keys) }, - Column.new(table: 'self', column: 'state', classname: 'state-col'), + Column.new(procedure_id: procedure.id, table: 'self', column: 'id', classname: 'number-col'), + *displayed_fields.map { Column.new(**_1.deep_symbolize_keys.merge(procedure_id: procedure.id)) }, + Column.new(procedure_id: procedure.id, table: 'self', column: 'state', classname: 'state-col'), *procedure.sva_svr_columns ] end @@ -201,7 +201,7 @@ class ProcedurePresentation < ApplicationRecord .map do |(table, column), filters| values = filters.pluck('value') value_column = filters.pluck('value_column').compact.first || :value - dossier_column = procedure.find_column(id: Column.make_id(table, column)) # hack to find json path columns + dossier_column = procedure.find_column(id: Column.make_id(procedure.id, table, column)) # hack to find json path columns if dossier_column.is_a?(Columns::JSONPathColumn) dossier_column.filtered_ids(dossiers, values) else diff --git a/app/models/types_de_champ/repetition_type_de_champ.rb b/app/models/types_de_champ/repetition_type_de_champ.rb index 163470308..dea997ad9 100644 --- a/app/models/types_de_champ/repetition_type_de_champ.rb +++ b/app/models/types_de_champ/repetition_type_de_champ.rb @@ -27,9 +27,9 @@ class TypesDeChamp::RepetitionTypeDeChamp < TypesDeChamp::TypeDeChampBase ActiveStorage::Filename.new(str.delete('[]*?')).sanitized end - def columns(displayable: true, prefix: nil) + def columns(procedure_id:, displayable: true, prefix: nil) @type_de_champ.procedure .all_revisions_types_de_champ(parent: @type_de_champ) - .flat_map { _1.columns(displayable: false, prefix: libelle) } + .flat_map { _1.columns(procedure_id:, displayable: false, prefix: libelle) } end end diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index 3b75b05da..19d37df0e 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -98,9 +98,10 @@ class TypesDeChamp::TypeDeChampBase end end - def columns(displayable: true, prefix: nil) + def columns(procedure_id:, displayable: true, prefix: nil) [ Column.new( + procedure_id:, table: Column::TYPE_DE_CHAMP_TABLE, column: stable_id.to_s, label: libelle_with_prefix(prefix), diff --git a/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml b/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml index dadfb4478..d6216f80b 100644 --- a/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml +++ b/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml @@ -6,6 +6,6 @@ - filters.each_with_index do |filter, i| - if i > 0 = " ou " - = link_to remove_filter_instructeur_procedure_path(procedure, { statut: statut, column: "#{filter['table']}/#{filter['column']}", value: filter['value'] }), + = link_to remove_filter_instructeur_procedure_path(procedure, { statut: statut, column: Column.make_id(procedure.id, filter['table'], filter['column']), value: filter['value'] }), class: "fr-tag fr-tag--dismiss fr-my-1w", aria: { label: "Retirer le filtre #{filter['column']}" } do = "#{filter['label'].truncate(50)} : #{procedure_presentation.human_value_for_filter(filter)}" diff --git a/spec/components/instructeurs/column_filter_component_spec.rb b/spec/components/instructeurs/column_filter_component_spec.rb index b7f736f42..426c52034 100644 --- a/spec/components/instructeurs/column_filter_component_spec.rb +++ b/spec/components/instructeurs/column_filter_component_spec.rb @@ -5,6 +5,7 @@ describe Instructeurs::ColumnFilterComponent, type: :component do let(:instructeur) { create(:instructeur) } let(:procedure) { create(:procedure, instructeurs: [instructeur]) } + let(:procedure_id) { procedure.id } let(:procedure_presentation) { nil } let(:statut) { nil } @@ -17,8 +18,8 @@ describe Instructeurs::ColumnFilterComponent, type: :component do let(:column) { nil } let(:included_displayable_field) do [ - Column.new(label: 'email', table: 'user', column: 'email'), - Column.new(label: "depose_since", table: "self", column: "depose_since", displayable: false) + Column.new(procedure_id:, label: 'email', table: 'user', column: 'email'), + Column.new(procedure_id:, label: "depose_since", table: "self", column: "depose_since", displayable: false) ] end @@ -26,7 +27,7 @@ describe Instructeurs::ColumnFilterComponent, type: :component do subject { component.filterable_columns_options } - it { is_expected.to eq([["email", "user/email"], ["depose_since", "self/depose_since"]]) } + it { is_expected.to eq([["email", Column.make_id(procedure_id, "user", "email")], ["depose_since", Column.make_id(procedure_id, "self", "depose_since")]]) } end end @@ -45,7 +46,7 @@ describe Instructeurs::ColumnFilterComponent, type: :component do let(:types_de_champ_public) { [{ type: :drop_down_list, libelle: 'Votre ville', options: ['Paris', 'Lyon', 'Marseille'] }] } let(:procedure) { create(:procedure, :published, types_de_champ_public:) } let(:drop_down_stable_id) { procedure.active_revision.types_de_champ.first.stable_id } - let(:column) { Column.new(table: 'type_de_champ', scope: nil, column: drop_down_stable_id) } + let(:column) { Column.new(procedure_id:, table: 'type_de_champ', scope: nil, column: drop_down_stable_id) } it 'find most recent tdc' do is_expected.to eq(['Paris', 'Lyon', 'Marseille']) diff --git a/spec/components/instructeurs/column_picker_component_spec.rb b/spec/components/instructeurs/column_picker_component_spec.rb index 13edeb9a4..92cdf4451 100644 --- a/spec/components/instructeurs/column_picker_component_spec.rb +++ b/spec/components/instructeurs/column_picker_component_spec.rb @@ -4,13 +4,15 @@ describe Instructeurs::ColumnPickerComponent, type: :component do let(:component) { described_class.new(procedure:, procedure_presentation:) } let(:procedure) { create(:procedure) } + let(:procedure_id) { procedure.id } let(:instructeur) { create(:instructeur) } let(:assign_to) { create(:assign_to, procedure: procedure, instructeur: instructeur) } let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) } describe "#displayable_columns_for_select" do - let(:default_user_email) { Column.new(label: 'email', table: 'user', column: 'email') } - let(:excluded_displayable_field) { Column.new(label: "label1", table: "table1", column: "column1", displayable: false) } + let(:default_user_email) { Column.new(procedure_id:, label: 'email', table: 'user', column: 'email') } + let(:excluded_displayable_field) { Column.new(procedure_id:, label: "label1", table: "table1", column: "column1", displayable: false) } + let(:email_column_id) { Column.make_id(procedure_id, 'user', 'email') } subject { component.displayable_columns_for_select } @@ -21,6 +23,6 @@ describe Instructeurs::ColumnPickerComponent, type: :component do ]) end - it { is_expected.to eq([[["email", "user/email"]], ["user/email"]]) } + it { is_expected.to eq([[["email", email_column_id]], [email_column_id]]) } end end diff --git a/spec/controllers/instructeurs/procedures_controller_spec.rb b/spec/controllers/instructeurs/procedures_controller_spec.rb index 52f6bbeb3..bab2a299b 100644 --- a/spec/controllers/instructeurs/procedures_controller_spec.rb +++ b/spec/controllers/instructeurs/procedures_controller_spec.rb @@ -886,12 +886,14 @@ describe Instructeurs::ProceduresController, type: :controller do end it 'can change order' do - expect { get :update_sort, params: { procedure_id: procedure.id, column_id: "individual/nom", order: 'asc' } } + column_id = Column.make_id(procedure.id, "individual", "nom") + expect { get :update_sort, params: { procedure_id: procedure.id, column_id:, order: 'asc' } } .to change { procedure_presentation.sort } .from({ "column" => "notifications", "order" => "desc", "table" => "notifications" }) .to({ "column" => "nom", "order" => "asc", "table" => "individual" }) end end + describe '#add_filter' do let(:instructeur) { create(:instructeur) } let(:procedure) { create(:procedure, :for_individual) } @@ -903,7 +905,8 @@ describe Instructeurs::ProceduresController, type: :controller do end subject do - post :add_filter, params: { procedure_id: procedure.id, column: "individual/nom", value: "n" * 110, statut: "a-suivre" } + column = Column.make_id(procedure.id, "individual", "nom") + post :add_filter, params: { procedure_id: procedure.id, column:, value: "n" * 110, statut: "a-suivre" } end it 'should render the error' do diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index ab13657ce..63ac65622 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -6,6 +6,7 @@ describe ColumnsConcern do context 'when the procedure can have a SIRET number' do let(:procedure) { create(:procedure, types_de_champ_public:, types_de_champ_private:) } + let(:procedure_id) { procedure.id } let(:tdc_1) { procedure.active_revision.types_de_champ_public[0] } let(:tdc_2) { procedure.active_revision.types_de_champ_public[1] } let(:tdc_private_1) { procedure.active_revision.types_de_champ_private[0] } @@ -43,7 +44,7 @@ describe ColumnsConcern do { label: tdc_2.libelle, table: 'type_de_champ', column: tdc_2.stable_id.to_s, classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, { label: tdc_private_1.libelle, table: 'type_de_champ', column: tdc_private_1.stable_id.to_s, classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, { label: tdc_private_2.libelle, table: 'type_de_champ', column: tdc_private_2.stable_id.to_s, classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true } - ].map { Column.new(**_1) } + ].map { Column.new(**_1.merge(procedure_id:)) } } context 'with explication/header_sections' do @@ -67,10 +68,11 @@ describe ColumnsConcern do end context 'when the procedure is for individuals' do - let(:name_field) { Column.new(label: "Prénom", table: "individual", column: "prenom", classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } - let(:surname_field) { Column.new(label: "Nom", table: "individual", column: "nom", classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } - let(:gender_field) { Column.new(label: "Civilité", table: "individual", column: "gender", classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } + let(:name_field) { Column.new(procedure_id:, label: "Prénom", table: "individual", column: "prenom", classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } + let(:surname_field) { Column.new(procedure_id:, label: "Nom", table: "individual", column: "nom", classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } + let(:gender_field) { Column.new(procedure_id:, label: "Civilité", table: "individual", column: "gender", classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } let(:procedure) { create(:procedure, :for_individual) } + let(:procedure_id) { procedure.id } let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) } it { is_expected.to include(name_field, surname_field, gender_field) } @@ -78,20 +80,22 @@ describe ColumnsConcern do context 'when the procedure is sva' do let(:procedure) { create(:procedure, :for_individual, :sva) } + let(:procedure_id) { procedure.id } let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) } - let(:decision_on) { Column.new(label: "Date décision SVA", table: "self", column: "sva_svr_decision_on", classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true) } - let(:decision_before_field) { Column.new(label: "Date décision SVA avant", table: "self", column: "sva_svr_decision_before", classname: '', displayable: false, type: :date, scope: '', value_column: :value, filterable: true) } + let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVA", table: "self", column: "sva_svr_decision_on", classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true) } + let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVA avant", table: "self", column: "sva_svr_decision_before", classname: '', displayable: false, type: :date, scope: '', value_column: :value, filterable: true) } it { is_expected.to include(decision_on, decision_before_field) } end context 'when the procedure is svr' do let(:procedure) { create(:procedure, :for_individual, :svr) } + let(:procedure_id) { procedure.id } let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) } - let(:decision_on) { Column.new(label: "Date décision SVR", table: "self", column: "sva_svr_decision_on", classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true) } - let(:decision_before_field) { Column.new(label: "Date décision SVR avant", table: "self", column: "sva_svr_decision_before", classname: '', displayable: false, type: :date, scope: '', value_column: :value, filterable: true) } + let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVR", table: "self", column: "sva_svr_decision_on", classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true) } + let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVR avant", table: "self", column: "sva_svr_decision_before", classname: '', displayable: false, type: :date, scope: '', value_column: :value, filterable: true) } it { is_expected.to include(decision_on, decision_before_field) } end diff --git a/spec/models/export_spec.rb b/spec/models/export_spec.rb index c1316dfe7..f9e34a47b 100644 --- a/spec/models/export_spec.rb +++ b/spec/models/export_spec.rb @@ -94,7 +94,7 @@ RSpec.describe Export, type: :model do let(:instructeur) { create(:instructeur) } let!(:gi_1) { create(:groupe_instructeur, procedure: procedure, instructeurs: [instructeur]) } let!(:pp) { gi_1.instructeurs.first.procedure_presentation_and_errors_for_procedure_id(procedure.id).first } - before { pp.add_filter('tous', 'self/created_at', '10/12/2021') } + before { pp.add_filter('tous', Column.make_id(procedure.id, 'self', 'created_at'), '10/12/2021') } context 'with procedure_presentation having different filters' do it 'works once' do @@ -105,7 +105,7 @@ RSpec.describe Export, type: :model do it 'works once, changes procedure_presentation, recreate a new' do expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) } .to change { Export.count }.by(1) - pp.add_filter('tous', 'self/updated_at', '10/12/2021') + pp.add_filter('tous', Column.make_id(procedure.id, 'self', 'updated_at'), '10/12/2021') expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) } .to change { Export.count }.by(1) end diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index 0ae5de859..f1f640a64 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -582,7 +582,7 @@ describe ProcedurePresentation do context 'for type_de_champ using AddressableColumnConcern' do let(:types_de_champ_public) { [{ type: :rna, stable_id: 1 }] } let(:type_de_champ) { procedure.active_revision.types_de_champ.first } - let(:available_columns) { type_de_champ.columns } + let(:available_columns) { type_de_champ.columns(procedure_id: procedure.id) } let(:column) { available_columns.find { _1.value_column == value_column_searched } } let(:filter) { [column.to_json.merge({ "value" => value })] } let(:kept_dossier) { create(:dossier, procedure: procedure) } @@ -611,6 +611,7 @@ describe ProcedurePresentation do create(:dossier, procedure: procedure).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "departement_code" => "unknown" }) end it { is_expected.to contain_exactly(kept_dossier.id) } + it 'describes column' do expect(column.type).to eq(:enum) expect(column.options_for_select.first).to eq(["99 – Etranger", "99"]) @@ -865,9 +866,10 @@ describe ProcedurePresentation do context 'when type_de_champ text' do let(:filters) { { "suivis" => [] } } + let(:column_id) { Column.make_id(procedure.id, 'type_de_champ', first_type_de_champ_id) } it 'should passthrough value' do - procedure_presentation.add_filter("suivis", "type_de_champ/#{first_type_de_champ_id}", "Oui") + procedure_presentation.add_filter("suivis", column_id, "Oui") expect(procedure_presentation.filters).to eq({ "suivis" => [ @@ -879,10 +881,11 @@ describe ProcedurePresentation do context 'when type_de_champ departements' do let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :departements }]) } + let(:column_id) { Column.make_id(procedure.id, 'type_de_champ', first_type_de_champ_id) } let(:filters) { { "suivis" => [] } } it 'should set value_column' do - procedure_presentation.add_filter("suivis", "type_de_champ/#{first_type_de_champ_id}", "13") + procedure_presentation.add_filter("suivis", column_id, "13") expect(procedure_presentation.filters).to eq({ "suivis" => [ From a8b41e90cc64f0901277676318f3e57b0ac780d2 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 7 Oct 2024 16:57:54 +0200 Subject: [PATCH 1120/1532] remove make_id --- app/models/column.rb | 4 ---- app/models/procedure_presentation.rb | 2 +- .../procedures/_dossiers_filter_tags.html.haml | 2 +- .../instructeurs/column_filter_component_spec.rb | 2 +- .../instructeurs/column_picker_component_spec.rb | 2 +- .../instructeurs/procedures_controller_spec.rb | 4 ++-- spec/models/export_spec.rb | 4 ++-- spec/models/procedure_presentation_spec.rb | 9 +++++---- 8 files changed, 13 insertions(+), 16 deletions(-) diff --git a/app/models/column.rb b/app/models/column.rb index 0add50b3a..240a11937 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -22,10 +22,6 @@ class Column "#{table}/#{column}" end - def self.make_id(table, column) - "#{table}/#{column}" - end - def ==(other) other.to_json == to_json end diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index d271c68ac..de6d162d4 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -201,7 +201,7 @@ class ProcedurePresentation < ApplicationRecord .map do |(table, column), filters| values = filters.pluck('value') value_column = filters.pluck('value_column').compact.first || :value - dossier_column = procedure.find_column(id: Column.make_id(procedure.id, table, column)) # hack to find json path columns + dossier_column = procedure.find_column(h_id: { procedure_id: procedure.id, column_id: "#{table}/#{column}" }) # hack to find json path columns if dossier_column.is_a?(Columns::JSONPathColumn) dossier_column.filtered_ids(dossiers, values) else diff --git a/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml b/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml index d6216f80b..d1b57d57e 100644 --- a/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml +++ b/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml @@ -6,6 +6,6 @@ - filters.each_with_index do |filter, i| - if i > 0 = " ou " - = link_to remove_filter_instructeur_procedure_path(procedure, { statut: statut, column: Column.make_id(procedure.id, filter['table'], filter['column']), value: filter['value'] }), + = link_to remove_filter_instructeur_procedure_path(procedure, { statut: statut, column: { procedure_id: procedure.id, column_id: filter['table'] + "/" + filter['column'] }.to_json, value: filter['value'] }), class: "fr-tag fr-tag--dismiss fr-my-1w", aria: { label: "Retirer le filtre #{filter['column']}" } do = "#{filter['label'].truncate(50)} : #{procedure_presentation.human_value_for_filter(filter)}" diff --git a/spec/components/instructeurs/column_filter_component_spec.rb b/spec/components/instructeurs/column_filter_component_spec.rb index 426c52034..8c31094dd 100644 --- a/spec/components/instructeurs/column_filter_component_spec.rb +++ b/spec/components/instructeurs/column_filter_component_spec.rb @@ -27,7 +27,7 @@ describe Instructeurs::ColumnFilterComponent, type: :component do subject { component.filterable_columns_options } - it { is_expected.to eq([["email", Column.make_id(procedure_id, "user", "email")], ["depose_since", Column.make_id(procedure_id, "self", "depose_since")]]) } + it { is_expected.to eq([["email", included_displayable_field.first.id], ["depose_since", included_displayable_field.second.id]]) } end end diff --git a/spec/components/instructeurs/column_picker_component_spec.rb b/spec/components/instructeurs/column_picker_component_spec.rb index 92cdf4451..ac2737f01 100644 --- a/spec/components/instructeurs/column_picker_component_spec.rb +++ b/spec/components/instructeurs/column_picker_component_spec.rb @@ -12,7 +12,7 @@ describe Instructeurs::ColumnPickerComponent, type: :component do describe "#displayable_columns_for_select" do let(:default_user_email) { Column.new(procedure_id:, label: 'email', table: 'user', column: 'email') } let(:excluded_displayable_field) { Column.new(procedure_id:, label: "label1", table: "table1", column: "column1", displayable: false) } - let(:email_column_id) { Column.make_id(procedure_id, 'user', 'email') } + let(:email_column_id) { default_user_email.id } subject { component.displayable_columns_for_select } diff --git a/spec/controllers/instructeurs/procedures_controller_spec.rb b/spec/controllers/instructeurs/procedures_controller_spec.rb index bab2a299b..d24a519ed 100644 --- a/spec/controllers/instructeurs/procedures_controller_spec.rb +++ b/spec/controllers/instructeurs/procedures_controller_spec.rb @@ -886,7 +886,7 @@ describe Instructeurs::ProceduresController, type: :controller do end it 'can change order' do - column_id = Column.make_id(procedure.id, "individual", "nom") + column_id = procedure.find_column(label: "Nom").id expect { get :update_sort, params: { procedure_id: procedure.id, column_id:, order: 'asc' } } .to change { procedure_presentation.sort } .from({ "column" => "notifications", "order" => "desc", "table" => "notifications" }) @@ -905,7 +905,7 @@ describe Instructeurs::ProceduresController, type: :controller do end subject do - column = Column.make_id(procedure.id, "individual", "nom") + column = procedure.find_column(label: "Nom").id post :add_filter, params: { procedure_id: procedure.id, column:, value: "n" * 110, statut: "a-suivre" } end diff --git a/spec/models/export_spec.rb b/spec/models/export_spec.rb index f9e34a47b..e960acb8f 100644 --- a/spec/models/export_spec.rb +++ b/spec/models/export_spec.rb @@ -94,7 +94,7 @@ RSpec.describe Export, type: :model do let(:instructeur) { create(:instructeur) } let!(:gi_1) { create(:groupe_instructeur, procedure: procedure, instructeurs: [instructeur]) } let!(:pp) { gi_1.instructeurs.first.procedure_presentation_and_errors_for_procedure_id(procedure.id).first } - before { pp.add_filter('tous', Column.make_id(procedure.id, 'self', 'created_at'), '10/12/2021') } + before { pp.add_filter('tous', procedure.find_column(label: 'Créé le').id, '10/12/2021') } context 'with procedure_presentation having different filters' do it 'works once' do @@ -105,7 +105,7 @@ RSpec.describe Export, type: :model do it 'works once, changes procedure_presentation, recreate a new' do expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) } .to change { Export.count }.by(1) - pp.add_filter('tous', Column.make_id(procedure.id, 'self', 'updated_at'), '10/12/2021') + pp.add_filter('tous', procedure.find_column(label: 'Mis à jour le').id, '10/12/2021') expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) } .to change { Export.count }.by(1) end diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index f1f640a64..3197c2c19 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -850,10 +850,11 @@ describe ProcedurePresentation do let(:filters) { { "suivis" => [] } } context 'when type_de_champ yes_no' do - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :yes_no }]) } + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :yes_no, libelle: 'oui ou non' }]) } it 'should downcase and transform value' do - procedure_presentation.add_filter("suivis", "type_de_champ/#{first_type_de_champ_id}", +"Oui") + column_id = procedure.find_column(label: 'oui ou non').id + procedure_presentation.add_filter("suivis", column_id, "Oui") expect(procedure_presentation.filters).to eq({ "suivis" => @@ -866,7 +867,7 @@ describe ProcedurePresentation do context 'when type_de_champ text' do let(:filters) { { "suivis" => [] } } - let(:column_id) { Column.make_id(procedure.id, 'type_de_champ', first_type_de_champ_id) } + let(:column_id) { procedure.find_column(label: first_type_de_champ.libelle).id } it 'should passthrough value' do procedure_presentation.add_filter("suivis", column_id, "Oui") @@ -881,7 +882,7 @@ describe ProcedurePresentation do context 'when type_de_champ departements' do let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :departements }]) } - let(:column_id) { Column.make_id(procedure.id, 'type_de_champ', first_type_de_champ_id) } + let(:column_id) { procedure.find_column(label: first_type_de_champ.libelle).id } let(:filters) { { "suivis" => [] } } it 'should set value_column' do From e3697bd9761641e9efe8bfecde68fe652765541e Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 7 Oct 2024 15:01:40 +0200 Subject: [PATCH 1121/1532] colonne.id = { procedure_id:, column_id: }.to_json because: - id should be a string as other id - id need procedure_id to allow ColumnType.deserialize(id) -> Column as the columns are built by a procedure --- .../instructeurs/procedures_controller.rb | 2 +- app/models/column.rb | 10 +++------- app/models/concerns/columns_concern.rb | 5 ++++- app/models/procedure_presentation.rb | 14 +++++++++----- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index f8a332189..302bde7b1 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -158,7 +158,7 @@ module Instructeurs @statut = statut @procedure = procedure @procedure_presentation = procedure_presentation - @column = procedure.find_column(id: params[:column]) + @column = procedure.find_column(h_id: JSON.parse(params[:column], symbolize_names: true)) end def remove_filter diff --git a/app/models/column.rb b/app/models/column.rb index 240a11937..a7f74fa69 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -18,13 +18,9 @@ class Column @displayable = displayable end - def id - "#{table}/#{column}" - end - - def ==(other) - other.to_json == to_json - end + def id = h_id.to_json + def h_id = { procedure_id: @procedure_id, column_id: "#{table}/#{column}" } + def ==(other) = h_id == other.h_id # using h_id instead of id to avoid inversion of keys def to_json { diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 772f5bc76..ea88cb73a 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -4,7 +4,10 @@ module ColumnsConcern extend ActiveSupport::Concern included do - def find_column(id:) = columns.find { |f| f.id == id } + def find_column(h_id: nil, label: nil) + return columns.find { _1.h_id == h_id } if h_id.present? + return columns.find { _1.label == label } if label.present? + end def columns columns = dossier_columns diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index de6d162d4..7cd4e0f48 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -81,8 +81,10 @@ class ProcedurePresentation < ApplicationRecord end def add_filter(statut, column_id, value) + h_id = JSON.parse(column_id, symbolize_names: true) + if value.present? - column = procedure.find_column(id: column_id) + column = procedure.find_column(h_id:) case column.table when TYPE_DE_CHAMP @@ -103,7 +105,8 @@ class ProcedurePresentation < ApplicationRecord end def remove_filter(statut, column_id, value) - column = procedure.find_column(id: column_id) + h_id = JSON.parse(column_id, symbolize_names: true) + column = procedure.find_column(h_id:) updated_filters = filters.dup updated_filters[statut] = filters[statut].reject do |filter| @@ -114,8 +117,8 @@ class ProcedurePresentation < ApplicationRecord end def update_displayed_fields(column_ids) - column_ids = Array.wrap(column_ids) - columns = column_ids.map { |id| procedure.find_column(id:) } + h_ids = Array.wrap(column_ids).map { |id| JSON.parse(id, symbolize_names: true) } + columns = h_ids.map { |h_id| procedure.find_column(h_id:) } update!(displayed_fields: columns) @@ -125,7 +128,8 @@ class ProcedurePresentation < ApplicationRecord end def update_sort(column_id, order) - column = procedure.find_column(id: column_id) + h_id = JSON.parse(column_id, symbolize_names: true) + column = procedure.find_column(h_id:) update!(sort: { TABLE => column.table, From 3d79c6176e143f49132876c616326f8dec44fa12 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 20 Sep 2024 11:12:52 +0200 Subject: [PATCH 1122/1532] add columns_ids to procedure_presentations table --- app/models/procedure_presentation.rb | 11 +++++++++++ ..._add_column_ids_to_procedure_presentations.rb | 16 ++++++++++++++++ db/schema.rb | 10 ++++++++++ 3 files changed, 37 insertions(+) create mode 100644 db/migrate/20240920090848_add_column_ids_to_procedure_presentations.rb diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 7cd4e0f48..5939f68cf 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -29,6 +29,17 @@ class ProcedurePresentation < ApplicationRecord validate :check_filters_max_length validate :check_filters_max_integer + attribute :sorted_column, :jsonb + + attribute :a_suivre_filters, :jsonb, array: true + attribute :suivis_filters, :jsonb, array: true + attribute :traites_filters, :jsonb, array: true + attribute :tous_filters, :jsonb, array: true + attribute :supprimes_filters, :jsonb, array: true + attribute :supprimes_recemment_filters, :jsonb, array: true + attribute :expirant_filters, :jsonb, array: true + attribute :archives_filters, :jsonb, array: true + def displayed_fields_for_headers [ Column.new(procedure_id: procedure.id, table: 'self', column: 'id', classname: 'number-col'), diff --git a/db/migrate/20240920090848_add_column_ids_to_procedure_presentations.rb b/db/migrate/20240920090848_add_column_ids_to_procedure_presentations.rb new file mode 100644 index 000000000..dd2a98142 --- /dev/null +++ b/db/migrate/20240920090848_add_column_ids_to_procedure_presentations.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddColumnIdsToProcedurePresentations < ActiveRecord::Migration[7.0] + def change + add_column :procedure_presentations, :displayed_columns, :jsonb, array: true, default: [], null: false + add_column :procedure_presentations, :tous_filters, :jsonb, array: true, default: [], null: false + add_column :procedure_presentations, :suivis_filters, :jsonb, array: true, default: [], null: false + add_column :procedure_presentations, :traites_filters, :jsonb, array: true, default: [], null: false + add_column :procedure_presentations, :a_suivre_filters, :jsonb, array: true, default: [], null: false + add_column :procedure_presentations, :archives_filters, :jsonb, array: true, default: [], null: false + add_column :procedure_presentations, :expirant_filters, :jsonb, array: true, default: [], null: false + add_column :procedure_presentations, :supprimes_filters, :jsonb, array: true, default: [], null: false + add_column :procedure_presentations, :supprimes_recemment_filters, :jsonb, array: true, default: [], null: false + add_column :procedure_presentations, :sorted_column, :jsonb + end +end diff --git a/db/schema.rb b/db/schema.rb index 6998464ea..a628a89ea 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -866,11 +866,21 @@ ActiveRecord::Schema[7.0].define(version: 2024_09_23_125619) do end create_table "procedure_presentations", id: :serial, force: :cascade do |t| + t.jsonb "a_suivre_filters", default: [], null: false, array: true + t.jsonb "archives_filters", default: [], null: false, array: true t.integer "assign_to_id" t.datetime "created_at", precision: nil + t.jsonb "displayed_columns", default: [], null: false, array: true t.jsonb "displayed_fields", default: [{"label"=>"Demandeur", "table"=>"user", "column"=>"email"}], null: false + t.jsonb "expirant_filters", default: [], null: false, array: true t.jsonb "filters", default: {"tous"=>[], "suivis"=>[], "traites"=>[], "a-suivre"=>[], "archives"=>[], "expirant"=>[], "supprimes"=>[]}, null: false t.jsonb "sort", default: {"order"=>"desc", "table"=>"notifications", "column"=>"notifications"}, null: false + t.jsonb "sorted_column" + t.jsonb "suivis_filters", default: [], null: false, array: true + t.jsonb "supprimes_filters", default: [], null: false, array: true + t.jsonb "supprimes_recemment_filters", default: [], null: false, array: true + t.jsonb "tous_filters", default: [], null: false, array: true + t.jsonb "traites_filters", default: [], null: false, array: true t.datetime "updated_at", precision: nil t.index ["assign_to_id"], name: "index_procedure_presentations_on_assign_to_id", unique: true end From 0abee083295bc4c61be915ced19e3548121dc3ec Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 7 Oct 2024 18:10:08 +0200 Subject: [PATCH 1123/1532] add filters for --- app/models/procedure_presentation.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 5939f68cf..a14ad5faf 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -40,6 +40,12 @@ class ProcedurePresentation < ApplicationRecord attribute :expirant_filters, :jsonb, array: true attribute :archives_filters, :jsonb, array: true + def filters_for(statut) + send(filters_name_for(statut)) + end + + def filters_name_for(statut) = statut.tr('-', '_').then { "#{_1}_filters" } + def displayed_fields_for_headers [ Column.new(procedure_id: procedure.id, table: 'self', column: 'id', classname: 'number-col'), From a7ebe235040f15d82c510424201fb3515af749f4 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 7 Oct 2024 15:22:27 +0200 Subject: [PATCH 1124/1532] migrate procedure_presentation to column --- ...ate_procedure_presentation_to_columns.rake | 49 +++++++++++++++ ...edure_presentation_to_columns.rake_spec.rb | 60 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake create mode 100644 spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb diff --git a/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake b/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake new file mode 100644 index 000000000..56236afe1 --- /dev/null +++ b/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +namespace :after_party do + desc 'Deployment task: migrate_procedure_presentation_to_columns' + task migrate_procedure_presentation_to_columns: :environment do + total = ProcedurePresentation.count + + progress = ProgressReport.new(total) + + ProcedurePresentation.find_each do |presentation| + procedure_id = presentation.procedure.id + + presentation.displayed_columns = presentation.displayed_fields + .filter(&:present?) + .map { Column.new(**_1.deep_symbolize_keys.merge(procedure_id:)) } + .map(&:h_id) + + sort = presentation.sort + + presentation.sorted_column = { + 'order' => sort['order'], + 'id' => make_id(procedure_id, sort['table'], sort['column']) + } + + presentation.filters.each do |key, filters| + raw_columns = filters.map do + { + id: make_id(procedure_id, _1['table'], _1['column']), + filter: _1['value'] + } + end + + presentation.send("#{presentation.filters_name_for(key)}=", raw_columns) + end + + presentation.save!(validate: false) + progress.inc + end + + AfterParty::TaskRecord + .create version: AfterParty::TaskRecorder.new(__FILE__).timestamp + end + + private + + def make_id(procedure_id, table, column) + { procedure_id:, column_id: "#{table}/#{column}" } + end +end diff --git a/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb b/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb new file mode 100644 index 000000000..6545cf027 --- /dev/null +++ b/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +describe '20240920130741_migrate_procedure_presentation_to_columns.rake' do + let(:rake_task) { Rake::Task['after_party:migrate_procedure_presentation_to_columns'] } + + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :text }]) } + let(:instructeur) { create(:instructeur) } + let(:assign_to) { create(:assign_to, procedure: procedure, instructeur: instructeur) } + let(:stable_id) { procedure.active_revision.types_de_champ.first.stable_id } + let!(:procedure_presentation) do + displayed_fields = [ + { "table" => "etablissement", "column" => "entreprise_raison_sociale" }, + { "table" => "type_de_champ", "column" => stable_id.to_s } + ] + + sort = { "order" => "desc", "table" => "self", "column" => "en_construction_at" } + + filters = { + "tous" => [], + "suivis" => [], + "traites" => [{ "label" => "Libellé NAF", "table" => "etablissement", "value" => "Administration publique générale", "column" => "libelle_naf", "value_column" => "value" }], + "a-suivre" => [], + "archives" => [], + "expirant" => [], + "supprimes" => [], + "supprimes_recemment" => [] + } + + create(:procedure_presentation, assign_to:, displayed_fields:, filters:, sort:) + end + + before do + rake_task.invoke + + procedure_presentation.reload + end + + it 'populates the columns' do + procedure_id = procedure.id + + expect(procedure_presentation.displayed_columns).to eq([ + { "procedure_id" => procedure_id, "column_id" => "etablissement/entreprise_raison_sociale" }, + { "procedure_id" => procedure_id, "column_id" => "type_de_champ/#{stable_id}" } + ]) + + order, column_id = procedure_presentation + .sorted_column + .then { |sorted| [sorted['order'], sorted['id']] } + + expect(order).to eq('desc') + expect(column_id).to eq("procedure_id" => procedure_id, "column_id" => "self/en_construction_at") + + expect(procedure_presentation.tous_filters).to eq([]) + + traites = procedure_presentation.traites_filters + .map { [_1['id'], _1['filter']] } + + expect(traites).to eq([[{ "column_id" => "etablissement/libelle_naf", "procedure_id" => procedure_id }, "Administration publique générale"]]) + end +end From 98c2b7e954c21a27f0cd09e91cdc44ab25f9da9c Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 7 Oct 2024 22:02:43 +0200 Subject: [PATCH 1125/1532] update_sort double write --- app/models/procedure_presentation.rb | 17 ++++++++++++----- spec/models/procedure_presentation_spec.rb | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index a14ad5faf..59b7470c6 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -147,12 +147,19 @@ class ProcedurePresentation < ApplicationRecord def update_sort(column_id, order) h_id = JSON.parse(column_id, symbolize_names: true) column = procedure.find_column(h_id:) + order = order.presence || opposite_order_for(column.table, column.column) - update!(sort: { - TABLE => column.table, - COLUMN => column.column, - ORDER => order.presence || opposite_order_for(column.table, column.column) - }) + update!( + sort: { + TABLE => column.table, + COLUMN => column.column, + ORDER => order + }, + sorted_column: { + order:, + id: h_id + } + ) end def opposite_order_for(table, column) diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index 3197c2c19..87892f1aa 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -943,4 +943,22 @@ describe ProcedurePresentation do end end end + + describe '#update_sort' do + let(:procedure_presentation) { create(:procedure_presentation, assign_to:) } + + subject do + column_id = procedure.find_column(label: 'En construction le').id + procedure_presentation.update_sort(column_id, 'asc') + end + + it 'should update sort and order' do + expect(procedure_presentation.sorted_column).to be_nil + + subject + + expect(procedure_presentation.sorted_column['id']).to eq("column_id" => "self/en_construction_at", "procedure_id" => procedure.id) + expect(procedure_presentation.sorted_column['order']).to eq('asc') + end + end end From 5f6d8e93ca1053e34ba84f74cb0539aea87d9c32 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 7 Oct 2024 22:04:44 +0200 Subject: [PATCH 1126/1532] update_display double write --- app/models/procedure.rb | 8 ------ app/models/procedure_presentation.rb | 8 ++++-- spec/models/procedure_presentation_spec.rb | 29 ++++++++++++++++++++++ spec/models/procedure_spec.rb | 4 --- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 168c486ec..10377d4ff 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -613,14 +613,6 @@ class Procedure < ApplicationRecord end end - def self.default_sort - { - 'table' => 'self', - 'column' => 'id', - 'order' => 'desc' - } - end - def whitelist! touch(:whitelisted_at) end diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 59b7470c6..1d2134e9c 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -137,10 +137,14 @@ class ProcedurePresentation < ApplicationRecord h_ids = Array.wrap(column_ids).map { |id| JSON.parse(id, symbolize_names: true) } columns = h_ids.map { |h_id| procedure.find_column(h_id:) } - update!(displayed_fields: columns) + update!( + displayed_fields: columns, + displayed_columns: columns.map(&:h_id) + ) if !sort_to_column_id(sort).in?(column_ids) - update!(sort: Procedure.default_sort) + default_column_id = procedure.dossier_id_column.id + update_sort(default_column_id, "desc") end end diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index 87892f1aa..5754b80da 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -944,6 +944,35 @@ describe ProcedurePresentation do end end + describe '#update_displayed_fields' do + let(:procedure_presentation) do + create(:procedure_presentation, assign_to:).tap do |pp| + pp.update_sort(procedure.find_column(label: 'Demandeur').id, 'desc') + end + end + + subject do + procedure_presentation.update_displayed_fields([ + procedure.find_column(label: 'En construction le').id, + procedure.find_column(label: 'Mis à jour le').id + ]) + end + + it 'should update displayed_fields' do + expect(procedure_presentation.displayed_columns).to eq([]) + + subject + + expect(procedure_presentation.displayed_columns).to eq([ + { "column_id" => "self/en_construction_at", "procedure_id" => procedure.id }, + { "column_id" => "self/updated_at", "procedure_id" => procedure.id } + ]) + + expect(procedure_presentation.sorted_column['id']).to eq("column_id" => "self/id", "procedure_id" => procedure.id) + expect(procedure_presentation.sorted_column['order']).to eq('desc') + end + end + describe '#update_sort' do let(:procedure_presentation) { create(:procedure_presentation, assign_to:) } diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index cda2f8330..4f580d727 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -1450,10 +1450,6 @@ describe Procedure do end end - describe ".default_sort" do - it { expect(Procedure.default_sort).to eq({ "table" => "self", "column" => "id", "order" => "desc" }) } - end - describe "#organisation_name" do subject { procedure.organisation_name } context 'when the procedure has a service (and no organization)' do From 870d67e84436b12c820cab56953de3d49737c4ce Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 7 Oct 2024 22:05:31 +0200 Subject: [PATCH 1127/1532] add_filter double write --- app/models/procedure_presentation.rb | 1 + spec/models/procedure_presentation_spec.rb | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 1d2134e9c..d606554c8 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -117,6 +117,7 @@ class ProcedurePresentation < ApplicationRecord 'value' => value } + filters_for(statut) << { id: h_id, filter: value } update(filters: updated_filters) end end diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index 5754b80da..f02e8b97c 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -862,6 +862,10 @@ describe ProcedurePresentation do { "label" => first_type_de_champ.libelle, "table" => "type_de_champ", "column" => first_type_de_champ_id, "value" => "true", "value_column" => "value" } ] }) + + suivis = procedure_presentation.suivis_filters.map { [_1['id'], _1['filter']] } + + expect(suivis).to eq([[{ "column_id" => "type_de_champ/#{first_type_de_champ_id}", "procedure_id" => procedure.id }, "true"]]) end end From a418cf6632e6e7ff4aefd097ee7d4312afdfe054 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 7 Oct 2024 21:59:49 +0200 Subject: [PATCH 1128/1532] remove_filter double_write --- app/models/procedure_presentation.rb | 3 +++ spec/models/procedure_presentation_spec.rb | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index d606554c8..6607f8adc 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -131,6 +131,9 @@ class ProcedurePresentation < ApplicationRecord filter.values_at(TABLE, COLUMN, 'value') == [column.table, column.column, value] end + collection = filters_for(statut) + collection.delete(collection.find { sym_h = _1.deep_symbolize_keys; sym_h[:id] == h_id && sym_h[:filter] == value }) + update!(filters: updated_filters) end diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index f02e8b97c..6fbdbf9c2 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -901,6 +901,26 @@ describe ProcedurePresentation do end end + describe "#remove_filter" do + let(:filters) { { "suivis" => [] } } + let(:email_column_id) { procedure.find_column(label: 'Demandeur').id } + + before do + procedure_presentation.add_filter("suivis", email_column_id, "a@a.com") + end + + it 'should remove filter' do + expect(procedure_presentation.filters).to eq({ "suivis" => [{ "column" => "email", "label" => "Demandeur", "table" => "user", "value" => "a@a.com", "value_column" => "value" }] }) + expect(procedure_presentation.suivis_filters).to eq([{ "filter" => "a@a.com", "id" => { "column_id" => "user/email", "procedure_id" => procedure.id } }]) + + procedure_presentation.remove_filter("suivis", email_column_id, "a@a.com") + procedure_presentation.reload + + expect(procedure_presentation.filters).to eq({ "suivis" => [] }) + expect(procedure_presentation.suivis_filters).to eq([]) + end + end + describe '#filtered_sorted_ids' do let(:procedure_presentation) { create(:procedure_presentation, assign_to:) } let(:dossier_1) { create(:dossier) } From d5a722c1439179abfef554b704b9f4909b33a567 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 25 Sep 2024 17:55:11 +0200 Subject: [PATCH 1129/1532] weird frozen_string bug --- app/models/types_de_champ/yes_no_type_de_champ.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/models/types_de_champ/yes_no_type_de_champ.rb b/app/models/types_de_champ/yes_no_type_de_champ.rb index 5c1870195..4e500a6fd 100644 --- a/app/models/types_de_champ/yes_no_type_de_champ.rb +++ b/app/models/types_de_champ/yes_no_type_de_champ.rb @@ -12,13 +12,13 @@ class TypesDeChamp::YesNoTypeDeChamp < TypesDeChamp::CheckboxTypeDeChamp end def human_to_filter(human_value) - human_value.downcase! - if human_value == "oui" + downcased = human_value.downcase + if downcased == "oui" "true" - elsif human_value == "non" + elsif downcased == "non" "false" else - human_value + downcased end end From 5cba847d1005cc1a93f89055b720a89f160ace1b Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 8 Oct 2024 11:10:10 +0200 Subject: [PATCH 1130/1532] perf(Instructeurs/Dossiers#demande): remove n+1 --- app/models/dossier_preloader.rb | 2 +- app/models/type_de_champ.rb | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/models/dossier_preloader.rb b/app/models/dossier_preloader.rb index 6882c23c9..23b18fd41 100644 --- a/app/models/dossier_preloader.rb +++ b/app/models/dossier_preloader.rb @@ -39,7 +39,7 @@ class DossierPreloader def revisions(pj_template: false) @revisions ||= ProcedureRevision.where(id: @dossiers.pluck(:revision_id).uniq) - .includes(types_de_champ_public: [], types_de_champ_private: [], types_de_champ: pj_template ? { piece_justificative_template_attachment: :blob } : []) + .includes(revision_types_de_champ: { parent: :type_de_champ }, types_de_champ_public: [], types_de_champ_private: [], types_de_champ: pj_template ? { piece_justificative_template_attachment: :blob } : []) .index_by(&:id) end diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 015721c60..559f621b8 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -526,7 +526,8 @@ class TypeDeChamp < ApplicationRecord end def level_for_revision(revision) - rtdc = revision.revision_types_de_champ.includes(:type_de_champ, parent: :type_de_champ).find { |rtdc| rtdc.stable_id == stable_id } + rtdc = revision.revision_types_de_champ.find { |rtdc| rtdc.stable_id == stable_id } + if rtdc.child? header_section_level_value.to_i + rtdc.parent.type_de_champ.current_section_level(revision) elsif header_section_level_value From ab8ac78ccb46446f6bed23c8fff0623bf9c1141e Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 3 Oct 2024 11:13:33 +0200 Subject: [PATCH 1131/1532] tech(sidekiq): use sidekiq by default, clean transition code --- Procfile.dev | 2 +- app/jobs/sidekiq_again_job.rb | 14 -------- config/env.example.optional | 3 ++ config/environments/production.rb | 2 +- config/initializers/transition_to_sidekiq.rb | 35 -------------------- 5 files changed, 5 insertions(+), 51 deletions(-) delete mode 100644 app/jobs/sidekiq_again_job.rb delete mode 100644 config/initializers/transition_to_sidekiq.rb diff --git a/Procfile.dev b/Procfile.dev index 953087cec..1459bc6df 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,3 @@ -web: RAILS_QUEUE_ADAPTER=delayed_job bin/rails server -p 3000 +web: bin/rails server -p 3000 jobs: bin/rake jobs:work vite: bin/vite dev diff --git a/app/jobs/sidekiq_again_job.rb b/app/jobs/sidekiq_again_job.rb deleted file mode 100644 index dcb7b4059..000000000 --- a/app/jobs/sidekiq_again_job.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -class SidekiqAgainJob < ApplicationJob - self.queue_adapter = :sidekiq - queue_as :default - - def perform(user, with_exception: false) - if with_exception - raise 'Nop' - end - Sentry.capture_message('this is a message from sidekiq') - UserMailer.new_account_warning(user).deliver_now - end -end diff --git a/config/env.example.optional b/config/env.example.optional index 74451810a..050e5d49b 100644 --- a/config/env.example.optional +++ b/config/env.example.optional @@ -282,3 +282,6 @@ VITE_LIGHTGALLERY_LICENSE_KEY = "" # This email will be visible to users whom dossier had been fixed by our maintenance_tasks # By default we use CONTACT_EMAIL, but you can customize it MAINTENANCE_INSTRUCTEUR_EMAIL="" + +# want to stay on delayed job ? set as 'delayed_job' +RAILS_QUEUE_ADAPTER=" \ No newline at end of file diff --git a/config/environments/production.rb b/config/environments/production.rb index eb1d964eb..cf942cd6c 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -83,7 +83,7 @@ Rails.application.configure do end # Use a real queuing backend for Active Job (and separate queues per environment). - config.active_job.queue_adapter = :delayed_job + config.active_job.queue_adapter = ENV.fetch('RAILS_QUEUE_ADAPTER') { :sidekiq } # config.active_job.queue_name_prefix = "tps_production" config.action_mailer.perform_caching = false diff --git a/config/initializers/transition_to_sidekiq.rb b/config/initializers/transition_to_sidekiq.rb deleted file mode 100644 index a60869ac1..000000000 --- a/config/initializers/transition_to_sidekiq.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -if Rails.env.production? && SIDEKIQ_ENABLED - ActiveSupport.on_load(:after_initialize) do - [ - ActiveStorage::AnalyzeJob, - ActiveStorage::PurgeJob, - AdminUpdateDefaultZonesJob, - APIEntreprise::Job, - AdminUpdateDefaultZonesJob, - BatchOperationEnqueueAllJob, - BatchOperationProcessOneJob, - ChampFetchExternalDataJob, - Cron::CronJob, - DestroyRecordLaterJob, - DossierIndexSearchTermsJob, - DossierOperationLogMoveToColdStorageBatchJob, - DossierRebaseJob, - HelpscoutCreateConversationJob, - ImageProcessorJob, - MaintenanceTasks::TaskJob, - Migrations::BackfillStableIdJob, - PriorizedMailDeliveryJob, - ProcedureExternalURLCheckJob, - ProcedureSVASVRProcessDossierJob, - ProcessStalledDeclarativeDossierJob, - ResetExpiringDossiersJob, - SendClosingNotificationJob, - VirusScannerJob, - WebHookJob - ].each do |job_class| - job_class.queue_adapter = :sidekiq - end - end -end From 5eec93bc8c7209abcf47e5addb71449bdb702d20 Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 3 Oct 2024 11:34:28 +0200 Subject: [PATCH 1132/1532] clean(delayed_jobs): remove dependencies and all occurences --- Gemfile | 4 -- Gemfile.lock | 27 --------- README.md | 3 +- app/jobs/cron/cron_job.rb | 6 +- app/jobs/cron/release_crashed_export_job.rb | 46 ---------------- .../manager/application/_navigation.html.erb | 1 - bin/delayed_job | 5 -- config/deploy.rb | 13 ----- config/environments/development.rb | 4 +- config/initializers/delayed_job.rb | 6 -- config/initializers/sentry.rb | 25 ++------- config/routes.rb | 1 - lib/tasks/deploy.rake | 2 +- ...e_old_cron_job_from_delayed_job_table.rake | 16 ------ ...bious_proc_job_from_delayed_job_table.rake | 16 ------ ...estroy_dossier_transfer_without_email.rake | 27 --------- .../cron/release_crashed_export_job_spec.rb | 55 ------------------- 17 files changed, 10 insertions(+), 247 deletions(-) delete mode 100644 app/jobs/cron/release_crashed_export_job.rb delete mode 100755 bin/delayed_job delete mode 100644 config/initializers/delayed_job.rb delete mode 100644 lib/tasks/deployment/20220517040643_remove_old_cron_job_from_delayed_job_table.rake delete mode 100644 lib/tasks/deployment/20220517120321_remove_old_dubious_proc_job_from_delayed_job_table.rake delete mode 100644 lib/tasks/deployment/20220802133502_destroy_dossier_transfer_without_email.rake delete mode 100644 spec/jobs/cron/release_crashed_export_job_spec.rb diff --git a/Gemfile b/Gemfile index 3903e5db1..b6ad31cfe 100644 --- a/Gemfile +++ b/Gemfile @@ -26,9 +26,6 @@ gem 'chunky_png' gem 'clamav-client', require: 'clamav/client' gem 'daemons' gem 'deep_cloneable' # Enable deep clone of active record models -gem 'delayed_cron_job', require: false # Cron jobs -gem 'delayed_job_active_record' -gem 'delayed_job_web' gem 'devise' gem 'devise-i18n' gem 'devise-two-factor' @@ -89,7 +86,6 @@ gem 'rexml' # add missing gem due to ruby3 (https://github.com/Shopify/bootsnap/ gem 'rqrcode' gem 'saml_idp' gem 'sassc-rails' # Use SCSS for stylesheets -gem 'sentry-delayed_job' gem 'sentry-rails' gem 'sentry-ruby' gem 'sentry-sidekiq' diff --git a/Gemfile.lock b/Gemfile.lock index 394298047..c27054274 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -191,18 +191,6 @@ GEM date (3.3.4) deep_cloneable (3.2.0) activerecord (>= 3.1.0, < 8) - delayed_cron_job (0.9.0) - fugit (>= 1.5) - delayed_job (4.1.11) - activesupport (>= 3.0, < 8.0) - delayed_job_active_record (4.1.8) - activerecord (>= 3.0, < 8.0) - delayed_job (>= 3.0, < 5) - delayed_job_web (1.4.4) - activerecord (> 3.0.0) - delayed_job (> 2.0.3) - rack-protection (>= 1.5.5) - sinatra (>= 1.4.4) descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) devise (4.9.4) @@ -448,8 +436,6 @@ GEM minitest (5.25.1) msgpack (1.7.2) multi_json (1.15.0) - mustermann (3.0.0) - ruby2_keywords (~> 0.0.1) net-http (0.4.1) uri net-imap (0.4.12) @@ -679,7 +665,6 @@ GEM ruby-progressbar (1.13.0) ruby-vips (2.2.0) ffi (~> 1.12) - ruby2_keywords (0.0.5) rubyzip (2.3.2) saml_idp (0.16.0) activesupport (>= 5.2) @@ -714,9 +699,6 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) - sentry-delayed_job (5.17.3) - delayed_job (>= 4.0) - sentry-ruby (~> 5.17.3) sentry-rails (5.17.3) railties (>= 5.0) sentry-ruby (~> 5.17.3) @@ -755,11 +737,6 @@ GEM simplecov_json_formatter (0.1.4) simpleidn (0.2.1) unf (~> 0.1.4) - sinatra (3.2.0) - mustermann (~> 3.0) - rack (~> 2.2, >= 2.2.4) - rack-protection (= 3.2.0) - tilt (~> 2.0) skylight (6.0.4) activesupport (>= 5.2.0) smart_properties (1.17.0) @@ -914,9 +891,6 @@ DEPENDENCIES clamav-client daemons deep_cloneable - delayed_cron_job - delayed_job_active_record - delayed_job_web devise devise-i18n devise-two-factor @@ -1000,7 +974,6 @@ DEPENDENCIES scss_lint selenium-devtools selenium-webdriver - sentry-delayed_job sentry-rails sentry-ruby sentry-sidekiq diff --git a/README.md b/README.md index 0261e2109..88fa5fd48 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,12 @@ Vous souhaitez y apporter des changements ou des améliorations ? Lisez notre [ ``` -Nous sommes en cours de migration de `delayed_job` vers `sidekiq` pour le traitement des jobs asynchrones. Pour faire tourner sidekiq, vous aurez besoin de : - redis +#### Crédits et licences + - lightgallery : une license a été souscrite pour soutenir le projet, mais elle n'est pas obligatoire si la librairie est utilisée dans le cadre d'une application open source. #### Développement diff --git a/app/jobs/cron/cron_job.rb b/app/jobs/cron/cron_job.rb index 2124474a4..521d24ab0 100644 --- a/app/jobs/cron/cron_job.rb +++ b/app/jobs/cron/cron_job.rb @@ -38,11 +38,7 @@ class Cron::CronJob < ApplicationJob end def enqueued_cron_job - if queue_adapter_name == "sidekiq" - sidekiq_cron_job - else - delayed_job - end + sidekiq_cron_job end def sidekiq_cron_job diff --git a/app/jobs/cron/release_crashed_export_job.rb b/app/jobs/cron/release_crashed_export_job.rb deleted file mode 100644 index 23adf0d2f..000000000 --- a/app/jobs/cron/release_crashed_export_job.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -class Cron::ReleaseCrashedExportJob < Cron::CronJob - self.schedule_expression = "every 10 minute" - SECSCAN_LIMIT = 20_000 - - def perform(*args) - return if !performable? - export_jobs = jobs_for_current_host - - return if export_jobs.empty? - - host_pids = Sys::ProcTable.ps.map(&:pid) - export_jobs.each do |job| - _, pid = hostname_and_pid(job.locked_by) - - reset(job:) if host_pids.exclude?(pid.to_i) - end - end - - def reset(job:) - job.locked_by = nil - job.locked_at = nil - job.attempts += 1 - job.save! - end - - def hostname_and_pid(worker_name) - matches = /host:(?.*) pid:(?\d+)/.match(worker_name) - [matches[:host], matches[:pid]] - end - - def jobs_for_current_host - Delayed::Job.where("locked_by like ?", "%#{whoami}%") - .where(queue: ExportJob.queue_name) - end - - def whoami - me, _ = hostname_and_pid(Delayed::Worker.new.name) - me - end - - def performable? - Delayed::Job.count < SECSCAN_LIMIT - end -end diff --git a/app/views/manager/application/_navigation.html.erb b/app/views/manager/application/_navigation.html.erb index 83dc6da3e..d04f2f72f 100644 --- a/app/views/manager/application/_navigation.html.erb +++ b/app/views/manager/application/_navigation.html.erb @@ -23,7 +23,6 @@ as defined by the routes in the `admin/` namespace
- <%= link_to "Delayed Jobs", manager_delayed_job_path, class: "navigation__link" %> <%= link_to "Sidekiq", manager_sidekiq_web_path, class: "navigation__link" %> <%= link_to "Maintenance Tasks", manager_maintenance_tasks_path, class: "navigation__link" %> <%= link_to "Features", manager_flipper_path, class: "navigation__link" %> diff --git a/bin/delayed_job b/bin/delayed_job deleted file mode 100755 index edf195985..000000000 --- a/bin/delayed_job +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env ruby - -require File.expand_path(File.join(File.dirname(__FILE__), '..', 'config', 'environment')) -require 'delayed/command' -Delayed::Command.new(ARGV).daemonize diff --git a/config/deploy.rb b/config/deploy.rb index bd6694041..3eeaab8a2 100644 --- a/config/deploy.rb +++ b/config/deploy.rb @@ -100,18 +100,6 @@ namespace :service do #{echo_cmd %[test -f #{webserver_file_path} && sudo systemctl reload nginx]} } end - - desc "Restart delayed_job" - task :restart_delayed_job do - worker_file_path = File.join(deploy_to, 'shared', SHARED_WORKER_FILE_NAME) - - command %{ - echo "-----> Restarting delayed_job service" - #{echo_cmd %[test -f #{worker_file_path} && echo 'it is a worker marchine, restarting delayed_job']} - #{echo_cmd %[test -f #{worker_file_path} && sudo systemctl restart delayed_job]} - #{echo_cmd %[test -f #{worker_file_path} || echo "it is not a worker marchine, #{worker_file_path} is absent"]} - } - end end desc "Deploys the current version to the server." @@ -135,7 +123,6 @@ task :deploy do on :launch do invoke :'service:restart_puma' invoke :'service:reload_nginx' - invoke :'service:restart_delayed_job' invoke :'deploy:cleanup' end end diff --git a/config/environments/development.rb b/config/environments/development.rb index e6fdffbb7..b179e79e4 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -111,8 +111,8 @@ Rails.application.configure do # Annotate rendered view with file names. # config.action_view.annotate_rendered_view_with_filenames = true - # We use the async adapter by default, but delayed_job can be set using - # RAILS_QUEUE_ADAPTER=delayed_job bin/rails server + # We use the async adapter by default, but sidekiq can be set using + # RAILS_QUEUE_ADAPTER=sidekiq bin/rails server config.active_job.queue_adapter = ENV.fetch('RAILS_QUEUE_ADAPTER', 'async').to_sym # Use an evented file watcher to asynchronously detect changes in source code, diff --git a/config/initializers/delayed_job.rb b/config/initializers/delayed_job.rb deleted file mode 100644 index af7f691d6..000000000 --- a/config/initializers/delayed_job.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -# Set max_run_time at the highest job duration we want, -# then at job level we'll decrease this value to a lower value -# except for ExportJob. -Delayed::Worker.max_run_time = 16.hours # same as Export::MAX_DUREE_GENERATION but we can't yet use this constant here diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb index c45a69db8..914e5d5a2 100644 --- a/config/initializers/sentry.rb +++ b/config/initializers/sentry.rb @@ -22,27 +22,10 @@ Sentry.init do |config| # transaction_context is the transaction object in hash form # keep in mind that sampling happens right after the transaction is initialized # for example, at the beginning of the request - transaction_context = sampling_context[:transaction_context] - - # transaction_context helps you sample transactions with more sophistication - # for example, you can provide different sample rates based on the operation or name - case transaction_context[:op] - when /delayed_job/ - contexts = Sentry.get_current_scope.contexts - job_class = contexts.dig(:"Active-Job", :job_class) - attempts = contexts.dig(:"Delayed-Job", :attempts) - max_attempts = job_class.safe_constantize&.new&.max_attempts rescue 25 - - # Don't trace on all attempts - [0, 2, 5, 10, 20, max_attempts].include?(attempts) - else # rails requests - if sampling_context.dig(:env, "REQUEST_METHOD") == "GET" - 0.001 - else - 0.01 - end + if sampling_context[:transaction_context].dig(:env, "REQUEST_METHOD") == "GET" + 0.001 + else + 0.01 end end - - config.delayed_job.report_after_job_retries = false # don't wait for all attempts before reporting end diff --git a/config/routes.rb b/config/routes.rb index e1f7cc8c2..99862a416 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -107,7 +107,6 @@ Rails.application.routes.draw do authenticate :super_admin do mount Flipper::UI.app(-> { Flipper.instance }) => "/features", as: :flipper - match "/delayed_job" => DelayedJobWeb, :anchor => false, :via => [:get, :post] mount MaintenanceTasks::Engine => "/maintenance_tasks" mount Sidekiq::Web => "/sidekiq" end diff --git a/lib/tasks/deploy.rake b/lib/tasks/deploy.rake index f7edf1cc5..db5fab695 100644 --- a/lib/tasks/deploy.rake +++ b/lib/tasks/deploy.rake @@ -37,6 +37,6 @@ task :rollback do branch = ENV.fetch('BRANCH') domains.each do |domain| - sh "mina rollback service:restart_puma service:reload_nginx service:restart_delayed_job domain=#{domain} branch=#{branch}" + sh "mina rollback service:restart_puma service:reload_nginx domain=#{domain} branch=#{branch}" end end diff --git a/lib/tasks/deployment/20220517040643_remove_old_cron_job_from_delayed_job_table.rake b/lib/tasks/deployment/20220517040643_remove_old_cron_job_from_delayed_job_table.rake deleted file mode 100644 index 6095daef7..000000000 --- a/lib/tasks/deployment/20220517040643_remove_old_cron_job_from_delayed_job_table.rake +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -namespace :after_party do - desc 'Deployment task: remove_old_cron_job_from_delayed_job_table' - task remove_old_cron_job_from_delayed_job_table: :environment do - puts "Running deploy task 'remove_old_cron_job_from_delayed_job_table'" - - cron = Delayed::Job.where.not(cron: nil) - .where("handler LIKE ?", "%UpdateAdministrateurUsageStatisticsJob%") - .first - cron.destroy if cron - - AfterParty::TaskRecord - .create version: AfterParty::TaskRecorder.new(__FILE__).timestamp - end -end diff --git a/lib/tasks/deployment/20220517120321_remove_old_dubious_proc_job_from_delayed_job_table.rake b/lib/tasks/deployment/20220517120321_remove_old_dubious_proc_job_from_delayed_job_table.rake deleted file mode 100644 index ab6c32c12..000000000 --- a/lib/tasks/deployment/20220517120321_remove_old_dubious_proc_job_from_delayed_job_table.rake +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -namespace :after_party do - desc 'Deployment task: remove_old_find_dubious_procedures_job_from_delayed_job_table' - task remove_old_dubious_proc_job_from_delayed_job_table: :environment do - puts "Running deploy task 'remove_old_dubious_proc_job_from_delayed_job_table'" - - cron = Delayed::Job.where.not(cron: nil) - .where("handler LIKE ?", "%FindDubiousProceduresJob%") - .first - cron.destroy if cron - - AfterParty::TaskRecord - .create version: AfterParty::TaskRecorder.new(__FILE__).timestamp - end -end diff --git a/lib/tasks/deployment/20220802133502_destroy_dossier_transfer_without_email.rake b/lib/tasks/deployment/20220802133502_destroy_dossier_transfer_without_email.rake deleted file mode 100644 index 7461f27e9..000000000 --- a/lib/tasks/deployment/20220802133502_destroy_dossier_transfer_without_email.rake +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -namespace :after_party do - desc 'Deployment task: destroy_dossier_transfer_without_email' - task destroy_dossier_transfer_without_email: :environment do - puts "Running deploy task 'destroy_dossier_transfer_without_email'" - - invalid_dossiers = DossierTransfer.where(email: "") - - progress = ProgressReport.new(invalid_dossiers.count) - - invalid_dossiers.find_each do |dossier_transfer| - puts "Destroy dossier transfer #{dossier_transfer.id}" - dossier_transfer.destroy_and_nullify - - job = Delayed::Job.where("handler LIKE ALL(ARRAY[?, ?])", "%ActionMailer::MailDeliveryJob%", "%aj_globalid: gid://tps/DossierTransfer/#{dossier_transfer.id}\n%").first - job.destroy if job - - progress.inc - end - - # Update task as completed. If you remove the line below, the task will - # run with every deploy (or every time you call after_party:run). - AfterParty::TaskRecord - .create version: AfterParty::TaskRecorder.new(__FILE__).timestamp - end -end diff --git a/spec/jobs/cron/release_crashed_export_job_spec.rb b/spec/jobs/cron/release_crashed_export_job_spec.rb deleted file mode 100644 index f51454372..000000000 --- a/spec/jobs/cron/release_crashed_export_job_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -describe Cron::ReleaseCrashedExportJob do - let(:handler) { "whocares" } - - def locked_by(hostname) - "delayed_job.33 host:#{hostname} pid:1252488" - end - - describe '.perform' do - subject { described_class.new.perform } - let!(:job) { Delayed::Job.create!(handler:, queue: ExportJob.queue_name, locked_by: locked_by(Socket.gethostname)) } - - it 'releases lock' do - expect { subject }.to change { job.reload.locked_by }.from(anything).to(nil) - end - it 'increases attempts' do - expect { subject }.to change { job.reload.attempts }.by(1) - end - end - - describe '.hostname_and_pid' do - subject { described_class.new.hostname_and_pid(Delayed::Worker.new.name) } - it 'extract hostname and pid from worker.name' do - hostname, pid = subject - - expect(hostname).to eq(Socket.gethostname) - expect(pid).to eq(Process.pid.to_s) - end - end - - describe 'whoami' do - subject { described_class.new.whoami } - it { is_expected.to eq(Socket.gethostname) } - end - - describe 'jobs_for_current_host' do - subject { described_class.new.jobs_for_current_host } - - context 'when jobs run an another host' do - let!(:job) { Delayed::Job.create!(handler:, queue: :default, locked_by: locked_by('spec1.prod')) } - it { is_expected.to be_empty } - end - - context 'when jobs run an same host with default queue' do - let!(:job) { Delayed::Job.create!(handler:, queue: :default, locked_by: locked_by(Socket.gethostname)) } - it { is_expected.to be_empty } - end - - context 'when jobs run an same host with exports queue' do - let!(:job) { Delayed::Job.create!(handler:, queue: ExportJob.queue_name, locked_by: locked_by(Socket.gethostname)) } - it { is_expected.to include(job) } - end - end -end From 02590b3a73bac7e116b613d7fff5ac5de981becd Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 8 Oct 2024 16:37:44 +0200 Subject: [PATCH 1133/1532] Revert "clean(delayed_jobs): remove dependencies and all occurences" This reverts commit 90ca937b7131575816d72e35e2c48f9d82e9a5e6. ReRevert me in order to remove all delayed job occurences. But we keep this dependencie for our instances at least for a year --- Gemfile | 4 ++ Gemfile.lock | 27 +++++++++ README.md | 3 +- app/jobs/cron/cron_job.rb | 6 +- app/jobs/cron/release_crashed_export_job.rb | 46 ++++++++++++++++ .../manager/application/_navigation.html.erb | 1 + bin/delayed_job | 5 ++ config/deploy.rb | 13 +++++ config/environments/development.rb | 4 +- config/initializers/delayed_job.rb | 6 ++ config/initializers/sentry.rb | 25 +++++++-- config/routes.rb | 1 + lib/tasks/deploy.rake | 2 +- ...e_old_cron_job_from_delayed_job_table.rake | 16 ++++++ ...bious_proc_job_from_delayed_job_table.rake | 16 ++++++ ...estroy_dossier_transfer_without_email.rake | 27 +++++++++ .../cron/release_crashed_export_job_spec.rb | 55 +++++++++++++++++++ 17 files changed, 247 insertions(+), 10 deletions(-) create mode 100644 app/jobs/cron/release_crashed_export_job.rb create mode 100755 bin/delayed_job create mode 100644 config/initializers/delayed_job.rb create mode 100644 lib/tasks/deployment/20220517040643_remove_old_cron_job_from_delayed_job_table.rake create mode 100644 lib/tasks/deployment/20220517120321_remove_old_dubious_proc_job_from_delayed_job_table.rake create mode 100644 lib/tasks/deployment/20220802133502_destroy_dossier_transfer_without_email.rake create mode 100644 spec/jobs/cron/release_crashed_export_job_spec.rb diff --git a/Gemfile b/Gemfile index b6ad31cfe..3903e5db1 100644 --- a/Gemfile +++ b/Gemfile @@ -26,6 +26,9 @@ gem 'chunky_png' gem 'clamav-client', require: 'clamav/client' gem 'daemons' gem 'deep_cloneable' # Enable deep clone of active record models +gem 'delayed_cron_job', require: false # Cron jobs +gem 'delayed_job_active_record' +gem 'delayed_job_web' gem 'devise' gem 'devise-i18n' gem 'devise-two-factor' @@ -86,6 +89,7 @@ gem 'rexml' # add missing gem due to ruby3 (https://github.com/Shopify/bootsnap/ gem 'rqrcode' gem 'saml_idp' gem 'sassc-rails' # Use SCSS for stylesheets +gem 'sentry-delayed_job' gem 'sentry-rails' gem 'sentry-ruby' gem 'sentry-sidekiq' diff --git a/Gemfile.lock b/Gemfile.lock index c27054274..394298047 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -191,6 +191,18 @@ GEM date (3.3.4) deep_cloneable (3.2.0) activerecord (>= 3.1.0, < 8) + delayed_cron_job (0.9.0) + fugit (>= 1.5) + delayed_job (4.1.11) + activesupport (>= 3.0, < 8.0) + delayed_job_active_record (4.1.8) + activerecord (>= 3.0, < 8.0) + delayed_job (>= 3.0, < 5) + delayed_job_web (1.4.4) + activerecord (> 3.0.0) + delayed_job (> 2.0.3) + rack-protection (>= 1.5.5) + sinatra (>= 1.4.4) descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) devise (4.9.4) @@ -436,6 +448,8 @@ GEM minitest (5.25.1) msgpack (1.7.2) multi_json (1.15.0) + mustermann (3.0.0) + ruby2_keywords (~> 0.0.1) net-http (0.4.1) uri net-imap (0.4.12) @@ -665,6 +679,7 @@ GEM ruby-progressbar (1.13.0) ruby-vips (2.2.0) ffi (~> 1.12) + ruby2_keywords (0.0.5) rubyzip (2.3.2) saml_idp (0.16.0) activesupport (>= 5.2) @@ -699,6 +714,9 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) + sentry-delayed_job (5.17.3) + delayed_job (>= 4.0) + sentry-ruby (~> 5.17.3) sentry-rails (5.17.3) railties (>= 5.0) sentry-ruby (~> 5.17.3) @@ -737,6 +755,11 @@ GEM simplecov_json_formatter (0.1.4) simpleidn (0.2.1) unf (~> 0.1.4) + sinatra (3.2.0) + mustermann (~> 3.0) + rack (~> 2.2, >= 2.2.4) + rack-protection (= 3.2.0) + tilt (~> 2.0) skylight (6.0.4) activesupport (>= 5.2.0) smart_properties (1.17.0) @@ -891,6 +914,9 @@ DEPENDENCIES clamav-client daemons deep_cloneable + delayed_cron_job + delayed_job_active_record + delayed_job_web devise devise-i18n devise-two-factor @@ -974,6 +1000,7 @@ DEPENDENCIES scss_lint selenium-devtools selenium-webdriver + sentry-delayed_job sentry-rails sentry-ruby sentry-sidekiq diff --git a/README.md b/README.md index 88fa5fd48..0261e2109 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,11 @@ Vous souhaitez y apporter des changements ou des améliorations ? Lisez notre [ ``` +Nous sommes en cours de migration de `delayed_job` vers `sidekiq` pour le traitement des jobs asynchrones. Pour faire tourner sidekiq, vous aurez besoin de : - redis -#### Crédits et licences - - lightgallery : une license a été souscrite pour soutenir le projet, mais elle n'est pas obligatoire si la librairie est utilisée dans le cadre d'une application open source. #### Développement diff --git a/app/jobs/cron/cron_job.rb b/app/jobs/cron/cron_job.rb index 521d24ab0..2124474a4 100644 --- a/app/jobs/cron/cron_job.rb +++ b/app/jobs/cron/cron_job.rb @@ -38,7 +38,11 @@ class Cron::CronJob < ApplicationJob end def enqueued_cron_job - sidekiq_cron_job + if queue_adapter_name == "sidekiq" + sidekiq_cron_job + else + delayed_job + end end def sidekiq_cron_job diff --git a/app/jobs/cron/release_crashed_export_job.rb b/app/jobs/cron/release_crashed_export_job.rb new file mode 100644 index 000000000..23adf0d2f --- /dev/null +++ b/app/jobs/cron/release_crashed_export_job.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class Cron::ReleaseCrashedExportJob < Cron::CronJob + self.schedule_expression = "every 10 minute" + SECSCAN_LIMIT = 20_000 + + def perform(*args) + return if !performable? + export_jobs = jobs_for_current_host + + return if export_jobs.empty? + + host_pids = Sys::ProcTable.ps.map(&:pid) + export_jobs.each do |job| + _, pid = hostname_and_pid(job.locked_by) + + reset(job:) if host_pids.exclude?(pid.to_i) + end + end + + def reset(job:) + job.locked_by = nil + job.locked_at = nil + job.attempts += 1 + job.save! + end + + def hostname_and_pid(worker_name) + matches = /host:(?.*) pid:(?\d+)/.match(worker_name) + [matches[:host], matches[:pid]] + end + + def jobs_for_current_host + Delayed::Job.where("locked_by like ?", "%#{whoami}%") + .where(queue: ExportJob.queue_name) + end + + def whoami + me, _ = hostname_and_pid(Delayed::Worker.new.name) + me + end + + def performable? + Delayed::Job.count < SECSCAN_LIMIT + end +end diff --git a/app/views/manager/application/_navigation.html.erb b/app/views/manager/application/_navigation.html.erb index d04f2f72f..83dc6da3e 100644 --- a/app/views/manager/application/_navigation.html.erb +++ b/app/views/manager/application/_navigation.html.erb @@ -23,6 +23,7 @@ as defined by the routes in the `admin/` namespace
+ <%= link_to "Delayed Jobs", manager_delayed_job_path, class: "navigation__link" %> <%= link_to "Sidekiq", manager_sidekiq_web_path, class: "navigation__link" %> <%= link_to "Maintenance Tasks", manager_maintenance_tasks_path, class: "navigation__link" %> <%= link_to "Features", manager_flipper_path, class: "navigation__link" %> diff --git a/bin/delayed_job b/bin/delayed_job new file mode 100755 index 000000000..edf195985 --- /dev/null +++ b/bin/delayed_job @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby + +require File.expand_path(File.join(File.dirname(__FILE__), '..', 'config', 'environment')) +require 'delayed/command' +Delayed::Command.new(ARGV).daemonize diff --git a/config/deploy.rb b/config/deploy.rb index 3eeaab8a2..bd6694041 100644 --- a/config/deploy.rb +++ b/config/deploy.rb @@ -100,6 +100,18 @@ namespace :service do #{echo_cmd %[test -f #{webserver_file_path} && sudo systemctl reload nginx]} } end + + desc "Restart delayed_job" + task :restart_delayed_job do + worker_file_path = File.join(deploy_to, 'shared', SHARED_WORKER_FILE_NAME) + + command %{ + echo "-----> Restarting delayed_job service" + #{echo_cmd %[test -f #{worker_file_path} && echo 'it is a worker marchine, restarting delayed_job']} + #{echo_cmd %[test -f #{worker_file_path} && sudo systemctl restart delayed_job]} + #{echo_cmd %[test -f #{worker_file_path} || echo "it is not a worker marchine, #{worker_file_path} is absent"]} + } + end end desc "Deploys the current version to the server." @@ -123,6 +135,7 @@ task :deploy do on :launch do invoke :'service:restart_puma' invoke :'service:reload_nginx' + invoke :'service:restart_delayed_job' invoke :'deploy:cleanup' end end diff --git a/config/environments/development.rb b/config/environments/development.rb index b179e79e4..e6fdffbb7 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -111,8 +111,8 @@ Rails.application.configure do # Annotate rendered view with file names. # config.action_view.annotate_rendered_view_with_filenames = true - # We use the async adapter by default, but sidekiq can be set using - # RAILS_QUEUE_ADAPTER=sidekiq bin/rails server + # We use the async adapter by default, but delayed_job can be set using + # RAILS_QUEUE_ADAPTER=delayed_job bin/rails server config.active_job.queue_adapter = ENV.fetch('RAILS_QUEUE_ADAPTER', 'async').to_sym # Use an evented file watcher to asynchronously detect changes in source code, diff --git a/config/initializers/delayed_job.rb b/config/initializers/delayed_job.rb new file mode 100644 index 000000000..af7f691d6 --- /dev/null +++ b/config/initializers/delayed_job.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Set max_run_time at the highest job duration we want, +# then at job level we'll decrease this value to a lower value +# except for ExportJob. +Delayed::Worker.max_run_time = 16.hours # same as Export::MAX_DUREE_GENERATION but we can't yet use this constant here diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb index 914e5d5a2..c45a69db8 100644 --- a/config/initializers/sentry.rb +++ b/config/initializers/sentry.rb @@ -22,10 +22,27 @@ Sentry.init do |config| # transaction_context is the transaction object in hash form # keep in mind that sampling happens right after the transaction is initialized # for example, at the beginning of the request - if sampling_context[:transaction_context].dig(:env, "REQUEST_METHOD") == "GET" - 0.001 - else - 0.01 + transaction_context = sampling_context[:transaction_context] + + # transaction_context helps you sample transactions with more sophistication + # for example, you can provide different sample rates based on the operation or name + case transaction_context[:op] + when /delayed_job/ + contexts = Sentry.get_current_scope.contexts + job_class = contexts.dig(:"Active-Job", :job_class) + attempts = contexts.dig(:"Delayed-Job", :attempts) + max_attempts = job_class.safe_constantize&.new&.max_attempts rescue 25 + + # Don't trace on all attempts + [0, 2, 5, 10, 20, max_attempts].include?(attempts) + else # rails requests + if sampling_context.dig(:env, "REQUEST_METHOD") == "GET" + 0.001 + else + 0.01 + end end end + + config.delayed_job.report_after_job_retries = false # don't wait for all attempts before reporting end diff --git a/config/routes.rb b/config/routes.rb index 99862a416..e1f7cc8c2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -107,6 +107,7 @@ Rails.application.routes.draw do authenticate :super_admin do mount Flipper::UI.app(-> { Flipper.instance }) => "/features", as: :flipper + match "/delayed_job" => DelayedJobWeb, :anchor => false, :via => [:get, :post] mount MaintenanceTasks::Engine => "/maintenance_tasks" mount Sidekiq::Web => "/sidekiq" end diff --git a/lib/tasks/deploy.rake b/lib/tasks/deploy.rake index db5fab695..f7edf1cc5 100644 --- a/lib/tasks/deploy.rake +++ b/lib/tasks/deploy.rake @@ -37,6 +37,6 @@ task :rollback do branch = ENV.fetch('BRANCH') domains.each do |domain| - sh "mina rollback service:restart_puma service:reload_nginx domain=#{domain} branch=#{branch}" + sh "mina rollback service:restart_puma service:reload_nginx service:restart_delayed_job domain=#{domain} branch=#{branch}" end end diff --git a/lib/tasks/deployment/20220517040643_remove_old_cron_job_from_delayed_job_table.rake b/lib/tasks/deployment/20220517040643_remove_old_cron_job_from_delayed_job_table.rake new file mode 100644 index 000000000..6095daef7 --- /dev/null +++ b/lib/tasks/deployment/20220517040643_remove_old_cron_job_from_delayed_job_table.rake @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +namespace :after_party do + desc 'Deployment task: remove_old_cron_job_from_delayed_job_table' + task remove_old_cron_job_from_delayed_job_table: :environment do + puts "Running deploy task 'remove_old_cron_job_from_delayed_job_table'" + + cron = Delayed::Job.where.not(cron: nil) + .where("handler LIKE ?", "%UpdateAdministrateurUsageStatisticsJob%") + .first + cron.destroy if cron + + AfterParty::TaskRecord + .create version: AfterParty::TaskRecorder.new(__FILE__).timestamp + end +end diff --git a/lib/tasks/deployment/20220517120321_remove_old_dubious_proc_job_from_delayed_job_table.rake b/lib/tasks/deployment/20220517120321_remove_old_dubious_proc_job_from_delayed_job_table.rake new file mode 100644 index 000000000..ab6c32c12 --- /dev/null +++ b/lib/tasks/deployment/20220517120321_remove_old_dubious_proc_job_from_delayed_job_table.rake @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +namespace :after_party do + desc 'Deployment task: remove_old_find_dubious_procedures_job_from_delayed_job_table' + task remove_old_dubious_proc_job_from_delayed_job_table: :environment do + puts "Running deploy task 'remove_old_dubious_proc_job_from_delayed_job_table'" + + cron = Delayed::Job.where.not(cron: nil) + .where("handler LIKE ?", "%FindDubiousProceduresJob%") + .first + cron.destroy if cron + + AfterParty::TaskRecord + .create version: AfterParty::TaskRecorder.new(__FILE__).timestamp + end +end diff --git a/lib/tasks/deployment/20220802133502_destroy_dossier_transfer_without_email.rake b/lib/tasks/deployment/20220802133502_destroy_dossier_transfer_without_email.rake new file mode 100644 index 000000000..7461f27e9 --- /dev/null +++ b/lib/tasks/deployment/20220802133502_destroy_dossier_transfer_without_email.rake @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +namespace :after_party do + desc 'Deployment task: destroy_dossier_transfer_without_email' + task destroy_dossier_transfer_without_email: :environment do + puts "Running deploy task 'destroy_dossier_transfer_without_email'" + + invalid_dossiers = DossierTransfer.where(email: "") + + progress = ProgressReport.new(invalid_dossiers.count) + + invalid_dossiers.find_each do |dossier_transfer| + puts "Destroy dossier transfer #{dossier_transfer.id}" + dossier_transfer.destroy_and_nullify + + job = Delayed::Job.where("handler LIKE ALL(ARRAY[?, ?])", "%ActionMailer::MailDeliveryJob%", "%aj_globalid: gid://tps/DossierTransfer/#{dossier_transfer.id}\n%").first + job.destroy if job + + progress.inc + end + + # Update task as completed. If you remove the line below, the task will + # run with every deploy (or every time you call after_party:run). + AfterParty::TaskRecord + .create version: AfterParty::TaskRecorder.new(__FILE__).timestamp + end +end diff --git a/spec/jobs/cron/release_crashed_export_job_spec.rb b/spec/jobs/cron/release_crashed_export_job_spec.rb new file mode 100644 index 000000000..f51454372 --- /dev/null +++ b/spec/jobs/cron/release_crashed_export_job_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +describe Cron::ReleaseCrashedExportJob do + let(:handler) { "whocares" } + + def locked_by(hostname) + "delayed_job.33 host:#{hostname} pid:1252488" + end + + describe '.perform' do + subject { described_class.new.perform } + let!(:job) { Delayed::Job.create!(handler:, queue: ExportJob.queue_name, locked_by: locked_by(Socket.gethostname)) } + + it 'releases lock' do + expect { subject }.to change { job.reload.locked_by }.from(anything).to(nil) + end + it 'increases attempts' do + expect { subject }.to change { job.reload.attempts }.by(1) + end + end + + describe '.hostname_and_pid' do + subject { described_class.new.hostname_and_pid(Delayed::Worker.new.name) } + it 'extract hostname and pid from worker.name' do + hostname, pid = subject + + expect(hostname).to eq(Socket.gethostname) + expect(pid).to eq(Process.pid.to_s) + end + end + + describe 'whoami' do + subject { described_class.new.whoami } + it { is_expected.to eq(Socket.gethostname) } + end + + describe 'jobs_for_current_host' do + subject { described_class.new.jobs_for_current_host } + + context 'when jobs run an another host' do + let!(:job) { Delayed::Job.create!(handler:, queue: :default, locked_by: locked_by('spec1.prod')) } + it { is_expected.to be_empty } + end + + context 'when jobs run an same host with default queue' do + let!(:job) { Delayed::Job.create!(handler:, queue: :default, locked_by: locked_by(Socket.gethostname)) } + it { is_expected.to be_empty } + end + + context 'when jobs run an same host with exports queue' do + let!(:job) { Delayed::Job.create!(handler:, queue: ExportJob.queue_name, locked_by: locked_by(Socket.gethostname)) } + it { is_expected.to include(job) } + end + end +end From 693629afc897bb9429194281f9116810bd4434bf Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 7 Oct 2024 15:00:22 +0200 Subject: [PATCH 1134/1532] add column type --- app/models/column.rb | 4 ++ app/types/column_type.rb | 38 ++++++++++++++++++ config/initializers/types.rb | 2 + spec/types/column_type_spec.rb | 71 ++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 app/types/column_type.rb create mode 100644 spec/types/column_type_spec.rb diff --git a/app/models/column.rb b/app/models/column.rb index a7f74fa69..7e7a8159c 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -27,4 +27,8 @@ class Column table:, column:, label:, classname:, type:, scope:, value_column:, filterable:, displayable: } end + + def self.find(h_id) + Procedure.with_discarded.find(h_id[:procedure_id]).find_column(h_id:) + end end diff --git a/app/types/column_type.rb b/app/types/column_type.rb new file mode 100644 index 000000000..b54e49f9c --- /dev/null +++ b/app/types/column_type.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ColumnType < ActiveRecord::Type::Value + # value can come from: + # setter: column (Column), + # form_input: column.id == { procedure_id:, column_id: }.to_json (String), + # from db: { procedure_id:, column_id: } (Hash) + def cast(value) + case value + in NilClass + nil + in Column + value + # from form + in String => id + h_id = JSON.parse(id, symbolize_names: true) + Column.find(h_id) + # from db + in Hash => h_id + Column.find(h_id) + end + end + + # db -> ruby + def deserialize(value) = cast(value) + + # ruby -> db + def serialize(value) + case value + in NilClass + nil + in Column + JSON.generate(value.h_id) + else + raise ArgumentError, "Invalid value for Column serialization: #{value}" + end + end +end diff --git a/config/initializers/types.rb b/config/initializers/types.rb index 00774df32..a77b4b64d 100644 --- a/config/initializers/types.rb +++ b/config/initializers/types.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true +require Rails.root.join("app/types/column_type") require Rails.root.join("app/types/export_item_type") ActiveSupport.on_load(:active_record) do + ActiveRecord::Type.register(:column, ColumnType) ActiveRecord::Type.register(:export_item, ExportItemType) end diff --git a/spec/types/column_type_spec.rb b/spec/types/column_type_spec.rb new file mode 100644 index 000000000..5512f2b2f --- /dev/null +++ b/spec/types/column_type_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +describe ColumnType do + let(:type) { ColumnType.new } + + describe 'cast' do + it 'from Column' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + expect(type.cast(column)).to eq(column) + end + + it 'from nil' do + expect(type.cast(nil)).to eq(nil) + end + + describe 'from form' do + it 'with valid column id' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + + expect(Column).to receive(:find).with(column.h_id).and_return(column) + expect(type.cast(column.id)).to eq(column) + end + + it 'with invalid column id' do + expect { type.cast('invalid') }.to raise_error(JSON::ParserError) + + id = { procedure_id: 'invalid', column_id: 'nop' }.to_json + expect { type.cast(id) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe 'from db' do + it 'with valid column id' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + expect(Column).to receive(:find).with(column.h_id).and_return(column) + expect(type.cast(column.h_id)).to eq(column) + end + + it 'with invalid column id' do + h_id = { procedure_id: 'invalid', column_id: 'nop' } + expect { type.cast(h_id) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + + describe 'deserialize' do + context 'with valid value' do + it 'works' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + expect(Column).to receive(:find).with(column.h_id).and_return(column) + + expect(type.deserialize(column.h_id)).to eq(column) + end + end + end + + describe 'serialize' do + it 'with SortedColumn' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + expect(type.serialize(column)).to eq(column.h_id.to_json) + end + + it 'with nil' do + expect(type.serialize(nil)).to eq(nil) + end + + it 'with invalid value' do + expect { type.serialize('invalid') }.to raise_error(ArgumentError) + end + end +end From 305b31e53b3da4ebf60a831132d7613b8a9dbfd1 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 23 Sep 2024 10:35:00 +0200 Subject: [PATCH 1135/1532] add sorted_column --- app/models/sorted_column.rb | 16 ++++++++++++++++ spec/models/sorted_column_spec.rb | 28 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 app/models/sorted_column.rb create mode 100644 spec/models/sorted_column_spec.rb diff --git a/app/models/sorted_column.rb b/app/models/sorted_column.rb new file mode 100644 index 000000000..ad96d7ac6 --- /dev/null +++ b/app/models/sorted_column.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class SortedColumn + attr_reader :column + + def initialize(column:, order:) + @column = column + @order = order + end + + def ascending? = @order == 'asc' + + def ==(other) + other&.column == column && other.order == order + end +end diff --git a/spec/models/sorted_column_spec.rb b/spec/models/sorted_column_spec.rb new file mode 100644 index 000000000..0cd0a49b8 --- /dev/null +++ b/spec/models/sorted_column_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +describe SortedColumn do + let(:column) { Column.new(procedure_id: 1, table: 'table', column: 'column') } + let(:sorted_column) { SortedColumn.new(column: column, order: 'asc') } + + describe '==' do + it 'returns true for the same sorted column' do + other = SortedColumn.new(column: column, order: 'asc') + expect(sorted_column == other).to eq(true) + end + + it 'returns false if the order is different' do + other = SortedColumn.new(column: column, order: 'desc') + expect(sorted_column == other).to eq(false) + end + + it 'returns false if the column is different' do + other_column = Column.new(procedure_id: 1, table: 'table', column: 'other') + other = SortedColumn.new(column: other_column, order: 'asc') + expect(sorted_column == other).to eq(false) + end + + it 'returns false if the other is nil' do + expect(sorted_column == nil).to eq(false) + end + end +end From 9652cf78c3c3c781c604700ea4107d2d3427dcea Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 25 Sep 2024 17:37:11 +0200 Subject: [PATCH 1136/1532] add default_sorted_column --- app/models/concerns/columns_concern.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index ea88cb73a..caac5bc74 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -57,6 +57,10 @@ module ColumnsConcern columns end + def default_sorted_column + SortedColumn.new(column: notifications_column, order: 'desc') + end + private def standard_columns From 21533f91e36162717663a46bbdb784ffa70a7a84 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 25 Sep 2024 17:41:58 +0200 Subject: [PATCH 1137/1532] add storted_column_type --- app/models/procedure_presentation.rb | 3 +- app/types/sorted_column_type.rb | 36 ++++++++++ config/initializers/types.rb | 2 + ...edure_presentation_to_columns.rake_spec.rb | 4 +- spec/types/sorted_column_type_spec.rb | 71 +++++++++++++++++++ 5 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 app/types/sorted_column_type.rb create mode 100644 spec/types/sorted_column_type_spec.rb diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 6607f8adc..d3e08befd 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -29,7 +29,8 @@ class ProcedurePresentation < ApplicationRecord validate :check_filters_max_length validate :check_filters_max_integer - attribute :sorted_column, :jsonb + attribute :sorted_column, :sorted_column + def sorted_column = super || procedure.default_sorted_column # Dummy override to set default value attribute :a_suivre_filters, :jsonb, array: true attribute :suivis_filters, :jsonb, array: true diff --git a/app/types/sorted_column_type.rb b/app/types/sorted_column_type.rb new file mode 100644 index 000000000..b9daae095 --- /dev/null +++ b/app/types/sorted_column_type.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class SortedColumnType < ActiveRecord::Type::Value + # form_input or setter -> type + def cast(value) + value = value.deep_symbolize_keys if value.respond_to?(:deep_symbolize_keys) + + case value + in SortedColumn + value + in NilClass # default value + nil + # from form (id is a string) or from db (id is a hash) + in { order: 'asc'|'desc', id: String|Hash } => h + SortedColumn.new(column: ColumnType.new.cast(h[:id]), order: h[:order]) + end + end + + # db -> ruby + def deserialize(value) = cast(value&.then { JSON.parse(_1) }) + + # ruby -> db + def serialize(value) + case value + in NilClass + nil + in SortedColumn + JSON.generate({ + id: value.column.h_id, + order: value.order + }) + else + raise ArgumentError, "Invalid value for SortedColumn serialization: #{value}" + end + end +end diff --git a/config/initializers/types.rb b/config/initializers/types.rb index a77b4b64d..8ac4a38ed 100644 --- a/config/initializers/types.rb +++ b/config/initializers/types.rb @@ -2,8 +2,10 @@ require Rails.root.join("app/types/column_type") require Rails.root.join("app/types/export_item_type") +require Rails.root.join("app/types/sorted_column_type") ActiveSupport.on_load(:active_record) do ActiveRecord::Type.register(:column, ColumnType) ActiveRecord::Type.register(:export_item, ExportItemType) + ActiveRecord::Type.register(:sorted_column, SortedColumnType) end diff --git a/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb b/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb index 6545cf027..46c868f03 100644 --- a/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb +++ b/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb @@ -45,10 +45,10 @@ describe '20240920130741_migrate_procedure_presentation_to_columns.rake' do order, column_id = procedure_presentation .sorted_column - .then { |sorted| [sorted['order'], sorted['id']] } + .then { |sorted| [sorted.order, sorted.column.h_id] } expect(order).to eq('desc') - expect(column_id).to eq("procedure_id" => procedure_id, "column_id" => "self/en_construction_at") + expect(column_id).to eq(procedure_id: procedure_id, column_id: "self/en_construction_at") expect(procedure_presentation.tous_filters).to eq([]) diff --git a/spec/types/sorted_column_type_spec.rb b/spec/types/sorted_column_type_spec.rb new file mode 100644 index 000000000..067c41fb5 --- /dev/null +++ b/spec/types/sorted_column_type_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +describe SortedColumnType do + let(:type) { SortedColumnType.new } + + describe 'cast' do + it 'from SortedColumn' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + sorted_column = SortedColumn.new(column:, order: 'asc') + expect(type.cast(sorted_column)).to eq(sorted_column) + end + + it 'from nil' do + expect(type.cast(nil)).to eq(nil) + end + + describe 'from form' do + it 'with valid column id' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + h = { order: 'asc', id: column.id } + + expect(Column).to receive(:find).with(column.h_id).and_return(column) + expect(type.cast(h)).to eq(SortedColumn.new(column: column, order: 'asc')) + end + + it 'with invalid column id' do + h = { order: 'asc', id: 'invalid' } + expect { type.cast(h) }.to raise_error(JSON::ParserError) + + h = { order: 'asc', id: { procedure_id: 'invalid', column_id: 'nop' }.to_json } + expect { type.cast(h) }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'with invalid order' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + h = { order: 'invalid', id: column.id } + expect { type.cast(h) }.to raise_error(NoMatchingPatternError) + end + end + end + + describe 'deserialize' do + context 'with valid value' do + it 'works' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + expect(Column).to receive(:find).with(column.h_id).and_return(column) + expect(type.deserialize({ id: column.h_id, order: 'asc' }.to_json)).to eq(SortedColumn.new(column: column, order: 'asc')) + end + end + + context 'with nil' do + it { expect(type.deserialize(nil)).to eq(nil) } + end + end + + describe 'serialize' do + it 'with SortedColumn' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + sorted_column = SortedColumn.new(column: column, order: 'asc') + expect(type.serialize(sorted_column)).to eq({ id: column.h_id, order: 'asc' }.to_json) + end + + it 'with nil' do + expect(type.serialize(nil)).to eq(nil) + end + + it 'with invalid value' do + expect { type.serialize('invalid') }.to raise_error(ArgumentError) + end + end +end From b582a2afc60722f14ca728f31bc4f6e85a848aca Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 24 Sep 2024 15:05:25 +0200 Subject: [PATCH 1138/1532] column_table_header_component use sorted_column --- .../column_table_header_component.rb | 46 ++++--------------- .../column_table_header_component.html.haml | 13 ++---- .../instructeurs/procedures/show.html.haml | 3 +- 3 files changed, 15 insertions(+), 47 deletions(-) diff --git a/app/components/instructeurs/column_table_header_component.rb b/app/components/instructeurs/column_table_header_component.rb index 09af953b7..0318da05e 100644 --- a/app/components/instructeurs/column_table_header_component.rb +++ b/app/components/instructeurs/column_table_header_component.rb @@ -1,47 +1,21 @@ # frozen_string_literal: true class Instructeurs::ColumnTableHeaderComponent < ApplicationComponent - attr_reader :procedure_presentation, :column - # maybe extract a ColumnSorter class? - # - - def initialize(procedure_presentation:, column:) + def initialize(procedure_presentation:) @procedure_presentation = procedure_presentation - @column = column + @columns = procedure_presentation.displayed_fields_for_headers + @sorted_column = procedure_presentation.sorted_column end - def column_id - column.id + def label_and_arrow(column) + return column.label if @sorted_column.column != column + + @sorted_column.ascending? ? "#{column.label} ↑" : "#{column.label} ↓" end - def sorted_by_current_column? - procedure_presentation.sort['table'] == column.table && - procedure_presentation.sort['column'] == column.column - end + def aria_sort(column) + return {} if @sorted_column.column != column - def sorted_ascending? - current_sort_order == 'asc' - end - - def sorted_descending? - current_sort_order == 'desc' - end - - def aria_sort - if sorted_by_current_column? - if sorted_ascending? - { "aria-sort": "ascending" } - elsif sorted_descending? - { "aria-sort": "descending" } - end - else - {} - end - end - - private - - def current_sort_order - procedure_presentation.sort['order'] + @sorted_column.ascending? ? { "aria-sort": "ascending" } : { "aria-sort": "descending" } end end diff --git a/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml b/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml index 67c762f2c..1542f0140 100644 --- a/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml +++ b/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml @@ -1,9 +1,4 @@ -%th{ aria_sort, scope: "col", class: column.classname } - = link_to update_sort_instructeur_procedure_path(@procedure_presentation.procedure, column_id:, order: @procedure_presentation.opposite_order_for(column.table, column.column)) do - - if sorted_by_current_column? - - if sorted_ascending? - #{column.label} ↑ - - else - #{column.label} ↓ - - else - #{column.label} +- @columns.each do |column| + %th{ aria_sort(column), scope: "col", class: column.classname } + = link_to update_sort_instructeur_procedure_path(@procedure_presentation.procedure, column_id: column.id, order: @procedure_presentation.opposite_order_for(column.table, column.column)) do + = label_and_arrow(column) diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index 024075234..2a9a62fca 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -93,8 +93,7 @@ %th.text-center %input{ type: "checkbox", disabled: @disable_checkbox_all, checked: @disable_checkbox_all, data: { action: "batch-operation#onCheckAll" }, id: dom_id(BatchOperation.new, :checkbox_all), aria: { label: t('views.instructeurs.dossiers.select_all') } } - - @procedure_presentation.displayed_fields_for_headers.each do |column| - = render Instructeurs::ColumnTableHeaderComponent.new(procedure_presentation: @procedure_presentation, column:) + = render Instructeurs::ColumnTableHeaderComponent.new(procedure_presentation: @procedure_presentation) %th.follow-col Actions From 22cbf725ec1496e6a671d644f6901103b1e72d4d Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 24 Sep 2024 15:06:12 +0200 Subject: [PATCH 1139/1532] column_table_header compute update_sort_path --- .../instructeurs/column_table_header_component.rb | 15 ++++++++++++++- .../column_table_header_component.html.haml | 3 +-- app/models/sorted_column.rb | 2 ++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/components/instructeurs/column_table_header_component.rb b/app/components/instructeurs/column_table_header_component.rb index 0318da05e..6a1575f38 100644 --- a/app/components/instructeurs/column_table_header_component.rb +++ b/app/components/instructeurs/column_table_header_component.rb @@ -2,11 +2,24 @@ class Instructeurs::ColumnTableHeaderComponent < ApplicationComponent def initialize(procedure_presentation:) - @procedure_presentation = procedure_presentation + @procedure = procedure_presentation.procedure @columns = procedure_presentation.displayed_fields_for_headers @sorted_column = procedure_presentation.sorted_column end + private + + def update_sort_path(column) + column_id = column.id + order = opposite_order_for(column) + + update_sort_instructeur_procedure_path(@procedure, column_id:, order:) + end + + def opposite_order_for(column) + @sorted_column.column == column ? @sorted_column.opposite_order : 'asc' + end + def label_and_arrow(column) return column.label if @sorted_column.column != column diff --git a/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml b/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml index 1542f0140..e7cfa0eac 100644 --- a/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml +++ b/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml @@ -1,4 +1,3 @@ - @columns.each do |column| %th{ aria_sort(column), scope: "col", class: column.classname } - = link_to update_sort_instructeur_procedure_path(@procedure_presentation.procedure, column_id: column.id, order: @procedure_presentation.opposite_order_for(column.table, column.column)) do - = label_and_arrow(column) + = link_to label_and_arrow(column), update_sort_path(column) diff --git a/app/models/sorted_column.rb b/app/models/sorted_column.rb index ad96d7ac6..9da384002 100644 --- a/app/models/sorted_column.rb +++ b/app/models/sorted_column.rb @@ -10,6 +10,8 @@ class SortedColumn def ascending? = @order == 'asc' + def opposite_order = ascending? ? 'desc' : 'asc' + def ==(other) other&.column == column && other.order == order end From 7349dd183a0c609880e86af4dd767953b7e03053 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 8 Oct 2024 19:40:19 +0200 Subject: [PATCH 1140/1532] notified_toggle_component use sorted_column --- .../dossiers/notified_toggle_component.rb | 33 +------------------ .../notified_toggle_component.html.haml | 9 +++-- app/models/column.rb | 4 +++ app/models/sorted_column.rb | 4 +++ 4 files changed, 15 insertions(+), 35 deletions(-) diff --git a/app/components/dossiers/notified_toggle_component.rb b/app/components/dossiers/notified_toggle_component.rb index 713afcc83..dc927958a 100644 --- a/app/components/dossiers/notified_toggle_component.rb +++ b/app/components/dossiers/notified_toggle_component.rb @@ -3,37 +3,6 @@ class Dossiers::NotifiedToggleComponent < ApplicationComponent def initialize(procedure:, procedure_presentation:) @procedure = procedure - @procedure_presentation = procedure_presentation - @current_sort = procedure_presentation.sort - end - - private - - def opposite_order - @procedure_presentation.opposite_order_for(current_table, current_column) - end - - def active? - sorted_by_notifications? && order_desc? - end - - def order_desc? - current_order == 'desc' - end - - def current_order - @current_sort['order'] - end - - def current_table - @current_sort['table'] - end - - def current_column - @current_sort['column'] - end - - def sorted_by_notifications? - current_table == 'notifications' && current_column == 'notifications' + @sorted_column = procedure_presentation.sorted_column end end diff --git a/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml b/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml index 84ff21cb9..4a45874c3 100644 --- a/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml +++ b/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml @@ -1,6 +1,9 @@ -= form_tag update_sort_instructeur_procedure_path(procedure_id: @procedure.id, column_id: @procedure.notifications_column.id, order: opposite_order), method: :get, data: { controller: 'autosubmit' } do += form_tag update_sort_instructeur_procedure_path(@procedure), + method: :get, data: { controller: 'autosubmit' } do .fr-fieldset__element.fr-m-0 .fr-checkbox-group.fr-checkbox-group--sm - = check_box_tag :order, opposite_order, active? - = label_tag :order, t('.show_notified_first'), class: 'fr-label' + = hidden_field_tag 'column_id', @procedure.notifications_column.id + = hidden_field_tag 'order', 'asc', id: nil + = check_box_tag 'order', 'desc', @sorted_column.sort_by_notifications? + = label_tag 'order', t('.show_notified_first'), class: 'fr-label' = submit_tag t('.show_notified_first'), data: {"checkbox-target": 'submit' }, class: 'visually-hidden' diff --git a/app/models/column.rb b/app/models/column.rb index 7e7a8159c..a62b129f1 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -28,6 +28,10 @@ class Column } end + def notifications? + table == 'notifications' && column == 'notifications' + end + def self.find(h_id) Procedure.with_discarded.find(h_id[:procedure_id]).find_column(h_id:) end diff --git a/app/models/sorted_column.rb b/app/models/sorted_column.rb index 9da384002..d6bf7dade 100644 --- a/app/models/sorted_column.rb +++ b/app/models/sorted_column.rb @@ -15,4 +15,8 @@ class SortedColumn def ==(other) other&.column == column && other.order == order end + + def sort_by_notifications? + @column.notifications? && @order == 'desc' + end end From 76fee126536a5c35ac7d1fe736cf669d12535817 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 24 Sep 2024 15:37:57 +0200 Subject: [PATCH 1141/1532] remove now unused procedure_presentation.opposite_order_for --- app/models/procedure_presentation.rb | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index d3e08befd..dc711e90c 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -171,16 +171,6 @@ class ProcedurePresentation < ApplicationRecord ) end - def opposite_order_for(table, column) - if sort.values_at(TABLE, COLUMN) == [table, column] - sort['order'] == 'asc' ? 'desc' : 'asc' - elsif [table, column] == ["notifications", "notifications"] - 'desc' # default order for notifications - else - 'asc' - end - end - def snapshot slice(:filters, :sort, :displayed_fields) end From b7ecff4f0dcb31e3a4838d1f30071d8bbd1060b2 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 25 Sep 2024 17:42:42 +0200 Subject: [PATCH 1142/1532] change route to update_sort with sorted_column params --- config/routes.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/routes.rb b/config/routes.rb index e1f7cc8c2..d414b1cd5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -481,7 +481,7 @@ Rails.application.routes.draw do end patch 'update_displayed_fields' - get 'update_sort/:column_id' => 'procedures#update_sort', as: 'update_sort' + get 'update_sort' => 'procedures#update_sort', as: 'update_sort' post 'add_filter' post 'update_filter' get 'remove_filter' From da98aa556be03967e475279acaac4277607c75a4 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 25 Sep 2024 17:43:43 +0200 Subject: [PATCH 1143/1532] use update(sorted_column:) and remove obsolete update_sort(column_id:, order:) --- .../notified_toggle_component.html.haml | 8 +++---- .../column_table_header_component.rb | 4 ++-- .../instructeurs/procedures_controller.rb | 6 ++++- app/models/procedure_presentation.rb | 18 -------------- .../procedures_controller_spec.rb | 10 ++++---- spec/models/procedure_presentation_spec.rb | 24 +++---------------- 6 files changed, 19 insertions(+), 51 deletions(-) diff --git a/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml b/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml index 4a45874c3..272167f90 100644 --- a/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml +++ b/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml @@ -2,8 +2,8 @@ method: :get, data: { controller: 'autosubmit' } do .fr-fieldset__element.fr-m-0 .fr-checkbox-group.fr-checkbox-group--sm - = hidden_field_tag 'column_id', @procedure.notifications_column.id - = hidden_field_tag 'order', 'asc', id: nil - = check_box_tag 'order', 'desc', @sorted_column.sort_by_notifications? - = label_tag 'order', t('.show_notified_first'), class: 'fr-label' + = hidden_field_tag 'sorted_column[id]', @procedure.notifications_column.id + = hidden_field_tag 'sorted_column[order]', 'asc', id: nil + = check_box_tag 'sorted_column[order]', 'desc', @sorted_column.sort_by_notifications? + = label_tag 'sorted_column[order]', t('.show_notified_first'), class: 'fr-label' = submit_tag t('.show_notified_first'), data: {"checkbox-target": 'submit' }, class: 'visually-hidden' diff --git a/app/components/instructeurs/column_table_header_component.rb b/app/components/instructeurs/column_table_header_component.rb index 6a1575f38..2ecb8f1df 100644 --- a/app/components/instructeurs/column_table_header_component.rb +++ b/app/components/instructeurs/column_table_header_component.rb @@ -10,10 +10,10 @@ class Instructeurs::ColumnTableHeaderComponent < ApplicationComponent private def update_sort_path(column) - column_id = column.id + id = column.id order = opposite_order_for(column) - update_sort_instructeur_procedure_path(@procedure, column_id:, order:) + update_sort_instructeur_procedure_path(@procedure, sorted_column: { id:, order: }) end def opposite_order_for(column) diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index 302bde7b1..91f685dd7 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -141,7 +141,7 @@ module Instructeurs end def update_sort - procedure_presentation.update_sort(params[:column_id], params[:order]) + procedure_presentation.update!(sorted_column_params) redirect_back(fallback_location: instructeur_procedure_url(procedure)) end @@ -411,5 +411,9 @@ module Instructeurs def cookies_export_key "exports_#{@procedure.id}_seen_at" end + + def sorted_column_params + params.permit(sorted_column: [:order, :id]) + end end end diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index dc711e90c..f75a04ed5 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -153,24 +153,6 @@ class ProcedurePresentation < ApplicationRecord end end - def update_sort(column_id, order) - h_id = JSON.parse(column_id, symbolize_names: true) - column = procedure.find_column(h_id:) - order = order.presence || opposite_order_for(column.table, column.column) - - update!( - sort: { - TABLE => column.table, - COLUMN => column.column, - ORDER => order - }, - sorted_column: { - order:, - id: h_id - } - ) - end - def snapshot slice(:filters, :sort, :displayed_fields) end diff --git a/spec/controllers/instructeurs/procedures_controller_spec.rb b/spec/controllers/instructeurs/procedures_controller_spec.rb index d24a519ed..1dcb18a35 100644 --- a/spec/controllers/instructeurs/procedures_controller_spec.rb +++ b/spec/controllers/instructeurs/procedures_controller_spec.rb @@ -886,11 +886,11 @@ describe Instructeurs::ProceduresController, type: :controller do end it 'can change order' do - column_id = procedure.find_column(label: "Nom").id - expect { get :update_sort, params: { procedure_id: procedure.id, column_id:, order: 'asc' } } - .to change { procedure_presentation.sort } - .from({ "column" => "notifications", "order" => "desc", "table" => "notifications" }) - .to({ "column" => "nom", "order" => "asc", "table" => "individual" }) + column = procedure.find_column(label: "Nom") + expect { get :update_sort, params: { procedure_id: procedure.id, sorted_column: { id: column.id, order: 'asc' } } } + .to change { procedure_presentation.sorted_column } + .from(procedure.default_sorted_column) + .to(SortedColumn.new(column:, order: 'asc')) end end diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index 6fbdbf9c2..a7677d4f7 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -971,7 +971,7 @@ describe ProcedurePresentation do describe '#update_displayed_fields' do let(:procedure_presentation) do create(:procedure_presentation, assign_to:).tap do |pp| - pp.update_sort(procedure.find_column(label: 'Demandeur').id, 'desc') + pp.update(sorted_column: SortedColumn.new(column: procedure.find_column(label: 'Demandeur'), order: 'desc')) end end @@ -992,26 +992,8 @@ describe ProcedurePresentation do { "column_id" => "self/updated_at", "procedure_id" => procedure.id } ]) - expect(procedure_presentation.sorted_column['id']).to eq("column_id" => "self/id", "procedure_id" => procedure.id) - expect(procedure_presentation.sorted_column['order']).to eq('desc') - end - end - - describe '#update_sort' do - let(:procedure_presentation) { create(:procedure_presentation, assign_to:) } - - subject do - column_id = procedure.find_column(label: 'En construction le').id - procedure_presentation.update_sort(column_id, 'asc') - end - - it 'should update sort and order' do - expect(procedure_presentation.sorted_column).to be_nil - - subject - - expect(procedure_presentation.sorted_column['id']).to eq("column_id" => "self/en_construction_at", "procedure_id" => procedure.id) - expect(procedure_presentation.sorted_column['order']).to eq('asc') + expect(procedure_presentation.sorted_column).to eq(procedure.default_sorted_column) + expect(procedure_presentation.sorted_column.order).to eq('desc') end end end From ba91f2f66e4131e2a003ee0d780f90b999507c2b Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 26 Sep 2024 15:21:26 +0200 Subject: [PATCH 1144/1532] remove now useless validation --- app/models/procedure_presentation.rb | 18 ----------------- ...remove_migration_status_on_filters_spec.rb | 20 ------------------- spec/models/procedure_presentation_spec.rb | 7 ------- 3 files changed, 45 deletions(-) diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index f75a04ed5..1185a416f 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -1,11 +1,6 @@ # frozen_string_literal: true class ProcedurePresentation < ApplicationRecord - EXTRA_SORT_COLUMNS = { - 'notifications' => ['notifications'], - 'self' => ['id', 'state'] - } - TABLE = 'table' COLUMN = 'column' ORDER = 'order' @@ -23,8 +18,6 @@ class ProcedurePresentation < ApplicationRecord delegate :procedure, :instructeur, to: :assign_to validate :check_allowed_displayed_fields - validate :check_allowed_sort_column - validate :check_allowed_sort_order validate :check_allowed_filter_columns validate :check_filters_max_length validate :check_filters_max_integer @@ -289,17 +282,6 @@ class ProcedurePresentation < ApplicationRecord end end - def check_allowed_sort_column - check_allowed_field(:sort, sort, EXTRA_SORT_COLUMNS) - end - - def check_allowed_sort_order - order = sort['order'] - if !["asc", "desc"].include?(order) - errors.add(:sort, "#{order} n’est pas une ordre permis") - end - end - def check_allowed_filter_columns filters.each do |key, columns| return true if key == 'migrated' diff --git a/spec/lib/tasks/deployment/20210720133539_remove_migration_status_on_filters_spec.rb b/spec/lib/tasks/deployment/20210720133539_remove_migration_status_on_filters_spec.rb index 866c5fe55..1eb4e20d5 100644 --- a/spec/lib/tasks/deployment/20210720133539_remove_migration_status_on_filters_spec.rb +++ b/spec/lib/tasks/deployment/20210720133539_remove_migration_status_on_filters_spec.rb @@ -47,24 +47,4 @@ describe '20201001161931_migrate_filters_to_use_stable_id' do expect(procedure_presentation_without_migration.filters['suivis']).to be_present end end - - context 'when the procedure presentation is invalid' do - before do - procedure_presentation_with_migration.update_column( - :sort, - { table: 'invalid-table', column: 'invalid-column', order: 'invalid-order' } - ) - end - - it 'removes the "migrated" key properly' do - run_task - expect(procedure_presentation_with_migration).not_to be_valid - expect(procedure_presentation_with_migration.filters).not_to have_key('migrated') - end - - it 'leaves the other keys unchanged' do - run_task - expect(procedure_presentation_without_migration.filters['suivis']).to be_present - end - end end diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index a7677d4f7..8fb60d672 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -41,13 +41,6 @@ describe ProcedurePresentation do it { expect(build(:procedure_presentation, displayed_fields: [{ table: "user", column: "reset_password_token", "order" => "asc" }])).to be_invalid } end - context 'of sort' do - it { expect(build(:procedure_presentation, sort: { table: "notifications", column: "notifications", "order" => "asc" })).to be_valid } - it { expect(build(:procedure_presentation, sort: { table: "self", column: "id", "order" => "asc" })).to be_valid } - it { expect(build(:procedure_presentation, sort: { table: "self", column: "state", "order" => "asc" })).to be_valid } - it { expect(build(:procedure_presentation, sort: { table: "user", column: "reset_password_token", "order" => "asc" })).to be_invalid } - end - context 'of filters' do it { expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "user", column: "reset_password_token", "order" => "asc" }] })).to be_invalid } it { expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "user", column: "email", "value" => "exceedingly long filter value" * 10 }] })).to be_invalid } From 4f0cac251dbddceb6f60faa7197bc1eb9557a029 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 26 Sep 2024 17:18:43 +0200 Subject: [PATCH 1145/1532] use sorted_column to sort dossier --- app/models/procedure_presentation.rb | 4 +- app/models/sorted_column.rb | 2 +- spec/models/procedure_presentation_spec.rb | 90 +++++++--------------- 3 files changed, 31 insertions(+), 65 deletions(-) diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 1185a416f..a9165c3cf 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -153,7 +153,9 @@ class ProcedurePresentation < ApplicationRecord private def sorted_ids(dossiers, count) - table, column, order = sort.values_at(TABLE, COLUMN, 'order') + table = sorted_column.column.table + column = sorted_column.column.column + order = sorted_column.order case table when 'notifications' diff --git a/app/models/sorted_column.rb b/app/models/sorted_column.rb index d6bf7dade..d254405d7 100644 --- a/app/models/sorted_column.rb +++ b/app/models/sorted_column.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class SortedColumn - attr_reader :column + attr_reader :column, :order def initialize(column:, order:) @column = column diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index 8fb60d672..1a42bb1a2 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -54,19 +54,18 @@ describe ProcedurePresentation do describe '#sorted_ids' do let(:instructeur) { create(:instructeur) } - let(:assign_to) { create(:assign_to, procedure: procedure, instructeur: instructeur) } - let(:sort) { { 'table' => table, 'column' => column, 'order' => order } } - let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to, sort: sort) } + let(:assign_to) { create(:assign_to, procedure:, instructeur:) } + let(:sorted_column) { SortedColumn.new(column:, order:) } + let(:procedure_presentation) { create(:procedure_presentation, assign_to:, sorted_column:) } subject { procedure_presentation.send(:sorted_ids, procedure.dossiers, procedure.dossiers.count) } context 'for notifications table' do - let(:table) { 'notifications' } - let(:column) { 'notifications' } + let(:column) { procedure.notifications_column } - let!(:notified_dossier) { create(:dossier, :en_construction, procedure: procedure) } - let!(:recent_dossier) { create(:dossier, :en_construction, procedure: procedure) } - let!(:older_dossier) { create(:dossier, :en_construction, procedure: procedure) } + let!(:notified_dossier) { create(:dossier, :en_construction, procedure:) } + let!(:recent_dossier) { create(:dossier, :en_construction, procedure:) } + let!(:older_dossier) { create(:dossier, :en_construction, procedure:) } before do notified_dossier.update!(last_champ_updated_at: Time.zone.local(2018, 9, 20)) @@ -89,7 +88,7 @@ describe ProcedurePresentation do end context 'with a dossier terminé' do - let!(:notified_dossier) { create(:dossier, :accepte, procedure: procedure) } + let!(:notified_dossier) { create(:dossier, :accepte, procedure:) } let(:order) { 'desc' } it { is_expected.to eq([notified_dossier, recent_dossier, older_dossier].map(&:id)) } @@ -97,11 +96,10 @@ describe ProcedurePresentation do end context 'for self table' do - let(:table) { 'self' } let(:order) { 'asc' } # Desc works the same, no extra test required context 'for created_at column' do - let(:column) { 'created_at' } + let!(:column) { procedure.find_column(label: 'Créé le') } let!(:recent_dossier) { Timecop.freeze(Time.zone.local(2018, 10, 17)) { create(:dossier, procedure: procedure) } } let!(:older_dossier) { Timecop.freeze(Time.zone.local(2003, 11, 11)) { create(:dossier, procedure: procedure) } } @@ -109,7 +107,7 @@ describe ProcedurePresentation do end context 'for en_construction_at column' do - let(:column) { 'en_construction_at' } + let!(:column) { procedure.find_column(label: 'En construction le') } let!(:recent_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 17)) } let!(:older_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2013, 1, 1)) } @@ -117,7 +115,7 @@ describe ProcedurePresentation do end context 'for updated_at column' do - let(:column) { 'updated_at' } + let(:column) { procedure.find_column(label: 'Mis à jour le') } let(:recent_dossier) { create(:dossier, procedure: procedure) } let(:older_dossier) { create(:dossier, procedure: procedure) } @@ -133,10 +131,10 @@ describe ProcedurePresentation do context 'for type_de_champ table' do context 'with no revisions' do let(:table) { 'type_de_champ' } - let(:column) { procedure.active_revision.types_de_champ_public.first.stable_id.to_s } + let(:column) { procedure.find_column(label: first_type_de_champ.libelle) } - let(:beurre_dossier) { create(:dossier, procedure: procedure) } - let(:tartine_dossier) { create(:dossier, procedure: procedure) } + let(:beurre_dossier) { create(:dossier, procedure:) } + let(:tartine_dossier) { create(:dossier, procedure:) } before do beurre_dossier.project_champs_public.first.update(value: 'beurre') @@ -158,12 +156,11 @@ describe ProcedurePresentation do context 'with a revision adding a new type_de_champ' do let!(:tdc) { { type_champ: :text, libelle: 'nouveau champ' } } - let(:table) { 'type_de_champ' } - let(:column) { procedure.active_revision.types_de_champ_public.last.stable_id.to_s } + let(:column) { procedure.find_column(label: 'nouveau champ') } - let(:nothing_dossier) { create(:dossier, procedure: procedure) } - let(:beurre_dossier) { create(:dossier, procedure: procedure) } - let(:tartine_dossier) { create(:dossier, procedure: procedure) } + let!(:nothing_dossier) { create(:dossier, procedure:) } + let!(:beurre_dossier) { create(:dossier, procedure:) } + let!(:tartine_dossier) { create(:dossier, procedure:) } before do nothing_dossier @@ -175,20 +172,19 @@ describe ProcedurePresentation do context 'asc' do let(:order) { 'asc' } - it { is_expected.to eq([beurre_dossier, tartine_dossier, nothing_dossier].map(&:id)) } + it { is_expected.to eq([nothing_dossier, beurre_dossier, tartine_dossier].map(&:id)) } end context 'desc' do let(:order) { 'desc' } - it { is_expected.to eq([nothing_dossier, tartine_dossier, beurre_dossier].map(&:id)) } + it { is_expected.to eq([tartine_dossier, beurre_dossier, nothing_dossier].map(&:id)) } end end end context 'for type_de_champ_private table' do context 'with no revisions' do - let(:table) { 'type_de_champ' } - let(:column) { procedure.active_revision.types_de_champ_private.first.stable_id.to_s } + let(:column) { procedure.find_column(label: procedure.active_revision.types_de_champ_private.first.libelle) } let(:biere_dossier) { create(:dossier, procedure: procedure) } let(:vin_dossier) { create(:dossier, procedure: procedure) } @@ -210,38 +206,9 @@ describe ProcedurePresentation do it { is_expected.to eq([vin_dossier, biere_dossier].map(&:id)) } end end - - context 'with a revision adding a new type_de_champ' do - let!(:tdc) { { type_champ: :text, private: true, libelle: 'nouveau champ' } } - let(:table) { 'type_de_champ' } - let(:column) { procedure.active_revision.types_de_champ_private.last.stable_id.to_s } - - let(:nothing_dossier) { create(:dossier, procedure: procedure) } - let(:biere_dossier) { create(:dossier, procedure: procedure) } - let(:vin_dossier) { create(:dossier, procedure: procedure) } - - before do - nothing_dossier - procedure.draft_revision.add_type_de_champ(tdc) - procedure.publish_revision! - biere_dossier.project_champs_private.last.update(value: 'biere') - vin_dossier.project_champs_private.last.update(value: 'vin') - end - - context 'asc' do - let(:order) { 'asc' } - it { is_expected.to eq([biere_dossier, vin_dossier, nothing_dossier].map(&:id)) } - end - - context 'desc' do - let(:order) { 'desc' } - it { is_expected.to eq([nothing_dossier, vin_dossier, biere_dossier].map(&:id)) } - end - end end context 'for individual table' do - let(:table) { 'individual' } let(:order) { 'asc' } # Desc works the same, no extra test required let(:procedure) { create(:procedure, :for_individual) } @@ -250,26 +217,25 @@ describe ProcedurePresentation do let!(:last_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'Mme', prenom: 'Zora', nom: 'Zemmour')) } context 'for gender column' do - let(:column) { 'gender' } + let(:column) { procedure.find_column(label: 'Civilité') } it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) } end context 'for prenom column' do - let(:column) { 'prenom' } + let(:column) { procedure.find_column(label: 'Prénom') } it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) } end context 'for nom column' do - let(:column) { 'nom' } + let(:column) { procedure.find_column(label: 'Nom') } it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) } end end context 'for followers_instructeurs table' do - let(:table) { 'followers_instructeurs' } let(:order) { 'asc' } # Desc works the same, no extra test required let!(:dossier_z) { create(:dossier, :en_construction, procedure: procedure) } @@ -283,15 +249,14 @@ describe ProcedurePresentation do end context 'for email column' do - let(:column) { 'email' } + let(:column) { procedure.find_column(label: 'Email instructeur') } it { is_expected.to eq([dossier_a, dossier_z, dossier_without_instructeur].map(&:id)) } end end context 'for avis table' do - let(:table) { 'avis' } - let(:column) { 'question_answer' } + let(:column) { procedure.find_column(label: 'Avis oui/non') } let(:order) { 'asc' } let!(:dossier_yes) { create(:dossier, procedure:) } @@ -308,8 +273,7 @@ describe ProcedurePresentation do context 'for other tables' do # All other columns and tables work the same so it’s ok to test only one - let(:table) { 'etablissement' } - let(:column) { 'code_postal' } + let(:column) { procedure.find_column(label: 'Code postal') } let(:order) { 'asc' } # Desc works the same, no extra test required let!(:huitieme_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '75008')) } From 249ddf291f47b5e1c6afc38670b8eb5435301e2b Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 8 Oct 2024 13:03:40 +0200 Subject: [PATCH 1146/1532] update_displayed_fields can use sorted_column --- app/models/procedure_presentation.rb | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index a9165c3cf..828827ecd 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -140,9 +140,8 @@ class ProcedurePresentation < ApplicationRecord displayed_columns: columns.map(&:h_id) ) - if !sort_to_column_id(sort).in?(column_ids) - default_column_id = procedure.dossier_id_column.id - update_sort(default_column_id, "desc") + if !sorted_column.column.in?(columns) + update(sorted_column: nil) end end @@ -265,11 +264,6 @@ class ProcedurePresentation < ApplicationRecord end.reduce(:&) end - # type_de_champ/4373429 - def sort_to_column_id(sort) - [sort[TABLE], sort[COLUMN]].join(SLASH) - end - def find_type_de_champ(column) TypeDeChamp .joins(:revision_types_de_champ) From 450420aa814d8a2619f62c3d32cdf15f2bcc9c4d Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 25 Sep 2024 12:43:23 +0200 Subject: [PATCH 1147/1532] cache columns per request using Current --- app/models/concerns/columns_concern.rb | 14 +++++++++----- app/models/current.rb | 1 + 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index caac5bc74..102df0b7a 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -10,11 +10,15 @@ module ColumnsConcern end def columns - columns = dossier_columns - columns.concat(standard_columns) - columns.concat(individual_columns) if for_individual - columns.concat(moral_columns) if !for_individual - columns.concat(types_de_champ_columns) + Current.procedure_columns ||= {} + + Current.procedure_columns[id] ||= begin + columns = dossier_columns + columns.concat(standard_columns) + columns.concat(individual_columns) if for_individual + columns.concat(moral_columns) if !for_individual + columns.concat(types_de_champ_columns) + end end def dossier_id_column diff --git a/app/models/current.rb b/app/models/current.rb index a2f863f3e..77045cfff 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -9,4 +9,5 @@ class Current < ActiveSupport::CurrentAttributes attribute :no_reply_email attribute :request_id attribute :user + attribute :procedure_columns end From 14809b35af261050ccd9e03ff16f2b394336ac31 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 9 Oct 2024 09:21:44 +0200 Subject: [PATCH 1148/1532] add comments --- app/models/column.rb | 5 +++++ app/models/concerns/columns_concern.rb | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/app/models/column.rb b/app/models/column.rb index a62b129f1..662bfda76 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -18,8 +18,13 @@ class Column @displayable = displayable end + # the id is a String to be used in forms def id = h_id.to_json + + # the h_id is a Hash and hold enough information to find the column + # in the ColumnType class, aka be able to do the h_id -> column conversion def h_id = { procedure_id: @procedure_id, column_id: "#{table}/#{column}" } + def ==(other) = h_id == other.h_id # using h_id instead of id to avoid inversion of keys def to_json diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 102df0b7a..a3c62fe50 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -4,6 +4,10 @@ module ColumnsConcern extend ActiveSupport::Concern included do + # we cannot use column.id ( == { procedure_id, column_id }.to_json) + # as the order of the keys is not guaranteed + # instead, we are using h_id == { procedure_id:, column_id: } + # another way to find a column is to look for its label def find_column(h_id: nil, label: nil) return columns.find { _1.h_id == h_id } if h_id.present? return columns.find { _1.label == label } if label.present? From 34b0379203af063ef3822da3bfbf893aba8f56cd Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 9 Oct 2024 09:23:06 +0200 Subject: [PATCH 1149/1532] procedure.find_column raise NotFound to fit AR interface --- app/models/concerns/columns_concern.rb | 8 ++++++-- spec/models/concerns/columns_concern_spec.rb | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index a3c62fe50..d21cb1cba 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -9,8 +9,12 @@ module ColumnsConcern # instead, we are using h_id == { procedure_id:, column_id: } # another way to find a column is to look for its label def find_column(h_id: nil, label: nil) - return columns.find { _1.h_id == h_id } if h_id.present? - return columns.find { _1.label == label } if label.present? + column = columns.find { _1.h_id == h_id } if h_id.present? + column = columns.find { _1.label == label } if label.present? + + raise ActiveRecord::RecordNotFound if column.nil? + + column end def columns diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index 63ac65622..e7f167865 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -1,6 +1,22 @@ # frozen_string_literal: true describe ColumnsConcern do + describe '#find_column' do + let(:procedure) { build(:procedure) } + let(:notifications_column) { procedure.notifications_column } + + it do + label = notifications_column.label + expect(procedure.find_column(label:)).to eq(notifications_column) + + h_id = notifications_column.h_id + expect(procedure.find_column(h_id:)).to eq(notifications_column) + + unknwon = 'unknown' + expect { procedure.find_column(h_id: unknwon) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + describe "#columns" do subject { procedure.columns } From e56bc9d35b48729fbc1beda062d7e4b8a3344a25 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 9 Oct 2024 11:26:53 +0200 Subject: [PATCH 1150/1532] chore(prefill): remove unused prefill support on complex champs --- app/models/type_de_champ.rb | 4 +-- ...refill_annuaire_education_type_de_champ.rb | 13 -------- .../types_de_champ/prefill_type_de_champ.rb | 2 -- spec/models/prefill_champs_spec.rb | 4 --- spec/models/type_de_champ_spec.rb | 4 +-- ...l_annuaire_education_type_de_champ_spec.rb | 33 ------------------- .../prefill_type_de_champ_spec.rb | 6 ---- .../shared_examples_for_prefilled_dossier.rb | 2 -- spec/system/users/dossier_prefill_get_spec.rb | 27 +++++---------- .../system/users/dossier_prefill_post_spec.rb | 27 +++++---------- 10 files changed, 19 insertions(+), 103 deletions(-) delete mode 100644 app/models/types_de_champ/prefill_annuaire_education_type_de_champ.rb delete mode 100644 spec/models/types_de_champ/prefill_annuaire_education_type_de_champ_spec.rb diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 559f621b8..7673b15cc 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -298,10 +298,8 @@ class TypeDeChamp < ApplicationRecord TypeDeChamp.type_champs.fetch(:repetition), TypeDeChamp.type_champs.fetch(:multiple_drop_down_list), TypeDeChamp.type_champs.fetch(:epci), - TypeDeChamp.type_champs.fetch(:annuaire_education), TypeDeChamp.type_champs.fetch(:dossier_link), - TypeDeChamp.type_champs.fetch(:siret), - TypeDeChamp.type_champs.fetch(:rna) + TypeDeChamp.type_champs.fetch(:siret) ]) end diff --git a/app/models/types_de_champ/prefill_annuaire_education_type_de_champ.rb b/app/models/types_de_champ/prefill_annuaire_education_type_de_champ.rb deleted file mode 100644 index 7790458d9..000000000 --- a/app/models/types_de_champ/prefill_annuaire_education_type_de_champ.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -class TypesDeChamp::PrefillAnnuaireEducationTypeDeChamp < TypesDeChamp::PrefillTypeDeChamp - def to_assignable_attributes(champ, value) - return nil if value.blank? - - { - id: champ.id, - external_id: value, - value: value - } - end -end diff --git a/app/models/types_de_champ/prefill_type_de_champ.rb b/app/models/types_de_champ/prefill_type_de_champ.rb index 46917ea62..1b112f094 100644 --- a/app/models/types_de_champ/prefill_type_de_champ.rb +++ b/app/models/types_de_champ/prefill_type_de_champ.rb @@ -31,8 +31,6 @@ class TypesDeChamp::PrefillTypeDeChamp < SimpleDelegator TypesDeChamp::PrefillAddressTypeDeChamp.new(type_de_champ, revision) when TypeDeChamp.type_champs.fetch(:epci) TypesDeChamp::PrefillEpciTypeDeChamp.new(type_de_champ, revision) - when TypeDeChamp.type_champs.fetch(:annuaire_education) - TypesDeChamp::PrefillAnnuaireEducationTypeDeChamp.new(type_de_champ, revision) else new(type_de_champ, revision) end diff --git a/spec/models/prefill_champs_spec.rb b/spec/models/prefill_champs_spec.rb index 14a31b117..fc6e5d4a4 100644 --- a/spec/models/prefill_champs_spec.rb +++ b/spec/models/prefill_champs_spec.rb @@ -124,12 +124,10 @@ RSpec.describe PrefillChamps do it_behaves_like "a champ public value that is authorized", :departements, "03" it_behaves_like "a champ public value that is authorized", :communes, ['01540', '01457'] it_behaves_like "a champ public value that is authorized", :address, "20 avenue de Ségur 75007 Paris" - it_behaves_like "a champ public value that is authorized", :annuaire_education, "0050009H" it_behaves_like "a champ public value that is authorized", :multiple_drop_down_list, ["val1", "val2"] it_behaves_like "a champ public value that is authorized", :dossier_link, "1" it_behaves_like "a champ public value that is authorized", :epci, ['01', '200042935'] it_behaves_like "a champ public value that is authorized", :siret, "13002526500013" - it_behaves_like "a champ public value that is authorized", :rna, "value" context "when the public type de champ is authorized (repetition)" do let(:types_de_champ_public) { [{ type: :repetition, children: [{ type: :text }] }] } @@ -164,12 +162,10 @@ RSpec.describe PrefillChamps do it_behaves_like "a champ private value that is authorized", :checkbox, "false" it_behaves_like "a champ private value that is authorized", :drop_down_list, "value" it_behaves_like "a champ private value that is authorized", :regions, "93" - it_behaves_like "a champ private value that is authorized", :rna, "value" it_behaves_like "a champ private value that is authorized", :siret, "13002526500013" it_behaves_like "a champ private value that is authorized", :departements, "03" it_behaves_like "a champ private value that is authorized", :communes, ['01540', '01457'] it_behaves_like "a champ private value that is authorized", :address, "20 avenue de Ségur 75007 Paris" - it_behaves_like "a champ private value that is authorized", :annuaire_education, "0050009H" it_behaves_like "a champ private value that is authorized", :multiple_drop_down_list, ["val1", "val2"] it_behaves_like "a champ private value that is authorized", :dossier_link, "1" it_behaves_like "a champ private value that is authorized", :epci, ['01', '200042935'] diff --git a/spec/models/type_de_champ_spec.rb b/spec/models/type_de_champ_spec.rb index 94e5ad974..4f766ba14 100644 --- a/spec/models/type_de_champ_spec.rb +++ b/spec/models/type_de_champ_spec.rb @@ -255,12 +255,10 @@ describe TypeDeChamp do it_behaves_like "a prefillable type de champ", :type_de_champ_checkbox it_behaves_like "a prefillable type de champ", :type_de_champ_drop_down_list it_behaves_like "a prefillable type de champ", :type_de_champ_repetition - it_behaves_like "a prefillable type de champ", :type_de_champ_annuaire_education it_behaves_like "a prefillable type de champ", :type_de_champ_multiple_drop_down_list it_behaves_like "a prefillable type de champ", :type_de_champ_epci it_behaves_like "a prefillable type de champ", :type_de_champ_dossier_link it_behaves_like "a prefillable type de champ", :type_de_champ_siret - it_behaves_like "a prefillable type de champ", :type_de_champ_rna it_behaves_like "a non-prefillable type de champ", :type_de_champ_number it_behaves_like "a non-prefillable type de champ", :type_de_champ_titre_identite @@ -273,6 +271,8 @@ describe TypeDeChamp do it_behaves_like "a non-prefillable type de champ", :type_de_champ_pole_emploi it_behaves_like "a non-prefillable type de champ", :type_de_champ_mesri it_behaves_like "a non-prefillable type de champ", :type_de_champ_carte + it_behaves_like "a non-prefillable type de champ", :type_de_champ_rna + it_behaves_like "a non-prefillable type de champ", :type_de_champ_annuaire_education end describe '#normalize_libelle' do diff --git a/spec/models/types_de_champ/prefill_annuaire_education_type_de_champ_spec.rb b/spec/models/types_de_champ/prefill_annuaire_education_type_de_champ_spec.rb deleted file mode 100644 index 6e3b80325..000000000 --- a/spec/models/types_de_champ/prefill_annuaire_education_type_de_champ_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe TypesDeChamp::PrefillAnnuaireEducationTypeDeChamp do - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :annuaire_education }]) } - let(:dossier) { create(:dossier, procedure:) } - let(:type_de_champ) { procedure.active_revision.types_de_champ.first } - - describe 'ancestors' do - subject { described_class.new(type_de_champ, procedure.active_revision) } - - it { is_expected.to be_kind_of(TypesDeChamp::PrefillTypeDeChamp) } - end - - describe '#to_assignable_attributes' do - let(:champ) { dossier.champs.first } - subject { described_class.build(type_de_champ, procedure.active_revision).to_assignable_attributes(champ, value) } - - context 'when the value is nil' do - let(:value) { nil } - it { is_expected.to eq(nil) } - end - - context 'when the value is empty' do - let(:value) { '' } - it { is_expected.to eq(nil) } - end - - context 'when the value is present' do - let(:value) { '0050009H' } - it { is_expected.to match({ id: champ.id, external_id: '0050009H', value: '0050009H' }) } - end - end -end diff --git a/spec/models/types_de_champ/prefill_type_de_champ_spec.rb b/spec/models/types_de_champ/prefill_type_de_champ_spec.rb index 38577f90d..35955a466 100644 --- a/spec/models/types_de_champ/prefill_type_de_champ_spec.rb +++ b/spec/models/types_de_champ/prefill_type_de_champ_spec.rb @@ -63,12 +63,6 @@ RSpec.describe TypesDeChamp::PrefillTypeDeChamp, type: :model do it { expect(built).to be_kind_of(TypesDeChamp::PrefillEpciTypeDeChamp) } end - context 'when the type de champ is an annuaire_education' do - let(:type_de_champ) { build(:type_de_champ_annuaire_education) } - - it { expect(built).to be_kind_of(TypesDeChamp::PrefillAnnuaireEducationTypeDeChamp) } - end - context 'when any other type de champ' do let(:type_de_champ) { build(:type_de_champ_date, procedure: procedure) } diff --git a/spec/support/shared_examples_for_prefilled_dossier.rb b/spec/support/shared_examples_for_prefilled_dossier.rb index 841a20c14..721eeb877 100644 --- a/spec/support/shared_examples_for_prefilled_dossier.rb +++ b/spec/support/shared_examples_for_prefilled_dossier.rb @@ -18,7 +18,6 @@ shared_examples "the user has got a prefilled dossier, owned by themselves" do expect(page).to have_field(type_de_champ_text.libelle, with: text_value) expect(page).to have_field(type_de_champ_phone.libelle, with: phone_value) expect(page).to have_css('label', text: type_de_champ_phone.libelle) - expect(page).to have_field(type_de_champ_rna.libelle, with: rna_value) expect(page).to have_field(type_de_champ_siret.libelle, with: siret_value) expect(page).to have_css('legend', text: type_de_champ_repetition.libelle) expect(page).to have_field(text_repetition_libelle, with: text_repetition_value) @@ -31,7 +30,6 @@ shared_examples "the user has got a prefilled dossier, owned by themselves" do expect(page).to have_selector("option[value='#{epci_value.last}'][selected]") expect(page).to have_field(type_de_champ_dossier_link.libelle, with: dossier_link_value) expect(page).to have_field(type_de_champ_commune.libelle, with: commune_libelle) - expect(page).to have_content(annuaire_education_value.last) expect(page).to have_content(address_value.last) end end diff --git a/spec/system/users/dossier_prefill_get_spec.rb b/spec/system/users/dossier_prefill_get_spec.rb index 616e282b0..b6271fd8c 100644 --- a/spec/system/users/dossier_prefill_get_spec.rb +++ b/spec/system/users/dossier_prefill_get_spec.rb @@ -7,12 +7,10 @@ describe 'Prefilling a dossier (with a GET request):', js: true do [ { type: :text }, { type: :phone }, - { type: :rna }, { type: :siret }, { type: :datetime }, { type: :multiple_drop_down_list }, { type: :epci }, - { type: :annuaire_education }, { type: :dossier_link }, { type: :communes }, { type: :address }, @@ -25,20 +23,17 @@ describe 'Prefilling a dossier (with a GET request):', js: true do let(:type_de_champ_text) { types_de_champ[0] } let(:type_de_champ_phone) { types_de_champ[1] } - let(:type_de_champ_rna) { types_de_champ[2] } - let(:type_de_champ_siret) { types_de_champ[3] } - let(:type_de_champ_datetime) { types_de_champ[4] } - let(:type_de_champ_multiple_drop_down_list) { types_de_champ[5] } - let(:type_de_champ_epci) { types_de_champ[6] } - let(:type_de_champ_annuaire_education) { types_de_champ[7] } - let(:type_de_champ_dossier_link) { types_de_champ[8] } - let(:type_de_champ_commune) { types_de_champ[9] } - let(:type_de_champ_address) { types_de_champ[10] } - let(:type_de_champ_repetition) { types_de_champ[11] } + let(:type_de_champ_siret) { types_de_champ[2] } + let(:type_de_champ_datetime) { types_de_champ[3] } + let(:type_de_champ_multiple_drop_down_list) { types_de_champ[4] } + let(:type_de_champ_epci) { types_de_champ[5] } + let(:type_de_champ_dossier_link) { types_de_champ[6] } + let(:type_de_champ_commune) { types_de_champ[7] } + let(:type_de_champ_address) { types_de_champ[8] } + let(:type_de_champ_repetition) { types_de_champ[9] } let(:text_value) { "My Neighbor Totoro is the best movie ever" } let(:phone_value) { "invalid phone value" } - let(:rna_value) { 'W595001988' } let(:siret_value) { '41816609600051' } let(:datetime_value) { "2023-02-01T10:32" } let(:multiple_drop_down_list_values) { @@ -57,7 +52,6 @@ describe 'Prefilling a dossier (with a GET request):', js: true do let(:integer_repetition_libelle) { sub_types_de_champ_repetition.second.libelle } let(:text_repetition_value) { "First repetition text" } let(:integer_repetition_value) { "42" } - let(:annuaire_education_value) { '0050009H' } let(:prenom_value) { 'Jean' } let(:nom_value) { 'Dupont' } let(:genre_value) { 'M.' } @@ -74,14 +68,12 @@ describe 'Prefilling a dossier (with a GET request):', js: true do "champ_#{type_de_champ_commune.to_typed_id_for_query}" => commune_value, "champ_#{type_de_champ_address.to_typed_id_for_query}" => address_value, "champ_#{type_de_champ_siret.to_typed_id_for_query}" => siret_value, - "champ_#{type_de_champ_rna.to_typed_id_for_query}" => rna_value, "champ_#{type_de_champ_repetition.to_typed_id_for_query}" => [ { "champ_#{sub_types_de_champ_repetition.first.to_typed_id_for_query}": text_repetition_value, "champ_#{sub_types_de_champ_repetition.second.to_typed_id_for_query}": integer_repetition_value } ], - "champ_#{type_de_champ_annuaire_education.to_typed_id_for_query}" => annuaire_education_value, "identite_prenom" => prenom_value, "identite_nom" => nom_value, "identite_genre" => genre_value @@ -94,9 +86,6 @@ describe 'Prefilling a dossier (with a GET request):', js: true do stub_request(:get, /https:\/\/entreprise.api.gouv.fr\/v3\/insee\/sirene\/unites_legales\/#{siret_value[0..8]}/) .to_return(status: 200, body: File.read('spec/fixtures/files/api_entreprise/entreprises.json')) - - stub_request(:get, /https:\/\/entreprise.api.gouv.fr\/v4\/djepva\/api-association\/associations\/open_data\/#{rna_value}/) - .to_return(status: 200, body: File.read('spec/fixtures/files/api_entreprise/associations.json')) end context 'when authenticated' do diff --git a/spec/system/users/dossier_prefill_post_spec.rb b/spec/system/users/dossier_prefill_post_spec.rb index 55d0f99da..1614a3f63 100644 --- a/spec/system/users/dossier_prefill_post_spec.rb +++ b/spec/system/users/dossier_prefill_post_spec.rb @@ -7,12 +7,10 @@ describe 'Prefilling a dossier (with a POST request):', js: true do [ { type: :text }, { type: :phone }, - { type: :rna }, { type: :siret }, { type: :datetime }, { type: :multiple_drop_down_list }, { type: :epci }, - { type: :annuaire_education }, { type: :dossier_link }, { type: :communes }, { type: :address }, @@ -25,20 +23,17 @@ describe 'Prefilling a dossier (with a POST request):', js: true do let(:type_de_champ_text) { types_de_champ[0] } let(:type_de_champ_phone) { types_de_champ[1] } - let(:type_de_champ_rna) { types_de_champ[2] } - let(:type_de_champ_siret) { types_de_champ[3] } - let(:type_de_champ_datetime) { types_de_champ[4] } - let(:type_de_champ_multiple_drop_down_list) { types_de_champ[5] } - let(:type_de_champ_epci) { types_de_champ[6] } - let(:type_de_champ_annuaire_education) { types_de_champ[7] } - let(:type_de_champ_dossier_link) { types_de_champ[8] } - let(:type_de_champ_commune) { types_de_champ[9] } - let(:type_de_champ_address) { types_de_champ[10] } - let(:type_de_champ_repetition) { types_de_champ[11] } + let(:type_de_champ_siret) { types_de_champ[2] } + let(:type_de_champ_datetime) { types_de_champ[3] } + let(:type_de_champ_multiple_drop_down_list) { types_de_champ[4] } + let(:type_de_champ_epci) { types_de_champ[5] } + let(:type_de_champ_dossier_link) { types_de_champ[6] } + let(:type_de_champ_commune) { types_de_champ[7] } + let(:type_de_champ_address) { types_de_champ[8] } + let(:type_de_champ_repetition) { types_de_champ[9] } let(:text_value) { "My Neighbor Totoro is the best movie ever" } let(:phone_value) { "invalid phone value" } - let(:rna_value) { 'W595001988' } let(:siret_value) { '41816609600051' } let(:datetime_value) { "2023-02-01T10:32" } let(:multiple_drop_down_list_values) { @@ -57,7 +52,6 @@ describe 'Prefilling a dossier (with a POST request):', js: true do let(:text_repetition_value) { "First repetition text" } let(:integer_repetition_value) { "42" } let(:dossier_link_value) { '42' } - let(:annuaire_education_value) { '0050009H' } let(:prenom_value) { 'Jean' } let(:nom_value) { 'Dupont' } let(:genre_value) { 'M.' } @@ -68,9 +62,6 @@ describe 'Prefilling a dossier (with a POST request):', js: true do stub_request(:get, /https:\/\/entreprise.api.gouv.fr\/v3\/insee\/sirene\/unites_legales\/#{siret_value[0..8]}/) .to_return(status: 200, body: File.read('spec/fixtures/files/api_entreprise/entreprises.json')) - - stub_request(:get, /https:\/\/entreprise.api.gouv.fr\/v4\/djepva\/api-association\/associations\/open_data\/#{rna_value}/) - .to_return(status: 200, body: File.read('spec/fixtures/files/api_entreprise/associations.json')) end scenario "the user get the URL of a prefilled orphan brouillon dossier" do @@ -166,7 +157,6 @@ describe 'Prefilling a dossier (with a POST request):', js: true do params: { "champ_#{type_de_champ_text.to_typed_id_for_query}" => text_value, "champ_#{type_de_champ_phone.to_typed_id_for_query}" => phone_value, - "champ_#{type_de_champ_rna.to_typed_id_for_query}" => rna_value, "champ_#{type_de_champ_siret.to_typed_id_for_query}" => siret_value, "champ_#{type_de_champ_repetition.to_typed_id_for_query}" => [ { @@ -180,7 +170,6 @@ describe 'Prefilling a dossier (with a POST request):', js: true do "champ_#{type_de_champ_dossier_link.to_typed_id_for_query}" => dossier_link_value, "champ_#{type_de_champ_commune.to_typed_id_for_query}" => commune_value, "champ_#{type_de_champ_address.to_typed_id_for_query}" => address_value, - "champ_#{type_de_champ_annuaire_education.to_typed_id_for_query}" => annuaire_education_value, "identite_prenom" => prenom_value, "identite_nom" => nom_value, "identite_genre" => genre_value From 668aba8986a94112bf4b94ab50237ac76efb8d93 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 9 Oct 2024 12:47:26 +0200 Subject: [PATCH 1151/1532] fix(api-entreprise): do not raise an error when the service is unavailable --- app/controllers/users/dossiers_controller.rb | 2 +- app/lib/api_entreprise/api.rb | 17 +++++++++-- .../api_entreprise/error_code_01000.json | 3 ++ .../api_entreprise/error_code_01001.json | 3 ++ .../api_entreprise/error_code_01002.json | 3 ++ spec/lib/api_entreprise/api_spec.rb | 30 +++++++++++++++++++ 6 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 spec/fixtures/files/api_entreprise/error_code_01000.json create mode 100644 spec/fixtures/files/api_entreprise/error_code_01001.json create mode 100644 spec/fixtures/files/api_entreprise/error_code_01002.json diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index ec60e2419..824751910 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -189,7 +189,7 @@ module Users etablissement = begin APIEntrepriseService.create_etablissement(@dossier, sanitized_siret, current_user.id) rescue => error - if error.try(:network_error?) && !APIEntrepriseService.api_insee_up? + if error.is_a?(APIEntreprise::API::Error::ServiceUnavailable) || (error.try(:network_error?) && !APIEntrepriseService.api_insee_up?) # TODO: notify ops APIEntrepriseService.create_etablissement_as_degraded_mode(@dossier, sanitized_siret, current_user.id) else diff --git a/app/lib/api_entreprise/api.rb b/app/lib/api_entreprise/api.rb index 3752ed49e..8b7fb819a 100644 --- a/app/lib/api_entreprise/api.rb +++ b/app/lib/api_entreprise/api.rb @@ -127,10 +127,10 @@ class APIEntreprise::API raise Error::ResourceNotFound.new(response) elsif response.code == 400 raise Error::BadFormatRequest.new(response) + elsif service_unavailable?(response) + raise Error::ServiceUnavailable.new(response) elsif response.code == 502 raise Error::BadGateway.new(response) - elsif response.code == 503 - raise Error::ServiceUnavailable.new(response) elsif response.timed_out? raise Error::TimedOut.new(response) else @@ -138,6 +138,19 @@ class APIEntreprise::API end end + def service_unavailable?(response) + return true if response.code == 503 + if response.code == 502 || response.code == 504 + parse_response_errors(response).any? { _1.is_a?(Hash) && ["01000", "01001", "01002"].include?(_1[:code]) } + end + end + + def parse_response_errors(response) + JSON.parse(response.body, symbolize_names: true).fetch(:errors, []) + rescue JSON::ParserError + [] + end + def make_url(resource_name, siret_or_siren = nil) [API_ENTREPRISE_URL, format(resource_name, id: siret_or_siren)].compact.join("/") end diff --git a/spec/fixtures/files/api_entreprise/error_code_01000.json b/spec/fixtures/files/api_entreprise/error_code_01000.json new file mode 100644 index 000000000..6ee2b7d84 --- /dev/null +++ b/spec/fixtures/files/api_entreprise/error_code_01000.json @@ -0,0 +1,3 @@ +{ + "errors": [{ "code": "01000" }] +} diff --git a/spec/fixtures/files/api_entreprise/error_code_01001.json b/spec/fixtures/files/api_entreprise/error_code_01001.json new file mode 100644 index 000000000..e23909ac6 --- /dev/null +++ b/spec/fixtures/files/api_entreprise/error_code_01001.json @@ -0,0 +1,3 @@ +{ + "errors": [{ "code": "01001" }] +} diff --git a/spec/fixtures/files/api_entreprise/error_code_01002.json b/spec/fixtures/files/api_entreprise/error_code_01002.json new file mode 100644 index 000000000..6454a0c88 --- /dev/null +++ b/spec/fixtures/files/api_entreprise/error_code_01002.json @@ -0,0 +1,3 @@ +{ + "errors": [{ "code": "01002" }] +} diff --git a/spec/lib/api_entreprise/api_spec.rb b/spec/lib/api_entreprise/api_spec.rb index 39cde9eda..fc9b4fbd5 100644 --- a/spec/lib/api_entreprise/api_spec.rb +++ b/spec/lib/api_entreprise/api_spec.rb @@ -24,6 +24,36 @@ describe APIEntreprise::API do end end + context 'when the service reponds with 01000 code' do + let(:siren) { '111111111' } + let(:status) { 502 } + let(:body) { Rails.root.join('spec/fixtures/files/api_entreprise/error_code_01000.json').read } + + it 'raises APIEntreprise::API::Error::RequestFailed' do + expect { subject }.to raise_error(APIEntreprise::API::Error::ServiceUnavailable) + end + end + + context 'when the service reponds with 01001 code' do + let(:siren) { '111111111' } + let(:status) { 502 } + let(:body) { Rails.root.join('spec/fixtures/files/api_entreprise/error_code_01001.json').read } + + it 'raises APIEntreprise::API::Error::RequestFailed' do + expect { subject }.to raise_error(APIEntreprise::API::Error::ServiceUnavailable) + end + end + + context 'when the service reponds with 01002 code' do + let(:siren) { '111111111' } + let(:status) { 504 } + let(:body) { Rails.root.join('spec/fixtures/files/api_entreprise/error_code_01002.json').read } + + it 'raises APIEntreprise::API::Error::RequestFailed' do + expect { subject }.to raise_error(APIEntreprise::API::Error::ServiceUnavailable) + end + end + context 'when siren does not exist' do let(:siren) { '111111111' } let(:status) { 404 } From cbc13c4c5c1051dabfdf6f279673ff5f76fba96c Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 18 Sep 2024 16:33:36 +0200 Subject: [PATCH 1152/1532] feat(tasks): prefix tasks with timestamp --- config/initializers/maintenance_tasks.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 config/initializers/maintenance_tasks.rb diff --git a/config/initializers/maintenance_tasks.rb b/config/initializers/maintenance_tasks.rb new file mode 100644 index 000000000..1be27793e --- /dev/null +++ b/config/initializers/maintenance_tasks.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +Rails.application.config.after_initialize do + if defined?(Rails::Generators) + require "generators/maintenance_tasks/task_generator" + + class MaintenanceTasks::TaskGenerator + alias_method :original_assign_names!, :assign_names! + + private + + # Prefix the task name with a date so the tasks are better sorted. + def assign_names!(name) + timestamped_name = "T#{Date.current.strftime("%Y%m%d")}#{name}" + original_assign_names!(timestamped_name) + end + end + end +end From c0ae02f458fdf1780d7faea517aa9c5ae11f4ace Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 19 Sep 2024 13:25:21 +0200 Subject: [PATCH 1153/1532] feat(tasks): use our task template, with example doc --- config/initializers/maintenance_tasks.rb | 1 + lib/templates/maintenance_tasks/task.rb.tt | 25 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 lib/templates/maintenance_tasks/task.rb.tt diff --git a/config/initializers/maintenance_tasks.rb b/config/initializers/maintenance_tasks.rb index 1be27793e..90e1bed87 100644 --- a/config/initializers/maintenance_tasks.rb +++ b/config/initializers/maintenance_tasks.rb @@ -6,6 +6,7 @@ Rails.application.config.after_initialize do class MaintenanceTasks::TaskGenerator alias_method :original_assign_names!, :assign_names! + source_paths << Rails.root.join("lib/templates/maintenance_tasks") private diff --git a/lib/templates/maintenance_tasks/task.rb.tt b/lib/templates/maintenance_tasks/task.rb.tt new file mode 100644 index 000000000..ef2e399ca --- /dev/null +++ b/lib/templates/maintenance_tasks/task.rb.tt @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module <%= tasks_module %> +<% module_namespacing do -%> + class <%= class_name %>Task < MaintenanceTasks::Task + # Documentation: cette tâche modifie les données pour… + + def collection + # Collection to be iterated over + # Must be Active Record Relation or Array + end + + def process(element) + # The work to be done in a single iteration of the task. + # This should be idempotent, as the same element may be processed more + # than once if the task is interrupted and resumed. + end + + def count + # Optionally, define the number of rows that will be iterated over + # This is used to track the task's progress + end + end +<% end -%> +end From d0f77d0aab3ca8920d7195330ae27b922f7e05e8 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 19 Sep 2024 13:31:37 +0200 Subject: [PATCH 1154/1532] feat(task): with_statement_timeout helper for long running collection or process query --- .../concerns/statements_helpers_concern.rb | 25 +++++++++++++++ config/application.rb | 1 + config/brakeman.ignore | 23 ++++++++++++++ lib/templates/maintenance_tasks/task.rb.tt | 2 ++ .../statements_helpers_concern_spec.rb | 31 +++++++++++++++++++ 5 files changed, 82 insertions(+) create mode 100644 app/tasks/maintenance/concerns/statements_helpers_concern.rb create mode 100644 spec/tasks/maintenance/concerns/statements_helpers_concern_spec.rb diff --git a/app/tasks/maintenance/concerns/statements_helpers_concern.rb b/app/tasks/maintenance/concerns/statements_helpers_concern.rb new file mode 100644 index 000000000..658c07b1b --- /dev/null +++ b/app/tasks/maintenance/concerns/statements_helpers_concern.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Maintenance + module StatementsHelpersConcern + extend ActiveSupport::Concern + + included do + # Execute block in transaction with a local statement timeout. + # A value of 0 disable the timeout. + # + # Example: + # def collection + # with_statement_timeout("5min") do + # Dossier.all + # end + # end + def with_statement_timeout(timeout) + ApplicationRecord.transaction do + ApplicationRecord.connection.execute("SET LOCAL statement_timeout = '#{timeout}'") + yield + end + end + end + end +end diff --git a/config/application.rb b/config/application.rb index b57986388..b45f7bd2f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -23,6 +23,7 @@ module TPS Rails.autoloaders.main.ignore(Rails.root.join('lib/cops')) Rails.autoloaders.main.ignore(Rails.root.join('lib/linters')) Rails.autoloaders.main.ignore(Rails.root.join('lib/tasks/task_helper.rb')) + Rails.autoloaders.main.collapse('app/tasks/maintenance/concerns') config.paths.add Rails.root.join('spec/mailers/previews').to_s, eager_load: true config.autoload_paths << "#{Rails.root}/app/jobs/concerns" diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 9185336fb..4cb15c70d 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -113,6 +113,29 @@ ], "note": "" }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "7dc4935d5b68365bedb8f6b953f01b396cff4daa533c98ee56a84249ca5a1f90", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/tasks/maintenance/concerns/statements_helpers_concern.rb", + "line": 19, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "ApplicationRecord.connection.execute(\"SET LOCAL statement_timeout = '#{timeout}'\")", + "render_path": null, + "location": { + "type": "method", + "class": "Maintenance::StatementsHelpersConcern", + "method": "with_statement_timeout" + }, + "user_input": "timeout", + "confidence": "Medium", + "cwe_id": [ + 89 + ], + "note": "" + }, { "warning_type": "Cross-Site Scripting", "warning_code": 2, diff --git a/lib/templates/maintenance_tasks/task.rb.tt b/lib/templates/maintenance_tasks/task.rb.tt index ef2e399ca..2c0b98570 100644 --- a/lib/templates/maintenance_tasks/task.rb.tt +++ b/lib/templates/maintenance_tasks/task.rb.tt @@ -5,6 +5,8 @@ module <%= tasks_module %> class <%= class_name %>Task < MaintenanceTasks::Task # Documentation: cette tâche modifie les données pour… + include StatementsHelpersConcern + def collection # Collection to be iterated over # Must be Active Record Relation or Array diff --git a/spec/tasks/maintenance/concerns/statements_helpers_concern_spec.rb b/spec/tasks/maintenance/concerns/statements_helpers_concern_spec.rb new file mode 100644 index 000000000..970ca4898 --- /dev/null +++ b/spec/tasks/maintenance/concerns/statements_helpers_concern_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Maintenance::StatementsHelpersConcern do + let(:dummy_class) do + Class.new do + include Maintenance::StatementsHelpersConcern + end + end + + let(:instance) { dummy_class.new } + + describe '#with_statement_timeout' do + it 'applies the statement timeout and raises an error for long-running queries' do + expect { + instance.with_statement_timeout('1ms') do + # Cette requête devrait prendre plus de 1ms et donc déclencher un timeout + ActiveRecord::Base.connection.execute("SELECT pg_sleep(1)") + end + }.to raise_error(ActiveRecord::StatementInvalid, /canceling statement due to statement timeout/i) + end + + it 'allows queries to complete within the timeout and returns the result' do + result = instance.with_statement_timeout('1s') do + ActiveRecord::Base.connection.execute("SELECT 42 AS answer").first['answer'] + end + expect(result).to eq 42 + end + end +end From 2127f8cef19031083d2be420cc27947c7e19f2a3 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 23 Sep 2024 18:07:06 +0200 Subject: [PATCH 1155/1532] refactor: remove dead deploy code --- lib/tasks/deploy.rake | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/lib/tasks/deploy.rake b/lib/tasks/deploy.rake index f7edf1cc5..9ca276237 100644 --- a/lib/tasks/deploy.rake +++ b/lib/tasks/deploy.rake @@ -1,42 +1,4 @@ # frozen_string_literal: true -def domains_for_stage - if ENV['DOMAINS'].present? - ENV['DOMAINS'].split - else - raise "DOMAINS is empty. It must be something like DOMAINS='web1.dev web2.dev'" - end -end - -task :setup do - domains = domains_for_stage - - domains.each do |domain| - sh "mina setup domain=#{domain}" - end -end - -task :deploy do - domains = domains_for_stage - branch = ENV.fetch('BRANCH') - - domains.each do |domain| - sh "mina deploy domain=#{domain} branch=#{branch} force_asset_precompile=true" - end -end - -task :post_deploy do - domains = domains_for_stage - branch = ENV.fetch('BRANCH') - - sh "mina post_deploy domain=#{domains.first} branch=#{branch}" -end - -task :rollback do - domains = domains_for_stage - branch = ENV.fetch('BRANCH') - - domains.each do |domain| - sh "mina rollback service:restart_puma service:reload_nginx service:restart_delayed_job domain=#{domain} branch=#{branch}" end end From cae5d8afed9f9cd001f1a01a7c3f792383844b4c Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 23 Sep 2024 18:32:13 +0200 Subject: [PATCH 1156/1532] feat(task): task enqueueing a maintenance task runnable on deploy --- .../concerns/runnable_on_deploy_concern.rb | 24 +++++++++ lib/tasks/deploy.rake | 10 ++++ lib/templates/maintenance_tasks/task.rb.tt | 5 ++ .../runnable_on_deploy_concern_spec.rb | 53 +++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 app/tasks/maintenance/concerns/runnable_on_deploy_concern.rb create mode 100644 spec/tasks/maintenance/concerns/runnable_on_deploy_concern_spec.rb diff --git a/app/tasks/maintenance/concerns/runnable_on_deploy_concern.rb b/app/tasks/maintenance/concerns/runnable_on_deploy_concern.rb new file mode 100644 index 000000000..f324cef29 --- /dev/null +++ b/app/tasks/maintenance/concerns/runnable_on_deploy_concern.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Maintenance + module RunnableOnDeployConcern + extend ActiveSupport::Concern + + class_methods do + def run_on_first_deploy + @run_on_first_deploy = true + end + + def run_on_deploy? + return false unless @run_on_first_deploy + + task = MaintenanceTasks::TaskDataShow.new(name) + + return false if task.completed_runs.not_errored.any? + return false if task.active_runs.any? + + true + end + end + end +end diff --git a/lib/tasks/deploy.rake b/lib/tasks/deploy.rake index 9ca276237..22c95bc7c 100644 --- a/lib/tasks/deploy.rake +++ b/lib/tasks/deploy.rake @@ -1,4 +1,14 @@ # frozen_string_literal: true +namespace :deploy do + task maintenance_tasks: :environment do + tasks = MaintenanceTasks::Task + .load_all + .filter { _1.respond_to?(:run_on_deploy?) && _1.run_on_deploy? } + + tasks.each do |task| + Rails.logger.info { "MaintenanceTask run on deploy #{task.name}" } + MaintenanceTasks::Runner.run(name: task.name) + end end end diff --git a/lib/templates/maintenance_tasks/task.rb.tt b/lib/templates/maintenance_tasks/task.rb.tt index 2c0b98570..2affa82a5 100644 --- a/lib/templates/maintenance_tasks/task.rb.tt +++ b/lib/templates/maintenance_tasks/task.rb.tt @@ -5,8 +5,13 @@ module <%= tasks_module %> class <%= class_name %>Task < MaintenanceTasks::Task # Documentation: cette tâche modifie les données pour… + include RunnableOnDeployConcern include StatementsHelpersConcern + # Uncomment only if this task MUST run imperatively on its first deployment. + # If possible, leave commented for manual execution later. + # run_on_first_deploy + def collection # Collection to be iterated over # Must be Active Record Relation or Array diff --git a/spec/tasks/maintenance/concerns/runnable_on_deploy_concern_spec.rb b/spec/tasks/maintenance/concerns/runnable_on_deploy_concern_spec.rb new file mode 100644 index 000000000..7252b1c9a --- /dev/null +++ b/spec/tasks/maintenance/concerns/runnable_on_deploy_concern_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Maintenance::RunnableOnDeployConcern do + let(:test_class) do + Class.new do + include Maintenance::RunnableOnDeployConcern + end + end + + describe '.run_on_deploy?' do + context 'when run_on_first_deploy is not set' do + it 'returns false' do + expect(test_class.run_on_deploy?).to be false + end + end + + context 'when run_on_first_deploy is set' do + before do + test_class.run_on_first_deploy + allow(MaintenanceTasks::TaskDataShow).to receive(:new).and_return(task_data_show) + end + + let(:task_data_show) { instance_double(MaintenanceTasks::TaskDataShow, completed_runs: completed_runs, active_runs: active_runs) } + let(:completed_runs) { double(ActiveRecord::Relation, not_errored: not_errored_runs) } + let(:active_runs) { [] } + let(:not_errored_runs) { [] } + + context 'when there are no run yet' do + it 'returns true' do + expect(test_class.run_on_deploy?).to be true + end + end + + context 'when there are completed runs without errors' do + let(:not_errored_runs) { [instance_double(MaintenanceTasks::Run)] } + + it 'returns false' do + expect(test_class.run_on_deploy?).to be false + end + end + + context 'when there are active runs' do + let(:active_runs) { [instance_double(MaintenanceTasks::Run)] } + + it 'returns false' do + expect(test_class.run_on_deploy?).to be false + end + end + end + end +end From 0d5b0e81e7c753fb7f4ff5aa02763d58d540cfc8 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 24 Sep 2024 14:48:04 +0200 Subject: [PATCH 1157/1532] chore(dev): on update, run maintenance tasks configured on deploy --- bin/update | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bin/update b/bin/update index 82a89894f..ccb6fb6e3 100755 --- a/bin/update +++ b/bin/update @@ -37,6 +37,9 @@ FileUtils.chdir APP_ROOT do puts "\n== Running after_party tasks ==" system! 'bin/rails after_party:run' + puts "\n== Running on deploy maintenance tasks ==" + system! 'bin/rails deploy:maintenance_tasks' + puts "\n== Removing old logs ==" system! 'bin/rails log:clear' From 9eb9e8023263e18fe0dd5eae465d5e4372728c33 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 30 Sep 2024 09:26:46 +0200 Subject: [PATCH 1158/1532] chore(doc): more information about many maintenance tasks --- .../backfill_bulk_messages_with_procedure_id_task.rb | 3 +++ app/tasks/maintenance/backfill_city_name_task.rb | 3 +++ ...kfill_cloned_champs_private_piece_justificatives_task.rb | 3 +++ .../backfill_closing_reason_in_closed_procedures_task.rb | 3 +++ .../maintenance/backfill_commune_code_from_name_task.rb | 3 +++ app/tasks/maintenance/backfill_departement_services_task.rb | 3 +++ .../backfill_depose_at_on_deleted_dossiers_task.rb | 2 ++ .../maintenance/backfill_effectif_annuel_annee_task.rb | 2 ++ .../maintenance/backfill_invalid_dossiers_for_tiers_task.rb | 2 ++ .../create_previews_for_pj_of_latest_dossiers_task.rb | 3 +++ .../create_variants_for_pj_of_latest_dossiers_task.rb | 3 +++ .../delete_draft_revision_type_de_champs_task.rb | 6 +++--- .../maintenance/destroy_incomplete_bulk_messages_task.rb | 4 ++++ ...edure_without_administrateur_and_without_dossier_task.rb | 2 ++ .../maintenance/disable_remaining_invalid_mon_avis_task.rb | 2 ++ .../maintenance/fix_decimal_number_with_spaces_task.rb | 3 +++ ...conservation_greater_than_max_duree_conservation_task.rb | 4 ++++ .../fix_open_procedures_with_closing_reason_task.rb | 2 ++ app/tasks/maintenance/move_dol_to_cold_storage_task.rb | 4 ++++ app/tasks/maintenance/recompute_blob_checksum_task.rb | 5 +++++ app/tasks/maintenance/samsung_browser_is_supported_task.rb | 3 +++ app/tasks/maintenance/spread_dossier_deletion_task.rb | 2 ++ .../update_closing_reason_if_no_replaced_by_id_task.rb | 3 +++ ...update_conditions_based_on_commune_or_epci_champ_task.rb | 4 ++++ .../update_draft_revision_type_de_champs_task.rb | 2 ++ ...ate_routing_rules_based_on_commune_or_epci_champ_task.rb | 4 ++++ .../maintenance/update_service_etablissement_infos_task.rb | 3 +++ app/tasks/maintenance/update_zones_task.rb | 2 ++ 28 files changed, 82 insertions(+), 3 deletions(-) diff --git a/app/tasks/maintenance/backfill_bulk_messages_with_procedure_id_task.rb b/app/tasks/maintenance/backfill_bulk_messages_with_procedure_id_task.rb index 952c36b09..b7fb63d16 100644 --- a/app/tasks/maintenance/backfill_bulk_messages_with_procedure_id_task.rb +++ b/app/tasks/maintenance/backfill_bulk_messages_with_procedure_id_task.rb @@ -2,6 +2,9 @@ module Maintenance class BackfillBulkMessagesWithProcedureIdTask < MaintenanceTasks::Task + # Périmètre: envoi d’un email groupé aux usagers ayant dossiers en brouillon. + # Change la manière dont ces messages sont liés aux démarches. + # 2024-03-12-01 PR #10071 def collection BulkMessage .where(procedure: nil) diff --git a/app/tasks/maintenance/backfill_city_name_task.rb b/app/tasks/maintenance/backfill_city_name_task.rb index e22aa403e..636a65e76 100644 --- a/app/tasks/maintenance/backfill_city_name_task.rb +++ b/app/tasks/maintenance/backfill_city_name_task.rb @@ -2,6 +2,9 @@ module Maintenance class BackfillCityNameTask < MaintenanceTasks::Task + # corrige des données du champ adresse suite à un bug + # introduit pendant quelques jours début mars + # 2024-04-09-02 PR #10290 attribute :champ_ids, :string validates :champ_ids, presence: true diff --git a/app/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task.rb b/app/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task.rb index 23ad7af96..a042c8db9 100644 --- a/app/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task.rb +++ b/app/tasks/maintenance/backfill_cloned_champs_private_piece_justificatives_task.rb @@ -2,6 +2,9 @@ module Maintenance class BackfillClonedChampsPrivatePieceJustificativesTask < MaintenanceTasks::Task + # Supprime les PJ d’annotations privées + # qui étaient conservées par erreur lorsqu’un dossier était cloné + # 2024-05-27-01 PR #10435 def collection Dossier.en_brouillon.where.not(parent_dossier_id: nil) end diff --git a/app/tasks/maintenance/backfill_closing_reason_in_closed_procedures_task.rb b/app/tasks/maintenance/backfill_closing_reason_in_closed_procedures_task.rb index 49981e6ed..88525d1f9 100644 --- a/app/tasks/maintenance/backfill_closing_reason_in_closed_procedures_task.rb +++ b/app/tasks/maintenance/backfill_closing_reason_in_closed_procedures_task.rb @@ -2,6 +2,9 @@ module Maintenance class BackfillClosingReasonInClosedProceduresTask < MaintenanceTasks::Task + # Remet les messages de cloture d'une démarche proprement (sinon affichage KO). + # Suite de UpdateClosingReasonIfNoReplacedByIdTask + # 2024-05-27-01 PR #9930 def collection Procedure .with_discarded diff --git a/app/tasks/maintenance/backfill_commune_code_from_name_task.rb b/app/tasks/maintenance/backfill_commune_code_from_name_task.rb index 922c3a377..b2285a602 100644 --- a/app/tasks/maintenance/backfill_commune_code_from_name_task.rb +++ b/app/tasks/maintenance/backfill_commune_code_from_name_task.rb @@ -2,6 +2,9 @@ module Maintenance class BackfillCommuneCodeFromNameTask < MaintenanceTasks::Task + # corrige structure champs commune pour une démarche donnée. Suite à un bug ? + # 2024-05-31-01 PR #10469 + attribute :procedure_id, :string validates :procedure_id, presence: true diff --git a/app/tasks/maintenance/backfill_departement_services_task.rb b/app/tasks/maintenance/backfill_departement_services_task.rb index cb75cee97..e2da6bc11 100644 --- a/app/tasks/maintenance/backfill_departement_services_task.rb +++ b/app/tasks/maintenance/backfill_departement_services_task.rb @@ -2,6 +2,9 @@ module Maintenance class BackfillDepartementServicesTask < MaintenanceTasks::Task + # Fait le lien service – département pour permettre + # le filtrage des démarches par département + # 2023-10-30-01 PR #9647 def collection Service.where.not(etablissement_infos: nil) end diff --git a/app/tasks/maintenance/backfill_depose_at_on_deleted_dossiers_task.rb b/app/tasks/maintenance/backfill_depose_at_on_deleted_dossiers_task.rb index 2f8eb6178..389eddef3 100644 --- a/app/tasks/maintenance/backfill_depose_at_on_deleted_dossiers_task.rb +++ b/app/tasks/maintenance/backfill_depose_at_on_deleted_dossiers_task.rb @@ -2,6 +2,8 @@ module Maintenance class BackfillDeposeAtOnDeletedDossiersTask < MaintenanceTasks::Task + # Améliore les stats à propos des dates de dépôts pour les dossiers supprimés + # 2024-04-05-01 PR #10259 def collection DeletedDossier.where(depose_at: nil) end diff --git a/app/tasks/maintenance/backfill_effectif_annuel_annee_task.rb b/app/tasks/maintenance/backfill_effectif_annuel_annee_task.rb index ce52d05f9..65d6d4a1e 100644 --- a/app/tasks/maintenance/backfill_effectif_annuel_annee_task.rb +++ b/app/tasks/maintenance/backfill_effectif_annuel_annee_task.rb @@ -2,6 +2,8 @@ module Maintenance class BackfillEffectifAnnuelAnneeTask < MaintenanceTasks::Task + # API entreprise: rattrape les informations d'effectif + # 2024-05-27-01 PR #10053 def collection Etablissement.where.not(entreprise_effectif_annuel: nil).where(entreprise_effectif_annuel_annee: nil) end diff --git a/app/tasks/maintenance/backfill_invalid_dossiers_for_tiers_task.rb b/app/tasks/maintenance/backfill_invalid_dossiers_for_tiers_task.rb index b7c1149ea..1af3f814a 100644 --- a/app/tasks/maintenance/backfill_invalid_dossiers_for_tiers_task.rb +++ b/app/tasks/maintenance/backfill_invalid_dossiers_for_tiers_task.rb @@ -2,6 +2,8 @@ module Maintenance class BackfillInvalidDossiersForTiersTask < MaintenanceTasks::Task + # Corrige les dossiers declarés pour un tiers mais sans avoir renseigné les infos du tiers + # 2024-05-22-01 def collection Dossier.where(for_tiers: true).where(mandataire_first_name: nil) end diff --git a/app/tasks/maintenance/create_previews_for_pj_of_latest_dossiers_task.rb b/app/tasks/maintenance/create_previews_for_pj_of_latest_dossiers_task.rb index 1c2378340..37e1d2b7b 100644 --- a/app/tasks/maintenance/create_previews_for_pj_of_latest_dossiers_task.rb +++ b/app/tasks/maintenance/create_previews_for_pj_of_latest_dossiers_task.rb @@ -2,6 +2,9 @@ module Maintenance class CreatePreviewsForPjOfLatestDossiersTask < MaintenanceTasks::Task + # Génère les vignettes de PJ existantes pour les dossiers déposés entre 2 dates (facultatif) + # Elles sont affichées dans le nouvel onglet "Pièces jointes" des instructeurs. + # 2024-07-11-01 attribute :start_text, :string validates :start_text, presence: true diff --git a/app/tasks/maintenance/create_variants_for_pj_of_latest_dossiers_task.rb b/app/tasks/maintenance/create_variants_for_pj_of_latest_dossiers_task.rb index 2b44d9ea6..2fa1bd2a4 100644 --- a/app/tasks/maintenance/create_variants_for_pj_of_latest_dossiers_task.rb +++ b/app/tasks/maintenance/create_variants_for_pj_of_latest_dossiers_task.rb @@ -2,6 +2,9 @@ module Maintenance class CreateVariantsForPjOfLatestDossiersTask < MaintenanceTasks::Task + # Génère les vignettes de fichiers PDF pour les dossiers déposés entre 2 dates (facultatif) + # Elles sont affichées dans le nouvel onglet "Pièces jointes" des instructeurs. + # 2024-07-11-01 attribute :start_text, :string validates :start_text, presence: true diff --git a/app/tasks/maintenance/delete_draft_revision_type_de_champs_task.rb b/app/tasks/maintenance/delete_draft_revision_type_de_champs_task.rb index 95f577403..cc08a519d 100644 --- a/app/tasks/maintenance/delete_draft_revision_type_de_champs_task.rb +++ b/app/tasks/maintenance/delete_draft_revision_type_de_champs_task.rb @@ -2,10 +2,10 @@ module Maintenance class DeleteDraftRevisionTypeDeChampsTask < MaintenanceTasks::Task - csv_collection - + # Modifie le form d’une démarche à partir d’un CSV (dev spécifique Fonds Verts). # See UpdateDraftRevisionTypeDeChampsTask for more information - # Just add delete_flag with "true" to effectively remove the type de champ from the draft. + # Just add delete_flag with "true" in CSV to effectively remove the type de champ from the draft. + csv_collection def process(row) return unless row["delete_flag"] == "true" diff --git a/app/tasks/maintenance/destroy_incomplete_bulk_messages_task.rb b/app/tasks/maintenance/destroy_incomplete_bulk_messages_task.rb index 32d1bef43..f0c96cbc8 100644 --- a/app/tasks/maintenance/destroy_incomplete_bulk_messages_task.rb +++ b/app/tasks/maintenance/destroy_incomplete_bulk_messages_task.rb @@ -2,6 +2,10 @@ module Maintenance class DestroyIncompleteBulkMessagesTask < MaintenanceTasks::Task + # Périmètre: envoi d’un email groupé aux usagers ayant dossiers en brouillon. + # Change la manière dont ces messages sont liés aux démarches. + # Suite de BackfillBulkMessagesWithProcedureIdTask + # 2024-03-12-01 PR #10071 def collection BulkMessage.where(procedure: nil).where.missing(:groupe_instructeurs) end diff --git a/app/tasks/maintenance/destroy_procedure_without_administrateur_and_without_dossier_task.rb b/app/tasks/maintenance/destroy_procedure_without_administrateur_and_without_dossier_task.rb index 70860af82..29768d104 100644 --- a/app/tasks/maintenance/destroy_procedure_without_administrateur_and_without_dossier_task.rb +++ b/app/tasks/maintenance/destroy_procedure_without_administrateur_and_without_dossier_task.rb @@ -2,6 +2,8 @@ module Maintenance class DestroyProcedureWithoutAdministrateurAndWithoutDossierTask < MaintenanceTasks::Task + # suppression de procédures closes sans admin et sans dossier + # 2024-03-18-01 PR #10125 def collection Procedure.with_discarded.where.missing(:administrateurs, :dossiers) end diff --git a/app/tasks/maintenance/disable_remaining_invalid_mon_avis_task.rb b/app/tasks/maintenance/disable_remaining_invalid_mon_avis_task.rb index 71a78696d..b0bef2c77 100644 --- a/app/tasks/maintenance/disable_remaining_invalid_mon_avis_task.rb +++ b/app/tasks/maintenance/disable_remaining_invalid_mon_avis_task.rb @@ -2,6 +2,8 @@ module Maintenance class DisableRemainingInvalidMonAvisTask < MaintenanceTasks::Task + # Supprime les codes d’intégration « mon avis » invalides + # 2024-03-18-01 PR #10120 def collection # rubocop:disable DS/Unscoped Procedure.unscoped.where.not(monavis_embed: nil) diff --git a/app/tasks/maintenance/fix_decimal_number_with_spaces_task.rb b/app/tasks/maintenance/fix_decimal_number_with_spaces_task.rb index 7e6fe41b8..499093a4b 100644 --- a/app/tasks/maintenance/fix_decimal_number_with_spaces_task.rb +++ b/app/tasks/maintenance/fix_decimal_number_with_spaces_task.rb @@ -2,6 +2,9 @@ module Maintenance class FixDecimalNumberWithSpacesTask < MaintenanceTasks::Task + # normalise les champs nombres en y supprimant les éventuels espaces + # 2024-07-01-01 PR #10554 + ANY_SPACES = /[[:space:]]/ def collection Champs::DecimalNumberChamp.where.not(value: nil) diff --git a/app/tasks/maintenance/fix_duree_conservation_greater_than_max_duree_conservation_task.rb b/app/tasks/maintenance/fix_duree_conservation_greater_than_max_duree_conservation_task.rb index 4d3618dbf..adcea8687 100644 --- a/app/tasks/maintenance/fix_duree_conservation_greater_than_max_duree_conservation_task.rb +++ b/app/tasks/maintenance/fix_duree_conservation_greater_than_max_duree_conservation_task.rb @@ -2,6 +2,10 @@ module Maintenance class FixDureeConservationGreaterThanMaxDureeConservationTask < MaintenanceTasks::Task + # Corrige la durée de conservation des dossiers : + # pour toutes les démarches dont la durée de conservation est supérieure + # à celle de l'instance, on prend la durée max de DS (12 mois) + # 2024-05-27-01 PR #10107 def collection Procedure.where('duree_conservation_dossiers_dans_ds > max_duree_conservation_dossiers_dans_ds') end diff --git a/app/tasks/maintenance/fix_open_procedures_with_closing_reason_task.rb b/app/tasks/maintenance/fix_open_procedures_with_closing_reason_task.rb index daf619542..711e454ff 100644 --- a/app/tasks/maintenance/fix_open_procedures_with_closing_reason_task.rb +++ b/app/tasks/maintenance/fix_open_procedures_with_closing_reason_task.rb @@ -2,6 +2,8 @@ module Maintenance class FixOpenProceduresWithClosingReasonTask < MaintenanceTasks::Task + # Corrige des démarches avec un motif de fermerture alors qu’elles ont été publiées + # 2024-05-27-01 PR #10181 def collection Procedure .with_discarded diff --git a/app/tasks/maintenance/move_dol_to_cold_storage_task.rb b/app/tasks/maintenance/move_dol_to_cold_storage_task.rb index 8f039d94c..f4ab280b3 100644 --- a/app/tasks/maintenance/move_dol_to_cold_storage_task.rb +++ b/app/tasks/maintenance/move_dol_to_cold_storage_task.rb @@ -2,6 +2,10 @@ module Maintenance class MoveDolToColdStorageTask < MaintenanceTasks::Task + # Opération de rattrapage suite à un cron qui ne fonctionnait plus. + # Permet de déplacer toutes les traces fonctionnelles (DossierOperationLog) + # vers le stockage object plutot que de les conserver en BDD + # 2024-04-15-01 attribute :start_text, :string validates :start_text, presence: true diff --git a/app/tasks/maintenance/recompute_blob_checksum_task.rb b/app/tasks/maintenance/recompute_blob_checksum_task.rb index 628ea9bd7..474c535b9 100644 --- a/app/tasks/maintenance/recompute_blob_checksum_task.rb +++ b/app/tasks/maintenance/recompute_blob_checksum_task.rb @@ -2,6 +2,11 @@ module Maintenance class RecomputeBlobChecksumTask < MaintenanceTasks::Task + # Avant février 2024, les filigranes ont corrompu les hash des fichiers. + # Régulièrement, des dossiers en brouillon étaient déposés avec ce problème + # (on retrouve les fichiers corrompu dans l'onglet retry de sidekiq). + # Cette tache recalcule les hashes. + # 2024-05-27-01 attribute :blob_ids, :string validates :blob_ids, presence: true diff --git a/app/tasks/maintenance/samsung_browser_is_supported_task.rb b/app/tasks/maintenance/samsung_browser_is_supported_task.rb index 988e3c010..d9ebfbb3d 100644 --- a/app/tasks/maintenance/samsung_browser_is_supported_task.rb +++ b/app/tasks/maintenance/samsung_browser_is_supported_task.rb @@ -2,6 +2,9 @@ module Maintenance class SamsungBrowserIsSupportedTask < MaintenanceTasks::Task + # Corrige une donnée si le navigateur utilisé + # dans l’historique des Traitements des dossiers + # 2024-02-21-01 def collection Traitement.where(browser_name: 'Samsung Browser', browser_version: 12..) end diff --git a/app/tasks/maintenance/spread_dossier_deletion_task.rb b/app/tasks/maintenance/spread_dossier_deletion_task.rb index 2e2f3c6a4..9c94bd6cf 100644 --- a/app/tasks/maintenance/spread_dossier_deletion_task.rb +++ b/app/tasks/maintenance/spread_dossier_deletion_task.rb @@ -2,6 +2,8 @@ module Maintenance class SpreadDossierDeletionTask < MaintenanceTasks::Task + # Contourne un égorgement de suppression de millions de dossiers qui aurait eu lieu le même jour + # 2024-05-27-01 PR #10062 ERROR_OCCURED_AT = Date.new(2024, 2, 14) ERROR_OCCURED_RANGE = ERROR_OCCURED_AT.at_midnight..(ERROR_OCCURED_AT + 1.day) SPREAD_DURATION_IN_DAYS = 150 diff --git a/app/tasks/maintenance/update_closing_reason_if_no_replaced_by_id_task.rb b/app/tasks/maintenance/update_closing_reason_if_no_replaced_by_id_task.rb index daf013ca0..99e715dd3 100644 --- a/app/tasks/maintenance/update_closing_reason_if_no_replaced_by_id_task.rb +++ b/app/tasks/maintenance/update_closing_reason_if_no_replaced_by_id_task.rb @@ -2,6 +2,9 @@ module Maintenance class UpdateClosingReasonIfNoReplacedByIdTask < MaintenanceTasks::Task + # Remet les messages de cloture d'une démarche proprement (sinon affichage KO). + # Avant BackfillClosingReasonInClosedProceduresTask + # 2024-03-21-01 PR #10158 def collection Procedure .with_discarded diff --git a/app/tasks/maintenance/update_conditions_based_on_commune_or_epci_champ_task.rb b/app/tasks/maintenance/update_conditions_based_on_commune_or_epci_champ_task.rb index c8f2afab5..bf67eb3b4 100644 --- a/app/tasks/maintenance/update_conditions_based_on_commune_or_epci_champ_task.rb +++ b/app/tasks/maintenance/update_conditions_based_on_commune_or_epci_champ_task.rb @@ -2,6 +2,10 @@ module Maintenance class UpdateConditionsBasedOnCommuneOrEpciChampTask < MaintenanceTasks::Task + # Met à jour les conditions et règles de routage + # pour les champs communes et ECPI suite à l'ajout de nouveaux opérateurs + # Voir aussi UpdateRoutingRulesBasedOnCommuneOrEpciChampTask + # 2023-12-20-01 PR #9850 include Logic def collection diff --git a/app/tasks/maintenance/update_draft_revision_type_de_champs_task.rb b/app/tasks/maintenance/update_draft_revision_type_de_champs_task.rb index af03e79ec..60b39e65b 100644 --- a/app/tasks/maintenance/update_draft_revision_type_de_champs_task.rb +++ b/app/tasks/maintenance/update_draft_revision_type_de_champs_task.rb @@ -2,6 +2,8 @@ module Maintenance class UpdateDraftRevisionTypeDeChampsTask < MaintenanceTasks::Task + # Modifie le form d’une démarche à partir d’un CSV (dev pour les Fonds Verts) + csv_collection # CSV structure: diff --git a/app/tasks/maintenance/update_routing_rules_based_on_commune_or_epci_champ_task.rb b/app/tasks/maintenance/update_routing_rules_based_on_commune_or_epci_champ_task.rb index afdda78e9..22f1ad2e4 100644 --- a/app/tasks/maintenance/update_routing_rules_based_on_commune_or_epci_champ_task.rb +++ b/app/tasks/maintenance/update_routing_rules_based_on_commune_or_epci_champ_task.rb @@ -2,6 +2,10 @@ module Maintenance class UpdateRoutingRulesBasedOnCommuneOrEpciChampTask < MaintenanceTasks::Task + # Ces 2 tâches mettent à jour les conditions et règles de routage + # pour les champs communes et ECPI suite à l'ajout de nouveaux opérateurs + # Voir aussi UpdateConditionsBasedOnCommuneOrEpciChampTask + # 2023-12-20-01 PR #9850 include Logic def collection diff --git a/app/tasks/maintenance/update_service_etablissement_infos_task.rb b/app/tasks/maintenance/update_service_etablissement_infos_task.rb index 8dcf511bd..a8fdfe15c 100644 --- a/app/tasks/maintenance/update_service_etablissement_infos_task.rb +++ b/app/tasks/maintenance/update_service_etablissement_infos_task.rb @@ -2,6 +2,9 @@ module Maintenance class UpdateServiceEtablissementInfosTask < MaintenanceTasks::Task + # Géocode les services à partir des établissements + # 2024-05-27-01 PR #10106 + # No more 20 geocoding by 10 seconds window THROTTLE_LIMIT = 20 THROTTLE_PERIOD = 10.seconds diff --git a/app/tasks/maintenance/update_zones_task.rb b/app/tasks/maintenance/update_zones_task.rb index c985f946a..203b00adf 100644 --- a/app/tasks/maintenance/update_zones_task.rb +++ b/app/tasks/maintenance/update_zones_task.rb @@ -2,6 +2,8 @@ module Maintenance class UpdateZonesTask < MaintenanceTasks::Task + # Synchronise les zones en base à partir du fichier de config zones.yml + # 2024-05-27-01 PR #10077 def collection config = Psych.safe_load(Rails.root.join("config", "zones.yml").read) config['ministeres'] From efa744c2276489d0cb7a39eddaa72af1aea407a3 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 9 Oct 2024 12:36:42 +0200 Subject: [PATCH 1159/1532] chore(task): deprecate after party tasks --- .../after_party/task/task_generator.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 lib/generators/after_party/task/task_generator.rb diff --git a/lib/generators/after_party/task/task_generator.rb b/lib/generators/after_party/task/task_generator.rb new file mode 100644 index 000000000..782bac34d --- /dev/null +++ b/lib/generators/after_party/task/task_generator.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails/generators' + +Rails.application.config.after_initialize do + module AfterParty + module Generators + class TaskGenerator + prepend Module.new { + def invoke_all + warn "[DEPRECATION] 'after_party:task' is deprecated. Use 'rails generate maintenance_tasks:task #{name}' instead." + end + } + end + end + end +end From da1bf573e381a6fdfde0ff1f8e311e1b7b1611f8 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 9 Oct 2024 12:41:49 +0200 Subject: [PATCH 1160/1532] chore(task): noop maintenance task verifying automatic run on deploy --- ...0241009_noop_attempt_run_on_deploy_task.rb | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 app/tasks/maintenance/t20241009_noop_attempt_run_on_deploy_task.rb diff --git a/app/tasks/maintenance/t20241009_noop_attempt_run_on_deploy_task.rb b/app/tasks/maintenance/t20241009_noop_attempt_run_on_deploy_task.rb new file mode 100644 index 000000000..36c5ae2e2 --- /dev/null +++ b/app/tasks/maintenance/t20241009_noop_attempt_run_on_deploy_task.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Maintenance + class T20241009NoopAttemptRunOnDeployTask < MaintenanceTasks::Task + # Documentation: cette tâche ne fait rien mais sert à vérifier + # qu'elle sera bien exécutée sur le déploiement suivant + # pour remplacer after party. + + include RunnableOnDeployConcern + include StatementsHelpersConcern + + # Uncomment only if this task MUST run imperatively on its first deployment. + # If possible, leave commented for manual execution later. + run_on_first_deploy + + def collection + 1.upto(10).to_a + end + + def process(element) + # NOOP + end + + def count + 10 + end + end +end From b8b727f06ba0e67a8dfab06355dfa5b9d6724954 Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 24 Sep 2024 21:25:10 +0200 Subject: [PATCH 1161/1532] feat(default.queues): mailers that are not critical are low, otherwise critical. analysis is default, purge is low --- app/jobs/priorized_mail_delivery_job.rb | 4 ++-- config/application.rb | 6 +++--- spec/mailers/administrateur_mailer_spec.rb | 6 ++---- spec/mailers/instructeur_mailer_spec.rb | 7 ++----- spec/mailers/user_mailer_spec.rb | 9 +++------ 5 files changed, 12 insertions(+), 20 deletions(-) diff --git a/app/jobs/priorized_mail_delivery_job.rb b/app/jobs/priorized_mail_delivery_job.rb index db35c9bd5..2404e3912 100644 --- a/app/jobs/priorized_mail_delivery_job.rb +++ b/app/jobs/priorized_mail_delivery_job.rb @@ -12,7 +12,7 @@ class PriorizedMailDeliveryJob < ActionMailer::MailDeliveryJob end end - def custom_queue # should be low - ENV.fetch('BULK_EMAIL_QUEUE') { Rails.application.config.action_mailer.deliver_later_queue_name.to_s } + def custom_queue + 'low' end end diff --git a/config/application.rb b/config/application.rb index b57986388..dc185a729 100644 --- a/config/application.rb +++ b/config/application.rb @@ -53,14 +53,14 @@ module TPS config.action_dispatch.ip_spoofing_check = false # Set the queue name for the mail delivery jobs to 'mailers' - config.action_mailer.deliver_later_queue_name = 'mailers' + config.action_mailer.deliver_later_queue_name = 'critical' # otherwise, :low # Allow the error messages format to be customized config.active_model.i18n_customize_full_message = true # Set the queue name for the analysis jobs to 'active_storage_analysis' - config.active_storage.queues.analysis = :active_storage_analysis - config.active_storage.queues.purge = :purge + config.active_storage.queues.analysis = :default + config.active_storage.queues.purge = :low config.active_support.cache_format_version = 7.0 diff --git a/spec/mailers/administrateur_mailer_spec.rb b/spec/mailers/administrateur_mailer_spec.rb index c038975f2..180877eb1 100644 --- a/spec/mailers/administrateur_mailer_spec.rb +++ b/spec/mailers/administrateur_mailer_spec.rb @@ -11,8 +11,7 @@ RSpec.describe AdministrateurMailer, type: :mailer do it { expect(subject.subject).to include("La suppression automatique des dossiers a été activée sur la démarche") } context 'when perform_later is called' do - let(:custom_queue) { 'low_priority' } - before { ENV['BULK_EMAIL_QUEUE'] = custom_queue } + let(:custom_queue) { 'low' } it 'enqueues email is custom queue for low priority delivery' do expect { subject.deliver_later }.to have_enqueued_job.on_queue(custom_queue) end @@ -52,8 +51,7 @@ end it { expect(subject.body).to include("un de vos services n'a pas son siret renseigné") } context 'when perform_later is called' do - let(:custom_queue) { 'low_priority' } - before { ENV['BULK_EMAIL_QUEUE'] = custom_queue } + let(:custom_queue) { 'low' } it 'enqueues email is custom queue for low priority delivery' do expect { subject.deliver_later }.to have_enqueued_job.on_queue(custom_queue) end diff --git a/spec/mailers/instructeur_mailer_spec.rb b/spec/mailers/instructeur_mailer_spec.rb index db53e6282..28667228e 100644 --- a/spec/mailers/instructeur_mailer_spec.rb +++ b/spec/mailers/instructeur_mailer_spec.rb @@ -10,8 +10,7 @@ RSpec.describe InstructeurMailer, type: :mailer do it { expect(subject.body).to include('Bonjour') } context 'when perform_later is called' do - let(:custom_queue) { 'low_priority' } - before { ENV['BULK_EMAIL_QUEUE'] = custom_queue } + let(:custom_queue) { 'low' } it 'enqueues email is custom queue for low priority delivery' do expect { subject.deliver_later }.to have_enqueued_job(PriorizedMailDeliveryJob).on_queue(custom_queue) @@ -81,9 +80,7 @@ RSpec.describe InstructeurMailer, type: :mailer do end context 'when perform_later is called' do - let(:custom_queue) { 'low_priority' } - before { ENV['BULK_EMAIL_QUEUE'] = custom_queue } - + let(:custom_queue) { 'low' } it 'enqueues email is custom queue for low priority delivery' do expect { subject.deliver_later }.to have_enqueued_job.on_queue(custom_queue) end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index c187c27d7..aa2b1812a 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -151,8 +151,7 @@ RSpec.describe UserMailer, type: :mailer do context 'when perform_later is called' do let(:role) { administrateurs(:default_admin) } - let(:custom_queue) { 'low_priority' } - before { ENV['BULK_EMAIL_QUEUE'] = custom_queue } + let(:custom_queue) { 'low' } it 'enqueues email is custom queue for low priority delivery' do expect { subject.deliver_later }.to have_enqueued_job.on_queue(custom_queue) end @@ -168,8 +167,7 @@ RSpec.describe UserMailer, type: :mailer do end context 'when perform_later is called' do - let(:custom_queue) { 'low_priority' } - before { ENV['BULK_EMAIL_QUEUE'] = custom_queue } + let(:custom_queue) { 'low' } it 'enqueues email is custom queue for low priority delivery' do expect { subject.deliver_later }.to have_enqueued_job.on_queue(custom_queue) end @@ -188,8 +186,7 @@ RSpec.describe UserMailer, type: :mailer do end context 'when perform_later is called' do - let(:custom_queue) { 'low_priority' } - before { ENV['BULK_EMAIL_QUEUE'] = custom_queue } + let(:custom_queue) { 'low' } it 'enqueues email is custom queue for low priority delivery' do expect { subject.deliver_later }.to have_enqueued_job.on_queue(custom_queue) end From d729c2f193535df699017ca1e0a2aa6949b474fe Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 10 Oct 2024 11:05:40 +0200 Subject: [PATCH 1162/1532] tech(perf): reschedule this job at 4am when our workers does not do anything --- app/jobs/cron/dossier_operation_log_move_to_cold_storage_job.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/jobs/cron/dossier_operation_log_move_to_cold_storage_job.rb b/app/jobs/cron/dossier_operation_log_move_to_cold_storage_job.rb index edec7aa36..221f9bdeb 100644 --- a/app/jobs/cron/dossier_operation_log_move_to_cold_storage_job.rb +++ b/app/jobs/cron/dossier_operation_log_move_to_cold_storage_job.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Cron::DossierOperationLogMoveToColdStorageJob < Cron::CronJob - self.schedule_expression = "every day at 10:00" + self.schedule_expression = "every day at 23:00" def perform DossierOperationLog From c48ad8c00305f303d7103d85d24cfcc9f871976c Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 10 Oct 2024 11:24:01 +0200 Subject: [PATCH 1163/1532] fix(job): missed changed queues --- app/jobs/api_entreprise/job.rb | 2 +- app/jobs/migrations/backfill_stable_id_job.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/jobs/api_entreprise/job.rb b/app/jobs/api_entreprise/job.rb index 240c364ac..d228acbe7 100644 --- a/app/jobs/api_entreprise/job.rb +++ b/app/jobs/api_entreprise/job.rb @@ -3,7 +3,7 @@ class APIEntreprise::Job < ApplicationJob DEFAULT_MAX_ATTEMPTS_API_ENTREPRISE_JOBS = 5 - queue_as :api_entreprise + queue_as :default # BadGateway could mean # - acoss: réessayer ultérieurement diff --git a/app/jobs/migrations/backfill_stable_id_job.rb b/app/jobs/migrations/backfill_stable_id_job.rb index 798291e2c..6cc33fcc9 100644 --- a/app/jobs/migrations/backfill_stable_id_job.rb +++ b/app/jobs/migrations/backfill_stable_id_job.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Migrations::BackfillStableIdJob < ApplicationJob - queue_as :low_priority + queue_as :low DEFAULT_LIMIT = 50_000 From bae6f92f3aac85fbdbf87300654bb4d70a81a1d1 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 10 Oct 2024 15:14:25 +0200 Subject: [PATCH 1164/1532] ci: run on ubuntu 22.04 (latest is now 24.04) --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59441d97d..b762a1b65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ on: jobs: linters: name: Linters - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 services: postgres: image: postgis/postgis:14-3.3 @@ -33,7 +33,7 @@ jobs: js_tests: name: JavaScript tests - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 @@ -58,7 +58,7 @@ jobs: unit_tests: name: Unit tests - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 timeout-minutes: 20 env: RUBY_YJIT_ENABLE: "1" @@ -114,7 +114,7 @@ jobs: system_tests: name: System tests - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 timeout-minutes: 20 env: RUBY_YJIT_ENABLE: "1" @@ -168,7 +168,7 @@ jobs: save_test_reports: name: Save test reports needs: [unit_tests, system_tests] - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 From 7f18db6c91397e8f8fbf72b1a1da68a791856504 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 10 Oct 2024 12:20:03 +0200 Subject: [PATCH 1165/1532] fix(job): non crirtical mail must be sent into < 15min, not hours --- app/jobs/priorized_mail_delivery_job.rb | 2 +- spec/mailers/administrateur_mailer_spec.rb | 8 ++++---- spec/mailers/instructeur_mailer_spec.rb | 8 ++++---- spec/mailers/user_mailer_spec.rb | 14 +++++++------- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/jobs/priorized_mail_delivery_job.rb b/app/jobs/priorized_mail_delivery_job.rb index 2404e3912..bf0ea10b7 100644 --- a/app/jobs/priorized_mail_delivery_job.rb +++ b/app/jobs/priorized_mail_delivery_job.rb @@ -13,6 +13,6 @@ class PriorizedMailDeliveryJob < ActionMailer::MailDeliveryJob end def custom_queue - 'low' + 'default' end end diff --git a/spec/mailers/administrateur_mailer_spec.rb b/spec/mailers/administrateur_mailer_spec.rb index 180877eb1..6cba576f0 100644 --- a/spec/mailers/administrateur_mailer_spec.rb +++ b/spec/mailers/administrateur_mailer_spec.rb @@ -11,8 +11,8 @@ RSpec.describe AdministrateurMailer, type: :mailer do it { expect(subject.subject).to include("La suppression automatique des dossiers a été activée sur la démarche") } context 'when perform_later is called' do - let(:custom_queue) { 'low' } - it 'enqueues email is custom queue for low priority delivery' do + let(:custom_queue) { 'default' } + it 'enqueues email is custom queue for non critical delivery' do expect { subject.deliver_later }.to have_enqueued_job.on_queue(custom_queue) end end @@ -51,8 +51,8 @@ end it { expect(subject.body).to include("un de vos services n'a pas son siret renseigné") } context 'when perform_later is called' do - let(:custom_queue) { 'low' } - it 'enqueues email is custom queue for low priority delivery' do + let(:custom_queue) { 'default' } + it 'enqueues email is custom queue for non critical delivery' do expect { subject.deliver_later }.to have_enqueued_job.on_queue(custom_queue) end end diff --git a/spec/mailers/instructeur_mailer_spec.rb b/spec/mailers/instructeur_mailer_spec.rb index 28667228e..ede31bfae 100644 --- a/spec/mailers/instructeur_mailer_spec.rb +++ b/spec/mailers/instructeur_mailer_spec.rb @@ -10,9 +10,9 @@ RSpec.describe InstructeurMailer, type: :mailer do it { expect(subject.body).to include('Bonjour') } context 'when perform_later is called' do - let(:custom_queue) { 'low' } + let(:custom_queue) { 'default' } - it 'enqueues email is custom queue for low priority delivery' do + it 'enqueues email is custom queue for non critical delivery' do expect { subject.deliver_later }.to have_enqueued_job(PriorizedMailDeliveryJob).on_queue(custom_queue) end end @@ -80,8 +80,8 @@ RSpec.describe InstructeurMailer, type: :mailer do end context 'when perform_later is called' do - let(:custom_queue) { 'low' } - it 'enqueues email is custom queue for low priority delivery' do + let(:custom_queue) { 'default' } + it 'enqueues email is custom queue for non critical delivery' do expect { subject.deliver_later }.to have_enqueued_job.on_queue(custom_queue) end end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index aa2b1812a..3ef0c4fce 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -151,8 +151,8 @@ RSpec.describe UserMailer, type: :mailer do context 'when perform_later is called' do let(:role) { administrateurs(:default_admin) } - let(:custom_queue) { 'low' } - it 'enqueues email is custom queue for low priority delivery' do + let(:custom_queue) { 'default' } + it 'enqueues email is custom queue for non critical delivery' do expect { subject.deliver_later }.to have_enqueued_job.on_queue(custom_queue) end end @@ -167,8 +167,8 @@ RSpec.describe UserMailer, type: :mailer do end context 'when perform_later is called' do - let(:custom_queue) { 'low' } - it 'enqueues email is custom queue for low priority delivery' do + let(:custom_queue) { 'default' } + it 'enqueues email is custom queue for non critical delivery' do expect { subject.deliver_later }.to have_enqueued_job.on_queue(custom_queue) end end @@ -181,13 +181,13 @@ RSpec.describe UserMailer, type: :mailer do it 'notifies user about procedure closing with detailed message' do expect(subject.to).to eq([user.email]) - expect(subject.body).to include("Clôture d'une démarche sur demarches-simplifiees.fr") + expect(subject.body).to include("Clôture d'une démarche sur #{APPLICATION_NAME}") expect(subject.body).to include("Bonjour,\r\n
saut de ligne") end context 'when perform_later is called' do - let(:custom_queue) { 'low' } - it 'enqueues email is custom queue for low priority delivery' do + let(:custom_queue) { 'default' } + it 'enqueues email is custom queue for non critical delivery' do expect { subject.deliver_later }.to have_enqueued_job.on_queue(custom_queue) end end From 0e5d77f15b2da27ee992438c8645dcff96881ba7 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 10 Oct 2024 11:59:39 +0200 Subject: [PATCH 1166/1532] fix(job): image process => low queue --- app/jobs/image_processor_job.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/jobs/image_processor_job.rb b/app/jobs/image_processor_job.rb index 16b76f32d..aa39a687e 100644 --- a/app/jobs/image_processor_job.rb +++ b/app/jobs/image_processor_job.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ImageProcessorJob < ApplicationJob + queue_as :low # thumbnails and watermarks. Execution depends of virus scanner which is more urgent + class FileNotScannedYetError < StandardError end From 0a60114e4c0c954e7b77e616563e1bec95fb8f4f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Oct 2024 19:22:51 +0000 Subject: [PATCH 1167/1532] chore(deps): bump @sentry/browser from 8.20.0 to 8.33.0 Bumps [@sentry/browser](https://github.com/getsentry/sentry-javascript) from 8.20.0 to 8.33.0. - [Release notes](https://github.com/getsentry/sentry-javascript/releases) - [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md) - [Commits](https://github.com/getsentry/sentry-javascript/compare/8.20.0...8.33.0) --- updated-dependencies: - dependency-name: "@sentry/browser" dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- bun.lockb | Bin 553868 -> 553868 bytes package.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lockb b/bun.lockb index 31bcb2a1bc5b1b6dbf0ef80ed65b7bedf2323f0e..79b3381c9e46b18973631d16580c311028a078bd 100755 GIT binary patch delta 1954 zcmeBquGsTjae|&g&C8po3_==?t&X+ddhzGEU@nQ`A9X~+p*E_mV}}D z8wLhB28M>4uNfG`fix$Se+J0s1@iYp>CHfz2gv`hIZ|@J=41nB2?_0K#=-pw;u|=w zvEDavZ_aS7oou(nEOY;64`-cgjEa*thGOh3N$hVsFj0(fdL^p6DaD2A$lLH zTI0724AvNG^RSAp1d3XstDUYP&B%jr()J8#MwNw{EI#wC=d0q{=F`k4!UGP0{5pdw93}&$4r}op^M;R*9rp zq3Y&6u07I>xs$J4$#R`+_2iFVr?|yN-s!&V%o6o=c`^%{7tdUI<@RO2ko!I-7yqbr z=u6g)-V!y{V~MNj6#F9_ywxg;SlX_XFH+uRtTyMRZEmlaptDR_?v+J1?k+-do;b4e zeDn6Nf4KCS#q|C2l(b_a?`4KIn(XggkooAlz-7UnGL8v%(-j52vr2kC)7Z0rhOF&d zujeI_v!-q_VLJaV>HVs_71RHMoVVM0#xjl09bx9?t-Ue_!U9=;F1Y<|`jCznFllZ!?_oRFL+iR?TnD+Z%Kx=Y%Z zEr>tS%~s94R^d^J{=GHE?zwJzW;KU;aq+wFS@z#gQn$YSWR*zPJp%#5Njpqsf>pWr z6Zb8=_uXOoS`MJ|?yBs#c6z!_%t!s=344ttU#%}oUFD)1wQYLJim0%RrN@>ltqUFpJgecq*u*fE5Q!j23zzDFy~uSpq9C5HhgR23DjXWMJh|kaPW|U%K045i%d9 z2PUxz*F%hk6=1}or#nE#4y+jg%KAp!3+ HJz)U=_I!1m delta 2017 zcmeBquGsTjae|)0)O0!C&B0Zs{_%$r?yiVqs95sOZMA^(kE=mvLfFNeEjIeyk}$M* z!@$7Hz|f!#rR9M%50K9Yq~(D0tJe$+;y`*ckOuKDZjO}PuQ}PkSwbR+`LF1w*A-}#HBc)D0|Ns>v;nK=ZlI_o zhT1Pc(GXLQX?un=qsl@}MxcY9vjYhz z%><-h0{JXJ`Yn|H2&9>}Z{5RqT$42QvV!f0zB6t=^quK5U%iT1G}rbSkNfY-HkWTCTtDjVVg7Nj&_oWN9FK8gE#<`0unt!M=9Ur$O$U zfi7ia_{aYbNQ*LqoC^Ugzk@90k7x9{Bndk9PgD5N`b6d9jpXI0r(QcOQz(D!zs1pY z_YBgN-$?$n&iotq;9R~p&n(_5|J)f)_txEi?6h=p({x{UW{LV2&EMW1TG8_=iGlC^ z+PjH;qDJ%c=1lkeGACQ8-D1vjYp1aDWgE?>x>Zyg8q{`exVnYyoUnF%LfwMJ6?JNA zTbCd?PaN5K?q_r6y|H_}HTdCzjq8^z^t69-v9EPHb6Mh&RGpY?PPUxVa> z*#W!{4a**9@Yt+>XnT5H>YhVklJg5X)u;ajIZwB=e#ybV8fUDyE~!-&>vTv;C(IMYj~R;uE%_|#1y}x=5N_Ngdm)kc1n)j?m7d5DCI+^Zo*!@eBRNkJ*?Ai@ zI^HTb$gK6_3;-UzxeSWD)EMfapw0uJ<0zW4%nVYa-KA@^JYXJSQr)RCCSX5 zpdhsWuJFBHPvcX{Kiv#uo~N9!PH{I#6S^p2S71uwnM2*e`O?&qBM2B_D zCij2RJngZZ%s|YtJ(iQzyCM=~ILQAJKn#i|F(3w|CJ+r{gXEFrq|o@ta&Y!`#wOO+ z5{yRE55}{JZfBa!YH_?CR93(W1SUocJtG4>0|thRz*0mKDD%2HX7SgBH-r!}uyO`g zm>^_eB@wK+LCC<$D%aY{c1z4M_akJq>wzT(s958;#(Lkxy*UFR1uyWX83*?#h;Km1 zz=}XjJ-B6XnO4uhfZIoS+)akFVI?z4XxD>-o4OW}Vp=^c#&HD@0|P0>5jHL39RmX& zFv24@YM9%%?`_|j!nS>D3j4Ke4p7R|D@m`Ke(NH;+;sMf?1IzZU1H~${_+Blb?O4U z_;iVj?7Y(_Utm|8p0bTydb-0!cJ}F}m)He?DVp&nyYlpc3+(KIC6xuK#rnGXU|JXG zAcWlXHJ8}=kQB2`pM8;CV7eU0oVbhZ!st>m=mK2RUteTbz!U|#Qw%1$AeEhEyUu0y GgarUGK6UT_ diff --git a/package.json b/package.json index 80e8027e2..5ed33b493 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@rails/activestorage": "^7.1.3-4", "@rails/ujs": "^7.1.3-4", "@reach/slider": "^0.17.0", - "@sentry/browser": "8.20.0", + "@sentry/browser": "8.33.1", "@tiptap/core": "^2.2.4", "@tiptap/extension-bold": "^2.2.4", "@tiptap/extension-bullet-list": "^2.2.4", From 7c84937c8c51b0613d158b4dfad8520c5bb968c9 Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 10 Oct 2024 11:29:54 +0200 Subject: [PATCH 1168/1532] =?UTF-8?q?ETQ=20tech,=20je=20souhaite=20que=20l?= =?UTF-8?q?es=20jobs=20asynchrone=20expir=C3=A9s=20les=20dossiers=20termin?= =?UTF-8?q?=C3=A9s=20se=20fassent=20en=20pleinne=20nuit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/expired.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/expired.rb b/app/services/expired.rb index 848172c33..11d79c76d 100644 --- a/app/services/expired.rb +++ b/app/services/expired.rb @@ -25,7 +25,7 @@ module Expired when 'Cron::ExpiredPrefilledDossiersDeletionJob' "every day at 3 am" when 'Cron::ExpiredDossiersTermineDeletionJob' - "every day at 7 am" + "every day at 1 am" when 'Cron::ExpiredDossiersBrouillonDeletionJob' "every day at 10 pm" when 'Cron::ExpiredUsersDeletionJob' From bae752f1aa59a7eaeb1b62a77ca551b09fa3abd8 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 6 Sep 2024 17:17:57 +0200 Subject: [PATCH 1169/1532] refactor(gallery): add attachment_gallery_item component --- .../attachment/gallery_item_component.rb | 22 +++++++++++ .../gallery_item_component.html.haml | 27 +++++++++++++ .../instructeurs/dossiers_controller.rb | 13 ++----- .../dossiers/pieces_jointes.html.haml | 31 +-------------- .../attachment/gallery_item_component_spec.rb | 38 +++++++++++++++++++ .../instructeurs/dossiers_controller_spec.rb | 4 +- 6 files changed, 96 insertions(+), 39 deletions(-) create mode 100644 app/components/attachment/gallery_item_component.rb create mode 100644 app/components/attachment/gallery_item_component/gallery_item_component.html.haml create mode 100644 spec/components/attachment/gallery_item_component_spec.rb diff --git a/app/components/attachment/gallery_item_component.rb b/app/components/attachment/gallery_item_component.rb new file mode 100644 index 000000000..e32ae942f --- /dev/null +++ b/app/components/attachment/gallery_item_component.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Attachment::GalleryItemComponent < ApplicationComponent + include GalleryHelper + attr_reader :attachment + + def initialize(attachment:) + @attachment = attachment + end + + def blob + attachment.blob + end + + def libelle + attachment.record.class.in?([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp]) ? attachment.record.libelle : 'Pièce jointe au message' + end + + def title + "#{libelle} -- #{sanitize(blob.filename.to_s)}" + end +end diff --git a/app/components/attachment/gallery_item_component/gallery_item_component.html.haml b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml new file mode 100644 index 000000000..09ebb3744 --- /dev/null +++ b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml @@ -0,0 +1,27 @@ +.gallery-item + - if displayable_pdf?(blob) + = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: title do + .thumbnail + = image_tag(preview_url_for(attachment), loading: :lazy) + .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } + Visualiser + .champ-libelle + = libelle.truncate(25) + = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) + + - elsif displayable_image?(blob) + = link_to image_url(blob_url(attachment)), title: title, data: { src: blob.url }, class: 'gallery-link' do + .thumbnail + = image_tag(variant_url_for(attachment), loading: :lazy) + .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } + Visualiser + .champ-libelle + = libelle.truncate(25) + = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) + + - else + .thumbnail + = image_tag('apercu-indisponible.png') + .champ-libelle + = libelle.truncate(25) + = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index 152b81f7b..2f4540d52 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -373,23 +373,18 @@ module Instructeurs def pieces_jointes @dossier = current_instructeur.dossiers.find(params[:dossier_id]) - champs_attachments_and_libelles = @dossier + champs_attachments = @dossier .champs .filter { _1.class.in?([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp]) } - .flat_map do |c| - c.piece_justificative_file.map do |attachment| - [attachment, c.libelle] - end - end + .flat_map(&:piece_justificative_file) - commentaires_attachments_and_libelles = @dossier + commentaires_attachments = @dossier .commentaires .map(&:piece_jointe) .map(&:attachments) .flatten - .map { [_1, 'Messagerie'] } - @attachments_and_libelles = champs_attachments_and_libelles + commentaires_attachments_and_libelles + @gallery_attachments = champs_attachments + commentaires_attachments end private diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml index 95e08d1fc..222e6e10e 100644 --- a/app/views/instructeurs/dossiers/pieces_jointes.html.haml +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -4,32 +4,5 @@ .fr-container .gallery.gallery-pieces-jointes{ "data-controller": "lightbox" } - - @attachments_and_libelles.each do |attachment, libelle| - .gallery-item - - blob = attachment.blob - - if displayable_pdf?(blob) - = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{libelle} -- #{sanitize(blob.filename.to_s)}" do - .thumbnail - = image_tag(preview_url_for(attachment), loading: :lazy) - .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } - Visualiser - .champ-libelle - = libelle.truncate(25) - = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) - - - elsif displayable_image?(blob) - = link_to image_url(blob_url(attachment)), title: "#{libelle} -- #{sanitize(blob.filename.to_s)}", data: { src: blob.url }, class: 'gallery-link' do - .thumbnail - = image_tag(variant_url_for(attachment), loading: :lazy) - .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } - Visualiser - .champ-libelle - = libelle.truncate(25) - = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) - - - else - .thumbnail - = image_tag('apercu-indisponible.png') - .champ-libelle - = libelle.truncate(25) - = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) + - @gallery_attachments.each do |attachment| + = render Attachment::GalleryItemComponent.new(attachment:) diff --git a/spec/components/attachment/gallery_item_component_spec.rb b/spec/components/attachment/gallery_item_component_spec.rb new file mode 100644 index 000000000..53a4ce17d --- /dev/null +++ b/spec/components/attachment/gallery_item_component_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Attachment::GalleryItemComponent, type: :component do + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :piece_justificative }] } + let(:dossier) { create(:dossier, :with_populated_champs, :en_construction, procedure:) } + let(:filename) { attachment.blob.filename.to_s } + + let(:component) { described_class.new(attachment: attachment) } + + subject { render_inline(component).to_html } + + context "when attachment is from a piece justificative champ" do + let(:champ) { dossier.champs.first } + let(:libelle) { champ.libelle } + let(:attachment) { champ.piece_justificative_file.attachments.first } + + it "displays libelle, link and renders title" do + expect(subject).to have_text(libelle) + expect(subject).not_to have_text('Pièce jointe au message') + expect(subject).to have_link(filename) + expect(component.title).to eq("#{libelle} -- #{filename}") + end + end + + context "when attachment is from a commentaire" do + let(:commentaire) { create(:commentaire, :with_file, dossier: dossier) } + let(:attachment) { commentaire.piece_jointe.first } + + it "displays a generic libelle, link and renders title" do + expect(subject).to have_text('Pièce jointe au message') + expect(subject).to have_link(filename) + expect(component.title).to eq("Pièce jointe au message -- #{filename}") + end + end +end diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index 510a16ab6..42a4a3bd3 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -1513,7 +1513,9 @@ describe Instructeurs::DossiersController, type: :controller do expect(response.body).to include('Télécharger le fichier logo_test_procedure.png') expect(response.body).to include('Télécharger le fichier RIB.pdf') expect(response.body).to include('Visualiser') - expect(assigns(:attachments_and_libelles).count).to eq 3 + expect(assigns(:gallery_attachments).count).to eq 3 + expect(assigns(:gallery_attachments)).to all(be_a(ActiveStorage::Attachment)) + expect([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp, Commentaire]).to include(*assigns(:gallery_attachments).map { _1.record.class }) end end end From 2882af43aa50e220374ca4145d58ded07c57c203 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 9 Sep 2024 09:46:59 +0200 Subject: [PATCH 1170/1532] refactor(gallery): extract representation_url_for method --- .../gallery_item_component.html.haml | 5 ++--- app/helpers/gallery_helper.rb | 6 ++++++ spec/helpers/gallery_helper_spec.rb | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/components/attachment/gallery_item_component/gallery_item_component.html.haml b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml index 09ebb3744..d59ade805 100644 --- a/app/components/attachment/gallery_item_component/gallery_item_component.html.haml +++ b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml @@ -2,7 +2,7 @@ - if displayable_pdf?(blob) = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: title do .thumbnail - = image_tag(preview_url_for(attachment), loading: :lazy) + = image_tag(representation_url_for(attachment), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } Visualiser .champ-libelle @@ -12,13 +12,12 @@ - elsif displayable_image?(blob) = link_to image_url(blob_url(attachment)), title: title, data: { src: blob.url }, class: 'gallery-link' do .thumbnail - = image_tag(variant_url_for(attachment), loading: :lazy) + = image_tag(representation_url_for(attachment), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } Visualiser .champ-libelle = libelle.truncate(25) = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) - - else .thumbnail = image_tag('apercu-indisponible.png') diff --git a/app/helpers/gallery_helper.rb b/app/helpers/gallery_helper.rb index b9f97ca8d..0f97ea9b9 100644 --- a/app/helpers/gallery_helper.rb +++ b/app/helpers/gallery_helper.rb @@ -9,6 +9,12 @@ module GalleryHelper blob.variable? && blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) end + def representation_url_for(attachment) + return variant_url_for(attachment) if displayable_image?(attachment.blob) + + preview_url_for(attachment) if displayable_pdf?(attachment.blob) + end + def preview_url_for(attachment) preview = attachment.preview(resize_to_limit: [400, 400]) preview.image.attached? ? preview.processed.url : 'pdf-placeholder.png' diff --git a/spec/helpers/gallery_helper_spec.rb b/spec/helpers/gallery_helper_spec.rb index 6f4d3567c..a6783f13f 100644 --- a/spec/helpers/gallery_helper_spec.rb +++ b/spec/helpers/gallery_helper_spec.rb @@ -74,4 +74,20 @@ RSpec.describe GalleryHelper, type: :helper do it { is_expected.to eq("pdf-placeholder.png") } end end + + describe ".representation_url_for" do + subject { representation_url_for(attachment) } + + context "when attachment is an image with no variant" do + let(:file) { fixture_file_upload('spec/fixtures/files/logo_test_procedure.png', 'image/png') } + + it { is_expected.to eq("apercu-indisponible.png") } + end + + context "when attachment is a pdf with no preview" do + let(:file) { fixture_file_upload('spec/fixtures/files/RIB.pdf', 'application/pdf') } + + it { is_expected.to eq("pdf-placeholder.png") } + end + end end From 664ef63e74b4793bb9f225288bfc30ab4c77205d Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 9 Sep 2024 09:48:45 +0200 Subject: [PATCH 1171/1532] refactor(gallery): extract gallery_link method --- .../attachment/gallery_item_component.rb | 12 ++++++++++++ .../gallery_item_component.html.haml | 14 ++------------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/app/components/attachment/gallery_item_component.rb b/app/components/attachment/gallery_item_component.rb index e32ae942f..0de346602 100644 --- a/app/components/attachment/gallery_item_component.rb +++ b/app/components/attachment/gallery_item_component.rb @@ -19,4 +19,16 @@ class Attachment::GalleryItemComponent < ApplicationComponent def title "#{libelle} -- #{sanitize(blob.filename.to_s)}" end + + def gallery_link(blob, &block) + if displayable_image?(blob) + link_to image_url(blob_url(attachment)), title: title, data: { src: blob.url }, class: 'gallery-link' do + yield + end + elsif displayable_pdf?(blob) + link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: title do + yield + end + end + end end diff --git a/app/components/attachment/gallery_item_component/gallery_item_component.html.haml b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml index d59ade805..50aeecd71 100644 --- a/app/components/attachment/gallery_item_component/gallery_item_component.html.haml +++ b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml @@ -1,16 +1,6 @@ .gallery-item - - if displayable_pdf?(blob) - = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: title do - .thumbnail - = image_tag(representation_url_for(attachment), loading: :lazy) - .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } - Visualiser - .champ-libelle - = libelle.truncate(25) - = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) - - - elsif displayable_image?(blob) - = link_to image_url(blob_url(attachment)), title: title, data: { src: blob.url }, class: 'gallery-link' do + - if displayable_pdf?(blob) || displayable_image?(blob) + = gallery_link(blob) do .thumbnail = image_tag(representation_url_for(attachment), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } From 3560d73b58d11eeaf5f82e7c803f5a0dc667ad16 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 9 Sep 2024 10:33:31 +0200 Subject: [PATCH 1172/1532] refactor(gallery): use gallery component in gallery demande --- .../attachment/gallery_item_component.rb | 5 ++++- .../gallery_item_component.html.haml | 14 +++++++------ .../piece_justificative/_show.html.haml | 20 +------------------ .../attachment/gallery_item_component_spec.rb | 11 +++++++++- 4 files changed, 23 insertions(+), 27 deletions(-) diff --git a/app/components/attachment/gallery_item_component.rb b/app/components/attachment/gallery_item_component.rb index 0de346602..8e5ce2814 100644 --- a/app/components/attachment/gallery_item_component.rb +++ b/app/components/attachment/gallery_item_component.rb @@ -4,14 +4,17 @@ class Attachment::GalleryItemComponent < ApplicationComponent include GalleryHelper attr_reader :attachment - def initialize(attachment:) + def initialize(attachment:, gallery_demande: false) @attachment = attachment + @gallery_demande = gallery_demande end def blob attachment.blob end + def gallery_demande? = @gallery_demande + def libelle attachment.record.class.in?([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp]) ? attachment.record.libelle : 'Pièce jointe au message' end diff --git a/app/components/attachment/gallery_item_component/gallery_item_component.html.haml b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml index 50aeecd71..93a5fe5cb 100644 --- a/app/components/attachment/gallery_item_component/gallery_item_component.html.haml +++ b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml @@ -5,12 +5,14 @@ = image_tag(representation_url_for(attachment), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } Visualiser - .champ-libelle - = libelle.truncate(25) - = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) + - if !gallery_demande? + .champ-libelle + = libelle.truncate(25) + = render Attachment::ShowComponent.new(attachment:, truncate: true, new_tab: gallery_demande?) - else .thumbnail = image_tag('apercu-indisponible.png') - .champ-libelle - = libelle.truncate(25) - = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) + - if !gallery_demande? + .champ-libelle + = libelle.truncate(25) + = render Attachment::ShowComponent.new(attachment:, truncate: true, new_tab: gallery_demande?) diff --git a/app/views/shared/champs/piece_justificative/_show.html.haml b/app/views/shared/champs/piece_justificative/_show.html.haml index 70041de21..46c3c0f3d 100644 --- a/app/views/shared/champs/piece_justificative/_show.html.haml +++ b/app/views/shared/champs/piece_justificative/_show.html.haml @@ -2,25 +2,7 @@ - if profile == 'instructeur' .gallery-items-list - champ.piece_justificative_file.attachments.with_all_variant_records.each do |attachment| - .gallery-item - - blob = attachment.blob - - if displayable_pdf?(blob) - = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{sanitize(blob.filename.to_s)}" do - .thumbnail - = image_tag(preview_url_for(attachment), loading: :lazy) - .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } - = 'Visualiser' - - - elsif displayable_image?(blob) - = link_to image_url(blob_url(attachment)), title: "#{champ.libelle} -- #{sanitize(blob.filename.to_s)}", data: { src: blob.url }, class: 'gallery-link' do - .thumbnail - = image_tag(variant_url_for(attachment), loading: :lazy) - .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } - = 'Visualiser' - - else - .thumbnail - = image_tag('apercu-indisponible.png') - = render Attachment::ShowComponent.new(attachment:, new_tab: true, truncate: true) + = render Attachment::GalleryItemComponent.new(attachment:, gallery_demande: true) - else %ul - champ.piece_justificative_file.attachments.each do |attachment| diff --git a/spec/components/attachment/gallery_item_component_spec.rb b/spec/components/attachment/gallery_item_component_spec.rb index 53a4ce17d..7dbdab7e4 100644 --- a/spec/components/attachment/gallery_item_component_spec.rb +++ b/spec/components/attachment/gallery_item_component_spec.rb @@ -7,8 +7,9 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do let(:types_de_champ_public) { [{ type: :piece_justificative }] } let(:dossier) { create(:dossier, :with_populated_champs, :en_construction, procedure:) } let(:filename) { attachment.blob.filename.to_s } + let(:gallery_demande) { false } - let(:component) { described_class.new(attachment: attachment) } + let(:component) { described_class.new(attachment: attachment, gallery_demande:) } subject { render_inline(component).to_html } @@ -23,6 +24,14 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do expect(subject).to have_link(filename) expect(component.title).to eq("#{libelle} -- #{filename}") end + + context "when gallery item is in page Demande" do + let(:gallery_demande) { true } + + it "does not display libelle" do + expect(subject).not_to have_text(libelle) + end + end end context "when attachment is from a commentaire" do From df08617387f177146d4a88eb85fd51dd8a79cf1b Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 9 Sep 2024 18:01:24 +0200 Subject: [PATCH 1173/1532] feat(gallery): add badge for date of created_at or updated_at --- app/assets/stylesheets/gallery.scss | 4 ++++ .../attachment/gallery_item_component.rb | 19 +++++++++++++++++++ .../gallery_item_component.en.yml | 3 +++ .../gallery_item_component.fr.yml | 3 +++ .../gallery_item_component.html.haml | 3 +++ .../attachment/gallery_item_component_spec.rb | 4 ++++ 6 files changed, 36 insertions(+) create mode 100644 app/components/attachment/gallery_item_component/gallery_item_component.en.yml create mode 100644 app/components/attachment/gallery_item_component/gallery_item_component.fr.yml diff --git a/app/assets/stylesheets/gallery.scss b/app/assets/stylesheets/gallery.scss index eb2bec9ee..524535356 100644 --- a/app/assets/stylesheets/gallery.scss +++ b/app/assets/stylesheets/gallery.scss @@ -13,6 +13,10 @@ object-fit: cover; } + .champ-updated { + width: 100%; + } + .thumbnail { position: relative; display: flex; diff --git a/app/components/attachment/gallery_item_component.rb b/app/components/attachment/gallery_item_component.rb index 8e5ce2814..8944d677a 100644 --- a/app/components/attachment/gallery_item_component.rb +++ b/app/components/attachment/gallery_item_component.rb @@ -34,4 +34,23 @@ class Attachment::GalleryItemComponent < ApplicationComponent end end end + + def created_at + attachment.record.created_at + end + + def updated? + attachment.record.class.in?([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp]) && updated_at > attachment.record.dossier.depose_at + end + + def updated_at + blob.created_at + end + + def badge_updated_class + class_names( + "fr-badge fr-badge--sm" => true, + "fr-badge--new" => updated? + ) + end end diff --git a/app/components/attachment/gallery_item_component/gallery_item_component.en.yml b/app/components/attachment/gallery_item_component/gallery_item_component.en.yml new file mode 100644 index 000000000..7e273db89 --- /dev/null +++ b/app/components/attachment/gallery_item_component/gallery_item_component.en.yml @@ -0,0 +1,3 @@ +en: + created_at: "Added on %{datetime}" + updated_at: "Updated on %{datetime}" diff --git a/app/components/attachment/gallery_item_component/gallery_item_component.fr.yml b/app/components/attachment/gallery_item_component/gallery_item_component.fr.yml new file mode 100644 index 000000000..788df7462 --- /dev/null +++ b/app/components/attachment/gallery_item_component/gallery_item_component.fr.yml @@ -0,0 +1,3 @@ +fr: + created_at: "Ajoutée le %{datetime}" + updated_at: "Modifiée le %{datetime}" diff --git a/app/components/attachment/gallery_item_component/gallery_item_component.html.haml b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml index 93a5fe5cb..486f14cef 100644 --- a/app/components/attachment/gallery_item_component/gallery_item_component.html.haml +++ b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml @@ -1,4 +1,7 @@ .gallery-item + - if !gallery_demande? + .fr-mb-2v.champ-updated{ class: badge_updated_class } + = t(updated? ? '.updated_at' : '.created_at', datetime: helpers.try_format_datetime(updated_at, format: :veryshort)) - if displayable_pdf?(blob) || displayable_image?(blob) = gallery_link(blob) do .thumbnail diff --git a/spec/components/attachment/gallery_item_component_spec.rb b/spec/components/attachment/gallery_item_component_spec.rb index 7dbdab7e4..ec80160bf 100644 --- a/spec/components/attachment/gallery_item_component_spec.rb +++ b/spec/components/attachment/gallery_item_component_spec.rb @@ -25,6 +25,10 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do expect(component.title).to eq("#{libelle} -- #{filename}") end + it "displays when gallery item has been added" do + expect(subject).to have_text(component.helpers.try_format_datetime(attachment.record.created_at, format: :veryshort)) + end + context "when gallery item is in page Demande" do let(:gallery_demande) { true } From bc237152e772b552acb3b052fdf43c48f77fa721 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Tue, 10 Sep 2024 11:43:25 +0200 Subject: [PATCH 1174/1532] feat(gallery): add origin tag to gallery item --- .../attachment/gallery_item_component.rb | 31 +++++++++++++++++-- .../gallery_item_component.html.haml | 3 ++ .../attachment/gallery_item_component_spec.rb | 22 ++++++++++--- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/app/components/attachment/gallery_item_component.rb b/app/components/attachment/gallery_item_component.rb index 8944d677a..b2f3c5b13 100644 --- a/app/components/attachment/gallery_item_component.rb +++ b/app/components/attachment/gallery_item_component.rb @@ -16,7 +16,34 @@ class Attachment::GalleryItemComponent < ApplicationComponent def gallery_demande? = @gallery_demande def libelle - attachment.record.class.in?([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp]) ? attachment.record.libelle : 'Pièce jointe au message' + from_dossier? ? attachment.record.libelle : 'Pièce jointe au message' + end + + def from_dossier? + attachment.record.class.in?([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp]) + end + + def from_messagerie? + attachment.record.is_a?(Commentaire) + end + + def from_messagerie_instructeur? + from_messagerie? && attachment.record.instructeur.present? + end + + def from_messagerie_usager? + from_messagerie? && attachment.record.instructeur.nil? + end + + def origin + case + when from_dossier? + 'Dossier usager' + when from_messagerie_instructeur? + 'Messagerie (instructeur)' + when from_messagerie_usager? + 'Messagerie (usager)' + end end def title @@ -40,7 +67,7 @@ class Attachment::GalleryItemComponent < ApplicationComponent end def updated? - attachment.record.class.in?([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp]) && updated_at > attachment.record.dossier.depose_at + from_dossier? && updated_at > attachment.record.dossier.depose_at end def updated_at diff --git a/app/components/attachment/gallery_item_component/gallery_item_component.html.haml b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml index 486f14cef..10666cb00 100644 --- a/app/components/attachment/gallery_item_component/gallery_item_component.html.haml +++ b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml @@ -1,5 +1,8 @@ .gallery-item - if !gallery_demande? + .fr-mb-1v + .fr-tag + = origin .fr-mb-2v.champ-updated{ class: badge_updated_class } = t(updated? ? '.updated_at' : '.created_at', datetime: helpers.try_format_datetime(updated_at, format: :veryshort)) - if displayable_pdf?(blob) || displayable_image?(blob) diff --git a/spec/components/attachment/gallery_item_component_spec.rb b/spec/components/attachment/gallery_item_component_spec.rb index ec80160bf..3ecb40f29 100644 --- a/spec/components/attachment/gallery_item_component_spec.rb +++ b/spec/components/attachment/gallery_item_component_spec.rb @@ -3,6 +3,7 @@ require 'rails_helper' RSpec.describe Attachment::GalleryItemComponent, type: :component do + let(:instructeur) { create(:instructeur) } let(:procedure) { create(:procedure, :published, types_de_champ_public:) } let(:types_de_champ_public) { [{ type: :piece_justificative }] } let(:dossier) { create(:dossier, :with_populated_champs, :en_construction, procedure:) } @@ -18,10 +19,11 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do let(:libelle) { champ.libelle } let(:attachment) { champ.piece_justificative_file.attachments.first } - it "displays libelle, link and renders title" do + it "displays libelle, link, tag and renders title" do expect(subject).to have_text(libelle) expect(subject).not_to have_text('Pièce jointe au message') expect(subject).to have_link(filename) + expect(subject).to have_text('Dossier usager') expect(component.title).to eq("#{libelle} -- #{filename}") end @@ -42,10 +44,20 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do let(:commentaire) { create(:commentaire, :with_file, dossier: dossier) } let(:attachment) { commentaire.piece_jointe.first } - it "displays a generic libelle, link and renders title" do - expect(subject).to have_text('Pièce jointe au message') - expect(subject).to have_link(filename) - expect(component.title).to eq("Pièce jointe au message -- #{filename}") + context 'from an usager' do + it "displays a generic libelle, link, tag and renders title" do + expect(subject).to have_text('Pièce jointe au message') + expect(subject).to have_link(filename) + expect(subject).to have_text('Messagerie (usager)') + expect(component.title).to eq("Pièce jointe au message -- #{filename}") + end + end + + context 'from an instructeur' do + before { commentaire.update!(instructeur:) } + it "displays the right tag" do + expect(subject).to have_text('Messagerie (instructeur)') + end end end end From 0006b6f504a434cc6161795a055bb0d67cfbf947 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Tue, 10 Sep 2024 16:08:38 +0200 Subject: [PATCH 1175/1532] db(migration): add pieces jointes updates to dossiers --- ...240910135752_add_pieces_jointes_updates_to_dossiers.rb | 8 ++++++++ db/schema.rb | 2 ++ 2 files changed, 10 insertions(+) create mode 100644 db/migrate/20240910135752_add_pieces_jointes_updates_to_dossiers.rb diff --git a/db/migrate/20240910135752_add_pieces_jointes_updates_to_dossiers.rb b/db/migrate/20240910135752_add_pieces_jointes_updates_to_dossiers.rb new file mode 100644 index 000000000..e8374aa23 --- /dev/null +++ b/db/migrate/20240910135752_add_pieces_jointes_updates_to_dossiers.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddPiecesJointesUpdatesToDossiers < ActiveRecord::Migration[7.0] + def change + add_column :dossiers, :last_champ_piece_jointe_updated_at, :datetime + add_column :dossiers, :last_commentaire_piece_jointe_updated_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index a628a89ea..fd9774819 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -488,8 +488,10 @@ ActiveRecord::Schema[7.0].define(version: 2024_09_23_125619) do t.datetime "hidden_by_user_at", precision: nil t.datetime "identity_updated_at", precision: nil t.datetime "last_avis_updated_at", precision: nil + t.datetime "last_champ_piece_jointe_updated_at" t.datetime "last_champ_private_updated_at", precision: nil t.datetime "last_champ_updated_at", precision: nil + t.datetime "last_commentaire_piece_jointe_updated_at" t.datetime "last_commentaire_updated_at", precision: nil t.string "mandataire_first_name" t.string "mandataire_last_name" From 5153b9a3ff0d7f831d171f257f091b62cd1f7245 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 11 Sep 2024 08:55:31 +0200 Subject: [PATCH 1176/1532] db(migration): add pieces_jointes_seen_at to follows --- ...145644_add_pieces_jointes_seen_at_to_follows.rb | 12 ++++++++++++ ...backfill_follows_with_pieces_jointes_seen_at.rb | 14 ++++++++++++++ db/schema.rb | 1 + 3 files changed, 27 insertions(+) create mode 100644 db/migrate/20240910145644_add_pieces_jointes_seen_at_to_follows.rb create mode 100644 db/migrate/20240911064340_backfill_follows_with_pieces_jointes_seen_at.rb diff --git a/db/migrate/20240910145644_add_pieces_jointes_seen_at_to_follows.rb b/db/migrate/20240910145644_add_pieces_jointes_seen_at_to_follows.rb new file mode 100644 index 000000000..cce53f945 --- /dev/null +++ b/db/migrate/20240910145644_add_pieces_jointes_seen_at_to_follows.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddPiecesJointesSeenAtToFollows < ActiveRecord::Migration[7.0] + def up + add_column :follows, :pieces_jointes_seen_at, :datetime + change_column_default :follows, :pieces_jointes_seen_at, from: nil, to: 'CURRENT_TIMESTAMP' + end + + def down + remove_column :follows, :pieces_jointes_seen_at + end +end diff --git a/db/migrate/20240911064340_backfill_follows_with_pieces_jointes_seen_at.rb b/db/migrate/20240911064340_backfill_follows_with_pieces_jointes_seen_at.rb new file mode 100644 index 000000000..82a51de34 --- /dev/null +++ b/db/migrate/20240911064340_backfill_follows_with_pieces_jointes_seen_at.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class BackfillFollowsWithPiecesJointesSeenAt < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + def up + Follow.in_batches do |relation| + relation.update_all pieces_jointes_seen_at: Time.zone.now + sleep(0.001) # throttle + end + end + + def down + end +end diff --git a/db/schema.rb b/db/schema.rb index fd9774819..e55fcb86b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -691,6 +691,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_09_23_125619) do t.integer "dossier_id", null: false t.integer "instructeur_id", null: false t.datetime "messagerie_seen_at", precision: nil, null: false + t.datetime "pieces_jointes_seen_at" t.datetime "unfollowed_at", precision: nil t.datetime "updated_at", precision: nil t.index ["dossier_id"], name: "index_follows_on_dossier_id" From d9f604e8ce478e2e6b248b036f3b5facf27542fd Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Tue, 10 Sep 2024 16:09:43 +0200 Subject: [PATCH 1177/1532] feat(gallery): notify instructeur if pieces jointes updates --- .../instructeurs/dossiers_controller.rb | 5 +++ app/controllers/users/dossiers_controller.rb | 5 ++- app/models/concerns/dossier_clone_concern.rb | 1 + app/models/dossier.rb | 4 ++- app/models/follow.rb | 1 + app/models/instructeur.rb | 10 +++--- .../dossiers/_header_bottom.html.haml | 3 +- .../concerns/dossier_clone_concern_spec.rb | 2 ++ spec/models/instructeur_spec.rb | 35 ++++++++++++------- 9 files changed, 47 insertions(+), 19 deletions(-) diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index 2f4540d52..575b3d0e4 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -18,6 +18,7 @@ module Instructeurs after_action :mark_messagerie_as_read, only: [:messagerie, :create_commentaire, :pending_correction] after_action :mark_avis_as_read, only: [:avis, :create_avis] after_action :mark_annotations_privees_as_read, only: [:annotations_privees, :update_annotations] + after_action :mark_pieces_jointes_as_read, only: [:pieces_jointes] def extend_conservation dossier.extend_conservation(1.month) @@ -466,6 +467,10 @@ module Instructeurs current_instructeur.mark_tab_as_seen(dossier, :annotations_privees) end + def mark_pieces_jointes_as_read + current_instructeur.mark_tab_as_seen(dossier, :pieces_jointes) + end + def aasm_error_message(exception, target_state:) if exception.originating_state == target_state "Le dossier est déjà #{dossier_display_state(target_state, lower: true)}." diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index 824751910..b7166d911 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -341,7 +341,10 @@ module Users @commentaire = CommentaireService.create(current_user, dossier, commentaire_params) if @commentaire.errors.empty? - @commentaire.dossier.update!(last_commentaire_updated_at: Time.zone.now) + timestamps = [:last_commentaire_updated_at, :updated_at] + timestamps << :last_commentaire_piece_jointe_updated_at if @commentaire.piece_jointe.attached? + + @commentaire.dossier.touch(*timestamps) flash.notice = t('.message_send') redirect_to messagerie_dossier_path(dossier) diff --git a/app/models/concerns/dossier_clone_concern.rb b/app/models/concerns/dossier_clone_concern.rb index 691226c0e..6d7b185e4 100644 --- a/app/models/concerns/dossier_clone_concern.rb +++ b/app/models/concerns/dossier_clone_concern.rb @@ -71,6 +71,7 @@ module DossierCloneConcern diff = make_diff(editing_fork) apply_diff(diff) touch(:last_champ_updated_at) + touch(:last_champ_piece_jointe_updated_at) if diff[:updated].any? { |c| c.class.in?([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp]) } end reload index_search_terms_later diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 9a980ae60..f6707f444 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -377,7 +377,9 @@ class Dossier < ApplicationRecord ' OR groupe_instructeur_updated_at > follows.demande_seen_at' \ ' OR last_champ_private_updated_at > follows.annotations_privees_seen_at' \ ' OR last_avis_updated_at > follows.avis_seen_at' \ - ' OR last_commentaire_updated_at > follows.messagerie_seen_at') + ' OR last_commentaire_updated_at > follows.messagerie_seen_at' \ + ' OR last_commentaire_piece_jointe_updated_at > follows.pieces_jointes_seen_at' \ + ' OR last_champ_piece_jointe_updated_at > follows.pieces_jointes_seen_at') .distinct end diff --git a/app/models/follow.rb b/app/models/follow.rb index c494eb47d..7c2dcaed2 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -18,5 +18,6 @@ class Follow < ApplicationRecord self.annotations_privees_seen_at ||= Time.zone.now self.avis_seen_at ||= Time.zone.now self.messagerie_seen_at ||= Time.zone.now + self.pieces_jointes_seen_at ||= Time.zone.now end end diff --git a/app/models/instructeur.rb b/app/models/instructeur.rb index b9e105802..6c9f388af 100644 --- a/app/models/instructeur.rb +++ b/app/models/instructeur.rb @@ -125,10 +125,11 @@ class Instructeur < ApplicationRecord annotations_privees = dossier.last_champ_private_updated_at&.>(follow.annotations_privees_seen_at) || false avis_notif = dossier.last_avis_updated_at&.>(follow.avis_seen_at) || false messagerie = dossier.last_commentaire_updated_at&.>(follow.messagerie_seen_at) || false + pieces_jointes = dossier.last_champ_piece_jointe_updated_at&.>(follow.pieces_jointes_seen_at) || dossier.last_commentaire_piece_jointe_updated_at&.>(follow.pieces_jointes_seen_at) || false - annotations_hash(demande, annotations_privees, avis_notif, messagerie) + annotations_hash(demande, annotations_privees, avis_notif, messagerie, pieces_jointes) else - annotations_hash(false, false, false, false) + annotations_hash(false, false, false, false, false) end end @@ -314,12 +315,13 @@ class Instructeur < ApplicationRecord private - def annotations_hash(demande, annotations_privees, avis, messagerie) + def annotations_hash(demande, annotations_privees, avis, messagerie, pieces_jointes) { demande: demande, annotations_privees: annotations_privees, avis: avis, - messagerie: messagerie + messagerie: messagerie, + pieces_jointes: pieces_jointes } end diff --git a/app/views/instructeurs/dossiers/_header_bottom.html.haml b/app/views/instructeurs/dossiers/_header_bottom.html.haml index 0358c4aa0..1af884845 100644 --- a/app/views/instructeurs/dossiers/_header_bottom.html.haml +++ b/app/views/instructeurs/dossiers/_header_bottom.html.haml @@ -9,7 +9,8 @@ - if dossier.champs.map(&:piece_justificative_file).flatten.any? = dynamic_tab_item(t('views.instructeurs.dossiers.tab_steps.attachments'), - pieces_jointes_instructeur_dossier_path(dossier.procedure, dossier)) + pieces_jointes_instructeur_dossier_path(dossier.procedure, dossier), + notification: notifications_summary[:pieces_jointes]) = dynamic_tab_item(t('views.instructeurs.dossiers.tab_steps.private_annotations'), annotations_privees_instructeur_dossier_path(dossier.procedure, dossier), diff --git a/spec/models/concerns/dossier_clone_concern_spec.rb b/spec/models/concerns/dossier_clone_concern_spec.rb index e7e5f4869..dd33470ab 100644 --- a/spec/models/concerns/dossier_clone_concern_spec.rb +++ b/spec/models/concerns/dossier_clone_concern_spec.rb @@ -44,7 +44,9 @@ RSpec.describe DossierCloneConcern do expect(new_dossier.last_avis_updated_at).to be_nil expect(new_dossier.last_champ_private_updated_at).to be_nil expect(new_dossier.last_champ_updated_at).to be_nil + expect(new_dossier.last_champ_piece_jointe_updated_at).to be_nil expect(new_dossier.last_commentaire_updated_at).to be_nil + expect(new_dossier.last_commentaire_piece_jointe_updated_at).to be_nil expect(new_dossier.motivation).to be_nil expect(new_dossier.processed_at).to be_nil end diff --git a/spec/models/instructeur_spec.rb b/spec/models/instructeur_spec.rb index 54f2a21b0..9523e6779 100644 --- a/spec/models/instructeur_spec.rb +++ b/spec/models/instructeur_spec.rb @@ -196,7 +196,7 @@ describe Instructeur, type: :model do subject { instructeur.notifications_for_dossier(dossier) } context 'when the instructeur has just followed the dossier' do - it { is_expected.to match({ demande: false, annotations_privees: false, avis: false, messagerie: false }) } + it { is_expected.to match({ demande: false, annotations_privees: false, avis: false, messagerie: false, pieces_jointes: false }) } end context 'when there is a modification on public champs' do @@ -205,20 +205,20 @@ describe Instructeur, type: :model do dossier.update(last_champ_updated_at: Time.zone.now) } - it { is_expected.to match({ demande: true, annotations_privees: false, avis: false, messagerie: false }) } + it { is_expected.to match({ demande: true, annotations_privees: false, avis: false, messagerie: false, pieces_jointes: false }) } end context 'when there is a modification on identity' do before { dossier.update(identity_updated_at: Time.zone.now) } - it { is_expected.to match({ demande: true, annotations_privees: false, avis: false, messagerie: false }) } + it { is_expected.to match({ demande: true, annotations_privees: false, avis: false, messagerie: false, pieces_jointes: false }) } end context 'when there is a modification on groupe instructeur' do let(:groupe_instructeur) { create(:groupe_instructeur, instructeurs: [instructeur], procedure: dossier.procedure) } before { dossier.assign_to_groupe_instructeur(groupe_instructeur, DossierAssignment.modes.fetch(:auto)) } - it { is_expected.to match({ demande: true, annotations_privees: false, avis: false, messagerie: false }) } + it { is_expected.to match({ demande: true, annotations_privees: false, avis: false, messagerie: false, pieces_jointes: false }) } end context 'when there is a modification on private champs' do @@ -227,7 +227,7 @@ describe Instructeur, type: :model do dossier.update(last_champ_private_updated_at: Time.zone.now) } - it { is_expected.to match({ demande: false, annotations_privees: true, avis: false, messagerie: false }) } + it { is_expected.to match({ demande: false, annotations_privees: true, avis: false, messagerie: false, pieces_jointes: false }) } end context 'when there is a modification on avis' do @@ -236,23 +236,34 @@ describe Instructeur, type: :model do dossier.update(last_avis_updated_at: Time.zone.now) } - it { is_expected.to match({ demande: false, annotations_privees: false, avis: true, messagerie: false }) } + it { is_expected.to match({ demande: false, annotations_privees: false, avis: true, messagerie: false, pieces_jointes: false }) } end context 'messagerie' do context 'when there is a new commentaire' do - before { - create(:commentaire, dossier: dossier, email: 'a@b.com') - dossier.update(last_commentaire_updated_at: Time.zone.now) - } + context 'without a file' do + before { + create(:commentaire, dossier: dossier, email: 'a@b.com') + dossier.update(last_commentaire_updated_at: Time.zone.now) + } - it { is_expected.to match({ demande: false, annotations_privees: false, avis: false, messagerie: true }) } + it { is_expected.to match({ demande: false, annotations_privees: false, avis: false, messagerie: true, pieces_jointes: false }) } + end + + context 'with a file' do + before { + create(:commentaire, :with_file, dossier: dossier, email: 'a@b.com') + dossier.update(last_commentaire_updated_at: Time.zone.now, last_commentaire_piece_jointe_updated_at: Time.zone.now) + } + + it { is_expected.to match({ demande: false, annotations_privees: false, avis: false, messagerie: true, pieces_jointes: true }) } + end end context 'when there is a new commentaire issued by tps' do before { create(:commentaire, dossier: dossier, email: CONTACT_EMAIL) } - it { is_expected.to match({ demande: false, annotations_privees: false, avis: false, messagerie: false }) } + it { is_expected.to match({ demande: false, annotations_privees: false, avis: false, messagerie: false, pieces_jointes: false }) } end end end From 3bc232e81e68de0ed4f8559ec557612b672541ff Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 11 Sep 2024 22:01:09 +0200 Subject: [PATCH 1178/1532] feat(gallery): update gallery item badge class after seen --- .../attachment/gallery_item_component.rb | 7 +-- .../instructeurs/dossiers_controller.rb | 1 + .../dossiers/pieces_jointes.html.haml | 2 +- .../attachment/gallery_item_component_spec.rb | 44 ++++++++++++++++++- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/app/components/attachment/gallery_item_component.rb b/app/components/attachment/gallery_item_component.rb index b2f3c5b13..5667074eb 100644 --- a/app/components/attachment/gallery_item_component.rb +++ b/app/components/attachment/gallery_item_component.rb @@ -2,11 +2,12 @@ class Attachment::GalleryItemComponent < ApplicationComponent include GalleryHelper - attr_reader :attachment + attr_reader :attachment, :seen_at - def initialize(attachment:, gallery_demande: false) + def initialize(attachment:, gallery_demande: false, seen_at: nil) @attachment = attachment @gallery_demande = gallery_demande + @seen_at = seen_at end def blob @@ -77,7 +78,7 @@ class Attachment::GalleryItemComponent < ApplicationComponent def badge_updated_class class_names( "fr-badge fr-badge--sm" => true, - "fr-badge--new" => updated? + "fr-badge--new" => seen_at.present? && updated_at&.>(seen_at) ) end end diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index 575b3d0e4..dec97f6d4 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -386,6 +386,7 @@ module Instructeurs .flatten @gallery_attachments = champs_attachments + commentaires_attachments + @pieces_jointes_seen_at = current_instructeur.follows.find_by(dossier: dossier)&.pieces_jointes_seen_at end private diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml index 222e6e10e..bfc0a0bc0 100644 --- a/app/views/instructeurs/dossiers/pieces_jointes.html.haml +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -5,4 +5,4 @@ .fr-container .gallery.gallery-pieces-jointes{ "data-controller": "lightbox" } - @gallery_attachments.each do |attachment| - = render Attachment::GalleryItemComponent.new(attachment:) + = render Attachment::GalleryItemComponent.new(attachment:, seen_at: @pieces_jointes_seen_at) diff --git a/spec/components/attachment/gallery_item_component_spec.rb b/spec/components/attachment/gallery_item_component_spec.rb index 3ecb40f29..0f200a76f 100644 --- a/spec/components/attachment/gallery_item_component_spec.rb +++ b/spec/components/attachment/gallery_item_component_spec.rb @@ -9,16 +9,23 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do let(:dossier) { create(:dossier, :with_populated_champs, :en_construction, procedure:) } let(:filename) { attachment.blob.filename.to_s } let(:gallery_demande) { false } + let(:seen_at) { nil } + let(:now) { Time.zone.parse('01/01/2010') } - let(:component) { described_class.new(attachment: attachment, gallery_demande:) } + let(:component) { described_class.new(attachment: attachment, gallery_demande:, seen_at: seen_at) } subject { render_inline(component).to_html } + after { Timecop.return } + context "when attachment is from a piece justificative champ" do let(:champ) { dossier.champs.first } let(:libelle) { champ.libelle } let(:attachment) { champ.piece_justificative_file.attachments.first } + # Correspond au cas standard où le blob est créé avant le dépôt du dossier + before { dossier.touch(:depose_at) } + it "displays libelle, link, tag and renders title" do expect(subject).to have_text(libelle) expect(subject).not_to have_text('Pièce jointe au message') @@ -28,9 +35,20 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do end it "displays when gallery item has been added" do + expect(subject).to have_text('Ajoutée le') + expect(subject).not_to have_css('.fr-badge--new') expect(subject).to have_text(component.helpers.try_format_datetime(attachment.record.created_at, format: :veryshort)) end + context "when gallery item has been updated" do + # un nouveau blob est créé après modification d'un champ pièce justificative + before { attachment.blob.touch(:created_at) } + + it 'displays the right text' do + expect(subject).to have_text('Modifiée le') + end + end + context "when gallery item is in page Demande" do let(:gallery_demande) { true } @@ -51,6 +69,30 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do expect(subject).to have_text('Messagerie (usager)') expect(component.title).to eq("Pièce jointe au message -- #{filename}") end + + context "when instructeur has not seen it yet" do + let(:seen_at) { Timecop.freeze(now - 1.day) } + + before do + attachment.blob.update(created_at: Timecop.freeze(now)) + end + + it 'displays datetime in the right style' do + expect(subject).to have_css('.fr-badge--new') + end + end + + context "when instructeur has already seen it" do + let!(:seen_at) { Timecop.freeze(now) } + + before do + attachment.blob.touch(:created_at) + end + + it 'displays datetime in the right style' do + expect(subject).not_to have_css('.fr-badge--new') + end + end end context 'from an instructeur' do From 4f42e00f4ed0782603ad6d4f950d274b41b5b583 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Thu, 12 Sep 2024 10:52:51 +0200 Subject: [PATCH 1179/1532] refactor(gallery): move methods in private --- .../attachment/gallery_item_component.rb | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/app/components/attachment/gallery_item_component.rb b/app/components/attachment/gallery_item_component.rb index 5667074eb..bc2284efc 100644 --- a/app/components/attachment/gallery_item_component.rb +++ b/app/components/attachment/gallery_item_component.rb @@ -20,22 +20,6 @@ class Attachment::GalleryItemComponent < ApplicationComponent from_dossier? ? attachment.record.libelle : 'Pièce jointe au message' end - def from_dossier? - attachment.record.class.in?([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp]) - end - - def from_messagerie? - attachment.record.is_a?(Commentaire) - end - - def from_messagerie_instructeur? - from_messagerie? && attachment.record.instructeur.present? - end - - def from_messagerie_usager? - from_messagerie? && attachment.record.instructeur.nil? - end - def origin case when from_dossier? @@ -81,4 +65,22 @@ class Attachment::GalleryItemComponent < ApplicationComponent "fr-badge--new" => seen_at.present? && updated_at&.>(seen_at) ) end + + private + + def from_dossier? + attachment.record.class.in?([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp]) + end + + def from_messagerie? + attachment.record.is_a?(Commentaire) + end + + def from_messagerie_instructeur? + from_messagerie? && attachment.record.instructeur.present? + end + + def from_messagerie_usager? + from_messagerie? && attachment.record.instructeur.nil? + end end From c6ab05dcc5d1b8198090b9bfcf6c68fea625f142 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 13 Sep 2024 19:07:32 +0200 Subject: [PATCH 1180/1532] fix(gallery): display pieces_jointes tab if any attachments --- .../instructeurs/dossiers_controller.rb | 31 +++++++++++-------- .../dossiers/_header_bottom.html.haml | 2 +- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index dec97f6d4..632eb1ac2 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -13,6 +13,7 @@ module Instructeurs before_action :redirect_on_dossier_not_found, only: :show before_action :redirect_on_dossier_in_batch_operation, only: [:archive, :unarchive, :follow, :unfollow, :passer_en_instruction, :repasser_en_construction, :repasser_en_instruction, :terminer, :restore, :destroy, :extend_conservation] + before_action :set_gallery_attachments, only: [:show, :pieces_jointes, :annotations_privees, :avis, :messagerie, :personnes_impliquees, :reaffectation] after_action :mark_demande_as_read, only: :show after_action :mark_messagerie_as_read, only: [:messagerie, :create_commentaire, :pending_correction] @@ -373,19 +374,6 @@ module Instructeurs def pieces_jointes @dossier = current_instructeur.dossiers.find(params[:dossier_id]) - - champs_attachments = @dossier - .champs - .filter { _1.class.in?([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp]) } - .flat_map(&:piece_justificative_file) - - commentaires_attachments = @dossier - .commentaires - .map(&:piece_jointe) - .map(&:attachments) - .flatten - - @gallery_attachments = champs_attachments + commentaires_attachments @pieces_jointes_seen_at = current_instructeur.follows.find_by(dossier: dossier)&.pieces_jointes_seen_at end @@ -499,5 +487,22 @@ module Instructeurs redirect_back(fallback_location: instructeur_dossier_path(procedure, dossier_in_batch)) end end + + def set_gallery_attachments + @dossier = current_instructeur.dossiers.find(params[:dossier_id]) + + champs_attachments = @dossier + .champs + .filter { _1.class.in?([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp]) } + .flat_map(&:piece_justificative_file) + + commentaires_attachments = @dossier + .commentaires + .map(&:piece_jointe) + .map(&:attachments) + .flatten + + @gallery_attachments = champs_attachments + commentaires_attachments + end end end diff --git a/app/views/instructeurs/dossiers/_header_bottom.html.haml b/app/views/instructeurs/dossiers/_header_bottom.html.haml index 1af884845..76f64be42 100644 --- a/app/views/instructeurs/dossiers/_header_bottom.html.haml +++ b/app/views/instructeurs/dossiers/_header_bottom.html.haml @@ -7,7 +7,7 @@ instructeur_dossier_path(dossier.procedure, dossier), notification: notifications_summary[:demande]) - - if dossier.champs.map(&:piece_justificative_file).flatten.any? + - if @gallery_attachments.present? = dynamic_tab_item(t('views.instructeurs.dossiers.tab_steps.attachments'), pieces_jointes_instructeur_dossier_path(dossier.procedure, dossier), notification: notifications_summary[:pieces_jointes]) From 838dc0a9e3210a7545e463ca53707a612d759f22 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 16 Sep 2024 08:42:41 +0200 Subject: [PATCH 1181/1532] perf(dossier): cache gallery attachments --- .../instructeurs/dossiers_controller.rb | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index 632eb1ac2..b2df3c8ca 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -489,20 +489,24 @@ module Instructeurs end def set_gallery_attachments - @dossier = current_instructeur.dossiers.find(params[:dossier_id]) + gallery_attachments_ids = Rails.cache.fetch([dossier, "gallery_attachments"], expires_in: 10.minutes) do + champs_attachments_ids = dossier + .champs + .where(type: [Champs::PieceJustificativeChamp.name, Champs::TitreIdentiteChamp.name]) + .flat_map(&:piece_justificative_file) + .map(&:id) - champs_attachments = @dossier - .champs - .filter { _1.class.in?([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp]) } - .flat_map(&:piece_justificative_file) + commentaires_attachments_ids = dossier + .commentaires + .includes(piece_jointe_attachments: :blob) + .map(&:piece_jointe) + .map(&:attachments) + .flatten + .map(&:id) - commentaires_attachments = @dossier - .commentaires - .map(&:piece_jointe) - .map(&:attachments) - .flatten - - @gallery_attachments = champs_attachments + commentaires_attachments + champs_attachments_ids + commentaires_attachments_ids + end + @gallery_attachments = ActiveStorage::Attachment.where(id: gallery_attachments_ids) end end end From bca2b79c70d9f9baa9236aa2465409b07085d875 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 16 Sep 2024 09:14:54 +0200 Subject: [PATCH 1182/1532] refactor(dossier): use dossier method to set dossier --- app/controllers/instructeurs/dossiers_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index b2df3c8ca..0b7eb3a74 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -373,7 +373,7 @@ module Instructeurs end def pieces_jointes - @dossier = current_instructeur.dossiers.find(params[:dossier_id]) + @dossier = dossier @pieces_jointes_seen_at = current_instructeur.follows.find_by(dossier: dossier)&.pieces_jointes_seen_at end From 72ae654ce7872d7ad69ab564245bfcf4bbcdced2 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Tue, 1 Oct 2024 16:15:06 +0200 Subject: [PATCH 1183/1532] style(pieces jointes): update UI --- app/assets/stylesheets/gallery.scss | 14 +++++++++----- .../attachment/gallery_item_component.rb | 2 +- .../gallery_item_component.html.haml | 16 +++++++++------- .../attachment/gallery_item_component_spec.rb | 6 +++--- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/app/assets/stylesheets/gallery.scss b/app/assets/stylesheets/gallery.scss index 524535356..3c8331912 100644 --- a/app/assets/stylesheets/gallery.scss +++ b/app/assets/stylesheets/gallery.scss @@ -13,10 +13,6 @@ object-fit: cover; } - .champ-updated { - width: 100%; - } - .thumbnail { position: relative; display: flex; @@ -56,7 +52,15 @@ flex-wrap: wrap; .gallery-item { - margin: 0 2rem 1.5rem 0; + margin: 0 2rem 3rem 0; + + .fr-download { + margin-bottom: 0; + } + + .fr-text--sm { + margin-bottom: 0; + } } } diff --git a/app/components/attachment/gallery_item_component.rb b/app/components/attachment/gallery_item_component.rb index bc2284efc..748387cf4 100644 --- a/app/components/attachment/gallery_item_component.rb +++ b/app/components/attachment/gallery_item_component.rb @@ -62,7 +62,7 @@ class Attachment::GalleryItemComponent < ApplicationComponent def badge_updated_class class_names( "fr-badge fr-badge--sm" => true, - "fr-badge--new" => seen_at.present? && updated_at&.>(seen_at) + "highlighted" => seen_at.present? && updated_at&.>(seen_at) ) end diff --git a/app/components/attachment/gallery_item_component/gallery_item_component.html.haml b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml index 10666cb00..2428d5822 100644 --- a/app/components/attachment/gallery_item_component/gallery_item_component.html.haml +++ b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml @@ -1,10 +1,6 @@ .gallery-item - if !gallery_demande? - .fr-mb-1v - .fr-tag - = origin - .fr-mb-2v.champ-updated{ class: badge_updated_class } - = t(updated? ? '.updated_at' : '.created_at', datetime: helpers.try_format_datetime(updated_at, format: :veryshort)) + %p.fr-tag.fr-tag--sm.fr-mb-3v= origin - if displayable_pdf?(blob) || displayable_image?(blob) = gallery_link(blob) do .thumbnail @@ -12,13 +8,19 @@ .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } Visualiser - if !gallery_demande? - .champ-libelle + .fr-text--sm.fr-mt-2v.fr-mb-1v = libelle.truncate(25) = render Attachment::ShowComponent.new(attachment:, truncate: true, new_tab: gallery_demande?) + - if !gallery_demande? + .fr-mt-2v.fr-mb-2v{ class: badge_updated_class } + = t(updated? ? '.updated_at' : '.created_at', datetime: helpers.try_format_datetime(updated_at, format: :veryshort)) - else .thumbnail = image_tag('apercu-indisponible.png') - if !gallery_demande? - .champ-libelle + .fr-text--sm.fr-mt-2v.fr-mb-1v = libelle.truncate(25) = render Attachment::ShowComponent.new(attachment:, truncate: true, new_tab: gallery_demande?) + - if !gallery_demande? + .fr-mt-2v.fr-mb-2v{ class: badge_updated_class } + = t(updated? ? '.updated_at' : '.created_at', datetime: helpers.try_format_datetime(updated_at, format: :veryshort)) diff --git a/spec/components/attachment/gallery_item_component_spec.rb b/spec/components/attachment/gallery_item_component_spec.rb index 0f200a76f..0806f2910 100644 --- a/spec/components/attachment/gallery_item_component_spec.rb +++ b/spec/components/attachment/gallery_item_component_spec.rb @@ -36,7 +36,7 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do it "displays when gallery item has been added" do expect(subject).to have_text('Ajoutée le') - expect(subject).not_to have_css('.fr-badge--new') + expect(subject).not_to have_css('.highlighted') expect(subject).to have_text(component.helpers.try_format_datetime(attachment.record.created_at, format: :veryshort)) end @@ -78,7 +78,7 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do end it 'displays datetime in the right style' do - expect(subject).to have_css('.fr-badge--new') + expect(subject).to have_css('.highlighted') end end @@ -90,7 +90,7 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do end it 'displays datetime in the right style' do - expect(subject).not_to have_css('.fr-badge--new') + expect(subject).not_to have_css('.highlighted') end end end From a3375be7c5e2afdbf5562e2edafed11b1b3e69b7 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Fri, 11 Oct 2024 12:20:24 +0200 Subject: [PATCH 1184/1532] fix(graphql): n+1 on procedure and france_connect_informations --- app/graphql/types/dossier_type.rb | 18 ++++-------------- app/models/dossier.rb | 2 +- app/models/dossier_preloader.rb | 2 +- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/app/graphql/types/dossier_type.rb b/app/graphql/types/dossier_type.rb index 3236b3a4a..4a90b7cee 100644 --- a/app/graphql/types/dossier_type.rb +++ b/app/graphql/types/dossier_type.rb @@ -105,14 +105,10 @@ module Types def connection_usager if object.user_deleted? :deleted + elsif object.user_from_france_connect? + :france_connect else - user_loader.then do |_user| - if object.user_from_france_connect? - :france_connect - else - :password - end - end + :password end end @@ -120,7 +116,7 @@ module Types if object.user_deleted? { email: object.user_email_for(:display), id: '' } else - user_loader + object.user end end @@ -221,11 +217,5 @@ module Types def self.authorized?(object, context) context.authorized_demarche?(object.revision.procedure) end - - private - - def user_loader - Loaders::Record.for(User, includes: :france_connect_informations).load(object.user_id) - end end end diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 9a980ae60..0d78d496d 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -368,7 +368,7 @@ class Dossier < ApplicationRecord .where.not(user: users_who_submitted) end - scope :for_api_v2, -> { includes(:attestation_template, revision: [procedure: [:administrateurs]], etablissement: [], individual: [], traitement: []) } + scope :for_api_v2, -> { includes(:attestation_template, revision: [procedure: [:administrateurs]], etablissement: [], individual: [], traitement: [], procedure: [], user: [:france_connect_informations]) } scope :with_notifications, -> do joins(:follows) diff --git a/app/models/dossier_preloader.rb b/app/models/dossier_preloader.rb index 23b18fd41..9b27f47cc 100644 --- a/app/models/dossier_preloader.rb +++ b/app/models/dossier_preloader.rb @@ -39,7 +39,7 @@ class DossierPreloader def revisions(pj_template: false) @revisions ||= ProcedureRevision.where(id: @dossiers.pluck(:revision_id).uniq) - .includes(revision_types_de_champ: { parent: :type_de_champ }, types_de_champ_public: [], types_de_champ_private: [], types_de_champ: pj_template ? { piece_justificative_template_attachment: :blob } : []) + .includes(procedure: [], revision_types_de_champ: { parent: :type_de_champ }, types_de_champ_public: [], types_de_champ_private: [], types_de_champ: pj_template ? { piece_justificative_template_attachment: :blob } : []) .index_by(&:id) end From 3397beb71dc4837eaa5d7866818a88797a994a06 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 11 Oct 2024 10:14:03 +0200 Subject: [PATCH 1185/1532] refactor(gallery test): use freeze_time instead of Timecop --- .../attachment/gallery_item_component_spec.rb | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/spec/components/attachment/gallery_item_component_spec.rb b/spec/components/attachment/gallery_item_component_spec.rb index 0806f2910..382c6cf10 100644 --- a/spec/components/attachment/gallery_item_component_spec.rb +++ b/spec/components/attachment/gallery_item_component_spec.rb @@ -10,14 +10,12 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do let(:filename) { attachment.blob.filename.to_s } let(:gallery_demande) { false } let(:seen_at) { nil } - let(:now) { Time.zone.parse('01/01/2010') } + let(:now) { Time.zone.now } let(:component) { described_class.new(attachment: attachment, gallery_demande:, seen_at: seen_at) } subject { render_inline(component).to_html } - after { Timecop.return } - context "when attachment is from a piece justificative champ" do let(:champ) { dossier.champs.first } let(:libelle) { champ.libelle } @@ -71,10 +69,10 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do end context "when instructeur has not seen it yet" do - let(:seen_at) { Timecop.freeze(now - 1.day) } + let(:seen_at) { now - 1.day } before do - attachment.blob.update(created_at: Timecop.freeze(now)) + attachment.blob.update(created_at: now) end it 'displays datetime in the right style' do @@ -83,9 +81,10 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do end context "when instructeur has already seen it" do - let!(:seen_at) { Timecop.freeze(now) } + let!(:seen_at) { now } before do + freeze_time attachment.blob.touch(:created_at) end From 3b054c5369c9273b2b6f33aadd0d5ee6f260eb5a Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 11 Oct 2024 11:52:30 +0200 Subject: [PATCH 1186/1532] refactor(views): do not use instance variable in views --- app/views/instructeurs/dossiers/_header.html.haml | 2 +- app/views/instructeurs/dossiers/_header_bottom.html.haml | 2 +- app/views/instructeurs/dossiers/annotations_privees.html.haml | 2 +- app/views/instructeurs/dossiers/avis.html.haml | 2 +- app/views/instructeurs/dossiers/avis_new.html.haml | 2 +- app/views/instructeurs/dossiers/messagerie.html.haml | 2 +- app/views/instructeurs/dossiers/personnes_impliquees.html.haml | 2 +- app/views/instructeurs/dossiers/pieces_jointes.html.haml | 2 +- app/views/instructeurs/dossiers/reaffectation.html.haml | 2 +- app/views/instructeurs/dossiers/show.html.haml | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/views/instructeurs/dossiers/_header.html.haml b/app/views/instructeurs/dossiers/_header.html.haml index 90e146b58..6432f87aa 100644 --- a/app/views/instructeurs/dossiers/_header.html.haml +++ b/app/views/instructeurs/dossiers/_header.html.haml @@ -7,7 +7,7 @@ .sub-header = render partial: 'instructeurs/dossiers/header_top', locals: { dossier: } - = render partial: 'instructeurs/dossiers/header_bottom', locals: { dossier: } + = render partial: 'instructeurs/dossiers/header_bottom', locals: { dossier:, gallery_attachments: } .fr-container .print-header diff --git a/app/views/instructeurs/dossiers/_header_bottom.html.haml b/app/views/instructeurs/dossiers/_header_bottom.html.haml index 76f64be42..b2cba4d8f 100644 --- a/app/views/instructeurs/dossiers/_header_bottom.html.haml +++ b/app/views/instructeurs/dossiers/_header_bottom.html.haml @@ -7,7 +7,7 @@ instructeur_dossier_path(dossier.procedure, dossier), notification: notifications_summary[:demande]) - - if @gallery_attachments.present? + - if gallery_attachments.present? = dynamic_tab_item(t('views.instructeurs.dossiers.tab_steps.attachments'), pieces_jointes_instructeur_dossier_path(dossier.procedure, dossier), notification: notifications_summary[:pieces_jointes]) diff --git a/app/views/instructeurs/dossiers/annotations_privees.html.haml b/app/views/instructeurs/dossiers/annotations_privees.html.haml index 9b556be5c..f603d753a 100644 --- a/app/views/instructeurs/dossiers/annotations_privees.html.haml +++ b/app/views/instructeurs/dossiers/annotations_privees.html.haml @@ -1,6 +1,6 @@ - content_for(:title, "Annotations privées · Dossier nº #{@dossier.id} (#{@dossier.owner_name})") -= render partial: "header", locals: { dossier: @dossier } += render partial: "header", locals: { dossier: @dossier, gallery_attachments: @gallery_attachments } #dossier-annotations-privees .fr-container diff --git a/app/views/instructeurs/dossiers/avis.html.haml b/app/views/instructeurs/dossiers/avis.html.haml index 46e608f9e..172816591 100644 --- a/app/views/instructeurs/dossiers/avis.html.haml +++ b/app/views/instructeurs/dossiers/avis.html.haml @@ -1,6 +1,6 @@ - content_for(:title, "Avis · Dossier nº #{@dossier.id} (#{@dossier.owner_name})") -= render partial: "header", locals: { dossier: @dossier } += render partial: "header", locals: { dossier: @dossier, gallery_attachments: @gallery_attachments } .container .fr-grid-row diff --git a/app/views/instructeurs/dossiers/avis_new.html.haml b/app/views/instructeurs/dossiers/avis_new.html.haml index 143fe618d..6499ad2be 100644 --- a/app/views/instructeurs/dossiers/avis_new.html.haml +++ b/app/views/instructeurs/dossiers/avis_new.html.haml @@ -1,6 +1,6 @@ - content_for(:title, "Avis · Dossier nº #{@dossier.id} (#{@dossier.owner_name})") -= render partial: "header", locals: { dossier: @dossier } += render partial: "header", locals: { dossier: @dossier, gallery_attachments: @gallery_attachments } .container .fr-grid-row diff --git a/app/views/instructeurs/dossiers/messagerie.html.haml b/app/views/instructeurs/dossiers/messagerie.html.haml index f98fbde63..212d521c8 100644 --- a/app/views/instructeurs/dossiers/messagerie.html.haml +++ b/app/views/instructeurs/dossiers/messagerie.html.haml @@ -1,5 +1,5 @@ - content_for(:title, "Messagerie · Dossier nº #{@dossier.id} (#{@dossier.owner_name})") -= render partial: "header", locals: { dossier: @dossier } += render partial: "header", locals: { dossier: @dossier, gallery_attachments: @gallery_attachments } = render partial: "shared/dossiers/messagerie", locals: { dossier: @dossier, connected_user: current_instructeur, messagerie_seen_at: @messagerie_seen_at , new_commentaire: @commentaire, form_url: commentaire_instructeur_dossier_path(@dossier.procedure, @dossier) } diff --git a/app/views/instructeurs/dossiers/personnes_impliquees.html.haml b/app/views/instructeurs/dossiers/personnes_impliquees.html.haml index b90952e91..30d3224ec 100644 --- a/app/views/instructeurs/dossiers/personnes_impliquees.html.haml +++ b/app/views/instructeurs/dossiers/personnes_impliquees.html.haml @@ -1,6 +1,6 @@ - content_for(:title, "Personnes impliquées · Dossier nº #{@dossier.id} (#{@dossier.owner_name})") -= render partial: "header", locals: { dossier: @dossier } += render partial: "header", locals: { dossier: @dossier, gallery_attachments: @gallery_attachments } .personnes-impliquees.container = render partial: 'instructeurs/dossiers/envoyer_dossier_block', locals: { dossier: @dossier, potential_recipients: @potential_recipients } diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml index bfc0a0bc0..527b2c65c 100644 --- a/app/views/instructeurs/dossiers/pieces_jointes.html.haml +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -1,6 +1,6 @@ - content_for(:title, "Pièces jointes") -= render partial: "header", locals: { dossier: @dossier } += render partial: "header", locals: { dossier: @dossier, gallery_attachments: @gallery_attachments } .fr-container .gallery.gallery-pieces-jointes{ "data-controller": "lightbox" } diff --git a/app/views/instructeurs/dossiers/reaffectation.html.haml b/app/views/instructeurs/dossiers/reaffectation.html.haml index 5b5307592..364b5415a 100644 --- a/app/views/instructeurs/dossiers/reaffectation.html.haml +++ b/app/views/instructeurs/dossiers/reaffectation.html.haml @@ -1,6 +1,6 @@ - content_for(:title, "Réaffectation · Dossier nº #{@dossier.id} (#{@dossier.owner_name})") -= render partial: "header", locals: { dossier: @dossier } += render partial: "header", locals: { dossier: @dossier, gallery_attachments: @gallery_attachments } .container.groupe-instructeur diff --git a/app/views/instructeurs/dossiers/show.html.haml b/app/views/instructeurs/dossiers/show.html.haml index b93389716..8c9ad55e4 100644 --- a/app/views/instructeurs/dossiers/show.html.haml +++ b/app/views/instructeurs/dossiers/show.html.haml @@ -1,6 +1,6 @@ - content_for(:title, "Demande · Dossier nº #{@dossier.id} (#{@dossier.owner_name})") -= render partial: "header", locals: { dossier: @dossier } += render partial: "header", locals: { dossier: @dossier, gallery_attachments: @gallery_attachments } - if @dossier.etablissement&.as_degraded_mode? From 2222b6ff2da214ef5729fea1756065f80de399be Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Fri, 11 Oct 2024 16:13:28 +0200 Subject: [PATCH 1187/1532] [#10921] Extend error handling to cover new error codes --- app/lib/api_entreprise/api.rb | 2 +- .../api_entreprise/error_code_02002.json | 3 +++ .../api_entreprise/error_code_03002.json | 3 +++ spec/lib/api_entreprise/api_spec.rb | 20 +++++++++++++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 spec/fixtures/files/api_entreprise/error_code_02002.json create mode 100644 spec/fixtures/files/api_entreprise/error_code_03002.json diff --git a/app/lib/api_entreprise/api.rb b/app/lib/api_entreprise/api.rb index 8b7fb819a..0c6b93bc4 100644 --- a/app/lib/api_entreprise/api.rb +++ b/app/lib/api_entreprise/api.rb @@ -141,7 +141,7 @@ class APIEntreprise::API def service_unavailable?(response) return true if response.code == 503 if response.code == 502 || response.code == 504 - parse_response_errors(response).any? { _1.is_a?(Hash) && ["01000", "01001", "01002"].include?(_1[:code]) } + parse_response_errors(response).any? { _1.is_a?(Hash) && ["01000", "01001", "01002", "02002", "03002"].include?(_1[:code]) } end end diff --git a/spec/fixtures/files/api_entreprise/error_code_02002.json b/spec/fixtures/files/api_entreprise/error_code_02002.json new file mode 100644 index 000000000..4faff400e --- /dev/null +++ b/spec/fixtures/files/api_entreprise/error_code_02002.json @@ -0,0 +1,3 @@ +{ + "errors": [{ "code": "02002" }] +} diff --git a/spec/fixtures/files/api_entreprise/error_code_03002.json b/spec/fixtures/files/api_entreprise/error_code_03002.json new file mode 100644 index 000000000..b6b4486dc --- /dev/null +++ b/spec/fixtures/files/api_entreprise/error_code_03002.json @@ -0,0 +1,3 @@ +{ + "errors": [{ "code": "03002" }] +} diff --git a/spec/lib/api_entreprise/api_spec.rb b/spec/lib/api_entreprise/api_spec.rb index fc9b4fbd5..e13d4f305 100644 --- a/spec/lib/api_entreprise/api_spec.rb +++ b/spec/lib/api_entreprise/api_spec.rb @@ -54,6 +54,26 @@ describe APIEntreprise::API do end end + context 'when the service reponds with 02002 code' do + let(:siren) { '111111111' } + let(:status) { 504 } + let(:body) { Rails.root.join('spec/fixtures/files/api_entreprise/error_code_02002.json').read } + + it 'raises APIEntreprise::API::Error::RequestFailed' do + expect { subject }.to raise_error(APIEntreprise::API::Error::ServiceUnavailable) + end + end + + context 'when the service reponds with 03002 code' do + let(:siren) { '111111111' } + let(:status) { 504 } + let(:body) { Rails.root.join('spec/fixtures/files/api_entreprise/error_code_03002.json').read } + + it 'raises APIEntreprise::API::Error::RequestFailed' do + expect { subject }.to raise_error(APIEntreprise::API::Error::ServiceUnavailable) + end + end + context 'when siren does not exist' do let(:siren) { '111111111' } let(:status) { 404 } From b0278397219b55c5cdf2a73c6a63c303f6f5c5a3 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Fri, 11 Oct 2024 17:13:53 +0200 Subject: [PATCH 1188/1532] =?UTF-8?q?fix(graphql):=20parse=5Fetablissement?= =?UTF-8?q?=5Faddress=20is=20slow=20(300ms)=20=E2=80=93=20bypasse=20it=20w?= =?UTF-8?q?hen=20possible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/graphql/types/personne_morale_type.rb | 7 +++++-- .../api/v2/graphql_controller_stored_queries_spec.rb | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/graphql/types/personne_morale_type.rb b/app/graphql/types/personne_morale_type.rb index cfa63db30..849e43f6e 100644 --- a/app/graphql/types/personne_morale_type.rb +++ b/app/graphql/types/personne_morale_type.rb @@ -124,8 +124,11 @@ module Types field :complement_adresse, String, null: true, deprecation_reason: "Utilisez le champ `address` à la place." def address - APIGeoService - .parse_etablissement_address(object) + address = object.champ&.value_json + if address.blank? || !address.key?("departement_code") + address = APIGeoService.parse_etablissement_address(object) + end + address .merge(label: object.adresse, type: "housenumber") .with_indifferent_access end diff --git a/spec/controllers/api/v2/graphql_controller_stored_queries_spec.rb b/spec/controllers/api/v2/graphql_controller_stored_queries_spec.rb index f3f6c11b9..db870b996 100644 --- a/spec/controllers/api/v2/graphql_controller_stored_queries_spec.rb +++ b/spec/controllers/api/v2/graphql_controller_stored_queries_spec.rb @@ -122,8 +122,9 @@ describe API::V2::GraphqlController do end context 'with entreprise' do + let(:types_de_champ_public) { [{ type: :siret }] } let(:procedure) { create(:procedure, :published, :with_service, administrateurs: [admin], types_de_champ_public:) } - let(:dossier) { create(:dossier, :en_construction, :with_entreprise, procedure: procedure) } + let(:dossier) { create(:dossier, :en_construction, :with_entreprise, :with_populated_champs, procedure: procedure) } it { expect(gql_errors).to be_nil From f025e08336d0d25390304512b3d97a38d8df3151 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Sun, 29 Sep 2024 17:11:10 +0200 Subject: [PATCH 1189/1532] Ajoute la table procedure tags et la liaison entre procedure et procedures tags --- .../manager/procedure_tags_controller.rb | 6 ++ app/dashboards/procedure_tag_dashboard.rb | 63 +++++++++++++++++++ app/models/procedure.rb | 1 + app/models/procedure_tag.rb | 7 +++ config/routes.rb | 2 + .../20240929124802_create_procedure_tags.rb | 12 ++++ ...te_join_table_procedures_procedure_tags.rb | 10 +++ db/schema.rb | 17 ++++- 8 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 app/controllers/manager/procedure_tags_controller.rb create mode 100644 app/dashboards/procedure_tag_dashboard.rb create mode 100644 app/models/procedure_tag.rb create mode 100644 db/migrate/20240929124802_create_procedure_tags.rb create mode 100644 db/migrate/20240929141825_create_join_table_procedures_procedure_tags.rb diff --git a/app/controllers/manager/procedure_tags_controller.rb b/app/controllers/manager/procedure_tags_controller.rb new file mode 100644 index 000000000..b314bd71d --- /dev/null +++ b/app/controllers/manager/procedure_tags_controller.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Manager + class ProcedureTagsController < Manager::ApplicationController + end +end diff --git a/app/dashboards/procedure_tag_dashboard.rb b/app/dashboards/procedure_tag_dashboard.rb new file mode 100644 index 000000000..0d898a750 --- /dev/null +++ b/app/dashboards/procedure_tag_dashboard.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "administrate/base_dashboard" + +class ProcedureTagDashboard < Administrate::BaseDashboard + # ATTRIBUTE_TYPES + # a hash that describes the type of each of the model's fields. + # + # Each different type represents an Administrate::Field object, + # which determines how the attribute is displayed + # on pages throughout the dashboard. + ATTRIBUTE_TYPES = { + id: Field::Number, + name: Field::String, + created_at: Field::DateTime, + updated_at: Field::DateTime + }.freeze + + # COLLECTION_ATTRIBUTES + # an array of attributes that will be displayed on the model's index page. + # + # By default, it's limited to four items to reduce clutter on index pages. + # Feel free to add, remove, or rearrange items. + COLLECTION_ATTRIBUTES = [ + :id, + :name + ].freeze + + # SHOW_PAGE_ATTRIBUTES + # an array of attributes that will be displayed on the model's show page. + SHOW_PAGE_ATTRIBUTES = [ + :id, + :name, + :created_at, + :updated_at + ].freeze + + # FORM_ATTRIBUTES + # an array of attributes that will be displayed + # on the model's form (`new` and `edit`) pages. + FORM_ATTRIBUTES = [ + :name + ].freeze + + # COLLECTION_FILTERS + # a hash that defines filters that can be used while searching via the search + # field of the dashboard. + # + # For example to add an option to search for open resources by typing "open:" + # in the search field: + # + # COLLECTION_FILTERS = { + # open: ->(resources) { resources.where(open: true) } + # }.freeze + COLLECTION_FILTERS = {}.freeze + + # Overwrite this method to customize how procedure tags are displayed + # across all pages of the admin dashboard. + # + def display_resource(procedure_tag) + "ProcedureTag ##{procedure_tag.id}" + end +end diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 10377d4ff..d959e4b39 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -56,6 +56,7 @@ class Procedure < ApplicationRecord belongs_to :service, optional: true belongs_to :zone, optional: true has_and_belongs_to_many :zones + has_and_belongs_to_many :procedure_tags has_many :bulk_messages, dependent: :destroy diff --git a/app/models/procedure_tag.rb b/app/models/procedure_tag.rb new file mode 100644 index 000000000..37d770b43 --- /dev/null +++ b/app/models/procedure_tag.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ProcedureTag < ApplicationRecord + has_and_belongs_to_many :procedures + + validates :name, presence: true, uniqueness: { case_sensitive: false } +end diff --git a/config/routes.rb b/config/routes.rb index d414b1cd5..fc64cd041 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -33,6 +33,8 @@ Rails.application.routes.draw do resources :administrateur_confirmations, only: [:new, :create] end + resources :procedure_tags, only: [:index, :show, :new, :create, :edit, :update, :destroy] + resources :archives, only: [:index, :show] resources :dossiers, only: [:index, :show] do diff --git a/db/migrate/20240929124802_create_procedure_tags.rb b/db/migrate/20240929124802_create_procedure_tags.rb new file mode 100644 index 000000000..96466ba45 --- /dev/null +++ b/db/migrate/20240929124802_create_procedure_tags.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateProcedureTags < ActiveRecord::Migration[7.0] + def change + create_table :procedure_tags do |t| + t.string :name, null: false + t.timestamps + end + + add_index :procedure_tags, :name, unique: true + end +end diff --git a/db/migrate/20240929141825_create_join_table_procedures_procedure_tags.rb b/db/migrate/20240929141825_create_join_table_procedures_procedure_tags.rb new file mode 100644 index 000000000..5cd970db0 --- /dev/null +++ b/db/migrate/20240929141825_create_join_table_procedures_procedure_tags.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class CreateJoinTableProceduresProcedureTags < ActiveRecord::Migration[7.0] + def change + create_join_table :procedures, :procedure_tags do |t| + t.index [:procedure_id, :procedure_tag_id], name: 'index_procedures_tags_on_procedure_id_and_tag_id' + t.index [:procedure_tag_id, :procedure_id], name: 'index_procedures_tags_on_tag_id_and_procedure_id' + end + end +end diff --git a/db/schema.rb b/db/schema.rb index e55fcb86b..f8f7dca89 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[7.0].define(version: 2024_09_23_125619) do +ActiveRecord::Schema[7.0].define(version: 2024_09_29_141825) do # These are extensions that must be enabled in order to support this database enable_extension "pg_buffercache" enable_extension "pg_stat_statements" @@ -913,6 +913,21 @@ ActiveRecord::Schema[7.0].define(version: 2024_09_23_125619) do t.index ["procedure_id"], name: "index_procedure_revisions_on_procedure_id" end + create_table "procedure_tags", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "description" + t.string "name", null: false + t.datetime "updated_at", null: false + t.index ["name"], name: "index_procedure_tags_on_name", unique: true + end + + create_table "procedure_tags_procedures", id: false, force: :cascade do |t| + t.bigint "procedure_id", null: false + t.bigint "procedure_tag_id", null: false + t.index ["procedure_id", "procedure_tag_id"], name: "index_procedures_tags_on_procedure_id_and_tag_id" + t.index ["procedure_tag_id", "procedure_id"], name: "index_procedures_tags_on_tag_id_and_procedure_id" + end + create_table "procedures", id: :serial, force: :cascade do |t| t.string "aasm_state", default: "brouillon" t.boolean "accuse_lecture", default: false, null: false From fd7dcc704811849a75b4bb236bab515f829fe4aa Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Tue, 1 Oct 2024 09:44:33 +0200 Subject: [PATCH 1190/1532] =?UTF-8?q?Les=20admins=20peuvent=20associer=20u?= =?UTF-8?q?ne=20d=C3=A9marche=20=C3=A0=20une=20th=C3=A9matique=20pr=C3=A9d?= =?UTF-8?q?=C3=A9finie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../administrateurs/procedures_controller.rb | 19 +++++++++++++++++-- .../procedures/_informations.html.haml | 8 ++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/app/controllers/administrateurs/procedures_controller.rb b/app/controllers/administrateurs/procedures_controller.rb index 7660194c1..d2db31b2b 100644 --- a/app/controllers/administrateurs/procedures_controller.rb +++ b/app/controllers/administrateurs/procedures_controller.rb @@ -476,7 +476,16 @@ module Administrateurs procedures_result = procedures_result.where(procedures_zones: { zone_id: filter.zone_ids }) if filter.zone_ids.present? procedures_result = procedures_result.where(hidden_at_as_template: nil) procedures_result = procedures_result.where(aasm_state: filter.statuses) if filter.statuses.present? - procedures_result = procedures_result.where("tags @> ARRAY[?]::text[]", filter.tags) if filter.tags.present? + if filter.tags.present? + tag_ids = ProcedureTag.where(name: filter.tags).pluck(:id).flatten + + if tag_ids.any? + procedures_result = procedures_result + .joins(:procedure_tags) + .where(procedure_tags: { id: tag_ids }) + .distinct + end + end procedures_result = procedures_result.where(template: true) if filter.template? procedures_result = procedures_result.where(published_at: filter.from_publication_date..) if filter.from_publication_date.present? procedures_result = procedures_result.where(service: service) if filter.service_siret.present? @@ -532,7 +541,7 @@ module Administrateurs :lien_dpo, :opendata, :procedure_expires_when_termine_enabled, - { zone_ids: [], tags: [] } + { zone_ids: [], procedure_tag_names: [] } ] editable_params << :piece_justificative_multiple if @procedure && !@procedure.piece_justificative_multiple? @@ -545,6 +554,12 @@ module Administrateurs if permited_params[:auto_archive_on].present? permited_params[:auto_archive_on] = Date.parse(permited_params[:auto_archive_on]) + 1.day end + + if permited_params[:procedure_tag_names].present? + tag_ids = ProcedureTag.where(name: permited_params[:procedure_tag_names]).pluck(:id) + permited_params[:procedure_tag_ids] = tag_ids + permited_params.delete(:procedure_tag_names) + end permited_params end diff --git a/app/views/administrateurs/procedures/_informations.html.haml b/app/views/administrateurs/procedures/_informations.html.haml index 2700aec4f..27acea4b5 100644 --- a/app/views/administrateurs/procedures/_informations.html.haml +++ b/app/views/administrateurs/procedures/_informations.html.haml @@ -125,11 +125,11 @@ %react-fragment = render ReactComponent.new "ComboBox/MultiComboBox", id: "procedure_tags_combo", - items: Procedure.tags, - selected_keys: @procedure.tags, - name: 'procedure[tags][]', + items: ProcedureTag.order(:name).pluck(:name), + selected_keys: @procedure.procedure_tags.pluck(:name), + name: 'procedure[procedure_tag_names][]', value_separator: ',|;', - allows_custom_value: true, + allows_custom_value: false, 'aria-label': 'Tags', 'aria-describedby': 'procedure-tags' From 1c651b4c93567a2f5893f21113af3568a0e623a1 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Tue, 1 Oct 2024 11:19:03 +0200 Subject: [PATCH 1191/1532] =?UTF-8?q?Les=20admins=20ont=20la=20possibilit?= =?UTF-8?q?=C3=A9=20de=20filtrer=20les=20d=C3=A9marches=20par=20liste=20pr?= =?UTF-8?q?=C3=A9d=C3=A9finie=20dans=20la=20page=20toutes=20les=20d=C3=A9m?= =?UTF-8?q?arches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/layouts/all.html.haml | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/app/views/layouts/all.html.haml b/app/views/layouts/all.html.haml index 44d69dacf..b7809a62b 100644 --- a/app/views/layouts/all.html.haml +++ b/app/views/layouts/all.html.haml @@ -25,6 +25,21 @@ %span.fr-icon-arrow-go-back-line Réinitialiser %ul + %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } + .fr-mb-1w + %button{ 'data-action': 'expand#toggle' } + %span.fr-icon-add-line.fr-icon--sm.fr-mr-1w.fr-text-action-high--blue-france{ 'aria-hidden': 'true', 'data-expand-target': 'icon' } + Thématique + .fr-ml-1w.hidden{ 'data-expand-target': 'content' } + %div + = f.search_field :tags, placeholder: 'Choisissez un thème', list: 'tags_list', class: 'fr-input', data: { no_autosubmit: 'input', turbo_force: :server }, multiple: true + %datalist#tags_list + - ProcedureTag.order(:name).each do |tag| + %option{ value: tag.name, data: { id: tag.id } } + - if @filter.tags.present? + - @filter.tags.each do |tag| + = f.hidden_field :tags, value: tag, multiple: true, id: "tag-#{tag.tr(' ', '_')}" + %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } .fr-mb-1w %button{ 'data-action': 'expand#toggle' } @@ -105,22 +120,6 @@ = b.check_box(checked: @filter.status_filtered?(b.value)) = b.label(class: 'fr-label') { t b.text, scope: 'activerecord.attributes.procedure.aasm_state' } - - %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } - .fr-mb-1w - %button{ 'data-action': 'expand#toggle' } - %span.fr-icon-add-line.fr-icon--sm.fr-mr-1w.fr-text-action-high--blue-france{ 'aria-hidden': 'true', 'data-expand-target': 'icon' } - Thématique - .fr-ml-1w.hidden{ 'data-expand-target': 'content' } - %div - = f.search_field :tags, placeholder: 'Choisissez un thème', list: 'tags_list', class: 'fr-input', data: { no_autosubmit: 'input', turbo_force: :server }, multiple: true - %datalist#tags_list - - Procedure.tags.each do |tag| - %option{ value: tag } - - if @filter.tags.present? - - @filter.tags.each do |tag| - = f.hidden_field :tags, value: tag, multiple: true, id: "tag-#{tag.tr(' ', '_')}" - .fr-col-9 = yield(:results) = render template: 'layouts/application' From 953ccbcfb613bc8895aa98a2dd9f7817d2dca1d6 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Thu, 3 Oct 2024 14:40:41 +0200 Subject: [PATCH 1192/1532] =?UTF-8?q?Mise=20=C3=A0=20jour=20des=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../procedures_controller_spec.rb | 32 ++++++++++++------- .../administrateurs/procedure_update_spec.rb | 21 ++++-------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/spec/controllers/administrateurs/procedures_controller_spec.rb b/spec/controllers/administrateurs/procedures_controller_spec.rb index 3658a0dd6..5dfd47e12 100644 --- a/spec/controllers/administrateurs/procedures_controller_spec.rb +++ b/spec/controllers/administrateurs/procedures_controller_spec.rb @@ -15,7 +15,8 @@ describe Administrateurs::ProceduresController, type: :controller do let(:lien_site_web) { 'http://mon-site.gouv.fr' } let(:zone) { create(:zone) } let(:zone_ids) { [zone.id] } - let(:tags) { ["planete", "environnement"] } + let!(:tag1) { ProcedureTag.create(name: 'Aao') } + let!(:tag2) { ProcedureTag.create(name: 'Accompagnement') } describe '#apercu' do subject { get :apercu, params: { id: procedure.id } } @@ -64,7 +65,7 @@ describe Administrateurs::ProceduresController, type: :controller do monavis_embed: monavis_embed, zone_ids: zone_ids, lien_site_web: lien_site_web, - tags: tags + procedure_tag_names: ['Aao', 'Accompagnement'] } } @@ -278,21 +279,29 @@ describe Administrateurs::ProceduresController, type: :controller do end context 'with specific tag' do - let!(:tags_procedure) { create(:procedure, :published, tags: ['environnement', 'diplomatie']) } + let!(:tag_environnement) { ProcedureTag.create(name: 'environnement') } + let!(:tag_diplomatie) { ProcedureTag.create(name: 'diplomatie') } + let!(:tag_football) { ProcedureTag.create(name: 'football') } + + let!(:procedure) do + procedure = create(:procedure, :published) + procedure.procedure_tags << [tag_environnement, tag_diplomatie] + procedure + end it 'returns procedure who contains at least one tag included in params' do - get :all, params: { tags: ['environnement'] } - expect(assigns(:procedures).any? { |p| p.id == tags_procedure.id }).to be_truthy + get :all, params: { procedure_tag_names: ['environnement'] } + expect(assigns(:procedures).any? { |p| p.id == procedure.id }).to be_truthy end it 'returns procedures who contains all tags included in params' do - get :all, params: { tags: ['environnement', 'diplomatie'] } - expect(assigns(:procedures).any? { |p| p.id == tags_procedure.id }).to be_truthy + get :all, params: { procedure_tag_names: ['environnement', 'diplomatie'] } + expect(assigns(:procedures).any? { |p| p.id == procedure.id }).to be_truthy end - it 'does not returns the procedure' do - get :all, params: { tags: ['environnement', 'diplomatie', 'football'] } - expect(assigns(:procedures).any? { |p| p.id == tags_procedure.id }).to be_falsey + it 'returns the procedure when at least one tag is include' do + get :all, params: { procedure_tag_names: ['environnement', 'diplomatie', 'football'] } + expect(assigns(:procedures).any? { |p| p.id == procedure.id }).to be_truthy end end @@ -495,8 +504,7 @@ describe Administrateurs::ProceduresController, type: :controller do expect(subject.organisation).to eq(organisation) expect(subject.administrateurs).to eq([admin]) expect(subject.duree_conservation_dossiers_dans_ds).to eq(duree_conservation_dossiers_dans_ds) - expect(subject.tags).to eq(["planete", "environnement"]) - + expect(subject.procedure_tags.pluck(:name)).to match_array(['Aao', 'Accompagnement']) expect(response).to redirect_to(champs_admin_procedure_path(Procedure.last)) expect(flash[:notice]).to be_present end diff --git a/spec/system/administrateurs/procedure_update_spec.rb b/spec/system/administrateurs/procedure_update_spec.rb index d4f2319cc..bf6dae878 100644 --- a/spec/system/administrateurs/procedure_update_spec.rb +++ b/spec/system/administrateurs/procedure_update_spec.rb @@ -58,23 +58,16 @@ describe 'Administrateurs can edit procedures', js: true do end context 'when we associate tags' do - scenario 'the administrator can edit and persist the tags' do - procedure.update!(tags: ['social']) + let!(:social_tag) { ProcedureTag.create(name: 'social') } + let!(:planete_tag) { ProcedureTag.create(name: 'planete') } + + scenario 'the tags are persisted when not interacting with the tags combobox' do + procedure.procedure_tags << social_tag visit edit_admin_procedure_path(procedure) - select_combobox('procedure_tags_combo', 'planete', custom_value: true) + click_on 'Enregistrer' - - expect(procedure.reload.tags).to eq(['social', 'planete']) - end - - scenario 'the tags are persisted when non interacting with the tags combobox' do - procedure.update!(tags: ['social']) - - visit edit_admin_procedure_path(procedure) - click_on 'Enregistrer' - - expect(procedure.reload.tags).to eq(['social']) + expect(procedure.procedure_tags.pluck(:name)).to match_array(['social']) end end From 2c857572898761a8c191013cfd47c89a094bee5e Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Wed, 9 Oct 2024 09:34:09 +0200 Subject: [PATCH 1193/1532] =?UTF-8?q?Cr=C3=A9=C3=A9=20la=20liste=20de=20no?= =?UTF-8?q?uveaux=20tags=20en=20base=20et=20les=20associe=20aux=20d=C3=A9m?= =?UTF-8?q?arches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../maintenance/create_procedure_tags_task.rb | 110 ++++++++++++++++++ .../create_procedure_tags_task_spec.rb | 32 +++++ 2 files changed, 142 insertions(+) create mode 100644 app/tasks/maintenance/create_procedure_tags_task.rb create mode 100644 spec/tasks/maintenance/create_procedure_tags_task_spec.rb diff --git a/app/tasks/maintenance/create_procedure_tags_task.rb b/app/tasks/maintenance/create_procedure_tags_task.rb new file mode 100644 index 000000000..7b8c7c1ab --- /dev/null +++ b/app/tasks/maintenance/create_procedure_tags_task.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +# this task is used to create the procedure_tags and backfill the procedures that have the tag in their tags array + +module Maintenance + class CreateProcedureTagsTask < MaintenanceTasks::Task + include RunnableOnDeployConcern + include StatementsHelpersConcern + run_on_first_deploy + + def collection + [ + "Aap", + "Accompagnement", + "Action sociale", + "Adeli", + "Affectation", + "Agrément", + "Agriculture", + "agroécologie", + "Aide aux entreprises", + "Aide financière", + "Appel à manifestation d'intérêt", + "AMI", + "Animaux", + "Appel à projets", + "Association", + "Auto-école", + "Autorisation", + "Autorisation d'exercer", + "Bilan", + "Biodiversité", + "Candidature", + "Cerfa", + "Chasse", + "Cinéma", + "Cmg", + "Collectivé territoriale", + "Collège", + "Convention", + "Covid", + "Culture", + "Dérogation", + "Diplôme", + "Drone", + "DSDEN", + "Eau", + "Ecoles", + "Education", + "Elections", + "Energie", + "Enseignant", + "ENT", + "Environnement", + "Étrangers", + "Formation", + "FPRNM", + "Funéraire", + "Handicap", + "Hygiène", + "Industrie", + "innovation", + "Inscription", + "Logement", + "Lycée", + "Manifestation", + "Médicament", + "Micro-crèche", + "MODELE DS", + "Numérique", + "Permis", + "Pompiers", + "Préfecture", + "Professionels de santé", + "Recrutement", + "Rh", + "Santé", + "Scolaire", + "SDIS", + "Sécurité", + "Sécurité routière", + "Sécurité sociale", + "Séjour", + "Service civique", + "Subvention", + "Supérieur", + "Taxi", + "Télétravail", + "Tirs", + "Transition écologique", + "Transport", + "Travail", + "Université", + "Urbanisme" + ] + end + + def process(tag) + procedure_tag = ProcedureTag.find_or_create_by(name: tag) + + Procedure.where("? ILIKE ANY(tags)", tag).find_each(batch_size: 500) do |procedure| + procedure.procedure_tags << procedure_tag unless procedure.procedure_tags.include?(procedure_tag) + end + end + + def count + collection.size + end + end +end diff --git a/spec/tasks/maintenance/create_procedure_tags_task_spec.rb b/spec/tasks/maintenance/create_procedure_tags_task_spec.rb new file mode 100644 index 000000000..1672cf398 --- /dev/null +++ b/spec/tasks/maintenance/create_procedure_tags_task_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" + +module Maintenance + RSpec.describe CreateProcedureTagsTask do + describe "#process" do + subject(:process) { described_class.new.process(tag) } + + let(:tag) { "Accompagnement" } + let!(:procedure) { create(:procedure, tags: ["Accompagnement"]) } + + it "creates the ProcedureTag if it does not exist" do + expect { process }.to change { ProcedureTag.count }.by(1) + expect(ProcedureTag.last.name).to eq(tag) + end + + context "when the ProcedureTag already exists" do + let!(:procedure_tag) { ProcedureTag.create(name: tag) } + + it "does not create a duplicate ProcedureTag" do + expect { process }.not_to change { ProcedureTag.count } + end + end + + it "associates procedures with the ProcedureTag" do + process + expect(procedure.reload.procedure_tags.map(&:name)).to include(tag) + end + end + end +end From ef5d196f803708ccd524986e1c8ebe75e660cdea Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Fri, 11 Oct 2024 10:30:08 +0200 Subject: [PATCH 1194/1532] =?UTF-8?q?fix(admin):=20homogeneize=20wording?= =?UTF-8?q?=20tags=20=3D>=20th=C3=A8mes/th=C3=A9matiques?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/administrateurs/procedures/_informations.html.haml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/administrateurs/procedures/_informations.html.haml b/app/views/administrateurs/procedures/_informations.html.haml index 27acea4b5..3fbb1b39e 100644 --- a/app/views/administrateurs/procedures/_informations.html.haml +++ b/app/views/administrateurs/procedures/_informations.html.haml @@ -121,7 +121,9 @@ .fr-fieldset__element = f.label :tags, 'Associez des thématiques à la démarche', class: 'fr-label' - %p.fr-hint-text Par des mots ou des expressions que vous attribuez aux démarches pour décrire leur contenu et pour les retrouver. Les tags sont partagés avec la communauté, ce qui vous permet de voir les tags attribués aux démarches créées par les autres administrateurs. + %p.fr-hint-text + Par des mots ou des expressions que vous attribuez aux démarches pour décrire leur contenu et pour les retrouver. + Les thèmes sont partagées avec la communauté, ce qui vous permet de voir les thèmes attribués aux démarches créées par les autres administrateurs. %react-fragment = render ReactComponent.new "ComboBox/MultiComboBox", id: "procedure_tags_combo", From 9e27295a36ef6e754b862725c9ac961d4a3047dd Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Fri, 11 Oct 2024 11:44:58 +0200 Subject: [PATCH 1195/1532] fix(admin): all procedures really filtered by tags --- .../procedures_controller_spec.rb | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/spec/controllers/administrateurs/procedures_controller_spec.rb b/spec/controllers/administrateurs/procedures_controller_spec.rb index 5dfd47e12..72b097a5d 100644 --- a/spec/controllers/administrateurs/procedures_controller_spec.rb +++ b/spec/controllers/administrateurs/procedures_controller_spec.rb @@ -290,18 +290,23 @@ describe Administrateurs::ProceduresController, type: :controller do end it 'returns procedure who contains at least one tag included in params' do - get :all, params: { procedure_tag_names: ['environnement'] } - expect(assigns(:procedures).any? { |p| p.id == procedure.id }).to be_truthy + get :all, params: { tags: ['environnement'] } + expect(assigns(:procedures).find { |p| p.id == procedure.id }).to be_present end it 'returns procedures who contains all tags included in params' do - get :all, params: { procedure_tag_names: ['environnement', 'diplomatie'] } - expect(assigns(:procedures).any? { |p| p.id == procedure.id }).to be_truthy + get :all, params: { tags: ['environnement', 'diplomatie'] } + expect(assigns(:procedures).find { |p| p.id == procedure.id }).to be_present end it 'returns the procedure when at least one tag is include' do - get :all, params: { procedure_tag_names: ['environnement', 'diplomatie', 'football'] } - expect(assigns(:procedures).any? { |p| p.id == procedure.id }).to be_truthy + get :all, params: { tags: ['environnement', 'diplomatie', 'football'] } + expect(assigns(:procedures).find { |p| p.id == procedure.id }).to be_present + end + + it 'does not return procedure not having the queried tag' do + get :all, params: { tags: ['football'] } + expect(assigns(:procedures)).to be_empty end end From 4336c444baa50d57d6826b84c6a4ee9f0a80cd6a Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Fri, 11 Oct 2024 10:29:48 +0200 Subject: [PATCH 1196/1532] style(combo): better vertical spacing between tags list and input --- app/assets/stylesheets/dsfr.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/dsfr.scss b/app/assets/stylesheets/dsfr.scss index d05bade9f..a2ca49e60 100644 --- a/app/assets/stylesheets/dsfr.scss +++ b/app/assets/stylesheets/dsfr.scss @@ -42,7 +42,7 @@ trix-editor.fr-input { display: flex; flex-wrap: wrap; gap: 0.3rem; - margin-bottom: 0.3rem; + margin-bottom: 0.5rem; } } From df0e0e9c3e216dcd89da3203c0f8f64f5e07d68b Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 14 Oct 2024 12:20:24 +0200 Subject: [PATCH 1197/1532] perf(admin): remove N+1 on procedures index about instructeurs or groupe instructeurs count --- .../administrateurs/procedures_controller.rb | 78 ++++++++++--------- .../procedures/_procedures_list.html.haml | 4 +- 2 files changed, 43 insertions(+), 39 deletions(-) diff --git a/app/controllers/administrateurs/procedures_controller.rb b/app/controllers/administrateurs/procedures_controller.rb index d2db31b2b..371ee4d3b 100644 --- a/app/controllers/administrateurs/procedures_controller.rb +++ b/app/controllers/administrateurs/procedures_controller.rb @@ -24,43 +24,6 @@ module Administrateurs @statut.blank? ? @statut = 'publiees' : @statut = params[:statut] end - def paginated_published_procedures - current_administrateur - .procedures - .publiees - .page(params[:page]) - .per(ITEMS_PER_PAGE) - .order(published_at: :desc) - end - - def paginated_draft_procedures - current_administrateur - .procedures - .brouillons - .page(params[:page]) - .per(ITEMS_PER_PAGE) - .order(created_at: :desc) - end - - def paginated_closed_procedures - current_administrateur - .procedures - .closes - .page(params[:page]) - .per(ITEMS_PER_PAGE) - .order(created_at: :desc) - end - - def paginated_deleted_procedures - current_administrateur - .procedures - .with_discarded - .discarded - .page(params[:page]) - .per(ITEMS_PER_PAGE) - .order(created_at: :desc) - end - def apercu @dossier = procedure_without_control.draft_revision.dossier_for_preview(current_user) DossierPreloader.load_one(@dossier) @@ -464,6 +427,47 @@ module Administrateurs private + def paginated_published_procedures + paginate_procedures(current_administrateur + .procedures + .publiees + .order(published_at: :desc)) + end + + def paginated_draft_procedures + paginate_procedures(current_administrateur + .procedures + .brouillons + .order(created_at: :desc)) + end + + def paginated_closed_procedures + paginate_procedures(current_administrateur + .procedures + .closes + .order(created_at: :desc)) + end + + def paginated_deleted_procedures + paginate_procedures(current_administrateur + .procedures + .with_discarded + .discarded + .order(created_at: :desc)) + end + + def paginate_procedures(procedures) + procedures + .with_attached_logo + .left_joins(groupe_instructeurs: :instructeurs) + .select('procedures.*, + COUNT(DISTINCT groupe_instructeurs.id) AS groupe_instructeurs_count, + COUNT(DISTINCT instructeurs.id) AS instructeurs_count') + .group('procedures.id') + .page(params[:page]) + .per(ITEMS_PER_PAGE) + end + def filter_procedures(filter) if filter.service_siret.present? service = Service.find_by(siret: filter.service_siret) diff --git a/app/views/administrateurs/procedures/_procedures_list.html.haml b/app/views/administrateurs/procedures/_procedures_list.html.haml index 8dc92859d..535c76267 100644 --- a/app/views/administrateurs/procedures/_procedures_list.html.haml +++ b/app/views/administrateurs/procedures/_procedures_list.html.haml @@ -45,9 +45,9 @@ %div = dsfr_icon('fr-icon-team-fill') - if procedure.routing_enabled? - %span.fr-badge= procedure.groupe_instructeurs.count + %span.fr-badge= procedure.groupe_instructeurs_count - else - %span.fr-badge= procedure.instructeurs.count + %span.fr-badge= procedure.instructeurs_count = dsfr_icon('fr-icon-file-text-fill fr-ml-1w') %span.fr-badge= procedure.dossiers.state_not_brouillon.visible_by_administration.count From c8e745a697ed04f0579560890b946a9e387b1398 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 14 Oct 2024 12:35:51 +0200 Subject: [PATCH 1198/1532] feat(Sidekiq): reliable-fetch in strict mode --- config/initializers/sidekiq.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 7bab2fdca..ba397c673 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -26,13 +26,14 @@ end Sidekiq.configure_server do |config| config.redis = sidekiq_redis - if ENV['PROMETHEUS_EXPORTER_ENABLED'] == 'enabled' Yabeda.configure! Yabeda::Prometheus::Exporter.start_metrics_server! end if ENV['SKIP_RELIABLE_FETCH'].blank? + config[:strict] = true + Sidekiq::ReliableFetch.setup_reliable_fetch!(config) end end From 59fe8b64c408a9b3a8363471327d642f76d64ac1 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 20 Sep 2024 16:40:13 +0200 Subject: [PATCH 1199/1532] wording(instructeurs management): update translations --- .../instructeurs_management_component.en.yml | 3 +++ .../instructeurs_management_component.fr.yml | 3 +++ ...nstructeurs_management_component.html.haml | 4 ++-- .../instructeurs_menu_component.en.yml | 5 ++++ .../_instructeurs.html.haml | 8 +++---- .../groupe_instructeurs/en.yml | 23 +++++++++++-------- .../groupe_instructeurs/fr.yml | 7 +++++- .../procedure_groupe_instructeur_spec.rb | 2 +- 8 files changed, 38 insertions(+), 17 deletions(-) create mode 100644 app/components/procedure/instructeurs_management_component/instructeurs_management_component.en.yml create mode 100644 app/components/procedure/instructeurs_management_component/instructeurs_management_component.fr.yml create mode 100644 app/components/procedure/instructeurs_menu_component/instructeurs_menu_component.en.yml diff --git a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.en.yml b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.en.yml new file mode 100644 index 000000000..8f4f9dc73 --- /dev/null +++ b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.en.yml @@ -0,0 +1,3 @@ +--- +en: + title: Instructors diff --git a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.fr.yml b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.fr.yml new file mode 100644 index 000000000..df1d83771 --- /dev/null +++ b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.fr.yml @@ -0,0 +1,3 @@ +--- +fr: + title: Instructeurs diff --git a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml index 3b538bdb1..d2f5701e0 100644 --- a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml +++ b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml @@ -1,5 +1,5 @@ -- content_for(:title, 'Instructeurs') -%h1.fr-h2 Instructeurs +- content_for(:title, t('.title')) +%h1.fr-h2=t('.title') = render partial: 'administrateurs/groupe_instructeurs/import_export', locals: { procedure: @procedure } diff --git a/app/components/procedure/instructeurs_menu_component/instructeurs_menu_component.en.yml b/app/components/procedure/instructeurs_menu_component/instructeurs_menu_component.en.yml new file mode 100644 index 000000000..bb7558137 --- /dev/null +++ b/app/components/procedure/instructeurs_menu_component/instructeurs_menu_component.en.yml @@ -0,0 +1,5 @@ +--- +en: + instructeurs: + one: "%{count} instructor" + other: "%{count} instructors" diff --git a/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml b/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml index 4c8c4371d..8797f6250 100644 --- a/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml @@ -1,11 +1,11 @@ .card = render Procedure::InvitationWithTypoComponent.new(maybe_typos: @maybe_typos, url: add_instructeur_admin_procedure_groupe_instructeur_path(@procedure, groupe_instructeur.id), title: "Avant d'ajouter l'email, veuillez confirmer" ) - .card-title Affectation des instructeurs + .card-title= t('.instructeur_assignation') = form_for :instructeur, url: { action: :add_instructeur, id: groupe_instructeur.id }, html: { class: 'form' } do |f| .instructeur-wrapper - if !procedure.routing_enabled? - %p Entrez les adresses email des instructeurs que vous souhaitez affecter à cette démarche + %p=t('.instructeur_emails') - if disabled_as_super_admin = f.select :emails, available_instructeur_emails, {}, disabled: disabled_as_super_admin, id: 'instructeur_emails' @@ -13,7 +13,7 @@ %react-fragment = render ReactComponent.new 'ComboBox/MultiComboBox', items: available_instructeur_emails, id: 'instructeur_emails', name: 'emails[]', allows_custom_value: true, 'aria-label': 'Emails' - = f.submit 'Affecter', class: 'fr-btn', disabled: disabled_as_super_admin + = f.submit t('.assign'), class: 'fr-btn', disabled: disabled_as_super_admin %table.fr-table.fr-mt-2w.width-100 %thead @@ -27,7 +27,7 @@ #{instructeur.email} - confirmation_message = procedure.routing_enabled? ? "Êtes-vous sûr de vouloir retirer l’instructeur « #{instructeur.email} » du groupe « #{groupe_instructeur.label} » ?" : "Êtes-vous sûr de vouloir retirer l’instructeur « #{instructeur.email} » de la démarche ?" - %td.actions= button_to 'Retirer', + %td.actions= button_to t('.remove'), { action: :remove_instructeur, id: groupe_instructeur.id }, { method: :delete, data: { confirm: confirmation_message }, diff --git a/config/locales/views/administrateurs/groupe_instructeurs/en.yml b/config/locales/views/administrateurs/groupe_instructeurs/en.yml index f4690053a..2aa0133ef 100644 --- a/config/locales/views/administrateurs/groupe_instructeurs/en.yml +++ b/config/locales/views/administrateurs/groupe_instructeurs/en.yml @@ -20,22 +20,27 @@ en: one: "%{count} group exist" other: "%{count} groups exist" instructeurs: + title: Instructors assigned_instructeur: one: "%{count} instructor is assigned" other: "%{count} instructors are assigned" + instructeur_assignation: Instructors assignment + assign: Assign + remove: Remove + instructeur_emails: Email addresses of the instructors you want to assign to this procedure import_export: csv_import: - import_file: Importer le fichier - import_file_alert: Tous les instructeurs ajoutés à la procédure vont être notifiés par email. Voulez-vous continuer ? - import_file_procedure_not_published: L’import par fichier CSV est disponible une fois la démarche publiée + import_file: Import file + import_file_alert: All instructors added to the procedure will be notified by email. Do you want to continue? + import_file_procedure_not_published: CSV file import is available once the procedure is published groupes: - title: Import / Export en masse - notice_1_html: Pour l'import, votre fichier csv doit comporter 2 colonnes (Groupe, Email) et être séparé par des virgules (exemple de fichier). Le poids du fichier doit être inférieur %{csv_max_size}. - notice_2: L’import n’écrase pas les groupes existants. Il permet uniquement d'en ajouter. Pour supprimer un groupe, allez dans la page dédiée et cliquez sur le bouton « Supprimer ». + title: Bulk Import / Export + notice_1_html: For the import, your csv file must have 2 columns (Group, Email) and be comma separated (example file). The file size must be less than %{csv_max_size}. + notice_2: The import does not overwrite existing groups and instructors. It only allows you to add them. instructeurs: - title: Import en masse - notice_1_html: Pour l'import, le fichier csv doit comporter 1 seule colonne (Email) avec une adresse email d'instructeur par ligne (exemple de fichier). Le poids du fichier doit être inférieur %{csv_max_size}. - notice_2: L’import n’écrase pas les instructeurs existants. Il permet uniquement d'en ajouter. Pour supprimer un instructeur, cliquez sur le bouton « Retirer ». + title: Adding instructors with file import + notice_1_html: For the import, the csv file must have only 1 column (Email) with one instructor email address per line (example file). The file size must be less than %{csv_max_size}. + notice_2: The import does not overwrite existing instructors. It only allows you to add them. existing_groupe: one: "%{count} groupe existe" other: "%{count} groupes existent" diff --git a/config/locales/views/administrateurs/groupe_instructeurs/fr.yml b/config/locales/views/administrateurs/groupe_instructeurs/fr.yml index e44fda6ff..d72670381 100644 --- a/config/locales/views/administrateurs/groupe_instructeurs/fr.yml +++ b/config/locales/views/administrateurs/groupe_instructeurs/fr.yml @@ -27,9 +27,14 @@ fr: one: "%{count} groupe existe" other: "%{count} groupes existent" instructeurs: + title: Instructeurs assigned_instructeur: one: "%{count} instructeur est affecté" other: "%{count} instructeurs sont affectés" + instructeur_assignation: Affectation des instructeurs + assign: Affecter + remove: Retirer + instructeur_emails: Adresse électronique des instructeurs que vous souhaitez affecter à cette démarche import_export: csv_import: import_file: Importer le fichier @@ -40,7 +45,7 @@ fr: notice_1_html: Pour l’import, votre fichier csv doit comporter 2 colonnes (Groupe, Email) et être séparé par des virgules (exemple de fichier). Le poids du fichier doit être inférieur à %{csv_max_size}. notice_2: L’import n’écrase pas les groupes et instructeurs existants. Il permet uniquement d'en ajouter. instructeurs: - title: Import en masse + title: Ajout d’instructeurs avec import de fichier notice_1_html: Pour l’import, le fichier csv doit comporter 1 seule colonne (Email) avec une adresse email d’instructeur par ligne (exemple de fichier). Le poids du fichier doit être inférieur à %{csv_max_size}. notice_2: L’import n’écrase pas les instructeurs existants. Il permet uniquement d'en ajouter. existing_groupe: diff --git a/spec/system/administrateurs/procedure_groupe_instructeur_spec.rb b/spec/system/administrateurs/procedure_groupe_instructeur_spec.rb index 48dc9a892..10c6ec43f 100644 --- a/spec/system/administrateurs/procedure_groupe_instructeur_spec.rb +++ b/spec/system/administrateurs/procedure_groupe_instructeur_spec.rb @@ -19,7 +19,7 @@ describe 'Manage procedure instructeurs', js: true do scenario 'it works' do visit admin_procedure_path(procedure) find('#groupe-instructeurs').click - expect(page).to have_css("h1", text: "Instructeurs") + expect(page).to have_css("h1", text: "Gestion des instructeurs") end end From af1fd3425653818f9f62292a7f924ca787dde9e9 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Tue, 24 Sep 2024 14:47:06 +0200 Subject: [PATCH 1200/1532] feat(instructeurs import): can import instructeurs even if procedure not published --- .../groupe_instructeurs_controller.rb | 52 +++++++++---------- .../_import_export.html.haml | 20 +++---- .../groupe_instructeurs/en.yml | 1 - .../groupe_instructeurs/fr.yml | 1 - 4 files changed, 32 insertions(+), 42 deletions(-) diff --git a/app/controllers/administrateurs/groupe_instructeurs_controller.rb b/app/controllers/administrateurs/groupe_instructeurs_controller.rb index 759bfe30c..c9ecd1563 100644 --- a/app/controllers/administrateurs/groupe_instructeurs_controller.rb +++ b/app/controllers/administrateurs/groupe_instructeurs_controller.rb @@ -322,44 +322,42 @@ module Administrateurs end def import - if procedure.publiee_or_close? - if !CSV_ACCEPTED_CONTENT_TYPES.include?(csv_file.content_type) && !CSV_ACCEPTED_CONTENT_TYPES.include?(marcel_content_type) - flash[:alert] = "Importation impossible : veuillez importer un fichier CSV" + if !CSV_ACCEPTED_CONTENT_TYPES.include?(csv_file.content_type) && !CSV_ACCEPTED_CONTENT_TYPES.include?(marcel_content_type) + flash[:alert] = "Importation impossible : veuillez importer un fichier CSV" - elsif csv_file.size > CSV_MAX_SIZE - flash[:alert] = "Importation impossible : le poids du fichier est supérieur à #{number_to_human_size(CSV_MAX_SIZE)}" + elsif csv_file.size > CSV_MAX_SIZE + flash[:alert] = "Importation impossible : le poids du fichier est supérieur à #{number_to_human_size(CSV_MAX_SIZE)}" - else - file = csv_file.read - base_encoding = CharlockHolmes::EncodingDetector.detect(file) + else + file = csv_file.read + base_encoding = CharlockHolmes::EncodingDetector.detect(file) - csv_content = ACSV::CSV.new_for_ruby3(file.encode("UTF-8", base_encoding[:encoding], invalid: :replace, replace: ""), headers: true, header_converters: :downcase).map(&:to_h) + csv_content = ACSV::CSV.new_for_ruby3(file.encode("UTF-8", base_encoding[:encoding], invalid: :replace, replace: ""), headers: true, header_converters: :downcase).map(&:to_h) - if csv_content.first.has_key?("groupe") && csv_content.first.has_key?("email") - groupes_emails = csv_content.map { |r| r.to_h.slice('groupe', 'email') } + if csv_content.first.has_key?("groupe") && csv_content.first.has_key?("email") + groupes_emails = csv_content.map { |r| r.to_h.slice('groupe', 'email') } - added_instructeurs_by_group, invalid_emails = InstructeursImportService.import_groupes(procedure, groupes_emails) + added_instructeurs_by_group, invalid_emails = InstructeursImportService.import_groupes(procedure, groupes_emails) - added_instructeurs_by_group.each do |groupe, added_instructeurs| - if added_instructeurs.present? - notify_instructeurs(groupe, added_instructeurs) - end - flash_message_for_import(invalid_emails) - end - - elsif csv_content.first.has_key?("email") && !csv_content.map(&:to_h).first.keys.many? && procedure.groupe_instructeurs.one? - instructors_emails = csv_content.map(&:to_h) - - added_instructeurs, invalid_emails = InstructeursImportService.import_instructeurs(procedure, instructors_emails) + added_instructeurs_by_group.each do |groupe, added_instructeurs| if added_instructeurs.present? - notify_instructeurs(groupe_instructeur, added_instructeurs) + notify_instructeurs(groupe, added_instructeurs) end flash_message_for_import(invalid_emails) - else - flash_message_for_invalid_csv end - redirect_to admin_procedure_groupe_instructeurs_path(procedure) + + elsif csv_content.first.has_key?("email") && !csv_content.map(&:to_h).first.keys.many? && procedure.groupe_instructeurs.one? + instructors_emails = csv_content.map(&:to_h) + + added_instructeurs, invalid_emails = InstructeursImportService.import_instructeurs(procedure, instructors_emails) + if added_instructeurs.present? + notify_instructeurs(groupe_instructeur, added_instructeurs) + end + flash_message_for_import(invalid_emails) + else + flash_message_for_invalid_csv end + redirect_to admin_procedure_groupe_instructeurs_path(procedure) end end diff --git a/app/views/administrateurs/groupe_instructeurs/_import_export.html.haml b/app/views/administrateurs/groupe_instructeurs/_import_export.html.haml index 1286c969b..429fef441 100644 --- a/app/views/administrateurs/groupe_instructeurs/_import_export.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/_import_export.html.haml @@ -5,19 +5,13 @@ = t(".csv_import.#{key}.title") .fr-collapse#accordion-106 - csv_max_size = Administrateurs::GroupeInstructeursController::CSV_MAX_SIZE - - if procedure.publiee_or_close? - %p.notice - = t(".csv_import.#{key}.notice_1_html", csv_max_size: number_to_human_size(csv_max_size)) - %p.notice - = t(".csv_import.#{key}.notice_2") - = form_tag import_admin_procedure_groupe_instructeurs_path(procedure), method: :post, multipart: true, class: "mt-4 form flex justify-between align-center" do - = file_field_tag :csv_file, required: true, accept: 'text/csv', size: "1" - = submit_tag t('.csv_import.import_file'), class: 'fr-btn fr-btn--secondary', data: { disable_with: "Envoi...", confirm: t('.csv_import.import_file_alert') } - - else - %p.mt-4.form.font-weight-bold.mb-2.text-lg - = t(".csv_import.#{key}.title") - %p.notice - = t('.csv_import.import_file_procedure_not_published') + %p.notice + = t(".csv_import.#{key}.notice_1_html", csv_max_size: number_to_human_size(csv_max_size)) + %p.notice + = t(".csv_import.#{key}.notice_2") + = form_tag import_admin_procedure_groupe_instructeurs_path(procedure), method: :post, multipart: true, class: "mt-4 form flex justify-between align-center" do + = file_field_tag :csv_file, required: true, accept: 'text/csv', size: "1" + = submit_tag t('.csv_import.import_file'), class: 'fr-btn fr-btn--secondary', data: { disable_with: "Envoi...", confirm: t('.csv_import.import_file_alert') } - if procedure.groupe_instructeurs.many? .flex.justify-between.align-center.mt-4 %div diff --git a/config/locales/views/administrateurs/groupe_instructeurs/en.yml b/config/locales/views/administrateurs/groupe_instructeurs/en.yml index 2aa0133ef..d50fc5b2b 100644 --- a/config/locales/views/administrateurs/groupe_instructeurs/en.yml +++ b/config/locales/views/administrateurs/groupe_instructeurs/en.yml @@ -32,7 +32,6 @@ en: csv_import: import_file: Import file import_file_alert: All instructors added to the procedure will be notified by email. Do you want to continue? - import_file_procedure_not_published: CSV file import is available once the procedure is published groupes: title: Bulk Import / Export notice_1_html: For the import, your csv file must have 2 columns (Group, Email) and be comma separated (example file). The file size must be less than %{csv_max_size}. diff --git a/config/locales/views/administrateurs/groupe_instructeurs/fr.yml b/config/locales/views/administrateurs/groupe_instructeurs/fr.yml index d72670381..cf5bac1a8 100644 --- a/config/locales/views/administrateurs/groupe_instructeurs/fr.yml +++ b/config/locales/views/administrateurs/groupe_instructeurs/fr.yml @@ -39,7 +39,6 @@ fr: csv_import: import_file: Importer le fichier import_file_alert: Tous les instructeurs ajoutés à la procédure vont être notifiés par email. Voulez-vous continuer ? - import_file_procedure_not_published: L’import par fichier CSV est disponible une fois la démarche publiée groupes: title: Import / Export en masse notice_1_html: Pour l’import, votre fichier csv doit comporter 2 colonnes (Groupe, Email) et être séparé par des virgules (exemple de fichier). Le poids du fichier doit être inférieur à %{csv_max_size}. From c68e0b5f1a70cd007e66bf6f2e70a355d2e0c4f0 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 25 Sep 2024 09:37:45 +0200 Subject: [PATCH 1201/1532] style(instructeurs import): update import card --- .../instructeurs_management_component.rb | 16 ++++++++++++++ ...nstructeurs_management_component.html.haml | 2 +- .../_import_export.html.haml | 22 ++++++++++++++----- .../groupe_instructeurs/en.yml | 9 ++++---- .../groupe_instructeurs/fr.yml | 10 +++++---- 5 files changed, 44 insertions(+), 15 deletions(-) diff --git a/app/components/procedure/instructeurs_management_component.rb b/app/components/procedure/instructeurs_management_component.rb index 919a2ebad..4b30aa376 100644 --- a/app/components/procedure/instructeurs_management_component.rb +++ b/app/components/procedure/instructeurs_management_component.rb @@ -8,4 +8,20 @@ class Procedure::InstructeursManagementComponent < ApplicationComponent @available_instructeur_emails = available_instructeur_emails @disabled_as_super_admin = disabled_as_super_admin end + + def csv_template + template_path.open + end + + def template_path + Rails.public_path.join('csv/import-instructeurs-test.csv') + end + + def template_url + template_path.to_s + end + + def template_detail + "#{File.extname(csv_template.to_path).upcase.delete_prefix('.')} – #{number_to_human_size(csv_template.size)}" + end end diff --git a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml index d2f5701e0..bae65f2e6 100644 --- a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml +++ b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml @@ -2,7 +2,7 @@ %h1.fr-h2=t('.title') = render partial: 'administrateurs/groupe_instructeurs/import_export', - locals: { procedure: @procedure } + locals: { procedure: @procedure, template_url:, template_detail: } = render partial: 'administrateurs/groupe_instructeurs/instructeurs', locals: { procedure: @procedure, diff --git a/app/views/administrateurs/groupe_instructeurs/_import_export.html.haml b/app/views/administrateurs/groupe_instructeurs/_import_export.html.haml index 429fef441..be7b7a7b4 100644 --- a/app/views/administrateurs/groupe_instructeurs/_import_export.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/_import_export.html.haml @@ -5,13 +5,23 @@ = t(".csv_import.#{key}.title") .fr-collapse#accordion-106 - csv_max_size = Administrateurs::GroupeInstructeursController::CSV_MAX_SIZE - %p.notice + .notice.fr-mb-1w = t(".csv_import.#{key}.notice_1_html", csv_max_size: number_to_human_size(csv_max_size)) - %p.notice - = t(".csv_import.#{key}.notice_2") - = form_tag import_admin_procedure_groupe_instructeurs_path(procedure), method: :post, multipart: true, class: "mt-4 form flex justify-between align-center" do - = file_field_tag :csv_file, required: true, accept: 'text/csv', size: "1" - = submit_tag t('.csv_import.import_file'), class: 'fr-btn fr-btn--secondary', data: { disable_with: "Envoi...", confirm: t('.csv_import.import_file_alert') } + .notice + = t(".csv_import.#{key}.notice_2_html") + + = form_tag import_admin_procedure_groupe_instructeurs_path(procedure), method: :post, multipart: true, class: "mt-4 column" do + %label.fr-label.font-weight-bold + = t('.csv_import.file_to_import') + .fr-download + = link_to template_url, {class: "fr-download__link", download: ''} do + = t('.csv_import.download_template') + %span.fr-download__detail + = template_detail + .flex.column + = file_field_tag :csv_file, required: true, accept: 'text/csv', size: "1", class: 'fr-mb-2w' + = submit_tag t('.csv_import.import_file'), class: 'fr-btn fr-btn--secondary', data: { disable_with: "Envoi...", confirm: t('.csv_import.import_file_alert') } + - if procedure.groupe_instructeurs.many? .flex.justify-between.align-center.mt-4 %div diff --git a/config/locales/views/administrateurs/groupe_instructeurs/en.yml b/config/locales/views/administrateurs/groupe_instructeurs/en.yml index d50fc5b2b..505a28c04 100644 --- a/config/locales/views/administrateurs/groupe_instructeurs/en.yml +++ b/config/locales/views/administrateurs/groupe_instructeurs/en.yml @@ -32,14 +32,15 @@ en: csv_import: import_file: Import file import_file_alert: All instructors added to the procedure will be notified by email. Do you want to continue? + file_to_import: File to import groupes: title: Bulk Import / Export - notice_1_html: For the import, your csv file must have 2 columns (Group, Email) and be comma separated (example file). The file size must be less than %{csv_max_size}. - notice_2: The import does not overwrite existing groups and instructors. It only allows you to add them. + notice_1_html: For the import, your csv file must have 2 columns (Group, Email) (see file model below). The file size must be less than %{csv_max_size}. + notice_2_html: The import does not overwrite existing groups and instructors. It only allows you to add them. instructeurs: title: Adding instructors with file import - notice_1_html: For the import, the csv file must have only 1 column (Email) with one instructor email address per line (example file). The file size must be less than %{csv_max_size}. - notice_2: The import does not overwrite existing instructors. It only allows you to add them. + notice_1_html: For the import, your csv file must have 1 column (Email) listing the email addresses of the instructors (see file model below). The file size must be less than %{csv_max_size}. + notice_2_html: The import does not overwrite existing instructors. It only allows you to add them. existing_groupe: one: "%{count} groupe existe" other: "%{count} groupes existent" diff --git a/config/locales/views/administrateurs/groupe_instructeurs/fr.yml b/config/locales/views/administrateurs/groupe_instructeurs/fr.yml index cf5bac1a8..5f3205bbb 100644 --- a/config/locales/views/administrateurs/groupe_instructeurs/fr.yml +++ b/config/locales/views/administrateurs/groupe_instructeurs/fr.yml @@ -39,14 +39,16 @@ fr: csv_import: import_file: Importer le fichier import_file_alert: Tous les instructeurs ajoutés à la procédure vont être notifiés par email. Voulez-vous continuer ? + file_to_import: Fichier à importer + download_template: Modèle à télécharger groupes: title: Import / Export en masse - notice_1_html: Pour l’import, votre fichier csv doit comporter 2 colonnes (Groupe, Email) et être séparé par des virgules (exemple de fichier). Le poids du fichier doit être inférieur à %{csv_max_size}. - notice_2: L’import n’écrase pas les groupes et instructeurs existants. Il permet uniquement d'en ajouter. + notice_1_html: Pour l’import, votre fichier csv doit comporter 2 colonnes (Groupe, Email) (voir modèle de fichier ci-dessous). Le poids du fichier doit être inférieur à %{csv_max_size}. + notice_2_html: L’import n’écrase pas les groupes et instructeurs existants. Il permet uniquement d’en ajouter. instructeurs: title: Ajout d’instructeurs avec import de fichier - notice_1_html: Pour l’import, le fichier csv doit comporter 1 seule colonne (Email) avec une adresse email d’instructeur par ligne (exemple de fichier). Le poids du fichier doit être inférieur à %{csv_max_size}. - notice_2: L’import n’écrase pas les instructeurs existants. Il permet uniquement d'en ajouter. + notice_1_html: Pour l’import, votre fichier csv doit comporter 1 seule colonne (Email) listant les adresses emails des instructeurs (voir modèle de fichier ci-dessous). Le poids du fichier doit être inférieur à %{csv_max_size}. + notice_2_html: L’import n’écrase pas les instructeurs existants. Il permet uniquement d’en ajouter. existing_groupe: one: "%{count} groupe existe" other: "%{count} groupes existent" From cdd91579272073de1e6f9b46038942247c997d9a Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 25 Sep 2024 10:04:35 +0200 Subject: [PATCH 1202/1532] feat(instructeurs import): add notification alert --- .../instructeurs_management_component.en.yml | 3 ++- .../instructeurs_management_component.fr.yml | 3 ++- .../instructeurs_management_component.html.haml | 6 +++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.en.yml b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.en.yml index 8f4f9dc73..bba6eec76 100644 --- a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.en.yml +++ b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.en.yml @@ -1,3 +1,4 @@ --- en: - title: Instructors + title: Instructors management + notification_alert: Even if your procedure is still in test / unpublished, all the instructors you add to the procedure will be notified by email. diff --git a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.fr.yml b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.fr.yml index df1d83771..adbb2780a 100644 --- a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.fr.yml +++ b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.fr.yml @@ -1,3 +1,4 @@ --- fr: - title: Instructeurs + title: Gestion des instructeurs + notification_alert: Même si votre démarche est encore en test / non publiée, tous les instructeurs que vous ajouterez à la démarche seront notifiés par email. diff --git a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml index bae65f2e6..faaa041bb 100644 --- a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml +++ b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml @@ -1,6 +1,7 @@ - content_for(:title, t('.title')) -%h1.fr-h2=t('.title') - +%h1.fr-h2= t('.title') +.fr-icon-mail-line.fr-alert.fr-mb-3w + %p= t('.notification_alert') = render partial: 'administrateurs/groupe_instructeurs/import_export', locals: { procedure: @procedure, template_url:, template_detail: } @@ -11,5 +12,4 @@ available_instructeur_emails: @available_instructeur_emails, disabled_as_super_admin: @disabled_as_super_admin } - = render Procedure::FixedFooterComponent.new(procedure: @procedure) From ec2f7c2405d12fdddae933c55caf11c87044dc6d Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 25 Sep 2024 10:34:39 +0200 Subject: [PATCH 1203/1532] feat(instructeurs import): add copy paste hint --- .../groupe_instructeurs/_instructeurs.html.haml | 3 ++- .../locales/views/administrateurs/groupe_instructeurs/en.yml | 1 + .../locales/views/administrateurs/groupe_instructeurs/fr.yml | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml b/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml index 8797f6250..4de760253 100644 --- a/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml @@ -5,7 +5,8 @@ = form_for :instructeur, url: { action: :add_instructeur, id: groupe_instructeur.id }, html: { class: 'form' } do |f| .instructeur-wrapper - if !procedure.routing_enabled? - %p=t('.instructeur_emails') + %p= t('.instructeur_emails') + %p.fr-hint-text= t('.copy_paste_hint') - if disabled_as_super_admin = f.select :emails, available_instructeur_emails, {}, disabled: disabled_as_super_admin, id: 'instructeur_emails' diff --git a/config/locales/views/administrateurs/groupe_instructeurs/en.yml b/config/locales/views/administrateurs/groupe_instructeurs/en.yml index 505a28c04..1d7830414 100644 --- a/config/locales/views/administrateurs/groupe_instructeurs/en.yml +++ b/config/locales/views/administrateurs/groupe_instructeurs/en.yml @@ -28,6 +28,7 @@ en: assign: Assign remove: Remove instructeur_emails: Email addresses of the instructors you want to assign to this procedure + copy_paste_hint: "You can enter addresses individually, or copy-paste a list of addresses separated by semicolons into the field below (example: adress1@mail.com; adress2@mail.com; adress3@mail.com)." import_export: csv_import: import_file: Import file diff --git a/config/locales/views/administrateurs/groupe_instructeurs/fr.yml b/config/locales/views/administrateurs/groupe_instructeurs/fr.yml index 5f3205bbb..67a70d19e 100644 --- a/config/locales/views/administrateurs/groupe_instructeurs/fr.yml +++ b/config/locales/views/administrateurs/groupe_instructeurs/fr.yml @@ -35,6 +35,7 @@ fr: assign: Affecter remove: Retirer instructeur_emails: Adresse électronique des instructeurs que vous souhaitez affecter à cette démarche + copy_paste_hint: "Vous pouvez saisir les adresses individuellement, ou bien copier-coller dans le champ ci-dessous une liste d’adresses séparées par des points-virgules (exemple : adresse1@mail.com; adresse2@mail.com; adresse3@mail.com)." import_export: csv_import: import_file: Importer le fichier From 1f6d76a4dde4658f158cec80aa4ef6cf87b4f6ed Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 25 Sep 2024 12:05:33 +0200 Subject: [PATCH 1204/1532] feat(instructeurs management): can export instructeurs if procedure not routed --- .../groupes_ajout_component.html.haml | 2 +- .../instructeurs_management_component.html.haml | 2 +- .../one_groupe_management_component.html.haml | 2 +- .../{_import_export.html.haml => _import.html.haml} | 11 +---------- .../groupe_instructeurs/_instructeurs.html.haml | 13 ++++++++++--- .../administrateurs/groupe_instructeurs/en.yml | 7 ++++--- .../administrateurs/groupe_instructeurs/fr.yml | 7 ++++--- 7 files changed, 22 insertions(+), 22 deletions(-) rename app/views/administrateurs/groupe_instructeurs/{_import_export.html.haml => _import.html.haml} (69%) diff --git a/app/components/procedure/groupes_ajout_component/groupes_ajout_component.html.haml b/app/components/procedure/groupes_ajout_component/groupes_ajout_component.html.haml index 25a61cafa..84804dc43 100644 --- a/app/components/procedure/groupes_ajout_component/groupes_ajout_component.html.haml +++ b/app/components/procedure/groupes_ajout_component/groupes_ajout_component.html.haml @@ -1,7 +1,7 @@ - content_for(:title, 'Ajout de groupes') %h1 Ajout de groupes d'instructeurs -= render partial: 'administrateurs/groupe_instructeurs/import_export', += render partial: 'administrateurs/groupe_instructeurs/import', locals: { procedure: @procedure } %section diff --git a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml index faaa041bb..a0b6d0b7f 100644 --- a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml +++ b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml @@ -2,7 +2,7 @@ %h1.fr-h2= t('.title') .fr-icon-mail-line.fr-alert.fr-mb-3w %p= t('.notification_alert') -= render partial: 'administrateurs/groupe_instructeurs/import_export', += render partial: 'administrateurs/groupe_instructeurs/import', locals: { procedure: @procedure, template_url:, template_detail: } = render partial: 'administrateurs/groupe_instructeurs/instructeurs', diff --git a/app/components/procedure/one_groupe_management_component/one_groupe_management_component.html.haml b/app/components/procedure/one_groupe_management_component/one_groupe_management_component.html.haml index 80d583cb9..8c014c302 100644 --- a/app/components/procedure/one_groupe_management_component/one_groupe_management_component.html.haml +++ b/app/components/procedure/one_groupe_management_component/one_groupe_management_component.html.haml @@ -1,7 +1,7 @@ %div{ id: dom_id(@groupe_instructeur, :routing) } %h1 Paramètres du groupe - = render partial: 'administrateurs/groupe_instructeurs/import_export', + = render partial: 'administrateurs/groupe_instructeurs/import', locals: { procedure: @procedure } = form_for @groupe_instructeur, diff --git a/app/views/administrateurs/groupe_instructeurs/_import_export.html.haml b/app/views/administrateurs/groupe_instructeurs/_import.html.haml similarity index 69% rename from app/views/administrateurs/groupe_instructeurs/_import_export.html.haml rename to app/views/administrateurs/groupe_instructeurs/_import.html.haml index be7b7a7b4..344b721c4 100644 --- a/app/views/administrateurs/groupe_instructeurs/_import_export.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/_import.html.haml @@ -20,13 +20,4 @@ = template_detail .flex.column = file_field_tag :csv_file, required: true, accept: 'text/csv', size: "1", class: 'fr-mb-2w' - = submit_tag t('.csv_import.import_file'), class: 'fr-btn fr-btn--secondary', data: { disable_with: "Envoi...", confirm: t('.csv_import.import_file_alert') } - - - if procedure.groupe_instructeurs.many? - .flex.justify-between.align-center.mt-4 - %div - = t(".existing_groupe", count: procedure.groupe_instructeurs.count) - = button_to "Exporter au format CSV", - export_groupe_instructeurs_admin_procedure_groupe_instructeurs_path(procedure, format: :csv), - method: :get, - class: 'fr-btn fr-btn--secondary' + = submit_tag t('.csv_import.import_file'), class: 'fr-btn fr-btn--tertiary', data: { disable_with: "Envoi...", confirm: t('.csv_import.import_file_alert') } diff --git a/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml b/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml index 4de760253..8c026dbd8 100644 --- a/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml @@ -14,12 +14,19 @@ %react-fragment = render ReactComponent.new 'ComboBox/MultiComboBox', items: available_instructeur_emails, id: 'instructeur_emails', name: 'emails[]', allows_custom_value: true, 'aria-label': 'Emails' - = f.submit t('.assign'), class: 'fr-btn', disabled: disabled_as_super_admin + = f.submit t('.assign'), class: 'fr-btn fr-btn--tertiary', disabled: disabled_as_super_admin - %table.fr-table.fr-mt-2w.width-100 + %hr.fr-mt-4w + + .flex.justify-between.align-baseline + .card-title= t('.assigned_instructeur', count: instructeurs.count) + = button_to export_groupe_instructeurs_admin_procedure_groupe_instructeurs_path(procedure, format: :csv), method: :get, class: 'fr-btn fr-btn--tertiary fr-btn--icon-left fr-icon-download-line' do + Exporter la liste (.CSV) + + %table.fr-table.width-100 %thead %tr - %th{ colspan: 2 }= t('.assigned_instructeur', count: instructeurs.count) + %th{ colspan: 2 }= t('.instructeurs_title') %tbody - instructeurs.each do |instructeur| %tr diff --git a/config/locales/views/administrateurs/groupe_instructeurs/en.yml b/config/locales/views/administrateurs/groupe_instructeurs/en.yml index 1d7830414..ec58bcdf8 100644 --- a/config/locales/views/administrateurs/groupe_instructeurs/en.yml +++ b/config/locales/views/administrateurs/groupe_instructeurs/en.yml @@ -22,14 +22,15 @@ en: instructeurs: title: Instructors assigned_instructeur: - one: "%{count} instructor is assigned" - other: "%{count} instructors are assigned" + one: "%{count} instructor assigned" + other: "%{count} instructors assigned" instructeur_assignation: Instructors assignment assign: Assign remove: Remove instructeur_emails: Email addresses of the instructors you want to assign to this procedure copy_paste_hint: "You can enter addresses individually, or copy-paste a list of addresses separated by semicolons into the field below (example: adress1@mail.com; adress2@mail.com; adress3@mail.com)." - import_export: + actions: Remove + import: csv_import: import_file: Import file import_file_alert: All instructors added to the procedure will be notified by email. Do you want to continue? diff --git a/config/locales/views/administrateurs/groupe_instructeurs/fr.yml b/config/locales/views/administrateurs/groupe_instructeurs/fr.yml index 67a70d19e..e28d9b16b 100644 --- a/config/locales/views/administrateurs/groupe_instructeurs/fr.yml +++ b/config/locales/views/administrateurs/groupe_instructeurs/fr.yml @@ -29,14 +29,15 @@ fr: instructeurs: title: Instructeurs assigned_instructeur: - one: "%{count} instructeur est affecté" - other: "%{count} instructeurs sont affectés" + one: "%{count} instructeur affecté" + other: "%{count} instructeurs affectés" instructeur_assignation: Affectation des instructeurs assign: Affecter remove: Retirer instructeur_emails: Adresse électronique des instructeurs que vous souhaitez affecter à cette démarche copy_paste_hint: "Vous pouvez saisir les adresses individuellement, ou bien copier-coller dans le champ ci-dessous une liste d’adresses séparées par des points-virgules (exemple : adresse1@mail.com; adresse2@mail.com; adresse3@mail.com)." - import_export: + actions: Retirer + import: csv_import: import_file: Importer le fichier import_file_alert: Tous les instructeurs ajoutés à la procédure vont être notifiés par email. Voulez-vous continuer ? From 822f856ae9ef1ebcbf3899996fc1847355ba205f Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 25 Sep 2024 13:44:10 +0200 Subject: [PATCH 1205/1532] feat(instructeurs management): update instructeur import for groupe instructeur page --- .../procedure/one_groupe_management_component.rb | 16 ++++++++++++++++ .../one_groupe_management_component.html.haml | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/components/procedure/one_groupe_management_component.rb b/app/components/procedure/one_groupe_management_component.rb index abf6da24e..c01b2ce61 100644 --- a/app/components/procedure/one_groupe_management_component.rb +++ b/app/components/procedure/one_groupe_management_component.rb @@ -8,4 +8,20 @@ class Procedure::OneGroupeManagementComponent < ApplicationComponent @groupe_instructeur = groupe_instructeur @procedure = revision.procedure end + + def csv_template + template_path.open + end + + def template_path + Rails.public_path.join('csv/import-instructeurs-test.csv') + end + + def template_url + template_path.to_s + end + + def template_detail + "#{File.extname(csv_template.to_path).upcase.delete_prefix('.')} – #{number_to_human_size(csv_template.size)}" + end end diff --git a/app/components/procedure/one_groupe_management_component/one_groupe_management_component.html.haml b/app/components/procedure/one_groupe_management_component/one_groupe_management_component.html.haml index 8c014c302..2c2202529 100644 --- a/app/components/procedure/one_groupe_management_component/one_groupe_management_component.html.haml +++ b/app/components/procedure/one_groupe_management_component/one_groupe_management_component.html.haml @@ -2,7 +2,7 @@ %h1 Paramètres du groupe = render partial: 'administrateurs/groupe_instructeurs/import', - locals: { procedure: @procedure } + locals: { procedure: @procedure, template_url:, template_detail: } = form_for @groupe_instructeur, url: admin_procedure_groupe_instructeur_path(@procedure, @groupe_instructeur), From ee71a8479f7a95921471f1f5b84e499f2fca11ee Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 25 Sep 2024 14:36:26 +0200 Subject: [PATCH 1206/1532] refactor(instructeurs import): extract import in a component --- .../groupes_ajout_component.html.haml | 3 -- app/components/procedure/import_component.rb | 41 +++++++++++++++++++ .../import_component/import_component.en.yml | 14 +++++++ .../import_component/import_component.fr.yml | 15 +++++++ .../import_component.html.haml} | 12 +++--- .../instructeurs_management_component.rb | 16 -------- ...nstructeurs_management_component.html.haml | 4 +- .../one_groupe_management_component.rb | 16 -------- .../one_groupe_management_component.html.haml | 3 +- .../groupe_instructeurs/en.yml | 16 -------- .../groupe_instructeurs/fr.yml | 16 -------- public/csv/en/import-groupe-test.csv | 5 --- public/csv/{fr => }/import-groupe-test.csv | 0 13 files changed, 79 insertions(+), 82 deletions(-) create mode 100644 app/components/procedure/import_component.rb create mode 100644 app/components/procedure/import_component/import_component.en.yml create mode 100644 app/components/procedure/import_component/import_component.fr.yml rename app/{views/administrateurs/groupe_instructeurs/_import.html.haml => components/procedure/import_component/import_component.html.haml} (63%) delete mode 100644 public/csv/en/import-groupe-test.csv rename public/csv/{fr => }/import-groupe-test.csv (100%) diff --git a/app/components/procedure/groupes_ajout_component/groupes_ajout_component.html.haml b/app/components/procedure/groupes_ajout_component/groupes_ajout_component.html.haml index 84804dc43..853158898 100644 --- a/app/components/procedure/groupes_ajout_component/groupes_ajout_component.html.haml +++ b/app/components/procedure/groupes_ajout_component/groupes_ajout_component.html.haml @@ -1,9 +1,6 @@ - content_for(:title, 'Ajout de groupes') %h1 Ajout de groupes d'instructeurs -= render partial: 'administrateurs/groupe_instructeurs/import', - locals: { procedure: @procedure } - %section = form_for :groupe_instructeur, method: :post do |f| diff --git a/app/components/procedure/import_component.rb b/app/components/procedure/import_component.rb new file mode 100644 index 000000000..f8917e1a1 --- /dev/null +++ b/app/components/procedure/import_component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Procedure::ImportComponent < ApplicationComponent + def initialize(procedure:) + @procedure = procedure + end + + def scope + @procedure.routing_enabled? ? 'groupes' : 'instructeurs' + end + + def template_url + if @procedure.routing_enabled? + '/csv/import-groupe-test.csv' + else + '/csv/import-instructeurs-test.csv' + end + end + + def template_detail + "#{File.extname(csv_template.to_path).upcase.delete_prefix('.')} – #{number_to_human_size(csv_template.size)}" + end + + def csv_max_size + Administrateurs::GroupeInstructeursController::CSV_MAX_SIZE + end + + private + + def csv_template + template_path.open + end + + def template_path + if @procedure.routing_enabled? + Rails.public_path.join('csv/import-groupe-test.csv') + else + Rails.public_path.join('csv/import-instructeurs-test.csv') + end + end +end diff --git a/app/components/procedure/import_component/import_component.en.yml b/app/components/procedure/import_component/import_component.en.yml new file mode 100644 index 000000000..3958a266b --- /dev/null +++ b/app/components/procedure/import_component/import_component.en.yml @@ -0,0 +1,14 @@ +en: + csv_import: + import_file: Import file + import_file_alert: All instructors added to the procedure will be notified by email. Do you want to continue? + file_to_import: File to import + file_size_limit: "File size limit : %{max_file_size}." + groupes: + title: Bulk Import / Export + notice_1_html: For the import, your csv file must have 2 columns (Group, Email) (see file model below). The file size must be less than %{csv_max_size}. + notice_2_html: The import does not overwrite existing groups and instructors. It only allows you to add them. + instructeurs: + title: Adding instructors with file import + notice_1_html: For the import, your csv file must have 1 column (Email) listing the email addresses of the instructors (see file model below). The file size must be less than %{csv_max_size}. + notice_2_html: The import does not overwrite existing instructors. It only allows you to add them. diff --git a/app/components/procedure/import_component/import_component.fr.yml b/app/components/procedure/import_component/import_component.fr.yml new file mode 100644 index 000000000..7773c6bae --- /dev/null +++ b/app/components/procedure/import_component/import_component.fr.yml @@ -0,0 +1,15 @@ +fr: + csv_import: + import_file: Importer le fichier + import_file_alert: Tous les instructeurs ajoutés à la procédure vont être notifiés par email. Voulez-vous continuer ? + file_to_import: Fichier à importer + download_template: Modèle à télécharger + file_size_limit: "Taille maximale : %{max_file_size}." + groupes: + title: Ajout de groupes / instructeurs avec import de fichier + notice_1_html: Pour l’import, votre fichier csv doit comporter 2 colonnes (Groupe, Email) (voir modèle de fichier ci-dessous). Le poids du fichier doit être inférieur à %{csv_max_size}. + notice_2_html: L’import n’écrase pas les groupes et instructeurs existants. Il permet uniquement d’en ajouter. + instructeurs: + title: Ajout d’instructeurs avec import de fichier + notice_1_html: Pour l’import, votre fichier csv doit comporter 1 seule colonne (Email) listant les adresses emails des instructeurs (voir modèle de fichier ci-dessous). Le poids du fichier doit être inférieur à %{csv_max_size}. + notice_2_html: L’import n’écrase pas les instructeurs existants. Il permet uniquement d’en ajouter. diff --git a/app/views/administrateurs/groupe_instructeurs/_import.html.haml b/app/components/procedure/import_component/import_component.html.haml similarity index 63% rename from app/views/administrateurs/groupe_instructeurs/_import.html.haml rename to app/components/procedure/import_component/import_component.html.haml index 344b721c4..cbf8bad57 100644 --- a/app/views/administrateurs/groupe_instructeurs/_import.html.haml +++ b/app/components/procedure/import_component/import_component.html.haml @@ -1,16 +1,14 @@ -- key = procedure.groupe_instructeurs.one? ? 'instructeurs' : 'groupes' %section.fr-accordion.fr-mb-3w %h3.fr-accordion__title %button.fr-accordion__btn{ "aria-controls" => "accordion-106", "aria-expanded" => "false" } - = t(".csv_import.#{key}.title") + = t(".csv_import.#{scope}.title") .fr-collapse#accordion-106 - - csv_max_size = Administrateurs::GroupeInstructeursController::CSV_MAX_SIZE .notice.fr-mb-1w - = t(".csv_import.#{key}.notice_1_html", csv_max_size: number_to_human_size(csv_max_size)) + = t(".csv_import.#{scope}.notice_1_html", csv_max_size: number_to_human_size(csv_max_size)) .notice - = t(".csv_import.#{key}.notice_2_html") + = t(".csv_import.#{scope}.notice_2_html") - = form_tag import_admin_procedure_groupe_instructeurs_path(procedure), method: :post, multipart: true, class: "mt-4 column" do + = form_tag import_admin_procedure_groupe_instructeurs_path(@procedure), method: :post, multipart: true, class: "mt-4 column" do %label.fr-label.font-weight-bold = t('.csv_import.file_to_import') .fr-download @@ -18,6 +16,8 @@ = t('.csv_import.download_template') %span.fr-download__detail = template_detail + .fr-hint-text.fr-mb-1w + = t('.csv_import.file_size_limit', max_file_size: number_to_human_size(csv_max_size)) .flex.column = file_field_tag :csv_file, required: true, accept: 'text/csv', size: "1", class: 'fr-mb-2w' = submit_tag t('.csv_import.import_file'), class: 'fr-btn fr-btn--tertiary', data: { disable_with: "Envoi...", confirm: t('.csv_import.import_file_alert') } diff --git a/app/components/procedure/instructeurs_management_component.rb b/app/components/procedure/instructeurs_management_component.rb index 4b30aa376..919a2ebad 100644 --- a/app/components/procedure/instructeurs_management_component.rb +++ b/app/components/procedure/instructeurs_management_component.rb @@ -8,20 +8,4 @@ class Procedure::InstructeursManagementComponent < ApplicationComponent @available_instructeur_emails = available_instructeur_emails @disabled_as_super_admin = disabled_as_super_admin end - - def csv_template - template_path.open - end - - def template_path - Rails.public_path.join('csv/import-instructeurs-test.csv') - end - - def template_url - template_path.to_s - end - - def template_detail - "#{File.extname(csv_template.to_path).upcase.delete_prefix('.')} – #{number_to_human_size(csv_template.size)}" - end end diff --git a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml index a0b6d0b7f..0ab12c41b 100644 --- a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml +++ b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml @@ -2,8 +2,8 @@ %h1.fr-h2= t('.title') .fr-icon-mail-line.fr-alert.fr-mb-3w %p= t('.notification_alert') -= render partial: 'administrateurs/groupe_instructeurs/import', - locals: { procedure: @procedure, template_url:, template_detail: } + += render Procedure::ImportComponent.new(procedure: @procedure) = render partial: 'administrateurs/groupe_instructeurs/instructeurs', locals: { procedure: @procedure, diff --git a/app/components/procedure/one_groupe_management_component.rb b/app/components/procedure/one_groupe_management_component.rb index c01b2ce61..abf6da24e 100644 --- a/app/components/procedure/one_groupe_management_component.rb +++ b/app/components/procedure/one_groupe_management_component.rb @@ -8,20 +8,4 @@ class Procedure::OneGroupeManagementComponent < ApplicationComponent @groupe_instructeur = groupe_instructeur @procedure = revision.procedure end - - def csv_template - template_path.open - end - - def template_path - Rails.public_path.join('csv/import-instructeurs-test.csv') - end - - def template_url - template_path.to_s - end - - def template_detail - "#{File.extname(csv_template.to_path).upcase.delete_prefix('.')} – #{number_to_human_size(csv_template.size)}" - end end diff --git a/app/components/procedure/one_groupe_management_component/one_groupe_management_component.html.haml b/app/components/procedure/one_groupe_management_component/one_groupe_management_component.html.haml index 2c2202529..6356b579e 100644 --- a/app/components/procedure/one_groupe_management_component/one_groupe_management_component.html.haml +++ b/app/components/procedure/one_groupe_management_component/one_groupe_management_component.html.haml @@ -1,8 +1,7 @@ %div{ id: dom_id(@groupe_instructeur, :routing) } %h1 Paramètres du groupe - = render partial: 'administrateurs/groupe_instructeurs/import', - locals: { procedure: @procedure, template_url:, template_detail: } + = render Procedure::ImportComponent.new(procedure: @procedure) = form_for @groupe_instructeur, url: admin_procedure_groupe_instructeur_path(@procedure, @groupe_instructeur), diff --git a/config/locales/views/administrateurs/groupe_instructeurs/en.yml b/config/locales/views/administrateurs/groupe_instructeurs/en.yml index ec58bcdf8..790906ba2 100644 --- a/config/locales/views/administrateurs/groupe_instructeurs/en.yml +++ b/config/locales/views/administrateurs/groupe_instructeurs/en.yml @@ -30,19 +30,3 @@ en: instructeur_emails: Email addresses of the instructors you want to assign to this procedure copy_paste_hint: "You can enter addresses individually, or copy-paste a list of addresses separated by semicolons into the field below (example: adress1@mail.com; adress2@mail.com; adress3@mail.com)." actions: Remove - import: - csv_import: - import_file: Import file - import_file_alert: All instructors added to the procedure will be notified by email. Do you want to continue? - file_to_import: File to import - groupes: - title: Bulk Import / Export - notice_1_html: For the import, your csv file must have 2 columns (Group, Email) (see file model below). The file size must be less than %{csv_max_size}. - notice_2_html: The import does not overwrite existing groups and instructors. It only allows you to add them. - instructeurs: - title: Adding instructors with file import - notice_1_html: For the import, your csv file must have 1 column (Email) listing the email addresses of the instructors (see file model below). The file size must be less than %{csv_max_size}. - notice_2_html: The import does not overwrite existing instructors. It only allows you to add them. - existing_groupe: - one: "%{count} groupe existe" - other: "%{count} groupes existent" diff --git a/config/locales/views/administrateurs/groupe_instructeurs/fr.yml b/config/locales/views/administrateurs/groupe_instructeurs/fr.yml index e28d9b16b..0594811e3 100644 --- a/config/locales/views/administrateurs/groupe_instructeurs/fr.yml +++ b/config/locales/views/administrateurs/groupe_instructeurs/fr.yml @@ -38,22 +38,6 @@ fr: copy_paste_hint: "Vous pouvez saisir les adresses individuellement, ou bien copier-coller dans le champ ci-dessous une liste d’adresses séparées par des points-virgules (exemple : adresse1@mail.com; adresse2@mail.com; adresse3@mail.com)." actions: Retirer import: - csv_import: - import_file: Importer le fichier - import_file_alert: Tous les instructeurs ajoutés à la procédure vont être notifiés par email. Voulez-vous continuer ? - file_to_import: Fichier à importer - download_template: Modèle à télécharger - groupes: - title: Import / Export en masse - notice_1_html: Pour l’import, votre fichier csv doit comporter 2 colonnes (Groupe, Email) (voir modèle de fichier ci-dessous). Le poids du fichier doit être inférieur à %{csv_max_size}. - notice_2_html: L’import n’écrase pas les groupes et instructeurs existants. Il permet uniquement d’en ajouter. - instructeurs: - title: Ajout d’instructeurs avec import de fichier - notice_1_html: Pour l’import, votre fichier csv doit comporter 1 seule colonne (Email) listant les adresses emails des instructeurs (voir modèle de fichier ci-dessous). Le poids du fichier doit être inférieur à %{csv_max_size}. - notice_2_html: L’import n’écrase pas les instructeurs existants. Il permet uniquement d’en ajouter. - existing_groupe: - one: "%{count} groupe existe" - other: "%{count} groupes existent" groupe: one: "%{count} groupe" other: "%{count} groupes" diff --git a/public/csv/en/import-groupe-test.csv b/public/csv/en/import-groupe-test.csv deleted file mode 100644 index 1045de444..000000000 --- a/public/csv/en/import-groupe-test.csv +++ /dev/null @@ -1,5 +0,0 @@ -Email,Groupe -camilia@gouv.fr,Nord -kara@gouv.fr,Finistère -simon@gouv.fr,Isère -pauline@gouv.fr,Bouches-du-Rhône \ No newline at end of file diff --git a/public/csv/fr/import-groupe-test.csv b/public/csv/import-groupe-test.csv similarity index 100% rename from public/csv/fr/import-groupe-test.csv rename to public/csv/import-groupe-test.csv From ec7aea50b36f2d15d9bbedb7af236bf69661e047 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 25 Sep 2024 17:45:51 +0200 Subject: [PATCH 1207/1532] style(instructeurs management): update instructeurs view --- .../import_component/import_component.html.haml | 2 +- .../groupe_instructeurs/_instructeurs.html.haml | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/components/procedure/import_component/import_component.html.haml b/app/components/procedure/import_component/import_component.html.haml index cbf8bad57..14b741740 100644 --- a/app/components/procedure/import_component/import_component.html.haml +++ b/app/components/procedure/import_component/import_component.html.haml @@ -20,4 +20,4 @@ = t('.csv_import.file_size_limit', max_file_size: number_to_human_size(csv_max_size)) .flex.column = file_field_tag :csv_file, required: true, accept: 'text/csv', size: "1", class: 'fr-mb-2w' - = submit_tag t('.csv_import.import_file'), class: 'fr-btn fr-btn--tertiary', data: { disable_with: "Envoi...", confirm: t('.csv_import.import_file_alert') } + = submit_tag t('.csv_import.import_file'), class: 'fr-btn fr-btn--tertiary', data: { disable_with: "Envoi...", confirm: t('.csv_import.import_file_alert') }, disabled: true diff --git a/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml b/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml index 8c026dbd8..968c6b938 100644 --- a/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/_instructeurs.html.haml @@ -23,15 +23,16 @@ = button_to export_groupe_instructeurs_admin_procedure_groupe_instructeurs_path(procedure, format: :csv), method: :get, class: 'fr-btn fr-btn--tertiary fr-btn--icon-left fr-icon-download-line' do Exporter la liste (.CSV) - %table.fr-table.width-100 + %table.fr-table.fr-table--bordered.width-100 %thead %tr - %th{ colspan: 2 }= t('.instructeurs_title') + %th= t('.title') + %th.text-right= t('.actions') %tbody - instructeurs.each do |instructeur| %tr %td - = dsfr_icon('fr-icon-user-fill') + = dsfr_icon('fr-icon-user-line') #{instructeur.email} - confirmation_message = procedure.routing_enabled? ? "Êtes-vous sûr de vouloir retirer l’instructeur « #{instructeur.email} » du groupe « #{groupe_instructeur.label} » ?" : "Êtes-vous sûr de vouloir retirer l’instructeur « #{instructeur.email} » de la démarche ?" @@ -40,6 +41,6 @@ { method: :delete, data: { confirm: confirmation_message }, params: { instructeur: { id: instructeur.id }}, - class: 'fr-btn fr-btn--secondary' } + class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-subtract-line' } = paginate instructeurs, views_prefix: 'shared' From 7af934daf5739d091e9690f96b47b6eb789f6d35 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 30 Sep 2024 10:14:27 +0200 Subject: [PATCH 1208/1532] feat(instructeurs import): display submit button only if file uploaded --- .../import_component.html.haml | 4 ++-- .../enable_submit_if_uploaded_controller.tsx | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 app/javascript/controllers/enable_submit_if_uploaded_controller.tsx diff --git a/app/components/procedure/import_component/import_component.html.haml b/app/components/procedure/import_component/import_component.html.haml index 14b741740..85eb3ffbf 100644 --- a/app/components/procedure/import_component/import_component.html.haml +++ b/app/components/procedure/import_component/import_component.html.haml @@ -8,7 +8,7 @@ .notice = t(".csv_import.#{scope}.notice_2_html") - = form_tag import_admin_procedure_groupe_instructeurs_path(@procedure), method: :post, multipart: true, class: "mt-4 column" do + = form_tag import_admin_procedure_groupe_instructeurs_path(@procedure), method: :post, multipart: true, class: "mt-4 column", "data-controller" => "enable-submit-if-uploaded" do %label.fr-label.font-weight-bold = t('.csv_import.file_to_import') .fr-download @@ -20,4 +20,4 @@ = t('.csv_import.file_size_limit', max_file_size: number_to_human_size(csv_max_size)) .flex.column = file_field_tag :csv_file, required: true, accept: 'text/csv', size: "1", class: 'fr-mb-2w' - = submit_tag t('.csv_import.import_file'), class: 'fr-btn fr-btn--tertiary', data: { disable_with: "Envoi...", confirm: t('.csv_import.import_file_alert') }, disabled: true + = submit_tag t('.csv_import.import_file'), class: 'fr-btn fr-btn--tertiary', id: 'submit-button', data: { disable_with: "Envoi...", confirm: t('.csv_import.import_file_alert') }, disabled: true diff --git a/app/javascript/controllers/enable_submit_if_uploaded_controller.tsx b/app/javascript/controllers/enable_submit_if_uploaded_controller.tsx new file mode 100644 index 000000000..7135f1da0 --- /dev/null +++ b/app/javascript/controllers/enable_submit_if_uploaded_controller.tsx @@ -0,0 +1,21 @@ +import { Controller } from '@hotwired/stimulus'; +import { enable, disable } from '@utils'; + +export class EnableSubmitIfUploadedController extends Controller { + connect() { + const fileInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + const submitButton = document.getElementById( + 'submit-button' + ) as HTMLButtonElement; + + fileInput.addEventListener('change', function () { + if (fileInput.files && fileInput.files.length > 0) { + enable(submitButton); + } else { + disable(submitButton); + } + }); + } +} From 60be8d68fca9215a23729cbd3350192b74796efe Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 30 Sep 2024 16:25:20 +0200 Subject: [PATCH 1209/1532] test(instructeurs management): update tests --- spec/system/routing/rules_full_scenario_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/system/routing/rules_full_scenario_spec.rb b/spec/system/routing/rules_full_scenario_spec.rb index d688f2457..ee4620705 100644 --- a/spec/system/routing/rules_full_scenario_spec.rb +++ b/spec/system/routing/rules_full_scenario_spec.rb @@ -75,7 +75,7 @@ describe 'The routing with rules', js: true do alain = User.find_by(email: 'alain@gouv.fr').instructeur # add inactive groupe - click_on 'Ajout de groupes' + visit ajout_admin_procedure_groupe_instructeurs_path(procedure) fill_in 'Nouveau groupe', with: 'non visible car inactif' click_on 'Ajouter' expect(page).to have_text('Le groupe d’instructeurs « non visible car inactif » a été créé. ') @@ -121,7 +121,7 @@ describe 'The routing with rules', js: true do procedure.groupe_instructeurs.where(closed: false).each { |gi| wait_until { gi.reload.routing_rule.present? } } # add a group without routing rules - click_on 'Ajout de groupes' + visit ajout_admin_procedure_groupe_instructeurs_path(procedure) fill_in 'Nouveau groupe', with: 'artistique' click_on 'Ajouter' expect(page).to have_text('Le groupe d’instructeurs « artistique » a été créé. ') From b75f2125de643e5f95d594bc141500db8e3ca95a Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 14 Oct 2024 14:52:44 +0200 Subject: [PATCH 1210/1532] refactor(import component): remove path duplication --- app/components/procedure/import_component.rb | 8 ++------ .../procedure/import_component/import_component.html.haml | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/app/components/procedure/import_component.rb b/app/components/procedure/import_component.rb index f8917e1a1..0638203ac 100644 --- a/app/components/procedure/import_component.rb +++ b/app/components/procedure/import_component.rb @@ -9,7 +9,7 @@ class Procedure::ImportComponent < ApplicationComponent @procedure.routing_enabled? ? 'groupes' : 'instructeurs' end - def template_url + def template_file if @procedure.routing_enabled? '/csv/import-groupe-test.csv' else @@ -32,10 +32,6 @@ class Procedure::ImportComponent < ApplicationComponent end def template_path - if @procedure.routing_enabled? - Rails.public_path.join('csv/import-groupe-test.csv') - else - Rails.public_path.join('csv/import-instructeurs-test.csv') - end + Rails.public_path.join(template_file.delete_prefix('/')) end end diff --git a/app/components/procedure/import_component/import_component.html.haml b/app/components/procedure/import_component/import_component.html.haml index 85eb3ffbf..97029f009 100644 --- a/app/components/procedure/import_component/import_component.html.haml +++ b/app/components/procedure/import_component/import_component.html.haml @@ -12,7 +12,7 @@ %label.fr-label.font-weight-bold = t('.csv_import.file_to_import') .fr-download - = link_to template_url, {class: "fr-download__link", download: ''} do + = link_to template_file, {class: "fr-download__link", download: ''} do = t('.csv_import.download_template') %span.fr-download__detail = template_detail From 3ed7e7d2e02c494f98fcffe583362e2522a46556 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 14 Oct 2024 15:11:08 +0200 Subject: [PATCH 1211/1532] feat(instructeurs management): update notification alert if procedure is published --- .../instructeurs_management_component.en.yml | 4 +++- .../instructeurs_management_component.fr.yml | 4 +++- .../instructeurs_management_component.html.haml | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.en.yml b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.en.yml index bba6eec76..60cd0618c 100644 --- a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.en.yml +++ b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.en.yml @@ -1,4 +1,6 @@ --- en: title: Instructors management - notification_alert: Even if your procedure is still in test / unpublished, all the instructors you add to the procedure will be notified by email. + notification_alert: + non_publiee: Even if your procedure is still in test / unpublished, all the instructors you add to the procedure will be notified by email. + publiee: All the instructors you add to the procedure will be notified by email. diff --git a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.fr.yml b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.fr.yml index adbb2780a..f3a044d78 100644 --- a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.fr.yml +++ b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.fr.yml @@ -1,4 +1,6 @@ --- fr: title: Gestion des instructeurs - notification_alert: Même si votre démarche est encore en test / non publiée, tous les instructeurs que vous ajouterez à la démarche seront notifiés par email. + notification_alert: + non_publiee: Même si votre démarche est encore en test / non publiée, tous les instructeurs que vous ajouterez à la démarche seront notifiés par email. + publiee: Tous les instructeurs que vous ajouterez à la démarche seront notifiés par email. diff --git a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml index 0ab12c41b..b6726a33c 100644 --- a/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml +++ b/app/components/procedure/instructeurs_management_component/instructeurs_management_component.html.haml @@ -1,7 +1,7 @@ - content_for(:title, t('.title')) %h1.fr-h2= t('.title') .fr-icon-mail-line.fr-alert.fr-mb-3w - %p= t('.notification_alert') + %p= t(@procedure.publiee? ? '.notification_alert.publiee' : '.notification_alert.non_publiee') = render Procedure::ImportComponent.new(procedure: @procedure) From 1c32a30b80bc750c0373f0d6611b5a5153fc499e Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 14 Oct 2024 15:36:18 +0200 Subject: [PATCH 1212/1532] fix(filters): drop down list filters can have much longer values. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notre infra supporte des urls d'au moins 8000 caractères, probablement plus encore, donc on est large. --- app/models/procedure_presentation.rb | 2 +- .../instructeurs/procedures_controller_spec.rb | 4 ++-- spec/models/procedure_presentation_spec.rb | 12 ++++++++---- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 828827ecd..d1fb52ceb 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -8,7 +8,7 @@ class ProcedurePresentation < ApplicationRecord SLASH = '/' TYPE_DE_CHAMP = 'type_de_champ' - FILTERS_VALUE_MAX_LENGTH = 100 + FILTERS_VALUE_MAX_LENGTH = 4048 # https://www.postgresql.org/docs/current/datatype-numeric.html PG_INTEGER_MAX_VALUE = 2147483647 diff --git a/spec/controllers/instructeurs/procedures_controller_spec.rb b/spec/controllers/instructeurs/procedures_controller_spec.rb index 1dcb18a35..c9e1a8894 100644 --- a/spec/controllers/instructeurs/procedures_controller_spec.rb +++ b/spec/controllers/instructeurs/procedures_controller_spec.rb @@ -906,12 +906,12 @@ describe Instructeurs::ProceduresController, type: :controller do subject do column = procedure.find_column(label: "Nom").id - post :add_filter, params: { procedure_id: procedure.id, column:, value: "n" * 110, statut: "a-suivre" } + post :add_filter, params: { procedure_id: procedure.id, column:, value: "n" * 4100, statut: "a-suivre" } end it 'should render the error' do subject - expect(flash.alert[0]).to include("Le filtre Nom est trop long (maximum: 100 caractères)") + expect(flash.alert[0]).to include("Le filtre Nom est trop long (maximum: 4048 caractères)") end end end diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index 1a42bb1a2..a160e0a5e 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -42,12 +42,16 @@ describe ProcedurePresentation do end context 'of filters' do - it { expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "user", column: "reset_password_token", "order" => "asc" }] })).to be_invalid } - it { expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "user", column: "email", "value" => "exceedingly long filter value" * 10 }] })).to be_invalid } + it do + expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "user", column: "reset_password_token", "order" => "asc" }] })).to be_invalid + expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "user", column: "email", "value" => "exceedingly long filter value" * 1000 }] })).to be_invalid + end describe 'check_filters_max_integer' do - it { expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "self", column: "id", "value" => ProcedurePresentation::PG_INTEGER_MAX_VALUE.to_s }] })).to be_invalid } - it { expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "self", column: "id", "value" => (ProcedurePresentation::PG_INTEGER_MAX_VALUE - 1).to_s }] })).to be_valid } + it do + expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "self", column: "id", "value" => ProcedurePresentation::PG_INTEGER_MAX_VALUE.to_s }] })).to be_invalid + expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "self", column: "id", "value" => (ProcedurePresentation::PG_INTEGER_MAX_VALUE - 1).to_s }] })).to be_valid + end end end end From 366c02dbb7eaad4e1a67e6a1ae2ddfa6e06e8683 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 14 Oct 2024 09:54:17 +0200 Subject: [PATCH 1213/1532] fix(typo): use english for normalized addresses component --- app/services/api_geo_service.rb | 34 ++++++++++++------- .../controllers/champs/rna_controller_spec.rb | 2 ++ spec/models/champs/rnf_champ_spec.rb | 2 ++ .../populate_rna_json_value_task_spec.rb | 2 ++ .../populate_rnf_json_value_task_spec.rb | 4 +++ .../populate_siret_value_json_task_spec.rb | 4 ++- 6 files changed, 35 insertions(+), 13 deletions(-) diff --git a/app/services/api_geo_service.rb b/app/services/api_geo_service.rb index 5a8e0bcc1..c12804c2a 100644 --- a/app/services/api_geo_service.rb +++ b/app/services/api_geo_service.rb @@ -140,7 +140,7 @@ class APIGeoService postal_code = address[:code_postal] city_name_fallback = address[:commune] city_code = address[:code_insee] - departement_code, region_code = if postal_code.present? && city_code.present? + department_code, region_code = if postal_code.present? && city_code.present? commune = communes_by_postal_code(postal_code).find { _1[:code] == city_code } if commune.present? [commune[:departement_code], commune[:region_code]] @@ -149,15 +149,18 @@ class APIGeoService end end + department_name = departement_name(department_code) { street_number: address[:numero_voie], street_name: address[:libelle_voie], street_address: address[:libelle_voie].present? ? [address[:numero_voie], address[:type_voie], address[:libelle_voie]].compact.join(' ') : nil, postal_code: postal_code.presence || '', - city_name: safely_normalize_city_name(departement_code, city_code, city_name_fallback), + city_name: safely_normalize_city_name(department_code, city_code, city_name_fallback), city_code: city_code.presence || '', - departement_code:, - departement_name: departement_name(departement_code), + departement_code: department_code, + department_code:, + departement_name: department_name, + department_name:, region_code:, region_name: region_name(region_code) } @@ -167,7 +170,7 @@ class APIGeoService postal_code = address[:postalCode] city_name_fallback = address[:cityName] city_code = address[:cityCode] - departement_code, region_code = if postal_code.present? && city_code.present? + department_code, region_code = if postal_code.present? && city_code.present? commune = communes_by_postal_code(postal_code).find { _1[:code] == city_code } if commune.present? [commune[:departement_code], commune[:region_code]] @@ -175,16 +178,19 @@ class APIGeoService [] end end + department_name = departement_name(department_code) { street_number: address[:streetNumber], street_name: address[:streetName], street_address: address[:streetAddress], postal_code: postal_code.presence || '', - city_name: safely_normalize_city_name(departement_code, city_code, city_name_fallback), + city_name: safely_normalize_city_name(department_code, city_code, city_name_fallback), city_code: city_code.presence || '', - departement_code:, - departement_name: departement_name(departement_code), + departement_code: department_code, + department_code:, + departement_name: department_name, + department_name:, region_code:, region_name: region_name(region_code) } @@ -194,7 +200,7 @@ class APIGeoService postal_code = etablissement.code_postal city_name_fallback = etablissement.localite.presence || '' city_code = etablissement.code_insee_localite - departement_code, region_code = if postal_code.present? && city_code.present? + department_code, region_code = if postal_code.present? && city_code.present? commune = communes_by_postal_code(postal_code).find { _1[:code] == city_code } if commune.present? [commune[:departement_code], commune[:region_code]] @@ -203,15 +209,19 @@ class APIGeoService end end + department_name = departement_name(department_code) + { street_number: etablissement.numero_voie, street_name: etablissement.nom_voie, street_address: etablissement.nom_voie.present? ? [etablissement.numero_voie, etablissement.type_voie, etablissement.nom_voie].compact.join(' ') : nil, postal_code: postal_code.presence || '', - city_name: safely_normalize_city_name(departement_code, city_code, city_name_fallback), + city_name: safely_normalize_city_name(department_code, city_code, city_name_fallback), city_code: city_code.presence || '', - departement_code:, - departement_name: departement_name(departement_code), + departement_code: department_code, + department_code:, + departement_name: department_name, + department_name:, region_code:, region_name: region_name(region_code) } diff --git a/spec/controllers/champs/rna_controller_spec.rb b/spec/controllers/champs/rna_controller_spec.rb index a4a09377b..9b149e8dc 100644 --- a/spec/controllers/champs/rna_controller_spec.rb +++ b/spec/controllers/champs/rna_controller_spec.rb @@ -126,7 +126,9 @@ describe Champs::RNAController, type: :controller do "city_code" => "75108", "city_name" => "Paris", "departement_code" => nil, # might seem broken lookup, but no, it's anonymized + "department_code" => nil, # might seem broken lookup, but no, it's anonymized "departement_name" => nil, + "department_name" => nil, "postal_code" => "75009", "region_code" => nil, "region_name" => nil, diff --git a/spec/models/champs/rnf_champ_spec.rb b/spec/models/champs/rnf_champ_spec.rb index d44e47fe4..f83759a83 100644 --- a/spec/models/champs/rnf_champ_spec.rb +++ b/spec/models/champs/rnf_champ_spec.rb @@ -109,7 +109,9 @@ describe Champs::RNFChamp, type: :model do :city_name => "Paris 15e Arrondissement", :city_code => "75115", :departement_code => "75", + :department_code => "75", :departement_name => "Paris", + :department_name => "Paris", :region_code => "11", :region_name => "Île-de-France" } diff --git a/spec/tasks/maintenance/populate_rna_json_value_task_spec.rb b/spec/tasks/maintenance/populate_rna_json_value_task_spec.rb index 9103b9a95..bdb3fd559 100644 --- a/spec/tasks/maintenance/populate_rna_json_value_task_spec.rb +++ b/spec/tasks/maintenance/populate_rna_json_value_task_spec.rb @@ -29,7 +29,9 @@ module Maintenance "city_name" => "Paris", "city_code" => "75108", "departement_code" => nil, + "department_code" => nil, "departement_name" => nil, + "department_name" => nil, "region_code" => nil, "region_name" => nil }) diff --git a/spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb b/spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb index de66e0033..a71eb25ad 100644 --- a/spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb +++ b/spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb @@ -62,7 +62,9 @@ module Maintenance "city_name" => "Paris 15e Arrondissement", "city_code" => "75115", "departement_code" => "75", + "department_code" => "75", "departement_name" => "Paris", + "department_name" => "Paris", "region_code" => "11", "region_name" => "Île-de-France" }) @@ -86,7 +88,9 @@ module Maintenance "city_name" => "Paris 15e Arrondissement", "city_code" => "75115", "departement_code" => "75", + "department_code" => "75", "departement_name" => "Paris", + "department_name" => "Paris", "region_code" => "11", "region_name" => "Île-de-France" }) diff --git a/spec/tasks/maintenance/populate_siret_value_json_task_spec.rb b/spec/tasks/maintenance/populate_siret_value_json_task_spec.rb index 76759ed40..f5e1a29a6 100644 --- a/spec/tasks/maintenance/populate_siret_value_json_task_spec.rb +++ b/spec/tasks/maintenance/populate_siret_value_json_task_spec.rb @@ -23,7 +23,9 @@ module Maintenance "street_number" => "6", "street_address" => "6 RUE RAOUL NORDLING", "departement_code" => "92", - "departement_name" => "Hauts-de-Seine" + "department_code" => "92", + "departement_name" => "Hauts-de-Seine", + "department_name" => "Hauts-de-Seine" }) end end From 47a4f40939d3a6c3a7b2b5bea00f7adc826f312b Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Tue, 15 Oct 2024 11:18:58 +0200 Subject: [PATCH 1214/1532] destroy export_templates when destroy groupe_instructeur --- app/models/groupe_instructeur.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/groupe_instructeur.rb b/app/models/groupe_instructeur.rb index 370dd69c9..b717090f8 100644 --- a/app/models/groupe_instructeur.rb +++ b/app/models/groupe_instructeur.rb @@ -11,7 +11,7 @@ class GroupeInstructeur < ApplicationRecord has_many :batch_operations, through: :dossiers, source: :batch_operations has_many :assignments, class_name: 'DossierAssignment', dependent: :nullify, inverse_of: :groupe_instructeur has_many :previous_assignments, class_name: 'DossierAssignment', dependent: :nullify, inverse_of: :previous_groupe_instructeur - has_many :export_templates + has_many :export_templates, dependent: :destroy has_and_belongs_to_many :exports, dependent: :destroy has_one :defaut_procedure, -> { with_discarded }, class_name: 'Procedure', foreign_key: :defaut_groupe_instructeur_id, dependent: :nullify, inverse_of: :defaut_groupe_instructeur From bd32f5693d0808dff60c7b30a3522f572f66b1fe Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Fri, 27 Sep 2024 15:44:57 +0200 Subject: [PATCH 1215/1532] refactor(repetition): remove parent_id --- app/models/champ.rb | 19 +-------- app/models/champs/repetition_champ.rb | 6 +-- app/models/concerns/dossier_champs_concern.rb | 17 +++----- app/models/concerns/dossier_clone_concern.rb | 32 ++++----------- app/models/concerns/dossier_rebase_concern.rb | 28 +++++-------- app/models/dossier.rb | 12 ++---- app/models/dossier_preloader.rb | 12 ------ ...ue_dossier_with_invalid_repetition_task.rb | 24 ----------- ...0240321081721_remove_champs_foreign_key.rb | 8 ++++ db/schema.rb | 2 - .../editable_champ_component_spec.rb | 2 +- .../instructeurs/dossiers_controller_spec.rb | 4 +- spec/factories/champ.rb | 4 +- spec/graphql/annotation_spec.rb | 4 +- spec/lib/recovery/dossier_life_cycle_spec.rb | 2 +- .../repetition_presentation_spec.rb | 2 +- spec/models/champ_spec.rb | 41 ++++--------------- spec/models/champs/repetition_champ_spec.rb | 2 +- .../concerns/dossier_champs_concern_spec.rb | 10 +---- .../concerns/dossier_clone_concern_spec.rb | 11 +++-- .../concerns/dossier_rebase_concern_spec.rb | 4 +- spec/models/dossier_preloader_spec.rb | 7 ++-- .../pieces_justificatives_service_spec.rb | 8 ++-- .../procedure_export_service_zip_spec.rb | 6 +-- ...ssier_with_invalid_repetition_task_spec.rb | 27 ------------ 25 files changed, 72 insertions(+), 222 deletions(-) delete mode 100644 app/tasks/maintenance/rescue_dossier_with_invalid_repetition_task.rb create mode 100644 db/migrate/20240321081721_remove_champs_foreign_key.rb delete mode 100644 spec/tasks/maintenance/rescue_dossier_with_invalid_repetition_task_spec.rb diff --git a/app/models/champ.rb b/app/models/champ.rb index 9daa99d41..e193a7a10 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -4,19 +4,17 @@ class Champ < ApplicationRecord include ChampConditionalConcern include ChampsValidateConcern - self.ignored_columns += [:type_de_champ_id] + self.ignored_columns += [:type_de_champ_id, :parent_id] attr_readonly :stable_id belongs_to :dossier, inverse_of: false, touch: true, optional: false - belongs_to :parent, class_name: 'Champ', optional: true has_many_attached :piece_justificative_file # We declare champ specific relationships (Champs::CarteChamp, Champs::SiretChamp and Champs::RepetitionChamp) # here because otherwise we can't easily use includes in our queries. has_many :geo_areas, -> { order(:created_at) }, dependent: :destroy, inverse_of: :champ belongs_to :etablissement, optional: true, dependent: :destroy - has_many :champs, foreign_key: :parent_id, inverse_of: :parent delegate :procedure, to: :dossier @@ -79,13 +77,8 @@ class Champ < ApplicationRecord delegate :used_by_routing_rules?, to: :type_de_champ scope :updated_since?, -> (date) { where('champs.updated_at > ?', date) } - scope :public_only, -> { where(private: false) } - scope :private_only, -> { where(private: true) } - scope :root, -> { where(parent_id: nil) } scope :prefilled, -> { where(prefilled: true) } - before_create :set_dossier_id, if: :needs_dossier_id? - before_validation :set_dossier_id, if: :needs_dossier_id? before_save :cleanup_if_empty before_save :normalize after_update_commit :fetch_external_data_later @@ -232,7 +225,7 @@ class Champ < ApplicationRecord end def clone(fork = false) - champ_attributes = [:parent_id, :private, :row_id, :type, :stable_id, :stream] + champ_attributes = [:private, :row_id, :type, :stable_id, :stream] value_attributes = fork || !private? ? [:value, :value_json, :data, :external_id] : [] relationships = fork || !private? ? [:etablissement, :geo_areas] : [] @@ -265,14 +258,6 @@ class Champ < ApplicationRecord type_de_champ.html_id(row_id) end - def needs_dossier_id? - !dossier_id && parent_id - end - - def set_dossier_id - self.dossier_id = parent.dossier_id - end - def cleanup_if_empty if fetch_external_data? && persisted? && external_id_changed? self.data = nil diff --git a/app/models/champs/repetition_champ.rb b/app/models/champs/repetition_champ.rb index e289171dc..cfa6ff5ab 100644 --- a/app/models/champs/repetition_champ.rb +++ b/app/models/champs/repetition_champ.rb @@ -12,11 +12,7 @@ class Champs::RepetitionChamp < Champ end def add_row(updated_by:) - # TODO: clean this up when parent_id is deprecated - row_id, added_champs = dossier.repetition_add_row(type_de_champ, updated_by:) - self.champs << added_champs - dossier.champs.reload if dossier.persisted? - row_id + dossier.repetition_add_row(type_de_champ, updated_by:) end def remove_row(row_id, updated_by:) diff --git a/app/models/concerns/dossier_champs_concern.rb b/app/models/concerns/dossier_champs_concern.rb index f6e3f4b96..657b93325 100644 --- a/app/models/concerns/dossier_champs_concern.rb +++ b/app/models/concerns/dossier_champs_concern.rb @@ -40,7 +40,7 @@ module DossierChampsConcern end def project_rows_for(type_de_champ) - [] if !type_de_champ.repetition? + return [] if !type_de_champ.repetition? children = revision.children_of(type_de_champ) row_ids = repetition_row_ids(type_de_champ) @@ -84,7 +84,7 @@ module DossierChampsConcern end def repetition_row_ids(type_de_champ) - [] if !type_de_champ.repetition? + return [] if !type_de_champ.repetition? stable_ids = revision.children_of(type_de_champ).map(&:stable_id) champs.filter { _1.stable_id.in?(stable_ids) && _1.row_id.present? } @@ -98,10 +98,10 @@ module DossierChampsConcern row_id = ULID.generate types_de_champ = revision.children_of(type_de_champ) - # TODO: clean this up when parent_id is deprecated - added_champs = types_de_champ.map { _1.build_champ(row_id:, updated_by:) } + self.champs += types_de_champ.map { _1.build_champ(row_id:, updated_by:) } + champs.reload if persisted? @champs_by_public_id = nil - [row_id, added_champs] + row_id end def repetition_remove_row(type_de_champ, row_id, updated_by:) @@ -158,13 +158,6 @@ module DossierChampsConcern attributes[:data] = nil end - parent = revision.parent_of(type_de_champ) - if parent.present? - attributes[:parent] = champs.find { _1.stable_id == parent.stable_id } - else - attributes[:parent] = nil - end - @champs_by_public_id = nil [champ, attributes] diff --git a/app/models/concerns/dossier_clone_concern.rb b/app/models/concerns/dossier_clone_concern.rb index 6d7b185e4..07750548c 100644 --- a/app/models/concerns/dossier_clone_concern.rb +++ b/app/models/concerns/dossier_clone_concern.rb @@ -101,7 +101,6 @@ module DossierCloneConcern kopy.state = Dossier.states.fetch(:brouillon) kopy.champs = cloned_champs.values.map do |(_, champ)| champ.dossier = kopy - champ.parent = cloned_champs[champ.parent_id].second if champ.child? champ end end @@ -152,33 +151,16 @@ module DossierCloneConcern def apply_diff(diff) champs_index = (champs_for_revision(scope: :public) + diff[:added]).index_by(&:public_id) - diff[:added].each do |champ| - if champ.child? - champ.update_columns(dossier_id: id, parent_id: champs_index.fetch(champ.parent.public_id).id) - else - champ.update_column(:dossier_id, id) - end - end + diff[:added].each { _1.update_column(:dossier_id, id) } - champs_to_remove = [] + # a bit of a hack to work around unicity index + remove_group_id = ULID.generate diff[:updated].each do |champ| - old_champ = champs_index.fetch(champ.public_id) - champs_to_remove << old_champ - - if champ.child? - # we need to do that in order to avoid a foreign key constraint - old_champ.update(row_id: nil) - champ.update_columns(dossier_id: id, parent_id: champs_index.fetch(champ.parent.public_id).id) - else - champ.update_column(:dossier_id, id) - end + champs_index.fetch(champ.public_id).update(row_id: remove_group_id) + champ.update_column(:dossier_id, id) end - champs_to_remove += diff[:removed] - children_champs_to_remove, root_champs_to_remove = champs_to_remove.partition(&:child?) - - children_champs_to_remove.each(&:destroy!) - Champ.where(parent_id: root_champs_to_remove.map(&:id)).destroy_all - root_champs_to_remove.each(&:destroy!) + Champ.where(row_id: remove_group_id).destroy_all + diff[:removed].each(&:destroy!) end end diff --git a/app/models/concerns/dossier_rebase_concern.rb b/app/models/concerns/dossier_rebase_concern.rb index 7ee91914d..b0932166b 100644 --- a/app/models/concerns/dossier_rebase_concern.rb +++ b/app/models/concerns/dossier_rebase_concern.rb @@ -65,9 +65,7 @@ module DossierRebaseConcern .tap { _1.default = Champ.none } # remove champ - children_champ, root_champ = changes_by_op[:remove].partition(&:child?) - children_champ.each { champs_by_stable_id[_1.stable_id].destroy_all } - root_champ.each { champs_by_stable_id[_1.stable_id].destroy_all } + changes_by_op[:remove].each { champs_by_stable_id[_1.stable_id].destroy_all } # update champ changes_by_op[:update].each { apply(_1, champs_by_stable_id[_1.stable_id]) } @@ -78,8 +76,6 @@ module DossierRebaseConcern # add champ (after changing dossier revision to avoid errors) changes_by_op[:add] .map { target_coordinates_by_stable_id[_1.stable_id] } - # add parent champs first so we can then add children - .sort_by { _1.child? ? 1 : 0 } .each { add_new_champs_for_revision(_1) } end @@ -119,28 +115,24 @@ module DossierRebaseConcern def add_new_champs_for_revision(target_coordinate) if target_coordinate.child? - # If this type de champ is a child, we create a new champ for each row of the parent - parent_stable_id = target_coordinate.parent.stable_id + row_ids = repetition_row_ids(target_coordinate.parent.type_de_champ) - champs.filter { _1.stable_id == parent_stable_id }.each do |champ_repetition| - if champ_repetition.champs.present? - champ_repetition.champs.map(&:row_id).uniq.each do |row_id| - champs << create_champ(target_coordinate, champ_repetition, row_id:) - end - elsif target_coordinate.parent.mandatory? - champs << create_champ(target_coordinate, champ_repetition, row_id: ULID.generate) + if row_ids.present? + row_ids.each do |row_id| + create_champ(target_coordinate, row_id:) end + elsif target_coordinate.parent.mandatory? + create_champ(target_coordinate, row_id: ULID.generate) end else - create_champ(target_coordinate, self) + create_champ(target_coordinate) end end - def create_champ(target_coordinate, parent, row_id: nil) - target_coordinate + def create_champ(target_coordinate, row_id: nil) + self.champs << target_coordinate .type_de_champ .build_champ(rebased_at: Time.zone.now, row_id:) - .tap { parent.champs << _1 } end def purge_piece_justificative_file(champ) diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 03658337a..b2d495d01 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -45,11 +45,7 @@ class Dossier < ApplicationRecord has_one_attached :justificatif_motivation - has_many :champs - # We have to remove champs in a particular order - champs with a reference to a parent have to be - # removed first, otherwise we get a foreign key constraint error. - has_many :champs_to_destroy, -> { order(:parent_id) }, class_name: 'Champ', inverse_of: false, dependent: :destroy - + has_many :champs, dependent: :destroy has_many :commentaires, inverse_of: :dossier, dependent: :destroy has_many :preloaded_commentaires, -> { includes(:dossier_correction, piece_jointe_attachments: :blob) }, class_name: 'Commentaire', inverse_of: :dossier @@ -1160,12 +1156,12 @@ class Dossier < ApplicationRecord def build_default_champs_for(types_de_champ) self.champs << types_de_champ.flat_map do |type_de_champ| + champ = type_de_champ.build_champ(dossier: self) if type_de_champ.repetition? && (type_de_champ.private? || type_de_champ.mandatory?) row_id = ULID.generate - parent = type_de_champ.build_champ(dossier: self) - [parent] + revision.children_of(type_de_champ).map { _1.build_champ(dossier: self, parent:, row_id:) } + [champ] + revision.children_of(type_de_champ).map { _1.build_champ(dossier: self, row_id:) } else - type_de_champ.build_champ(dossier: self) + champ end end end diff --git a/app/models/dossier_preloader.rb b/app/models/dossier_preloader.rb index 9b27f47cc..c1f8d82fa 100644 --- a/app/models/dossier_preloader.rb +++ b/app/models/dossier_preloader.rb @@ -81,20 +81,8 @@ class DossierPreloader end dossier.association(:champs).target = champs - # remove once parent_id is deprecated - champs_by_parent_id = champs.group_by(&:parent_id) - champs.each do |champ| champ.association(:dossier).target = dossier - - # remove once parent_id is deprecated - if champ.repetition? - children = champs_by_parent_id.fetch(champ.id, []) - children.each do |child| - child.association(:parent).target = champ - end - champ.association(:champs).target = children - end end # We need to do this because of the check on `Etablissement#champ` in diff --git a/app/tasks/maintenance/rescue_dossier_with_invalid_repetition_task.rb b/app/tasks/maintenance/rescue_dossier_with_invalid_repetition_task.rb deleted file mode 100644 index c65ac899a..000000000 --- a/app/tasks/maintenance/rescue_dossier_with_invalid_repetition_task.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module Maintenance - class RescueDossierWithInvalidRepetitionTask < MaintenanceTasks::Task - INVALID_RELEASE_DATETIME = DateTime.new(2024, 8, 30, 12) - def collection - Dossier.where("last_champ_updated_at > ?", INVALID_RELEASE_DATETIME).pluck(:id) # heure de l'incident - end - - def process(dossier_id) - Dossier.find(dossier_id) - .champs - .filter { _1.row_id.present? && _1.parent_id.blank? } - .each(&:destroy!) - rescue ActiveRecord::RecordNotFound - # some dossier had already been destroyed - end - - def count - # Optionally, define the number of rows that will be iterated over - # This is used to track the task's progress - end - end -end diff --git a/db/migrate/20240321081721_remove_champs_foreign_key.rb b/db/migrate/20240321081721_remove_champs_foreign_key.rb new file mode 100644 index 000000000..8d4060ba2 --- /dev/null +++ b/db/migrate/20240321081721_remove_champs_foreign_key.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class RemoveChampsForeignKey < ActiveRecord::Migration[7.0] + def change + remove_foreign_key :champs, column: :parent_id + remove_index :champs, :parent_id + end +end diff --git a/db/schema.rb b/db/schema.rb index f8f7dca89..76e7f27c5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -270,7 +270,6 @@ ActiveRecord::Schema[7.0].define(version: 2024_09_29_141825) do t.index ["dossier_id", "stream", "stable_id", "row_id"], name: "index_champs_on_dossier_id_and_stream_and_stable_id_and_row_id", unique: true t.index ["dossier_id"], name: "index_champs_on_dossier_id" t.index ["etablissement_id"], name: "index_champs_on_etablissement_id" - t.index ["parent_id"], name: "index_champs_on_parent_id" t.index ["row_id"], name: "index_champs_on_row_id" t.index ["stable_id"], name: "index_champs_on_stable_id" t.index ["type"], name: "index_champs_on_type" @@ -1264,7 +1263,6 @@ ActiveRecord::Schema[7.0].define(version: 2024_09_29_141825) do add_foreign_key "avis", "experts_procedures" add_foreign_key "batch_operations", "instructeurs" add_foreign_key "bulk_messages", "procedures" - add_foreign_key "champs", "champs", column: "parent_id" add_foreign_key "champs", "dossiers" add_foreign_key "champs", "etablissements" add_foreign_key "champs", "types_de_champ" diff --git a/spec/components/editable_champ/editable_champ_component_spec.rb b/spec/components/editable_champ/editable_champ_component_spec.rb index 190a8e162..d9669580e 100644 --- a/spec/components/editable_champ/editable_champ_component_spec.rb +++ b/spec/components/editable_champ/editable_champ_component_spec.rb @@ -5,7 +5,7 @@ describe EditableChamp::EditableChampComponent, type: :component do let(:types_de_champ_public) { [] } let(:types_de_champ_private) { [] } let(:dossier) { create(:dossier, :with_populated_champs, procedure:) } - let(:champ) { dossier.champs.first } + let(:champ) { (dossier.project_champs_public + dossier.project_champs_private).first } let(:component) { described_class.new(form: nil, champ:) } diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index 42a4a3bd3..4ad9070b2 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -1037,7 +1037,7 @@ describe Instructeurs::DossiersController, type: :controller do primary_value: 'primary', secondary_value: 'secondary' }, - champ_repetition.champs.first.public_id => { + champ_repetition.rows.first.first.public_id => { value: 'text' }, champ_drop_down_list.public_id => { @@ -1054,7 +1054,7 @@ describe Instructeurs::DossiersController, type: :controller do expect(champ_linked_drop_down_list.primary_value).to eq('primary') expect(champ_linked_drop_down_list.secondary_value).to eq('secondary') expect(champ_datetime.value).to eq(Time.zone.parse('2019-12-21T13:17:00').iso8601) - expect(champ_repetition.champs.first.value).to eq('text') + expect(champ_repetition.rows.first.first.value).to eq('text') expect(champ_drop_down_list.value).to eq('other value') expect(dossier.reload.last_champ_private_updated_at).to eq(now) expect(response).to have_http_status(200) diff --git a/spec/factories/champ.rb b/spec/factories/champ.rb index eea830d0f..e3f173c0a 100644 --- a/spec/factories/champ.rb +++ b/spec/factories/champ.rb @@ -196,8 +196,8 @@ FactoryBot.define do evaluator.rows.times do row_id = ULID.generate - champ_repetition.champs << types_de_champ.map do |type_de_champ| - attrs = { dossier: champ_repetition.dossier, parent: champ_repetition, private: champ_repetition.private?, stable_id: type_de_champ.stable_id, row_id: } + champ_repetition.dossier.champs << types_de_champ.map do |type_de_champ| + attrs = { dossier: champ_repetition.dossier, private: champ_repetition.private?, stable_id: type_de_champ.stable_id, row_id: } build(:"champ_do_not_use_#{type_de_champ.type_champ}", **attrs) end end diff --git a/spec/graphql/annotation_spec.rb b/spec/graphql/annotation_spec.rb index cd3d731f1..480b0a96b 100644 --- a/spec/graphql/annotation_spec.rb +++ b/spec/graphql/annotation_spec.rb @@ -48,14 +48,14 @@ RSpec.describe Mutations::DossierModifierAnnotation, type: :graphql do end it 'add row' do - expect(annotation.champs.size).to eq(4) + expect(annotation.row_ids.size).to eq(2) expect(data).to eq(dossierModifierAnnotationAjouterLigne: { annotation: { id: annotation.to_typed_id }, errors: nil }) - expect(annotation.reload.champs.size).to eq(6) + expect(annotation.reload.row_ids.size).to eq(3) end end diff --git a/spec/lib/recovery/dossier_life_cycle_spec.rb b/spec/lib/recovery/dossier_life_cycle_spec.rb index 863e3e8fe..3a6a05d4b 100644 --- a/spec/lib/recovery/dossier_life_cycle_spec.rb +++ b/spec/lib/recovery/dossier_life_cycle_spec.rb @@ -83,7 +83,7 @@ describe 'Dossier::Recovery::LifeCycle' do expect(reloaded_dossier.champs.count).not_to be(0) - expect(repetition(reloaded_dossier).champs.map(&:type)).to match_array(["Champs::PieceJustificativeChamp"]) + expect(repetition(reloaded_dossier).rows.flatten.map(&:type)).to match_array(["Champs::PieceJustificativeChamp"]) expect(pj_champ(reloaded_dossier).piece_justificative_file).to be_attached expect(carte(reloaded_dossier).geo_areas).to be_present diff --git a/spec/models/champ_presentations/repetition_presentation_spec.rb b/spec/models/champ_presentations/repetition_presentation_spec.rb index 3c1e1696e..66c498dd7 100644 --- a/spec/models/champ_presentations/repetition_presentation_spec.rb +++ b/spec/models/champ_presentations/repetition_presentation_spec.rb @@ -34,7 +34,7 @@ describe ChampPresentations::RepetitionPresentation do stars.update(value: 4) end - let(:representation) { described_class.new(libelle, dossier.champs[0].reload.rows) } + let(:representation) { described_class.new(libelle, champ_repetition.rows) } describe '#to_s' do it 'returns a key-value representation' do diff --git a/spec/models/champ_spec.rb b/spec/models/champ_spec.rb index 016c7f2e0..5c473b1fe 100644 --- a/spec/models/champ_spec.rb +++ b/spec/models/champ_spec.rb @@ -75,44 +75,27 @@ describe Champ do end end - describe '#public?' do + describe 'public and private' do let(:champ) { Champ.new } - - it { expect(champ.public?).to be_truthy } - it { expect(champ.private?).to be_falsey } - end - - describe '#public_only' do let(:dossier) { create(:dossier) } it 'partition public and private' do expect(dossier.project_champs_public.count).to eq(1) expect(dossier.project_champs_private.count).to eq(1) end - end - describe '#public_ordered' do - let(:procedure) { create(:simple_procedure) } - let(:dossier) { create(:dossier, procedure: procedure) } + it { expect(champ.public?).to be_truthy } + it { expect(champ.private?).to be_falsey } context 'when a procedure has 2 revisions' do - it 'does not duplicate the champs' do + it { expect(dossier.procedure.revisions.count).to eq(2) } + + it 'does not duplicate public champs' do expect(dossier.project_champs_public.count).to eq(1) - expect(procedure.revisions.count).to eq(2) end - end - end - describe '#private_ordered' do - let(:procedure) { create(:procedure, :with_type_de_champ_private) } - let(:dossier) { create(:dossier, procedure: procedure) } - - context 'when a procedure has 2 revisions' do - before { procedure.publish } - - it 'does not duplicate the champs private' do + it 'does not duplicate private champs' do expect(dossier.project_champs_private.count).to eq(1) - expect(procedure.revisions.count).to eq(2) end end end @@ -598,16 +581,6 @@ describe Champ do let(:champ) { Champs::TextChamp.new(private: true) } it { expect(champ.input_name).to eq "dossier[champs_private_attributes][#{champ.public_id}]" } end - - context "when has parent" do - let(:champ) { Champs::TextChamp.new(parent: Champs::TextChamp.new) } - it { expect(champ.input_name).to eq "dossier[champs_public_attributes][#{champ.public_id}]" } - end - - context "when has private parent" do - let(:champ) { Champs::TextChamp.new(private: true, parent: Champs::TextChamp.new(private: true)) } - it { expect(champ.input_name).to eq "dossier[champs_private_attributes][#{champ.public_id}]" } - end end describe 'dom_id' do diff --git a/spec/models/champs/repetition_champ_spec.rb b/spec/models/champs/repetition_champ_spec.rb index d63dec474..16a537b73 100644 --- a/spec/models/champs/repetition_champ_spec.rb +++ b/spec/models/champs/repetition_champ_spec.rb @@ -11,7 +11,7 @@ describe Champs::RepetitionChamp do ]) } let(:dossier) { create(:dossier, procedure:) } - let(:champ) { dossier.champs.first } + let(:champ) { dossier.champs.find(&:repetition?) } describe "#for_tag" do before do diff --git a/spec/models/concerns/dossier_champs_concern_spec.rb b/spec/models/concerns/dossier_champs_concern_spec.rb index 08106be52..79689ff44 100644 --- a/spec/models/concerns/dossier_champs_concern_spec.rb +++ b/spec/models/concerns/dossier_champs_concern_spec.rb @@ -49,7 +49,6 @@ RSpec.describe DossierChampsConcern do it { expect(subject.persisted?).to be_truthy expect(subject.row_id).to eq(row_id) - expect(subject.parent_id).not_to be_nil } context "invalid row_id" do @@ -137,12 +136,7 @@ RSpec.describe DossierChampsConcern do describe '#repetition_add_row' do let(:type_de_champ_repetition) { dossier.find_type_de_champ_by_stable_id(993) } let(:row_ids) { dossier.repetition_row_ids(type_de_champ_repetition) } - subject do - # TODO: clean this up when parent_id is deprecated - row_id, added_champs = dossier.repetition_add_row(type_de_champ_repetition, updated_by: 'test') - dossier.champs << added_champs - row_id - end + subject { dossier.repetition_add_row(type_de_champ_repetition, updated_by: 'test') } it { expect { subject }.to change { dossier.repetition_row_ids(type_de_champ_repetition).size }.by(1) } it { expect(subject).to be_in(row_ids) } @@ -206,7 +200,6 @@ RSpec.describe DossierChampsConcern do it { expect(subject.persisted?).to be_truthy expect(subject.row_id).to eq(row_id) - expect(subject.parent_id).not_to be_nil } end @@ -226,7 +219,6 @@ RSpec.describe DossierChampsConcern do expect(subject.persisted?).to be_truthy expect(subject.is_a?(Champs::TextChamp)).to be_truthy expect(subject.row_id).to eq(row_id) - expect(subject.parent_id).not_to be_nil } end end diff --git a/spec/models/concerns/dossier_clone_concern_spec.rb b/spec/models/concerns/dossier_clone_concern_spec.rb index dd33470ab..9629d5df5 100644 --- a/spec/models/concerns/dossier_clone_concern_spec.rb +++ b/spec/models/concerns/dossier_clone_concern_spec.rb @@ -135,12 +135,13 @@ RSpec.describe DossierCloneConcern do context 'for Champs::Repetition with rows, original_champ.repetition and rows are duped' do let(:types_de_champ_public) { [{ type: :repetition, children: [{}, {}] }] } - let(:champ_repetition) { dossier.champs.first } - let(:cloned_champ_repetition) { new_dossier.champs.first } + let(:champ_repetition) { dossier.champs.find(&:repetition?) } + let(:cloned_champ_repetition) { new_dossier.champs.find(&:repetition?) } it do - expect(cloned_champ_repetition.champs.count).to eq(4) - expect(cloned_champ_repetition.champs.ids).not_to eq(champ_repetition.champs.ids) + expect(cloned_champ_repetition.rows.flatten.count).to eq(4) + expect(cloned_champ_repetition.rows.flatten.map(&:id)).not_to eq(champ_repetition.rows.flatten.map(&:id)) + expect(cloned_champ_repetition.row_ids).to eq(champ_repetition.row_ids) end end @@ -407,9 +408,7 @@ RSpec.describe DossierCloneConcern do end context 'with old revision having repetition' do - let(:added_champ) { nil } let(:removed_champ) { dossier.champs.find(&:repetition?) } - let(:updated_champ) { nil } before do dossier.champs.each do |champ| diff --git a/spec/models/concerns/dossier_rebase_concern_spec.rb b/spec/models/concerns/dossier_rebase_concern_spec.rb index 5b4c1704a..93a1e7c43 100644 --- a/spec/models/concerns/dossier_rebase_concern_spec.rb +++ b/spec/models/concerns/dossier_rebase_concern_spec.rb @@ -349,7 +349,7 @@ describe DossierRebaseConcern do # Add two rows then remove previous to last row in order to create a "hole" in the sequence repetition_champ.add_row(updated_by: 'test') repetition_champ.add_row(updated_by: 'test') - repetition_champ.champs.where(row_id: repetition_champ.row_ids[-2]).destroy_all + repetition_champ.dossier.champs.where(row_id: repetition_champ.row_ids[-2]).destroy_all dossier.reload end @@ -728,7 +728,7 @@ describe DossierRebaseConcern do parent.update(type_champ: :integer_number) end - it { expect { subject }.to change { dossier.project_champs_public.first.champs.count }.from(2).to(0) } + it { expect { subject }.to change { dossier.champs.filter(&:child?).count }.from(2).to(0) } it { expect { subject }.to change { Champ.count }.from(3).to(1) } end end diff --git a/spec/models/dossier_preloader_spec.rb b/spec/models/dossier_preloader_spec.rb index fb2fadda7..692aa7881 100644 --- a/spec/models/dossier_preloader_spec.rb +++ b/spec/models/dossier_preloader_spec.rb @@ -12,7 +12,7 @@ describe DossierPreloader do let(:dossier) { create(:dossier, procedure: procedure) } let(:repetition) { subject.project_champs_public.second } let(:repetition_optional) { subject.project_champs_public.third } - let(:first_child) { subject.project_champs_public.second.champs.first } + let(:first_child) { repetition.rows.first.first } describe 'all' do subject { DossierPreloader.load_one(dossier, pj_template: true) } @@ -40,9 +40,8 @@ describe DossierPreloader do expect(subject.champs.find(&:public?).conditional?).to eq(false) expect(subject.project_champs_public.first.conditional?).to eq(false) - expect(first_child.parent).to eq(repetition) - expect(repetition.champs.first).to eq(first_child) - expect(repetition_optional.champs).to be_empty + expect(repetition.rows.first.first).to eq(first_child) + expect(repetition_optional.row_ids).to be_empty end expect(count).to eq(0) diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index 1c0979043..aa97ea78e 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -52,8 +52,8 @@ describe PiecesJustificativesService do end context 'with a repetition' do - let(:first_champ) { repetition(dossier).champs.first } - let(:second_champ) { repetition(dossier).champs.second } + let(:first_champ) { repetition(dossier).rows.first.first } + let(:second_champ) { repetition(dossier).rows.second.first } before do repetition(dossier).add_row(updated_by: 'test') @@ -65,8 +65,8 @@ describe PiecesJustificativesService do end it do - first_child_attachments = attachments(repetition(dossier).champs.first) - second_child_attachments = attachments(repetition(dossier).champs.second) + first_child_attachments = attachments(repetition(dossier).rows.first.first) + second_child_attachments = attachments(repetition(dossier).rows.second.first) expect(export_template).to receive(:attachment_path) .with(dossier, first_child_attachments.first, index: 0, row_index: 0, champ: first_champ) diff --git a/spec/services/procedure_export_service_zip_spec.rb b/spec/services/procedure_export_service_zip_spec.rb index 44602784e..13050d011 100644 --- a/spec/services/procedure_export_service_zip_spec.rb +++ b/spec/services/procedure_export_service_zip_spec.rb @@ -16,11 +16,11 @@ describe ProcedureExportService do attach_file_to_champ(pj_champ(dossier)) repetition(dossier).add_row(updated_by: 'test') - attach_file_to_champ(repetition(dossier).champs.first) - attach_file_to_champ(repetition(dossier).champs.first) + attach_file_to_champ(repetition(dossier).rows.first.first) + attach_file_to_champ(repetition(dossier).rows.first.first) repetition(dossier).add_row(updated_by: 'test') - attach_file_to_champ(repetition(dossier).champs.second) + attach_file_to_champ(repetition(dossier).rows.second.first) end allow_any_instance_of(ActiveStorage::Attachment).to receive(:url).and_return("https://opengraph.githubassets.com/d0e7862b24d8026a3c03516d865b28151eb3859029c6c6c2e86605891fbdcd7a/socketry/async-io") diff --git a/spec/tasks/maintenance/rescue_dossier_with_invalid_repetition_task_spec.rb b/spec/tasks/maintenance/rescue_dossier_with_invalid_repetition_task_spec.rb deleted file mode 100644 index 4d7a75ac9..000000000 --- a/spec/tasks/maintenance/rescue_dossier_with_invalid_repetition_task_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -module Maintenance - RSpec.describe RescueDossierWithInvalidRepetitionTask do - describe "#process" do - let(:procedure) { create(:procedure, types_de_champ_public:) } - let(:types_de_champ_public) do - [ - { type: :repetition, children: [{ type: :text, mandatory: true }] }, - { type: :checkbox } - ] - end - let(:dossier) { create(:dossier, :with_populated_champs, procedure:) } - let(:invalid_champ) { dossier.champs.find(&:checkbox?) } - - # reproduce bad data - before { invalid_champ.update!(row_id: dossier.champs[1].row_id) } - - it "dissociate champ having a row_id without a parent_id" do - expect { described_class.process(dossier.id) } - .to change { Champ.exists?(invalid_champ.id) }.from(true).to(false) - end - end - end -end From fbb7bd7989628a2f5013a76b9204725d098d1423 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Thu, 3 Oct 2024 14:50:37 +0200 Subject: [PATCH 1216/1532] Adds title before error list & remove useless link --- .../errors_full_messages_component.en.yml | 14 ++++++++++---- .../errors_full_messages_component.fr.yml | 14 ++++++++++---- .../errors_full_messages_component.html.haml | 3 ++- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml index 0a595e80a..99235b8b1 100644 --- a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml +++ b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.en.yml @@ -1,7 +1,13 @@ --- en: sumup_html: - one: | - Your file has 1 error. Fix-it to continue : - other: | - Your file has %{count} errors. Fix-them to continue : + title: + one: | + Your file has 1 error + other: | + Your file has %{count} errors + content: + one: | + Fix-it to continue: + other: | + Fix-them to continue: diff --git a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml index 3d94f636f..8ed2c3f63 100644 --- a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml +++ b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.fr.yml @@ -1,7 +1,13 @@ --- fr: sumup_html: - one: | - Votre dossier contient 1 champ en erreur. Corrigez-la pour poursuivre : - other: | - Votre dossier contient %{count} champs en erreurs. Corrigez-les pour poursuivre : + title: + one: | + Votre dossier contient 1 champ en erreur + other: | + Votre dossier contient %{count} champs en erreur + content: + one: | + Corrigez-la pour poursuivre : + other: | + Corrigez-les pour poursuivre : diff --git a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml index ada4150b5..4b0245804 100644 --- a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml +++ b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml @@ -1,4 +1,5 @@ .fr-alert.fr-alert--error.fr-mb-3w{ role: "alertdialog" } - if dedup_and_partitioned_errors.size > 0 - %p#sumup-errors= t('.sumup_html', count: dedup_and_partitioned_errors.size, url: dedup_and_partitioned_errors.first.anchor) + %h3#sumup-errors.fr-alert__title= t('.sumup_html.title', count: dedup_and_partitioned_errors.size) + %p= t('.sumup_html.content', count: dedup_and_partitioned_errors.size) = render ExpandableErrorList.new(errors: dedup_and_partitioned_errors) From 8ca1f82b0126711b96bed31152279f5f8f180dc3 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Fri, 4 Oct 2024 10:26:18 +0200 Subject: [PATCH 1217/1532] Place focus on error block on page reload --- .../errors_full_messages_component.html.haml | 5 +++-- app/javascript/controllers/autofocus_controller.ts | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml index 4b0245804..b28de1de3 100644 --- a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml +++ b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml @@ -1,5 +1,6 @@ -.fr-alert.fr-alert--error.fr-mb-3w{ role: "alertdialog" } +.fr-alert.fr-alert--error.fr-mb-3w{ role: 'alert' } - if dedup_and_partitioned_errors.size > 0 - %h3#sumup-errors.fr-alert__title= t('.sumup_html.title', count: dedup_and_partitioned_errors.size) + %h3#sumup-errors.fr-alert__title{ data: { controller: 'autofocus' }, tabindex: '-1' } + = t('.sumup_html.title', count: dedup_and_partitioned_errors.size) %p= t('.sumup_html.content', count: dedup_and_partitioned_errors.size) = render ExpandableErrorList.new(errors: dedup_and_partitioned_errors) diff --git a/app/javascript/controllers/autofocus_controller.ts b/app/javascript/controllers/autofocus_controller.ts index 289185948..72b5198ad 100644 --- a/app/javascript/controllers/autofocus_controller.ts +++ b/app/javascript/controllers/autofocus_controller.ts @@ -2,8 +2,10 @@ import { Controller } from '@hotwired/stimulus'; export class AutofocusController extends Controller { connect() { - const element = this.element as HTMLInputElement; + const element = this.element as HTMLInputElement | HTMLElement; element.focus(); - element.setSelectionRange(0, element.value.length); + if ('value' in element) { + element.setSelectionRange(0, element.value.length); + } } } From 7d7821c26b697708e0879b830fcfe584ed458d97 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 15 Oct 2024 12:03:00 +0200 Subject: [PATCH 1218/1532] chore(update): add playwright to update script --- bin/update | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/update b/bin/update index ccb6fb6e3..c0d9ecefe 100755 --- a/bin/update +++ b/bin/update @@ -17,6 +17,7 @@ FileUtils.chdir APP_ROOT do system('bundle check') || system!('bundle install') system! 'bun --version' system! 'bun install' + system! 'bunx playwright install chromium' if ENV["UPDATE_WEBDRIVER"] puts "\n== Updating webdrivers ==" From 89d31ddf113b788c43ca65e53f16be9d679e3a8a Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Mon, 14 Oct 2024 14:44:42 +0200 Subject: [PATCH 1219/1532] =?UTF-8?q?Improve=20rendering=20of=20=E2=80=9Cn?= =?UTF-8?q?=C2=B0=E2=80=9D=20by=20assistive=20technologies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../errors_full_messages_component.html.haml | 1 + app/views/shared/dossiers/_header.html.haml | 2 +- app/views/users/dossiers/show/_header.html.haml | 2 +- config/locales/en.yml | 2 +- config/locales/fr.yml | 2 +- spec/views/users/dossiers/demande.html.haml_spec.rb | 2 +- spec/views/users/dossiers/show.html.haml_spec.rb | 2 +- spec/views/users/dossiers/show/_header.html.haml_spec.rb | 6 +++--- 8 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml index b28de1de3..01ffd40bf 100644 --- a/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml +++ b/app/components/dossiers/errors_full_messages_component/errors_full_messages_component.html.haml @@ -1,4 +1,5 @@ .fr-alert.fr-alert--error.fr-mb-3w{ role: 'alert' } + - if dedup_and_partitioned_errors.size > 0 %h3#sumup-errors.fr-alert__title{ data: { controller: 'autofocus' }, tabindex: '-1' } = t('.sumup_html.title', count: dedup_and_partitioned_errors.size) diff --git a/app/views/shared/dossiers/_header.html.haml b/app/views/shared/dossiers/_header.html.haml index c162b080d..5a93fc5b4 100644 --- a/app/views/shared/dossiers/_header.html.haml +++ b/app/views/shared/dossiers/_header.html.haml @@ -2,7 +2,7 @@ = procedure_libelle(dossier.procedure) = status_badge_user(dossier, 'super') %h2 - = t('views.users.dossiers.show.header.dossier_number', dossier_id: dossier.id) + = t('views.users.dossiers.show.header.dossier_number_html', dossier_id: dossier.id) = t('views.users.dossiers.show.header.created_date', date_du_dossier: I18n.l(dossier.created_at)) = render(partial: 'users/dossiers/expiration_banner', locals: {dossier: dossier}) diff --git a/app/views/users/dossiers/show/_header.html.haml b/app/views/users/dossiers/show/_header.html.haml index 76d30870f..167029020 100644 --- a/app/views/users/dossiers/show/_header.html.haml +++ b/app/views/users/dossiers/show/_header.html.haml @@ -5,7 +5,7 @@ = status_badge_user(dossier, 'super') = pending_correction_badge(:for_user) if dossier.pending_correction? %h2 - = t('views.users.dossiers.show.header.dossier_number', dossier_id: dossier.id) + = t('views.users.dossiers.show.header.dossier_number_html', dossier_id: dossier.id) - if dossier.depose_at.present? = t('views.users.dossiers.show.header.submit_date', date_du_dossier: I18n.l(dossier.depose_at)) diff --git a/config/locales/en.yml b/config/locales/en.yml index ffebb947e..0a7bf8a67 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -461,7 +461,7 @@ en: summary: "Summary" request: "Request" mailbox: "Mailbox" - dossier_number: "File n. %{dossier_id}" + dossier_number_html: "File number  %{dossier_id}" created_date: "- Draft on %{date_du_dossier}" submit_date: "- Submit on %{date_du_dossier}" status_overview: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index b8b280bbd..1d512a13f 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -464,7 +464,7 @@ fr: summary: "Résumé" request: "Demande" mailbox: "Messagerie" - dossier_number: "Dossier nº %{dossier_id}" + dossier_number_html: "Dossier numéro  %{dossier_id}" created_date: "- En brouillon depuis le %{date_du_dossier}" submit_date: "- Déposé le %{date_du_dossier}" status_overview: diff --git a/spec/views/users/dossiers/demande.html.haml_spec.rb b/spec/views/users/dossiers/demande.html.haml_spec.rb index c0cf8ae36..c0a61176b 100644 --- a/spec/views/users/dossiers/demande.html.haml_spec.rb +++ b/spec/views/users/dossiers/demande.html.haml_spec.rb @@ -12,7 +12,7 @@ describe 'users/dossiers/demande', type: :view do subject! { render } it 'renders the header' do - expect(rendered).to have_text("Dossier nº #{dossier.id}") + expect(rendered).to have_text("Dossier numéro nº #{dossier.id}") end it 'renders the dossier infos' do diff --git a/spec/views/users/dossiers/show.html.haml_spec.rb b/spec/views/users/dossiers/show.html.haml_spec.rb index 7f947db25..84d2bc475 100644 --- a/spec/views/users/dossiers/show.html.haml_spec.rb +++ b/spec/views/users/dossiers/show.html.haml_spec.rb @@ -11,7 +11,7 @@ describe 'users/dossiers/show', type: :view do subject! { render } it 'renders a summary of the dossier state' do - expect(rendered).to have_text("Dossier nº #{dossier.id}") + expect(rendered).to have_text("Dossier numéro nº #{dossier.id}") expect(rendered).to have_text('dossier est en construction') end diff --git a/spec/views/users/dossiers/show/_header.html.haml_spec.rb b/spec/views/users/dossiers/show/_header.html.haml_spec.rb index 2b41319fb..b759aace6 100644 --- a/spec/views/users/dossiers/show/_header.html.haml_spec.rb +++ b/spec/views/users/dossiers/show/_header.html.haml_spec.rb @@ -12,7 +12,7 @@ describe 'users/dossiers/show/header', type: :view do it 'affiche les informations du dossier' do expect(rendered).to have_text(dossier.procedure.libelle) - expect(rendered).to have_text("Dossier nº #{dossier.id}") + expect(rendered).to have_text("Dossier numéro nº #{dossier.id}") expect(rendered).to have_text("en construction") expect(rendered).to have_selector("nav.fr-tabs") @@ -25,7 +25,7 @@ describe 'users/dossiers/show/header', type: :view do let(:dossier) { create(:dossier, :en_construction, procedure: procedure) } it "affiche les informations du dossier" do - expect(rendered).to have_text("Dossier nº #{dossier.id}") + expect(rendered).to have_text("Dossier numéro nº #{dossier.id}") expect(rendered).to have_text("en construction") end end @@ -35,7 +35,7 @@ describe 'users/dossiers/show/header', type: :view do let(:dossier) { create(:dossier, :accepte, procedure: procedure) } it "n'affiche pas les informations de décision" do - expect(rendered).to have_text("Dossier nº #{dossier.id}") + expect(rendered).to have_text("Dossier numéro nº #{dossier.id}") expect(rendered).to have_text("traité") end end From 49c9f274e4e322e95a7b8fa575d48c7cf298e26d Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Tue, 15 Oct 2024 14:37:32 +0200 Subject: [PATCH 1220/1532] [#10919] When data in geo_area is invalid do not crash when .label is called --- app/models/geo_area.rb | 2 +- spec/models/geo_area_spec.rb | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/models/geo_area.rb b/app/models/geo_area.rb index 364a088aa..6e847abac 100644 --- a/app/models/geo_area.rb +++ b/app/models/geo_area.rb @@ -65,7 +65,7 @@ class GeoArea < ApplicationRecord def label case source when GeoArea.sources.fetch(:cadastre) - I18n.t("cadastre", scope: 'geo_area.label', numero: numero, prefixe: prefixe, section: section, surface: surface.round, commune: commune) + I18n.t("cadastre", scope: 'geo_area.label', numero: numero, prefixe: prefixe, section: section, surface: surface&.round, commune: commune) when GeoArea.sources.fetch(:selection_utilisateur) if polygon? if area > 0 diff --git a/spec/models/geo_area_spec.rb b/spec/models/geo_area_spec.rb index 0c8554286..0707782fa 100644 --- a/spec/models/geo_area_spec.rb +++ b/spec/models/geo_area_spec.rb @@ -143,6 +143,16 @@ RSpec.describe GeoArea, type: :model do it "should return the label" do expect(geo_area.label).to eq("Parcelle n° 42 - Feuille 000 A11 - 123 m² – commune 75127") end + + context "when area is nil" do + let(:geo_area) { build(:geo_area, :selection_utilisateur, :cadastre, properties: { "description" => "48°51'45.81\"N 2°17'15,33\"E" }, geometry: { "type" => "Point", "coordinates" => [7.754444, 48.610556] }, champ: nil) } + + before { allow(geo_area).to receive(:area).and_return(nil) } + + it "should not crash" do + expect(geo_area.label).to eq("Parcelle n° - Feuille - m² – commune ") + end + end end end end From b588b775718c1531b55ea4877cdf243054a4ba6a Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 7 Oct 2024 14:35:23 +0200 Subject: [PATCH 1221/1532] =?UTF-8?q?TypeDeChamp:=20am=C3=A9lioration=20de?= =?UTF-8?q?=20la=20gestion=20des=20drop=5Fdown=5Foptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/type_de_champ.rb | 16 ++++++---------- .../card/annotations_component_spec.rb | 5 +++-- .../procedures/card/champs_component_spec.rb | 4 ++-- .../components/procedures/errors_summary_spec.rb | 16 ++++++++++------ .../editor_component_spec.rb | 12 +++++++----- spec/models/procedure_spec.rb | 13 +++++++++---- spec/models/type_de_champ_spec.rb | 5 ++++- ...multiple_drop_down_list_type_de_champ_spec.rb | 2 +- .../administrateurs/procedure_publish_spec.rb | 11 +++++++++-- 9 files changed, 51 insertions(+), 33 deletions(-) diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 015721c60..6f28c36a1 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -211,7 +211,7 @@ class TypeDeChamp < ApplicationRecord before_validation :normalize_libelle before_save :remove_piece_justificative_template, if: -> { type_champ_changed? } - before_validation :remove_drop_down_list, if: -> { type_champ_changed? } + before_validation :set_drop_down_list_options, if: -> { type_champ_changed? } before_save :remove_block, if: -> { type_champ_changed? } after_save if: -> { @remove_piece_justificative_template } do @@ -773,15 +773,11 @@ class TypeDeChamp < ApplicationRecord end end - def remove_drop_down_list - if !drop_down_list? - self.drop_down_options = nil - elsif !drop_down_options_changed? - self.drop_down_options = if linked_drop_down_list? - ['--Fromage--', 'bleu de sassenage', 'picodon', '--Dessert--', 'éclair', 'tarte aux pommes'] - else - ['Premier choix', 'Deuxième choix'] - end + def set_drop_down_list_options + if (simple_drop_down_list? || multiple_drop_down_list?) && drop_down_options.empty? + self.drop_down_options = ['Fromage', 'Dessert'] + elsif linked_drop_down_list? && drop_down_options.none?(/^--.*--$/) + self.drop_down_options = ['--Fromage--', 'bleu de sassenage', 'picodon', '--Dessert--', 'éclair', 'tarte aux pommes'] end end diff --git a/spec/components/procedures/card/annotations_component_spec.rb b/spec/components/procedures/card/annotations_component_spec.rb index 26897a85b..772e80e95 100644 --- a/spec/components/procedures/card/annotations_component_spec.rb +++ b/spec/components/procedures/card/annotations_component_spec.rb @@ -15,14 +15,15 @@ describe Procedure::Card::AnnotationsComponent, type: :component do end context 'when errors on types_de_champs_public' do - let(:types_de_champ_public) { [{ type: :drop_down_list, options: [] }] } + let(:types_de_champ_public) { [{ type: :repetition, children: [] }] } + it 'does not render' do expect(subject).to have_selector('.fr-badge--info', text: 'À configurer') end end context 'when errors on types_de_champs_private' do - let(:types_de_champ_private) { [{ type: :drop_down_list, options: [] }] } + let(:types_de_champ_private) { [{ type: :repetition, children: [] }] } it 'render the template' do expect(subject).to have_selector('.fr-badge--error', text: 'À modifier') diff --git a/spec/components/procedures/card/champs_component_spec.rb b/spec/components/procedures/card/champs_component_spec.rb index 40412d055..f986f3d0b 100644 --- a/spec/components/procedures/card/champs_component_spec.rb +++ b/spec/components/procedures/card/champs_component_spec.rb @@ -15,14 +15,14 @@ describe Procedure::Card::ChampsComponent, type: :component do end context 'when errors on types_de_champs_public' do - let(:types_de_champ_public) { [{ type: :drop_down_list, options: [] }] } + let(:types_de_champ_public) { [{ type: :repetition, children: [] }] } it 'does not render' do expect(subject).to have_selector('.fr-badge--error', text: 'À modifier') end end context 'when errors on types_de_champs_private' do - let(:types_de_champ_private) { [{ type: :drop_down_list, options: [] }] } + let(:types_de_champ_private) { [{ type: :repetition, children: [] }] } it 'render the template' do expect(subject).to have_selector('.fr-badge--warning', text: 'À faire') diff --git a/spec/components/procedures/errors_summary_spec.rb b/spec/components/procedures/errors_summary_spec.rb index 9ff3edadb..204bea9df 100644 --- a/spec/components/procedures/errors_summary_spec.rb +++ b/spec/components/procedures/errors_summary_spec.rb @@ -5,8 +5,8 @@ describe Procedure::ErrorsSummary, type: :component do describe 'validations context' do let(:procedure) { create(:procedure, types_de_champ_private:, types_de_champ_public:) } - let(:types_de_champ_private) { [{ type: :drop_down_list, options: [], libelle: 'private' }] } - let(:types_de_champ_public) { [{ type: :drop_down_list, options: [], libelle: 'public' }] } + let(:types_de_champ_private) { [{ type: :repetition, children: [], libelle: 'private' }] } + let(:types_de_champ_public) { [{ type: :repetition, children: [], libelle: 'public' }] } before { subject } @@ -17,7 +17,7 @@ describe Procedure::ErrorsSummary, type: :component do expect(page).to have_content("Erreur : Des problèmes empêchent la publication de la démarche") expect(page).to have_selector("a", text: "public") expect(page).to have_selector("a", text: "private") - expect(page).to have_text("doit comporter au moins un choix sélectionnable", count: 2) + expect(page).to have_text("doit comporter au moins un champ répétable", count: 2) end end @@ -27,7 +27,7 @@ describe Procedure::ErrorsSummary, type: :component do it 'shows errors and links for public only tdc' do expect(page).to have_text("Erreur : Les champs formulaire contiennent des erreurs") expect(page).to have_selector("a", text: "public") - expect(page).to have_text("doit comporter au moins un choix sélectionnable", count: 1) + expect(page).to have_text("doit comporter au moins un champ répétable", count: 1) expect(page).not_to have_selector("a", text: "private") end end @@ -38,7 +38,7 @@ describe Procedure::ErrorsSummary, type: :component do it 'shows errors and links for private only tdc' do expect(page).to have_text("Erreur : Les annotations privées contiennent des erreurs") expect(page).to have_selector("a", text: "private") - expect(page).to have_text("doit comporter au moins un choix sélectionnable") + expect(page).to have_text("doit comporter au moins un champ répétable") expect(page).not_to have_selector("a", text: "public") end end @@ -59,7 +59,11 @@ describe Procedure::ErrorsSummary, type: :component do let(:validation_context) { :types_de_champ_public_editor } - before { subject } + before do + drop_down_public = procedure.draft_revision.types_de_champ_public.find(&:drop_down_list?) + drop_down_public.update!(drop_down_options: []) + subject + end it 'renders all errors and links on champ' do expect(page).to have_selector("a", text: "drop down list requires options") diff --git a/spec/components/types_de_champ_editor/editor_component_spec.rb b/spec/components/types_de_champ_editor/editor_component_spec.rb index ae6947176..3e6bc2058 100644 --- a/spec/components/types_de_champ_editor/editor_component_spec.rb +++ b/spec/components/types_de_champ_editor/editor_component_spec.rb @@ -3,26 +3,28 @@ describe TypesDeChampEditor::EditorComponent, type: :component do let(:revision) { procedure.draft_revision } let(:procedure) { create(:procedure, id: 1, types_de_champ_private:, types_de_champ_public:) } - - let(:types_de_champ_private) { [{ type: :drop_down_list, options: [], libelle: 'private' }] } - let(:types_de_champ_public) { [{ type: :drop_down_list, options: [], libelle: 'public' }] } + let(:types_de_champ_private) { [{ type: :repetition, children: [], libelle: 'private' }] } + let(:types_de_champ_public) { [{ type: :repetition, children: [], libelle: 'public' }] } describe 'render' do subject { render_inline(described_class.new(revision:, is_annotation:)) } + context 'types_de_champ_public' do let(:is_annotation) { false } + it 'does not render private champs errors' do expect(subject).not_to have_text("private") expect(subject).to have_selector("a", text: "public") - expect(subject).to have_text("doit comporter au moins un choix sélectionnable") + expect(subject).to have_text("doit comporter au moins un champ répétable") end end context 'types_de_champ_private' do let(:is_annotation) { true } + it 'does not render public champs errors' do expect(subject).to have_selector("a", text: "private") - expect(subject).to have_text("doit comporter au moins un choix sélectionnable") + expect(subject).to have_text("doit comporter au moins un champ répétable") expect(subject).not_to have_text("public") end end diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index cda2f8330..68d6bddec 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -393,10 +393,12 @@ describe Procedure do end it 'validates that no drop-down type de champ is empty' do - procedure.validate(:publication) + drop_down = procedure.draft_revision.types_de_champ_public.find(&:drop_down_list?) + + drop_down.update!(drop_down_options: []) + procedure.reload.validate(:publication) expect(procedure.errors.messages_for(:draft_types_de_champ_public)).to include(invalid_drop_down_error_message) - drop_down = procedure.draft_revision.types_de_champ_public.find(&:drop_down_list?) drop_down.update!(drop_down_options: ["--title--", "some value"]) procedure.reload.validate(:publication) expect(procedure.errors.messages_for(:draft_types_de_champ_public)).not_to include(invalid_drop_down_error_message) @@ -418,14 +420,17 @@ describe Procedure do it 'validates that no repetition type de champ is empty' do procedure.validate(:publication) expect(procedure.errors.messages_for(:draft_types_de_champ_private)).to include(invalid_repetition_error_message) + repetition = procedure.draft_revision.types_de_champ_private.find(&:repetition?) expect(procedure.errors.to_enum.to_a.map { _1.options[:type_de_champ] }).to include(repetition) end it 'validates that no drop-down type de champ is empty' do - procedure.validate(:publication) - expect(procedure.errors.messages_for(:draft_types_de_champ_private)).to include(invalid_drop_down_error_message) drop_down = procedure.draft_revision.types_de_champ_private.find(&:drop_down_list?) + drop_down.update!(drop_down_options: []) + procedure.reload.validate(:publication) + + expect(procedure.errors.messages_for(:draft_types_de_champ_private)).to include(invalid_drop_down_error_message) expect(procedure.errors.to_enum.to_a.map { _1.options[:type_de_champ] }).to include(drop_down) end end diff --git a/spec/models/type_de_champ_spec.rb b/spec/models/type_de_champ_spec.rb index 94e5ad974..5fb9d8f2a 100644 --- a/spec/models/type_de_champ_spec.rb +++ b/spec/models/type_de_champ_spec.rb @@ -107,19 +107,22 @@ describe TypeDeChamp do context 'when the target type_champ is not drop_down_list' do let(:target_type_champ) { TypeDeChamp.type_champs.fetch(:text) } - it { expect(tdc.drop_down_options).to be_empty } + it { expect(tdc.drop_down_options).to be_present } + it { expect(tdc.drop_down_options).to eq(["val1", "val2", "val3"]) } end context 'when the target type_champ is linked_drop_down_list' do let(:target_type_champ) { TypeDeChamp.type_champs.fetch(:linked_drop_down_list) } it { expect(tdc.drop_down_options).to be_present } + it { expect(tdc.drop_down_options).to eq(['--Fromage--', 'bleu de sassenage', 'picodon', '--Dessert--', 'éclair', 'tarte aux pommes']) } end context 'when the target type_champ is multiple_drop_down_list' do let(:target_type_champ) { TypeDeChamp.type_champs.fetch(:multiple_drop_down_list) } it { expect(tdc.drop_down_options).to be_present } + it { expect(tdc.drop_down_options).to eq(["val1", "val2", "val3"]) } end end diff --git a/spec/models/types_de_champ/prefill_multiple_drop_down_list_type_de_champ_spec.rb b/spec/models/types_de_champ/prefill_multiple_drop_down_list_type_de_champ_spec.rb index a4d60e3f4..416bdc87f 100644 --- a/spec/models/types_de_champ/prefill_multiple_drop_down_list_type_de_champ_spec.rb +++ b/spec/models/types_de_champ/prefill_multiple_drop_down_list_type_de_champ_spec.rb @@ -16,7 +16,7 @@ RSpec.describe TypesDeChamp::PrefillMultipleDropDownListTypeDeChamp do context 'when the multiple drop down list has no option' do let(:drop_down_options_from_text) { "" } - it { expect(example_value).to eq(nil) } + it { expect(example_value).to eq(["Fromage", "Dessert"]) } end context 'when the multiple drop down list only has one option' do diff --git a/spec/system/administrateurs/procedure_publish_spec.rb b/spec/system/administrateurs/procedure_publish_spec.rb index ede1086fa..aac700f8d 100644 --- a/spec/system/administrateurs/procedure_publish_spec.rb +++ b/spec/system/administrateurs/procedure_publish_spec.rb @@ -66,8 +66,15 @@ describe 'Publishing a procedure', js: true do :with_zone, instructeurs: instructeurs, administrateur: administrateur, - types_de_champ_public: [{ type: :repetition, libelle: 'Enfants', children: [] }, { type: :drop_down_list, libelle: 'Civilité', options: [] }], - types_de_champ_private: [{ type: :drop_down_list, libelle: 'Civilité', options: [] }]) + types_de_champ_public: [{ type: :repetition, libelle: 'Enfants', children: [] }, { type: :drop_down_list, libelle: 'Civilité' }], + types_de_champ_private: [{ type: :drop_down_list, libelle: 'Civilité' }]) + end + + before do + drop_down = procedure.draft_revision.types_de_champ_public.find(&:drop_down_list?) + drop_down.update!(drop_down_options: []) + drop_down = procedure.draft_revision.types_de_champ_private.find(&:drop_down_list?) + drop_down.update!(drop_down_options: []) end scenario 'an error message prevents the publication' do From 3c5749e45abd101cf724c8e376ddbd62fe5500c9 Mon Sep 17 00:00:00 2001 From: benoitqueyron <72251526+Benoit-MINT@users.noreply.github.com> Date: Tue, 27 Aug 2024 17:52:21 +0200 Subject: [PATCH 1222/1532] TypeDeChamp: purge notice explicative lors d'un changement de type_champ --- app/models/type_de_champ.rb | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 6f28c36a1..3204b7cfc 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -210,14 +210,10 @@ class TypeDeChamp < ApplicationRecord before_validation :check_mandatory before_validation :normalize_libelle - before_save :remove_piece_justificative_template, if: -> { type_champ_changed? } + before_save :remove_attachment, if: -> { type_champ_changed? } before_validation :set_drop_down_list_options, if: -> { type_champ_changed? } before_save :remove_block, if: -> { type_champ_changed? } - after_save if: -> { @remove_piece_justificative_template } do - piece_justificative_template.purge_later - end - def valid?(context = nil) super if dynamic_type.present? @@ -767,9 +763,11 @@ class TypeDeChamp < ApplicationRecord end end - def remove_piece_justificative_template + def remove_attachment if !piece_justificative? && piece_justificative_template.attached? - @remove_piece_justificative_template = true + piece_justificative_template.purge_later + elsif !explication? && notice_explicative.attached? + notice_explicative.purge_later end end From 4f62590b7a28b42069cca1892651532defe752ea Mon Sep 17 00:00:00 2001 From: benoitqueyron <72251526+Benoit-MINT@users.noreply.github.com> Date: Wed, 28 Aug 2024 10:31:26 +0200 Subject: [PATCH 1223/1532] ProcedureRevision#compare_type_de_champ: fix bug comparaison character_limit chaine vide vs nil --- app/models/procedure_revision.rb | 2 +- spec/models/procedure_revision_spec.rb | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index 0c8c8dc27..6a0631eb9 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -440,7 +440,7 @@ class ProcedureRevision < ApplicationRecord to_type_de_champ.filename_for_attachement(:notice_explicative)) end elsif to_type_de_champ.textarea? - if from_type_de_champ.character_limit != to_type_de_champ.character_limit + if from_type_de_champ.character_limit.presence != to_type_de_champ.character_limit.presence changes << ProcedureRevisionChange::UpdateChamp.new(from_type_de_champ, :character_limit, from_type_de_champ.character_limit, diff --git a/spec/models/procedure_revision_spec.rb b/spec/models/procedure_revision_spec.rb index 592187aaa..97e53841f 100644 --- a/spec/models/procedure_revision_spec.rb +++ b/spec/models/procedure_revision_spec.rb @@ -537,6 +537,29 @@ describe ProcedureRevision do end end + context 'when a type de champ is transformed into a text_area with no character limit' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :text }]) } + + before do + updated_tdc = new_draft.find_and_ensure_exclusive_use(first_tdc.stable_id) + updated_tdc.update(type_champ: :textarea, options: { "character_limit" => "" }) + end + + it do + is_expected.to eq([ + { + op: :update, + attribute: :type_champ, + label: first_tdc.libelle, + private: false, + from: "text", + to: "textarea", + stable_id: first_tdc.stable_id + } + ]) + end + end + context 'when a type de champ is moved' do let(:procedure) { create(:procedure, types_de_champ_public: Array.new(3) { { type: :text } }) } let(:new_draft_second_tdc) { new_draft.types_de_champ_public.second } From e88d84cf57ecf90b524b8df622dd061fb25e640d Mon Sep 17 00:00:00 2001 From: benoitqueyron <72251526+Benoit-MINT@users.noreply.github.com> Date: Thu, 29 Aug 2024 17:25:42 +0200 Subject: [PATCH 1224/1532] ajout d'un nettoyage des options des types_de_champ lors de la publication d'une procedure ou d'une nouvelle revision --- app/models/procedure.rb | 8 ++ app/models/type_de_champ.rb | 18 +++++ spec/models/type_de_champ_spec.rb | 123 ++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 168c486ec..8b6bcd16e 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -325,6 +325,7 @@ class Procedure < ApplicationRecord Procedure.transaction do if brouillon? reset! + cleanup_types_de_champ_options! end other_procedure = other_procedure_with_path(path) @@ -347,6 +348,12 @@ class Procedure < ApplicationRecord end end + def cleanup_types_de_champ_options! + draft_revision.types_de_champ.each do |type_de_champ| + type_de_champ.update!(options: type_de_champ.clean_options) + end + end + def suggested_path(administrateur) if path_customized? return path @@ -807,6 +814,7 @@ class Procedure < ApplicationRecord def publish_revision! reset! + cleanup_types_de_champ_options! transaction do self.published_revision = draft_revision self.draft_revision = create_new_revision diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 3204b7cfc..0d624ce17 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -677,6 +677,24 @@ class TypeDeChamp < ApplicationRecord .parameterize end + OPTS_BY_TYPE = { + type_champs.fetch(:header_section) => [:header_section_level], + type_champs.fetch(:explication) => [:collapsible_explanation_enabled, :collapsible_explanation_text], + type_champs.fetch(:textarea) => [:character_limit], + type_champs.fetch(:carte) => TypesDeChamp::CarteTypeDeChamp::LAYERS, + type_champs.fetch(:drop_down_list) => [:drop_down_other, :drop_down_options], + type_champs.fetch(:multiple_drop_down_list) => [:drop_down_options], + type_champs.fetch(:linked_drop_down_list) => [:drop_down_options, :drop_down_secondary_libelle, :drop_down_secondary_description], + type_champs.fetch(:piece_justificative) => [:old_pj, :skip_pj_validation, :skip_content_type_pj_validation], + type_champs.fetch(:titre_identite) => [:old_pj, :skip_pj_validation, :skip_content_type_pj_validation], + type_champs.fetch(:expression_reguliere) => [:expression_reguliere, :expression_reguliere_error_message, :expression_reguliere_exemple_text] + } + + def clean_options + kept_keys = OPTS_BY_TYPE.fetch(type_champ.to_s) { [] } + options.slice(*kept_keys.map(&:to_s)) + end + class << self def champ_value(type_champ, champ) dynamic_type_class = type_champ_to_class_name(type_champ).constantize diff --git a/spec/models/type_de_champ_spec.rb b/spec/models/type_de_champ_spec.rb index 5fb9d8f2a..52c937bca 100644 --- a/spec/models/type_de_champ_spec.rb +++ b/spec/models/type_de_champ_spec.rb @@ -290,4 +290,127 @@ describe TypeDeChamp do it { is_expected.to eq("1-tres-interessant-bilan") } end + + describe '#clean_options' do + subject { procedure.published_revision.types_de_champ.first.options } + + let(:procedure) { create(:procedure) } + + context "Header section" do + let(:type_de_champ) { create(:type_de_champ_header_section, procedure:) } + + before do + type_de_champ.update!(options: { 'header_section_level' => '1', 'key' => 'value' }) + procedure.publish_revision! + end + + it 'keeping only the header_section_level' do + is_expected.to eq({ 'header_section_level' => '1' }) + end + end + + context "Explication" do + let(:type_de_champ) { create(:type_de_champ_explication, procedure:) } + + before do + type_de_champ.update!(options: { 'collapsible_explanation_enabled' => '1', 'collapsible_explanation_text' => 'hello', 'key' => 'value' }) + procedure.publish_revision! + end + + it 'keeping only the collapsible_explanation keys' do + is_expected.to eq({ 'collapsible_explanation_enabled' => '1', 'collapsible_explanation_text' => 'hello' }) + end + end + + context "Text area" do + let(:type_de_champ) { create(:type_de_champ_textarea, procedure:) } + + before do + type_de_champ.update!(options: { 'character_limit' => '400', 'key' => 'value' }) + procedure.publish_revision! + end + + it 'keeping only the character limit' do + is_expected.to eq({ 'character_limit' => '400' }) + end + end + + context "Carte" do + let(:type_de_champ) { create(:type_de_champ_carte, procedure:) } + + before do + type_de_champ.update!(options: { 'unesco' => '0', 'key' => 'value' }) + procedure.publish_revision! + end + + it 'keeping only the layers' do + is_expected.to eq({ 'unesco' => '0' }) + end + end + + context "Simple drop down_list" do + let(:type_de_champ) { create(:type_de_champ_drop_down_list, procedure:) } + + before do + type_de_champ.update!(options: { 'drop_down_other' => '0', 'drop_down_options' => ['Premier choix', 'Deuxième choix'], 'key' => 'value' }) + procedure.publish_revision! + end + + it 'keeping only the drop_down_other and drop_down_options' do + is_expected.to eq({ 'drop_down_other' => '0', 'drop_down_options' => ['Premier choix', 'Deuxième choix'] }) + end + end + + context "Multiple drop down_list" do + let(:type_de_champ) { create(:type_de_champ_multiple_drop_down_list, procedure:) } + + before do + type_de_champ.update!(options: { 'drop_down_options' => ['Premier choix', 'Deuxième choix'], 'key' => 'value' }) + procedure.publish_revision! + end + + it 'keeping only the drop_down_options' do + is_expected.to eq({ 'drop_down_options' => ['Premier choix', 'Deuxième choix'] }) + end + end + + context "Linked drop down list" do + let(:type_de_champ) { create(:type_de_champ_linked_drop_down_list, procedure:) } + + before do + type_de_champ.update!(options: { 'drop_down_options' => ['--Fromage--', 'bleu de sassenage', 'picodon', '--Dessert--', 'éclair', 'tarte aux pommes'], 'key' => 'value' }) + procedure.publish_revision! + end + + it 'keeping only the drop_down_options' do + is_expected.to eq({ 'drop_down_options' => ['--Fromage--', 'bleu de sassenage', 'picodon', '--Dessert--', 'éclair', 'tarte aux pommes'] }) + end + end + + context "Piece justificative" do + let(:type_de_champ) { create(:type_de_champ_piece_justificative, procedure:) } + + before do + type_de_champ.update!(options: { 'old_pj' => '123', 'skip_pj_validation' => '1', 'skip_content_type_pj_validation' => '1', 'key' => 'value' }) + procedure.publish_revision! + end + + it 'keeping only the old_pj, skip_validation_pj and skip_content_type_pj_validation' do + is_expected.to eq({ 'old_pj' => '123', 'skip_pj_validation' => '1', 'skip_content_type_pj_validation' => '1' }) + end + end + + context "Expression reguliere" do + let(:type_de_champ) { create(:type_de_champ_expression_reguliere, procedure:) } + + before do + type_de_champ.update!(options: { 'expression_reguliere' => '\d{9}', 'expression_reguliere_error_message' => 'error', 'expression_reguliere_exemple_text' => '123456789', 'key' => 'value' }) + procedure.publish_revision! + end + + it 'keeping only the expression_reguliere, expression_reguliere_error_message and expression_reguliere_exemple_text' do + is_expected.to eq({ 'expression_reguliere' => '\d{9}', 'expression_reguliere_error_message' => 'error', 'expression_reguliere_exemple_text' => '123456789' }) + end + end + end end From 2d9854dc01b111b7b8bf9c1af89ee90bbe6f2964 Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Tue, 24 Sep 2024 14:08:06 +0200 Subject: [PATCH 1225/1532] [#10799] Declare api_entreprise_token_expires_at attribute and feed it on save --- app/models/api_entreprise_token.rb | 4 +++ app/models/procedure.rb | 5 +++ ...treprise_token_expires_at_to_procedures.rb | 7 ++++ db/schema.rb | 3 +- spec/models/api_entreprise_token_spec.rb | 30 ++++++++++++++++ spec/models/procedure_spec.rb | 36 +++++++++++++++++++ 6 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20240924112458_add_api_entreprise_token_expires_at_to_procedures.rb diff --git a/app/models/api_entreprise_token.rb b/app/models/api_entreprise_token.rb index 65cbfa491..fac81acda 100644 --- a/app/models/api_entreprise_token.rb +++ b/app/models/api_entreprise_token.rb @@ -17,6 +17,10 @@ class APIEntrepriseToken decoded_token.key?("exp") && decoded_token["exp"] <= Time.zone.now.to_i end + def expiration + Time.zone.at(decoded_token["exp"]) + end + def role?(role) roles.include?(role) end diff --git a/app/models/procedure.rb b/app/models/procedure.rb index d959e4b39..792d457c6 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -288,6 +288,7 @@ class Procedure < ApplicationRecord validates :api_particulier_token, format: { with: /\A[A-Za-z0-9\-_=.]{15,}\z/ }, allow_blank: true validate :validate_auto_archive_on_in_the_future, if: :will_save_change_to_auto_archive_on? + before_save :set_api_entreprise_token_expires_at, if: :will_save_change_to_api_entreprise_token? before_save :update_juridique_required after_save :extend_conservation_for_dossiers @@ -973,6 +974,10 @@ class Procedure < ApplicationRecord monavis_embed.gsub('nd_source=button', "nd_source=#{source}").gsub(' Date: Tue, 24 Sep 2024 16:26:36 +0200 Subject: [PATCH 1226/1532] [#10799] Display a warning about token expiration on token form page --- ...ntreprise_token_expiration_alert.html.haml | 14 +++++++ .../procedures/jeton.html.haml | 2 + ...e_token_expiration_alert.html.haml_spec.rb | 37 +++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 app/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml create mode 100644 spec/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml_spec.rb diff --git a/app/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml b/app/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml new file mode 100644 index 000000000..56ce657d5 --- /dev/null +++ b/app/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml @@ -0,0 +1,14 @@ +- if procedure.api_entreprise_token_expires_at.present? + - if procedure.api_entreprise_token_expires_at < Time.zone.now + = render Dsfr::AlertComponent.new(state: :error, size: :sm, extra_class_names: 'fr-mb-2w') do |c| + - c.with_body do + %p + Votre jeton API Entreprise est expiré. + Merci de le renouveler. + - else + = render Dsfr::AlertComponent.new(state: :warning, size: :sm, extra_class_names: 'fr-mb-2w') do |c| + - c.with_body do + %p + Votre jeton API Entreprise expirera le + = procedure.api_entreprise_token_expires_at.strftime('%d/%m/%Y à %H:%M.') + Merci de le renouveler avant cette date. \ No newline at end of file diff --git a/app/views/administrateurs/procedures/jeton.html.haml b/app/views/administrateurs/procedures/jeton.html.haml index 35d92ab90..57dfa4534 100644 --- a/app/views/administrateurs/procedures/jeton.html.haml +++ b/app/views/administrateurs/procedures/jeton.html.haml @@ -18,6 +18,8 @@ = link_to 'API Entreprise', "https://api.gouv.fr/les-api/api-entreprise/demande-acces" propre à votre démarche. + = render partial: 'administrateurs/procedures/api_entreprise_token_expiration_alert', locals: { procedure: @procedure } + .fr-input-group = f.label :api_entreprise_token, "Jeton", class: 'fr-label' = f.password_field :api_entreprise_token, value: @procedure.read_attribute(:api_entreprise_token), class: 'fr-input' diff --git a/spec/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml_spec.rb b/spec/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml_spec.rb new file mode 100644 index 000000000..79bb64352 --- /dev/null +++ b/spec/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe 'administrateurs/procedures/_api_entreprise_token_expiration_alert', type: :view do + let(:procedure) { create(:procedure, api_entreprise_token:) } + + subject { render 'administrateurs/procedures/api_entreprise_token_expiration_alert', procedure: procedure } + + context "when there is no token" do + let(:api_entreprise_token) { nil } + + it "does not render anything" do + subject + expect(rendered).to be_empty + end + end + + context "when the token is expired" do + let(:api_entreprise_token) { JWT.encode({ exp: 2.days.ago.to_i }, nil, "none") } + + it "should display an error" do + subject + + expect(rendered).to have_content("Votre jeton API Entreprise est expiré") + end + end + + context "when the token is valid it should display the expiration date" do + let(:expiration) { 2.days.from_now } + let(:api_entreprise_token) { JWT.encode({ exp: expiration.to_i }, nil, "none") } + + it "should display an error" do + subject + + expect(rendered).to have_content("Votre jeton API Entreprise expirera le\n#{expiration.strftime('%d/%m/%Y à %H:%M')}") + end + end +end From 7009eed9d78c92c13193f1ff372b0f6f52539002 Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Tue, 24 Sep 2024 17:03:18 +0200 Subject: [PATCH 1227/1532] [#10799] Move api entreprise token logic in a concern --- .../concerns/api_entreprise_token_concern.rb | 32 +++++++++++++++++++ app/models/procedure.rb | 19 +---------- ...ntreprise_token_expiration_alert.html.haml | 2 +- .../api_entreprise_token_concern_spec.rb | 27 ++++++++++++++++ ...e_token_expiration_alert.html.haml_spec.rb | 12 ++++++- 5 files changed, 72 insertions(+), 20 deletions(-) create mode 100644 app/models/concerns/api_entreprise_token_concern.rb create mode 100644 spec/models/concerns/api_entreprise_token_concern_spec.rb diff --git a/app/models/concerns/api_entreprise_token_concern.rb b/app/models/concerns/api_entreprise_token_concern.rb new file mode 100644 index 000000000..e12d00048 --- /dev/null +++ b/app/models/concerns/api_entreprise_token_concern.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module APIEntrepriseTokenConcern + extend ActiveSupport::Concern + + SOON_TO_EXPIRE_DELAY = 1.month + + included do + validates :api_entreprise_token, jwt_token: true, allow_blank: true + before_save :set_api_entreprise_token_expires_at, if: :will_save_change_to_api_entreprise_token? + + def api_entreprise_role?(role) + APIEntrepriseToken.new(api_entreprise_token).role?(role) + end + + def api_entreprise_token + self[:api_entreprise_token].presence || Rails.application.secrets.api_entreprise[:key] + end + + def api_entreprise_token_expired? + APIEntrepriseToken.new(api_entreprise_token).expired? + end + + def api_entreprise_token_expires_soon? + api_entreprise_token_expires_at && api_entreprise_token_expires_at <= SOON_TO_EXPIRE_DELAY.from_now + end + + def set_api_entreprise_token_expires_at + self.api_entreprise_token_expires_at = APIEntrepriseToken.new(api_entreprise_token).expiration + end + end +end diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 792d457c6..9a0c9d42b 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Procedure < ApplicationRecord + include APIEntrepriseTokenConcern include ProcedureStatsConcern include EncryptableConcern include InitiationProcedureConcern @@ -284,11 +285,9 @@ class Procedure < ApplicationRecord size: { less_than: LOGO_MAX_SIZE }, if: -> { new_record? || created_at > Date.new(2020, 11, 13) } - validates :api_entreprise_token, jwt_token: true, allow_blank: true validates :api_particulier_token, format: { with: /\A[A-Za-z0-9\-_=.]{15,}\z/ }, allow_blank: true validate :validate_auto_archive_on_in_the_future, if: :will_save_change_to_auto_archive_on? - before_save :set_api_entreprise_token_expires_at, if: :will_save_change_to_api_entreprise_token? before_save :update_juridique_required after_save :extend_conservation_for_dossiers @@ -756,18 +755,6 @@ class Procedure < ApplicationRecord "Procedure;#{id}" end - def api_entreprise_role?(role) - APIEntrepriseToken.new(api_entreprise_token).role?(role) - end - - def api_entreprise_token - self[:api_entreprise_token].presence || Rails.application.secrets.api_entreprise[:key] - end - - def api_entreprise_token_expired? - APIEntrepriseToken.new(api_entreprise_token).expired? - end - def create_new_revision(revision = nil) transaction do new_revision = (revision || draft_revision) @@ -974,10 +961,6 @@ class Procedure < ApplicationRecord monavis_embed.gsub('nd_source=button', "nd_source=#{source}").gsub(' Date: Tue, 24 Sep 2024 18:33:35 +0200 Subject: [PATCH 1228/1532] [#10799] Handle the case when api_entreprise_token is not nil then set to nil --- .../concerns/api_entreprise_token_concern.rb | 6 ++- spec/models/procedure_spec.rb | 51 ++++++++++++------- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/app/models/concerns/api_entreprise_token_concern.rb b/app/models/concerns/api_entreprise_token_concern.rb index e12d00048..308e73249 100644 --- a/app/models/concerns/api_entreprise_token_concern.rb +++ b/app/models/concerns/api_entreprise_token_concern.rb @@ -17,6 +17,10 @@ module APIEntrepriseTokenConcern self[:api_entreprise_token].presence || Rails.application.secrets.api_entreprise[:key] end + def has_custom_api_entreprise_token? + self[:api_entreprise_token].present? + end + def api_entreprise_token_expired? APIEntrepriseToken.new(api_entreprise_token).expired? end @@ -26,7 +30,7 @@ module APIEntrepriseTokenConcern end def set_api_entreprise_token_expires_at - self.api_entreprise_token_expires_at = APIEntrepriseToken.new(api_entreprise_token).expiration + self.api_entreprise_token_expires_at = has_custom_api_entreprise_token? ? APIEntrepriseToken.new(api_entreprise_token).expiration : nil end end end diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 0e2ad73c0..ae0179513 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -1861,7 +1861,7 @@ describe Procedure do end describe '#set_api_entreprise_token_expires_at (before_save)' do - let(:procedure) { create(:procedure) } + let(:procedure) { create(:procedure, api_entreprise_token: initial_api_entreprise_token) } before do procedure.api_entreprise_token = api_entreprise_token @@ -1869,29 +1869,44 @@ describe Procedure do subject { procedure.save } - context 'when the api_entreprise_token is nil' do - let(:api_entreprise_token) { nil } + context "when procedure had no api_entreprise_token" do + let(:initial_api_entreprise_token) { nil } - it 'does not set the api_entreprise_token_expires_at' do - expect { subject }.not_to change { procedure.api_entreprise_token_expires_at }.from(nil) + context 'when the api_entreprise_token is nil' do + let(:api_entreprise_token) { nil } + + it 'does not set the api_entreprise_token_expires_at' do + expect { subject }.not_to change { procedure.api_entreprise_token_expires_at }.from(nil) + end + end + + context 'when the api_entreprise_token is not valid' do + let(:api_entreprise_token) { "not a token" } + + it do + expect { subject }.not_to change { procedure.api_entreprise_token_expires_at }.from(nil) + end + end + + context 'when the api_entreprise_token is valid' do + let(:expiration_date) { Time.zone.now.beginning_of_minute } + let(:api_entreprise_token) { JWT.encode({ exp: expiration_date.to_i }, nil, 'none') } + + it do + expect { subject }.to change { procedure.api_entreprise_token_expires_at }.from(nil).to(expiration_date) + end end end - context 'when the api_entreprise_token is not valid' do - let(:api_entreprise_token) { "not a token" } + context "when procedure had an api_entreprise_token" do + let(:initial_api_entreprise_token) { JWT.encode({ exp: 2.months.from_now.to_i }, nil, "none") } - it do - expect { subject }.not_to change { procedure.api_entreprise_token_expires_at }.from(nil) - end - end + context 'when the api_entreprise_token is set to nil' do + let(:api_entreprise_token) { nil } - context 'when the api_entreprise_token is valid' do - let(:expiration_date) { Time.zone.now.beginning_of_minute } - let(:api_entreprise_token) { JWT.encode({ exp: expiration_date.to_i }, nil, 'none') } - - it do - puts "expiration_date: #{expiration_date.to_i}" - expect { subject }.to change { procedure.api_entreprise_token_expires_at }.from(nil).to(expiration_date) + it do + expect { subject }.to change { procedure.api_entreprise_token_expires_at }.to(nil) + end end end end From ec2c913ab4edb2e68647255c3b8d3cea469a0333 Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Tue, 24 Sep 2024 18:59:18 +0200 Subject: [PATCH 1229/1532] [#10799] Display token error on related card --- .../api_entreprise_component.html.haml | 7 +++-- .../concerns/api_entreprise_token_concern.rb | 2 +- ...ntreprise_token_expiration_alert.html.haml | 2 +- .../card/api_entreprise_component_spec.rb | 27 +++++++++++++++++++ .../api_entreprise_token_concern_spec.rb | 10 +++++-- 5 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 spec/components/procedure/card/api_entreprise_component_spec.rb diff --git a/app/components/procedure/card/api_entreprise_component/api_entreprise_component.html.haml b/app/components/procedure/card/api_entreprise_component/api_entreprise_component.html.haml index 8c5ed09f9..32797f8bd 100644 --- a/app/components/procedure/card/api_entreprise_component/api_entreprise_component.html.haml +++ b/app/components/procedure/card/api_entreprise_component/api_entreprise_component.html.haml @@ -1,8 +1,11 @@ .fr-col-6.fr-col-md-4.fr-col-lg-3 = link_to jeton_admin_procedure_path(@procedure), class: 'fr-tile fr-enlarge-link' do .fr-tile__body.flex.column.align-center.justify-between - - if @procedure.api_entreprise_token.present? - %p.fr-badge.fr-badge--success Validé + - if @procedure.has_custom_api_entreprise_token? + - if @procedure.api_entreprise_token_expired_or_expires_soon? + %p.fr-badge.fr-badge--error À renouveler + - else + %p.fr-badge.fr-badge--success Validé - else %p.fr-badge.fr-badge--info À configurer %div diff --git a/app/models/concerns/api_entreprise_token_concern.rb b/app/models/concerns/api_entreprise_token_concern.rb index 308e73249..3962b72f6 100644 --- a/app/models/concerns/api_entreprise_token_concern.rb +++ b/app/models/concerns/api_entreprise_token_concern.rb @@ -25,7 +25,7 @@ module APIEntrepriseTokenConcern APIEntrepriseToken.new(api_entreprise_token).expired? end - def api_entreprise_token_expires_soon? + def api_entreprise_token_expired_or_expires_soon? api_entreprise_token_expires_at && api_entreprise_token_expires_at <= SOON_TO_EXPIRE_DELAY.from_now end diff --git a/app/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml b/app/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml index c83fb1cec..877fb464b 100644 --- a/app/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml +++ b/app/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml @@ -5,7 +5,7 @@ %p Votre jeton API Entreprise est expiré. Merci de le renouveler. - - elsif procedure.api_entreprise_token_expires_soon? + - elsif procedure.api_entreprise_token_expired_or_expires_soon? = render Dsfr::AlertComponent.new(state: :warning, size: :sm, extra_class_names: 'fr-mb-2w') do |c| - c.with_body do %p diff --git a/spec/components/procedure/card/api_entreprise_component_spec.rb b/spec/components/procedure/card/api_entreprise_component_spec.rb new file mode 100644 index 000000000..382d425ad --- /dev/null +++ b/spec/components/procedure/card/api_entreprise_component_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Procedure::Card::APIEntrepriseComponent, type: :component do + subject { render_inline(described_class.new(procedure:)) } + + let(:procedure) { create(:procedure, api_entreprise_token:) } + + context "Token is not configured" do + let(:api_entreprise_token) { nil } + + it { is_expected.to have_css('p.fr-badge.fr-badge--info', text: "À configurer") } + end + + context "Token expires soon" do + let(:api_entreprise_token) { JWT.encode({ exp: 2.days.from_now.to_i }, nil, "none") } + + it { is_expected.to have_css('p.fr-badge.fr-badge--error', text: "À renouveler") } + end + + context "Token expires in a long time" do + let(:api_entreprise_token) { JWT.encode({ exp: 2.months.from_now.to_i }, nil, "none") } + + it { is_expected.to have_css('p.fr-badge.fr-badge--success', text: "Validé") } + end +end diff --git a/spec/models/concerns/api_entreprise_token_concern_spec.rb b/spec/models/concerns/api_entreprise_token_concern_spec.rb index 3eedc3c4d..7a6040af6 100644 --- a/spec/models/concerns/api_entreprise_token_concern_spec.rb +++ b/spec/models/concerns/api_entreprise_token_concern_spec.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true describe APIEntrepriseTokenConcern do - describe "#api_entreprise_token_expires_soon?" do - subject { procedure.api_entreprise_token_expires_soon? } + describe "#api_entreprise_token_expired_or_expires_soon?" do + subject { procedure.api_entreprise_token_expired_or_expires_soon? } let(:procedure) { create(:procedure, api_entreprise_token:) } @@ -23,5 +23,11 @@ describe APIEntrepriseTokenConcern do it { is_expected.to be_truthy } end + + context "when the token is expired" do + let(:api_entreprise_token) { JWT.encode({ exp: 1.day.ago.to_i }, nil, "none") } + + it { is_expected.to be_truthy } + end end end From 2bf773b0b7919eeed45c523b02d4a54224aa4824 Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Thu, 26 Sep 2024 11:27:29 +0200 Subject: [PATCH 1230/1532] [#10799] Add warning badges when token is expiring --- app/views/administrateurs/_breadcrumbs.html.haml | 5 +++++ .../administrateurs/procedures/_procedures_list.html.haml | 4 ++++ config/locales/views/layouts/_breadcrumb.en.yml | 1 + config/locales/views/layouts/_breadcrumb.fr.yml | 1 + 4 files changed, 11 insertions(+) diff --git a/app/views/administrateurs/_breadcrumbs.html.haml b/app/views/administrateurs/_breadcrumbs.html.haml index d19911f36..41a20952a 100644 --- a/app/views/administrateurs/_breadcrumbs.html.haml +++ b/app/views/administrateurs/_breadcrumbs.html.haml @@ -28,6 +28,11 @@ - elsif @procedure.locked? = link_to commencer_url(@procedure.path), commencer_url(@procedure.path), class: "fr-link" .flex.fr-mt-1w + + - if @procedure.api_entreprise_token_expired_or_expires_soon? + %span.fr-badge.fr-badge--warning.fr-mr-1w + = t('to_modify', scope: [:layouts, :breadcrumb]) + %span.fr-badge.fr-badge--success.fr-mr-1w = t('published', scope: [:layouts, :breadcrumb]) = t('since', scope: [:layouts, :breadcrumb], number: @procedure.id, date: l(@procedure.published_at.to_date)) diff --git a/app/views/administrateurs/procedures/_procedures_list.html.haml b/app/views/administrateurs/procedures/_procedures_list.html.haml index 535c76267..dcf4d7c9c 100644 --- a/app/views/administrateurs/procedures/_procedures_list.html.haml +++ b/app/views/administrateurs/procedures/_procedures_list.html.haml @@ -54,11 +54,15 @@ .text-right %p.fr-mb-0.width-max-content N° #{number_with_html_delimiter(procedure.id)} + - if procedure.close? || procedure.depubliee? %span.fr-badge.fr-badge--sm.fr-badge--warning = t('closed', scope: [:layouts, :breadcrumb]) - elsif procedure.publiee? + - if procedure.api_entreprise_token_expired_or_expires_soon? + %span.fr-badge.fr-badge--sm.fr-badge--warning + = t('to_modify', scope: [:layouts, :breadcrumb]) %span.fr-badge.fr-badge--sm.fr-badge--success = t('published', scope: [:layouts, :breadcrumb]) diff --git a/config/locales/views/layouts/_breadcrumb.en.yml b/config/locales/views/layouts/_breadcrumb.en.yml index f0a4ff07d..129028235 100644 --- a/config/locales/views/layouts/_breadcrumb.en.yml +++ b/config/locales/views/layouts/_breadcrumb.en.yml @@ -11,6 +11,7 @@ en: closed: "Closed" published: "Published" draft: "Draft" + to_modify: "To modify" more_info_on_test: "For more information on test stage" go_to_FAQ: "read FAQ" url_FAQ: "/faq#accordion-administrateur-2" diff --git a/config/locales/views/layouts/_breadcrumb.fr.yml b/config/locales/views/layouts/_breadcrumb.fr.yml index 2cb0ed409..cc60f2133 100644 --- a/config/locales/views/layouts/_breadcrumb.fr.yml +++ b/config/locales/views/layouts/_breadcrumb.fr.yml @@ -11,6 +11,7 @@ fr: closed: "Close" published: "Publiée" draft: "En test" + to_modify: "À modifier" more_info_on_test: "Pour plus d’information sur la phase de test" go_to_FAQ: "consulter la FAQ" url_FAQ: "/faq#accordion-administrateur-2" From 99a1b681852989638d0f75be78c30ede21e8fe0c Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Thu, 26 Sep 2024 15:13:51 +0200 Subject: [PATCH 1231/1532] [#10799] reorder methods A->Z --- app/models/concerns/api_entreprise_token_concern.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/models/concerns/api_entreprise_token_concern.rb b/app/models/concerns/api_entreprise_token_concern.rb index 3962b72f6..ad1d0a41e 100644 --- a/app/models/concerns/api_entreprise_token_concern.rb +++ b/app/models/concerns/api_entreprise_token_concern.rb @@ -7,6 +7,7 @@ module APIEntrepriseTokenConcern included do validates :api_entreprise_token, jwt_token: true, allow_blank: true + before_save :set_api_entreprise_token_expires_at, if: :will_save_change_to_api_entreprise_token? def api_entreprise_role?(role) @@ -17,10 +18,6 @@ module APIEntrepriseTokenConcern self[:api_entreprise_token].presence || Rails.application.secrets.api_entreprise[:key] end - def has_custom_api_entreprise_token? - self[:api_entreprise_token].present? - end - def api_entreprise_token_expired? APIEntrepriseToken.new(api_entreprise_token).expired? end @@ -29,6 +26,10 @@ module APIEntrepriseTokenConcern api_entreprise_token_expires_at && api_entreprise_token_expires_at <= SOON_TO_EXPIRE_DELAY.from_now end + def has_custom_api_entreprise_token? + self[:api_entreprise_token].present? + end + def set_api_entreprise_token_expires_at self.api_entreprise_token_expires_at = has_custom_api_entreprise_token? ? APIEntrepriseToken.new(api_entreprise_token).expiration : nil end From 64297f9ee83c86a11d5c162f0085e6e62bb6fa70 Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Thu, 26 Sep 2024 16:15:31 +0200 Subject: [PATCH 1232/1532] [#10799] Add error on procedure#show if api entreprise token is expiring --- app/components/procedure/errors_summary.rb | 2 ++ app/models/concerns/api_entreprise_token_concern.rb | 7 +++++++ config/locales/models/procedure/en.yml | 1 + config/locales/models/procedure/fr.yml | 1 + 4 files changed, 11 insertions(+) diff --git a/app/components/procedure/errors_summary.rb b/app/components/procedure/errors_summary.rb index ff1dfc8aa..6eb065052 100644 --- a/app/components/procedure/errors_summary.rb +++ b/app/components/procedure/errors_summary.rb @@ -47,6 +47,8 @@ class Procedure::ErrorsSummary < ApplicationComponent when :initiated_mail, :received_mail, :closed_mail, :refused_mail, :without_continuation_mail, :re_instructed_mail klass = "Mails::#{error.attribute.to_s.classify}".constantize edit_admin_procedure_mail_template_path(@procedure, klass.const_get(:SLUG)) + when :api_entreprise_token + jeton_admin_procedure_path(@procedure) end end diff --git a/app/models/concerns/api_entreprise_token_concern.rb b/app/models/concerns/api_entreprise_token_concern.rb index ad1d0a41e..debb559c6 100644 --- a/app/models/concerns/api_entreprise_token_concern.rb +++ b/app/models/concerns/api_entreprise_token_concern.rb @@ -7,6 +7,7 @@ module APIEntrepriseTokenConcern included do validates :api_entreprise_token, jwt_token: true, allow_blank: true + validate :api_entreprise_token_expiration_valid?, on: [:publication] before_save :set_api_entreprise_token_expires_at, if: :will_save_change_to_api_entreprise_token? @@ -18,6 +19,12 @@ module APIEntrepriseTokenConcern self[:api_entreprise_token].presence || Rails.application.secrets.api_entreprise[:key] end + def api_entreprise_token_expiration_valid? + if api_entreprise_token_expired_or_expires_soon? + errors.add(:api_entreprise_token, "expiré ou expirant bientôt") + end + end + def api_entreprise_token_expired? APIEntrepriseToken.new(api_entreprise_token).expired? end diff --git a/config/locales/models/procedure/en.yml b/config/locales/models/procedure/en.yml index 1328196b5..489d0baa0 100644 --- a/config/locales/models/procedure/en.yml +++ b/config/locales/models/procedure/en.yml @@ -48,6 +48,7 @@ en: personne_morale: 'Legal entity' declarative_with_state/en_instruction: Instruction declarative_with_state/accepte: Accepted + api_entreprise_token: Token API Entreprise api_particulier_token: Token API Particulier initiated_mail: File sorted for processing notification email received_mail: File submitted notification email diff --git a/config/locales/models/procedure/fr.yml b/config/locales/models/procedure/fr.yml index 35713d133..6a020049f 100644 --- a/config/locales/models/procedure/fr.yml +++ b/config/locales/models/procedure/fr.yml @@ -54,6 +54,7 @@ fr: personne_morale: 'Personne morale' declarative_with_state/en_instruction: En instruction declarative_with_state/accepte: Accepté + api_entreprise_token: Jeton API Entreprise api_particulier_token: Jeton API Particulier initiated_mail: L’email de notification de passage de dossier en instruction received_mail: L’email de notification de dépôt de dossier From 687617cb084ae8eca3706e55fb04fe86f94b3d3f Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Thu, 26 Sep 2024 17:36:12 +0200 Subject: [PATCH 1233/1532] [#10799] Add test --- .../_api_entreprise_token_expiration_alert.html.haml | 2 +- app/views/administrateurs/procedures/jeton.html.haml | 2 +- spec/components/procedures/errors_summary_spec.rb | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml b/app/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml index 877fb464b..d37e14142 100644 --- a/app/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml +++ b/app/views/administrateurs/procedures/_api_entreprise_token_expiration_alert.html.haml @@ -11,4 +11,4 @@ %p Votre jeton API Entreprise expirera le = procedure.api_entreprise_token_expires_at.strftime('%d/%m/%Y à %H:%M.') - Merci de le renouveler avant cette date. \ No newline at end of file + Merci de le renouveler avant cette date. diff --git a/app/views/administrateurs/procedures/jeton.html.haml b/app/views/administrateurs/procedures/jeton.html.haml index 57dfa4534..862a989a0 100644 --- a/app/views/administrateurs/procedures/jeton.html.haml +++ b/app/views/administrateurs/procedures/jeton.html.haml @@ -19,7 +19,7 @@ propre à votre démarche. = render partial: 'administrateurs/procedures/api_entreprise_token_expiration_alert', locals: { procedure: @procedure } - + .fr-input-group = f.label :api_entreprise_token, "Jeton", class: 'fr-label' = f.password_field :api_entreprise_token, value: @procedure.read_attribute(:api_entreprise_token), class: 'fr-input' diff --git a/spec/components/procedures/errors_summary_spec.rb b/spec/components/procedures/errors_summary_spec.rb index 9ff3edadb..e673244f9 100644 --- a/spec/components/procedures/errors_summary_spec.rb +++ b/spec/components/procedures/errors_summary_spec.rb @@ -83,7 +83,8 @@ describe Procedure::ErrorsSummary, type: :component do include Logic let(:validation_context) { :publication } - let(:procedure) { create(:procedure, attestation_template:, initiated_mail:) } + let(:expired_token) { JWT.encode({ exp: 2.days.ago.to_i }, nil, 'none') } + let(:procedure) { create(:procedure, attestation_template:, initiated_mail:, api_entreprise_token: expired_token) } let(:attestation_template) { build(:attestation_template) } let(:initiated_mail) { build(:initiated_mail) } @@ -97,6 +98,7 @@ describe Procedure::ErrorsSummary, type: :component do expect(page).to have_selector("a", text: "Les règles d’inéligibilité") expect(page).to have_selector("a", text: "Le modèle d’attestation") expect(page).to have_selector("a", text: "L’email de notification de passage de dossier en instruction") + expect(page).to have_selector("a", text: "Jeton API Entreprise") expect(page).to have_text("n'est pas valide", count: 2) end end From 6b326b634e75a3a0ab08a036aef1effd250982a0 Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Fri, 27 Sep 2024 11:55:14 +0200 Subject: [PATCH 1234/1532] [#10799] Modifications after Marlene's comments --- .../api_entreprise_component.fr.yml | 2 +- .../api_entreprise_component.html.haml | 2 +- app/components/procedure/errors_summary.rb | 2 -- .../concerns/api_entreprise_token_concern.rb | 7 ------- app/views/administrateurs/_breadcrumbs.html.haml | 2 +- .../procedures/_procedures_list.html.haml | 2 +- .../administrateurs/procedures/jeton.html.haml | 16 +++++++++------- .../administrateurs/procedures/show.html.haml | 8 ++++++++ config/locales/models/procedure/en.yml | 1 + config/locales/models/procedure/fr.yml | 1 + .../views/administrateurs/procedures/en.yml | 1 + .../views/administrateurs/procedures/fr.yml | 1 + .../components/procedures/errors_summary_spec.rb | 4 +--- 13 files changed, 26 insertions(+), 23 deletions(-) diff --git a/app/components/procedure/card/api_entreprise_component/api_entreprise_component.fr.yml b/app/components/procedure/card/api_entreprise_component/api_entreprise_component.fr.yml index 688cb12c0..1dc20806f 100644 --- a/app/components/procedure/card/api_entreprise_component/api_entreprise_component.fr.yml +++ b/app/components/procedure/card/api_entreprise_component/api_entreprise_component.fr.yml @@ -1,3 +1,3 @@ --- fr: - title: Jeton Entreprise + title: Jeton API Entreprise diff --git a/app/components/procedure/card/api_entreprise_component/api_entreprise_component.html.haml b/app/components/procedure/card/api_entreprise_component/api_entreprise_component.html.haml index 32797f8bd..39fdeb27f 100644 --- a/app/components/procedure/card/api_entreprise_component/api_entreprise_component.html.haml +++ b/app/components/procedure/card/api_entreprise_component/api_entreprise_component.html.haml @@ -10,5 +10,5 @@ %p.fr-badge.fr-badge--info À configurer %div %h3.fr-h6.fr-mt-10v= t('.title') - %p.fr-tile-subtitle Configurer le jeton API entreprise + %p.fr-tile-subtitle Configurer le jeton API Entreprise %p.fr-btn.fr-btn--tertiary= t('views.shared.actions.edit') diff --git a/app/components/procedure/errors_summary.rb b/app/components/procedure/errors_summary.rb index 6eb065052..ff1dfc8aa 100644 --- a/app/components/procedure/errors_summary.rb +++ b/app/components/procedure/errors_summary.rb @@ -47,8 +47,6 @@ class Procedure::ErrorsSummary < ApplicationComponent when :initiated_mail, :received_mail, :closed_mail, :refused_mail, :without_continuation_mail, :re_instructed_mail klass = "Mails::#{error.attribute.to_s.classify}".constantize edit_admin_procedure_mail_template_path(@procedure, klass.const_get(:SLUG)) - when :api_entreprise_token - jeton_admin_procedure_path(@procedure) end end diff --git a/app/models/concerns/api_entreprise_token_concern.rb b/app/models/concerns/api_entreprise_token_concern.rb index debb559c6..ad1d0a41e 100644 --- a/app/models/concerns/api_entreprise_token_concern.rb +++ b/app/models/concerns/api_entreprise_token_concern.rb @@ -7,7 +7,6 @@ module APIEntrepriseTokenConcern included do validates :api_entreprise_token, jwt_token: true, allow_blank: true - validate :api_entreprise_token_expiration_valid?, on: [:publication] before_save :set_api_entreprise_token_expires_at, if: :will_save_change_to_api_entreprise_token? @@ -19,12 +18,6 @@ module APIEntrepriseTokenConcern self[:api_entreprise_token].presence || Rails.application.secrets.api_entreprise[:key] end - def api_entreprise_token_expiration_valid? - if api_entreprise_token_expired_or_expires_soon? - errors.add(:api_entreprise_token, "expiré ou expirant bientôt") - end - end - def api_entreprise_token_expired? APIEntrepriseToken.new(api_entreprise_token).expired? end diff --git a/app/views/administrateurs/_breadcrumbs.html.haml b/app/views/administrateurs/_breadcrumbs.html.haml index 41a20952a..9264a128e 100644 --- a/app/views/administrateurs/_breadcrumbs.html.haml +++ b/app/views/administrateurs/_breadcrumbs.html.haml @@ -30,7 +30,7 @@ .flex.fr-mt-1w - if @procedure.api_entreprise_token_expired_or_expires_soon? - %span.fr-badge.fr-badge--warning.fr-mr-1w + %span.fr-badge.fr-badge--error.fr-mr-1w = t('to_modify', scope: [:layouts, :breadcrumb]) %span.fr-badge.fr-badge--success.fr-mr-1w diff --git a/app/views/administrateurs/procedures/_procedures_list.html.haml b/app/views/administrateurs/procedures/_procedures_list.html.haml index dcf4d7c9c..99eecd912 100644 --- a/app/views/administrateurs/procedures/_procedures_list.html.haml +++ b/app/views/administrateurs/procedures/_procedures_list.html.haml @@ -61,7 +61,7 @@ - elsif procedure.publiee? - if procedure.api_entreprise_token_expired_or_expires_soon? - %span.fr-badge.fr-badge--sm.fr-badge--warning + %span.fr-badge.fr-badge--sm.fr-badge--error = t('to_modify', scope: [:layouts, :breadcrumb]) %span.fr-badge.fr-badge--sm.fr-badge--success = t('published', scope: [:layouts, :breadcrumb]) diff --git a/app/views/administrateurs/procedures/jeton.html.haml b/app/views/administrateurs/procedures/jeton.html.haml index 862a989a0..f51fbaba7 100644 --- a/app/views/administrateurs/procedures/jeton.html.haml +++ b/app/views/administrateurs/procedures/jeton.html.haml @@ -1,10 +1,10 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_back_path(@procedure)], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['Jeton Entreprise']] } + ['Jeton API Entreprise']] } .fr-container - %h1.fr-h2 Jeton Entreprise + %h1.fr-h2 Jeton API Entreprise = form_with model: @procedure, url: url_for({ controller: 'administrateurs/procedures', action: :update_jeton }) do |f| .fr-container @@ -14,14 +14,16 @@ Démarches Simplifiées utilise = link_to 'API Entreprise', "https://entreprise.api.gouv.fr/" qui permet de récupérer les informations administratives des entreprises et des associations. - Si votre démarche nécessite des autorisations spécifiques que Démarches Simplifiées n’a pas par défaut, merci de renseigner ici le jeton - = link_to 'API Entreprise', "https://api.gouv.fr/les-api/api-entreprise/demande-acces" + Si votre démarche nécessite des autorisations spécifiques que Démarches Simplifiées n’a pas par défaut, merci de renseigner ci dessous + %strong le jeton API Entreprise propre à votre démarche. + %p + Si besoin, vous pouvez demander une habilitation API Entreprise sur le site + = link_to 'api.gouv.fr.', "https://api.gouv.fr/les-api/api-entreprise/demande-acces" + = render partial: 'administrateurs/procedures/api_entreprise_token_expiration_alert', locals: { procedure: @procedure } - .fr-input-group - = f.label :api_entreprise_token, "Jeton", class: 'fr-label' - = f.password_field :api_entreprise_token, value: @procedure.read_attribute(:api_entreprise_token), class: 'fr-input' + = render Dsfr::InputComponent.new(form: f, attribute: :api_entreprise_token, input_type: :password_field, required: false, opts: { value: @procedure.read_attribute(:api_entreprise_token)}) = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f) diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml index 8cdfc01c2..4724df9ff 100644 --- a/app/views/administrateurs/procedures/show.html.haml +++ b/app/views/administrateurs/procedures/show.html.haml @@ -27,6 +27,14 @@ = link_to 'Clore', admin_procedure_close_path(procedure_id: @procedure.id), class: 'fr-btn fr-btn--tertiary fr-btn--icon-left fr-icon-calendar-close-fill', id: "close-procedure-link" .fr-container + - if @procedure.api_entreprise_token_expired_or_expires_soon? + = render Dsfr::AlertComponent.new(state: :error, title: t(:technical_issues, scope: [:administrateurs, :procedures]), extra_class_names: 'fr-mb-2w') do |c| + - c.with_body do + %ul.fr-mb-0 + %li + = link_to "Jeton API Entreprise", jeton_admin_procedure_path(@procedure), class: 'error-anchor' + est expiré ou va expirer prochainement + - if @procedure.draft_changed? = render Dsfr::CalloutComponent.new(title: t(:has_changes, scope: [:administrateurs, :revision_changes]), icon: "fr-fi-information-line") do |c| - c.with_body do diff --git a/config/locales/models/procedure/en.yml b/config/locales/models/procedure/en.yml index 489d0baa0..98d27a1f4 100644 --- a/config/locales/models/procedure/en.yml +++ b/config/locales/models/procedure/en.yml @@ -7,6 +7,7 @@ en: attributes: procedure: hints: + api_entreprise_token: 'For example: eyJhbGciOiJIUzI1NiJ9.eyJ1...' description: Describe in a few lines the context, the aim etc. description_target_audience: Describe in a few lines the final recipients of the process, the eligibility criteria if there are any, the prerequisites, etc. description_pj: Describe the required attachments list if there is any diff --git a/config/locales/models/procedure/fr.yml b/config/locales/models/procedure/fr.yml index 6a020049f..9cc851e1f 100644 --- a/config/locales/models/procedure/fr.yml +++ b/config/locales/models/procedure/fr.yml @@ -7,6 +7,7 @@ fr: attributes: procedure: hints: + api_entreprise_token: 'Exemple : eyJhbGciOiJIUzI1NiJ9.eyJ1...' description: Décrivez en quelques lignes le contexte, la finalité, etc. description_target_audience: Décrivez en quelques lignes les destinataires finaux de la démarche, les conditions d’éligibilité s’il y en a, les pré-requis, etc. description_pj: Décrivez la liste des pièces jointes à fournir s’il y en a diff --git a/config/locales/views/administrateurs/procedures/en.yml b/config/locales/views/administrateurs/procedures/en.yml index c1776e841..2f471930d 100644 --- a/config/locales/views/administrateurs/procedures/en.yml +++ b/config/locales/views/administrateurs/procedures/en.yml @@ -67,6 +67,7 @@ en: dpd_part_4: How to do ? You can either send him the link to the procedure on test stage by email, or name him "administrator". In any case, publish your approach only after having had his opinion. back_to_procedure: 'Cancel and return to the procedure page' submit: Publish + technical_issues: "Issues are affecting the proper functioning of the process" check_path: path_not_available: owner: This URL is identical to another of your published procedures. If you publish this procedure, the old one will be unpublished and will no longer be accessible to the public. diff --git a/config/locales/views/administrateurs/procedures/fr.yml b/config/locales/views/administrateurs/procedures/fr.yml index f86a93276..036a90daa 100644 --- a/config/locales/views/administrateurs/procedures/fr.yml +++ b/config/locales/views/administrateurs/procedures/fr.yml @@ -67,6 +67,7 @@ fr: dpd_part_4: Comment faire ? Vous pouvez soit lui communiquer par email le lien vers la démarche en test, ou bien le nommer « administrateur ». Dans tous les cas, ne publiez votre démarche qu’après avoir eu son avis. back_to_procedure: 'Annuler et revenir à la page de la démarche' submit: Publier + technical_issues: Des problèmes impactent le bon fonctionnement de la démarche check_path: path_not_available: owner: Cette url est identique à celle d’une autre de vos démarches publiées. Si vous publiez cette démarche, l’ancienne sera dépubliée et ne sera plus accessible au public. diff --git a/spec/components/procedures/errors_summary_spec.rb b/spec/components/procedures/errors_summary_spec.rb index e673244f9..9ff3edadb 100644 --- a/spec/components/procedures/errors_summary_spec.rb +++ b/spec/components/procedures/errors_summary_spec.rb @@ -83,8 +83,7 @@ describe Procedure::ErrorsSummary, type: :component do include Logic let(:validation_context) { :publication } - let(:expired_token) { JWT.encode({ exp: 2.days.ago.to_i }, nil, 'none') } - let(:procedure) { create(:procedure, attestation_template:, initiated_mail:, api_entreprise_token: expired_token) } + let(:procedure) { create(:procedure, attestation_template:, initiated_mail:) } let(:attestation_template) { build(:attestation_template) } let(:initiated_mail) { build(:initiated_mail) } @@ -98,7 +97,6 @@ describe Procedure::ErrorsSummary, type: :component do expect(page).to have_selector("a", text: "Les règles d’inéligibilité") expect(page).to have_selector("a", text: "Le modèle d’attestation") expect(page).to have_selector("a", text: "L’email de notification de passage de dossier en instruction") - expect(page).to have_selector("a", text: "Jeton API Entreprise") expect(page).to have_text("n'est pas valide", count: 2) end end From f26ff3053880d7e16a56ff3b2b75e78843063205 Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Fri, 27 Sep 2024 16:31:41 +0200 Subject: [PATCH 1235/1532] [#10799] Add maintenance task to fill api_entreprise_token_expires_at for previous data --- ...te_api_entreprise_token_expires_at_task.rb | 14 +++++++++++++ ...i_entreprise_token_expires_at_task_spec.rb | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 app/tasks/maintenance/update_api_entreprise_token_expires_at_task.rb create mode 100644 spec/tasks/maintenance/update_api_entreprise_token_expires_at_task_spec.rb diff --git a/app/tasks/maintenance/update_api_entreprise_token_expires_at_task.rb b/app/tasks/maintenance/update_api_entreprise_token_expires_at_task.rb new file mode 100644 index 000000000..4c111d7dc --- /dev/null +++ b/app/tasks/maintenance/update_api_entreprise_token_expires_at_task.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Maintenance + class UpdateAPIEntrepriseTokenExpiresAtTask < MaintenanceTasks::Task + def collection + Procedure.where.not(api_entreprise_token: nil) + end + + def process(procedure) + procedure.set_api_entreprise_token_expires_at + procedure.save! + end + end +end diff --git a/spec/tasks/maintenance/update_api_entreprise_token_expires_at_task_spec.rb b/spec/tasks/maintenance/update_api_entreprise_token_expires_at_task_spec.rb new file mode 100644 index 000000000..87a78b147 --- /dev/null +++ b/spec/tasks/maintenance/update_api_entreprise_token_expires_at_task_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Maintenance + RSpec.describe UpdateAPIEntrepriseTokenExpiresAtTask do + describe "#process" do + subject(:process) { described_class.process(procedure) } + + let(:expiration) { 1.month.from_now.beginning_of_minute } + let(:procedure) { create(:procedure) } + + before do + procedure.update_column(:api_entreprise_token, JWT.encode({ exp: expiration.to_i }, nil, "none")) + end + + it do + expect { process }.to change { procedure.reload.api_entreprise_token_expires_at }.from(nil).to(expiration) + end + end + end +end From e172f3ed6cae59017daa88f7ec4e5544a7b51500 Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Fri, 27 Sep 2024 16:49:23 +0200 Subject: [PATCH 1236/1532] [#10799] Fix tests --- app/models/api_entreprise_token.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/api_entreprise_token.rb b/app/models/api_entreprise_token.rb index fac81acda..b3c1e5b7b 100644 --- a/app/models/api_entreprise_token.rb +++ b/app/models/api_entreprise_token.rb @@ -18,7 +18,7 @@ class APIEntrepriseToken end def expiration - Time.zone.at(decoded_token["exp"]) + decoded_token.key?("exp") && Time.zone.at(decoded_token["exp"]) end def role?(role) From c897893e8a2315398d1bc2340888d1ca17a0839d Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Fri, 27 Sep 2024 17:02:56 +0200 Subject: [PATCH 1237/1532] [#10799] Remove dead code --- .../concerns/api_entreprise_token_concern.rb | 4 --- spec/models/procedure_spec.rb | 25 ------------------- 2 files changed, 29 deletions(-) diff --git a/app/models/concerns/api_entreprise_token_concern.rb b/app/models/concerns/api_entreprise_token_concern.rb index ad1d0a41e..2cfacb346 100644 --- a/app/models/concerns/api_entreprise_token_concern.rb +++ b/app/models/concerns/api_entreprise_token_concern.rb @@ -18,10 +18,6 @@ module APIEntrepriseTokenConcern self[:api_entreprise_token].presence || Rails.application.secrets.api_entreprise[:key] end - def api_entreprise_token_expired? - APIEntrepriseToken.new(api_entreprise_token).expired? - end - def api_entreprise_token_expired_or_expires_soon? api_entreprise_token_expires_at && api_entreprise_token_expires_at <= SOON_TO_EXPIRE_DELAY.from_now end diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index ae0179513..2453f7749 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -616,31 +616,6 @@ describe Procedure do end end - describe 'api_entreprise_token_expired?' do - let(:token) { "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" } - let(:procedure) { create(:procedure, api_entreprise_token: token) } - let(:payload) { - [ - { "exp" => expiration_time } - ] - } - let(:subject) { procedure.api_entreprise_token_expired? } - - before do - allow(JWT).to receive(:decode).with(token, nil, false).and_return(payload) - end - - context "with token expired" do - let(:expiration_time) { (1.day.ago).to_i } - it { is_expected.to be_truthy } - end - - context "with token not expired" do - let(:expiration_time) { (1.day.from_now).to_i } - it { is_expected.to be_falsey } - end - end - describe 'clone' do let(:service) { create(:service) } let(:procedure) do From 4266a76db612b0f973dfec8bd9d76bffe65d6ffb Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Fri, 27 Sep 2024 17:32:39 +0200 Subject: [PATCH 1238/1532] [#10799] Move test in right file --- .../api_entreprise_token_concern_spec.rb | 51 +++++++++++++++++++ spec/models/procedure_spec.rb | 51 ------------------- 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/spec/models/concerns/api_entreprise_token_concern_spec.rb b/spec/models/concerns/api_entreprise_token_concern_spec.rb index 7a6040af6..ce58d2b15 100644 --- a/spec/models/concerns/api_entreprise_token_concern_spec.rb +++ b/spec/models/concerns/api_entreprise_token_concern_spec.rb @@ -30,4 +30,55 @@ describe APIEntrepriseTokenConcern do it { is_expected.to be_truthy } end end + + describe '#set_api_entreprise_token_expires_at (before_save)' do + let(:procedure) { create(:procedure, api_entreprise_token: initial_api_entreprise_token) } + + before do + procedure.api_entreprise_token = api_entreprise_token + end + + subject { procedure.save } + + context "when procedure had no api_entreprise_token" do + let(:initial_api_entreprise_token) { nil } + + context 'when the api_entreprise_token is nil' do + let(:api_entreprise_token) { nil } + + it 'does not set the api_entreprise_token_expires_at' do + expect { subject }.not_to change { procedure.api_entreprise_token_expires_at }.from(nil) + end + end + + context 'when the api_entreprise_token is not valid' do + let(:api_entreprise_token) { "not a token" } + + it do + expect { subject }.not_to change { procedure.api_entreprise_token_expires_at }.from(nil) + end + end + + context 'when the api_entreprise_token is valid' do + let(:expiration_date) { Time.zone.now.beginning_of_minute } + let(:api_entreprise_token) { JWT.encode({ exp: expiration_date.to_i }, nil, 'none') } + + it do + expect { subject }.to change { procedure.api_entreprise_token_expires_at }.from(nil).to(expiration_date) + end + end + end + + context "when procedure had an api_entreprise_token" do + let(:initial_api_entreprise_token) { JWT.encode({ exp: 2.months.from_now.to_i }, nil, "none") } + + context 'when the api_entreprise_token is set to nil' do + let(:api_entreprise_token) { nil } + + it do + expect { subject }.to change { procedure.api_entreprise_token_expires_at }.to(nil) + end + end + end + end end diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 2453f7749..78f5a45d1 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -1835,57 +1835,6 @@ describe Procedure do end end - describe '#set_api_entreprise_token_expires_at (before_save)' do - let(:procedure) { create(:procedure, api_entreprise_token: initial_api_entreprise_token) } - - before do - procedure.api_entreprise_token = api_entreprise_token - end - - subject { procedure.save } - - context "when procedure had no api_entreprise_token" do - let(:initial_api_entreprise_token) { nil } - - context 'when the api_entreprise_token is nil' do - let(:api_entreprise_token) { nil } - - it 'does not set the api_entreprise_token_expires_at' do - expect { subject }.not_to change { procedure.api_entreprise_token_expires_at }.from(nil) - end - end - - context 'when the api_entreprise_token is not valid' do - let(:api_entreprise_token) { "not a token" } - - it do - expect { subject }.not_to change { procedure.api_entreprise_token_expires_at }.from(nil) - end - end - - context 'when the api_entreprise_token is valid' do - let(:expiration_date) { Time.zone.now.beginning_of_minute } - let(:api_entreprise_token) { JWT.encode({ exp: expiration_date.to_i }, nil, 'none') } - - it do - expect { subject }.to change { procedure.api_entreprise_token_expires_at }.from(nil).to(expiration_date) - end - end - end - - context "when procedure had an api_entreprise_token" do - let(:initial_api_entreprise_token) { JWT.encode({ exp: 2.months.from_now.to_i }, nil, "none") } - - context 'when the api_entreprise_token is set to nil' do - let(:api_entreprise_token) { nil } - - it do - expect { subject }.to change { procedure.api_entreprise_token_expires_at }.to(nil) - end - end - end - end - describe "#parsed_latest_zone_labels" do let!(:draft_procedure) { create(:procedure) } let!(:published_procedure) { create(:procedure_with_dossiers, :published, dossiers_count: 2) } From dfa3276cb646c00f9d8e3958a28faf3244ff6ed5 Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Fri, 27 Sep 2024 17:42:56 +0200 Subject: [PATCH 1239/1532] [#10799] Missing test --- ...ate_api_entreprise_token_expires_at_task_spec.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/spec/tasks/maintenance/update_api_entreprise_token_expires_at_task_spec.rb b/spec/tasks/maintenance/update_api_entreprise_token_expires_at_task_spec.rb index 87a78b147..4fca851f8 100644 --- a/spec/tasks/maintenance/update_api_entreprise_token_expires_at_task_spec.rb +++ b/spec/tasks/maintenance/update_api_entreprise_token_expires_at_task_spec.rb @@ -2,6 +2,19 @@ module Maintenance RSpec.describe UpdateAPIEntrepriseTokenExpiresAtTask do + describe '#collection' do + subject(:collection) { described_class.collection } + + let!(:procedures_with_token) { create_list(:procedure, 3, api_entreprise_token: JWT.encode({}, nil, 'none')) } + let!(:procedure_without_token) { create(:procedure, api_entreprise_token: nil) } + + it 'returns procedures with api_entreprise_token present' do + expect(collection).to match_array(procedures_with_token) + + expect(collection).not_to include(procedure_without_token) + end + end + describe "#process" do subject(:process) { described_class.process(procedure) } From 6bdc641cdfa654ce81a25c7649d5e16f8d095526 Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Fri, 27 Sep 2024 18:08:09 +0200 Subject: [PATCH 1240/1532] =?UTF-8?q?[#10799]=20Fix=20after=20Marl=C3=A8ne?= =?UTF-8?q?'s=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/administrateurs/procedures/jeton.html.haml | 4 ++-- app/views/administrateurs/procedures/show.html.haml | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/views/administrateurs/procedures/jeton.html.haml b/app/views/administrateurs/procedures/jeton.html.haml index f51fbaba7..036bffba4 100644 --- a/app/views/administrateurs/procedures/jeton.html.haml +++ b/app/views/administrateurs/procedures/jeton.html.haml @@ -18,8 +18,8 @@ %strong le jeton API Entreprise propre à votre démarche. %p - Si besoin, vous pouvez demander une habilitation API Entreprise sur le site - = link_to 'api.gouv.fr.', "https://api.gouv.fr/les-api/api-entreprise/demande-acces" + Si besoin, vous pouvez demander une habilitation API Entreprise en cliquant sur le lien suivant : + = link_to "https://api.gouv.fr/les-api/api-entreprise/demande-acces.", "https://api.gouv.fr/les-api/api-entreprise/demande-acces" = render partial: 'administrateurs/procedures/api_entreprise_token_expiration_alert', locals: { procedure: @procedure } diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml index 4724df9ff..4414f921f 100644 --- a/app/views/administrateurs/procedures/show.html.haml +++ b/app/views/administrateurs/procedures/show.html.haml @@ -32,6 +32,7 @@ - c.with_body do %ul.fr-mb-0 %li + Le = link_to "Jeton API Entreprise", jeton_admin_procedure_path(@procedure), class: 'error-anchor' est expiré ou va expirer prochainement From 029a75404d607844ea3f43c4e0c786d637a5ac58 Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Tue, 8 Oct 2024 09:48:19 +0200 Subject: [PATCH 1241/1532] [#10799] Fixes after @E-L-T review's --- .../api_entreprise_component.html.haml | 2 +- app/models/concerns/api_entreprise_token_concern.rb | 4 ++-- .../update_api_entreprise_token_expires_at_task.rb | 2 +- app/views/administrateurs/procedures/jeton.html.haml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/components/procedure/card/api_entreprise_component/api_entreprise_component.html.haml b/app/components/procedure/card/api_entreprise_component/api_entreprise_component.html.haml index 39fdeb27f..e7eda1d7c 100644 --- a/app/components/procedure/card/api_entreprise_component/api_entreprise_component.html.haml +++ b/app/components/procedure/card/api_entreprise_component/api_entreprise_component.html.haml @@ -1,7 +1,7 @@ .fr-col-6.fr-col-md-4.fr-col-lg-3 = link_to jeton_admin_procedure_path(@procedure), class: 'fr-tile fr-enlarge-link' do .fr-tile__body.flex.column.align-center.justify-between - - if @procedure.has_custom_api_entreprise_token? + - if @procedure.has_api_entreprise_token? - if @procedure.api_entreprise_token_expired_or_expires_soon? %p.fr-badge.fr-badge--error À renouveler - else diff --git a/app/models/concerns/api_entreprise_token_concern.rb b/app/models/concerns/api_entreprise_token_concern.rb index 2cfacb346..3750d35ef 100644 --- a/app/models/concerns/api_entreprise_token_concern.rb +++ b/app/models/concerns/api_entreprise_token_concern.rb @@ -22,12 +22,12 @@ module APIEntrepriseTokenConcern api_entreprise_token_expires_at && api_entreprise_token_expires_at <= SOON_TO_EXPIRE_DELAY.from_now end - def has_custom_api_entreprise_token? + def has_api_entreprise_token? self[:api_entreprise_token].present? end def set_api_entreprise_token_expires_at - self.api_entreprise_token_expires_at = has_custom_api_entreprise_token? ? APIEntrepriseToken.new(api_entreprise_token).expiration : nil + self.api_entreprise_token_expires_at = has_api_entreprise_token? ? APIEntrepriseToken.new(api_entreprise_token).expiration : nil end end end diff --git a/app/tasks/maintenance/update_api_entreprise_token_expires_at_task.rb b/app/tasks/maintenance/update_api_entreprise_token_expires_at_task.rb index 4c111d7dc..206a270ec 100644 --- a/app/tasks/maintenance/update_api_entreprise_token_expires_at_task.rb +++ b/app/tasks/maintenance/update_api_entreprise_token_expires_at_task.rb @@ -3,7 +3,7 @@ module Maintenance class UpdateAPIEntrepriseTokenExpiresAtTask < MaintenanceTasks::Task def collection - Procedure.where.not(api_entreprise_token: nil) + Procedure.with_discarded.where.not(api_entreprise_token: nil) end def process(procedure) diff --git a/app/views/administrateurs/procedures/jeton.html.haml b/app/views/administrateurs/procedures/jeton.html.haml index 036bffba4..8172136d5 100644 --- a/app/views/administrateurs/procedures/jeton.html.haml +++ b/app/views/administrateurs/procedures/jeton.html.haml @@ -14,7 +14,7 @@ Démarches Simplifiées utilise = link_to 'API Entreprise', "https://entreprise.api.gouv.fr/" qui permet de récupérer les informations administratives des entreprises et des associations. - Si votre démarche nécessite des autorisations spécifiques que Démarches Simplifiées n’a pas par défaut, merci de renseigner ci dessous + Si votre démarche nécessite des autorisations spécifiques que Démarches Simplifiées n’a pas par défaut, merci de renseigner ci-dessous %strong le jeton API Entreprise propre à votre démarche. %p From d13c475170c1c9ded27e46a48c06626dcc82dd21 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 14 Oct 2024 15:58:32 +0200 Subject: [PATCH 1242/1532] fix(api_entreprise): better handle api entreprise errors --- app/controllers/users/dossiers_controller.rb | 4 +-- app/jobs/api_entreprise/etablissement_job.rb | 8 ++++++ .../cron/backfill_siret_degraded_mode_job.rb | 1 + app/lib/api_entreprise/api.rb | 4 +-- ...rna_champ_association_fetchable_concern.rb | 12 ++++++--- ...t_champ_etablissement_fetchable_concern.rb | 8 +++--- app/services/api_entreprise_service.rb | 25 +++++++++++++++++-- 7 files changed, 48 insertions(+), 14 deletions(-) create mode 100644 app/jobs/api_entreprise/etablissement_job.rb diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index b7166d911..fbc164888 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -188,8 +188,8 @@ module Users sanitized_siret = siret_model.siret etablissement = begin APIEntrepriseService.create_etablissement(@dossier, sanitized_siret, current_user.id) - rescue => error - if error.is_a?(APIEntreprise::API::Error::ServiceUnavailable) || (error.try(:network_error?) && !APIEntrepriseService.api_insee_up?) + rescue APIEntreprise::API::Error, APIEntrepriseToken::TokenError => error + if APIEntrepriseService.service_unavailable_error?(error, target: :insee) # TODO: notify ops APIEntrepriseService.create_etablissement_as_degraded_mode(@dossier, sanitized_siret, current_user.id) else diff --git a/app/jobs/api_entreprise/etablissement_job.rb b/app/jobs/api_entreprise/etablissement_job.rb new file mode 100644 index 000000000..fac8732f6 --- /dev/null +++ b/app/jobs/api_entreprise/etablissement_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class APIEntreprise::EtablissementJob < APIEntreprise::Job + def perform(etablissement_id, procedure_id) + find_etablissement(etablissement_id) + APIEntrepriseService.update_etablissement_from_degraded_mode(etablissement, procedure_id) + end +end diff --git a/app/jobs/cron/backfill_siret_degraded_mode_job.rb b/app/jobs/cron/backfill_siret_degraded_mode_job.rb index 30233f0a6..bb80ed637 100644 --- a/app/jobs/cron/backfill_siret_degraded_mode_job.rb +++ b/app/jobs/cron/backfill_siret_degraded_mode_job.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# TODO: remove this job in a few days once all failed etablissements are queued as separate jobs class Cron::BackfillSiretDegradedModeJob < Cron::CronJob self.schedule_expression = "every 2 hour" diff --git a/app/lib/api_entreprise/api.rb b/app/lib/api_entreprise/api.rb index 0c6b93bc4..1977b5ee0 100644 --- a/app/lib/api_entreprise/api.rb +++ b/app/lib/api_entreprise/api.rb @@ -138,10 +138,10 @@ class APIEntreprise::API end end + SERVICE_UNAVAILABLE_ERRORS = ["01000", "01001", "01002", "02002", "03002", "28002", "29002", "31002", "34002"] def service_unavailable?(response) - return true if response.code == 503 if response.code == 502 || response.code == 504 - parse_response_errors(response).any? { _1.is_a?(Hash) && ["01000", "01001", "01002", "02002", "03002"].include?(_1[:code]) } + parse_response_errors(response).any? { _1.is_a?(Hash) && _1[:code]&.in?(SERVICE_UNAVAILABLE_ERRORS) } end end diff --git a/app/models/concerns/rna_champ_association_fetchable_concern.rb b/app/models/concerns/rna_champ_association_fetchable_concern.rb index 6fe19ba2d..b1e95c97e 100644 --- a/app/models/concerns/rna_champ_association_fetchable_concern.rb +++ b/app/models/concerns/rna_champ_association_fetchable_concern.rb @@ -12,10 +12,14 @@ module RNAChampAssociationFetchableConcern return clear_association!(:invalid) unless valid_champ_value? return clear_association!(:not_found) if (data = APIEntreprise::RNAAdapter.new(rna, procedure_id).to_params).blank? - update!(data: data, value_json: APIGeoService.parse_rna_address(data['adresse'])) - rescue APIEntreprise::API::Error => error - error_key = :network_error if error.try(:network_error?) && !APIEntrepriseService.api_djepva_up? - clear_association!(error_key) + update!(data:, value_json: APIGeoService.parse_rna_address(data['adresse'])) + rescue APIEntreprise::API::Error, APIEntrepriseToken::TokenError => error + if APIEntrepriseService.service_unavailable_error?(error, target: :djepva) + clear_association!(:network_error) + else + Sentry.capture_exception(error, extra: { dossier_id:, rna: }) + clear_association!(nil) + end end private diff --git a/app/models/concerns/siret_champ_etablissement_fetchable_concern.rb b/app/models/concerns/siret_champ_etablissement_fetchable_concern.rb index ad88d9e74..e6dbba3e3 100644 --- a/app/models/concerns/siret_champ_etablissement_fetchable_concern.rb +++ b/app/models/concerns/siret_champ_etablissement_fetchable_concern.rb @@ -11,16 +11,16 @@ module SiretChampEtablissementFetchableConcern return clear_etablissement!(:invalid_checksum) if invalid_because?(siret, :checksum) # i18n-tasks-use t('errors.messages.invalid_siret_checksum') return clear_etablissement!(:not_found) unless (etablissement = APIEntrepriseService.create_etablissement(self, siret, user&.id)) # i18n-tasks-use t('errors.messages.siret_not_found') - update!(etablissement: etablissement, value_json: APIGeoService.parse_etablissement_address(etablissement)) - rescue => error - if error.try(:network_error?) && !APIEntrepriseService.api_insee_up? + update!(etablissement:) + rescue APIEntreprise::API::Error, APIEntrepriseToken::TokenError => error + if APIEntrepriseService.service_unavailable_error?(error, target: :insee) update!( etablissement: APIEntrepriseService.create_etablissement_as_degraded_mode(self, siret, user.id) ) @etablissement_fetch_error_key = :api_entreprise_down false else - Sentry.capture_exception(error, extra: { dossier_id: dossier_id, siret: siret }) + Sentry.capture_exception(error, extra: { dossier_id:, siret: }) clear_etablissement!(:network_error) # i18n-tasks-use t('errors.messages.siret_network_error') end end diff --git a/app/services/api_entreprise_service.rb b/app/services/api_entreprise_service.rb index 4d038993b..f73ccbcb0 100644 --- a/app/services/api_entreprise_service.rb +++ b/app/services/api_entreprise_service.rb @@ -23,6 +23,10 @@ class APIEntrepriseService etablissement = dossier_or_champ.build_etablissement(etablissement_params) etablissement.save! + if dossier_or_champ.is_a?(Champ) + dossier_or_champ.update!(value_json: APIGeoService.parse_etablissement_address(etablissement)) + end + perform_later_fetch_jobs(etablissement, procedure_id, user_id) etablissement @@ -45,15 +49,25 @@ class APIEntrepriseService return nil if etablissement_params.empty? etablissement.update!(etablissement_params) + + if etablissement.champ.present? + etablissement.champ.update!(value_json: APIGeoService.parse_etablissement_address(etablissement)) + end + + etablissement end def perform_later_fetch_jobs(etablissement, procedure_id, user_id, wait: nil) - [ + jobs = [ APIEntreprise::EntrepriseJob, APIEntreprise::ExtraitKbisJob, APIEntreprise::TvaJob, APIEntreprise::AssociationJob, APIEntreprise::ExercicesJob, APIEntreprise::EffectifsJob, APIEntreprise::EffectifsAnnuelsJob, APIEntreprise::AttestationSocialeJob, APIEntreprise::BilansBdfJob - ].each do |job| + ] + if etablissement.as_degraded_mode? + jobs << APIEntreprise::EtablissementJob + end + jobs.each do |job| job.set(wait:).perform_later(etablissement.id, procedure_id) end @@ -69,6 +83,13 @@ class APIEntrepriseService api_up?("https://entreprise.api.gouv.fr/ping/djepva/api-association") end + def service_unavailable_error?(error, target:) + return false if !error.try(:network_error?) + return true if target == :insee && !APIEntrepriseService.api_insee_up? + return true if target == :djepva && !APIEntrepriseService.api_djepva_up? + error.is_a?(APIEntreprise::API::Error::ServiceUnavailable) + end + private def api_up?(url) From 4b5235e42fbe803b958081a85d36e50ecafb0883 Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Tue, 15 Oct 2024 15:32:46 +0200 Subject: [PATCH 1243/1532] [#10919] Fix tests --- spec/models/geo_area_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/models/geo_area_spec.rb b/spec/models/geo_area_spec.rb index 0707782fa..e2eb63da5 100644 --- a/spec/models/geo_area_spec.rb +++ b/spec/models/geo_area_spec.rb @@ -150,7 +150,7 @@ RSpec.describe GeoArea, type: :model do before { allow(geo_area).to receive(:area).and_return(nil) } it "should not crash" do - expect(geo_area.label).to eq("Parcelle n° - Feuille - m² – commune ") + expect(geo_area.label).to eq("Parcelle n°  - Feuille   -  m² – commune ") end end end From 3e73ff0d35a83fc38f8b8523cd14ba3258d4c746 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 27 Sep 2024 10:00:48 +0200 Subject: [PATCH 1244/1532] small refactors --- app/components/instructeurs/column_filter_component.rb | 6 +----- .../procedures/_dossiers_filter_dropdown.html.haml | 2 +- app/views/instructeurs/procedures/show.html.haml | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/app/components/instructeurs/column_filter_component.rb b/app/components/instructeurs/column_filter_component.rb index de1826037..3ea2b1153 100644 --- a/app/components/instructeurs/column_filter_component.rb +++ b/app/components/instructeurs/column_filter_component.rb @@ -39,11 +39,7 @@ class Instructeurs::ColumnFilterComponent < ApplicationComponent end def filterable_columns_options - procedure.columns.filter_map do |column| - next if column.filterable == false - - [column.label, column.id] - end + @procedure.columns.filter(&:filterable).map { [_1.label, _1.id] } end private diff --git a/app/views/instructeurs/procedures/_dossiers_filter_dropdown.html.haml b/app/views/instructeurs/procedures/_dossiers_filter_dropdown.html.haml index e301a5c53..2fe1a3065 100644 --- a/app/views/instructeurs/procedures/_dossiers_filter_dropdown.html.haml +++ b/app/views/instructeurs/procedures/_dossiers_filter_dropdown.html.haml @@ -3,4 +3,4 @@ = t('views.instructeurs.dossiers.filters.title') - menu.with_form do - = render Instructeurs::ColumnFilterComponent.new(procedure:, procedure_presentation: @procedure_presentation, statut:) + = render Instructeurs::ColumnFilterComponent.new(procedure:, procedure_presentation:, statut:) diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index 2a9a62fca..a7dd1e0a3 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -61,7 +61,7 @@ %hr .flex.align-center - if @filtered_sorted_paginated_ids.present? || @current_filters.count > 0 - = render partial: "dossiers_filter_dropdown", locals: { procedure: @procedure, statut: @statut} + = render partial: "dossiers_filter_dropdown", locals: { procedure: @procedure, statut: @statut, procedure_presentation: @procedure_presentation } = render Dossiers::NotifiedToggleComponent.new(procedure: @procedure, procedure_presentation: @procedure_presentation) if @statut != 'a-suivre' .fr-ml-auto From 4e0d3c2df1952236e2dc1d25330b3d6b905aa78d Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 11 Oct 2024 11:23:36 +0200 Subject: [PATCH 1245/1532] improve Column not found error message --- app/models/column.rb | 8 +++++++- app/models/concerns/columns_concern.rb | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/models/column.rb b/app/models/column.rb index 662bfda76..b6c6a43d6 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -38,6 +38,12 @@ class Column end def self.find(h_id) - Procedure.with_discarded.find(h_id[:procedure_id]).find_column(h_id:) + begin + procedure = Procedure.with_discarded.find(h_id[:procedure_id]) + rescue ActiveRecord::RecordNotFound + raise ActiveRecord::RecordNotFound.new("Column: unable to find procedure #{h_id[:procedure_id]} from h_id #{h_id}") + end + + procedure.find_column(h_id: h_id) end end diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index d21cb1cba..c98a621df 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -12,7 +12,7 @@ module ColumnsConcern column = columns.find { _1.h_id == h_id } if h_id.present? column = columns.find { _1.label == label } if label.present? - raise ActiveRecord::RecordNotFound if column.nil? + raise ActiveRecord::RecordNotFound.new("Column: unable to find h_id: #{h_id} or label: #{label} for procedure_id #{id}") if column.nil? column end From d54ab64e4067ce4d50a83d77ffe374959bd129fa Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 27 Sep 2024 12:50:57 +0200 Subject: [PATCH 1246/1532] add filtered_column type --- app/models/filtered_column.rb | 12 +++++ app/models/procedure_presentation.rb | 16 +++--- app/types/filtered_column_type.rb | 36 ++++++++++++++ config/initializers/types.rb | 2 + spec/models/export_spec.rb | 9 +++- spec/types/filtered_column_type_spec.rb | 65 +++++++++++++++++++++++++ 6 files changed, 130 insertions(+), 10 deletions(-) create mode 100644 app/models/filtered_column.rb create mode 100644 app/types/filtered_column_type.rb create mode 100644 spec/types/filtered_column_type_spec.rb diff --git a/app/models/filtered_column.rb b/app/models/filtered_column.rb new file mode 100644 index 000000000..c147e0cce --- /dev/null +++ b/app/models/filtered_column.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class FilteredColumn + def initialize(column:, filter:) + @column = column + @filter = filter + end + + def ==(other) + other&.column == column && other.filter == filter + end +end diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index d1fb52ceb..86b227029 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -25,14 +25,14 @@ class ProcedurePresentation < ApplicationRecord attribute :sorted_column, :sorted_column def sorted_column = super || procedure.default_sorted_column # Dummy override to set default value - attribute :a_suivre_filters, :jsonb, array: true - attribute :suivis_filters, :jsonb, array: true - attribute :traites_filters, :jsonb, array: true - attribute :tous_filters, :jsonb, array: true - attribute :supprimes_filters, :jsonb, array: true - attribute :supprimes_recemment_filters, :jsonb, array: true - attribute :expirant_filters, :jsonb, array: true - attribute :archives_filters, :jsonb, array: true + attribute :a_suivre_filters, :filtered_column, array: true + attribute :suivis_filters, :filtered_column, array: true + attribute :traites_filters, :filtered_column, array: true + attribute :tous_filters, :filtered_column, array: true + attribute :supprimes_filters, :filtered_column, array: true + attribute :supprimes_recemment_filters, :filtered_column, array: true + attribute :expirant_filters, :filtered_column, array: true + attribute :archives_filters, :filtered_column, array: true def filters_for(statut) send(filters_name_for(statut)) diff --git a/app/types/filtered_column_type.rb b/app/types/filtered_column_type.rb new file mode 100644 index 000000000..ca097e4d5 --- /dev/null +++ b/app/types/filtered_column_type.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class FilteredColumnType < ActiveRecord::Type::Value + # form_input or setter -> type + def cast(value) + value = value.deep_symbolize_keys if value.respond_to?(:deep_symbolize_keys) + + case value + in FilteredColumn + value + in NilClass # default value + nil + # from form (id is a string) or from db (id is a hash) + in { id: String|Hash, filter: String } => h + FilteredColumn.new(column: ColumnType.new.cast(h[:id]), filter: h[:filter]) + end + end + + # db -> ruby + def deserialize(value) = cast(value&.then { JSON.parse(_1) }) + + # ruby -> db + def serialize(value) + case value + in NilClass + nil + in FilteredColumn + JSON.generate({ + id: value.column.h_id, + filter: value.filter + }) + else + raise ArgumentError, "Invalid value for FilteredColumn serialization: #{value}" + end + end +end diff --git a/config/initializers/types.rb b/config/initializers/types.rb index 8ac4a38ed..46f40f05d 100644 --- a/config/initializers/types.rb +++ b/config/initializers/types.rb @@ -3,9 +3,11 @@ require Rails.root.join("app/types/column_type") require Rails.root.join("app/types/export_item_type") require Rails.root.join("app/types/sorted_column_type") +require Rails.root.join("app/types/filtered_column_type") ActiveSupport.on_load(:active_record) do ActiveRecord::Type.register(:column, ColumnType) ActiveRecord::Type.register(:export_item, ExportItemType) ActiveRecord::Type.register(:sorted_column, SortedColumnType) + ActiveRecord::Type.register(:filtered_column, FilteredColumnType) end diff --git a/spec/models/export_spec.rb b/spec/models/export_spec.rb index e960acb8f..880e3163a 100644 --- a/spec/models/export_spec.rb +++ b/spec/models/export_spec.rb @@ -94,7 +94,9 @@ RSpec.describe Export, type: :model do let(:instructeur) { create(:instructeur) } let!(:gi_1) { create(:groupe_instructeur, procedure: procedure, instructeurs: [instructeur]) } let!(:pp) { gi_1.instructeurs.first.procedure_presentation_and_errors_for_procedure_id(procedure.id).first } - before { pp.add_filter('tous', procedure.find_column(label: 'Créé le').id, '10/12/2021') } + let(:created_at_column) { FilteredColumn.new(column: procedure.find_column(label: 'Créé le'), filter: '10/12/2021') } + + before { pp.update(tous_filters: [created_at_column]) } context 'with procedure_presentation having different filters' do it 'works once' do @@ -105,7 +107,10 @@ RSpec.describe Export, type: :model do it 'works once, changes procedure_presentation, recreate a new' do expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) } .to change { Export.count }.by(1) - pp.add_filter('tous', procedure.find_column(label: 'Mis à jour le').id, '10/12/2021') + + update_at_column = FilteredColumn.new(column: procedure.find_column(label: 'Mis à jour le'), filter: '10/12/2021') + pp.update(tous_filters: [created_at_column, update_at_column]) + expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) } .to change { Export.count }.by(1) end diff --git a/spec/types/filtered_column_type_spec.rb b/spec/types/filtered_column_type_spec.rb new file mode 100644 index 000000000..05595135c --- /dev/null +++ b/spec/types/filtered_column_type_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +describe FilteredColumnType do + let(:type) { FilteredColumnType.new } + + describe 'cast' do + it 'from FilteredColumn' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + filtered_column = FilteredColumn.new(column:, filter: 'filter') + expect(type.cast(filtered_column)).to eq(filtered_column) + end + + it 'from nil' do + expect(type.cast(nil)).to eq(nil) + end + + describe 'from form' do + it 'with valid column id' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + h = { filter: 'filter', id: column.id } + + expect(Column).to receive(:find).with(column.h_id).and_return(column) + expect(type.cast(h)).to eq(FilteredColumn.new(column:, filter: 'filter')) + end + + it 'with invalid column id' do + h = { filter: 'filter', id: 'invalid' } + expect { type.cast(h) }.to raise_error(JSON::ParserError) + + h = { filter: 'filter', id: { procedure_id: 'invalid', column_id: 'nop' }.to_json } + expect { type.cast(h) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + + describe 'deserialize' do + context 'with valid value' do + it 'works' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + expect(Column).to receive(:find).with(column.h_id).and_return(column) + expect(type.deserialize({ id: column.h_id, filter: 'filter' }.to_json)).to eq(FilteredColumn.new(column: column, filter: 'filter')) + end + end + + context 'with nil' do + it { expect(type.deserialize(nil)).to eq(nil) } + end + end + + describe 'serialize' do + it 'with FilteredColumn' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + sorted_column = FilteredColumn.new(column: column, filter: 'filter') + expect(type.serialize(sorted_column)).to eq({ id: column.h_id, filter: 'filter' }.to_json) + end + + it 'with nil' do + expect(type.serialize(nil)).to eq(nil) + end + + it 'with invalid value' do + expect { type.serialize('invalid') }.to raise_error(ArgumentError) + end + end +end From e9c11a95f1f72fd848a1f772efefb7f602e92dab Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 27 Sep 2024 15:58:34 +0200 Subject: [PATCH 1247/1532] directly write in new filter columns --- .../instructeurs/column_filter_component.rb | 13 ++++++++++++- .../column_filter_component.html.haml | 6 ++++-- .../instructeurs/procedures_controller.rb | 17 ++++++++++++++--- .../column_filter_component_spec.rb | 19 +++++++------------ .../procedures_controller_spec.rb | 4 ++-- 5 files changed, 39 insertions(+), 20 deletions(-) diff --git a/app/components/instructeurs/column_filter_component.rb b/app/components/instructeurs/column_filter_component.rb index 3ea2b1153..7843bcbb1 100644 --- a/app/components/instructeurs/column_filter_component.rb +++ b/app/components/instructeurs/column_filter_component.rb @@ -30,7 +30,7 @@ class Instructeurs::ColumnFilterComponent < ApplicationComponent { selected_key: column.present? ? column.id : '', items: filterable_columns_options, - name: :column, + name: "#{prefix}[id]", id: 'search-filter', 'aria-describedby': 'instructeur-filter-combo-label', form: 'filter-component', @@ -42,6 +42,17 @@ class Instructeurs::ColumnFilterComponent < ApplicationComponent @procedure.columns.filter(&:filterable).map { [_1.label, _1.id] } end + def current_filter_tags + @procedure_presentation.filters_for(@statut).flat_map do + [ + hidden_field_tag("#{prefix}[id]", _1.column.id, id: nil), + hidden_field_tag("#{prefix}[filter]", _1.filter, id: nil) + ] + end.reduce(&:concat) + end + + def prefix = "#{procedure_presentation.filters_name_for(@statut)}[]" + private def find_type_de_champ(column) diff --git a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml index 6f43592d3..484bf88f5 100644 --- a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml +++ b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml @@ -1,4 +1,6 @@ = form_tag add_filter_instructeur_procedure_path(procedure), method: :post, class: 'dropdown-form large', id: 'filter-component', data: { turbo: true, controller: 'autosubmit' } do + = current_filter_tags + .fr-select-group = label_tag :column, t('.column'), class: 'fr-label fr-m-0', id: 'instructeur-filter-combo-label', for: 'search-filter' %react-fragment @@ -8,9 +10,9 @@ = label_tag :value, t('.value'), for: 'value', class: 'fr-label' - if column_type == :enum - = select_tag :value, options_for_select(options_for_select_of_column), id: 'value', name: 'value', class: 'fr-select', data: { no_autosubmit: true } + = select_tag :filter, options_for_select(options_for_select_of_column), id: 'value', name: "#{prefix}[filter]", class: 'fr-select', data: { no_autosubmit: true } - else - %input#value.fr-input{ type: column_type, name: :value, maxlength: ProcedurePresentation::FILTERS_VALUE_MAX_LENGTH, disabled: column.nil? ? true : false, data: { no_autosubmit: true } } + %input#value.fr-input{ type: column_type, name: "#{prefix}[filter]", maxlength: ProcedurePresentation::FILTERS_VALUE_MAX_LENGTH, disabled: column.nil? ? true : false, data: { no_autosubmit: true } } = hidden_field_tag :statut, statut = submit_tag t('.add_filter'), class: 'fr-btn fr-btn--secondary fr-mt-2w' diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index 91f685dd7..03d731e12 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -147,8 +147,10 @@ module Instructeurs end def add_filter - if !procedure_presentation.add_filter(statut, params[:column], params[:value]) - flash.alert = procedure_presentation.errors.full_messages + if !procedure_presentation.update(filter_params) + # complicated way to display inner error messages + flash.alert = procedure_presentation.errors + .flat_map { _1.detail[:value].errors.full_messages } end redirect_back(fallback_location: instructeur_procedure_url(procedure)) @@ -158,7 +160,10 @@ module Instructeurs @statut = statut @procedure = procedure @procedure_presentation = procedure_presentation - @column = procedure.find_column(h_id: JSON.parse(params[:column], symbolize_names: true)) + current_filter = procedure_presentation.filters_name_for(@statut) + # According to the html, the selected column is the last one + h_id = JSON.parse(params[current_filter].last[:id], symbolize_names: true) + @column = procedure.find_column(h_id:) end def remove_filter @@ -415,5 +420,11 @@ module Instructeurs def sorted_column_params params.permit(sorted_column: [:order, :id]) end + + def filter_params + keys = [:tous_filters, :a_suivre_filters, :suivis_filters, :traites_filters, :expirant_filters, :archives_filters, :supprimes_filters] + h = keys.index_with { [:id, :filter] } + params.permit(h) + end end end diff --git a/spec/components/instructeurs/column_filter_component_spec.rb b/spec/components/instructeurs/column_filter_component_spec.rb index 8c31094dd..b1dc597ae 100644 --- a/spec/components/instructeurs/column_filter_component_spec.rb +++ b/spec/components/instructeurs/column_filter_component_spec.rb @@ -8,27 +8,22 @@ describe Instructeurs::ColumnFilterComponent, type: :component do let(:procedure_id) { procedure.id } let(:procedure_presentation) { nil } let(:statut) { nil } + let(:column) { nil } before do allow(component).to receive(:current_instructeur).and_return(instructeur) end describe ".filterable_columns_options" do - context 'filders' do - let(:column) { nil } - let(:included_displayable_field) do - [ - Column.new(procedure_id:, label: 'email', table: 'user', column: 'email'), - Column.new(procedure_id:, label: "depose_since", table: "self", column: "depose_since", displayable: false) - ] - end + let(:filterable_column) { Column.new(procedure_id:, label: 'email', table: 'user', column: 'email') } + let(:non_filterable_column) { Column.new(procedure_id:, label: 'depose_since', table: 'self', column: 'depose_since', filterable: false) } + let(:mocked_columns) { [filterable_column, non_filterable_column] } - before { allow(procedure).to receive(:columns).and_return(included_displayable_field) } + before { allow(procedure).to receive(:columns).and_return(mocked_columns) } - subject { component.filterable_columns_options } + subject { component.filterable_columns_options } - it { is_expected.to eq([["email", included_displayable_field.first.id], ["depose_since", included_displayable_field.second.id]]) } - end + it { is_expected.to eq([[filterable_column.label, filterable_column.id]]) } end describe '.options_for_select_of_column' do diff --git a/spec/controllers/instructeurs/procedures_controller_spec.rb b/spec/controllers/instructeurs/procedures_controller_spec.rb index c9e1a8894..d5954af14 100644 --- a/spec/controllers/instructeurs/procedures_controller_spec.rb +++ b/spec/controllers/instructeurs/procedures_controller_spec.rb @@ -905,8 +905,8 @@ describe Instructeurs::ProceduresController, type: :controller do end subject do - column = procedure.find_column(label: "Nom").id - post :add_filter, params: { procedure_id: procedure.id, column:, value: "n" * 4100, statut: "a-suivre" } + column = procedure.find_column(label: "Nom") + post :add_filter, params: { procedure_id: procedure.id, a_suivre_filters: { id: column.id, filter: "n" * 110 } } end it 'should render the error' do From 175f30339962111526e5fad2303457ddb4b5494a Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 27 Sep 2024 13:09:59 +0200 Subject: [PATCH 1248/1532] display filter tags --- .../instructeurs/procedures_controller.rb | 2 +- app/models/filtered_column.rb | 2 ++ app/models/procedure_presentation.rb | 22 +++++++++---------- .../_dossiers_filter_tags.html.haml | 8 +++---- spec/models/procedure_presentation_spec.rb | 10 +++++---- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index 03d731e12..43f6f1768 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -73,7 +73,7 @@ module Instructeurs # Setting it here to make clear that it is used by the view @procedure_presentation = procedure_presentation - @current_filters = current_filters + @current_filters = procedure_presentation.filters_for(statut) @counts = current_instructeur .dossiers_count_summary(groupe_instructeur_ids) .symbolize_keys diff --git a/app/models/filtered_column.rb b/app/models/filtered_column.rb index c147e0cce..8578bbcf8 100644 --- a/app/models/filtered_column.rb +++ b/app/models/filtered_column.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class FilteredColumn + attr_reader :column, :filter + def initialize(column:, filter:) @column = column @filter = filter diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 86b227029..ef1156e4e 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -60,28 +60,28 @@ class ProcedurePresentation < ApplicationRecord end end - def human_value_for_filter(filter) - if filter[TABLE] == TYPE_DE_CHAMP - find_type_de_champ(filter[COLUMN]).dynamic_type.filter_to_human(filter['value']) - elsif filter['column'] == 'state' - if filter['value'] == 'pending_correction' + def human_value_for_filter(filtered_column) + if filtered_column.column.table == TYPE_DE_CHAMP + find_type_de_champ(filtered_column.column.column).dynamic_type.filter_to_human(filtered_column.filter) + elsif filtered_column.column.column == 'state' + if filtered_column.filter == 'pending_correction' Dossier.human_attribute_name("pending_correction.for_instructeur") else - Dossier.human_attribute_name("state.#{filter['value']}") + Dossier.human_attribute_name("state.#{filtered_column.filter}") end - elsif filter['table'] == 'groupe_instructeur' && filter['column'] == 'id' + elsif filtered_column.column.table == 'groupe_instructeur' && filtered_column.column.column == 'id' instructeur.groupe_instructeurs - .find { _1.id == filter['value'].to_i }&.label || filter['value'] + .find { _1.id == filtered_column.filter.to_i }&.label || filtered_column.filter else - column = procedure.columns.find { _1.table == filter[TABLE] && _1.column == filter[COLUMN] } + column = procedure.columns.find { _1.table == filtered_column.column.table && _1.column == filtered_column.column.column } if column.type == :date - parsed_date = safe_parse_date(filter['value']) + parsed_date = safe_parse_date(filtered_column.filter) return parsed_date.present? ? I18n.l(parsed_date) : nil end - filter['value'] + filtered_column.filter end end diff --git a/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml b/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml index d1b57d57e..855e52f05 100644 --- a/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml +++ b/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml @@ -1,11 +1,11 @@ - if current_filters.count > 0 .fr-mb-2w - - current_filters.group_by { |filter| filter['table'] }.each_with_index do |(table, filters), i| + - current_filters.group_by { |filter| filter.column.table }.each_with_index do |(table, filters), i| - if i > 0 = " et " - filters.each_with_index do |filter, i| - if i > 0 = " ou " - = link_to remove_filter_instructeur_procedure_path(procedure, { statut: statut, column: { procedure_id: procedure.id, column_id: filter['table'] + "/" + filter['column'] }.to_json, value: filter['value'] }), - class: "fr-tag fr-tag--dismiss fr-my-1w", aria: { label: "Retirer le filtre #{filter['column']}" } do - = "#{filter['label'].truncate(50)} : #{procedure_presentation.human_value_for_filter(filter)}" + = link_to remove_filter_instructeur_procedure_path(procedure, { statut: statut, column: filter.column.id, value: filter.filter }), + class: "fr-tag fr-tag--dismiss fr-my-1w", aria: { label: "Retirer le filtre #{filter.column.label}" } do + = "#{filter.column.label.truncate(50)} : #{procedure_presentation.human_value_for_filter(filter)}" diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index a160e0a5e..cc8418499 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -772,9 +772,11 @@ describe ProcedurePresentation do end describe "#human_value_for_filter" do - let(:filters) { { "suivis" => [{ label: "label1", table: "type_de_champ", column: first_type_de_champ_id, "value" => "true" }] } } + let(:filtered_column) { to_filter([first_type_de_champ.libelle, "true"]) } - subject { procedure_presentation.human_value_for_filter(procedure_presentation.filters["suivis"].first) } + subject do + procedure_presentation.human_value_for_filter(filtered_column) + end context 'when type_de_champ text' do it 'should passthrough value' do @@ -791,7 +793,7 @@ describe ProcedurePresentation do end context 'when filter is state' do - let(:filters) { { "suivis" => [{ table: "self", column: "state", "value" => "en_construction" }] } } + let(:filtered_column) { to_filter(['Statut', "en_construction"]) } it 'should get i18n value' do expect(subject).to eq("En construction") @@ -799,7 +801,7 @@ describe ProcedurePresentation do end context 'when filter is a date' do - let(:filters) { { "suivis" => [{ table: "self", column: "en_instruction_at", "value" => "15/06/2023" }] } } + let(:filtered_column) { to_filter(['Créé le', "15/06/2023"]) } it 'should get formatted value' do expect(subject).to eq("15/06/2023") From 72c389161ab32da0ba4621e31bf63688c25372cc Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 27 Sep 2024 14:42:37 +0200 Subject: [PATCH 1249/1532] remove filter using generic add_filter methods --- .../procedures/_dossiers_filter_tags.html.haml | 12 +++++++++--- spec/system/instructeurs/procedure_filters_spec.rb | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml b/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml index 855e52f05..b8673ed2f 100644 --- a/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml +++ b/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml @@ -6,6 +6,12 @@ - filters.each_with_index do |filter, i| - if i > 0 = " ou " - = link_to remove_filter_instructeur_procedure_path(procedure, { statut: statut, column: filter.column.id, value: filter.filter }), - class: "fr-tag fr-tag--dismiss fr-my-1w", aria: { label: "Retirer le filtre #{filter.column.label}" } do - = "#{filter.column.label.truncate(50)} : #{procedure_presentation.human_value_for_filter(filter)}" + = form_tag(add_filter_instructeur_procedure_path(procedure), class: 'inline') do + - prefix = procedure_presentation.filters_name_for(statut) + = hidden_field_tag "#{prefix}[]", '' + - (current_filters - [filter]).each do |f| + = hidden_field_tag "#{prefix}[][id]", f.column.id + = hidden_field_tag "#{prefix}[][filter]", f.filter + + = button_tag "#{filter.column.label.truncate(50)} : #{procedure_presentation.human_value_for_filter(filter)}", + class: 'fr-tag fr-tag--dismiss fr-my-1w' diff --git a/spec/system/instructeurs/procedure_filters_spec.rb b/spec/system/instructeurs/procedure_filters_spec.rb index c8b588c89..85555764f 100644 --- a/spec/system/instructeurs/procedure_filters_spec.rb +++ b/spec/system/instructeurs/procedure_filters_spec.rb @@ -224,7 +224,7 @@ describe "procedure filters" do end def remove_filter(filter_value) - click_link text: filter_value + click_button text: filter_value end def add_column(column_name) From 4c5d7e29503c689cc468a3a7ceb0c4477ccda856 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 27 Sep 2024 15:25:00 +0200 Subject: [PATCH 1250/1532] remove now useless add_filters and remove_filters --- .../instructeurs/procedures_controller.rb | 6 -- app/models/procedure_presentation.rb | 50 ------------- spec/models/procedure_presentation_spec.rb | 75 ------------------- 3 files changed, 131 deletions(-) diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index 43f6f1768..89044e7d2 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -166,12 +166,6 @@ module Instructeurs @column = procedure.find_column(h_id:) end - def remove_filter - procedure_presentation.remove_filter(statut, params[:column], params[:value]) - - redirect_back(fallback_location: instructeur_procedure_url(procedure)) - end - def download_export groupe_instructeurs = current_instructeur .groupe_instructeurs diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index ef1156e4e..878e35411 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -18,7 +18,6 @@ class ProcedurePresentation < ApplicationRecord delegate :procedure, :instructeur, to: :assign_to validate :check_allowed_displayed_fields - validate :check_allowed_filter_columns validate :check_filters_max_length validate :check_filters_max_integer @@ -91,46 +90,6 @@ class ProcedurePresentation < ApplicationRecord nil end - def add_filter(statut, column_id, value) - h_id = JSON.parse(column_id, symbolize_names: true) - - if value.present? - column = procedure.find_column(h_id:) - - case column.table - when TYPE_DE_CHAMP - value = find_type_de_champ(column.column).dynamic_type.human_to_filter(value) - end - - updated_filters = filters.dup - updated_filters[statut] << { - 'label' => column.label, - TABLE => column.table, - COLUMN => column.column, - 'value_column' => column.value_column, - 'value' => value - } - - filters_for(statut) << { id: h_id, filter: value } - update(filters: updated_filters) - end - end - - def remove_filter(statut, column_id, value) - h_id = JSON.parse(column_id, symbolize_names: true) - column = procedure.find_column(h_id:) - updated_filters = filters.dup - - updated_filters[statut] = filters[statut].reject do |filter| - filter.values_at(TABLE, COLUMN, 'value') == [column.table, column.column, value] - end - - collection = filters_for(statut) - collection.delete(collection.find { sym_h = _1.deep_symbolize_keys; sym_h[:id] == h_id && sym_h[:filter] == value }) - - update!(filters: updated_filters) - end - def update_displayed_fields(column_ids) h_ids = Array.wrap(column_ids).map { |id| JSON.parse(id, symbolize_names: true) } columns = h_ids.map { |h_id| procedure.find_column(h_id:) } @@ -278,15 +237,6 @@ class ProcedurePresentation < ApplicationRecord end end - def check_allowed_filter_columns - filters.each do |key, columns| - return true if key == 'migrated' - columns.each do |column| - check_allowed_field(:filters, column) - end - end - end - def check_allowed_field(kind, field, extra_columns = {}) table, column = field.values_at(TABLE, COLUMN) if !valid_column?(table, column, extra_columns) diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index cc8418499..093a0b565 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -809,81 +809,6 @@ describe ProcedurePresentation do end end - describe "#add_filter" do - let(:filters) { { "suivis" => [] } } - - context 'when type_de_champ yes_no' do - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :yes_no, libelle: 'oui ou non' }]) } - - it 'should downcase and transform value' do - column_id = procedure.find_column(label: 'oui ou non').id - procedure_presentation.add_filter("suivis", column_id, "Oui") - - expect(procedure_presentation.filters).to eq({ - "suivis" => - [ - { "label" => first_type_de_champ.libelle, "table" => "type_de_champ", "column" => first_type_de_champ_id, "value" => "true", "value_column" => "value" } - ] - }) - - suivis = procedure_presentation.suivis_filters.map { [_1['id'], _1['filter']] } - - expect(suivis).to eq([[{ "column_id" => "type_de_champ/#{first_type_de_champ_id}", "procedure_id" => procedure.id }, "true"]]) - end - end - - context 'when type_de_champ text' do - let(:filters) { { "suivis" => [] } } - let(:column_id) { procedure.find_column(label: first_type_de_champ.libelle).id } - - it 'should passthrough value' do - procedure_presentation.add_filter("suivis", column_id, "Oui") - - expect(procedure_presentation.filters).to eq({ - "suivis" => [ - { "label" => first_type_de_champ.libelle, "table" => "type_de_champ", "column" => first_type_de_champ_id, "value" => "Oui", "value_column" => "value" } - ] - }) - end - end - - context 'when type_de_champ departements' do - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :departements }]) } - let(:column_id) { procedure.find_column(label: first_type_de_champ.libelle).id } - let(:filters) { { "suivis" => [] } } - - it 'should set value_column' do - procedure_presentation.add_filter("suivis", column_id, "13") - - expect(procedure_presentation.filters).to eq({ - "suivis" => [ - { "label" => first_type_de_champ.libelle, "table" => "type_de_champ", "column" => first_type_de_champ_id, "value" => "13", "value_column" => "external_id" } - ] - }) - end - end - end - - describe "#remove_filter" do - let(:filters) { { "suivis" => [] } } - let(:email_column_id) { procedure.find_column(label: 'Demandeur').id } - - before do - procedure_presentation.add_filter("suivis", email_column_id, "a@a.com") - end - - it 'should remove filter' do - expect(procedure_presentation.filters).to eq({ "suivis" => [{ "column" => "email", "label" => "Demandeur", "table" => "user", "value" => "a@a.com", "value_column" => "value" }] }) - expect(procedure_presentation.suivis_filters).to eq([{ "filter" => "a@a.com", "id" => { "column_id" => "user/email", "procedure_id" => procedure.id } }]) - - procedure_presentation.remove_filter("suivis", email_column_id, "a@a.com") - procedure_presentation.reload - - expect(procedure_presentation.filters).to eq({ "suivis" => [] }) - expect(procedure_presentation.suivis_filters).to eq([]) - end - end - describe '#filtered_sorted_ids' do let(:procedure_presentation) { create(:procedure_presentation, assign_to:) } let(:dossier_1) { create(:dossier) } From 7e4ca07df231b1ec5372577c12e5509b1c55973f Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 27 Sep 2024 15:45:37 +0200 Subject: [PATCH 1251/1532] use filtered_column to filter ! Co-authored-by: mfo --- app/models/procedure_presentation.rb | 27 +-- ...edure_presentation_to_columns.rake_spec.rb | 5 +- spec/models/procedure_presentation_spec.rb | 206 ++++++------------ 3 files changed, 88 insertions(+), 150 deletions(-) diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 878e35411..1619ecf37 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -52,7 +52,7 @@ class ProcedurePresentation < ApplicationRecord dossiers_by_statut = dossiers.by_statut(statut, instructeur) dossiers_sorted_ids = self.sorted_ids(dossiers_by_statut, count || dossiers_by_statut.size) - if filters[statut].present? + if filters_for(statut).present? dossiers_sorted_ids.intersection(filtered_ids(dossiers_by_statut, statut)) else dossiers_sorted_ids @@ -158,31 +158,32 @@ class ProcedurePresentation < ApplicationRecord end def filtered_ids(dossiers, statut) - filters.fetch(statut) - .group_by { |filter| filter.values_at(TABLE, COLUMN) } - .map do |(table, column), filters| - values = filters.pluck('value') - value_column = filters.pluck('value_column').compact.first || :value - dossier_column = procedure.find_column(h_id: { procedure_id: procedure.id, column_id: "#{table}/#{column}" }) # hack to find json path columns - if dossier_column.is_a?(Columns::JSONPathColumn) - dossier_column.filtered_ids(dossiers, values) + filters_for(statut) + .group_by { |filter| filter.column.then { [_1.table, _1.column] } } + .map do |(table, column), filters_for_column| + values = filters_for_column.map(&:filter) + filtered_column = filters_for_column.first.column + value_column = filtered_column.value_column + + if filtered_column.is_a?(Columns::JSONPathColumn) + filtered_column.filtered_ids(dossiers, values) else case table when 'self' - if dossier_column.type == :date + if filtered_column.type == :date dates = values .filter_map { |v| Time.zone.parse(v).beginning_of_day rescue nil } dossiers.filter_by_datetimes(column, dates) - elsif dossier_column.column == "state" && values.include?("pending_correction") + elsif filtered_column.column == "state" && values.include?("pending_correction") dossiers.joins(:corrections).where(corrections: DossierCorrection.pending) - elsif dossier_column.column == "state" && values.include?("en_construction") + elsif filtered_column.column == "state" && values.include?("en_construction") dossiers.where("dossiers.#{column} IN (?)", values).includes(:corrections).where.not(corrections: DossierCorrection.pending) else dossiers.where("dossiers.#{column} IN (?)", values) end when TYPE_DE_CHAMP - if dossier_column.type == :enum + if filtered_column.type == :enum dossiers.with_type_de_champ(column) .filter_enum(:champs, value_column, values) else diff --git a/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb b/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb index 46c868f03..0494ba5e7 100644 --- a/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb +++ b/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb @@ -52,9 +52,8 @@ describe '20240920130741_migrate_procedure_presentation_to_columns.rake' do expect(procedure_presentation.tous_filters).to eq([]) - traites = procedure_presentation.traites_filters - .map { [_1['id'], _1['filter']] } + traites = procedure_presentation.traites_filters.map { [_1.label, _1.filter] } - expect(traites).to eq([[{ "column_id" => "etablissement/libelle_naf", "procedure_id" => procedure_id }, "Administration publique générale"]]) + expect(traites).to eq([["Libellé NAF", "Administration publique générale"]]) end end diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index 093a0b565..315deccb0 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -4,14 +4,15 @@ describe ProcedurePresentation do include ActiveSupport::Testing::TimeHelpers let(:procedure) { create(:procedure, :published, types_de_champ_public:, types_de_champ_private: [{}]) } + let(:procedure_id) { procedure.id } let(:types_de_champ_public) { [{}] } let(:instructeur) { create(:instructeur) } - let(:assign_to) { create(:assign_to, procedure: procedure, instructeur: instructeur) } + let(:assign_to) { create(:assign_to, procedure:, instructeur:) } let(:first_type_de_champ) { assign_to.procedure.active_revision.types_de_champ_public.first } let(:first_type_de_champ_id) { first_type_de_champ.stable_id.to_s } let(:procedure_presentation) { create(:procedure_presentation, - assign_to: assign_to, + assign_to:, displayed_fields: [ { label: "test1", table: "user", column: "email" }, { label: "test2", table: "type_de_champ", column: first_type_de_champ_id } @@ -22,6 +23,8 @@ describe ProcedurePresentation do let(:procedure_presentation_id) { procedure_presentation.id } let(:filters) { { "a-suivre" => [], "suivis" => [{ "label" => "label1", "table" => "self", "column" => "created_at" }] } } + def to_filter((label, filter)) = FilteredColumn.new(column: procedure.find_column(label: label), filter: filter) + describe "#displayed_fields" do it { expect(procedure_presentation.displayed_fields).to eq([{ "label" => "test1", "table" => "user", "column" => "email" }, { "label" => "test2", "table" => "type_de_champ", "column" => first_type_de_champ_id }]) } end @@ -134,7 +137,6 @@ describe ProcedurePresentation do context 'for type_de_champ table' do context 'with no revisions' do - let(:table) { 'type_de_champ' } let(:column) { procedure.find_column(label: first_type_de_champ.libelle) } let(:beurre_dossier) { create(:dossier, procedure:) } @@ -288,13 +290,15 @@ describe ProcedurePresentation do end describe '#filtered_ids' do - let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to, filters: { "suivis" => filter }) } + let(:procedure_presentation) { create(:procedure_presentation, assign_to:, suivis_filters: filtered_columns) } + let(:filtered_columns) { filters.map { to_filter(_1) } } + let(:filters) { [filter] } subject { procedure_presentation.send(:filtered_ids, procedure.dossiers.joins(:user), 'suivis') } context 'for self table' do context 'for created_at column' do - let(:filter) { [{ 'table' => 'self', 'column' => 'created_at', 'value' => '18/9/2018' }] } + let(:filter) { ['Créé le', '18/9/2018'] } let!(:kept_dossier) { create(:dossier, procedure: procedure, created_at: Time.zone.local(2018, 9, 18, 14, 28)) } let!(:discarded_dossier) { create(:dossier, procedure: procedure, created_at: Time.zone.local(2018, 9, 17, 23, 59)) } @@ -303,7 +307,7 @@ describe ProcedurePresentation do end context 'for en_construction_at column' do - let(:filter) { [{ 'table' => 'self', 'column' => 'en_construction_at', 'value' => '17/10/2018' }] } + let(:filter) { ['En construction le', '17/10/2018'] } let!(:kept_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 17)) } let!(:discarded_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2013, 1, 1)) } @@ -312,7 +316,7 @@ describe ProcedurePresentation do end context 'for updated_at column' do - let(:filter) { [{ 'table' => 'self', 'column' => 'updated_at', 'value' => '18/9/2018' }] } + let(:filter) { ['Mis à jour le', '18/9/2018'] } let(:kept_dossier) { create(:dossier, procedure: procedure) } let(:discarded_dossier) { create(:dossier, procedure: procedure) } @@ -326,7 +330,7 @@ describe ProcedurePresentation do end context 'for updated_since column' do - let(:filter) { [{ 'table' => 'self', 'column' => 'updated_since', 'value' => '18/9/2018' }] } + let(:filter) { ['Mis à jour depuis', '18/9/2018'] } let(:kept_dossier) { create(:dossier, procedure: procedure) } let(:later_dossier) { create(:dossier, procedure: procedure) } @@ -347,7 +351,7 @@ describe ProcedurePresentation do end let(:procedure) { create(:procedure, :published, :sva, types_de_champ_public: [{}], types_de_champ_private: [{}]) } - let(:filter) { [{ 'table' => 'self', 'column' => 'sva_svr_decision_before', 'value' => '15/06/2023' }] } + let(:filter) { ['Date décision SVA avant', '15/06/2023'] } let!(:kept_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current) } let!(:later_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current + 2.days) } @@ -359,7 +363,7 @@ describe ProcedurePresentation do end context 'ignore time of day' do - let(:filter) { [{ 'table' => 'self', 'column' => 'en_construction_at', 'value' => '17/10/2018 19:30' }] } + let(:filter) { ['En construction le', '17/10/2018 19:30'] } let!(:kept_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 17, 15, 56)) } let!(:discarded_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 18, 5, 42)) } @@ -369,29 +373,24 @@ describe ProcedurePresentation do context 'for a malformed date' do context 'when its a string' do - let(:filter) { [{ 'table' => 'self', 'column' => 'updated_at', 'value' => 'malformed date' }] } + let(:filter) { ['Mis à jour le', 'malformed date'] } it { is_expected.to match([]) } end context 'when its a number' do - let(:filter) { [{ 'table' => 'self', 'column' => 'updated_at', 'value' => '177500' }] } + let(:filter) { ['Mis à jour le', '177500'] } it { is_expected.to match([]) } end end context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'self', 'column' => 'en_construction_at', 'value' => '17/10/2018' }, - { 'table' => 'self', 'column' => 'en_construction_at', 'value' => '19/10/2018' } - ] - end + let(:filters) { [['En construction le', '17/10/2018'], ['En construction le', '19/10/2018']] } - let!(:kept_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 17)) } - let!(:other_kept_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 19)) } - let!(:discarded_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2013, 1, 1)) } + let!(:kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17)) } + let!(:other_kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 19)) } + let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2013, 1, 1)) } it 'returns every dossier that matches any of the search criteria for a given column' do is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) @@ -399,16 +398,11 @@ describe ProcedurePresentation do end context 'with multiple state filters' do - let(:filter) do - [ - { 'table' => 'self', 'column' => 'state', 'value' => 'en_construction' }, - { 'table' => 'self', 'column' => 'state', 'value' => 'en_instruction' } - ] - end + let(:filters) { [['Statut', 'en_construction'], ['Statut', 'en_instruction']] } - let!(:kept_dossier) { create(:dossier, :en_construction, procedure: procedure) } - let!(:other_kept_dossier) { create(:dossier, :en_instruction, procedure: procedure) } - let!(:discarded_dossier) { create(:dossier, :accepte, procedure: procedure) } + let!(:kept_dossier) { create(:dossier, :en_construction, procedure:) } + let!(:other_kept_dossier) { create(:dossier, :en_instruction, procedure:) } + let!(:discarded_dossier) { create(:dossier, :accepte, procedure:) } it 'returns every dossier that matches any of the search criteria for a given column' do is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) @@ -416,14 +410,10 @@ describe ProcedurePresentation do end context 'with en_construction state filters' do - let(:filter) do - [ - { 'table' => 'self', 'column' => 'state', 'value' => 'en_construction' } - ] - end + let(:filter) { ['Statut', 'en_construction'] } - let!(:en_construction) { create(:dossier, :en_construction, procedure: procedure) } - let!(:en_construction_with_correction) { create(:dossier, :en_construction, procedure: procedure) } + let!(:en_construction) { create(:dossier, :en_construction, procedure:) } + let!(:en_construction_with_correction) { create(:dossier, :en_construction, procedure:) } let!(:correction) { create(:dossier_correction, dossier: en_construction_with_correction) } it 'excludes dossier en construction with pending correction' do is_expected.to contain_exactly(en_construction.id) @@ -432,7 +422,7 @@ describe ProcedurePresentation do end context 'for type_de_champ table' do - let(:filter) { [{ 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value' => 'keep' }] } + let(:filter) { [type_de_champ.libelle, 'keep'] } let(:kept_dossier) { create(:dossier, procedure: procedure) } let(:discarded_dossier) { create(:dossier, procedure: procedure) } @@ -448,13 +438,7 @@ describe ProcedurePresentation do end context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value' => 'keep' }, - { 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value' => 'and' } - ] - end - + let(:filters) { [[type_de_champ.libelle, 'keep'], [type_de_champ.libelle, 'and']] } let(:other_kept_dossier) { create(:dossier, procedure: procedure) } before do @@ -469,7 +453,7 @@ describe ProcedurePresentation do end context 'with yes_no type_de_champ' do - let(:filter) { [{ 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value' => 'true' }] } + let(:filter) { [type_de_champ.libelle, 'true'] } let(:types_de_champ_public) { [{ type: :yes_no }] } before do @@ -481,7 +465,7 @@ describe ProcedurePresentation do end context 'with departement type_de_champ' do - let(:filter) { [{ 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value_column' => :external_id, 'value' => '13' }] } + let(:filter) { [type_de_champ.libelle, '13'] } let(:types_de_champ_public) { [{ type: :departements }] } before do @@ -493,8 +477,7 @@ describe ProcedurePresentation do end context 'with enum type_de_champ' do - let(:filter_value) { 'Favorable' } - let(:filter) { [{ 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value_column' => :value, 'value' => filter_value }] } + let(:filter) { [type_de_champ.libelle, 'Favorable'] } let(:types_de_champ_public) { [{ type: :drop_down_list, options: ['Favorable', 'Defavorable'] }] } before do @@ -507,7 +490,7 @@ describe ProcedurePresentation do end context 'for type_de_champ_private table' do - let(:filter) { [{ 'table' => 'type_de_champ', 'column' => type_de_champ_private.stable_id.to_s, 'value' => 'keep' }] } + let(:filter) { [type_de_champ_private.libelle, 'keep'] } let(:kept_dossier) { create(:dossier, procedure: procedure) } let(:discarded_dossier) { create(:dossier, procedure: procedure) } @@ -519,44 +502,25 @@ describe ProcedurePresentation do end it { is_expected.to contain_exactly(kept_dossier.id) } - - context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'type_de_champ', 'column' => type_de_champ_private.stable_id.to_s, 'value' => 'keep' }, - { 'table' => 'type_de_champ', 'column' => type_de_champ_private.stable_id.to_s, 'value' => 'and' } - ] - end - - let(:other_kept_dossier) { create(:dossier, procedure: procedure) } - - before do - other_kept_dossier.champs.find_by(stable_id: type_de_champ_private.stable_id).update(value: 'and me too') - end - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end end context 'for type_de_champ using AddressableColumnConcern' do - let(:types_de_champ_public) { [{ type: :rna, stable_id: 1 }] } + let(:column) { filtered_columns.first.column } + let(:types_de_champ_public) { [{ type: :rna, stable_id: 1, libelle: 'rna' }] } let(:type_de_champ) { procedure.active_revision.types_de_champ.first } - let(:available_columns) { type_de_champ.columns(procedure_id: procedure.id) } - let(:column) { available_columns.find { _1.value_column == value_column_searched } } - let(:filter) { [column.to_json.merge({ "value" => value })] } let(:kept_dossier) { create(:dossier, procedure: procedure) } context "when searching by postal_code (text)" do let(:value) { "60580" } - let(:value_column_searched) { ['postal_code'] } + let(:filter) { ["rna – code postal (5 chiffres)", value] } before do kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "postal_code" => value }) create(:dossier, procedure: procedure).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "postal_code" => "unknown" }) end + it { is_expected.to contain_exactly(kept_dossier.id) } + it 'describes column' do expect(column.type).to eq(:text) expect(column.options_for_select).to eq([]) @@ -565,12 +529,13 @@ describe ProcedurePresentation do context "when searching by departement_code (enum)" do let(:value) { "99" } - let(:value_column_searched) { ['departement_code'] } + let(:filter) { ["rna – département", value] } before do kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "departement_code" => value }) create(:dossier, procedure: procedure).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "departement_code" => "unknown" }) end + it { is_expected.to contain_exactly(kept_dossier.id) } it 'describes column' do @@ -581,13 +546,15 @@ describe ProcedurePresentation do context "when searching by region_name" do let(:value) { "60" } - let(:value_column_searched) { ['region_name'] } + let(:filter) { ["rna – region", value] } before do kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "region_name" => value }) create(:dossier, procedure: procedure).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "region_name" => "unknown" }) end + it { is_expected.to contain_exactly(kept_dossier.id) } + it 'describes column' do expect(column.type).to eq(:enum) expect(column.options_for_select.first).to eq(["Auvergne-Rhône-Alpes", "Auvergne-Rhône-Alpes"]) @@ -597,7 +564,7 @@ describe ProcedurePresentation do context 'for etablissement table' do context 'for entreprise_date_creation column' do - let(:filter) { [{ 'table' => 'etablissement', 'column' => 'entreprise_date_creation', 'value' => '21/6/2018' }] } + let(:filter) { ['Date de création', '21/6/2018'] } let!(:kept_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2018, 6, 21))) } let!(:discarded_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2008, 6, 21))) } @@ -605,12 +572,7 @@ describe ProcedurePresentation do it { is_expected.to contain_exactly(kept_dossier.id) } context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'etablissement', 'column' => 'entreprise_date_creation', 'value' => '21/6/2016' }, - { 'table' => 'etablissement', 'column' => 'entreprise_date_creation', 'value' => '21/6/2018' } - ] - end + let(:filters) { [['Date de création', '21/6/2016'], ['Date de création', '21/6/2018']] } let!(:other_kept_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2016, 6, 21))) } @@ -623,7 +585,7 @@ describe ProcedurePresentation do context 'for code_postal column' do # All columns except entreprise_date_creation work exacly the same, just testing one - let(:filter) { [{ 'table' => 'etablissement', 'column' => 'code_postal', 'value' => '75017' }] } + let(:filter) { ['Code postal', '75017'] } let!(:kept_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '75017')) } let!(:discarded_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '25000')) } @@ -631,12 +593,7 @@ describe ProcedurePresentation do it { is_expected.to contain_exactly(kept_dossier.id) } context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'etablissement', 'column' => 'code_postal', 'value' => '75017' }, - { 'table' => 'etablissement', 'column' => 'code_postal', 'value' => '88100' } - ] - end + let(:filters) { [['Code postal', '75017'], ['Code postal', '88100']] } let!(:other_kept_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '88100')) } @@ -648,7 +605,7 @@ describe ProcedurePresentation do end context 'for user table' do - let(:filter) { [{ 'table' => 'user', 'column' => 'email', 'value' => 'keepmail' }] } + let(:filter) { ['Demandeur', 'keepmail'] } let!(:kept_dossier) { create(:dossier, procedure: procedure, user: create(:user, email: 'me@keepmail.com')) } let!(:discarded_dossier) { create(:dossier, procedure: procedure, user: create(:user, email: 'me@discard.com')) } @@ -656,12 +613,7 @@ describe ProcedurePresentation do it { is_expected.to contain_exactly(kept_dossier.id) } context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'user', 'column' => 'email', 'value' => 'keepmail' }, - { 'table' => 'user', 'column' => 'email', 'value' => 'beta.gouv.fr' } - ] - end + let(:filters) { [['Demandeur', 'keepmail'], ['Demandeur', 'beta.gouv.fr']] } let!(:other_kept_dossier) { create(:dossier, procedure: procedure, user: create(:user, email: 'bazinga@beta.gouv.fr')) } @@ -677,30 +629,25 @@ describe ProcedurePresentation do let!(:discarded_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'M', prenom: 'Jean', nom: 'Tremblay')) } context 'for gender column' do - let(:filter) { [{ 'table' => 'individual', 'column' => 'gender', 'value' => 'Mme' }] } + let(:filter) { ['Civilité', 'Mme'] } it { is_expected.to contain_exactly(kept_dossier.id) } end context 'for prenom column' do - let(:filter) { [{ 'table' => 'individual', 'column' => 'prenom', 'value' => 'Josephine' }] } + let(:filter) { ['Prénom', 'Josephine'] } it { is_expected.to contain_exactly(kept_dossier.id) } end context 'for nom column' do - let(:filter) { [{ 'table' => 'individual', 'column' => 'nom', 'value' => 'Baker' }] } + let(:filter) { ['Nom', 'Baker'] } it { is_expected.to contain_exactly(kept_dossier.id) } end context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'individual', 'column' => 'prenom', 'value' => 'Josephine' }, - { 'table' => 'individual', 'column' => 'prenom', 'value' => 'Romuald' } - ] - end + let(:filters) { [['Prénom', 'Josephine'], ['Prénom', 'Romuald']] } let!(:other_kept_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'M', prenom: 'Romuald', nom: 'Pistis')) } @@ -711,7 +658,7 @@ describe ProcedurePresentation do end context 'for followers_instructeurs table' do - let(:filter) { [{ 'table' => 'followers_instructeurs', 'column' => 'email', 'value' => 'keepmail' }] } + let(:filter) { ['Email instructeur', 'keepmail'] } let!(:kept_dossier) { create(:dossier, procedure: procedure) } let!(:discarded_dossier) { create(:dossier, procedure: procedure) } @@ -724,14 +671,9 @@ describe ProcedurePresentation do it { is_expected.to contain_exactly(kept_dossier.id) } context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'followers_instructeurs', 'column' => 'email', 'value' => 'keepmail' }, - { 'table' => 'followers_instructeurs', 'column' => 'email', 'value' => 'beta.gouv.fr' } - ] - end + let(:filters) { [['Email instructeur', 'keepmail'], ['Email instructeur', 'beta.gouv.fr']] } - let(:other_kept_dossier) { create(:dossier, procedure: procedure) } + let(:other_kept_dossier) { create(:dossier, procedure:) } before do create(:follow, dossier: other_kept_dossier, instructeur: create(:instructeur, email: 'bazinga@beta.gouv.fr')) @@ -744,25 +686,20 @@ describe ProcedurePresentation do end context 'for groupe_instructeur table' do - let(:filter) { [{ 'table' => 'groupe_instructeur', 'column' => 'id', 'value' => procedure.defaut_groupe_instructeur.id.to_s }] } + let(:filter) { ['Groupe instructeur', procedure.defaut_groupe_instructeur.id.to_s] } - let!(:gi_2) { create(:groupe_instructeur, label: 'gi2', procedure: procedure) } - let!(:gi_3) { create(:groupe_instructeur, label: 'gi3', procedure: procedure) } + let!(:gi_2) { create(:groupe_instructeur, label: 'gi2', procedure:) } + let!(:gi_3) { create(:groupe_instructeur, label: 'gi3', procedure:) } - let!(:kept_dossier) { create(:dossier, :en_construction, procedure: procedure) } - let!(:discarded_dossier) { create(:dossier, :en_construction, procedure: procedure, groupe_instructeur: gi_2) } + let!(:kept_dossier) { create(:dossier, :en_construction, procedure:) } + let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, groupe_instructeur: gi_2) } it { is_expected.to contain_exactly(kept_dossier.id) } context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'groupe_instructeur', 'column' => 'id', 'value' => procedure.defaut_groupe_instructeur.id.to_s }, - { 'table' => 'groupe_instructeur', 'column' => 'id', 'value' => gi_3.id.to_s } - ] - end + let(:filters) { [['Groupe instructeur', procedure.defaut_groupe_instructeur.id.to_s], ['Groupe instructeur', gi_3.id.to_s]] } - let!(:other_kept_dossier) { create(:dossier, procedure: procedure, groupe_instructeur: gi_3) } + let!(:other_kept_dossier) { create(:dossier, procedure:, groupe_instructeur: gi_3) } it 'returns every dossier that matches any of the search criteria for a given column' do is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) @@ -811,13 +748,6 @@ describe ProcedurePresentation do describe '#filtered_sorted_ids' do let(:procedure_presentation) { create(:procedure_presentation, assign_to:) } - let(:dossier_1) { create(:dossier) } - let(:dossier_2) { create(:dossier) } - let(:dossier_3) { create(:dossier) } - let(:dossiers) { Dossier.where(id: [dossier_1, dossier_2, dossier_3].map(&:id)) } - - let(:sorted_ids) { [dossier_2, dossier_3, dossier_1].map(&:id) } - let(:statut) { 'tous' } subject { procedure_presentation.filtered_sorted_ids(dossiers, statut) } @@ -837,6 +767,14 @@ describe ProcedurePresentation do end context 'with mocked sorted_ids' do + let(:dossier_1) { create(:dossier) } + let(:dossier_2) { create(:dossier) } + let(:dossier_3) { create(:dossier) } + let(:dossiers) { Dossier.where(id: [dossier_1, dossier_2, dossier_3].map(&:id)) } + + let(:sorted_ids) { [dossier_2, dossier_3, dossier_1].map(&:id) } + let(:statut) { 'tous' } + before do expect(procedure_presentation).to receive(:sorted_ids).and_return(sorted_ids) end @@ -847,7 +785,7 @@ describe ProcedurePresentation do let(:filtered_ids) { [dossier_1, dossier_2, dossier_3].map(&:id) } before do - procedure_presentation.filters['tous'] = 'some_filter' + procedure_presentation.tous_filters = [to_filter(['Statut', 'en_construction'])] expect(procedure_presentation).to receive(:filtered_ids).and_return(filtered_ids) end From 14fe11b612555ff50b2691ce3aeff0c251a4ed26 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 7 Oct 2024 15:00:05 +0200 Subject: [PATCH 1252/1532] use displayed_columns ! --- .../instructeurs/column_picker_component.rb | 2 +- .../instructeurs/procedures_controller.rb | 6 +- app/controllers/recherche_controller.rb | 10 +- app/models/procedure_presentation.rb | 4 +- app/services/dossier_projection_service.rb | 8 +- ...edure_presentation_to_columns.rake_spec.rb | 5 +- spec/models/instructeur_spec.rb | 14 --- spec/models/procedure_presentation_spec.rb | 23 ++-- .../dossier_projection_service_spec.rb | 103 ++++++++---------- 9 files changed, 78 insertions(+), 97 deletions(-) diff --git a/app/components/instructeurs/column_picker_component.rb b/app/components/instructeurs/column_picker_component.rb index 92cc524da..f8c1b0fba 100644 --- a/app/components/instructeurs/column_picker_component.rb +++ b/app/components/instructeurs/column_picker_component.rb @@ -12,7 +12,7 @@ class Instructeurs::ColumnPickerComponent < ApplicationComponent def displayable_columns_for_select [ procedure.columns.filter(&:displayable).map { |column| [column.label, column.id] }, - procedure_presentation.displayed_fields.map { Column.new(**_1.deep_symbolize_keys.merge(procedure_id: procedure.id)).id } + procedure_presentation.displayed_columns.map(&:id) ] end end diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index 89044e7d2..68ed3b7df 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -104,7 +104,7 @@ module Instructeurs .page(page) .per(ITEMS_PER_PAGE) - @projected_dossiers = DossierProjectionService.project(@filtered_sorted_paginated_ids, procedure_presentation.displayed_fields) + @projected_dossiers = DossierProjectionService.project(@filtered_sorted_paginated_ids, procedure_presentation.displayed_columns) @disable_checkbox_all = @projected_dossiers.all? { _1.batch_operation_id.present? } @batch_operations = BatchOperation.joins(:groupe_instructeurs) @@ -133,9 +133,9 @@ module Instructeurs end def update_displayed_fields - values = (params['values'].presence || []).reject(&:empty?) + ids = (params['values'].presence || []).reject(&:empty?) - procedure_presentation.update_displayed_fields(values) + procedure_presentation.update!(displayed_columns: ids) redirect_back(fallback_location: instructeur_procedure_url(procedure)) end diff --git a/app/controllers/recherche_controller.rb b/app/controllers/recherche_controller.rb index 98d684792..63772f839 100644 --- a/app/controllers/recherche_controller.rb +++ b/app/controllers/recherche_controller.rb @@ -3,10 +3,14 @@ class RechercheController < ApplicationController before_action :authenticate_logged_user! ITEMS_PER_PAGE = 25 + + # the columns are generally procedure specific + # but in the search context, we are looking for dossiers from multiple procedures + # so we are faking the columns with a random procedure_id PROJECTIONS = [ - { "table" => 'procedure', "column" => 'libelle' }, - { "table" => 'user', "column" => 'email' }, - { "table" => 'procedure', "column" => 'procedure_id' } + Column.new(procedure_id: 666, table: 'procedure', column: 'libelle'), + Column.new(procedure_id: 666, table: 'user', column: 'email'), + Column.new(procedure_id: 666, table: 'procedure', column: 'procedure_id') ] def nav_bar_profile diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 1619ecf37..d4dcecc09 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -21,6 +21,8 @@ class ProcedurePresentation < ApplicationRecord validate :check_filters_max_length validate :check_filters_max_integer + attribute :displayed_columns, :column, array: true + attribute :sorted_column, :sorted_column def sorted_column = super || procedure.default_sorted_column # Dummy override to set default value @@ -42,7 +44,7 @@ class ProcedurePresentation < ApplicationRecord def displayed_fields_for_headers [ Column.new(procedure_id: procedure.id, table: 'self', column: 'id', classname: 'number-col'), - *displayed_fields.map { Column.new(**_1.deep_symbolize_keys.merge(procedure_id: procedure.id)) }, + *displayed_columns, Column.new(procedure_id: procedure.id, table: 'self', column: 'state', classname: 'state-col'), *procedure.sva_svr_columns ] diff --git a/app/services/dossier_projection_service.rb b/app/services/dossier_projection_service.rb index fbbe64da8..041969489 100644 --- a/app/services/dossier_projection_service.rb +++ b/app/services/dossier_projection_service.rb @@ -40,8 +40,10 @@ class DossierProjectionService # Those hashes are needed because: # - the order of the intermediary query results are unknown # - some values can be missing (if a revision added or removed them) - def self.project(dossiers_ids, fields) - fields = fields.deep_dup + def self.project(dossiers_ids, columns) + fields = columns.map { |c| { TABLE => c.table, COLUMN => c.column } } + champ_value = champ_value_formatter(dossiers_ids, fields) + state_field = { TABLE => 'self', COLUMN => 'state' } archived_field = { TABLE => 'self', COLUMN => 'archived' } batch_operation_field = { TABLE => 'self', COLUMN => 'batch_operation_id' } @@ -53,7 +55,7 @@ class DossierProjectionService individual_last_name = { TABLE => 'individual', COLUMN => 'nom' } sva_svr_decision_on_field = { TABLE => 'self', COLUMN => 'sva_svr_decision_on' } dossier_corrections = { TABLE => 'dossier_corrections', COLUMN => 'resolved_at' } - champ_value = champ_value_formatter(dossiers_ids, fields) + ([state_field, archived_field, sva_svr_decision_on_field, hidden_by_user_at_field, hidden_by_administration_at_field, hidden_by_reason_field, for_tiers_field, individual_first_name, individual_last_name, batch_operation_field, dossier_corrections] + fields) .each { |f| f[:id_value_h] = {} } .group_by { |f| f[TABLE] } # one query per table diff --git a/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb b/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb index 0494ba5e7..795736c26 100644 --- a/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb +++ b/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb @@ -38,10 +38,7 @@ describe '20240920130741_migrate_procedure_presentation_to_columns.rake' do it 'populates the columns' do procedure_id = procedure.id - expect(procedure_presentation.displayed_columns).to eq([ - { "procedure_id" => procedure_id, "column_id" => "etablissement/entreprise_raison_sociale" }, - { "procedure_id" => procedure_id, "column_id" => "type_de_champ/#{stable_id}" } - ]) + expect(procedure_presentation.displayed_columns.map(&:label)).to eq(["Raison sociale", procedure.active_revision.types_de_champ.first.libelle]) order, column_id = procedure_presentation .sorted_column diff --git a/spec/models/instructeur_spec.rb b/spec/models/instructeur_spec.rb index 9523e6779..5ac18c0df 100644 --- a/spec/models/instructeur_spec.rb +++ b/spec/models/instructeur_spec.rb @@ -167,20 +167,6 @@ describe Instructeur, type: :model do it { expect(errors).to be_nil } end - context 'with invalid presentation' do - let(:procedure_id) { procedure.id } - before do - pp = ProcedurePresentation.create(assign_to: procedure_assign, displayed_fields: [{ 'table' => 'invalid', 'column' => 'random' }]) - pp.save(:validate => false) - end - - it 'recreates a valid prsentation' do - expect(procedure_presentation).to be_persisted - end - it { expect(procedure_presentation).to be_valid } - it { expect(errors).to be_present } - end - context 'with default presentation' do let(:procedure_id) { procedure_2.id } diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index 315deccb0..2fd15c6e2 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -40,8 +40,11 @@ describe ProcedurePresentation do describe 'validation' do it { expect(build(:procedure_presentation)).to be_valid } - context 'of displayed fields' do - it { expect(build(:procedure_presentation, displayed_fields: [{ table: "user", column: "reset_password_token", "order" => "asc" }])).to be_invalid } + context 'of displayed columns' do + it do + pp = build(:procedure_presentation, displayed_columns: [{ table: "user", column: "reset_password_token", procedure_id: }]) + expect { pp.displayed_columns }.to raise_error(ActiveRecord::RecordNotFound) + end end context 'of filters' do @@ -795,6 +798,9 @@ describe ProcedurePresentation do end describe '#update_displayed_fields' do + let(:en_construction_column) { procedure.find_column(label: 'En construction le') } + let(:mise_a_jour_column) { procedure.find_column(label: 'Mis à jour le') } + let(:procedure_presentation) do create(:procedure_presentation, assign_to:).tap do |pp| pp.update(sorted_column: SortedColumn.new(column: procedure.find_column(label: 'Demandeur'), order: 'desc')) @@ -802,24 +808,19 @@ describe ProcedurePresentation do end subject do - procedure_presentation.update_displayed_fields([ - procedure.find_column(label: 'En construction le').id, - procedure.find_column(label: 'Mis à jour le').id + procedure_presentation.update(displayed_columns: [ + en_construction_column.id, mise_a_jour_column.id ]) end it 'should update displayed_fields' do - expect(procedure_presentation.displayed_columns).to eq([]) + expect(procedure_presentation.displayed_columns).to eq(procedure.default_displayed_columns) subject expect(procedure_presentation.displayed_columns).to eq([ - { "column_id" => "self/en_construction_at", "procedure_id" => procedure.id }, - { "column_id" => "self/updated_at", "procedure_id" => procedure.id } + en_construction_column, mise_a_jour_column ]) - - expect(procedure_presentation.sorted_column).to eq(procedure.default_sorted_column) - expect(procedure_presentation.sorted_column.order).to eq('desc') end end end diff --git a/spec/services/dossier_projection_service_spec.rb b/spec/services/dossier_projection_service_spec.rb index 1b577cf40..7671bdda5 100644 --- a/spec/services/dossier_projection_service_spec.rb +++ b/spec/services/dossier_projection_service_spec.rb @@ -2,7 +2,7 @@ describe DossierProjectionService do describe '#project' do - subject { described_class.project(dossiers_ids, fields) } + subject { described_class.project(dossiers_ids, columns) } context 'with multiple dossier' do let!(:procedure) { create(:procedure, types_de_champ_public: [{}, { type: :linked_drop_down_list }]) } @@ -11,12 +11,9 @@ describe DossierProjectionService do let!(:dossier_3) { create(:dossier, :en_instruction, procedure: procedure) } let(:dossiers_ids) { [dossier_3.id, dossier_1.id, dossier_2.id] } - let(:fields) do + let(:columns) do procedure.active_revision.types_de_champ_public.map do |type_de_champ| - { - "table" => "type_de_champ", - "column" => type_de_champ.stable_id.to_s - } + procedure.find_column(label: type_de_champ.libelle) end end @@ -55,12 +52,9 @@ describe DossierProjectionService do let!(:dossier) { create(:dossier, procedure:) } let(:dossiers_ids) { [dossier.id] } - let(:fields) do + let(:columns) do [ - { - "table" => "type_de_champ", - "column" => procedure.active_revision.types_de_champ_public[0].stable_id.to_s - } + procedure.find_column(label: procedure.active_revision.types_de_champ_public[0].libelle) ] end @@ -78,38 +72,37 @@ describe DossierProjectionService do end context 'attributes by attributes' do - let(:fields) { [{ "table" => table, "column" => column }] } + let(:procedure) { create(:procedure) } + let(:columns) { [procedure.find_column(label:)] } let(:dossiers_ids) { [dossier.id] } subject { super()[0].columns[0] } context 'for self table' do - let(:table) { 'self' } - context 'for created_at column' do - let(:column) { 'created_at' } - let(:dossier) { Timecop.freeze(Time.zone.local(1992, 3, 22)) { create(:dossier) } } + let(:label) { 'Créé le' } + let(:dossier) { Timecop.freeze(Time.zone.local(1992, 3, 22)) { create(:dossier, procedure:) } } it { is_expected.to eq('22/03/1992') } end context 'for en_construction_at column' do - let(:column) { 'en_construction_at' } - let(:dossier) { create(:dossier, :en_construction, en_construction_at: Time.zone.local(2018, 10, 17)) } + let(:label) { 'En construction le' } + let(:dossier) { create(:dossier, :en_construction, en_construction_at: Time.zone.local(2018, 10, 17), procedure:) } it { is_expected.to eq('17/10/2018') } end context 'for depose_at column' do - let(:column) { 'depose_at' } - let(:dossier) { create(:dossier, :en_construction, depose_at: Time.zone.local(2018, 10, 17)) } + let(:label) { 'Déposé le' } + let(:dossier) { create(:dossier, :en_construction, depose_at: Time.zone.local(2018, 10, 17), procedure:) } it { is_expected.to eq('17/10/2018') } end context 'for updated_at column' do - let(:column) { 'updated_at' } - let(:dossier) { create(:dossier) } + let(:label) { 'Mis à jour le' } + let(:dossier) { create(:dossier, procedure:) } before { dossier.touch(time: Time.zone.local(2018, 9, 25)) } @@ -118,61 +111,56 @@ describe DossierProjectionService do end context 'for user table' do - let(:table) { 'user' } - let(:column) { 'email' } + let(:label) { 'Demandeur' } - let(:dossier) { create(:dossier, user: create(:user, email: 'bla@yopmail.com')) } + let(:dossier) { create(:dossier, user: create(:user, email: 'bla@yopmail.com'), procedure:) } it { is_expected.to eq('bla@yopmail.com') } end context 'for individual table' do - let(:table) { 'individual' } let(:procedure) { create(:procedure, :for_individual, :with_type_de_champ, :with_type_de_champ_private) } - let(:dossier) { create(:dossier, procedure: procedure, individual: build(:individual, nom: 'Martin', prenom: 'Jacques', gender: 'M.')) } + let(:dossier) { create(:dossier, procedure:, individual: build(:individual, nom: 'Martin', prenom: 'Jacques', gender: 'M.')) } context 'for prenom column' do - let(:column) { 'prenom' } + let(:label) { 'Prénom' } it { is_expected.to eq('Jacques') } end context 'for nom column' do - let(:column) { 'nom' } + let(:label) { 'Nom' } it { is_expected.to eq('Martin') } end context 'for gender column' do - let(:column) { 'gender' } + let(:label) { 'Civilité' } it { is_expected.to eq('M.') } end end context 'for etablissement table' do - let(:table) { 'etablissement' } - let(:column) { 'code_postal' } # All other columns work the same, no extra test required + let(:label) { 'Code postal' } - let!(:dossier) { create(:dossier, etablissement: create(:etablissement, code_postal: '75008')) } + let!(:dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '75008')) } it { is_expected.to eq('75008') } end context 'for groupe_instructeur table' do - let(:table) { 'groupe_instructeur' } - let(:column) { 'label' } + let(:label) { 'Groupe instructeur' } - let!(:dossier) { create(:dossier) } + let!(:dossier) { create(:dossier, procedure:) } it { is_expected.to eq('défaut') } end context 'for followers_instructeurs table' do - let(:table) { 'followers_instructeurs' } - let(:column) { 'email' } + let(:label) { 'Email instructeur' } - let(:dossier) { create(:dossier) } + let(:dossier) { create(:dossier, procedure:) } let!(:follow1) { create(:follow, dossier: dossier, instructeur: create(:instructeur, email: 'b@host.fr')) } let!(:follow2) { create(:follow, dossier: dossier, instructeur: create(:instructeur, email: 'a@host.fr')) } let!(:follow3) { create(:follow, dossier: dossier, instructeur: create(:instructeur, email: 'c@host.fr')) } @@ -181,19 +169,21 @@ describe DossierProjectionService do end context 'for type_de_champ table' do - let(:table) { 'type_de_champ' } - let(:dossier) { create(:dossier) } - let(:column) { dossier.procedure.active_revision.types_de_champ_public.first.stable_id.to_s } + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :text }]) } + let(:dossier) { create(:dossier, procedure:) } + let(:label) { dossier.procedure.active_revision.types_de_champ_public.first.libelle } - before { dossier.project_champs_public.first.update(value: 'kale') } + before do + dossier.project_champs_public.first.update(value: 'kale') + end it { is_expected.to eq('kale') } end context 'for type_de_champ_private table' do - let(:table) { 'type_de_champ_private' } - let(:dossier) { create(:dossier) } - let(:column) { dossier.procedure.active_revision.types_de_champ_private.first.stable_id.to_s } + let(:procedure) { create(:procedure, types_de_champ_private: [{ type: :text }]) } + let(:dossier) { create(:dossier, procedure:) } + let(:label) { dossier.procedure.active_revision.types_de_champ_private.first.libelle } before { dossier.project_champs_private.first.update(value: 'quinoa') } @@ -201,10 +191,9 @@ describe DossierProjectionService do end context 'for type_de_champ table and value to.s' do - let(:table) { 'type_de_champ' } let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :yes_no }]) } - let(:dossier) { create(:dossier, procedure: procedure) } - let(:column) { dossier.procedure.active_revision.types_de_champ_public.first.stable_id.to_s } + let(:dossier) { create(:dossier, procedure:) } + let(:label) { dossier.procedure.active_revision.types_de_champ_public.first.libelle } before { dossier.project_champs_public.first.update(value: 'true') } @@ -212,10 +201,9 @@ describe DossierProjectionService do end context 'for type_de_champ table and value to.s which needs data field' do - let(:table) { 'type_de_champ' } let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :address }]) } - let(:dossier) { create(:dossier, procedure: procedure) } - let(:column) { dossier.procedure.active_revision.types_de_champ_public.first.stable_id.to_s } + let(:dossier) { create(:dossier, procedure:) } + let(:label) { dossier.procedure.active_revision.types_de_champ_public.first.libelle } before { dossier.project_champs_public.first.update(value: '18 a la bonne rue', data: { 'label' => '18 a la bonne rue', 'departement' => 'd' }) } @@ -223,10 +211,9 @@ describe DossierProjectionService do end context 'for type_de_champ table: type_de_champ pays which needs external_id field' do - let(:table) { 'type_de_champ' } let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :pays }]) } - let(:dossier) { create(:dossier, procedure: procedure) } - let(:column) { dossier.procedure.active_revision.types_de_champ_public.first.stable_id.to_s } + let(:dossier) { create(:dossier, procedure:) } + let(:label) { dossier.procedure.active_revision.types_de_champ_public.first.libelle } around do |example| I18n.with_locale(:fr) do @@ -254,8 +241,10 @@ describe DossierProjectionService do context 'for dossier corrections table' do let(:table) { 'dossier_corrections' } let(:column) { 'resolved_at' } - let(:dossier) { create(:dossier, :en_construction) } - subject { described_class.project(dossiers_ids, fields)[0] } + let(:procedure) { create(:procedure) } + let(:columns) { [Column.new(procedure_id: procedure.id, table:, column:)] } # should somehow be present in column concern + let(:dossier) { create(:dossier, :en_construction, procedure:) } + subject { described_class.project(dossiers_ids, columns)[0] } context "when dossier has pending correction" do before { create(:dossier_correction, dossier:) } From 6b5efbda07b3f3c0b3109783f7b6b9438549f426 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 15 Oct 2024 14:55:08 +0200 Subject: [PATCH 1253/1532] remove now unused code --- app/models/procedure_presentation.rb | 41 ---------------------------- 1 file changed, 41 deletions(-) diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index d4dcecc09..d4d98c1a9 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -17,7 +17,6 @@ class ProcedurePresentation < ApplicationRecord delegate :procedure, :instructeur, to: :assign_to - validate :check_allowed_displayed_fields validate :check_filters_max_length validate :check_filters_max_integer @@ -92,20 +91,6 @@ class ProcedurePresentation < ApplicationRecord nil end - def update_displayed_fields(column_ids) - h_ids = Array.wrap(column_ids).map { |id| JSON.parse(id, symbolize_names: true) } - columns = h_ids.map { |h_id| procedure.find_column(h_id:) } - - update!( - displayed_fields: columns, - displayed_columns: columns.map(&:h_id) - ) - - if !sorted_column.column.in?(columns) - update(sorted_column: nil) - end - end - def snapshot slice(:filters, :sort, :displayed_fields) end @@ -234,19 +219,6 @@ class ProcedurePresentation < ApplicationRecord .find_by(stable_id: column) end - def check_allowed_displayed_fields - displayed_fields.each do |field| - check_allowed_field(:displayed_fields, field) - end - end - - def check_allowed_field(kind, field, extra_columns = {}) - table, column = field.values_at(TABLE, COLUMN) - if !valid_column?(table, column, extra_columns) - errors.add(kind, "#{table}.#{column} n’est pas une colonne permise") - end - end - def check_filters_max_length filters.values.flatten.each do |filter| next if !filter.is_a?(Hash) @@ -266,19 +238,6 @@ class ProcedurePresentation < ApplicationRecord end end - def valid_column?(table, column, extra_columns = {}) - valid_columns_for_table(table).include?(column) || - extra_columns[table]&.include?(column) - end - - def valid_columns_for_table(table) - @column_whitelist ||= procedure.columns - .group_by(&:table) - .transform_values { |columns| Set.new(columns.map(&:column)) } - - @column_whitelist[table] || [] - end - def self.sanitized_column(association, column) table = if association == 'self' Dossier.table_name From b2754cd26c90dff9c87890d0b00d595d29c4b4f2 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 7 Oct 2024 09:54:17 +0200 Subject: [PATCH 1254/1532] move validations concern to filtered_column Co-authored-by: mfo --- .../column_filter_component.html.haml | 2 +- .../instructeurs/procedures_controller.rb | 2 +- app/models/filtered_column.rb | 28 +++++++++ app/models/procedure_presentation.rb | 29 +-------- ...ean_invalid_procedure_presentation_task.rb | 4 +- .../procedures_controller_spec.rb | 2 +- spec/models/filtered_column_spec.rb | 59 +++++++++++++++++++ spec/models/procedure_presentation_spec.rb | 13 +--- ...nvalid_procedure_presentation_task_spec.rb | 4 +- 9 files changed, 100 insertions(+), 43 deletions(-) create mode 100644 spec/models/filtered_column_spec.rb diff --git a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml index 484bf88f5..eaa41be42 100644 --- a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml +++ b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml @@ -12,7 +12,7 @@ - if column_type == :enum = select_tag :filter, options_for_select(options_for_select_of_column), id: 'value', name: "#{prefix}[filter]", class: 'fr-select', data: { no_autosubmit: true } - else - %input#value.fr-input{ type: column_type, name: "#{prefix}[filter]", maxlength: ProcedurePresentation::FILTERS_VALUE_MAX_LENGTH, disabled: column.nil? ? true : false, data: { no_autosubmit: true } } + %input#value.fr-input{ type: column_type, name: "#{prefix}[filter]", maxlength: FilteredColumn::FILTERS_VALUE_MAX_LENGTH, disabled: column.nil? ? true : false, data: { no_autosubmit: true } } = hidden_field_tag :statut, statut = submit_tag t('.add_filter'), class: 'fr-btn fr-btn--secondary fr-mt-2w' diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index 68ed3b7df..8a8f0700a 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -150,7 +150,7 @@ module Instructeurs if !procedure_presentation.update(filter_params) # complicated way to display inner error messages flash.alert = procedure_presentation.errors - .flat_map { _1.detail[:value].errors.full_messages } + .flat_map { _1.detail[:value].flat_map { |c| c.errors.full_messages } } end redirect_back(fallback_location: instructeur_procedure_url(procedure)) diff --git a/app/models/filtered_column.rb b/app/models/filtered_column.rb index 8578bbcf8..50579b8b5 100644 --- a/app/models/filtered_column.rb +++ b/app/models/filtered_column.rb @@ -1,8 +1,19 @@ # frozen_string_literal: true class FilteredColumn + include ActiveModel::Validations + + FILTERS_VALUE_MAX_LENGTH = 4048 + # https://www.postgresql.org/docs/current/datatype-numeric.html + PG_INTEGER_MAX_VALUE = 2147483647 + attr_reader :column, :filter + delegate :label, to: :column + + validate :check_filter_max_length + validate :check_filter_max_integer + def initialize(column:, filter:) @column = column @filter = filter @@ -11,4 +22,21 @@ class FilteredColumn def ==(other) other&.column == column && other.filter == filter end + + private + + def check_filter_max_length + if @filter.present? && @filter.length.to_i > FILTERS_VALUE_MAX_LENGTH + errors.add( + :base, + "Le filtre #{label} est trop long (maximum: #{FILTERS_VALUE_MAX_LENGTH} caractères)" + ) + end + end + + def check_filter_max_integer + if @column.column == 'id' && @filter.to_i > PG_INTEGER_MAX_VALUE + errors.add(:base, "Le filtre #{label} n'est pas un numéro de dossier possible") + end + end end diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index d4d98c1a9..05c7c20dc 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -8,18 +8,11 @@ class ProcedurePresentation < ApplicationRecord SLASH = '/' TYPE_DE_CHAMP = 'type_de_champ' - FILTERS_VALUE_MAX_LENGTH = 4048 - # https://www.postgresql.org/docs/current/datatype-numeric.html - PG_INTEGER_MAX_VALUE = 2147483647 - belongs_to :assign_to, optional: false has_many :exports, dependent: :destroy delegate :procedure, :instructeur, to: :assign_to - validate :check_filters_max_length - validate :check_filters_max_integer - attribute :displayed_columns, :column, array: true attribute :sorted_column, :sorted_column @@ -34,6 +27,9 @@ class ProcedurePresentation < ApplicationRecord attribute :expirant_filters, :filtered_column, array: true attribute :archives_filters, :filtered_column, array: true + validates_associated :a_suivre_filters, :suivis_filters, :traites_filters, + :tous_filters, :supprimes_filters, :expirant_filters, :archives_filters + def filters_for(statut) send(filters_name_for(statut)) end @@ -219,25 +215,6 @@ class ProcedurePresentation < ApplicationRecord .find_by(stable_id: column) end - def check_filters_max_length - filters.values.flatten.each do |filter| - next if !filter.is_a?(Hash) - next if filter['value']&.length.to_i <= FILTERS_VALUE_MAX_LENGTH - - errors.add(:base, "Le filtre #{filter['label']} est trop long (maximum: #{FILTERS_VALUE_MAX_LENGTH} caractères)") - end - end - - def check_filters_max_integer - filters.values.flatten.each do |filter| - next if !filter.is_a?(Hash) - next if filter['column'] != 'id' - next if filter['value']&.to_i&. < PG_INTEGER_MAX_VALUE - - errors.add(:base, "Le filtre #{filter['label']} n'est pas un numéro de dossier possible") - end - end - def self.sanitized_column(association, column) table = if association == 'self' Dossier.table_name diff --git a/app/tasks/maintenance/clean_invalid_procedure_presentation_task.rb b/app/tasks/maintenance/clean_invalid_procedure_presentation_task.rb index 9da038ac4..669c6d3bf 100644 --- a/app/tasks/maintenance/clean_invalid_procedure_presentation_task.rb +++ b/app/tasks/maintenance/clean_invalid_procedure_presentation_task.rb @@ -2,7 +2,7 @@ module Maintenance # PR: 10774 - # why: postgres does not support integer greater than ProcedurePresentation::PG_INTEGER_MAX_VALUE) + # why: postgres does not support integer greater than FilteredColumn::PG_INTEGER_MAX_VALUE) # it occures when user copypaste the dossier id twice (like missed copy paste,paste) # once this huge integer is saved on procedure presentation, page with this filter can't be loaded # when: run this migration when it appears in your maintenance tasks list, this file fix the data and we added some validations too @@ -16,7 +16,7 @@ module Maintenance filters_by_status.reject do |filter| filter.is_a?(Hash) && filter['column'] == 'id' && - (filter['value']&.to_i&. >= ProcedurePresentation::PG_INTEGER_MAX_VALUE) + (filter['value']&.to_i&. >= FilteredColumn::PG_INTEGER_MAX_VALUE) end end element.save diff --git a/spec/controllers/instructeurs/procedures_controller_spec.rb b/spec/controllers/instructeurs/procedures_controller_spec.rb index d5954af14..996966fd8 100644 --- a/spec/controllers/instructeurs/procedures_controller_spec.rb +++ b/spec/controllers/instructeurs/procedures_controller_spec.rb @@ -906,7 +906,7 @@ describe Instructeurs::ProceduresController, type: :controller do subject do column = procedure.find_column(label: "Nom") - post :add_filter, params: { procedure_id: procedure.id, a_suivre_filters: { id: column.id, filter: "n" * 110 } } + post :add_filter, params: { procedure_id: procedure.id, a_suivre_filters: [{ id: column.id, filter: "n" * 4049 }] } end it 'should render the error' do diff --git a/spec/models/filtered_column_spec.rb b/spec/models/filtered_column_spec.rb new file mode 100644 index 000000000..f4882d960 --- /dev/null +++ b/spec/models/filtered_column_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +describe FilteredColumn do + describe '#check_filters_max_length' do + let(:column) { Column.new(procedure_id: 1, table: 'table', column: 'column', label: 'label') } + let(:filtered_column) { described_class.new(column:, filter:) } + + before { filtered_column.valid? } + + context 'when the filter is too long' do + let(:filter) { 'a' * (FilteredColumn::FILTERS_VALUE_MAX_LENGTH + 1) } + + it 'adds an error' do + expect(filtered_column.errors.map(&:message)).to include(/Le filtre label est trop long/) + end + end + + context 'when then filter is not too long' do + let(:filter) { 'a' * FilteredColumn::FILTERS_VALUE_MAX_LENGTH } + + it 'does not add an error' do + expect(filtered_column.errors).to be_empty + end + end + + context 'when the filter is empty' do + let(:filter) { nil } + + it 'does not add an error' do + expect(filtered_column.errors).to be_empty + end + end + end + + describe '#check_filters_max_integer' do + context 'when the target column is an id column' do + let(:column) { Column.new(procedure_id: 1, table: 'table', column: 'id', label: 'label') } + let(:filtered_column) { described_class.new(column:, filter:) } + + before { filtered_column.valid? } + + context 'when the filter is too high' do + let(:filter) { (FilteredColumn::PG_INTEGER_MAX_VALUE + 1).to_s } + + it 'adds an error' do + expect(filtered_column.errors.map(&:message)).to include(/Le filtre label n'est pas un numéro de dossier possible/) + end + end + + context 'when the filter is not too high' do + let(:filter) { FilteredColumn::PG_INTEGER_MAX_VALUE.to_s } + + it 'does not add an error' do + expect(filtered_column.errors).to be_empty + end + end + end + end +end diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index 2fd15c6e2..ecf427de9 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -48,16 +48,9 @@ describe ProcedurePresentation do end context 'of filters' do - it do - expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "user", column: "reset_password_token", "order" => "asc" }] })).to be_invalid - expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "user", column: "email", "value" => "exceedingly long filter value" * 1000 }] })).to be_invalid - end - - describe 'check_filters_max_integer' do - it do - expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "self", column: "id", "value" => ProcedurePresentation::PG_INTEGER_MAX_VALUE.to_s }] })).to be_invalid - expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "self", column: "id", "value" => (ProcedurePresentation::PG_INTEGER_MAX_VALUE - 1).to_s }] })).to be_valid - end + it 'validates the filter_column objects' do + expect(build(:procedure_presentation, "suivis_filters": [{ id: { column_id: "user/email", procedure_id: }, "filter": "not so long filter value" }])).to be_valid + expect(build(:procedure_presentation, "suivis_filters": [{ id: { column_id: "user/email", procedure_id: }, "filter": "exceedingly long filter value" * 400 }])).to be_invalid end end end diff --git a/spec/tasks/maintenance/clean_invalid_procedure_presentation_task_spec.rb b/spec/tasks/maintenance/clean_invalid_procedure_presentation_task_spec.rb index 6b95fe523..ba0915b52 100644 --- a/spec/tasks/maintenance/clean_invalid_procedure_presentation_task_spec.rb +++ b/spec/tasks/maintenance/clean_invalid_procedure_presentation_task_spec.rb @@ -14,14 +14,14 @@ module Maintenance before { element.update_column(:filters, filters) } context 'when filter is valid' do - let(:filters) { { "suivis" => [{ 'table' => "self", 'column' => "id", "value" => (ProcedurePresentation::PG_INTEGER_MAX_VALUE - 1).to_s }] } } + let(:filters) { { "suivis" => [{ 'table' => "self", 'column' => "id", "value" => (FilteredColumn::PG_INTEGER_MAX_VALUE - 1).to_s }] } } it 'keeps it filters' do expect { subject }.not_to change { element.reload.filters } end end context 'when filter is invalid, drop it' do - let(:filters) { { "suivis" => [{ 'table' => "self", 'column' => "id", "value" => (ProcedurePresentation::PG_INTEGER_MAX_VALUE).to_s }] } } + let(:filters) { { "suivis" => [{ 'table' => "self", 'column' => "id", "value" => (FilteredColumn::PG_INTEGER_MAX_VALUE).to_s }] } } it 'drop invalid filters' do expect { subject }.to change { element.reload.filters }.to({ "suivis" => [] }) end From 0a54db6db5ea57641eff4946adb34bff947885ed Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 7 Oct 2024 15:09:21 +0200 Subject: [PATCH 1255/1532] remove human_to_filter --- app/models/types_de_champ/type_de_champ_base.rb | 4 ---- app/models/types_de_champ/yes_no_type_de_champ.rb | 11 ----------- 2 files changed, 15 deletions(-) diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index 19d37df0e..c01ad1d25 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -54,10 +54,6 @@ class TypesDeChamp::TypeDeChampBase filter_value end - def human_to_filter(human_value) - human_value - end - class << self def champ_value(champ) champ.value.present? ? champ.value.to_s : champ_default_value diff --git a/app/models/types_de_champ/yes_no_type_de_champ.rb b/app/models/types_de_champ/yes_no_type_de_champ.rb index 4e500a6fd..c34ba1dcf 100644 --- a/app/models/types_de_champ/yes_no_type_de_champ.rb +++ b/app/models/types_de_champ/yes_no_type_de_champ.rb @@ -11,17 +11,6 @@ class TypesDeChamp::YesNoTypeDeChamp < TypesDeChamp::CheckboxTypeDeChamp end end - def human_to_filter(human_value) - downcased = human_value.downcase - if downcased == "oui" - "true" - elsif downcased == "non" - "false" - else - downcased - end - end - class << self def champ_value(champ) champ_formatted_value(champ) From 14483270babb828d47729acbf5906b817160265d Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 9 Oct 2024 11:08:41 +0200 Subject: [PATCH 1256/1532] default default_displayed_column --- app/models/concerns/columns_concern.rb | 8 +++++++- app/models/procedure_presentation.rb | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index c98a621df..2757e7e49 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -73,11 +73,17 @@ module ColumnsConcern SortedColumn.new(column: notifications_column, order: 'desc') end + def default_displayed_columns = [email_column] + private + def email_column + Column.new(procedure_id: id, table: 'user', column: 'email') + end + def standard_columns [ - Column.new(procedure_id: id, table: 'user', column: 'email'), + email_column, Column.new(procedure_id: id, table: 'followers_instructeurs', column: 'email'), Column.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum), Column.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false) # not filterable ? diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 05c7c20dc..fcb42957d 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -27,6 +27,8 @@ class ProcedurePresentation < ApplicationRecord attribute :expirant_filters, :filtered_column, array: true attribute :archives_filters, :filtered_column, array: true + before_create { self.displayed_columns = procedure.default_displayed_columns } + validates_associated :a_suivre_filters, :suivis_filters, :traites_filters, :tous_filters, :supprimes_filters, :expirant_filters, :archives_filters From 242ab78235e5f4d425dc6555d4bb411e0b6c8b4d Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 7 Oct 2024 10:03:16 +0200 Subject: [PATCH 1257/1532] clean(deadcode): remove unused current_filters method --- app/controllers/instructeurs/procedures_controller.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index 8a8f0700a..59797c67a 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -382,10 +382,6 @@ module Instructeurs end end - def current_filters - @current_filters ||= procedure_presentation.filters.fetch(statut, []) - end - def bulk_message_params params.require(:bulk_message).permit(:body) end From 30fcb75da4248b58558f9764dbfc573dd308d3fd Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 9 Oct 2024 15:16:13 +0200 Subject: [PATCH 1258/1532] extract dossier_state_column --- app/models/column.rb | 6 +++--- app/models/concerns/columns_concern.rb | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/models/column.rb b/app/models/column.rb index b6c6a43d6..c50cb9183 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -33,9 +33,9 @@ class Column } end - def notifications? - table == 'notifications' && column == 'notifications' - end + def notifications? = [table, column] == ['notifications', 'notifications'] + + def dossier_state? = [table, column] == ['self', 'state'] def self.find(h_id) begin diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 2757e7e49..887484ff5 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -33,6 +33,10 @@ module ColumnsConcern Column.new(procedure_id: id, table: 'self', column: 'id', classname: 'number-col', type: :number) end + def dossier_state_column + Column.new(procedure_id: id, table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false) + end + def notifications_column Column.new(procedure_id: id, table: 'notifications', column: 'notifications', label: "notifications", filterable: false) end @@ -46,7 +50,7 @@ module ColumnsConcern non_displayable_dates = ['updated_since', 'depose_since', 'en_construction_since', 'en_instruction_since', 'processed_since'] .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date, displayable: false) } - states = [Column.new(procedure_id: id, table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false)] + states = [dossier_state_column] [common, dates, sva_svr_columns(for_filters: true), non_displayable_dates, states].flatten.compact end From 3677f3b2d3b47d44e022927f8c14ef4936d86d7a Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 9 Oct 2024 09:51:47 +0200 Subject: [PATCH 1259/1532] tech(clean): remove Column classname: attribute Co-authored-by: mfo --- .../column_table_header_component.rb | 6 ++ .../column_table_header_component.html.haml | 2 +- app/models/column.rb | 7 +- app/models/concerns/columns_concern.rb | 2 +- app/models/procedure_presentation.rb | 4 +- spec/models/concerns/columns_concern_spec.rb | 74 +++++++++---------- 6 files changed, 50 insertions(+), 45 deletions(-) diff --git a/app/components/instructeurs/column_table_header_component.rb b/app/components/instructeurs/column_table_header_component.rb index 2ecb8f1df..004ef3138 100644 --- a/app/components/instructeurs/column_table_header_component.rb +++ b/app/components/instructeurs/column_table_header_component.rb @@ -9,6 +9,12 @@ class Instructeurs::ColumnTableHeaderComponent < ApplicationComponent private + def classname(column) + return 'status-col' if column.dossier_state? + return 'number-col' if column.type == :number + return 'sva-col' if column.column == 'sva_svr_decision_on' + end + def update_sort_path(column) id = column.id order = opposite_order_for(column) diff --git a/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml b/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml index e7cfa0eac..1e2857ec6 100644 --- a/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml +++ b/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml @@ -1,3 +1,3 @@ - @columns.each do |column| - %th{ aria_sort(column), scope: "col", class: column.classname } + %th{ aria_sort(column), scope: "col", class: classname(column) } = link_to label_and_arrow(column), update_sort_path(column) diff --git a/app/models/column.rb b/app/models/column.rb index c50cb9183..1a6871c65 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -3,14 +3,13 @@ class Column TYPE_DE_CHAMP_TABLE = 'type_de_champ' - attr_reader :table, :column, :label, :classname, :type, :scope, :value_column, :filterable, :displayable + attr_reader :table, :column, :label, :type, :scope, :value_column, :filterable, :displayable - def initialize(procedure_id:, table:, column:, label: nil, type: :text, value_column: :value, filterable: true, displayable: true, classname: '', scope: '') + def initialize(procedure_id:, table:, column:, label: nil, type: :text, value_column: :value, filterable: true, displayable: true, scope: '') @procedure_id = procedure_id @table = table @column = column @label = label || I18n.t(column, scope: [:activerecord, :attributes, :procedure_presentation, :fields, table]) - @classname = classname @type = type @scope = scope @value_column = value_column @@ -29,7 +28,7 @@ class Column def to_json { - table:, column:, label:, classname:, type:, scope:, value_column:, filterable:, displayable: + table:, column:, label:, type:, scope:, value_column:, filterable:, displayable: } end diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 887484ff5..e71cff289 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -30,7 +30,7 @@ module ColumnsConcern end def dossier_id_column - Column.new(procedure_id: id, table: 'self', column: 'id', classname: 'number-col', type: :number) + Column.new(procedure_id: id, table: 'self', column: 'id', type: :number) end def dossier_state_column diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index fcb42957d..fdf03b013 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -40,9 +40,9 @@ class ProcedurePresentation < ApplicationRecord def displayed_fields_for_headers [ - Column.new(procedure_id: procedure.id, table: 'self', column: 'id', classname: 'number-col'), + procedure.dossier_id_column, *displayed_columns, - Column.new(procedure_id: procedure.id, table: 'self', column: 'state', classname: 'state-col'), + procedure.dossier_state_column, *procedure.sva_svr_columns ] end diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index e7f167865..d787329d9 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -29,37 +29,37 @@ describe ColumnsConcern do let(:tdc_private_2) { procedure.active_revision.types_de_champ_private[1] } let(:expected) { [ - { label: 'Nº dossier', table: 'self', column: 'id', classname: 'number-col', displayable: true, type: :number, scope: '', value_column: :value, filterable: true }, + { label: 'Nº dossier', table: 'self', column: 'id', displayable: true, type: :number, scope: '', value_column: :value, filterable: true }, { label: 'notifications', table: 'notifications', column: 'notifications', displayable: true, type: :text, scope: '', value_column: :value, filterable: false }, - { label: 'Créé le', table: 'self', column: 'created_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'Mis à jour le', table: 'self', column: 'updated_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'Déposé le', table: 'self', column: 'depose_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'En construction le', table: 'self', column: 'en_construction_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'En instruction le', table: 'self', column: 'en_instruction_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'Terminé le', table: 'self', column: 'processed_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "Mis à jour depuis", table: "self", column: "updated_since", classname: "", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "Déposé depuis", table: "self", column: "depose_since", classname: "", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "En construction depuis", table: "self", column: "en_construction_since", classname: "", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "En instruction depuis", table: "self", column: "en_instruction_since", classname: "", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "Terminé depuis", table: "self", column: "processed_since", classname: "", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "Statut", table: "self", column: "state", classname: "", displayable: false, scope: 'instructeurs.dossiers.filterable_state', type: :enum, value_column: :value, filterable: true }, - { label: 'Demandeur', table: 'user', column: 'email', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Email instructeur', table: 'followers_instructeurs', column: 'email', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Groupe instructeur', table: 'groupe_instructeur', column: 'id', classname: '', displayable: true, type: :enum, scope: '', value_column: :value, filterable: true }, - { label: 'Avis oui/non', table: 'avis', column: 'question_answer', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: false }, - { label: 'SIREN', table: 'etablissement', column: 'entreprise_siren', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Forme juridique', table: 'etablissement', column: 'entreprise_forme_juridique', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Nom commercial', table: 'etablissement', column: 'entreprise_nom_commercial', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Raison sociale', table: 'etablissement', column: 'entreprise_raison_sociale', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'SIRET siège social', table: 'etablissement', column: 'entreprise_siret_siege_social', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Date de création', table: 'etablissement', column: 'entreprise_date_creation', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'SIRET', table: 'etablissement', column: 'siret', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Libellé NAF', table: 'etablissement', column: 'libelle_naf', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Code postal', table: 'etablissement', column: 'code_postal', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: tdc_1.libelle, table: 'type_de_champ', column: tdc_1.stable_id.to_s, classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: tdc_2.libelle, table: 'type_de_champ', column: tdc_2.stable_id.to_s, classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: tdc_private_1.libelle, table: 'type_de_champ', column: tdc_private_1.stable_id.to_s, classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: tdc_private_2.libelle, table: 'type_de_champ', column: tdc_private_2.stable_id.to_s, classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true } + { label: 'Créé le', table: 'self', column: 'created_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, + { label: 'Mis à jour le', table: 'self', column: 'updated_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, + { label: 'Déposé le', table: 'self', column: 'depose_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, + { label: 'En construction le', table: 'self', column: 'en_construction_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, + { label: 'En instruction le', table: 'self', column: 'en_instruction_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, + { label: 'Terminé le', table: 'self', column: 'processed_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, + { label: "Mis à jour depuis", table: "self", column: "updated_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, + { label: "Déposé depuis", table: "self", column: "depose_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, + { label: "En construction depuis", table: "self", column: "en_construction_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, + { label: "En instruction depuis", table: "self", column: "en_instruction_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, + { label: "Terminé depuis", table: "self", column: "processed_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, + { label: "Statut", table: "self", column: "state", displayable: false, scope: 'instructeurs.dossiers.filterable_state', type: :enum, value_column: :value, filterable: true }, + { label: 'Demandeur', table: 'user', column: 'email', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: 'Email instructeur', table: 'followers_instructeurs', column: 'email', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: 'Groupe instructeur', table: 'groupe_instructeur', column: 'id', displayable: true, type: :enum, scope: '', value_column: :value, filterable: true }, + { label: 'Avis oui/non', table: 'avis', column: 'question_answer', displayable: true, type: :text, scope: '', value_column: :value, filterable: false }, + { label: 'SIREN', table: 'etablissement', column: 'entreprise_siren', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: 'Forme juridique', table: 'etablissement', column: 'entreprise_forme_juridique', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: 'Nom commercial', table: 'etablissement', column: 'entreprise_nom_commercial', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: 'Raison sociale', table: 'etablissement', column: 'entreprise_raison_sociale', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: 'SIRET siège social', table: 'etablissement', column: 'entreprise_siret_siege_social', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: 'Date de création', table: 'etablissement', column: 'entreprise_date_creation', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, + { label: 'SIRET', table: 'etablissement', column: 'siret', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: 'Libellé NAF', table: 'etablissement', column: 'libelle_naf', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: 'Code postal', table: 'etablissement', column: 'code_postal', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: tdc_1.libelle, table: 'type_de_champ', column: tdc_1.stable_id.to_s, displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: tdc_2.libelle, table: 'type_de_champ', column: tdc_2.stable_id.to_s, displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: tdc_private_1.libelle, table: 'type_de_champ', column: tdc_private_1.stable_id.to_s, displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: tdc_private_2.libelle, table: 'type_de_champ', column: tdc_private_2.stable_id.to_s, displayable: true, type: :text, scope: '', value_column: :value, filterable: true } ].map { Column.new(**_1.merge(procedure_id:)) } } @@ -84,9 +84,9 @@ describe ColumnsConcern do end context 'when the procedure is for individuals' do - let(:name_field) { Column.new(procedure_id:, label: "Prénom", table: "individual", column: "prenom", classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } - let(:surname_field) { Column.new(procedure_id:, label: "Nom", table: "individual", column: "nom", classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } - let(:gender_field) { Column.new(procedure_id:, label: "Civilité", table: "individual", column: "gender", classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } + let(:name_field) { Column.new(procedure_id:, label: "Prénom", table: "individual", column: "prenom", displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } + let(:surname_field) { Column.new(procedure_id:, label: "Nom", table: "individual", column: "nom", displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } + let(:gender_field) { Column.new(procedure_id:, label: "Civilité", table: "individual", column: "gender", displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } let(:procedure) { create(:procedure, :for_individual) } let(:procedure_id) { procedure.id } let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) } @@ -99,8 +99,8 @@ describe ColumnsConcern do let(:procedure_id) { procedure.id } let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) } - let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVA", table: "self", column: "sva_svr_decision_on", classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true) } - let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVA avant", table: "self", column: "sva_svr_decision_before", classname: '', displayable: false, type: :date, scope: '', value_column: :value, filterable: true) } + let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVA", table: "self", column: "sva_svr_decision_on", displayable: true, type: :date, scope: '', value_column: :value, filterable: true) } + let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVA avant", table: "self", column: "sva_svr_decision_before", displayable: false, type: :date, scope: '', value_column: :value, filterable: true) } it { is_expected.to include(decision_on, decision_before_field) } end @@ -110,8 +110,8 @@ describe ColumnsConcern do let(:procedure_id) { procedure.id } let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) } - let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVR", table: "self", column: "sva_svr_decision_on", classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true) } - let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVR avant", table: "self", column: "sva_svr_decision_before", classname: '', displayable: false, type: :date, scope: '', value_column: :value, filterable: true) } + let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVR", table: "self", column: "sva_svr_decision_on", displayable: true, type: :date, scope: '', value_column: :value, filterable: true) } + let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVR avant", table: "self", column: "sva_svr_decision_before", displayable: false, type: :date, scope: '', value_column: :value, filterable: true) } it { is_expected.to include(decision_on, decision_before_field) } end From e41326dad53b9683c51f985003ae8fdf08f89c90 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 9 Oct 2024 09:52:37 +0200 Subject: [PATCH 1260/1532] tech(clean): remove sva_columns(for_filters) Co-authored-by: mfo --- app/models/concerns/columns_concern.rb | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index e71cff289..0d1b9a984 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -52,23 +52,21 @@ module ColumnsConcern states = [dossier_state_column] - [common, dates, sva_svr_columns(for_filters: true), non_displayable_dates, states].flatten.compact + [common, dates, sva_svr_columns, non_displayable_dates, states].flatten.compact end - def sva_svr_columns(for_filters: false) + def sva_svr_columns return if !sva_svr_enabled? scope = [:activerecord, :attributes, :procedure_presentation, :fields, :self] columns = [ Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_on', type: :date, - label: I18n.t("#{sva_svr_decision}_decision_on", scope:), classname: for_filters ? '' : 'sva-col') + label: I18n.t("#{sva_svr_decision}_decision_on", scope:)) ] - if for_filters - columns << Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false, - label: I18n.t("#{sva_svr_decision}_decision_before", scope:)) - end + columns << Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false, + label: I18n.t("#{sva_svr_decision}_decision_before", scope:)) columns end From 112d49cb510c56c657104746c51a925cb2048304 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 7 Oct 2024 10:50:50 +0200 Subject: [PATCH 1261/1532] tech(deadcode): remove unused constant on ProcedurePresentation --- app/models/procedure_presentation.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index fdf03b013..57f61d796 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -1,11 +1,6 @@ # frozen_string_literal: true class ProcedurePresentation < ApplicationRecord - TABLE = 'table' - COLUMN = 'column' - ORDER = 'order' - - SLASH = '/' TYPE_DE_CHAMP = 'type_de_champ' belongs_to :assign_to, optional: false From 16e93a217b07155455e68982b3ac1a86bdcd2c80 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 11 Oct 2024 15:29:52 +0200 Subject: [PATCH 1262/1532] reset procedure_presentation if a pb occurs with a column deserialization --- app/models/assign_to.rb | 21 ++++++++------ app/models/column.rb | 5 ++++ app/models/procedure_presentation.rb | 4 +-- app/models/sorted_column.rb | 5 ++++ spec/models/assign_to_spec.rb | 43 +++++++++++++++++----------- 5 files changed, 51 insertions(+), 27 deletions(-) diff --git a/app/models/assign_to.rb b/app/models/assign_to.rb index bfd7990e0..d9f6071c5 100644 --- a/app/models/assign_to.rb +++ b/app/models/assign_to.rb @@ -10,28 +10,31 @@ class AssignTo < ApplicationRecord def procedure_presentation_or_default_and_errors errors = reset_procedure_presentation_if_invalid + if self.procedure_presentation.nil? - self.procedure_presentation = build_procedure_presentation - self.procedure_presentation.save if procedure_presentation.valid? && !procedure_presentation.persisted? + self.procedure_presentation = create_procedure_presentation! end + [self.procedure_presentation, errors] end private def reset_procedure_presentation_if_invalid - if procedure_presentation&.invalid? - # This is a last defense against invalid `ProcedurePresentation`s persistently - # hindering instructeurs. Whenever this gets triggered, it means that there is - # a bug somewhere else that we need to fix. + errors = begin + procedure_presentation.errors if procedure_presentation&.invalid? + rescue ActiveRecord::RecordNotFound => e + [e.message] + end - errors = procedure_presentation.errors + if errors.present? Sentry.capture_message( "Destroying invalid ProcedurePresentation", - extra: { procedure_presentation: procedure_presentation.as_json } + extra: { procedure_presentation_id: procedure_presentation.id, errors: } ) self.procedure_presentation = nil - errors end + + errors end end diff --git a/app/models/column.rb b/app/models/column.rb index 1a6871c65..2e95a9c3e 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true class Column + # include validations to enable procedure_presentation.validate_associate, + # which enforces the deserialization of columns in the displayed_columns attribute + # and raises an error if a column is not found + include ActiveModel::Validations + TYPE_DE_CHAMP_TABLE = 'type_de_champ' attr_reader :table, :column, :label, :type, :scope, :value_column, :filterable, :displayable diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 57f61d796..37247174a 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -24,8 +24,8 @@ class ProcedurePresentation < ApplicationRecord before_create { self.displayed_columns = procedure.default_displayed_columns } - validates_associated :a_suivre_filters, :suivis_filters, :traites_filters, - :tous_filters, :supprimes_filters, :expirant_filters, :archives_filters + validates_associated :displayed_columns, :sorted_column, :a_suivre_filters, :suivis_filters, + :traites_filters, :tous_filters, :supprimes_filters, :expirant_filters, :archives_filters def filters_for(statut) send(filters_name_for(statut)) diff --git a/app/models/sorted_column.rb b/app/models/sorted_column.rb index d254405d7..d92036885 100644 --- a/app/models/sorted_column.rb +++ b/app/models/sorted_column.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true class SortedColumn + # include validations to enable procedure_presentation.validate_associate, + # which enforces the deserialization of columns in the sorted_column attribute + # and raises an error if a column is not found + include ActiveModel::Validations + attr_reader :column, :order def initialize(column:, order:) diff --git a/spec/models/assign_to_spec.rb b/spec/models/assign_to_spec.rb index 8147d5bd0..59aaf55dd 100644 --- a/spec/models/assign_to_spec.rb +++ b/spec/models/assign_to_spec.rb @@ -9,32 +9,43 @@ describe AssignTo, type: :model do let(:procedure_presentation_or_default) { procedure_presentation_and_errors.first } let(:errors) { procedure_presentation_and_errors.second } - context "without a procedure_presentation" do - it { expect(procedure_presentation_or_default).to be_persisted } - it { expect(procedure_presentation_or_default).to be_valid } - it { expect(errors).to be_nil } + context "without a preexisting procedure_presentation" do + it 'creates a default pp' do + expect(procedure_presentation_or_default).to be_persisted + expect(procedure_presentation_or_default).to be_valid + expect(errors).to be_nil + end end - context "with a procedure_presentation" do - let!(:procedure_presentation) { ProcedurePresentation.create(assign_to: assign_to) } + context "with a preexisting procedure_presentation" do + let!(:procedure_presentation) { ProcedurePresentation.create(assign_to:) } - it { expect(procedure_presentation_or_default).to eq(procedure_presentation) } - it { expect(procedure_presentation_or_default).to be_valid } - it { expect(errors).to be_nil } + it 'returns the preexisting pp' do + expect(procedure_presentation_or_default).to eq(procedure_presentation) + expect(procedure_presentation_or_default).to be_valid + expect(errors).to be_nil + end end context "with an invalid procedure_presentation" do let!(:procedure_presentation) do - pp = ProcedurePresentation.new(assign_to: assign_to, displayed_fields: [{ 'table' => 'invalid', 'column' => 'random' }]) - pp.save(validate: false) - pp + pp = ProcedurePresentation.create(assign_to: assign_to) + + sql = <<-SQL.squish + UPDATE procedure_presentations + SET displayed_columns = ARRAY['{\"procedure_id\":666}'::jsonb] + WHERE id = #{pp.id} ; + SQL + + pp.class.connection.execute(sql) + + assign_to.reload end - it { expect(procedure_presentation_or_default).to be_persisted } - it { expect(procedure_presentation_or_default).to be_valid } - it { expect(errors).to be_present } it do - procedure_presentation_or_default + expect(procedure_presentation_or_default).to be_persisted + expect(procedure_presentation_or_default).to be_valid + expect(errors).to be_present expect(assign_to.procedure_presentation).not_to be(procedure_presentation) end end From 4d7715fbb663229b3fdc6278f7ac678a286a7697 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 14 Oct 2024 21:46:52 +0200 Subject: [PATCH 1263/1532] extract filter and sort function to a filter_service --- .../concerns/dossier_filtering_concern.rb | 4 +- app/models/procedure_presentation.rb | 150 ---- app/services/dossier_filter_service.rb | 157 ++++ config/brakeman.ignore | 54 +- spec/models/procedure_presentation_spec.rb | 697 ----------------- spec/services/dossier_filter_service_spec.rb | 709 ++++++++++++++++++ 6 files changed, 895 insertions(+), 876 deletions(-) create mode 100644 app/services/dossier_filter_service.rb create mode 100644 spec/services/dossier_filter_service_spec.rb diff --git a/app/models/concerns/dossier_filtering_concern.rb b/app/models/concerns/dossier_filtering_concern.rb index e102c1239..a1470bbda 100644 --- a/app/models/concerns/dossier_filtering_concern.rb +++ b/app/models/concerns/dossier_filtering_concern.rb @@ -29,13 +29,13 @@ module DossierFilteringConcern } scope :filter_ilike, lambda { |table, column, values| - table_column = ProcedurePresentation.sanitized_column(table, column) + table_column = DossierFilterService.sanitized_column(table, column) q = Array.new(values.count, "(#{table_column} ILIKE ?)").join(' OR ') where(q, *(values.map { |value| "%#{value}%" })) } scope :filter_enum, lambda { |table, column, values| - table_column = ProcedurePresentation.sanitized_column(table, column) + table_column = DossierFilterService.sanitized_column(table, column) q = Array.new(values.count, "(#{table_column} = ?)").join(' OR ') where(q, *(values)) } diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 37247174a..42345edd9 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -42,17 +42,6 @@ class ProcedurePresentation < ApplicationRecord ] end - def filtered_sorted_ids(dossiers, statut, count: nil) - dossiers_by_statut = dossiers.by_statut(statut, instructeur) - dossiers_sorted_ids = self.sorted_ids(dossiers_by_statut, count || dossiers_by_statut.size) - - if filters_for(statut).present? - dossiers_sorted_ids.intersection(filtered_ids(dossiers_by_statut, statut)) - else - dossiers_sorted_ids - end - end - def human_value_for_filter(filtered_column) if filtered_column.column.table == TYPE_DE_CHAMP find_type_de_champ(filtered_column.column.column).dynamic_type.filter_to_human(filtered_column.filter) @@ -90,120 +79,6 @@ class ProcedurePresentation < ApplicationRecord private - def sorted_ids(dossiers, count) - table = sorted_column.column.table - column = sorted_column.column.column - order = sorted_column.order - - case table - when 'notifications' - dossiers_id_with_notification = dossiers.merge(instructeur.followed_dossiers).with_notifications.ids - if order == 'desc' - dossiers_id_with_notification + - (dossiers.order('dossiers.updated_at desc').ids - dossiers_id_with_notification) - else - (dossiers.order('dossiers.updated_at asc').ids - dossiers_id_with_notification) + - dossiers_id_with_notification - end - when TYPE_DE_CHAMP - ids = dossiers - .with_type_de_champ(column) - .order("champs.value #{order}") - .pluck(:id) - if ids.size != count - rest = dossiers.where.not(id: ids).order(id: order).pluck(:id) - order == 'asc' ? ids + rest : rest + ids - else - ids - end - when 'followers_instructeurs' - assert_supported_column(table, column) - # LEFT OUTER JOIN allows to keep dossiers without assigned instructeurs yet - dossiers - .includes(:followers_instructeurs) - .joins('LEFT OUTER JOIN users instructeurs_users ON instructeurs_users.id = instructeurs.user_id') - .order("instructeurs_users.email #{order}") - .pluck(:id) - .uniq - when 'avis' - dossiers.includes(table) - .order("#{self.class.sanitized_column(table, column)} #{order}") - .pluck(:id) - .uniq - when 'self', 'user', 'individual', 'etablissement', 'groupe_instructeur' - (table == 'self' ? dossiers : dossiers.includes(table)) - .order("#{self.class.sanitized_column(table, column)} #{order}") - .pluck(:id) - end - end - - def filtered_ids(dossiers, statut) - filters_for(statut) - .group_by { |filter| filter.column.then { [_1.table, _1.column] } } - .map do |(table, column), filters_for_column| - values = filters_for_column.map(&:filter) - filtered_column = filters_for_column.first.column - value_column = filtered_column.value_column - - if filtered_column.is_a?(Columns::JSONPathColumn) - filtered_column.filtered_ids(dossiers, values) - else - case table - when 'self' - if filtered_column.type == :date - dates = values - .filter_map { |v| Time.zone.parse(v).beginning_of_day rescue nil } - - dossiers.filter_by_datetimes(column, dates) - elsif filtered_column.column == "state" && values.include?("pending_correction") - dossiers.joins(:corrections).where(corrections: DossierCorrection.pending) - elsif filtered_column.column == "state" && values.include?("en_construction") - dossiers.where("dossiers.#{column} IN (?)", values).includes(:corrections).where.not(corrections: DossierCorrection.pending) - else - dossiers.where("dossiers.#{column} IN (?)", values) - end - when TYPE_DE_CHAMP - if filtered_column.type == :enum - dossiers.with_type_de_champ(column) - .filter_enum(:champs, value_column, values) - else - dossiers.with_type_de_champ(column) - .filter_ilike(:champs, value_column, values) - end - when 'etablissement' - if column == 'entreprise_date_creation' - dates = values - .filter_map { |v| v.to_date rescue nil } - - dossiers - .includes(table) - .where(table.pluralize => { column => dates }) - else - dossiers - .includes(table) - .filter_ilike(table, column, values) - end - when 'followers_instructeurs' - assert_supported_column(table, column) - dossiers - .includes(:followers_instructeurs) - .joins('INNER JOIN users instructeurs_users ON instructeurs_users.id = instructeurs.user_id') - .filter_ilike('instructeurs_users', :email, values) # ilike OK, user may want to search by *@domain - when 'user', 'individual' # user_columns: [email], individual_columns: ['nom', 'prenom', 'gender'] - dossiers - .includes(table) - .filter_ilike(table, column, values) # ilike or where column == 'value' are both valid, we opted for ilike - when 'groupe_instructeur' - assert_supported_column(table, column) - - dossiers - .joins(:groupe_instructeur) - .where(groupe_instructeur_id: values) - end.pluck(:id) - end - end.reduce(:&) - end - def find_type_de_champ(column) TypeDeChamp .joins(:revision_types_de_champ) @@ -211,29 +86,4 @@ class ProcedurePresentation < ApplicationRecord .order(created_at: :desc) .find_by(stable_id: column) end - - def self.sanitized_column(association, column) - table = if association == 'self' - Dossier.table_name - elsif (association_reflection = Dossier.reflect_on_association(association)) - association_reflection.klass.table_name - else - # Allow filtering on a joined table alias (which doesn’t exist - # in the ActiveRecord domain). - association - end - - [table, column] - .map { |name| ActiveRecord::Base.connection.quote_column_name(name) } - .join('.') - end - - def assert_supported_column(table, column) - if table == 'followers_instructeurs' && column != 'email' - raise ArgumentError, 'Table `followers_instructeurs` only supports the `email` column.' - end - if table == 'groupe_instructeur' && (column != 'label' && column != 'id') - raise ArgumentError, 'Table `groupe_instructeur` only supports the `label` or `id` column.' - end - end end diff --git a/app/services/dossier_filter_service.rb b/app/services/dossier_filter_service.rb new file mode 100644 index 000000000..586213451 --- /dev/null +++ b/app/services/dossier_filter_service.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +class DossierFilterService + TYPE_DE_CHAMP = 'type_de_champ' + + def self.filtered_sorted_ids(dossiers, statut, filters, sorted_column, instructeur, count: nil) + dossiers_by_statut = dossiers.by_statut(statut, instructeur) + dossiers_sorted_ids = self.sorted_ids(dossiers_by_statut, sorted_column, instructeur, count || dossiers_by_statut.size) + + if filters.present? + dossiers_sorted_ids.intersection(filtered_ids(dossiers_by_statut, filters)) + else + dossiers_sorted_ids + end + end + + private + + def self.sorted_ids(dossiers, sorted_column, instructeur, count) + table = sorted_column.column.table + column = sorted_column.column.column + order = sorted_column.order + + case table + when 'notifications' + dossiers_id_with_notification = dossiers.merge(instructeur.followed_dossiers).with_notifications.ids + if order == 'desc' + dossiers_id_with_notification + + (dossiers.order('dossiers.updated_at desc').ids - dossiers_id_with_notification) + else + (dossiers.order('dossiers.updated_at asc').ids - dossiers_id_with_notification) + + dossiers_id_with_notification + end + when TYPE_DE_CHAMP + ids = dossiers + .with_type_de_champ(column) + .order("champs.value #{order}") + .pluck(:id) + if ids.size != count + rest = dossiers.where.not(id: ids).order(id: order).pluck(:id) + order == 'asc' ? ids + rest : rest + ids + else + ids + end + when 'followers_instructeurs' + assert_supported_column(table, column) + # LEFT OUTER JOIN allows to keep dossiers without assigned instructeurs yet + dossiers + .includes(:followers_instructeurs) + .joins('LEFT OUTER JOIN users instructeurs_users ON instructeurs_users.id = instructeurs.user_id') + .order("instructeurs_users.email #{order}") + .pluck(:id) + .uniq + when 'avis' + dossiers.includes(table) + .order("#{sanitized_column(table, column)} #{order}") + .pluck(:id) + .uniq + when 'self', 'user', 'individual', 'etablissement', 'groupe_instructeur' + (table == 'self' ? dossiers : dossiers.includes(table)) + .order("#{sanitized_column(table, column)} #{order}") + .pluck(:id) + end + end + + def self.filtered_ids(dossiers, filters) + filters + .group_by { |filter| filter.column.then { [_1.table, _1.column] } } + .map do |(table, column), filters_for_column| + values = filters_for_column.map(&:filter) + filtered_column = filters_for_column.first.column + value_column = filtered_column.value_column + + if filtered_column.is_a?(Columns::JSONPathColumn) + filtered_column.filtered_ids(dossiers, values) + else + case table + when 'self' + if filtered_column.type == :date + dates = values + .filter_map { |v| Time.zone.parse(v).beginning_of_day rescue nil } + + dossiers.filter_by_datetimes(column, dates) + elsif filtered_column.column == "state" && values.include?("pending_correction") + dossiers.joins(:corrections).where(corrections: DossierCorrection.pending) + elsif filtered_column.column == "state" && values.include?("en_construction") + dossiers.where("dossiers.#{column} IN (?)", values).includes(:corrections).where.not(corrections: DossierCorrection.pending) + else + dossiers.where("dossiers.#{column} IN (?)", values) + end + when TYPE_DE_CHAMP + if filtered_column.type == :enum + dossiers.with_type_de_champ(column) + .filter_enum(:champs, value_column, values) + else + dossiers.with_type_de_champ(column) + .filter_ilike(:champs, value_column, values) + end + when 'etablissement' + if column == 'entreprise_date_creation' + dates = values + .filter_map { |v| v.to_date rescue nil } + + dossiers + .includes(table) + .where(table.pluralize => { column => dates }) + else + dossiers + .includes(table) + .filter_ilike(table, column, values) + end + when 'followers_instructeurs' + assert_supported_column(table, column) + dossiers + .includes(:followers_instructeurs) + .joins('INNER JOIN users instructeurs_users ON instructeurs_users.id = instructeurs.user_id') + .filter_ilike('instructeurs_users', :email, values) # ilike OK, user may want to search by *@domain + when 'user', 'individual' # user_columns: [email], individual_columns: ['nom', 'prenom', 'gender'] + dossiers + .includes(table) + .filter_ilike(table, column, values) # ilike or where column == 'value' are both valid, we opted for ilike + when 'groupe_instructeur' + assert_supported_column(table, column) + + dossiers + .joins(:groupe_instructeur) + .where(groupe_instructeur_id: values) + end.pluck(:id) + end + end.reduce(:&) + end + + def self.sanitized_column(association, column) + table = if association == 'self' + Dossier.table_name + elsif (association_reflection = Dossier.reflect_on_association(association)) + association_reflection.klass.table_name + else + # Allow filtering on a joined table alias (which doesn’t exist + # in the ActiveRecord domain). + association + end + + [table, column] + .map { |name| ActiveRecord::Base.connection.quote_column_name(name) } + .join('.') + end + + def self.assert_supported_column(table, column) + if table == 'followers_instructeurs' && column != 'email' + raise ArgumentError, 'Table `followers_instructeurs` only supports the `email` column.' + end + if table == 'groupe_instructeur' && (column != 'label' && column != 'id') + raise ArgumentError, 'Table `groupe_instructeur` only supports the `label` or `id` column.' + end + end +end diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 4cb15c70d..69ad55629 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -44,29 +44,6 @@ ], "note": "" }, - { - "warning_type": "SQL Injection", - "warning_code": 0, - "fingerprint": "31693060072e27c02ca4f884f2a07f4f1c1247b7a6f5cc5c724e88e6ca9b4873", - "check_name": "SQL", - "message": "Possible SQL injection", - "file": "app/models/concerns/dossier_filtering_concern.rb", - "line": 40, - "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", - "code": "where(\"#{values.count} OR #{\"(#{ProcedurePresentation.sanitized_column(table, column)} = ?)\"}\", *values)", - "render_path": null, - "location": { - "type": "method", - "class": "DossierFilteringConcern", - "method": null - }, - "user_input": "values.count", - "confidence": "Medium", - "cwe_id": [ - 89 - ], - "note": "filtered by rails query params where(something: ?, values)" - }, { "warning_type": "SQL Injection", "warning_code": 0, @@ -136,6 +113,29 @@ ], "note": "" }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "91ff8031e7c639c95fe6c244867349a72078ef456d8b3507deaf2bdb9bf62fe2", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/models/concerns/dossier_filtering_concern.rb", + "line": 34, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "where(\"#{values.count} OR #{\"(#{DossierFilterService.sanitized_column(table, column)} ILIKE ?)\"}\", *values.map do\n \"%#{value}%\"\n end)", + "render_path": null, + "location": { + "type": "method", + "class": "DossierFilteringConcern", + "method": null + }, + "user_input": "values.count", + "confidence": "Medium", + "cwe_id": [ + 89 + ], + "note": "filtered by rails query params where(something: ?, values)" + }, { "warning_type": "Cross-Site Scripting", "warning_code": 2, @@ -196,13 +196,13 @@ { "warning_type": "SQL Injection", "warning_code": 0, - "fingerprint": "bd1df30f95135357b646e21a03d95498874faffa32e3804fc643e9b6b957ee14", + "fingerprint": "aaff41afa7bd5a551cd2e3a385071090cb53c95caa40fad3785cd3d68c9b939c", "check_name": "SQL", "message": "Possible SQL injection", "file": "app/models/concerns/dossier_filtering_concern.rb", - "line": 34, + "line": 40, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", - "code": "where(\"#{values.count} OR #{\"(#{ProcedurePresentation.sanitized_column(table, column)} ILIKE ?)\"}\", *values.map do\n \"%#{value}%\"\n end)", + "code": "where(\"#{values.count} OR #{\"(#{DossierFilterService.sanitized_column(table, column)} = ?)\"}\", *values)", "render_path": null, "location": { "type": "method", @@ -272,6 +272,6 @@ "note": "Current is not a model" } ], - "updated": "2024-09-24 20:56:24 +0200", + "updated": "2024-10-15 15:57:27 +0200", "brakeman_version": "6.1.2" } diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index ecf427de9..ac30ea06e 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -55,655 +55,6 @@ describe ProcedurePresentation do end end - describe '#sorted_ids' do - let(:instructeur) { create(:instructeur) } - let(:assign_to) { create(:assign_to, procedure:, instructeur:) } - let(:sorted_column) { SortedColumn.new(column:, order:) } - let(:procedure_presentation) { create(:procedure_presentation, assign_to:, sorted_column:) } - - subject { procedure_presentation.send(:sorted_ids, procedure.dossiers, procedure.dossiers.count) } - - context 'for notifications table' do - let(:column) { procedure.notifications_column } - - let!(:notified_dossier) { create(:dossier, :en_construction, procedure:) } - let!(:recent_dossier) { create(:dossier, :en_construction, procedure:) } - let!(:older_dossier) { create(:dossier, :en_construction, procedure:) } - - before do - notified_dossier.update!(last_champ_updated_at: Time.zone.local(2018, 9, 20)) - create(:follow, instructeur: instructeur, dossier: notified_dossier, demande_seen_at: Time.zone.local(2018, 9, 10)) - notified_dossier.touch(time: Time.zone.local(2018, 9, 20)) - recent_dossier.touch(time: Time.zone.local(2018, 9, 25)) - older_dossier.touch(time: Time.zone.local(2018, 5, 13)) - end - - context 'in ascending order' do - let(:order) { 'asc' } - - it { is_expected.to eq([older_dossier, recent_dossier, notified_dossier].map(&:id)) } - end - - context 'in descending order' do - let(:order) { 'desc' } - - it { is_expected.to eq([notified_dossier, recent_dossier, older_dossier].map(&:id)) } - end - - context 'with a dossier terminé' do - let!(:notified_dossier) { create(:dossier, :accepte, procedure:) } - let(:order) { 'desc' } - - it { is_expected.to eq([notified_dossier, recent_dossier, older_dossier].map(&:id)) } - end - end - - context 'for self table' do - let(:order) { 'asc' } # Desc works the same, no extra test required - - context 'for created_at column' do - let!(:column) { procedure.find_column(label: 'Créé le') } - let!(:recent_dossier) { Timecop.freeze(Time.zone.local(2018, 10, 17)) { create(:dossier, procedure: procedure) } } - let!(:older_dossier) { Timecop.freeze(Time.zone.local(2003, 11, 11)) { create(:dossier, procedure: procedure) } } - - it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) } - end - - context 'for en_construction_at column' do - let!(:column) { procedure.find_column(label: 'En construction le') } - let!(:recent_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 17)) } - let!(:older_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2013, 1, 1)) } - - it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) } - end - - context 'for updated_at column' do - let(:column) { procedure.find_column(label: 'Mis à jour le') } - let(:recent_dossier) { create(:dossier, procedure: procedure) } - let(:older_dossier) { create(:dossier, procedure: procedure) } - - before do - recent_dossier.touch(time: Time.zone.local(2018, 9, 25)) - older_dossier.touch(time: Time.zone.local(2018, 5, 13)) - end - - it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) } - end - end - - context 'for type_de_champ table' do - context 'with no revisions' do - let(:column) { procedure.find_column(label: first_type_de_champ.libelle) } - - let(:beurre_dossier) { create(:dossier, procedure:) } - let(:tartine_dossier) { create(:dossier, procedure:) } - - before do - beurre_dossier.project_champs_public.first.update(value: 'beurre') - tartine_dossier.project_champs_public.first.update(value: 'tartine') - end - - context 'asc' do - let(:order) { 'asc' } - - it { is_expected.to eq([beurre_dossier, tartine_dossier].map(&:id)) } - end - - context 'desc' do - let(:order) { 'desc' } - - it { is_expected.to eq([tartine_dossier, beurre_dossier].map(&:id)) } - end - end - - context 'with a revision adding a new type_de_champ' do - let!(:tdc) { { type_champ: :text, libelle: 'nouveau champ' } } - let(:column) { procedure.find_column(label: 'nouveau champ') } - - let!(:nothing_dossier) { create(:dossier, procedure:) } - let!(:beurre_dossier) { create(:dossier, procedure:) } - let!(:tartine_dossier) { create(:dossier, procedure:) } - - before do - nothing_dossier - procedure.draft_revision.add_type_de_champ(tdc) - procedure.publish_revision! - beurre_dossier.project_champs_public.last.update(value: 'beurre') - tartine_dossier.project_champs_public.last.update(value: 'tartine') - end - - context 'asc' do - let(:order) { 'asc' } - it { is_expected.to eq([nothing_dossier, beurre_dossier, tartine_dossier].map(&:id)) } - end - - context 'desc' do - let(:order) { 'desc' } - it { is_expected.to eq([tartine_dossier, beurre_dossier, nothing_dossier].map(&:id)) } - end - end - end - - context 'for type_de_champ_private table' do - context 'with no revisions' do - let(:column) { procedure.find_column(label: procedure.active_revision.types_de_champ_private.first.libelle) } - - let(:biere_dossier) { create(:dossier, procedure: procedure) } - let(:vin_dossier) { create(:dossier, procedure: procedure) } - - before do - biere_dossier.project_champs_private.first.update(value: 'biere') - vin_dossier.project_champs_private.first.update(value: 'vin') - end - - context 'asc' do - let(:order) { 'asc' } - - it { is_expected.to eq([biere_dossier, vin_dossier].map(&:id)) } - end - - context 'desc' do - let(:order) { 'desc' } - - it { is_expected.to eq([vin_dossier, biere_dossier].map(&:id)) } - end - end - end - - context 'for individual table' do - let(:order) { 'asc' } # Desc works the same, no extra test required - - let(:procedure) { create(:procedure, :for_individual) } - - let!(:first_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'M', prenom: 'Alain', nom: 'Antonelli')) } - let!(:last_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'Mme', prenom: 'Zora', nom: 'Zemmour')) } - - context 'for gender column' do - let(:column) { procedure.find_column(label: 'Civilité') } - - it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) } - end - - context 'for prenom column' do - let(:column) { procedure.find_column(label: 'Prénom') } - - it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) } - end - - context 'for nom column' do - let(:column) { procedure.find_column(label: 'Nom') } - - it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) } - end - end - - context 'for followers_instructeurs table' do - let(:order) { 'asc' } # Desc works the same, no extra test required - - let!(:dossier_z) { create(:dossier, :en_construction, procedure: procedure) } - let!(:dossier_a) { create(:dossier, :en_construction, procedure: procedure) } - let!(:dossier_without_instructeur) { create(:dossier, :en_construction, procedure: procedure) } - - before do - create(:follow, dossier: dossier_z, instructeur: create(:instructeur, email: 'zythum@exemple.fr')) - create(:follow, dossier: dossier_a, instructeur: create(:instructeur, email: 'abaca@exemple.fr')) - create(:follow, dossier: dossier_a, instructeur: create(:instructeur, email: 'abaca2@exemple.fr')) - end - - context 'for email column' do - let(:column) { procedure.find_column(label: 'Email instructeur') } - - it { is_expected.to eq([dossier_a, dossier_z, dossier_without_instructeur].map(&:id)) } - end - end - - context 'for avis table' do - let(:column) { procedure.find_column(label: 'Avis oui/non') } - let(:order) { 'asc' } - - let!(:dossier_yes) { create(:dossier, procedure:) } - let!(:dossier_no) { create(:dossier, procedure:) } - - before do - create_list(:avis, 2, dossier: dossier_yes, question_answer: true) - create(:avis, dossier: dossier_no, question_answer: true) - create(:avis, dossier: dossier_no, question_answer: false) - end - - it { is_expected.to eq([dossier_no, dossier_yes].map(&:id)) } - end - - context 'for other tables' do - # All other columns and tables work the same so it’s ok to test only one - let(:column) { procedure.find_column(label: 'Code postal') } - let(:order) { 'asc' } # Desc works the same, no extra test required - - let!(:huitieme_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '75008')) } - let!(:vingtieme_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '75020')) } - - it { is_expected.to eq([huitieme_dossier, vingtieme_dossier].map(&:id)) } - end - end - - describe '#filtered_ids' do - let(:procedure_presentation) { create(:procedure_presentation, assign_to:, suivis_filters: filtered_columns) } - let(:filtered_columns) { filters.map { to_filter(_1) } } - let(:filters) { [filter] } - - subject { procedure_presentation.send(:filtered_ids, procedure.dossiers.joins(:user), 'suivis') } - - context 'for self table' do - context 'for created_at column' do - let(:filter) { ['Créé le', '18/9/2018'] } - - let!(:kept_dossier) { create(:dossier, procedure: procedure, created_at: Time.zone.local(2018, 9, 18, 14, 28)) } - let!(:discarded_dossier) { create(:dossier, procedure: procedure, created_at: Time.zone.local(2018, 9, 17, 23, 59)) } - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'for en_construction_at column' do - let(:filter) { ['En construction le', '17/10/2018'] } - - let!(:kept_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 17)) } - let!(:discarded_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2013, 1, 1)) } - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'for updated_at column' do - let(:filter) { ['Mis à jour le', '18/9/2018'] } - - let(:kept_dossier) { create(:dossier, procedure: procedure) } - let(:discarded_dossier) { create(:dossier, procedure: procedure) } - - before do - kept_dossier.touch(time: Time.zone.local(2018, 9, 18, 14, 28)) - discarded_dossier.touch(time: Time.zone.local(2018, 9, 17, 23, 59)) - end - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'for updated_since column' do - let(:filter) { ['Mis à jour depuis', '18/9/2018'] } - - let(:kept_dossier) { create(:dossier, procedure: procedure) } - let(:later_dossier) { create(:dossier, procedure: procedure) } - let(:discarded_dossier) { create(:dossier, procedure: procedure) } - - before do - kept_dossier.touch(time: Time.zone.local(2018, 9, 18, 14, 28)) - later_dossier.touch(time: Time.zone.local(2018, 9, 19, 14, 28)) - discarded_dossier.touch(time: Time.zone.local(2018, 9, 17, 14, 28)) - end - - it { is_expected.to match_array([kept_dossier.id, later_dossier.id]) } - end - - context 'for sva_svr_decision_before column' do - before do - travel_to Time.zone.local(2023, 6, 10, 10) - end - - let(:procedure) { create(:procedure, :published, :sva, types_de_champ_public: [{}], types_de_champ_private: [{}]) } - let(:filter) { ['Date décision SVA avant', '15/06/2023'] } - - let!(:kept_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current) } - let!(:later_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current + 2.days) } - let!(:discarded_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current + 10.days) } - let!(:en_construction_dossier) { create(:dossier, :en_construction, procedure:, sva_svr_decision_on: Date.current + 2.days) } - let!(:accepte_dossier) { create(:dossier, :accepte, procedure:, sva_svr_decision_on: Date.current + 2.days) } - - it { is_expected.to match_array([kept_dossier.id, later_dossier.id, en_construction_dossier.id]) } - end - - context 'ignore time of day' do - let(:filter) { ['En construction le', '17/10/2018 19:30'] } - - let!(:kept_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 17, 15, 56)) } - let!(:discarded_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 18, 5, 42)) } - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'for a malformed date' do - context 'when its a string' do - let(:filter) { ['Mis à jour le', 'malformed date'] } - - it { is_expected.to match([]) } - end - - context 'when its a number' do - let(:filter) { ['Mis à jour le', '177500'] } - - it { is_expected.to match([]) } - end - end - - context 'with multiple search values' do - let(:filters) { [['En construction le', '17/10/2018'], ['En construction le', '19/10/2018']] } - - let!(:kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17)) } - let!(:other_kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 19)) } - let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2013, 1, 1)) } - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - - context 'with multiple state filters' do - let(:filters) { [['Statut', 'en_construction'], ['Statut', 'en_instruction']] } - - let!(:kept_dossier) { create(:dossier, :en_construction, procedure:) } - let!(:other_kept_dossier) { create(:dossier, :en_instruction, procedure:) } - let!(:discarded_dossier) { create(:dossier, :accepte, procedure:) } - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - - context 'with en_construction state filters' do - let(:filter) { ['Statut', 'en_construction'] } - - let!(:en_construction) { create(:dossier, :en_construction, procedure:) } - let!(:en_construction_with_correction) { create(:dossier, :en_construction, procedure:) } - let!(:correction) { create(:dossier_correction, dossier: en_construction_with_correction) } - it 'excludes dossier en construction with pending correction' do - is_expected.to contain_exactly(en_construction.id) - end - end - end - - context 'for type_de_champ table' do - let(:filter) { [type_de_champ.libelle, 'keep'] } - - let(:kept_dossier) { create(:dossier, procedure: procedure) } - let(:discarded_dossier) { create(:dossier, procedure: procedure) } - let(:type_de_champ) { procedure.active_revision.types_de_champ_public.first } - - context 'with single value' do - before do - kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'keep me') - discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'discard me') - end - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'with multiple search values' do - let(:filters) { [[type_de_champ.libelle, 'keep'], [type_de_champ.libelle, 'and']] } - let(:other_kept_dossier) { create(:dossier, procedure: procedure) } - - before do - kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'keep me') - discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'discard me') - other_kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'and me too') - end - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - - context 'with yes_no type_de_champ' do - let(:filter) { [type_de_champ.libelle, 'true'] } - let(:types_de_champ_public) { [{ type: :yes_no }] } - - before do - kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'true') - discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'false') - end - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'with departement type_de_champ' do - let(:filter) { [type_de_champ.libelle, '13'] } - let(:types_de_champ_public) { [{ type: :departements }] } - - before do - kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: '13') - discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: '69') - end - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'with enum type_de_champ' do - let(:filter) { [type_de_champ.libelle, 'Favorable'] } - let(:types_de_champ_public) { [{ type: :drop_down_list, options: ['Favorable', 'Defavorable'] }] } - - before do - kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'Favorable') - discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: 'Defavorable') - end - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - end - - context 'for type_de_champ_private table' do - let(:filter) { [type_de_champ_private.libelle, 'keep'] } - - let(:kept_dossier) { create(:dossier, procedure: procedure) } - let(:discarded_dossier) { create(:dossier, procedure: procedure) } - let(:type_de_champ_private) { procedure.active_revision.types_de_champ_private.first } - - before do - kept_dossier.champs.find_by(stable_id: type_de_champ_private.stable_id).update(value: 'keep me') - discarded_dossier.champs.find_by(stable_id: type_de_champ_private.stable_id).update(value: 'discard me') - end - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'for type_de_champ using AddressableColumnConcern' do - let(:column) { filtered_columns.first.column } - let(:types_de_champ_public) { [{ type: :rna, stable_id: 1, libelle: 'rna' }] } - let(:type_de_champ) { procedure.active_revision.types_de_champ.first } - let(:kept_dossier) { create(:dossier, procedure: procedure) } - - context "when searching by postal_code (text)" do - let(:value) { "60580" } - let(:filter) { ["rna – code postal (5 chiffres)", value] } - - before do - kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "postal_code" => value }) - create(:dossier, procedure: procedure).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "postal_code" => "unknown" }) - end - - it { is_expected.to contain_exactly(kept_dossier.id) } - - it 'describes column' do - expect(column.type).to eq(:text) - expect(column.options_for_select).to eq([]) - end - end - - context "when searching by departement_code (enum)" do - let(:value) { "99" } - let(:filter) { ["rna – département", value] } - - before do - kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "departement_code" => value }) - create(:dossier, procedure: procedure).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "departement_code" => "unknown" }) - end - - it { is_expected.to contain_exactly(kept_dossier.id) } - - it 'describes column' do - expect(column.type).to eq(:enum) - expect(column.options_for_select.first).to eq(["99 – Etranger", "99"]) - end - end - - context "when searching by region_name" do - let(:value) { "60" } - let(:filter) { ["rna – region", value] } - - before do - kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "region_name" => value }) - create(:dossier, procedure: procedure).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "region_name" => "unknown" }) - end - - it { is_expected.to contain_exactly(kept_dossier.id) } - - it 'describes column' do - expect(column.type).to eq(:enum) - expect(column.options_for_select.first).to eq(["Auvergne-Rhône-Alpes", "Auvergne-Rhône-Alpes"]) - end - end - end - - context 'for etablissement table' do - context 'for entreprise_date_creation column' do - let(:filter) { ['Date de création', '21/6/2018'] } - - let!(:kept_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2018, 6, 21))) } - let!(:discarded_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2008, 6, 21))) } - - it { is_expected.to contain_exactly(kept_dossier.id) } - - context 'with multiple search values' do - let(:filters) { [['Date de création', '21/6/2016'], ['Date de création', '21/6/2018']] } - - let!(:other_kept_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2016, 6, 21))) } - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - end - - context 'for code_postal column' do - # All columns except entreprise_date_creation work exacly the same, just testing one - - let(:filter) { ['Code postal', '75017'] } - - let!(:kept_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '75017')) } - let!(:discarded_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '25000')) } - - it { is_expected.to contain_exactly(kept_dossier.id) } - - context 'with multiple search values' do - let(:filters) { [['Code postal', '75017'], ['Code postal', '88100']] } - - let!(:other_kept_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '88100')) } - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - end - end - - context 'for user table' do - let(:filter) { ['Demandeur', 'keepmail'] } - - let!(:kept_dossier) { create(:dossier, procedure: procedure, user: create(:user, email: 'me@keepmail.com')) } - let!(:discarded_dossier) { create(:dossier, procedure: procedure, user: create(:user, email: 'me@discard.com')) } - - it { is_expected.to contain_exactly(kept_dossier.id) } - - context 'with multiple search values' do - let(:filters) { [['Demandeur', 'keepmail'], ['Demandeur', 'beta.gouv.fr']] } - - let!(:other_kept_dossier) { create(:dossier, procedure: procedure, user: create(:user, email: 'bazinga@beta.gouv.fr')) } - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - end - - context 'for individual table' do - let(:procedure) { create(:procedure, :for_individual) } - let!(:kept_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'Mme', prenom: 'Josephine', nom: 'Baker')) } - let!(:discarded_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'M', prenom: 'Jean', nom: 'Tremblay')) } - - context 'for gender column' do - let(:filter) { ['Civilité', 'Mme'] } - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'for prenom column' do - let(:filter) { ['Prénom', 'Josephine'] } - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'for nom column' do - let(:filter) { ['Nom', 'Baker'] } - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'with multiple search values' do - let(:filters) { [['Prénom', 'Josephine'], ['Prénom', 'Romuald']] } - - let!(:other_kept_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'M', prenom: 'Romuald', nom: 'Pistis')) } - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - end - - context 'for followers_instructeurs table' do - let(:filter) { ['Email instructeur', 'keepmail'] } - - let!(:kept_dossier) { create(:dossier, procedure: procedure) } - let!(:discarded_dossier) { create(:dossier, procedure: procedure) } - - before do - create(:follow, dossier: kept_dossier, instructeur: create(:instructeur, email: 'me@keepmail.com')) - create(:follow, dossier: discarded_dossier, instructeur: create(:instructeur, email: 'me@discard.com')) - end - - it { is_expected.to contain_exactly(kept_dossier.id) } - - context 'with multiple search values' do - let(:filters) { [['Email instructeur', 'keepmail'], ['Email instructeur', 'beta.gouv.fr']] } - - let(:other_kept_dossier) { create(:dossier, procedure:) } - - before do - create(:follow, dossier: other_kept_dossier, instructeur: create(:instructeur, email: 'bazinga@beta.gouv.fr')) - end - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - end - - context 'for groupe_instructeur table' do - let(:filter) { ['Groupe instructeur', procedure.defaut_groupe_instructeur.id.to_s] } - - let!(:gi_2) { create(:groupe_instructeur, label: 'gi2', procedure:) } - let!(:gi_3) { create(:groupe_instructeur, label: 'gi3', procedure:) } - - let!(:kept_dossier) { create(:dossier, :en_construction, procedure:) } - let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, groupe_instructeur: gi_2) } - - it { is_expected.to contain_exactly(kept_dossier.id) } - - context 'with multiple search values' do - let(:filters) { [['Groupe instructeur', procedure.defaut_groupe_instructeur.id.to_s], ['Groupe instructeur', gi_3.id.to_s]] } - - let!(:other_kept_dossier) { create(:dossier, procedure:, groupe_instructeur: gi_3) } - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - end - end - describe "#human_value_for_filter" do let(:filtered_column) { to_filter([first_type_de_champ.libelle, "true"]) } @@ -742,54 +93,6 @@ describe ProcedurePresentation do end end - describe '#filtered_sorted_ids' do - let(:procedure_presentation) { create(:procedure_presentation, assign_to:) } - - subject { procedure_presentation.filtered_sorted_ids(dossiers, statut) } - - context 'with no filters' do - let(:statut) { 'suivis' } - let(:dossiers) { procedure.dossiers } - - before do - create(:follow, dossier: en_construction_dossier, instructeur: procedure_presentation.instructeur) - create(:follow, dossier: accepte_dossier, instructeur: procedure_presentation.instructeur) - end - - let(:en_construction_dossier) { create(:dossier, :en_construction, procedure:) } - let(:accepte_dossier) { create(:dossier, :accepte, procedure:) } - - it { is_expected.to contain_exactly(en_construction_dossier.id) } - end - - context 'with mocked sorted_ids' do - let(:dossier_1) { create(:dossier) } - let(:dossier_2) { create(:dossier) } - let(:dossier_3) { create(:dossier) } - let(:dossiers) { Dossier.where(id: [dossier_1, dossier_2, dossier_3].map(&:id)) } - - let(:sorted_ids) { [dossier_2, dossier_3, dossier_1].map(&:id) } - let(:statut) { 'tous' } - - before do - expect(procedure_presentation).to receive(:sorted_ids).and_return(sorted_ids) - end - - it { is_expected.to eq(sorted_ids) } - - context 'when a filter is present' do - let(:filtered_ids) { [dossier_1, dossier_2, dossier_3].map(&:id) } - - before do - procedure_presentation.tous_filters = [to_filter(['Statut', 'en_construction'])] - expect(procedure_presentation).to receive(:filtered_ids).and_return(filtered_ids) - end - - it { is_expected.to eq(sorted_ids) } - end - end - end - describe '#update_displayed_fields' do let(:en_construction_column) { procedure.find_column(label: 'En construction le') } let(:mise_a_jour_column) { procedure.find_column(label: 'Mis à jour le') } diff --git a/spec/services/dossier_filter_service_spec.rb b/spec/services/dossier_filter_service_spec.rb new file mode 100644 index 000000000..95737dd25 --- /dev/null +++ b/spec/services/dossier_filter_service_spec.rb @@ -0,0 +1,709 @@ +# frozen_string_literal: true + +describe DossierFilterService do + def to_filter((label, filter)) = FilteredColumn.new(column: procedure.find_column(label:), filter:) + + describe '.filtered_sorted_ids' do + let(:procedure) { create(:procedure) } + let(:instructeur) { create(:instructeur) } + let(:dossiers) { procedure.dossiers } + let(:statut) { 'suivis' } + let(:filters) { [] } + let(:sorted_columns) { procedure.default_sorted_column } + + subject { described_class.filtered_sorted_ids(dossiers, statut, filters, sorted_columns, instructeur) } + + context 'with no filters' do + let(:en_construction_dossier) { create(:dossier, :en_construction, procedure:) } + let(:accepte_dossier) { create(:dossier, :accepte, procedure:) } + + before do + create(:follow, dossier: en_construction_dossier, instructeur:) + create(:follow, dossier: accepte_dossier, instructeur:) + end + + it { is_expected.to contain_exactly(en_construction_dossier.id) } + end + + context 'with mocked sorted_ids' do + let(:dossier_1) { create(:dossier) } + let(:dossier_2) { create(:dossier) } + let(:dossier_3) { create(:dossier) } + let(:dossiers) { Dossier.where(id: [dossier_1, dossier_2, dossier_3].map(&:id)) } + + let(:sorted_ids) { [dossier_2, dossier_3, dossier_1].map(&:id) } + + before do + expect(described_class).to receive(:sorted_ids).and_return(sorted_ids) + end + + it { is_expected.to eq(sorted_ids) } + + context 'when a filter is present' do + let(:filtered_ids) { [dossier_1, dossier_2, dossier_3].map(&:id) } + let(:filters) { [to_filter(['Statut', 'en_construction'])] } + + before do + expect(described_class).to receive(:filtered_ids).and_return(filtered_ids) + end + + it { is_expected.to eq(sorted_ids) } + end + end + end + + describe '#sorted_ids' do + let(:procedure) { create(:procedure, :published, types_de_champ_public:, types_de_champ_private: [{}]) } + let(:types_de_champ_public) { [{}] } + let(:first_type_de_champ) { assign_to.procedure.active_revision.types_de_champ_public.first } + let(:dossiers) { procedure.dossiers } + let(:instructeur) { create(:instructeur) } + let(:assign_to) { create(:assign_to, procedure:, instructeur:) } + let(:sorted_column) { SortedColumn.new(column:, order:) } + + subject { described_class.send(:sorted_ids, dossiers, sorted_column, instructeur, dossiers.count) } + + context 'for notifications table' do + let(:column) { procedure.notifications_column } + + let!(:notified_dossier) { create(:dossier, :en_construction, procedure:) } + let!(:recent_dossier) { create(:dossier, :en_construction, procedure:) } + let!(:older_dossier) { create(:dossier, :en_construction, procedure:) } + + before do + notified_dossier.update!(last_champ_updated_at: Time.zone.local(2018, 9, 20)) + create(:follow, instructeur: instructeur, dossier: notified_dossier, demande_seen_at: Time.zone.local(2018, 9, 10)) + notified_dossier.touch(time: Time.zone.local(2018, 9, 20)) + recent_dossier.touch(time: Time.zone.local(2018, 9, 25)) + older_dossier.touch(time: Time.zone.local(2018, 5, 13)) + end + + context 'in ascending order' do + let(:order) { 'asc' } + + it { is_expected.to eq([older_dossier, recent_dossier, notified_dossier].map(&:id)) } + end + + context 'in descending order' do + let(:order) { 'desc' } + + it { is_expected.to eq([notified_dossier, recent_dossier, older_dossier].map(&:id)) } + end + + context 'with a dossier terminé' do + let!(:notified_dossier) { create(:dossier, :accepte, procedure:) } + let(:order) { 'desc' } + + it { is_expected.to eq([notified_dossier, recent_dossier, older_dossier].map(&:id)) } + end + end + + context 'for self table' do + let(:order) { 'asc' } # Desc works the same, no extra test required + + context 'for created_at column' do + let!(:column) { procedure.find_column(label: 'Créé le') } + let!(:recent_dossier) { Timecop.freeze(Time.zone.local(2018, 10, 17)) { create(:dossier, procedure:) } } + let!(:older_dossier) { Timecop.freeze(Time.zone.local(2003, 11, 11)) { create(:dossier, procedure:) } } + + it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) } + end + + context 'for en_construction_at column' do + let!(:column) { procedure.find_column(label: 'En construction le') } + let!(:recent_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17)) } + let!(:older_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2013, 1, 1)) } + + it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) } + end + + context 'for updated_at column' do + let(:column) { procedure.find_column(label: 'Mis à jour le') } + let(:recent_dossier) { create(:dossier, procedure:) } + let(:older_dossier) { create(:dossier, procedure:) } + + before do + recent_dossier.touch(time: Time.zone.local(2018, 9, 25)) + older_dossier.touch(time: Time.zone.local(2018, 5, 13)) + end + + it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) } + end + end + + context 'for type_de_champ table' do + context 'with no revisions' do + let(:column) { procedure.find_column(label: first_type_de_champ.libelle) } + + let(:beurre_dossier) { create(:dossier, procedure:) } + let(:tartine_dossier) { create(:dossier, procedure:) } + + before do + beurre_dossier.project_champs_public.first.update(value: 'beurre') + tartine_dossier.project_champs_public.first.update(value: 'tartine') + end + + context 'asc' do + let(:order) { 'asc' } + + it { is_expected.to eq([beurre_dossier, tartine_dossier].map(&:id)) } + end + + context 'desc' do + let(:order) { 'desc' } + + it { is_expected.to eq([tartine_dossier, beurre_dossier].map(&:id)) } + end + end + + context 'with a revision adding a new type_de_champ' do + let!(:tdc) { { type_champ: :text, libelle: 'nouveau champ' } } + let(:column) { procedure.find_column(label: 'nouveau champ') } + + let!(:nothing_dossier) { create(:dossier, procedure:) } + let!(:beurre_dossier) { create(:dossier, procedure:) } + let!(:tartine_dossier) { create(:dossier, procedure:) } + + before do + nothing_dossier + procedure.draft_revision.add_type_de_champ(tdc) + procedure.publish_revision! + beurre_dossier.project_champs_public.last.update(value: 'beurre') + tartine_dossier.project_champs_public.last.update(value: 'tartine') + end + + context 'asc' do + let(:order) { 'asc' } + it { is_expected.to eq([nothing_dossier, beurre_dossier, tartine_dossier].map(&:id)) } + end + + context 'desc' do + let(:order) { 'desc' } + it { is_expected.to eq([tartine_dossier, beurre_dossier, nothing_dossier].map(&:id)) } + end + end + end + + context 'for type_de_champ_private table' do + context 'with no revisions' do + let(:column) { procedure.find_column(label: procedure.active_revision.types_de_champ_private.first.libelle) } + + let(:biere_dossier) { create(:dossier, procedure:) } + let(:vin_dossier) { create(:dossier, procedure:) } + + before do + biere_dossier.project_champs_private.first.update(value: 'biere') + vin_dossier.project_champs_private.first.update(value: 'vin') + end + + context 'asc' do + let(:order) { 'asc' } + + it { is_expected.to eq([biere_dossier, vin_dossier].map(&:id)) } + end + + context 'desc' do + let(:order) { 'desc' } + + it { is_expected.to eq([vin_dossier, biere_dossier].map(&:id)) } + end + end + end + + context 'for individual table' do + let(:order) { 'asc' } # Desc works the same, no extra test required + + let(:procedure) { create(:procedure, :for_individual) } + + let!(:first_dossier) { create(:dossier, procedure:, individual: build(:individual, gender: 'M', prenom: 'Alain', nom: 'Antonelli')) } + let!(:last_dossier) { create(:dossier, procedure:, individual: build(:individual, gender: 'Mme', prenom: 'Zora', nom: 'Zemmour')) } + + context 'for gender column' do + let(:column) { procedure.find_column(label: 'Civilité') } + + it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) } + end + + context 'for prenom column' do + let(:column) { procedure.find_column(label: 'Prénom') } + + it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) } + end + + context 'for nom column' do + let(:column) { procedure.find_column(label: 'Nom') } + + it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) } + end + end + + context 'for followers_instructeurs table' do + let(:order) { 'asc' } # Desc works the same, no extra test required + + let!(:dossier_z) { create(:dossier, :en_construction, procedure:) } + let!(:dossier_a) { create(:dossier, :en_construction, procedure:) } + let!(:dossier_without_instructeur) { create(:dossier, :en_construction, procedure:) } + + before do + create(:follow, dossier: dossier_z, instructeur: create(:instructeur, email: 'zythum@exemple.fr')) + create(:follow, dossier: dossier_a, instructeur: create(:instructeur, email: 'abaca@exemple.fr')) + create(:follow, dossier: dossier_a, instructeur: create(:instructeur, email: 'abaca2@exemple.fr')) + end + + context 'for email column' do + let(:column) { procedure.find_column(label: 'Email instructeur') } + + it { is_expected.to eq([dossier_a, dossier_z, dossier_without_instructeur].map(&:id)) } + end + end + + context 'for avis table' do + let(:column) { procedure.find_column(label: 'Avis oui/non') } + let(:order) { 'asc' } + + let!(:dossier_yes) { create(:dossier, procedure:) } + let!(:dossier_no) { create(:dossier, procedure:) } + + before do + create_list(:avis, 2, dossier: dossier_yes, question_answer: true) + create(:avis, dossier: dossier_no, question_answer: true) + create(:avis, dossier: dossier_no, question_answer: false) + end + + it { is_expected.to eq([dossier_no, dossier_yes].map(&:id)) } + end + + context 'for other tables' do + # All other columns and tables work the same so it’s ok to test only one + let(:column) { procedure.find_column(label: 'Code postal') } + let(:order) { 'asc' } # Desc works the same, no extra test required + + let!(:huitieme_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '75008')) } + let!(:vingtieme_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '75020')) } + + it { is_expected.to eq([huitieme_dossier, vingtieme_dossier].map(&:id)) } + end + end + + describe '#filtered_ids' do + let(:procedure) { create(:procedure, types_de_champ_public:, types_de_champ_private:) } + let(:types_de_champ_public) { [{}] } + let(:types_de_champ_private) { [{}] } + let(:dossiers) { procedure.dossiers } + let(:filtered_columns) { filters.map { to_filter(_1) } } + let(:filters) { [filter] } + + subject { described_class.send(:filtered_ids, dossiers.joins(:user), filtered_columns) } + + context 'for self table' do + context 'for created_at column' do + let(:filter) { ['Créé le', '18/9/2018'] } + + let!(:kept_dossier) { create(:dossier, procedure:, created_at: Time.zone.local(2018, 9, 18, 14, 28)) } + let!(:discarded_dossier) { create(:dossier, procedure:, created_at: Time.zone.local(2018, 9, 17, 23, 59)) } + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'for en_construction_at column' do + let(:filter) { ['En construction le', '17/10/2018'] } + + let!(:kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17)) } + let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2013, 1, 1)) } + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'for updated_at column' do + let(:filter) { ['Mis à jour le', '18/9/2018'] } + + let(:kept_dossier) { create(:dossier, procedure:) } + let(:discarded_dossier) { create(:dossier, procedure:) } + + before do + kept_dossier.touch(time: Time.zone.local(2018, 9, 18, 14, 28)) + discarded_dossier.touch(time: Time.zone.local(2018, 9, 17, 23, 59)) + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'for updated_since column' do + let(:filter) { ['Mis à jour depuis', '18/9/2018'] } + + let(:kept_dossier) { create(:dossier, procedure:) } + let(:later_dossier) { create(:dossier, procedure:) } + let(:discarded_dossier) { create(:dossier, procedure:) } + + before do + kept_dossier.touch(time: Time.zone.local(2018, 9, 18, 14, 28)) + later_dossier.touch(time: Time.zone.local(2018, 9, 19, 14, 28)) + discarded_dossier.touch(time: Time.zone.local(2018, 9, 17, 14, 28)) + end + + it { is_expected.to match_array([kept_dossier.id, later_dossier.id]) } + end + + context 'for sva_svr_decision_before column' do + before do + travel_to Time.zone.local(2023, 6, 10, 10) + end + + let(:procedure) { create(:procedure, :published, :sva, types_de_champ_public: [{}], types_de_champ_private: [{}]) } + let(:filter) { ['Date décision SVA avant', '15/06/2023'] } + + let!(:kept_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current) } + let!(:later_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current + 2.days) } + let!(:discarded_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current + 10.days) } + let!(:en_construction_dossier) { create(:dossier, :en_construction, procedure:, sva_svr_decision_on: Date.current + 2.days) } + let!(:accepte_dossier) { create(:dossier, :accepte, procedure:, sva_svr_decision_on: Date.current + 2.days) } + + it { is_expected.to match_array([kept_dossier.id, later_dossier.id, en_construction_dossier.id]) } + end + + context 'ignore time of day' do + let(:filter) { ['En construction le', '17/10/2018 19:30'] } + + let!(:kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17, 15, 56)) } + let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 18, 5, 42)) } + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'for a malformed date' do + context 'when its a string' do + let(:filter) { ['Mis à jour le', 'malformed date'] } + + it { is_expected.to match([]) } + end + + context 'when its a number' do + let(:filter) { ['Mis à jour le', '177500'] } + + it { is_expected.to match([]) } + end + end + + context 'with multiple search values' do + let(:filters) { [['En construction le', '17/10/2018'], ['En construction le', '19/10/2018']] } + + let!(:kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17)) } + let!(:other_kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 19)) } + let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2013, 1, 1)) } + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + + context 'with multiple state filters' do + let(:filters) { [['Statut', 'en_construction'], ['Statut', 'en_instruction']] } + + let!(:kept_dossier) { create(:dossier, :en_construction, procedure:) } + let!(:other_kept_dossier) { create(:dossier, :en_instruction, procedure:) } + let!(:discarded_dossier) { create(:dossier, :accepte, procedure:) } + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + + context 'with en_construction state filters' do + let(:filter) { ['Statut', 'en_construction'] } + + let!(:en_construction) { create(:dossier, :en_construction, procedure:) } + let!(:en_construction_with_correction) { create(:dossier, :en_construction, procedure:) } + let!(:correction) { create(:dossier_correction, dossier: en_construction_with_correction) } + it 'excludes dossier en construction with pending correction' do + is_expected.to contain_exactly(en_construction.id) + end + end + end + + context 'for type_de_champ table' do + let(:filter) { [type_de_champ.libelle, 'keep'] } + + let(:kept_dossier) { create(:dossier, procedure:) } + let(:discarded_dossier) { create(:dossier, procedure:) } + let(:type_de_champ) { procedure.active_revision.types_de_champ_public.first } + + context 'with single value' do + before do + kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'keep me') + discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'discard me') + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'with multiple search values' do + let(:filters) { [[type_de_champ.libelle, 'keep'], [type_de_champ.libelle, 'and']] } + let(:other_kept_dossier) { create(:dossier, procedure:) } + + before do + kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'keep me') + discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'discard me') + other_kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'and me too') + end + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + + context 'with yes_no type_de_champ' do + let(:filter) { [type_de_champ.libelle, 'true'] } + let(:types_de_champ_public) { [{ type: :yes_no }] } + + before do + kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'true') + discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'false') + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'with departement type_de_champ' do + let(:filter) { [type_de_champ.libelle, '13'] } + let(:types_de_champ_public) { [{ type: :departements }] } + + before do + kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: '13') + discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: '69') + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'with enum type_de_champ' do + let(:filter) { [type_de_champ.libelle, 'Favorable'] } + let(:types_de_champ_public) { [{ type: :drop_down_list, options: ['Favorable', 'Defavorable'] }] } + + before do + kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'Favorable') + discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: 'Defavorable') + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + end + + context 'for type_de_champ_private table' do + let(:filter) { [type_de_champ_private.libelle, 'keep'] } + + let(:kept_dossier) { create(:dossier, procedure:) } + let(:discarded_dossier) { create(:dossier, procedure:) } + let(:type_de_champ_private) { procedure.active_revision.types_de_champ_private.first } + + before do + kept_dossier.champs.find_by(stable_id: type_de_champ_private.stable_id).update(value: 'keep me') + discarded_dossier.champs.find_by(stable_id: type_de_champ_private.stable_id).update(value: 'discard me') + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'for type_de_champ using AddressableColumnConcern' do + let(:column) { filtered_columns.first.column } + let(:types_de_champ_public) { [{ type: :rna, stable_id: 1, libelle: 'rna' }] } + let(:type_de_champ) { procedure.active_revision.types_de_champ.first } + let(:kept_dossier) { create(:dossier, procedure:) } + + context "when searching by postal_code (text)" do + let(:value) { "60580" } + let(:filter) { ["rna – code postal (5 chiffres)", value] } + + before do + kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "postal_code" => value }) + create(:dossier, procedure:).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "postal_code" => "unknown" }) + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + + it 'describes column' do + expect(column.type).to eq(:text) + expect(column.options_for_select).to eq([]) + end + end + + context "when searching by departement_code (enum)" do + let(:value) { "99" } + let(:filter) { ["rna – département", value] } + + before do + kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "departement_code" => value }) + create(:dossier, procedure:).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "departement_code" => "unknown" }) + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + + it 'describes column' do + expect(column.type).to eq(:enum) + expect(column.options_for_select.first).to eq(["99 – Etranger", "99"]) + end + end + + context "when searching by region_name" do + let(:value) { "60" } + let(:filter) { ["rna – region", value] } + + before do + kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "region_name" => value }) + create(:dossier, procedure:).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "region_name" => "unknown" }) + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + + it 'describes column' do + expect(column.type).to eq(:enum) + expect(column.options_for_select.first).to eq(["Auvergne-Rhône-Alpes", "Auvergne-Rhône-Alpes"]) + end + end + end + + context 'for etablissement table' do + context 'for entreprise_date_creation column' do + let(:filter) { ['Date de création', '21/6/2018'] } + + let!(:kept_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2018, 6, 21))) } + let!(:discarded_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2008, 6, 21))) } + + it { is_expected.to contain_exactly(kept_dossier.id) } + + context 'with multiple search values' do + let(:filters) { [['Date de création', '21/6/2016'], ['Date de création', '21/6/2018']] } + + let!(:other_kept_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2016, 6, 21))) } + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + end + + context 'for code_postal column' do + # All columns except entreprise_date_creation work exacly the same, just testing one + + let(:filter) { ['Code postal', '75017'] } + + let!(:kept_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '75017')) } + let!(:discarded_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '25000')) } + + it { is_expected.to contain_exactly(kept_dossier.id) } + + context 'with multiple search values' do + let(:filters) { [['Code postal', '75017'], ['Code postal', '88100']] } + + let!(:other_kept_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '88100')) } + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + end + end + + context 'for user table' do + let(:filter) { ['Demandeur', 'keepmail'] } + + let!(:kept_dossier) { create(:dossier, procedure:, user: create(:user, email: 'me@keepmail.com')) } + let!(:discarded_dossier) { create(:dossier, procedure:, user: create(:user, email: 'me@discard.com')) } + + it { is_expected.to contain_exactly(kept_dossier.id) } + + context 'with multiple search values' do + let(:filters) { [['Demandeur', 'keepmail'], ['Demandeur', 'beta.gouv.fr']] } + + let!(:other_kept_dossier) { create(:dossier, procedure:, user: create(:user, email: 'bazinga@beta.gouv.fr')) } + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + end + + context 'for individual table' do + let(:procedure) { create(:procedure, :for_individual) } + let!(:kept_dossier) { create(:dossier, procedure:, individual: build(:individual, gender: 'Mme', prenom: 'Josephine', nom: 'Baker')) } + let!(:discarded_dossier) { create(:dossier, procedure:, individual: build(:individual, gender: 'M', prenom: 'Jean', nom: 'Tremblay')) } + + context 'for gender column' do + let(:filter) { ['Civilité', 'Mme'] } + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'for prenom column' do + let(:filter) { ['Prénom', 'Josephine'] } + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'for nom column' do + let(:filter) { ['Nom', 'Baker'] } + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'with multiple search values' do + let(:filters) { [['Prénom', 'Josephine'], ['Prénom', 'Romuald']] } + + let!(:other_kept_dossier) { create(:dossier, procedure:, individual: build(:individual, gender: 'M', prenom: 'Romuald', nom: 'Pistis')) } + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + end + + context 'for followers_instructeurs table' do + let(:filter) { ['Email instructeur', 'keepmail'] } + + let!(:kept_dossier) { create(:dossier, procedure:) } + let!(:discarded_dossier) { create(:dossier, procedure:) } + + before do + create(:follow, dossier: kept_dossier, instructeur: create(:instructeur, email: 'me@keepmail.com')) + create(:follow, dossier: discarded_dossier, instructeur: create(:instructeur, email: 'me@discard.com')) + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + + context 'with multiple search values' do + let(:filters) { [['Email instructeur', 'keepmail'], ['Email instructeur', 'beta.gouv.fr']] } + + let(:other_kept_dossier) { create(:dossier, procedure:) } + + before do + create(:follow, dossier: other_kept_dossier, instructeur: create(:instructeur, email: 'bazinga@beta.gouv.fr')) + end + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + end + + context 'for groupe_instructeur table' do + let(:filter) { ['Groupe instructeur', procedure.defaut_groupe_instructeur.id.to_s] } + + let!(:gi_2) { create(:groupe_instructeur, label: 'gi2', procedure:) } + let!(:gi_3) { create(:groupe_instructeur, label: 'gi3', procedure:) } + + let!(:kept_dossier) { create(:dossier, :en_construction, procedure:) } + let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, groupe_instructeur: gi_2) } + + it { is_expected.to contain_exactly(kept_dossier.id) } + + context 'with multiple search values' do + let(:filters) { [['Groupe instructeur', procedure.defaut_groupe_instructeur.id.to_s], ['Groupe instructeur', gi_3.id.to_s]] } + + let!(:other_kept_dossier) { create(:dossier, procedure:, groupe_instructeur: gi_3) } + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + end + end +end From d1530b40a108f00a432f18f13cb5f7ebdc6ff715 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 15 Oct 2024 13:28:44 +0200 Subject: [PATCH 1264/1532] use the filter_service --- .../instructeurs/procedures_controller.rb | 2 +- app/models/export.rb | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index 59797c67a..2d9b4b26f 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -95,7 +95,7 @@ module Instructeurs @has_export_notification = notify_exports? @last_export = last_export_for(statut) - @filtered_sorted_ids = procedure_presentation.filtered_sorted_ids(dossiers, statut, count: dossiers_count) + @filtered_sorted_ids = DossierFilterService.filtered_sorted_ids(dossiers, statut, procedure_presentation.filters_for(statut), procedure_presentation.sorted_column, current_instructeur, count: dossiers_count) page = params[:page].presence || 1 @dossiers_count = @filtered_sorted_ids.size diff --git a/app/models/export.rb b/app/models/export.rb index 0eeff606a..436020a2c 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -140,8 +140,8 @@ class Export < ApplicationRecord if since.present? dossiers.visible_by_administration.where('dossiers.depose_at > ?', since) elsif procedure_presentation.present? - filtered_sorted_ids = procedure_presentation - .filtered_sorted_ids(dossiers, statut) + instructeur = instructeur_from(user_profile) + filtered_sorted_ids = DossierFilterService.filtered_sorted_ids(dossiers, statut, filtered_columns, sorted_column, instructeur) dossiers.where(id: filtered_sorted_ids) else @@ -150,6 +150,15 @@ class Export < ApplicationRecord end end + def instructeur_from(user_profile) + case user_profile + when Administrateur + user_profile.instructeur + when Instructeur + user_profile + end + end + def blob service = ProcedureExportService.new(procedure, dossiers_for_export, user_profile, export_template) From 603c2a108e4f4999e06cfd8273e0471d30b9bc63 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 14 Oct 2024 15:22:05 +0200 Subject: [PATCH 1265/1532] remove useless Export.by_key procedure_presentation arg --- .../administrateurs/archives_controller.rb | 2 +- app/models/export.rb | 7 ++---- spec/models/export_spec.rb | 25 +++++-------------- 3 files changed, 9 insertions(+), 25 deletions(-) diff --git a/app/controllers/administrateurs/archives_controller.rb b/app/controllers/administrateurs/archives_controller.rb index 2157ee99e..0e265f84f 100644 --- a/app/controllers/administrateurs/archives_controller.rb +++ b/app/controllers/administrateurs/archives_controller.rb @@ -8,7 +8,7 @@ module Administrateurs helper_method :create_archive_url def index - @exports = Export.ante_chronological.by_key(all_groupe_instructeurs.map(&:id), nil) + @exports = Export.ante_chronological.by_key(all_groupe_instructeurs.map(&:id)) @average_dossier_weight = @procedure.average_dossier_weight @count_dossiers_termines_by_month = @procedure.dossiers.processed_by_month(all_groupe_instructeurs).count @archives = Archive.for_groupe_instructeur(all_groupe_instructeurs).to_a diff --git a/app/models/export.rb b/app/models/export.rb index 436020a2c..58fbdfc9c 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -95,11 +95,8 @@ class Export < ApplicationRecord joins(:groupe_instructeurs).where(groupe_instructeurs: groupe_instructeurs_ids).distinct(:id) end - def self.by_key(groupe_instructeurs_ids, procedure_presentation) - where(key: [ - generate_cache_key(groupe_instructeurs_ids), - generate_cache_key(groupe_instructeurs_ids, procedure_presentation) - ]) + def self.by_key(groupe_instructeurs_ids) + where(key: generate_cache_key(groupe_instructeurs_ids)) end def self.generate_cache_key(groupe_instructeurs_ids, procedure_presentation = nil) diff --git a/spec/models/export_spec.rb b/spec/models/export_spec.rb index 880e3163a..b56881320 100644 --- a/spec/models/export_spec.rb +++ b/spec/models/export_spec.rb @@ -61,31 +61,18 @@ RSpec.describe Export, type: :model do it { expect(groupe_instructeur.reload).to be_present } end - describe '.find_by groupe_instructeurs' do + describe '.by_key groupe_instructeurs' do let!(:procedure) { create(:procedure) } let!(:gi_1) { create(:groupe_instructeur, procedure: procedure, instructeurs: [create(:instructeur)]) } let!(:gi_2) { create(:groupe_instructeur, procedure: procedure, instructeurs: [create(:instructeur)]) } let!(:gi_3) { create(:groupe_instructeur, procedure: procedure, instructeurs: [create(:instructeur)]) } - context 'without procedure_presentation' do - context 'when an export is made for one groupe instructeur' do - let!(:export) { create(:export, groupe_instructeurs: [gi_1, gi_2]) } + context 'when an export is made for one groupe instructeur' do + let!(:export) { create(:export, groupe_instructeurs: [gi_1, gi_2]) } - it { expect(Export.by_key([gi_1.id], nil)).to be_empty } - it { expect(Export.by_key([gi_2.id, gi_1.id], nil)).to eq([export]) } - it { expect(Export.by_key([gi_1.id, gi_2.id, gi_3.id], nil)).to be_empty } - end - end - - context 'with procedure_presentation and without' do - let!(:export_global) { create(:export, statut: Export.statuts.fetch(:tous), groupe_instructeurs: [gi_1, gi_2], procedure_presentation: nil) } - let!(:export_with_filter) { create(:export, statut: Export.statuts.fetch(:suivis), groupe_instructeurs: [gi_1, gi_2], procedure_presentation: create(:procedure_presentation, procedure: procedure, assign_to: gi_1.instructeurs.first.assign_to.first)) } - let!(:procedure_presentation) { create(:procedure_presentation, procedure: gi_1.procedure) } - - it 'find global exports as well as filtered one' do - expect(Export.by_key([gi_2.id, gi_1.id], export_with_filter.procedure_presentation)) - .to contain_exactly(export_with_filter, export_global) - end + it { expect(Export.by_key([gi_1.id])).to be_empty } + it { expect(Export.by_key([gi_2.id, gi_1.id])).to eq([export]) } + it { expect(Export.by_key([gi_1.id, gi_2.id, gi_3.id])).to be_empty } end end From 954d232a478758667527ca232986ee48b3da0c31 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 15 Oct 2024 13:30:31 +0200 Subject: [PATCH 1266/1532] add filtered and sorted columns to export --- app/models/export.rb | 14 +++++++++++--- ..._filtered_and_sorted_column_to_exports_table.rb | 8 ++++++++ db/schema.rb | 4 +++- 3 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20241014084333_add_filtered_and_sorted_column_to_exports_table.rb diff --git a/app/models/export.rb b/app/models/export.rb index 58fbdfc9c..c2332510c 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -37,6 +37,9 @@ class Export < ApplicationRecord has_one_attached :file + attribute :sorted_column, :sorted_column + attribute :filtered_columns, :filtered_column, array: true + validates :format, :groupe_instructeurs, :key, presence: true scope :ante_chronological, -> { order(updated_at: :desc) } @@ -66,10 +69,13 @@ class Export < ApplicationRecord end def filtered? - procedure_presentation_id.present? + filtered_columns.present? end def self.find_or_create_fresh_export(format, groupe_instructeurs, user_profile, time_span_type: time_span_types.fetch(:everything), statut: statuts.fetch(:tous), procedure_presentation: nil, export_template: nil) + filtered_columns = Array.wrap(procedure_presentation&.filters_for(statut)) + sorted_column = procedure_presentation&.sorted_column + attributes = { format:, export_template:, @@ -88,7 +94,9 @@ class Export < ApplicationRecord create!(**attributes, groupe_instructeurs:, user_profile:, procedure_presentation:, - procedure_presentation_snapshot: procedure_presentation&.snapshot) + procedure_presentation_snapshot: procedure_presentation&.snapshot, + filtered_columns:, + sorted_column:) end def self.for_groupe_instructeurs(groupe_instructeurs_ids) @@ -136,7 +144,7 @@ class Export < ApplicationRecord if since.present? dossiers.visible_by_administration.where('dossiers.depose_at > ?', since) - elsif procedure_presentation.present? + elsif filtered_columns.present? || sorted_column.present? instructeur = instructeur_from(user_profile) filtered_sorted_ids = DossierFilterService.filtered_sorted_ids(dossiers, statut, filtered_columns, sorted_column, instructeur) diff --git a/db/migrate/20241014084333_add_filtered_and_sorted_column_to_exports_table.rb b/db/migrate/20241014084333_add_filtered_and_sorted_column_to_exports_table.rb new file mode 100644 index 000000000..64d4c398c --- /dev/null +++ b/db/migrate/20241014084333_add_filtered_and_sorted_column_to_exports_table.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddFilteredAndSortedColumnToExportsTable < ActiveRecord::Migration[7.0] + def change + add_column :exports, :filtered_columns, :jsonb, array: true, default: [], null: false + add_column :exports, :sorted_column, :jsonb + end +end diff --git a/db/schema.rb b/db/schema.rb index 30d626290..93aea3e2b 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[7.0].define(version: 2024_09_29_141825) do +ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do # These are extensions that must be enabled in order to support this database enable_extension "pg_buffercache" enable_extension "pg_stat_statements" @@ -628,12 +628,14 @@ ActiveRecord::Schema[7.0].define(version: 2024_09_29_141825) do t.datetime "created_at", precision: nil, null: false t.integer "dossiers_count" t.bigint "export_template_id" + t.jsonb "filtered_columns", default: [], null: false, array: true t.string "format", null: false t.bigint "instructeur_id" t.string "job_status", default: "pending", null: false t.text "key", null: false t.bigint "procedure_presentation_id" t.jsonb "procedure_presentation_snapshot" + t.jsonb "sorted_column" t.string "statut", default: "tous" t.string "time_span_type", default: "everything", null: false t.datetime "updated_at", precision: nil, null: false From b5ed8c9b61a218649cd106ee481de092e3a83e0c Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 15 Oct 2024 10:19:48 +0200 Subject: [PATCH 1267/1532] export cache_key based on column --- app/models/export.rb | 19 ++++++++----------- app/models/filtered_column.rb | 4 ++++ app/models/sorted_column.rb | 4 ++++ spec/factories/export.rb | 5 ++++- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/app/models/export.rb b/app/models/export.rb index c2332510c..e8dd08002 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -81,7 +81,7 @@ class Export < ApplicationRecord export_template:, time_span_type:, statut:, - key: generate_cache_key(groupe_instructeurs.map(&:id), procedure_presentation) + key: generate_cache_key(groupe_instructeurs.map(&:id), filtered_columns, sorted_column) } recent_export = pending @@ -107,16 +107,13 @@ class Export < ApplicationRecord where(key: generate_cache_key(groupe_instructeurs_ids)) end - def self.generate_cache_key(groupe_instructeurs_ids, procedure_presentation = nil) - if procedure_presentation.present? - [ - groupe_instructeurs_ids.sort.join('-'), - procedure_presentation.id, - Digest::MD5.hexdigest(procedure_presentation.snapshot.slice(:filters, :sort).to_s) - ].join('--') - else - groupe_instructeurs_ids.sort.join('-') - end + def self.generate_cache_key(groupe_instructeurs_ids, filtered_columns = [], sorted_column = nil) + columns_key = ([sorted_column] + filtered_columns).compact.map(&:id).sort.join + + [ + groupe_instructeurs_ids.sort.join('-'), + Digest::MD5.hexdigest(columns_key) + ].join('--') end def count diff --git a/app/models/filtered_column.rb b/app/models/filtered_column.rb index 50579b8b5..c348e60fd 100644 --- a/app/models/filtered_column.rb +++ b/app/models/filtered_column.rb @@ -23,6 +23,10 @@ class FilteredColumn other&.column == column && other.filter == filter end + def id + column.h_id.merge(filter:).sort.to_json + end + private def check_filter_max_length diff --git a/app/models/sorted_column.rb b/app/models/sorted_column.rb index d92036885..469c07559 100644 --- a/app/models/sorted_column.rb +++ b/app/models/sorted_column.rb @@ -24,4 +24,8 @@ class SortedColumn def sort_by_notifications? @column.notifications? && @order == 'desc' end + + def id + column.h_id.merge(order:).sort.to_json + end end diff --git a/spec/factories/export.rb b/spec/factories/export.rb index d3afa2cb7..fa16c9ee6 100644 --- a/spec/factories/export.rb +++ b/spec/factories/export.rb @@ -8,7 +8,10 @@ FactoryBot.define do groupe_instructeurs { [association(:groupe_instructeur)] } after(:build) do |export, _evaluator| - export.key = Export.generate_cache_key(export.groupe_instructeurs.map(&:id), export.procedure_presentation) + procedure_presentation = export.procedure_presentation + filters = Array.wrap(procedure_presentation&.filters_for(export.statut)) + sorted_column = procedure_presentation&.sorted_column + export.key = Export.generate_cache_key(export.groupe_instructeurs.map(&:id), filters, sorted_column) export.user_profile = export.groupe_instructeurs.first&.instructeurs&.first if export.user_profile.nil? export.dossiers_count = 10 if !export.pending? end From 71bcbbc440377cbb26481dd21d0d046da43c7f47 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 14 Oct 2024 22:11:25 +0200 Subject: [PATCH 1268/1532] remove useless snapshot --- app/dashboards/export_dashboard.rb | 3 +- app/models/export.rb | 10 ++----- app/models/procedure_presentation.rb | 4 --- spec/models/export_spec.rb | 45 +++++++++++++++++++--------- 4 files changed, 34 insertions(+), 28 deletions(-) diff --git a/app/dashboards/export_dashboard.rb b/app/dashboards/export_dashboard.rb index bab3021d2..ee50290a5 100644 --- a/app/dashboards/export_dashboard.rb +++ b/app/dashboards/export_dashboard.rb @@ -17,7 +17,6 @@ class ExportDashboard < Administrate::BaseDashboard job_status: Field::Select.with_options(searchable: false, collection: -> (field) { field.resource.class.send(field.attribute.to_s.pluralize).keys }), key: Field::Text, procedure_presentation: IdField, - procedure_presentation_snapshot: Field::String.with_options(searchable: false), statut: Field::Select.with_options(searchable: false, collection: -> (field) { field.resource.class.send(field.attribute.to_s.pluralize).keys }), time_span_type: Field::Select.with_options(searchable: false, collection: -> (field) { field.resource.class.send(field.attribute.to_s.pluralize).keys }), created_at: Field::DateTime.with_options(format: "%d/%m %H:%M:%S"), @@ -34,7 +33,7 @@ class ExportDashboard < Administrate::BaseDashboard # SHOW_PAGE_ATTRIBUTES # an array of attributes that will be displayed on the model's show page. - SHOW_PAGE_ATTRIBUTES = [:id, :procedure, :job_status, :format, :statut, :file, :groupe_instructeurs, :key, :procedure_presentation, :procedure_presentation_snapshot, :time_span_type, :created_at, :updated_at].freeze + SHOW_PAGE_ATTRIBUTES = [:id, :procedure, :job_status, :format, :statut, :file, :groupe_instructeurs, :key, :procedure_presentation, :time_span_type, :created_at, :updated_at].freeze # FORM_ATTRIBUTES # an array of attributes that will be displayed diff --git a/app/models/export.rb b/app/models/export.rb index e8dd08002..f949bca63 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -3,6 +3,8 @@ class Export < ApplicationRecord include TransientModelsWithPurgeableJobConcern + self.ignored_columns += ["procedure_presentation_snapshot"] + MAX_DUREE_CONSERVATION_EXPORT = 32.hours MAX_DUREE_GENERATION = 16.hours @@ -59,7 +61,6 @@ class Export < ApplicationRecord def compute self.dossiers_count = dossiers_for_export.count - load_snapshot! file.attach(blob.signed_id) # attaching a blob directly might run identify/virus scanner and wipe it end @@ -94,7 +95,6 @@ class Export < ApplicationRecord create!(**attributes, groupe_instructeurs:, user_profile:, procedure_presentation:, - procedure_presentation_snapshot: procedure_presentation&.snapshot, filtered_columns:, sorted_column:) end @@ -129,12 +129,6 @@ class Export < ApplicationRecord private - def load_snapshot! - if procedure_presentation_snapshot.present? - procedure_presentation.attributes = procedure_presentation_snapshot - end - end - def dossiers_for_export @dossiers_for_export ||= begin dossiers = Dossier.where(groupe_instructeur: groupe_instructeurs) diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 42345edd9..07365e9b7 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -73,10 +73,6 @@ class ProcedurePresentation < ApplicationRecord nil end - def snapshot - slice(:filters, :sort, :displayed_fields) - end - private def find_type_de_champ(column) diff --git a/spec/models/export_spec.rb b/spec/models/export_spec.rb index b56881320..3a796ce93 100644 --- a/spec/models/export_spec.rb +++ b/spec/models/export_spec.rb @@ -154,10 +154,16 @@ RSpec.describe Export, type: :model do let!(:dossier_accepte) { create(:dossier, :accepte, procedure: procedure) } let(:export) do - create(:export, - groupe_instructeurs: [procedure.groupe_instructeurs.first], - procedure_presentation: procedure_presentation, - statut: statut) + groupe_instructeurs = [procedure.groupe_instructeurs.first] + user_profile = groupe_instructeurs.first.instructeurs.first + + Export.find_or_create_fresh_export( + :csv, + groupe_instructeurs, + user_profile, + procedure_presentation:, + statut: + ) end context 'without procedure_presentation or since' do @@ -171,17 +177,28 @@ RSpec.describe Export, type: :model do end end - context 'with procedure_presentation and statut supprimes' do - let(:statut) { 'supprimes' } - let(:procedure_presentation) do - create(:procedure_presentation, - procedure: procedure, - assign_to: procedure.groupe_instructeurs.first.assign_tos.first) - end - let!(:dossier_supprime) { create(:dossier, :accepte, procedure: procedure, hidden_by_administration_at: 2.days.ago) } + context 'with procedure_presentation and statut tous and filter en_construction' do + let(:statut) { 'tous' } - it 'includes supprimes' do - expect(export.send(:dossiers_for_export)).to include(dossier_supprime) + let(:procedure_presentation) do + statut_column = procedure.find_column(label: 'Statut') + en_construction_filter = FilteredColumn.new(column: statut_column, filter: 'en_construction') + create(:procedure_presentation, + procedure:, + assign_to: procedure.groupe_instructeurs.first.assign_tos.first, + tous_filters: [en_construction_filter]) + end + + before do + # ensure the export is generated + export + + # change the procedure presentation + procedure_presentation.update(tous_filters: []) + end + + it 'only includes the en_construction' do + expect(export.send(:dossiers_for_export)).to eq([dossier_en_construction]) end end end From f850924dc0f9122e09fc091fc271955f5bf0f873 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 14 Oct 2024 22:23:59 +0200 Subject: [PATCH 1269/1532] remove procedure_presentation from export --- app/components/dossiers/export_link_component.rb | 2 +- app/models/export.rb | 7 +++++-- spec/components/dossiers/export_link_component_spec.rb | 6 +++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/components/dossiers/export_link_component.rb b/app/components/dossiers/export_link_component.rb index 78a997f71..f3ce43e34 100644 --- a/app/components/dossiers/export_link_component.rb +++ b/app/components/dossiers/export_link_component.rb @@ -30,7 +30,7 @@ class Dossiers::ExportLinkComponent < ApplicationComponent end def export_title(export) - if export.procedure_presentation_id.nil? + if !export.built_from_procedure_presentation? t(".export_title_everything", export_format: export.format) elsif export.tous? t(".export_title", export_format: export.format, count: export.count) diff --git a/app/models/export.rb b/app/models/export.rb index f949bca63..0f35d4bdb 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -94,7 +94,6 @@ class Export < ApplicationRecord create!(**attributes, groupe_instructeurs:, user_profile:, - procedure_presentation:, filtered_columns:, sorted_column:) end @@ -118,7 +117,7 @@ class Export < ApplicationRecord def count return dossiers_count if !dossiers_count.nil? # export generated - return dossiers_for_export.count if procedure_presentation_id.present? + return dossiers_for_export.count if built_from_procedure_presentation? nil end @@ -127,6 +126,10 @@ class Export < ApplicationRecord groupe_instructeurs.first.procedure end + def built_from_procedure_presentation? + sorted_column.present? # hack has we know that procedure_presentation always has a sorted_column + end + private def dossiers_for_export diff --git a/spec/components/dossiers/export_link_component_spec.rb b/spec/components/dossiers/export_link_component_spec.rb index 4aa757b90..f1834c8ca 100644 --- a/spec/components/dossiers/export_link_component_spec.rb +++ b/spec/components/dossiers/export_link_component_spec.rb @@ -36,9 +36,9 @@ RSpec.describe Dossiers::ExportLinkComponent, type: :component do end end - context 'when export is for a presentation' do + context 'when export is from a presentation' do before do - export.update!(procedure_presentation: procedure_presentation) + export.update!(sorted_column: procedure.default_sorted_column) end it 'display the persisted dossiers count' do @@ -48,7 +48,7 @@ RSpec.describe Dossiers::ExportLinkComponent, type: :component do end context "when the export is not available" do - let(:export) { create(:export, :pending, groupe_instructeurs: [groupe_instructeur], procedure_presentation: procedure_presentation, created_at: 10.minutes.ago) } + let(:export) { create(:export, :pending, groupe_instructeurs: [groupe_instructeur], sorted_column: procedure.default_sorted_column, created_at: 10.minutes.ago) } before do create_list(:dossier, 3, :en_construction, procedure: procedure, groupe_instructeur: groupe_instructeur) From 5621edcca8d43ac6d284bfb30d85ff007699ed6f Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 15 Oct 2024 10:53:53 +0200 Subject: [PATCH 1270/1532] remove unused filtered? method --- app/models/export.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/models/export.rb b/app/models/export.rb index 0f35d4bdb..7cce92ca9 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -69,10 +69,6 @@ class Export < ApplicationRecord time_span_type == Export.time_span_types.fetch(:monthly) ? 30.days.ago : nil end - def filtered? - filtered_columns.present? - end - def self.find_or_create_fresh_export(format, groupe_instructeurs, user_profile, time_span_type: time_span_types.fetch(:everything), statut: statuts.fetch(:tous), procedure_presentation: nil, export_template: nil) filtered_columns = Array.wrap(procedure_presentation&.filters_for(statut)) sorted_column = procedure_presentation&.sorted_column From e552a5cbf57689f78a915863a38a4f15a1349c11 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 15 Oct 2024 16:23:23 +0200 Subject: [PATCH 1271/1532] fix(crisp): csp for crisp iframe help --- config/initializers/content_security_policy.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 9797185fd..4c841ec47 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -40,6 +40,7 @@ Rails.application.config.content_security_policy do |policy| frame_whitelist << URI(MATOMO_IFRAME_URL).host if Rails.application.secrets.matomo[:enabled] # allow pdf iframes in the PJ gallery frame_whitelist << URI(DS_PROXY_URL).host if DS_PROXY_URL.present? + frame_whitelist << "*.crisp.help" if Rails.application.secrets.crisp[:enabled] policy.frame_src(:self, *frame_whitelist) # Everything else: allow us From ea27d3208f4a7c9530ef771b855b6aad411e7ba9 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 14 Oct 2024 15:58:46 +0200 Subject: [PATCH 1272/1532] feat(groupes management): add import component --- .../groupes_management_component.html.haml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/components/procedure/groupes_management_component/groupes_management_component.html.haml b/app/components/procedure/groupes_management_component/groupes_management_component.html.haml index ced294476..3ae0bb3f9 100644 --- a/app/components/procedure/groupes_management_component/groupes_management_component.html.haml +++ b/app/components/procedure/groupes_management_component/groupes_management_component.html.haml @@ -1,6 +1,8 @@ - content_for(:title, 'Groupes') %h1 Gestion des groupes += render Procedure::ImportComponent.new(procedure: @procedure) + = render Procedure::GroupesSearchComponent.new(procedure: @procedure, query: @query, to_configure_count: @procedure.groupe_instructeurs.filter(&:routing_to_configure?).count, From 871ae074c3a78b28be31464525f07fc3e346ab63 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 16 Oct 2024 09:35:37 +0200 Subject: [PATCH 1273/1532] chore: bump rails 7.0.8.4 => 7.0.8.5 Fix multiple low CVE --- Gemfile.lock | 112 +++++++++++++++++++++++++-------------------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index f306ffc8b..a1c162cbd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -12,47 +12,47 @@ GEM aasm (5.5.0) concurrent-ruby (~> 1.0) acsv (0.0.1) - actioncable (7.0.8.4) - actionpack (= 7.0.8.4) - activesupport (= 7.0.8.4) + actioncable (7.0.8.5) + actionpack (= 7.0.8.5) + activesupport (= 7.0.8.5) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.8.4) - actionpack (= 7.0.8.4) - activejob (= 7.0.8.4) - activerecord (= 7.0.8.4) - activestorage (= 7.0.8.4) - activesupport (= 7.0.8.4) + actionmailbox (7.0.8.5) + actionpack (= 7.0.8.5) + activejob (= 7.0.8.5) + activerecord (= 7.0.8.5) + activestorage (= 7.0.8.5) + activesupport (= 7.0.8.5) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.8.4) - actionpack (= 7.0.8.4) - actionview (= 7.0.8.4) - activejob (= 7.0.8.4) - activesupport (= 7.0.8.4) + actionmailer (7.0.8.5) + actionpack (= 7.0.8.5) + actionview (= 7.0.8.5) + activejob (= 7.0.8.5) + activesupport (= 7.0.8.5) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.8.4) - actionview (= 7.0.8.4) - activesupport (= 7.0.8.4) + actionpack (7.0.8.5) + actionview (= 7.0.8.5) + activesupport (= 7.0.8.5) rack (~> 2.0, >= 2.2.4) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.8.4) - actionpack (= 7.0.8.4) - activerecord (= 7.0.8.4) - activestorage (= 7.0.8.4) - activesupport (= 7.0.8.4) + actiontext (7.0.8.5) + actionpack (= 7.0.8.5) + activerecord (= 7.0.8.5) + activestorage (= 7.0.8.5) + activesupport (= 7.0.8.5) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.8.4) - activesupport (= 7.0.8.4) + actionview (7.0.8.5) + activesupport (= 7.0.8.5) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -67,26 +67,26 @@ GEM activemodel (>= 5.2.0) activestorage (>= 5.2.0) activesupport (>= 5.2.0) - activejob (7.0.8.4) - activesupport (= 7.0.8.4) + activejob (7.0.8.5) + activesupport (= 7.0.8.5) globalid (>= 0.3.6) - activemodel (7.0.8.4) - activesupport (= 7.0.8.4) - activerecord (7.0.8.4) - activemodel (= 7.0.8.4) - activesupport (= 7.0.8.4) - activestorage (7.0.8.4) - actionpack (= 7.0.8.4) - activejob (= 7.0.8.4) - activerecord (= 7.0.8.4) - activesupport (= 7.0.8.4) + activemodel (7.0.8.5) + activesupport (= 7.0.8.5) + activerecord (7.0.8.5) + activemodel (= 7.0.8.5) + activesupport (= 7.0.8.5) + activestorage (7.0.8.5) + actionpack (= 7.0.8.5) + activejob (= 7.0.8.5) + activerecord (= 7.0.8.5) + activesupport (= 7.0.8.5) marcel (~> 1.0) mini_mime (>= 1.1.0) activestorage-openstack (1.6.0) fog-openstack (>= 1.0.9) marcel rails (>= 5.2.2) - activesupport (7.0.8.4) + activesupport (7.0.8.5) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -452,7 +452,7 @@ GEM ruby2_keywords (~> 0.0.1) net-http (0.4.1) uri - net-imap (0.4.12) + net-imap (0.4.17) date net-protocol net-pop (0.1.2) @@ -518,7 +518,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (2.2.9) + rack (2.2.10) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-mini-profiler (3.3.1) @@ -542,20 +542,20 @@ GEM rack_session_access (0.2.0) builder (>= 2.0.0) rack (>= 1.0.0) - rails (7.0.8.4) - actioncable (= 7.0.8.4) - actionmailbox (= 7.0.8.4) - actionmailer (= 7.0.8.4) - actionpack (= 7.0.8.4) - actiontext (= 7.0.8.4) - actionview (= 7.0.8.4) - activejob (= 7.0.8.4) - activemodel (= 7.0.8.4) - activerecord (= 7.0.8.4) - activestorage (= 7.0.8.4) - activesupport (= 7.0.8.4) + rails (7.0.8.5) + actioncable (= 7.0.8.5) + actionmailbox (= 7.0.8.5) + actionmailer (= 7.0.8.5) + actionpack (= 7.0.8.5) + actiontext (= 7.0.8.5) + actionview (= 7.0.8.5) + activejob (= 7.0.8.5) + activemodel (= 7.0.8.5) + activerecord (= 7.0.8.5) + activestorage (= 7.0.8.5) + activesupport (= 7.0.8.5) bundler (>= 1.15.0) - railties (= 7.0.8.4) + railties (= 7.0.8.5) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -578,9 +578,9 @@ GEM rails-pg-extras (5.3.1) rails ruby-pg-extras (= 5.3.1) - railties (7.0.8.4) - actionpack (= 7.0.8.4) - activesupport (= 7.0.8.4) + railties (7.0.8.5) + actionpack (= 7.0.8.5) + activesupport (= 7.0.8.5) method_source rake (>= 12.2) thor (~> 1.0) @@ -874,7 +874,7 @@ GEM anyway_config (>= 1.3, < 3) sidekiq yabeda (~> 0.6) - zeitwerk (2.6.18) + zeitwerk (2.7.0) zip_tricks (5.6.0) zipline (1.5.0) actionpack (>= 6.0, < 8.0) From c417614695fb2fd13d4fbe6106f30a736426d8b3 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 16 Oct 2024 14:15:26 +0200 Subject: [PATCH 1274/1532] fix(dossier): fix apply_diff with multiple changed rows --- app/models/concerns/dossier_clone_concern.rb | 5 +---- spec/models/concerns/dossier_clone_concern_spec.rb | 6 ++++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/models/concerns/dossier_clone_concern.rb b/app/models/concerns/dossier_clone_concern.rb index 07750548c..dd417cd8a 100644 --- a/app/models/concerns/dossier_clone_concern.rb +++ b/app/models/concerns/dossier_clone_concern.rb @@ -153,14 +153,11 @@ module DossierCloneConcern diff[:added].each { _1.update_column(:dossier_id, id) } - # a bit of a hack to work around unicity index - remove_group_id = ULID.generate diff[:updated].each do |champ| - champs_index.fetch(champ.public_id).update(row_id: remove_group_id) + champs_index.fetch(champ.public_id)&.destroy! champ.update_column(:dossier_id, id) end - Champ.where(row_id: remove_group_id).destroy_all diff[:removed].each(&:destroy!) end end diff --git a/spec/models/concerns/dossier_clone_concern_spec.rb b/spec/models/concerns/dossier_clone_concern_spec.rb index 9629d5df5..f5339d842 100644 --- a/spec/models/concerns/dossier_clone_concern_spec.rb +++ b/spec/models/concerns/dossier_clone_concern_spec.rb @@ -334,15 +334,17 @@ RSpec.describe DossierCloneConcern do subject { dossier.merge_fork(forked_dossier) } context 'with updated champ' do + let(:repetition_champ) { dossier.project_champs_public.last } let(:updated_champ) { forked_dossier.champs.find { _1.stable_id == 99 } } - let(:updated_repetition_champ) { forked_dossier.champs.find { _1.stable_id == 994 } } + let(:updated_repetition_champs) { forked_dossier.champs.filter { _1.stable_id == 994 } } before do + repetition_champ.add_row(updated_by: 'test') dossier.champs.each do |champ| champ.update(value: 'old value') end updated_champ.update(value: 'new value') - updated_repetition_champ.update(value: 'new value in repetition') + updated_repetition_champs.each { _1.update(value: 'new value in repetition') } dossier.debounce_index_search_terms_flag.remove end From 4059bfdc110821af41a3a53594c2a440317702da Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 16 Oct 2024 17:58:11 +0200 Subject: [PATCH 1275/1532] fix: make filter works on multiple_drop_down_list --- .../concerns/dossier_filtering_concern.rb | 6 +++++ app/models/type_de_champ.rb | 4 ++- app/services/dossier_filter_service.rb | 3 +++ config/brakeman.ignore | 25 ++++++++++++++++++- spec/services/dossier_filter_service_spec.rb | 17 +++++++++++++ 5 files changed, 53 insertions(+), 2 deletions(-) diff --git a/app/models/concerns/dossier_filtering_concern.rb b/app/models/concerns/dossier_filtering_concern.rb index a1470bbda..ac040f3f5 100644 --- a/app/models/concerns/dossier_filtering_concern.rb +++ b/app/models/concerns/dossier_filtering_concern.rb @@ -39,5 +39,11 @@ module DossierFilteringConcern q = Array.new(values.count, "(#{table_column} = ?)").join(' OR ') where(q, *(values)) } + + scope :filter_array_enum, lambda { |table, column, values| + table_column = DossierFilterService.sanitized_column(table, column) + q = Array.new(values.count, "(#{table_column} = ?)").join(' OR ') + where(q, *(values. map { |value| "[\"#{value}\"]" })) + } end end diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 902b3bb66..39b221fa7 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -532,7 +532,9 @@ class TypeDeChamp < ApplicationRecord end def self.filter_hash_type(type_champ) - if is_choice_type_from(type_champ) + if type_champ == 'multiple_drop_down_list' + :enums + elsif is_choice_type_from(type_champ) :enum else :text diff --git a/app/services/dossier_filter_service.rb b/app/services/dossier_filter_service.rb index 586213451..b046dbe5c 100644 --- a/app/services/dossier_filter_service.rb +++ b/app/services/dossier_filter_service.rb @@ -92,6 +92,9 @@ class DossierFilterService if filtered_column.type == :enum dossiers.with_type_de_champ(column) .filter_enum(:champs, value_column, values) + elsif filtered_column.type == :enums + dossiers.with_type_de_champ(column) + .filter_array_enum(:champs, value_column, values) else dossiers.with_type_de_champ(column) .filter_ilike(:champs, value_column, values) diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 69ad55629..753bf47e5 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -44,6 +44,29 @@ ], "note": "" }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "5092b33433aef8fe42b688a780325f3791a77b39e55131256c78cebc3c14c0a3", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/models/concerns/dossier_filtering_concern.rb", + "line": 46, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "where(\"#{values.count} OR #{\"(#{DossierFilterService.sanitized_column(table, column)} = ?)\"}\", *values.map do\n \"[\\\"#{value}\\\"]\"\n end)", + "render_path": null, + "location": { + "type": "method", + "class": "DossierFilteringConcern", + "method": null + }, + "user_input": "values.count", + "confidence": "Medium", + "cwe_id": [ + 89 + ], + "note": "filtered by rails query params where(something: ?, values)" + }, { "warning_type": "SQL Injection", "warning_code": 0, @@ -272,6 +295,6 @@ "note": "Current is not a model" } ], - "updated": "2024-10-15 15:57:27 +0200", + "updated": "2024-10-16 18:07:17 +0200", "brakeman_version": "6.1.2" } diff --git a/spec/services/dossier_filter_service_spec.rb b/spec/services/dossier_filter_service_spec.rb index 95737dd25..5997d46b1 100644 --- a/spec/services/dossier_filter_service_spec.rb +++ b/spec/services/dossier_filter_service_spec.rb @@ -486,6 +486,23 @@ describe DossierFilterService do it { is_expected.to contain_exactly(kept_dossier.id) } end + + context 'with enums type_de_champ' do + let(:filter) { [type_de_champ.libelle, 'Favorable'] } + let(:types_de_champ_public) { [{ type: :multiple_drop_down_list, options: ['Favorable', 'Defavorable'] }] } + + before do + kept_champ = kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id) + kept_champ.value = ['Favorable'] + kept_champ.save! + + discarded_champ = discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id) + discarded_champ.value = ['Defavorable'] + discarded_champ.save! + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + end end context 'for type_de_champ_private table' do From 7963746ed79b6b168afc5ee6e1566fc57ce7e4b9 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 16 Oct 2024 17:58:54 +0200 Subject: [PATCH 1276/1532] fix: suggest multiple_drop_down options on the filter component --- .../column_filter_component/column_filter_component.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml index eaa41be42..d440fb861 100644 --- a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml +++ b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml @@ -9,7 +9,7 @@ %input.hidden{ type: 'submit', formaction: update_filter_instructeur_procedure_path(procedure), data: { autosubmit_target: 'submitter' } } = label_tag :value, t('.value'), for: 'value', class: 'fr-label' - - if column_type == :enum + - if column_type == :enum || column_type == :enums = select_tag :filter, options_for_select(options_for_select_of_column), id: 'value', name: "#{prefix}[filter]", class: 'fr-select', data: { no_autosubmit: true } - else %input#value.fr-input{ type: column_type, name: "#{prefix}[filter]", maxlength: FilteredColumn::FILTERS_VALUE_MAX_LENGTH, disabled: column.nil? ? true : false, data: { no_autosubmit: true } } From 8c8bb870fc730c1713868e86afda427db281d54b Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 7 Oct 2024 11:45:05 +0200 Subject: [PATCH 1277/1532] refactor(dossier): filled champs --- app/models/concerns/dossier_champs_concern.rb | 28 +++++++++++++++++ app/models/dossier.rb | 10 +++--- .../concerns/dossier_champs_concern_spec.rb | 31 +++++++++++++++++++ spec/models/dossier_spec.rb | 2 +- ...ustificative_file_not_visible_task_spec.rb | 2 +- 5 files changed, 66 insertions(+), 7 deletions(-) diff --git a/app/models/concerns/dossier_champs_concern.rb b/app/models/concerns/dossier_champs_concern.rb index 657b93325..f251734bb 100644 --- a/app/models/concerns/dossier_champs_concern.rb +++ b/app/models/concerns/dossier_champs_concern.rb @@ -39,6 +39,34 @@ module DossierChampsConcern revision.types_de_champ_private.map { project_champ(_1, nil) } end + def filled_champs_public + project_champs_public.flat_map do |champ| + if champ.repetition? + champ.rows.flatten.filter { _1.persisted? && _1.fillable? } + elsif champ.persisted? && champ.fillable? + champ + else + [] + end + end + end + + def filled_champs_private + project_champs_private.flat_map do |champ| + if champ.repetition? + champ.rows.flatten.filter { _1.persisted? && _1.fillable? } + elsif champ.persisted? && champ.fillable? + champ + else + [] + end + end + end + + def filled_champs + filled_champs_public + filled_champs_private + end + def project_rows_for(type_de_champ) return [] if !type_de_champ.repetition? diff --git a/app/models/dossier.rb b/app/models/dossier.rb index b2d495d01..fc80584f4 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -517,7 +517,7 @@ class Dossier < ApplicationRecord def can_passer_en_construction? return true if !revision.ineligibilite_enabled || !revision.ineligibilite_rules - !revision.ineligibilite_rules.compute(champs_for_revision(scope: :public)) + !revision.ineligibilite_rules.compute(filled_champs_public) end def can_passer_en_instruction? @@ -567,7 +567,7 @@ class Dossier < ApplicationRecord def any_etablissement_as_degraded_mode? return true if etablissement&.as_degraded_mode? - return true if champs_for_revision(scope: :public).any? { _1.etablissement&.as_degraded_mode? } + return true if filled_champs_public.any? { _1.etablissement&.as_degraded_mode? } false end @@ -1031,7 +1031,7 @@ class Dossier < ApplicationRecord end def linked_dossiers_for(instructeur_or_expert) - dossier_ids = champs_for_revision.filter(&:dossier_link?).filter_map(&:value) + dossier_ids = filled_champs.filter(&:dossier_link?).filter_map(&:value) instructeur_or_expert.dossiers.where(id: dossier_ids) end @@ -1040,7 +1040,7 @@ class Dossier < ApplicationRecord end def geo_data? - GeoArea.exists?(champ_id: champs_for_revision) + GeoArea.exists?(champ_id: filled_champs) end def to_feature_collection @@ -1195,7 +1195,7 @@ class Dossier < ApplicationRecord end def geo_areas - champs_for_revision.flat_map(&:geo_areas) + filled_champs.flat_map(&:geo_areas) end def bounding_box diff --git a/spec/models/concerns/dossier_champs_concern_spec.rb b/spec/models/concerns/dossier_champs_concern_spec.rb index 79689ff44..6b864f28a 100644 --- a/spec/models/concerns/dossier_champs_concern_spec.rb +++ b/spec/models/concerns/dossier_champs_concern_spec.rb @@ -110,6 +110,7 @@ RSpec.describe DossierChampsConcern do subject { dossier.project_champs_public } it { expect(subject.size).to eq(4) } + it { expect(subject.find { _1.libelle == 'Nom' }).to be_falsey } end describe '#project_champs_private' do @@ -118,6 +119,36 @@ RSpec.describe DossierChampsConcern do it { expect(subject.size).to eq(1) } end + describe '#filled_champs_public' do + let(:types_de_champ_public) do + [ + { type: :header_section }, + { type: :text, libelle: "Un champ text" }, + { type: :text, libelle: "Un autre champ text" }, + { type: :yes_no, libelle: "Un champ yes no" }, + { type: :repetition, libelle: "Un champ répétable", mandatory: true, children: [{ type: :text, libelle: 'Nom' }] }, + { type: :explication } + ] + end + subject { dossier.filled_champs_public } + + it { expect(subject.size).to eq(4) } + it { expect(subject.find { _1.libelle == 'Nom' }).to be_truthy } + end + + describe '#filled_champs_private' do + let(:types_de_champ_private) do + [ + { type: :header_section }, + { type: :text, libelle: "Une annotation" }, + { type: :explication } + ] + end + subject { dossier.filled_champs_private } + + it { expect(subject.size).to eq(1) } + end + describe '#repetition_row_ids' do let(:type_de_champ_repetition) { dossier.find_type_de_champ_by_stable_id(993) } subject { dossier.repetition_row_ids(type_de_champ_repetition) } diff --git a/spec/models/dossier_spec.rb b/spec/models/dossier_spec.rb index 8deed619b..9062e9e4f 100644 --- a/spec/models/dossier_spec.rb +++ b/spec/models/dossier_spec.rb @@ -1852,7 +1852,7 @@ describe Dossier, type: :model do let(:types_de_champ_public) { [{ type: :carte }, { type: :carte }, { type: :carte }] } it do - dossier.champs_for_revision + dossier.filled_champs count = 0 diff --git a/spec/tasks/maintenance/remove_piece_justificative_file_not_visible_task_spec.rb b/spec/tasks/maintenance/remove_piece_justificative_file_not_visible_task_spec.rb index d6d52631f..e7e68ff2b 100644 --- a/spec/tasks/maintenance/remove_piece_justificative_file_not_visible_task_spec.rb +++ b/spec/tasks/maintenance/remove_piece_justificative_file_not_visible_task_spec.rb @@ -13,7 +13,7 @@ module Maintenance before { expect(champ).to receive(:visible?).and_return(visible) } context 'when piece_justificative' do - let(:champ) { dossier.champs_for_revision(scope: :public).find(&:piece_justificative?) } + let(:champ) { dossier.filled_champs_public.find(&:piece_justificative?) } context 'when not visible' do let(:visible) { false } From dd97c2fffd7578f50b5894760ec5ddf1c277e20c Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Sun, 6 Oct 2024 18:28:19 +0200 Subject: [PATCH 1278/1532] refactor(dossier): diff and merge --- app/models/concerns/dossier_clone_concern.rb | 39 +++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/app/models/concerns/dossier_clone_concern.rb b/app/models/concerns/dossier_clone_concern.rb index dd417cd8a..193561fe4 100644 --- a/app/models/concerns/dossier_clone_concern.rb +++ b/app/models/concerns/dossier_clone_concern.rb @@ -44,10 +44,10 @@ module DossierCloneConcern end def make_diff(editing_fork) - origin_champs_index = champs_for_revision(scope: :public).index_by(&:public_id) - forked_champs_index = editing_fork.champs_for_revision(scope: :public).index_by(&:public_id) + origin_champs_index = project_champs_public_all.index_by(&:public_id) + forked_champs_index = editing_fork.project_champs_public_all.index_by(&:public_id) updated_champs_index = editing_fork - .champs_for_revision(scope: :public) + .project_champs_public_all .filter { _1.updated_at > editing_fork.created_at } .index_by(&:public_id) @@ -83,7 +83,7 @@ module DossierCloneConcern dossier_attributes += [:groupe_instructeur_id] if fork relationships = [:individual, :etablissement] - cloned_champs = champs_for_revision + cloned_champs = champs .index_by(&:id) .transform_values { [_1, _1.clone(fork)] } @@ -149,15 +149,34 @@ module DossierCloneConcern end def apply_diff(diff) - champs_index = (champs_for_revision(scope: :public) + diff[:added]).index_by(&:public_id) + champs_added = diff[:added].filter(&:persisted?) + champs_updated = diff[:updated].filter(&:persisted?) + champs_removed = diff[:removed].filter(&:persisted?) - diff[:added].each { _1.update_column(:dossier_id, id) } + champs_added.each { _1.update_column(:dossier_id, id) } - diff[:updated].each do |champ| - champs_index.fetch(champ.public_id)&.destroy! - champ.update_column(:dossier_id, id) + if champs_updated.present? + champs_index = filled_champs_public.index_by(&:public_id) + champs_updated.each do |champ| + champs_index[champ.public_id]&.destroy! + champ.update_column(:dossier_id, id) + end end - diff[:removed].each(&:destroy!) + champs_removed.each(&:destroy!) + end + + protected + + # This is a temporary method that is only used by diff/merge algorithm. Once it's gone, this method should be removed. + def project_champs_public_all + revision.types_de_champ_public.flat_map do |type_de_champ| + champ = project_champ(type_de_champ, nil) + if type_de_champ.repetition? + [champ] + project_rows_for(type_de_champ).flatten + else + champ + end + end end end From 0529718c4b529f90704a83459659d82cf22887ea Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 7 Oct 2024 14:22:21 +0200 Subject: [PATCH 1279/1532] refactor(dossier): the end of champs_for_revision --- app/models/concerns/dossier_champs_concern.rb | 27 +++++++------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/app/models/concerns/dossier_champs_concern.rb b/app/models/concerns/dossier_champs_concern.rb index f251734bb..70795761a 100644 --- a/app/models/concerns/dossier_champs_concern.rb +++ b/app/models/concerns/dossier_champs_concern.rb @@ -3,24 +3,6 @@ module DossierChampsConcern extend ActiveSupport::Concern - def champs_for_revision(scope: nil) - champs_index = champs.group_by(&:stable_id) - revision.types_de_champ_for(scope:) - .flat_map { champs_index[_1.stable_id] || [] } - end - - # Get all the champs values for the types de champ in the final list. - # Dossier might not have corresponding champ – display nil. - # To do so, we build a virtual champ when there is no value so we can call for_export with all indexes - def champs_for_export(types_de_champ, row_id = nil) - types_de_champ.flat_map do |type_de_champ| - champ = champ_for_export(type_de_champ, row_id) - type_de_champ.libelles_for_export.map do |(libelle, path)| - [libelle, TypeDeChamp.champ_value_for_export(type_de_champ.type_champ, champ, path)] - end - end - end - def project_champ(type_de_champ, row_id) check_valid_row_id?(type_de_champ, row_id) champ = champs_by_public_id[type_de_champ.public_id(row_id)] @@ -97,6 +79,15 @@ module DossierChampsConcern .map { _1.repetition? ? project_champ(_1, nil) : champ_for_update(_1, nil, updated_by: nil) } end + def champs_for_export(types_de_champ, row_id = nil) + types_de_champ.flat_map do |type_de_champ| + champ = champ_for_export(type_de_champ, row_id) + type_de_champ.libelles_for_export.map do |(libelle, path)| + [libelle, TypeDeChamp.champ_value_for_export(type_de_champ.type_champ, champ, path)] + end + end + end + def champ_for_update(type_de_champ, row_id, updated_by:) champ, attributes = champ_with_attributes_for_update(type_de_champ, row_id, updated_by:) champ.assign_attributes(attributes) From 6888a3da94f6d6bb36f13f652018b274b3738513 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 18 Oct 2024 15:02:10 +0200 Subject: [PATCH 1280/1532] fix(gallery): display pdf previews without ImageMagick and pdftoppm installed in web machines --- app/helpers/gallery_helper.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/helpers/gallery_helper.rb b/app/helpers/gallery_helper.rb index 0f97ea9b9..c65b26fd3 100644 --- a/app/helpers/gallery_helper.rb +++ b/app/helpers/gallery_helper.rb @@ -2,7 +2,7 @@ module GalleryHelper def displayable_pdf?(blob) - blob.previewable? && blob.content_type.in?(AUTHORIZED_PDF_TYPES) + blob.content_type.in?(AUTHORIZED_PDF_TYPES) end def displayable_image?(blob) @@ -16,8 +16,7 @@ module GalleryHelper end def preview_url_for(attachment) - preview = attachment.preview(resize_to_limit: [400, 400]) - preview.image.attached? ? preview.processed.url : 'pdf-placeholder.png' + attachment.blob.preview_image.url.presence || 'pdf-placeholder.png' rescue StandardError 'pdf-placeholder.png' end From 478103b01a5e3ee7d98e5a9cc64b07051f614463 Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Fri, 18 Oct 2024 16:22:13 +0200 Subject: [PATCH 1281/1532] [#10966] Fix 500 when email is malformed --- app/controllers/email_checker_controller.rb | 2 +- spec/controllers/email_checker_controller_spec.rb | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/controllers/email_checker_controller.rb b/app/controllers/email_checker_controller.rb index 48926a52b..29e9e1230 100644 --- a/app/controllers/email_checker_controller.rb +++ b/app/controllers/email_checker_controller.rb @@ -2,6 +2,6 @@ class EmailCheckerController < ApplicationController def show - render json: EmailChecker.check(email: params[:email]) + render json: EmailChecker.check(email: params.permit(:email)[:email]) end end diff --git a/spec/controllers/email_checker_controller_spec.rb b/spec/controllers/email_checker_controller_spec.rb index ed5732ea2..ce366ec67 100644 --- a/spec/controllers/email_checker_controller_spec.rb +++ b/spec/controllers/email_checker_controller_spec.rb @@ -45,5 +45,13 @@ describe EmailCheckerController, type: :controller do expect(body).to eq({ success: false }) end end + + context 'malformed' do + let(:params) { { email: { some: 'hash' } } } + it do + expect(response).to have_http_status(:success) + expect(body).to eq({ success: false }) + end + end end end From d17b913ccce183ef850d76fb1bf5a549ffc04af1 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 18 Oct 2024 16:05:37 +0200 Subject: [PATCH 1282/1532] db(task): set a date_time to pieces_jointes_seen_at for remaining follows --- ...ws_with_nil_pieces_jointes_seen_at_task.rb | 28 +++++++++++++++++++ ...th_nil_pieces_jointes_seen_at_task_spec.rb | 19 +++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 app/tasks/maintenance/t20241018fix_follows_with_nil_pieces_jointes_seen_at_task.rb create mode 100644 spec/tasks/maintenance/t20241018fix_follows_with_nil_pieces_jointes_seen_at_task_spec.rb diff --git a/app/tasks/maintenance/t20241018fix_follows_with_nil_pieces_jointes_seen_at_task.rb b/app/tasks/maintenance/t20241018fix_follows_with_nil_pieces_jointes_seen_at_task.rb new file mode 100644 index 000000000..96c62ad53 --- /dev/null +++ b/app/tasks/maintenance/t20241018fix_follows_with_nil_pieces_jointes_seen_at_task.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Maintenance + class T20241018fixFollowsWithNilPiecesJointesSeenAtTask < MaintenanceTasks::Task + # Normalement tous les follows auraient du être mis à jour lors de la migration db/migrate/20240911064340_backfill_follows_with_pieces_jointes_seen_at.rb + # Mais, sur l'instance de DS, 57 follows créés lorsque la migration a tourné ont gardé une valeur nulle pour pieces_jointes_seen_at. On les met à jour ici. + + include RunnableOnDeployConcern + include StatementsHelpersConcern + + # Uncomment only if this task MUST run imperatively on its first deployment. + # If possible, leave commented for manual execution later. + # run_on_first_deploy + + def collection + # Collection to be iterated over + # Must be Active Record Relation or Array + Follow.where(pieces_jointes_seen_at: nil) + end + + def process(element) + # The work to be done in a single iteration of the task. + # This should be idempotent, as the same element may be processed more + # than once if the task is interrupted and resumed. + element.update_columns(pieces_jointes_seen_at: Time.zone.now) + end + end +end diff --git a/spec/tasks/maintenance/t20241018fix_follows_with_nil_pieces_jointes_seen_at_task_spec.rb b/spec/tasks/maintenance/t20241018fix_follows_with_nil_pieces_jointes_seen_at_task_spec.rb new file mode 100644 index 000000000..09004f3c8 --- /dev/null +++ b/spec/tasks/maintenance/t20241018fix_follows_with_nil_pieces_jointes_seen_at_task_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +module Maintenance + RSpec.describe T20241018fixFollowsWithNilPiecesJointesSeenAtTask do + describe "#process" do + subject { described_class.process(follow) } + + let(:follow) { create(:follow) } + + before { follow.update_columns(pieces_jointes_seen_at: nil) } + + it "updates the pieces_jointes_seen_at attribute" do + expect { subject }.to change { follow.pieces_jointes_seen_at }.from(nil).to be_within(1.second).of(Time.zone.now) + end + end + end +end From fb07bdb8aab44cec5a0b59815493ebd19ed16ebd Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Fri, 18 Oct 2024 18:17:17 +0200 Subject: [PATCH 1283/1532] fix(dossier): fix preloader with champs outside of revision --- app/models/dossier_preloader.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/dossier_preloader.rb b/app/models/dossier_preloader.rb index c1f8d82fa..f1056a9ad 100644 --- a/app/models/dossier_preloader.rb +++ b/app/models/dossier_preloader.rb @@ -63,7 +63,8 @@ class DossierPreloader def load_etablissements(champs) to_include = @includes_for_etablissement.dup - champs_siret = champs.filter(&:siret?) + # `champs.siret?` will delegate to type_de_champ; this is not what we want here + champs_siret = champs.filter { _1.type == 'Champs::SiretChamp' } etablissements_by_id = Etablissement.includes(to_include).where(id: champs_siret.map(&:etablissement_id).compact).index_by(&:id) champs_siret.each do |champ| etablissement = etablissements_by_id[champ.etablissement_id] From 02934188b46eedee80a1b3ddd7d2a2f0d4b2aa5b Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 21 Oct 2024 11:51:34 +0200 Subject: [PATCH 1284/1532] refactor(champ): move champ value format methods from TypeDeChamp class to instance --- app/graphql/types/champ_type.rb | 6 +- app/models/champ.rb | 18 +-- app/models/type_de_champ.rb | 126 +++++++++--------- .../types_de_champ/address_type_de_champ.rb | 48 ++++--- .../types_de_champ/carte_type_de_champ.rb | 12 +- .../types_de_champ/checkbox_type_de_champ.rb | 58 ++++---- .../types_de_champ/cojo_type_de_champ.rb | 6 +- .../types_de_champ/commune_type_de_champ.rb | 42 +++--- .../types_de_champ/date_type_de_champ.rb | 10 +- .../types_de_champ/datetime_type_de_champ.rb | 6 +- .../decimal_number_type_de_champ.rb | 40 +++--- .../departement_type_de_champ.rb | 48 ++++--- .../types_de_champ/epci_type_de_champ.rb | 36 +++-- .../integer_number_type_de_champ.rb | 40 +++--- .../linked_drop_down_list_type_de_champ.rb | 56 ++++---- .../multiple_drop_down_list_type_de_champ.rb | 18 ++- .../types_de_champ/pays_type_de_champ.rb | 34 +++-- .../types_de_champ/phone_type_de_champ.rb | 16 +-- .../piece_justificative_type_de_champ.rb | 22 ++- .../types_de_champ/region_type_de_champ.rb | 34 +++-- .../repetition_type_de_champ.rb | 2 +- .../types_de_champ/rna_type_de_champ.rb | 6 +- .../types_de_champ/rnf_type_de_champ.rb | 52 ++++---- .../types_de_champ/textarea_type_de_champ.rb | 6 +- .../titre_identite_type_de_champ.rb | 18 ++- .../types_de_champ/type_de_champ_base.rb | 58 ++++---- .../types_de_champ/yes_no_type_de_champ.rb | 78 ++++++----- app/serializers/champ_serializer.rb | 2 +- app/serializers/dossier_serializer.rb | 2 +- app/services/dossier_projection_service.rb | 14 +- .../api/v2/graphql_controller_spec.rb | 2 +- spec/models/champ_spec.rb | 24 ++-- spec/models/champs/carte_champ_spec.rb | 4 +- spec/models/champs/commune_champ_spec.rb | 12 +- .../champs/decimal_number_champ_spec.rb | 2 +- spec/models/champs/departement_champ_spec.rb | 1 - .../linked_drop_down_list_champ_spec.rb | 2 +- .../multiple_drop_down_list_champ_spec.rb | 2 +- .../champs/piece_justificative_champ_spec.rb | 4 +- spec/models/champs/repetition_champ_spec.rb | 4 +- spec/models/champs/rna_champ_spec.rb | 4 +- spec/models/champs/rnf_champ_spec.rb | 10 +- .../champs/titre_identite_champ_spec.rb | 2 +- spec/system/users/en_construction_spec.rb | 4 +- 44 files changed, 468 insertions(+), 523 deletions(-) diff --git a/app/graphql/types/champ_type.rb b/app/graphql/types/champ_type.rb index f5d7ec3e1..183955fec 100644 --- a/app/graphql/types/champ_type.rb +++ b/app/graphql/types/champ_type.rb @@ -7,10 +7,14 @@ module Types global_id_field :id field :champ_descriptor_id, String, "L'identifiant du champDescriptor de ce champ", null: false field :label, String, "Libellé du champ.", null: false, method: :libelle - field :string_value, String, "La valeur du champ sous forme texte.", null: true, method: :for_api_v2 + field :string_value, String, "La valeur du champ sous forme texte.", null: true field :updated_at, GraphQL::Types::ISO8601DateTime, "Date de dernière modification du champ.", null: false field :prefilled, Boolean, null: false, method: :prefilled? + def string_value + object.type_de_champ.champ_value_for_api(object) + end + definition_methods do def resolve_type(object, context) case object diff --git a/app/models/champ.rb b/app/models/champ.rb index e193a7a10..7bd7fc223 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -111,23 +111,11 @@ class Champ < ApplicationRecord end def to_s - TypeDeChamp.champ_value(type_champ, self) + type_de_champ.champ_value(self) end - def for_api - TypeDeChamp.champ_value_for_api(type_champ, self, 1) - end - - def for_api_v2 - TypeDeChamp.champ_value_for_api(type_champ, self, 2) - end - - def for_export(path = :value) - TypeDeChamp.champ_value_for_export(type_champ, self, path) - end - - def for_tag(path = :value) - TypeDeChamp.champ_value_for_tag(type_champ, self, path) + def last_write_type_champ + TypeDeChamp::CHAMP_TYPE_TO_TYPE_CHAMP.fetch(type) end def main_value_name diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 39b221fa7..d0f6865e3 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -696,77 +696,35 @@ class TypeDeChamp < ApplicationRecord options.slice(*kept_keys.map(&:to_s)) end - class << self - def champ_value(type_champ, champ) - dynamic_type_class = type_champ_to_class_name(type_champ).constantize - if use_default_value?(type_champ, champ) - dynamic_type_class.champ_default_value - else - dynamic_type_class.champ_value(champ) - end + def champ_value(champ) + if use_default_value?(champ) + dynamic_type.champ_default_value + else + dynamic_type.champ_value(champ) end + end - def champ_value_for_api(type_champ, champ, version = 2) - dynamic_type_class = type_champ_to_class_name(type_champ).constantize - if use_default_value?(type_champ, champ) - dynamic_type_class.champ_default_api_value(version) - else - dynamic_type_class.champ_value_for_api(champ, version) - end + def champ_value_for_api(champ, version: 2) + if use_default_value?(champ) + dynamic_type.champ_default_api_value(version) + else + dynamic_type.champ_value_for_api(champ, version:) end + end - def champ_value_for_export(type_champ, champ, path = :value) - dynamic_type_class = type_champ_to_class_name(type_champ).constantize - if use_default_value?(type_champ, champ) - dynamic_type_class.champ_default_export_value(path) - else - dynamic_type_class.champ_value_for_export(champ, path) - end + def champ_value_for_export(champ, path = :value) + if use_default_value?(champ) + dynamic_type.champ_default_export_value(path) + else + dynamic_type.champ_value_for_export(champ, path) end + end - def champ_value_for_tag(type_champ, champ, path = :value) - if use_default_value?(type_champ, champ) - '' - else - dynamic_type_class = type_champ_to_class_name(type_champ).constantize - dynamic_type_class.champ_value_for_tag(champ, path) - end - end - - def type_champ_to_champ_class_name(type_champ) - "Champs::#{type_champ.classify}Champ" - end - - def type_champ_to_class_name(type_champ) - "TypesDeChamp::#{type_champ.classify}TypeDeChamp" - end - - private - - def use_default_value?(type_champ, champ) - # no champ - return true if champ.nil? - # type de champ on the revision changed - if type_champ != champ.type_champ - return !castable_on_change?(type_champ, champ.type_champ) - end - # special case for linked drop down champ – it's blank implementation is not what you think - return champ.value.blank? if type_champ == TypeDeChamp.type_champs.fetch(:linked_drop_down_list) - champ.blank? - end - - def castable_on_change?(from_type, to_type) - case [from_type, to_type] - when ['integer_number', 'decimal_number'], # recast numbers automatically - ['decimal_number', 'integer_number'], # may lose some data, but who cares ? - ['text', 'textarea'], # allow short text to long text - ['drop_down_list', 'multiple_drop_down_list'], # single list can become multi - ['date', 'datetime'], # date <=> datetime - ['datetime', 'date'] # may lose some data, but who cares ? - true - else - false - end + def champ_value_for_tag(champ, path = :value) + if use_default_value?(champ) + '' + else + dynamic_type.champ_value_for_tag(champ, path) end end @@ -774,8 +732,46 @@ class TypeDeChamp < ApplicationRecord "champ-#{public_id(row_id)}" end + class << self + def type_champ_to_champ_class_name(type_champ) + "Champs::#{type_champ.classify}Champ" + end + + def type_champ_to_class_name(type_champ) + "TypesDeChamp::#{type_champ.classify}TypeDeChamp" + end + end + + CHAMP_TYPE_TO_TYPE_CHAMP = type_champs.values.map { [type_champ_to_champ_class_name(_1), _1] }.to_h + private + def use_default_value?(champ) + # no champ + return true if champ.nil? + # type de champ on the revision changed + if champ.last_write_type_champ != type_champ + return !castable_on_change?(champ.last_write_type_champ, type_champ) + end + # special case for linked drop down champ – it's blank implementation is not what you think + return champ.value.blank? if type_champ == TypeDeChamp.type_champs.fetch(:linked_drop_down_list) + champ.blank? + end + + def castable_on_change?(from_type, to_type) + case [from_type, to_type] + when ['integer_number', 'decimal_number'], # recast numbers automatically + ['decimal_number', 'integer_number'], # may lose some data, but who cares ? + ['text', 'textarea'], # allow short text to long text + ['drop_down_list', 'multiple_drop_down_list'], # single list can become multi + ['date', 'datetime'], # date <=> datetime + ['datetime', 'date'] # may lose some data, but who cares ? + true + else + false + end + end + def populate_stable_id if !stable_id update_column(:stable_id, id) diff --git a/app/models/types_de_champ/address_type_de_champ.rb b/app/models/types_de_champ/address_type_de_champ.rb index 33889846b..5173b4712 100644 --- a/app/models/types_de_champ/address_type_de_champ.rb +++ b/app/models/types_de_champ/address_type_de_champ.rb @@ -6,35 +6,33 @@ class TypesDeChamp::AddressTypeDeChamp < TypesDeChamp::TextTypeDeChamp [[path[:libelle], path[:path]]] end - class << self - def champ_value(champ) - champ.address_label.presence || '' - end + def champ_value(champ) + champ.address_label.presence || '' + end - def champ_value_for_api(champ, version = 2) + def champ_value_for_api(champ, version: 2) + champ_value(champ) + end + + def champ_value_for_tag(champ, path = :value) + case path + when :value champ_value(champ) + when :departement + champ.departement_code_and_name || '' + when :commune + champ.commune_name || '' end + end - def champ_value_for_tag(champ, path = :value) - case path - when :value - champ_value(champ) - when :departement - champ.departement_code_and_name || '' - when :commune - champ.commune_name || '' - end - end - - def champ_value_for_export(champ, path = :value) - case path - when :value - champ_value(champ) - when :departement - champ.departement_code_and_name - when :commune - champ.commune_name - end + def champ_value_for_export(champ, path = :value) + case path + when :value + champ_value(champ) + when :departement + champ.departement_code_and_name + when :commune + champ.commune_name end end diff --git a/app/models/types_de_champ/carte_type_de_champ.rb b/app/models/types_de_champ/carte_type_de_champ.rb index 304c64105..9a16c6392 100644 --- a/app/models/types_de_champ/carte_type_de_champ.rb +++ b/app/models/types_de_champ/carte_type_de_champ.rb @@ -20,13 +20,11 @@ class TypesDeChamp::CarteTypeDeChamp < TypesDeChamp::TypeDeChampBase def tags_for_template = [].freeze - class << self - def champ_value_for_api(champ, version = 2) - nil - end + def champ_value_for_api(champ, version: 2) + nil + end - def champ_value_for_export(champ, path = :value) - champ.geo_areas.map(&:label).join("\n") - end + def champ_value_for_export(champ, path = :value) + champ.geo_areas.map(&:label).join("\n") end end diff --git a/app/models/types_de_champ/checkbox_type_de_champ.rb b/app/models/types_de_champ/checkbox_type_de_champ.rb index f2692c5e5..338352c22 100644 --- a/app/models/types_de_champ/checkbox_type_de_champ.rb +++ b/app/models/types_de_champ/checkbox_type_de_champ.rb @@ -11,43 +11,41 @@ class TypesDeChamp::CheckboxTypeDeChamp < TypesDeChamp::TypeDeChampBase end end - class << self - def champ_value(champ) - champ.true? ? 'Oui' : 'Non' - end + def champ_value(champ) + champ.true? ? 'Oui' : 'Non' + end - def champ_value_for_tag(champ, path = :value) - champ_value(champ) - end + def champ_value_for_tag(champ, path = :value) + champ_value(champ) + end - def champ_value_for_export(champ, path = :value) - champ.true? ? 'on' : 'off' - end + def champ_value_for_export(champ, path = :value) + champ.true? ? 'on' : 'off' + end - def champ_value_for_api(champ, version = 2) - case version - when 2 - champ.true? ? 'true' : 'false' - else - super - end + def champ_value_for_api(champ, version: 2) + case version + when 2 + champ.true? ? 'true' : 'false' + else + super end + end - def champ_default_value - 'Non' - end + def champ_default_value + 'Non' + end - def champ_default_export_value(path = :value) - 'off' - end + def champ_default_export_value(path = :value) + 'off' + end - def champ_default_api_value(version = 2) - case version - when 2 - 'false' - else - nil - end + def champ_default_api_value(version = 2) + case version + when 2 + 'false' + else + nil end end end diff --git a/app/models/types_de_champ/cojo_type_de_champ.rb b/app/models/types_de_champ/cojo_type_de_champ.rb index 3c1f45e97..d596d6a7c 100644 --- a/app/models/types_de_champ/cojo_type_de_champ.rb +++ b/app/models/types_de_champ/cojo_type_de_champ.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true class TypesDeChamp::COJOTypeDeChamp < TypesDeChamp::TextTypeDeChamp - class << self - def champ_value(champ) - "#{champ.accreditation_number} – #{champ.accreditation_birthdate}" - end + def champ_value(champ) + "#{champ.accreditation_number} – #{champ.accreditation_birthdate}" end end diff --git a/app/models/types_de_champ/commune_type_de_champ.rb b/app/models/types_de_champ/commune_type_de_champ.rb index 9706f26b9..3f2766d6c 100644 --- a/app/models/types_de_champ/commune_type_de_champ.rb +++ b/app/models/types_de_champ/commune_type_de_champ.rb @@ -1,32 +1,30 @@ # frozen_string_literal: true class TypesDeChamp::CommuneTypeDeChamp < TypesDeChamp::TypeDeChampBase - class << self - def champ_value_for_export(champ, path = :value) - case path - when :value - champ_value(champ) - when :departement - champ.departement_code_and_name || '' - when :code - champ.code || '' - end + def champ_value_for_export(champ, path = :value) + case path + when :value + champ_value(champ) + when :departement + champ.departement_code_and_name || '' + when :code + champ.code || '' end + end - def champ_value_for_tag(champ, path = :value) - case path - when :value - champ_value(champ) - when :departement - champ.departement_code_and_name || '' - when :code - champ.code || '' - end + def champ_value_for_tag(champ, path = :value) + case path + when :value + champ_value(champ) + when :departement + champ.departement_code_and_name || '' + when :code + champ.code || '' end + end - def champ_value(champ) - champ.code_postal? ? "#{champ.name} (#{champ.code_postal})" : champ.name - end + def champ_value(champ) + champ.code_postal? ? "#{champ.name} (#{champ.code_postal})" : champ.name end private diff --git a/app/models/types_de_champ/date_type_de_champ.rb b/app/models/types_de_champ/date_type_de_champ.rb index b6a838853..b332e34d7 100644 --- a/app/models/types_de_champ/date_type_de_champ.rb +++ b/app/models/types_de_champ/date_type_de_champ.rb @@ -1,11 +1,9 @@ # frozen_string_literal: true class TypesDeChamp::DateTypeDeChamp < TypesDeChamp::TypeDeChampBase - class << self - def champ_value(champ) - I18n.l(Time.zone.parse(champ.value), format: '%d %B %Y') - rescue ArgumentError - champ.value.presence || "" # old dossiers can have not parseable dates - end + def champ_value(champ) + I18n.l(Time.zone.parse(champ.value), format: '%d %B %Y') + rescue ArgumentError + champ.value.presence || "" # old dossiers can have not parseable dates end end diff --git a/app/models/types_de_champ/datetime_type_de_champ.rb b/app/models/types_de_champ/datetime_type_de_champ.rb index 52b6ac6cd..e74423665 100644 --- a/app/models/types_de_champ/datetime_type_de_champ.rb +++ b/app/models/types_de_champ/datetime_type_de_champ.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true class TypesDeChamp::DatetimeTypeDeChamp < TypesDeChamp::TypeDeChampBase - class << self - def champ_value(champ) - I18n.l(Time.zone.parse(champ.value)) - end + def champ_value(champ) + I18n.l(Time.zone.parse(champ.value)) end end diff --git a/app/models/types_de_champ/decimal_number_type_de_champ.rb b/app/models/types_de_champ/decimal_number_type_de_champ.rb index c16ac6023..baf62f2b1 100644 --- a/app/models/types_de_champ/decimal_number_type_de_champ.rb +++ b/app/models/types_de_champ/decimal_number_type_de_champ.rb @@ -1,28 +1,26 @@ # frozen_string_literal: true class TypesDeChamp::DecimalNumberTypeDeChamp < TypesDeChamp::TypeDeChampBase - class << self - def champ_value_for_export(champ, path = :value) + def champ_value_for_export(champ, path = :value) + champ_formatted_value(champ) + end + + def champ_value_for_api(champ, version: 2) + case version + when 1 champ_formatted_value(champ) - end - - def champ_value_for_api(champ, version = 2) - case version - when 1 - champ_formatted_value(champ) - else - super - end - end - - def champ_default_export_value(path = :value) - 0 - end - - private - - def champ_formatted_value(champ) - champ.value&.to_f + else + super end end + + def champ_default_export_value(path = :value) + 0 + end + + private + + def champ_formatted_value(champ) + champ.value&.to_f + end end diff --git a/app/models/types_de_champ/departement_type_de_champ.rb b/app/models/types_de_champ/departement_type_de_champ.rb index af1014a05..cf296bac0 100644 --- a/app/models/types_de_champ/departement_type_de_champ.rb +++ b/app/models/types_de_champ/departement_type_de_champ.rb @@ -5,36 +5,34 @@ class TypesDeChamp::DepartementTypeDeChamp < TypesDeChamp::TextTypeDeChamp APIGeoService.departement_name(filter_value).presence || filter_value end - class << self - def champ_value(champ) - "#{champ.code} – #{champ.name}" - end + def champ_value(champ) + "#{champ.code} – #{champ.name}" + end - def champ_value_for_export(champ, path = :value) - case path - when :code - champ.code - when :value - champ.name - end + def champ_value_for_export(champ, path = :value) + case path + when :code + champ.code + when :value + champ.name end + end - def champ_value_for_tag(champ, path = :value) - case path - when :code - champ.code - when :value - champ_value(champ) - end + def champ_value_for_tag(champ, path = :value) + case path + when :code + champ.code + when :value + champ_value(champ) end + end - def champ_value_for_api(champ, version = 2) - case version - when 2 - champ_value(champ).tr('–', '-') - else - champ_value(champ) - end + def champ_value_for_api(champ, version: 2) + case version + when 2 + champ_value(champ).tr('–', '-') + else + champ_value(champ) end end diff --git a/app/models/types_de_champ/epci_type_de_champ.rb b/app/models/types_de_champ/epci_type_de_champ.rb index 19ab9623c..5fcc09ad4 100644 --- a/app/models/types_de_champ/epci_type_de_champ.rb +++ b/app/models/types_de_champ/epci_type_de_champ.rb @@ -1,27 +1,25 @@ # frozen_string_literal: true class TypesDeChamp::EpciTypeDeChamp < TypesDeChamp::TextTypeDeChamp - class << self - def champ_value_for_export(champ, path = :value) - case path - when :value - champ_value(champ) - when :code - champ.code - when :departement - champ.departement_code_and_name - end + def champ_value_for_export(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code + when :departement + champ.departement_code_and_name end + end - def champ_value_for_tag(champ, path = :value) - case path - when :value - champ_value(champ) - when :code - champ.code - when :departement - champ.departement_code_and_name - end + def champ_value_for_tag(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code + when :departement + champ.departement_code_and_name end end diff --git a/app/models/types_de_champ/integer_number_type_de_champ.rb b/app/models/types_de_champ/integer_number_type_de_champ.rb index d36575533..5a7670a83 100644 --- a/app/models/types_de_champ/integer_number_type_de_champ.rb +++ b/app/models/types_de_champ/integer_number_type_de_champ.rb @@ -1,28 +1,26 @@ # frozen_string_literal: true class TypesDeChamp::IntegerNumberTypeDeChamp < TypesDeChamp::TypeDeChampBase - class << self - def champ_value_for_export(champ, path = :value) + def champ_value_for_export(champ, path = :value) + champ_formatted_value(champ) + end + + def champ_value_for_api(champ, version: 2) + case version + when 1 champ_formatted_value(champ) - end - - def champ_value_for_api(champ, version = 2) - case version - when 1 - champ_formatted_value(champ) - else - super - end - end - - def champ_default_export_value(path = :value) - 0 - end - - private - - def champ_formatted_value(champ) - champ.value&.to_i + else + super end end + + def champ_default_export_value(path = :value) + 0 + end + + private + + def champ_formatted_value(champ) + champ.value&.to_i + end end diff --git a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb index 5459a7d4e..85b7922fb 100644 --- a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb @@ -32,40 +32,38 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas secondary_options end - class << self - def champ_value(champ) - [champ.primary_value, champ.secondary_value].filter(&:present?).join(' / ') - end + def champ_value(champ) + [champ.primary_value, champ.secondary_value].filter(&:present?).join(' / ') + end - def champ_value_for_tag(champ, path = :value) - case path - when :primary - champ.primary_value - when :secondary - champ.secondary_value - when :value - champ_value(champ) - end + def champ_value_for_tag(champ, path = :value) + case path + when :primary + champ.primary_value + when :secondary + champ.secondary_value + when :value + champ_value(champ) end + end - def champ_value_for_export(champ, path = :value) - case path - when :primary - champ.primary_value - when :secondary - champ.secondary_value - when :value - "#{champ.primary_value || ''};#{champ.secondary_value || ''}" - end + def champ_value_for_export(champ, path = :value) + case path + when :primary + champ.primary_value + when :secondary + champ.secondary_value + when :value + "#{champ.primary_value || ''};#{champ.secondary_value || ''}" end + end - def champ_value_for_api(champ, version = 2) - case version - when 1 - { primary: champ.primary_value, secondary: champ.secondary_value } - else - super - end + def champ_value_for_api(champ, version: 2) + case version + when 1 + { primary: champ.primary_value, secondary: champ.secondary_value } + else + super end end diff --git a/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb index 2e8adf47d..3a0d3c2c6 100644 --- a/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb @@ -1,17 +1,15 @@ # frozen_string_literal: true class TypesDeChamp::MultipleDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBase - class << self - def champ_value(champ) - champ.selected_options.join(', ') - end + def champ_value(champ) + champ.selected_options.join(', ') + end - def champ_value_for_tag(champ, path = :value) - ChampPresentations::MultipleDropDownListPresentation.new(champ.selected_options) - end + def champ_value_for_tag(champ, path = :value) + ChampPresentations::MultipleDropDownListPresentation.new(champ.selected_options) + end - def champ_value_for_export(champ, path = :value) - champ.selected_options.join(', ') - end + def champ_value_for_export(champ, path = :value) + champ.selected_options.join(', ') end end diff --git a/app/models/types_de_champ/pays_type_de_champ.rb b/app/models/types_de_champ/pays_type_de_champ.rb index 7a92ce0ec..54aa9de9b 100644 --- a/app/models/types_de_champ/pays_type_de_champ.rb +++ b/app/models/types_de_champ/pays_type_de_champ.rb @@ -1,27 +1,25 @@ # frozen_string_literal: true class TypesDeChamp::PaysTypeDeChamp < TypesDeChamp::TextTypeDeChamp - class << self - def champ_value(champ) - champ.name - end + def champ_value(champ) + champ.name + end - def champ_value_for_export(champ, path = :value) - case path - when :value - champ_value(champ) - when :code - champ.code - end + def champ_value_for_export(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code end + end - def champ_value_for_tag(champ, path = :value) - case path - when :value - champ_value(champ) - when :code - champ.code - end + def champ_value_for_tag(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code end end diff --git a/app/models/types_de_champ/phone_type_de_champ.rb b/app/models/types_de_champ/phone_type_de_champ.rb index 3e0329564..7d95156b3 100644 --- a/app/models/types_de_champ/phone_type_de_champ.rb +++ b/app/models/types_de_champ/phone_type_de_champ.rb @@ -22,15 +22,13 @@ class TypesDeChamp::PhoneTypeDeChamp < TypesDeChamp::TextTypeDeChamp # See issue #6996. DEFAULT_COUNTRY_CODES = [:FR, :GP, :GF, :MQ, :RE, :YT, :NC, :PF].freeze - class << self - def champ_value(champ) - if Phonelib.valid_for_countries?(champ.value, DEFAULT_COUNTRY_CODES) - Phonelib.parse_for_countries(champ.value, DEFAULT_COUNTRY_CODES).full_national - else - # When he phone number is possible for the default countries, but not strictly valid, - # `full_national` could mess up the formatting. In this case just return the original. - champ.value - end + def champ_value(champ) + if Phonelib.valid_for_countries?(champ.value, DEFAULT_COUNTRY_CODES) + Phonelib.parse_for_countries(champ.value, DEFAULT_COUNTRY_CODES).full_national + else + # When he phone number is possible for the default countries, but not strictly valid, + # `full_national` could mess up the formatting. In this case just return the original. + champ.value end end end diff --git a/app/models/types_de_champ/piece_justificative_type_de_champ.rb b/app/models/types_de_champ/piece_justificative_type_de_champ.rb index 6afc67ee1..6f00eec3a 100644 --- a/app/models/types_de_champ/piece_justificative_type_de_champ.rb +++ b/app/models/types_de_champ/piece_justificative_type_de_champ.rb @@ -7,21 +7,19 @@ class TypesDeChamp::PieceJustificativeTypeDeChamp < TypesDeChamp::TypeDeChampBas def tags_for_template = [].freeze - class << self - def champ_value_for_export(champ, path = :value) - champ.piece_justificative_file.map { _1.filename.to_s }.join(', ') - end + def champ_value_for_export(champ, path = :value) + champ.piece_justificative_file.map { _1.filename.to_s }.join(', ') + end - def champ_value_for_api(champ, version = 2) - return if version == 2 + def champ_value_for_api(champ, version: 2) + return if version == 2 - # API v1 don't support multiple PJ - attachment = champ.piece_justificative_file.first - return if attachment.nil? + # API v1 don't support multiple PJ + attachment = champ.piece_justificative_file.first + return if attachment.nil? - if attachment.virus_scanner.safe? || attachment.virus_scanner.pending? - attachment.url - end + if attachment.virus_scanner.safe? || attachment.virus_scanner.pending? + attachment.url end end end diff --git a/app/models/types_de_champ/region_type_de_champ.rb b/app/models/types_de_champ/region_type_de_champ.rb index 439dac6eb..ad31c7710 100644 --- a/app/models/types_de_champ/region_type_de_champ.rb +++ b/app/models/types_de_champ/region_type_de_champ.rb @@ -5,27 +5,25 @@ class TypesDeChamp::RegionTypeDeChamp < TypesDeChamp::TextTypeDeChamp APIGeoService.region_name(filter_value).presence || filter_value end - class << self - def champ_value(champ) - champ.name - end + def champ_value(champ) + champ.name + end - def champ_value_for_export(champ, path = :value) - case path - when :value - champ_value(champ) - when :code - champ.code - end + def champ_value_for_export(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code end + end - def champ_value_for_tag(champ, path = :value) - case path - when :value - champ_value(champ) - when :code - champ.code - end + def champ_value_for_tag(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code end end diff --git a/app/models/types_de_champ/repetition_type_de_champ.rb b/app/models/types_de_champ/repetition_type_de_champ.rb index dea997ad9..e281c0abf 100644 --- a/app/models/types_de_champ/repetition_type_de_champ.rb +++ b/app/models/types_de_champ/repetition_type_de_champ.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class TypesDeChamp::RepetitionTypeDeChamp < TypesDeChamp::TypeDeChampBase - def self.champ_value_for_tag(champ, path = :value) + def champ_value_for_tag(champ, path = :value) return nil if path != :value return champ_default_value if champ.rows.blank? diff --git a/app/models/types_de_champ/rna_type_de_champ.rb b/app/models/types_de_champ/rna_type_de_champ.rb index bd55e5298..e60c34a89 100644 --- a/app/models/types_de_champ/rna_type_de_champ.rb +++ b/app/models/types_de_champ/rna_type_de_champ.rb @@ -7,9 +7,7 @@ class TypesDeChamp::RNATypeDeChamp < TypesDeChamp::TypeDeChampBase FILL_DURATION_MEDIUM end - class << self - def champ_value_for_export(champ, path = :value) - champ.identifier - end + def champ_value_for_export(champ, path = :value) + champ.identifier end end diff --git a/app/models/types_de_champ/rnf_type_de_champ.rb b/app/models/types_de_champ/rnf_type_de_champ.rb index e7d0fe426..b93ae872e 100644 --- a/app/models/types_de_champ/rnf_type_de_champ.rb +++ b/app/models/types_de_champ/rnf_type_de_champ.rb @@ -3,35 +3,33 @@ class TypesDeChamp::RNFTypeDeChamp < TypesDeChamp::TextTypeDeChamp include AddressableColumnConcern - class << self - def champ_value_for_export(champ, path = :value) - case path - when :value - champ.rnf_id - when :departement - champ.departement_code_and_name - when :code_insee - champ.commune&.fetch(:code) - when :address - champ.full_address - when :nom - champ.title - end + def champ_value_for_export(champ, path = :value) + case path + when :value + champ.rnf_id + when :departement + champ.departement_code_and_name + when :code_insee + champ.commune&.fetch(:code) + when :address + champ.full_address + when :nom + champ.title end + end - def champ_value_for_tag(champ, path = :value) - case path - when :value - champ.rnf_id - when :departement - champ.departement_code_and_name || '' - when :code_insee - champ.commune&.fetch(:code) || '' - when :address - champ.full_address || '' - when :nom - champ.title || '' - end + def champ_value_for_tag(champ, path = :value) + case path + when :value + champ.rnf_id + when :departement + champ.departement_code_and_name || '' + when :code_insee + champ.commune&.fetch(:code) || '' + when :address + champ.full_address || '' + when :nom + champ.title || '' end end diff --git a/app/models/types_de_champ/textarea_type_de_champ.rb b/app/models/types_de_champ/textarea_type_de_champ.rb index 4c8a47c7a..d16309498 100644 --- a/app/models/types_de_champ/textarea_type_de_champ.rb +++ b/app/models/types_de_champ/textarea_type_de_champ.rb @@ -5,9 +5,7 @@ class TypesDeChamp::TextareaTypeDeChamp < TypesDeChamp::TextTypeDeChamp FILL_DURATION_MEDIUM end - class << self - def champ_value_for_export(champ, path = :value) - ActionView::Base.full_sanitizer.sanitize(champ.value) - end + def champ_value_for_export(champ, path = :value) + ActionView::Base.full_sanitizer.sanitize(champ.value) end end diff --git a/app/models/types_de_champ/titre_identite_type_de_champ.rb b/app/models/types_de_champ/titre_identite_type_de_champ.rb index 59353b669..7a10280c5 100644 --- a/app/models/types_de_champ/titre_identite_type_de_champ.rb +++ b/app/models/types_de_champ/titre_identite_type_de_champ.rb @@ -10,17 +10,15 @@ class TypesDeChamp::TitreIdentiteTypeDeChamp < TypesDeChamp::TypeDeChampBase def tags_for_template = [].freeze - class << self - def champ_value_for_export(champ, path = :value) - champ.piece_justificative_file.attached? ? "présent" : "absent" - end + def champ_value_for_export(champ, path = :value) + champ.piece_justificative_file.attached? ? "présent" : "absent" + end - def champ_value_for_api(champ, version = 2) - nil - end + def champ_value_for_api(champ, version: 2) + nil + end - def champ_default_export_value(path = :value) - "absent" - end + def champ_default_export_value(path = :value) + "absent" end end diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index c01ad1d25..990c68987 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -54,44 +54,42 @@ class TypesDeChamp::TypeDeChampBase filter_value end - class << self - def champ_value(champ) - champ.value.present? ? champ.value.to_s : champ_default_value - end + def champ_value(champ) + champ.value.present? ? champ.value.to_s : champ_default_value + end - def champ_value_for_api(champ, version = 2) - case version - when 2 - champ_value(champ) - else - champ.value.presence || champ_default_api_value(version) - end + def champ_value_for_api(champ, version: 2) + case version + when 2 + champ_value(champ) + else + champ.value.presence || champ_default_api_value(version) end + end - def champ_value_for_export(champ, path = :value) - path == :value ? champ.value.presence : champ_default_export_value(path) - end + def champ_value_for_export(champ, path = :value) + path == :value ? champ.value.presence : champ_default_export_value(path) + end - def champ_value_for_tag(champ, path = :value) - path == :value ? champ_value(champ) : nil - end + def champ_value_for_tag(champ, path = :value) + path == :value ? champ_value(champ) : nil + end - def champ_default_value + def champ_default_value + '' + end + + def champ_default_export_value(path = :value) + nil + end + + def champ_default_api_value(version = 2) + case version + when 2 '' - end - - def champ_default_export_value(path = :value) + else nil end - - def champ_default_api_value(version = 2) - case version - when 2 - '' - else - nil - end - end end def columns(procedure_id:, displayable: true, prefix: nil) diff --git a/app/models/types_de_champ/yes_no_type_de_champ.rb b/app/models/types_de_champ/yes_no_type_de_champ.rb index c34ba1dcf..33148b161 100644 --- a/app/models/types_de_champ/yes_no_type_de_champ.rb +++ b/app/models/types_de_champ/yes_no_type_de_champ.rb @@ -11,49 +11,47 @@ class TypesDeChamp::YesNoTypeDeChamp < TypesDeChamp::CheckboxTypeDeChamp end end - class << self - def champ_value(champ) - champ_formatted_value(champ) - end + def champ_value(champ) + champ_formatted_value(champ) + end - def champ_value_for_tag(champ, path = :value) - champ_formatted_value(champ) - end + def champ_value_for_tag(champ, path = :value) + champ_formatted_value(champ) + end - def champ_value_for_export(champ, path = :value) - champ_formatted_value(champ) - end + def champ_value_for_export(champ, path = :value) + champ_formatted_value(champ) + end - def champ_value_for_api(champ, version = 2) - case version - when 2 - champ.true? ? 'true' : 'false' - else - super - end - end - - def champ_default_value - 'Non' - end - - def champ_default_export_value(path = :value) - 'Non' - end - - def champ_default_api_value(version = 2) - case version - when 2 - 'false' - else - nil - end - end - - private - - def champ_formatted_value(champ) - champ.true? ? 'Oui' : 'Non' + def champ_value_for_api(champ, version: 2) + case version + when 2 + champ.true? ? 'true' : 'false' + else + super end end + + def champ_default_value + 'Non' + end + + def champ_default_export_value(path = :value) + 'Non' + end + + def champ_default_api_value(version = 2) + case version + when 2 + 'false' + else + nil + end + end + + private + + def champ_formatted_value(champ) + champ.true? ? 'Oui' : 'Non' + end end diff --git a/app/serializers/champ_serializer.rb b/app/serializers/champ_serializer.rb index 5a555cd27..4ba762f05 100644 --- a/app/serializers/champ_serializer.rb +++ b/app/serializers/champ_serializer.rb @@ -17,7 +17,7 @@ class ChampSerializer < ActiveModel::Serializer when GeoArea object.geometry else - object.for_api + object.type_de_champ.champ_value_for_api(object, version: 1) end end diff --git a/app/serializers/dossier_serializer.rb b/app/serializers/dossier_serializer.rb index 474a79131..33bc5e75c 100644 --- a/app/serializers/dossier_serializer.rb +++ b/app/serializers/dossier_serializer.rb @@ -65,7 +65,7 @@ class DossierSerializer < ActiveModel::Serializer { created_at: champ.created_at&.in_time_zone('UTC'), type_de_piece_justificative_id: champ.type_de_champ.old_pj[:stable_id], - content_url: champ.for_api, + content_url: champ.type_de_champ.champ_value_for_api(champ, version: 1), user: champ.dossier.user } end.flatten diff --git a/app/services/dossier_projection_service.rb b/app/services/dossier_projection_service.rb index 041969489..dfe9039b7 100644 --- a/app/services/dossier_projection_service.rb +++ b/app/services/dossier_projection_service.rb @@ -175,16 +175,16 @@ class DossierProjectionService def champ_value_formatter(dossiers_ids, fields) stable_ids = fields.filter { _1[TABLE].in?(['type_de_champ', 'type_de_champ_private']) }.map { _1[COLUMN] } revision_ids_by_dossier_ids = Dossier.where(id: dossiers_ids).pluck(:id, :revision_id).to_h - stable_ids_and_types_champ_by_revision_ids = ProcedureRevisionTypeDeChamp.includes(:type_de_champ) + stable_ids_and_types_de_champ_by_revision_ids = ProcedureRevisionTypeDeChamp.includes(:type_de_champ) .where(revision_id: revision_ids_by_dossier_ids.values.uniq, type_de_champ: { stable_id: stable_ids }) - .pluck(:revision_id, 'type_de_champ.stable_id', 'type_de_champ.type_champ') + .map { [_1.revision_id, _1.type_de_champ] } .group_by(&:first) - .transform_values { _1.map { |_, stable_id, type_champ| [stable_id, type_champ] }.to_h } - stable_ids_and_types_champ_by_dossier_ids = revision_ids_by_dossier_ids.transform_values { stable_ids_and_types_champ_by_revision_ids[_1] }.compact + .transform_values { _1.map { |_, type_de_champ| [type_de_champ.stable_id, type_de_champ] }.to_h } + stable_ids_and_types_de_champ_by_dossier_ids = revision_ids_by_dossier_ids.transform_values { stable_ids_and_types_de_champ_by_revision_ids[_1] }.compact -> (champ) { - type_champ = stable_ids_and_types_champ_by_dossier_ids.fetch(champ.dossier_id, {})[champ.stable_id] - if type_champ.present? && TypeDeChamp.type_champ_to_champ_class_name(type_champ) == champ.type - TypeDeChamp.champ_value(type_champ, champ) + type_de_champ = stable_ids_and_types_de_champ_by_dossier_ids.fetch(champ.dossier_id, {})[champ.stable_id] + if type_de_champ.present? && type_de_champ.type_champ == champ.last_write_type_champ + type_de_champ.champ_value(champ) else '' end diff --git a/spec/controllers/api/v2/graphql_controller_spec.rb b/spec/controllers/api/v2/graphql_controller_spec.rb index 6ceef733d..aebf599a1 100644 --- a/spec/controllers/api/v2/graphql_controller_spec.rb +++ b/spec/controllers/api/v2/graphql_controller_spec.rb @@ -519,7 +519,7 @@ describe API::V2::GraphqlController do { id: champ.to_typed_id, label: champ.libelle, - stringValue: champ.for_api_v2 + stringValue: champ.type_de_champ.champ_value_for_api(champ) } end expect(gql_data[:dossier][:champs]).to match_array(expected_champs) diff --git a/spec/models/champ_spec.rb b/spec/models/champ_spec.rb index 5c473b1fe..1afb2ce2f 100644 --- a/spec/models/champ_spec.rb +++ b/spec/models/champ_spec.rb @@ -176,10 +176,12 @@ describe Champ do let(:champ) { Champs::TextChamp.new(value:, dossier: build(:dossier)) } before { allow(champ).to receive(:type_de_champ).and_return(build(:type_de_champ_text)) } + let(:value_for_export) { champ.type_de_champ.champ_value_for_export(champ) } + context 'when type_de_champ is text' do let(:value) { '123' } - it { expect(champ.for_export).to eq('123') } + it { expect(value_for_export).to eq('123') } end context 'when type_de_champ is textarea' do @@ -188,7 +190,7 @@ describe Champ do let(:value) { 'gras' } - it { expect(champ.for_export).to eq('gras') } + it { expect(value_for_export).to eq('gras') } end context 'when type_de_champ is yes_no' do @@ -198,19 +200,19 @@ describe Champ do context 'if yes' do let(:value) { 'true' } - it { expect(champ.for_export).to eq('Oui') } + it { expect(value_for_export).to eq('Oui') } end context 'if no' do let(:value) { 'false' } - it { expect(champ.for_export).to eq('Non') } + it { expect(value_for_export).to eq('Non') } end context 'if nil' do let(:value) { nil } - it { expect(champ.for_export).to eq('Non') } + it { expect(value_for_export).to eq('Non') } end end @@ -220,19 +222,21 @@ describe Champ do let(:value) { '["Crétinier", "Mousserie"]' } - it { expect(champ.for_export).to eq('Crétinier, Mousserie') } + it { expect(value_for_export).to eq('Crétinier, Mousserie') } end context 'when type_de_champ and champ.type mismatch' do let(:value) { :noop } let(:champ_yes_no) { Champs::YesNoChamp.new(value: 'true') } let(:champ_text) { Champs::TextChamp.new(value: 'hello') } + let(:type_de_champ_yes_no) { build(:type_de_champ_yes_no) } + let(:type_de_champ_text) { build(:type_de_champ_text) } before do - allow(champ_yes_no).to receive(:type_de_champ).and_return(build(:type_de_champ_yes_no)) - allow(champ_text).to receive(:type_de_champ).and_return(build(:type_de_champ_text)) + allow(champ_yes_no).to receive(:type_de_champ).and_return(type_de_champ_yes_no) + allow(champ_text).to receive(:type_de_champ).and_return(type_de_champ_text) end - it { expect(TypeDeChamp.champ_value_for_export('text', champ_yes_no)).to eq(nil) } - it { expect(TypeDeChamp.champ_value_for_export('yes_no', champ_text)).to eq('Non') } + it { expect(type_de_champ_text.champ_value_for_export(champ_yes_no)).to eq(nil) } + it { expect(type_de_champ_yes_no.champ_value_for_export(champ_text)).to eq('Non') } end end diff --git a/spec/models/champs/carte_champ_spec.rb b/spec/models/champs/carte_champ_spec.rb index d84d39778..79129d733 100644 --- a/spec/models/champs/carte_champ_spec.rb +++ b/spec/models/champs/carte_champ_spec.rb @@ -50,7 +50,7 @@ describe Champs::CarteChamp do let(:geo_areas) { [build(:geo_area, :selection_utilisateur, :point)] } it "returns point label" do - expect(champ.for_export).to eq("Un point situé à 46°32'19\"N 2°25'42\"E") + expect(champ.type_de_champ.champ_value_for_export(champ)).to eq("Un point situé à 46°32'19\"N 2°25'42\"E") end end @@ -58,7 +58,7 @@ describe Champs::CarteChamp do let(:geo_areas) { [build(:geo_area, :selection_utilisateur, :cadastre)] } it "returns cadastre parcelle label" do - expect(champ.for_export).to match(/Parcelle n° 42/) + expect(champ.type_de_champ.champ_value_for_export(champ)).to match(/Parcelle n° 42/) end end end diff --git a/spec/models/champs/commune_champ_spec.rb b/spec/models/champs/commune_champ_spec.rb index 97e5e8569..d191f1f3d 100644 --- a/spec/models/champs/commune_champ_spec.rb +++ b/spec/models/champs/commune_champ_spec.rb @@ -23,9 +23,9 @@ describe Champs::CommuneChamp do expect(champ.code).to eq(code_insee) expect(champ.code_departement).to eq(code_departement) expect(champ.code_postal).to eq(code_postal) - expect(champ.for_export(:value)).to eq 'Châteldon (63290)' - expect(champ.for_export(:code)).to eq '63102' - expect(champ.for_export(:departement)).to eq '63 – Puy-de-Dôme' + expect(champ.type_de_champ.champ_value_for_export(champ, :value)).to eq 'Châteldon (63290)' + expect(champ.type_de_champ.champ_value_for_export(champ, :code)).to eq '63102' + expect(champ.type_de_champ.champ_value_for_export(champ, :departement)).to eq '63 – Puy-de-Dôme' end context 'with tricky bug (should not happen, but it happens)' do @@ -59,9 +59,9 @@ describe Champs::CommuneChamp do expect(champ.code).to eq(code_insee) expect(champ.code_departement).to eq(code_departement) expect(champ.code_postal).to eq(code_postal) - expect(champ.for_export(:value)).to eq 'Châteldon (63290)' - expect(champ.for_export(:code)).to eq '63102' - expect(champ.for_export(:departement)).to eq '63 – Puy-de-Dôme' + expect(champ.type_de_champ.champ_value_for_export(champ, :value)).to eq 'Châteldon (63290)' + expect(champ.type_de_champ.champ_value_for_export(champ, :code)).to eq '63102' + expect(champ.type_de_champ.champ_value_for_export(champ, :departement)).to eq '63 – Puy-de-Dôme' end end end diff --git a/spec/models/champs/decimal_number_champ_spec.rb b/spec/models/champs/decimal_number_champ_spec.rb index 8603f2f9a..f2ea17098 100644 --- a/spec/models/champs/decimal_number_champ_spec.rb +++ b/spec/models/champs/decimal_number_champ_spec.rb @@ -69,7 +69,7 @@ describe Champs::DecimalNumberChamp do describe 'for_export' do let(:champ) { Champs::DecimalNumberChamp.new(value:) } before { allow(champ).to receive(:type_de_champ).and_return(build(:type_de_champ_decimal_number)) } - subject { champ.for_export } + subject { champ.type_de_champ.champ_value_for_export(champ) } context 'with nil' do let(:value) { 0 } it { is_expected.to eq(0.0) } diff --git a/spec/models/champs/departement_champ_spec.rb b/spec/models/champs/departement_champ_spec.rb index 3bfb5f41c..ef4560912 100644 --- a/spec/models/champs/departement_champ_spec.rb +++ b/spec/models/champs/departement_champ_spec.rb @@ -80,7 +80,6 @@ describe Champs::DepartementChamp, type: :model do expect(champ.value).to eq('Ain') expect(champ.selected).to eq('01') expect(champ.to_s).to eq('01 – Ain') - expect(champ.for_api_v2).to eq('01 - Ain') end it 'with code having 3 chars' do diff --git a/spec/models/champs/linked_drop_down_list_champ_spec.rb b/spec/models/champs/linked_drop_down_list_champ_spec.rb index 7c02edbfe..9b0e194ea 100644 --- a/spec/models/champs/linked_drop_down_list_champ_spec.rb +++ b/spec/models/champs/linked_drop_down_list_champ_spec.rb @@ -55,7 +55,7 @@ describe Champs::LinkedDropDownListChamp do let(:secondary_value) { nil } before { allow(champ).to receive(:type_de_champ).and_return(build(:type_de_champ_linked_drop_down_list)) } - subject { champ.for_export } + subject { champ.type_de_champ.champ_value_for_export(champ) } context 'with no value' do let(:value) { nil } diff --git a/spec/models/champs/multiple_drop_down_list_champ_spec.rb b/spec/models/champs/multiple_drop_down_list_champ_spec.rb index 6687145b1..d8d2499cc 100644 --- a/spec/models/champs/multiple_drop_down_list_champ_spec.rb +++ b/spec/models/champs/multiple_drop_down_list_champ_spec.rb @@ -90,6 +90,6 @@ describe Champs::MultipleDropDownListChamp do describe "#for_tag" do let(:value) { ["val1", "val2"] } - it { expect(champ.for_tag.to_s).to eq("val1, val2") } + it { expect(champ.type_de_champ.champ_value_for_tag(champ).to_s).to eq("val1, val2") } end end diff --git a/spec/models/champs/piece_justificative_champ_spec.rb b/spec/models/champs/piece_justificative_champ_spec.rb index a7a0cd758..ae5b6dfb6 100644 --- a/spec/models/champs/piece_justificative_champ_spec.rb +++ b/spec/models/champs/piece_justificative_champ_spec.rb @@ -33,7 +33,7 @@ describe Champs::PieceJustificativeChamp do let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :piece_justificative }]) } let(:dossier) { create(:dossier, :with_populated_champs, procedure:) } let(:champ) { dossier.champs.first } - subject { champ.for_export } + subject { champ.type_de_champ.champ_value_for_export(champ) } it { is_expected.to eq('toto.txt') } @@ -50,7 +50,7 @@ describe Champs::PieceJustificativeChamp do before { champ.piece_justificative_file.first.blob.update(virus_scan_result:) } - subject { champ.for_api } + subject { champ.type_de_champ.champ_value_for_api(champ, version: 1) } context 'when file is safe' do let(:virus_scan_result) { ActiveStorage::VirusScanner::SAFE } diff --git a/spec/models/champs/repetition_champ_spec.rb b/spec/models/champs/repetition_champ_spec.rb index 16a537b73..38bb197f7 100644 --- a/spec/models/champs/repetition_champ_spec.rb +++ b/spec/models/champs/repetition_champ_spec.rb @@ -19,7 +19,7 @@ describe Champs::RepetitionChamp do end it "can render as string" do - expect(champ.for_tag.to_s).to eq( + expect(champ.type_de_champ.champ_value_for_tag(champ).to_s).to eq( <<~TXT.strip Languages @@ -29,7 +29,7 @@ describe Champs::RepetitionChamp do end it "as tiptap node" do - expect(champ.for_tag.to_tiptap_node).to include(type: 'orderedList') + expect(champ.type_de_champ.champ_value_for_tag(champ).to_tiptap_node).to include(type: 'orderedList') end end end diff --git a/spec/models/champs/rna_champ_spec.rb b/spec/models/champs/rna_champ_spec.rb index c2968f752..23f6bc556 100644 --- a/spec/models/champs/rna_champ_spec.rb +++ b/spec/models/champs/rna_champ_spec.rb @@ -23,11 +23,11 @@ describe Champs::RNAChamp do champ.update(data: { association_titre: "Super asso" }) end - it { expect(champ.for_export).to eq("W182736273 (Super asso)") } + it { expect(champ.type_de_champ.champ_value_for_export(champ)).to eq("W182736273 (Super asso)") } end context "no association title" do - it { expect(champ.for_export).to eq("W182736273") } + it { expect(champ.type_de_champ.champ_value_for_export(champ)).to eq("W182736273") } end end end diff --git a/spec/models/champs/rnf_champ_spec.rb b/spec/models/champs/rnf_champ_spec.rb index f83759a83..fbca7072a 100644 --- a/spec/models/champs/rnf_champ_spec.rb +++ b/spec/models/champs/rnf_champ_spec.rb @@ -125,11 +125,11 @@ describe Champs::RNFChamp, type: :model do let(:champ) { described_class.new(external_id:, data: JSON.parse(body)) } before { allow(champ).to receive(:type_de_champ).and_return(build(:type_de_champ_rnf)) } it do - expect(champ.for_export(:value)).to eq '075-FDD-00003-01' - expect(champ.for_export(:nom)).to eq 'Fondation SFR' - expect(champ.for_export(:address)).to eq '16 Rue du Général de Boissieu 75015 Paris' - expect(champ.for_export(:code_insee)).to eq '75115' - expect(champ.for_export(:departement)).to eq '75 – Paris' + expect(champ.type_de_champ.champ_value_for_export(champ, :value)).to eq '075-FDD-00003-01' + expect(champ.type_de_champ.champ_value_for_export(champ, :nom)).to eq 'Fondation SFR' + expect(champ.type_de_champ.champ_value_for_export(champ, :address)).to eq '16 Rue du Général de Boissieu 75015 Paris' + expect(champ.type_de_champ.champ_value_for_export(champ, :code_insee)).to eq '75115' + expect(champ.type_de_champ.champ_value_for_export(champ, :departement)).to eq '75 – Paris' end end end diff --git a/spec/models/champs/titre_identite_champ_spec.rb b/spec/models/champs/titre_identite_champ_spec.rb index e2ee9867a..96677ec3b 100644 --- a/spec/models/champs/titre_identite_champ_spec.rb +++ b/spec/models/champs/titre_identite_champ_spec.rb @@ -4,7 +4,7 @@ describe Champs::TitreIdentiteChamp do describe "#for_export" do let(:champ) { described_class.new } before { allow(champ).to receive(:type_de_champ).and_return(build(:type_de_champ_titre_identite)) } - subject { champ.for_export } + subject { champ.type_de_champ.champ_value_for_export(champ) } context 'without attached file' do let(:piece_justificative_file) { double(attached?: true) } diff --git a/spec/system/users/en_construction_spec.rb b/spec/system/users/en_construction_spec.rb index ba9d06788..f37a121e4 100644 --- a/spec/system/users/en_construction_spec.rb +++ b/spec/system/users/en_construction_spec.rb @@ -19,7 +19,7 @@ describe "Dossier en_construction", js: true do expect(page).not_to have_button("Remplacer") click_on "Supprimer le fichier toto.txt" - wait_until { champ.reload.for_export.blank? } + wait_until { champ.reload.blank? } expect(page).not_to have_text("toto.txt") end @@ -38,7 +38,7 @@ describe "Dossier en_construction", js: true do expect(page).to have_selector(input_selector) find(input_selector).attach_file(Rails.root.join('spec/fixtures/files/file.pdf')) - wait_until { champ.reload.for_export == 'file.pdf' } + wait_until { champ.reload.piece_justificative_file.first&.filename == 'file.pdf' } expect(page).to have_text("file.pdf") end end From 1ded040730eae26e890454208a7155d7ae41b144 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 21 Oct 2024 11:43:39 +0200 Subject: [PATCH 1285/1532] fix(iban): format iban through type_de_champ --- app/models/champs/iban_champ.rb | 8 -------- app/models/types_de_champ/iban_type_de_champ.rb | 4 ++++ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/app/models/champs/iban_champ.rb b/app/models/champs/iban_champ.rb index 9c8b24053..cc26eafa8 100644 --- a/app/models/champs/iban_champ.rb +++ b/app/models/champs/iban_champ.rb @@ -4,14 +4,6 @@ class Champs::IbanChamp < Champ validates_with IbanValidator, if: :validate_champ_value_or_prefill? after_validation :format_iban - def for_api - to_s.gsub(/\s+/, '') - end - - def for_api_v2 - for_api - end - private def format_iban diff --git a/app/models/types_de_champ/iban_type_de_champ.rb b/app/models/types_de_champ/iban_type_de_champ.rb index 4fed46419..a5a683f25 100644 --- a/app/models/types_de_champ/iban_type_de_champ.rb +++ b/app/models/types_de_champ/iban_type_de_champ.rb @@ -4,4 +4,8 @@ class TypesDeChamp::IbanTypeDeChamp < TypesDeChamp::TypeDeChampBase def estimated_fill_duration(revision) FILL_DURATION_MEDIUM end + + def champ_value_for_api(champ, version: 2) + champ_value(champ).gsub(/\s+/, '') + end end From 5024b5b5496479f5dbefc5a33cba6006d6f43bfa Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 21 Oct 2024 11:58:03 +0200 Subject: [PATCH 1286/1532] refactor(champs): no need to project empty champs for tag --- app/models/concerns/dossier_champs_concern.rb | 11 ++++++++--- app/models/types_de_champ/type_de_champ_base.rb | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/models/concerns/dossier_champs_concern.rb b/app/models/concerns/dossier_champs_concern.rb index 70795761a..17ce89a25 100644 --- a/app/models/concerns/dossier_champs_concern.rb +++ b/app/models/concerns/dossier_champs_concern.rb @@ -81,13 +81,18 @@ module DossierChampsConcern def champs_for_export(types_de_champ, row_id = nil) types_de_champ.flat_map do |type_de_champ| - champ = champ_for_export(type_de_champ, row_id) + champ = filled_champ(type_de_champ, row_id) type_de_champ.libelles_for_export.map do |(libelle, path)| - [libelle, TypeDeChamp.champ_value_for_export(type_de_champ.type_champ, champ, path)] + [libelle, type_de_champ.champ_value_for_export(champ, path)] end end end + def champ_value_for_tag(type_de_champ, path = :value) + champ = filled_champ(type_de_champ, nil) + type_de_champ.champ_value_for_tag(champ, path) + end + def champ_for_update(type_de_champ, row_id, updated_by:) champ, attributes = champ_with_attributes_for_update(type_de_champ, row_id, updated_by:) champ.assign_attributes(attributes) @@ -143,7 +148,7 @@ module DossierChampsConcern @champs_by_public_id ||= champs.sort_by(&:id).index_by(&:public_id) end - def champ_for_export(type_de_champ, row_id) + def filled_champ(type_de_champ, row_id) champ = champs_by_public_id[type_de_champ.public_id(row_id)] if champ.blank? || !champ.visible? nil diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index 990c68987..e081d0e4e 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -15,12 +15,12 @@ class TypesDeChamp::TypeDeChampBase end def tags_for_template - tdc = @type_de_champ + type_de_champ = @type_de_champ paths.map do |path| path.merge( libelle: TagsSubstitutionConcern::TagsParser.normalize(path[:libelle]), id: path[:path] == :value ? "tdc#{stable_id}" : "tdc#{stable_id}/#{path[:path]}", - lambda: -> (dossier) { dossier.project_champ(tdc, nil).for_tag(path[:path]) } + lambda: -> (dossier) { dossier.champ_value_for_tag(type_de_champ, path[:path]) } ) end end From 94b06cb50fab9cba75afc344396b4a2dd0687bca Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 21 Oct 2024 13:09:40 +0200 Subject: [PATCH 1287/1532] fix(logic): we need to expose raw typed values for champs --- app/models/logic/champ_value.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/logic/champ_value.rb b/app/models/logic/champ_value.rb index 688d2c113..f4f655a5e 100644 --- a/app/models/logic/champ_value.rb +++ b/app/models/logic/champ_value.rb @@ -50,7 +50,8 @@ class Logic::ChampValue < Logic::Term "Champs::CheckboxChamp" targeted_champ.true? when "Champs::IntegerNumberChamp", "Champs::DecimalNumberChamp" - targeted_champ.for_api + # TODO expose raw typed value of champs + targeted_champ.type_de_champ.champ_value_for_api(targeted_champ, version: 1) when "Champs::DropDownListChamp" targeted_champ.selected when "Champs::MultipleDropDownListChamp" From 9a55cd8002ebe8421ae52d62e015089b80c4b674 Mon Sep 17 00:00:00 2001 From: benoitqueyron <72251526+Benoit-MINT@users.noreply.github.com> Date: Fri, 18 Oct 2024 11:29:29 +0200 Subject: [PATCH 1288/1532] correction affichage service au dsfr --- app/assets/stylesheets/table_service.scss | 5 --- .../administrateurs/services_controller.rb | 2 +- .../administrateurs/services/index.html.haml | 35 +++++++++++-------- 3 files changed, 22 insertions(+), 20 deletions(-) delete mode 100644 app/assets/stylesheets/table_service.scss diff --git a/app/assets/stylesheets/table_service.scss b/app/assets/stylesheets/table_service.scss deleted file mode 100644 index 6d03c1f30..000000000 --- a/app/assets/stylesheets/table_service.scss +++ /dev/null @@ -1,5 +0,0 @@ -@import "constants"; - -.change { - width: 300px; -} diff --git a/app/controllers/administrateurs/services_controller.rb b/app/controllers/administrateurs/services_controller.rb index 01c14a85c..6ca2af356 100644 --- a/app/controllers/administrateurs/services_controller.rb +++ b/app/controllers/administrateurs/services_controller.rb @@ -5,8 +5,8 @@ module Administrateurs skip_before_action :alert_for_missing_siret_service, only: :edit skip_before_action :alert_for_missing_service, only: :edit def index - @services = services.ordered @procedure = procedure + @services = services.ordered.sort_by { |service| service == procedure.service ? 0 : 1 } end def new diff --git a/app/views/administrateurs/services/index.html.haml b/app/views/administrateurs/services/index.html.haml index 123e76536..b0532b69b 100644 --- a/app/views/administrateurs/services/index.html.haml +++ b/app/views/administrateurs/services/index.html.haml @@ -6,6 +6,8 @@ #services-index.fr-container %h1.fr-h2 Service + = link_to "Nouveau service", new_admin_service_path(procedure_id: @procedure.id), class: "fr-btn fr-btn--primary fr-btn--icon-left fr-icon-add-circle-line mb-3" + .fr-table.fr-table--layout-fixed %table %caption Liste des services pouvant être affectés à la démarche @@ -13,25 +15,30 @@ %tr %th{ scope: "col" } Nom - %th.change{ scope: "col" } - = link_to "Nouveau service", new_admin_service_path(procedure_id: @procedure.id), class: "fr-btn fr-btn--secondary" + %th.fr-col-4{ scope: "col" } + Actions %tbody - @services.each do |service| %tr %td = service.nom - %td.change - - if @procedure.service == service - %strong.mr-2 (Assigné) - - else - = button_to "Assigner", add_to_procedure_admin_services_path(procedure: { id: @procedure.id, service_id: service.id, }), method: :patch, class: 'link mr-2', form_class: 'inline' - = link_to('Modifier', edit_admin_service_path(service, procedure_id: @procedure.id), class: 'link my-2') - - if @procedure.service != service - = link_to 'Supprimer', - admin_service_path(service, procedure_id: @procedure.id), - method: :delete, - data: { confirm: "Confirmez vous la suppression de #{service.nom}" }, - class: 'btn btn-link ml-2' + %td.fr-col-4 + .fr-container.flex.px-0 + .fr-col-4.fr-col--middle + - if @procedure.service == service + %p.fr-badge.fr-badge--success.fr-badge--sm + ASSIGNÉ + - else + = button_to "Assigner", add_to_procedure_admin_services_path(procedure: { id: @procedure.id, service_id: service.id, }), method: :patch, class: 'fr-btn fr-btn--sm fr-btn--secondary fr-btn--icon-left fr-icon-checkbox-circle-line' + .fr-col-4 + = link_to('Modifier', edit_admin_service_path(service, procedure_id: @procedure.id), class: 'fr-btn fr-btn--sm fr-btn--secondary fr-btn--icon-left fr-icon-pencil-line') + .fr-col-4 + = button_to 'Supprimer', + admin_service_path(service, procedure_id: @procedure.id), + method: :delete, + data: { confirm: "Confirmez vous la suppression de #{service.nom}" }, + class: 'fr-btn fr-btn--sm fr-btn--secondary fr-btn--icon-left fr-icon-delete-line', + disabled: (@procedure.service == service) = render Procedure::FixedFooterComponent.new(procedure: @procedure) From 0137763d42fcd86d198b5d9a5cd5bd0bfff99eae Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 22 Oct 2024 10:55:40 +0200 Subject: [PATCH 1289/1532] feat: add empty_brouillon scope --- app/models/concerns/dossier_empty_concern.rb | 29 +++++++++++++++++++ app/models/dossier.rb | 1 + .../concerns/dossier_empty_concern_spec.rb | 27 +++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 app/models/concerns/dossier_empty_concern.rb create mode 100644 spec/models/concerns/dossier_empty_concern_spec.rb diff --git a/app/models/concerns/dossier_empty_concern.rb b/app/models/concerns/dossier_empty_concern.rb new file mode 100644 index 000000000..af2acbbab --- /dev/null +++ b/app/models/concerns/dossier_empty_concern.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module DossierEmptyConcern + extend ActiveSupport::Concern + + included do + scope :empty_brouillon, -> (created_at) do + dossiers_ids = Dossier.brouillon.where(created_at:).ids + + dossiers_with_value = Dossier.select('id').includes(:champs) + .where.not(champs: { value: nil }) + .where(id: dossiers_ids) + + dossier_with_geo_areas = Dossier.select('id').includes(champs: :geo_areas) + .where.not(geo_areas: { id: nil }) + .where(id: dossiers_ids) + + dossier_with_pj = Dossier.select('id') + .joins(champs: :piece_justificative_file_attachments) + .where(id: dossiers_ids) + + brouillon + .where.not(id: dossiers_with_value) + .where.not(id: dossier_with_geo_areas) + .where.not(id: dossier_with_pj) + .where(id: dossiers_ids) + end + end +end diff --git a/app/models/dossier.rb b/app/models/dossier.rb index fc80584f4..ca122b153 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -12,6 +12,7 @@ class Dossier < ApplicationRecord include DossierSectionsConcern include DossierStateConcern include DossierChampsConcern + include DossierEmptyConcern enum state: { brouillon: 'brouillon', diff --git a/spec/models/concerns/dossier_empty_concern_spec.rb b/spec/models/concerns/dossier_empty_concern_spec.rb new file mode 100644 index 000000000..6a6a1e524 --- /dev/null +++ b/spec/models/concerns/dossier_empty_concern_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.describe DossierEmptyConcern do + describe 'empty_brouillon' do + let(:types) { [{ type: :text }, { type: :carte }, { type: :piece_justificative }] } + let(:procedure) { create(:procedure, types_de_champ_public: types) } + let!(:empty_brouillon) { create(:dossier, procedure:) } + let!(:empty_en_construction) { create(:dossier, :en_construction, procedure:) } + let!(:value_filled_dossier) { create(:dossier, procedure:) } + let!(:carte_filled_dossier) { create(:dossier, procedure:) } + let!(:pj_filled_dossier) { create(:dossier, procedure:) } + let(:geo_area) { build(:geo_area, :selection_utilisateur, :polygon) } + let(:attachment) { { io: StringIO.new("toto"), filename: "toto.png", content_type: "image/png" } } + + subject { Dossier.empty_brouillon(2.days.ago..) } + + before do + value_filled_dossier.champs.first.update(value: 'filled') + carte_filled_dossier.champs.second.update(geo_areas: [geo_area]) + pj_filled_dossier.champs.third.piece_justificative_file.attach(attachment) + end + + it do + is_expected.to eq([empty_brouillon]) + end + end +end From 51a8c3cf988e2c0328d2f6330ed083c639dc9d36 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 22 Oct 2024 12:31:40 +0200 Subject: [PATCH 1290/1532] refactor(export): do not use dossier.champs in export --- app/models/champs/repetition_champ.rb | 6 ------ app/models/concerns/dossier_champs_concern.rb | 6 ++++++ app/models/dossier.rb | 7 ------- app/services/procedure_export_service.rb | 16 +++++++--------- 4 files changed, 13 insertions(+), 22 deletions(-) diff --git a/app/models/champs/repetition_champ.rb b/app/models/champs/repetition_champ.rb index cfa6ff5ab..4bb65c484 100644 --- a/app/models/champs/repetition_champ.rb +++ b/app/models/champs/repetition_champ.rb @@ -31,12 +31,6 @@ class Champs::RepetitionChamp < Champ # The user cannot enter any information here so it doesn’t make much sense to search end - def rows_for_export - row_ids.map.with_index(1) do |row_id, index| - Champs::RepetitionChamp::Row.new(index:, row_id:, dossier:) - end - end - class Row < Hashie::Dash property :index property :row_id diff --git a/app/models/concerns/dossier_champs_concern.rb b/app/models/concerns/dossier_champs_concern.rb index 17ce89a25..82fa8e5fc 100644 --- a/app/models/concerns/dossier_champs_concern.rb +++ b/app/models/concerns/dossier_champs_concern.rb @@ -107,6 +107,12 @@ module DossierChampsConcern assign_attributes(champs_attributes:) end + def repetition_rows_for_export(type_de_champ) + repetition_row_ids(type_de_champ).map.with_index(1) do |row_id, index| + Champs::RepetitionChamp::Row.new(index:, row_id:, dossier: self) + end + end + def repetition_row_ids(type_de_champ) return [] if !type_de_champ.repetition? diff --git a/app/models/dossier.rb b/app/models/dossier.rb index fc80584f4..90ca00d4f 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -1052,13 +1052,6 @@ class Dossier < ApplicationRecord } end - def self.to_feature_collection - { - type: 'FeatureCollection', - features: GeoArea.joins(:champ).where(champ: { dossier: ids }).map(&:to_feature) - } - end - def log_api_entreprise_job_exception(exception) exceptions = self.api_entreprise_job_exceptions ||= [] exceptions << exception.inspect diff --git a/app/services/procedure_export_service.rb b/app/services/procedure_export_service.rb index 001ee7ef7..9482cc045 100644 --- a/app/services/procedure_export_service.rb +++ b/app/services/procedure_export_service.rb @@ -47,7 +47,9 @@ class ProcedureExportService end def to_geo_json - io = StringIO.new(dossiers.to_feature_collection.to_json) + champs_carte = dossiers.flat_map { _1.filled_champs.filter(&:carte?) } + features = GeoArea.where(champ_id: champs_carte).map(&:to_feature) + io = StringIO.new({ type: 'FeatureCollection', features: }.to_json) create_blob(io, :json) end @@ -92,9 +94,9 @@ class ProcedureExportService end def etablissements - @etablissements ||= dossiers.flat_map do |dossier| - dossier.champs.filter { _1.is_a?(Champs::SiretChamp) } - end.filter_map(&:etablissement) + dossiers.filter_map(&:etablissement) + @etablissements ||= dossiers + .flat_map { _1.filled_champs.filter(&:siret?) } + .filter_map(&:etablissement) + dossiers.filter_map(&:etablissement) end def avis @@ -102,16 +104,12 @@ class ProcedureExportService end def champs_repetables_options - champs_by_stable_id = dossiers - .flat_map { _1.champs.filter(&:repetition?) } - .group_by(&:stable_id) - procedure .all_revisions_types_de_champ .repetition .filter_map do |type_de_champ_repetition| types_de_champ = procedure.all_revisions_types_de_champ(parent: type_de_champ_repetition).to_a - rows = champs_by_stable_id.fetch(type_de_champ_repetition.stable_id, []).flat_map(&:rows_for_export) + rows = dossiers.flat_map { _1.repetition_rows_for_export(type_de_champ_repetition) } if types_de_champ.present? && rows.present? { From 5195ebd5c773bb287287d5381f20a76ba95c7854 Mon Sep 17 00:00:00 2001 From: Thibaut Poullain Date: Thu, 17 Oct 2024 12:09:06 +0200 Subject: [PATCH 1291/1532] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20Fix=20|=20Pro?= =?UTF-8?q?cedure=20minimal=20admins=20presence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/procedure.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 437fbd2a7..1d87dd735 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -119,7 +119,7 @@ class Procedure < ApplicationRecord end has_many :administrateurs_procedures, dependent: :delete_all - has_many :administrateurs, through: :administrateurs_procedures, after_remove: -> (procedure, _admin) { procedure.validate! } + has_many :administrateurs, through: :administrateurs_procedures, before_remove: :check_administrateur_minimal_presence has_many :groupe_instructeurs, -> { order(:label) }, inverse_of: :procedure, dependent: :destroy has_many :instructeurs, through: :groupe_instructeurs has_many :export_templates, through: :groupe_instructeurs @@ -318,6 +318,12 @@ class Procedure < ApplicationRecord end end + def check_administrateur_minimal_presence(_object) + if self.administrateurs.count <= 1 + raise ActiveRecord::RecordNotDestroyed.new("Cannot remove the last administrateur of procedure #{self.libelle} (#{self.id})") + end + end + def dossiers_close_to_expiration dossiers.close_to_expiration.count end From b969c1717f88b53001a0106ad2891716cccf5ddc Mon Sep 17 00:00:00 2001 From: Thibaut Poullain Date: Tue, 22 Oct 2024 16:36:50 +0200 Subject: [PATCH 1292/1532] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20Fix=20|=20add?= =?UTF-8?q?=20test=20to=20the=20minimal=20admin=20presence=20for=20procedu?= =?UTF-8?q?re?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec/models/procedure_spec.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index a5adf5280..ba22d25b1 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -210,6 +210,20 @@ describe Procedure do it { is_expected.not_to allow_value([]).for(:administrateurs) } end + context 'before_remove callback for minimal administrator presence' do + let(:procedure) { create(:procedure) } + + it 'raises an error when trying to remove the last administrateur' do + expect(procedure.administrateurs.count).to eq(1) + expect { + procedure.administrateurs.destroy(procedure.administrateurs.first) + }.to raise_error( + ActiveRecord::RecordNotDestroyed, + "Cannot remove the last administrateur of procedure #{procedure.libelle} (#{procedure.id})" + ) + end + end + context 'juridique' do it { is_expected.not_to allow_value(nil).on(:publication).for(:cadre_juridique) } it { is_expected.to allow_value('text').on(:publication).for(:cadre_juridique) } From 81cbda05538f8d31d06419e3b19b3cddd45539ed Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Thu, 17 Oct 2024 17:45:11 +0200 Subject: [PATCH 1293/1532] feat(instructeurs): can add many instructeurs --- .../groupe_instructeurs_controller.rb | 72 ++++++++++--------- .../groupe_instructeurs/show.html.haml | 15 ++-- .../instructeurs/groupe_instructeurs/fr.yml | 16 +++++ .../groupe_instructeurs_controller_spec.rb | 16 +++-- 4 files changed, 74 insertions(+), 45 deletions(-) create mode 100644 config/locales/views/instructeurs/groupe_instructeurs/fr.yml diff --git a/app/controllers/instructeurs/groupe_instructeurs_controller.rb b/app/controllers/instructeurs/groupe_instructeurs_controller.rb index f7caec975..046da2574 100644 --- a/app/controllers/instructeurs/groupe_instructeurs_controller.rb +++ b/app/controllers/instructeurs/groupe_instructeurs_controller.rb @@ -21,41 +21,51 @@ module Instructeurs end def add_instructeur - email = instructeur_email.present? ? [instructeur_email] : [] - email = check_if_typo(email)&.first + emails = params['emails'].presence || [] + emails = check_if_typo(emails) errors = Array.wrap(generate_emails_suggestions_message(@maybe_typos)) + instructeurs, invalid_emails = groupe_instructeur.add_instructeurs(emails:) + + if invalid_emails.present? + errors += [ + t('.wrong_address', + count: invalid_emails.size, + emails: invalid_emails.join(', ')) + ] + end + + if instructeurs.present? + flash[:notice] = if procedure.routing_enabled? + t('.assignment', count: instructeurs.size, + emails: instructeurs.map(&:email).join(', '), + groupe: groupe_instructeur.label) + else + "Les instructeurs ont bien été affectés à la démarche" + end + + known_instructeurs, not_verified_instructeurs = instructeurs.partition { |instructeur| instructeur.user.email_verified_at } + + not_verified_instructeurs.filter(&:should_receive_email_activation?).each do + InstructeurMailer.confirm_and_notify_added_instructeur(_1, groupe_instructeur, current_instructeur.email).deliver_later + end + + if known_instructeurs.present? + GroupeInstructeurMailer + .notify_added_instructeurs(groupe_instructeur, known_instructeurs, current_instructeur.email) + .deliver_later + end + end + + @procedure = procedure + @groupe_instructeur = groupe_instructeur + @instructeurs = paginated_instructeurs + if !errors.empty? flash.now[:alert] = errors.join(". ") if !errors.empty? - - @procedure = procedure - @groupe_instructeur = groupe_instructeur - @instructeurs = paginated_instructeurs - return render :show end - instructeur = Instructeur.by_email(email) || - create_instructeur(email) - - if instructeur.blank? - flash[:alert] = "L’adresse email « #{email} » n’est pas valide." - elsif groupe_instructeur.instructeurs.include?(instructeur) - flash[:alert] = "L’instructeur « #{email} » est déjà dans le groupe." - else - groupe_instructeur.add(instructeur) - flash[:notice] = "L’instructeur « #{email} » a été affecté au groupe." - - if instructeur.user.email_verified_at - GroupeInstructeurMailer - .notify_added_instructeurs(groupe_instructeur, [instructeur], current_user.email) - .deliver_later - elsif instructeur.should_receive_email_activation? - InstructeurMailer.confirm_and_notify_added_instructeur(instructeur, groupe_instructeur, current_user.email).deliver_later - end - # else instructeur already exists and email is not verified, so do not spam them - end - - redirect_to instructeur_groupe_path(procedure, groupe_instructeur) + render :show end def remove_instructeur @@ -115,10 +125,6 @@ module Instructeurs .order(:email) end - def instructeur_email - params.dig('instructeur', 'email')&.strip&.downcase - end - def instructeur_id params[:instructeur][:id] end diff --git a/app/views/instructeurs/groupe_instructeurs/show.html.haml b/app/views/instructeurs/groupe_instructeurs/show.html.haml index e0a42aa46..ea54dc215 100644 --- a/app/views/instructeurs/groupe_instructeurs/show.html.haml +++ b/app/views/instructeurs/groupe_instructeurs/show.html.haml @@ -22,11 +22,16 @@ .card.fr-mt-2w = render Procedure::InvitationWithTypoComponent.new(maybe_typos: @maybe_typos, url: add_instructeur_instructeur_groupe_path(@procedure, @groupe_instructeur.id), title: "Avant d'ajouter l'email, veuillez confirmer" ) - %h2.fr-h3 Gestion des instructeurs - = form_for(Instructeur.new(user: User.new), url: { action: :add_instructeur }, html: { class: 'form' }) do |f| - %h3.fr-h4 Affecter un nouvel instructeur - = render Dsfr::InputComponent.new(form: f, attribute: :email) - = f.submit 'Affecter', class: 'fr-btn fr-primary' + %h2.fr-h3= t('.title') + + = form_for :instructeur, url: { action: :add_instructeur, id: @groupe_instructeur.id }, html: { class: 'form' } do |f| + .instructeur-wrapper + %p= t('.instructeur_emails') + %p.fr-hint-text= t('.copy_paste_hint') + %react-fragment + = render ReactComponent.new 'ComboBox/MultiComboBox', id: 'instructeur_emails', name: 'emails[]', allows_custom_value: true, 'aria-label': 'Emails' + + = f.submit t('.assign'), class: 'fr-btn fr-btn--tertiary' %table.fr-table.fr-mt-2w.width-100 %thead diff --git a/config/locales/views/instructeurs/groupe_instructeurs/fr.yml b/config/locales/views/instructeurs/groupe_instructeurs/fr.yml new file mode 100644 index 000000000..a198cb320 --- /dev/null +++ b/config/locales/views/instructeurs/groupe_instructeurs/fr.yml @@ -0,0 +1,16 @@ +fr: + instructeurs: + groupe_instructeurs: + show: + title: Affecter un nouvel instructeur + instructeur_emails: Adresse électronique des instructeurs que vous souhaitez affecter à cette démarche. + copy_paste_hint: "Vous pouvez saisir les adresses individuellement, ou bien copier-coller dans le champ ci-dessous une liste d’adresses séparées par des points-virgules (exemple : adresse1@mail.com; adresse2@mail.com; adresse3@mail.com)." + assign: Affecter + wrong_adress: "L’adresse électronique saisie n’est pas valide." + add_instructeur: + wrong_address: + one: "%{emails} n’est pas une adresse email valide" + other: "%{emails} ne sont pas des adresses emails valides" + assignment: + one: "L’instructeur %{emails} a été affecté au groupe « %{groupe} »." + other: "Les instructeurs %{emails} ont été affectés au groupe « %{groupe} »." diff --git a/spec/controllers/instructeurs/groupe_instructeurs_controller_spec.rb b/spec/controllers/instructeurs/groupe_instructeurs_controller_spec.rb index 043d20071..eeff1104e 100644 --- a/spec/controllers/instructeurs/groupe_instructeurs_controller_spec.rb +++ b/spec/controllers/instructeurs/groupe_instructeurs_controller_spec.rb @@ -88,7 +88,7 @@ describe Instructeurs::GroupeInstructeursController, type: :controller do params: { procedure_id: procedure.id, id: gi_1_2.id, - instructeur: { email: new_instructeur_email } + emails: [new_instructeur_email] } end @@ -99,7 +99,7 @@ describe Instructeurs::GroupeInstructeursController, type: :controller do it "works" do expect(gi_1_2.instructeurs.map(&:email)).to include(new_instructeur_email) expect(flash.notice).to be_present - expect(response).to redirect_to(instructeur_groupe_path(procedure, gi_1_2)) + expect(response).to have_http_status(:success) expect(InstructeurMailer).to have_received(:confirm_and_notify_added_instructeur).with(instance_of(Instructeur), gi_1_2, anything) expect(GroupeInstructeurMailer).not_to have_received(:notify_added_instructeurs) end @@ -114,7 +114,7 @@ describe Instructeurs::GroupeInstructeursController, type: :controller do it "works" do expect(gi_1_2.instructeurs.map(&:email)).to include(new_instructeur_email) expect(flash.notice).to be_present - expect(response).to redirect_to(instructeur_groupe_path(procedure, gi_1_2)) + expect(response).to have_http_status(:success) expect(InstructeurMailer).not_to have_received(:confirm_and_notify_added_instructeur) expect(GroupeInstructeurMailer).to have_received(:notify_added_instructeurs) end @@ -129,7 +129,7 @@ describe Instructeurs::GroupeInstructeursController, type: :controller do it "works" do expect(gi_1_2.instructeurs.map(&:email)).to include(new_instructeur_email) expect(flash.notice).to be_present - expect(response).to redirect_to(instructeur_groupe_path(procedure, gi_1_2)) + expect(response).to have_http_status(:success) expect(InstructeurMailer).to have_received(:confirm_and_notify_added_instructeur) expect(GroupeInstructeurMailer).not_to have_received(:notify_added_instructeurs) end @@ -137,11 +137,13 @@ describe Instructeurs::GroupeInstructeursController, type: :controller do context 'of an instructeur already in the group' do let(:new_instructeur_email) { instructeur.email } - before { subject } + before do + instructeur.user.update(email_verified_at: 1.day.ago) + subject + end it "works" do - expect(flash.alert).to be_present - expect(response).to redirect_to(instructeur_groupe_path(procedure, gi_1_2)) + expect(response).to have_http_status(:success) expect(InstructeurMailer).not_to have_received(:confirm_and_notify_added_instructeur) expect(GroupeInstructeurMailer).not_to have_received(:notify_added_instructeurs) end From fa449d3e41e9790bb1b56df2b889404530c4c9e2 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Tue, 22 Oct 2024 17:33:31 +0200 Subject: [PATCH 1294/1532] fix(instructeurs test): avoid duplication of instructor with same email --- .../instructeurs/groupe_instructeurs_controller_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/controllers/instructeurs/groupe_instructeurs_controller_spec.rb b/spec/controllers/instructeurs/groupe_instructeurs_controller_spec.rb index eeff1104e..3ecfddda6 100644 --- a/spec/controllers/instructeurs/groupe_instructeurs_controller_spec.rb +++ b/spec/controllers/instructeurs/groupe_instructeurs_controller_spec.rb @@ -2,9 +2,9 @@ describe Instructeurs::GroupeInstructeursController, type: :controller do render_views - let(:administrateurs) { [create(:administrateur, user: instructeur.user)] } - let(:instructeur) { create(:instructeur) } - let(:procedure) { create(:procedure, :published, administrateurs:) } + let(:administrateur) { create(:administrateur) } + let(:instructeur) { administrateur.instructeur } + let(:procedure) { create(:procedure, :published, administrateurs: [administrateur]) } let!(:gi_1_1) { procedure.defaut_groupe_instructeur } let!(:gi_1_2) { create(:groupe_instructeur, label: 'groupe instructeur 2', procedure: procedure) } From 08490bfb82fb4558a4e8d4798f765a55faa0cad2 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 22 Oct 2024 18:15:28 +0200 Subject: [PATCH 1295/1532] =?UTF-8?q?ETQ=20Instructeur,=20je=20veux=20pouv?= =?UTF-8?q?oir=20faire=20la=20difference=20entre=20un=20champ=20Oui/Non=20?= =?UTF-8?q?vide=20et=20=E2=80=9CNon=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/graphql/api/v2/schema.rb | 1 + app/graphql/api/v2/stored_query.rb | 3 +++ app/graphql/schema.graphql | 25 +++++++++++++++++++ app/graphql/types/champ_type.rb | 8 +++++- app/graphql/types/champs/yes_no_champ_type.rb | 17 +++++++++++++ .../types_de_champ/yes_no_type_de_champ.rb | 13 ++-------- spec/models/champ_spec.rb | 4 +-- spec/models/champs/checkbox_champ_spec.rb | 2 +- spec/models/champs/yes_no_champ_spec.rb | 2 +- .../shared_examples_for_boolean_champs.rb | 4 +-- 10 files changed, 61 insertions(+), 18 deletions(-) create mode 100644 app/graphql/types/champs/yes_no_champ_type.rb diff --git a/app/graphql/api/v2/schema.rb b/app/graphql/api/v2/schema.rb index 17ca8d19b..fdd16a39b 100644 --- a/app/graphql/api/v2/schema.rb +++ b/app/graphql/api/v2/schema.rb @@ -78,6 +78,7 @@ class API::V2::Schema < GraphQL::Schema Types::Champs::TextChampType, Types::Champs::TitreIdentiteChampType, Types::Champs::EngagementJuridiqueChampType, + Types::Champs::YesNoChampType, Types::GeoAreas::ParcelleCadastraleType, Types::GeoAreas::SelectionUtilisateurType, Types::PersonneMoraleType, diff --git a/app/graphql/api/v2/stored_query.rb b/app/graphql/api/v2/stored_query.rb index 0997ad69a..93682590b 100644 --- a/app/graphql/api/v2/stored_query.rb +++ b/app/graphql/api/v2/stored_query.rb @@ -509,6 +509,9 @@ class API::V2::StoredQuery ... on CheckboxChamp { checked: value } + ... on YesNoChamp { + selected: value + } ... on DecimalNumberChamp { decimalNumber: value } diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index 2b54aedb8..c89a4dcc8 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -4605,6 +4605,31 @@ type WarningMessage { message: String! } +type YesNoChamp implements Champ { + """ + L'identifiant du champDescriptor de ce champ + """ + champDescriptorId: String! + id: ID! + + """ + Libellé du champ. + """ + label: String! + prefilled: Boolean! + + """ + La valeur du champ sous forme texte. + """ + stringValue: String + + """ + Date de dernière modification du champ. + """ + updatedAt: ISO8601DateTime! + value: Boolean +} + type YesNoChampDescriptor implements ChampDescriptor { """ Description des champs d’un bloc répétable. diff --git a/app/graphql/types/champ_type.rb b/app/graphql/types/champ_type.rb index 183955fec..b13f6e250 100644 --- a/app/graphql/types/champ_type.rb +++ b/app/graphql/types/champ_type.rb @@ -24,8 +24,14 @@ module Types else Types::Champs::TextChampType end - when ::Champs::YesNoChamp, ::Champs::CheckboxChamp + when ::Champs::CheckboxChamp Types::Champs::CheckboxChampType + when ::Champs::YesNoChamp + if context.has_fragment?(:YesNoChamp) + Types::Champs::YesNoChampType + else + Types::Champs::CheckboxChampType + end when ::Champs::DateChamp Types::Champs::DateChampType when ::Champs::DatetimeChamp diff --git a/app/graphql/types/champs/yes_no_champ_type.rb b/app/graphql/types/champs/yes_no_champ_type.rb new file mode 100644 index 000000000..29c942098 --- /dev/null +++ b/app/graphql/types/champs/yes_no_champ_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types::Champs + class YesNoChampType < Types::BaseObject + implements Types::ChampType + + field :value, Boolean, null: true + + def value + if object.blank? + nil + else + object.true? + end + end + end +end diff --git a/app/models/types_de_champ/yes_no_type_de_champ.rb b/app/models/types_de_champ/yes_no_type_de_champ.rb index 33148b161..947e31d79 100644 --- a/app/models/types_de_champ/yes_no_type_de_champ.rb +++ b/app/models/types_de_champ/yes_no_type_de_champ.rb @@ -33,20 +33,11 @@ class TypesDeChamp::YesNoTypeDeChamp < TypesDeChamp::CheckboxTypeDeChamp end def champ_default_value - 'Non' + '' end def champ_default_export_value(path = :value) - 'Non' - end - - def champ_default_api_value(version = 2) - case version - when 2 - 'false' - else - nil - end + '' end private diff --git a/spec/models/champ_spec.rb b/spec/models/champ_spec.rb index 1afb2ce2f..3c2368f95 100644 --- a/spec/models/champ_spec.rb +++ b/spec/models/champ_spec.rb @@ -212,7 +212,7 @@ describe Champ do context 'if nil' do let(:value) { nil } - it { expect(value_for_export).to eq('Non') } + it { expect(value_for_export).to eq('') } end end @@ -236,7 +236,7 @@ describe Champ do allow(champ_text).to receive(:type_de_champ).and_return(type_de_champ_text) end it { expect(type_de_champ_text.champ_value_for_export(champ_yes_no)).to eq(nil) } - it { expect(type_de_champ_yes_no.champ_value_for_export(champ_text)).to eq('Non') } + it { expect(type_de_champ_yes_no.champ_value_for_export(champ_text)).to eq('') } end end diff --git a/spec/models/champs/checkbox_champ_spec.rb b/spec/models/champs/checkbox_champ_spec.rb index bbe6edca0..6c93e3176 100644 --- a/spec/models/champs/checkbox_champ_spec.rb +++ b/spec/models/champs/checkbox_champ_spec.rb @@ -3,7 +3,7 @@ describe Champs::CheckboxChamp do let(:boolean_champ) { described_class.new(value: value) } before { allow(boolean_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_checkbox)) } - it_behaves_like "a boolean champ" + it_behaves_like "a boolean champ", false # TODO remove when normalize_checkbox_values is over describe '#true?' do diff --git a/spec/models/champs/yes_no_champ_spec.rb b/spec/models/champs/yes_no_champ_spec.rb index 3a8ccb04e..de3983bd7 100644 --- a/spec/models/champs/yes_no_champ_spec.rb +++ b/spec/models/champs/yes_no_champ_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true describe Champs::YesNoChamp do - it_behaves_like "a boolean champ" do + it_behaves_like "a boolean champ", true do let(:boolean_champ) { described_class.new(value: value) } before { allow(boolean_champ).to receive(:type_de_champ).and_return(build(:type_de_champ_yes_no)) } end diff --git a/spec/support/shared_examples_for_boolean_champs.rb b/spec/support/shared_examples_for_boolean_champs.rb index bb7219717..8aba22115 100644 --- a/spec/support/shared_examples_for_boolean_champs.rb +++ b/spec/support/shared_examples_for_boolean_champs.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.shared_examples "a boolean champ" do +RSpec.shared_examples "a boolean champ" do |nullable| describe 'before validation' do subject { boolean_champ.valid? } @@ -55,7 +55,7 @@ RSpec.shared_examples "a boolean champ" do context 'when the value is nil' do let(:value) { nil } - it { is_expected.to eq("Non") } + it { is_expected.to eq(nullable ? '' : 'Non') } end end end From b5c7e78bbf97d7f4ee9ae5b7512a9b42f55a3f10 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 22 Oct 2024 22:09:43 +0200 Subject: [PATCH 1296/1532] fix(js): fix sentry errors --- .../controllers/autosave_controller.ts | 12 ++++---- .../controllers/date_input_hint_controller.ts | 2 +- .../controllers/email_input_controller.ts | 30 +++++++++++-------- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/app/javascript/controllers/autosave_controller.ts b/app/javascript/controllers/autosave_controller.ts index 863794195..0a3cc0570 100644 --- a/app/javascript/controllers/autosave_controller.ts +++ b/app/javascript/controllers/autosave_controller.ts @@ -1,13 +1,13 @@ -import { httpRequest, ResponseError, getConfig } from '@utils'; -import { matchInputElement, isButtonElement } from '@coldwired/utils'; +import { isButtonElement, matchInputElement } from '@coldwired/utils'; +import { getConfig, httpRequest, ResponseError } from '@utils'; -import { ApplicationController } from './application_controller'; import { AutoUpload } from '../shared/activestorage/auto-upload'; import { - FileUploadError, + ERROR_CODE_READ, FAILURE_CLIENT, - ERROR_CODE_READ + FileUploadError } from '../shared/activestorage/file-upload-error'; +import { ApplicationController } from './application_controller'; const { autosave: { debounce_delay } @@ -182,7 +182,7 @@ export class AutosaveController extends ApplicationController { .catch((e) => { const error = e as FileUploadError; - this.globalDispatch('autosave:error'); + this.globalDispatch('autosave:error', { error }); // Report unexpected client errors to Sentry. // (But ignore usual client errors, or errors we can monitor better on the server side.) diff --git a/app/javascript/controllers/date_input_hint_controller.ts b/app/javascript/controllers/date_input_hint_controller.ts index 8eacd6d53..d7cad2ebf 100644 --- a/app/javascript/controllers/date_input_hint_controller.ts +++ b/app/javascript/controllers/date_input_hint_controller.ts @@ -36,7 +36,7 @@ export class DateInputHintController extends ApplicationController { private translatePlaceholder() { const locale = document.documentElement.lang as 'fr' | 'en'; - const parts = PARTS[locale]; + const parts = PARTS[locale] ?? PARTS.fr; const example = new Date(2022, 9, 15).toLocaleDateString(); return [ Object.entries(parts).reduce( diff --git a/app/javascript/controllers/email_input_controller.ts b/app/javascript/controllers/email_input_controller.ts index 63fce7953..31849ceaa 100644 --- a/app/javascript/controllers/email_input_controller.ts +++ b/app/javascript/controllers/email_input_controller.ts @@ -1,11 +1,12 @@ -import { httpRequest } from '@utils'; -import { show, hide } from '@utils'; +import { hide, httpRequest, show } from '@utils'; import { ApplicationController } from './application_controller'; -type checkEmailResponse = { - success: boolean; - suggestions: string[]; -}; +type CheckEmailResponse = + | { + success: true; + suggestions: string[]; + } + | { success: false }; export class EmailInputController extends ApplicationController { static targets = ['ariaRegion', 'suggestion', 'input']; @@ -32,14 +33,17 @@ export class EmailInputController extends ApplicationController { const url = new URL(this.urlValue, document.baseURI); url.searchParams.append('email', this.inputTarget.value); - const data: checkEmailResponse | null = await httpRequest( - url.toString() - ).json(); + const data = await httpRequest(url.toString()) + .json() + .catch(() => null); - if (data && data.suggestions && data.suggestions.length > 0) { - this.suggestionTarget.innerHTML = data.suggestions[0]; - show(this.ariaRegionTarget); - this.ariaRegionTarget.focus(); + if (data?.success) { + const suggestion = data.suggestions.at(0); + if (suggestion) { + this.suggestionTarget.innerHTML = suggestion; + show(this.ariaRegionTarget); + this.ariaRegionTarget.focus(); + } } } From 1169dae3108f59c83edf84c55bd06d4015e5c3af Mon Sep 17 00:00:00 2001 From: benoitqueyron <72251526+Benoit-MINT@users.noreply.github.com> Date: Fri, 18 Oct 2024 15:54:11 +0200 Subject: [PATCH 1297/1532] task: clean header_section options --- .../clean_header_section_options_task.rb | 23 +++++ .../clean_header_section_options_task_spec.rb | 95 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 app/tasks/maintenance/clean_header_section_options_task.rb create mode 100644 spec/tasks/maintenance/clean_header_section_options_task_spec.rb diff --git a/app/tasks/maintenance/clean_header_section_options_task.rb b/app/tasks/maintenance/clean_header_section_options_task.rb new file mode 100644 index 000000000..7bcd250bb --- /dev/null +++ b/app/tasks/maintenance/clean_header_section_options_task.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Maintenance + class CleanHeaderSectionOptionsTask < MaintenanceTasks::Task + # In the rest of PR 10713 + # TypeDeChamp options may contain options which are not consistent with the + # type_champ (e.g. a ‘header_section’ TypeDeChamp which has a + # drop_down_options key/value in its options). + # The aim here is to clean up the options so that only those wich are useful + # for the type_champ in question. + + def collection + TypeDeChamp + .where(type_champ: 'header_section') + .where.not(options: {}) + .where.not("(SELECT COUNT(*) FROM jsonb_each_text(options)) = 1 AND options ? 'header_section_level'") + end + + def process(tdc) + tdc.update(options: tdc.options.slice(:header_section_level)) + end + end +end diff --git a/spec/tasks/maintenance/clean_header_section_options_task_spec.rb b/spec/tasks/maintenance/clean_header_section_options_task_spec.rb new file mode 100644 index 000000000..ba3571141 --- /dev/null +++ b/spec/tasks/maintenance/clean_header_section_options_task_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "rails_helper" + +module Maintenance + RSpec.describe CleanHeaderSectionOptionsTask do + describe '#collection' do + subject(:collection) { described_class.collection } + + context 'clean header_section tdc with header_section_level' do + let(:tdc) { + create(:type_de_champ_header_section, + options: { + 'header_section_level' => '1' + }) + } + + it do + expect(collection).not_to include(tdc) + end + end + + context 'clean header_section tdc with no header_section_level' do + let(:tdc) { + create(:type_de_champ_header_section, + options: {}) + } + + it do + expect(collection).not_to include(tdc) + end + end + + context 'header_section tdc with bad data options' do + let(:tdc) { + create(:type_de_champ_header_section, + options: { + 'header_section_level' => '1', + 'key' => 'value' + }) + } + + it do + expect(collection).to include(tdc) + end + end + + context 'other tdc' do + let(:tdc) { + create(:type_de_champ_textarea, + options: { + 'character_limit' => '400' + }) + } + + it do + expect(collection).not_to include(tdc) + end + end + end + + describe "#process" do + subject(:process) { described_class.process(tdc) } + + context 'bad data in options' do + let(:tdc) { + create(:type_de_champ_header_section, + options: { + 'header_section_level' => '1', + 'key' => 'value' + }) + } + + it do + subject + expect(tdc.reload.options).to eq({ 'header_section_level' => '1' }) + end + end + + context 'only bad data in options' do + let(:tdc) { + create(:type_de_champ_header_section, + options: { + 'key' => 'value' + }) + } + + it do + subject + expect(tdc.reload.options).to eq({}) + end + end + end + end +end From a1abaa4ac2a610c2eedd4c9e51ed16c622dca667 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Tue, 15 Oct 2024 10:10:59 +0200 Subject: [PATCH 1298/1532] Replace span by div & tab-index by tabindex --- .../dropdown/menu_component/menu_component.html.haml | 2 +- app/views/invites/_dropdown.html.haml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/components/dropdown/menu_component/menu_component.html.haml b/app/components/dropdown/menu_component/menu_component.html.haml index b1a98d8bc..d580c563b 100644 --- a/app/components/dropdown/menu_component/menu_component.html.haml +++ b/app/components/dropdown/menu_component/menu_component.html.haml @@ -2,7 +2,7 @@ %button{ class: button_class_names, id: button_id, disabled: disabled?, data: data, "aria-expanded": "false", 'aria-haspopup': 'true', 'aria-controls': menu_id } = button_inner_html - %div{ data: { menu_button_target: 'menu' }, id: menu_id, 'aria-labelledby': button_id, role: menu_role, 'tab-index': -1, class: menu_class_names } + %div{ data: { menu_button_target: 'menu' }, id: menu_id, 'aria-labelledby': button_id, role: menu_role, 'tabindex': -1, class: menu_class_names } = menu_header_html -# the dropdown can be a menu with a list of item diff --git a/app/views/invites/_dropdown.html.haml b/app/views/invites/_dropdown.html.haml index 45a00864c..3d7de5a12 100644 --- a/app/views/invites/_dropdown.html.haml +++ b/app/views/invites/_dropdown.html.haml @@ -1,5 +1,6 @@ - invites = dossier.invites.load -= render Dropdown::MenuComponent.new(wrapper: :span, wrapper_options: {class: 'invite-user-action'}, button_options: { class: ['fr-btn--secondary'] }, menu_options: { id: 'invite-content' }) do |menu| += render Dropdown::MenuComponent.new(wrapper: :div, wrapper_options: {class: 'invite-user-action'}, button_options: { class: ['fr-btn--secondary'] }, menu_options: { id: 'invite-content' }) do |menu| + = 'lab' - menu.with_button_inner_html do = dsfr_icon('fr-icon-user-add-fill', :sm, :mr) - if invites.present? From b0b1cdbbeb2535e654d7f5f068201bd16f915ca3 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Tue, 15 Oct 2024 11:15:08 +0200 Subject: [PATCH 1299/1532] Remove duplicate id --- .../champ_label_content_component.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml index 09fdd72f9..ddbec2cf3 100644 --- a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml +++ b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml @@ -21,7 +21,7 @@ %span.fr-hint-text{ data: { controller: 'date-input-hint' } }= hint - if @champ.description.present? - %span.fr-hint-text{ id: @champ.describedby_id }= render SimpleFormatComponent.new(@champ.description, allow_a: true) + %span.fr-hint-text= render SimpleFormatComponent.new(@champ.description, allow_a: true) - if @champ.textarea? %span.sr-only= t('.recommended_size', size: @champ.character_limit_base) From d86673156f01b842a835dbec8f757c4901e8d5d3 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Tue, 15 Oct 2024 15:23:39 +0200 Subject: [PATCH 1300/1532] Specify current stage in title --- config/locales/metas.en.yml | 8 ++++---- config/locales/metas.fr.yml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/config/locales/metas.en.yml b/config/locales/metas.en.yml index a8533b1bf..fcec3b045 100644 --- a/config/locales/metas.en.yml +++ b/config/locales/metas.en.yml @@ -12,13 +12,13 @@ en: identite: title: "New file (%{procedure_label}) - Step 1: Identity" show: - title: "Summary · File nº %{number} (%{procedure_label})" + title: "Summary · File %{number} (%{procedure_label})" demande: - title: "Application · File nº %{number} (%{procedure_label})" + title: "Application · File %{number} (%{procedure_label})" messagerie: - title: "Mailbox · File nº %{number} (%{procedure_label})" + title: "Mailbox · File %{number} (%{procedure_label})" brouillon: - title: "Modification of draft nº %{number} (%{procedure_label})" + title: "File %{number} in draft (%{procedure_label}) - Step 2: Form" merci: title: "File submitted (%{procedure_label})" activate: diff --git a/config/locales/metas.fr.yml b/config/locales/metas.fr.yml index b8cc06caf..44f503cd4 100644 --- a/config/locales/metas.fr.yml +++ b/config/locales/metas.fr.yml @@ -12,13 +12,13 @@ fr: identite: title: "Nouveau dossier (%{procedure_label}) - Étape 1 : Identité" show: - title: "Résumé · Dossier nº %{number} (%{procedure_label})" + title: "Résumé · Dossier %{number} (%{procedure_label})" demande: - title: "Demande · Dossier nº %{number} (%{procedure_label})" + title: "Demande · Dossier %{number} (%{procedure_label})" messagerie: - title: "Messagerie · Dossier nº %{number} (%{procedure_label})" + title: "Messagerie · Dossier %{number} (%{procedure_label})" brouillon: - title: "Modification du brouillon nº %{number} (%{procedure_label})" + title: "Dossier %{number} en brouillon (%{procedure_label}) - Étape 2 : Formulaire" merci: title: "Dossier envoyé (%{procedure_label})" activate: From 48f1cd4a4a81125640cb5fcf0d1d2add129a7f37 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Tue, 15 Oct 2024 16:23:55 +0200 Subject: [PATCH 1301/1532] Enhance contrast of notification dot --- app/assets/stylesheets/notifications.scss | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/assets/stylesheets/notifications.scss b/app/assets/stylesheets/notifications.scss index fb3923cfc..7097b9368 100644 --- a/app/assets/stylesheets/notifications.scss +++ b/app/assets/stylesheets/notifications.scss @@ -1,11 +1,9 @@ -@import "colors"; - span.notifications { position: absolute; width: 8px; height: 8px; border-radius: 4px; - background-color: $orange; + background-color: var(--background-flat-warning); } .fr-tabs__list span.notifications { From 4e64eb1af5e3472566dfa6b0ba083d640f5efed2 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Tue, 15 Oct 2024 17:12:53 +0200 Subject: [PATCH 1302/1532] Hide pesudo-element content from assistive technologies --- app/assets/stylesheets/buttons.scss | 4 +--- .../batch_operation_component.html.haml | 1 + .../dropdown/menu_component/menu_component.html.haml | 1 + app/views/layouts/_header.haml | 2 +- public/500.html | 2 +- public/502.html | 2 +- public/503.html | 2 +- public/504.html | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/assets/stylesheets/buttons.scss b/app/assets/stylesheets/buttons.scss index be434a232..1c60811ad 100644 --- a/app/assets/stylesheets/buttons.scss +++ b/app/assets/stylesheets/buttons.scss @@ -151,10 +151,8 @@ .dropdown-button { white-space: nowrap; - &::after { + [aria-hidden="true"].fr-ml-2v::after { content: "▾"; - margin-left: $default-spacer; - font-weight: bold; } &.icon-only { diff --git a/app/components/dossiers/batch_operation_component/batch_operation_component.html.haml b/app/components/dossiers/batch_operation_component/batch_operation_component.html.haml index f75cbc6da..c8357feca 100644 --- a/app/components/dossiers/batch_operation_component/batch_operation_component.html.haml +++ b/app/components/dossiers/batch_operation_component/batch_operation_component.html.haml @@ -13,6 +13,7 @@ -# Dropdown button title %button#batch_operation_others.fr-btn.fr-btn--sm.fr-btn--secondary.fr-ml-1w.dropdown-button{ disabled: true, data: { menu_button_target: 'button', batch_operation_target: 'dropdown' } } = t('.operations.other') + %span.fr-ml-2v{ 'aria-hidden': 'true' } #state-menu.dropdown-content.fade-in-down{ data: { menu_button_target: 'menu' }, "aria-labelledby" => "batch_operation_others" } %ul.dropdown-items diff --git a/app/components/dropdown/menu_component/menu_component.html.haml b/app/components/dropdown/menu_component/menu_component.html.haml index d580c563b..7b6f16b4f 100644 --- a/app/components/dropdown/menu_component/menu_component.html.haml +++ b/app/components/dropdown/menu_component/menu_component.html.haml @@ -1,6 +1,7 @@ = content_tag(@wrapper, wrapper_options) do %button{ class: button_class_names, id: button_id, disabled: disabled?, data: data, "aria-expanded": "false", 'aria-haspopup': 'true', 'aria-controls': menu_id } = button_inner_html + %span.fr-ml-2v{ 'aria-hidden': 'true' } %div{ data: { menu_button_target: 'menu' }, id: menu_id, 'aria-labelledby': button_id, role: menu_role, 'tabindex': -1, class: menu_class_names } = menu_header_html diff --git a/app/views/layouts/_header.haml b/app/views/layouts/_header.haml index fda1d2b0c..d65475e72 100644 --- a/app/views/layouts/_header.haml +++ b/app/views/layouts/_header.haml @@ -53,7 +53,7 @@ = render partial: 'shared/help/help_dropdown_instructeur' - else -# NB: on mobile in order to have links correctly aligned, we need a left icon # - = link_to t('help'), t("links.common.faq.url"), class: 'fr-btn dropdown-button' + = link_to t('help'), t("links.common.faq.url"), class: 'fr-btn' diff --git a/public/500.html b/public/500.html index 2bb269345..0f25f7b6e 100644 --- a/public/500.html +++ b/public/500.html @@ -2109,7 +2109,7 @@
  • Aide diff --git a/public/503.html b/public/503.html index 896d3bd4c..592e9380c 100644 --- a/public/503.html +++ b/public/503.html @@ -2108,7 +2108,7 @@
  • Aide diff --git a/public/504.html b/public/504.html index f2265dc7a..196f24022 100644 --- a/public/504.html +++ b/public/504.html @@ -2108,7 +2108,7 @@
  • Aide From fd049f6025b6c6718b1d697843e2e3724ba00cc2 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Fri, 18 Oct 2024 16:45:40 +0200 Subject: [PATCH 1303/1532] Improve status message delivery to assistive technologies --- app/helpers/application_helper.rb | 9 +------ app/views/layouts/_flash_messages.html.haml | 30 +++++++++++++-------- spec/helpers/application_helper_spec.rb | 6 ++--- 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index a2f9c6029..0ca885593 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -36,16 +36,9 @@ module ApplicationHelper end end - def flash_class(level, sticky: false, fixed: false) + def flash_class(sticky: false, fixed: false) class_names = [] - case level - when 'notice' - class_names << 'alert-success' - when 'alert', 'error' - class_names << 'alert-danger' - end - if sticky class_names << 'sticky' end diff --git a/app/views/layouts/_flash_messages.html.haml b/app/views/layouts/_flash_messages.html.haml index b0a3e5d72..55dd13ac8 100644 --- a/app/views/layouts/_flash_messages.html.haml +++ b/app/views/layouts/_flash_messages.html.haml @@ -1,14 +1,22 @@ -#flash_messages{ aria: { live: 'assertive' } } - - if flash.any? - #flash_message.center +#flash_messages + #flash_message.center + - if flash.any? - flash.each do |key, value| - sticky = defined?(sticky) ? sticky : false - fixed = defined?(fixed) ? fixed : false - - if value.class == Array - .alert{ class: flash_class(key, sticky: sticky, fixed: fixed), role: flash_role(key) } - - value.each do |message| - = sanitize_with_link(message) - %br - - elsif value.present? - .alert{ class: flash_class(key, sticky: sticky, fixed: fixed), role: flash_role(key) } - = sanitize_with_link(value) + - if flash_role(key) == 'status' + .alert.alert-success{ role: 'status', class: flash_class(sticky: sticky, fixed: fixed), tabindex: '-1', data: { controller: 'autofocus' } } + - if value.class == Array + - value.each do |message| + = sanitize_with_link(message) + %br + - elsif value.present? + = sanitize_with_link(value) + - elsif flash_role(key) == 'alert' + .alert.alert-danger{ role: 'alert', class: flash_class(sticky: sticky, fixed: fixed), tabindex: '-1', data: { controller: 'autofocus' } } + - if value.class == Array + - value.each do |message| + = sanitize_with_link(message) + %br + - elsif value.present? + = sanitize_with_link(value) diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 44416c4b8..6b70a8097 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -71,10 +71,8 @@ describe ApplicationHelper do end describe "#flash_class" do - it { expect(flash_class('notice')).to eq 'alert-success' } - it { expect(flash_class('alert', sticky: true, fixed: true)).to eq 'alert-danger sticky alert-fixed' } - it { expect(flash_class('error')).to eq 'alert-danger' } - it { expect(flash_class('unknown-level')).to eq '' } + it { expect(flash_class(sticky: true)).to eq 'sticky' } + it { expect(flash_class(fixed: true)).to eq 'alert-fixed' } end describe "#try_format_date" do From e21ebc75e17054d18884905a715719c81fa17c49 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Fri, 18 Oct 2024 17:06:03 +0200 Subject: [PATCH 1304/1532] Explain context of the 'revoke authorization' link to assistive technologies --- app/views/invites/_form.html.haml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/invites/_form.html.haml b/app/views/invites/_form.html.haml index 824d1b36b..8f3922f6f 100644 --- a/app/views/invites/_form.html.haml +++ b/app/views/invites/_form.html.haml @@ -4,13 +4,13 @@ %h5.fr-h6= t('views.invites.form.edit_dossier', count: invites.size) - if invites.present? - #invite-list{ morphing ? { tabindex: "-1" } : {} } + #invite-list %ul - - invites.each do |invite| + - invites.each_with_index do |invite, index| %li - = invite.email + %span{ :id => "invite_#{index}" }= invite.email %small{ 'data-turbo': 'true' } - = link_to t('views.invites.form.withdraw_permission'), invite_path(invite), data: { turbo_method: :delete, turbo_confirm: t('views.invites.form.want_to_withdraw_permission', email: invite.email) }, class: "fr-btn fr-btn--sm fr-btn--tertiary-no-outline" + = link_to t('views.invites.form.withdraw_permission'), invite_path(invite), data: { turbo_method: :delete, turbo_confirm: t('views.invites.form.want_to_withdraw_permission', email: invite.email) }, class: "fr-btn fr-btn--sm fr-btn--tertiary-no-outline", id: "link_#{index}", "aria-labelledby": "link_#{index} invite_#{index}" - if dossier.brouillon? %p= t('views.invites.form.submit_dossier_yourself') From 536160f83e94a2c42341a8a394f8327495be5a30 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Mon, 21 Oct 2024 11:35:18 +0200 Subject: [PATCH 1305/1532] Remove useless aria-live attribute --- .../input_status_message_component.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/dsfr/input_status_message_component/input_status_message_component.html.haml b/app/components/dsfr/input_status_message_component/input_status_message_component.html.haml index 305d2d07f..9edef67b1 100644 --- a/app/components/dsfr/input_status_message_component/input_status_message_component.html.haml +++ b/app/components/dsfr/input_status_message_component/input_status_message_component.html.haml @@ -1,4 +1,4 @@ -.fr-messages-group{ id: @describedby_id, aria: { live: :assertive } } +.fr-messages-group{ id: @describedby_id } - if @error_full_messages.size > 0 %p{ class: class_names('fr-message' => true, "fr-message--#{@errors_on_attribute ? 'error' : 'valid'}" => true) } = "« #{@champ.libelle} » " From c91d8698bda9ae55025c6445ad543f09c6d40519 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Mon, 21 Oct 2024 14:13:53 +0200 Subject: [PATCH 1306/1532] Remove useless note for screen readers --- .../champ_label_content_component.html.haml | 2 -- spec/system/users/brouillon_spec.rb | 12 ++++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml index ddbec2cf3..a6dc331f1 100644 --- a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml +++ b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml @@ -2,8 +2,6 @@ - if @champ.public? - if @champ.mandatory? = render EditableChamp::AsteriskMandatoryComponent.new - - else - %span.sr-only= t('.optional_champ') - if @champ.forked_with_changes? %span.updated-at.highlighted diff --git a/spec/system/users/brouillon_spec.rb b/spec/system/users/brouillon_spec.rb index ade916005..06eccfb32 100644 --- a/spec/system/users/brouillon_spec.rb +++ b/spec/system/users/brouillon_spec.rb @@ -442,7 +442,7 @@ describe 'The user', js: true do fill_individual - fill_in('age (facultatif)', with: 10) + fill_in('age', with: 10) click_on 'Déposer le dossier' expect(page).to have_current_path(merci_dossier_path(user_dossier)) end @@ -513,7 +513,7 @@ describe 'The user', js: true do fill_individual - fill_in('age (facultatif)', with: '18') + fill_in('age', with: '18') expect(page).to have_css('label', text: 'nom', visible: :visible) expect(page).to have_css('.icon.mandatory') click_on 'Déposer le dossier' @@ -550,7 +550,7 @@ describe 'The user', js: true do expect(page).to have_no_css('legend', text: 'info voiture', visible: true) expect(page).to have_no_css('label', text: 'tonnage', visible: true) - fill_in('age du candidat (facultatif)', with: '18') + fill_in('age du candidat', with: '18') expect(page).to have_css('legend', text: 'permis de conduire', visible: true) expect(page).to have_css('legend', text: 'info voiture', visible: true) expect(page).to have_no_css('label', text: 'tonnage', visible: true) @@ -563,10 +563,10 @@ describe 'The user', js: true do expect(page).to have_css('label', text: 'parking', visible: true) # try to fill with invalid data - fill_in('tonnage (facultatif)', with: 'a') + fill_in('tonnage', with: 'a') expect(page).to have_no_css('label', text: 'parking', visible: true) - fill_in('age du candidat (facultatif)', with: '2') + fill_in('age du candidat', with: '2') expect(page).to have_no_css('legend', text: 'permis de conduire', visible: true) expect(page).to have_no_css('label', text: 'tonnage', visible: true) @@ -578,7 +578,7 @@ describe 'The user', js: true do expect(page).to have_no_css('legend', text: 'permis de conduire', visible: true) expect(page).to have_no_css('label', text: 'tonnage', visible: true) - fill_in('age du candidat (facultatif)', with: '18') + fill_in('age du candidat', with: '18') wait_for_autosave # the champ keeps their previous value so they are all displayed From f3d0968da172e9716388b743c74a593b5b11764b Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Wed, 23 Oct 2024 14:57:55 +0200 Subject: [PATCH 1307/1532] Enable restitution of morphed messages --- app/views/layouts/_flash_messages.html.haml | 4 ++-- app/views/layouts/application.turbo_stream.haml | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/views/layouts/_flash_messages.html.haml b/app/views/layouts/_flash_messages.html.haml index 55dd13ac8..f8b280447 100644 --- a/app/views/layouts/_flash_messages.html.haml +++ b/app/views/layouts/_flash_messages.html.haml @@ -1,5 +1,5 @@ -#flash_messages - #flash_message.center +#flash_messages{ tabindex: '-1' } + #flash_message.center{ class: defined?(unique_classname) ? unique_classname : '' } - if flash.any? - flash.each do |key, value| - sticky = defined?(sticky) ? sticky : false diff --git a/app/views/layouts/application.turbo_stream.haml b/app/views/layouts/application.turbo_stream.haml index c359b707a..bb1d0ef95 100644 --- a/app/views/layouts/application.turbo_stream.haml +++ b/app/views/layouts/application.turbo_stream.haml @@ -1,7 +1,9 @@ - if flash.any? - = turbo_stream.replace 'flash_messages', partial: 'layouts/flash_messages' + - unique_classname= "u#{SecureRandom.hex}" + = turbo_stream.replace 'flash_messages', partial: 'layouts/flash_messages', locals: { unique_classname: } + = turbo_stream.focus 'flash_messages' = turbo_stream.show 'flash_messages' - = turbo_stream.hide 'flash_messages', delay: 30000 + = turbo_stream.hide_all ".#{unique_classname}", delay: 15000 - flash.clear = yield From dd362055dff125802d9c5adde3420a6feffed5a7 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Wed, 23 Oct 2024 14:58:21 +0200 Subject: [PATCH 1308/1532] Remove useless code --- spec/support/system_helpers.rb | 7 ------- 1 file changed, 7 deletions(-) diff --git a/spec/support/system_helpers.rb b/spec/support/system_helpers.rb index e68926aed..7728cb875 100644 --- a/spec/support/system_helpers.rb +++ b/spec/support/system_helpers.rb @@ -76,13 +76,6 @@ module SystemHelpers click_on 'Ajouter un champ' end - def remove_flash_message - expect(page).to have_button('Ajouter un champ', disabled: false) - expect(page).to have_content('Formulaire enregistré') - execute_script("document.querySelector('#flash_message').remove();") - execute_script("document.querySelector('#autosave-notice').remove();") - end - def hide_autonotice_message expect(page).to have_text('Formulaire enregistré') execute_script("document.querySelector('#autosave-notice').classList.add('hidden');") From 84870615d8f38d951b78700c8a9784d38891d190 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 23 Oct 2024 15:18:43 +0200 Subject: [PATCH 1309/1532] feat(flash_messages): dry up code and ensure turbo_force: :server otherwise morphing does not refresh page with new attrs --- app/helpers/application_helper.rb | 8 ++++++- app/views/layouts/_flash_messages.html.haml | 25 +++++++-------------- spec/helpers/application_helper_spec.rb | 6 +++-- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0ca885593..88a398cd7 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -36,9 +36,15 @@ module ApplicationHelper end end - def flash_class(sticky: false, fixed: false) + def flash_class(level, sticky: false, fixed: false) class_names = [] + case level + when 'notice' + class_names << 'alert-success' + when 'alert', 'error' + class_names << 'alert-danger' + end if sticky class_names << 'sticky' end diff --git a/app/views/layouts/_flash_messages.html.haml b/app/views/layouts/_flash_messages.html.haml index f8b280447..d2e0eff4c 100644 --- a/app/views/layouts/_flash_messages.html.haml +++ b/app/views/layouts/_flash_messages.html.haml @@ -1,22 +1,13 @@ -#flash_messages{ tabindex: '-1' } +#flash_messages{ tabindex: '-1', data: { turbo_force: :server } } #flash_message.center{ class: defined?(unique_classname) ? unique_classname : '' } - if flash.any? - flash.each do |key, value| - sticky = defined?(sticky) ? sticky : false - fixed = defined?(fixed) ? fixed : false - - if flash_role(key) == 'status' - .alert.alert-success{ role: 'status', class: flash_class(sticky: sticky, fixed: fixed), tabindex: '-1', data: { controller: 'autofocus' } } - - if value.class == Array - - value.each do |message| - = sanitize_with_link(message) - %br - - elsif value.present? - = sanitize_with_link(value) - - elsif flash_role(key) == 'alert' - .alert.alert-danger{ role: 'alert', class: flash_class(sticky: sticky, fixed: fixed), tabindex: '-1', data: { controller: 'autofocus' } } - - if value.class == Array - - value.each do |message| - = sanitize_with_link(message) - %br - - elsif value.present? - = sanitize_with_link(value) + .alert{ role: flash_role(key), class: flash_class(key, sticky: sticky, fixed: fixed) } + - if value.class == Array + - value.each do |message| + = sanitize_with_link(message) + %br + - elsif value.present? + = sanitize_with_link(value) diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 6b70a8097..44416c4b8 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -71,8 +71,10 @@ describe ApplicationHelper do end describe "#flash_class" do - it { expect(flash_class(sticky: true)).to eq 'sticky' } - it { expect(flash_class(fixed: true)).to eq 'alert-fixed' } + it { expect(flash_class('notice')).to eq 'alert-success' } + it { expect(flash_class('alert', sticky: true, fixed: true)).to eq 'alert-danger sticky alert-fixed' } + it { expect(flash_class('error')).to eq 'alert-danger' } + it { expect(flash_class('unknown-level')).to eq '' } end describe "#try_format_date" do From 84ae13eb6b2296c778a80c963da8f095defad936 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 23 Oct 2024 16:57:54 +0200 Subject: [PATCH 1310/1532] fix(helpscout): don't create conversation with an empty attachment --- app/jobs/helpscout_create_conversation_job.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/jobs/helpscout_create_conversation_job.rb b/app/jobs/helpscout_create_conversation_job.rb index f18781d53..85dda42a0 100644 --- a/app/jobs/helpscout_create_conversation_job.rb +++ b/app/jobs/helpscout_create_conversation_job.rb @@ -49,6 +49,7 @@ class HelpscoutCreateConversationJob < ApplicationJob def safe_blob return if !contact_form.piece_jointe.virus_scanner&.safe? + return if contact_form.piece_jointe.byte_size.zero? # HS don't support empty attachment contact_form.piece_jointe end From 4b48ee02cd0e06267a121dea01b0ee635303fb62 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 23 Oct 2024 16:58:15 +0200 Subject: [PATCH 1311/1532] fix(helpscout): limit retries to a few hours --- app/jobs/helpscout_create_conversation_job.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/jobs/helpscout_create_conversation_job.rb b/app/jobs/helpscout_create_conversation_job.rb index 85dda42a0..b7f59c043 100644 --- a/app/jobs/helpscout_create_conversation_job.rb +++ b/app/jobs/helpscout_create_conversation_job.rb @@ -2,6 +2,9 @@ class HelpscoutCreateConversationJob < ApplicationJob queue_as :critical # user feedback is critical + + def max_attempts = 15 # ~10h + class FileNotScannedYetError < StandardError end From eaa3350b778a583be4be0692948602a0b72f95e7 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 23 Oct 2024 17:05:27 +0200 Subject: [PATCH 1312/1532] feat(contact): delete contact forms when max attempts has been reached --- app/jobs/helpscout_create_conversation_job.rb | 4 ++++ .../helpscout_create_conversation_job_spec.rb | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/app/jobs/helpscout_create_conversation_job.rb b/app/jobs/helpscout_create_conversation_job.rb index b7f59c043..8239898c7 100644 --- a/app/jobs/helpscout_create_conversation_job.rb +++ b/app/jobs/helpscout_create_conversation_job.rb @@ -25,6 +25,10 @@ class HelpscoutCreateConversationJob < ApplicationJob create_conversation contact_form.delete + rescue StandardError + contact_form.delete if executions >= max_attempts + + raise end private diff --git a/spec/jobs/helpscout_create_conversation_job_spec.rb b/spec/jobs/helpscout_create_conversation_job_spec.rb index 2ed841cb0..d94609731 100644 --- a/spec/jobs/helpscout_create_conversation_job_spec.rb +++ b/spec/jobs/helpscout_create_conversation_job_spec.rb @@ -110,5 +110,21 @@ RSpec.describe HelpscoutCreateConversationJob, type: :job do end end end + + context 'when max attempts are reached' do + before do + allow(api).to receive(:create_conversation).and_raise(StandardError) + allow_any_instance_of(described_class).to receive(:executions).and_return(described_class.new.max_attempts) + end + + it 'deletes the contact form' do + expect { subject }.to raise_error(StandardError) + expect(contact_form).to be_destroyed + end + + it 'does not enqueue the job again' do + expect { subject rescue nil }.not_to have_enqueued_job(described_class) + end + end end end From b22f04931818e47331997b53bc29d279d4be3d5b Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 24 Oct 2024 11:16:12 +0200 Subject: [PATCH 1313/1532] fix: do not allow empty filter in models --- app/models/filtered_column.rb | 7 ++-- config/i18n-tasks.yml | 1 + config/locales/models/filtered_column/en.yml | 7 ++++ config/locales/models/filtered_column/fr.yml | 7 ++++ .../procedures_controller_spec.rb | 2 +- spec/models/filtered_column_spec.rb | 35 +++++++++++++------ 6 files changed, 46 insertions(+), 13 deletions(-) create mode 100644 config/locales/models/filtered_column/en.yml create mode 100644 config/locales/models/filtered_column/fr.yml diff --git a/app/models/filtered_column.rb b/app/models/filtered_column.rb index c348e60fd..cd1e1f774 100644 --- a/app/models/filtered_column.rb +++ b/app/models/filtered_column.rb @@ -13,6 +13,9 @@ class FilteredColumn validate :check_filter_max_length validate :check_filter_max_integer + validates :filter, presence: { + message: -> (object, _data) { "Le filtre « #{object.label} » ne peut pas être vide" } + } def initialize(column:, filter:) @column = column @@ -33,14 +36,14 @@ class FilteredColumn if @filter.present? && @filter.length.to_i > FILTERS_VALUE_MAX_LENGTH errors.add( :base, - "Le filtre #{label} est trop long (maximum: #{FILTERS_VALUE_MAX_LENGTH} caractères)" + "Le filtre « #{label} » est trop long (maximum: #{FILTERS_VALUE_MAX_LENGTH} caractères)" ) end end def check_filter_max_integer if @column.column == 'id' && @filter.to_i > PG_INTEGER_MAX_VALUE - errors.add(:base, "Le filtre #{label} n'est pas un numéro de dossier possible") + errors.add(:base, "Le filtre « #{label} » n'est pas un numéro de dossier possible") end end end diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 2e98b95ae..697dc1102 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -103,6 +103,7 @@ ignore_unused: - 'activerecord.attributes.*' - 'activemodel.attributes.map_filter.*' - 'activemodel.attributes.helpscout/form.*' +- 'activemodel.errors.models.*' - 'activerecord.errors.*' - 'errors.messages.blank' - 'errors.messages.content_type_invalid' diff --git a/config/locales/models/filtered_column/en.yml b/config/locales/models/filtered_column/en.yml new file mode 100644 index 000000000..b9d099d2e --- /dev/null +++ b/config/locales/models/filtered_column/en.yml @@ -0,0 +1,7 @@ +en: + activemodel: + errors: + models: + filtered_column: + format: "%{message}" + diff --git a/config/locales/models/filtered_column/fr.yml b/config/locales/models/filtered_column/fr.yml new file mode 100644 index 000000000..aa5942414 --- /dev/null +++ b/config/locales/models/filtered_column/fr.yml @@ -0,0 +1,7 @@ +fr: + activemodel: + errors: + models: + filtered_column: + format: "%{message}" + diff --git a/spec/controllers/instructeurs/procedures_controller_spec.rb b/spec/controllers/instructeurs/procedures_controller_spec.rb index 996966fd8..1f3ea9acb 100644 --- a/spec/controllers/instructeurs/procedures_controller_spec.rb +++ b/spec/controllers/instructeurs/procedures_controller_spec.rb @@ -911,7 +911,7 @@ describe Instructeurs::ProceduresController, type: :controller do it 'should render the error' do subject - expect(flash.alert[0]).to include("Le filtre Nom est trop long (maximum: 4048 caractères)") + expect(flash.alert[0]).to include("Le filtre « Nom » est trop long (maximum: 4048 caractères)") end end end diff --git a/spec/models/filtered_column_spec.rb b/spec/models/filtered_column_spec.rb index f4882d960..30a02e83b 100644 --- a/spec/models/filtered_column_spec.rb +++ b/spec/models/filtered_column_spec.rb @@ -11,7 +11,7 @@ describe FilteredColumn do let(:filter) { 'a' * (FilteredColumn::FILTERS_VALUE_MAX_LENGTH + 1) } it 'adds an error' do - expect(filtered_column.errors.map(&:message)).to include(/Le filtre label est trop long/) + expect(filtered_column.errors.map(&:message)).to include(/Le filtre « label » est trop long/) end end @@ -22,14 +22,6 @@ describe FilteredColumn do expect(filtered_column.errors).to be_empty end end - - context 'when the filter is empty' do - let(:filter) { nil } - - it 'does not add an error' do - expect(filtered_column.errors).to be_empty - end - end end describe '#check_filters_max_integer' do @@ -43,7 +35,7 @@ describe FilteredColumn do let(:filter) { (FilteredColumn::PG_INTEGER_MAX_VALUE + 1).to_s } it 'adds an error' do - expect(filtered_column.errors.map(&:message)).to include(/Le filtre label n'est pas un numéro de dossier possible/) + expect(filtered_column.errors.map(&:message)).to include(/Le filtre « label » n'est pas un numéro de dossier possible/) end end @@ -56,4 +48,27 @@ describe FilteredColumn do end end end + + describe '#check_filter_is_not_blank' do + let(:column) { Column.new(procedure_id: 1, table: 'table', column: 'column', label: 'label') } + let(:filtered_column) { described_class.new(column:, filter:) } + + before { filtered_column.valid? } + + context 'when the filter is blank' do + let(:filter) { '' } + + it 'adds an error' do + expect(filtered_column.errors.map(&:message)).to include(/Le filtre « label » ne peut pas être vide/) + end + end + + context 'when the filter is not blank' do + let(:filter) { 'a' } + + it 'does not add an error' do + expect(filtered_column.errors).to be_empty + end + end + end end From d32a4a24d9aef86e224dc9d7df118b96ed5ef61c Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 24 Oct 2024 11:38:32 +0200 Subject: [PATCH 1314/1532] fix: do not allow empty filter in views --- .../column_filter_component.html.haml | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml index d440fb861..603aa3819 100644 --- a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml +++ b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml @@ -6,13 +6,30 @@ %react-fragment = render ReactComponent.new "ComboBox/SingleComboBox", **filter_react_props - %input.hidden{ type: 'submit', formaction: update_filter_instructeur_procedure_path(procedure), data: { autosubmit_target: 'submitter' } } + %input.hidden{ + type: 'submit', + formaction: update_filter_instructeur_procedure_path(procedure), + formnovalidate: 'true', + data: { autosubmit_target: 'submitter' } + } = label_tag :value, t('.value'), for: 'value', class: 'fr-label' - if column_type == :enum || column_type == :enums - = select_tag :filter, options_for_select(options_for_select_of_column), id: 'value', name: "#{prefix}[filter]", class: 'fr-select', data: { no_autosubmit: true } + = select_tag :filter, + options_for_select(options_for_select_of_column), + id: 'value', + name: "#{prefix}[filter]", + class: 'fr-select', + data: { no_autosubmit: true } - else - %input#value.fr-input{ type: column_type, name: "#{prefix}[filter]", maxlength: FilteredColumn::FILTERS_VALUE_MAX_LENGTH, disabled: column.nil? ? true : false, data: { no_autosubmit: true } } + %input#value.fr-input{ + type: column_type, + name: "#{prefix}[filter]", + maxlength: FilteredColumn::FILTERS_VALUE_MAX_LENGTH, + disabled: column.nil? ? true : false, + data: { no_autosubmit: true }, + required: true + } = hidden_field_tag :statut, statut = submit_tag t('.add_filter'), class: 'fr-btn fr-btn--secondary fr-mt-2w' From 406516a3465820416b8ff765e70a88143d518924 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 24 Oct 2024 13:17:34 +0200 Subject: [PATCH 1315/1532] fix(email-checker): success can have no suggestions --- app/javascript/controllers/email_input_controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/controllers/email_input_controller.ts b/app/javascript/controllers/email_input_controller.ts index 31849ceaa..6a51cc2a2 100644 --- a/app/javascript/controllers/email_input_controller.ts +++ b/app/javascript/controllers/email_input_controller.ts @@ -4,7 +4,7 @@ import { ApplicationController } from './application_controller'; type CheckEmailResponse = | { success: true; - suggestions: string[]; + suggestions?: string[]; } | { success: false }; @@ -38,7 +38,7 @@ export class EmailInputController extends ApplicationController { .catch(() => null); if (data?.success) { - const suggestion = data.suggestions.at(0); + const suggestion = data.suggestions?.at(0); if (suggestion) { this.suggestionTarget.innerHTML = suggestion; show(this.ariaRegionTarget); From e6845cd94db3d7925959df317b917034af48c89e Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Thu, 24 Oct 2024 10:13:34 +0200 Subject: [PATCH 1316/1532] fix(image processing): handle case of blob without attachments --- app/models/concerns/attachment_image_processor_concern.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/concerns/attachment_image_processor_concern.rb b/app/models/concerns/attachment_image_processor_concern.rb index 5047e1fe7..aee9fa9d6 100644 --- a/app/models/concerns/attachment_image_processor_concern.rb +++ b/app/models/concerns/attachment_image_processor_concern.rb @@ -18,7 +18,7 @@ module AttachmentImageProcessorConcern def process_image return if blob.nil? - return if blob.attachments.size > 1 + return if blob.attachments.size != 1 return if blob.attachments.last.record_type == "Export" ImageProcessorJob.perform_later(blob) From 4dc13cc56c046139d0bba2004393a9b8e2adbb92 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Thu, 24 Oct 2024 11:50:41 +0200 Subject: [PATCH 1317/1532] fix(image processing): process only authorized image and pdf types --- app/models/concerns/attachment_image_processor_concern.rb | 1 + config/initializers/authorized_content_types.rb | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/attachment_image_processor_concern.rb b/app/models/concerns/attachment_image_processor_concern.rb index aee9fa9d6..cd0e3ff65 100644 --- a/app/models/concerns/attachment_image_processor_concern.rb +++ b/app/models/concerns/attachment_image_processor_concern.rb @@ -20,6 +20,7 @@ module AttachmentImageProcessorConcern return if blob.nil? return if blob.attachments.size != 1 return if blob.attachments.last.record_type == "Export" + return if !blob.content_type.in?(PROCESSABLE_TYPES) ImageProcessorJob.perform_later(blob) end diff --git a/config/initializers/authorized_content_types.rb b/config/initializers/authorized_content_types.rb index 2a9a38d78..497757957 100644 --- a/config/initializers/authorized_content_types.rb +++ b/config/initializers/authorized_content_types.rb @@ -21,7 +21,9 @@ RARE_IMAGE_TYPES = [ 'image/tiff' # multimedia x 3985 ] -AUTHORIZED_CONTENT_TYPES = AUTHORIZED_IMAGE_TYPES + AUTHORIZED_PDF_TYPES + [ +PROCESSABLE_TYPES = AUTHORIZED_IMAGE_TYPES + AUTHORIZED_PDF_TYPES + +AUTHORIZED_CONTENT_TYPES = PROCESSABLE_TYPES + [ # multimedia 'video/mp4', # multimedia x 2075 'video/quicktime', # multimedia x 486 From 93fcd4ad49aeda6ea10d691d28f45c5ef97f3af7 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Thu, 24 Oct 2024 18:13:00 +0200 Subject: [PATCH 1318/1532] fix(image processing): do not uninterlace if no png path --- app/services/uninterlace_service.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/services/uninterlace_service.rb b/app/services/uninterlace_service.rb index d4cef3aaf..0ec100e38 100644 --- a/app/services/uninterlace_service.rb +++ b/app/services/uninterlace_service.rb @@ -17,6 +17,7 @@ class UninterlaceService end def interlaced?(png_path) + return false if png_path.blank? png = MiniMagick::Image.open(png_path) png.data["interlace"] != "None" end From 6898287dac622c85d75b000065d19a494a535a98 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 25 Oct 2024 11:07:35 +0200 Subject: [PATCH 1319/1532] fix: typo in ineligibilite panel --- .../revision_changes_component.fr.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/procedure/revision_changes_component/revision_changes_component.fr.yml b/app/components/procedure/revision_changes_component/revision_changes_component.fr.yml index 3228c76a8..921a99ff1 100644 --- a/app/components/procedure/revision_changes_component/revision_changes_component.fr.yml +++ b/app/components/procedure/revision_changes_component/revision_changes_component.fr.yml @@ -83,7 +83,7 @@ fr: ineligibilite_rules: add: La condition d’inéligibilité « %{new_condition} » a été ajoutée. remove: La condition d’inéligibilité « %{previous_condition} » a été supprimée - update: La conditon d’inéligibilité « %{previous_condition} » a été changée pour « %{new_condition} » + update: La condition d’inéligibilité « %{previous_condition} » a été changée pour « %{new_condition} » enabled: "L’inéligibilité des dossiers a été activée" disabled: "L’inéligibilité des dossiers a été désactivée" - message_updated: "Le message d’inéligibilité a été changé pour « %{ineligibilite_message} »" \ No newline at end of file + message_updated: "Le message d’inéligibilité a été changé pour « %{ineligibilite_message} »" From d66487138f2578c6df7e24fa014137d4627d9c0d Mon Sep 17 00:00:00 2001 From: mfo Date: Fri, 25 Oct 2024 13:49:32 +0200 Subject: [PATCH 1320/1532] fix(sentry#6013588148): allowing recasting from drop_down_list to multiple_drop_down_list raises due to call to .selected_options --- app/models/type_de_champ.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index d0f6865e3..56bd4e7aa 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -763,7 +763,7 @@ class TypeDeChamp < ApplicationRecord when ['integer_number', 'decimal_number'], # recast numbers automatically ['decimal_number', 'integer_number'], # may lose some data, but who cares ? ['text', 'textarea'], # allow short text to long text - ['drop_down_list', 'multiple_drop_down_list'], # single list can become multi + # ['drop_down_list', 'multiple_drop_down_list'], # single list can become multi ['date', 'datetime'], # date <=> datetime ['datetime', 'date'] # may lose some data, but who cares ? true From 2429f11d5c8aa2b927b4b437851cd659b1a542fe Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 28 Oct 2024 10:39:36 +0100 Subject: [PATCH 1321/1532] fix(spec): fill_in(label) search by grep like, so if another label contains your label, you got an Ambiguous match --- spec/system/users/brouillon_spec.rb | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/spec/system/users/brouillon_spec.rb b/spec/system/users/brouillon_spec.rb index 06eccfb32..98c414e4b 100644 --- a/spec/system/users/brouillon_spec.rb +++ b/spec/system/users/brouillon_spec.rb @@ -428,7 +428,7 @@ describe 'The user', js: true do let(:procedure) do create(:procedure, :published, :for_individual, types_de_champ_public: [ - { type: :integer_number, libelle: 'age', mandatory: false, stable_id: }, + { type: :integer_number, libelle: 'UNIQ_LABEL', mandatory: false, stable_id: }, { type: :repetition, libelle: 'repetition', condition:, children: [ { type: :text, libelle: 'nom', mandatory: true } @@ -441,8 +441,7 @@ describe 'The user', js: true do log_in(user, procedure) fill_individual - - fill_in('age', with: 10) + fill_in('UNIQ_LABEL', with: 10) click_on 'Déposer le dossier' expect(page).to have_current_path(merci_dossier_path(user_dossier)) end @@ -494,7 +493,7 @@ describe 'The user', js: true do let(:procedure) do create(:procedure, :published, :for_individual, types_de_champ_public: [ - { type: :integer_number, libelle: 'age', mandatory: false, stable_id: }, + { type: :integer_number, libelle: 'UNIQ_LABEL', mandatory: false, stable_id: }, { type: :text, libelle: 'nom', mandatory: true, condition: } ]) end @@ -513,7 +512,7 @@ describe 'The user', js: true do fill_individual - fill_in('age', with: '18') + fill_in('UNIQ_LABEL', with: '18') expect(page).to have_css('label', text: 'nom', visible: :visible) expect(page).to have_css('.icon.mandatory') click_on 'Déposer le dossier' From 623034647242e6aaa3be0a89816ac39d0cfe4b8e Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Tue, 29 Oct 2024 11:54:44 +0100 Subject: [PATCH 1322/1532] fix(trad): Typo --- config/locales/models/champs/carte_champ/en.yml | 2 +- config/locales/models/champs/decimal_number_champ/en.yml | 2 +- config/locales/models/champs/iban_champ/en.yml | 2 +- config/locales/models/champs/phone_champ/en.yml | 2 +- config/locales/models/champs/rna_champ/en.yml | 2 +- config/locales/models/champs/siret_champ/en.yml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/config/locales/models/champs/carte_champ/en.yml b/config/locales/models/champs/carte_champ/en.yml index 332657310..8b8833e29 100644 --- a/config/locales/models/champs/carte_champ/en.yml +++ b/config/locales/models/champs/carte_champ/en.yml @@ -4,4 +4,4 @@ en: champs/carte_champ: hints: value_html: | - Need help ? Check our video tutorial" + Need help ? Check our video tutorial diff --git a/config/locales/models/champs/decimal_number_champ/en.yml b/config/locales/models/champs/decimal_number_champ/en.yml index 1e324bac5..3866bd120 100644 --- a/config/locales/models/champs/decimal_number_champ/en.yml +++ b/config/locales/models/champs/decimal_number_champ/en.yml @@ -3,4 +3,4 @@ en: attributes: champs/decimal_number_champ: hints: - value: "You can enter up to 3 decimal places after the decimal point. Exemple: 3.141" + value: "You can enter up to 3 decimal places after the decimal point. Example: 3.141" diff --git a/config/locales/models/champs/iban_champ/en.yml b/config/locales/models/champs/iban_champ/en.yml index 9761a5a3a..c02db4946 100644 --- a/config/locales/models/champs/iban_champ/en.yml +++ b/config/locales/models/champs/iban_champ/en.yml @@ -9,4 +9,4 @@ en: attributes: champs/iban_champ: hints: - value: "Example (France) : FR76 1234 1234 1234 1234 1234 123" + value: "Example (France): FR76 1234 1234 1234 1234 1234 123" diff --git a/config/locales/models/champs/phone_champ/en.yml b/config/locales/models/champs/phone_champ/en.yml index c475c4f94..8a3a69310 100644 --- a/config/locales/models/champs/phone_champ/en.yml +++ b/config/locales/models/champs/phone_champ/en.yml @@ -3,4 +3,4 @@ en: attributes: champs/phone_champ: hints: - value: "Phone number must be valid. Example : 0612345678" + value: "Phone number must be valid. Example: 0612345678" diff --git a/config/locales/models/champs/rna_champ/en.yml b/config/locales/models/champs/rna_champ/en.yml index d40042092..b67542e65 100644 --- a/config/locales/models/champs/rna_champ/en.yml +++ b/config/locales/models/champs/rna_champ/en.yml @@ -15,4 +15,4 @@ en: attributes: champs/rna_champ: hints: - value: "Expected format : W123456789. Exemple : W503726238" + value: "Expected format: W123456789. Example: W503726238" diff --git a/config/locales/models/champs/siret_champ/en.yml b/config/locales/models/champs/siret_champ/en.yml index a4104f3be..9100d144f 100644 --- a/config/locales/models/champs/siret_champ/en.yml +++ b/config/locales/models/champs/siret_champ/en.yml @@ -3,4 +3,4 @@ en: attributes: champs/siret_champ: hints: - value: "Enter 14 digits. Example : 500 0012 345 6789" + value: "Enter 14 digits. Example: 500 0012 345 6789" From 340595cc861e3ef6b63af7c6e3188a605654da4b Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Tue, 29 Oct 2024 12:36:21 +0100 Subject: [PATCH 1323/1532] fix(trad): Remove useless entries --- .../champ_label_content_component.en.yml | 1 - .../champ_label_content_component.fr.yml | 1 - .../champ_label_content_component.html.haml | 1 - 3 files changed, 3 deletions(-) diff --git a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.en.yml b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.en.yml index f761f5f52..ec1a56453 100644 --- a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.en.yml +++ b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.en.yml @@ -3,5 +3,4 @@ en: changes_to_save: "modifications to submit" modified_at: "modified on %{datetime}" check_content_rebased: "Information: field updated by administration. Check its content." - optional_champ: (optional) recommended_size: The recommended maximum size is %{size} characters. diff --git a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.fr.yml b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.fr.yml index 4e983b829..60cf6d066 100644 --- a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.fr.yml +++ b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.fr.yml @@ -3,5 +3,4 @@ fr: changes_to_save: "modification à déposer" modified_at: "modifié le %{datetime}" check_content_rebased: "Information : champ actualisé par l'administration. Vérifier son contenu." - optional_champ: (facultatif) recommended_size: La taille maximale conseillée est de %{size} caractères. diff --git a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml index a6dc331f1..297b84b64 100644 --- a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml +++ b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml @@ -10,7 +10,6 @@ %span.updated-at{ class: highlight_if_unseen_class } = t('.modified_at', datetime: try_format_datetime(@champ.updated_at)) - - if @champ.rebased_at.present? && @champ.rebased_at > (@seen_at || @champ.updated_at) && current_user.owns_or_invite?(@champ.dossier) %span.updated-at.highlighted = t('.check_content_rebased') From f72c55631ceaa13840d432017f454e6ee42bb177 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Tue, 29 Oct 2024 14:39:20 +0100 Subject: [PATCH 1324/1532] fix(trad): Add traduction to supporting document --- app/views/shared/_piece_justificative_template.html.haml | 2 +- config/locales/views/shared/en.yml | 3 +++ config/locales/views/shared/fr.yml | 3 +++ .../piece_justificative_component_spec.rb | 4 ++-- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/views/shared/_piece_justificative_template.html.haml b/app/views/shared/_piece_justificative_template.html.haml index 1f45bb7ab..9dbc8d2b7 100644 --- a/app/views/shared/_piece_justificative_template.html.haml +++ b/app/views/shared/_piece_justificative_template.html.haml @@ -1 +1 @@ -= render Dsfr::DownloadComponent.new(attachment: champ.type_de_champ.piece_justificative_template, url: champs_piece_justificative_template_path(champ.dossier, champ.stable_id, row_id: champ.row_id), name: "Modèle à télécharger", ephemeral_link: administrateur_signed_in? ) += render Dsfr::DownloadComponent.new(attachment: champ.type_de_champ.piece_justificative_template, url: champs_piece_justificative_template_path(champ.dossier, champ.stable_id, row_id: champ.row_id), name: t('views.shared.piece_justificative.name'), ephemeral_link: administrateur_signed_in? ) diff --git a/config/locales/views/shared/en.yml b/config/locales/views/shared/en.yml index 749f4bc18..28605257e 100644 --- a/config/locales/views/shared/en.yml +++ b/config/locales/views/shared/en.yml @@ -23,3 +23,6 @@ en: signin: 'Sign in' messages: remove_file: 'Remove file' + piece_justificative: + name: "Download template" + title: "Download template %{filename}" diff --git a/config/locales/views/shared/fr.yml b/config/locales/views/shared/fr.yml index 803f7c726..36c4f2b9b 100644 --- a/config/locales/views/shared/fr.yml +++ b/config/locales/views/shared/fr.yml @@ -24,3 +24,6 @@ fr: messages: remove_file: 'Supprimer le fichier' remove_all: "Supprimer tous les fichiers" + piece_justificative: + name: "Télécharger le modèle" + title: "Télécharger le modèle %{filename}" diff --git a/spec/components/editable_champ/piece_justificative_component/piece_justificative_component_spec.rb b/spec/components/editable_champ/piece_justificative_component/piece_justificative_component_spec.rb index 2da24b79a..e8310aed0 100644 --- a/spec/components/editable_champ/piece_justificative_component/piece_justificative_component_spec.rb +++ b/spec/components/editable_champ/piece_justificative_component/piece_justificative_component_spec.rb @@ -22,14 +22,14 @@ describe EditableChamp::PieceJustificativeComponent, type: :component do end it 'renders a link to template' do - expect(subject).to have_link('Modèle à télécharger') + expect(subject).to have_link('Télécharger le modèle') expect(subject).not_to have_text("éphémère") end context 'as an administrator' do let(:profil) { :administrateur } it 'warn about ephemeral template url' do - expect(subject).to have_link('Modèle à télécharger') + expect(subject).to have_link('Télécharger le modèle') expect(subject).to have_text("éphémère") end end From c6329ef10a600d425602fc41bb0f856a729719ca Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 29 Oct 2024 16:38:17 +0100 Subject: [PATCH 1325/1532] feat(column): add column get_value --- .../instructeurs/column_filter_component.rb | 14 ++- .../column_filter_component.html.haml | 4 +- app/models/champ.rb | 4 + app/models/column.rb | 96 +++++++++++++++++++ app/models/columns/dossier_column.rb | 18 ++++ app/models/columns/json_path_column.rb | 8 ++ app/models/columns/linked_drop_down_column.rb | 35 +++++++ app/models/concerns/columns_concern.rb | 30 +++--- app/models/type_de_champ.rb | 30 ++++-- .../linked_drop_down_list_type_de_champ.rb | 23 +++++ .../types_de_champ/type_de_champ_base.rb | 4 +- app/services/dossier_filter_service.rb | 2 +- spec/models/column_spec.rb | 93 ++++++++++++++++++ spec/models/concerns/columns_concern_spec.rb | 9 ++ 14 files changed, 340 insertions(+), 30 deletions(-) create mode 100644 app/models/columns/dossier_column.rb create mode 100644 app/models/columns/linked_drop_down_column.rb create mode 100644 spec/models/column_spec.rb diff --git a/app/components/instructeurs/column_filter_component.rb b/app/components/instructeurs/column_filter_component.rb index 7843bcbb1..5016b4bc5 100644 --- a/app/components/instructeurs/column_filter_component.rb +++ b/app/components/instructeurs/column_filter_component.rb @@ -12,6 +12,17 @@ class Instructeurs::ColumnFilterComponent < ApplicationComponent def column_type = column.present? ? column.type : :text + def html_column_type + case column_type + when :datetime, :date + 'date' + when :integer, :decimal + 'number' + else + 'text' + end + end + def options_for_select_of_column if column.scope.present? I18n.t(column.scope).map(&:to_a).map(&:reverse) @@ -56,10 +67,11 @@ class Instructeurs::ColumnFilterComponent < ApplicationComponent private def find_type_de_champ(column) + stable_id = column.to_s.split('->').first TypeDeChamp .joins(:revision_types_de_champ) .where(revision_types_de_champ: { revision_id: procedure.revisions }) .order(created_at: :desc) - .find_by(stable_id: column) + .find_by(stable_id:) end end diff --git a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml index 603aa3819..ec9c7eee6 100644 --- a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml +++ b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml @@ -14,7 +14,7 @@ } = label_tag :value, t('.value'), for: 'value', class: 'fr-label' - - if column_type == :enum || column_type == :enums + - if column_type.in?([:enum, :enums, :boolean]) = select_tag :filter, options_for_select(options_for_select_of_column), id: 'value', @@ -23,7 +23,7 @@ data: { no_autosubmit: true } - else %input#value.fr-input{ - type: column_type, + type: html_column_type, name: "#{prefix}[filter]", maxlength: FilteredColumn::FILTERS_VALUE_MAX_LENGTH, disabled: column.nil? ? true : false, diff --git a/app/models/champ.rb b/app/models/champ.rb index 7bd7fc223..02f82d25a 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -118,6 +118,10 @@ class Champ < ApplicationRecord TypeDeChamp::CHAMP_TYPE_TO_TYPE_CHAMP.fetch(type) end + def last_write_column_type + TypeDeChamp.column_type(last_write_type_champ) + end + def main_value_name :value end diff --git a/app/models/column.rb b/app/models/column.rb index 2e95a9c3e..65497078a 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -50,4 +50,100 @@ class Column procedure.find_column(h_id: h_id) end + + def get_value(champ) + return if champ.nil? + + value = get_raw_value(champ) + if should_cast? + # FIXME: remove this, once displayable is implemented through columns + return nil if champ.last_write_type_champ == TypeDeChamp.type_champs.fetch(:linked_drop_down_list) + from_type = champ.last_write_column_type + to_type = type + parsed_value = parse_value(value, from_type) + cast_value(parsed_value, from_type:, to_type:) + else + value + end + end + + private + + def get_raw_value(champ) + champ.public_send(value_column) + end + + def should_cast? + true + end + + def parse_value(value, type) + return if value.blank? + + case type + when :boolean + parse_boolean(value) + when :integer + value.to_i + when :decimal + value.to_f + when :datetime + parse_datetime(value) + when :date + parse_datetime(value)&.to_date + when :enums + parse_enums(value) + else + value + end + end + + def cast_value(value, from_type:, to_type:) + return if value.blank? + return value if from_type == to_type + + case [from_type, to_type] + when [:integer, :decimal] # recast numbers automatically + value.to_f + when [:decimal, :integer] # may lose some data, but who cares ? + value.to_i + when [:integer, :text], [:decimal, :text] # number to text + value.to_s + when [:enum, :enums] # single list can become multi + [value] + when [:enum, :text] # single list can become text + value + when [:enums, :enum] # multi list can become single list + value.first + when [:enums, :text] # multi list can become text + value.join(', ') + when [:date, :datetime] # date <=> datetime + value.to_datetime + when [:datetime, :date] # may lose some data, but who cares ? + value.to_date + else + nil + end + end + + def parse_boolean(value) + case value + when 'true', 'on', '1' + true + when 'false' + false + end + end + + def parse_enums(value) + JSON.parse(value) + rescue JSON::ParserError + nil + end + + def parse_datetime(value) + Time.zone.parse(value) + rescue ArgumentError + nil + end end diff --git a/app/models/columns/dossier_column.rb b/app/models/columns/dossier_column.rb new file mode 100644 index 000000000..138b6092b --- /dev/null +++ b/app/models/columns/dossier_column.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Columns::DossierColumn < Column + def get_value(dossier) + case table + when 'self' + dossier.public_send(column) + when 'etablissement' + dossier.etablissement.public_send(column) + when 'individual' + dossier.individual.public_send(column) + when 'groupe_instructeur' + dossier.groupe_instructeur.label + when 'followers_instructeurs' + dossier.followers_instructeurs.map(&:email).join(' ') + end + end +end diff --git a/app/models/columns/json_path_column.rb b/app/models/columns/json_path_column.rb index 10b0f3537..210e41e7a 100644 --- a/app/models/columns/json_path_column.rb +++ b/app/models/columns/json_path_column.rb @@ -25,6 +25,14 @@ class Columns::JSONPathColumn < Column private + def get_raw_value(champ) + champ.value_json&.dig(*value_column) + end + + def should_cast? + false + end + def stable_id @column end diff --git a/app/models/columns/linked_drop_down_column.rb b/app/models/columns/linked_drop_down_column.rb new file mode 100644 index 000000000..dfa7efe66 --- /dev/null +++ b/app/models/columns/linked_drop_down_column.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class Columns::LinkedDropDownColumn < Column + def column + "#{@column}->#{value_column}" # override column otherwise json path facets will have same id as other + end + + def filtered_ids(dossiers, values) + dossiers.with_type_de_champ(@column) + .filter_ilike(:champs, :value, values) + .ids + end + + private + + def get_raw_value(champ) + primary_value, secondary_value = unpack_values(champ.value) + case value_column + when :primary + primary_value + when :secondary + secondary_value + end + end + + def should_cast? + false + end + + def unpack_values(value) + JSON.parse(value) + rescue JSON::ParserError + [] + end +end diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 0d1b9a984..943d772a7 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -30,25 +30,25 @@ module ColumnsConcern end def dossier_id_column - Column.new(procedure_id: id, table: 'self', column: 'id', type: :number) + Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'id', type: :number) end def dossier_state_column - Column.new(procedure_id: id, table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false) + Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false) end def notifications_column - Column.new(procedure_id: id, table: 'notifications', column: 'notifications', label: "notifications", filterable: false) + Columns::DossierColumn.new(procedure_id: id, table: 'notifications', column: 'notifications', label: "notifications", filterable: false) end def dossier_columns common = [dossier_id_column, notifications_column] dates = ['created_at', 'updated_at', 'depose_at', 'en_construction_at', 'en_instruction_at', 'processed_at'] - .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date) } + .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'self', column:, type: :date) } non_displayable_dates = ['updated_since', 'depose_since', 'en_construction_since', 'en_instruction_since', 'processed_since'] - .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date, displayable: false) } + .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'self', column:, type: :date, displayable: false) } states = [dossier_state_column] @@ -61,11 +61,11 @@ module ColumnsConcern scope = [:activerecord, :attributes, :procedure_presentation, :fields, :self] columns = [ - Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_on', type: :date, + Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_on', type: :date, label: I18n.t("#{sva_svr_decision}_decision_on", scope:)) ] - columns << Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false, + columns << Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false, label: I18n.t("#{sva_svr_decision}_decision_before", scope:)) columns @@ -80,29 +80,29 @@ module ColumnsConcern private def email_column - Column.new(procedure_id: id, table: 'user', column: 'email') + Columns::DossierColumn.new(procedure_id: id, table: 'user', column: 'email') end def standard_columns [ email_column, - Column.new(procedure_id: id, table: 'followers_instructeurs', column: 'email'), - Column.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum), - Column.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false) # not filterable ? + Columns::DossierColumn.new(procedure_id: id, table: 'followers_instructeurs', column: 'email'), + Columns::DossierColumn.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum), + Columns::DossierColumn.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false) # not filterable ? ] end def individual_columns - ['nom', 'prenom', 'gender'].map { |column| Column.new(procedure_id: id, table: 'individual', column:) } + ['nom', 'prenom', 'gender'].map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'individual', column:) } end def moral_columns etablissements = ['entreprise_siren', 'entreprise_forme_juridique', 'entreprise_nom_commercial', 'entreprise_raison_sociale', 'entreprise_siret_siege_social'] - .map { |column| Column.new(procedure_id: id, table: 'etablissement', column:) } + .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'etablissement', column:) } - etablissement_dates = ['entreprise_date_creation'].map { |column| Column.new(procedure_id: id, table: 'etablissement', column:, type: :date) } + etablissement_dates = ['entreprise_date_creation'].map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'etablissement', column:, type: :date) } - other = ['siret', 'libelle_naf', 'code_postal'].map { |column| Column.new(procedure_id: id, table: 'etablissement', column:) } + other = ['siret', 'libelle_naf', 'code_postal'].map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'etablissement', column:) } [etablissements, etablissement_dates, other].flatten end diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 56bd4e7aa..9d82f64b5 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -327,11 +327,6 @@ class TypeDeChamp < ApplicationRecord ]) end - def self.is_choice_type_from(type_champ) - return false if type_champ == TypeDeChamp.type_champs.fetch(:linked_drop_down_list) # To remove when we stop using linked_drop_down_list - TYPE_DE_CHAMP_TO_CATEGORIE[type_champ.to_sym] == CHOICE || type_champ.in?([TypeDeChamp.type_champs.fetch(:departements), TypeDeChamp.type_champs.fetch(:regions)]) - end - def drop_down_list? type_champ.in?([ TypeDeChamp.type_champs.fetch(:drop_down_list), @@ -531,17 +526,28 @@ class TypeDeChamp < ApplicationRecord end end - def self.filter_hash_type(type_champ) - if type_champ == 'multiple_drop_down_list' + def self.column_type(type_champ) + case type_champ + when TypeDeChamp.type_champs.fetch(:datetime) + :datetime + when TypeDeChamp.type_champs.fetch(:date) + :date + when TypeDeChamp.type_champs.fetch(:integer_number) + :integer + when TypeDeChamp.type_champs.fetch(:decimal_number) + :decimal + when TypeDeChamp.type_champs.fetch(:multiple_drop_down_list) :enums - elsif is_choice_type_from(type_champ) + when TypeDeChamp.type_champs.fetch(:drop_down_list), TypeDeChamp.type_champs.fetch(:departements), TypeDeChamp.type_champs.fetch(:regions) :enum + when TypeDeChamp.type_champs.fetch(:checkbox), TypeDeChamp.type_champs.fetch(:yes_no) + :boolean else :text end end - def self.filter_hash_value_column(type_champ) + def self.value_column(type_champ) if type_champ.in?([TypeDeChamp.type_champs.fetch(:departements), TypeDeChamp.type_champs.fetch(:regions)]) :external_id else @@ -554,6 +560,12 @@ class TypeDeChamp < ApplicationRecord APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } elsif region? APIGeoService.regions.map { [_1[:name], _1[:code]] } + elsif linked_drop_down_list? + if column.value_column == :primary + primary_options + else + secondary_options.values.flatten + end elsif choice_type? if drop_down_list? drop_down_options diff --git a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb index 85b7922fb..103922861 100644 --- a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb @@ -67,6 +67,29 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas end end + def columns(procedure_id:, displayable: true, prefix: nil) + super.concat([ + Columns::LinkedDropDownColumn.new( + procedure_id:, + table: Column::TYPE_DE_CHAMP_TABLE, + column: stable_id.to_s, + label: "#{libelle_with_prefix(prefix)} (Primaire)", + type: :enum, + value_column: :primary, + displayable: false + ), + Columns::LinkedDropDownColumn.new( + procedure_id:, + table: Column::TYPE_DE_CHAMP_TABLE, + column: stable_id.to_s, + label: "#{libelle_with_prefix(prefix)} (Secondaire)", + type: :enum, + value_column: :secondary, + displayable: false + ) + ]) + end + private def paths diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index e081d0e4e..8d15205d2 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -99,8 +99,8 @@ class TypesDeChamp::TypeDeChampBase table: Column::TYPE_DE_CHAMP_TABLE, column: stable_id.to_s, label: libelle_with_prefix(prefix), - type: TypeDeChamp.filter_hash_type(type_champ), - value_column: TypeDeChamp.filter_hash_value_column(type_champ), + type: TypeDeChamp.column_type(type_champ), + value_column: TypeDeChamp.value_column(type_champ), displayable: ) ] diff --git a/app/services/dossier_filter_service.rb b/app/services/dossier_filter_service.rb index b046dbe5c..d43dae082 100644 --- a/app/services/dossier_filter_service.rb +++ b/app/services/dossier_filter_service.rb @@ -71,7 +71,7 @@ class DossierFilterService filtered_column = filters_for_column.first.column value_column = filtered_column.value_column - if filtered_column.is_a?(Columns::JSONPathColumn) + if filtered_column.respond_to?(:filtered_ids) filtered_column.filtered_ids(dossiers, values) else case table diff --git a/spec/models/column_spec.rb b/spec/models/column_spec.rb new file mode 100644 index 000000000..2d7652822 --- /dev/null +++ b/spec/models/column_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +describe Column do + describe 'get_value' do + let(:groupe_instructeur) { create(:groupe_instructeur, instructeurs: [create(:instructeur)]) } + + context 'when dossier columns' do + context 'when procedure for individual' do + let(:individual) { create(:individual, nom: "Sim", prenom: "Paul", gender: 'M.') } + let(:procedure) { create(:procedure, for_individual: true, groupe_instructeurs: [groupe_instructeur]) } + let(:dossier) { create(:dossier, individual:, mandataire_first_name: "Martin", mandataire_last_name: "Christophe", for_tiers: true) } + + it 'retrieve individual information' do + expect(procedure.find_column(label: "Prénom").get_value(dossier)).to eq("Paul") + expect(procedure.find_column(label: "Nom").get_value(dossier)).to eq("Sim") + expect(procedure.find_column(label: "Civilité").get_value(dossier)).to eq("M.") + end + end + + context 'when procedure for entreprise' do + let(:procedure) { create(:procedure, for_individual: false, groupe_instructeurs: [groupe_instructeur]) } + let(:dossier) { create(:dossier, :en_instruction, :with_entreprise, procedure:) } + + it 'retrieve entreprise information' do + expect(procedure.find_column(label: "Libellé NAF").get_value(dossier)).to eq('Transports par conduites') + end + end + + context 'when sva/svr enabled' do + let(:procedure) { create(:procedure, :sva, for_individual: true, groupe_instructeurs: [groupe_instructeur]) } + let(:dossier) { create(:dossier, :en_instruction, procedure:) } + + it 'does not fail' do + expect(procedure.find_column(label: "Date décision SVA").get_value(dossier)).to eq(nil) + end + end + end + + context 'when champ columns' do + let(:procedure) { create(:procedure, :with_all_champs_mandatory, groupe_instructeurs: [groupe_instructeur]) } + let(:dossier) { create(:dossier, :with_populated_champs, procedure:) } + let(:types_de_champ) { procedure.all_revisions_types_de_champ } + + it 'extracts values for columns and type de champ' do + expect_type_de_champ_values('civilite', ["M."]) + expect_type_de_champ_values('email', ['yoda@beta.gouv.fr']) + expect_type_de_champ_values('phone', ['0666666666']) + expect_type_de_champ_values('address', ["2 rue des Démarches"]) + expect_type_de_champ_values('communes', ["Coye-la-Forêt"]) + expect_type_de_champ_values('departements', ['01']) + expect_type_de_champ_values('regions', ['01']) + expect_type_de_champ_values('pays', ['France']) + expect_type_de_champ_values('epci', [nil]) + expect_type_de_champ_values('iban', [nil]) + expect_type_de_champ_values('siret', ["44011762001530", "postal_code", "city_name", "departement_code", "region_name"]) + expect_type_de_champ_values('text', ['text']) + expect_type_de_champ_values('textarea', ['textarea']) + expect_type_de_champ_values('number', ['42']) + expect_type_de_champ_values('decimal_number', [42.1]) + expect_type_de_champ_values('integer_number', [42]) + expect_type_de_champ_values('date', [Time.zone.parse('2019-07-10').to_date]) + expect_type_de_champ_values('datetime', [Time.zone.parse("1962-09-15T15:35:00+01:00")]) + expect_type_de_champ_values('checkbox', [true]) + expect_type_de_champ_values('drop_down_list', ['val1']) + expect_type_de_champ_values('multiple_drop_down_list', [["val1", "val2"]]) + expect_type_de_champ_values('linked_drop_down_list', [nil, "categorie 1", "choix 1"]) + expect_type_de_champ_values('yes_no', [true]) + expect_type_de_champ_values('annuaire_education', [nil]) + expect_type_de_champ_values('carte', [nil]) + expect_type_de_champ_values('cnaf', [nil]) + expect_type_de_champ_values('dgfip', [nil]) + expect_type_de_champ_values('pole_emploi', [nil]) + expect_type_de_champ_values('mesri', [nil]) + expect_type_de_champ_values('cojo', [nil]) + expect_type_de_champ_values('expression_reguliere', [nil]) + end + end + end + + private + + def expect_type_de_champ_values(type, values) + type_de_champ = types_de_champ.find { _1.type_champ == type } + champ = dossier.send(:filled_champ, type_de_champ, nil) + columns = type_de_champ.columns(procedure_id: procedure.id) + expect(columns.map { _1.get_value(champ) }).to eq(values) + end + + def retrieve_champ(type) + type_de_champ = types_de_champ.find { _1.type_champ == type } + dossier.send(:filled_champ, type_de_champ, nil) + end +end diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index d787329d9..27d062f32 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -81,6 +81,15 @@ describe ColumnsConcern do let(:types_de_champ_private) { [] } it { expect(subject.map(&:label)).to include('rna – commune') } end + + context 'with linked drop down list' do + let(:types_de_champ_public) { [{ type: :linked_drop_down_list, libelle: 'linked' }] } + let(:types_de_champ_private) { [] } + it { + expect(subject.map(&:label)).to include('linked (Primaire)') + expect(subject.map(&:label)).to include('linked (Secondaire)') + } + end end context 'when the procedure is for individuals' do From 54c9e46df07dba20cdb5379016ade7f2bbc746fc Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 30 Oct 2024 09:50:59 +0100 Subject: [PATCH 1326/1532] feat(shared/_piece_justificative_template): forward traduction for better naming of download title element Co-Authored-By: Corinne Durrmeyer --- app/components/dsfr/download_component.rb | 5 +++-- app/views/shared/_piece_justificative_template.html.haml | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/components/dsfr/download_component.rb b/app/components/dsfr/download_component.rb index 02697b5f1..b576c843e 100644 --- a/app/components/dsfr/download_component.rb +++ b/app/components/dsfr/download_component.rb @@ -9,7 +9,7 @@ class Dsfr::DownloadComponent < ApplicationComponent attr_reader :new_tab attr_reader :truncate - def initialize(attachment:, name: nil, url: nil, ephemeral_link: false, virus_not_analyzed: false, new_tab: false, truncate: false) + def initialize(attachment:, name: nil, url: nil, ephemeral_link: false, virus_not_analyzed: false, new_tab: false, truncate: false, title: nil) @attachment = attachment @name = name || attachment.filename.to_s @url = url @@ -17,10 +17,11 @@ class Dsfr::DownloadComponent < ApplicationComponent @virus_not_analyzed = virus_not_analyzed @new_tab = new_tab @truncate = truncate + @title = title end def title - t(".title", filename: attachment.filename.to_s) + @title || t(".title", filename: attachment.filename.to_s) end def url diff --git a/app/views/shared/_piece_justificative_template.html.haml b/app/views/shared/_piece_justificative_template.html.haml index 9dbc8d2b7..6a6c0226f 100644 --- a/app/views/shared/_piece_justificative_template.html.haml +++ b/app/views/shared/_piece_justificative_template.html.haml @@ -1 +1 @@ -= render Dsfr::DownloadComponent.new(attachment: champ.type_de_champ.piece_justificative_template, url: champs_piece_justificative_template_path(champ.dossier, champ.stable_id, row_id: champ.row_id), name: t('views.shared.piece_justificative.name'), ephemeral_link: administrateur_signed_in? ) += render Dsfr::DownloadComponent.new(attachment: champ.type_de_champ.piece_justificative_template, url: champs_piece_justificative_template_path(champ.dossier, champ.stable_id, row_id: champ.row_id), name: t('views.shared.piece_justificative.name'), ephemeral_link: administrateur_signed_in?, title: t('views.shared.piece_justificative.title', filename: champ.type_de_champ.piece_justificative_template.blob.filename.to_s) ) From 861514dba94d15507b25a38b6107a5042950a49e Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Wed, 23 Oct 2024 11:11:37 +0200 Subject: [PATCH 1327/1532] Add translations to card component --- .../editable_champ/carte_component.rb | 13 +++++++++++- .../carte_component/carte_component.en.yml | 11 ++++++++++ .../carte_component/carte_component.fr.yml | 11 ++++++++++ .../MapEditor/components/AddressInput.tsx | 8 ++++--- .../MapEditor/components/ImportFileInput.tsx | 12 ++++++----- .../MapEditor/components/PointInput.tsx | 21 +++++++++---------- app/javascript/components/MapEditor/index.tsx | 16 +++++++++++--- 7 files changed, 69 insertions(+), 23 deletions(-) create mode 100644 app/components/editable_champ/carte_component/carte_component.en.yml create mode 100644 app/components/editable_champ/carte_component/carte_component.fr.yml diff --git a/app/components/editable_champ/carte_component.rb b/app/components/editable_champ/carte_component.rb index 062576ee2..fbbe6d6f7 100644 --- a/app/components/editable_champ/carte_component.rb +++ b/app/components/editable_champ/carte_component.rb @@ -12,7 +12,18 @@ class EditableChamp::CarteComponent < EditableChamp::EditableChampBaseComponent champ_id: @champ.input_id, url: update_path, adresse_source: data_sources_data_source_adresse_path, - options: @champ.render_options + options: @champ.render_options, + translations: { + address_input_label: t(".address_input_label"), + address_input_description: t(".address_input_description"), + pin_input_label: t(".pin_input_label"), + pin_input_description: t(".pin_input_description"), + show_pin: t(".show_pin"), + add_pin: t(".add_pin"), + add_file: t(".add_file"), + choose_file: t(".choose_file"), + delete_file: t(".delete_file") + } } end diff --git a/app/components/editable_champ/carte_component/carte_component.en.yml b/app/components/editable_champ/carte_component/carte_component.en.yml new file mode 100644 index 000000000..1ec1620cf --- /dev/null +++ b/app/components/editable_champ/carte_component/carte_component.en.yml @@ -0,0 +1,11 @@ +--- +en: + address_input_label: "Find an address" + address_input_description: "Enter at least 2 characters" + pin_input_label: "Add a point to the map" + pin_input_description: "Example: " + show_pin: "Show your location on the map" + add_pin: "Add the point with the coordinates entered on the map" + add_file: "Add a GPX or KML file" + choose_file: "Choose a GPX or KML file" + delete_file: "Delete the file" diff --git a/app/components/editable_champ/carte_component/carte_component.fr.yml b/app/components/editable_champ/carte_component/carte_component.fr.yml new file mode 100644 index 000000000..e54f53112 --- /dev/null +++ b/app/components/editable_champ/carte_component/carte_component.fr.yml @@ -0,0 +1,11 @@ +--- +fr: + address_input_label: "Rechercher une adresse" + address_input_description: "Saisissez au moins 2 caractères" + pin_input_label: "Ajouter un point sur la carte" + pin_input_description: "Exemple : " + show_pin: "Afficher votre position sur la carte" + add_pin: "Ajouter le point avec les coordonnées saisies sur la carte" + add_file: "Ajouter un fichier GPX ou KML" + choose_file: "Choisir un fichier GPX ou KML" + delete_file: "Supprimer le fichier" diff --git a/app/javascript/components/MapEditor/components/AddressInput.tsx b/app/javascript/components/MapEditor/components/AddressInput.tsx index 0e7c22a22..8f250adc6 100644 --- a/app/javascript/components/MapEditor/components/AddressInput.tsx +++ b/app/javascript/components/MapEditor/components/AddressInput.tsx @@ -6,11 +6,13 @@ import { RemoteComboBox } from '../../ComboBox'; export function AddressInput({ source, featureCollection, - champId + champId, + translations }: { source: string; featureCollection: FeatureCollection; champId: string; + translations: Record; }) { return (
    @@ -18,8 +20,8 @@ export function AddressInput({ minimumInputLength={2} id={champId} loader={source} - label="Rechercher une Adresse" - description="Saisissez au moins 2 caractères" + label={translations.address_input_label} + description={translations.address_input_description} onChange={(item) => { if (item && item.data) { fire(document, 'map:zoom', { diff --git a/app/javascript/components/MapEditor/components/ImportFileInput.tsx b/app/javascript/components/MapEditor/components/ImportFileInput.tsx index 28122dba5..98cff6c2b 100644 --- a/app/javascript/components/MapEditor/components/ImportFileInput.tsx +++ b/app/javascript/components/MapEditor/components/ImportFileInput.tsx @@ -9,11 +9,13 @@ import { CreateFeatures, DeleteFeatures } from '../hooks'; export function ImportFileInput({ featureCollection, createFeatures, - deleteFeatures + deleteFeatures, + translations }: { featureCollection: FeatureCollection; createFeatures: CreateFeatures; deleteFeatures: DeleteFeatures; + translations: Record; }) { const { inputs, addInputFile, removeInputFile, onFileChange } = useImportFiles(featureCollection, { createFeatures, deleteFeatures }); @@ -24,14 +26,14 @@ export function ImportFileInput({ className="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-circle-line" onClick={addInputFile} > - Ajouter un fichier GPX ou KML + {translations.add_file}
    {inputs.map((input) => (
    {input.hasValue && ( ; }) { const inputId = useId(); const [value, setValue] = useState(''); @@ -35,9 +37,10 @@ export function PointInput({ return (
    @@ -46,11 +49,9 @@ export function PointInput({ type="button" className="fr-btn fr-btn--secondary fr-icon-map-pin-2-line" onClick={getCurrentPosition} - title="Afficher votre position sur la carte" + title={translations.show_pin} > - - Afficher votre position sur la carte - + {translations.show_pin} ) : null} - - Ajouter le point avec les coordonnées saisies sur la carte - + {translations.add_pin}
    diff --git a/app/javascript/components/MapEditor/index.tsx b/app/javascript/components/MapEditor/index.tsx index f900e3fac..08bad1c61 100644 --- a/app/javascript/components/MapEditor/index.tsx +++ b/app/javascript/components/MapEditor/index.tsx @@ -16,13 +16,15 @@ export default function MapEditor({ url, adresseSource, options, - champId + champId, + translations }: { featureCollection: FeatureCollection; url: string; adresseSource: string; options: { layers: string[] }; champId: string; + translations: Record; }) { const [cadastreEnabled, setCadastreEnabled] = useState(false); @@ -35,11 +37,16 @@ export default function MapEditor({ <> {error && } - + @@ -57,7 +64,10 @@ export default function MapEditor({ /> ) : null} - + ); } From df71c6a68916934c20168a0440f1a80e1862c4a9 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 14:09:14 +0200 Subject: [PATCH 1328/1532] align columns naming and order for filter and add for export Co-authored-by: mfo --- app/models/concerns/columns_concern.rb | 109 +++++++++++---- app/models/dossier.rb | 4 + .../models/procedure_presentation/en.yml | 50 ++++++- .../models/procedure_presentation/fr.yml | 64 +++++++-- ...edure_presentation_to_columns.rake_spec.rb | 2 +- spec/models/concerns/columns_concern_spec.rb | 128 +++++++++++++++++- spec/models/export_spec.rb | 4 +- spec/models/procedure_presentation_spec.rb | 6 +- spec/services/dossier_filter_service_spec.rb | 38 +++--- .../dossier_projection_service_spec.rb | 8 +- .../instructeurs/procedure_filters_spec.rb | 4 +- 11 files changed, 338 insertions(+), 79 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 943d772a7..78a533a7c 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -25,49 +25,97 @@ module ColumnsConcern columns.concat(standard_columns) columns.concat(individual_columns) if for_individual columns.concat(moral_columns) if !for_individual + columns.concat(chorus_columns) columns.concat(types_de_champ_columns) end end + def chorus_columns + if chorusable? && chorus_configuration.complete? + ['domaine_fonctionnel', 'referentiel_prog', 'centre_de_cout'] + .map { |column| Column.new(procedure_id: id, table: 'procedure', column:, displayable: false, filterable: false) } + else + [] + end + end + + def all_usager_columns_for_export + common = [ + dossier_id_column, + Column.new(procedure_id: id, table: 'self', column: 'user_email_for_display', filterable: false, displayable: false), + Column.new(procedure_id: id, table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false) + ] + + individual_or_moral_columns = for_individual? ? individual_columns : moral_columns + + [common, individual_or_moral_columns, chorus_columns].flatten.compact + end + + def all_dossier_columns_for_export + states = [dossier_state_column] + + for_export_before_date = ['archived'] + .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :text, displayable: false, filterable: false) } + for_export_after_date = ['motivation'] + .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :text, displayable: false, filterable: false) } + routing = + if self.routing_enabled? + [Column.new(procedure_id: id, table: 'groupe_instructeur', column: 'id')] + else + [] + end + + instructeurs = [Column.new(procedure_id: id, table: 'followers_instructeurs', column: 'email')] + + [states, for_export_before_date, dossier_dates_columns, for_export_after_date, sva_svr_columns(for_export: true), routing, instructeurs].flatten.compact + end + def dossier_id_column - Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'id', type: :number) + Column.new(procedure_id: id, table: 'self', column: 'id', type: :number) end def dossier_state_column - Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false) + Column.new(procedure_id: id, table: 'self', column: 'state', label: I18n.t('activerecord.attributes.procedure_presentation.fields.self.state'), type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false) end def notifications_column - Columns::DossierColumn.new(procedure_id: id, table: 'notifications', column: 'notifications', label: "notifications", filterable: false) + Column.new(procedure_id: id, table: 'notifications', column: 'notifications', label: "notifications", filterable: false) end def dossier_columns common = [dossier_id_column, notifications_column] - dates = ['created_at', 'updated_at', 'depose_at', 'en_construction_at', 'en_instruction_at', 'processed_at'] - .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'self', column:, type: :date) } - - non_displayable_dates = ['updated_since', 'depose_since', 'en_construction_since', 'en_instruction_since', 'processed_since'] - .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'self', column:, type: :date, displayable: false) } - states = [dossier_state_column] - [common, dates, sva_svr_columns, non_displayable_dates, states].flatten.compact + non_displayable_dates = ['updated_since', 'depose_since', 'en_construction_since', 'en_instruction_since', 'processed_since'] + .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date, displayable: false) } + + for_export_before_date = ['archived'] + .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :text, displayable: false, filterable: false) } + for_export_after_date = ['motivation'] + .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :text, displayable: false, filterable: false) } + + [common, states, for_export_before_date, dossier_dates_columns, for_export_after_date, sva_svr_columns(for_export: false), non_displayable_dates].flatten.compact end - def sva_svr_columns + def dossier_dates_columns + ['created_at', 'updated_at', 'last_champ_updated_at', 'depose_at', 'en_construction_at', 'en_instruction_at', 'processed_at'] + .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date) } + end + + def sva_svr_columns(for_export: false) return if !sva_svr_enabled? scope = [:activerecord, :attributes, :procedure_presentation, :fields, :self] columns = [ - Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_on', type: :date, - label: I18n.t("#{sva_svr_decision}_decision_on", scope:)) + Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_on', type: :date, + label: I18n.t("#{sva_svr_decision}_decision_on", scope:, type: sva_svr_configuration.human_decision)) ] - - columns << Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false, - label: I18n.t("#{sva_svr_decision}_decision_before", scope:)) - + if !for_export + columns << Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false, + label: I18n.t("#{sva_svr_decision}_decision_before", scope:)) + end columns end @@ -80,31 +128,38 @@ module ColumnsConcern private def email_column - Columns::DossierColumn.new(procedure_id: id, table: 'user', column: 'email') + Column.new(procedure_id: id, table: 'user', column: 'email') end def standard_columns [ email_column, - Columns::DossierColumn.new(procedure_id: id, table: 'followers_instructeurs', column: 'email'), - Columns::DossierColumn.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum), - Columns::DossierColumn.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false) # not filterable ? + Column.new(procedure_id: id, table: 'self', column: 'user_email_for_display', filterable: false, displayable: false), + Column.new(procedure_id: id, table: 'followers_instructeurs', column: 'email'), + Column.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum), + Column.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false), + Column.new(procedure_id: id, table: 'user', column: 'id', filterable: false, displayable: false), + Column.new(procedure_id: id, table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false) ] end def individual_columns - ['nom', 'prenom', 'gender'].map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'individual', column:) } + ['gender', 'nom', 'prenom'].map { |column| Column.new(procedure_id: id, table: 'individual', column:) } + .concat ['for_tiers', 'mandataire_last_name', 'mandataire_first_name'].map { |column| Column.new(procedure_id: id, table: 'self', column:) } end def moral_columns - etablissements = ['entreprise_siren', 'entreprise_forme_juridique', 'entreprise_nom_commercial', 'entreprise_raison_sociale', 'entreprise_siret_siege_social'] - .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'etablissement', column:) } + etablissements = ['entreprise_forme_juridique', 'entreprise_siren', 'entreprise_nom_commercial', 'entreprise_raison_sociale', 'entreprise_siret_siege_social'] + .map { |column| Column.new(procedure_id: id, table: 'etablissement', column:) } - etablissement_dates = ['entreprise_date_creation'].map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'etablissement', column:, type: :date) } + etablissement_dates = ['entreprise_date_creation'].map { |column| Column.new(procedure_id: id, table: 'etablissement', column:, type: :date) } - other = ['siret', 'libelle_naf', 'code_postal'].map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'etablissement', column:) } + for_export = ["siege_social", "naf", "adresse", "numero_voie", "type_voie", "nom_voie", "complement_adresse", "localite", "code_insee_localite", "entreprise_siren", "entreprise_capital_social", "entreprise_numero_tva_intracommunautaire", "entreprise_forme_juridique_code", "entreprise_code_effectif_entreprise", "entreprise_etat_administratif", "entreprise_nom", "entreprise_prenom", "association_rna", "association_titre", "association_objet", "association_date_creation", "association_date_declaration", "association_date_publication"] + .map { |column| Column.new(procedure_id: id, table: 'etablissement', column:, displayable: false, filterable: false) } - [etablissements, etablissement_dates, other].flatten + other = ['siret', 'libelle_naf', 'code_postal'].map { |column| Column.new(procedure_id: id, table: 'etablissement', column:) } + + [etablissements, etablissement_dates, other, for_export].flatten end def types_de_champ_columns diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 90ca00d4f..2f9a469aa 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -450,6 +450,10 @@ class Dossier < ApplicationRecord end end + def user_email_for_display + user_email_for(:display) + end + def expiration_started? [ brouillon_close_to_expiration_notice_sent_at, diff --git a/config/locales/models/procedure_presentation/en.yml b/config/locales/models/procedure_presentation/en.yml index 069e2853e..fc12fb311 100644 --- a/config/locales/models/procedure_presentation/en.yml +++ b/config/locales/models/procedure_presentation/en.yml @@ -5,24 +5,34 @@ en: fields: self: id: File Nº + user_email_for_display: Email state: State created_at: Created on updated_at: Updated on depose_at: First submission on en_construction_at: Submitted on - en_instruction_at: En instruction on + en_instruction_at: Instructed on processed_at: Done on depose_since: First Submission since updated_since: Updated since en_construction_since: Submitted since en_instruction_since: Instructed since processed_since: Finished since - sva_decision_on: SVA decision date + sva_decision_on: Date décision %{type} sva_decision_before: SVA decision date before - svr_decision_on: SVR decision date + svr_decision_on: Date décision %{type} svr_decision_before: SVR decision date before + user_from_france_connect?: "FranceConnect ?" + for_tiers: "For tiers" + mandataire_last_name: "Tier last name" + mandataire_first_name: "Tier first name" user: + id: User Id email: Requester + procedure: + domaine_fonctionnel: Domaine Fonctionnel + referentiel_prog: Référentiel De Programmation + centre_de_cout: Centre De Coût followers_instructeurs: email: Email instructeur groupe_instructeur: @@ -36,12 +46,38 @@ en: answer: Opinion question_answer: Opinion yes/no etablissement: - entreprise_siren: SIREN + entreprise_etat_administratif: 'Entreprise état administratif' entreprise_forme_juridique: Forme juridique entreprise_nom_commercial: Commercial name entreprise_raison_sociale: Raison sociale entreprise_siret_siege_social: SIRET siège social - entreprise_date_creation: Creation date - siret: SIRET + entreprise_date_creation: Entreprise date de création + siret: Établissement SIRET libelle_naf: Libellé NAF - code_postal: Postal code + siege_social: "Établissement siège social" + naf: "Établissement NAF" + adresse: "Établissement Adresse" + numero_voie: "Établissement numero voie" + type_voie: "Établissement type voie" + nom_voie: "Établissement nom voie" + complement_adresse: "Établissement complément adresse" + code_postal: "Établissement code postal" + localite: "Établissement localité" + code_insee_localite: "Établissement code INSEE localité" + entreprise_siren: SIREN + entreprise_capital_social: "Entreprise capital social" + entreprise_numero_tva_intracommunautaire: "Entreprise numero TVA intracommunautaire" + entreprise_forme_juridique: "Entreprise forme juridique" + entreprise_forme_juridique_code: "Entreprise forme juridique code" + entreprise_nom_commercial: "Entreprise nom commercial" + entreprise_raison_sociale: "Entreprise raison sociale" + entreprise_siret_siege_social: "Entreprise SIRET siège social" + entreprise_code_effectif_entreprise: "Entreprise code effectif entreprise" + entreprise_nom: 'Entreprise nom' + entreprise_prenom: 'Entreprise prénom' + association_rna: 'Association RNA' + association_titre: 'Association titre' + association_objet: 'Association objet' + association_date_creation: 'Association date de création' + association_date_declaration: 'Association date de déclaration' + association_date_publication: 'Association date de publication' diff --git a/config/locales/models/procedure_presentation/fr.yml b/config/locales/models/procedure_presentation/fr.yml index a5e3fe196..c6fba136f 100644 --- a/config/locales/models/procedure_presentation/fr.yml +++ b/config/locales/models/procedure_presentation/fr.yml @@ -5,26 +5,40 @@ fr: fields: self: id: Nº dossier - state: Statut + user_email_for_display: Email + state: État du dossier created_at: Créé le - updated_at: Mis à jour le + updated_at: Dernière mise à jour le depose_at: Déposé le - en_construction_at: En construction le - en_instruction_at: En instruction le - processed_at: Terminé le + en_construction_at: Passé en construction le + en_instruction_at: Passé en instruction le + processed_at: Traité le updated_since: Mis à jour depuis depose_since: Déposé depuis en_construction_since: En construction depuis en_instruction_since: En instruction depuis processed_since: Terminé depuis - sva_decision_on: Date décision SVA + sva_decision_on: Date décision %{type} sva_decision_before: Date décision SVA avant - svr_decision_on: Date décision SVR + svr_decision_on: Date décision %{type} svr_decision_before: Date décision SVR avant + user_from_france_connect?: "FranceConnect ?" + for_tiers: "Dépôt pour un tiers" + mandataire_last_name: "Nom du mandataire" + mandataire_first_name: "Prénom du mandataire" + archived: 'Archivé' + dossier_state: 'État du dossier' + last_champ_updated_at: 'Dernière mise à jour du dossier le' + motivation: 'Motivation de la décision' user: + id: Identifiant du demandeur email: Demandeur + procedure: + domaine_fonctionnel: Domaine Fonctionnel + referentiel_prog: Référentiel De Programmation + centre_de_cout: Centre De Coût followers_instructeurs: - email: Email instructeur + email: Instructeurs groupe_instructeur: id: Groupe instructeur label: Groupe instructeur @@ -36,12 +50,38 @@ fr: answer: Avis question_answer: Avis oui/non etablissement: - entreprise_siren: SIREN + entreprise_etat_administratif: 'Entreprise état administratif' entreprise_forme_juridique: Forme juridique entreprise_nom_commercial: Nom commercial entreprise_raison_sociale: Raison sociale entreprise_siret_siege_social: SIRET siège social - entreprise_date_creation: Date de création - siret: SIRET + entreprise_date_creation: Entreprise date de création + siret: Établissement SIRET libelle_naf: Libellé NAF - code_postal: Code postal + siege_social: "Établissement siège social" + naf: "Établissement NAF" + adresse: "Établissement Adresse" + numero_voie: "Établissement numero voie" + type_voie: "Établissement type voie" + nom_voie: "Établissement nom voie" + complement_adresse: "Établissement complément adresse" + code_postal: "Établissement code postal" + localite: "Établissement localité" + code_insee_localite: "Établissement code INSEE localité" + entreprise_siren: "Entreprise SIREN" + entreprise_capital_social: "Entreprise capital social" + entreprise_numero_tva_intracommunautaire: "Entreprise numero TVA intracommunautaire" + entreprise_forme_juridique: "Entreprise forme juridique" + entreprise_forme_juridique_code: "Entreprise forme juridique code" + entreprise_nom_commercial: "Entreprise nom commercial" + entreprise_raison_sociale: "Entreprise raison sociale" + entreprise_siret_siege_social: "Entreprise SIRET siège social" + entreprise_code_effectif_entreprise: "Entreprise code effectif entreprise" + entreprise_nom: 'Entreprise nom' + entreprise_prenom: 'Entreprise prénom' + association_rna: 'Association RNA' + association_titre: 'Association titre' + association_objet: 'Association objet' + association_date_creation: 'Association date de création' + association_date_declaration: 'Association date de déclaration' + association_date_publication: 'Association date de publication' diff --git a/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb b/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb index 795736c26..1e2dfcc10 100644 --- a/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb +++ b/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb @@ -38,7 +38,7 @@ describe '20240920130741_migrate_procedure_presentation_to_columns.rake' do it 'populates the columns' do procedure_id = procedure.id - expect(procedure_presentation.displayed_columns.map(&:label)).to eq(["Raison sociale", procedure.active_revision.types_de_champ.first.libelle]) + expect(procedure_presentation.displayed_columns.map(&:label)).to eq(["Entreprise raison sociale", procedure.active_revision.types_de_champ.first.libelle]) order, column_id = procedure_presentation .sorted_column diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index 27d062f32..7a518f0fa 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -29,7 +29,7 @@ describe ColumnsConcern do let(:tdc_private_2) { procedure.active_revision.types_de_champ_private[1] } let(:expected) { [ - { label: 'Nº dossier', table: 'self', column: 'id', displayable: true, type: :number, scope: '', value_column: :value, filterable: true }, + { label: 'Dossier ID', table: 'self', column: 'id', displayable: true, type: :number, scope: '', value_column: :value, filterable: true }, { label: 'notifications', table: 'notifications', column: 'notifications', displayable: true, type: :text, scope: '', value_column: :value, filterable: false }, { label: 'Créé le', table: 'self', column: 'created_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, { label: 'Mis à jour le', table: 'self', column: 'updated_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, @@ -43,10 +43,15 @@ describe ColumnsConcern do { label: "En instruction depuis", table: "self", column: "en_instruction_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, { label: "Terminé depuis", table: "self", column: "processed_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, { label: "Statut", table: "self", column: "state", displayable: false, scope: 'instructeurs.dossiers.filterable_state', type: :enum, value_column: :value, filterable: true }, + { label: "Archivé", table: "self", column: "archived", displayable: false, scope: '', type: :text, value_column: :value, filterable: false }, + { label: "Motivation de la décision", table: "self", column: "motivation", displayable: false, scope: '', type: :text, value_column: :value, filterable: false }, + { label: "Dernière mise à jour du dossier le", table: "self", column: "last_champ_updated_at", displayable: false, scope: '', type: :text, value_column: :value, filterable: false }, { label: 'Demandeur', table: 'user', column: 'email', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, { label: 'Email instructeur', table: 'followers_instructeurs', column: 'email', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, { label: 'Groupe instructeur', table: 'groupe_instructeur', column: 'id', displayable: true, type: :enum, scope: '', value_column: :value, filterable: true }, { label: 'Avis oui/non', table: 'avis', column: 'question_answer', displayable: true, type: :text, scope: '', value_column: :value, filterable: false }, + { label: 'Identifiant du demandeur', table: 'user', column: 'id', displayable: false, type: :text, scope: '', value_column: :value, filterable: false }, + { label: 'FranceConnect ?', table: 'self', column: 'user_from_france_connect?', displayable: false, type: :text, scope: '', value_column: :value, filterable: false }, { label: 'SIREN', table: 'etablissement', column: 'entreprise_siren', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, { label: 'Forme juridique', table: 'etablissement', column: 'entreprise_forme_juridique', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, { label: 'Nom commercial', table: 'etablissement', column: 'entreprise_nom_commercial', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, @@ -73,7 +78,11 @@ describe ColumnsConcern do procedure.active_revision.types_de_champ_private[3].update_attribute(:type_champ, TypeDeChamp.type_champs.fetch(:explication)) end - it { expect(subject).to eq(expected) } + it { + expected.each do |expected| + expect(subject).to include(expected) + end + } end context 'with rna' do @@ -125,4 +134,119 @@ describe ColumnsConcern do it { is_expected.to include(decision_on, decision_before_field) } end end + + describe 'export' do + let(:procedure) { create(:procedure_with_dossiers, :published, types_de_champ_public:, for_individual:) } + let(:for_individual) { true } + let(:types_de_champ_public) do + [ + { type: :text, libelle: "Ca va ?", mandatory: true, stable_id: 1 }, + { type: :communes, libelle: "Commune", mandatory: true, stable_id: 17 }, + { type: :siret, libelle: 'siret', stable_id: 20 }, + { type: :repetition, mandatory: true, stable_id: 7, libelle: "Champ répétable", children: [{ type: 'text', libelle: 'Qqchose à rajouter?', stable_id: 8 }] } + ] + end + + describe '#all_usager_columns_for_export' do + context 'for individual procedure' do + let(:for_individual) { true } + + it "returns all usager columns" do + expected = [ + procedure.find_column(label: "Nº dossier"), + procedure.find_column(label: "Email"), + procedure.find_column(label: "FranceConnect ?"), + procedure.find_column(label: "Civilité"), + procedure.find_column(label: "Nom"), + procedure.find_column(label: "Prénom"), + procedure.find_column(label: "Dépôt pour un tiers"), + procedure.find_column(label: "Nom du mandataire"), + procedure.find_column(label: "Prénom du mandataire") + ] + actuals = procedure.all_usager_columns_for_export.map(&:h_id) + expected.each do |expected_col| + expect(actuals).to include(expected_col.h_id) + end + end + end + + context 'for entreprise procedure' do + let(:for_individual) { false } + + it "returns all usager columns" do + expected = [ + procedure.find_column(label: "Nº dossier"), + procedure.find_column(label: "Email"), + procedure.find_column(label: "FranceConnect ?"), + procedure.find_column(label: "Établissement SIRET"), + procedure.find_column(label: "Établissement siège social"), + procedure.find_column(label: "Établissement NAF"), + procedure.find_column(label: "Libellé NAF"), + procedure.find_column(label: "Établissement Adresse"), + procedure.find_column(label: "Établissement numero voie"), + procedure.find_column(label: "Établissement type voie"), + procedure.find_column(label: "Établissement nom voie"), + procedure.find_column(label: "Établissement complément adresse"), + procedure.find_column(label: "Établissement code postal"), + procedure.find_column(label: "Établissement localité"), + procedure.find_column(label: "Établissement code INSEE localité"), + procedure.find_column(label: "Entreprise SIREN"), + procedure.find_column(label: "Entreprise capital social"), + procedure.find_column(label: "Entreprise numero TVA intracommunautaire"), + procedure.find_column(label: "Entreprise forme juridique"), + procedure.find_column(label: "Entreprise forme juridique code"), + procedure.find_column(label: "Entreprise nom commercial"), + procedure.find_column(label: "Entreprise raison sociale"), + procedure.find_column(label: "Entreprise SIRET siège social"), + procedure.find_column(label: "Entreprise code effectif entreprise") + ] + actuals = procedure.all_usager_columns_for_export + expected.each do |expected_col| + expect(actuals.map(&:h_id)).to include(expected_col.h_id) + end + + expect(actuals.any? { _1.label == "Nom" }).to eq false + end + end + + context 'when procedure chorusable' do + let(:procedure) { create(:procedure_with_dossiers, :filled_chorus, types_de_champ_public:) } + it 'returns specific chorus columns' do + allow_any_instance_of(Procedure).to receive(:chorusable?).and_return(true) + expected = [ + procedure.find_column(label: "Domaine Fonctionnel"), + procedure.find_column(label: "Référentiel De Programmation"), + procedure.find_column(label: "Centre De Coût") + ] + actuals = procedure.all_usager_columns_for_export.map(&:h_id) + expected.each do |expected_col| + expect(actuals).to include(expected_col.h_id) + end + end + end + end + + describe '#all_dossier_columns_for_export' do + let(:procedure) { create(:procedure_with_dossiers, :routee, :published, types_de_champ_public:, for_individual:) } + + it "returns all dossier columns" do + expected = [ + procedure.find_column(label: "Archivé"), + procedure.find_column(label: "État du dossier"), + procedure.find_column(label: "Dernière mise à jour le"), + procedure.find_column(label: "Dernière mise à jour du dossier le"), + procedure.find_column(label: "Déposé le"), + procedure.find_column(label: "Passé en instruction le"), + procedure.find_column(label: "Traité le"), + procedure.find_column(label: "Motivation de la décision"), + procedure.find_column(label: "Instructeurs"), + procedure.find_column(label: "Groupe instructeur") + ] + actuals = procedure.all_dossier_columns_for_export.map(&:h_id) + expected.each do |expected_col| + expect(actuals).to include(expected_col.h_id) + end + end + end + end end diff --git a/spec/models/export_spec.rb b/spec/models/export_spec.rb index 3a796ce93..4d219553f 100644 --- a/spec/models/export_spec.rb +++ b/spec/models/export_spec.rb @@ -95,7 +95,7 @@ RSpec.describe Export, type: :model do expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) } .to change { Export.count }.by(1) - update_at_column = FilteredColumn.new(column: procedure.find_column(label: 'Mis à jour le'), filter: '10/12/2021') + update_at_column = FilteredColumn.new(column: procedure.find_column(label: 'Dernière mise à jour le'), filter: '10/12/2021') pp.update(tous_filters: [created_at_column, update_at_column]) expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) } @@ -181,7 +181,7 @@ RSpec.describe Export, type: :model do let(:statut) { 'tous' } let(:procedure_presentation) do - statut_column = procedure.find_column(label: 'Statut') + statut_column = procedure.find_column(label: 'État du dossier') en_construction_filter = FilteredColumn.new(column: statut_column, filter: 'en_construction') create(:procedure_presentation, procedure:, diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index ac30ea06e..0a74640ec 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -77,7 +77,7 @@ describe ProcedurePresentation do end context 'when filter is state' do - let(:filtered_column) { to_filter(['Statut', "en_construction"]) } + let(:filtered_column) { to_filter(['État du dossier', "en_construction"]) } it 'should get i18n value' do expect(subject).to eq("En construction") @@ -94,8 +94,8 @@ describe ProcedurePresentation do end describe '#update_displayed_fields' do - let(:en_construction_column) { procedure.find_column(label: 'En construction le') } - let(:mise_a_jour_column) { procedure.find_column(label: 'Mis à jour le') } + let(:en_construction_column) { procedure.find_column(label: 'Passé en construction le') } + let(:mise_a_jour_column) { procedure.find_column(label: 'Dernière mise à jour le') } let(:procedure_presentation) do create(:procedure_presentation, assign_to:).tap do |pp| diff --git a/spec/services/dossier_filter_service_spec.rb b/spec/services/dossier_filter_service_spec.rb index 5997d46b1..f883161cd 100644 --- a/spec/services/dossier_filter_service_spec.rb +++ b/spec/services/dossier_filter_service_spec.rb @@ -41,7 +41,7 @@ describe DossierFilterService do context 'when a filter is present' do let(:filtered_ids) { [dossier_1, dossier_2, dossier_3].map(&:id) } - let(:filters) { [to_filter(['Statut', 'en_construction'])] } + let(:filters) { [to_filter(['État du dossier', 'en_construction'])] } before do expect(described_class).to receive(:filtered_ids).and_return(filtered_ids) @@ -110,7 +110,7 @@ describe DossierFilterService do end context 'for en_construction_at column' do - let!(:column) { procedure.find_column(label: 'En construction le') } + let!(:column) { procedure.find_column(label: 'Passé en construction le') } let!(:recent_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17)) } let!(:older_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2013, 1, 1)) } @@ -118,7 +118,7 @@ describe DossierFilterService do end context 'for updated_at column' do - let(:column) { procedure.find_column(label: 'Mis à jour le') } + let(:column) { procedure.find_column(label: 'Dernière mise à jour le') } let(:recent_dossier) { create(:dossier, procedure:) } let(:older_dossier) { create(:dossier, procedure:) } @@ -251,7 +251,7 @@ describe DossierFilterService do end context 'for email column' do - let(:column) { procedure.find_column(label: 'Email instructeur') } + let(:column) { procedure.find_column(label: 'Instructeurs') } it { is_expected.to eq([dossier_a, dossier_z, dossier_without_instructeur].map(&:id)) } end @@ -275,7 +275,7 @@ describe DossierFilterService do context 'for other tables' do # All other columns and tables work the same so it’s ok to test only one - let(:column) { procedure.find_column(label: 'Code postal') } + let(:column) { procedure.find_column(label: 'Établissement code postal') } let(:order) { 'asc' } # Desc works the same, no extra test required let!(:huitieme_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '75008')) } @@ -306,7 +306,7 @@ describe DossierFilterService do end context 'for en_construction_at column' do - let(:filter) { ['En construction le', '17/10/2018'] } + let(:filter) { ['Passé en construction le', '17/10/2018'] } let!(:kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17)) } let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2013, 1, 1)) } @@ -315,7 +315,7 @@ describe DossierFilterService do end context 'for updated_at column' do - let(:filter) { ['Mis à jour le', '18/9/2018'] } + let(:filter) { ['Dernière mise à jour le', '18/9/2018'] } let(:kept_dossier) { create(:dossier, procedure:) } let(:discarded_dossier) { create(:dossier, procedure:) } @@ -362,7 +362,7 @@ describe DossierFilterService do end context 'ignore time of day' do - let(:filter) { ['En construction le', '17/10/2018 19:30'] } + let(:filter) { ['Passé en construction le', '17/10/2018 19:30'] } let!(:kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17, 15, 56)) } let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 18, 5, 42)) } @@ -372,20 +372,20 @@ describe DossierFilterService do context 'for a malformed date' do context 'when its a string' do - let(:filter) { ['Mis à jour le', 'malformed date'] } + let(:filter) { ['Dernière mise à jour le', 'malformed date'] } it { is_expected.to match([]) } end context 'when its a number' do - let(:filter) { ['Mis à jour le', '177500'] } + let(:filter) { ['Dernière mise à jour le', '177500'] } it { is_expected.to match([]) } end end context 'with multiple search values' do - let(:filters) { [['En construction le', '17/10/2018'], ['En construction le', '19/10/2018']] } + let(:filters) { [['Passé en construction le', '17/10/2018'], ['Passé en construction le', '19/10/2018']] } let!(:kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17)) } let!(:other_kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 19)) } @@ -397,7 +397,7 @@ describe DossierFilterService do end context 'with multiple state filters' do - let(:filters) { [['Statut', 'en_construction'], ['Statut', 'en_instruction']] } + let(:filters) { [['État du dossier', 'en_construction'], ['État du dossier', 'en_instruction']] } let!(:kept_dossier) { create(:dossier, :en_construction, procedure:) } let!(:other_kept_dossier) { create(:dossier, :en_instruction, procedure:) } @@ -409,7 +409,7 @@ describe DossierFilterService do end context 'with en_construction state filters' do - let(:filter) { ['Statut', 'en_construction'] } + let(:filter) { ['État du dossier', 'en_construction'] } let!(:en_construction) { create(:dossier, :en_construction, procedure:) } let!(:en_construction_with_correction) { create(:dossier, :en_construction, procedure:) } @@ -580,7 +580,7 @@ describe DossierFilterService do context 'for etablissement table' do context 'for entreprise_date_creation column' do - let(:filter) { ['Date de création', '21/6/2018'] } + let(:filter) { ['Entreprise date de création', '21/6/2018'] } let!(:kept_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2018, 6, 21))) } let!(:discarded_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2008, 6, 21))) } @@ -588,7 +588,7 @@ describe DossierFilterService do it { is_expected.to contain_exactly(kept_dossier.id) } context 'with multiple search values' do - let(:filters) { [['Date de création', '21/6/2016'], ['Date de création', '21/6/2018']] } + let(:filters) { [['Entreprise date de création', '21/6/2016'], ['Entreprise date de création', '21/6/2018']] } let!(:other_kept_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2016, 6, 21))) } @@ -601,7 +601,7 @@ describe DossierFilterService do context 'for code_postal column' do # All columns except entreprise_date_creation work exacly the same, just testing one - let(:filter) { ['Code postal', '75017'] } + let(:filter) { ['Établissement code postal', '75017'] } let!(:kept_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '75017')) } let!(:discarded_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '25000')) } @@ -609,7 +609,7 @@ describe DossierFilterService do it { is_expected.to contain_exactly(kept_dossier.id) } context 'with multiple search values' do - let(:filters) { [['Code postal', '75017'], ['Code postal', '88100']] } + let(:filters) { [['Établissement code postal', '75017'], ['Établissement code postal', '88100']] } let!(:other_kept_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '88100')) } @@ -674,7 +674,7 @@ describe DossierFilterService do end context 'for followers_instructeurs table' do - let(:filter) { ['Email instructeur', 'keepmail'] } + let(:filter) { ['Instructeurs', 'keepmail'] } let!(:kept_dossier) { create(:dossier, procedure:) } let!(:discarded_dossier) { create(:dossier, procedure:) } @@ -687,7 +687,7 @@ describe DossierFilterService do it { is_expected.to contain_exactly(kept_dossier.id) } context 'with multiple search values' do - let(:filters) { [['Email instructeur', 'keepmail'], ['Email instructeur', 'beta.gouv.fr']] } + let(:filters) { [['Instructeurs', 'keepmail'], ['Instructeurs', 'beta.gouv.fr']] } let(:other_kept_dossier) { create(:dossier, procedure:) } diff --git a/spec/services/dossier_projection_service_spec.rb b/spec/services/dossier_projection_service_spec.rb index 7671bdda5..9cdd52d16 100644 --- a/spec/services/dossier_projection_service_spec.rb +++ b/spec/services/dossier_projection_service_spec.rb @@ -87,7 +87,7 @@ describe DossierProjectionService do end context 'for en_construction_at column' do - let(:label) { 'En construction le' } + let(:label) { 'Passé en construction le' } let(:dossier) { create(:dossier, :en_construction, en_construction_at: Time.zone.local(2018, 10, 17), procedure:) } it { is_expected.to eq('17/10/2018') } @@ -101,7 +101,7 @@ describe DossierProjectionService do end context 'for updated_at column' do - let(:label) { 'Mis à jour le' } + let(:label) { 'Dernière mise à jour le' } let(:dossier) { create(:dossier, procedure:) } before { dossier.touch(time: Time.zone.local(2018, 9, 25)) } @@ -142,7 +142,7 @@ describe DossierProjectionService do end context 'for etablissement table' do - let(:label) { 'Code postal' } + let(:label) { 'Établissement code postal' } let!(:dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '75008')) } @@ -158,7 +158,7 @@ describe DossierProjectionService do end context 'for followers_instructeurs table' do - let(:label) { 'Email instructeur' } + let(:label) { 'Instructeurs' } let(:dossier) { create(:dossier, procedure:) } let!(:follow1) { create(:follow, dossier: dossier, instructeur: create(:instructeur, email: 'b@host.fr')) } diff --git a/spec/system/instructeurs/procedure_filters_spec.rb b/spec/system/instructeurs/procedure_filters_spec.rb index 85555764f..5814335a8 100644 --- a/spec/system/instructeurs/procedure_filters_spec.rb +++ b/spec/system/instructeurs/procedure_filters_spec.rb @@ -99,10 +99,10 @@ describe "procedure filters" do scenario "should be able to user custom fiters", js: true do # use date filter - add_filter("En construction le", "10/10/2010", type: :date) + add_filter("Passé en construction le", "10/10/2010", type: :date) # use statut dropdown filter - add_filter('Statut', 'En construction', type: :enum) + add_filter('État du dossier', 'En construction', type: :enum) # use choice dropdown filter add_filter('Choix unique', 'val1', type: :enum) From 8afe4374c7d611d0052a8792b020bc61492e6bc1 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 28 Oct 2024 17:01:27 +0100 Subject: [PATCH 1329/1532] review(pull/10591#discussion_r1818744664): extract some column builder --- app/models/concerns/columns_concern.rb | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 78a533a7c..91acfcf80 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -42,8 +42,8 @@ module ColumnsConcern def all_usager_columns_for_export common = [ dossier_id_column, - Column.new(procedure_id: id, table: 'self', column: 'user_email_for_display', filterable: false, displayable: false), - Column.new(procedure_id: id, table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false) + email_for_display_column, + france_connected_column ] individual_or_moral_columns = for_individual? ? individual_columns : moral_columns @@ -78,6 +78,10 @@ module ColumnsConcern Column.new(procedure_id: id, table: 'self', column: 'state', label: I18n.t('activerecord.attributes.procedure_presentation.fields.self.state'), type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false) end + def email_for_display_column = Column.new(procedure_id: id, table: 'self', column: 'user_email_for_display', filterable: false, displayable: false) + + def france_connected_column = Column.new(procedure_id: id, table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false) + def notifications_column Column.new(procedure_id: id, table: 'notifications', column: 'notifications', label: "notifications", filterable: false) end @@ -134,12 +138,12 @@ module ColumnsConcern def standard_columns [ email_column, - Column.new(procedure_id: id, table: 'self', column: 'user_email_for_display', filterable: false, displayable: false), + email_for_display_column, Column.new(procedure_id: id, table: 'followers_instructeurs', column: 'email'), Column.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum), Column.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false), Column.new(procedure_id: id, table: 'user', column: 'id', filterable: false, displayable: false), - Column.new(procedure_id: id, table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false) + france_connected_column ] end From d9fc48ad706248e514dffa9965c5b646b91cbf05 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 28 Oct 2024 17:07:22 +0100 Subject: [PATCH 1330/1532] review(10591#discussion_r1818752344): homogenize interface for columns builders --- app/models/concerns/columns_concern.rb | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 91acfcf80..120b47698 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -31,24 +31,17 @@ module ColumnsConcern end def chorus_columns - if chorusable? && chorus_configuration.complete? - ['domaine_fonctionnel', 'referentiel_prog', 'centre_de_cout'] - .map { |column| Column.new(procedure_id: id, table: 'procedure', column:, displayable: false, filterable: false) } - else - [] - end + ['domaine_fonctionnel', 'referentiel_prog', 'centre_de_cout'] + .map { |column| Column.new(procedure_id: id, table: 'procedure', column:, displayable: false, filterable: false) } end def all_usager_columns_for_export - common = [ - dossier_id_column, - email_for_display_column, - france_connected_column - ] + columns = [dossier_id_column, email_for_display_column, france_connected_column] + columns.concat(individual_columns) if for_individual + columns.concat(moral_columns) if !for_individual + columns.concat(chorus_columns) if chorusable? && chorus_configuration.complete? - individual_or_moral_columns = for_individual? ? individual_columns : moral_columns - - [common, individual_or_moral_columns, chorus_columns].flatten.compact + columns.flatten.compact end def all_dossier_columns_for_export From fc45e537cfe2945d902c477e88f6a6ffd655481e Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 28 Oct 2024 17:14:07 +0100 Subject: [PATCH 1331/1532] review(pull/10591#discussion_r1818914395): extract and dry archived/motivation column --- app/models/concerns/columns_concern.rb | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 120b47698..acab0d098 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -47,10 +47,6 @@ module ColumnsConcern def all_dossier_columns_for_export states = [dossier_state_column] - for_export_before_date = ['archived'] - .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :text, displayable: false, filterable: false) } - for_export_after_date = ['motivation'] - .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :text, displayable: false, filterable: false) } routing = if self.routing_enabled? [Column.new(procedure_id: id, table: 'groupe_instructeur', column: 'id')] @@ -60,7 +56,7 @@ module ColumnsConcern instructeurs = [Column.new(procedure_id: id, table: 'followers_instructeurs', column: 'email')] - [states, for_export_before_date, dossier_dates_columns, for_export_after_date, sva_svr_columns(for_export: true), routing, instructeurs].flatten.compact + [states, dossier_archived_column, dossier_dates_columns, dossier_motivation_column, sva_svr_columns(for_export: true), routing, instructeurs].flatten.compact end def dossier_id_column @@ -87,12 +83,7 @@ module ColumnsConcern non_displayable_dates = ['updated_since', 'depose_since', 'en_construction_since', 'en_instruction_since', 'processed_since'] .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date, displayable: false) } - for_export_before_date = ['archived'] - .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :text, displayable: false, filterable: false) } - for_export_after_date = ['motivation'] - .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :text, displayable: false, filterable: false) } - - [common, states, for_export_before_date, dossier_dates_columns, for_export_after_date, sva_svr_columns(for_export: false), non_displayable_dates].flatten.compact + [common, states, dossier_archived_column, dossier_dates_columns, dossier_motivation_column, sva_svr_columns(for_export: false), non_displayable_dates].flatten.compact end def dossier_dates_columns @@ -122,6 +113,10 @@ module ColumnsConcern def default_displayed_columns = [email_column] + def dossier_archived_column = Column.new(procedure_id: id, table: 'self', column: 'archived', type: :text, displayable: false, filterable: false); + + def dossier_motivation_column = Column.new(procedure_id: id, table: 'self', column: 'motivation', type: :text, displayable: false, filterable: false); + private def email_column From 3bed049a292581fc245a3636234f1cca372c1401 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 28 Oct 2024 17:16:48 +0100 Subject: [PATCH 1332/1532] tech(style): prefix columns builders by their table, easier to understand --- app/models/concerns/columns_concern.rb | 44 ++++++++++++-------------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index acab0d098..b33e6b9e9 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -25,21 +25,16 @@ module ColumnsConcern columns.concat(standard_columns) columns.concat(individual_columns) if for_individual columns.concat(moral_columns) if !for_individual - columns.concat(chorus_columns) + columns.concat(procedure_chorus_columns) columns.concat(types_de_champ_columns) end end - def chorus_columns - ['domaine_fonctionnel', 'referentiel_prog', 'centre_de_cout'] - .map { |column| Column.new(procedure_id: id, table: 'procedure', column:, displayable: false, filterable: false) } - end - def all_usager_columns_for_export - columns = [dossier_id_column, email_for_display_column, france_connected_column] + columns = [dossier_id_column, user_email_for_display_column, user_france_connected_column] columns.concat(individual_columns) if for_individual columns.concat(moral_columns) if !for_individual - columns.concat(chorus_columns) if chorusable? && chorus_configuration.complete? + columns.concat(procedure_chorus_columns) if chorusable? && chorus_configuration.complete? columns.flatten.compact end @@ -59,20 +54,25 @@ module ColumnsConcern [states, dossier_archived_column, dossier_dates_columns, dossier_motivation_column, sva_svr_columns(for_export: true), routing, instructeurs].flatten.compact end - def dossier_id_column - Column.new(procedure_id: id, table: 'self', column: 'id', type: :number) - end + #### - def dossier_state_column - Column.new(procedure_id: id, table: 'self', column: 'state', label: I18n.t('activerecord.attributes.procedure_presentation.fields.self.state'), type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false) - end + def dossier_archived_column = Column.new(procedure_id: id, table: 'self', column: 'archived', type: :text, displayable: false, filterable: false); - def email_for_display_column = Column.new(procedure_id: id, table: 'self', column: 'user_email_for_display', filterable: false, displayable: false) + def dossier_motivation_column = Column.new(procedure_id: id, table: 'self', column: 'motivation', type: :text, displayable: false, filterable: false); - def france_connected_column = Column.new(procedure_id: id, table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false) + def dossier_id_column = Column.new(procedure_id: id, table: 'self', column: 'id', type: :number) - def notifications_column - Column.new(procedure_id: id, table: 'notifications', column: 'notifications', label: "notifications", filterable: false) + def dossier_state_column = Column.new(procedure_id: id, table: 'self', column: 'state', label: I18n.t('activerecord.attributes.procedure_presentation.fields.self.state'), type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false) + + def notifications_column = Column.new(procedure_id: id, table: 'notifications', column: 'notifications', label: "notifications", filterable: false) + + def user_email_for_display_column = Column.new(procedure_id: id, table: 'self', column: 'user_email_for_display', filterable: false, displayable: false) + + def user_france_connected_column = Column.new(procedure_id: id, table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false) + + def procedure_chorus_columns + ['domaine_fonctionnel', 'referentiel_prog', 'centre_de_cout'] + .map { |column| Column.new(procedure_id: id, table: 'procedure', column:, displayable: false, filterable: false) } end def dossier_columns @@ -113,10 +113,6 @@ module ColumnsConcern def default_displayed_columns = [email_column] - def dossier_archived_column = Column.new(procedure_id: id, table: 'self', column: 'archived', type: :text, displayable: false, filterable: false); - - def dossier_motivation_column = Column.new(procedure_id: id, table: 'self', column: 'motivation', type: :text, displayable: false, filterable: false); - private def email_column @@ -126,12 +122,12 @@ module ColumnsConcern def standard_columns [ email_column, - email_for_display_column, + user_email_for_display_column, Column.new(procedure_id: id, table: 'followers_instructeurs', column: 'email'), Column.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum), Column.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false), Column.new(procedure_id: id, table: 'user', column: 'id', filterable: false, displayable: false), - france_connected_column + user_france_connected_column ] end From cbb9854f4ce862685947b9f7fefaee82df28b8b2 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 28 Oct 2024 17:38:48 +0100 Subject: [PATCH 1333/1532] review(pull/10591#discussion_r1818916426): extract groupe_instructeurs_column and followers_instructeurs_email_column --- app/models/concerns/columns_concern.rb | 51 +++++++++++++------------- app/models/procedure_presentation.rb | 7 ++-- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index b33e6b9e9..3a473ba58 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -40,21 +40,30 @@ module ColumnsConcern end def all_dossier_columns_for_export - states = [dossier_state_column] - - routing = - if self.routing_enabled? - [Column.new(procedure_id: id, table: 'groupe_instructeur', column: 'id')] - else - [] - end - - instructeurs = [Column.new(procedure_id: id, table: 'followers_instructeurs', column: 'email')] - - [states, dossier_archived_column, dossier_dates_columns, dossier_motivation_column, sva_svr_columns(for_export: true), routing, instructeurs].flatten.compact + columns = [dossier_state_column] + columns.concat([dossier_archived_column]) + columns.concat(dossier_dates_columns) + columns.concat([dossier_motivation_column]) + columns.concat(sva_svr_columns(for_export: true)) if sva_svr_enabled? + columns.concat([groupe_instructeurs_id_column]) + columns.concat([followers_instructeurs_email_column]) + columns.flatten.compact end - #### + def dossier_columns + dossier_columns = [dossier_id_column, notifications_column] + dossier_columns.concat([dossier_state_column]) + dossier_columns.concat([dossier_archived_column]) + dossier_columns.concat(dossier_dates_columns) + dossier_columns.concat([dossier_motivation_column]) + dossier_columns.concat(sva_svr_columns(for_export: false)) if sva_svr_enabled? + dossier_columns.concat(dossier_non_displayable_dates_columns) + dossier_columns.flatten.compact + end + + def groupe_instructeurs_id_column = Column.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum) + + def followers_instructeurs_email_column = Column.new(procedure_id: id, table: 'followers_instructeurs', column: 'email') def dossier_archived_column = Column.new(procedure_id: id, table: 'self', column: 'archived', type: :text, displayable: false, filterable: false); @@ -75,15 +84,9 @@ module ColumnsConcern .map { |column| Column.new(procedure_id: id, table: 'procedure', column:, displayable: false, filterable: false) } end - def dossier_columns - common = [dossier_id_column, notifications_column] - - states = [dossier_state_column] - - non_displayable_dates = ['updated_since', 'depose_since', 'en_construction_since', 'en_instruction_since', 'processed_since'] + def dossier_non_displayable_dates_columns + ['updated_since', 'depose_since', 'en_construction_since', 'en_instruction_since', 'processed_since'] .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date, displayable: false) } - - [common, states, dossier_archived_column, dossier_dates_columns, dossier_motivation_column, sva_svr_columns(for_export: false), non_displayable_dates].flatten.compact end def dossier_dates_columns @@ -92,8 +95,6 @@ module ColumnsConcern end def sva_svr_columns(for_export: false) - return if !sva_svr_enabled? - scope = [:activerecord, :attributes, :procedure_presentation, :fields, :self] columns = [ @@ -123,8 +124,8 @@ module ColumnsConcern [ email_column, user_email_for_display_column, - Column.new(procedure_id: id, table: 'followers_instructeurs', column: 'email'), - Column.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum), + followers_instructeurs_email_column, + groupe_instructeurs_id_column, Column.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false), Column.new(procedure_id: id, table: 'user', column: 'id', filterable: false, displayable: false), user_france_connected_column diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 07365e9b7..c45e6ac20 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -34,12 +34,13 @@ class ProcedurePresentation < ApplicationRecord def filters_name_for(statut) = statut.tr('-', '_').then { "#{_1}_filters" } def displayed_fields_for_headers - [ + columns = [ procedure.dossier_id_column, *displayed_columns, - procedure.dossier_state_column, - *procedure.sva_svr_columns + procedure.dossier_state_column ] + columns.concat(procedure.sva_svr_columns) if procedure.sva_svr_enabled? + columns end def human_value_for_filter(filtered_column) From fa50e21101c4f79faa6c85d37758d61adeb9850c Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 28 Oct 2024 17:40:14 +0100 Subject: [PATCH 1334/1532] review(pull/10591#discussion_r1818919818): export without user id --- app/models/concerns/columns_concern.rb | 1 - spec/models/concerns/columns_concern_spec.rb | 1 - 2 files changed, 2 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 3a473ba58..bc963da8d 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -127,7 +127,6 @@ module ColumnsConcern followers_instructeurs_email_column, groupe_instructeurs_id_column, Column.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false), - Column.new(procedure_id: id, table: 'user', column: 'id', filterable: false, displayable: false), user_france_connected_column ] end diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index 7a518f0fa..8262588c4 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -50,7 +50,6 @@ describe ColumnsConcern do { label: 'Email instructeur', table: 'followers_instructeurs', column: 'email', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, { label: 'Groupe instructeur', table: 'groupe_instructeur', column: 'id', displayable: true, type: :enum, scope: '', value_column: :value, filterable: true }, { label: 'Avis oui/non', table: 'avis', column: 'question_answer', displayable: true, type: :text, scope: '', value_column: :value, filterable: false }, - { label: 'Identifiant du demandeur', table: 'user', column: 'id', displayable: false, type: :text, scope: '', value_column: :value, filterable: false }, { label: 'FranceConnect ?', table: 'self', column: 'user_from_france_connect?', displayable: false, type: :text, scope: '', value_column: :value, filterable: false }, { label: 'SIREN', table: 'etablissement', column: 'entreprise_siren', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, { label: 'Forme juridique', table: 'etablissement', column: 'entreprise_forme_juridique', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, From 26a078bc451120d10714bc88984db7481378005d Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 28 Oct 2024 17:48:14 +0100 Subject: [PATCH 1335/1532] feat(naming): enhance naming of dates columns. --- .../models/procedure_presentation/en.yml | 2 +- .../models/procedure_presentation/fr.yml | 20 +++++++------- spec/models/concerns/columns_concern_spec.rb | 26 +++++++++---------- spec/models/export_spec.rb | 6 ++--- spec/models/procedure_presentation_spec.rb | 6 ++--- spec/services/dossier_filter_service_spec.rb | 22 ++++++++-------- .../dossier_projection_service_spec.rb | 8 +++--- .../instructeurs/procedure_filters_spec.rb | 6 ++--- 8 files changed, 48 insertions(+), 48 deletions(-) diff --git a/config/locales/models/procedure_presentation/en.yml b/config/locales/models/procedure_presentation/en.yml index fc12fb311..78957508d 100644 --- a/config/locales/models/procedure_presentation/en.yml +++ b/config/locales/models/procedure_presentation/en.yml @@ -22,7 +22,7 @@ en: sva_decision_before: SVA decision date before svr_decision_on: Date décision %{type} svr_decision_before: SVR decision date before - user_from_france_connect?: "FranceConnect ?" + user_from_france_connect?: "France connecté ?" for_tiers: "For tiers" mandataire_last_name: "Tier last name" mandataire_first_name: "Tier first name" diff --git a/config/locales/models/procedure_presentation/fr.yml b/config/locales/models/procedure_presentation/fr.yml index c6fba136f..b46c82a37 100644 --- a/config/locales/models/procedure_presentation/fr.yml +++ b/config/locales/models/procedure_presentation/fr.yml @@ -7,28 +7,28 @@ fr: id: Nº dossier user_email_for_display: Email state: État du dossier - created_at: Créé le - updated_at: Dernière mise à jour le - depose_at: Déposé le - en_construction_at: Passé en construction le - en_instruction_at: Passé en instruction le - processed_at: Traité le - updated_since: Mis à jour depuis + created_at: Date de création + updated_at: Date du dernier évènement + depose_at: Date de dépot + en_construction_at: Date de passage en construction + en_instruction_at: Date de passage en instruction + processed_at: Date de traitement + updated_since: Dernier évènement depuis depose_since: Déposé depuis en_construction_since: En construction depuis en_instruction_since: En instruction depuis - processed_since: Terminé depuis + processed_since: Traité depuis sva_decision_on: Date décision %{type} sva_decision_before: Date décision SVA avant svr_decision_on: Date décision %{type} svr_decision_before: Date décision SVR avant - user_from_france_connect?: "FranceConnect ?" + user_from_france_connect?: "France connecté ?" for_tiers: "Dépôt pour un tiers" mandataire_last_name: "Nom du mandataire" mandataire_first_name: "Prénom du mandataire" archived: 'Archivé' dossier_state: 'État du dossier' - last_champ_updated_at: 'Dernière mise à jour du dossier le' + last_champ_updated_at: Date de dernière modification (usager) motivation: 'Motivation de la décision' user: id: Identifiant du demandeur diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index 8262588c4..d1386be36 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -31,26 +31,26 @@ describe ColumnsConcern do [ { label: 'Dossier ID', table: 'self', column: 'id', displayable: true, type: :number, scope: '', value_column: :value, filterable: true }, { label: 'notifications', table: 'notifications', column: 'notifications', displayable: true, type: :text, scope: '', value_column: :value, filterable: false }, - { label: 'Créé le', table: 'self', column: 'created_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, + { label: 'Date de création', table: 'self', column: 'created_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, { label: 'Mis à jour le', table: 'self', column: 'updated_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'Déposé le', table: 'self', column: 'depose_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, + { label: 'Date de dépot', table: 'self', column: 'depose_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, { label: 'En construction le', table: 'self', column: 'en_construction_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, { label: 'En instruction le', table: 'self', column: 'en_instruction_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, { label: 'Terminé le', table: 'self', column: 'processed_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "Mis à jour depuis", table: "self", column: "updated_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, + { label: "Dernier évènement depuis", table: "self", column: "updated_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, { label: "Déposé depuis", table: "self", column: "depose_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, { label: "En construction depuis", table: "self", column: "en_construction_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, { label: "En instruction depuis", table: "self", column: "en_instruction_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "Terminé depuis", table: "self", column: "processed_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, + { label: "Traité depuis", table: "self", column: "processed_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, { label: "Statut", table: "self", column: "state", displayable: false, scope: 'instructeurs.dossiers.filterable_state', type: :enum, value_column: :value, filterable: true }, { label: "Archivé", table: "self", column: "archived", displayable: false, scope: '', type: :text, value_column: :value, filterable: false }, { label: "Motivation de la décision", table: "self", column: "motivation", displayable: false, scope: '', type: :text, value_column: :value, filterable: false }, - { label: "Dernière mise à jour du dossier le", table: "self", column: "last_champ_updated_at", displayable: false, scope: '', type: :text, value_column: :value, filterable: false }, + { label: "Date de dernière modification (usager)", table: "self", column: "last_champ_updated_at", displayable: false, scope: '', type: :text, value_column: :value, filterable: false }, { label: 'Demandeur', table: 'user', column: 'email', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, { label: 'Email instructeur', table: 'followers_instructeurs', column: 'email', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, { label: 'Groupe instructeur', table: 'groupe_instructeur', column: 'id', displayable: true, type: :enum, scope: '', value_column: :value, filterable: true }, { label: 'Avis oui/non', table: 'avis', column: 'question_answer', displayable: true, type: :text, scope: '', value_column: :value, filterable: false }, - { label: 'FranceConnect ?', table: 'self', column: 'user_from_france_connect?', displayable: false, type: :text, scope: '', value_column: :value, filterable: false }, + { label: 'France connecté ?', table: 'self', column: 'user_from_france_connect?', displayable: false, type: :text, scope: '', value_column: :value, filterable: false }, { label: 'SIREN', table: 'etablissement', column: 'entreprise_siren', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, { label: 'Forme juridique', table: 'etablissement', column: 'entreprise_forme_juridique', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, { label: 'Nom commercial', table: 'etablissement', column: 'entreprise_nom_commercial', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, @@ -154,7 +154,7 @@ describe ColumnsConcern do expected = [ procedure.find_column(label: "Nº dossier"), procedure.find_column(label: "Email"), - procedure.find_column(label: "FranceConnect ?"), + procedure.find_column(label: "France connecté ?"), procedure.find_column(label: "Civilité"), procedure.find_column(label: "Nom"), procedure.find_column(label: "Prénom"), @@ -176,7 +176,7 @@ describe ColumnsConcern do expected = [ procedure.find_column(label: "Nº dossier"), procedure.find_column(label: "Email"), - procedure.find_column(label: "FranceConnect ?"), + procedure.find_column(label: "France connecté ?"), procedure.find_column(label: "Établissement SIRET"), procedure.find_column(label: "Établissement siège social"), procedure.find_column(label: "Établissement NAF"), @@ -232,11 +232,11 @@ describe ColumnsConcern do expected = [ procedure.find_column(label: "Archivé"), procedure.find_column(label: "État du dossier"), - procedure.find_column(label: "Dernière mise à jour le"), - procedure.find_column(label: "Dernière mise à jour du dossier le"), - procedure.find_column(label: "Déposé le"), - procedure.find_column(label: "Passé en instruction le"), - procedure.find_column(label: "Traité le"), + procedure.find_column(label: "Date du dernier évènement"), + procedure.find_column(label: "Date de dernière modification (usager)"), + procedure.find_column(label: "Date de dépot"), + procedure.find_column(label: "Date de passage en instruction"), + procedure.find_column(label: "Date de traitement"), procedure.find_column(label: "Motivation de la décision"), procedure.find_column(label: "Instructeurs"), procedure.find_column(label: "Groupe instructeur") diff --git a/spec/models/export_spec.rb b/spec/models/export_spec.rb index 4d219553f..db075a1c4 100644 --- a/spec/models/export_spec.rb +++ b/spec/models/export_spec.rb @@ -81,7 +81,7 @@ RSpec.describe Export, type: :model do let(:instructeur) { create(:instructeur) } let!(:gi_1) { create(:groupe_instructeur, procedure: procedure, instructeurs: [instructeur]) } let!(:pp) { gi_1.instructeurs.first.procedure_presentation_and_errors_for_procedure_id(procedure.id).first } - let(:created_at_column) { FilteredColumn.new(column: procedure.find_column(label: 'Créé le'), filter: '10/12/2021') } + let(:created_at_column) { FilteredColumn.new(column: procedure.find_column(label: 'Date de création'), filter: '10/12/2021') } before { pp.update(tous_filters: [created_at_column]) } @@ -95,7 +95,7 @@ RSpec.describe Export, type: :model do expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) } .to change { Export.count }.by(1) - update_at_column = FilteredColumn.new(column: procedure.find_column(label: 'Dernière mise à jour le'), filter: '10/12/2021') + update_at_column = FilteredColumn.new(column: procedure.find_column(label: 'Date du dernier évènement'), filter: '10/12/2021') pp.update(tous_filters: [created_at_column, update_at_column]) expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) } @@ -181,7 +181,7 @@ RSpec.describe Export, type: :model do let(:statut) { 'tous' } let(:procedure_presentation) do - statut_column = procedure.find_column(label: 'État du dossier') + statut_column = procedure.dossier_state_column en_construction_filter = FilteredColumn.new(column: statut_column, filter: 'en_construction') create(:procedure_presentation, procedure:, diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index 0a74640ec..821204a3c 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -85,7 +85,7 @@ describe ProcedurePresentation do end context 'when filter is a date' do - let(:filtered_column) { to_filter(['Créé le', "15/06/2023"]) } + let(:filtered_column) { to_filter(['Date de création', "15/06/2023"]) } it 'should get formatted value' do expect(subject).to eq("15/06/2023") @@ -94,8 +94,8 @@ describe ProcedurePresentation do end describe '#update_displayed_fields' do - let(:en_construction_column) { procedure.find_column(label: 'Passé en construction le') } - let(:mise_a_jour_column) { procedure.find_column(label: 'Dernière mise à jour le') } + let(:en_construction_column) { procedure.find_column(label: 'Date de passage en construction') } + let(:mise_a_jour_column) { procedure.find_column(label: 'Date du dernier évènement') } let(:procedure_presentation) do create(:procedure_presentation, assign_to:).tap do |pp| diff --git a/spec/services/dossier_filter_service_spec.rb b/spec/services/dossier_filter_service_spec.rb index f883161cd..cb8e395bd 100644 --- a/spec/services/dossier_filter_service_spec.rb +++ b/spec/services/dossier_filter_service_spec.rb @@ -102,7 +102,7 @@ describe DossierFilterService do let(:order) { 'asc' } # Desc works the same, no extra test required context 'for created_at column' do - let!(:column) { procedure.find_column(label: 'Créé le') } + let!(:column) { procedure.find_column(label: 'Date de création') } let!(:recent_dossier) { Timecop.freeze(Time.zone.local(2018, 10, 17)) { create(:dossier, procedure:) } } let!(:older_dossier) { Timecop.freeze(Time.zone.local(2003, 11, 11)) { create(:dossier, procedure:) } } @@ -110,7 +110,7 @@ describe DossierFilterService do end context 'for en_construction_at column' do - let!(:column) { procedure.find_column(label: 'Passé en construction le') } + let!(:column) { procedure.find_column(label: 'Date de passage en construction') } let!(:recent_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17)) } let!(:older_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2013, 1, 1)) } @@ -118,7 +118,7 @@ describe DossierFilterService do end context 'for updated_at column' do - let(:column) { procedure.find_column(label: 'Dernière mise à jour le') } + let(:column) { procedure.find_column(label: 'Date du dernier évènement') } let(:recent_dossier) { create(:dossier, procedure:) } let(:older_dossier) { create(:dossier, procedure:) } @@ -297,7 +297,7 @@ describe DossierFilterService do context 'for self table' do context 'for created_at column' do - let(:filter) { ['Créé le', '18/9/2018'] } + let(:filter) { ['Date de création', '18/9/2018'] } let!(:kept_dossier) { create(:dossier, procedure:, created_at: Time.zone.local(2018, 9, 18, 14, 28)) } let!(:discarded_dossier) { create(:dossier, procedure:, created_at: Time.zone.local(2018, 9, 17, 23, 59)) } @@ -306,7 +306,7 @@ describe DossierFilterService do end context 'for en_construction_at column' do - let(:filter) { ['Passé en construction le', '17/10/2018'] } + let(:filter) { ['Date de passage en construction', '17/10/2018'] } let!(:kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17)) } let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2013, 1, 1)) } @@ -315,7 +315,7 @@ describe DossierFilterService do end context 'for updated_at column' do - let(:filter) { ['Dernière mise à jour le', '18/9/2018'] } + let(:filter) { ['Date du dernier évènement', '18/9/2018'] } let(:kept_dossier) { create(:dossier, procedure:) } let(:discarded_dossier) { create(:dossier, procedure:) } @@ -329,7 +329,7 @@ describe DossierFilterService do end context 'for updated_since column' do - let(:filter) { ['Mis à jour depuis', '18/9/2018'] } + let(:filter) { ['Dernier évènement depuis', '18/9/2018'] } let(:kept_dossier) { create(:dossier, procedure:) } let(:later_dossier) { create(:dossier, procedure:) } @@ -362,7 +362,7 @@ describe DossierFilterService do end context 'ignore time of day' do - let(:filter) { ['Passé en construction le', '17/10/2018 19:30'] } + let(:filter) { ['Date de passage en construction', '17/10/2018 19:30'] } let!(:kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17, 15, 56)) } let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 18, 5, 42)) } @@ -372,20 +372,20 @@ describe DossierFilterService do context 'for a malformed date' do context 'when its a string' do - let(:filter) { ['Dernière mise à jour le', 'malformed date'] } + let(:filter) { ['Date du dernier évènement', 'malformed date'] } it { is_expected.to match([]) } end context 'when its a number' do - let(:filter) { ['Dernière mise à jour le', '177500'] } + let(:filter) { ['Date du dernier évènement', '177500'] } it { is_expected.to match([]) } end end context 'with multiple search values' do - let(:filters) { [['Passé en construction le', '17/10/2018'], ['Passé en construction le', '19/10/2018']] } + let(:filters) { [['Date de passage en construction', '17/10/2018'], ['Date de passage en construction', '19/10/2018']] } let!(:kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17)) } let!(:other_kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 19)) } diff --git a/spec/services/dossier_projection_service_spec.rb b/spec/services/dossier_projection_service_spec.rb index 9cdd52d16..af4969f8f 100644 --- a/spec/services/dossier_projection_service_spec.rb +++ b/spec/services/dossier_projection_service_spec.rb @@ -80,28 +80,28 @@ describe DossierProjectionService do context 'for self table' do context 'for created_at column' do - let(:label) { 'Créé le' } + let(:label) { 'Date de création' } let(:dossier) { Timecop.freeze(Time.zone.local(1992, 3, 22)) { create(:dossier, procedure:) } } it { is_expected.to eq('22/03/1992') } end context 'for en_construction_at column' do - let(:label) { 'Passé en construction le' } + let(:label) { 'Date de passage en construction' } let(:dossier) { create(:dossier, :en_construction, en_construction_at: Time.zone.local(2018, 10, 17), procedure:) } it { is_expected.to eq('17/10/2018') } end context 'for depose_at column' do - let(:label) { 'Déposé le' } + let(:label) { 'Date de dépot' } let(:dossier) { create(:dossier, :en_construction, depose_at: Time.zone.local(2018, 10, 17), procedure:) } it { is_expected.to eq('17/10/2018') } end context 'for updated_at column' do - let(:label) { 'Dernière mise à jour le' } + let(:label) { 'Date du dernier évènement' } let(:dossier) { create(:dossier, procedure:) } before { dossier.touch(time: Time.zone.local(2018, 9, 25)) } diff --git a/spec/system/instructeurs/procedure_filters_spec.rb b/spec/system/instructeurs/procedure_filters_spec.rb index 5814335a8..65440b22d 100644 --- a/spec/system/instructeurs/procedure_filters_spec.rb +++ b/spec/system/instructeurs/procedure_filters_spec.rb @@ -44,9 +44,9 @@ describe "procedure filters" do end scenario "should add be able to add created_at column", js: true do - add_column("Créé le") + add_column("Date de création") within ".dossiers-table" do - expect(page).to have_link("Créé le") + expect(page).to have_link("Date de création") expect(page).to have_link(new_unfollow_dossier.created_at.strftime('%d/%m/%Y')) end end @@ -99,7 +99,7 @@ describe "procedure filters" do scenario "should be able to user custom fiters", js: true do # use date filter - add_filter("Passé en construction le", "10/10/2010", type: :date) + add_filter("Date de passage en construction", "10/10/2010", type: :date) # use statut dropdown filter add_filter('État du dossier', 'En construction', type: :enum) From 656080538bee88b1a6746e989edcf5f88150b6e2 Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 29 Oct 2024 09:17:45 +0100 Subject: [PATCH 1336/1532] review(pull/10591#discussion_r1818942794): remove tested enum labels --- app/models/concerns/columns_concern.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index bc963da8d..0b4fc61d4 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -71,7 +71,7 @@ module ColumnsConcern def dossier_id_column = Column.new(procedure_id: id, table: 'self', column: 'id', type: :number) - def dossier_state_column = Column.new(procedure_id: id, table: 'self', column: 'state', label: I18n.t('activerecord.attributes.procedure_presentation.fields.self.state'), type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false) + def dossier_state_column = Column.new(procedure_id: id, table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false) def notifications_column = Column.new(procedure_id: id, table: 'notifications', column: 'notifications', label: "notifications", filterable: false) From 2181a917a2ff922c223a0125f9fa2700e80bc19b Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 30 Oct 2024 15:58:08 +0100 Subject: [PATCH 1337/1532] move col def to private when possible --- app/models/concerns/columns_concern.rb | 76 +++++++++++++------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 0b4fc61d4..37c27a2ea 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -50,50 +50,12 @@ module ColumnsConcern columns.flatten.compact end - def dossier_columns - dossier_columns = [dossier_id_column, notifications_column] - dossier_columns.concat([dossier_state_column]) - dossier_columns.concat([dossier_archived_column]) - dossier_columns.concat(dossier_dates_columns) - dossier_columns.concat([dossier_motivation_column]) - dossier_columns.concat(sva_svr_columns(for_export: false)) if sva_svr_enabled? - dossier_columns.concat(dossier_non_displayable_dates_columns) - dossier_columns.flatten.compact - end - - def groupe_instructeurs_id_column = Column.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum) - - def followers_instructeurs_email_column = Column.new(procedure_id: id, table: 'followers_instructeurs', column: 'email') - - def dossier_archived_column = Column.new(procedure_id: id, table: 'self', column: 'archived', type: :text, displayable: false, filterable: false); - - def dossier_motivation_column = Column.new(procedure_id: id, table: 'self', column: 'motivation', type: :text, displayable: false, filterable: false); - def dossier_id_column = Column.new(procedure_id: id, table: 'self', column: 'id', type: :number) def dossier_state_column = Column.new(procedure_id: id, table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false) def notifications_column = Column.new(procedure_id: id, table: 'notifications', column: 'notifications', label: "notifications", filterable: false) - def user_email_for_display_column = Column.new(procedure_id: id, table: 'self', column: 'user_email_for_display', filterable: false, displayable: false) - - def user_france_connected_column = Column.new(procedure_id: id, table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false) - - def procedure_chorus_columns - ['domaine_fonctionnel', 'referentiel_prog', 'centre_de_cout'] - .map { |column| Column.new(procedure_id: id, table: 'procedure', column:, displayable: false, filterable: false) } - end - - def dossier_non_displayable_dates_columns - ['updated_since', 'depose_since', 'en_construction_since', 'en_instruction_since', 'processed_since'] - .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date, displayable: false) } - end - - def dossier_dates_columns - ['created_at', 'updated_at', 'last_champ_updated_at', 'depose_at', 'en_construction_at', 'en_instruction_at', 'processed_at'] - .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date) } - end - def sva_svr_columns(for_export: false) scope = [:activerecord, :attributes, :procedure_presentation, :fields, :self] @@ -116,10 +78,48 @@ module ColumnsConcern private + def groupe_instructeurs_id_column = Column.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum) + + def followers_instructeurs_email_column = Column.new(procedure_id: id, table: 'followers_instructeurs', column: 'email') + + def dossier_archived_column = Column.new(procedure_id: id, table: 'self', column: 'archived', type: :text, displayable: false, filterable: false); + + def dossier_motivation_column = Column.new(procedure_id: id, table: 'self', column: 'motivation', type: :text, displayable: false, filterable: false); + + def user_email_for_display_column = Column.new(procedure_id: id, table: 'self', column: 'user_email_for_display', filterable: false, displayable: false) + + def user_france_connected_column = Column.new(procedure_id: id, table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false) + + def procedure_chorus_columns + ['domaine_fonctionnel', 'referentiel_prog', 'centre_de_cout'] + .map { |column| Column.new(procedure_id: id, table: 'procedure', column:, displayable: false, filterable: false) } + end + + def dossier_non_displayable_dates_columns + ['updated_since', 'depose_since', 'en_construction_since', 'en_instruction_since', 'processed_since'] + .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date, displayable: false) } + end + + def dossier_dates_columns + ['created_at', 'updated_at', 'last_champ_updated_at', 'depose_at', 'en_construction_at', 'en_instruction_at', 'processed_at'] + .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date) } + end + def email_column Column.new(procedure_id: id, table: 'user', column: 'email') end + def dossier_columns + columns = [dossier_id_column, notifications_column] + columns.concat([dossier_state_column]) + columns.concat([dossier_archived_column]) + columns.concat(dossier_dates_columns) + columns.concat([dossier_motivation_column]) + columns.concat(sva_svr_columns(for_export: false)) if sva_svr_enabled? + columns.concat(dossier_non_displayable_dates_columns) + columns.flatten.compact + end + def standard_columns [ email_column, From 92a863d48c0c8139b22a6a681c9887cbd57683ee Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 30 Oct 2024 16:07:16 +0100 Subject: [PATCH 1338/1532] remove_all as there is no `some_usager_columns` --- app/models/concerns/columns_concern.rb | 4 ++-- spec/models/concerns/columns_concern_spec.rb | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 37c27a2ea..b827c3655 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -30,7 +30,7 @@ module ColumnsConcern end end - def all_usager_columns_for_export + def usager_columns_for_export columns = [dossier_id_column, user_email_for_display_column, user_france_connected_column] columns.concat(individual_columns) if for_individual columns.concat(moral_columns) if !for_individual @@ -39,7 +39,7 @@ module ColumnsConcern columns.flatten.compact end - def all_dossier_columns_for_export + def dossier_columns_for_export columns = [dossier_state_column] columns.concat([dossier_archived_column]) columns.concat(dossier_dates_columns) diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index d1386be36..44ca9f1d3 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -146,7 +146,7 @@ describe ColumnsConcern do ] end - describe '#all_usager_columns_for_export' do + describe '#usager_columns_for_export' do context 'for individual procedure' do let(:for_individual) { true } @@ -162,7 +162,7 @@ describe ColumnsConcern do procedure.find_column(label: "Nom du mandataire"), procedure.find_column(label: "Prénom du mandataire") ] - actuals = procedure.all_usager_columns_for_export.map(&:h_id) + actuals = procedure.usager_columns_for_export.map(&:h_id) expected.each do |expected_col| expect(actuals).to include(expected_col.h_id) end @@ -199,7 +199,7 @@ describe ColumnsConcern do procedure.find_column(label: "Entreprise SIRET siège social"), procedure.find_column(label: "Entreprise code effectif entreprise") ] - actuals = procedure.all_usager_columns_for_export + actuals = procedure.usager_columns_for_export expected.each do |expected_col| expect(actuals.map(&:h_id)).to include(expected_col.h_id) end @@ -217,7 +217,7 @@ describe ColumnsConcern do procedure.find_column(label: "Référentiel De Programmation"), procedure.find_column(label: "Centre De Coût") ] - actuals = procedure.all_usager_columns_for_export.map(&:h_id) + actuals = procedure.usager_columns_for_export.map(&:h_id) expected.each do |expected_col| expect(actuals).to include(expected_col.h_id) end @@ -225,7 +225,7 @@ describe ColumnsConcern do end end - describe '#all_dossier_columns_for_export' do + describe '#dossier_columns_for_export' do let(:procedure) { create(:procedure_with_dossiers, :routee, :published, types_de_champ_public:, for_individual:) } it "returns all dossier columns" do @@ -241,7 +241,7 @@ describe ColumnsConcern do procedure.find_column(label: "Instructeurs"), procedure.find_column(label: "Groupe instructeur") ] - actuals = procedure.all_dossier_columns_for_export.map(&:h_id) + actuals = procedure.dossier_columns_for_export.map(&:h_id) expected.each do |expected_col| expect(actuals).to include(expected_col.h_id) end From d618f7cc0f3afa6efcf5394a7b8a97e7609db113 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 30 Oct 2024 16:09:40 +0100 Subject: [PATCH 1339/1532] only expose chorus col when necessary --- app/models/concerns/columns_concern.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index b827c3655..7590cf80e 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -25,7 +25,7 @@ module ColumnsConcern columns.concat(standard_columns) columns.concat(individual_columns) if for_individual columns.concat(moral_columns) if !for_individual - columns.concat(procedure_chorus_columns) + columns.concat(procedure_chorus_columns) if chorusable? && chorus_configuration.complete? columns.concat(types_de_champ_columns) end end From 96cd4fda72fafe0130a3ea5ebafb066a33a2c7c2 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 30 Oct 2024 16:38:43 +0100 Subject: [PATCH 1340/1532] ensure exported columns existed in main columns function --- app/models/concerns/columns_concern.rb | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 7590cf80e..d360d3d1d 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -36,18 +36,19 @@ module ColumnsConcern columns.concat(moral_columns) if !for_individual columns.concat(procedure_chorus_columns) if chorusable? && chorus_configuration.complete? - columns.flatten.compact + # ensure the columns exist in main list + columns.filter { _1.id.in?(self.columns.map(&:id)) } end def dossier_columns_for_export - columns = [dossier_state_column] - columns.concat([dossier_archived_column]) + columns = [dossier_state_column, dossier_archived_column] columns.concat(dossier_dates_columns) columns.concat([dossier_motivation_column]) columns.concat(sva_svr_columns(for_export: true)) if sva_svr_enabled? - columns.concat([groupe_instructeurs_id_column]) - columns.concat([followers_instructeurs_email_column]) - columns.flatten.compact + columns.concat([groupe_instructeurs_id_column, followers_instructeurs_email_column]) + + # ensure the columns exist in main list + columns.filter { _1.id.in?(self.columns.map(&:id)) } end def dossier_id_column = Column.new(procedure_id: id, table: 'self', column: 'id', type: :number) From 5981de90a2266daddf6a358a8435b8e19e8aa6de Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 30 Oct 2024 18:30:38 +0100 Subject: [PATCH 1341/1532] use Columns::DossierColumn --- app/models/concerns/columns_concern.rb | 44 +++++++++++++------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index d360d3d1d..da9c312a1 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -51,21 +51,21 @@ module ColumnsConcern columns.filter { _1.id.in?(self.columns.map(&:id)) } end - def dossier_id_column = Column.new(procedure_id: id, table: 'self', column: 'id', type: :number) + def dossier_id_column = Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'id', type: :number) - def dossier_state_column = Column.new(procedure_id: id, table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false) + def dossier_state_column = Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false) - def notifications_column = Column.new(procedure_id: id, table: 'notifications', column: 'notifications', label: "notifications", filterable: false) + def notifications_column = Columns::DossierColumn.new(procedure_id: id, table: 'notifications', column: 'notifications', label: "notifications", filterable: false) def sva_svr_columns(for_export: false) scope = [:activerecord, :attributes, :procedure_presentation, :fields, :self] columns = [ - Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_on', type: :date, + Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_on', type: :date, label: I18n.t("#{sva_svr_decision}_decision_on", scope:, type: sva_svr_configuration.human_decision)) ] if !for_export - columns << Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false, + columns << Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false, label: I18n.t("#{sva_svr_decision}_decision_before", scope:)) end columns @@ -79,35 +79,35 @@ module ColumnsConcern private - def groupe_instructeurs_id_column = Column.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum) + def groupe_instructeurs_id_column = Columns::DossierColumn.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum) - def followers_instructeurs_email_column = Column.new(procedure_id: id, table: 'followers_instructeurs', column: 'email') + def followers_instructeurs_email_column = Columns::DossierColumn.new(procedure_id: id, table: 'followers_instructeurs', column: 'email') - def dossier_archived_column = Column.new(procedure_id: id, table: 'self', column: 'archived', type: :text, displayable: false, filterable: false); + def dossier_archived_column = Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'archived', type: :text, displayable: false, filterable: false); - def dossier_motivation_column = Column.new(procedure_id: id, table: 'self', column: 'motivation', type: :text, displayable: false, filterable: false); + def dossier_motivation_column = Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'motivation', type: :text, displayable: false, filterable: false); - def user_email_for_display_column = Column.new(procedure_id: id, table: 'self', column: 'user_email_for_display', filterable: false, displayable: false) + def user_email_for_display_column = Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'user_email_for_display', filterable: false, displayable: false) - def user_france_connected_column = Column.new(procedure_id: id, table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false) + def user_france_connected_column = Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false) def procedure_chorus_columns ['domaine_fonctionnel', 'referentiel_prog', 'centre_de_cout'] - .map { |column| Column.new(procedure_id: id, table: 'procedure', column:, displayable: false, filterable: false) } + .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'procedure', column:, displayable: false, filterable: false) } end def dossier_non_displayable_dates_columns ['updated_since', 'depose_since', 'en_construction_since', 'en_instruction_since', 'processed_since'] - .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date, displayable: false) } + .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'self', column:, type: :date, displayable: false) } end def dossier_dates_columns ['created_at', 'updated_at', 'last_champ_updated_at', 'depose_at', 'en_construction_at', 'en_instruction_at', 'processed_at'] - .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date) } + .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'self', column:, type: :date) } end def email_column - Column.new(procedure_id: id, table: 'user', column: 'email') + Columns::DossierColumn.new(procedure_id: id, table: 'user', column: 'email') end def dossier_columns @@ -127,26 +127,26 @@ module ColumnsConcern user_email_for_display_column, followers_instructeurs_email_column, groupe_instructeurs_id_column, - Column.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false), + Columns::DossierColumn.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false), user_france_connected_column ] end def individual_columns - ['gender', 'nom', 'prenom'].map { |column| Column.new(procedure_id: id, table: 'individual', column:) } - .concat ['for_tiers', 'mandataire_last_name', 'mandataire_first_name'].map { |column| Column.new(procedure_id: id, table: 'self', column:) } + ['gender', 'nom', 'prenom'].map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'individual', column:) } + .concat ['for_tiers', 'mandataire_last_name', 'mandataire_first_name'].map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'self', column:) } end def moral_columns etablissements = ['entreprise_forme_juridique', 'entreprise_siren', 'entreprise_nom_commercial', 'entreprise_raison_sociale', 'entreprise_siret_siege_social'] - .map { |column| Column.new(procedure_id: id, table: 'etablissement', column:) } + .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'etablissement', column:) } - etablissement_dates = ['entreprise_date_creation'].map { |column| Column.new(procedure_id: id, table: 'etablissement', column:, type: :date) } + etablissement_dates = ['entreprise_date_creation'].map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'etablissement', column:, type: :date) } for_export = ["siege_social", "naf", "adresse", "numero_voie", "type_voie", "nom_voie", "complement_adresse", "localite", "code_insee_localite", "entreprise_siren", "entreprise_capital_social", "entreprise_numero_tva_intracommunautaire", "entreprise_forme_juridique_code", "entreprise_code_effectif_entreprise", "entreprise_etat_administratif", "entreprise_nom", "entreprise_prenom", "association_rna", "association_titre", "association_objet", "association_date_creation", "association_date_declaration", "association_date_publication"] - .map { |column| Column.new(procedure_id: id, table: 'etablissement', column:, displayable: false, filterable: false) } + .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'etablissement', column:, displayable: false, filterable: false) } - other = ['siret', 'libelle_naf', 'code_postal'].map { |column| Column.new(procedure_id: id, table: 'etablissement', column:) } + other = ['siret', 'libelle_naf', 'code_postal'].map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'etablissement', column:) } [etablissements, etablissement_dates, other, for_export].flatten end From ab9a0fc34fd9d1744c4b7428fd55b1b53c268dc5 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 30 Oct 2024 18:34:11 +0100 Subject: [PATCH 1342/1532] remove extraneous flatten compact --- app/models/concerns/columns_concern.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index da9c312a1..1a9ecab1a 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -118,7 +118,6 @@ module ColumnsConcern columns.concat([dossier_motivation_column]) columns.concat(sva_svr_columns(for_export: false)) if sva_svr_enabled? columns.concat(dossier_non_displayable_dates_columns) - columns.flatten.compact end def standard_columns From d8e63221b5fdb47d15629bfc452568768452d0f3 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 25 Oct 2024 10:27:46 +0200 Subject: [PATCH 1343/1532] extract a procedure_presentation controller --- .../procedure_presentation_controller.rb | 44 ++++++++++ config/routes.rb | 2 + .../procedure_presentation_controller_spec.rb | 83 +++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 app/controllers/instructeurs/procedure_presentation_controller.rb create mode 100644 spec/controllers/instructeurs/procedure_presentation_controller_spec.rb diff --git a/app/controllers/instructeurs/procedure_presentation_controller.rb b/app/controllers/instructeurs/procedure_presentation_controller.rb new file mode 100644 index 000000000..56c9e4af1 --- /dev/null +++ b/app/controllers/instructeurs/procedure_presentation_controller.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Instructeurs + class ProcedurePresentationController < InstructeurController + before_action :set_procedure_presentation + + def update + if !@procedure_presentation.update(procedure_presentation_params) + # complicated way to display inner error messages + flash.alert = @procedure_presentation.errors + .flat_map { _1.detail[:value].flat_map { |c| c.errors.full_messages } } + end + + redirect_back_or_to([:instructeur, procedure]) + end + + private + + def procedure = @procedure_presentation.procedure + + def procedure_presentation_params + filters = [ + :tous_filters, :a_suivre_filters, :suivis_filters, :traites_filters, + :expirant_filters, :archives_filters, :supprimes_filters + ].index_with { [:id, :filter] } + + h = params.permit(displayed_columns: [], sorted_column: [:order, :id], **filters).to_h + + # React ComboBox/MultiComboBox return [''] when no value is selected + # We need to remove them + if h[:displayed_columns].present? + h[:displayed_columns] = h[:displayed_columns].reject(&:empty?) + end + + h + end + + def set_procedure_presentation + @procedure_presentation = ProcedurePresentation + .includes(:assign_to) + .find_by!(id: params[:id], assign_to: { instructeur: current_instructeur }) + end + end +end diff --git a/config/routes.rb b/config/routes.rb index fc64cd041..2ab0614e4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -461,6 +461,8 @@ Rails.application.routes.draw do end end + resources :procedure_presentation, only: [:update] + resources :procedures, only: [:index, :show], param: :procedure_id do member do resources :archives, only: [:index, :create] diff --git a/spec/controllers/instructeurs/procedure_presentation_controller_spec.rb b/spec/controllers/instructeurs/procedure_presentation_controller_spec.rb new file mode 100644 index 000000000..df0548301 --- /dev/null +++ b/spec/controllers/instructeurs/procedure_presentation_controller_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +describe Instructeurs::ProcedurePresentationController, type: :controller do + describe '#update' do + subject { patch :update, params: } + + let(:procedure) { create(:procedure) } + let(:instructeur) { create(:instructeur) } + let(:procedure_presentation) do + groupe_instructeur = procedure.defaut_groupe_instructeur + assign_to = create(:assign_to, instructeur:, groupe_instructeur:) + assign_to.procedure_presentation_or_default_and_errors.first + end + let(:state_column) { procedure.dossier_state_column } + + let(:params) { { id: procedure_presentation.id }.merge(presentation_params) } + + context 'nominal case' do + before { sign_in(instructeur.user) } + + let(:presentation_params) do + { + displayed_columns: [state_column.id], + sorted_column: { order: 'asc', id: state_column.id }, + tous_filters: [{ id: state_column.id, filter: 'en_construction' }] + } + end + + it 'updates the procedure_presentation' do + expect(procedure_presentation.displayed_columns).to eq(procedure.default_displayed_columns) + expect(procedure_presentation.sorted_column).to eq(procedure.default_sorted_column) + expect(procedure_presentation.tous_filters).to eq([]) + + subject + expect(response).to redirect_to(instructeur_procedure_url(procedure)) + + procedure_presentation.reload + + expect(procedure_presentation.displayed_columns).to eq([state_column]) + + expect(procedure_presentation.sorted_column.column).to eq(state_column) + expect(procedure_presentation.sorted_column.order).to eq('asc') + + filtered_column = FilteredColumn.new(column: state_column, filter: 'en_construction') + expect(procedure_presentation.tous_filters).to eq([filtered_column]) + end + end + + context 'with a wrong instructeur' do + let(:another_instructeur) { create(:instructeur) } + before { sign_in(another_instructeur.user) } + + let(:presentation_params) { { displayed_columns: [state_column.id] } } + + it 'does not update the procedure_presentation' do + expect { subject }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'with an empty string in displayed_columns' do + before { sign_in(instructeur.user) } + + let(:presentation_params) { { displayed_columns: [''] } } + + it 'removes the empty string' do + subject + expect(procedure_presentation.reload.displayed_columns).to eq([]) + end + end + + context 'with an error in filters' do + before { sign_in(instructeur.user) } + + let(:presentation_params) { { tous_filters: [{ id: state_column.id, filter: '' }] } } + + it 'does not update the procedure_presentation' do + subject + + expect(flash.alert).to include(/ne peut pas être vide/) + end + end + end +end From 7ed0b913515e6b3e1f8c3e6f0cbfa748a6aec997 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 25 Oct 2024 10:34:58 +0200 Subject: [PATCH 1344/1532] plug column_filter, column_picker, column_header in new controller --- .../column_filter_component.html.haml | 2 +- .../column_picker_component.html.haml | 4 ++-- .../instructeurs/column_table_header_component.rb | 11 ++++++++--- .../column_table_header_component.html.haml | 2 +- spec/system/instructeurs/procedure_filters_spec.rb | 12 ++++++------ spec/system/instructeurs/procedure_sort_spec.rb | 12 +++++++----- 6 files changed, 25 insertions(+), 18 deletions(-) diff --git a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml index ec9c7eee6..eaf9e8902 100644 --- a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml +++ b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml @@ -1,4 +1,4 @@ -= form_tag add_filter_instructeur_procedure_path(procedure), method: :post, class: 'dropdown-form large', id: 'filter-component', data: { turbo: true, controller: 'autosubmit' } do += form_with model: [:instructeur, @procedure_presentation], class: 'dropdown-form large', id: 'filter-component', data: { turbo: true, controller: 'autosubmit' } do = current_filter_tags .fr-select-group diff --git a/app/components/instructeurs/column_picker_component/column_picker_component.html.haml b/app/components/instructeurs/column_picker_component/column_picker_component.html.haml index a5953d0be..717ac143e 100644 --- a/app/components/instructeurs/column_picker_component/column_picker_component.html.haml +++ b/app/components/instructeurs/column_picker_component/column_picker_component.html.haml @@ -1,5 +1,5 @@ -= form_tag update_displayed_fields_instructeur_procedure_path(@procedure), method: :patch, class: 'dropdown-form large columns-form' do += form_with model: [:instructeur, @procedure_presentation], class: 'dropdown-form large columns-form' do %react-fragment - = render ReactComponent.new "ComboBox/MultiComboBox", items: @displayable_columns_for_select, selected_keys: @displayable_columns_selected, name: 'values[]', 'aria-label': 'Colonne à afficher', value_separator: false + = render ReactComponent.new "ComboBox/MultiComboBox", items: @displayable_columns_for_select, selected_keys: @displayable_columns_selected, name: 'displayed_columns[]', 'aria-label': 'Colonne à afficher', value_separator: false = submit_tag t('.save'), class: 'fr-btn fr-btn--secondary' diff --git a/app/components/instructeurs/column_table_header_component.rb b/app/components/instructeurs/column_table_header_component.rb index 004ef3138..fa4ff6f90 100644 --- a/app/components/instructeurs/column_table_header_component.rb +++ b/app/components/instructeurs/column_table_header_component.rb @@ -2,7 +2,7 @@ class Instructeurs::ColumnTableHeaderComponent < ApplicationComponent def initialize(procedure_presentation:) - @procedure = procedure_presentation.procedure + @procedure_presentation = procedure_presentation @columns = procedure_presentation.displayed_fields_for_headers @sorted_column = procedure_presentation.sorted_column end @@ -15,11 +15,16 @@ class Instructeurs::ColumnTableHeaderComponent < ApplicationComponent return 'sva-col' if column.column == 'sva_svr_decision_on' end - def update_sort_path(column) + def column_header(column) id = column.id order = opposite_order_for(column) - update_sort_instructeur_procedure_path(@procedure, sorted_column: { id:, order: }) + button_to( + label_and_arrow(column), + [:instructeur, @procedure_presentation], + params: { sorted_column: { id: id, order: order } }, + class: 'fr-text--bold' + ) end def opposite_order_for(column) diff --git a/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml b/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml index 1e2857ec6..0a15f2350 100644 --- a/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml +++ b/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml @@ -1,3 +1,3 @@ - @columns.each do |column| %th{ aria_sort(column), scope: "col", class: classname(column) } - = link_to label_and_arrow(column), update_sort_path(column) + = column_header(column) diff --git a/spec/system/instructeurs/procedure_filters_spec.rb b/spec/system/instructeurs/procedure_filters_spec.rb index 65440b22d..c105775f4 100644 --- a/spec/system/instructeurs/procedure_filters_spec.rb +++ b/spec/system/instructeurs/procedure_filters_spec.rb @@ -19,7 +19,7 @@ describe "procedure filters" do scenario "should display demandeur by default" do within ".dossiers-table" do - expect(page).to have_link("Demandeur") + expect(page).to have_button("Demandeur") expect(page).to have_link(new_unfollow_dossier.user.email) end end @@ -28,7 +28,7 @@ describe "procedure filters" do procedure.update!(sva_svr: SVASVRConfiguration.new(decision: :sva).attributes) visit instructeur_procedure_path(procedure) within ".dossiers-table" do - expect(page).to have_link("Date décision SVA") + expect(page).to have_button("Date décision SVA") expect(page).to have_link(new_unfollow_dossier.user.email) end end @@ -46,7 +46,7 @@ describe "procedure filters" do scenario "should add be able to add created_at column", js: true do add_column("Date de création") within ".dossiers-table" do - expect(page).to have_link("Date de création") + expect(page).to have_button("Date de création") expect(page).to have_link(new_unfollow_dossier.created_at.strftime('%d/%m/%Y')) end end @@ -54,20 +54,20 @@ describe "procedure filters" do scenario "should add be able to add and remove custom type_de_champ column", js: true do add_column(type_de_champ.libelle) within ".dossiers-table" do - expect(page).to have_link(type_de_champ.libelle) + expect(page).to have_button(type_de_champ.libelle) expect(page).to have_link(champ.value) end remove_column(type_de_champ.libelle) within ".dossiers-table" do - expect(page).not_to have_link(type_de_champ.libelle) + expect(page).not_to have_button(type_de_champ.libelle) expect(page).not_to have_link(champ.value) end # Test removal of all customizable fields remove_column("Demandeur") within ".dossiers-table" do - expect(page).not_to have_link("Demandeur") + expect(page).not_to have_button("Demandeur") end end diff --git a/spec/system/instructeurs/procedure_sort_spec.rb b/spec/system/instructeurs/procedure_sort_spec.rb index 166b42105..beb59e8a0 100644 --- a/spec/system/instructeurs/procedure_sort_spec.rb +++ b/spec/system/instructeurs/procedure_sort_spec.rb @@ -22,12 +22,12 @@ describe "procedure sort", js: true do expect(find(".dossiers-table tbody tr:nth-child(2) .number-col a").text).to eq(followed_dossier.id.to_s) expect(find(".dossiers-table tbody tr:nth-child(3) .number-col a").text).to eq(followed_dossier_2.id.to_s) - find("thead .number-col a").click # sort by id asc + click_on "Nº dossier" # sort by id asc expect(find(".dossiers-table tbody tr:nth-child(2) .number-col a").text).to eq(followed_dossier.id.to_s) expect(find(".dossiers-table tbody tr:nth-child(3) .number-col a").text).to eq(followed_dossier_2.id.to_s) - find("thead .number-col a").click # reverse order - sort by id desc + click_on "Nº dossier" # reverse order - sort by id desc expect(find(".dossiers-table tbody tr:nth-child(2) .number-col a").text).to eq(followed_dossier_2.id.to_s) expect(find(".dossiers-table tbody tr:nth-child(3) .number-col a").text).to eq(followed_dossier.id.to_s) @@ -44,12 +44,14 @@ describe "procedure sort", js: true do expect(find(".dossiers-table tbody tr:nth-child(2) .number-col a").text).to eq(followed_dossier.id.to_s) expect(find(".dossiers-table tbody tr:nth-child(3) .number-col a").text).to eq(followed_dossier_2.id.to_s) - find("thead .sva-col a").click # sort by sva date asc + click_on "Date décision SVA", exact: true # sort by sva date asc + # find("thead .sva-col a").click # sort by sva date asc expect(find(".dossiers-table tbody tr:nth-child(2) .number-col a").text).to eq(followed_dossier.id.to_s) expect(find(".dossiers-table tbody tr:nth-child(3) .number-col a").text).to eq(followed_dossier_2.id.to_s) - find("thead .sva-col a").click # reverse order - sort by sva date desc + click_on "Date décision SVA ↑", exact: true # reverse order - sort by sva date desc + # find("thead .sva-col a").click # reverse order - sort by sva date desc expect(find(".dossiers-table tbody tr:nth-child(2) .number-col a").text).to eq(followed_dossier_2.id.to_s) expect(find(".dossiers-table tbody tr:nth-child(3) .number-col a").text).to eq(followed_dossier.id.to_s) @@ -74,7 +76,7 @@ describe "procedure sort", js: true do end scenario "should be able to sort back by notification filter after any other sort" do - find("thead .number-col a").click # sort by id asc + click_on "Nº dossier" # sort by id asc expect(page).not_to have_checked_field("Remonter les dossiers avec une notification") From d421d41e16efb61fe8f6cbafc461a8abe7fd83b9 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 25 Oct 2024 13:51:12 +0200 Subject: [PATCH 1345/1532] plug refresh_column_filter to dedicated controller --- .../column_filter_component.html.haml | 3 ++- .../procedure_presentation_controller.rb | 13 +++++++++++++ config/routes.rb | 6 +++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml index eaf9e8902..f30f700e0 100644 --- a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml +++ b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml @@ -8,7 +8,8 @@ %input.hidden{ type: 'submit', - formaction: update_filter_instructeur_procedure_path(procedure), + formmethod: 'get', + formaction: url_for([:refresh_column_filter, :instructeur, @procedure_presentation]), formnovalidate: 'true', data: { autosubmit_target: 'submitter' } } diff --git a/app/controllers/instructeurs/procedure_presentation_controller.rb b/app/controllers/instructeurs/procedure_presentation_controller.rb index 56c9e4af1..1ce51e9e8 100644 --- a/app/controllers/instructeurs/procedure_presentation_controller.rb +++ b/app/controllers/instructeurs/procedure_presentation_controller.rb @@ -14,6 +14,19 @@ module Instructeurs redirect_back_or_to([:instructeur, procedure]) end + def refresh_column_filter + procedure_presentation = @procedure_presentation + statut = params[:statut] + current_filter = procedure_presentation.filters_name_for(statut) + # According to the html, the selected column is the last one + h_id = JSON.parse(params[current_filter].last[:id], symbolize_names: true) + column = procedure.find_column(h_id:) + + filter_component = Instructeurs::ColumnFilterComponent.new(procedure:, procedure_presentation:, statut:, column:) + + render turbo_stream: turbo_stream.replace('filter-component', filter_component) + end + private def procedure = @procedure_presentation.procedure diff --git a/config/routes.rb b/config/routes.rb index 2ab0614e4..b44d09de6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -461,7 +461,11 @@ Rails.application.routes.draw do end end - resources :procedure_presentation, only: [:update] + resources :procedure_presentation, only: [:update] do + member do + get 'refresh_column_filter' + end + end resources :procedures, only: [:index, :show], param: :procedure_id do member do From 13356f26c353a7b823f2d27b298026b818518bf0 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 25 Oct 2024 13:51:19 +0200 Subject: [PATCH 1346/1532] remove procedure from component signature --- .../instructeurs/column_filter_component.rb | 4 ++-- .../column_filter_component.html.haml | 5 ++++- .../procedures/_dossiers_filter_dropdown.html.haml | 2 +- .../procedures/update_filter.turbo_stream.haml | 2 +- .../instructeurs/column_filter_component_spec.rb | 13 +++++++++---- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/app/components/instructeurs/column_filter_component.rb b/app/components/instructeurs/column_filter_component.rb index 5016b4bc5..ee354340a 100644 --- a/app/components/instructeurs/column_filter_component.rb +++ b/app/components/instructeurs/column_filter_component.rb @@ -3,9 +3,9 @@ class Instructeurs::ColumnFilterComponent < ApplicationComponent attr_reader :procedure, :procedure_presentation, :statut, :column - def initialize(procedure:, procedure_presentation:, statut:, column: nil) - @procedure = procedure + def initialize(procedure_presentation:, statut:, column: nil) @procedure_presentation = procedure_presentation + @procedure = procedure_presentation.procedure @statut = statut @column = column end diff --git a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml index f30f700e0..d1077f04b 100644 --- a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml +++ b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml @@ -1,4 +1,7 @@ -= form_with model: [:instructeur, @procedure_presentation], class: 'dropdown-form large', id: 'filter-component', data: { turbo: true, controller: 'autosubmit' } do += form_with model: [:instructeur, @procedure_presentation], + class: 'dropdown-form large', + id: 'filter-component', + data: { turbo: true, controller: 'autosubmit' } do = current_filter_tags .fr-select-group diff --git a/app/views/instructeurs/procedures/_dossiers_filter_dropdown.html.haml b/app/views/instructeurs/procedures/_dossiers_filter_dropdown.html.haml index 2fe1a3065..56c08315b 100644 --- a/app/views/instructeurs/procedures/_dossiers_filter_dropdown.html.haml +++ b/app/views/instructeurs/procedures/_dossiers_filter_dropdown.html.haml @@ -3,4 +3,4 @@ = t('views.instructeurs.dossiers.filters.title') - menu.with_form do - = render Instructeurs::ColumnFilterComponent.new(procedure:, procedure_presentation:, statut:) + = render Instructeurs::ColumnFilterComponent.new(procedure_presentation:, statut:) diff --git a/app/views/instructeurs/procedures/update_filter.turbo_stream.haml b/app/views/instructeurs/procedures/update_filter.turbo_stream.haml index 790b268bf..efc3ed43b 100644 --- a/app/views/instructeurs/procedures/update_filter.turbo_stream.haml +++ b/app/views/instructeurs/procedures/update_filter.turbo_stream.haml @@ -1,2 +1,2 @@ = turbo_stream.replace 'filter-component' do - = render Instructeurs::ColumnFilterComponent.new(procedure: @procedure, procedure_presentation: @procedure_presentation, statut: @statut, column: @column) + = render Instructeurs::ColumnFilterComponent.new(procedure_presentation: @procedure_presentation, statut: @statut, column: @column) diff --git a/spec/components/instructeurs/column_filter_component_spec.rb b/spec/components/instructeurs/column_filter_component_spec.rb index b1dc597ae..02cf8a6d2 100644 --- a/spec/components/instructeurs/column_filter_component_spec.rb +++ b/spec/components/instructeurs/column_filter_component_spec.rb @@ -1,12 +1,17 @@ # frozen_string_literal: true describe Instructeurs::ColumnFilterComponent, type: :component do - let(:component) { described_class.new(procedure:, procedure_presentation:, statut:, column:) } + let(:component) { described_class.new(procedure_presentation:, statut:, column:) } let(:instructeur) { create(:instructeur) } - let(:procedure) { create(:procedure, instructeurs: [instructeur]) } + let(:procedure) { create(:procedure) } let(:procedure_id) { procedure.id } - let(:procedure_presentation) { nil } + let(:procedure_presentation) do + groupe_instructeur = procedure.defaut_groupe_instructeur + assign_to = create(:assign_to, instructeur:, groupe_instructeur:) + assign_to.procedure_presentation_or_default_and_errors.first + end + let(:statut) { nil } let(:column) { nil } @@ -19,7 +24,7 @@ describe Instructeurs::ColumnFilterComponent, type: :component do let(:non_filterable_column) { Column.new(procedure_id:, label: 'depose_since', table: 'self', column: 'depose_since', filterable: false) } let(:mocked_columns) { [filterable_column, non_filterable_column] } - before { allow(procedure).to receive(:columns).and_return(mocked_columns) } + before { allow_any_instance_of(Procedure).to receive(:columns).and_return(mocked_columns) } subject { component.filterable_columns_options } From c8332b5e22b58a112a053a1115b02bf37f1aca8d Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 28 Oct 2024 10:09:39 +0100 Subject: [PATCH 1347/1532] column: add more identifier --- app/models/column.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/column.rb b/app/models/column.rb index 65497078a..94f9295f8 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -38,8 +38,9 @@ class Column end def notifications? = [table, column] == ['notifications', 'notifications'] - def dossier_state? = [table, column] == ['self', 'state'] + def groupe_instructeur? = [table, column] == ['groupe_instructeur', 'id'] + def type_de_champ? = table == TYPE_DE_CHAMP_TABLE def self.find(h_id) begin From b4ed393a55eb1047ac3d62d4302ea0ae2804e16b Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 25 Oct 2024 17:30:20 +0200 Subject: [PATCH 1348/1532] extract column_filter_value --- .../instructeurs/column_filter_component.rb | 38 ---------- .../column_filter_component.html.haml | 18 +---- .../column_filter_value_component.rb | 72 +++++++++++++++++++ .../procedure_presentation_controller.rb | 16 ++--- .../column_filter_component_spec.rb | 23 ------ .../column_filter_value_component_spec.rb | 35 +++++++++ 6 files changed, 117 insertions(+), 85 deletions(-) create mode 100644 app/components/instructeurs/column_filter_value_component.rb create mode 100644 spec/components/instructeurs/column_filter_value_component_spec.rb diff --git a/app/components/instructeurs/column_filter_component.rb b/app/components/instructeurs/column_filter_component.rb index ee354340a..93d4364b4 100644 --- a/app/components/instructeurs/column_filter_component.rb +++ b/app/components/instructeurs/column_filter_component.rb @@ -10,33 +10,6 @@ class Instructeurs::ColumnFilterComponent < ApplicationComponent @column = column end - def column_type = column.present? ? column.type : :text - - def html_column_type - case column_type - when :datetime, :date - 'date' - when :integer, :decimal - 'number' - else - 'text' - end - end - - def options_for_select_of_column - if column.scope.present? - I18n.t(column.scope).map(&:to_a).map(&:reverse) - elsif column.table == 'groupe_instructeur' - current_instructeur.groupe_instructeurs.filter_map do - if _1.procedure_id == procedure.id - [_1.label, _1.id] - end - end - else - find_type_de_champ(column.column).options_for_select(column) - end - end - def filter_react_props { selected_key: column.present? ? column.id : '', @@ -63,15 +36,4 @@ class Instructeurs::ColumnFilterComponent < ApplicationComponent end def prefix = "#{procedure_presentation.filters_name_for(@statut)}[]" - - private - - def find_type_de_champ(column) - stable_id = column.to_s.split('->').first - TypeDeChamp - .joins(:revision_types_de_champ) - .where(revision_types_de_champ: { revision_id: procedure.revisions }) - .order(created_at: :desc) - .find_by(stable_id:) - end end diff --git a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml index d1077f04b..ae6d34935 100644 --- a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml +++ b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml @@ -18,22 +18,8 @@ } = label_tag :value, t('.value'), for: 'value', class: 'fr-label' - - if column_type.in?([:enum, :enums, :boolean]) - = select_tag :filter, - options_for_select(options_for_select_of_column), - id: 'value', - name: "#{prefix}[filter]", - class: 'fr-select', - data: { no_autosubmit: true } - - else - %input#value.fr-input{ - type: html_column_type, - name: "#{prefix}[filter]", - maxlength: FilteredColumn::FILTERS_VALUE_MAX_LENGTH, - disabled: column.nil? ? true : false, - data: { no_autosubmit: true }, - required: true - } + = render Instructeurs::ColumnFilterValueComponent.new(column:, prefix:) = hidden_field_tag :statut, statut + = hidden_field_tag :prefix, prefix = submit_tag t('.add_filter'), class: 'fr-btn fr-btn--secondary fr-mt-2w' diff --git a/app/components/instructeurs/column_filter_value_component.rb b/app/components/instructeurs/column_filter_value_component.rb new file mode 100644 index 000000000..684964b29 --- /dev/null +++ b/app/components/instructeurs/column_filter_value_component.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class Instructeurs::ColumnFilterValueComponent < ApplicationComponent + attr_reader :column + + def initialize(column:, prefix:) + @column = column + @prefix = prefix + end + + def column_type = column.present? ? column.type : :text + + def call + if column_type.in?([:enum, :enums, :boolean]) + select_tag :filter, + options_for_select(options_for_select_of_column), + id: 'value', + name: "#{@prefix}[filter]", + class: 'fr-select', + data: { no_autosubmit: true } + else + tag.input( + class: 'fr-input', + id: 'value', + type: html_column_type, + name: "#{@prefix}[filter]", + maxlength: FilteredColumn::FILTERS_VALUE_MAX_LENGTH, + disabled: column.nil? ? true : false, + data: { no_autosubmit: true }, + required: true + ) + end + end + + private + + def type + case column_type + when :datetime, :date + 'date' + when :integer, :decimal + 'number' + else + 'text' + end + end + + def options_for_select_of_column + if column.scope.present? + I18n.t(column.scope).map(&:to_a).map(&:reverse) + elsif column.table == 'groupe_instructeur' + current_instructeur.groupe_instructeurs.filter_map do + if _1.procedure_id == procedure_id + [_1.label, _1.id] + end + end + else + find_type_de_champ(column.column).options_for_select(column) + end + end + + def find_type_de_champ(column) + stable_id = column.to_s.split('->').first + TypeDeChamp + .joins(:revision_types_de_champ) + .where(revision_types_de_champ: { revision_id: ProcedureRevision.where(procedure_id:) }) + .order(created_at: :desc) + .find_by(stable_id:) + end + + def procedure_id = @column.h_id[:procedure_id] +end diff --git a/app/controllers/instructeurs/procedure_presentation_controller.rb b/app/controllers/instructeurs/procedure_presentation_controller.rb index 1ce51e9e8..b79d678d7 100644 --- a/app/controllers/instructeurs/procedure_presentation_controller.rb +++ b/app/controllers/instructeurs/procedure_presentation_controller.rb @@ -15,16 +15,13 @@ module Instructeurs end def refresh_column_filter - procedure_presentation = @procedure_presentation - statut = params[:statut] - current_filter = procedure_presentation.filters_name_for(statut) - # According to the html, the selected column is the last one - h_id = JSON.parse(params[current_filter].last[:id], symbolize_names: true) - column = procedure.find_column(h_id:) + prefix = params[:prefix] + key = prefix.gsub('[]', '') + column = ColumnType.new.cast(params[key].last['id']) - filter_component = Instructeurs::ColumnFilterComponent.new(procedure:, procedure_presentation:, statut:, column:) + component = Instructeurs::ColumnFilterValueComponent.new(column:, prefix:) - render turbo_stream: turbo_stream.replace('filter-component', filter_component) + render turbo_stream: turbo_stream.replace('value', component) end private @@ -32,6 +29,9 @@ module Instructeurs def procedure = @procedure_presentation.procedure def procedure_presentation_params + # TODO: peut etre simplifier en transformer un parametre filter -> tous_filter, suivant le params statut + + filters = [ :tous_filters, :a_suivre_filters, :suivis_filters, :traites_filters, :expirant_filters, :archives_filters, :supprimes_filters diff --git a/spec/components/instructeurs/column_filter_component_spec.rb b/spec/components/instructeurs/column_filter_component_spec.rb index 02cf8a6d2..aa730a13c 100644 --- a/spec/components/instructeurs/column_filter_component_spec.rb +++ b/spec/components/instructeurs/column_filter_component_spec.rb @@ -30,27 +30,4 @@ describe Instructeurs::ColumnFilterComponent, type: :component do it { is_expected.to eq([[filterable_column.label, filterable_column.id]]) } end - - describe '.options_for_select_of_column' do - subject { component.options_for_select_of_column } - - context "column is groupe_instructeur" do - let(:column) { double("Column", scope: nil, table: 'groupe_instructeur') } - let!(:gi_2) { instructeur.groupe_instructeurs.create(label: 'gi2', procedure:) } - let!(:gi_3) { instructeur.groupe_instructeurs.create(label: 'gi3', procedure: create(:procedure)) } - - it { is_expected.to eq([['défaut', procedure.defaut_groupe_instructeur.id], ['gi2', gi_2.id]]) } - end - - context 'when column is dropdown' do - let(:types_de_champ_public) { [{ type: :drop_down_list, libelle: 'Votre ville', options: ['Paris', 'Lyon', 'Marseille'] }] } - let(:procedure) { create(:procedure, :published, types_de_champ_public:) } - let(:drop_down_stable_id) { procedure.active_revision.types_de_champ.first.stable_id } - let(:column) { Column.new(procedure_id:, table: 'type_de_champ', scope: nil, column: drop_down_stable_id) } - - it 'find most recent tdc' do - is_expected.to eq(['Paris', 'Lyon', 'Marseille']) - end - end - end end diff --git a/spec/components/instructeurs/column_filter_value_component_spec.rb b/spec/components/instructeurs/column_filter_value_component_spec.rb new file mode 100644 index 000000000..fd9a1f9a9 --- /dev/null +++ b/spec/components/instructeurs/column_filter_value_component_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +describe Instructeurs::ColumnFilterValueComponent, type: :component do + let(:component) { described_class.new(column:) } + let(:instructeur) { create(:instructeur) } + let(:procedure) { create(:procedure, instructeurs: [instructeur]) } + let(:procedure_id) { procedure.id } + + before do + allow(component).to receive(:current_instructeur).and_return(instructeur) + end + + describe '.options_for_select_of_column' do + subject { component.send(:options_for_select_of_column) } + + context "column is groupe_instructeur" do + let(:column) { double("Column", scope: nil, table: 'groupe_instructeur', h_id: { procedure_id: }) } + let!(:gi_2) { instructeur.groupe_instructeurs.create(label: 'gi2', procedure:) } + let!(:gi_3) { instructeur.groupe_instructeurs.create(label: 'gi3', procedure: create(:procedure)) } + + it { is_expected.to eq([['défaut', procedure.defaut_groupe_instructeur.id], ['gi2', gi_2.id]]) } + end + + context 'when column is dropdown' do + let(:types_de_champ_public) { [{ type: :drop_down_list, libelle: 'Votre ville', options: ['Paris', 'Lyon', 'Marseille'] }] } + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:drop_down_stable_id) { procedure.active_revision.types_de_champ.first.stable_id } + let(:column) { Column.new(procedure_id:, table: 'type_de_champ', scope: nil, column: drop_down_stable_id) } + + it 'find most recent tdc' do + is_expected.to eq(['Paris', 'Lyon', 'Marseille']) + end + end + end +end From a0671a13215580be969a39eb522fb55cd34e3a37 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Sat, 26 Oct 2024 21:12:39 +0200 Subject: [PATCH 1349/1532] remove prefix, use status in controller --- .../instructeurs/column_filter_component.rb | 8 +++---- .../column_filter_component.html.haml | 3 +-- .../column_filter_value_component.rb | 9 ++++---- .../procedure_presentation_controller.rb | 23 ++++++++----------- .../procedure_presentation_controller_spec.rb | 7 ++++-- 5 files changed, 22 insertions(+), 28 deletions(-) diff --git a/app/components/instructeurs/column_filter_component.rb b/app/components/instructeurs/column_filter_component.rb index 93d4364b4..f92a3c623 100644 --- a/app/components/instructeurs/column_filter_component.rb +++ b/app/components/instructeurs/column_filter_component.rb @@ -14,7 +14,7 @@ class Instructeurs::ColumnFilterComponent < ApplicationComponent { selected_key: column.present? ? column.id : '', items: filterable_columns_options, - name: "#{prefix}[id]", + name: "filters[][id]", id: 'search-filter', 'aria-describedby': 'instructeur-filter-combo-label', form: 'filter-component', @@ -29,11 +29,9 @@ class Instructeurs::ColumnFilterComponent < ApplicationComponent def current_filter_tags @procedure_presentation.filters_for(@statut).flat_map do [ - hidden_field_tag("#{prefix}[id]", _1.column.id, id: nil), - hidden_field_tag("#{prefix}[filter]", _1.filter, id: nil) + hidden_field_tag("filters[][id]", _1.column.id, id: nil), + hidden_field_tag("filters[][filter]", _1.filter, id: nil) ] end.reduce(&:concat) end - - def prefix = "#{procedure_presentation.filters_name_for(@statut)}[]" end diff --git a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml index ae6d34935..c8b94293f 100644 --- a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml +++ b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml @@ -18,8 +18,7 @@ } = label_tag :value, t('.value'), for: 'value', class: 'fr-label' - = render Instructeurs::ColumnFilterValueComponent.new(column:, prefix:) + = render Instructeurs::ColumnFilterValueComponent.new(column:) = hidden_field_tag :statut, statut - = hidden_field_tag :prefix, prefix = submit_tag t('.add_filter'), class: 'fr-btn fr-btn--secondary fr-mt-2w' diff --git a/app/components/instructeurs/column_filter_value_component.rb b/app/components/instructeurs/column_filter_value_component.rb index 684964b29..d83a239ea 100644 --- a/app/components/instructeurs/column_filter_value_component.rb +++ b/app/components/instructeurs/column_filter_value_component.rb @@ -3,9 +3,8 @@ class Instructeurs::ColumnFilterValueComponent < ApplicationComponent attr_reader :column - def initialize(column:, prefix:) + def initialize(column:) @column = column - @prefix = prefix end def column_type = column.present? ? column.type : :text @@ -15,15 +14,15 @@ class Instructeurs::ColumnFilterValueComponent < ApplicationComponent select_tag :filter, options_for_select(options_for_select_of_column), id: 'value', - name: "#{@prefix}[filter]", + name: "filters[][filter]", class: 'fr-select', data: { no_autosubmit: true } else tag.input( class: 'fr-input', id: 'value', - type: html_column_type, - name: "#{@prefix}[filter]", + type:, + name: "filters[][filter]", maxlength: FilteredColumn::FILTERS_VALUE_MAX_LENGTH, disabled: column.nil? ? true : false, data: { no_autosubmit: true }, diff --git a/app/controllers/instructeurs/procedure_presentation_controller.rb b/app/controllers/instructeurs/procedure_presentation_controller.rb index b79d678d7..952464188 100644 --- a/app/controllers/instructeurs/procedure_presentation_controller.rb +++ b/app/controllers/instructeurs/procedure_presentation_controller.rb @@ -2,7 +2,7 @@ module Instructeurs class ProcedurePresentationController < InstructeurController - before_action :set_procedure_presentation + before_action :set_procedure_presentation, only: [:update] def update if !@procedure_presentation.update(procedure_presentation_params) @@ -15,11 +15,9 @@ module Instructeurs end def refresh_column_filter - prefix = params[:prefix] - key = prefix.gsub('[]', '') - column = ColumnType.new.cast(params[key].last['id']) - - component = Instructeurs::ColumnFilterValueComponent.new(column:, prefix:) + # According to the html, the selected filters is the last one + column = ColumnType.new.cast(params['filters'].last['id']) + component = Instructeurs::ColumnFilterValueComponent.new(column:) render turbo_stream: turbo_stream.replace('value', component) end @@ -29,15 +27,12 @@ module Instructeurs def procedure = @procedure_presentation.procedure def procedure_presentation_params - # TODO: peut etre simplifier en transformer un parametre filter -> tous_filter, suivant le params statut + h = params.permit(displayed_columns: [], sorted_column: [:order, :id], filters: [:id, :filter]).to_h - - filters = [ - :tous_filters, :a_suivre_filters, :suivis_filters, :traites_filters, - :expirant_filters, :archives_filters, :supprimes_filters - ].index_with { [:id, :filter] } - - h = params.permit(displayed_columns: [], sorted_column: [:order, :id], **filters).to_h + if params[:statut].present? + filter_name = @procedure_presentation.filters_name_for(params[:statut]) + h[filter_name] = h.delete("filters") # move filters to the right key, ex: tous_filters + end # React ComboBox/MultiComboBox return [''] when no value is selected # We need to remove them diff --git a/spec/controllers/instructeurs/procedure_presentation_controller_spec.rb b/spec/controllers/instructeurs/procedure_presentation_controller_spec.rb index df0548301..ccc3d51cb 100644 --- a/spec/controllers/instructeurs/procedure_presentation_controller_spec.rb +++ b/spec/controllers/instructeurs/procedure_presentation_controller_spec.rb @@ -22,7 +22,8 @@ describe Instructeurs::ProcedurePresentationController, type: :controller do { displayed_columns: [state_column.id], sorted_column: { order: 'asc', id: state_column.id }, - tous_filters: [{ id: state_column.id, filter: 'en_construction' }] + filters: [{ id: state_column.id, filter: 'en_construction' }], + statut: 'tous' } end @@ -71,7 +72,9 @@ describe Instructeurs::ProcedurePresentationController, type: :controller do context 'with an error in filters' do before { sign_in(instructeur.user) } - let(:presentation_params) { { tous_filters: [{ id: state_column.id, filter: '' }] } } + let(:presentation_params) do + { filters: [{ id: state_column.id, filter: '' }], statut: 'tous' } + end it 'does not update the procedure_presentation' do subject From 1268f323cb080cedfed61fcac4b1d698910352f6 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Sat, 26 Oct 2024 21:14:05 +0200 Subject: [PATCH 1350/1532] move filter button to component and use plug it to new route --- .../instructeurs/filter_buttons_component.rb | 47 ++++++++++ .../_dossiers_filter_tags.html.haml | 17 ---- .../instructeurs/procedures/show.html.haml | 2 +- .../filter_buttons_component_spec.rb | 88 +++++++++++++++++++ spec/models/procedure_presentation_spec.rb | 38 -------- 5 files changed, 136 insertions(+), 56 deletions(-) create mode 100644 app/components/instructeurs/filter_buttons_component.rb delete mode 100644 app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml create mode 100644 spec/components/instructeurs/filter_buttons_component_spec.rb diff --git a/app/components/instructeurs/filter_buttons_component.rb b/app/components/instructeurs/filter_buttons_component.rb new file mode 100644 index 000000000..960b67b4e --- /dev/null +++ b/app/components/instructeurs/filter_buttons_component.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class Instructeurs::FilterButtonsComponent < ApplicationComponent + def initialize(filters:, procedure_presentation:, statut:) + @filters = filters + @procedure_presentation = procedure_presentation + @statut = statut + end + + def call + safe_join(filters_by_family, ' et ') + end + + private + + def filters_by_family + @filters + .group_by { _1.column.id } + .values + .map { |group| group.map { |f| filter_form(f) } } + .map { |group| safe_join(group, ' ou ') } + end + + def filter_form(filter) + form_with(model: [:instructeur, @procedure_presentation], class: 'inline') do + safe_join([ + hidden_field_tag('filters[]', ''), # to ensure the filters is not empty + *other_hidden_fields(filter), # other filters to keep + hidden_field_tag('statut', @statut), # collection to set + button_tag(button_content(filter), class: 'fr-tag fr-tag--dismiss fr-my-1w') + ]) + end + end + + def other_hidden_fields(filter) + @filters.reject { _1 == filter }.flat_map do |f| + [ + hidden_field_tag("filters[][id]", f.column.id), + hidden_field_tag("filters[][filter]", f.filter) + ] + end + end + + def button_content(filter) + "#{filter.column.label.truncate(50)} : #{@procedure_presentation.human_value_for_filter(filter)}" + end +end diff --git a/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml b/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml deleted file mode 100644 index b8673ed2f..000000000 --- a/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml +++ /dev/null @@ -1,17 +0,0 @@ -- if current_filters.count > 0 - .fr-mb-2w - - current_filters.group_by { |filter| filter.column.table }.each_with_index do |(table, filters), i| - - if i > 0 - = " et " - - filters.each_with_index do |filter, i| - - if i > 0 - = " ou " - = form_tag(add_filter_instructeur_procedure_path(procedure), class: 'inline') do - - prefix = procedure_presentation.filters_name_for(statut) - = hidden_field_tag "#{prefix}[]", '' - - (current_filters - [filter]).each do |f| - = hidden_field_tag "#{prefix}[][id]", f.column.id - = hidden_field_tag "#{prefix}[][filter]", f.filter - - = button_tag "#{filter.column.label.truncate(50)} : #{procedure_presentation.human_value_for_filter(filter)}", - class: 'fr-tag fr-tag--dismiss fr-my-1w' diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index a7dd1e0a3..e36560e88 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -70,7 +70,7 @@ = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_templates: current_instructeur.export_templates_for(@procedure), statut: @statut, count: @dossiers_count, class_btn: 'fr-btn--tertiary', export_url: method(:download_export_instructeur_procedure_path)) - if @filtered_sorted_paginated_ids.present? || @current_filters.count > 0 - = render partial: "dossiers_filter_tags", locals: { procedure: @procedure, procedure_presentation: @procedure_presentation, current_filters: @current_filters, statut: @statut } + = render Instructeurs::FilterButtonsComponent.new(filters: @current_filters, procedure_presentation: @procedure_presentation, statut: @statut) - batch_operation_component = Dossiers::BatchOperationComponent.new(statut: @statut, procedure: @procedure) diff --git a/spec/components/instructeurs/filter_buttons_component_spec.rb b/spec/components/instructeurs/filter_buttons_component_spec.rb new file mode 100644 index 000000000..e022bc150 --- /dev/null +++ b/spec/components/instructeurs/filter_buttons_component_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +describe Instructeurs::FilterButtonsComponent, type: :component do + let(:component) { described_class.new(filters:, procedure_presentation:, statut:) } + let(:instructeur) { create(:instructeur) } + let(:assign_to) { create(:assign_to, procedure:, instructeur:) } + let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) } + let(:statut) { 'tous' } + let(:filters) { [filter] } + + def to_filter((label, filter)) = FilteredColumn.new(column: procedure.find_column(label: label), filter: filter) + + before { render_inline(component) } + + describe "visible text" do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :text }]) } + let(:first_type_de_champ) { procedure.active_revision.types_de_champ_public.first } + let(:filter) { to_filter([first_type_de_champ.libelle, "true"]) } + + context 'when type_de_champ text' do + it 'should passthrough value' do + expect(page).to have_text("true") + end + end + + context 'when type_de_champ yes_no' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :yes_no }]) } + + it 'should transform value' do + expect(page).to have_text("oui") + end + end + + context 'when filter is state' do + let(:filter) { to_filter(['État du dossier', "en_construction"]) } + + it 'should get i18n value' do + expect(page).to have_text("En construction") + end + end + + context 'when filter is a date' do + let(:filter) { to_filter(['Date de création', "15/06/2023"]) } + + it 'should get formatted value' do + expect(page).to have_text("15/06/2023") + end + end + + context 'when there are multiple filters' do + let(:filters) do + [ + to_filter(['État du dossier', "en_construction"]), + to_filter(['État du dossier', "en_instruction"]), + to_filter(['Date de création', "15/06/2023"]) + ] + end + + it 'should display all filters' do + text = "État du dossier : En construction ou État du dossier : En instruction et Date de création : 15/06/2023" + expect(page).to have_text(text) + end + end + end + + describe "hidden inputs" do + let(:procedure) { create(:procedure) } + + context 'with 2 filters' do + let(:en_construction_filter) { to_filter(['État du dossier', "en_construction"]) } + let(:en_instruction_filter) { to_filter(['État du dossier', "en_instruction"]) } + let(:column_id) { procedure.find_column(label: 'État du dossier').id } + let(:filters) { [en_construction_filter, en_instruction_filter] } + + it 'should have the necessary inputs' do + expect(page).to have_field('statut', with: 'tous', type: 'hidden') + + expect(page.all('form').count).to eq(2) + + del_en_construction = page.all('form').first + expect(del_en_construction).to have_text('En construction') + expect(del_en_construction).to have_field('filters[]', with: '', type: 'hidden') + expect(del_en_construction).to have_field('filters[][id]', with: column_id, type: 'hidden') + expect(del_en_construction).to have_field('filters[][filter]', with: 'en_instruction', type: 'hidden') + end + end + end +end diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index 821204a3c..36c4197d3 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -55,44 +55,6 @@ describe ProcedurePresentation do end end - describe "#human_value_for_filter" do - let(:filtered_column) { to_filter([first_type_de_champ.libelle, "true"]) } - - subject do - procedure_presentation.human_value_for_filter(filtered_column) - end - - context 'when type_de_champ text' do - it 'should passthrough value' do - expect(subject).to eq("true") - end - end - - context 'when type_de_champ yes_no' do - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :yes_no }]) } - - it 'should transform value' do - expect(subject).to eq("oui") - end - end - - context 'when filter is state' do - let(:filtered_column) { to_filter(['État du dossier', "en_construction"]) } - - it 'should get i18n value' do - expect(subject).to eq("En construction") - end - end - - context 'when filter is a date' do - let(:filtered_column) { to_filter(['Date de création', "15/06/2023"]) } - - it 'should get formatted value' do - expect(subject).to eq("15/06/2023") - end - end - end - describe '#update_displayed_fields' do let(:en_construction_column) { procedure.find_column(label: 'Date de passage en construction') } let(:mise_a_jour_column) { procedure.find_column(label: 'Date du dernier évènement') } From f50e63ea40e725b20fb5b0ab4c45e04107fa7a38 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 28 Oct 2024 10:07:48 +0100 Subject: [PATCH 1351/1532] remove useless conversion --- app/models/filtered_column.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/filtered_column.rb b/app/models/filtered_column.rb index cd1e1f774..c861b7338 100644 --- a/app/models/filtered_column.rb +++ b/app/models/filtered_column.rb @@ -33,7 +33,7 @@ class FilteredColumn private def check_filter_max_length - if @filter.present? && @filter.length.to_i > FILTERS_VALUE_MAX_LENGTH + if @filter.present? && @filter.length > FILTERS_VALUE_MAX_LENGTH errors.add( :base, "Le filtre « #{label} » est trop long (maximum: #{FILTERS_VALUE_MAX_LENGTH} caractères)" From c28087ba3bb148dc089f12628b5f3a2baef9730a Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 28 Oct 2024 10:15:52 +0100 Subject: [PATCH 1352/1532] move human_value_for_filter to filter_buttons_component --- .../instructeurs/filter_buttons_component.rb | 33 +++++++++++++- app/helpers/application_helper.rb | 4 ++ app/models/procedure_presentation.rb | 43 ------------------- 3 files changed, 36 insertions(+), 44 deletions(-) diff --git a/app/components/instructeurs/filter_buttons_component.rb b/app/components/instructeurs/filter_buttons_component.rb index 960b67b4e..950adee9b 100644 --- a/app/components/instructeurs/filter_buttons_component.rb +++ b/app/components/instructeurs/filter_buttons_component.rb @@ -42,6 +42,37 @@ class Instructeurs::FilterButtonsComponent < ApplicationComponent end def button_content(filter) - "#{filter.column.label.truncate(50)} : #{@procedure_presentation.human_value_for_filter(filter)}" + "#{filter.label.truncate(50)} : #{human_value(filter)}" + end + + def human_value(filter_column) + column, filter = filter_column.column, filter_column.filter + + if column.type_de_champ? + find_type_de_champ(column.column).dynamic_type.filter_to_human(filter) + elsif column.dossier_state? + if filter == 'pending_correction' + Dossier.human_attribute_name("pending_correction.for_instructeur") + else + Dossier.human_attribute_name("state.#{filter}") + end + elsif column.groupe_instructeur? + current_instructeur.groupe_instructeurs + .find { _1.id == filter.to_i }&.label || filter + elsif column.type == :date + helpers.try_parse_format_date(filter) + else + filter + end + end + + def find_type_de_champ(column) + stable_id = column.to_s.split('->').first + + TypeDeChamp + .joins(:revision_types_de_champ) + .where(revision_types_de_champ: { revision_id: @procedure_presentation.procedure.revisions }) + .order(created_at: :desc) + .find_by(stable_id:) end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 88a398cd7..3fdc45795 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -113,6 +113,10 @@ module ApplicationHelper datetime.present? ? I18n.l(datetime, format:) : '' end + def try_parse_format_date(date) + date.then { Date.parse(_1) rescue nil }&.then { I18n.l(_1) } + end + def try_format_mois_effectif(etablissement) if etablissement.entreprise_effectif_mois.present? && etablissement.entreprise_effectif_annee.present? [etablissement.entreprise_effectif_mois, etablissement.entreprise_effectif_annee].join('/') diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index c45e6ac20..2df4db930 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class ProcedurePresentation < ApplicationRecord - TYPE_DE_CHAMP = 'type_de_champ' - belongs_to :assign_to, optional: false has_many :exports, dependent: :destroy @@ -42,45 +40,4 @@ class ProcedurePresentation < ApplicationRecord columns.concat(procedure.sva_svr_columns) if procedure.sva_svr_enabled? columns end - - def human_value_for_filter(filtered_column) - if filtered_column.column.table == TYPE_DE_CHAMP - find_type_de_champ(filtered_column.column.column).dynamic_type.filter_to_human(filtered_column.filter) - elsif filtered_column.column.column == 'state' - if filtered_column.filter == 'pending_correction' - Dossier.human_attribute_name("pending_correction.for_instructeur") - else - Dossier.human_attribute_name("state.#{filtered_column.filter}") - end - elsif filtered_column.column.table == 'groupe_instructeur' && filtered_column.column.column == 'id' - instructeur.groupe_instructeurs - .find { _1.id == filtered_column.filter.to_i }&.label || filtered_column.filter - else - column = procedure.columns.find { _1.table == filtered_column.column.table && _1.column == filtered_column.column.column } - - if column.type == :date - parsed_date = safe_parse_date(filtered_column.filter) - - return parsed_date.present? ? I18n.l(parsed_date) : nil - end - - filtered_column.filter - end - end - - def safe_parse_date(string) - Date.parse(string) - rescue Date::Error - nil - end - - private - - def find_type_de_champ(column) - TypeDeChamp - .joins(:revision_types_de_champ) - .where(revision_types_de_champ: { revision_id: procedure.revisions }) - .order(created_at: :desc) - .find_by(stable_id: column) - end end From 59b7b3dc9184bd1022b4c39b407e408632443624 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 28 Oct 2024 10:16:24 +0100 Subject: [PATCH 1353/1532] tag old methods to be remove in a next pr --- app/controllers/instructeurs/procedures_controller.rb | 6 ++++++ config/routes.rb | 2 ++ 2 files changed, 8 insertions(+) diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index 2d9b4b26f..d110d1434 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -132,6 +132,7 @@ module Instructeurs @statut = 'supprime' end + # TODO: to remove because of new procedure_presentation_controller def update_displayed_fields ids = (params['values'].presence || []).reject(&:empty?) @@ -140,12 +141,14 @@ module Instructeurs redirect_back(fallback_location: instructeur_procedure_url(procedure)) end + # TODO: to remove because of new procedure_presentation_controller def update_sort procedure_presentation.update!(sorted_column_params) redirect_back(fallback_location: instructeur_procedure_url(procedure)) end + # TODO: to remove because of new procedure_presentation_controller def add_filter if !procedure_presentation.update(filter_params) # complicated way to display inner error messages @@ -156,6 +159,7 @@ module Instructeurs redirect_back(fallback_location: instructeur_procedure_url(procedure)) end + # TODO: to remove because of new procedure_presentation_controller def update_filter @statut = statut @procedure = procedure @@ -407,10 +411,12 @@ module Instructeurs "exports_#{@procedure.id}_seen_at" end + # TODO: to remove because of new procedure_presentation_controller def sorted_column_params params.permit(sorted_column: [:order, :id]) end + # TODO: to remove because of new procedure_presentation_controller def filter_params keys = [:tous_filters, :a_suivre_filters, :suivis_filters, :traites_filters, :expirant_filters, :archives_filters, :supprimes_filters] h = keys.index_with { [:id, :filter] } diff --git a/config/routes.rb b/config/routes.rb index b44d09de6..d194eb920 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -488,11 +488,13 @@ Rails.application.routes.draw do end end + # TODO: to remove because of new procedure_presentation_controller patch 'update_displayed_fields' get 'update_sort' => 'procedures#update_sort', as: 'update_sort' post 'add_filter' post 'update_filter' get 'remove_filter' + get 'download_export' post 'download_export' get 'polling_last_export' From f27598f235253dce7628a0c1899fdd29592b02a8 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 31 Oct 2024 03:27:05 +0100 Subject: [PATCH 1354/1532] fix: assign_to.procedure_presentation should return an ActiveModel::Errors if needed the caller will then calls `errors.full_messages` --- app/models/assign_to.rb | 4 +++- spec/models/assign_to_spec.rb | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/models/assign_to.rb b/app/models/assign_to.rb index d9f6071c5..cfab77484 100644 --- a/app/models/assign_to.rb +++ b/app/models/assign_to.rb @@ -24,7 +24,9 @@ class AssignTo < ApplicationRecord errors = begin procedure_presentation.errors if procedure_presentation&.invalid? rescue ActiveRecord::RecordNotFound => e - [e.message] + errors = ActiveModel::Errors.new(self) + errors.add(:procedure_presentation, e.message) + errors end if errors.present? diff --git a/spec/models/assign_to_spec.rb b/spec/models/assign_to_spec.rb index 59aaf55dd..8ddad0c57 100644 --- a/spec/models/assign_to_spec.rb +++ b/spec/models/assign_to_spec.rb @@ -45,7 +45,7 @@ describe AssignTo, type: :model do it do expect(procedure_presentation_or_default).to be_persisted expect(procedure_presentation_or_default).to be_valid - expect(errors).to be_present + expect(errors.full_messages).to include(/unable to find procedure 666/) expect(assign_to.procedure_presentation).not_to be(procedure_presentation) end end From e2ace4f6bdd550e1972eb365ac0b8e7c50060aac Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 30 Oct 2024 14:28:23 +0100 Subject: [PATCH 1355/1532] chore: only create columns on fillable champs --- .../types_de_champ/type_de_champ_base.rb | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index 8d15205d2..e4babe257 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -93,17 +93,21 @@ class TypesDeChamp::TypeDeChampBase end def columns(procedure_id:, displayable: true, prefix: nil) - [ - Column.new( - procedure_id:, - table: Column::TYPE_DE_CHAMP_TABLE, - column: stable_id.to_s, - label: libelle_with_prefix(prefix), - type: TypeDeChamp.column_type(type_champ), - value_column: TypeDeChamp.value_column(type_champ), - displayable: - ) - ] + if fillable? + [ + Column.new( + procedure_id:, + table: Column::TYPE_DE_CHAMP_TABLE, + column: stable_id.to_s, + label: libelle_with_prefix(prefix), + type: TypeDeChamp.column_type(type_champ), + value_column: TypeDeChamp.value_column(type_champ), + displayable: + ) + ] + else + [] + end end private From e9991573e722753912d301c83d476d4e9d211f58 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 30 Oct 2024 14:29:13 +0100 Subject: [PATCH 1356/1532] chore: columns on some champs at this point do not make sense --- app/models/types_de_champ/carte_type_de_champ.rb | 4 ++++ .../types_de_champ/piece_justificative_type_de_champ.rb | 4 ++++ spec/models/column_spec.rb | 3 ++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/models/types_de_champ/carte_type_de_champ.rb b/app/models/types_de_champ/carte_type_de_champ.rb index 9a16c6392..01cbd2a20 100644 --- a/app/models/types_de_champ/carte_type_de_champ.rb +++ b/app/models/types_de_champ/carte_type_de_champ.rb @@ -27,4 +27,8 @@ class TypesDeChamp::CarteTypeDeChamp < TypesDeChamp::TypeDeChampBase def champ_value_for_export(champ, path = :value) champ.geo_areas.map(&:label).join("\n") end + + def columns(procedure_id:, displayable: true, prefix: nil) + [] + end end diff --git a/app/models/types_de_champ/piece_justificative_type_de_champ.rb b/app/models/types_de_champ/piece_justificative_type_de_champ.rb index 6f00eec3a..2c68ef035 100644 --- a/app/models/types_de_champ/piece_justificative_type_de_champ.rb +++ b/app/models/types_de_champ/piece_justificative_type_de_champ.rb @@ -22,4 +22,8 @@ class TypesDeChamp::PieceJustificativeTypeDeChamp < TypesDeChamp::TypeDeChampBas attachment.url end end + + def columns(procedure_id:, displayable: true, prefix: nil) + [] + end end diff --git a/spec/models/column_spec.rb b/spec/models/column_spec.rb index 2d7652822..c1f1442dd 100644 --- a/spec/models/column_spec.rb +++ b/spec/models/column_spec.rb @@ -66,7 +66,8 @@ describe Column do expect_type_de_champ_values('linked_drop_down_list', [nil, "categorie 1", "choix 1"]) expect_type_de_champ_values('yes_no', [true]) expect_type_de_champ_values('annuaire_education', [nil]) - expect_type_de_champ_values('carte', [nil]) + expect_type_de_champ_values('carte', []) + expect_type_de_champ_values('piece_justificative', []) expect_type_de_champ_values('cnaf', [nil]) expect_type_de_champ_values('dgfip', [nil]) expect_type_de_champ_values('pole_emploi', [nil]) From 503da1d160a129e7929e7f5a776d9b4026bd9ef0 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 30 Oct 2024 15:43:47 +0100 Subject: [PATCH 1357/1532] chore: implement special columns --- app/models/column.rb | 2 -- app/models/columns/linked_drop_down_column.rb | 8 +++++++- app/models/columns/titre_identite_column.rb | 9 +++++++++ app/models/type_de_champ.rb | 16 ++++++++-------- .../linked_drop_down_list_type_de_champ.rb | 13 +++++++++++-- .../types_de_champ/repetition_type_de_champ.rb | 2 +- .../titre_identite_type_de_champ.rb | 15 +++++++++++++++ spec/models/column_spec.rb | 1 + 8 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 app/models/columns/titre_identite_column.rb diff --git a/app/models/column.rb b/app/models/column.rb index 94f9295f8..198aada85 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -57,8 +57,6 @@ class Column value = get_raw_value(champ) if should_cast? - # FIXME: remove this, once displayable is implemented through columns - return nil if champ.last_write_type_champ == TypeDeChamp.type_champs.fetch(:linked_drop_down_list) from_type = champ.last_write_column_type to_type = type parsed_value = parse_value(value, from_type) diff --git a/app/models/columns/linked_drop_down_column.rb b/app/models/columns/linked_drop_down_column.rb index dfa7efe66..396dcb8cd 100644 --- a/app/models/columns/linked_drop_down_column.rb +++ b/app/models/columns/linked_drop_down_column.rb @@ -2,7 +2,11 @@ class Columns::LinkedDropDownColumn < Column def column - "#{@column}->#{value_column}" # override column otherwise json path facets will have same id as other + if value_column == :value + super + else + "#{@column}->#{value_column}" # override column otherwise json path facets will have same id as other + end end def filtered_ids(dossiers, values) @@ -16,6 +20,8 @@ class Columns::LinkedDropDownColumn < Column def get_raw_value(champ) primary_value, secondary_value = unpack_values(champ.value) case value_column + when :value + nil when :primary primary_value when :secondary diff --git a/app/models/columns/titre_identite_column.rb b/app/models/columns/titre_identite_column.rb new file mode 100644 index 000000000..3141df1d8 --- /dev/null +++ b/app/models/columns/titre_identite_column.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Columns::TitreIdentiteColumn < Column + private + + def get_raw_value(champ) + champ.piece_justificative_file.attached?.to_s + end +end diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 9d82f64b5..79bfa7088 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -528,19 +528,19 @@ class TypeDeChamp < ApplicationRecord def self.column_type(type_champ) case type_champ - when TypeDeChamp.type_champs.fetch(:datetime) + when type_champs.fetch(:datetime) :datetime - when TypeDeChamp.type_champs.fetch(:date) + when type_champs.fetch(:date) :date - when TypeDeChamp.type_champs.fetch(:integer_number) + when type_champs.fetch(:integer_number) :integer - when TypeDeChamp.type_champs.fetch(:decimal_number) + when type_champs.fetch(:decimal_number) :decimal - when TypeDeChamp.type_champs.fetch(:multiple_drop_down_list) + when type_champs.fetch(:multiple_drop_down_list) :enums - when TypeDeChamp.type_champs.fetch(:drop_down_list), TypeDeChamp.type_champs.fetch(:departements), TypeDeChamp.type_champs.fetch(:regions) + when type_champs.fetch(:drop_down_list), type_champs.fetch(:departements), type_champs.fetch(:regions) :enum - when TypeDeChamp.type_champs.fetch(:checkbox), TypeDeChamp.type_champs.fetch(:yes_no) + when type_champs.fetch(:checkbox), type_champs.fetch(:yes_no), type_champs.fetch(:titre_identite) :boolean else :text @@ -548,7 +548,7 @@ class TypeDeChamp < ApplicationRecord end def self.value_column(type_champ) - if type_champ.in?([TypeDeChamp.type_champs.fetch(:departements), TypeDeChamp.type_champs.fetch(:regions)]) + if type_champ.in?([type_champs.fetch(:departements), type_champs.fetch(:regions)]) :external_id else :value diff --git a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb index 103922861..44333e0b2 100644 --- a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb @@ -68,7 +68,16 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas end def columns(procedure_id:, displayable: true, prefix: nil) - super.concat([ + [ + Columns::LinkedDropDownColumn.new( + procedure_id:, + table: Column::TYPE_DE_CHAMP_TABLE, + column: stable_id.to_s, + label: libelle_with_prefix(prefix), + type: :text, + value_column: :value, + displayable: + ), Columns::LinkedDropDownColumn.new( procedure_id:, table: Column::TYPE_DE_CHAMP_TABLE, @@ -87,7 +96,7 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas value_column: :secondary, displayable: false ) - ]) + ] end private diff --git a/app/models/types_de_champ/repetition_type_de_champ.rb b/app/models/types_de_champ/repetition_type_de_champ.rb index e281c0abf..b1325472f 100644 --- a/app/models/types_de_champ/repetition_type_de_champ.rb +++ b/app/models/types_de_champ/repetition_type_de_champ.rb @@ -27,7 +27,7 @@ class TypesDeChamp::RepetitionTypeDeChamp < TypesDeChamp::TypeDeChampBase ActiveStorage::Filename.new(str.delete('[]*?')).sanitized end - def columns(procedure_id:, displayable: true, prefix: nil) + def columns(procedure_id:, displayable: nil, prefix: nil) @type_de_champ.procedure .all_revisions_types_de_champ(parent: @type_de_champ) .flat_map { _1.columns(procedure_id:, displayable: false, prefix: libelle) } diff --git a/app/models/types_de_champ/titre_identite_type_de_champ.rb b/app/models/types_de_champ/titre_identite_type_de_champ.rb index 7a10280c5..617603c10 100644 --- a/app/models/types_de_champ/titre_identite_type_de_champ.rb +++ b/app/models/types_de_champ/titre_identite_type_de_champ.rb @@ -21,4 +21,19 @@ class TypesDeChamp::TitreIdentiteTypeDeChamp < TypesDeChamp::TypeDeChampBase def champ_default_export_value(path = :value) "absent" end + + def columns(procedure_id:, displayable: nil, prefix: nil) + [ + Columns::TitreIdentiteColumn.new( + procedure_id:, + table: Column::TYPE_DE_CHAMP_TABLE, + column: stable_id.to_s, + label: libelle_with_prefix(prefix), + type: TypeDeChamp.column_type(type_champ), + value_column: TypeDeChamp.value_column(type_champ), + displayable: false, + filterable: false + ) + ] + end end diff --git a/spec/models/column_spec.rb b/spec/models/column_spec.rb index c1f1442dd..f91798d7a 100644 --- a/spec/models/column_spec.rb +++ b/spec/models/column_spec.rb @@ -68,6 +68,7 @@ describe Column do expect_type_de_champ_values('annuaire_education', [nil]) expect_type_de_champ_values('carte', []) expect_type_de_champ_values('piece_justificative', []) + expect_type_de_champ_values('titre_identite', [true]) expect_type_de_champ_values('cnaf', [nil]) expect_type_de_champ_values('dgfip', [nil]) expect_type_de_champ_values('pole_emploi', [nil]) From 7fddec484d2c5ab7a6eca7a25e02ce2c7436351c Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 31 Oct 2024 21:36:13 +0100 Subject: [PATCH 1358/1532] =?UTF-8?q?refactor(column):=20no=20more=20java?= =?UTF-8?q?=20=F0=9F=8E=89=20get=5Fvalue=20->=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/column.rb | 23 ++++++++----------- app/models/columns/dossier_column.rb | 2 +- app/models/columns/json_path_column.rb | 6 +---- app/models/columns/linked_drop_down_column.rb | 17 ++++---------- app/models/columns/titre_identite_column.rb | 4 ++-- spec/models/column_spec.rb | 14 +++++------ 6 files changed, 26 insertions(+), 40 deletions(-) diff --git a/app/models/column.rb b/app/models/column.rb index 198aada85..b9c223fe8 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -52,15 +52,12 @@ class Column procedure.find_column(h_id: h_id) end - def get_value(champ) + def value(champ) return if champ.nil? - value = get_raw_value(champ) - if should_cast? - from_type = champ.last_write_column_type - to_type = type - parsed_value = parse_value(value, from_type) - cast_value(parsed_value, from_type:, to_type:) + value = typed_value(champ) + if default_column? + cast_value(value, from_type: champ.last_write_column_type, to_type: type) else value end @@ -68,15 +65,15 @@ class Column private - def get_raw_value(champ) - champ.public_send(value_column) + def typed_value(champ) + value = string_value(champ) + parse_value(value, type: champ.last_write_column_type) end - def should_cast? - true - end + def string_value(champ) = champ.public_send(value_column) + def default_column? = value_column.in?([:value, :external_id]) - def parse_value(value, type) + def parse_value(value, type:) return if value.blank? case type diff --git a/app/models/columns/dossier_column.rb b/app/models/columns/dossier_column.rb index 138b6092b..72ec97405 100644 --- a/app/models/columns/dossier_column.rb +++ b/app/models/columns/dossier_column.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Columns::DossierColumn < Column - def get_value(dossier) + def value(dossier) case table when 'self' dossier.public_send(column) diff --git a/app/models/columns/json_path_column.rb b/app/models/columns/json_path_column.rb index 210e41e7a..f78585688 100644 --- a/app/models/columns/json_path_column.rb +++ b/app/models/columns/json_path_column.rb @@ -25,14 +25,10 @@ class Columns::JSONPathColumn < Column private - def get_raw_value(champ) + def typed_value(champ) champ.value_json&.dig(*value_column) end - def should_cast? - false - end - def stable_id @column end diff --git a/app/models/columns/linked_drop_down_column.rb b/app/models/columns/linked_drop_down_column.rb index 396dcb8cd..bab15cd68 100644 --- a/app/models/columns/linked_drop_down_column.rb +++ b/app/models/columns/linked_drop_down_column.rb @@ -2,11 +2,8 @@ class Columns::LinkedDropDownColumn < Column def column - if value_column == :value - super - else - "#{@column}->#{value_column}" # override column otherwise json path facets will have same id as other - end + return super if default_column? + "#{@column}->#{value_column}" # override column otherwise json path facets will have same id as other end def filtered_ids(dossiers, values) @@ -17,11 +14,11 @@ class Columns::LinkedDropDownColumn < Column private - def get_raw_value(champ) + def typed_value(champ) + return nil if default_column? + primary_value, secondary_value = unpack_values(champ.value) case value_column - when :value - nil when :primary primary_value when :secondary @@ -29,10 +26,6 @@ class Columns::LinkedDropDownColumn < Column end end - def should_cast? - false - end - def unpack_values(value) JSON.parse(value) rescue JSON::ParserError diff --git a/app/models/columns/titre_identite_column.rb b/app/models/columns/titre_identite_column.rb index 3141df1d8..9ce491e51 100644 --- a/app/models/columns/titre_identite_column.rb +++ b/app/models/columns/titre_identite_column.rb @@ -3,7 +3,7 @@ class Columns::TitreIdentiteColumn < Column private - def get_raw_value(champ) - champ.piece_justificative_file.attached?.to_s + def typed_value(champ) + champ.piece_justificative_file.attached? end end diff --git a/spec/models/column_spec.rb b/spec/models/column_spec.rb index f91798d7a..74634c718 100644 --- a/spec/models/column_spec.rb +++ b/spec/models/column_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true describe Column do - describe 'get_value' do + describe 'value' do let(:groupe_instructeur) { create(:groupe_instructeur, instructeurs: [create(:instructeur)]) } context 'when dossier columns' do @@ -11,9 +11,9 @@ describe Column do let(:dossier) { create(:dossier, individual:, mandataire_first_name: "Martin", mandataire_last_name: "Christophe", for_tiers: true) } it 'retrieve individual information' do - expect(procedure.find_column(label: "Prénom").get_value(dossier)).to eq("Paul") - expect(procedure.find_column(label: "Nom").get_value(dossier)).to eq("Sim") - expect(procedure.find_column(label: "Civilité").get_value(dossier)).to eq("M.") + expect(procedure.find_column(label: "Prénom").value(dossier)).to eq("Paul") + expect(procedure.find_column(label: "Nom").value(dossier)).to eq("Sim") + expect(procedure.find_column(label: "Civilité").value(dossier)).to eq("M.") end end @@ -22,7 +22,7 @@ describe Column do let(:dossier) { create(:dossier, :en_instruction, :with_entreprise, procedure:) } it 'retrieve entreprise information' do - expect(procedure.find_column(label: "Libellé NAF").get_value(dossier)).to eq('Transports par conduites') + expect(procedure.find_column(label: "Libellé NAF").value(dossier)).to eq('Transports par conduites') end end @@ -31,7 +31,7 @@ describe Column do let(:dossier) { create(:dossier, :en_instruction, procedure:) } it 'does not fail' do - expect(procedure.find_column(label: "Date décision SVA").get_value(dossier)).to eq(nil) + expect(procedure.find_column(label: "Date décision SVA").value(dossier)).to eq(nil) end end end @@ -85,7 +85,7 @@ describe Column do type_de_champ = types_de_champ.find { _1.type_champ == type } champ = dossier.send(:filled_champ, type_de_champ, nil) columns = type_de_champ.columns(procedure_id: procedure.id) - expect(columns.map { _1.get_value(champ) }).to eq(values) + expect(columns.map { _1.value(champ) }).to eq(values) end def retrieve_champ(type) From b29893a843ebc138ec0fd1ae185c09051d3cee43 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 4 Nov 2024 10:54:00 +0100 Subject: [PATCH 1359/1532] feat(conditional): can condition and route with pays tdc --- app/models/logic/champ_value.rb | 9 +++++--- .../champs_conditions_component_spec.rb | 12 ++++++++++ spec/models/routing_engine_spec.rb | 23 +++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/app/models/logic/champ_value.rb b/app/models/logic/champ_value.rb index f4f655a5e..0d0015198 100644 --- a/app/models/logic/champ_value.rb +++ b/app/models/logic/champ_value.rb @@ -12,7 +12,8 @@ class Logic::ChampValue < Logic::Term :epci, :departements, :regions, - :address + :address, + :pays ) CHAMP_VALUE_TYPE = { @@ -56,7 +57,7 @@ class Logic::ChampValue < Logic::Term targeted_champ.selected when "Champs::MultipleDropDownListChamp" targeted_champ.selected_options - when "Champs::RegionChamp" + when "Champs::RegionChamp", "Champs::PaysChamp" targeted_champ.code when "Champs::DepartementChamp" { @@ -81,7 +82,7 @@ class Logic::ChampValue < Logic::Term when MANAGED_TYPE_DE_CHAMP.fetch(:integer_number), MANAGED_TYPE_DE_CHAMP.fetch(:decimal_number) CHAMP_VALUE_TYPE.fetch(:number) when MANAGED_TYPE_DE_CHAMP.fetch(:drop_down_list), - MANAGED_TYPE_DE_CHAMP.fetch(:regions) + MANAGED_TYPE_DE_CHAMP.fetch(:regions), MANAGED_TYPE_DE_CHAMP.fetch(:pays) CHAMP_VALUE_TYPE.fetch(:enum) when MANAGED_TYPE_DE_CHAMP.fetch(:communes) CHAMP_VALUE_TYPE.fetch(:commune_enum) @@ -128,6 +129,8 @@ class Logic::ChampValue < Logic::Term APIGeoService.regions.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } elsif operator_name.in?([Logic::InDepartementOperator.name, Logic::NotInDepartementOperator.name]) || tdc.type_champ.in?([MANAGED_TYPE_DE_CHAMP.fetch(:communes), MANAGED_TYPE_DE_CHAMP.fetch(:epci), MANAGED_TYPE_DE_CHAMP.fetch(:departements), MANAGED_TYPE_DE_CHAMP.fetch(:address)]) APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } + elsif tdc.type_champ == MANAGED_TYPE_DE_CHAMP.fetch(:pays) + APIGeoService.countries.map { ["#{_1[:name]} – #{_1[:code]}", _1[:code]] } else tdc.drop_down_options_with_other.map { _1.is_a?(Array) ? _1 : [_1, _1] } end diff --git a/spec/components/conditions/champs_conditions_component_spec.rb b/spec/components/conditions/champs_conditions_component_spec.rb index b24abd8ae..e6e826cba 100644 --- a/spec/components/conditions/champs_conditions_component_spec.rb +++ b/spec/components/conditions/champs_conditions_component_spec.rb @@ -131,6 +131,18 @@ describe Conditions::ChampsConditionsComponent, type: :component do end end + context 'pays' do + let(:pays) { create(:type_de_champ_pays) } + let(:upper_tdcs) { [pays] } + let(:condition) { empty_operator(champ_value(pays.stable_id), constant(true)) } + let(:pays_options) { APIGeoService.countries.map { "#{_1[:name]} – #{_1[:code]}" } } + + it do + expect(page).to have_select('type_de_champ[condition_form][rows][][operator_name]', with_options: ['Est']) + expect(page).to have_select('type_de_champ[condition_form][rows][][value]', options: (['Sélectionner'] + pays_options)) + end + end + context 'address' do let(:address) { create(:type_de_champ_address) } let(:upper_tdcs) { [address] } diff --git a/spec/models/routing_engine_spec.rb b/spec/models/routing_engine_spec.rb index b929844f5..9d8a986a4 100644 --- a/spec/models/routing_engine_spec.rb +++ b/spec/models/routing_engine_spec.rb @@ -181,6 +181,29 @@ describe RoutingEngine, type: :model do end end + context 'with a pays type de champ' do + let(:procedure) do + create(:procedure, types_de_champ_public: [{ type: :pays }]).tap do |p| + p.groupe_instructeurs.create(label: 'a third group') + end + end + + let(:pays_tdc) { procedure.draft_revision.types_de_champ.first } + + context 'with a matching rule' do + before do + gi_2.update(routing_rule: ds_eq(champ_value(pays_tdc.stable_id), constant('BE'))) + dossier.champs.first.update_columns( + value: "Belgique" + ) + end + + it do + is_expected.to eq(gi_2) + end + end + end + context 'routing rules priorities' do let(:procedure) do create(:procedure, From 30aa0b71d0ebbcd276920e171d1212f096dfc9f4 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 4 Nov 2024 11:08:14 +0100 Subject: [PATCH 1360/1532] feat(routing): can create simple routing with pays tdc --- .../groupe_instructeurs_controller.rb | 4 ++++ app/models/type_de_champ.rb | 1 + .../groupe_instructeurs_controller_spec.rb | 20 +++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/app/controllers/administrateurs/groupe_instructeurs_controller.rb b/app/controllers/administrateurs/groupe_instructeurs_controller.rb index c9ecd1563..4984e601e 100644 --- a/app/controllers/administrateurs/groupe_instructeurs_controller.rb +++ b/app/controllers/administrateurs/groupe_instructeurs_controller.rb @@ -60,6 +60,10 @@ module Administrateurs rule_operator = :ds_eq tdc_options = APIGeoService.regions.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } create_groups_from_territorial_tdc(tdc_options, stable_id, rule_operator) + when TypeDeChamp.type_champs.fetch(:pays) + rule_operator = :ds_eq + tdc_options = APIGeoService.countries.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } + create_groups_from_territorial_tdc(tdc_options, stable_id, rule_operator) when TypeDeChamp.type_champs.fetch(:drop_down_list) tdc_options = tdc.drop_down_options.reject(&:empty?) create_groups_from_drop_down_list_tdc(tdc_options, stable_id) diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 79bfa7088..2c8e83406 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -115,6 +115,7 @@ class TypeDeChamp < ApplicationRecord type_champs.fetch(:communes), type_champs.fetch(:departements), type_champs.fetch(:regions), + type_champs.fetch(:pays), type_champs.fetch(:epci), type_champs.fetch(:address) ] diff --git a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb index 6e4b823dc..0c6790606 100644 --- a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb +++ b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb @@ -959,6 +959,26 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do end end + context 'with a pays type de champ' do + let!(:procedure3) do + create(:procedure, + types_de_champ_public: [{ type: :pays }], + administrateurs: [admin]) + end + + let!(:pays_tdc) { procedure3.draft_revision.types_de_champ.first } + + before { post :create_simple_routing, params: { procedure_id: procedure3.id, create_simple_routing: { stable_id: pays_tdc.stable_id } } } + + it do + expect(response).to redirect_to(admin_procedure_groupe_instructeurs_path(procedure3)) + expect(flash.notice).to eq 'Les groupes instructeurs ont été ajoutés' + expect(procedure3.groupe_instructeurs.pluck(:label)).to include("AD – Andorre") + expect(procedure3.reload.defaut_groupe_instructeur.routing_rule).to eq(ds_eq(champ_value(pays_tdc.stable_id), constant('AD'))) + expect(procedure3.routing_enabled).to be_truthy + end + end + context 'with a communes type de champ' do let!(:procedure3) do create(:procedure, From aeb1d1c53a4a1cadbc3cd97161883396c1c302a7 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 21 Oct 2024 12:24:12 +0200 Subject: [PATCH 1361/1532] refactor(champ): move champ.blank? implementation to type_de_champ --- app/models/champ.rb | 4 +- app/models/champs/carte_champ.rb | 4 -- app/models/champs/checkbox_champ.rb | 16 ------- app/models/champs/cnaf_champ.rb | 4 -- app/models/champs/cojo_champ.rb | 4 -- app/models/champs/dgfip_champ.rb | 4 -- .../champs/linked_drop_down_list_champ.rb | 5 --- app/models/champs/mesri_champ.rb | 4 -- .../champs/multiple_drop_down_list_champ.rb | 4 -- app/models/champs/pays_champ.rb | 4 -- .../champs/piece_justificative_champ.rb | 8 ---- app/models/champs/pole_emploi_champ.rb | 4 -- app/models/champs/repetition_champ.rb | 4 -- app/models/champs/rnf_champ.rb | 4 -- app/models/champs/siret_champ.rb | 4 -- app/models/champs/titre_identite_champ.rb | 8 ---- app/models/type_de_champ.rb | 42 ++++++++++++------- .../types_de_champ/carte_type_de_champ.rb | 2 + .../types_de_champ/checkbox_type_de_champ.rb | 16 +++---- .../types_de_champ/cnaf_type_de_champ.rb | 2 + .../types_de_champ/cojo_type_de_champ.rb | 2 + .../types_de_champ/dgfip_type_de_champ.rb | 2 + .../linked_drop_down_list_type_de_champ.rb | 41 ++++++++++++------ .../types_de_champ/mesri_type_de_champ.rb | 2 + .../multiple_drop_down_list_type_de_champ.rb | 22 ++++++++-- .../types_de_champ/pays_type_de_champ.rb | 4 ++ .../piece_justificative_type_de_champ.rb | 2 + .../pole_emploi_type_de_champ.rb | 2 + .../repetition_type_de_champ.rb | 6 +-- .../types_de_champ/rnf_type_de_champ.rb | 2 + .../types_de_champ/siret_type_de_champ.rb | 2 + .../titre_identite_type_de_champ.rb | 2 + .../types_de_champ/type_de_champ_base.rb | 3 ++ .../types_de_champ/yes_no_type_de_champ.rb | 16 +++---- spec/models/champ_spec.rb | 2 +- spec/models/champs/checkbox_champ_spec.rb | 6 --- 36 files changed, 122 insertions(+), 141 deletions(-) diff --git a/app/models/champ.rb b/app/models/champ.rb index 02f82d25a..fb7bf56e9 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -99,11 +99,11 @@ class Champ < ApplicationRecord end def mandatory_blank? - mandatory? && blank? + type_de_champ.mandatory_blank?(self) end def blank? - value.blank? + type_de_champ.champ_blank?(self) end def search_terms diff --git a/app/models/champs/carte_champ.rb b/app/models/champs/carte_champ.rb index 49c4e90ac..aa7e1560f 100644 --- a/app/models/champs/carte_champ.rb +++ b/app/models/champs/carte_champ.rb @@ -85,10 +85,6 @@ class Champs::CarteChamp < Champ end end - def blank? - geo_areas.blank? - end - private def selection_utilisateur_legacy_geometry diff --git a/app/models/champs/checkbox_champ.rb b/app/models/champs/checkbox_champ.rb index 2610b6bb0..111eaeb00 100644 --- a/app/models/champs/checkbox_champ.rb +++ b/app/models/champs/checkbox_champ.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true class Champs::CheckboxChamp < Champs::BooleanChamp - def mandatory_blank? - mandatory? && (blank? || !true?) - end - def legend_label? false end @@ -13,11 +9,6 @@ class Champs::CheckboxChamp < Champs::BooleanChamp [[I18n.t('activerecord.attributes.type_de_champ.type_champs.checkbox_true'), true], [I18n.t('activerecord.attributes.type_de_champ.type_champs.checkbox_false'), false]] end - # TODO remove when normalize_checkbox_values is over - def true? - value_with_legacy == TRUE_VALUE - end - def html_label? false end @@ -25,11 +16,4 @@ class Champs::CheckboxChamp < Champs::BooleanChamp def single_checkbox? true end - - private - - # TODO remove when normalize_checkbox_values is over - def value_with_legacy - value == 'on' ? TRUE_VALUE : value - end end diff --git a/app/models/champs/cnaf_champ.rb b/app/models/champs/cnaf_champ.rb index abbca23d1..c0cdc9383 100644 --- a/app/models/champs/cnaf_champ.rb +++ b/app/models/champs/cnaf_champ.rb @@ -8,10 +8,6 @@ class Champs::CnafChamp < Champs::TextChamp store_accessor :value_json, :numero_allocataire, :code_postal - def blank? - external_id.nil? - end - def fetch_external_data? true end diff --git a/app/models/champs/cojo_champ.rb b/app/models/champs/cojo_champ.rb index 14dc41816..a770521eb 100644 --- a/app/models/champs/cojo_champ.rb +++ b/app/models/champs/cojo_champ.rb @@ -20,10 +20,6 @@ class Champs::COJOChamp < Champ accreditation_success == false end - def blank? - accreditation_success != true - end - def fetch_external_data? true end diff --git a/app/models/champs/dgfip_champ.rb b/app/models/champs/dgfip_champ.rb index 3e959659f..c64e55b9a 100644 --- a/app/models/champs/dgfip_champ.rb +++ b/app/models/champs/dgfip_champ.rb @@ -7,10 +7,6 @@ class Champs::DgfipChamp < Champs::TextChamp store_accessor :value_json, :numero_fiscal, :reference_avis - def blank? - external_id.nil? - end - def fetch_external_data? true end diff --git a/app/models/champs/linked_drop_down_list_champ.rb b/app/models/champs/linked_drop_down_list_champ.rb index 7bb349476..65e6c576c 100644 --- a/app/models/champs/linked_drop_down_list_champ.rb +++ b/app/models/champs/linked_drop_down_list_champ.rb @@ -35,11 +35,6 @@ class Champs::LinkedDropDownListChamp < Champ :primary_value end - def blank? - primary_value.blank? || - (has_secondary_options_for_primary? && secondary_value.blank?) - end - def search_terms [primary_value, secondary_value] end diff --git a/app/models/champs/mesri_champ.rb b/app/models/champs/mesri_champ.rb index eab2e5a44..2d8bc2114 100644 --- a/app/models/champs/mesri_champ.rb +++ b/app/models/champs/mesri_champ.rb @@ -4,10 +4,6 @@ class Champs::MesriChamp < Champs::TextChamp # see https://github.com/betagouv/api-particulier/blob/master/src/presentation/middlewares/mesri-input-validation.middleware.ts store_accessor :value_json, :ine - def blank? - external_id.nil? - end - def fetch_external_data? true end diff --git a/app/models/champs/multiple_drop_down_list_champ.rb b/app/models/champs/multiple_drop_down_list_champ.rb index 8ea0bec25..2bc715a06 100644 --- a/app/models/champs/multiple_drop_down_list_champ.rb +++ b/app/models/champs/multiple_drop_down_list_champ.rb @@ -29,10 +29,6 @@ class Champs::MultipleDropDownListChamp < Champ render_as_checkboxes? end - def blank? - selected_options.blank? - end - def in?(options) (selected_options - options).size != selected_options.size end diff --git a/app/models/champs/pays_champ.rb b/app/models/champs/pays_champ.rb index bcd1827b6..a1a6e3d37 100644 --- a/app/models/champs/pays_champ.rb +++ b/app/models/champs/pays_champ.rb @@ -35,10 +35,6 @@ class Champs::PaysChamp < Champs::TextChamp end end - def blank? - value.blank? && external_id.blank? - end - def code external_id || APIGeoService.country_code(value) end diff --git a/app/models/champs/piece_justificative_champ.rb b/app/models/champs/piece_justificative_champ.rb index 5d64239b2..b282aee35 100644 --- a/app/models/champs/piece_justificative_champ.rb +++ b/app/models/champs/piece_justificative_champ.rb @@ -21,12 +21,4 @@ class Champs::PieceJustificativeChamp < Champ def search_terms # We don’t know how to search inside documents yet end - - def mandatory_blank? - mandatory? && !piece_justificative_file.attached? - end - - def blank? - piece_justificative_file.blank? - end end diff --git a/app/models/champs/pole_emploi_champ.rb b/app/models/champs/pole_emploi_champ.rb index 374debacd..5a5d917f4 100644 --- a/app/models/champs/pole_emploi_champ.rb +++ b/app/models/champs/pole_emploi_champ.rb @@ -4,10 +4,6 @@ class Champs::PoleEmploiChamp < Champs::TextChamp # see https://github.com/betagouv/api-particulier/blob/master/src/presentation/middlewares/pole-emploi-input-validation.middleware.ts store_accessor :value_json, :identifiant - def blank? - external_id.nil? - end - def fetch_external_data? true end diff --git a/app/models/champs/repetition_champ.rb b/app/models/champs/repetition_champ.rb index 4bb65c484..e94bbf518 100644 --- a/app/models/champs/repetition_champ.rb +++ b/app/models/champs/repetition_champ.rb @@ -23,10 +23,6 @@ class Champs::RepetitionChamp < Champ rows.last&.first&.focusable_input_id end - def blank? - row_ids.empty? - end - def search_terms # The user cannot enter any information here so it doesn’t make much sense to search end diff --git a/app/models/champs/rnf_champ.rb b/app/models/champs/rnf_champ.rb index 56850c55f..3b9a8fa7a 100644 --- a/app/models/champs/rnf_champ.rb +++ b/app/models/champs/rnf_champ.rb @@ -27,10 +27,6 @@ class Champs::RNFChamp < Champ true end - def blank? - rnf_id.blank? - end - def code_departement address.present? && address['departmentCode'] end diff --git a/app/models/champs/siret_champ.rb b/app/models/champs/siret_champ.rb index bb48c77d9..f124d218d 100644 --- a/app/models/champs/siret_champ.rb +++ b/app/models/champs/siret_champ.rb @@ -6,8 +6,4 @@ class Champs::SiretChamp < Champ def search_terms etablissement.present? ? etablissement.search_terms : [value] end - - def mandatory_blank? - mandatory? && Siret.new(siret: value).invalid? - end end diff --git a/app/models/champs/titre_identite_champ.rb b/app/models/champs/titre_identite_champ.rb index a58cdcdbd..790259f7c 100644 --- a/app/models/champs/titre_identite_champ.rb +++ b/app/models/champs/titre_identite_champ.rb @@ -16,12 +16,4 @@ class Champs::TitreIdentiteChamp < Champ def search_terms # We don’t know how to search inside documents yet end - - def mandatory_blank? - mandatory? && !piece_justificative_file.attached? - end - - def blank? - piece_justificative_file.blank? - end end diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 79bfa7088..11615ddd5 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -709,7 +709,7 @@ class TypeDeChamp < ApplicationRecord end def champ_value(champ) - if use_default_value?(champ) + if champ_blank?(champ) dynamic_type.champ_default_value else dynamic_type.champ_value(champ) @@ -717,7 +717,7 @@ class TypeDeChamp < ApplicationRecord end def champ_value_for_api(champ, version: 2) - if use_default_value?(champ) + if champ_blank?(champ) dynamic_type.champ_default_api_value(version) else dynamic_type.champ_value_for_api(champ, version:) @@ -725,7 +725,7 @@ class TypeDeChamp < ApplicationRecord end def champ_value_for_export(champ, path = :value) - if use_default_value?(champ) + if champ_blank?(champ) dynamic_type.champ_default_export_value(path) else dynamic_type.champ_value_for_export(champ, path) @@ -733,13 +733,35 @@ class TypeDeChamp < ApplicationRecord end def champ_value_for_tag(champ, path = :value) - if use_default_value?(champ) + if champ_blank?(champ) '' else dynamic_type.champ_value_for_tag(champ, path) end end + def champ_blank?(champ) + # no champ + return true if champ.nil? + # type de champ on the revision changed + if champ.last_write_type_champ == type_champ || castable_on_change?(champ.last_write_type_champ, type_champ) + dynamic_type.champ_blank?(champ) + else + true + end + end + + def mandatory_blank?(champ) + # no champ + return true if champ.nil? + # type de champ on the revision changed + if champ.last_write_type_champ == type_champ || castable_on_change?(champ.last_write_type_champ, type_champ) + mandatory? && dynamic_type.champ_blank_or_invalid?(champ) + else + true + end + end + def html_id(row_id = nil) "champ-#{public_id(row_id)}" end @@ -758,18 +780,6 @@ class TypeDeChamp < ApplicationRecord private - def use_default_value?(champ) - # no champ - return true if champ.nil? - # type de champ on the revision changed - if champ.last_write_type_champ != type_champ - return !castable_on_change?(champ.last_write_type_champ, type_champ) - end - # special case for linked drop down champ – it's blank implementation is not what you think - return champ.value.blank? if type_champ == TypeDeChamp.type_champs.fetch(:linked_drop_down_list) - champ.blank? - end - def castable_on_change?(from_type, to_type) case [from_type, to_type] when ['integer_number', 'decimal_number'], # recast numbers automatically diff --git a/app/models/types_de_champ/carte_type_de_champ.rb b/app/models/types_de_champ/carte_type_de_champ.rb index 01cbd2a20..5903c5a2c 100644 --- a/app/models/types_de_champ/carte_type_de_champ.rb +++ b/app/models/types_de_champ/carte_type_de_champ.rb @@ -28,6 +28,8 @@ class TypesDeChamp::CarteTypeDeChamp < TypesDeChamp::TypeDeChampBase champ.geo_areas.map(&:label).join("\n") end + def champ_blank?(champ) = champ.geo_areas.blank? + def columns(procedure_id:, displayable: true, prefix: nil) [] end diff --git a/app/models/types_de_champ/checkbox_type_de_champ.rb b/app/models/types_de_champ/checkbox_type_de_champ.rb index 338352c22..9cf47a847 100644 --- a/app/models/types_de_champ/checkbox_type_de_champ.rb +++ b/app/models/types_de_champ/checkbox_type_de_champ.rb @@ -12,21 +12,17 @@ class TypesDeChamp::CheckboxTypeDeChamp < TypesDeChamp::TypeDeChampBase end def champ_value(champ) - champ.true? ? 'Oui' : 'Non' - end - - def champ_value_for_tag(champ, path = :value) - champ_value(champ) + champ_value_true?(champ) ? 'Oui' : 'Non' end def champ_value_for_export(champ, path = :value) - champ.true? ? 'on' : 'off' + champ_value_true?(champ) ? 'on' : 'off' end def champ_value_for_api(champ, version: 2) case version when 2 - champ.true? ? 'true' : 'false' + champ_value_true?(champ).to_s else super end @@ -48,4 +44,10 @@ class TypesDeChamp::CheckboxTypeDeChamp < TypesDeChamp::TypeDeChampBase nil end end + + def champ_blank_or_invalid?(champ) = !champ_value_true?(champ) + + private + + def champ_value_true?(champ) = champ.value == 'true' end diff --git a/app/models/types_de_champ/cnaf_type_de_champ.rb b/app/models/types_de_champ/cnaf_type_de_champ.rb index 70dafb9a6..9a6d1b58e 100644 --- a/app/models/types_de_champ/cnaf_type_de_champ.rb +++ b/app/models/types_de_champ/cnaf_type_de_champ.rb @@ -4,4 +4,6 @@ class TypesDeChamp::CnafTypeDeChamp < TypesDeChamp::TextTypeDeChamp def estimated_fill_duration(revision) FILL_DURATION_MEDIUM end + + def champ_blank?(champ) = champ.external_id.blank? end diff --git a/app/models/types_de_champ/cojo_type_de_champ.rb b/app/models/types_de_champ/cojo_type_de_champ.rb index d596d6a7c..5d65a80ff 100644 --- a/app/models/types_de_champ/cojo_type_de_champ.rb +++ b/app/models/types_de_champ/cojo_type_de_champ.rb @@ -4,4 +4,6 @@ class TypesDeChamp::COJOTypeDeChamp < TypesDeChamp::TextTypeDeChamp def champ_value(champ) "#{champ.accreditation_number} – #{champ.accreditation_birthdate}" end + + def champ_blank?(champ) = champ.accreditation_success != true end diff --git a/app/models/types_de_champ/dgfip_type_de_champ.rb b/app/models/types_de_champ/dgfip_type_de_champ.rb index ecc107530..7c20a7c7d 100644 --- a/app/models/types_de_champ/dgfip_type_de_champ.rb +++ b/app/models/types_de_champ/dgfip_type_de_champ.rb @@ -4,4 +4,6 @@ class TypesDeChamp::DgfipTypeDeChamp < TypesDeChamp::TextTypeDeChamp def estimated_fill_duration(revision) FILL_DURATION_MEDIUM end + + def champ_blank?(champ) = champ.external_id.blank? end diff --git a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb index 44333e0b2..577936c9b 100644 --- a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb @@ -11,11 +11,6 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas [[path[:libelle], path[:path]]] end - def add_blank_option_when_not_mandatory(options) - return options if mandatory - options.unshift('') - end - def primary_options primary_options = unpack_options.map(&:first) if primary_options.present? @@ -33,15 +28,15 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas end def champ_value(champ) - [champ.primary_value, champ.secondary_value].filter(&:present?).join(' / ') + [primary_value(champ), secondary_value(champ)].filter(&:present?).join(' / ') end def champ_value_for_tag(champ, path = :value) case path when :primary - champ.primary_value + primary_value(champ) when :secondary - champ.secondary_value + secondary_value(champ) when :value champ_value(champ) end @@ -50,23 +45,32 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas def champ_value_for_export(champ, path = :value) case path when :primary - champ.primary_value + primary_value(champ) when :secondary - champ.secondary_value + secondary_value(champ) when :value - "#{champ.primary_value || ''};#{champ.secondary_value || ''}" + "#{primary_value(champ) || ''};#{secondary_value(champ) || ''}" end end def champ_value_for_api(champ, version: 2) case version when 1 - { primary: champ.primary_value, secondary: champ.secondary_value } + { primary: primary_value(champ), secondary: secondary_value(champ) } else super end end + def champ_blank?(champ) + primary_value(champ).blank? && secondary_value(champ).blank? + end + + def champ_blank_or_invalid?(champ) + primary_value(champ).blank? || + (has_secondary_options_for_primary?(champ) && secondary_value(champ).blank?) + end + def columns(procedure_id:, displayable: true, prefix: nil) [ Columns::LinkedDropDownColumn.new( @@ -101,6 +105,19 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas private + def add_blank_option_when_not_mandatory(options) + return options if mandatory + options.unshift('') + end + + def primary_value(champ) = unpack_value(champ.value, 0) + def secondary_value(champ) = unpack_value(champ.value, 1) + def unpack_value(value, index) = value&.then { JSON.parse(_1)[index] rescue nil } + + def has_secondary_options_for_primary?(champ) + primary_value(champ).present? && secondary_options[primary_value(champ)]&.any?(&:present?) + end + def paths paths = super paths.push({ diff --git a/app/models/types_de_champ/mesri_type_de_champ.rb b/app/models/types_de_champ/mesri_type_de_champ.rb index 3d947c0a1..ff0584800 100644 --- a/app/models/types_de_champ/mesri_type_de_champ.rb +++ b/app/models/types_de_champ/mesri_type_de_champ.rb @@ -4,4 +4,6 @@ class TypesDeChamp::MesriTypeDeChamp < TypesDeChamp::TextTypeDeChamp def estimated_fill_duration(revision) FILL_DURATION_MEDIUM end + + def champ_blank?(champ) = champ.external_id.blank? end diff --git a/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb index 3a0d3c2c6..e14bd6cfa 100644 --- a/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb @@ -2,14 +2,30 @@ class TypesDeChamp::MultipleDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBase def champ_value(champ) - champ.selected_options.join(', ') + selected_options(champ).join(', ') end def champ_value_for_tag(champ, path = :value) - ChampPresentations::MultipleDropDownListPresentation.new(champ.selected_options) + ChampPresentations::MultipleDropDownListPresentation.new(selected_options(champ)) end def champ_value_for_export(champ, path = :value) - champ.selected_options.join(', ') + champ_value(champ) + end + + def champ_blank?(champ) = selected_options(champ).blank? + + private + + def selected_options(champ) + return [] if champ.value.blank? + + if champ.last_write_type_champ == TypeDeChamp.type_champs.fetch(:drop_down_list) + [champ.value] + else + JSON.parse(champ.value) + end + rescue JSON::ParserError + [] end end diff --git a/app/models/types_de_champ/pays_type_de_champ.rb b/app/models/types_de_champ/pays_type_de_champ.rb index 54aa9de9b..40d60e1b3 100644 --- a/app/models/types_de_champ/pays_type_de_champ.rb +++ b/app/models/types_de_champ/pays_type_de_champ.rb @@ -23,6 +23,10 @@ class TypesDeChamp::PaysTypeDeChamp < TypesDeChamp::TextTypeDeChamp end end + def champ_blank?(champ) + champ.value.blank? && champ.external_id.blank? + end + private def paths diff --git a/app/models/types_de_champ/piece_justificative_type_de_champ.rb b/app/models/types_de_champ/piece_justificative_type_de_champ.rb index 2c68ef035..5ddd1bef6 100644 --- a/app/models/types_de_champ/piece_justificative_type_de_champ.rb +++ b/app/models/types_de_champ/piece_justificative_type_de_champ.rb @@ -23,6 +23,8 @@ class TypesDeChamp::PieceJustificativeTypeDeChamp < TypesDeChamp::TypeDeChampBas end end + def champ_blank?(champ) = champ.piece_justificative_file.blank? + def columns(procedure_id:, displayable: true, prefix: nil) [] end diff --git a/app/models/types_de_champ/pole_emploi_type_de_champ.rb b/app/models/types_de_champ/pole_emploi_type_de_champ.rb index db6271939..e41113f6a 100644 --- a/app/models/types_de_champ/pole_emploi_type_de_champ.rb +++ b/app/models/types_de_champ/pole_emploi_type_de_champ.rb @@ -4,4 +4,6 @@ class TypesDeChamp::PoleEmploiTypeDeChamp < TypesDeChamp::TextTypeDeChamp def estimated_fill_duration(revision) FILL_DURATION_MEDIUM end + + def champ_blank?(champ) = champ.external_id.blank? end diff --git a/app/models/types_de_champ/repetition_type_de_champ.rb b/app/models/types_de_champ/repetition_type_de_champ.rb index b1325472f..28db1512d 100644 --- a/app/models/types_de_champ/repetition_type_de_champ.rb +++ b/app/models/types_de_champ/repetition_type_de_champ.rb @@ -3,9 +3,7 @@ class TypesDeChamp::RepetitionTypeDeChamp < TypesDeChamp::TypeDeChampBase def champ_value_for_tag(champ, path = :value) return nil if path != :value - return champ_default_value if champ.rows.blank? - - ChampPresentations::RepetitionPresentation.new(champ.libelle, champ.rows) + ChampPresentations::RepetitionPresentation.new(libelle, champ.dossier.project_rows_for(@type_de_champ)) end def estimated_fill_duration(revision) @@ -32,4 +30,6 @@ class TypesDeChamp::RepetitionTypeDeChamp < TypesDeChamp::TypeDeChampBase .all_revisions_types_de_champ(parent: @type_de_champ) .flat_map { _1.columns(procedure_id:, displayable: false, prefix: libelle) } end + + def champ_blank?(champ) = champ.dossier.repetition_row_ids(@type_de_champ).blank? end diff --git a/app/models/types_de_champ/rnf_type_de_champ.rb b/app/models/types_de_champ/rnf_type_de_champ.rb index b93ae872e..aacad9bae 100644 --- a/app/models/types_de_champ/rnf_type_de_champ.rb +++ b/app/models/types_de_champ/rnf_type_de_champ.rb @@ -33,6 +33,8 @@ class TypesDeChamp::RNFTypeDeChamp < TypesDeChamp::TextTypeDeChamp end end + def champ_blank?(champ) = champ.external_id.blank? + private def paths diff --git a/app/models/types_de_champ/siret_type_de_champ.rb b/app/models/types_de_champ/siret_type_de_champ.rb index b7ed7732b..a75a091d2 100644 --- a/app/models/types_de_champ/siret_type_de_champ.rb +++ b/app/models/types_de_champ/siret_type_de_champ.rb @@ -6,4 +6,6 @@ class TypesDeChamp::SiretTypeDeChamp < TypesDeChamp::TypeDeChampBase def estimated_fill_duration(revision) FILL_DURATION_MEDIUM end + + def champ_blank_or_invalid?(champ) = Siret.new(siret: champ.value).invalid? end diff --git a/app/models/types_de_champ/titre_identite_type_de_champ.rb b/app/models/types_de_champ/titre_identite_type_de_champ.rb index 617603c10..0cbd1e906 100644 --- a/app/models/types_de_champ/titre_identite_type_de_champ.rb +++ b/app/models/types_de_champ/titre_identite_type_de_champ.rb @@ -22,6 +22,8 @@ class TypesDeChamp::TitreIdentiteTypeDeChamp < TypesDeChamp::TypeDeChampBase "absent" end + def champ_blank?(champ) = champ.piece_justificative_file.blank? + def columns(procedure_id:, displayable: nil, prefix: nil) [ Columns::TitreIdentiteColumn.new( diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index e4babe257..336193dea 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -92,6 +92,9 @@ class TypesDeChamp::TypeDeChampBase end end + def champ_blank?(champ) = champ.value.blank? + def champ_blank_or_invalid?(champ) = champ_blank?(champ) + def columns(procedure_id:, displayable: true, prefix: nil) if fillable? [ diff --git a/app/models/types_de_champ/yes_no_type_de_champ.rb b/app/models/types_de_champ/yes_no_type_de_champ.rb index 947e31d79..1821ce4cb 100644 --- a/app/models/types_de_champ/yes_no_type_de_champ.rb +++ b/app/models/types_de_champ/yes_no_type_de_champ.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class TypesDeChamp::YesNoTypeDeChamp < TypesDeChamp::CheckboxTypeDeChamp +class TypesDeChamp::YesNoTypeDeChamp < TypesDeChamp::TypeDeChampBase def filter_to_human(filter_value) if filter_value == "true" I18n.t('activerecord.attributes.type_de_champ.type_champs.yes_no_true') @@ -12,21 +12,17 @@ class TypesDeChamp::YesNoTypeDeChamp < TypesDeChamp::CheckboxTypeDeChamp end def champ_value(champ) - champ_formatted_value(champ) - end - - def champ_value_for_tag(champ, path = :value) - champ_formatted_value(champ) + champ_value_true?(champ) ? 'Oui' : 'Non' end def champ_value_for_export(champ, path = :value) - champ_formatted_value(champ) + champ_value_true?(champ) ? 'Oui' : 'Non' end def champ_value_for_api(champ, version: 2) case version when 2 - champ.true? ? 'true' : 'false' + champ_value_true?(champ).to_s else super end @@ -42,7 +38,7 @@ class TypesDeChamp::YesNoTypeDeChamp < TypesDeChamp::CheckboxTypeDeChamp private - def champ_formatted_value(champ) - champ.true? ? 'Oui' : 'Non' + def champ_value_true?(champ) + champ.value == 'true' end end diff --git a/spec/models/champ_spec.rb b/spec/models/champ_spec.rb index 3c2368f95..dd4598017 100644 --- a/spec/models/champ_spec.rb +++ b/spec/models/champ_spec.rb @@ -5,7 +5,7 @@ describe Champ do describe 'mandatory_blank?' do let(:type_de_champ) { build(:type_de_champ, mandatory: mandatory) } - let(:champ) { Champ.new(value: value) } + let(:champ) { Champs::TextChamp.new(value: value) } let(:value) { '' } let(:mandatory) { true } diff --git a/spec/models/champs/checkbox_champ_spec.rb b/spec/models/champs/checkbox_champ_spec.rb index 6c93e3176..720ae1ec4 100644 --- a/spec/models/champs/checkbox_champ_spec.rb +++ b/spec/models/champs/checkbox_champ_spec.rb @@ -9,12 +9,6 @@ describe Champs::CheckboxChamp do describe '#true?' do subject { boolean_champ.true? } - context "when the checkbox value is 'on'" do - let(:value) { 'on' } - - it { is_expected.to eq(true) } - end - context "when the checkbox value is 'off'" do let(:value) { 'off' } From 415be4f9ea3e1eb1b23b977317cc67c5e834ddaa Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 4 Nov 2024 10:56:12 +0100 Subject: [PATCH 1362/1532] feat(type_de_champ): test value casts --- app/models/type_de_champ.rb | 2 +- spec/models/type_de_champ_spec.rb | 62 +++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 11615ddd5..b979fdf45 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -785,7 +785,7 @@ class TypeDeChamp < ApplicationRecord when ['integer_number', 'decimal_number'], # recast numbers automatically ['decimal_number', 'integer_number'], # may lose some data, but who cares ? ['text', 'textarea'], # allow short text to long text - # ['drop_down_list', 'multiple_drop_down_list'], # single list can become multi + ['drop_down_list', 'multiple_drop_down_list'], # single list can become multi ['date', 'datetime'], # date <=> datetime ['datetime', 'date'] # may lose some data, but who cares ? true diff --git a/spec/models/type_de_champ_spec.rb b/spec/models/type_de_champ_spec.rb index a0481a31e..605ddb961 100644 --- a/spec/models/type_de_champ_spec.rb +++ b/spec/models/type_de_champ_spec.rb @@ -413,4 +413,66 @@ describe TypeDeChamp do end end end + + describe 'champ_value with cast' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: type_champ }]) } + let(:dossier) { create(:dossier, procedure:) } + let(:type_champ) { :text } + let(:last_write_type_champ) { :text } + let(:champ_value) { 'hello' } + let(:champ_type) { TypeDeChamp.type_champ_to_champ_class_name(last_write_type_champ.to_s) } + let(:type_de_champ) { procedure.active_revision.types_de_champ.first } + let(:champ) { dossier.champs.first } + + subject { champ.update_columns(type: champ_type, value: champ_value); type_de_champ.champ_value(champ) } + + it { expect(subject).to eq('hello') } + + context 'text -> integer_number' do + let(:last_write_type_champ) { :text } + let(:type_champ) { :integer_number } + + it { expect(subject).to eq('') } + end + + context 'integer_number -> text' do + let(:last_write_type_champ) { :integer_number } + let(:type_champ) { :text } + let(:champ_value) { '42' } + + it { expect(subject).to eq('') } + end + + context 'integer_number -> decimal_number' do + let(:last_write_type_champ) { :integer_number } + let(:type_champ) { :decimal_number } + let(:champ_value) { '42' } + + it { expect(subject).to eq('42') } + end + + context 'decimal_number -> integer_number' do + let(:last_write_type_champ) { :decimal_number } + let(:type_champ) { :integer_number } + let(:champ_value) { '42.1' } + + it { expect(subject).to eq('42.1') } + end + + context 'drop_down_list -> multiple_drop_down_list' do + let(:last_write_type_champ) { :drop_down_list } + let(:type_champ) { :multiple_drop_down_list } + let(:champ_value) { type_de_champ.drop_down_options.first } + + it { expect(subject).to eq(champ_value) } + end + + context 'multiple_drop_down_list -> drop_down_list' do + let(:last_write_type_champ) { :multiple_drop_down_list } + let(:type_champ) { :drop_down_list } + let(:champ_value) { "[\"#{type_de_champ.drop_down_options.first}\"]" } + + it { expect(subject).to eq('') } + end + end end From 1c99a7c18758d0d2a31d3a55e7f03a375f9899d5 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Wed, 30 Oct 2024 17:28:34 +0100 Subject: [PATCH 1363/1532] fix(character_limit_base) Displays the number of characters recommended for everyone --- .../champ_label_content_component.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml index 297b84b64..5a65163b7 100644 --- a/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml +++ b/app/components/editable_champ/champ_label_content_component/champ_label_content_component.html.haml @@ -20,5 +20,5 @@ - if @champ.description.present? %span.fr-hint-text= render SimpleFormatComponent.new(@champ.description, allow_a: true) -- if @champ.textarea? - %span.sr-only= t('.recommended_size', size: @champ.character_limit_base) +- if @champ.textarea? && @champ.character_limit_base&.positive? + %span.fr-hint-text= t('.recommended_size', size: @champ.character_limit_base) From d7c97f5775bb1972775a95a0aeb10486947b9607 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Wed, 30 Oct 2024 17:39:20 +0100 Subject: [PATCH 1364/1532] fix(fr-hint-text) Ensures that screen readers can read the point in the decimal number --- config/locales/models/champs/decimal_number_champ/en.yml | 2 +- config/locales/models/champs/decimal_number_champ/fr.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/locales/models/champs/decimal_number_champ/en.yml b/config/locales/models/champs/decimal_number_champ/en.yml index 3866bd120..a69dda4a8 100644 --- a/config/locales/models/champs/decimal_number_champ/en.yml +++ b/config/locales/models/champs/decimal_number_champ/en.yml @@ -3,4 +3,4 @@ en: attributes: champs/decimal_number_champ: hints: - value: "You can enter up to 3 decimal places after the decimal point. Example: 3.141" + value_html: "You can enter up to 3 decimal places after the decimal point. Example: 3.141" diff --git a/config/locales/models/champs/decimal_number_champ/fr.yml b/config/locales/models/champs/decimal_number_champ/fr.yml index 88b3d422e..248ee2930 100644 --- a/config/locales/models/champs/decimal_number_champ/fr.yml +++ b/config/locales/models/champs/decimal_number_champ/fr.yml @@ -3,4 +3,4 @@ fr: attributes: champs/decimal_number_champ: hints: - value: "Vous pouvez saisir jusqu’à 3 décimales après le point. Exemple: 3.141" + value_html: "Vous pouvez saisir jusqu’à 3 décimales après le point. Exemple: 3 point 141" From f457c174ee0b2cc0569f46aa178602201b5257a5 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Wed, 30 Oct 2024 17:57:36 +0100 Subject: [PATCH 1365/1532] fix(character_limit) Return number of remaining or excess characters to screen reader --- .../textarea_component/textarea_component.en.yml | 2 +- .../textarea_component/textarea_component.fr.yml | 2 +- .../textarea_component/textarea_component.html.haml | 13 +++++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/components/editable_champ/textarea_component/textarea_component.en.yml b/app/components/editable_champ/textarea_component/textarea_component.en.yml index 8e9b49901..760b46d14 100644 --- a/app/components/editable_champ/textarea_component/textarea_component.en.yml +++ b/app/components/editable_champ/textarea_component/textarea_component.en.yml @@ -1,3 +1,3 @@ en: remaining_characters: You have %{remaining_words} characters remaining. - excess_characters: You have %{excess_words} characters too many. + excess_characters: You have exceeded the recommended size of %{excess_words} characters. Please reduce the number of characters. diff --git a/app/components/editable_champ/textarea_component/textarea_component.fr.yml b/app/components/editable_champ/textarea_component/textarea_component.fr.yml index fa8fafdc1..c2f36291b 100644 --- a/app/components/editable_champ/textarea_component/textarea_component.fr.yml +++ b/app/components/editable_champ/textarea_component/textarea_component.fr.yml @@ -1,3 +1,3 @@ fr: remaining_characters: Il vous reste %{remaining_words} caractères. - excess_characters: Vous avez dépassé la taille conseillée de %{excess_words} caractères. Réduire le nombre de caractères. + excess_characters: Vous avez dépassé la taille conseillée de %{excess_words} caractères. Veuillez réduire le nombre de caractères. diff --git a/app/components/editable_champ/textarea_component/textarea_component.html.haml b/app/components/editable_champ/textarea_component/textarea_component.html.haml index e6c7f20cd..cc76ab438 100644 --- a/app/components/editable_champ/textarea_component/textarea_component.html.haml +++ b/app/components/editable_champ/textarea_component/textarea_component.html.haml @@ -1,9 +1,10 @@ ~ @form.text_area(:value, input_opts(id: @champ.input_id, aria: { describedby: @champ.describedby_id }, rows: 6, required: @champ.required?, value: html_to_string(@champ.value), data: { controller: 'autoresize' })) -- if @champ.character_limit_info? - %p.fr-info-text - = t('.remaining_characters', remaining_words: @champ.remaining_characters) +%div{ role: 'status' } + - if @champ.character_limit_info? + %p.fr-info-text + = t('.remaining_characters', remaining_words: @champ.remaining_characters) -- if @champ.character_limit_warning? - %p.fr-icon--sm.fr-mt-4v.fr-mb-0.fr-hint-text.fr-icon-warning-fill.fr-text-default--warning.characters-count - = t('.excess_characters', excess_words: @champ.excess_characters) + - if @champ.character_limit_warning? + %p.fr-icon--sm.fr-mt-4v.fr-mb-0.fr-hint-text.fr-icon-warning-fill.fr-text-default--warning + = t('.excess_characters', excess_words: @champ.excess_characters) From a4054053f71693f2d07865152b7d892af573376a Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 26 Sep 2024 18:27:58 +0200 Subject: [PATCH 1366/1532] refactor(admin): move siret input up --- app/views/administrateurs/services/_form.html.haml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/views/administrateurs/services/_form.html.haml b/app/views/administrateurs/services/_form.html.haml index 9c6040508..2ac6fb5ff 100644 --- a/app/views/administrateurs/services/_form.html.haml +++ b/app/views/administrateurs/services/_form.html.haml @@ -1,5 +1,12 @@ = form_with model: [ :admin, service], local: true do |f| + = render Dsfr::InputComponent.new(form: f, attribute: :siret, input_type: :text_field, opts: { placeholder: "14 chiffres, sans espace" }) do |c| + - c.with_hint do + = "Indiquez le numéro de SIRET de l’organisme dont ce service dépend. Rechercher le SIRET sur " + = link_to("annuaire-entreprises.data.gouv.fr", annuaire_link, **external_link_attributes) + %br + = "Nous préremplirons les informations de contact à partir de l’Annuaire Service Public correspondant." + = render Dsfr::InputComponent.new(form: f, attribute: :nom, input_type: :text_field) = render Dsfr::InputComponent.new(form: f, attribute: :organisme, input_type: :text_field) @@ -10,11 +17,6 @@ = f.select :type_organisme, Service.type_organismes.keys.map { |key| [ I18n.t("type_organisme.#{key}"), key] }, {}, class: 'fr-select' - = render Dsfr::InputComponent.new(form: f, attribute: :siret, input_type: :text_field, opts: { placeholder: "14 chiffres, sans espace" }) do |c| - - c.with_hint do - = "Indiquez le numéro de SIRET de l’organisme dont ce service dépend. Rechercher le SIRET sur " - = link_to("annuaire-entreprises.data.gouv.fr", annuaire_link, **external_link_attributes) - = render Dsfr::CalloutComponent.new(title: "Informations de contact") do |c| - c.with_body do Votre démarche sera hébergée par #{Current.application_name} – mais nous ne pouvons pas assurer le support des démarches. Et malgré la dématérialisation, les usagers se poseront parfois des questions légitimes sur le processus administratif. From 8dc47c1b93e8a192cfb99ec0e356564769a40aa3 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 7 Oct 2024 18:07:37 +0200 Subject: [PATCH 1367/1532] feat(service): prefill contact information from annuaire service public --- ...prefillable_from_service_public_concern.rb | 54 ++++++++++ app/models/service.rb | 2 + app/schemas/service-public.json | 76 +++++++++++++ .../annuaire_service_public_service.rb | 52 +++++++++ app/services/api_geo_service.rb | 15 +++ ..._service_public_failure_20004021000000.yml | 70 ++++++++++++ ..._service_public_success_20004021000060.yml | 100 ++++++++++++++++++ ...llable_from_service_public_concern_spec.rb | 93 ++++++++++++++++ 8 files changed, 462 insertions(+) create mode 100644 app/models/concerns/prefillable_from_service_public_concern.rb create mode 100644 app/schemas/service-public.json create mode 100644 app/services/annuaire_service_public_service.rb create mode 100644 spec/fixtures/cassettes/annuaire_service_public_failure_20004021000000.yml create mode 100644 spec/fixtures/cassettes/annuaire_service_public_success_20004021000060.yml create mode 100644 spec/models/concerns/prefillable_from_service_public_concern_spec.rb diff --git a/app/models/concerns/prefillable_from_service_public_concern.rb b/app/models/concerns/prefillable_from_service_public_concern.rb new file mode 100644 index 000000000..ba421dc49 --- /dev/null +++ b/app/models/concerns/prefillable_from_service_public_concern.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module PrefillableFromServicePublicConcern + extend ActiveSupport::Concern + + included do + def prefill_from_siret + result = AnnuaireServicePublicService.new.(siret:) + # TODO: get organisme, … from API Entreprise + case result + in Dry::Monads::Success(data) + self.nom = data[:nom] if nom.blank? + self.email = data[:adresse_courriel] if email.blank? + self.telephone = data[:telephone]&.first&.dig("valeur") if telephone.blank? + self.horaires = denormalize_plage_ouverture(data[:plage_ouverture]) if horaires.blank? + self.adresse = APIGeoService.inline_service_public_address(data[:adresse]&.first) if adresse.blank? + else + # NOOP + end + + result + end + + private + + def denormalize_plage_ouverture(data) + return if data.blank? + + data.map do |range| + day_range = range.values_at('nom_jour_debut', 'nom_jour_fin').uniq.join(' au ') + + hours_range = (1..2).each_with_object([]) do |i, hours| + start_hour = range["valeur_heure_debut_#{i}"] + end_hour = range["valeur_heure_fin_#{i}"] + + if start_hour.present? && end_hour.present? + hours << "de #{format_time(start_hour)} à #{format_time(end_hour)}" + end + end + + result = day_range + result += " : #{hours_range.join(' et ')}" if hours_range.present? + result += " (#{range['commentaire']})" if range['commentaire'].present? + result + end.join("\n") + end + + def format_time(str_time) + Time.zone + .parse(str_time) + .strftime("%-H:%M") + end + end +end diff --git a/app/models/service.rb b/app/models/service.rb index 67d6ac761..487bc15af 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Service < ApplicationRecord + include PrefillableFromServicePublicConcern + has_many :procedures belongs_to :administrateur, optional: false diff --git a/app/schemas/service-public.json b/app/schemas/service-public.json new file mode 100644 index 000000000..f5ceb71c5 --- /dev/null +++ b/app/schemas/service-public.json @@ -0,0 +1,76 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://demarches-simplifiees.fr/service-public.schema.json", + "title": "Service Public", + "type": "object", + "properties": { + "total_count": { + "type": "integer" + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "plage_ouverture": { "type": ["string", "null"] }, + "site_internet": { "type": ["string", "null"] }, + "copyright": { "type": ["string", "null"] }, + "siren": { "type": ["string", "null"] }, + "ancien_code_pivot": { "type": ["string", "null"] }, + "reseau_social": { "type": ["string", "null"] }, + "texte_reference": { "type": ["string", "null"] }, + "partenaire": { "type": ["string", "null"] }, + "telecopie": { "type": ["string", "null"] }, + "nom": { "type": "string" }, + "siret": { "type": ["string", "null"] }, + "itm_identifiant": { "type": ["string", "null"] }, + "sigle": { "type": ["string", "null"] }, + "affectation_personne": { "type": ["string", "null"] }, + "date_modification": { "type": "string" }, + "adresse_courriel": { "type": ["string", "null"] }, + "service_disponible": { "type": ["string", "null"] }, + "organigramme": { "type": ["string", "null"] }, + "pivot": { "type": ["string", "null"] }, + "partenaire_identifiant": { "type": ["string", "null"] }, + "ancien_identifiant": { "type": ["string", "null"] }, + "id": { "type": "string" }, + "ancien_nom": { "type": ["string", "null"] }, + "commentaire_plage_ouverture": { "type": ["string", "null"] }, + "annuaire": { "type": ["string", "null"] }, + "tchat": { "type": ["string", "null"] }, + "hierarchie": { "type": ["string", "null"] }, + "categorie": { "type": "string" }, + "sve": { "type": ["string", "null"] }, + "telephone_accessible": { "type": ["string", "null"] }, + "application_mobile": { "type": ["string", "null"] }, + "version_type": { "type": "string" }, + "type_repertoire": { "type": ["string", "null"] }, + "telephone": { "type": ["string", "null"] }, + "version_etat_modification": { "type": ["string", "null"] }, + "date_creation": { "type": "string" }, + "partenaire_date_modification": { "type": ["string", "null"] }, + "mission": { "type": ["string", "null"] }, + "formulaire_contact": { "type": ["string", "null"] }, + "version_source": { "type": ["string", "null"] }, + "type_organisme": { "type": ["string", "null"] }, + "code_insee_commune": { "type": ["string", "null"] }, + "statut_de_diffusion": { "type": ["string", "null"] }, + "adresse": { "type": ["string", "null"] }, + "url_service_public": { "type": ["string", "null"] }, + "information_complementaire": { "type": ["string", "null"] }, + "date_diffusion": { "type": ["string", "null"] } + }, + "required": [ + "id", + "nom", + "categorie", + "adresse", + "adresse_courriel", + "telephone", + "plage_ouverture" + ] + } + } + }, + "required": ["total_count", "results"] +} diff --git a/app/services/annuaire_service_public_service.rb b/app/services/annuaire_service_public_service.rb new file mode 100644 index 000000000..7c5728fd8 --- /dev/null +++ b/app/services/annuaire_service_public_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class AnnuaireServicePublicService + include Dry::Monads[:result] + + def call(siret:) + result = API::Client.new.call(url: url(siret), schema:, timeout: 1.second) + + case result + in Success(body:) + result = body[:results].first + + if result.present? + Success( + result.slice(:nom, :adresse, :adresse_courriel).merge( + telephone: maybe_json_parse(result[:telephone]), + plage_ouverture: maybe_json_parse(result[:plage_ouverture]), + adresse: maybe_json_parse(result[:adresse]) + ) + ) + else + Failure(API::Client::Error[:not_found, 404, false, "No result found for this SIRET."]) + end + in Failure(code:, reason:) if code.in?(401..403) + Sentry.capture_message("#{self.class.name}: #{reason} code: #{code}", extra: { siret: }) + Failure(API::Client::Error[:unauthorized, code, false, reason]) + in Failure(type: :schema, code:, reason:) + reason.errors[0].first + Sentry.capture_exception(reason, extra: { siret:, code: }) + + Failure(API::Client::Error[:schema, code, false, reason]) + else + result + end + end + + private + + def schema + JSONSchemer.schema(Rails.root.join('app/schemas/service-public.json')) + end + + def url(siret) + "https://api-lannuaire.service-public.fr/api/explore/v2.1/catalog/datasets/api-lannuaire-administration/records?where=siret:#{siret}" + end + + def maybe_json_parse(value) + return nil if value.blank? + + JSON.parse(value) + end +end diff --git a/app/services/api_geo_service.rb b/app/services/api_geo_service.rb index c12804c2a..a6d051e36 100644 --- a/app/services/api_geo_service.rb +++ b/app/services/api_geo_service.rb @@ -263,6 +263,21 @@ class APIGeoService end end + def inline_service_public_address(address_data) + return nil if address_data.blank? + + components = [ + address_data['numero_voie'], + address_data['complement1'], + address_data['complement2'], + address_data['service_distribution'], + address_data['code_postal'], + address_data['nom_commune'] + ].compact_blank + + components.join(' ') + end + private def code_metropole?(result) diff --git a/spec/fixtures/cassettes/annuaire_service_public_failure_20004021000000.yml b/spec/fixtures/cassettes/annuaire_service_public_failure_20004021000000.yml new file mode 100644 index 000000000..f38317634 --- /dev/null +++ b/spec/fixtures/cassettes/annuaire_service_public_failure_20004021000000.yml @@ -0,0 +1,70 @@ +--- +http_interactions: +- request: + method: get + uri: https://api-lannuaire.service-public.fr/api/explore/v2.1/catalog/datasets/api-lannuaire-administration/records?where=siret:20004021000000 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - demarches.gouv.fr + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + Server: + - openresty + Date: + - Mon, 07 Oct 2024 14:41:59 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '33' + X-Ratelimit-Remaining: + - '999978' + X-Ratelimit-Limit: + - '1000000' + X-Ratelimit-Reset: + - '2024-10-08 00:00:00+00:00' + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Vary: + - Accept-Language, Cookie, Host + Content-Language: + - fr-fr + Access-Control-Allow-Origin: + - "*" + Access-Control-Allow-Methods: + - POST, GET, OPTIONS + Access-Control-Max-Age: + - '1000' + Access-Control-Allow-Headers: + - Authorization, X-Requested-With, Origin, ODS-API-Analytics-App, ODS-API-Analytics-Embed-Type, + ODS-API-Analytics-Embed-Referrer, ODS-Widgets-Version, Accept + Access-Control-Expose-Headers: + - ODS-Explore-API-Deprecation, Link, X-RateLimit-Remaining, X-RateLimit-Limit, + X-RateLimit-Reset, X-RateLimit-dataset-Remaining, X-RateLimit-dataset-Limit, + X-RateLimit-dataset-Reset + Strict-Transport-Security: + - max-age=31536000 + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + Referrer-Policy: + - strict-origin-when-cross-origin + Permissions-Policy: + - midi=(),microphone=(),camera=(),magnetometer=(),gyroscope=(),fullscreen=(self),payment=() + Content-Security-Policy: + - upgrade-insecure-requests; + X-Ua-Compatible: + - IE=edge + body: + encoding: ASCII-8BIT + string: '{"total_count": 0, "results": []}' + recorded_at: Mon, 07 Oct 2024 14:41:59 GMT +recorded_with: VCR 6.2.0 diff --git a/spec/fixtures/cassettes/annuaire_service_public_success_20004021000060.yml b/spec/fixtures/cassettes/annuaire_service_public_success_20004021000060.yml new file mode 100644 index 000000000..92b868dbf --- /dev/null +++ b/spec/fixtures/cassettes/annuaire_service_public_success_20004021000060.yml @@ -0,0 +1,100 @@ +--- +http_interactions: +- request: + method: get + uri: https://api-lannuaire.service-public.fr/api/explore/v2.1/catalog/datasets/api-lannuaire-administration/records?where=siret:20004021000060 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - demarches.gouv.fr + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + Server: + - openresty + Date: + - Mon, 07 Oct 2024 14:41:57 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '2573' + X-Ratelimit-Remaining: + - '999979' + X-Ratelimit-Limit: + - '1000000' + X-Ratelimit-Reset: + - '2024-10-08 00:00:00+00:00' + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Vary: + - Accept-Language, Cookie, Host + Content-Language: + - fr-fr + Access-Control-Allow-Origin: + - "*" + Access-Control-Allow-Methods: + - POST, GET, OPTIONS + Access-Control-Max-Age: + - '1000' + Access-Control-Allow-Headers: + - Authorization, X-Requested-With, Origin, ODS-API-Analytics-App, ODS-API-Analytics-Embed-Type, + ODS-API-Analytics-Embed-Referrer, ODS-Widgets-Version, Accept + Access-Control-Expose-Headers: + - ODS-Explore-API-Deprecation, Link, X-RateLimit-Remaining, X-RateLimit-Limit, + X-RateLimit-Reset, X-RateLimit-dataset-Remaining, X-RateLimit-dataset-Limit, + X-RateLimit-dataset-Reset + Strict-Transport-Security: + - max-age=31536000 + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + Referrer-Policy: + - strict-origin-when-cross-origin + Permissions-Policy: + - midi=(),microphone=(),camera=(),magnetometer=(),gyroscope=(),fullscreen=(self),payment=() + Content-Security-Policy: + - upgrade-insecure-requests; + X-Ua-Compatible: + - IE=edge + body: + encoding: ASCII-8BIT + string: '{"total_count": 1, "results": [{"plage_ouverture": "[{\"nom_jour_debut\": + \"Lundi\", \"nom_jour_fin\": \"Jeudi\", \"valeur_heure_debut_1\": \"08:00:00\", + \"valeur_heure_fin_1\": \"12:00:00\", \"valeur_heure_debut_2\": \"13:30:00\", + \"valeur_heure_fin_2\": \"17:30:00\", \"commentaire\": \"\"}, {\"nom_jour_debut\": + \"Vendredi\", \"nom_jour_fin\": \"Vendredi\", \"valeur_heure_debut_1\": \"08:00:00\", + \"valeur_heure_fin_1\": \"12:00:00\", \"valeur_heure_debut_2\": \"\", \"valeur_heure_fin_2\": + \"\", \"commentaire\": \"\"}]", "site_internet": "[{\"libelle\": \"\", \"valeur\": + \"https://www.cc-lacsgorgesverdon.fr/\"}]", "copyright": "Direction de l''information + l\u00e9gale et administrative (Premier ministre)", "siren": "200040210", "ancien_code_pivot": + "epci-83007-01", "reseau_social": null, "texte_reference": null, "partenaire": + null, "telecopie": null, "nom": "Communaut\u00e9 de communes - Lacs et Gorges + du Verdon", "siret": "20004021000060", "itm_identifiant": "999974", "sigle": + null, "affectation_personne": null, "date_modification": "31/01/2024 14:25:10", + "adresse_courriel": "redacted@email.fr", "service_disponible": null, "organigramme": + null, "pivot": "[{\"type_service_local\": \"epci\", \"code_insee_commune\": + [\"83007\"]}]", "partenaire_identifiant": null, "ancien_identifiant": null, + "id": "3b9ce22d-f7bd-46d9-82d1-8b44c8d08e39", "ancien_nom": null, "commentaire_plage_ouverture": + null, "annuaire": null, "tchat": null, "hierarchie": null, "categorie": "SL", + "sve": null, "telephone_accessible": null, "application_mobile": null, "version_type": + "Publiable", "type_repertoire": null, "telephone": "[{\"valeur\": \"04 94 + 70 00 00\", \"description\": \"\"}]", "version_etat_modification": null, "date_creation": + "11/05/2017 11:28:41", "partenaire_date_modification": null, "mission": null, + "formulaire_contact": "https://www.cc-lacsgorgesverdon.fr/contacts-comcom", + "version_source": null, "type_organisme": null, "code_insee_commune": "83007", + "statut_de_diffusion": "true", "adresse": "[{\"type_adresse\": \"Adresse\", + \"complement1\": \"\", \"complement2\": \"\", \"numero_voie\": \"242 avenue + Albert-1er\", \"service_distribution\": \"\", \"code_postal\": \"83630\", + \"nom_commune\": \"Aups\", \"pays\": \"\", \"continent\": \"\", \"longitude\": + \"6.22516\", \"latitude\": \"43.627448\", \"accessibilite\": \"ACC\", \"note_accessibilite\": + \"ascenseur\"}]", "url_service_public": "https://lannuaire.service-public.fr/provence-alpes-cote-d-azur/var/3b9ce22d-f7bd-46d9-82d1-8b44c8d08e39", + "information_complementaire": null, "date_diffusion": null}]}' + recorded_at: Mon, 07 Oct 2024 14:41:57 GMT +recorded_with: VCR 6.2.0 diff --git a/spec/models/concerns/prefillable_from_service_public_concern_spec.rb b/spec/models/concerns/prefillable_from_service_public_concern_spec.rb new file mode 100644 index 000000000..2475972ae --- /dev/null +++ b/spec/models/concerns/prefillable_from_service_public_concern_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PrefillableFromServicePublicConcern, type: :model do + let(:siret) { '20004021000060' } + let(:service) { build(:service, siret:) } + + describe '#prefill_from_siret' do + let(:service) { Service.new(siret:) } + subject { service.prefill_from_siret } + context 'when API call is successful' do + it 'prefills service attributes' do + VCR.use_cassette('annuaire_service_public_success_20004021000060') do + expect(subject).to be_success + + expect(service.nom).to eq("Communauté de communes - Lacs et Gorges du Verdon") + expect(service.email).to eq("redacted@email.fr") + expect(service.telephone).to eq("04 94 70 00 00") + expect(service.horaires).to eq("Lundi au Jeudi : de 8:00 à 12:00 et de 13:30 à 17:30\nVendredi : de 8:00 à 12:00") + expect(service.adresse).to eq("242 avenue Albert-1er 83630 Aups") + end + end + + it 'does not overwrite existing attributes' do + service.nom = "Existing Name" + service.email = "existing@email.com" + + VCR.use_cassette('annuaire_service_public_success_20004021000060') do + service.prefill_from_siret + + expect(service.nom).to eq("Existing Name") + expect(service.email).to eq("existing@email.com") + end + end + end + + context 'when API call do not find siret' do + let(:siret) { '20004021000000' } + it 'returns a failure result' do + VCR.use_cassette('annuaire_service_public_failure_20004021000000') do + expect(subject).to be_failure + end + end + end + end + + describe '#denormalize_plage_ouverture' do + it 'correctly formats opening hours with one time range' do + data = [ + { + "nom_jour_debut" => "Lundi", + "nom_jour_fin" => "Vendredi", + "valeur_heure_debut_1" => "09:00:00", + "valeur_heure_fin_1" => "17:00:00" + } + ] + expect(service.send(:denormalize_plage_ouverture, data)).to eq("Lundi au Vendredi : de 9:00 à 17:00") + end + + it 'correctly formats opening hours with two time ranges' do + data = [ + { + "nom_jour_debut" => "Lundi", + "nom_jour_fin" => "Jeudi", + "valeur_heure_debut_1" => "08:00:00", + "valeur_heure_fin_1" => "12:00:00", + "valeur_heure_debut_2" => "13:30:00", + "valeur_heure_fin_2" => "17:30:00" + }, { + "nom_jour_debut" => "Vendredi", + "nom_jour_fin" => "Vendredi", + "valeur_heure_debut_1" => "08:00:00", + "valeur_heure_fin_1" => "12:00:00" + } + ] + expect(service.send(:denormalize_plage_ouverture, data)).to eq("Lundi au Jeudi : de 8:00 à 12:00 et de 13:30 à 17:30\nVendredi : de 8:00 à 12:00") + end + + it 'includes comments when present' do + data = [ + { + "nom_jour_debut" => "Lundi", + "nom_jour_fin" => "Vendredi", + "valeur_heure_debut_1" => "09:00:00", + "valeur_heure_fin_1" => "17:00:00", + "commentaire" => "Fermé les jours fériés" + } + ] + expect(service.send(:denormalize_plage_ouverture, data)).to eq("Lundi au Vendredi : de 9:00 à 17:00 (Fermé les jours fériés)") + end + end +end From 21dc77e587a541729fa8c4f64f32b217630d47bf Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 7 Oct 2024 18:10:37 +0200 Subject: [PATCH 1368/1532] fix(input): don't show success feedback when value was not set --- app/components/dsfr/input_component.rb | 2 ++ app/components/dsfr/input_errorable.rb | 6 +++--- .../editable_champ/champ_label_component.rb | 2 ++ .../editable_champ/champ_label_content_component.rb | 2 ++ .../editable_champ/editable_champ_base_component.rb | 12 +++++++----- 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/app/components/dsfr/input_component.rb b/app/components/dsfr/input_component.rb index feb1a40c2..39ddd0020 100644 --- a/app/components/dsfr/input_component.rb +++ b/app/components/dsfr/input_component.rb @@ -6,6 +6,8 @@ class Dsfr::InputComponent < ApplicationComponent delegate :object, to: :@form delegate :errors, to: :object + attr_reader :attribute + # use it to indicate detailed about the inputs, ex: https://www.systeme-de-design.gouv.fr/elements-d-interface/modeles-et-blocs-fonctionnels/demande-de-mot-de-passe # it uses aria-describedby on input and link it to yielded content renders_one :describedby diff --git a/app/components/dsfr/input_errorable.rb b/app/components/dsfr/input_errorable.rb index 2da8dee85..91fcfcde2 100644 --- a/app/components/dsfr/input_errorable.rb +++ b/app/components/dsfr/input_errorable.rb @@ -23,7 +23,7 @@ module Dsfr { "#{dsfr_group_classname}--error" => errors_on_attribute?, - "#{dsfr_group_classname}--valid" => !errors_on_attribute? && errors_on_another_attribute? + "#{dsfr_group_classname}--valid" => !errors_on_attribute? && errors_on_another_attribute? && object.public_send(attribute).present? } end @@ -51,9 +51,9 @@ module Dsfr def attribute_or_rich_body case @input_type when :rich_text_area - @attribute.to_s.sub(/\Arich_/, '').to_sym + attribute.to_s.sub(/\Arich_/, '').to_sym else - @attribute + attribute end end diff --git a/app/components/editable_champ/champ_label_component.rb b/app/components/editable_champ/champ_label_component.rb index fac9e4c28..3143ad276 100644 --- a/app/components/editable_champ/champ_label_component.rb +++ b/app/components/editable_champ/champ_label_component.rb @@ -3,6 +3,8 @@ class EditableChamp::ChampLabelComponent < ApplicationComponent include Dsfr::InputErrorable + attr_reader :attribute + def initialize(form:, champ:, seen_at: nil) @form, @champ, @seen_at = form, champ, seen_at @attribute = :value diff --git a/app/components/editable_champ/champ_label_content_component.rb b/app/components/editable_champ/champ_label_content_component.rb index d373b240f..94978c7b0 100644 --- a/app/components/editable_champ/champ_label_content_component.rb +++ b/app/components/editable_champ/champ_label_content_component.rb @@ -4,6 +4,8 @@ class EditableChamp::ChampLabelContentComponent < ApplicationComponent include ApplicationHelper include Dsfr::InputErrorable + attr_reader :attribute + def initialize(form:, champ:, seen_at: nil) @form, @champ, @seen_at = form, champ, seen_at @attribute = :value diff --git a/app/components/editable_champ/editable_champ_base_component.rb b/app/components/editable_champ/editable_champ_base_component.rb index 9d74d9698..5acb853c1 100644 --- a/app/components/editable_champ/editable_champ_base_component.rb +++ b/app/components/editable_champ/editable_champ_base_component.rb @@ -3,6 +3,13 @@ class EditableChamp::EditableChampBaseComponent < ApplicationComponent include Dsfr::InputErrorable + attr_reader :attribute + + def initialize(form:, champ:, seen_at: nil, opts: {}) + @form, @champ, @seen_at, @opts = form, champ, seen_at, opts + @attribute = :value + end + def dsfr_champ_container :div end @@ -14,9 +21,4 @@ class EditableChamp::EditableChampBaseComponent < ApplicationComponent def describedby_id @champ.describedby_id end - - def initialize(form:, champ:, seen_at: nil, opts: {}) - @form, @champ, @seen_at, @opts = form, champ, seen_at, opts - @attribute = :value - end end From 68cca713185e3b32487db2bc3bfd4359cbf61cbf Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 14 Oct 2024 22:25:08 +0200 Subject: [PATCH 1369/1532] feat(service): prefill contact information UI interactions --- .../administrateurs/services_controller.rb | 24 ++++++++- .../controllers/autosave_controller.ts | 5 +- .../administrateurs/services/_form.html.haml | 29 +++++++---- .../administrateurs/services/edit.html.haml | 2 +- .../administrateurs/services/new.html.haml | 2 +- config/locales/models/service/en.yml | 7 +-- config/locales/models/service/fr.yml | 7 +-- .../services_controller_spec.rb | 52 +++++++++++++++++-- 8 files changed, 104 insertions(+), 24 deletions(-) diff --git a/app/controllers/administrateurs/services_controller.rb b/app/controllers/administrateurs/services_controller.rb index 6ca2af356..ba87ccdef 100644 --- a/app/controllers/administrateurs/services_controller.rb +++ b/app/controllers/administrateurs/services_controller.rb @@ -18,7 +18,9 @@ module Administrateurs @service = Service.new(service_params) @service.administrateur = current_administrateur - if @service.save + if request.xhr? && params[:service][:siret].present? + handle_siret_update + elsif @service.save @service.enqueue_api_entreprise redirect_to admin_services_path(procedure_id: params[:procedure_id]), @@ -108,5 +110,25 @@ module Administrateurs def procedure current_administrateur.procedures.find(params[:procedure_id]) end + + def handle_siret_update + @service.assign_attributes(siret: params[:service][:siret]) + @service.validate + + if !@service.errors.include?(:siret) + result = @service.prefill_from_siret + prefilled = result.success? ? :success : :failure + end + + siret_errors = @service.errors.where(:siret) + @service.errors.clear + siret_errors.each { @service.errors.import(_1) } + + render turbo_stream: turbo_stream.replace( + "service_form", + partial: "administrateurs/services/form", + locals: { service: @service, prefilled:, procedure: @procedure } + ) + end end end diff --git a/app/javascript/controllers/autosave_controller.ts b/app/javascript/controllers/autosave_controller.ts index 0a3cc0570..96aca094d 100644 --- a/app/javascript/controllers/autosave_controller.ts +++ b/app/javascript/controllers/autosave_controller.ts @@ -252,7 +252,10 @@ export class AutosaveController extends ApplicationController { return httpRequest(form.action, { method: 'post', body: formData, - headers: { 'x-http-method-override': 'PATCH' }, + headers: { + 'x-http-method-override': + form.dataset.turboMethod?.toUpperCase() || 'PATCH' + }, signal: this.#abortController.signal, timeout: AUTOSAVE_TIMEOUT_DELAY }).turbo(); diff --git a/app/views/administrateurs/services/_form.html.haml b/app/views/administrateurs/services/_form.html.haml index 2ac6fb5ff..6386655b2 100644 --- a/app/views/administrateurs/services/_form.html.haml +++ b/app/views/administrateurs/services/_form.html.haml @@ -1,11 +1,21 @@ -= form_with model: [ :admin, service], local: true do |f| += form_with model: [:admin, service], id: "service_form", data: { turbo: token_list('true' => service.new_record?), controller: token_list('autosave' => service.new_record?), turbo_method: 'post' } do |f| = render Dsfr::InputComponent.new(form: f, attribute: :siret, input_type: :text_field, opts: { placeholder: "14 chiffres, sans espace" }) do |c| - - c.with_hint do - = "Indiquez le numéro de SIRET de l’organisme dont ce service dépend. Rechercher le SIRET sur " - = link_to("annuaire-entreprises.data.gouv.fr", annuaire_link, **external_link_attributes) - %br - = "Nous préremplirons les informations de contact à partir de l’Annuaire Service Public correspondant." + - if service.etablissement_infos.blank? && local_assigns[:prefilled].nil? + - c.with_hint do + = "Indiquez le numéro de SIRET de l’organisme dont ce service dépend. Rechercher le SIRET sur " + = link_to("annuaire-entreprises.data.gouv.fr", annuaire_link, **external_link_attributes) + - if service.new_record? + %br + = "Nous préremplirons les informations de contact à partir de l’Annuaire Service Public correspondant." + + .fr-mb-2w + - if local_assigns[:prefilled] == :success + %p.fr-info-text Génial ! Les informations du service ont été préremplies ci-dessous. Vérifiez-les et complétez-les le cas échéant. + - elsif local_assigns[:prefilled] == :failure + %p.fr-error-text + Une erreur a empêché le préremplissage des informations. + Vérifiez que le numéro de SIRET est correct et complétez les informations manuellement le cas échéant. = render Dsfr::InputComponent.new(form: f, attribute: :nom, input_type: :text_field) @@ -33,7 +43,6 @@ = render Dsfr::InputComponent.new(form: f, attribute: :horaires, input_type: :text_area) = render Dsfr::InputComponent.new(form: f, attribute: :adresse, input_type: :text_area) - - if procedure_id.present? - = hidden_field_tag :procedure_id, procedure_id - - = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f) + - if local_assigns[:procedure].present? + = hidden_field_tag :procedure_id, procedure.id + = render Procedure::FixedFooterComponent.new(procedure: procedure, form: f) diff --git a/app/views/administrateurs/services/edit.html.haml b/app/views/administrateurs/services/edit.html.haml index 0b056372c..3cd11f6fc 100644 --- a/app/views/administrateurs/services/edit.html.haml +++ b/app/views/administrateurs/services/edit.html.haml @@ -23,4 +23,4 @@ %p.mt-3 Si vous souhaitez modifier uniquement les informations pour ce service, créez un nouveau service puis associez-le à la démarche = render partial: 'form', - locals: { service: @service, procedure_id: @procedure.id } + locals: { service: @service, procedure: @procedure } diff --git a/app/views/administrateurs/services/new.html.haml b/app/views/administrateurs/services/new.html.haml index 691b864e7..27a39f2b5 100644 --- a/app/views/administrateurs/services/new.html.haml +++ b/app/views/administrateurs/services/new.html.haml @@ -8,4 +8,4 @@ %h1 Nouveau Service = render partial: 'form', - locals: { service: @service, procedure_id: @procedure.id } + locals: { service: @service, procedure: @procedure } diff --git a/config/locales/models/service/en.yml b/config/locales/models/service/en.yml index a28cd43a5..e9da428d0 100644 --- a/config/locales/models/service/en.yml +++ b/config/locales/models/service/en.yml @@ -14,6 +14,7 @@ en: service: attributes: siret: - format: "SIRET number %{message}" - length: "must contain exactly 14 digits" - checksum: "is invalid" + format: 'SIRET number %{message}' + length: 'must contain exactly 14 digits' + checksum: 'is invalid' + not_prefillable: 'Unable to pre-fill information for this SIRET, please fill it manually' diff --git a/config/locales/models/service/fr.yml b/config/locales/models/service/fr.yml index 1abe6afdb..40c35197c 100644 --- a/config/locales/models/service/fr.yml +++ b/config/locales/models/service/fr.yml @@ -34,9 +34,10 @@ fr: service: attributes: siret: - format: "Le numéro SIRET %{message}" - length: "doit comporter exactement 14 chiffres" - checksum: "est invalide" + format: 'Le numéro SIRET %{message}' + length: 'doit comporter exactement 14 chiffres' + checksum: 'est invalide' + not_prefillable: 'Impossible de préremplir les informations pour ce SIRET, veuillez les saisir manuellement' type_organisme: administration_centrale: 'Administration centrale' association: 'Association' diff --git a/spec/controllers/administrateurs/services_controller_spec.rb b/spec/controllers/administrateurs/services_controller_spec.rb index 380d23787..fbfb334f4 100644 --- a/spec/controllers/administrateurs/services_controller_spec.rb +++ b/spec/controllers/administrateurs/services_controller_spec.rb @@ -7,7 +7,47 @@ describe Administrateurs::ServicesController, type: :controller do describe '#create' do before do sign_in(admin.user) - post :create, params: params + end + + let(:xhr) { false } + subject { post :create, params:, xhr: } + + context 'when prefilling from a SIRET' do + let(:xhr) { true } + let(:params) do + { + procedure_id: procedure.id, + service: { siret: "20004021000060" } + } + end + + it "prefill from annuaire public" do + VCR.use_cassette('annuaire_service_public_success_20004021000060') do + subject + expect(response.body).to include('turbo-stream') + expect(assigns[:service].nom).to eq("Communauté de communes - Lacs et Gorges du Verdon") + expect(assigns[:service].adresse).to eq("242 avenue Albert-1er 83630 Aups") + end + end + end + + context 'when attempting to prefilling from unknown SIRET' do + let(:xhr) { true } + let(:params) do + { + procedure_id: procedure.id, + service: { siret: "20004021000000" } + } + end + + it "render an error" do + VCR.use_cassette('annuaire_service_public_failure_20004021000000') do + subject + expect(response.body).to include('turbo-stream') + expect(assigns[:service].nom).to be_nil + expect(assigns[:service].errors.key?(:siret)).to be_present + end + end end context 'when submitting a new service' do @@ -28,6 +68,7 @@ describe Administrateurs::ServicesController, type: :controller do end it do + subject expect(flash.alert).to be_nil expect(flash.notice).to eq('super service créé') expect(Service.last.nom).to eq('super service') @@ -47,9 +88,12 @@ describe Administrateurs::ServicesController, type: :controller do context 'when submitting an invalid service' do let(:params) { { service: { nom: 'super service' }, procedure_id: procedure.id } } - it { expect(flash.alert).not_to be_nil } - it { expect(response).to render_template(:new) } - it { expect(assigns(:service).nom).to eq('super service') } + it do + subject + expect(flash.alert).not_to be_nil + expect(response).to render_template(:new) + expect(assigns(:service).nom).to eq('super service') + end end end From 065d380b701c2d6b158e6f2841a333fa5dca0940 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 15 Oct 2024 18:26:19 +0200 Subject: [PATCH 1370/1532] feat(service): prefill type organisme from API Entreprise --- .../administrateurs/services_controller.rb | 10 +- ...prefillable_from_service_public_concern.rb | 37 +++++- .../administrateurs/services/_form.html.haml | 8 +- .../services_controller_spec.rb | 26 +++- ..._service_public_success_11004601800013.yml | 116 +++++++++++++++++ ..._service_public_success_19750664500013.yml | 120 +++++++++++++++++ ..._service_public_success_20004021000060.yml | 46 +++++++ ..._service_public_success_35600082800018.yml | 122 ++++++++++++++++++ ..._service_public_success_41816609600051.yml | 119 +++++++++++++++++ ...llable_from_service_public_concern_spec.rb | 46 ++++++- 10 files changed, 635 insertions(+), 15 deletions(-) create mode 100644 spec/fixtures/cassettes/annuaire_service_public_success_11004601800013.yml create mode 100644 spec/fixtures/cassettes/annuaire_service_public_success_19750664500013.yml create mode 100644 spec/fixtures/cassettes/annuaire_service_public_success_35600082800018.yml create mode 100644 spec/fixtures/cassettes/annuaire_service_public_success_41816609600051.yml diff --git a/app/controllers/administrateurs/services_controller.rb b/app/controllers/administrateurs/services_controller.rb index ba87ccdef..c025a0ff1 100644 --- a/app/controllers/administrateurs/services_controller.rb +++ b/app/controllers/administrateurs/services_controller.rb @@ -116,8 +116,14 @@ module Administrateurs @service.validate if !@service.errors.include?(:siret) - result = @service.prefill_from_siret - prefilled = result.success? ? :success : :failure + prefilled = case @service.prefill_from_siret + in [Dry::Monads::Result::Success, Dry::Monads::Result::Success] + :success + in [Dry::Monads::Result::Failure, Dry::Monads::Result::Success] | [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] + :partial + else + :failure + end end siret_errors = @service.errors.where(:siret) diff --git a/app/models/concerns/prefillable_from_service_public_concern.rb b/app/models/concerns/prefillable_from_service_public_concern.rb index ba421dc49..4d7fb5f27 100644 --- a/app/models/concerns/prefillable_from_service_public_concern.rb +++ b/app/models/concerns/prefillable_from_service_public_concern.rb @@ -5,9 +5,9 @@ module PrefillableFromServicePublicConcern included do def prefill_from_siret - result = AnnuaireServicePublicService.new.(siret:) - # TODO: get organisme, … from API Entreprise - case result + result_sp = AnnuaireServicePublicService.new.(siret:) + + case result_sp in Dry::Monads::Success(data) self.nom = data[:nom] if nom.blank? self.email = data[:adresse_courriel] if email.blank? @@ -18,7 +18,19 @@ module PrefillableFromServicePublicConcern # NOOP end - result + result_api_ent = APIRechercheEntreprisesService.new.call(siret:) + case result_api_ent + in Dry::Monads::Success(data) + self.type_organisme = detect_type_organisme(data) if type_organisme.blank? + + # some services (etablissements, …) are not in service public, so we also try to prefill them with API Entreprise + self.nom = data[:nom_complet] if nom.blank? + self.adresse = data.dig(:siege, :geo_adresse) if adresse.blank? + else + # NOOP + end + + [result_sp, result_api_ent] end private @@ -45,6 +57,23 @@ module PrefillableFromServicePublicConcern end.join("\n") end + def detect_type_organisme(data) + # Cf https://recherche-entreprises.api.gouv.fr/docs/#tag/Recherche-textuelle/paths/~1search/get + type = if data.dig(:complements, :collectivite_territoriale).present? + :collectivite_territoriale + elsif data.dig(:complements, :est_association) + :association + elsif data[:section_activite_principale] == "P" + :etablissement_enseignement + elsif data[:nom_complet].match?(/MINISTERE|MINISTERIEL/) + :administration_centrale + else # we can't differentiate between operateur d'état, administration centrale and service déconcentré de l'état, set the most frequent + :service_deconcentre_de_l_etat + end + + Service.type_organismes[type] + end + def format_time(str_time) Time.zone .parse(str_time) diff --git a/app/views/administrateurs/services/_form.html.haml b/app/views/administrateurs/services/_form.html.haml index 6386655b2..f9791c198 100644 --- a/app/views/administrateurs/services/_form.html.haml +++ b/app/views/administrateurs/services/_form.html.haml @@ -11,7 +11,10 @@ .fr-mb-2w - if local_assigns[:prefilled] == :success - %p.fr-info-text Génial ! Les informations du service ont été préremplies ci-dessous. Vérifiez-les et complétez-les le cas échéant. + %p.fr-info-text Génial ! La plupart des informations du service ont été préremplies ci-dessous. Vérifiez-les et complétez-les le cas échéant. + - elsif local_assigns[:prefilled] == :partial + %p.fr-info-text + Nous avons prérempli certaines informations correspondant à ce SIRET. Complétez les autres manuellement. - elsif local_assigns[:prefilled] == :failure %p.fr-error-text Une erreur a empêché le préremplissage des informations. @@ -24,8 +27,9 @@ .fr-input-group = f.label :type_organisme, class: "fr-label" do Type d’organisme + = render EditableChamp::AsteriskMandatoryComponent.new - = f.select :type_organisme, Service.type_organismes.keys.map { |key| [ I18n.t("type_organisme.#{key}"), key] }, {}, class: 'fr-select' + = f.select :type_organisme, Service.type_organismes.keys.map { |key| [ I18n.t("type_organisme.#{key}"), key] }, { include_blank: true }, { class: "fr-select" , required: true } = render Dsfr::CalloutComponent.new(title: "Informations de contact") do |c| - c.with_body do diff --git a/spec/controllers/administrateurs/services_controller_spec.rb b/spec/controllers/administrateurs/services_controller_spec.rb index fbfb334f4..510086688 100644 --- a/spec/controllers/administrateurs/services_controller_spec.rb +++ b/spec/controllers/administrateurs/services_controller_spec.rb @@ -31,7 +31,7 @@ describe Administrateurs::ServicesController, type: :controller do end end - context 'when attempting to prefilling from unknown SIRET' do + context 'when attempting to prefilling from invalid SIRET' do let(:xhr) { true } let(:params) do { @@ -41,11 +41,29 @@ describe Administrateurs::ServicesController, type: :controller do end it "render an error" do - VCR.use_cassette('annuaire_service_public_failure_20004021000000') do + subject + expect(response.body).to include('turbo-stream') + expect(assigns[:service].nom).to be_nil + expect(assigns[:service].errors.key?(:siret)).to be_present + end + end + + context 'when attempting to prefilling from not service public SIRET' do + let(:xhr) { true } + let(:params) do + { + procedure_id: procedure.id, + service: { siret: "41816609600051" } + } + end + + it "render partial information" do + VCR.use_cassette('annuaire_service_public_success_41816609600051') do subject expect(response.body).to include('turbo-stream') - expect(assigns[:service].nom).to be_nil - expect(assigns[:service].errors.key?(:siret)).to be_present + expect(assigns[:service].nom).to eq("OCTO-TECHNOLOGY") + expect(assigns[:service].horaires).to be_nil + expect(assigns[:service].errors.key?(:siret)).not_to be_present end end end diff --git a/spec/fixtures/cassettes/annuaire_service_public_success_11004601800013.yml b/spec/fixtures/cassettes/annuaire_service_public_success_11004601800013.yml new file mode 100644 index 000000000..36a6adc39 --- /dev/null +++ b/spec/fixtures/cassettes/annuaire_service_public_success_11004601800013.yml @@ -0,0 +1,116 @@ +--- +http_interactions: +- request: + method: get + uri: https://api-lannuaire.service-public.fr/api/explore/v2.1/catalog/datasets/api-lannuaire-administration/records?where=siret:11004601800013 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - demarches-simplifiees.fr + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + Server: + - openresty + Date: + - Tue, 15 Oct 2024 16:24:17 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '33' + X-Ratelimit-Remaining: + - '999918' + X-Ratelimit-Limit: + - '1000000' + X-Ratelimit-Reset: + - '2024-10-16 00:00:00+00:00' + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Vary: + - Accept-Language, Cookie, Host + Content-Language: + - fr-fr + Access-Control-Allow-Origin: + - "*" + Access-Control-Allow-Methods: + - POST, GET, OPTIONS + Access-Control-Max-Age: + - '1000' + Access-Control-Allow-Headers: + - Authorization, X-Requested-With, Origin, ODS-API-Analytics-App, ODS-API-Analytics-Embed-Type, + ODS-API-Analytics-Embed-Referrer, ODS-Widgets-Version, Accept + Access-Control-Expose-Headers: + - ODS-Explore-API-Deprecation, Link, X-RateLimit-Remaining, X-RateLimit-Limit, + X-RateLimit-Reset, X-RateLimit-dataset-Remaining, X-RateLimit-dataset-Limit, + X-RateLimit-dataset-Reset + Strict-Transport-Security: + - max-age=31536000 + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + Referrer-Policy: + - strict-origin-when-cross-origin + Permissions-Policy: + - midi=(),microphone=(),camera=(),magnetometer=(),gyroscope=(),fullscreen=(self),payment=() + Content-Security-Policy: + - upgrade-insecure-requests; + X-Ua-Compatible: + - IE=edge + body: + encoding: ASCII-8BIT + string: '{"total_count": 0, "results": []}' + recorded_at: Tue, 15 Oct 2024 16:24:17 GMT +- request: + method: get + uri: https://recherche-entreprises.api.gouv.fr/search?q=11004601800013 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - demarches-simplifiees.fr + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + Server: + - nginx/1.27.1 + Date: + - Tue, 15 Oct 2024 16:24:17 GMT + Content-Type: + - application/json + Content-Length: + - '3289' + Vary: + - Accept-Encoding + - Accept-Encoding + Annuaire-Entreprises-Instance-Number: + - '02' + X-Frame-Options: + - DENY + X-Content-Type-Options: + - nosniff + Strict-Transport-Security: + - max-age=31536000 + X-Xss-Protection: + - 1; mode=block + Access-Control-Allow-Headers: + - Content-Type + Access-Control-Allow-Origin: + - "*" + body: + encoding: ASCII-8BIT + string: !binary |- + eyJyZXN1bHRzIjpbeyJzaXJlbiI6IjExMDA0NjAxOCIsIm5vbV9jb21wbGV0IjoiTUlOSVNURVJFIERFIExBIENVTFRVUkUiLCJub21fcmFpc29uX3NvY2lhbGUiOiJNSU5JU1RFUkUgREUgTEEgQ1VMVFVSRSIsInNpZ2xlIjpudWxsLCJub21icmVfZXRhYmxpc3NlbWVudHMiOjMsIm5vbWJyZV9ldGFibGlzc2VtZW50c19vdXZlcnRzIjoxLCJzaWVnZSI6eyJhY3Rpdml0ZV9wcmluY2lwYWxlIjoiODQuMTFaIiwiYWN0aXZpdGVfcHJpbmNpcGFsZV9yZWdpc3RyZV9tZXRpZXIiOm51bGwsImFubmVlX3RyYW5jaGVfZWZmZWN0aWZfc2FsYXJpZSI6IjIwMjIiLCJhZHJlc3NlIjoiMTgyIFJVRSBTQUlOVC1IT05PUkUgNzUwMDEgUEFSSVMiLCJjYXJhY3RlcmVfZW1wbG95ZXVyIjoiTiIsImNlZGV4IjpudWxsLCJjb2RlX3BheXNfZXRyYW5nZXIiOm51bGwsImNvZGVfcG9zdGFsIjoiNzUwMDEiLCJjb21tdW5lIjoiNzUxMDEiLCJjb21wbGVtZW50X2FkcmVzc2UiOm51bGwsImNvb3Jkb25uZWVzIjoiNDguODYyMzk4LDIuMzM4OTAzIiwiZGF0ZV9jcmVhdGlvbiI6IjE5ODMtMDMtMDEiLCJkYXRlX2RlYnV0X2FjdGl2aXRlIjoiMjAwOC0wMS0wMSIsImRhdGVfZmVybWV0dXJlIjpudWxsLCJkYXRlX21pc2VfYV9qb3VyIjpudWxsLCJkYXRlX21pc2VfYV9qb3VyX2luc2VlIjoiMjAyNC0wMy0zMFQxMTozMDowNiIsImRlcGFydGVtZW50IjoiNzUiLCJkaXN0cmlidXRpb25fc3BlY2lhbGUiOm51bGwsImVwY2kiOiIyMDAwNTQ3ODEiLCJlc3Rfc2llZ2UiOnRydWUsImV0YXRfYWRtaW5pc3RyYXRpZiI6IkEiLCJnZW9fYWRyZXNzZSI6IjE4MiBSdWUgU2FpbnQtSG9ub3LDqSA3NTAwMSBQYXJpcyIsImdlb19pZCI6Ijc1MTAxXzg2MzVfMDAxODIiLCJpbmRpY2VfcmVwZXRpdGlvbiI6bnVsbCwibGF0aXR1ZGUiOiI0OC44NjIzOTgiLCJsaWJlbGxlX2NlZGV4IjpudWxsLCJsaWJlbGxlX2NvbW11bmUiOiJQQVJJUyIsImxpYmVsbGVfY29tbXVuZV9ldHJhbmdlciI6bnVsbCwibGliZWxsZV9wYXlzX2V0cmFuZ2VyIjpudWxsLCJsaWJlbGxlX3ZvaWUiOiJTQUlOVC1IT05PUkUiLCJsaXN0ZV9lbnNlaWduZXMiOm51bGwsImxpc3RlX2ZpbmVzcyI6bnVsbCwibGlzdGVfaWRfYmlvIjpudWxsLCJsaXN0ZV9pZGNjIjpudWxsLCJsaXN0ZV9pZF9vcmdhbmlzbWVfZm9ybWF0aW9uIjpudWxsLCJsaXN0ZV9yZ2UiOm51bGwsImxpc3RlX3VhaSI6bnVsbCwibG9uZ2l0dWRlIjoiMi4zMzg5MDMiLCJub21fY29tbWVyY2lhbCI6bnVsbCwibnVtZXJvX3ZvaWUiOiIxODIiLCJyZWdpb24iOiIxMSIsInNpcmV0IjoiMTEwMDQ2MDE4MDAwMTMiLCJzdGF0dXRfZGlmZnVzaW9uX2V0YWJsaXNzZW1lbnQiOiJPIiwidHJhbmNoZV9lZmZlY3RpZl9zYWxhcmllIjoiMjIiLCJ0eXBlX3ZvaWUiOiJSVUUifSwiYWN0aXZpdGVfcHJpbmNpcGFsZSI6Ijg0LjExWiIsImNhdGVnb3JpZV9lbnRyZXByaXNlIjoiUE1FIiwiY2FyYWN0ZXJlX2VtcGxveWV1ciI6bnVsbCwiYW5uZWVfY2F0ZWdvcmllX2VudHJlcHJpc2UiOiIyMDIyIiwiZGF0ZV9jcmVhdGlvbiI6IjE5ODEtMDYtMjMiLCJkYXRlX2Zlcm1ldHVyZSI6bnVsbCwiZGF0ZV9taXNlX2Ffam91ciI6IjIwMjQtMTAtMTRUMTQ6NTk6NDkiLCJkYXRlX21pc2VfYV9qb3VyX2luc2VlIjoiMjAyNC0wOS0yN1QxMToxMjoyOSIsImRhdGVfbWlzZV9hX2pvdXJfcm5lIjpudWxsLCJkaXJpZ2VhbnRzIjpbXSwiZXRhdF9hZG1pbmlzdHJhdGlmIjoiQSIsIm5hdHVyZV9qdXJpZGlxdWUiOiI3MTEzIiwic2VjdGlvbl9hY3Rpdml0ZV9wcmluY2lwYWxlIjoiTyIsInRyYW5jaGVfZWZmZWN0aWZfc2FsYXJpZSI6IjIyIiwiYW5uZWVfdHJhbmNoZV9lZmZlY3RpZl9zYWxhcmllIjoiMjAyMiIsInN0YXR1dF9kaWZmdXNpb24iOiJPIiwibWF0Y2hpbmdfZXRhYmxpc3NlbWVudHMiOlt7ImFjdGl2aXRlX3ByaW5jaXBhbGUiOiI4NC4xMVoiLCJhbmNpZW5fc2llZ2UiOmZhbHNlLCJhbm5lZV90cmFuY2hlX2VmZmVjdGlmX3NhbGFyaWUiOiIyMDIyIiwiYWRyZXNzZSI6IjE4MiBSVUUgU0FJTlQtSE9OT1JFIDc1MDAxIFBBUklTIiwiY2FyYWN0ZXJlX2VtcGxveWV1ciI6Ik4iLCJjb2RlX3Bvc3RhbCI6Ijc1MDAxIiwiY29tbXVuZSI6Ijc1MTAxIiwiZGF0ZV9jcmVhdGlvbiI6IjE5ODMtMDMtMDEiLCJkYXRlX2RlYnV0X2FjdGl2aXRlIjoiMjAwOC0wMS0wMSIsImRhdGVfZmVybWV0dXJlIjpudWxsLCJlcGNpIjoiMjAwMDU0NzgxIiwiZXN0X3NpZWdlIjp0cnVlLCJldGF0X2FkbWluaXN0cmF0aWYiOiJBIiwiZ2VvX2lkIjoiNzUxMDFfODYzNV8wMDE4MiIsImxhdGl0dWRlIjoiNDguODYyMzk4IiwibGliZWxsZV9jb21tdW5lIjoiUEFSSVMiLCJsaXN0ZV9lbnNlaWduZXMiOm51bGwsImxpc3RlX2ZpbmVzcyI6bnVsbCwibGlzdGVfaWRfYmlvIjpudWxsLCJsaXN0ZV9pZGNjIjpudWxsLCJsaXN0ZV9pZF9vcmdhbmlzbWVfZm9ybWF0aW9uIjpudWxsLCJsaXN0ZV9yZ2UiOm51bGwsImxpc3RlX3VhaSI6bnVsbCwibG9uZ2l0dWRlIjoiMi4zMzg5MDMiLCJub21fY29tbWVyY2lhbCI6bnVsbCwicmVnaW9uIjoiMTEiLCJzaXJldCI6IjExMDA0NjAxODAwMDEzIiwic3RhdHV0X2RpZmZ1c2lvbl9ldGFibGlzc2VtZW50IjoiTyIsInRyYW5jaGVfZWZmZWN0aWZfc2FsYXJpZSI6IjIyIn1dLCJmaW5hbmNlcyI6bnVsbCwiY29tcGxlbWVudHMiOnsiY29sbGVjdGl2aXRlX3RlcnJpdG9yaWFsZSI6bnVsbCwiY29udmVudGlvbl9jb2xsZWN0aXZlX3JlbnNlaWduZWUiOmZhbHNlLCJsaXN0ZV9pZGNjIjpudWxsLCJlZ2Fwcm9fcmVuc2VpZ25lZSI6ZmFsc2UsImVzdF9hc3NvY2lhdGlvbiI6ZmFsc2UsImVzdF9iaW8iOmZhbHNlLCJlc3RfZW50cmVwcmVuZXVyX2luZGl2aWR1ZWwiOmZhbHNlLCJlc3RfZW50cmVwcmVuZXVyX3NwZWN0YWNsZSI6ZmFsc2UsImVzdF9lc3MiOmZhbHNlLCJlc3RfZmluZXNzIjpmYWxzZSwiZXN0X29yZ2FuaXNtZV9mb3JtYXRpb24iOnRydWUsImVzdF9xdWFsaW9waSI6ZmFsc2UsImxpc3RlX2lkX29yZ2FuaXNtZV9mb3JtYXRpb24iOlsiMTE3NTU2MDg5NzUiXSwiZXN0X3JnZSI6ZmFsc2UsImVzdF9zZXJ2aWNlX3B1YmxpYyI6dHJ1ZSwiZXN0X3NpYWUiOmZhbHNlLCJlc3Rfc29jaWV0ZV9taXNzaW9uIjpmYWxzZSwiZXN0X3VhaSI6ZmFsc2UsImlkZW50aWZpYW50X2Fzc29jaWF0aW9uIjpudWxsLCJzdGF0dXRfZW50cmVwcmVuZXVyX3NwZWN0YWNsZSI6bnVsbCwidHlwZV9zaWFlIjpudWxsfX1dLCJ0b3RhbF9yZXN1bHRzIjoxLCJwYWdlIjoxLCJwZXJfcGFnZSI6MTAsInRvdGFsX3BhZ2VzIjoxfQ== + recorded_at: Tue, 15 Oct 2024 16:24:17 GMT +recorded_with: VCR 6.2.0 diff --git a/spec/fixtures/cassettes/annuaire_service_public_success_19750664500013.yml b/spec/fixtures/cassettes/annuaire_service_public_success_19750664500013.yml new file mode 100644 index 000000000..66250c90b --- /dev/null +++ b/spec/fixtures/cassettes/annuaire_service_public_success_19750664500013.yml @@ -0,0 +1,120 @@ +--- +http_interactions: +- request: + method: get + uri: https://api-lannuaire.service-public.fr/api/explore/v2.1/catalog/datasets/api-lannuaire-administration/records?where=siret:19750664500013 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - demarches-simplifiees.fr + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + Server: + - openresty + Date: + - Tue, 15 Oct 2024 14:57:11 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '33' + X-Ratelimit-Remaining: + - '999997' + X-Ratelimit-Limit: + - '1000000' + X-Ratelimit-Reset: + - '2024-10-16 00:00:00+00:00' + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Vary: + - Accept-Language, Cookie, Host + Content-Language: + - fr-fr + Access-Control-Allow-Origin: + - "*" + Access-Control-Allow-Methods: + - POST, GET, OPTIONS + Access-Control-Max-Age: + - '1000' + Access-Control-Allow-Headers: + - Authorization, X-Requested-With, Origin, ODS-API-Analytics-App, ODS-API-Analytics-Embed-Type, + ODS-API-Analytics-Embed-Referrer, ODS-Widgets-Version, Accept + Access-Control-Expose-Headers: + - ODS-Explore-API-Deprecation, Link, X-RateLimit-Remaining, X-RateLimit-Limit, + X-RateLimit-Reset, X-RateLimit-dataset-Remaining, X-RateLimit-dataset-Limit, + X-RateLimit-dataset-Reset + Strict-Transport-Security: + - max-age=31536000 + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + Referrer-Policy: + - strict-origin-when-cross-origin + Permissions-Policy: + - midi=(),microphone=(),camera=(),magnetometer=(),gyroscope=(),fullscreen=(self),payment=() + Content-Security-Policy: + - upgrade-insecure-requests; + X-Ua-Compatible: + - IE=edge + body: + encoding: ASCII-8BIT + string: '{"total_count": 0, "results": []}' + recorded_at: Tue, 15 Oct 2024 14:57:11 GMT +- request: + method: get + uri: https://recherche-entreprises.api.gouv.fr/search?q=19750664500013 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - demarches-simplifiees.fr + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + Server: + - nginx/1.27.1 + Date: + - Tue, 15 Oct 2024 14:57:12 GMT + Content-Type: + - application/json + Content-Length: + - '3310' + Vary: + - Accept-Encoding + - Accept-Encoding + Annuaire-Entreprises-Instance-Number: + - '02' + X-Frame-Options: + - DENY + X-Content-Type-Options: + - nosniff + Strict-Transport-Security: + - max-age=31536000 + X-Xss-Protection: + - 1; mode=block + Access-Control-Allow-Headers: + - Content-Type + Access-Control-Allow-Origin: + - "*" + body: + encoding: ASCII-8BIT + string: '{"results":[{"siren":"197506645","nom_complet":"LYCEE GENERAL ET TECHNOLOGIQUE + RACINE","nom_raison_sociale":"LYCEE GENERAL ET TECHNOLOGIQUE RACINE","sigle":null,"nombre_etablissements":1,"nombre_etablissements_ouverts":1,"siege":{"activite_principale":"85.31Z","activite_principale_registre_metier":null,"annee_tranche_effectif_salarie":"2022","adresse":"20 + RUE DU ROCHER 75008 PARIS","caractere_employeur":"N","cedex":null,"code_pays_etranger":null,"code_postal":"75008","commune":"75108","complement_adresse":null,"coordonnees":"48.876451,2.3223","date_creation":"1983-03-01","date_debut_activite":"2008-01-01","date_fermeture":null,"date_mise_a_jour":null,"date_mise_a_jour_insee":"2024-03-30T03:25:45","departement":"75","distribution_speciale":null,"epci":"200054781","est_siege":true,"etat_administratif":"A","geo_adresse":"20 + Rue du Rocher 75008 Paris","geo_id":"75108_8291_00020","indice_repetition":null,"latitude":"48.876451","libelle_cedex":null,"libelle_commune":"PARIS","libelle_commune_etranger":null,"libelle_pays_etranger":null,"libelle_voie":"DU + ROCHER","liste_enseignes":null,"liste_finess":null,"liste_id_bio":null,"liste_idcc":["9999"],"liste_id_organisme_formation":null,"liste_rge":null,"liste_uai":["0750664P"],"longitude":"2.3223","nom_commercial":null,"numero_voie":"20","region":"11","siret":"19750664500013","statut_diffusion_etablissement":"O","tranche_effectif_salarie":"22","type_voie":"RUE"},"activite_principale":"85.31Z","categorie_entreprise":"PME","caractere_employeur":null,"annee_categorie_entreprise":"2022","date_creation":"1965-05-01","date_fermeture":null,"date_mise_a_jour":"2024-10-14T15:00:03","date_mise_a_jour_insee":"2024-03-22T14:26:06","date_mise_a_jour_rne":null,"dirigeants":[],"etat_administratif":"A","nature_juridique":"7331","section_activite_principale":"P","tranche_effectif_salarie":"22","annee_tranche_effectif_salarie":"2022","statut_diffusion":"O","matching_etablissements":[{"activite_principale":"85.31Z","ancien_siege":false,"annee_tranche_effectif_salarie":"2022","adresse":"20 + RUE DU ROCHER 75008 PARIS","caractere_employeur":"N","code_postal":"75008","commune":"75108","date_creation":"1983-03-01","date_debut_activite":"2008-01-01","date_fermeture":null,"epci":"200054781","est_siege":true,"etat_administratif":"A","geo_id":"75108_8291_00020","latitude":"48.876451","libelle_commune":"PARIS","liste_enseignes":null,"liste_finess":null,"liste_id_bio":null,"liste_idcc":["9999"],"liste_id_organisme_formation":null,"liste_rge":null,"liste_uai":["0750664P"],"longitude":"2.3223","nom_commercial":null,"region":"11","siret":"19750664500013","statut_diffusion_etablissement":"O","tranche_effectif_salarie":"22"}],"finances":null,"complements":{"collectivite_territoriale":null,"convention_collective_renseignee":true,"liste_idcc":["9999"],"egapro_renseignee":false,"est_association":false,"est_bio":false,"est_entrepreneur_individuel":false,"est_entrepreneur_spectacle":false,"est_ess":false,"est_finess":false,"est_organisme_formation":false,"est_qualiopi":false,"liste_id_organisme_formation":null,"est_rge":false,"est_service_public":true,"est_siae":false,"est_societe_mission":false,"est_uai":true,"identifiant_association":null,"statut_entrepreneur_spectacle":null,"type_siae":null}}],"total_results":1,"page":1,"per_page":10,"total_pages":1}' + recorded_at: Tue, 15 Oct 2024 14:57:11 GMT +recorded_with: VCR 6.2.0 diff --git a/spec/fixtures/cassettes/annuaire_service_public_success_20004021000060.yml b/spec/fixtures/cassettes/annuaire_service_public_success_20004021000060.yml index 92b868dbf..cbeeede03 100644 --- a/spec/fixtures/cassettes/annuaire_service_public_success_20004021000060.yml +++ b/spec/fixtures/cassettes/annuaire_service_public_success_20004021000060.yml @@ -97,4 +97,50 @@ http_interactions: \"ascenseur\"}]", "url_service_public": "https://lannuaire.service-public.fr/provence-alpes-cote-d-azur/var/3b9ce22d-f7bd-46d9-82d1-8b44c8d08e39", "information_complementaire": null, "date_diffusion": null}]}' recorded_at: Mon, 07 Oct 2024 14:41:57 GMT +- request: + method: get + uri: https://recherche-entreprises.api.gouv.fr/search?q=20004021000060 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - demarches-simplifiees.fr + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + Server: + - nginx/1.27.1 + Date: + - Tue, 15 Oct 2024 13:53:25 GMT + Content-Type: + - application/json + Content-Length: + - '6715' + Vary: + - Accept-Encoding + - Accept-Encoding + Annuaire-Entreprises-Instance-Number: + - '02' + X-Frame-Options: + - DENY + X-Content-Type-Options: + - nosniff + Strict-Transport-Security: + - max-age=31536000 + X-Xss-Protection: + - 1; mode=block + Access-Control-Allow-Headers: + - Content-Type + Access-Control-Allow-Origin: + - "*" + body: + encoding: ASCII-8BIT + string: !binary |- + eyJyZXN1bHRzIjpbeyJzaXJlbiI6IjIwMDA0MDIxMCIsIm5vbV9jb21wbGV0IjoiQ09NTVVOQVVURSBERSBDT01NVU5FUyBMQUNTIEVUIEdPUkdFUyBEVSBWRVJET04gKENDTEdWKSIsIm5vbV9yYWlzb25fc29jaWFsZSI6IkNPTU1VTkFVVEUgREUgQ09NTVVORVMgTEFDUyBFVCBHT1JHRVMgRFUgVkVSRE9OIiwic2lnbGUiOiJDQ0xHViIsIm5vbWJyZV9ldGFibGlzc2VtZW50cyI6Nywibm9tYnJlX2V0YWJsaXNzZW1lbnRzX291dmVydHMiOjYsInNpZWdlIjp7ImFjdGl2aXRlX3ByaW5jaXBhbGUiOiI4NC4xMVoiLCJhY3Rpdml0ZV9wcmluY2lwYWxlX3JlZ2lzdHJlX21ldGllciI6bnVsbCwiYW5uZWVfdHJhbmNoZV9lZmZlY3RpZl9zYWxhcmllIjpudWxsLCJhZHJlc3NlIjoiMjQyIEFWRU5VRSBBTEJFUlQgMUVSIDgzNjMwIEFVUFMiLCJjYXJhY3RlcmVfZW1wbG95ZXVyIjoiTyIsImNlZGV4IjpudWxsLCJjb2RlX3BheXNfZXRyYW5nZXIiOm51bGwsImNvZGVfcG9zdGFsIjoiODM2MzAiLCJjb21tdW5lIjoiODMwMDciLCJjb21wbGVtZW50X2FkcmVzc2UiOm51bGwsImNvb3Jkb25uZWVzIjoiNDMuNjMwNTI0LDYuMjIxMjE4IiwiZGF0ZV9jcmVhdGlvbiI6IjIwMjMtMDQtMTMiLCJkYXRlX2RlYnV0X2FjdGl2aXRlIjoiMjAyMy0wNC0xMyIsImRhdGVfZmVybWV0dXJlIjpudWxsLCJkYXRlX21pc2VfYV9qb3VyIjpudWxsLCJkYXRlX21pc2VfYV9qb3VyX2luc2VlIjoiMjAyNC0wMy0zMFQwOTozMzo1MCIsImRlcGFydGVtZW50IjoiODMiLCJkaXN0cmlidXRpb25fc3BlY2lhbGUiOm51bGwsImVwY2kiOiIyMDAwNDAyMTAiLCJlc3Rfc2llZ2UiOnRydWUsImV0YXRfYWRtaW5pc3RyYXRpZiI6IkEiLCJnZW9fYWRyZXNzZSI6IjI0MiBBdmVudWUgQWxiZXJ0IDFlciA4MzYzMCBBdXBzIiwiZ2VvX2lkIjoiODMwMDdfMDAyMF8wMDI0MiIsImluZGljZV9yZXBldGl0aW9uIjpudWxsLCJsYXRpdHVkZSI6IjQzLjYzMDUyNCIsImxpYmVsbGVfY2VkZXgiOm51bGwsImxpYmVsbGVfY29tbXVuZSI6IkFVUFMiLCJsaWJlbGxlX2NvbW11bmVfZXRyYW5nZXIiOm51bGwsImxpYmVsbGVfcGF5c19ldHJhbmdlciI6bnVsbCwibGliZWxsZV92b2llIjoiQUxCRVJUIDFFUiIsImxpc3RlX2Vuc2VpZ25lcyI6bnVsbCwibGlzdGVfZmluZXNzIjpudWxsLCJsaXN0ZV9pZF9iaW8iOm51bGwsImxpc3RlX2lkY2MiOlsiNTAyMSJdLCJsaXN0ZV9pZF9vcmdhbmlzbWVfZm9ybWF0aW9uIjpudWxsLCJsaXN0ZV9yZ2UiOm51bGwsImxpc3RlX3VhaSI6bnVsbCwibG9uZ2l0dWRlIjoiNi4yMjEyMTgiLCJub21fY29tbWVyY2lhbCI6bnVsbCwibnVtZXJvX3ZvaWUiOiIyNDIiLCJyZWdpb24iOiI5MyIsInNpcmV0IjoiMjAwMDQwMjEwMDAwNjAiLCJzdGF0dXRfZGlmZnVzaW9uX2V0YWJsaXNzZW1lbnQiOiJPIiwidHJhbmNoZV9lZmZlY3RpZl9zYWxhcmllIjpudWxsLCJ0eXBlX3ZvaWUiOiJBVkVOVUUifSwiYWN0aXZpdGVfcHJpbmNpcGFsZSI6Ijg0LjExWiIsImNhdGVnb3JpZV9lbnRyZXByaXNlIjoiUE1FIiwiY2FyYWN0ZXJlX2VtcGxveWV1ciI6bnVsbCwiYW5uZWVfY2F0ZWdvcmllX2VudHJlcHJpc2UiOiIyMDIyIiwiZGF0ZV9jcmVhdGlvbiI6IjIwMTQtMDEtMDEiLCJkYXRlX2Zlcm1ldHVyZSI6bnVsbCwiZGF0ZV9taXNlX2Ffam91ciI6IjIwMjQtMTAtMTRUMTU6MDA6MDciLCJkYXRlX21pc2VfYV9qb3VyX2luc2VlIjoiMjAyNC0wMy0yMlQxNDoyNjowNiIsImRhdGVfbWlzZV9hX2pvdXJfcm5lIjpudWxsLCJkaXJpZ2VhbnRzIjpbXSwiZXRhdF9hZG1pbmlzdHJhdGlmIjoiQSIsIm5hdHVyZV9qdXJpZGlxdWUiOiI3MzQ2Iiwic2VjdGlvbl9hY3Rpdml0ZV9wcmluY2lwYWxlIjoiTyIsInRyYW5jaGVfZWZmZWN0aWZfc2FsYXJpZSI6IjIxIiwiYW5uZWVfdHJhbmNoZV9lZmZlY3RpZl9zYWxhcmllIjoiMjAyMiIsInN0YXR1dF9kaWZmdXNpb24iOiJPIiwibWF0Y2hpbmdfZXRhYmxpc3NlbWVudHMiOlt7ImFjdGl2aXRlX3ByaW5jaXBhbGUiOiI4NC4xMVoiLCJhbmNpZW5fc2llZ2UiOmZhbHNlLCJhbm5lZV90cmFuY2hlX2VmZmVjdGlmX3NhbGFyaWUiOm51bGwsImFkcmVzc2UiOiIyNDIgQVZFTlVFIEFMQkVSVCAxRVIgODM2MzAgQVVQUyIsImNhcmFjdGVyZV9lbXBsb3lldXIiOiJPIiwiY29kZV9wb3N0YWwiOiI4MzYzMCIsImNvbW11bmUiOiI4MzAwNyIsImRhdGVfY3JlYXRpb24iOiIyMDIzLTA0LTEzIiwiZGF0ZV9kZWJ1dF9hY3Rpdml0ZSI6IjIwMjMtMDQtMTMiLCJkYXRlX2Zlcm1ldHVyZSI6bnVsbCwiZXBjaSI6IjIwMDA0MDIxMCIsImVzdF9zaWVnZSI6dHJ1ZSwiZXRhdF9hZG1pbmlzdHJhdGlmIjoiQSIsImdlb19pZCI6IjgzMDA3XzAwMjBfMDAyNDIiLCJsYXRpdHVkZSI6IjQzLjYzMDUyNCIsImxpYmVsbGVfY29tbXVuZSI6IkFVUFMiLCJsaXN0ZV9lbnNlaWduZXMiOm51bGwsImxpc3RlX2ZpbmVzcyI6bnVsbCwibGlzdGVfaWRfYmlvIjpudWxsLCJsaXN0ZV9pZGNjIjpbIjUwMjEiXSwibGlzdGVfaWRfb3JnYW5pc21lX2Zvcm1hdGlvbiI6bnVsbCwibGlzdGVfcmdlIjpudWxsLCJsaXN0ZV91YWkiOm51bGwsImxvbmdpdHVkZSI6IjYuMjIxMjE4Iiwibm9tX2NvbW1lcmNpYWwiOm51bGwsInJlZ2lvbiI6IjkzIiwic2lyZXQiOiIyMDAwNDAyMTAwMDA2MCIsInN0YXR1dF9kaWZmdXNpb25fZXRhYmxpc3NlbWVudCI6Ik8iLCJ0cmFuY2hlX2VmZmVjdGlmX3NhbGFyaWUiOm51bGx9XSwiZmluYW5jZXMiOm51bGwsImNvbXBsZW1lbnRzIjp7ImNvbGxlY3Rpdml0ZV90ZXJyaXRvcmlhbGUiOnsiY29kZSI6IjIwMDA0MDIxMCIsImNvZGVfaW5zZWUiOm51bGwsImVsdXMiOlt7Im5vbSI6IkRBR1VFVCIsInByZW5vbXMiOiJDYXRoZXJpbmUiLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTU3LTEyIiwiZm9uY3Rpb24iOm51bGwsInNleGUiOiJGIn0seyJub20iOiJNT1JERUxFVCIsInByZW5vbXMiOiJDaGFybGVzLUFudG9pbmUiLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTUyLTAxIiwiZm9uY3Rpb24iOiIzZW1lIFZpY2UtcHLDqXNpZGVudCBkdSBjb25zZWlsIGNvbW11bmF1dGFpcmUiLCJzZXhlIjoiTSJ9LHsibm9tIjoiQ09OU1RBTlMiLCJwcmVub21zIjoiU2VyZ2UiLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTY3LTAyIiwiZm9uY3Rpb24iOiI2ZW1lIFZpY2UtcHLDqXNpZGVudCBkdSBjb25zZWlsIGNvbW11bmF1dGFpcmUiLCJzZXhlIjoiTSJ9LHsibm9tIjoiQk9OQVZFTlRVUkUiLCJwcmVub21zIjoiTWFyaWUtRnJhbsOnb2lzZSIsImFubmVlX2RlX25haXNzYW5jZSI6IjE5NjQtMDQiLCJmb25jdGlvbiI6bnVsbCwic2V4ZSI6IkYifSx7Im5vbSI6IkZBVVJFIiwicHJlbm9tcyI6IkFudG9pbmUiLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTU1LTEwIiwiZm9uY3Rpb24iOiIyZW1lIFZpY2UtcHLDqXNpZGVudCBkdSBjb25zZWlsIGNvbW11bmF1dGFpcmUiLCJzZXhlIjoiTSJ9LHsibm9tIjoiUEFOVEVMIiwicHJlbm9tcyI6IkJlcm5hcmQiLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTUwLTA4IiwiZm9uY3Rpb24iOm51bGwsInNleGUiOiJNIn0seyJub20iOiJST1VYIiwicHJlbm9tcyI6Ik1hcmzDqG5lIiwiYW5uZWVfZGVfbmFpc3NhbmNlIjoiMTk3My0wMyIsImZvbmN0aW9uIjpudWxsLCJzZXhlIjoiRiJ9LHsibm9tIjoiVEVSUkFTU09OIiwicHJlbm9tcyI6Ik1hcmllIENocmlzdGluZSIsImFubmVlX2RlX25haXNzYW5jZSI6IjE5NTYtMDEiLCJmb25jdGlvbiI6bnVsbCwic2V4ZSI6IkYifSx7Im5vbSI6IlZJTkNFTlRFTExJIiwicHJlbm9tcyI6IlBhdHJpY2siLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTUxLTEwIiwiZm9uY3Rpb24iOm51bGwsInNleGUiOiJNIn0seyJub20iOiJBTkdMSU9OSU4iLCJwcmVub21zIjoiSm9hbm5lbCIsImFubmVlX2RlX25haXNzYW5jZSI6IjE5NzAtMDYiLCJmb25jdGlvbiI6bnVsbCwic2V4ZSI6Ik0ifSx7Im5vbSI6IlJPVVgiLCJwcmVub21zIjoiSmVhbi1QYXVsIiwiYW5uZWVfZGVfbmFpc3NhbmNlIjoiMTk1My0wOSIsImZvbmN0aW9uIjpudWxsLCJzZXhlIjoiTSJ9LHsibm9tIjoiUk9VVklFUiIsInByZW5vbXMiOiJBcm1hbmQiLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTYxLTAzIiwiZm9uY3Rpb24iOm51bGwsInNleGUiOiJNIn0seyJub20iOiJCRUxMSU5JIiwicHJlbm9tcyI6Ik5hbnMiLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTg5LTA1IiwiZm9uY3Rpb24iOm51bGwsInNleGUiOiJNIn0seyJub20iOiJDQVJMRVRUSSIsInByZW5vbXMiOiJSYXltb25kZSIsImFubmVlX2RlX25haXNzYW5jZSI6IjE5NDYtMTIiLCJmb25jdGlvbiI6IjFlciBWaWNlLXByw6lzaWRlbnQgZHUgY29uc2VpbCBjb21tdW5hdXRhaXJlIiwic2V4ZSI6IkYifSx7Im5vbSI6IlJJQk9VTEVUIiwicHJlbm9tcyI6IkdpbGJlcnQiLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTU0LTExIiwiZm9uY3Rpb24iOm51bGwsInNleGUiOiJNIn0seyJub20iOiJCT05ORVQiLCJwcmVub21zIjoiUmVuw6kiLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTU1LTAyIiwiZm9uY3Rpb24iOm51bGwsInNleGUiOiJNIn0seyJub20iOiJEQVJSSUdPTCIsInByZW5vbXMiOiJHw6lyYXJkIiwiYW5uZWVfZGVfbmFpc3NhbmNlIjoiMTk1MC0xMiIsImZvbmN0aW9uIjpudWxsLCJzZXhlIjoiTSJ9LHsibm9tIjoiRklMSVBQSSIsInByZW5vbXMiOiJBbGFpbiIsImFubmVlX2RlX25haXNzYW5jZSI6IjE5NTAtMTEiLCJmb25jdGlvbiI6bnVsbCwic2V4ZSI6Ik0ifSx7Im5vbSI6IkpFQU5ORVJFVCIsInByZW5vbXMiOiJSZW7DqWUiLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTU4LTA4IiwiZm9uY3Rpb24iOm51bGwsInNleGUiOiJGIn0seyJub20iOiJMSU9OIiwicHJlbm9tcyI6IkplYW4gUGllcnJlIiwiYW5uZWVfZGVfbmFpc3NhbmNlIjoiMTk1MS0wMyIsImZvbmN0aW9uIjpudWxsLCJzZXhlIjoiTSJ9LHsibm9tIjoiTUFUSElFVSIsInByZW5vbXMiOiJGcmFuY2siLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTc1LTEwIiwiZm9uY3Rpb24iOm51bGwsInNleGUiOiJNIn0seyJub20iOiJHVUlHVUVTIiwicHJlbm9tcyI6IkRlbmlzZSIsImFubmVlX2RlX25haXNzYW5jZSI6IjE5NTktMDEiLCJmb25jdGlvbiI6bnVsbCwic2V4ZSI6IkYifSx7Im5vbSI6IkJSSUVVR05FIiwicHJlbm9tcyI6IkZhYmllbiIsImFubmVlX2RlX25haXNzYW5jZSI6IjE5NTctMTIiLCJmb25jdGlvbiI6IjRlbWUgVmljZS1wcsOpc2lkZW50IGR1IGNvbnNlaWwgY29tbXVuYXV0YWlyZSIsInNleGUiOiJNIn0seyJub20iOiJHQUdMSUFOTyIsInByZW5vbXMiOiJDaHJpc3RpYW4iLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTQ5LTA3IiwiZm9uY3Rpb24iOm51bGwsInNleGUiOiJNIn0seyJub20iOiJMQVZBTCIsInByZW5vbXMiOiJTdMOpcGhhbmUiLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTU5LTA4IiwiZm9uY3Rpb24iOm51bGwsInNleGUiOiJNIn0seyJub20iOiJNVVJBVC1EQVZJRCIsInByZW5vbXMiOiJQaGlsaXBwZSIsImFubmVlX2RlX25haXNzYW5jZSI6IjE5NDgtMTAiLCJmb25jdGlvbiI6bnVsbCwic2V4ZSI6Ik0ifSx7Im5vbSI6IkJBTEJJUyIsInByZW5vbXMiOiJSb2xsYW5kIiwiYW5uZWVfZGVfbmFpc3NhbmNlIjoiMTk1Ni0wNiIsImZvbmN0aW9uIjoiUHLDqXNpZGVudCBkdSBjb25zZWlsIGNvbW11bmF1dGFpcmUiLCJzZXhlIjoiTSJ9LHsibm9tIjoiQkFTU0UiLCJwcmVub21zIjoiSmVhbi1DbGF1ZGUiLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTU3LTA4IiwiZm9uY3Rpb24iOm51bGwsInNleGUiOiJNIn0seyJub20iOiJCT1RUQUNDSEkiLCJwcmVub21zIjoiTHlkaWUiLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTY4LTA5IiwiZm9uY3Rpb24iOm51bGwsInNleGUiOiJGIn0seyJub20iOiJDT05TVEFOUyIsInByZW5vbXMiOiJQaWVycmUiLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTUwLTExIiwiZm9uY3Rpb24iOiI1ZW1lIFZpY2UtcHLDqXNpZGVudCBkdSBjb25zZWlsIGNvbW11bmF1dGFpcmUiLCJzZXhlIjoiTSJ9LHsibm9tIjoiTU9NQlJJQUwtRkFZQVVCT1NUIiwicHJlbm9tcyI6Ik1hcnRpbmUiLCJhbm5lZV9kZV9uYWlzc2FuY2UiOiIxOTQ2LTA4IiwiZm9uY3Rpb24iOm51bGwsInNleGUiOiJGIn1dLCJuaXZlYXUiOiJlcGNpIn0sImNvbnZlbnRpb25fY29sbGVjdGl2ZV9yZW5zZWlnbmVlIjp0cnVlLCJsaXN0ZV9pZGNjIjpbIjUwMjEiXSwiZWdhcHJvX3JlbnNlaWduZWUiOmZhbHNlLCJlc3RfYXNzb2NpYXRpb24iOmZhbHNlLCJlc3RfYmlvIjpmYWxzZSwiZXN0X2VudHJlcHJlbmV1cl9pbmRpdmlkdWVsIjpmYWxzZSwiZXN0X2VudHJlcHJlbmV1cl9zcGVjdGFjbGUiOmZhbHNlLCJlc3RfZXNzIjpmYWxzZSwiZXN0X2ZpbmVzcyI6ZmFsc2UsImVzdF9vcmdhbmlzbWVfZm9ybWF0aW9uIjpmYWxzZSwiZXN0X3F1YWxpb3BpIjpmYWxzZSwibGlzdGVfaWRfb3JnYW5pc21lX2Zvcm1hdGlvbiI6bnVsbCwiZXN0X3JnZSI6ZmFsc2UsImVzdF9zZXJ2aWNlX3B1YmxpYyI6dHJ1ZSwiZXN0X3NpYWUiOmZhbHNlLCJlc3Rfc29jaWV0ZV9taXNzaW9uIjpmYWxzZSwiZXN0X3VhaSI6ZmFsc2UsImlkZW50aWZpYW50X2Fzc29jaWF0aW9uIjpudWxsLCJzdGF0dXRfZW50cmVwcmVuZXVyX3NwZWN0YWNsZSI6bnVsbCwidHlwZV9zaWFlIjpudWxsfX1dLCJ0b3RhbF9yZXN1bHRzIjoxLCJwYWdlIjoxLCJwZXJfcGFnZSI6MTAsInRvdGFsX3BhZ2VzIjoxfQ== + recorded_at: Tue, 15 Oct 2024 13:53:25 GMT recorded_with: VCR 6.2.0 diff --git a/spec/fixtures/cassettes/annuaire_service_public_success_35600082800018.yml b/spec/fixtures/cassettes/annuaire_service_public_success_35600082800018.yml new file mode 100644 index 000000000..6414d37be --- /dev/null +++ b/spec/fixtures/cassettes/annuaire_service_public_success_35600082800018.yml @@ -0,0 +1,122 @@ +--- +http_interactions: +- request: + method: get + uri: https://api-lannuaire.service-public.fr/api/explore/v2.1/catalog/datasets/api-lannuaire-administration/records?where=siret:35600082800018 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - demarches-simplifiees.fr + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + Server: + - openresty + Date: + - Tue, 15 Oct 2024 16:24:16 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '33' + X-Ratelimit-Remaining: + - '999919' + X-Ratelimit-Limit: + - '1000000' + X-Ratelimit-Reset: + - '2024-10-16 00:00:00+00:00' + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Vary: + - Accept-Language, Cookie, Host + Content-Language: + - fr-fr + Access-Control-Allow-Origin: + - "*" + Access-Control-Allow-Methods: + - POST, GET, OPTIONS + Access-Control-Max-Age: + - '1000' + Access-Control-Allow-Headers: + - Authorization, X-Requested-With, Origin, ODS-API-Analytics-App, ODS-API-Analytics-Embed-Type, + ODS-API-Analytics-Embed-Referrer, ODS-Widgets-Version, Accept + Access-Control-Expose-Headers: + - ODS-Explore-API-Deprecation, Link, X-RateLimit-Remaining, X-RateLimit-Limit, + X-RateLimit-Reset, X-RateLimit-dataset-Remaining, X-RateLimit-dataset-Limit, + X-RateLimit-dataset-Reset + Strict-Transport-Security: + - max-age=31536000 + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + Referrer-Policy: + - strict-origin-when-cross-origin + Permissions-Policy: + - midi=(),microphone=(),camera=(),magnetometer=(),gyroscope=(),fullscreen=(self),payment=() + Content-Security-Policy: + - upgrade-insecure-requests; + X-Ua-Compatible: + - IE=edge + body: + encoding: ASCII-8BIT + string: '{"total_count": 0, "results": []}' + recorded_at: Tue, 15 Oct 2024 16:24:16 GMT +- request: + method: get + uri: https://recherche-entreprises.api.gouv.fr/search?q=35600082800018 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - demarches-simplifiees.fr + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + Server: + - nginx/1.27.1 + Date: + - Tue, 15 Oct 2024 16:24:16 GMT + Content-Type: + - application/json + Content-Length: + - '3374' + Vary: + - Accept-Encoding + - Accept-Encoding + - Accept-Encoding + Annuaire-Entreprises-Instance-Number: + - '01' + - '02' + X-Frame-Options: + - DENY + X-Content-Type-Options: + - nosniff + Strict-Transport-Security: + - max-age=31536000 + X-Xss-Protection: + - 1; mode=block + Access-Control-Allow-Headers: + - Content-Type + Access-Control-Allow-Origin: + - "*" + body: + encoding: ASCII-8BIT + string: '{"results":[{"siren":"356000828","nom_complet":"LA POSTE (REGION RHONE + ALPES)","nom_raison_sociale":"LA POSTE","sigle":null,"nombre_etablissements":2948,"nombre_etablissements_ouverts":0,"siege":{"activite_principale":"53.10Z","activite_principale_registre_metier":null,"annee_tranche_effectif_salarie":null,"adresse":"4 + QUAI DU POINT DU JOUR 92100 BOULOGNE-BILLANCOURT","caractere_employeur":"N","cedex":null,"code_pays_etranger":null,"code_postal":"92100","commune":"92012","complement_adresse":null,"coordonnees":"48.832893,2.259972","date_creation":"1991-01-01","date_debut_activite":"2011-12-31","date_fermeture":"2011-12-31","date_mise_a_jour":null,"date_mise_a_jour_insee":"2024-03-22T15:40:57","departement":"92","distribution_speciale":null,"epci":"200054781","est_siege":true,"etat_administratif":"F","geo_adresse":"4 + Quai du Point du Jour 92100 Boulogne-Billancourt","geo_id":"92012_7231_00004","indice_repetition":null,"latitude":"48.832893","libelle_cedex":null,"libelle_commune":"BOULOGNE-BILLANCOURT","libelle_commune_etranger":null,"libelle_pays_etranger":null,"libelle_voie":"DU + POINT DU JOUR","liste_enseignes":null,"liste_finess":null,"liste_id_bio":null,"liste_idcc":null,"liste_id_organisme_formation":null,"liste_rge":null,"liste_uai":null,"longitude":"2.259972","nom_commercial":null,"numero_voie":"4","region":"11","siret":"35600082800018","statut_diffusion_etablissement":"O","tranche_effectif_salarie":null,"type_voie":"QUAI"},"activite_principale":"53.10Z","categorie_entreprise":null,"caractere_employeur":null,"annee_categorie_entreprise":null,"date_creation":"1991-01-01","date_fermeture":"2011-12-31","date_mise_a_jour":"2024-10-14T15:38:40","date_mise_a_jour_insee":"2024-03-22T14:26:06","date_mise_a_jour_rne":null,"dirigeants":[],"etat_administratif":"C","nature_juridique":"4130","section_activite_principale":"H","tranche_effectif_salarie":null,"annee_tranche_effectif_salarie":null,"statut_diffusion":"O","matching_etablissements":[{"activite_principale":"53.10Z","ancien_siege":false,"annee_tranche_effectif_salarie":null,"adresse":"4 + QUAI DU POINT DU JOUR 92100 BOULOGNE-BILLANCOURT","caractere_employeur":"N","code_postal":"92100","commune":"92012","date_creation":"1991-01-01","date_debut_activite":"2011-12-31","date_fermeture":"2011-12-31","epci":"200054781","est_siege":true,"etat_administratif":"F","geo_id":"92012_7231_00004","latitude":"48.832893","libelle_commune":"BOULOGNE-BILLANCOURT","liste_enseignes":null,"liste_finess":null,"liste_id_bio":null,"liste_idcc":null,"liste_id_organisme_formation":null,"liste_rge":null,"liste_uai":null,"longitude":"2.259972","nom_commercial":null,"region":"11","siret":"35600082800018","statut_diffusion_etablissement":"O","tranche_effectif_salarie":null}],"finances":null,"complements":{"collectivite_territoriale":null,"convention_collective_renseignee":false,"liste_idcc":null,"egapro_renseignee":false,"est_association":false,"est_bio":false,"est_entrepreneur_individuel":false,"est_entrepreneur_spectacle":false,"est_ess":false,"est_finess":false,"est_organisme_formation":false,"est_qualiopi":false,"liste_id_organisme_formation":null,"est_rge":false,"est_service_public":true,"est_siae":false,"est_societe_mission":false,"est_uai":false,"identifiant_association":null,"statut_entrepreneur_spectacle":null,"type_siae":null}}],"total_results":1,"page":1,"per_page":10,"total_pages":1}' + recorded_at: Tue, 15 Oct 2024 16:24:16 GMT +recorded_with: VCR 6.2.0 diff --git a/spec/fixtures/cassettes/annuaire_service_public_success_41816609600051.yml b/spec/fixtures/cassettes/annuaire_service_public_success_41816609600051.yml new file mode 100644 index 000000000..dc50ca6b5 --- /dev/null +++ b/spec/fixtures/cassettes/annuaire_service_public_success_41816609600051.yml @@ -0,0 +1,119 @@ +--- +http_interactions: +- request: + method: get + uri: https://recherche-entreprises.api.gouv.fr/search?q=41816609600051 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - demarches-simplifiees.fr + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + Server: + - nginx/1.27.1 + Date: + - Thu, 17 Oct 2024 07:51:06 GMT + Content-Type: + - application/json + Content-Length: + - '3471' + Vary: + - Accept-Encoding + - Accept-Encoding + - Accept-Encoding + Annuaire-Entreprises-Instance-Number: + - '02' + X-Frame-Options: + - DENY + X-Content-Type-Options: + - nosniff + Strict-Transport-Security: + - max-age=31536000 + - max-age=31536000 + X-Xss-Protection: + - 1; mode=block + - 1; mode=block + Access-Control-Allow-Headers: + - Content-Type + Access-Control-Allow-Origin: + - "*" + body: + encoding: ASCII-8BIT + string: !binary |- + eyJyZXN1bHRzIjpbeyJzaXJlbiI6IjQxODE2NjA5NiIsIm5vbV9jb21wbGV0IjoiT0NUTy1URUNITk9MT0dZIiwibm9tX3JhaXNvbl9zb2NpYWxlIjoiT0NUTy1URUNITk9MT0dZIiwic2lnbGUiOm51bGwsIm5vbWJyZV9ldGFibGlzc2VtZW50cyI6Nywibm9tYnJlX2V0YWJsaXNzZW1lbnRzX291dmVydHMiOjEsInNpZWdlIjp7ImFjdGl2aXRlX3ByaW5jaXBhbGUiOiI2Mi4wMkEiLCJhY3Rpdml0ZV9wcmluY2lwYWxlX3JlZ2lzdHJlX21ldGllciI6bnVsbCwiYW5uZWVfdHJhbmNoZV9lZmZlY3RpZl9zYWxhcmllIjoiMjAyMiIsImFkcmVzc2UiOiIzNCBBVkVOVUUgREUgTCdPUEVSQSA3NTAwMiBQQVJJUyIsImNhcmFjdGVyZV9lbXBsb3lldXIiOiJPIiwiY2VkZXgiOm51bGwsImNvZGVfcGF5c19ldHJhbmdlciI6bnVsbCwiY29kZV9wb3N0YWwiOiI3NTAwMiIsImNvbW11bmUiOiI3NTEwMiIsImNvbXBsZW1lbnRfYWRyZXNzZSI6bnVsbCwiY29vcmRvbm5lZXMiOiI0OC44Njg2NjIsMi4zMzMzODIiLCJkYXRlX2NyZWF0aW9uIjoiMjAxNi0xMS0yOCIsImRhdGVfZGVidXRfYWN0aXZpdGUiOiIyMDE2LTExLTI4IiwiZGF0ZV9mZXJtZXR1cmUiOm51bGwsImRhdGVfbWlzZV9hX2pvdXIiOm51bGwsImRhdGVfbWlzZV9hX2pvdXJfaW5zZWUiOiIyMDI0LTA1LTMxVDA1OjI5OjU4IiwiZGVwYXJ0ZW1lbnQiOiI3NSIsImRpc3RyaWJ1dGlvbl9zcGVjaWFsZSI6bnVsbCwiZXBjaSI6IjIwMDA1NDc4MSIsImVzdF9zaWVnZSI6dHJ1ZSwiZXRhdF9hZG1pbmlzdHJhdGlmIjoiQSIsImdlb19hZHJlc3NlIjoiMzQgQXZlbnVlIGRlIGwnT3DDqXJhIDc1MDAyIFBhcmlzIiwiZ2VvX2lkIjoiNzUxMDJfNjkwNF8wMDAzNCIsImluZGljZV9yZXBldGl0aW9uIjpudWxsLCJsYXRpdHVkZSI6IjQ4Ljg2ODY2MiIsImxpYmVsbGVfY2VkZXgiOm51bGwsImxpYmVsbGVfY29tbXVuZSI6IlBBUklTIiwibGliZWxsZV9jb21tdW5lX2V0cmFuZ2VyIjpudWxsLCJsaWJlbGxlX3BheXNfZXRyYW5nZXIiOm51bGwsImxpYmVsbGVfdm9pZSI6IkRFIEwnT1BFUkEiLCJsaXN0ZV9lbnNlaWduZXMiOm51bGwsImxpc3RlX2ZpbmVzcyI6bnVsbCwibGlzdGVfaWRfYmlvIjpudWxsLCJsaXN0ZV9pZGNjIjpbIjE0ODYiXSwibGlzdGVfaWRfb3JnYW5pc21lX2Zvcm1hdGlvbiI6bnVsbCwibGlzdGVfcmdlIjpudWxsLCJsaXN0ZV91YWkiOm51bGwsImxvbmdpdHVkZSI6IjIuMzMzMzgyIiwibm9tX2NvbW1lcmNpYWwiOm51bGwsIm51bWVyb192b2llIjoiMzQiLCJyZWdpb24iOiIxMSIsInNpcmV0IjoiNDE4MTY2MDk2MDAwNjkiLCJzdGF0dXRfZGlmZnVzaW9uX2V0YWJsaXNzZW1lbnQiOiJPIiwidHJhbmNoZV9lZmZlY3RpZl9zYWxhcmllIjoiNDEiLCJ0eXBlX3ZvaWUiOiJBVkVOVUUifSwiYWN0aXZpdGVfcHJpbmNpcGFsZSI6IjYyLjAyQSIsImNhdGVnb3JpZV9lbnRyZXByaXNlIjoiR0UiLCJjYXJhY3RlcmVfZW1wbG95ZXVyIjpudWxsLCJhbm5lZV9jYXRlZ29yaWVfZW50cmVwcmlzZSI6IjIwMjIiLCJkYXRlX2NyZWF0aW9uIjoiMTk5OC0wNC0wMSIsImRhdGVfZmVybWV0dXJlIjpudWxsLCJkYXRlX21pc2VfYV9qb3VyIjoiMjAyNC0xMC0xNlQxMzowODowMyIsImRhdGVfbWlzZV9hX2pvdXJfaW5zZWUiOiIyMDI0LTA1LTMxVDA1OjI5OjU4IiwiZGF0ZV9taXNlX2Ffam91cl9ybmUiOiIyMDI0LTA1LTE5VDE2OjQ5OjMzIiwiZGlyaWdlYW50cyI6W3sic2lyZW4iOm51bGwsImRlbm9taW5hdGlvbiI6IktQTUciLCJxdWFsaXRlIjoiQ29tbWlzc2FpcmUgYXV4IGNvbXB0ZXMgdGl0dWxhaXJlIiwidHlwZV9kaXJpZ2VhbnQiOiJwZXJzb25uZSBtb3JhbGUifV0sImV0YXRfYWRtaW5pc3RyYXRpZiI6IkEiLCJuYXR1cmVfanVyaWRpcXVlIjoiNTcxMCIsInNlY3Rpb25fYWN0aXZpdGVfcHJpbmNpcGFsZSI6IkoiLCJ0cmFuY2hlX2VmZmVjdGlmX3NhbGFyaWUiOiI0MSIsImFubmVlX3RyYW5jaGVfZWZmZWN0aWZfc2FsYXJpZSI6IjIwMjIiLCJzdGF0dXRfZGlmZnVzaW9uIjoiTyIsIm1hdGNoaW5nX2V0YWJsaXNzZW1lbnRzIjpbeyJhY3Rpdml0ZV9wcmluY2lwYWxlIjoiNjIuMDJBIiwiYW5jaWVuX3NpZWdlIjp0cnVlLCJhbm5lZV90cmFuY2hlX2VmZmVjdGlmX3NhbGFyaWUiOm51bGwsImFkcmVzc2UiOiI1MCBBVkVOVUUgREVTIENIQU1QUyBFTFlTRUVTIDc1MDA4IFBBUklTIiwiY2FyYWN0ZXJlX2VtcGxveWV1ciI6Ik8iLCJjb2RlX3Bvc3RhbCI6Ijc1MDA4IiwiY29tbXVuZSI6Ijc1MTA4IiwiZGF0ZV9jcmVhdGlvbiI6IjIwMDUtMDItMTciLCJkYXRlX2RlYnV0X2FjdGl2aXRlIjoiMjAxNi0xMS0yOCIsImRhdGVfZmVybWV0dXJlIjoiMjAxNi0xMS0yOCIsImVwY2kiOiIyMDAwNTQ3ODEiLCJlc3Rfc2llZ2UiOmZhbHNlLCJldGF0X2FkbWluaXN0cmF0aWYiOiJGIiwiZ2VvX2lkIjoiNzUxMDhfMTczM18wMDA1MCIsImxhdGl0dWRlIjoiNDguODcwMzcxIiwibGliZWxsZV9jb21tdW5lIjoiUEFSSVMiLCJsaXN0ZV9lbnNlaWduZXMiOm51bGwsImxpc3RlX2ZpbmVzcyI6bnVsbCwibGlzdGVfaWRfYmlvIjpudWxsLCJsaXN0ZV9pZGNjIjpudWxsLCJsaXN0ZV9pZF9vcmdhbmlzbWVfZm9ybWF0aW9uIjpudWxsLCJsaXN0ZV9yZ2UiOm51bGwsImxpc3RlX3VhaSI6bnVsbCwibG9uZ2l0dWRlIjoiMi4zMDY4OTkiLCJub21fY29tbWVyY2lhbCI6bnVsbCwicmVnaW9uIjoiMTEiLCJzaXJldCI6IjQxODE2NjA5NjAwMDUxIiwic3RhdHV0X2RpZmZ1c2lvbl9ldGFibGlzc2VtZW50IjoiTyIsInRyYW5jaGVfZWZmZWN0aWZfc2FsYXJpZSI6bnVsbH1dLCJmaW5hbmNlcyI6eyIyMDIzIjp7ImNhIjoxNDYxMzk4MDksInJlc3VsdGF0X25ldCI6MTA2MjY2Mzh9fSwiY29tcGxlbWVudHMiOnsiY29sbGVjdGl2aXRlX3RlcnJpdG9yaWFsZSI6bnVsbCwiY29udmVudGlvbl9jb2xsZWN0aXZlX3JlbnNlaWduZWUiOnRydWUsImxpc3RlX2lkY2MiOlsiMTQ4NiJdLCJlZ2Fwcm9fcmVuc2VpZ25lZSI6dHJ1ZSwiZXN0X2Fzc29jaWF0aW9uIjpmYWxzZSwiZXN0X2JpbyI6ZmFsc2UsImVzdF9lbnRyZXByZW5ldXJfaW5kaXZpZHVlbCI6ZmFsc2UsImVzdF9lbnRyZXByZW5ldXJfc3BlY3RhY2xlIjpmYWxzZSwiZXN0X2VzcyI6ZmFsc2UsImVzdF9maW5lc3MiOmZhbHNlLCJlc3Rfb3JnYW5pc21lX2Zvcm1hdGlvbiI6dHJ1ZSwiZXN0X3F1YWxpb3BpIjp0cnVlLCJsaXN0ZV9pZF9vcmdhbmlzbWVfZm9ybWF0aW9uIjpbIjExNzU0ODkzNjc1Il0sImVzdF9yZ2UiOmZhbHNlLCJlc3Rfc2VydmljZV9wdWJsaWMiOmZhbHNlLCJlc3Rfc2lhZSI6ZmFsc2UsImVzdF9zb2NpZXRlX21pc3Npb24iOmZhbHNlLCJlc3RfdWFpIjpmYWxzZSwiaWRlbnRpZmlhbnRfYXNzb2NpYXRpb24iOm51bGwsInN0YXR1dF9lbnRyZXByZW5ldXJfc3BlY3RhY2xlIjpudWxsLCJ0eXBlX3NpYWUiOm51bGx9fV0sInRvdGFsX3Jlc3VsdHMiOjEsInBhZ2UiOjEsInBlcl9wYWdlIjoxMCwidG90YWxfcGFnZXMiOjF9 + recorded_at: Thu, 17 Oct 2024 07:51:06 GMT +- request: + method: get + uri: https://api-lannuaire.service-public.fr/api/explore/v2.1/catalog/datasets/api-lannuaire-administration/records?where=siret:41816609600051 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - demarches-simplifiees.fr + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + Server: + - openresty + Date: + - Thu, 17 Oct 2024 07:51:06 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '33' + X-Ratelimit-Remaining: + - '999989' + X-Ratelimit-Limit: + - '1000000' + X-Ratelimit-Reset: + - '2024-10-18 00:00:00+00:00' + Cache-Control: + - no-cache, no-store, max-age=0, must-revalidate + Vary: + - Accept-Language, Cookie, Host + Content-Language: + - fr-fr + Access-Control-Allow-Origin: + - "*" + Access-Control-Allow-Methods: + - POST, GET, OPTIONS + Access-Control-Max-Age: + - '1000' + Access-Control-Allow-Headers: + - Authorization, X-Requested-With, Origin, ODS-API-Analytics-App, ODS-API-Analytics-Embed-Type, + ODS-API-Analytics-Embed-Referrer, ODS-Widgets-Version, Accept + Access-Control-Expose-Headers: + - ODS-Explore-API-Deprecation, Link, X-RateLimit-Remaining, X-RateLimit-Limit, + X-RateLimit-Reset, X-RateLimit-dataset-Remaining, X-RateLimit-dataset-Limit, + X-RateLimit-dataset-Reset + Strict-Transport-Security: + - max-age=31536000 + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + Referrer-Policy: + - strict-origin-when-cross-origin + Permissions-Policy: + - midi=(),microphone=(),camera=(),magnetometer=(),gyroscope=(),fullscreen=(self),payment=() + Content-Security-Policy: + - upgrade-insecure-requests; + X-Ua-Compatible: + - IE=edge + body: + encoding: ASCII-8BIT + string: '{"total_count": 0, "results": []}' + recorded_at: Thu, 17 Oct 2024 07:51:06 GMT +recorded_with: VCR 6.2.0 diff --git a/spec/models/concerns/prefillable_from_service_public_concern_spec.rb b/spec/models/concerns/prefillable_from_service_public_concern_spec.rb index 2475972ae..4580ac689 100644 --- a/spec/models/concerns/prefillable_from_service_public_concern_spec.rb +++ b/spec/models/concerns/prefillable_from_service_public_concern_spec.rb @@ -9,12 +9,13 @@ RSpec.describe PrefillableFromServicePublicConcern, type: :model do describe '#prefill_from_siret' do let(:service) { Service.new(siret:) } subject { service.prefill_from_siret } - context 'when API call is successful' do + context 'when API call is successful with collectivite' do it 'prefills service attributes' do VCR.use_cassette('annuaire_service_public_success_20004021000060') do - expect(subject).to be_success + expect(subject.all?(&:success?)).to be_truthy expect(service.nom).to eq("Communauté de communes - Lacs et Gorges du Verdon") + expect(service).to be_collectivite_territoriale expect(service.email).to eq("redacted@email.fr") expect(service.telephone).to eq("04 94 70 00 00") expect(service.horaires).to eq("Lundi au Jeudi : de 8:00 à 12:00 et de 13:30 à 17:30\nVendredi : de 8:00 à 12:00") @@ -39,7 +40,46 @@ RSpec.describe PrefillableFromServicePublicConcern, type: :model do let(:siret) { '20004021000000' } it 'returns a failure result' do VCR.use_cassette('annuaire_service_public_failure_20004021000000') do - expect(subject).to be_failure + expect(subject.all?(&:failure?)).to be_truthy + end + end + end + + context 'when SIRET is enseignement' do + let(:siret) { '19750664500013' } + it 'prefills for enseignement' do + VCR.use_cassette('annuaire_service_public_success_19750664500013') do + expect(subject.one?(&:success?)).to be_truthy + expect(service.nom).to eq("LYCEE GENERAL ET TECHNOLOGIQUE RACINE") + expect(service).to be_etablissement_enseignement + expect(service.adresse).to eq("20 Rue du Rocher 75008 Paris") + expect(service.horaires).to be_nil + end + end + end + + context 'when SIRET is a ministere' do + let(:siret) { '11004601800013' } + it 'prefills for administration centrale' do + VCR.use_cassette('annuaire_service_public_success_11004601800013') do + expect(subject.one?(&:success?)).to be_truthy + expect(service.nom).to eq("MINISTERE DE LA CULTURE") + expect(service).to be_administration_centrale + expect(service.adresse).to eq("182 Rue Saint-Honoré 75001 Paris") + expect(service.horaires).to be_nil + end + end + end + + context 'when SIRET is La Poste' do + let(:siret) { '35600082800018' } + it 'prefills for administration centrale' do + VCR.use_cassette('annuaire_service_public_success_35600082800018') do + expect(subject.one?(&:success?)).to be_truthy + expect(service.nom).to eq("LA POSTE (REGION RHONE ALPES)") + expect(service).to be_service_deconcentre_de_l_etat + expect(service.adresse).to eq("4 Quai du Point du Jour 92100 Boulogne-Billancourt") + expect(service.horaires).to be_nil end end end From 7742703081611c1a372045a6dede8da3f6d69e43 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 15 Oct 2024 18:35:18 +0200 Subject: [PATCH 1371/1532] refactor(service): concurrent prefill API calls --- app/controllers/recoveries_controller.rb | 2 +- ...prefillable_from_service_public_concern.rb | 28 ++++++++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/app/controllers/recoveries_controller.rb b/app/controllers/recoveries_controller.rb index 6a34c3e55..0d38b4d00 100644 --- a/app/controllers/recoveries_controller.rb +++ b/app/controllers/recoveries_controller.rb @@ -68,7 +68,7 @@ class RecoveriesController < ApplicationController def structure_name # we know that the structure exists because # of the ensure_collectivite_territoriale guard - APIRechercheEntreprisesService.new.(siret:).value![:nom_complet] + APIRechercheEntreprisesService.new.call(siret:).value![:nom_complet] end def ensure_agent_connect_is_used diff --git a/app/models/concerns/prefillable_from_service_public_concern.rb b/app/models/concerns/prefillable_from_service_public_concern.rb index 4d7fb5f27..bcf4f8475 100644 --- a/app/models/concerns/prefillable_from_service_public_concern.rb +++ b/app/models/concerns/prefillable_from_service_public_concern.rb @@ -5,9 +5,22 @@ module PrefillableFromServicePublicConcern included do def prefill_from_siret - result_sp = AnnuaireServicePublicService.new.(siret:) + future_sp = Concurrent::Future.execute { AnnuaireServicePublicService.new.call(siret:) } + future_api_ent = Concurrent::Future.execute { APIRechercheEntreprisesService.new.call(siret:) } - case result_sp + result_sp = future_sp.value! + result_api_ent = future_api_ent.value! + + prefill_from_service_public(result_sp) + prefill_from_api_entreprise(result_api_ent) + + [result_sp, result_api_ent] + end + + private + + def prefill_from_service_public(result) + case result in Dry::Monads::Success(data) self.nom = data[:nom] if nom.blank? self.email = data[:adresse_courriel] if email.blank? @@ -17,24 +30,19 @@ module PrefillableFromServicePublicConcern else # NOOP end + end - result_api_ent = APIRechercheEntreprisesService.new.call(siret:) - case result_api_ent + def prefill_from_api_entreprise(result) + case result in Dry::Monads::Success(data) self.type_organisme = detect_type_organisme(data) if type_organisme.blank? - - # some services (etablissements, …) are not in service public, so we also try to prefill them with API Entreprise self.nom = data[:nom_complet] if nom.blank? self.adresse = data.dig(:siege, :geo_adresse) if adresse.blank? else # NOOP end - - [result_sp, result_api_ent] end - private - def denormalize_plage_ouverture(data) return if data.blank? From 384d089cb33d028c6826704c7d873f5da2a9250b Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 16 Oct 2024 16:49:00 +0200 Subject: [PATCH 1372/1532] feat(service): prefill first service with AgentConnect siret --- .../administrateurs/services_controller.rb | 23 +++++++---- .../administrateurs/services/new.html.haml | 2 +- .../services_controller_spec.rb | 38 +++++++++++++++++++ 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/app/controllers/administrateurs/services_controller.rb b/app/controllers/administrateurs/services_controller.rb index c025a0ff1..e3439307e 100644 --- a/app/controllers/administrateurs/services_controller.rb +++ b/app/controllers/administrateurs/services_controller.rb @@ -12,6 +12,12 @@ module Administrateurs def new @procedure = procedure @service = Service.new + + siret = current_administrateur.instructeur.last_agent_connect_information&.siret + if siret + @service.siret = siret + @prefilled = handle_siret_prefill + end end def create @@ -19,7 +25,13 @@ module Administrateurs @service.administrateur = current_administrateur if request.xhr? && params[:service][:siret].present? - handle_siret_update + @service.siret = params[:service][:siret] + prefilled = handle_siret_prefill + render turbo_stream: turbo_stream.replace( + "service_form", + partial: "administrateurs/services/form", + locals: { service: @service, prefilled:, procedure: @procedure } + ) elsif @service.save @service.enqueue_api_entreprise @@ -111,8 +123,7 @@ module Administrateurs current_administrateur.procedures.find(params[:procedure_id]) end - def handle_siret_update - @service.assign_attributes(siret: params[:service][:siret]) + def handle_siret_prefill @service.validate if !@service.errors.include?(:siret) @@ -130,11 +141,7 @@ module Administrateurs @service.errors.clear siret_errors.each { @service.errors.import(_1) } - render turbo_stream: turbo_stream.replace( - "service_form", - partial: "administrateurs/services/form", - locals: { service: @service, prefilled:, procedure: @procedure } - ) + prefilled end end end diff --git a/app/views/administrateurs/services/new.html.haml b/app/views/administrateurs/services/new.html.haml index 27a39f2b5..7477ad9b2 100644 --- a/app/views/administrateurs/services/new.html.haml +++ b/app/views/administrateurs/services/new.html.haml @@ -8,4 +8,4 @@ %h1 Nouveau Service = render partial: 'form', - locals: { service: @service, procedure: @procedure } + locals: { service: @service, procedure: @procedure, prefilled: @prefilled } diff --git a/spec/controllers/administrateurs/services_controller_spec.rb b/spec/controllers/administrateurs/services_controller_spec.rb index 510086688..77ff1a3ba 100644 --- a/spec/controllers/administrateurs/services_controller_spec.rb +++ b/spec/controllers/administrateurs/services_controller_spec.rb @@ -4,6 +4,44 @@ describe Administrateurs::ServicesController, type: :controller do let(:admin) { administrateurs(:default_admin) } let(:procedure) { create(:procedure, administrateur: admin) } + describe '#new' do + let(:admin) { administrateurs(:default_admin) } + let(:procedure) { create(:procedure, administrateur: admin) } + + before do + sign_in(admin.user) + end + + subject { get :new, params: { procedure_id: procedure.id } } + + context 'when admin has a SIRET from AgentConnect' do + let(:siret) { "20004021000060" } + + before do + agi = build(:agent_connect_information, siret:) + admin.instructeur.agent_connect_information << agi + end + + it 'prefills the SIRET and fetches service information' do + VCR.use_cassette("annuaire_service_public_success_#{siret}") do + subject + expect(assigns[:service].siret).to eq(siret) + expect(assigns[:service].nom).to eq("Communauté de communes - Lacs et Gorges du Verdon") + expect(assigns[:service].adresse).to eq("242 avenue Albert-1er 83630 Aups") + expect(assigns[:prefilled]).to eq(:success) + end + end + end + + context 'when admin has no SIRET from AgentConnect' do + it 'does not prefill the SIRET' do + subject + expect(assigns[:service].siret).to be_nil + expect(assigns[:prefilled]).to be_nil + end + end + end + describe '#create' do before do sign_in(admin.user) From 8b3634ea7658810b6a9a1c64bac3a7fffa51cefa Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 4 Nov 2024 12:47:42 +0100 Subject: [PATCH 1373/1532] refactor(service): dedicated route for prefill from siret --- .../administrateurs/services_controller.rb | 25 ++++++++++++------- .../administrateurs/services/_form.html.haml | 6 +++-- config/routes.rb | 1 + .../services_controller_spec.rb | 22 +++++++++------- 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/app/controllers/administrateurs/services_controller.rb b/app/controllers/administrateurs/services_controller.rb index e3439307e..7fc329efd 100644 --- a/app/controllers/administrateurs/services_controller.rb +++ b/app/controllers/administrateurs/services_controller.rb @@ -24,15 +24,7 @@ module Administrateurs @service = Service.new(service_params) @service.administrateur = current_administrateur - if request.xhr? && params[:service][:siret].present? - @service.siret = params[:service][:siret] - prefilled = handle_siret_prefill - render turbo_stream: turbo_stream.replace( - "service_form", - partial: "administrateurs/services/form", - locals: { service: @service, prefilled:, procedure: @procedure } - ) - elsif @service.save + if @service.save @service.enqueue_api_entreprise redirect_to admin_services_path(procedure_id: params[:procedure_id]), @@ -66,6 +58,19 @@ module Administrateurs end end + def prefill + @procedure = procedure + @service = Service.new(siret: params[:siret]) + + prefilled = handle_siret_prefill + + render turbo_stream: turbo_stream.replace( + "service_form", + partial: "administrateurs/services/form", + locals: { service: @service, prefilled:, procedure: @procedure } + ) + end + def add_to_procedure procedure = current_administrateur.procedures.find(procedure_params[:id]) service = services.find(procedure_params[:service_id]) @@ -137,6 +142,8 @@ module Administrateurs end end + # On prefill from SIRET, we only want to display errors for the SIRET input + # so we have to remove other errors (ie. required attributes not yet filled) siret_errors = @service.errors.where(:siret) @service.errors.clear siret_errors.each { @service.errors.import(_1) } diff --git a/app/views/administrateurs/services/_form.html.haml b/app/views/administrateurs/services/_form.html.haml index f9791c198..8a463a3d7 100644 --- a/app/views/administrateurs/services/_form.html.haml +++ b/app/views/administrateurs/services/_form.html.haml @@ -1,6 +1,8 @@ -= form_with model: [:admin, service], id: "service_form", data: { turbo: token_list('true' => service.new_record?), controller: token_list('autosave' => service.new_record?), turbo_method: 'post' } do |f| += form_with model: [:admin, service], id: "service_form" do |f| - = render Dsfr::InputComponent.new(form: f, attribute: :siret, input_type: :text_field, opts: { placeholder: "14 chiffres, sans espace" }) do |c| + = render Dsfr::InputComponent.new(form: f, attribute: :siret, input_type: :text_field, + opts: { placeholder: "14 chiffres, sans espace", + onblur: token_list("Turbo.visit('#{prefill_admin_services_path(procedure_id: procedure.id)}?siret=' + this.value)" => service.new_record?) }) do |c| - if service.etablissement_infos.blank? && local_assigns[:prefilled].nil? - c.with_hint do = "Indiquez le numéro de SIRET de l’organisme dont ce service dépend. Rechercher le SIRET sur " diff --git a/config/routes.rb b/config/routes.rb index d194eb920..170ce0b4f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -734,6 +734,7 @@ Rails.application.routes.draw do resources :services, except: [:show] do collection do patch 'add_to_procedure' + get ':procedure_id/prefill' => :prefill, as: :prefill end end diff --git a/spec/controllers/administrateurs/services_controller_spec.rb b/spec/controllers/administrateurs/services_controller_spec.rb index 77ff1a3ba..e8bc20c5d 100644 --- a/spec/controllers/administrateurs/services_controller_spec.rb +++ b/spec/controllers/administrateurs/services_controller_spec.rb @@ -42,20 +42,18 @@ describe Administrateurs::ServicesController, type: :controller do end end - describe '#create' do + describe '#prefill' do before do sign_in(admin.user) end - let(:xhr) { false } - subject { post :create, params:, xhr: } + subject { get :prefill, params:, xhr: true } context 'when prefilling from a SIRET' do - let(:xhr) { true } let(:params) do { procedure_id: procedure.id, - service: { siret: "20004021000060" } + siret: "20004021000060" } end @@ -70,11 +68,10 @@ describe Administrateurs::ServicesController, type: :controller do end context 'when attempting to prefilling from invalid SIRET' do - let(:xhr) { true } let(:params) do { procedure_id: procedure.id, - service: { siret: "20004021000000" } + siret: "20004021000000" } end @@ -87,11 +84,10 @@ describe Administrateurs::ServicesController, type: :controller do end context 'when attempting to prefilling from not service public SIRET' do - let(:xhr) { true } let(:params) do { procedure_id: procedure.id, - service: { siret: "41816609600051" } + siret: "41816609600051" } end @@ -105,6 +101,14 @@ describe Administrateurs::ServicesController, type: :controller do end end end + end + + describe '#create' do + before do + sign_in(admin.user) + end + + subject { post :create, params: } context 'when submitting a new service' do let(:params) do From 9dd224c3c71361e40703b3ba323f3e4dc1d1a837 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Wed, 25 Sep 2024 15:05:09 +0200 Subject: [PATCH 1374/1532] create procedure_labels --- app/models/procedure.rb | 1 + app/models/procedure_label.rb | 7 +++++++ db/migrate/20240924151336_create_procedure_labels.rb | 12 ++++++++++++ db/schema.rb | 10 ++++++++++ 4 files changed, 30 insertions(+) create mode 100644 app/models/procedure_label.rb create mode 100644 db/migrate/20240924151336_create_procedure_labels.rb diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 1d87dd735..1d54e6cf8 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -60,6 +60,7 @@ class Procedure < ApplicationRecord has_and_belongs_to_many :procedure_tags has_many :bulk_messages, dependent: :destroy + has_many :procedure_labels, dependent: :destroy def active_dossier_submitted_message published_dossier_submitted_message || draft_dossier_submitted_message diff --git a/app/models/procedure_label.rb b/app/models/procedure_label.rb new file mode 100644 index 000000000..27ce139ef --- /dev/null +++ b/app/models/procedure_label.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ProcedureLabel < ApplicationRecord + belongs_to :procedure + + validates :name, :color, presence: true +end diff --git a/db/migrate/20240924151336_create_procedure_labels.rb b/db/migrate/20240924151336_create_procedure_labels.rb new file mode 100644 index 000000000..0d916fd25 --- /dev/null +++ b/db/migrate/20240924151336_create_procedure_labels.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateProcedureLabels < ActiveRecord::Migration[7.0] + def change + create_table :procedure_labels do |t| + t.string :name + t.string :color + t.references :procedure, null: false, foreign_key: true + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 93aea3e2b..d61271b55 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -869,6 +869,15 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do t.index ["from"], name: "index_path_rewrites_on_from", unique: true end + create_table "procedure_labels", force: :cascade do |t| + t.string "color" + t.datetime "created_at", null: false + t.string "name" + t.bigint "procedure_id", null: false + t.datetime "updated_at", null: false + t.index ["procedure_id"], name: "index_procedure_labels_on_procedure_id" + end + create_table "procedure_presentations", id: :serial, force: :cascade do |t| t.jsonb "a_suivre_filters", default: [], null: false, array: true t.jsonb "archives_filters", default: [], null: false, array: true @@ -1301,6 +1310,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do add_foreign_key "initiated_mails", "procedures" add_foreign_key "instructeurs", "users" add_foreign_key "merge_logs", "users" + add_foreign_key "procedure_labels", "procedures" add_foreign_key "procedure_presentations", "assign_tos" add_foreign_key "procedure_revision_types_de_champ", "procedure_revision_types_de_champ", column: "parent_id" add_foreign_key "procedure_revision_types_de_champ", "procedure_revisions", column: "revision_id" From 96cfb6cc434be9c9119024ea8a87b0bd6b092819 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Wed, 25 Sep 2024 15:08:10 +0200 Subject: [PATCH 1375/1532] create generic labels for old and new procedure --- app/models/procedure.rb | 7 ++++++ app/models/procedure_label.rb | 6 +++++ ...ll_procedure_labels_for_procedures_task.rb | 25 +++++++++++++++++++ .../procedures_controller_spec.rb | 5 ++++ 4 files changed, 43 insertions(+) create mode 100644 app/tasks/maintenance/backfill_procedure_labels_for_procedures_task.rb diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 1d54e6cf8..51647b10d 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -295,6 +295,7 @@ class Procedure < ApplicationRecord after_initialize :ensure_path_exists before_save :ensure_path_exists after_create :ensure_defaut_groupe_instructeur + after_create :create_generic_procedure_labels include AASM @@ -936,6 +937,12 @@ class Procedure < ApplicationRecord end end + def create_generic_procedure_labels + ProcedureLabel::GENERIC_LABELS.each do |label| + ProcedureLabel.create(name: label[:name], color: label[:color], procedure_id: self.id) + end + end + def stable_ids_used_by_routing_rules @stable_ids_used_by_routing_rules ||= groupe_instructeurs.flat_map { _1.routing_rule&.sources }.compact end diff --git a/app/models/procedure_label.rb b/app/models/procedure_label.rb index 27ce139ef..a20b5eee4 100644 --- a/app/models/procedure_label.rb +++ b/app/models/procedure_label.rb @@ -3,5 +3,11 @@ class ProcedureLabel < ApplicationRecord belongs_to :procedure + GENERIC_LABELS = [ + { name: 'à relancer', color: 'brown-caramel' }, + { name: 'complet', color: 'green-bourgeon' }, + { name: 'prêt pour validation', color: 'green-archipel' } + ] + validates :name, :color, presence: true end diff --git a/app/tasks/maintenance/backfill_procedure_labels_for_procedures_task.rb b/app/tasks/maintenance/backfill_procedure_labels_for_procedures_task.rb new file mode 100644 index 000000000..d6aeab298 --- /dev/null +++ b/app/tasks/maintenance/backfill_procedure_labels_for_procedures_task.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Maintenance + class BackfillProcedureLabelsForProceduresTask < MaintenanceTasks::Task + # Cette tâche permet de créer un jeu de labels génériques pour les anciennes procédures + # Plus d'informations sur l'implémentation des labels ici : https://github.com/demarches-simplifiees/demarches-simplifiees.fr/issues/9787 + # 2024-10-15 + + include RunnableOnDeployConcern + + run_on_first_deploy + + def collection + Procedure + .includes(:procedure_labels) + .where(procedure_labels: { id: nil }) + end + + def process(procedure) + ProcedureLabel::GENERIC_LABELS.each do |label| + ProcedureLabel.create(name: label[:name], color: label[:color], procedure_id: procedure.id) + end + end + end +end diff --git a/spec/controllers/administrateurs/procedures_controller_spec.rb b/spec/controllers/administrateurs/procedures_controller_spec.rb index 72b097a5d..21e7139fb 100644 --- a/spec/controllers/administrateurs/procedures_controller_spec.rb +++ b/spec/controllers/administrateurs/procedures_controller_spec.rb @@ -513,6 +513,11 @@ describe Administrateurs::ProceduresController, type: :controller do expect(response).to redirect_to(champs_admin_procedure_path(Procedure.last)) expect(flash[:notice]).to be_present end + + it "create generic labels" do + expect(subject.procedure_labels.size).to eq(3) + expect(subject.procedure_labels.first.name).to eq('à relancer') + end end describe "procedure is saved with custom retention period" do From 2006dd283fb367c43a4d2f07788cdf74e5494a26 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Wed, 25 Sep 2024 16:03:58 +0200 Subject: [PATCH 1376/1532] create dossier_labels --- app/models/dossier.rb | 2 ++ app/models/dossier_label.rb | 6 ++++++ app/models/procedure_label.rb | 1 + db/migrate/20240925133719_create_dossier_labels.rb | 12 ++++++++++++ db/schema.rb | 11 +++++++++++ 5 files changed, 32 insertions(+) create mode 100644 app/models/dossier_label.rb create mode 100644 db/migrate/20240925133719_create_dossier_labels.rb diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 2f9a469aa..ae8e46e34 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -132,6 +132,8 @@ class Dossier < ApplicationRecord belongs_to :transfer, class_name: 'DossierTransfer', foreign_key: 'dossier_transfer_id', optional: true, inverse_of: :dossiers has_many :transfer_logs, class_name: 'DossierTransferLog', dependent: :destroy + has_many :dossier_labels, dependent: :destroy + has_many :procedure_labels, through: :dossier_labels after_destroy_commit :log_destroy diff --git a/app/models/dossier_label.rb b/app/models/dossier_label.rb new file mode 100644 index 000000000..dfbf47fe0 --- /dev/null +++ b/app/models/dossier_label.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class DossierLabel < ApplicationRecord + belongs_to :dossier + belongs_to :procedure_label +end diff --git a/app/models/procedure_label.rb b/app/models/procedure_label.rb index a20b5eee4..d8ab558f4 100644 --- a/app/models/procedure_label.rb +++ b/app/models/procedure_label.rb @@ -2,6 +2,7 @@ class ProcedureLabel < ApplicationRecord belongs_to :procedure + has_many :dossier_labels, dependent: :destroy GENERIC_LABELS = [ { name: 'à relancer', color: 'brown-caramel' }, diff --git a/db/migrate/20240925133719_create_dossier_labels.rb b/db/migrate/20240925133719_create_dossier_labels.rb new file mode 100644 index 000000000..0fab9bfc6 --- /dev/null +++ b/db/migrate/20240925133719_create_dossier_labels.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateDossierLabels < ActiveRecord::Migration[7.0] + def change + create_table :dossier_labels do |t| + t.references :dossier, null: false, foreign_key: true + t.references :procedure_label, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index d61271b55..0235b3b0f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -416,6 +416,15 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do t.index ["resolved_at"], name: "index_dossier_corrections_on_resolved_at", where: "((resolved_at IS NULL) OR (resolved_at IS NOT NULL))" end + create_table "dossier_labels", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "dossier_id", null: false + t.bigint "procedure_label_id", null: false + t.datetime "updated_at", null: false + t.index ["dossier_id"], name: "index_dossier_labels_on_dossier_id" + t.index ["procedure_label_id"], name: "index_dossier_labels_on_procedure_label_id" + end + create_table "dossier_operation_logs", force: :cascade do |t| t.boolean "automatic_operation", default: false, null: false t.bigint "bill_signature_id" @@ -1289,6 +1298,8 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do add_foreign_key "dossier_batch_operations", "dossiers" add_foreign_key "dossier_corrections", "commentaires" add_foreign_key "dossier_corrections", "dossiers" + add_foreign_key "dossier_labels", "dossiers" + add_foreign_key "dossier_labels", "procedure_labels" add_foreign_key "dossier_operation_logs", "bill_signatures" add_foreign_key "dossier_transfer_logs", "dossiers" add_foreign_key "dossiers", "batch_operations" From d0b1292060350e7daf7e820c1422e9c5701c1530 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Tue, 1 Oct 2024 15:17:09 +0200 Subject: [PATCH 1377/1532] instructeur can add and delete labels on dossier show --- app/assets/stylesheets/dsfr.scss | 10 ++++++ app/assets/stylesheets/instructeur.scss | 4 +++ .../instructeurs/dossiers_controller.rb | 13 ++++++++ .../dossiers/_header_top.html.haml | 21 +++++++++++- config/routes.rb | 1 + .../instructeurs/dossiers_controller_spec.rb | 33 +++++++++++++++++++ spec/system/instructeurs/instruction_spec.rb | 29 ++++++++++++++++ .../dossiers/show.html.haml_spec.rb | 28 ++++++++++++++++ 8 files changed, 138 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/dsfr.scss b/app/assets/stylesheets/dsfr.scss index a2ca49e60..824ef4263 100644 --- a/app/assets/stylesheets/dsfr.scss +++ b/app/assets/stylesheets/dsfr.scss @@ -262,3 +262,13 @@ button.fr-tag-bug { .fr-badge--lowercase { text-transform: lowercase; } + +// we use badge with small checkbox - to align them we need to reduce the margin +.fr-checkbox-group--sm.fr-checkbox-custom-with-labels input[type="checkbox"] + label::before { + margin-top: 0.15rem; +} + +// we use badge with checkbox so we need to add a background white to the uncheched checkbox +.fr-checkbox-group input[type="checkbox"] + label::before { + background-color: #FFFFFF; +} diff --git a/app/assets/stylesheets/instructeur.scss b/app/assets/stylesheets/instructeur.scss index 9227d5063..8d95fdd70 100644 --- a/app/assets/stylesheets/instructeur.scss +++ b/app/assets/stylesheets/instructeur.scss @@ -48,6 +48,10 @@ width: 450px; } +.dropdown-label.dropdown-content { + min-width: 390px; +} + .print-menu { display: none; position: absolute; diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index 0b7eb3a74..07193eac2 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -63,6 +63,19 @@ module Instructeurs end end + def dossier_labels + labels = params[:procedure_label_id]&.map(&:to_i) || [] + + @dossier = dossier + labels.each { |params_label| DossierLabel.find_or_create_by(dossier_id: @dossier.id, procedure_label_id: params_label) } + + all_labels = DossierLabel.where(dossier_id: @dossier.id).pluck(:procedure_label_id) + + (all_labels - labels).each { DossierLabel.find_by(dossier_id: @dossier.id, procedure_label_id: _1).destroy } + + render :change_state + end + def messagerie @commentaire = Commentaire.new @messagerie_seen_at = current_instructeur.follows.find_by(dossier: dossier)&.messagerie_seen_at diff --git a/app/views/instructeurs/dossiers/_header_top.html.haml b/app/views/instructeurs/dossiers/_header_top.html.haml index 765abe36b..e752a54bb 100644 --- a/app/views/instructeurs/dossiers/_header_top.html.haml +++ b/app/views/instructeurs/dossiers/_header_top.html.haml @@ -16,7 +16,6 @@ = render Instructeurs::SVASVRDecisionBadgeComponent.new(projection_or_dossier: dossier, procedure: dossier.procedure, with_label: true) - .header-actions.fr-ml-auto = render partial: 'instructeurs/dossiers/header_actions', locals: { dossier: } = render partial: 'instructeurs/dossiers/print_and_export_actions', locals: { dossier: } @@ -26,3 +25,23 @@ - if dossier.user_deleted? %p.fr-mb-1w %small L’usager a supprimé son compte. Vous pouvez archiver puis supprimer le dossier. + + .fr-mb-3w + - if dossier.procedure_labels.present? + - dossier.procedure_labels.each do |label| + .fr-badge.fr-badge--sm{ class: "fr-badge--#{label.color}" } + = label.name + + = render Dropdown::MenuComponent.new(wrapper: :span, button_options: { class: ['fr-btn--sm fr-btn--tertiary-no-outline fr-pl-1v']}, menu_options: { class: ['dropdown-label left-aligned'] }) do |menu| + - if dossier.procedure_labels.empty? + - menu.with_button_inner_html do + Ajouter un label + + - menu.with_form do + = form_with(url: dossier_labels_instructeur_dossier_path(dossier_id: dossier.id, procedure_id: dossier.procedure.id), method: :post, class: 'fr-p-3w', data: { controller: 'autosubmit', turbo: 'true' }) do |f| + %fieldset.fr-fieldset.fr-mt-2w.fr-mb-0 + = f.collection_check_boxes :procedure_label_id, dossier.procedure.procedure_labels, :id, :name, include_hidden: false do |b| + .fr-fieldset__element + .fr-checkbox-group.fr-checkbox-group--sm.fr-checkbox-custom-with-labels.fr-mb-1w + = b.check_box(checked: DossierLabel.find_by(dossier_id: dossier.id, procedure_label_id: b.value).present? ) + = b.label(class: "fr-label fr-badge fr-badge--sm fr-badge--#{b.object.color}") { b.text } diff --git a/config/routes.rb b/config/routes.rb index d194eb920..5b6dd9634 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -512,6 +512,7 @@ Rails.application.routes.draw do resources :commentaires, only: [:destroy] post 'repousser-expiration' => 'dossiers#extend_conservation' post 'repousser-expiration-and-restore' => 'dossiers#extend_conservation_and_restore' + post 'dossier_labels' => 'dossiers#dossier_labels' get 'geo_data' get 'apercu_attestation' get 'bilans_bdf' diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index 4ad9070b2..a035aa8c2 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -1518,4 +1518,37 @@ describe Instructeurs::DossiersController, type: :controller do expect([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp, Commentaire]).to include(*assigns(:gallery_attachments).map { _1.record.class }) end end + + describe 'dossier_labels' do + context 'it create dossier labels' do + subject { post :dossier_labels, params: { procedure_id: procedure.id, dossier_id: dossier.id, procedure_label_id: [ProcedureLabel.first.id] }, format: :turbo_stream } + it 'works' do + subject + dossier.reload + + expect(dossier.dossier_labels.count).to eq(1) + end + + it { expect(subject.body).to include('header-top') } + end + + context 'it remove dossier labels' do + before do + DossierLabel.create(dossier_id: dossier.id, procedure_label_id: dossier.procedure.procedure_labels.first.id) + end + + subject { post :dossier_labels, params: { procedure_id: procedure.id, dossier_id: dossier.id, procedure_label_id: [] }, format: :turbo_stream } + + it 'works' do + expect(dossier.dossier_labels.count).to eq(1) + + subject + dossier.reload + + expect(dossier.dossier_labels.count).to eq(0) + end + + it { expect(subject.body).to include('header-top') } + end + end end diff --git a/spec/system/instructeurs/instruction_spec.rb b/spec/system/instructeurs/instruction_spec.rb index 62390e084..7f667c9da 100644 --- a/spec/system/instructeurs/instruction_spec.rb +++ b/spec/system/instructeurs/instruction_spec.rb @@ -272,6 +272,35 @@ describe 'Instructing a dossier:', js: true do after { DownloadHelpers.clear_downloads } end + scenario 'An instructeur can add labels to a dossier' do + log_in(instructeur.email, password) + + visit instructeur_dossier_path(procedure, dossier) + click_on 'Ajouter un label' + + check 'à relancer', allow_label_click: true + expect(page).to have_css('.fr-badge', text: "à relancer", count: 2) + expect(dossier.dossier_labels.count).to eq(1) + + expect(page).not_to have_text('Ajouter un label') + find('span.dropdown button.dropdown-button').click + + expect(page).to have_checked_field('à relancer') + check 'complet', allow_label_click: true + + expect(page).to have_css('.fr-badge', text: "complet", count: 2) + expect(dossier.dossier_labels.count).to eq(2) + + find('span.dropdown button.dropdown-button').click + uncheck 'à relancer', allow_label_click: true + + expect(page).to have_unchecked_field('à relancer') + expect(page).to have_checked_field('complet') + expect(page).to have_css('.fr-badge', text: "à relancer", count: 1) + expect(page).to have_css('.fr-badge', text: "complet", count: 2) + expect(dossier.dossier_labels.count).to eq(1) + end + def log_in(email, password, check_email: true) visit new_user_session_path expect(page).to have_current_path(new_user_session_path) diff --git a/spec/views/instructeur/dossiers/show.html.haml_spec.rb b/spec/views/instructeur/dossiers/show.html.haml_spec.rb index bb7163332..372f2a5d1 100644 --- a/spec/views/instructeur/dossiers/show.html.haml_spec.rb +++ b/spec/views/instructeur/dossiers/show.html.haml_spec.rb @@ -217,4 +217,32 @@ describe 'instructeurs/dossiers/show', type: :view do expect(subject).to have_selector('a.fr-sidemenu__link', text: 'l1') end end + + describe "Dossier labels" do + context "Dossier without labels" do + it 'displays button with text to add label' do + expect(subject).to have_text("Ajouter un label") + expect(subject).to have_selector("button.dropdown-button") + expect(subject).to have_text("à relancer", count: 1) + within('.dropdown') do + expect(subject).to have_text("à relancer", count: 1) + end + end + end + + context "Dossier with labels" do + before do + DossierLabel.create(dossier_id: dossier.id, procedure_label_id: dossier.procedure.procedure_labels.first.id) + end + + it 'displays labels and button without text to add label' do + expect(subject).not_to have_text("Ajouter un label") + expect(subject).to have_selector("button.dropdown-button") + expect(subject).to have_text("à relancer", count: 2) + within('.dropdown') do + expect(subject).to have_text("à relancer", count: 1) + end + end + end + end end From a5b8b936d5b6d813593c731d121c1c9a65099940 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 31 Oct 2024 18:28:47 +0100 Subject: [PATCH 1378/1532] a column can override its column_id --- app/models/column.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/models/column.rb b/app/models/column.rb index b9c223fe8..c4d54b6c5 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -27,7 +27,7 @@ class Column # the h_id is a Hash and hold enough information to find the column # in the ColumnType class, aka be able to do the h_id -> column conversion - def h_id = { procedure_id: @procedure_id, column_id: "#{table}/#{column}" } + def h_id = { procedure_id: @procedure_id, column_id: } def ==(other) = h_id == other.h_id # using h_id instead of id to avoid inversion of keys @@ -65,6 +65,8 @@ class Column private + def column_id = "#{table}/#{column}" + def typed_value(champ) value = string_value(champ) parse_value(value, type: champ.last_write_column_type) From 3a45524d39b2a4781b6140707a3975caf2b5ab4c Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Mon, 7 Oct 2024 16:52:27 +0200 Subject: [PATCH 1379/1532] add column label to procedure table --- .../instructeurs/column_filter_value_component.rb | 4 ++++ .../instructeurs/filter_buttons_component.rb | 2 ++ app/helpers/dossier_helper.rb | 8 ++++++++ app/models/column.rb | 1 + app/models/concerns/columns_concern.rb | 5 ++++- app/services/dossier_filter_service.rb | 10 ++++++++++ app/services/dossier_projection_service.rb | 12 ++++++++++++ app/views/instructeurs/procedures/show.html.haml | 4 ++-- config/locales/models/procedure_presentation/en.yml | 2 ++ config/locales/models/procedure_presentation/fr.yml | 2 ++ spec/models/concerns/columns_concern_spec.rb | 1 + 11 files changed, 48 insertions(+), 3 deletions(-) diff --git a/app/components/instructeurs/column_filter_value_component.rb b/app/components/instructeurs/column_filter_value_component.rb index d83a239ea..848c89b76 100644 --- a/app/components/instructeurs/column_filter_value_component.rb +++ b/app/components/instructeurs/column_filter_value_component.rb @@ -53,6 +53,10 @@ class Instructeurs::ColumnFilterValueComponent < ApplicationComponent [_1.label, _1.id] end end + elsif column.table == 'dossier_labels' + Procedure.find(procedure_id).labels.filter_map do + [_1.name, _1.id] + end else find_type_de_champ(column.column).options_for_select(column) end diff --git a/app/components/instructeurs/filter_buttons_component.rb b/app/components/instructeurs/filter_buttons_component.rb index 950adee9b..3a78bf9c5 100644 --- a/app/components/instructeurs/filter_buttons_component.rb +++ b/app/components/instructeurs/filter_buttons_component.rb @@ -59,6 +59,8 @@ class Instructeurs::FilterButtonsComponent < ApplicationComponent elsif column.groupe_instructeur? current_instructeur.groupe_instructeurs .find { _1.id == filter.to_i }&.label || filter + elsif column.dossier_labels? + Label.find(filter)&.name || filter elsif column.type == :date helpers.try_parse_format_date(filter) else diff --git a/app/helpers/dossier_helper.rb b/app/helpers/dossier_helper.rb index bc9e86127..8a425de90 100644 --- a/app/helpers/dossier_helper.rb +++ b/app/helpers/dossier_helper.rb @@ -118,6 +118,14 @@ module DossierHelper tag.span(Dossier.human_attribute_name("pending_correction.resolved"), class: ['fr-badge fr-badge--sm fr-badge--success super', html_class], role: 'status') end + def label_badges(badges) + badges.map { label_badge(_1[1], _1[2]) }.join('
    ').html_safe + end + + def label_badge(name, color) + tag.span(name, class: ["fr-badge fr-badge--sm fr fr-badge--#{color}"]) + end + def demandeur_dossier(dossier) if dossier.procedure.for_individual? && dossier.for_tiers? return t('shared.dossiers.beneficiaire', mandataire: dossier.mandataire_full_name, beneficiaire: "#{dossier&.individual&.prenom} #{dossier&.individual&.nom}") diff --git a/app/models/column.rb b/app/models/column.rb index b9c223fe8..c9968b826 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -40,6 +40,7 @@ class Column def notifications? = [table, column] == ['notifications', 'notifications'] def dossier_state? = [table, column] == ['self', 'state'] def groupe_instructeur? = [table, column] == ['groupe_instructeur', 'id'] + def dossier_labels? = [table, column] == ['dossier_labels', 'label_id'] def type_de_champ? = table == TYPE_DE_CHAMP_TABLE def self.find(h_id) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 1a9ecab1a..c30074c68 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -91,6 +91,8 @@ module ColumnsConcern def user_france_connected_column = Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false) + def dossier_labels_column = Columns::DossierColumn.new(procedure_id: id, table: 'dossier_labels', column: 'label_id', type: :enum) + def procedure_chorus_columns ['domaine_fonctionnel', 'referentiel_prog', 'centre_de_cout'] .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'procedure', column:, displayable: false, filterable: false) } @@ -127,7 +129,8 @@ module ColumnsConcern followers_instructeurs_email_column, groupe_instructeurs_id_column, Columns::DossierColumn.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false), - user_france_connected_column + user_france_connected_column, + dossier_labels_column ] end diff --git a/app/services/dossier_filter_service.rb b/app/services/dossier_filter_service.rb index d43dae082..ac6148a99 100644 --- a/app/services/dossier_filter_service.rb +++ b/app/services/dossier_filter_service.rb @@ -56,6 +56,11 @@ class DossierFilterService .order("#{sanitized_column(table, column)} #{order}") .pluck(:id) .uniq + when 'dossier_labels' + dossiers.includes(table) + .order("#{self.class.sanitized_column(table, column)} #{order}") + .pluck(:id) + .uniq when 'self', 'user', 'individual', 'etablissement', 'groupe_instructeur' (table == 'self' ? dossiers : dossiers.includes(table)) .order("#{sanitized_column(table, column)} #{order}") @@ -122,6 +127,11 @@ class DossierFilterService dossiers .includes(table) .filter_ilike(table, column, values) # ilike or where column == 'value' are both valid, we opted for ilike + when 'dossier_labels' + assert_supported_column(table, column) + dossiers + .joins(:dossier_labels) + .where(dossier_labels: { procedure_label_id: values }) when 'groupe_instructeur' assert_supported_column(table, column) diff --git a/app/services/dossier_projection_service.rb b/app/services/dossier_projection_service.rb index dfe9039b7..abe79ae32 100644 --- a/app/services/dossier_projection_service.rb +++ b/app/services/dossier_projection_service.rb @@ -123,6 +123,18 @@ class DossierProjectionService fields[0][:id_value_h] = id_value_h + when 'dossier_labels' + columns = fields.map { _1[COLUMN].to_sym } + + id_value_h = + DossierLabel + .includes(:procedure_label) + .where(dossier_id: dossiers_ids) + .pluck('dossier_id, procedure_labels.name, procedure_labels.color') + .group_by { |dossier_id, _| dossier_id } + + fields[0][:id_value_h] = id_value_h.transform_values { |v| { value: v, type: :label } } + when 'procedure' Dossier .joins(:procedure) diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index e36560e88..4724cc033 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -132,12 +132,12 @@ %td - if p.hidden_by_administration_at.present? %span.cell-link - = column + = column.is_a?(Hash) ? label_badges(column[:value]) : column - if p.hidden_by_user_at.present? = "- #{t("views.instructeurs.dossiers.deleted_reason.#{p.hidden_by_reason}")}" - else %a.cell-link{ href: path } - = column + = column.is_a?(Hash) ? label_badges(column[:value]) : column = "- #{t("views.instructeurs.dossiers.deleted_reason.#{p.hidden_by_reason}")}" if p.hidden_by_user_at.present? %td.status-col diff --git a/config/locales/models/procedure_presentation/en.yml b/config/locales/models/procedure_presentation/en.yml index 78957508d..95ff9d0ff 100644 --- a/config/locales/models/procedure_presentation/en.yml +++ b/config/locales/models/procedure_presentation/en.yml @@ -81,3 +81,5 @@ en: association_date_creation: 'Association date de création' association_date_declaration: 'Association date de déclaration' association_date_publication: 'Association date de publication' + dossier_labels: + label_id: Labels diff --git a/config/locales/models/procedure_presentation/fr.yml b/config/locales/models/procedure_presentation/fr.yml index b46c82a37..f02ba070f 100644 --- a/config/locales/models/procedure_presentation/fr.yml +++ b/config/locales/models/procedure_presentation/fr.yml @@ -85,3 +85,5 @@ fr: association_date_creation: 'Association date de création' association_date_declaration: 'Association date de déclaration' association_date_publication: 'Association date de publication' + dossier_labels: + label_id: Labels diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index 44ca9f1d3..e11514646 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -51,6 +51,7 @@ describe ColumnsConcern do { label: 'Groupe instructeur', table: 'groupe_instructeur', column: 'id', displayable: true, type: :enum, scope: '', value_column: :value, filterable: true }, { label: 'Avis oui/non', table: 'avis', column: 'question_answer', displayable: true, type: :text, scope: '', value_column: :value, filterable: false }, { label: 'France connecté ?', table: 'self', column: 'user_from_france_connect?', displayable: false, type: :text, scope: '', value_column: :value, filterable: false }, + { label: "Labels", table: "dossier_labels", column: "procedure_label_id", displayable: true, scope: '', value_column: :value, filterable: true }, { label: 'SIREN', table: 'etablissement', column: 'entreprise_siren', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, { label: 'Forme juridique', table: 'etablissement', column: 'entreprise_forme_juridique', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, { label: 'Nom commercial', table: 'etablissement', column: 'entreprise_nom_commercial', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, From 9ab49a08b17bbfe39c3fbcb83179c6713a6f8c40 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Wed, 9 Oct 2024 15:35:58 +0200 Subject: [PATCH 1380/1532] remove old badge css and only use DSFR --- app/assets/stylesheets/badges.scss | 34 ------------------- ...teur_expert_navigation_component.html.haml | 2 +- .../dossiers/_header_top.html.haml | 4 +-- app/views/invites/_dropdown.html.haml | 2 +- app/views/root/patron.html.haml | 5 --- app/views/shared/_tab_item.html.haml | 2 +- ...ucteur_expert_navigation_component_spec.rb | 4 +-- spec/system/experts/expert_spec.rb | 4 +-- 8 files changed, 9 insertions(+), 48 deletions(-) delete mode 100644 app/assets/stylesheets/badges.scss diff --git a/app/assets/stylesheets/badges.scss b/app/assets/stylesheets/badges.scss deleted file mode 100644 index f1e19da3a..000000000 --- a/app/assets/stylesheets/badges.scss +++ /dev/null @@ -1,34 +0,0 @@ -@import "colors"; -@import "constants"; - -.badge { - padding: 0 5px; - font-size: 14px; - font-weight: bold; - text-align: center; - white-space: nowrap; - border-radius: 100px; - background-color: rgba(0, 0, 0, 0.08); - vertical-align: top; - - &.baseline { - vertical-align: baseline; - } - - &.warning { - background-color: $orange; - color: #FFFFFF; - } -} - -.badge-group { - display: flex; - - .fr-badge { - margin-right: $default-spacer; - } - - .fr-badge:last-child { - margin-right: 0; - } -} diff --git a/app/components/main_navigation/instructeur_expert_navigation_component/instructeur_expert_navigation_component.html.haml b/app/components/main_navigation/instructeur_expert_navigation_component/instructeur_expert_navigation_component.html.haml index ee0d7dbb6..47c160cf1 100644 --- a/app/components/main_navigation/instructeur_expert_navigation_component/instructeur_expert_navigation_component.html.haml +++ b/app/components/main_navigation/instructeur_expert_navigation_component/instructeur_expert_navigation_component.html.haml @@ -9,6 +9,6 @@ = link_to expert_all_avis_path, class: 'fr-nav__link', aria: aria_current_for(:avis) do = Avis.model_name.human(count: 10) - if helpers.current_expert.avis_summary[:unanswered] > 0 - %span.badge.warning= helpers.current_expert.avis_summary[:unanswered] + %span.fr-badge.fr-badge--new.fr-badge--no-icon= helpers.current_expert.avis_summary[:unanswered] = render MainNavigation::AnnouncesLinkComponent.new diff --git a/app/views/instructeurs/dossiers/_header_top.html.haml b/app/views/instructeurs/dossiers/_header_top.html.haml index e752a54bb..c71d2346b 100644 --- a/app/views/instructeurs/dossiers/_header_top.html.haml +++ b/app/views/instructeurs/dossiers/_header_top.html.haml @@ -1,11 +1,11 @@ #header-top.fr-container - .flex.fr-mb-3w + .flex %div %h1.fr-h3.fr-mb-1w = "Dossier nº #{dossier.id}" = link_to dossier.procedure.libelle.truncate_words(10), instructeur_procedure_path(dossier.procedure), title: dossier.procedure.libelle, class: "fr-link" - .fr-mt-2w.badge-group + .fr-mt-2w.fr-badge-group = procedure_badge(dossier.procedure) = status_badge(dossier.state) diff --git a/app/views/invites/_dropdown.html.haml b/app/views/invites/_dropdown.html.haml index 3d7de5a12..e2da2ffab 100644 --- a/app/views/invites/_dropdown.html.haml +++ b/app/views/invites/_dropdown.html.haml @@ -5,7 +5,7 @@ = dsfr_icon('fr-icon-user-add-fill', :sm, :mr) - if invites.present? = t('views.invites.dropdown.view_invited_people') - %span.badge= invites.size + %span.fr-badge.fr-ml-1v= invites.size - else - if dossier.read_only? = t('views.invites.dropdown.invite_to_view') diff --git a/app/views/root/patron.html.haml b/app/views/root/patron.html.haml index 968b94d70..e6b2dd10e 100644 --- a/app/views/root/patron.html.haml +++ b/app/views/root/patron.html.haml @@ -153,11 +153,6 @@ %span.label.refused .label.refused %span.label.without-continuation .label.without-continuation - %h1 Badges - - %span.badge 1 - %span.badge.warning 1 - %h1 Cards .card diff --git a/app/views/shared/_tab_item.html.haml b/app/views/shared/_tab_item.html.haml index cd4103ef8..a9de4a2a7 100644 --- a/app/views/shared/_tab_item.html.haml +++ b/app/views/shared/_tab_item.html.haml @@ -3,5 +3,5 @@ %span.notifications{ 'aria-label': 'notifications' } = link_to(url, 'aria-selected': active ? true : nil, class: 'fr-tabs__tab', role: 'tab' ) do - if badge.present? - %span.badge.fr-mr-1w= badge + %span.fr-badge.fr-badge--blue-ecume.fr-mr-1w= badge = label diff --git a/spec/components/main_navigation/instructeur_expert_navigation_component_spec.rb b/spec/components/main_navigation/instructeur_expert_navigation_component_spec.rb index 486af1473..309430144 100644 --- a/spec/components/main_navigation/instructeur_expert_navigation_component_spec.rb +++ b/spec/components/main_navigation/instructeur_expert_navigation_component_spec.rb @@ -68,7 +68,7 @@ describe MainNavigation::InstructeurExpertNavigationComponent, type: :component it 'renders a link to expert all avis with current page class' do expect(subject).to have_link('Avis', href: component.helpers.expert_all_avis_path) expect(subject).to have_selector('a[aria-current="true"]', text: 'Avis') - expect(subject).not_to have_selector('span.badge') + expect(subject).not_to have_selector('span.fr-badge') end it 'does not have Démarches link' do @@ -79,7 +79,7 @@ describe MainNavigation::InstructeurExpertNavigationComponent, type: :component let(:unanswered) { 2 } it 'renders an unanswered avis badge for the expert' do - expect(subject).to have_selector('span.badge.warning', text: '2') + expect(subject).to have_selector('span.fr-badge', text: '2') end end diff --git a/spec/system/experts/expert_spec.rb b/spec/system/experts/expert_spec.rb index 7ad4e7685..433bead97 100644 --- a/spec/system/experts/expert_spec.rb +++ b/spec/system/experts/expert_spec.rb @@ -72,7 +72,7 @@ describe 'Inviting an expert:', js: true do expect(page).to have_text('1 avis à donner') expect(page).to have_text('0 avis donnés') - expect(page).to have_selector('.badge', text: 1) + expect(page).to have_selector('.fr-badge', text: 1) expect(page).to have_selector('.notifications') click_on '1 avis à donner' @@ -93,7 +93,7 @@ describe 'Inviting an expert:', js: true do expect(page).to have_text('0 avis à donner') expect(page).to have_text('1 avis donné') - expect(page).not_to have_selector('.badge', text: 1) + expect(page).not_to have_selector('.fr-badge', text: 1) expect(page).not_to have_selector('.notifications') end From c0dc4877324d955df9be5456c0f304dccc57ba3c Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Thu, 10 Oct 2024 14:58:38 +0200 Subject: [PATCH 1381/1532] add css to custom tags and display labels with correct UI --- app/assets/stylesheets/02_utils.scss | 2 +- app/assets/stylesheets/dsfr.scss | 11 ++---- app/assets/stylesheets/tags.scss | 38 +++++++++++++++++++ app/helpers/dossier_helper.rb | 13 +++++-- .../dossiers/_header_top.html.haml | 7 ++-- .../instructeurs/procedures/show.html.haml | 4 +- 6 files changed, 56 insertions(+), 19 deletions(-) create mode 100644 app/assets/stylesheets/tags.scss diff --git a/app/assets/stylesheets/02_utils.scss b/app/assets/stylesheets/02_utils.scss index c445acbfa..891a06575 100644 --- a/app/assets/stylesheets/02_utils.scss +++ b/app/assets/stylesheets/02_utils.scss @@ -37,7 +37,7 @@ } .text-right { - text-align: right; + text-align: right !important; } .text-sm { diff --git a/app/assets/stylesheets/dsfr.scss b/app/assets/stylesheets/dsfr.scss index 824ef4263..95e6e0060 100644 --- a/app/assets/stylesheets/dsfr.scss +++ b/app/assets/stylesheets/dsfr.scss @@ -263,12 +263,7 @@ button.fr-tag-bug { text-transform: lowercase; } -// we use badge with small checkbox - to align them we need to reduce the margin -.fr-checkbox-group--sm.fr-checkbox-custom-with-labels input[type="checkbox"] + label::before { - margin-top: 0.15rem; -} - -// we use badge with checkbox so we need to add a background white to the uncheched checkbox -.fr-checkbox-group input[type="checkbox"] + label::before { - background-color: #FFFFFF; +// We don't want badge to split in two lines +.fr-tag { + white-space: nowrap; } diff --git a/app/assets/stylesheets/tags.scss b/app/assets/stylesheets/tags.scss new file mode 100644 index 000000000..992ef9609 --- /dev/null +++ b/app/assets/stylesheets/tags.scss @@ -0,0 +1,38 @@ +@import "colors"; +@import "constants"; + +$colors: +"green-tilleul-verveine", +"green-bourgeon", +"green-emeraude", +"green-menthe", +"green-archipel", +"blue-ecume", +"blue-cumulus", +"purple-glycine", +"pink-macaron", +"pink-tuile", +"yellow-tournesol", +"yellow-moutarde", +"orange-terre-battue", +"brown-cafe-creme", +"brown-caramel", +"brown-opera", +"beige-gris-galet"; + + +@each $color in $colors { + .fr-tag--#{$color}, + a.fr-tag--#{$color}, + button.fr-tag--#{$color}, + input[type=button].fr-tag--#{$color}, + input[type=image].fr-tag--#{$color}, + input[type=reset].fr-tag--#{$color}, + input[type=submit].fr-tag--#{$color} { + --idle:transparent; + --hover:var(--background-action-low-#{$color}-hover); + --active:var(--background-action-low-#{$color}-active); + background-color:var(--background-action-low-#{$color}); + color:var(--text-action-high-#{$color}) + } +} diff --git a/app/helpers/dossier_helper.rb b/app/helpers/dossier_helper.rb index 8a425de90..54364c8ad 100644 --- a/app/helpers/dossier_helper.rb +++ b/app/helpers/dossier_helper.rb @@ -118,12 +118,17 @@ module DossierHelper tag.span(Dossier.human_attribute_name("pending_correction.resolved"), class: ['fr-badge fr-badge--sm fr-badge--success super', html_class], role: 'status') end - def label_badges(badges) - badges.map { label_badge(_1[1], _1[2]) }.join('
    ').html_safe + def tags_label(tags) + if tags.count > 1 + tag.div(tags.map { tag_label(_1[1], _1[2]) }.join('
    ').html_safe, class: 'fr-tags-group') + else + tag = tags.first + tag_label(tag[1], tag[2]) + end end - def label_badge(name, color) - tag.span(name, class: ["fr-badge fr-badge--sm fr fr-badge--#{color}"]) + def tag_label(name, color) + tag.span(name, class: "fr-tag fr-tag--sm fr-tag--#{color}") end def demandeur_dossier(dossier) diff --git a/app/views/instructeurs/dossiers/_header_top.html.haml b/app/views/instructeurs/dossiers/_header_top.html.haml index c71d2346b..035ceb0d6 100644 --- a/app/views/instructeurs/dossiers/_header_top.html.haml +++ b/app/views/instructeurs/dossiers/_header_top.html.haml @@ -29,8 +29,7 @@ .fr-mb-3w - if dossier.procedure_labels.present? - dossier.procedure_labels.each do |label| - .fr-badge.fr-badge--sm{ class: "fr-badge--#{label.color}" } - = label.name + = tag_label(label.name, label.color) = render Dropdown::MenuComponent.new(wrapper: :span, button_options: { class: ['fr-btn--sm fr-btn--tertiary-no-outline fr-pl-1v']}, menu_options: { class: ['dropdown-label left-aligned'] }) do |menu| - if dossier.procedure_labels.empty? @@ -42,6 +41,6 @@ %fieldset.fr-fieldset.fr-mt-2w.fr-mb-0 = f.collection_check_boxes :procedure_label_id, dossier.procedure.procedure_labels, :id, :name, include_hidden: false do |b| .fr-fieldset__element - .fr-checkbox-group.fr-checkbox-group--sm.fr-checkbox-custom-with-labels.fr-mb-1w + .fr-checkbox-group.fr-checkbox-group--sm.fr-mb-1w = b.check_box(checked: DossierLabel.find_by(dossier_id: dossier.id, procedure_label_id: b.value).present? ) - = b.label(class: "fr-label fr-badge fr-badge--sm fr-badge--#{b.object.color}") { b.text } + = b.label(class: "fr-label fr-tag fr-tag--sm fr-tag--#{b.object.color}") { b.text } diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index 4724cc033..8c6063308 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -132,12 +132,12 @@ %td - if p.hidden_by_administration_at.present? %span.cell-link - = column.is_a?(Hash) ? label_badges(column[:value]) : column + = column.is_a?(Hash) ? tags_label(column[:value]) : column - if p.hidden_by_user_at.present? = "- #{t("views.instructeurs.dossiers.deleted_reason.#{p.hidden_by_reason}")}" - else %a.cell-link{ href: path } - = column.is_a?(Hash) ? label_badges(column[:value]) : column + = column.is_a?(Hash) ? tags_label(column[:value]) : column = "- #{t("views.instructeurs.dossiers.deleted_reason.#{p.hidden_by_reason}")}" if p.hidden_by_user_at.present? %td.status-col From f68f4c88eb3aabae7eb7245c79d119454f3e1eb0 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Mon, 14 Oct 2024 18:07:01 +0200 Subject: [PATCH 1382/1532] correction after first review --- app/assets/stylesheets/dsfr.scss | 5 +++ app/helpers/dossier_helper.rb | 2 +- .../instructeurs/dossiers_controller_spec.rb | 7 ++--- .../procedures_controller_spec.rb | 31 +++++++++++++++++++ .../instructeurs/procedure_filters_spec.rb | 10 ++++++ 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/app/assets/stylesheets/dsfr.scss b/app/assets/stylesheets/dsfr.scss index 95e6e0060..fb1ecc189 100644 --- a/app/assets/stylesheets/dsfr.scss +++ b/app/assets/stylesheets/dsfr.scss @@ -267,3 +267,8 @@ button.fr-tag-bug { .fr-tag { white-space: nowrap; } + +// We remove the line height because it creates unharmonized spaces - most of all in table +.fr-tags-group > li { + line-height: inherit; +} diff --git a/app/helpers/dossier_helper.rb b/app/helpers/dossier_helper.rb index 54364c8ad..e2047738a 100644 --- a/app/helpers/dossier_helper.rb +++ b/app/helpers/dossier_helper.rb @@ -120,7 +120,7 @@ module DossierHelper def tags_label(tags) if tags.count > 1 - tag.div(tags.map { tag_label(_1[1], _1[2]) }.join('
    ').html_safe, class: 'fr-tags-group') + tag.ul(tags.map { tag.li(tag_label(_1[1], _1[2])) }.join.html_safe, class: 'fr-tags-group') else tag = tags.first tag_label(tag[1], tag[2]) diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index a035aa8c2..45ffa7b32 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -1527,9 +1527,9 @@ describe Instructeurs::DossiersController, type: :controller do dossier.reload expect(dossier.dossier_labels.count).to eq(1) + expect(subject.body).to include('fr-tag--brown-caramel') + expect(subject.body).not_to include('Ajouter un label') end - - it { expect(subject.body).to include('header-top') } end context 'it remove dossier labels' do @@ -1546,9 +1546,8 @@ describe Instructeurs::DossiersController, type: :controller do dossier.reload expect(dossier.dossier_labels.count).to eq(0) + expect(subject.body).to include('Ajouter un label') end - - it { expect(subject.body).to include('header-top') } end end end diff --git a/spec/controllers/instructeurs/procedures_controller_spec.rb b/spec/controllers/instructeurs/procedures_controller_spec.rb index 1f3ea9acb..20c14a6a0 100644 --- a/spec/controllers/instructeurs/procedures_controller_spec.rb +++ b/spec/controllers/instructeurs/procedures_controller_spec.rb @@ -637,6 +637,37 @@ describe Instructeurs::ProceduresController, type: :controller do it { expect(assigns(:last_export)).to eq(nil) } end end + + context 'dossier labels' do + let!(:dossier) { create(:dossier, :en_construction, groupe_instructeur: gi_2) } + let!(:dossier_2) { create(:dossier, :en_construction, groupe_instructeur: gi_2) } + let(:statut) { 'tous' } + let(:procedure_label_id) { procedure.find_column(label: 'Labels') } + let!(:procedure_presentation) do + ProcedurePresentation.create!(assign_to: AssignTo.first) + end + render_views + + before do + DossierLabel.create(dossier_id: dossier.id, procedure_label_id: dossier.procedure.procedure_labels.first.id) + DossierLabel.create(dossier_id: dossier.id, procedure_label_id: dossier.procedure.procedure_labels.second.id) + DossierLabel.create(dossier_id: dossier_2.id, procedure_label_id: dossier.procedure.procedure_labels.last.id) + + procedure_presentation.update(displayed_columns: [ + procedure_label_id.id + ]) + + subject + end + + it 'displays correctly labels in instructeur table' do + expect(response.body).to include("Labels") + expect(response.body).to have_selector('ul.fr-tags-group li span.fr-tag', text: 'à relancer') + expect(response.body).to have_selector('ul.fr-tags-group li span.fr-tag', text: 'complet') + expect(response.body).not_to have_selector('ul li span.fr-tag', text: 'prêt pour validation') + expect(response.body).to have_selector('span.fr-tag', text: 'prêt pour validation') + end + end end end diff --git a/spec/system/instructeurs/procedure_filters_spec.rb b/spec/system/instructeurs/procedure_filters_spec.rb index c105775f4..9dc278194 100644 --- a/spec/system/instructeurs/procedure_filters_spec.rb +++ b/spec/system/instructeurs/procedure_filters_spec.rb @@ -94,6 +94,7 @@ describe "procedure filters" do expect(page).to have_link(new_unfollow_dossier_2.user.email) end end + describe 'with dropdown' do let(:types_de_champ_public) { [{ type: :drop_down_list }] } @@ -171,6 +172,15 @@ describe "procedure filters" do end end + describe 'dossier labels' do + scenario "should be able to filter by dossier labels", js: true do + DossierLabel.create!(dossier_id: new_unfollow_dossier.id, procedure_label_id: procedure.procedure_labels.first.id) + add_filter('Labels', procedure.procedure_labels.first.name, type: :enum) + expect(page).to have_link(new_unfollow_dossier.id.to_s) + expect(page).not_to have_link(new_unfollow_dossier_2.id.to_s) + end + end + scenario "should be able to add and remove two filters for the same field", js: true do add_filter(type_de_champ.libelle, champ.value) add_filter(type_de_champ.libelle, champ_2.value) From e723c9e365f6ac94f6f71efcdd04b2f463136224 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Tue, 15 Oct 2024 11:12:21 +0200 Subject: [PATCH 1383/1532] create generic label for procedure in controller instead of using a hook --- .../administrateurs/procedures_controller.rb | 1 + app/models/procedure.rb | 2 +- .../procedures_controller_spec.rb | 7 ++- .../instructeurs/dossiers_controller_spec.rb | 2 + .../procedures_controller_spec.rb | 5 ++- spec/factories/procedure.rb | 6 +++ spec/system/instructeurs/instruction_spec.rb | 44 ++++++++++--------- .../instructeurs/procedure_filters_spec.rb | 2 +- .../dossiers/show.html.haml_spec.rb | 2 + 9 files changed, 46 insertions(+), 25 deletions(-) diff --git a/app/controllers/administrateurs/procedures_controller.rb b/app/controllers/administrateurs/procedures_controller.rb index 371ee4d3b..99b0152db 100644 --- a/app/controllers/administrateurs/procedures_controller.rb +++ b/app/controllers/administrateurs/procedures_controller.rb @@ -108,6 +108,7 @@ module Administrateurs flash.now.alert = @procedure.errors.full_messages render 'new' else + @procedure.create_generic_procedure_labels flash.notice = 'Démarche enregistrée.' current_administrateur.instructeur.assign_to_procedure(@procedure) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 51647b10d..32d246bb5 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -295,7 +295,6 @@ class Procedure < ApplicationRecord after_initialize :ensure_path_exists before_save :ensure_path_exists after_create :ensure_defaut_groupe_instructeur - after_create :create_generic_procedure_labels include AASM @@ -530,6 +529,7 @@ class Procedure < ApplicationRecord procedure.closing_notification_en_cours = false procedure.template = false procedure.monavis_embed = nil + procedure.procedure_labels = procedure_labels.map(&:dup) if !procedure.valid? procedure.errors.attribute_names.each do |attribute| diff --git a/spec/controllers/administrateurs/procedures_controller_spec.rb b/spec/controllers/administrateurs/procedures_controller_spec.rb index 21e7139fb..89184cf56 100644 --- a/spec/controllers/administrateurs/procedures_controller_spec.rb +++ b/spec/controllers/administrateurs/procedures_controller_spec.rb @@ -662,7 +662,7 @@ describe Administrateurs::ProceduresController, type: :controller do end describe 'PUT #clone' do - let(:procedure) { create(:procedure, :with_notice, :with_deliberation, administrateur: admin) } + let(:procedure) { create(:procedure, :with_notice, :with_deliberation, :with_labels, administrateur: admin) } let(:params) { { procedure_id: procedure.id } } subject { put :clone, params: params } @@ -684,6 +684,10 @@ describe Administrateurs::ProceduresController, type: :controller do expect(Procedure.last.cloned_from_library).to be_falsey expect(Procedure.last.notice.attached?).to be_truthy expect(Procedure.last.deliberation.attached?).to be_truthy + expect(Procedure.last.procedure_labels.present?).to be_truthy + expect(Procedure.last.procedure_labels.first.procedure_id).to eq(Procedure.last.id) + expect(procedure.procedure_labels.first.procedure_id).to eq(procedure.id) + expect(flash[:notice]).to have_content 'Démarche clonée. Pensez à vérifier la présentation et choisir le service à laquelle cette démarche est associée.' end @@ -705,6 +709,7 @@ describe Administrateurs::ProceduresController, type: :controller do it 'creates a new procedure and redirect to it' do expect(response).to redirect_to admin_procedure_path(id: Procedure.last.id) + expect(Procedure.last.procedure_labels.present?).to be_truthy expect(flash[:notice]).to have_content 'Démarche clonée. Pensez à vérifier la présentation et choisir le service à laquelle cette démarche est associée.' end end diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index 45ffa7b32..7ecddb561 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -1520,6 +1520,8 @@ describe Instructeurs::DossiersController, type: :controller do end describe 'dossier_labels' do + let(:procedure) { create(:procedure, :with_labels, instructeurs: [instructeur]) } + let!(:dossier) { create(:dossier, :en_construction, procedure:) } context 'it create dossier labels' do subject { post :dossier_labels, params: { procedure_id: procedure.id, dossier_id: dossier.id, procedure_label_id: [ProcedureLabel.first.id] }, format: :turbo_stream } it 'works' do diff --git a/spec/controllers/instructeurs/procedures_controller_spec.rb b/spec/controllers/instructeurs/procedures_controller_spec.rb index 20c14a6a0..c7e4f932f 100644 --- a/spec/controllers/instructeurs/procedures_controller_spec.rb +++ b/spec/controllers/instructeurs/procedures_controller_spec.rb @@ -639,8 +639,9 @@ describe Instructeurs::ProceduresController, type: :controller do end context 'dossier labels' do - let!(:dossier) { create(:dossier, :en_construction, groupe_instructeur: gi_2) } - let!(:dossier_2) { create(:dossier, :en_construction, groupe_instructeur: gi_2) } + let(:procedure) { create(:procedure, :with_labels, instructeurs: [instructeur]) } + let!(:dossier) { create(:dossier, :en_construction, procedure:, groupe_instructeur: gi_2) } + let!(:dossier_2) { create(:dossier, :en_construction, procedure:, groupe_instructeur: gi_2) } let(:statut) { 'tous' } let(:procedure_label_id) { procedure.find_column(label: 'Labels') } let!(:procedure_presentation) do diff --git a/spec/factories/procedure.rb b/spec/factories/procedure.rb index 13db75fb4..9e0e4cfaa 100644 --- a/spec/factories/procedure.rb +++ b/spec/factories/procedure.rb @@ -291,6 +291,12 @@ FactoryBot.define do trait :accuse_lecture do accuse_lecture { true } end + + trait :with_labels do + after(:create) do |procedure, _evaluator| + procedure.create_generic_procedure_labels + end + end end end diff --git a/spec/system/instructeurs/instruction_spec.rb b/spec/system/instructeurs/instruction_spec.rb index 7f667c9da..9517afd80 100644 --- a/spec/system/instructeurs/instruction_spec.rb +++ b/spec/system/instructeurs/instruction_spec.rb @@ -272,33 +272,37 @@ describe 'Instructing a dossier:', js: true do after { DownloadHelpers.clear_downloads } end - scenario 'An instructeur can add labels to a dossier' do - log_in(instructeur.email, password) + context 'An instructeur can add labels' do + let(:procedure) { create(:procedure, :with_labels, :published, instructeurs: [instructeur]) } - visit instructeur_dossier_path(procedure, dossier) - click_on 'Ajouter un label' + scenario 'An instructeur can add and remove labels to a dossier' do + log_in(instructeur.email, password) - check 'à relancer', allow_label_click: true - expect(page).to have_css('.fr-badge', text: "à relancer", count: 2) - expect(dossier.dossier_labels.count).to eq(1) + visit instructeur_dossier_path(procedure, dossier) + click_on 'Ajouter un label' - expect(page).not_to have_text('Ajouter un label') - find('span.dropdown button.dropdown-button').click + check 'à relancer', allow_label_click: true + expect(page).to have_css('.fr-tag', text: "à relancer", count: 2) + expect(dossier.dossier_labels.count).to eq(1) - expect(page).to have_checked_field('à relancer') - check 'complet', allow_label_click: true + expect(page).not_to have_text('Ajouter un label') + find('span.dropdown button.dropdown-button').click - expect(page).to have_css('.fr-badge', text: "complet", count: 2) - expect(dossier.dossier_labels.count).to eq(2) + expect(page).to have_checked_field('à relancer') + check 'complet', allow_label_click: true - find('span.dropdown button.dropdown-button').click - uncheck 'à relancer', allow_label_click: true + expect(page).to have_css('.fr-tag', text: "complet", count: 2) + expect(dossier.dossier_labels.count).to eq(2) - expect(page).to have_unchecked_field('à relancer') - expect(page).to have_checked_field('complet') - expect(page).to have_css('.fr-badge', text: "à relancer", count: 1) - expect(page).to have_css('.fr-badge', text: "complet", count: 2) - expect(dossier.dossier_labels.count).to eq(1) + find('span.dropdown button.dropdown-button').click + uncheck 'à relancer', allow_label_click: true + + expect(page).to have_unchecked_field('à relancer') + expect(page).to have_checked_field('complet') + expect(page).to have_css('.fr-tag', text: "à relancer", count: 1) + expect(page).to have_css('.fr-tag', text: "complet", count: 2) + expect(dossier.dossier_labels.count).to eq(1) + end end def log_in(email, password, check_email: true) diff --git a/spec/system/instructeurs/procedure_filters_spec.rb b/spec/system/instructeurs/procedure_filters_spec.rb index 9dc278194..fb8fec122 100644 --- a/spec/system/instructeurs/procedure_filters_spec.rb +++ b/spec/system/instructeurs/procedure_filters_spec.rb @@ -2,7 +2,7 @@ describe "procedure filters" do let(:instructeur) { create(:instructeur) } - let(:procedure) { create(:procedure, :published, types_de_champ_public:, instructeurs: [instructeur]) } + let(:procedure) { create(:procedure, :published, :with_labels, types_de_champ_public:, instructeurs: [instructeur]) } let(:types_de_champ_public) { [{ type: :text }] } let!(:type_de_champ) { procedure.active_revision.types_de_champ_public.first } let!(:new_unfollow_dossier) { create(:dossier, procedure: procedure, state: Dossier.states.fetch(:en_instruction)) } diff --git a/spec/views/instructeur/dossiers/show.html.haml_spec.rb b/spec/views/instructeur/dossiers/show.html.haml_spec.rb index 372f2a5d1..3b6ca0045 100644 --- a/spec/views/instructeur/dossiers/show.html.haml_spec.rb +++ b/spec/views/instructeur/dossiers/show.html.haml_spec.rb @@ -219,6 +219,8 @@ describe 'instructeurs/dossiers/show', type: :view do end describe "Dossier labels" do + let(:procedure) { create(:procedure, :with_labels) } + let(:dossier) { create(:dossier, :en_construction, procedure:) } context "Dossier without labels" do it 'displays button with text to add label' do expect(subject).to have_text("Ajouter un label") From fd38288cf8f57fa0d0f8ec5f99842e4eb087be57 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 16 Oct 2024 11:39:19 +0200 Subject: [PATCH 1384/1532] fix(label): don't html_safe user label from bdd --- app/helpers/dossier_helper.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/helpers/dossier_helper.rb b/app/helpers/dossier_helper.rb index e2047738a..1ef31e14d 100644 --- a/app/helpers/dossier_helper.rb +++ b/app/helpers/dossier_helper.rb @@ -120,7 +120,9 @@ module DossierHelper def tags_label(tags) if tags.count > 1 - tag.ul(tags.map { tag.li(tag_label(_1[1], _1[2])) }.join.html_safe, class: 'fr-tags-group') + tag.ul(class: 'fr-tags-group') do + safe_join(tags.map { |t| tag.li(tag_label(t[1], t[2])) }) + end else tag = tags.first tag_label(tag[1], tag[2]) @@ -128,7 +130,7 @@ module DossierHelper end def tag_label(name, color) - tag.span(name, class: "fr-tag fr-tag--sm fr-tag--#{color}") + tag.span(name, class: "fr-tag fr-tag--sm fr-tag--#{color}") end def demandeur_dossier(dossier) From 84c2965edf2de5424c35ad67ac5fa5ad7c94f982 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Wed, 16 Oct 2024 14:41:09 +0200 Subject: [PATCH 1385/1532] fix scss linter --- app/assets/stylesheets/tags.scss | 45 ++++++++++++++++---------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/app/assets/stylesheets/tags.scss b/app/assets/stylesheets/tags.scss index 992ef9609..577b375d7 100644 --- a/app/assets/stylesheets/tags.scss +++ b/app/assets/stylesheets/tags.scss @@ -1,24 +1,23 @@ @import "colors"; @import "constants"; -$colors: -"green-tilleul-verveine", -"green-bourgeon", -"green-emeraude", -"green-menthe", -"green-archipel", -"blue-ecume", -"blue-cumulus", -"purple-glycine", -"pink-macaron", -"pink-tuile", -"yellow-tournesol", -"yellow-moutarde", -"orange-terre-battue", -"brown-cafe-creme", -"brown-caramel", -"brown-opera", -"beige-gris-galet"; +$colors: "green-tilleul-verveine", + "green-bourgeon", + "green-emeraude", + "green-menthe", + "green-archipel", + "blue-ecume", + "blue-cumulus", + "purple-glycine", + "pink-macaron", + "pink-tuile", + "yellow-tournesol", + "yellow-moutarde", + "orange-terre-battue", + "brown-cafe-creme", + "brown-caramel", + "brown-opera", + "beige-gris-galet"; @each $color in $colors { @@ -29,10 +28,10 @@ $colors: input[type=image].fr-tag--#{$color}, input[type=reset].fr-tag--#{$color}, input[type=submit].fr-tag--#{$color} { - --idle:transparent; - --hover:var(--background-action-low-#{$color}-hover); - --active:var(--background-action-low-#{$color}-active); - background-color:var(--background-action-low-#{$color}); - color:var(--text-action-high-#{$color}) + --idle: transparent; + --hover: var(--background-action-low-#{$color}-hover); + --active: var(--background-action-low-#{$color}-active); + background-color: var(--background-action-low-#{$color}); + color: var(--text-action-high-#{$color}); } } From 1a3f73eb01b37cea8adacbb72cbb36d22cae302e Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Wed, 16 Oct 2024 16:38:16 +0200 Subject: [PATCH 1386/1532] dont display tags on show if no label in procedure --- .../dossiers/_header_top.html.haml | 33 ++++++++++--------- .../dossiers/show.html.haml_spec.rb | 10 ++++++ 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/app/views/instructeurs/dossiers/_header_top.html.haml b/app/views/instructeurs/dossiers/_header_top.html.haml index 035ceb0d6..44a117c15 100644 --- a/app/views/instructeurs/dossiers/_header_top.html.haml +++ b/app/views/instructeurs/dossiers/_header_top.html.haml @@ -26,21 +26,22 @@ %p.fr-mb-1w %small L’usager a supprimé son compte. Vous pouvez archiver puis supprimer le dossier. - .fr-mb-3w - - if dossier.procedure_labels.present? - - dossier.procedure_labels.each do |label| - = tag_label(label.name, label.color) + - if dossier.procedure.procedure_labels.present? + .fr-mb-3w + - if dossier.procedure_labels.present? + - dossier.procedure_labels.each do |label| + = tag_label(label.name, label.color) - = render Dropdown::MenuComponent.new(wrapper: :span, button_options: { class: ['fr-btn--sm fr-btn--tertiary-no-outline fr-pl-1v']}, menu_options: { class: ['dropdown-label left-aligned'] }) do |menu| - - if dossier.procedure_labels.empty? - - menu.with_button_inner_html do - Ajouter un label + = render Dropdown::MenuComponent.new(wrapper: :span, button_options: { class: ['fr-btn--sm fr-btn--tertiary-no-outline fr-pl-1v']}, menu_options: { class: ['dropdown-label left-aligned'] }) do |menu| + - if dossier.procedure_labels.empty? + - menu.with_button_inner_html do + Ajouter un label - - menu.with_form do - = form_with(url: dossier_labels_instructeur_dossier_path(dossier_id: dossier.id, procedure_id: dossier.procedure.id), method: :post, class: 'fr-p-3w', data: { controller: 'autosubmit', turbo: 'true' }) do |f| - %fieldset.fr-fieldset.fr-mt-2w.fr-mb-0 - = f.collection_check_boxes :procedure_label_id, dossier.procedure.procedure_labels, :id, :name, include_hidden: false do |b| - .fr-fieldset__element - .fr-checkbox-group.fr-checkbox-group--sm.fr-mb-1w - = b.check_box(checked: DossierLabel.find_by(dossier_id: dossier.id, procedure_label_id: b.value).present? ) - = b.label(class: "fr-label fr-tag fr-tag--sm fr-tag--#{b.object.color}") { b.text } + - menu.with_form do + = form_with(url: dossier_labels_instructeur_dossier_path(dossier_id: dossier.id, procedure_id: dossier.procedure.id), method: :post, class: 'fr-p-3w', data: { controller: 'autosubmit', turbo: 'true' }) do |f| + %fieldset.fr-fieldset.fr-mt-2w.fr-mb-0 + = f.collection_check_boxes :procedure_label_id, dossier.procedure.procedure_labels, :id, :name, include_hidden: false do |b| + .fr-fieldset__element + .fr-checkbox-group.fr-checkbox-group--sm.fr-mb-1w + = b.check_box(checked: DossierLabel.find_by(dossier_id: dossier.id, procedure_label_id: b.value).present? ) + = b.label(class: "fr-label fr-tag fr-tag--sm fr-tag--#{b.object.color}") { b.text } diff --git a/spec/views/instructeur/dossiers/show.html.haml_spec.rb b/spec/views/instructeur/dossiers/show.html.haml_spec.rb index 3b6ca0045..c277e28e9 100644 --- a/spec/views/instructeur/dossiers/show.html.haml_spec.rb +++ b/spec/views/instructeur/dossiers/show.html.haml_spec.rb @@ -221,6 +221,16 @@ describe 'instructeurs/dossiers/show', type: :view do describe "Dossier labels" do let(:procedure) { create(:procedure, :with_labels) } let(:dossier) { create(:dossier, :en_construction, procedure:) } + + context "Procedure without labels" do + let(:procedure_without_labels) { create(:procedure) } + let(:dossier) { create(:dossier, :en_construction, procedure: procedure_without_labels) } + it 'does not display button to add label or dropdown' do + expect(subject).not_to have_text("Ajouter un label") + expect(subject).not_to have_text("à relancer") + end + end + context "Dossier without labels" do it 'displays button with text to add label' do expect(subject).to have_text("Ajouter un label") From 35dee477eaded2bb9ac517ec7a65e27a7aee2e01 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Tue, 29 Oct 2024 14:52:14 +0100 Subject: [PATCH 1387/1532] rename ProcedureLabel by Label --- .../administrateurs/procedures_controller.rb | 2 +- .../instructeurs/dossiers_controller.rb | 8 +++--- app/models/dossier.rb | 2 +- app/models/dossier_label.rb | 2 +- app/models/{procedure_label.rb => label.rb} | 2 +- app/models/procedure.rb | 10 +++---- app/services/dossier_filter_service.rb | 2 +- app/services/dossier_projection_service.rb | 4 +-- ...=> backfill_labels_for_procedures_task.rb} | 10 +++---- .../dossiers/_header_top.html.haml | 12 ++++----- ...els.rb => 20240924151336_create_labels.rb} | 4 +-- .../20240925133719_create_dossier_labels.rb | 2 +- db/schema.rb | 26 +++++++++---------- .../procedures_controller_spec.rb | 12 ++++----- .../instructeurs/dossiers_controller_spec.rb | 6 ++--- .../procedures_controller_spec.rb | 10 +++---- spec/factories/procedure.rb | 2 +- spec/models/concerns/columns_concern_spec.rb | 2 +- .../instructeurs/procedure_filters_spec.rb | 4 +-- .../dossiers/show.html.haml_spec.rb | 2 +- 20 files changed, 62 insertions(+), 62 deletions(-) rename app/models/{procedure_label.rb => label.rb} (89%) rename app/tasks/maintenance/{backfill_procedure_labels_for_procedures_task.rb => backfill_labels_for_procedures_task.rb} (60%) rename db/migrate/{20240924151336_create_procedure_labels.rb => 20240924151336_create_labels.rb} (64%) diff --git a/app/controllers/administrateurs/procedures_controller.rb b/app/controllers/administrateurs/procedures_controller.rb index 99b0152db..b29493517 100644 --- a/app/controllers/administrateurs/procedures_controller.rb +++ b/app/controllers/administrateurs/procedures_controller.rb @@ -108,7 +108,7 @@ module Administrateurs flash.now.alert = @procedure.errors.full_messages render 'new' else - @procedure.create_generic_procedure_labels + @procedure.create_generic_labels flash.notice = 'Démarche enregistrée.' current_administrateur.instructeur.assign_to_procedure(@procedure) diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index 07193eac2..a5ac2c0db 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -64,14 +64,14 @@ module Instructeurs end def dossier_labels - labels = params[:procedure_label_id]&.map(&:to_i) || [] + labels = params[:label_id]&.map(&:to_i) || [] @dossier = dossier - labels.each { |params_label| DossierLabel.find_or_create_by(dossier_id: @dossier.id, procedure_label_id: params_label) } + labels.each { |params_label| DossierLabel.find_or_create_by(dossier_id: @dossier.id, label_id: params_label) } - all_labels = DossierLabel.where(dossier_id: @dossier.id).pluck(:procedure_label_id) + all_labels = DossierLabel.where(dossier_id: @dossier.id).pluck(:label_id) - (all_labels - labels).each { DossierLabel.find_by(dossier_id: @dossier.id, procedure_label_id: _1).destroy } + (all_labels - labels).each { DossierLabel.find_by(dossier_id: @dossier.id, label_id: _1).destroy } render :change_state end diff --git a/app/models/dossier.rb b/app/models/dossier.rb index ae8e46e34..a07393b05 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -133,7 +133,7 @@ class Dossier < ApplicationRecord belongs_to :transfer, class_name: 'DossierTransfer', foreign_key: 'dossier_transfer_id', optional: true, inverse_of: :dossiers has_many :transfer_logs, class_name: 'DossierTransferLog', dependent: :destroy has_many :dossier_labels, dependent: :destroy - has_many :procedure_labels, through: :dossier_labels + has_many :labels, through: :dossier_labels after_destroy_commit :log_destroy diff --git a/app/models/dossier_label.rb b/app/models/dossier_label.rb index dfbf47fe0..6e9cc96f5 100644 --- a/app/models/dossier_label.rb +++ b/app/models/dossier_label.rb @@ -2,5 +2,5 @@ class DossierLabel < ApplicationRecord belongs_to :dossier - belongs_to :procedure_label + belongs_to :label end diff --git a/app/models/procedure_label.rb b/app/models/label.rb similarity index 89% rename from app/models/procedure_label.rb rename to app/models/label.rb index d8ab558f4..6c4e73e13 100644 --- a/app/models/procedure_label.rb +++ b/app/models/label.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ProcedureLabel < ApplicationRecord +class Label < ApplicationRecord belongs_to :procedure has_many :dossier_labels, dependent: :destroy diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 32d246bb5..3aebfec72 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -60,7 +60,7 @@ class Procedure < ApplicationRecord has_and_belongs_to_many :procedure_tags has_many :bulk_messages, dependent: :destroy - has_many :procedure_labels, dependent: :destroy + has_many :labels, dependent: :destroy def active_dossier_submitted_message published_dossier_submitted_message || draft_dossier_submitted_message @@ -529,7 +529,7 @@ class Procedure < ApplicationRecord procedure.closing_notification_en_cours = false procedure.template = false procedure.monavis_embed = nil - procedure.procedure_labels = procedure_labels.map(&:dup) + procedure.labels = labels.map(&:dup) if !procedure.valid? procedure.errors.attribute_names.each do |attribute| @@ -937,9 +937,9 @@ class Procedure < ApplicationRecord end end - def create_generic_procedure_labels - ProcedureLabel::GENERIC_LABELS.each do |label| - ProcedureLabel.create(name: label[:name], color: label[:color], procedure_id: self.id) + def create_generic_labels + Label::GENERIC_LABELS.each do |label| + Label.create(name: label[:name], color: label[:color], procedure_id: self.id) end end diff --git a/app/services/dossier_filter_service.rb b/app/services/dossier_filter_service.rb index ac6148a99..54e700965 100644 --- a/app/services/dossier_filter_service.rb +++ b/app/services/dossier_filter_service.rb @@ -131,7 +131,7 @@ class DossierFilterService assert_supported_column(table, column) dossiers .joins(:dossier_labels) - .where(dossier_labels: { procedure_label_id: values }) + .where(dossier_labels: { label_id: values }) when 'groupe_instructeur' assert_supported_column(table, column) diff --git a/app/services/dossier_projection_service.rb b/app/services/dossier_projection_service.rb index abe79ae32..a79aeacba 100644 --- a/app/services/dossier_projection_service.rb +++ b/app/services/dossier_projection_service.rb @@ -128,9 +128,9 @@ class DossierProjectionService id_value_h = DossierLabel - .includes(:procedure_label) + .includes(:label) .where(dossier_id: dossiers_ids) - .pluck('dossier_id, procedure_labels.name, procedure_labels.color') + .pluck('dossier_id, labels.name, labels.color') .group_by { |dossier_id, _| dossier_id } fields[0][:id_value_h] = id_value_h.transform_values { |v| { value: v, type: :label } } diff --git a/app/tasks/maintenance/backfill_procedure_labels_for_procedures_task.rb b/app/tasks/maintenance/backfill_labels_for_procedures_task.rb similarity index 60% rename from app/tasks/maintenance/backfill_procedure_labels_for_procedures_task.rb rename to app/tasks/maintenance/backfill_labels_for_procedures_task.rb index d6aeab298..b207454f1 100644 --- a/app/tasks/maintenance/backfill_procedure_labels_for_procedures_task.rb +++ b/app/tasks/maintenance/backfill_labels_for_procedures_task.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Maintenance - class BackfillProcedureLabelsForProceduresTask < MaintenanceTasks::Task + class BackfillLabelsForProceduresTask < MaintenanceTasks::Task # Cette tâche permet de créer un jeu de labels génériques pour les anciennes procédures # Plus d'informations sur l'implémentation des labels ici : https://github.com/demarches-simplifiees/demarches-simplifiees.fr/issues/9787 # 2024-10-15 @@ -12,13 +12,13 @@ module Maintenance def collection Procedure - .includes(:procedure_labels) - .where(procedure_labels: { id: nil }) + .includes(:labels) + .where(labels: { id: nil }) end def process(procedure) - ProcedureLabel::GENERIC_LABELS.each do |label| - ProcedureLabel.create(name: label[:name], color: label[:color], procedure_id: procedure.id) + Label::GENERIC_LABELS.each do |label| + Label.create(name: label[:name], color: label[:color], procedure_id: procedure.id) end end end diff --git a/app/views/instructeurs/dossiers/_header_top.html.haml b/app/views/instructeurs/dossiers/_header_top.html.haml index 44a117c15..c0f406abb 100644 --- a/app/views/instructeurs/dossiers/_header_top.html.haml +++ b/app/views/instructeurs/dossiers/_header_top.html.haml @@ -26,22 +26,22 @@ %p.fr-mb-1w %small L’usager a supprimé son compte. Vous pouvez archiver puis supprimer le dossier. - - if dossier.procedure.procedure_labels.present? + - if dossier.procedure.labels.present? .fr-mb-3w - - if dossier.procedure_labels.present? - - dossier.procedure_labels.each do |label| + - if dossier.labels.present? + - dossier.labels.each do |label| = tag_label(label.name, label.color) = render Dropdown::MenuComponent.new(wrapper: :span, button_options: { class: ['fr-btn--sm fr-btn--tertiary-no-outline fr-pl-1v']}, menu_options: { class: ['dropdown-label left-aligned'] }) do |menu| - - if dossier.procedure_labels.empty? + - if dossier.labels.empty? - menu.with_button_inner_html do Ajouter un label - menu.with_form do = form_with(url: dossier_labels_instructeur_dossier_path(dossier_id: dossier.id, procedure_id: dossier.procedure.id), method: :post, class: 'fr-p-3w', data: { controller: 'autosubmit', turbo: 'true' }) do |f| %fieldset.fr-fieldset.fr-mt-2w.fr-mb-0 - = f.collection_check_boxes :procedure_label_id, dossier.procedure.procedure_labels, :id, :name, include_hidden: false do |b| + = f.collection_check_boxes :label_id, dossier.procedure.labels, :id, :name, include_hidden: false do |b| .fr-fieldset__element .fr-checkbox-group.fr-checkbox-group--sm.fr-mb-1w - = b.check_box(checked: DossierLabel.find_by(dossier_id: dossier.id, procedure_label_id: b.value).present? ) + = b.check_box(checked: DossierLabel.find_by(dossier_id: dossier.id, label_id: b.value).present? ) = b.label(class: "fr-label fr-tag fr-tag--sm fr-tag--#{b.object.color}") { b.text } diff --git a/db/migrate/20240924151336_create_procedure_labels.rb b/db/migrate/20240924151336_create_labels.rb similarity index 64% rename from db/migrate/20240924151336_create_procedure_labels.rb rename to db/migrate/20240924151336_create_labels.rb index 0d916fd25..b6897db2a 100644 --- a/db/migrate/20240924151336_create_procedure_labels.rb +++ b/db/migrate/20240924151336_create_labels.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -class CreateProcedureLabels < ActiveRecord::Migration[7.0] +class CreateLabels < ActiveRecord::Migration[7.0] def change - create_table :procedure_labels do |t| + create_table :labels do |t| t.string :name t.string :color t.references :procedure, null: false, foreign_key: true diff --git a/db/migrate/20240925133719_create_dossier_labels.rb b/db/migrate/20240925133719_create_dossier_labels.rb index 0fab9bfc6..5b4ed3ac9 100644 --- a/db/migrate/20240925133719_create_dossier_labels.rb +++ b/db/migrate/20240925133719_create_dossier_labels.rb @@ -4,7 +4,7 @@ class CreateDossierLabels < ActiveRecord::Migration[7.0] def change create_table :dossier_labels do |t| t.references :dossier, null: false, foreign_key: true - t.references :procedure_label, null: false, foreign_key: true + t.references :label, null: false, foreign_key: true t.timestamps end diff --git a/db/schema.rb b/db/schema.rb index 0235b3b0f..bdb19f2c4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -419,10 +419,10 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do create_table "dossier_labels", force: :cascade do |t| t.datetime "created_at", null: false t.bigint "dossier_id", null: false - t.bigint "procedure_label_id", null: false + t.bigint "label_id", null: false t.datetime "updated_at", null: false t.index ["dossier_id"], name: "index_dossier_labels_on_dossier_id" - t.index ["procedure_label_id"], name: "index_dossier_labels_on_procedure_label_id" + t.index ["label_id"], name: "index_dossier_labels_on_label_id" end create_table "dossier_operation_logs", force: :cascade do |t| @@ -829,6 +829,15 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do t.index ["email", "dossier_id"], name: "index_invites_on_email_and_dossier_id", unique: true end + create_table "labels", force: :cascade do |t| + t.string "color" + t.datetime "created_at", null: false + t.string "name" + t.bigint "procedure_id", null: false + t.datetime "updated_at", null: false + t.index ["procedure_id"], name: "index_labels_on_procedure_id" + end + create_table "maintenance_tasks_runs", force: :cascade do |t| t.text "arguments" t.text "backtrace" @@ -878,15 +887,6 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do t.index ["from"], name: "index_path_rewrites_on_from", unique: true end - create_table "procedure_labels", force: :cascade do |t| - t.string "color" - t.datetime "created_at", null: false - t.string "name" - t.bigint "procedure_id", null: false - t.datetime "updated_at", null: false - t.index ["procedure_id"], name: "index_procedure_labels_on_procedure_id" - end - create_table "procedure_presentations", id: :serial, force: :cascade do |t| t.jsonb "a_suivre_filters", default: [], null: false, array: true t.jsonb "archives_filters", default: [], null: false, array: true @@ -1299,7 +1299,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do add_foreign_key "dossier_corrections", "commentaires" add_foreign_key "dossier_corrections", "dossiers" add_foreign_key "dossier_labels", "dossiers" - add_foreign_key "dossier_labels", "procedure_labels" + add_foreign_key "dossier_labels", "labels" add_foreign_key "dossier_operation_logs", "bill_signatures" add_foreign_key "dossier_transfer_logs", "dossiers" add_foreign_key "dossiers", "batch_operations" @@ -1320,8 +1320,8 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do add_foreign_key "groupe_instructeurs", "procedures" add_foreign_key "initiated_mails", "procedures" add_foreign_key "instructeurs", "users" + add_foreign_key "labels", "procedures" add_foreign_key "merge_logs", "users" - add_foreign_key "procedure_labels", "procedures" add_foreign_key "procedure_presentations", "assign_tos" add_foreign_key "procedure_revision_types_de_champ", "procedure_revision_types_de_champ", column: "parent_id" add_foreign_key "procedure_revision_types_de_champ", "procedure_revisions", column: "revision_id" diff --git a/spec/controllers/administrateurs/procedures_controller_spec.rb b/spec/controllers/administrateurs/procedures_controller_spec.rb index 89184cf56..03e87e346 100644 --- a/spec/controllers/administrateurs/procedures_controller_spec.rb +++ b/spec/controllers/administrateurs/procedures_controller_spec.rb @@ -515,8 +515,8 @@ describe Administrateurs::ProceduresController, type: :controller do end it "create generic labels" do - expect(subject.procedure_labels.size).to eq(3) - expect(subject.procedure_labels.first.name).to eq('à relancer') + expect(subject.labels.size).to eq(3) + expect(subject.labels.first.name).to eq('à relancer') end end @@ -684,9 +684,9 @@ describe Administrateurs::ProceduresController, type: :controller do expect(Procedure.last.cloned_from_library).to be_falsey expect(Procedure.last.notice.attached?).to be_truthy expect(Procedure.last.deliberation.attached?).to be_truthy - expect(Procedure.last.procedure_labels.present?).to be_truthy - expect(Procedure.last.procedure_labels.first.procedure_id).to eq(Procedure.last.id) - expect(procedure.procedure_labels.first.procedure_id).to eq(procedure.id) + expect(Procedure.last.labels.present?).to be_truthy + expect(Procedure.last.labels.first.procedure_id).to eq(Procedure.last.id) + expect(procedure.labels.first.procedure_id).to eq(procedure.id) expect(flash[:notice]).to have_content 'Démarche clonée. Pensez à vérifier la présentation et choisir le service à laquelle cette démarche est associée.' end @@ -709,7 +709,7 @@ describe Administrateurs::ProceduresController, type: :controller do it 'creates a new procedure and redirect to it' do expect(response).to redirect_to admin_procedure_path(id: Procedure.last.id) - expect(Procedure.last.procedure_labels.present?).to be_truthy + expect(Procedure.last.labels.present?).to be_truthy expect(flash[:notice]).to have_content 'Démarche clonée. Pensez à vérifier la présentation et choisir le service à laquelle cette démarche est associée.' end end diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index 7ecddb561..c543ba69b 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -1523,7 +1523,7 @@ describe Instructeurs::DossiersController, type: :controller do let(:procedure) { create(:procedure, :with_labels, instructeurs: [instructeur]) } let!(:dossier) { create(:dossier, :en_construction, procedure:) } context 'it create dossier labels' do - subject { post :dossier_labels, params: { procedure_id: procedure.id, dossier_id: dossier.id, procedure_label_id: [ProcedureLabel.first.id] }, format: :turbo_stream } + subject { post :dossier_labels, params: { procedure_id: procedure.id, dossier_id: dossier.id, label_id: [Label.first.id] }, format: :turbo_stream } it 'works' do subject dossier.reload @@ -1536,10 +1536,10 @@ describe Instructeurs::DossiersController, type: :controller do context 'it remove dossier labels' do before do - DossierLabel.create(dossier_id: dossier.id, procedure_label_id: dossier.procedure.procedure_labels.first.id) + DossierLabel.create(dossier_id: dossier.id, label_id: dossier.procedure.labels.first.id) end - subject { post :dossier_labels, params: { procedure_id: procedure.id, dossier_id: dossier.id, procedure_label_id: [] }, format: :turbo_stream } + subject { post :dossier_labels, params: { procedure_id: procedure.id, dossier_id: dossier.id, label_id: [] }, format: :turbo_stream } it 'works' do expect(dossier.dossier_labels.count).to eq(1) diff --git a/spec/controllers/instructeurs/procedures_controller_spec.rb b/spec/controllers/instructeurs/procedures_controller_spec.rb index c7e4f932f..c14575ef5 100644 --- a/spec/controllers/instructeurs/procedures_controller_spec.rb +++ b/spec/controllers/instructeurs/procedures_controller_spec.rb @@ -643,19 +643,19 @@ describe Instructeurs::ProceduresController, type: :controller do let!(:dossier) { create(:dossier, :en_construction, procedure:, groupe_instructeur: gi_2) } let!(:dossier_2) { create(:dossier, :en_construction, procedure:, groupe_instructeur: gi_2) } let(:statut) { 'tous' } - let(:procedure_label_id) { procedure.find_column(label: 'Labels') } + let(:label_id) { procedure.find_column(label: 'Labels') } let!(:procedure_presentation) do ProcedurePresentation.create!(assign_to: AssignTo.first) end render_views before do - DossierLabel.create(dossier_id: dossier.id, procedure_label_id: dossier.procedure.procedure_labels.first.id) - DossierLabel.create(dossier_id: dossier.id, procedure_label_id: dossier.procedure.procedure_labels.second.id) - DossierLabel.create(dossier_id: dossier_2.id, procedure_label_id: dossier.procedure.procedure_labels.last.id) + DossierLabel.create(dossier_id: dossier.id, label_id: dossier.procedure.labels.first.id) + DossierLabel.create(dossier_id: dossier.id, label_id: dossier.procedure.labels.second.id) + DossierLabel.create(dossier_id: dossier_2.id, label_id: dossier.procedure.labels.last.id) procedure_presentation.update(displayed_columns: [ - procedure_label_id.id + label_id.id ]) subject diff --git a/spec/factories/procedure.rb b/spec/factories/procedure.rb index 9e0e4cfaa..672aa4d2a 100644 --- a/spec/factories/procedure.rb +++ b/spec/factories/procedure.rb @@ -294,7 +294,7 @@ FactoryBot.define do trait :with_labels do after(:create) do |procedure, _evaluator| - procedure.create_generic_procedure_labels + procedure.create_generic_labels end end end diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index e11514646..1f6f0b98b 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -51,7 +51,7 @@ describe ColumnsConcern do { label: 'Groupe instructeur', table: 'groupe_instructeur', column: 'id', displayable: true, type: :enum, scope: '', value_column: :value, filterable: true }, { label: 'Avis oui/non', table: 'avis', column: 'question_answer', displayable: true, type: :text, scope: '', value_column: :value, filterable: false }, { label: 'France connecté ?', table: 'self', column: 'user_from_france_connect?', displayable: false, type: :text, scope: '', value_column: :value, filterable: false }, - { label: "Labels", table: "dossier_labels", column: "procedure_label_id", displayable: true, scope: '', value_column: :value, filterable: true }, + { label: "Labels", table: "dossier_labels", column: "label_id", displayable: true, scope: '', value_column: :value, filterable: true }, { label: 'SIREN', table: 'etablissement', column: 'entreprise_siren', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, { label: 'Forme juridique', table: 'etablissement', column: 'entreprise_forme_juridique', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, { label: 'Nom commercial', table: 'etablissement', column: 'entreprise_nom_commercial', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, diff --git a/spec/system/instructeurs/procedure_filters_spec.rb b/spec/system/instructeurs/procedure_filters_spec.rb index fb8fec122..1cd23ea13 100644 --- a/spec/system/instructeurs/procedure_filters_spec.rb +++ b/spec/system/instructeurs/procedure_filters_spec.rb @@ -174,8 +174,8 @@ describe "procedure filters" do describe 'dossier labels' do scenario "should be able to filter by dossier labels", js: true do - DossierLabel.create!(dossier_id: new_unfollow_dossier.id, procedure_label_id: procedure.procedure_labels.first.id) - add_filter('Labels', procedure.procedure_labels.first.name, type: :enum) + DossierLabel.create!(dossier_id: new_unfollow_dossier.id, label_id: procedure.labels.first.id) + add_filter('Labels', procedure.labels.first.name, type: :enum) expect(page).to have_link(new_unfollow_dossier.id.to_s) expect(page).not_to have_link(new_unfollow_dossier_2.id.to_s) end diff --git a/spec/views/instructeur/dossiers/show.html.haml_spec.rb b/spec/views/instructeur/dossiers/show.html.haml_spec.rb index c277e28e9..056ab6622 100644 --- a/spec/views/instructeur/dossiers/show.html.haml_spec.rb +++ b/spec/views/instructeur/dossiers/show.html.haml_spec.rb @@ -244,7 +244,7 @@ describe 'instructeurs/dossiers/show', type: :view do context "Dossier with labels" do before do - DossierLabel.create(dossier_id: dossier.id, procedure_label_id: dossier.procedure.procedure_labels.first.id) + DossierLabel.create(dossier_id: dossier.id, label_id: dossier.procedure.labels.first.id) end it 'displays labels and button without text to add label' do From 725a97da7e41d94f6ac593c1f872cbc6c1fd8596 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Wed, 16 Oct 2024 14:31:08 +0200 Subject: [PATCH 1388/1532] admin can modify procedure labels --- .../procedure/card/labels_component.rb | 7 + .../labels_component/labels_component.fr.yml | 3 + .../labels_component.html.haml | 17 ++ .../procedure_labels_controller.rb | 66 +++++++ app/helpers/dossier_helper.rb | 2 +- app/models/label.rb | 28 ++- .../procedure_labels/_form.html.haml | 8 + .../procedure_labels/edit.html.haml | 16 ++ .../procedure_labels/index.html.haml | 35 ++++ .../procedure_labels/new.html.haml | 16 ++ .../administrateurs/procedures/show.html.haml | 1 + .../dossiers/_header_top.html.haml | 9 +- config/locales/models/procedure_label/fr.yml | 6 + config/routes.rb | 2 + .../procedure_labels_controller_spec.rb | 168 ++++++++++++++++++ spec/factories/procedure_label.rb | 9 + 16 files changed, 388 insertions(+), 5 deletions(-) create mode 100644 app/components/procedure/card/labels_component.rb create mode 100644 app/components/procedure/card/labels_component/labels_component.fr.yml create mode 100644 app/components/procedure/card/labels_component/labels_component.html.haml create mode 100644 app/controllers/administrateurs/procedure_labels_controller.rb create mode 100644 app/views/administrateurs/procedure_labels/_form.html.haml create mode 100644 app/views/administrateurs/procedure_labels/edit.html.haml create mode 100644 app/views/administrateurs/procedure_labels/index.html.haml create mode 100644 app/views/administrateurs/procedure_labels/new.html.haml create mode 100644 config/locales/models/procedure_label/fr.yml create mode 100644 spec/controllers/administrateurs/procedure_labels_controller_spec.rb create mode 100644 spec/factories/procedure_label.rb diff --git a/app/components/procedure/card/labels_component.rb b/app/components/procedure/card/labels_component.rb new file mode 100644 index 000000000..4b484db35 --- /dev/null +++ b/app/components/procedure/card/labels_component.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Procedure::Card::LabelsComponent < ApplicationComponent + def initialize(procedure:) + @procedure = procedure + end +end diff --git a/app/components/procedure/card/labels_component/labels_component.fr.yml b/app/components/procedure/card/labels_component/labels_component.fr.yml new file mode 100644 index 000000000..31f598407 --- /dev/null +++ b/app/components/procedure/card/labels_component/labels_component.fr.yml @@ -0,0 +1,3 @@ +--- +fr: + title: Labels diff --git a/app/components/procedure/card/labels_component/labels_component.html.haml b/app/components/procedure/card/labels_component/labels_component.html.haml new file mode 100644 index 000000000..456f74ec7 --- /dev/null +++ b/app/components/procedure/card/labels_component/labels_component.html.haml @@ -0,0 +1,17 @@ +.fr-col-6.fr-col-md-4.fr-col-lg-3 + = link_to admin_procedure_procedure_labels_path(@procedure), class: 'fr-tile fr-enlarge-link' do + .fr-tile__body.flex.column.align-center.justify-between + - if @procedure.procedure_labels.present? + %p.fr-badge.fr-badge--info + Configuré + %div + .line-count.fr-my-1w + %p.fr-tag= @procedure.procedure_labels.size + - else + %p.fr-badge + Non configuré + + %h3.fr-h6 + = t('.title') + %p.fr-tile-subtitle Gérer les labels utilisables par les instructeurs + %p.fr-btn.fr-btn--tertiary= t('views.shared.actions.edit') diff --git a/app/controllers/administrateurs/procedure_labels_controller.rb b/app/controllers/administrateurs/procedure_labels_controller.rb new file mode 100644 index 000000000..b77800116 --- /dev/null +++ b/app/controllers/administrateurs/procedure_labels_controller.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Administrateurs + class ProcedureLabelsController < AdministrateurController + before_action :retrieve_procedure + before_action :set_colors_collection, only: [:edit, :new, :create, :update] + + def index + @labels = @procedure.procedure_labels + end + + def edit + @label = label + end + + def new + @label = ProcedureLabel.new + end + + def create + @label = @procedure.procedure_labels.build(procedure_label_params) + + if @label.save + flash.notice = 'Le label a bien été créé' + redirect_to admin_procedure_procedure_labels_path(@procedure) + else + flash.alert = @label.errors.full_messages + render :new + end + end + + def update + @label = label + @label.update(procedure_label_params) + + if @label.valid? + flash.notice = 'Le label a bien été modifié' + redirect_to admin_procedure_procedure_labels_path(@procedure) + else + flash.alert = @label.errors.full_messages + render :edit + end + end + + def destroy + @label = label + @label.destroy! + flash.notice = 'Le label a bien été supprimé' + redirect_to admin_procedure_procedure_labels_path(@procedure) + end + + private + + def procedure_label_params + params.require(:procedure_label).permit(:name, :color) + end + + def label + @procedure.procedure_labels.find(params[:id]) + end + + def set_colors_collection + @colors_collection = ProcedureLabel.colors.values + end + end +end diff --git a/app/helpers/dossier_helper.rb b/app/helpers/dossier_helper.rb index 1ef31e14d..ba6a1004c 100644 --- a/app/helpers/dossier_helper.rb +++ b/app/helpers/dossier_helper.rb @@ -130,7 +130,7 @@ module DossierHelper end def tag_label(name, color) - tag.span(name, class: "fr-tag fr-tag--sm fr-tag--#{color}") + tag.span(name, class: "fr-tag fr-tag--sm fr-tag--#{ProcedureLabel.colors.fetch(color.underscore)}") end def demandeur_dossier(dossier) diff --git a/app/models/label.rb b/app/models/label.rb index 6c4e73e13..bebfddf21 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -4,11 +4,33 @@ class Label < ApplicationRecord belongs_to :procedure has_many :dossier_labels, dependent: :destroy + NAME_MAX_LENGTH = 30 GENERIC_LABELS = [ - { name: 'à relancer', color: 'brown-caramel' }, - { name: 'complet', color: 'green-bourgeon' }, - { name: 'prêt pour validation', color: 'green-archipel' } + { name: 'à relancer', color: 'brown_caramel' }, + { name: 'complet', color: 'green_bourgeon' }, + { name: 'prêt pour validation', color: 'green_archipel' } ] + enum color: { + green_tilleul_verveine: "green-tilleul-verveine", + green_bourgeon: "green-bourgeon", + green_emeraude: "green-emeraude", + green_menthe: "green-menthe", + green_archipel: "green-archipel", + blue_ecume: "blue-ecume", + blue_cumulus: "blue-cumulus", + purple_glycine: "purple-glycine", + pink_macaron: "pink-macaron", + pink_tuile: "pink-tuile", + yellow_tournesol: "yellow-tournesol", + yellow_moutarde: "yellow-moutarde", + orange_terre_battue: "orange-terre-battue", + brown_cafe_creme: "brown-cafe-creme", + brown_caramel: "brown-caramel", + brown_opera: "brown-opera", + beige_gris_galet: "beige-gris-galet" + } + validates :name, :color, presence: true + validates :name, length: { maximum: NAME_MAX_LENGTH } end diff --git a/app/views/administrateurs/procedure_labels/_form.html.haml b/app/views/administrateurs/procedure_labels/_form.html.haml new file mode 100644 index 000000000..a37eb26c5 --- /dev/null +++ b/app/views/administrateurs/procedure_labels/_form.html.haml @@ -0,0 +1,8 @@ += form_with model: label, url: admin_procedure_procedure_labels_path(@procedure, id: @label.id), local: true do |f| + = render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field, opts: { maxlength: ProcedureLabel::NAME_MAX_LENGTH}) + = f.label :color, class: 'fr-label' do + = t('activerecord.attributes.procedure_label.color') + = render EditableChamp::AsteriskMandatoryComponent.new + = f.select :color, options_for_select(@colors_collection, selected: @label.color), {prompt: 'Choisir une couleur'}, {class: 'fr-select'} + + = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f) diff --git a/app/views/administrateurs/procedure_labels/edit.html.haml b/app/views/administrateurs/procedure_labels/edit.html.haml new file mode 100644 index 000000000..fd712d69a --- /dev/null +++ b/app/views/administrateurs/procedure_labels/edit.html.haml @@ -0,0 +1,16 @@ += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [['Démarches', admin_procedures_path], + [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], + ['gestion des labels', admin_procedure_procedure_labels_path(procedure_id: @procedure.id)], + ['Modifier le label']] } + + +.fr-container + .fr-mb-3w + = link_to "Liste de tous les labels", admin_procedure_procedure_labels_path(procedure_id: @procedure.id), class: "fr-link fr-icon-arrow-left-line fr-link--icon-left" + + %h1.fr-h2 + Modifier le label + + = render partial: 'form', + locals: { label: @label, procedure_id: @procedure.id } diff --git a/app/views/administrateurs/procedure_labels/index.html.haml b/app/views/administrateurs/procedure_labels/index.html.haml new file mode 100644 index 000000000..bb5f219e0 --- /dev/null +++ b/app/views/administrateurs/procedure_labels/index.html.haml @@ -0,0 +1,35 @@ += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [['Démarches', admin_procedures_path], + [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], + ['Labels']] } + +.fr-container + %h1.fr-h2 Labels + + = link_to "Nouveau label", new_admin_procedure_procedure_label_path(procedure_id: @procedure.id), class: "fr-btn fr-btn--primary fr-btn--icon-left fr-icon-add-circle-line mb-3" + + - if @procedure.procedure_labels.present? + .fr-table.fr-table--layout-fixed.fr-table--bordered + %table + %caption Liste des labels + %thead + %tr + %th{ scope: "col" } + Nom + %th.change{ scope: "col" } + Actions + + %tbody + - @labels.each do |label| + %tr + %td + = tag_label(label.name, label.color) + %td.change + = link_to('Modifier', edit_admin_procedure_procedure_label_path(procedure_id: @procedure.id, id: label.id), class: 'fr-btn fr-btn--sm fr-btn--secondary fr-btn--icon-left fr-icon-pencil-line') + = link_to 'Supprimer', + admin_procedure_procedure_label_path(procedure_id: @procedure.id, id: label.id), + method: :delete, + data: { confirm: "Confirmez vous la suppression de #{label.name}" }, + class: 'fr-btn fr-btn--sm fr-btn--secondary fr-btn--icon-left fr-icon-delete-line fr-ml-1w' + += render Procedure::FixedFooterComponent.new(procedure: @procedure) diff --git a/app/views/administrateurs/procedure_labels/new.html.haml b/app/views/administrateurs/procedure_labels/new.html.haml new file mode 100644 index 000000000..a6bed20ad --- /dev/null +++ b/app/views/administrateurs/procedure_labels/new.html.haml @@ -0,0 +1,16 @@ += render partial: 'administrateurs/breadcrumbs', + locals: { steps: [['Démarches', admin_procedures_path], + [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], + ['gestion des labels', admin_procedure_procedure_labels_path(procedure_id: @procedure.id)], + ['Nouveau label']] } + + +.fr-container + .fr-mb-3w + = link_to "Liste de tous les labels", admin_procedure_procedure_labels_path(procedure_id: @procedure.id), class: "fr-link fr-icon-arrow-left-line fr-link--icon-left" + + %h1.fr-h2 + Créer un nouveau label + + = render partial: 'form', + locals: { label: @label, procedure_id: @procedure.id } diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml index 4414f921f..877f4ef98 100644 --- a/app/views/administrateurs/procedures/show.html.haml +++ b/app/views/administrateurs/procedures/show.html.haml @@ -98,3 +98,4 @@ = render Procedure::Card::DossierSubmittedMessageComponent.new(procedure: @procedure) = render Procedure::Card::ChorusComponent.new(procedure: @procedure) = render Procedure::Card::AccuseLectureComponent.new(procedure: @procedure) + = render Procedure::Card::LabelsComponent.new(procedure: @procedure) diff --git a/app/views/instructeurs/dossiers/_header_top.html.haml b/app/views/instructeurs/dossiers/_header_top.html.haml index c0f406abb..7fe1fb2f8 100644 --- a/app/views/instructeurs/dossiers/_header_top.html.haml +++ b/app/views/instructeurs/dossiers/_header_top.html.haml @@ -44,4 +44,11 @@ .fr-fieldset__element .fr-checkbox-group.fr-checkbox-group--sm.fr-mb-1w = b.check_box(checked: DossierLabel.find_by(dossier_id: dossier.id, label_id: b.value).present? ) - = b.label(class: "fr-label fr-tag fr-tag--sm fr-tag--#{b.object.color}") { b.text } + = b.label(class: "fr-label fr-tag fr-tag--sm fr-tag--#{Label.colors.fetch(b.object.color)}") { b.text } + + %hr + %p.fr-text--sm.fr-text-mention--grey.fr-mb-0 + %b Besoin d'autres labels ? + %br + Contactez les + = link_to 'administrateurs de la démarche', administrateurs_instructeur_procedure_path(dossier.procedure), class: 'fr-link fr-link--sm', **external_link_attributes diff --git a/config/locales/models/procedure_label/fr.yml b/config/locales/models/procedure_label/fr.yml new file mode 100644 index 000000000..deabcbd8e --- /dev/null +++ b/config/locales/models/procedure_label/fr.yml @@ -0,0 +1,6 @@ +fr: + activerecord: + attributes: + procedure_label: + color: Couleur + name: Nom diff --git a/config/routes.rb b/config/routes.rb index 5b6dd9634..0bf0c4602 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -708,6 +708,8 @@ Rails.application.routes.draw do get 'preview', on: :member end + resources :procedure_labels, controller: 'procedure_labels' + resource :attestation_template, only: [:show, :edit, :update, :create] do get 'preview', on: :member end diff --git a/spec/controllers/administrateurs/procedure_labels_controller_spec.rb b/spec/controllers/administrateurs/procedure_labels_controller_spec.rb new file mode 100644 index 000000000..4d93b4271 --- /dev/null +++ b/spec/controllers/administrateurs/procedure_labels_controller_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +describe Administrateurs::ProcedureLabelsController, type: :controller do + let(:admin) { administrateurs(:default_admin) } + let(:procedure) { create(:procedure, administrateur: admin) } + let(:admin_2) { create(:administrateur) } + let(:procedure_2) { create(:procedure, administrateur: admin_2) } + + describe '#index' do + render_views + let!(:label_1) { create(:procedure_label, procedure:) } + let!(:label_2) { create(:procedure_label, procedure:) } + let!(:label_3) { create(:procedure_label, procedure:) } + + before do + sign_in(admin.user) + end + + subject { get :index, params: { procedure_id: procedure.id } } + + it 'displays all procedure labels' do + subject + expect(response.body).to have_link("Nouveau label") + expect(response.body).to have_link("Modifier", count: 3) + expect(response.body).to have_link("Supprimer", count: 3) + end + end + + describe '#create' do + before do + sign_in(admin.user) + end + + subject { post :create, params: params } + + context 'when submitting a new label' do + let(:params) do + { + procedure_label: { + name: 'Nouveau label', + color: 'green-bourgeon' + }, + procedure_id: procedure.id + } + end + + it { expect { subject }.to change { ProcedureLabel.count } .by(1) } + + it 'creates a new label' do + subject + expect(flash.alert).to be_nil + expect(flash.notice).to eq('Le label a bien été créé') + expect(ProcedureLabel.last.name).to eq('Nouveau label') + expect(ProcedureLabel.last.color).to eq('green_bourgeon') + expect(procedure.procedure_labels.last).to eq(ProcedureLabel.last) + end + end + + context 'when submitting an invalid label' do + let(:params) { { procedure_label: { name: 'Nouveau label' }, procedure_id: procedure.id } } + + it { expect { subject }.not_to change { ProcedureLabel.count } } + + it 'does not create a new label' do + subject + expect(flash.alert).to eq(["Le champ « Couleur » doit être rempli"]) + expect(response).to render_template(:new) + expect(assigns(:label).name).to eq('Nouveau label') + end + end + + context 'when submitting a label for a not own procedure' do + let(:params) do + { + procedure_label: { + name: 'Nouveau label', + color: 'green-bourgeon' + }, + procedure_id: procedure_2.id + } + end + + it { expect { subject }.not_to change { ProcedureLabel.count } } + + it 'does not create a new label' do + subject + expect(flash.alert).to eq("Démarche inexistante") + expect(response.status).to eq(404) + end + end + end + + describe '#update' do + let!(:label) { create(:procedure_label, procedure:) } + let(:label_params) { { name: 'Nouveau nom' } } + let(:params) { { id: label.id, procedure_label: label_params, procedure_id: procedure.id } } + + before do + sign_in(admin.user) + end + + subject { patch :update, params: } + + context 'when updating a label' do + it 'updates correctly' do + subject + expect(flash.alert).to be_nil + expect(flash.notice).to eq('Le label a bien été modifié') + expect(label.reload.name).to eq('Nouveau nom') + expect(label.reload.color).to eq('green_bourgeon') + expect(label.reload.updated_at).not_to eq(label.reload.created_at) + expect(response).to redirect_to(admin_procedure_procedure_labels_path(procedure_id: procedure.id)) + end + end + + context 'when updating a service with invalid data' do + let(:label_params) { { name: '' } } + + it 'does not update' do + subject + expect(flash.alert).not_to be_nil + expect(response).to render_template(:edit) + expect(label.reload.updated_at).to eq(label.reload.created_at) + end + end + + context 'when updating a label for a not own procedure' do + let(:params) { { id: label.id, procedure_label: label_params, procedure_id: procedure_2.id } } + + it 'does not update' do + subject + expect(label.reload.updated_at).to eq(label.reload.created_at) + end + end + end + + describe '#destroy' do + let(:label) { create(:procedure_label, procedure:) } + + before do + sign_in(admin.user) + end + + subject { delete :destroy, params: } + + context "when deleting a label" do + let(:params) { { id: label.id, procedure_id: procedure.id } } + + it "delete the label" do + subject + expect { label.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect(flash.notice).to eq('Le label a bien été supprimé') + expect(response).to redirect_to((admin_procedure_procedure_labels_path(procedure_id: procedure.id))) + end + end + + context 'when deleting a label for a not own procedure' do + let(:params) { { id: label.id, procedure_id: procedure_2.id } } + + it 'does not delete' do + subject + expect(flash.alert).to eq("Démarche inexistante") + expect(response.status).to eq(404) + expect { label.reload }.not_to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/factories/procedure_label.rb b/spec/factories/procedure_label.rb new file mode 100644 index 000000000..fbc907b14 --- /dev/null +++ b/spec/factories/procedure_label.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :procedure_label do + name { 'Un label' } + color { 'green-bourgeon' } + association :procedure + end +end From 507ea7039860e42faca63f069f736bd7b2f896cb Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Mon, 28 Oct 2024 14:33:57 +0100 Subject: [PATCH 1389/1532] improve color selection for Admin and improve generic label lists --- app/assets/stylesheets/tags.scss | 19 +++++++++++------- app/models/label.rb | 15 +++++--------- .../procedure_labels/_form.html.haml | 16 +++++++++++---- .../procedures_controller_spec.rb | 4 ++-- .../instructeurs/dossiers_controller_spec.rb | 2 +- .../procedures_controller_spec.rb | 8 ++++---- spec/system/instructeurs/instruction_spec.rb | 20 +++++++++---------- .../dossiers/show.html.haml_spec.rb | 10 +++++----- 8 files changed, 51 insertions(+), 43 deletions(-) diff --git a/app/assets/stylesheets/tags.scss b/app/assets/stylesheets/tags.scss index 577b375d7..5eda8aeeb 100644 --- a/app/assets/stylesheets/tags.scss +++ b/app/assets/stylesheets/tags.scss @@ -5,18 +5,11 @@ $colors: "green-tilleul-verveine", "green-bourgeon", "green-emeraude", "green-menthe", - "green-archipel", "blue-ecume", - "blue-cumulus", "purple-glycine", "pink-macaron", - "pink-tuile", "yellow-tournesol", - "yellow-moutarde", - "orange-terre-battue", "brown-cafe-creme", - "brown-caramel", - "brown-opera", "beige-gris-galet"; @@ -35,3 +28,15 @@ $colors: "green-tilleul-verveine", color: var(--text-action-high-#{$color}); } } + +.grid-tags { + display: grid; + grid-template-columns: repeat(2, 1fr); +} + +@media (min-width: 62em) { + .grid-tags { + display: grid; + grid-template-columns: repeat(5, 1fr); + } +} diff --git a/app/models/label.rb b/app/models/label.rb index bebfddf21..785507a80 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -6,9 +6,11 @@ class Label < ApplicationRecord NAME_MAX_LENGTH = 30 GENERIC_LABELS = [ - { name: 'à relancer', color: 'brown_caramel' }, - { name: 'complet', color: 'green_bourgeon' }, - { name: 'prêt pour validation', color: 'green_archipel' } + { name: 'À examiner', color: 'purple_glycine' }, + { name: 'À relancer', color: 'green_tilleul_verveine' }, + { name: 'Complet', color: 'green_emeraude' }, + { name: 'À signer', color: 'blue_ecume' }, + { name: 'Urgent', color: 'pink_macaron' } ] enum color: { @@ -16,18 +18,11 @@ class Label < ApplicationRecord green_bourgeon: "green-bourgeon", green_emeraude: "green-emeraude", green_menthe: "green-menthe", - green_archipel: "green-archipel", blue_ecume: "blue-ecume", - blue_cumulus: "blue-cumulus", purple_glycine: "purple-glycine", pink_macaron: "pink-macaron", - pink_tuile: "pink-tuile", yellow_tournesol: "yellow-tournesol", - yellow_moutarde: "yellow-moutarde", - orange_terre_battue: "orange-terre-battue", brown_cafe_creme: "brown-cafe-creme", - brown_caramel: "brown-caramel", - brown_opera: "brown-opera", beige_gris_galet: "beige-gris-galet" } diff --git a/app/views/administrateurs/procedure_labels/_form.html.haml b/app/views/administrateurs/procedure_labels/_form.html.haml index a37eb26c5..c36b3d643 100644 --- a/app/views/administrateurs/procedure_labels/_form.html.haml +++ b/app/views/administrateurs/procedure_labels/_form.html.haml @@ -1,8 +1,16 @@ = form_with model: label, url: admin_procedure_procedure_labels_path(@procedure, id: @label.id), local: true do |f| = render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field, opts: { maxlength: ProcedureLabel::NAME_MAX_LENGTH}) - = f.label :color, class: 'fr-label' do - = t('activerecord.attributes.procedure_label.color') - = render EditableChamp::AsteriskMandatoryComponent.new - = f.select :color, options_for_select(@colors_collection, selected: @label.color), {prompt: 'Choisir une couleur'}, {class: 'fr-select'} + + %fieldset.fr-fieldset + %legend.fr-fieldset__legend.fr-fieldset__legend--regular + = t('activerecord.attributes.procedure_label.color') + = render EditableChamp::AsteriskMandatoryComponent.new + + .grid-tags + - @colors_collection.each do |color| + .fr-fieldset__element + .fr-radio-group + = f.radio_button :color, color, checked: (label.color == color.underscore) + = f.label :color, value: color, class: "fr-label fr-tag fr-tag--sm fr-tag--#{color}" = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f) diff --git a/spec/controllers/administrateurs/procedures_controller_spec.rb b/spec/controllers/administrateurs/procedures_controller_spec.rb index 03e87e346..469fc8b2f 100644 --- a/spec/controllers/administrateurs/procedures_controller_spec.rb +++ b/spec/controllers/administrateurs/procedures_controller_spec.rb @@ -515,8 +515,8 @@ describe Administrateurs::ProceduresController, type: :controller do end it "create generic labels" do - expect(subject.labels.size).to eq(3) - expect(subject.labels.first.name).to eq('à relancer') + expect(subject.labels.size).to eq(5) + expect(subject.labels.first.name).to eq('À examiner') end end diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index c543ba69b..3ec50f3cc 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -1529,7 +1529,7 @@ describe Instructeurs::DossiersController, type: :controller do dossier.reload expect(dossier.dossier_labels.count).to eq(1) - expect(subject.body).to include('fr-tag--brown-caramel') + expect(subject.body).to include('fr-tag--purple-glycine') expect(subject.body).not_to include('Ajouter un label') end end diff --git a/spec/controllers/instructeurs/procedures_controller_spec.rb b/spec/controllers/instructeurs/procedures_controller_spec.rb index c14575ef5..8fcd18be9 100644 --- a/spec/controllers/instructeurs/procedures_controller_spec.rb +++ b/spec/controllers/instructeurs/procedures_controller_spec.rb @@ -663,10 +663,10 @@ describe Instructeurs::ProceduresController, type: :controller do it 'displays correctly labels in instructeur table' do expect(response.body).to include("Labels") - expect(response.body).to have_selector('ul.fr-tags-group li span.fr-tag', text: 'à relancer') - expect(response.body).to have_selector('ul.fr-tags-group li span.fr-tag', text: 'complet') - expect(response.body).not_to have_selector('ul li span.fr-tag', text: 'prêt pour validation') - expect(response.body).to have_selector('span.fr-tag', text: 'prêt pour validation') + expect(response.body).to have_selector('ul.fr-tags-group li span.fr-tag', text: 'À examiner') + expect(response.body).to have_selector('ul.fr-tags-group li span.fr-tag', text: 'À relancer') + expect(response.body).not_to have_selector('ul li span.fr-tag', text: 'Urgent') + expect(response.body).to have_selector('span.fr-tag', text: 'Urgent') end end end diff --git a/spec/system/instructeurs/instruction_spec.rb b/spec/system/instructeurs/instruction_spec.rb index 9517afd80..098878767 100644 --- a/spec/system/instructeurs/instruction_spec.rb +++ b/spec/system/instructeurs/instruction_spec.rb @@ -281,26 +281,26 @@ describe 'Instructing a dossier:', js: true do visit instructeur_dossier_path(procedure, dossier) click_on 'Ajouter un label' - check 'à relancer', allow_label_click: true - expect(page).to have_css('.fr-tag', text: "à relancer", count: 2) + check 'À relancer', allow_label_click: true + expect(page).to have_css('.fr-tag', text: "À relancer", count: 2) expect(dossier.dossier_labels.count).to eq(1) expect(page).not_to have_text('Ajouter un label') find('span.dropdown button.dropdown-button').click - expect(page).to have_checked_field('à relancer') - check 'complet', allow_label_click: true + expect(page).to have_checked_field('À relancer') + check 'Complet', allow_label_click: true - expect(page).to have_css('.fr-tag', text: "complet", count: 2) + expect(page).to have_css('.fr-tag', text: "Complet", count: 2) expect(dossier.dossier_labels.count).to eq(2) find('span.dropdown button.dropdown-button').click - uncheck 'à relancer', allow_label_click: true + uncheck 'À relancer', allow_label_click: true - expect(page).to have_unchecked_field('à relancer') - expect(page).to have_checked_field('complet') - expect(page).to have_css('.fr-tag', text: "à relancer", count: 1) - expect(page).to have_css('.fr-tag', text: "complet", count: 2) + expect(page).to have_unchecked_field('À relancer') + expect(page).to have_checked_field('Complet') + expect(page).to have_css('.fr-tag', text: "À relancer", count: 1) + expect(page).to have_css('.fr-tag', text: "Complet", count: 2) expect(dossier.dossier_labels.count).to eq(1) end end diff --git a/spec/views/instructeur/dossiers/show.html.haml_spec.rb b/spec/views/instructeur/dossiers/show.html.haml_spec.rb index 056ab6622..3a567c551 100644 --- a/spec/views/instructeur/dossiers/show.html.haml_spec.rb +++ b/spec/views/instructeur/dossiers/show.html.haml_spec.rb @@ -227,7 +227,7 @@ describe 'instructeurs/dossiers/show', type: :view do let(:dossier) { create(:dossier, :en_construction, procedure: procedure_without_labels) } it 'does not display button to add label or dropdown' do expect(subject).not_to have_text("Ajouter un label") - expect(subject).not_to have_text("à relancer") + expect(subject).not_to have_text("À examiner") end end @@ -235,9 +235,9 @@ describe 'instructeurs/dossiers/show', type: :view do it 'displays button with text to add label' do expect(subject).to have_text("Ajouter un label") expect(subject).to have_selector("button.dropdown-button") - expect(subject).to have_text("à relancer", count: 1) + expect(subject).to have_text("À examiner", count: 1) within('.dropdown') do - expect(subject).to have_text("à relancer", count: 1) + expect(subject).to have_text("À examiner", count: 1) end end end @@ -250,9 +250,9 @@ describe 'instructeurs/dossiers/show', type: :view do it 'displays labels and button without text to add label' do expect(subject).not_to have_text("Ajouter un label") expect(subject).to have_selector("button.dropdown-button") - expect(subject).to have_text("à relancer", count: 2) + expect(subject).to have_text("À examiner", count: 2) within('.dropdown') do - expect(subject).to have_text("à relancer", count: 1) + expect(subject).to have_text("À examiner", count: 1) end end end From 9595730fdeef7640f0a691c9663baaefcc88ad9d Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Tue, 29 Oct 2024 11:03:57 +0100 Subject: [PATCH 1390/1532] feedback PR - prettier url and other style improvement --- .../labels_component.html.haml | 2 +- .../procedure_labels_controller.rb | 18 +++++++----------- .../procedure_labels/_form.html.haml | 4 ++-- .../procedure_labels/edit.html.haml | 8 ++++++-- .../procedure_labels/index.html.haml | 14 +++++++++++--- .../procedure_labels/new.html.haml | 8 ++++++-- 6 files changed, 33 insertions(+), 21 deletions(-) diff --git a/app/components/procedure/card/labels_component/labels_component.html.haml b/app/components/procedure/card/labels_component/labels_component.html.haml index 456f74ec7..78a7cb033 100644 --- a/app/components/procedure/card/labels_component/labels_component.html.haml +++ b/app/components/procedure/card/labels_component/labels_component.html.haml @@ -1,5 +1,5 @@ .fr-col-6.fr-col-md-4.fr-col-lg-3 - = link_to admin_procedure_procedure_labels_path(@procedure), class: 'fr-tile fr-enlarge-link' do + = link_to [:admin, @procedure, :procedure_labels], class: 'fr-tile fr-enlarge-link' do .fr-tile__body.flex.column.align-center.justify-between - if @procedure.procedure_labels.present? %p.fr-badge.fr-badge--info diff --git a/app/controllers/administrateurs/procedure_labels_controller.rb b/app/controllers/administrateurs/procedure_labels_controller.rb index b77800116..de1dc1721 100644 --- a/app/controllers/administrateurs/procedure_labels_controller.rb +++ b/app/controllers/administrateurs/procedure_labels_controller.rb @@ -3,6 +3,7 @@ module Administrateurs class ProcedureLabelsController < AdministrateurController before_action :retrieve_procedure + before_action :retrieve_label, only: [:edit, :update, :destroy] before_action :set_colors_collection, only: [:edit, :new, :create, :update] def index @@ -10,7 +11,6 @@ module Administrateurs end def edit - @label = label end def new @@ -22,7 +22,7 @@ module Administrateurs if @label.save flash.notice = 'Le label a bien été créé' - redirect_to admin_procedure_procedure_labels_path(@procedure) + redirect_to [:admin, @procedure, :procedure_labels] else flash.alert = @label.errors.full_messages render :new @@ -30,12 +30,9 @@ module Administrateurs end def update - @label = label - @label.update(procedure_label_params) - - if @label.valid? + if @label.update(procedure_label_params) flash.notice = 'Le label a bien été modifié' - redirect_to admin_procedure_procedure_labels_path(@procedure) + redirect_to [:admin, @procedure, :procedure_labels] else flash.alert = @label.errors.full_messages render :edit @@ -43,10 +40,9 @@ module Administrateurs end def destroy - @label = label @label.destroy! flash.notice = 'Le label a bien été supprimé' - redirect_to admin_procedure_procedure_labels_path(@procedure) + redirect_to [:admin, @procedure, :procedure_labels] end private @@ -55,8 +51,8 @@ module Administrateurs params.require(:procedure_label).permit(:name, :color) end - def label - @procedure.procedure_labels.find(params[:id]) + def retrieve_label + @label = @procedure.procedure_labels.find(params[:id]) end def set_colors_collection diff --git a/app/views/administrateurs/procedure_labels/_form.html.haml b/app/views/administrateurs/procedure_labels/_form.html.haml index c36b3d643..56d57b6ac 100644 --- a/app/views/administrateurs/procedure_labels/_form.html.haml +++ b/app/views/administrateurs/procedure_labels/_form.html.haml @@ -1,10 +1,10 @@ -= form_with model: label, url: admin_procedure_procedure_labels_path(@procedure, id: @label.id), local: true do |f| += form_with model: label, url: [:admin, @procedure, @label], local: true do |f| = render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field, opts: { maxlength: ProcedureLabel::NAME_MAX_LENGTH}) %fieldset.fr-fieldset %legend.fr-fieldset__legend.fr-fieldset__legend--regular = t('activerecord.attributes.procedure_label.color') - = render EditableChamp::AsteriskMandatoryComponent.new + = asterisk .grid-tags - @colors_collection.each do |color| diff --git a/app/views/administrateurs/procedure_labels/edit.html.haml b/app/views/administrateurs/procedure_labels/edit.html.haml index fd712d69a..695ba74a0 100644 --- a/app/views/administrateurs/procedure_labels/edit.html.haml +++ b/app/views/administrateurs/procedure_labels/edit.html.haml @@ -1,13 +1,17 @@ +- content_for :title, "Modifier le label" + = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['gestion des labels', admin_procedure_procedure_labels_path(procedure_id: @procedure.id)], + ['gestion des labels', [:admin, @procedure, :procedure_labels]], ['Modifier le label']] } .fr-container .fr-mb-3w - = link_to "Liste de tous les labels", admin_procedure_procedure_labels_path(procedure_id: @procedure.id), class: "fr-link fr-icon-arrow-left-line fr-link--icon-left" + = link_to "Liste de tous les labels", + [:admin, @procedure, :procedure_labels], + class: "fr-link fr-icon-arrow-left-line fr-link--icon-left" %h1.fr-h2 Modifier le label diff --git a/app/views/administrateurs/procedure_labels/index.html.haml b/app/views/administrateurs/procedure_labels/index.html.haml index bb5f219e0..7a124da3c 100644 --- a/app/views/administrateurs/procedure_labels/index.html.haml +++ b/app/views/administrateurs/procedure_labels/index.html.haml @@ -1,3 +1,5 @@ +- content_for :title, "Labels" + = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], @@ -6,7 +8,9 @@ .fr-container %h1.fr-h2 Labels - = link_to "Nouveau label", new_admin_procedure_procedure_label_path(procedure_id: @procedure.id), class: "fr-btn fr-btn--primary fr-btn--icon-left fr-icon-add-circle-line mb-3" + = link_to "Nouveau label", + [:new, :admin, @procedure, :procedure_label], + class: "fr-btn fr-btn--primary fr-btn--icon-left fr-icon-add-circle-line mb-3" - if @procedure.procedure_labels.present? .fr-table.fr-table--layout-fixed.fr-table--bordered @@ -25,9 +29,13 @@ %td = tag_label(label.name, label.color) %td.change - = link_to('Modifier', edit_admin_procedure_procedure_label_path(procedure_id: @procedure.id, id: label.id), class: 'fr-btn fr-btn--sm fr-btn--secondary fr-btn--icon-left fr-icon-pencil-line') + + = link_to 'Modifier', + [:edit, :admin, @procedure, label], + class: 'fr-btn fr-btn--sm fr-btn--secondary fr-btn--icon-left fr-icon-pencil-line' + = link_to 'Supprimer', - admin_procedure_procedure_label_path(procedure_id: @procedure.id, id: label.id), + [:admin, @procedure, label], method: :delete, data: { confirm: "Confirmez vous la suppression de #{label.name}" }, class: 'fr-btn fr-btn--sm fr-btn--secondary fr-btn--icon-left fr-icon-delete-line fr-ml-1w' diff --git a/app/views/administrateurs/procedure_labels/new.html.haml b/app/views/administrateurs/procedure_labels/new.html.haml index a6bed20ad..46e9c11d0 100644 --- a/app/views/administrateurs/procedure_labels/new.html.haml +++ b/app/views/administrateurs/procedure_labels/new.html.haml @@ -1,13 +1,17 @@ +- content_for :title, "Nouveau label" + = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['gestion des labels', admin_procedure_procedure_labels_path(procedure_id: @procedure.id)], + ['gestion des labels', [:admin, @procedure, :procedure_labels]], ['Nouveau label']] } .fr-container .fr-mb-3w - = link_to "Liste de tous les labels", admin_procedure_procedure_labels_path(procedure_id: @procedure.id), class: "fr-link fr-icon-arrow-left-line fr-link--icon-left" + = link_to "Liste de tous les labels", + [:admin, @procedure, :procedure_labels], + class: "fr-link fr-icon-arrow-left-line fr-link--icon-left" %h1.fr-h2 Créer un nouveau label From 468c159b52a17b034e93ef6e1c161e834be1b5dc Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Tue, 29 Oct 2024 12:59:35 +0100 Subject: [PATCH 1391/1532] handle label color translation and class name --- app/assets/stylesheets/tags.scss | 12 ------------ .../administrateurs/procedure_labels_controller.rb | 2 +- app/helpers/dossier_helper.rb | 2 +- app/models/label.rb | 4 ++++ .../administrateurs/procedure_labels/_form.html.haml | 11 +++++------ config/locales/models/procedure_label/fr.yml | 11 +++++++++++ 6 files changed, 22 insertions(+), 20 deletions(-) diff --git a/app/assets/stylesheets/tags.scss b/app/assets/stylesheets/tags.scss index 5eda8aeeb..5d45bfb1e 100644 --- a/app/assets/stylesheets/tags.scss +++ b/app/assets/stylesheets/tags.scss @@ -28,15 +28,3 @@ $colors: "green-tilleul-verveine", color: var(--text-action-high-#{$color}); } } - -.grid-tags { - display: grid; - grid-template-columns: repeat(2, 1fr); -} - -@media (min-width: 62em) { - .grid-tags { - display: grid; - grid-template-columns: repeat(5, 1fr); - } -} diff --git a/app/controllers/administrateurs/procedure_labels_controller.rb b/app/controllers/administrateurs/procedure_labels_controller.rb index de1dc1721..b20ddad6c 100644 --- a/app/controllers/administrateurs/procedure_labels_controller.rb +++ b/app/controllers/administrateurs/procedure_labels_controller.rb @@ -56,7 +56,7 @@ module Administrateurs end def set_colors_collection - @colors_collection = ProcedureLabel.colors.values + @colors_collection = ProcedureLabel.colors.keys end end end diff --git a/app/helpers/dossier_helper.rb b/app/helpers/dossier_helper.rb index ba6a1004c..05d64e6f9 100644 --- a/app/helpers/dossier_helper.rb +++ b/app/helpers/dossier_helper.rb @@ -130,7 +130,7 @@ module DossierHelper end def tag_label(name, color) - tag.span(name, class: "fr-tag fr-tag--sm fr-tag--#{ProcedureLabel.colors.fetch(color.underscore)}") + tag.span(name, class: "fr-tag fr-tag--sm fr-tag--#{ProcedureLabel.class_name(color)}") end def demandeur_dossier(dossier) diff --git a/app/models/label.rb b/app/models/label.rb index 785507a80..ceaef10d1 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -28,4 +28,8 @@ class Label < ApplicationRecord validates :name, :color, presence: true validates :name, length: { maximum: NAME_MAX_LENGTH } + + def self.class_name(color) + ProcedureLabel.colors.fetch(color.underscore) + end end diff --git a/app/views/administrateurs/procedure_labels/_form.html.haml b/app/views/administrateurs/procedure_labels/_form.html.haml index 56d57b6ac..bb8983f39 100644 --- a/app/views/administrateurs/procedure_labels/_form.html.haml +++ b/app/views/administrateurs/procedure_labels/_form.html.haml @@ -6,11 +6,10 @@ = t('activerecord.attributes.procedure_label.color') = asterisk - .grid-tags - - @colors_collection.each do |color| - .fr-fieldset__element - .fr-radio-group - = f.radio_button :color, color, checked: (label.color == color.underscore) - = f.label :color, value: color, class: "fr-label fr-tag fr-tag--sm fr-tag--#{color}" + - @colors_collection.each do |color| + .fr-fieldset__element.fr-fieldset__element--inline + .fr-radio-group + = f.radio_button :color, color, checked: (label.color == color) + = f.label :color, t("activerecord.attributes.label/color.#{color}"), value: color, class: "fr-label fr-tag fr-tag--sm fr-tag--#{ProcedureLabel.class_name(color)}" = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f) diff --git a/config/locales/models/procedure_label/fr.yml b/config/locales/models/procedure_label/fr.yml index deabcbd8e..23e1ac877 100644 --- a/config/locales/models/procedure_label/fr.yml +++ b/config/locales/models/procedure_label/fr.yml @@ -4,3 +4,14 @@ fr: procedure_label: color: Couleur name: Nom + procedure_label/color: &color + green_tilleul_verveine: 'tilleul' + green_bourgeon: 'bourgeon' + green_emeraude: 'émeraude' + green_menthe: 'menthe' + blue_ecume: 'écume' + purple_glycine: 'glycine' + pink_macaron: 'macaron' + yellow_tournesol: 'tournesol' + brown_cafe_creme: 'café' + beige_gris_galet: 'galet' From dcf56616c3b42a0f15418ed730954c65b97277c5 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Tue, 29 Oct 2024 15:39:58 +0100 Subject: [PATCH 1392/1532] rename ProcedureLabel by Label part 2 --- .../labels_component.html.haml | 6 +-- ...els_controller.rb => labels_controller.rb} | 24 ++++++------ app/helpers/dossier_helper.rb | 2 +- app/models/label.rb | 2 +- .../_form.html.haml | 6 +-- .../edit.html.haml | 4 +- .../index.html.haml | 4 +- .../new.html.haml | 4 +- .../models/{procedure_label => label}/fr.yml | 4 +- config/routes.rb | 2 +- ...ller_spec.rb => labels_controller_spec.rb} | 38 +++++++++---------- .../{procedure_label.rb => label.rb} | 2 +- 12 files changed, 49 insertions(+), 49 deletions(-) rename app/controllers/administrateurs/{procedure_labels_controller.rb => labels_controller.rb} (57%) rename app/views/administrateurs/{procedure_labels => labels}/_form.html.haml (78%) rename app/views/administrateurs/{procedure_labels => labels}/edit.html.haml (90%) rename app/views/administrateurs/{procedure_labels => labels}/index.html.haml (93%) rename app/views/administrateurs/{procedure_labels => labels}/new.html.haml (90%) rename config/locales/models/{procedure_label => label}/fr.yml (87%) rename spec/controllers/administrateurs/{procedure_labels_controller_spec.rb => labels_controller_spec.rb} (73%) rename spec/factories/{procedure_label.rb => label.rb} (82%) diff --git a/app/components/procedure/card/labels_component/labels_component.html.haml b/app/components/procedure/card/labels_component/labels_component.html.haml index 78a7cb033..d18982afe 100644 --- a/app/components/procedure/card/labels_component/labels_component.html.haml +++ b/app/components/procedure/card/labels_component/labels_component.html.haml @@ -1,12 +1,12 @@ .fr-col-6.fr-col-md-4.fr-col-lg-3 - = link_to [:admin, @procedure, :procedure_labels], class: 'fr-tile fr-enlarge-link' do + = link_to [:admin, @procedure, :labels], class: 'fr-tile fr-enlarge-link' do .fr-tile__body.flex.column.align-center.justify-between - - if @procedure.procedure_labels.present? + - if @procedure.labels.present? %p.fr-badge.fr-badge--info Configuré %div .line-count.fr-my-1w - %p.fr-tag= @procedure.procedure_labels.size + %p.fr-tag= @procedure.labels.size - else %p.fr-badge Non configuré diff --git a/app/controllers/administrateurs/procedure_labels_controller.rb b/app/controllers/administrateurs/labels_controller.rb similarity index 57% rename from app/controllers/administrateurs/procedure_labels_controller.rb rename to app/controllers/administrateurs/labels_controller.rb index b20ddad6c..e5b987bc8 100644 --- a/app/controllers/administrateurs/procedure_labels_controller.rb +++ b/app/controllers/administrateurs/labels_controller.rb @@ -1,28 +1,28 @@ # frozen_string_literal: true module Administrateurs - class ProcedureLabelsController < AdministrateurController + class LabelsController < AdministrateurController before_action :retrieve_procedure before_action :retrieve_label, only: [:edit, :update, :destroy] before_action :set_colors_collection, only: [:edit, :new, :create, :update] def index - @labels = @procedure.procedure_labels + @labels = @procedure.labels end def edit end def new - @label = ProcedureLabel.new + @label = Label.new end def create - @label = @procedure.procedure_labels.build(procedure_label_params) + @label = @procedure.labels.build(label_params) if @label.save flash.notice = 'Le label a bien été créé' - redirect_to [:admin, @procedure, :procedure_labels] + redirect_to [:admin, @procedure, :labels] else flash.alert = @label.errors.full_messages render :new @@ -30,9 +30,9 @@ module Administrateurs end def update - if @label.update(procedure_label_params) + if @label.update(label_params) flash.notice = 'Le label a bien été modifié' - redirect_to [:admin, @procedure, :procedure_labels] + redirect_to [:admin, @procedure, :labels] else flash.alert = @label.errors.full_messages render :edit @@ -42,21 +42,21 @@ module Administrateurs def destroy @label.destroy! flash.notice = 'Le label a bien été supprimé' - redirect_to [:admin, @procedure, :procedure_labels] + redirect_to [:admin, @procedure, :labels] end private - def procedure_label_params - params.require(:procedure_label).permit(:name, :color) + def label_params + params.require(:label).permit(:name, :color) end def retrieve_label - @label = @procedure.procedure_labels.find(params[:id]) + @label = @procedure.labels.find(params[:id]) end def set_colors_collection - @colors_collection = ProcedureLabel.colors.keys + @colors_collection = Label.colors.keys end end end diff --git a/app/helpers/dossier_helper.rb b/app/helpers/dossier_helper.rb index 05d64e6f9..ab60b1222 100644 --- a/app/helpers/dossier_helper.rb +++ b/app/helpers/dossier_helper.rb @@ -130,7 +130,7 @@ module DossierHelper end def tag_label(name, color) - tag.span(name, class: "fr-tag fr-tag--sm fr-tag--#{ProcedureLabel.class_name(color)}") + tag.span(name, class: "fr-tag fr-tag--sm fr-tag--#{Label.class_name(color)}") end def demandeur_dossier(dossier) diff --git a/app/models/label.rb b/app/models/label.rb index ceaef10d1..aaeeebf0d 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -30,6 +30,6 @@ class Label < ApplicationRecord validates :name, length: { maximum: NAME_MAX_LENGTH } def self.class_name(color) - ProcedureLabel.colors.fetch(color.underscore) + Label.colors.fetch(color.underscore) end end diff --git a/app/views/administrateurs/procedure_labels/_form.html.haml b/app/views/administrateurs/labels/_form.html.haml similarity index 78% rename from app/views/administrateurs/procedure_labels/_form.html.haml rename to app/views/administrateurs/labels/_form.html.haml index bb8983f39..6742b1d35 100644 --- a/app/views/administrateurs/procedure_labels/_form.html.haml +++ b/app/views/administrateurs/labels/_form.html.haml @@ -1,15 +1,15 @@ = form_with model: label, url: [:admin, @procedure, @label], local: true do |f| - = render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field, opts: { maxlength: ProcedureLabel::NAME_MAX_LENGTH}) + = render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field, opts: { maxlength: Label::NAME_MAX_LENGTH}) %fieldset.fr-fieldset %legend.fr-fieldset__legend.fr-fieldset__legend--regular - = t('activerecord.attributes.procedure_label.color') + = t('activerecord.attributes.label.color') = asterisk - @colors_collection.each do |color| .fr-fieldset__element.fr-fieldset__element--inline .fr-radio-group = f.radio_button :color, color, checked: (label.color == color) - = f.label :color, t("activerecord.attributes.label/color.#{color}"), value: color, class: "fr-label fr-tag fr-tag--sm fr-tag--#{ProcedureLabel.class_name(color)}" + = f.label :color, t("activerecord.attributes.label/color.#{color}"), value: color, class: "fr-label fr-tag fr-tag--sm fr-tag--#{Label.class_name(color)}" = render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f) diff --git a/app/views/administrateurs/procedure_labels/edit.html.haml b/app/views/administrateurs/labels/edit.html.haml similarity index 90% rename from app/views/administrateurs/procedure_labels/edit.html.haml rename to app/views/administrateurs/labels/edit.html.haml index 695ba74a0..8f3784ca4 100644 --- a/app/views/administrateurs/procedure_labels/edit.html.haml +++ b/app/views/administrateurs/labels/edit.html.haml @@ -3,14 +3,14 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['gestion des labels', [:admin, @procedure, :procedure_labels]], + ['gestion des labels', [:admin, @procedure, :labels]], ['Modifier le label']] } .fr-container .fr-mb-3w = link_to "Liste de tous les labels", - [:admin, @procedure, :procedure_labels], + [:admin, @procedure, :labels], class: "fr-link fr-icon-arrow-left-line fr-link--icon-left" %h1.fr-h2 diff --git a/app/views/administrateurs/procedure_labels/index.html.haml b/app/views/administrateurs/labels/index.html.haml similarity index 93% rename from app/views/administrateurs/procedure_labels/index.html.haml rename to app/views/administrateurs/labels/index.html.haml index 7a124da3c..f0dc512f5 100644 --- a/app/views/administrateurs/procedure_labels/index.html.haml +++ b/app/views/administrateurs/labels/index.html.haml @@ -9,10 +9,10 @@ %h1.fr-h2 Labels = link_to "Nouveau label", - [:new, :admin, @procedure, :procedure_label], + [:new, :admin, @procedure, :label], class: "fr-btn fr-btn--primary fr-btn--icon-left fr-icon-add-circle-line mb-3" - - if @procedure.procedure_labels.present? + - if @procedure.labels.present? .fr-table.fr-table--layout-fixed.fr-table--bordered %table %caption Liste des labels diff --git a/app/views/administrateurs/procedure_labels/new.html.haml b/app/views/administrateurs/labels/new.html.haml similarity index 90% rename from app/views/administrateurs/procedure_labels/new.html.haml rename to app/views/administrateurs/labels/new.html.haml index 46e9c11d0..fc4713cd3 100644 --- a/app/views/administrateurs/procedure_labels/new.html.haml +++ b/app/views/administrateurs/labels/new.html.haml @@ -3,14 +3,14 @@ = render partial: 'administrateurs/breadcrumbs', locals: { steps: [['Démarches', admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], - ['gestion des labels', [:admin, @procedure, :procedure_labels]], + ['gestion des labels', [:admin, @procedure, :labels]], ['Nouveau label']] } .fr-container .fr-mb-3w = link_to "Liste de tous les labels", - [:admin, @procedure, :procedure_labels], + [:admin, @procedure, :labels], class: "fr-link fr-icon-arrow-left-line fr-link--icon-left" %h1.fr-h2 diff --git a/config/locales/models/procedure_label/fr.yml b/config/locales/models/label/fr.yml similarity index 87% rename from config/locales/models/procedure_label/fr.yml rename to config/locales/models/label/fr.yml index 23e1ac877..ca20608be 100644 --- a/config/locales/models/procedure_label/fr.yml +++ b/config/locales/models/label/fr.yml @@ -1,10 +1,10 @@ fr: activerecord: attributes: - procedure_label: + label: color: Couleur name: Nom - procedure_label/color: &color + label/color: &color green_tilleul_verveine: 'tilleul' green_bourgeon: 'bourgeon' green_emeraude: 'émeraude' diff --git a/config/routes.rb b/config/routes.rb index 0bf0c4602..ed06d5b7b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -708,7 +708,7 @@ Rails.application.routes.draw do get 'preview', on: :member end - resources :procedure_labels, controller: 'procedure_labels' + resources :labels, controller: 'labels' resource :attestation_template, only: [:show, :edit, :update, :create] do get 'preview', on: :member diff --git a/spec/controllers/administrateurs/procedure_labels_controller_spec.rb b/spec/controllers/administrateurs/labels_controller_spec.rb similarity index 73% rename from spec/controllers/administrateurs/procedure_labels_controller_spec.rb rename to spec/controllers/administrateurs/labels_controller_spec.rb index 4d93b4271..55b68a9f3 100644 --- a/spec/controllers/administrateurs/procedure_labels_controller_spec.rb +++ b/spec/controllers/administrateurs/labels_controller_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe Administrateurs::ProcedureLabelsController, type: :controller do +describe Administrateurs::LabelsController, type: :controller do let(:admin) { administrateurs(:default_admin) } let(:procedure) { create(:procedure, administrateur: admin) } let(:admin_2) { create(:administrateur) } @@ -8,9 +8,9 @@ describe Administrateurs::ProcedureLabelsController, type: :controller do describe '#index' do render_views - let!(:label_1) { create(:procedure_label, procedure:) } - let!(:label_2) { create(:procedure_label, procedure:) } - let!(:label_3) { create(:procedure_label, procedure:) } + let!(:label_1) { create(:label, procedure:) } + let!(:label_2) { create(:label, procedure:) } + let!(:label_3) { create(:label, procedure:) } before do sign_in(admin.user) @@ -36,7 +36,7 @@ describe Administrateurs::ProcedureLabelsController, type: :controller do context 'when submitting a new label' do let(:params) do { - procedure_label: { + label: { name: 'Nouveau label', color: 'green-bourgeon' }, @@ -44,22 +44,22 @@ describe Administrateurs::ProcedureLabelsController, type: :controller do } end - it { expect { subject }.to change { ProcedureLabel.count } .by(1) } + it { expect { subject }.to change { Label.count } .by(1) } it 'creates a new label' do subject expect(flash.alert).to be_nil expect(flash.notice).to eq('Le label a bien été créé') - expect(ProcedureLabel.last.name).to eq('Nouveau label') - expect(ProcedureLabel.last.color).to eq('green_bourgeon') - expect(procedure.procedure_labels.last).to eq(ProcedureLabel.last) + expect(Label.last.name).to eq('Nouveau label') + expect(Label.last.color).to eq('green_bourgeon') + expect(procedure.labels.last).to eq(Label.last) end end context 'when submitting an invalid label' do - let(:params) { { procedure_label: { name: 'Nouveau label' }, procedure_id: procedure.id } } + let(:params) { { label: { name: 'Nouveau label' }, procedure_id: procedure.id } } - it { expect { subject }.not_to change { ProcedureLabel.count } } + it { expect { subject }.not_to change { Label.count } } it 'does not create a new label' do subject @@ -72,7 +72,7 @@ describe Administrateurs::ProcedureLabelsController, type: :controller do context 'when submitting a label for a not own procedure' do let(:params) do { - procedure_label: { + label: { name: 'Nouveau label', color: 'green-bourgeon' }, @@ -80,7 +80,7 @@ describe Administrateurs::ProcedureLabelsController, type: :controller do } end - it { expect { subject }.not_to change { ProcedureLabel.count } } + it { expect { subject }.not_to change { Label.count } } it 'does not create a new label' do subject @@ -91,9 +91,9 @@ describe Administrateurs::ProcedureLabelsController, type: :controller do end describe '#update' do - let!(:label) { create(:procedure_label, procedure:) } + let!(:label) { create(:label, procedure:) } let(:label_params) { { name: 'Nouveau nom' } } - let(:params) { { id: label.id, procedure_label: label_params, procedure_id: procedure.id } } + let(:params) { { id: label.id, label: label_params, procedure_id: procedure.id } } before do sign_in(admin.user) @@ -109,7 +109,7 @@ describe Administrateurs::ProcedureLabelsController, type: :controller do expect(label.reload.name).to eq('Nouveau nom') expect(label.reload.color).to eq('green_bourgeon') expect(label.reload.updated_at).not_to eq(label.reload.created_at) - expect(response).to redirect_to(admin_procedure_procedure_labels_path(procedure_id: procedure.id)) + expect(response).to redirect_to(admin_procedure_labels_path(procedure_id: procedure.id)) end end @@ -125,7 +125,7 @@ describe Administrateurs::ProcedureLabelsController, type: :controller do end context 'when updating a label for a not own procedure' do - let(:params) { { id: label.id, procedure_label: label_params, procedure_id: procedure_2.id } } + let(:params) { { id: label.id, label: label_params, procedure_id: procedure_2.id } } it 'does not update' do subject @@ -135,7 +135,7 @@ describe Administrateurs::ProcedureLabelsController, type: :controller do end describe '#destroy' do - let(:label) { create(:procedure_label, procedure:) } + let(:label) { create(:label, procedure:) } before do sign_in(admin.user) @@ -150,7 +150,7 @@ describe Administrateurs::ProcedureLabelsController, type: :controller do subject expect { label.reload }.to raise_error(ActiveRecord::RecordNotFound) expect(flash.notice).to eq('Le label a bien été supprimé') - expect(response).to redirect_to((admin_procedure_procedure_labels_path(procedure_id: procedure.id))) + expect(response).to redirect_to(admin_procedure_labels_path(procedure_id: procedure.id)) end end diff --git a/spec/factories/procedure_label.rb b/spec/factories/label.rb similarity index 82% rename from spec/factories/procedure_label.rb rename to spec/factories/label.rb index fbc907b14..feeae49d1 100644 --- a/spec/factories/procedure_label.rb +++ b/spec/factories/label.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :procedure_label do + factory :label do name { 'Un label' } color { 'green-bourgeon' } association :procedure From f59834015fb99cf522b0e4c419c05099b8f12829 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Mon, 4 Nov 2024 15:37:52 +0100 Subject: [PATCH 1393/1532] fix(RNF verification) Return search results to assistive technology --- .../editable_champ/rnf_component/rnf_component.en.yml | 2 +- .../editable_champ/rnf_component/rnf_component.fr.yml | 2 +- .../editable_champ/rnf_component/rnf_component.html.haml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/editable_champ/rnf_component/rnf_component.en.yml b/app/components/editable_champ/rnf_component/rnf_component.en.yml index 24faeae48..5221ee0d0 100644 --- a/app/components/editable_champ/rnf_component/rnf_component.en.yml +++ b/app/components/editable_champ/rnf_component/rnf_component.en.yml @@ -2,4 +2,4 @@ en: rnf_info_error: No foundation found rnf_info_pending: RNF verification pending - rnf_info_success: "This RNF matches %{title}, %{address}" + rnf_info_success: "This RNF matches: %{title}, %{address}" diff --git a/app/components/editable_champ/rnf_component/rnf_component.fr.yml b/app/components/editable_champ/rnf_component/rnf_component.fr.yml index 6c1441985..0a334be79 100644 --- a/app/components/editable_champ/rnf_component/rnf_component.fr.yml +++ b/app/components/editable_champ/rnf_component/rnf_component.fr.yml @@ -2,4 +2,4 @@ fr: rnf_info_error: Aucune fondation trouvée rnf_info_pending: Vérification du RNF en cours - rnf_info_success: "Ce RNF correspond à %{title}, %{address}" + rnf_info_success: "Ce RNF correspond à : %{title}, %{address}" diff --git a/app/components/editable_champ/rnf_component/rnf_component.html.haml b/app/components/editable_champ/rnf_component/rnf_component.html.haml index c8685d8ca..1a1ef7977 100644 --- a/app/components/editable_champ/rnf_component/rnf_component.html.haml +++ b/app/components/editable_champ/rnf_component/rnf_component.html.haml @@ -1,6 +1,6 @@ = @form.text_field :external_id, input_opts(id: @champ.input_id, required: @champ.required?, class: "width-33-desktop fr-input small-margin", aria: { describedby: @champ.describedby_id }) -.rnf-info{ id: dom_id(@champ, :rnf_info) } +.rnf-info{ id: dom_id(@champ, :rnf_info), role: 'status' } - if @champ.fetch_external_data_error? %p.fr-error-text= t('.rnf_info_error') - elsif @champ.fetch_external_data_pending? From 2b3ad15f07af9ddedef33d6cfc8bc025d2004557 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Mon, 4 Nov 2024 15:29:09 +0100 Subject: [PATCH 1394/1532] fix(fr-hint-text) Ensures that screen readers can read the hyphens in the RNF example --- config/locales/models/champs/rnf_champ/en.yml | 2 +- config/locales/models/champs/rnf_champ/fr.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/locales/models/champs/rnf_champ/en.yml b/config/locales/models/champs/rnf_champ/en.yml index 8050acc4b..7b65475ef 100644 --- a/config/locales/models/champs/rnf_champ/en.yml +++ b/config/locales/models/champs/rnf_champ/en.yml @@ -18,4 +18,4 @@ en: attributes: champs/rnf_champ: hints: - value: "Expected format : 075-FDD-00003-01" + value_html: "Expected format: 075 hyphen FDD hyphen 00003 hyphen 01" diff --git a/config/locales/models/champs/rnf_champ/fr.yml b/config/locales/models/champs/rnf_champ/fr.yml index a847d6e52..7d95e7f71 100644 --- a/config/locales/models/champs/rnf_champ/fr.yml +++ b/config/locales/models/champs/rnf_champ/fr.yml @@ -18,4 +18,4 @@ fr: attributes: champs/rnf_champ: hints: - value: "Format attendu : 075-FDD-00003-01" + value_html: "Format attendu : 075 tiret FDD tiret 00003 tiret 01" From a3c3db5434c921fd4b41e076158a6ba8bf16afd2 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Mon, 4 Nov 2024 15:58:30 +0100 Subject: [PATCH 1395/1532] fix(outdated code) Remove useless class --- .../editable_champ/rnf_component/rnf_component.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/editable_champ/rnf_component/rnf_component.html.haml b/app/components/editable_champ/rnf_component/rnf_component.html.haml index 1a1ef7977..ea5da960c 100644 --- a/app/components/editable_champ/rnf_component/rnf_component.html.haml +++ b/app/components/editable_champ/rnf_component/rnf_component.html.haml @@ -1,4 +1,4 @@ -= @form.text_field :external_id, input_opts(id: @champ.input_id, required: @champ.required?, class: "width-33-desktop fr-input small-margin", aria: { describedby: @champ.describedby_id }) += @form.text_field :external_id, input_opts(id: @champ.input_id, required: @champ.required?, class: "width-33-desktop fr-input", aria: { describedby: @champ.describedby_id }) .rnf-info{ id: dom_id(@champ, :rnf_info), role: 'status' } - if @champ.fetch_external_data_error? From d1c9da0a0a592a96fc348e27f48bc56e86f3810d Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Mon, 4 Nov 2024 16:33:58 +0100 Subject: [PATCH 1396/1532] fix(RNA) Add translation of missing error message --- app/views/shared/champs/rna/_association.html.haml | 3 +-- config/locales/shared.en.yml | 3 ++- config/locales/shared.fr.yml | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/views/shared/champs/rna/_association.html.haml b/app/views/shared/champs/rna/_association.html.haml index f0520db90..9fae5d37e 100644 --- a/app/views/shared/champs/rna/_association.html.haml +++ b/app/views/shared/champs/rna/_association.html.haml @@ -1,7 +1,6 @@ - case error - when :invalid - %p.fr-error-text - Le numéro RNA doit commencer par un W majuscule suivi de 9 chiffres ou lettres + %p.fr-error-text= t('.invalid_number') - when :not_found %p.fr-error-text= t('.not_found') - when :network_error diff --git a/config/locales/shared.en.yml b/config/locales/shared.en.yml index 044deec35..8f057684d 100644 --- a/config/locales/shared.en.yml +++ b/config/locales/shared.en.yml @@ -28,9 +28,10 @@ en: not_filled: not filled not_found: "RNA number %{rna} (no association found)" association: - data_fetched: "This RNA number is linked to %{title}, %{address}" + data_fetched: "This RNA number is linked to: %{title}, %{address}" not_found: "No association found" network_error: "A network error has prevented the association associated with this RNA to be fetched" + invalid_number: "The RNA number must begin with a capital W followed by 9 digits or letters" rnf: show: not_found: "RNF %{rnf} (no foundation found)" diff --git a/config/locales/shared.fr.yml b/config/locales/shared.fr.yml index fbca54f2d..968309730 100644 --- a/config/locales/shared.fr.yml +++ b/config/locales/shared.fr.yml @@ -30,9 +30,10 @@ fr: not_filled: non renseigné not_found: "RNA %{rna} (aucun établissement trouvé)" association: - data_fetched: "Ce RNA correspond à %{title}, %{address}" + data_fetched: "Ce RNA correspond à : %{title}, %{address}" not_found: "Aucun établissement trouvé" network_error: "Une erreur réseau a empêché l’association liée à ce RNA d’être trouvée" + invalid_number: "Le numéro RNA doit commencer par un W majuscule suivi de 9 chiffres ou lettres" rnf: show: not_found: "RNF %{rnf} (aucune fondation trouvée)" From d03d5d0daee0281e00d2be5d839e80f583574188 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 31 Oct 2024 18:29:23 +0100 Subject: [PATCH 1397/1532] add ChampColumn --- app/models/column.rb | 89 --------------- app/models/columns/champ_column.rb | 105 ++++++++++++++++++ .../types_de_champ/type_de_champ_base.rb | 5 +- .../column_filter_value_component_spec.rb | 2 +- spec/models/column_spec.rb | 91 --------------- spec/models/columns/champ_column_spec.rb | 61 ++++++++++ spec/models/columns/dossier_column_spec.rb | 39 +++++++ 7 files changed, 208 insertions(+), 184 deletions(-) create mode 100644 app/models/columns/champ_column.rb create mode 100644 spec/models/columns/champ_column_spec.rb create mode 100644 spec/models/columns/dossier_column_spec.rb diff --git a/app/models/column.rb b/app/models/column.rb index c4d54b6c5..87d53aae1 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -52,96 +52,7 @@ class Column procedure.find_column(h_id: h_id) end - def value(champ) - return if champ.nil? - - value = typed_value(champ) - if default_column? - cast_value(value, from_type: champ.last_write_column_type, to_type: type) - else - value - end - end - private def column_id = "#{table}/#{column}" - - def typed_value(champ) - value = string_value(champ) - parse_value(value, type: champ.last_write_column_type) - end - - def string_value(champ) = champ.public_send(value_column) - def default_column? = value_column.in?([:value, :external_id]) - - def parse_value(value, type:) - return if value.blank? - - case type - when :boolean - parse_boolean(value) - when :integer - value.to_i - when :decimal - value.to_f - when :datetime - parse_datetime(value) - when :date - parse_datetime(value)&.to_date - when :enums - parse_enums(value) - else - value - end - end - - def cast_value(value, from_type:, to_type:) - return if value.blank? - return value if from_type == to_type - - case [from_type, to_type] - when [:integer, :decimal] # recast numbers automatically - value.to_f - when [:decimal, :integer] # may lose some data, but who cares ? - value.to_i - when [:integer, :text], [:decimal, :text] # number to text - value.to_s - when [:enum, :enums] # single list can become multi - [value] - when [:enum, :text] # single list can become text - value - when [:enums, :enum] # multi list can become single list - value.first - when [:enums, :text] # multi list can become text - value.join(', ') - when [:date, :datetime] # date <=> datetime - value.to_datetime - when [:datetime, :date] # may lose some data, but who cares ? - value.to_date - else - nil - end - end - - def parse_boolean(value) - case value - when 'true', 'on', '1' - true - when 'false' - false - end - end - - def parse_enums(value) - JSON.parse(value) - rescue JSON::ParserError - nil - end - - def parse_datetime(value) - Time.zone.parse(value) - rescue ArgumentError - nil - end end diff --git a/app/models/columns/champ_column.rb b/app/models/columns/champ_column.rb new file mode 100644 index 000000000..e60472284 --- /dev/null +++ b/app/models/columns/champ_column.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +class Columns::ChampColumn < Column + attr_reader :stable_id + + def initialize(procedure_id:, label:, stable_id:, displayable: true, filterable: true, type: :text, value_column: :value) + @stable_id = stable_id + + super( + procedure_id:, + table: 'type_de_champ', + column: stable_id.to_s, + label:, + type:, + value_column:, + displayable:, + filterable: + ) + end + + def value(champ) + return if champ.nil? + + value = typed_value(champ) + if default_column? + cast_value(value, from_type: champ.last_write_column_type, to_type: type) + else + value + end + end + + private + + def column_id = "type_de_champ/#{stable_id}" + + def typed_value(champ) + value = string_value(champ) + parse_value(value, type: champ.last_write_column_type) + end + + def string_value(champ) = champ.public_send(value_column) + def default_column? = value_column.in?([:value, :external_id]) + + def parse_value(value, type:) + return if value.blank? + + case type + when :boolean + parse_boolean(value) + when :integer + value.to_i + when :decimal + value.to_f + when :datetime + parse_datetime(value) + when :date + parse_datetime(value)&.to_date + when :enums + parse_enums(value) + else + value + end + end + + def cast_value(value, from_type:, to_type:) + return if value.blank? + return value if from_type == to_type + + case [from_type, to_type] + when [:integer, :decimal] # recast numbers automatically + value.to_f + when [:decimal, :integer] # may lose some data, but who cares ? + value.to_i + when [:integer, :text], [:decimal, :text] # number to text + value.to_s + when [:enum, :enums] # single list can become multi + [value] + when [:enum, :text] # single list can become text + value + when [:enums, :enum] # multi list can become single list + value.first + when [:enums, :text] # multi list can become text + value.join(', ') + when [:date, :datetime] # date <=> datetime + value.to_datetime + when [:datetime, :date] # may lose some data, but who cares ? + value.to_date + else + nil + end + end + + def parse_boolean(value) + case value + when 'true', 'on', '1' + true + when 'false' + false + end + end + + def parse_enums(value) = JSON.parse(value) rescue nil + + def parse_datetime(value) = Time.zone.parse(value) rescue nil +end diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index 336193dea..ec5940e2a 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -98,10 +98,9 @@ class TypesDeChamp::TypeDeChampBase def columns(procedure_id:, displayable: true, prefix: nil) if fillable? [ - Column.new( + Columns::ChampColumn.new( procedure_id:, - table: Column::TYPE_DE_CHAMP_TABLE, - column: stable_id.to_s, + stable_id: stable_id, label: libelle_with_prefix(prefix), type: TypeDeChamp.column_type(type_champ), value_column: TypeDeChamp.value_column(type_champ), diff --git a/spec/components/instructeurs/column_filter_value_component_spec.rb b/spec/components/instructeurs/column_filter_value_component_spec.rb index fd9a1f9a9..11452b963 100644 --- a/spec/components/instructeurs/column_filter_value_component_spec.rb +++ b/spec/components/instructeurs/column_filter_value_component_spec.rb @@ -25,7 +25,7 @@ describe Instructeurs::ColumnFilterValueComponent, type: :component do let(:types_de_champ_public) { [{ type: :drop_down_list, libelle: 'Votre ville', options: ['Paris', 'Lyon', 'Marseille'] }] } let(:procedure) { create(:procedure, :published, types_de_champ_public:) } let(:drop_down_stable_id) { procedure.active_revision.types_de_champ.first.stable_id } - let(:column) { Column.new(procedure_id:, table: 'type_de_champ', scope: nil, column: drop_down_stable_id) } + let(:column) { procedure.find_column(label: 'Votre ville') } it 'find most recent tdc' do is_expected.to eq(['Paris', 'Lyon', 'Marseille']) diff --git a/spec/models/column_spec.rb b/spec/models/column_spec.rb index 74634c718..7b1f4b3d9 100644 --- a/spec/models/column_spec.rb +++ b/spec/models/column_spec.rb @@ -1,95 +1,4 @@ # frozen_string_literal: true describe Column do - describe 'value' do - let(:groupe_instructeur) { create(:groupe_instructeur, instructeurs: [create(:instructeur)]) } - - context 'when dossier columns' do - context 'when procedure for individual' do - let(:individual) { create(:individual, nom: "Sim", prenom: "Paul", gender: 'M.') } - let(:procedure) { create(:procedure, for_individual: true, groupe_instructeurs: [groupe_instructeur]) } - let(:dossier) { create(:dossier, individual:, mandataire_first_name: "Martin", mandataire_last_name: "Christophe", for_tiers: true) } - - it 'retrieve individual information' do - expect(procedure.find_column(label: "Prénom").value(dossier)).to eq("Paul") - expect(procedure.find_column(label: "Nom").value(dossier)).to eq("Sim") - expect(procedure.find_column(label: "Civilité").value(dossier)).to eq("M.") - end - end - - context 'when procedure for entreprise' do - let(:procedure) { create(:procedure, for_individual: false, groupe_instructeurs: [groupe_instructeur]) } - let(:dossier) { create(:dossier, :en_instruction, :with_entreprise, procedure:) } - - it 'retrieve entreprise information' do - expect(procedure.find_column(label: "Libellé NAF").value(dossier)).to eq('Transports par conduites') - end - end - - context 'when sva/svr enabled' do - let(:procedure) { create(:procedure, :sva, for_individual: true, groupe_instructeurs: [groupe_instructeur]) } - let(:dossier) { create(:dossier, :en_instruction, procedure:) } - - it 'does not fail' do - expect(procedure.find_column(label: "Date décision SVA").value(dossier)).to eq(nil) - end - end - end - - context 'when champ columns' do - let(:procedure) { create(:procedure, :with_all_champs_mandatory, groupe_instructeurs: [groupe_instructeur]) } - let(:dossier) { create(:dossier, :with_populated_champs, procedure:) } - let(:types_de_champ) { procedure.all_revisions_types_de_champ } - - it 'extracts values for columns and type de champ' do - expect_type_de_champ_values('civilite', ["M."]) - expect_type_de_champ_values('email', ['yoda@beta.gouv.fr']) - expect_type_de_champ_values('phone', ['0666666666']) - expect_type_de_champ_values('address', ["2 rue des Démarches"]) - expect_type_de_champ_values('communes', ["Coye-la-Forêt"]) - expect_type_de_champ_values('departements', ['01']) - expect_type_de_champ_values('regions', ['01']) - expect_type_de_champ_values('pays', ['France']) - expect_type_de_champ_values('epci', [nil]) - expect_type_de_champ_values('iban', [nil]) - expect_type_de_champ_values('siret', ["44011762001530", "postal_code", "city_name", "departement_code", "region_name"]) - expect_type_de_champ_values('text', ['text']) - expect_type_de_champ_values('textarea', ['textarea']) - expect_type_de_champ_values('number', ['42']) - expect_type_de_champ_values('decimal_number', [42.1]) - expect_type_de_champ_values('integer_number', [42]) - expect_type_de_champ_values('date', [Time.zone.parse('2019-07-10').to_date]) - expect_type_de_champ_values('datetime', [Time.zone.parse("1962-09-15T15:35:00+01:00")]) - expect_type_de_champ_values('checkbox', [true]) - expect_type_de_champ_values('drop_down_list', ['val1']) - expect_type_de_champ_values('multiple_drop_down_list', [["val1", "val2"]]) - expect_type_de_champ_values('linked_drop_down_list', [nil, "categorie 1", "choix 1"]) - expect_type_de_champ_values('yes_no', [true]) - expect_type_de_champ_values('annuaire_education', [nil]) - expect_type_de_champ_values('carte', []) - expect_type_de_champ_values('piece_justificative', []) - expect_type_de_champ_values('titre_identite', [true]) - expect_type_de_champ_values('cnaf', [nil]) - expect_type_de_champ_values('dgfip', [nil]) - expect_type_de_champ_values('pole_emploi', [nil]) - expect_type_de_champ_values('mesri', [nil]) - expect_type_de_champ_values('cojo', [nil]) - expect_type_de_champ_values('expression_reguliere', [nil]) - end - end - end - - private - - def expect_type_de_champ_values(type, values) - type_de_champ = types_de_champ.find { _1.type_champ == type } - champ = dossier.send(:filled_champ, type_de_champ, nil) - columns = type_de_champ.columns(procedure_id: procedure.id) - expect(columns.map { _1.value(champ) }).to eq(values) - end - - def retrieve_champ(type) - type_de_champ = types_de_champ.find { _1.type_champ == type } - dossier.send(:filled_champ, type_de_champ, nil) - end end diff --git a/spec/models/columns/champ_column_spec.rb b/spec/models/columns/champ_column_spec.rb new file mode 100644 index 000000000..effaf0914 --- /dev/null +++ b/spec/models/columns/champ_column_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +describe Columns::ChampColumn do + describe '#value' do + context 'when champ columns' do + let(:procedure) { create(:procedure, :with_all_champs_mandatory) } + let(:dossier) { create(:dossier, :with_populated_champs, procedure:) } + let(:types_de_champ) { procedure.all_revisions_types_de_champ } + + it 'extracts values for columns and type de champ' do + expect_type_de_champ_values('civilite', ["M."]) + expect_type_de_champ_values('email', ['yoda@beta.gouv.fr']) + expect_type_de_champ_values('phone', ['0666666666']) + expect_type_de_champ_values('address', ["2 rue des Démarches"]) + expect_type_de_champ_values('communes', ["Coye-la-Forêt"]) + expect_type_de_champ_values('departements', ['01']) + expect_type_de_champ_values('regions', ['01']) + expect_type_de_champ_values('pays', ['France']) + expect_type_de_champ_values('epci', [nil]) + expect_type_de_champ_values('iban', [nil]) + expect_type_de_champ_values('siret', ["44011762001530", "postal_code", "city_name", "departement_code", "region_name"]) + expect_type_de_champ_values('text', ['text']) + expect_type_de_champ_values('textarea', ['textarea']) + expect_type_de_champ_values('number', ['42']) + expect_type_de_champ_values('decimal_number', [42.1]) + expect_type_de_champ_values('integer_number', [42]) + expect_type_de_champ_values('date', [Time.zone.parse('2019-07-10').to_date]) + expect_type_de_champ_values('datetime', [Time.zone.parse("1962-09-15T15:35:00+01:00")]) + expect_type_de_champ_values('checkbox', [true]) + expect_type_de_champ_values('drop_down_list', ['val1']) + expect_type_de_champ_values('multiple_drop_down_list', [["val1", "val2"]]) + expect_type_de_champ_values('linked_drop_down_list', [nil, "categorie 1", "choix 1"]) + expect_type_de_champ_values('yes_no', [true]) + expect_type_de_champ_values('annuaire_education', [nil]) + expect_type_de_champ_values('carte', []) + expect_type_de_champ_values('piece_justificative', []) + expect_type_de_champ_values('titre_identite', [true]) + expect_type_de_champ_values('cnaf', [nil]) + expect_type_de_champ_values('dgfip', [nil]) + expect_type_de_champ_values('pole_emploi', [nil]) + expect_type_de_champ_values('mesri', [nil]) + expect_type_de_champ_values('cojo', [nil]) + expect_type_de_champ_values('expression_reguliere', [nil]) + end + end + end + + private + + def expect_type_de_champ_values(type, values) + type_de_champ = types_de_champ.find { _1.type_champ == type } + champ = dossier.send(:filled_champ, type_de_champ, nil) + columns = type_de_champ.columns(procedure_id: procedure.id) + expect(columns.map { _1.value(champ) }).to eq(values) + end + + def retrieve_champ(type) + type_de_champ = types_de_champ.find { _1.type_champ == type } + dossier.send(:filled_champ, type_de_champ, nil) + end +end diff --git a/spec/models/columns/dossier_column_spec.rb b/spec/models/columns/dossier_column_spec.rb new file mode 100644 index 000000000..5b8651d2a --- /dev/null +++ b/spec/models/columns/dossier_column_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +describe Columns::DossierColumn do + describe 'value' do + let(:groupe_instructeur) { create(:groupe_instructeur, instructeurs: [create(:instructeur)]) } + + context 'when dossier columns' do + context 'when procedure for individual' do + let(:individual) { create(:individual, nom: "Sim", prenom: "Paul", gender: 'M.') } + let(:procedure) { create(:procedure, for_individual: true, groupe_instructeurs: [groupe_instructeur]) } + let(:dossier) { create(:dossier, individual:, mandataire_first_name: "Martin", mandataire_last_name: "Christophe", for_tiers: true) } + + it 'retrieve individual information' do + expect(procedure.find_column(label: "Prénom").value(dossier)).to eq("Paul") + expect(procedure.find_column(label: "Nom").value(dossier)).to eq("Sim") + expect(procedure.find_column(label: "Civilité").value(dossier)).to eq("M.") + end + end + + context 'when procedure for entreprise' do + let(:procedure) { create(:procedure, for_individual: false, groupe_instructeurs: [groupe_instructeur]) } + let(:dossier) { create(:dossier, :en_instruction, :with_entreprise, procedure:) } + + it 'retrieve entreprise information' do + expect(procedure.find_column(label: "Libellé NAF").value(dossier)).to eq('Transports par conduites') + end + end + + context 'when sva/svr enabled' do + let(:procedure) { create(:procedure, :sva, for_individual: true, groupe_instructeurs: [groupe_instructeur]) } + let(:dossier) { create(:dossier, :en_instruction, procedure:) } + + it 'does not fail' do + expect(procedure.find_column(label: "Date décision SVA").value(dossier)).to eq(nil) + end + end + end + end +end From a4617abb0ed855ad5e5756de6a9011c632f31980 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 4 Nov 2024 10:18:07 +0100 Subject: [PATCH 1398/1532] rework JSONPathColumn to be a ChampColumn --- app/models/columns/json_path_column.rb | 58 ++++++++----------- .../concerns/addressable_column_concern.rb | 19 +++--- config/brakeman.ignore | 48 +++++++-------- spec/models/columns/json_path_column_spec.rb | 47 +++++++++++++++ spec/services/dossier_filter_service_spec.rb | 2 +- 5 files changed, 105 insertions(+), 69 deletions(-) create mode 100644 spec/models/columns/json_path_column_spec.rb diff --git a/app/models/columns/json_path_column.rb b/app/models/columns/json_path_column.rb index f78585688..523b065ff 100644 --- a/app/models/columns/json_path_column.rb +++ b/app/models/columns/json_path_column.rb @@ -1,19 +1,32 @@ # frozen_string_literal: true -class Columns::JSONPathColumn < Column - def column - "#{@column}->#{value_column}" # override column otherwise json path facets will have same id as other +class Columns::JSONPathColumn < Columns::ChampColumn + attr_reader :jsonpath + + def initialize(procedure_id:, label:, stable_id:, jsonpath:, displayable:, type: :text) + @jsonpath = quote_string(jsonpath) + + super( + procedure_id:, + label:, + stable_id:, + displayable:, + type: + ) end - def filtered_ids(dossiers, search_occurences) - queries = Array.new(search_occurences.count, "(#{json_path_query_part} ILIKE ?)").join(' OR ') + def filtered_ids(dossiers, search_terms) + value = quote_string(search_terms.join('|')) + + condition = %{champs.value_json @? '#{jsonpath} ? (@ like_regex "#{value}" flag "i")'} + dossiers.with_type_de_champ(stable_id) - .where(queries, *(search_occurences.map { |value| "%#{value}%" })) + .where(condition) .ids end def options_for_select - case value_column.last + case jsonpath.split('.').last when 'departement_code' APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } when 'region_name' @@ -25,34 +38,11 @@ class Columns::JSONPathColumn < Column private + def column_id = "type_de_champ/#{stable_id}-#{jsonpath}" + def typed_value(champ) - champ.value_json&.dig(*value_column) + champ.value_json&.dig(*jsonpath.split('.')[1..]) end - def stable_id - @column - end - - # given a value_column as ['value_json', 'address', 'postal_code'] - # build SQL query as 'champs'.'value_json'->'address'->>'postal_code' - # see: https://www.postgresql.org/docs/9.5/functions-json.html - def json_path_query_part - *json_segments, key = value_column - - if json_segments.blank? # not nested, only access using ->> Get JSON array element as text - "#{quote_table_column('champs')}.#{quote_table_column('value_json')}->>#{quote_json_segment(key)}" - else # nested, have to dig in json using -> Get JSON object field by key - field_accessor = json_segments.map(&method(:quote_json_segment)).join('->') - - "#{quote_table_column('champs')}.#{quote_table_column('value_json')}->#{field_accessor}->>#{quote_json_segment(key)}" - end - end - - def quote_table_column(table_or_column) - ActiveRecord::Base.connection.quote_column_name(table_or_column) - end - - def quote_json_segment(path) - "'#{path}'" - end + def quote_string(string) = ActiveRecord::Base.connection.quote_string(string) end diff --git a/app/models/concerns/addressable_column_concern.rb b/app/models/concerns/addressable_column_concern.rb index b439d50bc..23c80acb7 100644 --- a/app/models/concerns/addressable_column_concern.rb +++ b/app/models/concerns/addressable_column_concern.rb @@ -6,19 +6,18 @@ module AddressableColumnConcern included do def columns(procedure_id:, displayable: true, prefix: nil) super.concat([ - ["code postal (5 chiffres)", ['postal_code'], :text], - ["commune", ['city_name'], :text], - ["département", ['departement_code'], :enum], - ["region", ['region_name'], :enum] - ].map do |(label, value_column, type)| + ["code postal (5 chiffres)", '$.postal_code', :text], + ["commune", '$.city_name', :text], + ["département", '$.departement_code', :enum], + ["region", '$.region_name', :enum] + ].map do |(label, jsonpath, type)| Columns::JSONPathColumn.new( procedure_id:, - table: Column::TYPE_DE_CHAMP_TABLE, - column: stable_id, + stable_id:, label: "#{libelle_with_prefix(prefix)} – #{label}", - displayable: false, - type:, - value_column: + jsonpath:, + displayable:, + type: ) end) end diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 753bf47e5..78ea26d4c 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -67,29 +67,6 @@ ], "note": "filtered by rails query params where(something: ?, values)" }, - { - "warning_type": "SQL Injection", - "warning_code": 0, - "fingerprint": "5ba3f5d525b15c710215829e0db49f58e8cca06d68eff5931ebfd7d0ca0e35de", - "check_name": "SQL", - "message": "Possible SQL injection", - "file": "app/models/columns/json_path_column.rb", - "line": 11, - "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", - "code": "dossiers.with_type_de_champ(stable_id).where(\"#{search_occurences.count} OR #{\"(#{json_path_query_part} ILIKE ?)\"}\", *search_occurences.map do\n \"%#{value}%\"\n end)", - "render_path": null, - "location": { - "type": "method", - "class": "Columns::JSONPathColumn", - "method": "filtered_ids" - }, - "user_input": "search_occurences.count", - "confidence": "Weak", - "cwe_id": [ - 89 - ], - "note": "already sanitized" - }, { "warning_type": "SQL Injection", "warning_code": 0, @@ -136,6 +113,29 @@ ], "note": "" }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "83b5a474065af330c47603d1f60fc31edaab55be162825385d53b77c1c98a6d7", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/models/columns/json_path_column.rb", + "line": 24, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "dossiers.with_type_de_champ(stable_id).where(\"champs.value_json @? '#{jsonpath} ? (@ like_regex \\\"#{quote_string(search_terms.join(\"|\"))}\\\" flag \\\"i\\\")'\")", + "render_path": null, + "location": { + "type": "method", + "class": "Columns::JSONPathColumn", + "method": "filtered_ids" + }, + "user_input": "jsonpath", + "confidence": "Weak", + "cwe_id": [ + 89 + ], + "note": "escaped by hand" + }, { "warning_type": "SQL Injection", "warning_code": 0, @@ -295,6 +295,6 @@ "note": "Current is not a model" } ], - "updated": "2024-10-16 18:07:17 +0200", + "updated": "2024-11-04 09:56:55 +0100", "brakeman_version": "6.1.2" } diff --git a/spec/models/columns/json_path_column_spec.rb b/spec/models/columns/json_path_column_spec.rb new file mode 100644 index 000000000..0da283764 --- /dev/null +++ b/spec/models/columns/json_path_column_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +describe Columns::JSONPathColumn do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :address }]) } + let(:dossier) { create(:dossier, procedure:) } + let(:champ) { dossier.champs.first } + let(:stable_id) { champ.stable_id } + let(:column) { described_class.new(procedure_id: procedure.id, label: 'label', stable_id:, jsonpath:, displayable: true) } + + describe '#value' do + let(:jsonpath) { '$.city_name' } + + subject { column.value(champ) } + + context 'when champ has value_json' do + before { champ.update(value_json: { city_name: 'Grenoble' }) } + + it { is_expected.to eq('Grenoble') } + end + + context 'when champ has no value_json' do + it { is_expected.to be_nil } + end + end + + describe '#filtered_ids' do + let(:jsonpath) { '$.city_name' } + + subject { column.filtered_ids(Dossier.all, ['reno', 'Lyon']) } + + context 'when champ has value_json' do + before { champ.update(value_json: { city_name: 'Grenoble' }) } + + it { is_expected.to eq([dossier.id]) } + end + + context 'when champ has no value_json' do + it { is_expected.to eq([]) } + end + end + + describe '#initializer' do + let(:jsonpath) { %{$.'city_name} } + + it { expect(column.jsonpath).to eq(%{$.''city_name}) } + end +end diff --git a/spec/services/dossier_filter_service_spec.rb b/spec/services/dossier_filter_service_spec.rb index cb8e395bd..c1dbc539a 100644 --- a/spec/services/dossier_filter_service_spec.rb +++ b/spec/services/dossier_filter_service_spec.rb @@ -528,7 +528,7 @@ describe DossierFilterService do context "when searching by postal_code (text)" do let(:value) { "60580" } - let(:filter) { ["rna – code postal (5 chiffres)", value] } + let(:filter) { ["rna – code postal (5 chiffres)", value] } before do kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "postal_code" => value }) From 9d6304e7d4c03de052d00e2699f158463512d35a Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 4 Nov 2024 16:13:55 +0100 Subject: [PATCH 1399/1532] TitreIdentite is a ChampColumn --- app/models/columns/titre_identite_column.rb | 2 +- app/models/types_de_champ/titre_identite_type_de_champ.rb | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/models/columns/titre_identite_column.rb b/app/models/columns/titre_identite_column.rb index 9ce491e51..80ddcaf63 100644 --- a/app/models/columns/titre_identite_column.rb +++ b/app/models/columns/titre_identite_column.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Columns::TitreIdentiteColumn < Column +class Columns::TitreIdentiteColumn < Columns::ChampColumn private def typed_value(champ) diff --git a/app/models/types_de_champ/titre_identite_type_de_champ.rb b/app/models/types_de_champ/titre_identite_type_de_champ.rb index 0cbd1e906..bb8e31785 100644 --- a/app/models/types_de_champ/titre_identite_type_de_champ.rb +++ b/app/models/types_de_champ/titre_identite_type_de_champ.rb @@ -28,8 +28,7 @@ class TypesDeChamp::TitreIdentiteTypeDeChamp < TypesDeChamp::TypeDeChampBase [ Columns::TitreIdentiteColumn.new( procedure_id:, - table: Column::TYPE_DE_CHAMP_TABLE, - column: stable_id.to_s, + stable_id:, label: libelle_with_prefix(prefix), type: TypeDeChamp.column_type(type_champ), value_column: TypeDeChamp.value_column(type_champ), From de8cad888e6b5068f6a89eab1ffb5cc49402d014 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 31 Oct 2024 18:34:30 +0100 Subject: [PATCH 1400/1532] LinkedDropDownColumn is also a ChampColumn by the way --- app/models/columns/linked_drop_down_column.rb | 22 +++++++++++++++---- .../linked_drop_down_list_type_de_champ.rb | 9 +++----- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/app/models/columns/linked_drop_down_column.rb b/app/models/columns/linked_drop_down_column.rb index bab15cd68..5492d3c70 100644 --- a/app/models/columns/linked_drop_down_column.rb +++ b/app/models/columns/linked_drop_down_column.rb @@ -1,9 +1,15 @@ # frozen_string_literal: true -class Columns::LinkedDropDownColumn < Column - def column - return super if default_column? - "#{@column}->#{value_column}" # override column otherwise json path facets will have same id as other +class Columns::LinkedDropDownColumn < Columns::ChampColumn + def initialize(procedure_id:, label:, stable_id:, value_column:, displayable:, type: :text) + super( + procedure_id:, + label:, + stable_id:, + displayable:, + type:, + value_column: + ) end def filtered_ids(dossiers, values) @@ -14,6 +20,14 @@ class Columns::LinkedDropDownColumn < Column private + def column_id + if value_column == :value + "type_de_champ/#{stable_id}" + else + "type_de_champ/#{stable_id}->#{path}" + end + end + def typed_value(champ) return nil if default_column? diff --git a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb index 577936c9b..dc291c2c2 100644 --- a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb @@ -75,17 +75,15 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas [ Columns::LinkedDropDownColumn.new( procedure_id:, - table: Column::TYPE_DE_CHAMP_TABLE, - column: stable_id.to_s, label: libelle_with_prefix(prefix), + stable_id: stable_id, type: :text, value_column: :value, displayable: ), Columns::LinkedDropDownColumn.new( procedure_id:, - table: Column::TYPE_DE_CHAMP_TABLE, - column: stable_id.to_s, + stable_id:, label: "#{libelle_with_prefix(prefix)} (Primaire)", type: :enum, value_column: :primary, @@ -93,8 +91,7 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas ), Columns::LinkedDropDownColumn.new( procedure_id:, - table: Column::TYPE_DE_CHAMP_TABLE, - column: stable_id.to_s, + stable_id:, label: "#{libelle_with_prefix(prefix)} (Secondaire)", type: :enum, value_column: :secondary, From 74e6834ce2d69ca1b61fd3941f3848007510c3dc Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Sun, 3 Nov 2024 22:07:38 +0100 Subject: [PATCH 1401/1532] use path instead of value_column in linked_drop_down --- app/models/columns/linked_drop_down_column.rb | 15 +++++++++------ app/models/type_de_champ.rb | 2 +- .../linked_drop_down_list_type_de_champ.rb | 6 +++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/app/models/columns/linked_drop_down_column.rb b/app/models/columns/linked_drop_down_column.rb index 5492d3c70..41bfcd8ac 100644 --- a/app/models/columns/linked_drop_down_column.rb +++ b/app/models/columns/linked_drop_down_column.rb @@ -1,14 +1,17 @@ # frozen_string_literal: true class Columns::LinkedDropDownColumn < Columns::ChampColumn - def initialize(procedure_id:, label:, stable_id:, value_column:, displayable:, type: :text) + attr_reader :path + + def initialize(procedure_id:, label:, stable_id:, path:, displayable:, type: :text) + @path = path + super( procedure_id:, label:, stable_id:, displayable:, - type:, - value_column: + type: ) end @@ -21,7 +24,7 @@ class Columns::LinkedDropDownColumn < Columns::ChampColumn private def column_id - if value_column == :value + if path == :value "type_de_champ/#{stable_id}" else "type_de_champ/#{stable_id}->#{path}" @@ -29,10 +32,10 @@ class Columns::LinkedDropDownColumn < Columns::ChampColumn end def typed_value(champ) - return nil if default_column? + return nil if path == :value primary_value, secondary_value = unpack_values(champ.value) - case value_column + case path when :primary primary_value when :secondary diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index b979fdf45..420b4a2cf 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -561,7 +561,7 @@ class TypeDeChamp < ApplicationRecord elsif region? APIGeoService.regions.map { [_1[:name], _1[:code]] } elsif linked_drop_down_list? - if column.value_column == :primary + if column.path == :primary primary_options else secondary_options.values.flatten diff --git a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb index dc291c2c2..d50fa4121 100644 --- a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb @@ -78,7 +78,7 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas label: libelle_with_prefix(prefix), stable_id: stable_id, type: :text, - value_column: :value, + path: :value, displayable: ), Columns::LinkedDropDownColumn.new( @@ -86,7 +86,7 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas stable_id:, label: "#{libelle_with_prefix(prefix)} (Primaire)", type: :enum, - value_column: :primary, + path: :primary, displayable: false ), Columns::LinkedDropDownColumn.new( @@ -94,7 +94,7 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas stable_id:, label: "#{libelle_with_prefix(prefix)} (Secondaire)", type: :enum, - value_column: :secondary, + path: :secondary, displayable: false ) ] From 8507733250bf818a073fcaaaadb72701a7604431 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 31 Oct 2024 18:35:20 +0100 Subject: [PATCH 1402/1532] use champ_column.stable_id --- .../instructeurs/column_filter_value_component.rb | 5 ++--- app/components/instructeurs/filter_buttons_component.rb | 6 ++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/app/components/instructeurs/column_filter_value_component.rb b/app/components/instructeurs/column_filter_value_component.rb index d83a239ea..bb4298e5f 100644 --- a/app/components/instructeurs/column_filter_value_component.rb +++ b/app/components/instructeurs/column_filter_value_component.rb @@ -54,12 +54,11 @@ class Instructeurs::ColumnFilterValueComponent < ApplicationComponent end end else - find_type_de_champ(column.column).options_for_select(column) + find_type_de_champ(column.stable_id).options_for_select(column) end end - def find_type_de_champ(column) - stable_id = column.to_s.split('->').first + def find_type_de_champ(stable_id) TypeDeChamp .joins(:revision_types_de_champ) .where(revision_types_de_champ: { revision_id: ProcedureRevision.where(procedure_id:) }) diff --git a/app/components/instructeurs/filter_buttons_component.rb b/app/components/instructeurs/filter_buttons_component.rb index 950adee9b..23ffcb851 100644 --- a/app/components/instructeurs/filter_buttons_component.rb +++ b/app/components/instructeurs/filter_buttons_component.rb @@ -49,7 +49,7 @@ class Instructeurs::FilterButtonsComponent < ApplicationComponent column, filter = filter_column.column, filter_column.filter if column.type_de_champ? - find_type_de_champ(column.column).dynamic_type.filter_to_human(filter) + find_type_de_champ(column.stable_id).dynamic_type.filter_to_human(filter) elsif column.dossier_state? if filter == 'pending_correction' Dossier.human_attribute_name("pending_correction.for_instructeur") @@ -66,9 +66,7 @@ class Instructeurs::FilterButtonsComponent < ApplicationComponent end end - def find_type_de_champ(column) - stable_id = column.to_s.split('->').first - + def find_type_de_champ(stable_id) TypeDeChamp .joins(:revision_types_de_champ) .where(revision_types_de_champ: { revision_id: @procedure_presentation.procedure.revisions }) From 7e9b68ed0ed71cd6e11349e6651209c37156697c Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 31 Oct 2024 18:36:14 +0100 Subject: [PATCH 1403/1532] fix projection --- app/services/dossier_projection_service.rb | 28 ++++++++++++++++------ 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/app/services/dossier_projection_service.rb b/app/services/dossier_projection_service.rb index dfe9039b7..0577d93a5 100644 --- a/app/services/dossier_projection_service.rb +++ b/app/services/dossier_projection_service.rb @@ -27,6 +27,7 @@ class DossierProjectionService TABLE = 'table' COLUMN = 'column' + STABLE_ID = 'stable_id' # Returns [DossierProjection(dossier, columns)] ordered by dossiers_ids # and the columns orderd by fields. @@ -41,7 +42,13 @@ class DossierProjectionService # - the order of the intermediary query results are unknown # - some values can be missing (if a revision added or removed them) def self.project(dossiers_ids, columns) - fields = columns.map { |c| { TABLE => c.table, COLUMN => c.column } } + fields = columns.map do |c| + if c.is_a?(Columns::ChampColumn) + { TABLE => c.table, STABLE_ID => c.stable_id, original_column: c } + else + { TABLE => c.table, COLUMN => c.column } + end + end champ_value = champ_value_formatter(dossiers_ids, fields) state_field = { TABLE => 'self', COLUMN => 'state' } @@ -64,14 +71,17 @@ class DossierProjectionService when 'type_de_champ', 'type_de_champ_private' Champ .where( - stable_id: fields.map { |f| f[COLUMN] }, + stable_id: fields.map { |f| f[STABLE_ID] }, dossier_id: dossiers_ids ) .select(:dossier_id, :value, :stable_id, :type, :external_id, :data, :value_json) # we cannot pluck :value, as we need the champ.to_s method .group_by(&:stable_id) # the champs are redispatched to their respective fields .map do |stable_id, champs| - field = fields.find { |f| f[COLUMN] == stable_id.to_s } - field[:id_value_h] = champs.to_h { |c| [c.dossier_id, champ_value.(c)] } + fields + .filter { |f| f[STABLE_ID] == stable_id } + .each do |field| + field[:id_value_h] = champs.to_h { |c| [c.dossier_id, champ_value.(c, field[:original_column])] } + end end when 'self' Dossier @@ -173,7 +183,7 @@ class DossierProjectionService private def champ_value_formatter(dossiers_ids, fields) - stable_ids = fields.filter { _1[TABLE].in?(['type_de_champ', 'type_de_champ_private']) }.map { _1[COLUMN] } + stable_ids = fields.filter { _1[TABLE].in?(['type_de_champ', 'type_de_champ_private']) }.map { _1[STABLE_ID] } revision_ids_by_dossier_ids = Dossier.where(id: dossiers_ids).pluck(:id, :revision_id).to_h stable_ids_and_types_de_champ_by_revision_ids = ProcedureRevisionTypeDeChamp.includes(:type_de_champ) .where(revision_id: revision_ids_by_dossier_ids.values.uniq, type_de_champ: { stable_id: stable_ids }) @@ -181,10 +191,14 @@ class DossierProjectionService .group_by(&:first) .transform_values { _1.map { |_, type_de_champ| [type_de_champ.stable_id, type_de_champ] }.to_h } stable_ids_and_types_de_champ_by_dossier_ids = revision_ids_by_dossier_ids.transform_values { stable_ids_and_types_de_champ_by_revision_ids[_1] }.compact - -> (champ) { + -> (champ, column) { type_de_champ = stable_ids_and_types_de_champ_by_dossier_ids.fetch(champ.dossier_id, {})[champ.stable_id] if type_de_champ.present? && type_de_champ.type_champ == champ.last_write_type_champ - type_de_champ.champ_value(champ) + if column.is_a?(Columns::JSONPathColumn) + column.get_value(champ) + else + type_de_champ.champ_value(champ) + end else '' end From 0b2bc68d483e36cf3080ddd8173bf354641242d3 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 1 Nov 2024 22:22:40 +0100 Subject: [PATCH 1404/1532] remove unused `type_de_champ_private` string --- app/services/dossier_projection_service.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/services/dossier_projection_service.rb b/app/services/dossier_projection_service.rb index 0577d93a5..7cf8d88ca 100644 --- a/app/services/dossier_projection_service.rb +++ b/app/services/dossier_projection_service.rb @@ -68,7 +68,7 @@ class DossierProjectionService .group_by { |f| f[TABLE] } # one query per table .each do |table, fields| case table - when 'type_de_champ', 'type_de_champ_private' + when 'type_de_champ' Champ .where( stable_id: fields.map { |f| f[STABLE_ID] }, @@ -183,7 +183,7 @@ class DossierProjectionService private def champ_value_formatter(dossiers_ids, fields) - stable_ids = fields.filter { _1[TABLE].in?(['type_de_champ', 'type_de_champ_private']) }.map { _1[STABLE_ID] } + stable_ids = fields.filter { _1[TABLE].in?(['type_de_champ']) }.map { _1[STABLE_ID] } revision_ids_by_dossier_ids = Dossier.where(id: dossiers_ids).pluck(:id, :revision_id).to_h stable_ids_and_types_de_champ_by_revision_ids = ProcedureRevisionTypeDeChamp.includes(:type_de_champ) .where(revision_id: revision_ids_by_dossier_ids.values.uniq, type_de_champ: { stable_id: stable_ids }) From 52aaff5ffcd2d3f16003025b9d4163b7b180c51c Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 4 Nov 2024 08:26:57 +0100 Subject: [PATCH 1405/1532] breaking: simplify linked_drop_down_column column_id --- app/models/columns/linked_drop_down_column.rb | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/app/models/columns/linked_drop_down_column.rb b/app/models/columns/linked_drop_down_column.rb index 41bfcd8ac..2be2724d3 100644 --- a/app/models/columns/linked_drop_down_column.rb +++ b/app/models/columns/linked_drop_down_column.rb @@ -23,13 +23,7 @@ class Columns::LinkedDropDownColumn < Columns::ChampColumn private - def column_id - if path == :value - "type_de_champ/#{stable_id}" - else - "type_de_champ/#{stable_id}->#{path}" - end - end + def column_id = "type_de_champ/#{stable_id}->#{path}" def typed_value(champ) return nil if path == :value From ea57a97c065031ce4b7b5127ee97128745ed15d4 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 4 Nov 2024 16:34:00 +0100 Subject: [PATCH 1406/1532] introduce tdc_type --- app/models/columns/champ_column.rb | 3 ++- app/models/columns/json_path_column.rb | 3 ++- app/models/columns/linked_drop_down_column.rb | 3 ++- app/models/concerns/addressable_column_concern.rb | 1 + .../types_de_champ/linked_drop_down_list_type_de_champ.rb | 5 ++++- app/models/types_de_champ/titre_identite_type_de_champ.rb | 1 + app/models/types_de_champ/type_de_champ_base.rb | 3 ++- spec/models/columns/json_path_column_spec.rb | 3 ++- 8 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/models/columns/champ_column.rb b/app/models/columns/champ_column.rb index e60472284..2d6ee6c4a 100644 --- a/app/models/columns/champ_column.rb +++ b/app/models/columns/champ_column.rb @@ -3,8 +3,9 @@ class Columns::ChampColumn < Column attr_reader :stable_id - def initialize(procedure_id:, label:, stable_id:, displayable: true, filterable: true, type: :text, value_column: :value) + def initialize(procedure_id:, label:, stable_id:, tdc_type:, displayable: true, filterable: true, type: :text, value_column: :value) @stable_id = stable_id + @tdc_type = tdc_type super( procedure_id:, diff --git a/app/models/columns/json_path_column.rb b/app/models/columns/json_path_column.rb index 523b065ff..89e4e15f3 100644 --- a/app/models/columns/json_path_column.rb +++ b/app/models/columns/json_path_column.rb @@ -3,13 +3,14 @@ class Columns::JSONPathColumn < Columns::ChampColumn attr_reader :jsonpath - def initialize(procedure_id:, label:, stable_id:, jsonpath:, displayable:, type: :text) + def initialize(procedure_id:, label:, stable_id:, tdc_type:, jsonpath:, displayable:, type: :text) @jsonpath = quote_string(jsonpath) super( procedure_id:, label:, stable_id:, + tdc_type:, displayable:, type: ) diff --git a/app/models/columns/linked_drop_down_column.rb b/app/models/columns/linked_drop_down_column.rb index 2be2724d3..4530bf97f 100644 --- a/app/models/columns/linked_drop_down_column.rb +++ b/app/models/columns/linked_drop_down_column.rb @@ -3,13 +3,14 @@ class Columns::LinkedDropDownColumn < Columns::ChampColumn attr_reader :path - def initialize(procedure_id:, label:, stable_id:, path:, displayable:, type: :text) + def initialize(procedure_id:, label:, stable_id:, tdc_type:, path:, displayable:, type: :text) @path = path super( procedure_id:, label:, stable_id:, + tdc_type:, displayable:, type: ) diff --git a/app/models/concerns/addressable_column_concern.rb b/app/models/concerns/addressable_column_concern.rb index 23c80acb7..4f10b62b5 100644 --- a/app/models/concerns/addressable_column_concern.rb +++ b/app/models/concerns/addressable_column_concern.rb @@ -14,6 +14,7 @@ module AddressableColumnConcern Columns::JSONPathColumn.new( procedure_id:, stable_id:, + tdc_type: type_champ, label: "#{libelle_with_prefix(prefix)} – #{label}", jsonpath:, displayable:, diff --git a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb index d50fa4121..cee354c02 100644 --- a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb @@ -76,7 +76,8 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas Columns::LinkedDropDownColumn.new( procedure_id:, label: libelle_with_prefix(prefix), - stable_id: stable_id, + stable_id:, + tdc_type: type_champ, type: :text, path: :value, displayable: @@ -84,6 +85,7 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas Columns::LinkedDropDownColumn.new( procedure_id:, stable_id:, + tdc_type: type_champ, label: "#{libelle_with_prefix(prefix)} (Primaire)", type: :enum, path: :primary, @@ -92,6 +94,7 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas Columns::LinkedDropDownColumn.new( procedure_id:, stable_id:, + tdc_type: type_champ, label: "#{libelle_with_prefix(prefix)} (Secondaire)", type: :enum, path: :secondary, diff --git a/app/models/types_de_champ/titre_identite_type_de_champ.rb b/app/models/types_de_champ/titre_identite_type_de_champ.rb index bb8e31785..02cd74fb6 100644 --- a/app/models/types_de_champ/titre_identite_type_de_champ.rb +++ b/app/models/types_de_champ/titre_identite_type_de_champ.rb @@ -29,6 +29,7 @@ class TypesDeChamp::TitreIdentiteTypeDeChamp < TypesDeChamp::TypeDeChampBase Columns::TitreIdentiteColumn.new( procedure_id:, stable_id:, + tdc_type: type_champ, label: libelle_with_prefix(prefix), type: TypeDeChamp.column_type(type_champ), value_column: TypeDeChamp.value_column(type_champ), diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index ec5940e2a..2c81fc430 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -100,7 +100,8 @@ class TypesDeChamp::TypeDeChampBase [ Columns::ChampColumn.new( procedure_id:, - stable_id: stable_id, + stable_id:, + tdc_type: type_champ, label: libelle_with_prefix(prefix), type: TypeDeChamp.column_type(type_champ), value_column: TypeDeChamp.value_column(type_champ), diff --git a/spec/models/columns/json_path_column_spec.rb b/spec/models/columns/json_path_column_spec.rb index 0da283764..062da2e57 100644 --- a/spec/models/columns/json_path_column_spec.rb +++ b/spec/models/columns/json_path_column_spec.rb @@ -5,7 +5,8 @@ describe Columns::JSONPathColumn do let(:dossier) { create(:dossier, procedure:) } let(:champ) { dossier.champs.first } let(:stable_id) { champ.stable_id } - let(:column) { described_class.new(procedure_id: procedure.id, label: 'label', stable_id:, jsonpath:, displayable: true) } + let(:tdc_type) { champ.type_champ } + let(:column) { described_class.new(procedure_id: procedure.id, label: 'label', stable_id:, tdc_type:, jsonpath:, displayable: true) } describe '#value' do let(:jsonpath) { '$.city_name' } From c364be12f18153500236e5502525d4c7b7a3929f Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 4 Nov 2024 16:34:29 +0100 Subject: [PATCH 1407/1532] rework ChampColumn.value --- app/models/columns/champ_column.rb | 55 ++++++++--------- spec/models/columns/champ_column_spec.rb | 77 +++++++++++++++++++++++- 2 files changed, 101 insertions(+), 31 deletions(-) diff --git a/app/models/columns/champ_column.rb b/app/models/columns/champ_column.rb index 2d6ee6c4a..c75b223ab 100644 --- a/app/models/columns/champ_column.rb +++ b/app/models/columns/champ_column.rb @@ -22,11 +22,11 @@ class Columns::ChampColumn < Column def value(champ) return if champ.nil? - value = typed_value(champ) - if default_column? - cast_value(value, from_type: champ.last_write_column_type, to_type: type) + # nominal case + if @tdc_type == champ.last_write_type_champ + typed_value(champ) else - value + cast_value(champ) end end @@ -34,15 +34,11 @@ class Columns::ChampColumn < Column def column_id = "type_de_champ/#{stable_id}" + def string_value(champ) = champ.public_send(value_column) + def typed_value(champ) value = string_value(champ) - parse_value(value, type: champ.last_write_column_type) - end - def string_value(champ) = champ.public_send(value_column) - def default_column? = value_column.in?([:value, :external_id]) - - def parse_value(value, type:) return if value.blank? case type @@ -63,29 +59,30 @@ class Columns::ChampColumn < Column end end - def cast_value(value, from_type:, to_type:) - return if value.blank? - return value if from_type == to_type + def cast_value(champ) + value = string_value(champ) - case [from_type, to_type] - when [:integer, :decimal] # recast numbers automatically + return if value.blank? + + case [champ.last_write_type_champ, @tdc_type] + when ['integer_number', 'decimal_number'] # recast numbers automatically value.to_f - when [:decimal, :integer] # may lose some data, but who cares ? + when ['decimal_number', 'integer_number'] # may lose some data, but who cares ? value.to_i - when [:integer, :text], [:decimal, :text] # number to text - value.to_s - when [:enum, :enums] # single list can become multi - [value] - when [:enum, :text] # single list can become text + when ['integer_number', 'text'], ['decimal_number', 'text'] # number to text value - when [:enums, :enum] # multi list can become single list - value.first - when [:enums, :text] # multi list can become text - value.join(', ') - when [:date, :datetime] # date <=> datetime - value.to_datetime - when [:datetime, :date] # may lose some data, but who cares ? - value.to_date + when ['drop_down_list', 'multiple_drop_down_list'] # single list can become multi + [value] + when ['drop_down_list', 'text'] # single list can become text + value + when ['multiple_drop_down_list', 'drop_down_list'] # multi list can become single + parse_enums(value).first + when ['multiple_drop_down_list', 'text'] # single list can become text + parse_enums(value).join(', ') + when ['date', 'datetime'] # date <=> datetime + parse_datetime(value)&.to_datetime + when ['datetime', 'date'] # may lose some data, but who cares ? + parse_datetime(value)&.to_date else nil end diff --git a/spec/models/columns/champ_column_spec.rb b/spec/models/columns/champ_column_spec.rb index effaf0914..8318cf2c9 100644 --- a/spec/models/columns/champ_column_spec.rb +++ b/spec/models/columns/champ_column_spec.rb @@ -2,8 +2,9 @@ describe Columns::ChampColumn do describe '#value' do - context 'when champ columns' do - let(:procedure) { create(:procedure, :with_all_champs_mandatory) } + let(:procedure) { create(:procedure, :with_all_champs_mandatory) } + + context 'without any cast' do let(:dossier) { create(:dossier, :with_populated_champs, procedure:) } let(:types_de_champ) { procedure.all_revisions_types_de_champ } @@ -43,6 +44,78 @@ describe Columns::ChampColumn do expect_type_de_champ_values('expression_reguliere', [nil]) end end + + context 'with cast' do + def column(label) = procedure.find_column(label:) + + context 'from a integer_number' do + let(:champ) { double(last_write_type_champ: 'integer_number', value: '42') } + + it do + expect(column('decimal_number').value(champ)).to eq(42.0) + expect(column('text').value(champ)).to eq('42') + end + end + + context 'from a decimal_number' do + let(:champ) { double(last_write_type_champ: 'decimal_number', value: '42.1') } + + it do + expect(column('integer_number').value(champ)).to eq(42) + expect(column('text').value(champ)).to eq('42.1') + end + end + + context 'from a date' do + let(:champ) { double(last_write_type_champ: 'date', value:) } + + describe 'when the value is valid' do + let(:value) { '2019-07-10' } + + it { expect(column('datetime').value(champ)).to eq(Time.zone.parse('2019-07-10')) } + end + + describe 'when the value is invalid' do + let(:value) { 'invalid' } + + it { expect(column('datetime').value(champ)).to be_nil } + end + end + + context 'from a datetime' do + let(:champ) { double(last_write_type_champ: 'datetime', value:) } + + describe 'when the value is valid' do + let(:value) { '1962-09-15T15:35:00+01:00' } + + it { expect(column('date').value(champ)).to eq('1962-09-15'.to_date) } + end + + describe 'when the value is invalid' do + let(:value) { 'invalid' } + + it { expect(column('date').value(champ)).to be_nil } + end + end + + context 'from a drop_down_list' do + let(:champ) { double(last_write_type_champ: 'drop_down_list', value: 'val1') } + + it do + expect(column('multiple_drop_down_list').value(champ)).to eq(['val1']) + expect(column('text').value(champ)).to eq('val1') + end + end + + context 'from a multiple_drop_down_list' do + let(:champ) { double(last_write_type_champ: 'multiple_drop_down_list', value: '["val1","val2"]') } + + it do + expect(column('simple_drop_down_list').value(champ)).to eq('val1') + expect(column('text').value(champ)).to eq('val1, val2') + end + end + end end private From 7cf81d3e9bc4dbd3ceed9b54a6804b9305b05017 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 4 Nov 2024 17:40:45 +0100 Subject: [PATCH 1408/1532] refactor(manager data exports): renaming methods --- app/controllers/manager/administrateurs_controller.rb | 2 +- app/controllers/manager/instructeurs_controller.rb | 2 +- app/views/manager/administrateurs/data_exports.html.erb | 4 ++-- config/routes.rb | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/controllers/manager/administrateurs_controller.rb b/app/controllers/manager/administrateurs_controller.rb index 238793ee7..eb69eb473 100644 --- a/app/controllers/manager/administrateurs_controller.rb +++ b/app/controllers/manager/administrateurs_controller.rb @@ -40,7 +40,7 @@ module Manager def data_exports end - def export_last_month + def export_last_half_year administrateurs = Administrateur.joins(:user).where(created_at: 6.months.ago..).where.not(users: { email_verified_at: nil }) csv = CSV.generate(headers: true) do |csv| csv << ['ID', 'Email', 'Date de création'] diff --git a/app/controllers/manager/instructeurs_controller.rb b/app/controllers/manager/instructeurs_controller.rb index 3acb6a784..ffe95bc78 100644 --- a/app/controllers/manager/instructeurs_controller.rb +++ b/app/controllers/manager/instructeurs_controller.rb @@ -23,7 +23,7 @@ module Manager redirect_to manager_instructeurs_path end - def export_last_month + def export_last_half_year instructeurs = Instructeur.joins(:user).where(created_at: 6.months.ago..).where.not(users: { email_verified_at: nil }) csv = CSV.generate(headers: true) do |csv| csv << ['ID', 'Email', 'Date de création'] diff --git a/app/views/manager/administrateurs/data_exports.html.erb b/app/views/manager/administrateurs/data_exports.html.erb index 061ec3280..fbe47f6fc 100644 --- a/app/views/manager/administrateurs/data_exports.html.erb +++ b/app/views/manager/administrateurs/data_exports.html.erb @@ -1,6 +1,6 @@ diff --git a/config/routes.rb b/config/routes.rb index ed06d5b7b..02846ab5b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -115,8 +115,8 @@ Rails.application.routes.draw do end get 'data_exports' => 'administrateurs#data_exports' - get 'exports/administrateurs/last_month' => 'administrateurs#export_last_month' - get 'exports/instructeurs/last_month' => 'instructeurs#export_last_month' + get 'exports/administrateurs/last_half_year' => 'administrateurs#export_last_half_year' + get 'exports/instructeurs/last_half_year' => 'instructeurs#export_last_half_year' get 'import_procedure_tags' => 'procedures#import_data' post 'import_tags' => 'procedures#import_tags' From d5de21fac46f43588964616a985b990326d1e39e Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 4 Nov 2024 17:55:22 +0100 Subject: [PATCH 1409/1532] feat(manager data exports): add exports for newsletter --- .../manager/administrateurs_controller.rb | 14 +++++++++++++- .../manager/instructeurs_controller.rb | 14 +++++++++++++- .../administrateurs/data_exports.html.erb | 17 +++++++++++++++-- config/routes.rb | 2 ++ 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/app/controllers/manager/administrateurs_controller.rb b/app/controllers/manager/administrateurs_controller.rb index eb69eb473..23b047f96 100644 --- a/app/controllers/manager/administrateurs_controller.rb +++ b/app/controllers/manager/administrateurs_controller.rb @@ -49,7 +49,19 @@ module Manager end end - send_data csv, filename: "administrateurs_#{Date.today.strftime('%d-%m-%Y')}.csv" + send_data csv, filename: "administrateurs_recents_#{Date.today.strftime('%d-%m-%Y')}.csv" + end + + def export_with_publiee_procedure + administrateurs = Administrateur.joins(:user).where.not(users: { email_verified_at: nil }).joins(:procedures).where(procedures: { aasm_state: [:publiee] }) + csv = CSV.generate(headers: true) do |csv| + csv << ['ID', 'Email', 'Date de création'] + administrateurs.each do |administrateur| + csv << [administrateur.id, administrateur.email, administrateur.created_at] + end + end + + send_data csv, filename: "administrateurs_actifs_#{Date.today.strftime('%d-%m-%Y')}.csv" end private diff --git a/app/controllers/manager/instructeurs_controller.rb b/app/controllers/manager/instructeurs_controller.rb index ffe95bc78..4c72a1662 100644 --- a/app/controllers/manager/instructeurs_controller.rb +++ b/app/controllers/manager/instructeurs_controller.rb @@ -32,7 +32,19 @@ module Manager end end - send_data csv, filename: "instructeurs_#{Date.today.strftime('%d-%m-%Y')}.csv" + send_data csv, filename: "instructeurs_recents_#{Date.today.strftime('%d-%m-%Y')}.csv" + end + + def export_currently_active + instructeurs = Instructeur.joins(:user).where(users: { current_sign_in_at: 6.months.ago.. }).where.not(users: { email_verified_at: nil }) + csv = CSV.generate(headers: true) do |csv| + csv << ['ID', 'Email', 'Date de création'] + instructeurs.each do |instructeur| + csv << [instructeur.id, instructeur.email, instructeur.created_at] + end + end + + send_data csv, filename: "instructeurs_actifs_#{Date.today.strftime('%d-%m-%Y')}.csv" end end end diff --git a/app/views/manager/administrateurs/data_exports.html.erb b/app/views/manager/administrateurs/data_exports.html.erb index fbe47f6fc..1e3d897cd 100644 --- a/app/views/manager/administrateurs/data_exports.html.erb +++ b/app/views/manager/administrateurs/data_exports.html.erb @@ -1,6 +1,19 @@ -
    +
    +

    + Export des administrateurs et instructeurs +

    +
    + +
    +

    Pour les invitations aux webinaires

    -
    + +

    Pour les newsletters

    + + diff --git a/config/routes.rb b/config/routes.rb index 02846ab5b..58fe3255c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -117,6 +117,8 @@ Rails.application.routes.draw do get 'data_exports' => 'administrateurs#data_exports' get 'exports/administrateurs/last_half_year' => 'administrateurs#export_last_half_year' get 'exports/instructeurs/last_half_year' => 'instructeurs#export_last_half_year' + get 'exports/administrateurs/with_publiee_procedure' => 'administrateurs#export_with_publiee_procedure' + get 'exports/instructeurs/currently_active' => 'instructeurs#export_currently_active' get 'import_procedure_tags' => 'procedures#import_data' post 'import_tags' => 'procedures#import_tags' From 2ff224ec4734d29c86bc8e6f3e72dccda1594f66 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 4 Nov 2024 18:02:42 +0100 Subject: [PATCH 1410/1532] refactor(manager data exports): extract method to generate csv --- .../manager/administrateurs_controller.rb | 16 ++++------------ .../manager/application_controller.rb | 9 +++++++++ .../manager/instructeurs_controller.rb | 16 ++++------------ 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/app/controllers/manager/administrateurs_controller.rb b/app/controllers/manager/administrateurs_controller.rb index 23b047f96..e4e84f86c 100644 --- a/app/controllers/manager/administrateurs_controller.rb +++ b/app/controllers/manager/administrateurs_controller.rb @@ -42,24 +42,16 @@ module Manager def export_last_half_year administrateurs = Administrateur.joins(:user).where(created_at: 6.months.ago..).where.not(users: { email_verified_at: nil }) - csv = CSV.generate(headers: true) do |csv| - csv << ['ID', 'Email', 'Date de création'] - administrateurs.each do |administrateur| - csv << [administrateur.id, administrateur.email, administrateur.created_at] - end - end + + csv = generate_csv(administrateurs) send_data csv, filename: "administrateurs_recents_#{Date.today.strftime('%d-%m-%Y')}.csv" end def export_with_publiee_procedure administrateurs = Administrateur.joins(:user).where.not(users: { email_verified_at: nil }).joins(:procedures).where(procedures: { aasm_state: [:publiee] }) - csv = CSV.generate(headers: true) do |csv| - csv << ['ID', 'Email', 'Date de création'] - administrateurs.each do |administrateur| - csv << [administrateur.id, administrateur.email, administrateur.created_at] - end - end + + csv = generate_csv(administrateurs) send_data csv, filename: "administrateurs_actifs_#{Date.today.strftime('%d-%m-%Y')}.csv" end diff --git a/app/controllers/manager/application_controller.rb b/app/controllers/manager/application_controller.rb index 5586966e2..c10ebfffb 100644 --- a/app/controllers/manager/application_controller.rb +++ b/app/controllers/manager/application_controller.rb @@ -56,5 +56,14 @@ module Manager payload[:to_log] = to_log end + + def generate_csv(users) + CSV.generate(headers: true) do |csv| + csv << ['ID', 'Email', 'Date de création'] + users.each do |user| + csv << [user.id, user.email, user.created_at] + end + end + end end end diff --git a/app/controllers/manager/instructeurs_controller.rb b/app/controllers/manager/instructeurs_controller.rb index 4c72a1662..072df90fc 100644 --- a/app/controllers/manager/instructeurs_controller.rb +++ b/app/controllers/manager/instructeurs_controller.rb @@ -25,24 +25,16 @@ module Manager def export_last_half_year instructeurs = Instructeur.joins(:user).where(created_at: 6.months.ago..).where.not(users: { email_verified_at: nil }) - csv = CSV.generate(headers: true) do |csv| - csv << ['ID', 'Email', 'Date de création'] - instructeurs.each do |instructeur| - csv << [instructeur.id, instructeur.email, instructeur.created_at] - end - end + + csv = generate_csv(instructeurs) send_data csv, filename: "instructeurs_recents_#{Date.today.strftime('%d-%m-%Y')}.csv" end def export_currently_active instructeurs = Instructeur.joins(:user).where(users: { current_sign_in_at: 6.months.ago.. }).where.not(users: { email_verified_at: nil }) - csv = CSV.generate(headers: true) do |csv| - csv << ['ID', 'Email', 'Date de création'] - instructeurs.each do |instructeur| - csv << [instructeur.id, instructeur.email, instructeur.created_at] - end - end + + csv = generate_csv(instructeurs) send_data csv, filename: "instructeurs_actifs_#{Date.today.strftime('%d-%m-%Y')}.csv" end From 9d13ebb3ffaf95fc834a2f7e147d344e551fde62 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 4 Nov 2024 17:01:00 +0100 Subject: [PATCH 1411/1532] feat(pj): add specialized column for attachments --- app/models/columns/attached_many_column.rb | 9 +++ app/models/columns/titre_identite_column.rb | 9 --- app/models/type_de_champ.rb | 4 +- .../piece_justificative_type_de_champ.rb | 13 +++- .../titre_identite_type_de_champ.rb | 2 +- spec/models/column_spec.rb | 4 -- spec/models/columns/champ_column_spec.rb | 69 +++++++++---------- 7 files changed, 59 insertions(+), 51 deletions(-) create mode 100644 app/models/columns/attached_many_column.rb delete mode 100644 app/models/columns/titre_identite_column.rb delete mode 100644 spec/models/column_spec.rb diff --git a/app/models/columns/attached_many_column.rb b/app/models/columns/attached_many_column.rb new file mode 100644 index 000000000..439f7adc5 --- /dev/null +++ b/app/models/columns/attached_many_column.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Columns::AttachedManyColumn < Columns::ChampColumn + private + + def typed_value(champ) + champ.piece_justificative_file.to_a + end +end diff --git a/app/models/columns/titre_identite_column.rb b/app/models/columns/titre_identite_column.rb deleted file mode 100644 index 80ddcaf63..000000000 --- a/app/models/columns/titre_identite_column.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class Columns::TitreIdentiteColumn < Columns::ChampColumn - private - - def typed_value(champ) - champ.piece_justificative_file.attached? - end -end diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 420b4a2cf..6364d3d24 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -540,8 +540,10 @@ class TypeDeChamp < ApplicationRecord :enums when type_champs.fetch(:drop_down_list), type_champs.fetch(:departements), type_champs.fetch(:regions) :enum - when type_champs.fetch(:checkbox), type_champs.fetch(:yes_no), type_champs.fetch(:titre_identite) + when type_champs.fetch(:checkbox), type_champs.fetch(:yes_no) :boolean + when type_champs.fetch(:titre_identite), type_champs.fetch(:piece_justificative) + :attachements else :text end diff --git a/app/models/types_de_champ/piece_justificative_type_de_champ.rb b/app/models/types_de_champ/piece_justificative_type_de_champ.rb index 5ddd1bef6..cb65fb132 100644 --- a/app/models/types_de_champ/piece_justificative_type_de_champ.rb +++ b/app/models/types_de_champ/piece_justificative_type_de_champ.rb @@ -26,6 +26,17 @@ class TypesDeChamp::PieceJustificativeTypeDeChamp < TypesDeChamp::TypeDeChampBas def champ_blank?(champ) = champ.piece_justificative_file.blank? def columns(procedure_id:, displayable: true, prefix: nil) - [] + [ + Columns::AttachedManyColumn.new( + procedure_id:, + stable_id:, + tdc_type: type_champ, + label: libelle_with_prefix(prefix), + type: TypeDeChamp.column_type(type_champ), + value_column: TypeDeChamp.value_column(type_champ), + displayable: false, + filterable: false + ) + ] end end diff --git a/app/models/types_de_champ/titre_identite_type_de_champ.rb b/app/models/types_de_champ/titre_identite_type_de_champ.rb index 02cd74fb6..9938808de 100644 --- a/app/models/types_de_champ/titre_identite_type_de_champ.rb +++ b/app/models/types_de_champ/titre_identite_type_de_champ.rb @@ -26,7 +26,7 @@ class TypesDeChamp::TitreIdentiteTypeDeChamp < TypesDeChamp::TypeDeChampBase def columns(procedure_id:, displayable: nil, prefix: nil) [ - Columns::TitreIdentiteColumn.new( + Columns::AttachedManyColumn.new( procedure_id:, stable_id:, tdc_type: type_champ, diff --git a/spec/models/column_spec.rb b/spec/models/column_spec.rb deleted file mode 100644 index 7b1f4b3d9..000000000 --- a/spec/models/column_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -describe Column do -end diff --git a/spec/models/columns/champ_column_spec.rb b/spec/models/columns/champ_column_spec.rb index 8318cf2c9..0cbf1da91 100644 --- a/spec/models/columns/champ_column_spec.rb +++ b/spec/models/columns/champ_column_spec.rb @@ -9,39 +9,38 @@ describe Columns::ChampColumn do let(:types_de_champ) { procedure.all_revisions_types_de_champ } it 'extracts values for columns and type de champ' do - expect_type_de_champ_values('civilite', ["M."]) - expect_type_de_champ_values('email', ['yoda@beta.gouv.fr']) - expect_type_de_champ_values('phone', ['0666666666']) - expect_type_de_champ_values('address', ["2 rue des Démarches"]) - expect_type_de_champ_values('communes', ["Coye-la-Forêt"]) - expect_type_de_champ_values('departements', ['01']) - expect_type_de_champ_values('regions', ['01']) - expect_type_de_champ_values('pays', ['France']) - expect_type_de_champ_values('epci', [nil]) - expect_type_de_champ_values('iban', [nil]) - expect_type_de_champ_values('siret', ["44011762001530", "postal_code", "city_name", "departement_code", "region_name"]) - expect_type_de_champ_values('text', ['text']) - expect_type_de_champ_values('textarea', ['textarea']) - expect_type_de_champ_values('number', ['42']) - expect_type_de_champ_values('decimal_number', [42.1]) - expect_type_de_champ_values('integer_number', [42]) - expect_type_de_champ_values('date', [Time.zone.parse('2019-07-10').to_date]) - expect_type_de_champ_values('datetime', [Time.zone.parse("1962-09-15T15:35:00+01:00")]) - expect_type_de_champ_values('checkbox', [true]) - expect_type_de_champ_values('drop_down_list', ['val1']) - expect_type_de_champ_values('multiple_drop_down_list', [["val1", "val2"]]) - expect_type_de_champ_values('linked_drop_down_list', [nil, "categorie 1", "choix 1"]) - expect_type_de_champ_values('yes_no', [true]) - expect_type_de_champ_values('annuaire_education', [nil]) - expect_type_de_champ_values('carte', []) - expect_type_de_champ_values('piece_justificative', []) - expect_type_de_champ_values('titre_identite', [true]) - expect_type_de_champ_values('cnaf', [nil]) - expect_type_de_champ_values('dgfip', [nil]) - expect_type_de_champ_values('pole_emploi', [nil]) - expect_type_de_champ_values('mesri', [nil]) - expect_type_de_champ_values('cojo', [nil]) - expect_type_de_champ_values('expression_reguliere', [nil]) + expect_type_de_champ_values('civilite', eq(["M."])) + expect_type_de_champ_values('email', eq(['yoda@beta.gouv.fr'])) + expect_type_de_champ_values('phone', eq(['0666666666'])) + expect_type_de_champ_values('address', eq(["2 rue des Démarches"])) + expect_type_de_champ_values('communes', eq(["Coye-la-Forêt"])) + expect_type_de_champ_values('departements', eq(['01'])) + expect_type_de_champ_values('regions', eq(['01'])) + expect_type_de_champ_values('pays', eq(['France'])) + expect_type_de_champ_values('epci', eq([nil])) + expect_type_de_champ_values('iban', eq([nil])) + expect_type_de_champ_values('siret', eq(["44011762001530", "postal_code", "city_name", "departement_code", "region_name"])) + expect_type_de_champ_values('text', eq(['text'])) + expect_type_de_champ_values('textarea', eq(['textarea'])) + expect_type_de_champ_values('number', eq(['42'])) + expect_type_de_champ_values('decimal_number', eq([42.1])) + expect_type_de_champ_values('integer_number', eq([42])) + expect_type_de_champ_values('date', eq([Time.zone.parse('2019-07-10').to_date])) + expect_type_de_champ_values('datetime', eq([Time.zone.parse("1962-09-15T15:35:00+01:00")])) + expect_type_de_champ_values('checkbox', eq([true])) + expect_type_de_champ_values('drop_down_list', eq(['val1'])) + expect_type_de_champ_values('multiple_drop_down_list', eq([["val1", "val2"]])) + expect_type_de_champ_values('linked_drop_down_list', eq([nil, "categorie 1", "choix 1"])) + expect_type_de_champ_values('yes_no', eq([true])) + expect_type_de_champ_values('annuaire_education', eq([nil])) + expect_type_de_champ_values('piece_justificative', be_an_instance_of(Array)) + expect_type_de_champ_values('titre_identite', be_an_instance_of(Array)) + expect_type_de_champ_values('cnaf', eq([nil])) + expect_type_de_champ_values('dgfip', eq([nil])) + expect_type_de_champ_values('pole_emploi', eq([nil])) + expect_type_de_champ_values('mesri', eq([nil])) + expect_type_de_champ_values('cojo', eq([nil])) + expect_type_de_champ_values('expression_reguliere', eq([nil])) end end @@ -120,11 +119,11 @@ describe Columns::ChampColumn do private - def expect_type_de_champ_values(type, values) + def expect_type_de_champ_values(type, assertion) type_de_champ = types_de_champ.find { _1.type_champ == type } champ = dossier.send(:filled_champ, type_de_champ, nil) columns = type_de_champ.columns(procedure_id: procedure.id) - expect(columns.map { _1.value(champ) }).to eq(values) + expect(columns.map { _1.value(champ) }).to assertion end def retrieve_champ(type) From 47f2194e92f6477d89a01823f15ee9fb28e9a7c4 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 28 Oct 2024 10:00:33 +0100 Subject: [PATCH 1412/1532] fix(Administrateurs::GroupesInstructeur#add_instructeur): re-rendering a template lead to miss usage of pagination. redirect after add_instructeur --- .../groupe_instructeurs_controller.rb | 8 ++++---- .../groupe_instructeurs_controller_spec.rb | 20 ++++++++++++------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/app/controllers/administrateurs/groupe_instructeurs_controller.rb b/app/controllers/administrateurs/groupe_instructeurs_controller.rb index c9ecd1563..50618a6fe 100644 --- a/app/controllers/administrateurs/groupe_instructeurs_controller.rb +++ b/app/controllers/administrateurs/groupe_instructeurs_controller.rb @@ -242,7 +242,7 @@ module Administrateurs end if instructeurs.present? - flash.now[:notice] = if procedure.routing_enabled? + flash[:notice] = if procedure.routing_enabled? t('.assignment', count: instructeurs.size, emails: instructeurs.map(&:email).join(', '), @@ -264,7 +264,7 @@ module Administrateurs end end - flash.now[:alert] = errors.join(". ") if !errors.empty? + flash[:alert] = errors.join(". ") if !errors.empty? @procedure = procedure @instructeurs = paginated_instructeurs @@ -272,10 +272,10 @@ module Administrateurs if procedure.routing_enabled? @groupe_instructeur = groupe_instructeur - render :show + redirect_to admin_procedure_groupe_instructeur_path(@procedure, @groupe_instructeur) else @groupes_instructeurs = paginated_groupe_instructeurs - render :index + redirect_to admin_procedure_groupe_instructeurs_path(@procedure) end end diff --git a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb index 6e4b823dc..9325ad1ef 100644 --- a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb +++ b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb @@ -343,7 +343,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do context 'when all emails are valid' do let(:emails) { ['test@b.gouv.fr', 'test2@b.gouv.fr'] } it do - expect(subject).to render_template(:index) + expect(subject).to redirect_to(admin_procedure_groupe_instructeurs_path(procedure_non_routee)) expect(subject.request.flash[:alert]).to be_nil expect(subject.request.flash[:notice]).to be_present end @@ -352,7 +352,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do context 'when there is at least one bad email' do let(:emails) { ['badmail', 'instructeur2@gmail.com'] } it do - expect(subject).to render_template(:index) + expect(subject).to redirect_to(admin_procedure_groupe_instructeurs_path(procedure_non_routee)) expect(subject.request.flash[:alert]).to be_present expect(subject.request.flash[:notice]).to be_present end @@ -362,7 +362,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do let(:instructeur) { create(:instructeur) } before { procedure_non_routee.groupe_instructeurs.first.add_instructeurs(emails: [instructeur.user.email]) } let(:emails) { [instructeur.email] } - it { expect(subject).to render_template(:index) } + it { expect(subject).to redirect_to(admin_procedure_groupe_instructeurs_path(procedure_non_routee)) } end context 'when signed in admin comes from manager' do @@ -403,7 +403,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do it 'validates changes and responses' do expect(gi_1_2.instructeurs.pluck(:email)).to include(*new_instructeur_emails) expect(flash.notice).to be_present - expect(response).to render_template(:show) + expect(response).to redirect_to(admin_procedure_groupe_instructeur_path(procedure, gi_1_2)) expect(procedure.routing_enabled?).to be_truthy expect(GroupeInstructeurMailer).to have_received(:notify_added_instructeurs).with( gi_1_2, @@ -442,7 +442,10 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do context 'of an instructeur already in the group' do let(:new_instructeur_emails) { [instructeur.email] } before { do_request } - it { expect(response).to render_template(:show) } + it do + expect(flash.alert).not_to be_present + expect(response).to redirect_to(admin_procedure_groupe_instructeur_path(procedure, gi_1_2)) + end end context 'of badly formed email' do @@ -450,14 +453,17 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do before { do_request } it do expect(flash.alert).to be_present - expect(response).to render_template(:show) + expect(response).to redirect_to(admin_procedure_groupe_instructeur_path(procedure, gi_1_2)) end end context 'of an empty string' do let(:new_instructeur_emails) { [''] } before { do_request } - it { expect(response).to render_template(:show) } + it do + expect(flash.alert).to be_present + expect(response).to redirect_to(admin_procedure_groupe_instructeur_path(procedure, gi_1_2)) + end end context 'when connected as an administrateur from manager' do From 95acc0adb6182eac6ae7f3d44e8e02f0fcb34b07 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Mon, 21 Oct 2024 15:31:51 +0200 Subject: [PATCH 1413/1532] Add context to delete button --- .../attachment/edit_component/edit_component.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/attachment/edit_component/edit_component.html.haml b/app/components/attachment/edit_component/edit_component.html.haml index 45d8ceafc..423490e50 100644 --- a/app/components/attachment/edit_component/edit_component.html.haml +++ b/app/components/attachment/edit_component/edit_component.html.haml @@ -6,7 +6,7 @@ .flex.flex-gap-2{ class: class_names("attachment-error": attachment.virus_scanner_error?) } - if user_can_destroy? = render NestedForms::OwnedButtonComponent.new(formaction: destroy_attachment_path, http_method: :delete, opt: {class: "fr-btn fr-btn--tertiary fr-btn--sm fr-icon-delete-line", title: t(".delete_file", filename: attachment.filename)}) do - = t('.delete') + = t('.delete_file', filename: attachment.filename) - if downloadable?(attachment) = render Dsfr::DownloadComponent.new(attachment: attachment) @@ -24,7 +24,7 @@ .flex.flex-gap-2{ class: class_names("attachment-error": attachment.virus_scanner_error?) } - if user_can_destroy? = render NestedForms::OwnedButtonComponent.new(formaction: destroy_attachment_path, http_method: :delete, opt: {class: "fr-btn fr-btn--tertiary fr-btn--sm fr-icon-delete-line", title: t(".delete_file", filename: attachment.filename)}) do - = t('.delete') + = t('.delete_file', filename: attachment.filename) - if downloadable?(attachment) = render Dsfr::DownloadComponent.new(attachment:) From 4ad4f9be0b1fb66009a51996a95aac60d561526b Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Tue, 5 Nov 2024 14:23:08 +0100 Subject: [PATCH 1414/1532] fix(outdated code) Remove useless condition --- .../champ_label_component/champ_label_component.html.haml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/components/editable_champ/champ_label_component/champ_label_component.html.haml b/app/components/editable_champ/champ_label_component/champ_label_component.html.haml index 0be774df0..5dffbf6cd 100644 --- a/app/components/editable_champ/champ_label_component/champ_label_component.html.haml +++ b/app/components/editable_champ/champ_label_component/champ_label_component.html.haml @@ -5,9 +5,3 @@ - render EditableChamp::ChampLabelContentComponent.new form: @form, champ: @champ, seen_at: @seen_at - elsif @champ.legend_label? %legend.fr-fieldset__legend.fr-text--regular{ id: @champ.labelledby_id }= render EditableChamp::ChampLabelContentComponent.new form: @form, champ: @champ, seen_at: @seen_at -- elsif @champ.single_checkbox? - -# no label to add -- else - -# champ civilite (and other?) - .fr-label.fr-mb-1w{ id: @champ.labelledby_id } - = render EditableChamp::ChampLabelContentComponent.new form: @form, champ: @champ, seen_at: @seen_at From 82963efb608f7a306ebf7551271357d0be6a9373 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Tue, 5 Nov 2024 14:25:07 +0100 Subject: [PATCH 1415/1532] fix(typo) Correct misspelled attribute --- .../release_note/form_component/form_component.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/release_note/form_component/form_component.html.haml b/app/components/release_note/form_component/form_component.html.haml index 94d74ff9c..9102ae1be 100644 --- a/app/components/release_note/form_component/form_component.html.haml +++ b/app/components/release_note/form_component/form_component.html.haml @@ -24,7 +24,7 @@ = category.humanize - if categories_error? - .fr-messages-group{ id: "checkboxes-error-messages", aria_live: "assertive" } + .fr-messages-group{ id: "checkboxes-error-messages", "aria-live": "assertive" } - if categories_full_messages_errors.one? %p.fr-message.fr-message--error{ id: categories_errors_describedby_id }= categories_full_messages_errors.first - else From d44d8f526e8f668433b23eeb40ba027be83c9eba Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Tue, 5 Nov 2024 14:36:15 +0100 Subject: [PATCH 1416/1532] fix(trad) Correct misspelled key --- config/locales/models/champs/champs.en.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/locales/models/champs/champs.en.yml b/config/locales/models/champs/champs.en.yml index ead703062..b647ec55d 100644 --- a/config/locales/models/champs/champs.en.yml +++ b/config/locales/models/champs/champs.en.yml @@ -1,4 +1,4 @@ -fr: +en: activerecord: errors: models: From 264594fd581eed18aa9f0c980a37c3ca8b2970f7 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 14 Oct 2024 16:22:22 +0200 Subject: [PATCH 1417/1532] feat(instruction options management): update alert info and confirmation --- .../instructeurs_options_component.fr.yml | 4 ++++ .../instructeurs_options_component.html.haml | 16 +++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.fr.yml b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.fr.yml index d9d12cff9..43c22013d 100644 --- a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.fr.yml +++ b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.fr.yml @@ -2,6 +2,10 @@ fr: routing_configuration_notice_1: Le routage permet d’acheminer les dossiers vers différents groupes d’instructeurs. + routing_not_configured: + Vous n’avez pas configuré de routage. + routing_configured_html: + Vous avez configuré un routage avec %{groupe_instructeurs_count} groupes d’instructeurs. routing_configuration_notice_2_html: |

    Pour le configurer, votre formulaire doit comporter au moins un champ de type %{conditionable_types}.

    diff --git a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml index 903cf6e64..8caeb0814 100644 --- a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml +++ b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml @@ -16,14 +16,20 @@ disabled: false) %hr - %p.fr-mt-2w Routage - %p.fr-mt-2w= t('.routing_configuration_notice_1') - %p.fr-icon-info-line.fr-hint-text{ aria: { hidden: true } } - Plus d'informations sur le routage dans la - = link_to('doc', + %p Routage + %p.fr-mt-1w.fr-hint-text= t('.routing_configuration_notice_1') + %p.fr-alert.fr-alert--info.fr-mb-3w{ aria: { hidden: true } } + En savoir plus sur la + = link_to('configuration du routage', ROUTAGE_URL, title: t('.routage_doc.title'), **helpers.external_link_attributes) + - if !@procedure.routing_enabled? + %p.fr-mt-2w + %i.fr-mt-2w= t('.routing_not_configured') + - else + %p.fr-mt-2w + %i.fr-mt-2w= t('.routing_configured_html', groupe_instructeurs_count: @procedure.groupe_instructeurs.count) - if @procedure.active_revision.conditionable_types_de_champ.none? %p.fr-mt-2w= t('.routing_configuration_notice_2_html', path: champs_admin_procedure_path(@procedure), conditionable_types: TypeDeChamp.humanized_conditionable_types) - elsif @procedure.groupe_instructeurs.active.one? From d979bf6c680e7f52c1230c18f3760b8f8c35ad97 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 14 Oct 2024 18:06:59 +0200 Subject: [PATCH 1418/1532] feat(instruction options management): add routable champs by category --- .../instructeurs_options_component.fr.yml | 7 ++++--- .../instructeurs_options_component.html.haml | 7 ++++++- app/models/logic/champ_value.rb | 5 +++++ app/models/type_de_champ.rb | 7 +++---- spec/models/type_de_champ_spec.rb | 6 ++++++ 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.fr.yml b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.fr.yml index 43c22013d..a36d12183 100644 --- a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.fr.yml +++ b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.fr.yml @@ -7,9 +7,10 @@ fr: routing_configured_html: Vous avez configuré un routage avec %{groupe_instructeurs_count} groupes d’instructeurs. routing_configuration_notice_2_html: | -

    Pour le configurer, votre formulaire doit comporter - au moins un champ de type %{conditionable_types}.

    -

    Ajoutez ce champ dans la page « Configuration des champs ».

    + Pour le configurer, votre formulaire doit comporter + au moins un « champ routable », soit un champ de type : + routing_configuration_notice_3_html: | + Ajoutez ce champ dans la page d’édition des champs du formulaire. delete_title: Aucun dossier ne sera supprimé. Les groupes d'instructeurs vont être supprimés. Seuls les instructeurs du groupe « %{defaut_label} » resteront affectés à la démarche. delete_confirmation: | Attention : tous les dossiers vont être déplacés dans le groupe « %{defaut_label} » et seuls les instructeurs présent dans ce groupe resteront affectés à la démarche. Souhaitez-vous continuer ? diff --git a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml index 8caeb0814..4b281219a 100644 --- a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml +++ b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml @@ -31,7 +31,12 @@ %p.fr-mt-2w %i.fr-mt-2w= t('.routing_configured_html', groupe_instructeurs_count: @procedure.groupe_instructeurs.count) - if @procedure.active_revision.conditionable_types_de_champ.none? - %p.fr-mt-2w= t('.routing_configuration_notice_2_html', path: champs_admin_procedure_path(@procedure), conditionable_types: TypeDeChamp.humanized_conditionable_types) + %p.fr-mt-2w.fr-mb-0= t('.routing_configuration_notice_2_html') + %ul + - TypeDeChamp.humanized_conditionable_types_by_category.each do |category| + %li + = category.join(', ') + %p.fr-mt-2w= t('.routing_configuration_notice_3_html', path: champs_admin_procedure_path(@procedure)) - elsif @procedure.groupe_instructeurs.active.one? = link_to 'Configurer le routage', options_admin_procedure_groupe_instructeurs_path(@procedure, state: :choix), class: 'fr-btn' diff --git a/app/models/logic/champ_value.rb b/app/models/logic/champ_value.rb index 0d0015198..c515b5bf3 100644 --- a/app/models/logic/champ_value.rb +++ b/app/models/logic/champ_value.rb @@ -16,6 +16,11 @@ class Logic::ChampValue < Logic::Term :pays ) + MANAGED_TYPE_DE_CHAMP_BY_CATEGORY = MANAGED_TYPE_DE_CHAMP.keys.map(&:to_sym) + .each_with_object(Hash.new { |h, k| h[k] = [] }) do |type, h| + h[TypeDeChamp::TYPE_DE_CHAMP_TO_CATEGORIE[type]] << type + end + CHAMP_VALUE_TYPE = { boolean: :boolean, # from yes_no or checkbox champ number: :number, # from integer or decimal number champ diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 563ee0220..abb5df34e 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -655,10 +655,9 @@ class TypeDeChamp < ApplicationRecord Logic::ChampValue::MANAGED_TYPE_DE_CHAMP.values.include?(type_champ) end - def self.humanized_conditionable_types - Logic::ChampValue::MANAGED_TYPE_DE_CHAMP.values.map do - "« #{I18n.t(_1, scope: [:activerecord, :attributes, :type_de_champ, :type_champs])} »" - end.to_sentence(last_word_connector: ' ou ') + def self.humanized_conditionable_types_by_category + Logic::ChampValue::MANAGED_TYPE_DE_CHAMP_BY_CATEGORY + .map { |_, v| v.map { "« #{I18n.t(_1, scope: [:activerecord, :attributes, :type_de_champ, :type_champs])} »" } } end def invalid_regexp? diff --git a/spec/models/type_de_champ_spec.rb b/spec/models/type_de_champ_spec.rb index 605ddb961..33b3ccc57 100644 --- a/spec/models/type_de_champ_spec.rb +++ b/spec/models/type_de_champ_spec.rb @@ -475,4 +475,10 @@ describe TypeDeChamp do it { expect(subject).to eq('') } end end + + describe '#humanized_conditionable_types_by_category' do + subject { TypeDeChamp.humanized_conditionable_types_by_category } + + it { is_expected.to eq([["« Oui/Non »", "« Case à cocher seule »", "« Choix simple »", "« Choix multiple »"], ["« Nombre entier »", "« Nombre décimal »"], ["« Communes »", "« EPCI »", "« Départements »", "« Régions »", "« Adresse »", "« Pays »"]]) } + end end From b7af591e8d740792f8553d6a1ec1e3d368ae9bdb Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 30 Jan 2024 09:54:06 +0100 Subject: [PATCH 1419/1532] extract libelle_id_procedures --- .../administrateurs/api_tokens_controller.rb | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/app/controllers/administrateurs/api_tokens_controller.rb b/app/controllers/administrateurs/api_tokens_controller.rb index 9b35d5faa..7b4dd0381 100644 --- a/app/controllers/administrateurs/api_tokens_controller.rb +++ b/app/controllers/administrateurs/api_tokens_controller.rb @@ -13,11 +13,7 @@ module Administrateurs def autorisations @name = name - @libelle_id_procedures = current_administrateur - .procedures - .order(:libelle) - .pluck(:libelle, :id) - .map { |libelle, id| ["#{id} - #{libelle}", id] } + @libelle_id_procedures = libelle_id_procedures end def securite @@ -37,6 +33,7 @@ module Administrateurs end def edit + @libelle_id_procedures = libelle_id_procedures end def update @@ -74,6 +71,14 @@ module Administrateurs EOF end + def libelle_id_procedures + current_administrateur + .procedures + .order(:libelle) + .pluck(:libelle, :id) + .map { |libelle, id| ["#{id} - #{libelle}", id] } + end + def all_params [:name, :access, :target, :targets, :networkFiltering, :networks, :lifetime, :customLifetime] .index_with { |param| params[param] } From 928c12e065e37e1e859e76e853cd2ed739e1bdaf Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 30 Jan 2024 09:56:36 +0100 Subject: [PATCH 1420/1532] update using turbo --- .../administrateurs/api_tokens_controller.rb | 31 +++++--- .../administrateurs/api_tokens/edit.html.haml | 74 ++++++++++--------- .../api_tokens_controller_spec.rb | 10 +-- 3 files changed, 66 insertions(+), 49 deletions(-) diff --git a/app/controllers/administrateurs/api_tokens_controller.rb b/app/controllers/administrateurs/api_tokens_controller.rb index 7b4dd0381..32ca47b16 100644 --- a/app/controllers/administrateurs/api_tokens_controller.rb +++ b/app/controllers/administrateurs/api_tokens_controller.rb @@ -37,20 +37,33 @@ module Administrateurs end def update - if invalid_network? - @invalid_network = true - return render :edit + @libelle_id_procedures = libelle_id_procedures + + h = {} + + if !params[:networks].nil? + if invalid_network? + @invalid_network_message = "vous devez entrer des adresses ipv4 ou ipv6 valides" + return render :edit + end + + if @api_token.eternal? && networks.empty? + @invalid_network_message = "Vous ne pouvez pas supprimer les restrictions d'accès à l'API d'un jeton permanent." + @api_token.reload + return render :edit + end + + h[:authorized_networks] = networks + end end - if @api_token.eternal? && networks.empty? - flash[:alert] = "Vous ne pouvez pas supprimer les restrictions d'accès à l'API d'un jeton permanent." - return render :edit + if params[:name].present? + h[:name] = name end - @api_token.update!(name:, authorized_networks: networks) + @api_token.update!(h) - flash[:notice] = "Le jeton d'API a été mis à jour." - redirect_to profil_path + render :edit end def destroy diff --git a/app/views/administrateurs/api_tokens/edit.html.haml b/app/views/administrateurs/api_tokens/edit.html.haml index 6efa775f5..47f1ac7d4 100644 --- a/app/views/administrateurs/api_tokens/edit.html.haml +++ b/app/views/administrateurs/api_tokens/edit.html.haml @@ -6,41 +6,47 @@ ["Jeton d’API : #{@api_token.name}"]] } .fr-container.fr-mt-2w - %h1 Modification du jeton d'API « #{@api_token.name} » - = form_with url: admin_api_token_path(@api_token), method: :patch, html: { class: 'fr-mt-2w' } do |f| - .fr-input-group - = f.label :name, class: 'fr-label' do - = t('name', scope: [:administrateurs, :api_tokens, :nom]) - %span.fr-hint-text= t('name-hint', scope: [:administrateurs, :api_tokens, :nom]) - = f.text_field :name, - class: 'fr-input width-33', - autocomplete: 'off', - autocapitalize: 'off', - autocorrect: 'off', - spellcheck: false, - required: true, - value: @api_token.name + %turbo-frame#tokenUpdate + %h1 Modification du jeton d'API « #{@api_token.name} » - .fr-input-group.fr-mb-4w{ - class: class_names('fr-input-group--error': @invalid_network) } - = f.label :name, class: 'fr-label' do - = @api_token.eternal? ? "Entrez au moins 1 réseau autorisé" : "Entrez les adresses ip autorisées" - %span.fr-hint-text adresses réseaux séparées par des espaces. ex: 176.31.79.200 192.168.33.0/24 2001:41d0:304:400::52f/128 - = f.text_field :networks, - class: class_names('fr-input': true, 'fr-input--error': @invalid_network), - autocomplete: 'off', - autocapitalize: 'off', - autocorrect: 'off', - spellcheck: false, - required: @api_token.eternal?, - value: @api_token.authorized_networks_for_ui.gsub(/,/, ' ') + = form_with url: admin_api_token_path(@api_token), method: :patch, html: { class: 'fr-mt-2w' } do |f| + .fr-input-group + = f.label :name, class: 'fr-label' do + = t('name', scope: [:administrateurs, :api_tokens, :nom]) + %span.fr-hint-text= t('name-hint', scope: [:administrateurs, :api_tokens, :nom]) + .flex + = f.text_field :name, + class: 'fr-input width-33', + autocomplete: 'off', + autocapitalize: 'off', + autocorrect: 'off', + spellcheck: false, + required: true, + value: @api_token.name - - if @invalid_network - %p.fr-error-text vous devez entrer des adresses ipv4 ou ipv6 valides + %button.fr-btn.fr-btn--secondary.fr-ml-1w Renommer - %ul.fr-btns-group.fr-btns-group--inline - %li - = f.button 'Modifier', type: :submit, class: "fr-btn fr-btn--primary" - %li - = link_to 'Revenir', profil_path, class: "fr-btn fr-btn--secondary" + = form_with url: admin_api_token_path(@api_token), method: :patch, html: { class: 'fr-mt-2w' } do |f| + .fr-input-group.fr-mb-4w{ + class: class_names('fr-input-group--error': @invalid_network_message.present?) } + = f.label :name, class: 'fr-label' do + = @api_token.eternal? ? "Entrez au moins 1 réseau autorisé" : "Entrez les adresses ip autorisées" + %span.fr-hint-text adresses réseaux séparées par des espaces. ex: 176.31.79.200 192.168.33.0/24 2001:41d0:304:400::52f/128 + .flex + = f.text_field :networks, + class: class_names('fr-input': true, 'fr-input--error': @invalid_network_message.present?), + autocomplete: 'off', + autocapitalize: 'off', + autocorrect: 'off', + spellcheck: false, + value: @api_token.authorized_networks_for_ui.gsub(/,/, ' ') + + %button.fr-btn.fr-btn--secondary.fr-ml-1w Modifier + + - if @invalid_network_message.present? + %p.fr-error-text= @invalid_network_message + + %ul.fr-btns-group.fr-btns-group--inline + %li + = link_to 'Revenir', profil_path, class: "fr-btn fr-btn--secondary" diff --git a/spec/controllers/administrateurs/api_tokens_controller_spec.rb b/spec/controllers/administrateurs/api_tokens_controller_spec.rb index b16321cc5..551cdfcbd 100644 --- a/spec/controllers/administrateurs/api_tokens_controller_spec.rb +++ b/spec/controllers/administrateurs/api_tokens_controller_spec.rb @@ -119,9 +119,8 @@ describe Administrateurs::APITokensController, type: :controller do before { subject; token.reload } it 'does not update a token' do - expect(token.name).not_to eq('new name') - expect(assigns(:invalid_network)).to be true - expect(response).to render_template(:edit) + expect(token.authorized_networks).to be_blank + expect(assigns(:invalid_network_message)).to eq('vous devez entrer des adresses ipv4 ou ipv6 valides') end end @@ -135,9 +134,8 @@ describe Administrateurs::APITokensController, type: :controller do let(:networks) { '' } it 'does not update a token' do - expect(token.name).not_to eq('new name') - expect(flash[:alert]).to eq("Vous ne pouvez pas supprimer les restrictions d'accès à l'API d'un jeton permanent.") - expect(response).to render_template(:edit) + expect(token.authorized_networks).to eq([IPAddr.new('118.218.200.200')]) + expect(assigns(:invalid_network_message)).to eq("Vous ne pouvez pas supprimer les restrictions d'accès à l'API d'un jeton permanent.") end end end From bc583e0fe2b0bf177d4b41d790e7973b24b9211c Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 30 Jan 2024 09:57:30 +0100 Subject: [PATCH 1421/1532] can add a demarche --- .../administrateurs/api_tokens_controller.rb | 12 ++++++++ .../administrateurs/api_tokens/edit.html.haml | 23 +++++++++++++++ .../api_tokens_controller_spec.rb | 28 ++++++++++++++++++- 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/app/controllers/administrateurs/api_tokens_controller.rb b/app/controllers/administrateurs/api_tokens_controller.rb index 32ca47b16..9d6403e9b 100644 --- a/app/controllers/administrateurs/api_tokens_controller.rb +++ b/app/controllers/administrateurs/api_tokens_controller.rb @@ -55,6 +55,14 @@ module Administrateurs h[:authorized_networks] = networks end + + if procedure_to_add.present? + to_add = current_administrateur + .procedure_ids + .intersection([procedure_to_add]) + + h[:allowed_procedure_ids] = + (Array.wrap(@api_token.allowed_procedure_ids) + to_add).uniq end if params[:name].present? @@ -133,6 +141,10 @@ module Administrateurs params[:name] end + def procedure_to_add + params[:procedure_to_add]&.to_i + end + def write_access params[:access] == "read_write" end diff --git a/app/views/administrateurs/api_tokens/edit.html.haml b/app/views/administrateurs/api_tokens/edit.html.haml index 47f1ac7d4..1d8e607a9 100644 --- a/app/views/administrateurs/api_tokens/edit.html.haml +++ b/app/views/administrateurs/api_tokens/edit.html.haml @@ -47,6 +47,29 @@ - if @invalid_network_message.present? %p.fr-error-text= @invalid_network_message + = form_with url: admin_api_token_path(@api_token), method: :patch, html: { class: 'fr-mt-2w' } do |f| + .fr-mb-4w + - if @api_token.full_access? + %p Votre jeton d'API a accès à toutes vos démarches. + = hidden_field_tag :procedure_to_add, '[]' + %button.fr-btn.fr-btn--secondary.fr-btn--sm Restreindre l'accès à certaines les démarches + - else + .fr-select-group + %label.fr-label{ for: 'procedure_to_add' } Ajouter des démarches autorisées + .flex + = f.select :value, + options_for_select(@libelle_id_procedures), + { include_blank: true }, + { class: 'fr-select width-33', + name: 'procedure_to_add'} + + %button.fr-btn.fr-btn--secondary.fr-ml-1w Ajouter + + %ul.fr-mb-4w + - @api_token.procedures.each do |procedure| + %li{ id: dom_id(procedure, :authorized) } + = procedure.libelle + %ul.fr-btns-group.fr-btns-group--inline %li = link_to 'Revenir', profil_path, class: "fr-btn fr-btn--secondary" diff --git a/spec/controllers/administrateurs/api_tokens_controller_spec.rb b/spec/controllers/administrateurs/api_tokens_controller_spec.rb index 551cdfcbd..ae359aa2f 100644 --- a/spec/controllers/administrateurs/api_tokens_controller_spec.rb +++ b/spec/controllers/administrateurs/api_tokens_controller_spec.rb @@ -98,9 +98,10 @@ describe Administrateurs::APITokensController, type: :controller do describe 'update' do let(:token) { APIToken.generate(admin).first } - let(:params) { { name:, networks: } } + let(:params) { { name:, networks:, procedure_to_add: } } let(:name) { 'new name' } let(:networks) { '118.218.200.200' } + let(:procedure_to_add) { nil } subject { patch :update, params: params.merge(id: token.id) } @@ -138,5 +139,30 @@ describe Administrateurs::APITokensController, type: :controller do expect(assigns(:invalid_network_message)).to eq("Vous ne pouvez pas supprimer les restrictions d'accès à l'API d'un jeton permanent.") end end + + context 'with a legitime procedure to add' do + let(:params) { { procedure_to_add: procedure.id } } + + before { subject; token.reload } + + it { expect(token.allowed_procedure_ids).to eq([procedure.id]) } + end + + context 'with a procedure to add not owned by the admin' do + let(:another_procedure) { create(:procedure, administrateurs: [create(:administrateur)]) } + let(:params) { { procedure_to_add: another_procedure.id } } + + before { subject; token.reload } + + it { expect(token.allowed_procedure_ids).to eq([]) } + end + + context 'with an empty procedure to add' do + let(:params) { { procedure_to_add: '' } } + + before { subject; token.reload } + + it { expect(token.allowed_procedure_ids).to eq([]) } + end end end From 775e423914a622267687c068c2a6fe7aaa46250d Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 30 Jan 2024 09:57:43 +0100 Subject: [PATCH 1422/1532] can remove a demarche --- .../administrateurs/api_tokens_controller.rb | 11 ++++++++++- .../administrateurs/api_tokens/edit.html.haml | 14 ++++++++++---- config/routes.rb | 3 +++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/app/controllers/administrateurs/api_tokens_controller.rb b/app/controllers/administrateurs/api_tokens_controller.rb index 9d6403e9b..742f5916c 100644 --- a/app/controllers/administrateurs/api_tokens_controller.rb +++ b/app/controllers/administrateurs/api_tokens_controller.rb @@ -5,7 +5,7 @@ module Administrateurs include ActionView::RecordIdentifier before_action :authenticate_administrateur! - before_action :set_api_token, only: [:edit, :update, :destroy] + before_action :set_api_token, only: [:edit, :update, :destroy, :remove_procedure] def nom @name = name @@ -74,6 +74,15 @@ module Administrateurs render :edit end + def remove_procedure + procedure_id = params[:procedure_id].to_i + @api_token.allowed_procedure_ids = + @api_token.allowed_procedure_ids - [procedure_id] + @api_token.save! + + render turbo_stream: turbo_stream.remove("authorized_procedure_#{procedure_id}") + end + def destroy @api_token.destroy diff --git a/app/views/administrateurs/api_tokens/edit.html.haml b/app/views/administrateurs/api_tokens/edit.html.haml index 1d8e607a9..1d4635e43 100644 --- a/app/views/administrateurs/api_tokens/edit.html.haml +++ b/app/views/administrateurs/api_tokens/edit.html.haml @@ -65,10 +65,16 @@ %button.fr-btn.fr-btn--secondary.fr-ml-1w Ajouter - %ul.fr-mb-4w - - @api_token.procedures.each do |procedure| - %li{ id: dom_id(procedure, :authorized) } - = procedure.libelle + %ul.fr-mb-4w + - @api_token.procedures.each do |procedure| + %li{ id: dom_id(procedure, :authorized) } + = procedure.libelle + = button_to 'Supprimer', + remove_procedure_admin_api_token_path(@api_token, procedure_id: procedure.id), + class: 'fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-btn--icon-left fr-icon-delete-line', + form_class: 'inline', + method: :delete, + form: { data: { turbo: 'true' } } %ul.fr-btns-group.fr-btns-group--inline %li diff --git a/config/routes.rb b/config/routes.rb index e6440634a..4ecc68874 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -744,6 +744,9 @@ Rails.application.routes.draw do end resources :api_tokens, only: [:create, :destroy, :edit, :update] do + member do + delete 'remove_procedure' + end collection do get :nom get :autorisations From d10df6e17ca564092e594f51a999ce367dae1715 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 9 Oct 2024 17:41:46 +0200 Subject: [PATCH 1423/1532] feat(gallery): add attachments from avis --- .../attachment/gallery_item_component.rb | 24 +++++++++- .../instructeurs/dossiers_controller.rb | 11 +++-- .../concerns/blob_image_processor_concern.rb | 6 ++- .../attachment/gallery_item_component_spec.rb | 48 +++++++++++++++++++ .../instructeurs/dossiers_controller_spec.rb | 18 +++++-- 5 files changed, 99 insertions(+), 8 deletions(-) diff --git a/app/components/attachment/gallery_item_component.rb b/app/components/attachment/gallery_item_component.rb index 748387cf4..0aa3e766f 100644 --- a/app/components/attachment/gallery_item_component.rb +++ b/app/components/attachment/gallery_item_component.rb @@ -17,7 +17,13 @@ class Attachment::GalleryItemComponent < ApplicationComponent def gallery_demande? = @gallery_demande def libelle - from_dossier? ? attachment.record.libelle : 'Pièce jointe au message' + if from_dossier? + attachment.record.libelle + elsif from_messagerie? + 'Pièce jointe au message' + elsif from_avis_externe? + 'Pièce jointe à l’avis' + end end def origin @@ -28,6 +34,10 @@ class Attachment::GalleryItemComponent < ApplicationComponent 'Messagerie (instructeur)' when from_messagerie_usager? 'Messagerie (usager)' + when from_avis_externe_instructeur? + 'Avis externe (instructeur)' + when from_avis_externe_expert? + 'Avis externe (expert)' end end @@ -83,4 +93,16 @@ class Attachment::GalleryItemComponent < ApplicationComponent def from_messagerie_usager? from_messagerie? && attachment.record.instructeur.nil? end + + def from_avis_externe? + attachment.record.is_a?(Avis) + end + + def from_avis_externe_instructeur? + from_avis_externe? && attachment.name == 'introduction_file' + end + + def from_avis_externe_expert? + from_avis_externe? && attachment.name == 'piece_justificative_file' + end end diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index a5ac2c0db..58e911b29 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -513,11 +513,16 @@ module Instructeurs .commentaires .includes(piece_jointe_attachments: :blob) .map(&:piece_jointe) - .map(&:attachments) - .flatten + .flat_map(&:attachments) .map(&:id) - champs_attachments_ids + commentaires_attachments_ids + avis_attachments_ids = dossier + .avis.flat_map { [_1.introduction_file, _1.piece_justificative_file] } + .flat_map(&:attachments) + .compact + .map(&:id) + + champs_attachments_ids + commentaires_attachments_ids + avis_attachments_ids end @gallery_attachments = ActiveStorage::Attachment.where(id: gallery_attachments_ids) end diff --git a/app/models/concerns/blob_image_processor_concern.rb b/app/models/concerns/blob_image_processor_concern.rb index 08d6f1067..f42abeee8 100644 --- a/app/models/concerns/blob_image_processor_concern.rb +++ b/app/models/concerns/blob_image_processor_concern.rb @@ -10,7 +10,7 @@ module BlobImageProcessorConcern end def representation_required? - from_champ? || from_messagerie? || logo? || from_action_text? + from_champ? || from_messagerie? || logo? || from_action_text? || from_avis? end private @@ -31,6 +31,10 @@ module BlobImageProcessorConcern attachments.any? { _1.record.class == ActionText::RichText } end + def from_avis? + attachments.any? { _1.record.class == Avis } + end + def watermark_required? attachments.any? { _1.record.class == Champs::TitreIdentiteChamp } end diff --git a/spec/components/attachment/gallery_item_component_spec.rb b/spec/components/attachment/gallery_item_component_spec.rb index 382c6cf10..172ed0574 100644 --- a/spec/components/attachment/gallery_item_component_spec.rb +++ b/spec/components/attachment/gallery_item_component_spec.rb @@ -101,4 +101,52 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do end end end + + context "when attachment is from an avis" do + context 'from an instructeur' do + let(:avis) { create(:avis, :with_introduction, dossier: dossier) } + let(:attachment) { avis.introduction_file.attachment } + + it "displays a generic libelle, link, tag and renders title" do + expect(subject).to have_text('Pièce jointe à l’avis') + expect(subject).to have_link(filename) + expect(subject).to have_text('Avis externe (instructeur)') + expect(component.title).to eq("Pièce jointe à l’avis -- #{filename}") + end + + context "when instructeur has not seen it yet" do + let(:seen_at) { now - 1.day } + + before do + attachment.blob.update(created_at: now) + end + + it 'displays datetime in the right style' do + expect(subject).to have_css('.highlighted') + end + end + + context "when instructeur has already seen it" do + let!(:seen_at) { now } + + before do + freeze_time + attachment.blob.touch(:created_at) + end + + it 'displays datetime in the right style' do + expect(subject).not_to have_css('.highlighted') + end + end + end + + context 'from an expert' do + let(:avis) { create(:avis, :with_piece_justificative, dossier: dossier) } + let(:attachment) { avis.piece_justificative_file.attachment } + + it "displays the right tag" do + expect(subject).to have_text('Avis externe (expert)') + end + end + end end diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index 3ec50f3cc..94edad04e 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -1486,6 +1486,9 @@ describe Instructeurs::DossiersController, type: :controller do let(:logo_path) { 'spec/fixtures/files/logo_test_procedure.png' } let(:rib_path) { 'spec/fixtures/files/RIB.pdf' } let(:commentaire) { create(:commentaire, dossier: dossier) } + let(:expert) { create(:expert) } + let(:experts_procedure) { create(:experts_procedure, expert: expert, procedure: procedure) } + let(:avis) { create(:avis, :with_answer, :with_piece_justificative, dossier: dossier, claimant: expert, experts_procedure: experts_procedure) } before do dossier.champs.first.piece_justificative_file.attach( @@ -1502,20 +1505,29 @@ describe Instructeurs::DossiersController, type: :controller do metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE } ) + avis.piece_justificative_file.attach( + io: File.open(rib_path), + filename: "RIB.pdf", + content_type: "application/pdf", + metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE } + ) + get :pieces_jointes, params: { procedure_id: procedure.id, dossier_id: dossier.id } end - it 'returns pieces jointes from champs and from messagerie' do + it 'returns pieces jointes from champs, messagerie and avis' do expect(response.body).to include('Télécharger le fichier toto.txt') expect(response.body).to include('Télécharger le fichier logo_test_procedure.png') expect(response.body).to include('Télécharger le fichier RIB.pdf') expect(response.body).to include('Visualiser') - expect(assigns(:gallery_attachments).count).to eq 3 + expect(response.body).to include('Pièce jointe au message') + expect(response.body).to include('Pièce jointe à l’avis') + expect(assigns(:gallery_attachments).count).to eq 4 expect(assigns(:gallery_attachments)).to all(be_a(ActiveStorage::Attachment)) - expect([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp, Commentaire]).to include(*assigns(:gallery_attachments).map { _1.record.class }) + expect([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp, Commentaire, Avis]).to include(*assigns(:gallery_attachments).map { _1.record.class }) end end From 8d8e29065947e596c97e8bf82700ec8b7febacff Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 9 Oct 2024 17:54:32 +0200 Subject: [PATCH 1424/1532] db(migration): add last_avis_piece_jointe_updated_at to dossiers --- ...20241009155037_add_last_avis_piece_jointe_updated_at.rb | 7 +++++++ db/schema.rb | 1 + 2 files changed, 8 insertions(+) create mode 100644 db/migrate/20241009155037_add_last_avis_piece_jointe_updated_at.rb diff --git a/db/migrate/20241009155037_add_last_avis_piece_jointe_updated_at.rb b/db/migrate/20241009155037_add_last_avis_piece_jointe_updated_at.rb new file mode 100644 index 000000000..411e59f2a --- /dev/null +++ b/db/migrate/20241009155037_add_last_avis_piece_jointe_updated_at.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddLastAvisPieceJointeUpdatedAt < ActiveRecord::Migration[7.0] + def change + add_column :dossiers, :last_avis_piece_jointe_updated_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index bdb19f2c4..07f4bc83b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -495,6 +495,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do t.string "hidden_by_reason" t.datetime "hidden_by_user_at", precision: nil t.datetime "identity_updated_at", precision: nil + t.datetime "last_avis_piece_jointe_updated_at" t.datetime "last_avis_updated_at", precision: nil t.datetime "last_champ_piece_jointe_updated_at" t.datetime "last_champ_private_updated_at", precision: nil From ad98bafecacba2e8ca7c6ee2e795459b391a9f88 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Thu, 10 Oct 2024 09:41:28 +0200 Subject: [PATCH 1425/1532] feat(gallery): notify instructeur if pieces jointes added in avis --- app/controllers/experts/avis_controller.rb | 7 ++++++- app/models/dossier.rb | 3 ++- app/models/instructeur.rb | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/controllers/experts/avis_controller.rb b/app/controllers/experts/avis_controller.rb index f05d6d411..6d9d737e8 100644 --- a/app/controllers/experts/avis_controller.rb +++ b/app/controllers/experts/avis_controller.rb @@ -104,7 +104,12 @@ module Experts updated_recently = @avis.updated_recently? if @avis.update(avis_params) flash.notice = 'Votre réponse est enregistrée.' - @avis.dossier.update!(last_avis_updated_at: Time.zone.now) + + timestamps = [:last_avis_updated_at, :updated_at] + timestamps << :last_avis_piece_jointe_updated_at if @avis.piece_justificative_file.attached? + + @avis.dossier.touch(*timestamps) + if !updated_recently @avis.dossier.followers_instructeurs .with_instant_expert_avis_email_notifications_enabled diff --git a/app/models/dossier.rb b/app/models/dossier.rb index d27b7e9ad..d4586e55d 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -378,7 +378,8 @@ class Dossier < ApplicationRecord ' OR last_avis_updated_at > follows.avis_seen_at' \ ' OR last_commentaire_updated_at > follows.messagerie_seen_at' \ ' OR last_commentaire_piece_jointe_updated_at > follows.pieces_jointes_seen_at' \ - ' OR last_champ_piece_jointe_updated_at > follows.pieces_jointes_seen_at') + ' OR last_champ_piece_jointe_updated_at > follows.pieces_jointes_seen_at' \ + ' OR last_avis_piece_jointe_updated_at > follows.pieces_jointes_seen_at') .distinct end diff --git a/app/models/instructeur.rb b/app/models/instructeur.rb index 6c9f388af..b84e2c3b7 100644 --- a/app/models/instructeur.rb +++ b/app/models/instructeur.rb @@ -125,7 +125,7 @@ class Instructeur < ApplicationRecord annotations_privees = dossier.last_champ_private_updated_at&.>(follow.annotations_privees_seen_at) || false avis_notif = dossier.last_avis_updated_at&.>(follow.avis_seen_at) || false messagerie = dossier.last_commentaire_updated_at&.>(follow.messagerie_seen_at) || false - pieces_jointes = dossier.last_champ_piece_jointe_updated_at&.>(follow.pieces_jointes_seen_at) || dossier.last_commentaire_piece_jointe_updated_at&.>(follow.pieces_jointes_seen_at) || false + pieces_jointes = dossier.last_champ_piece_jointe_updated_at&.>(follow.pieces_jointes_seen_at) || dossier.last_commentaire_piece_jointe_updated_at&.>(follow.pieces_jointes_seen_at) || dossier.last_avis_piece_jointe_updated_at&.>(follow.pieces_jointes_seen_at) || false annotations_hash(demande, annotations_privees, avis_notif, messagerie, pieces_jointes) else From 2e2d2afecb0e7c6c131f63b5252fbb7edcbf2e73 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Thu, 10 Oct 2024 10:20:47 +0200 Subject: [PATCH 1426/1532] feat(gallery): add attachments from commentaires from experts --- app/components/attachment/gallery_item_component.rb | 8 +++++++- app/controllers/experts/avis_controller.rb | 6 +++++- spec/components/attachment/gallery_item_component_spec.rb | 8 ++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/app/components/attachment/gallery_item_component.rb b/app/components/attachment/gallery_item_component.rb index 0aa3e766f..3319ad3d9 100644 --- a/app/components/attachment/gallery_item_component.rb +++ b/app/components/attachment/gallery_item_component.rb @@ -30,6 +30,8 @@ class Attachment::GalleryItemComponent < ApplicationComponent case when from_dossier? 'Dossier usager' + when from_messagerie_expert? + 'Messagerie (expert)' when from_messagerie_instructeur? 'Messagerie (instructeur)' when from_messagerie_usager? @@ -90,8 +92,12 @@ class Attachment::GalleryItemComponent < ApplicationComponent from_messagerie? && attachment.record.instructeur.present? end + def from_messagerie_expert? + from_messagerie? && attachment.record.expert.present? + end + def from_messagerie_usager? - from_messagerie? && attachment.record.instructeur.nil? + from_messagerie? && attachment.record.instructeur.nil? && attachment.record.expert.nil? end def from_avis_externe? diff --git a/app/controllers/experts/avis_controller.rb b/app/controllers/experts/avis_controller.rb index 6d9d737e8..f994f0e50 100644 --- a/app/controllers/experts/avis_controller.rb +++ b/app/controllers/experts/avis_controller.rb @@ -167,7 +167,11 @@ module Experts @commentaire = CommentaireService.create(current_expert, avis.dossier, commentaire_params) if @commentaire.errors.empty? - @commentaire.dossier.update!(last_commentaire_updated_at: Time.zone.now) + timestamps = [:last_commentaire_updated_at, :updated_at] + timestamps << :last_commentaire_piece_jointe_updated_at if @commentaire.piece_jointe.attached? + + @commentaire.dossier.touch(*timestamps) + flash.notice = "Message envoyé" redirect_to messagerie_expert_avis_path(avis.procedure, avis) else diff --git a/spec/components/attachment/gallery_item_component_spec.rb b/spec/components/attachment/gallery_item_component_spec.rb index 172ed0574..e9fda3802 100644 --- a/spec/components/attachment/gallery_item_component_spec.rb +++ b/spec/components/attachment/gallery_item_component_spec.rb @@ -100,6 +100,14 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do expect(subject).to have_text('Messagerie (instructeur)') end end + + context 'from an expert' do + let(:expert) { create(:expert) } + before { commentaire.update!(expert:) } + it "displays the right tag" do + expect(subject).to have_text('Messagerie (expert)') + end + end end context "when attachment is from an avis" do From 17aaa7b32cf664060edf0be12212a5c061a29fad Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Thu, 10 Oct 2024 10:49:33 +0200 Subject: [PATCH 1427/1532] =?UTF-8?q?feat(gallery):=20display=20right=20ta?= =?UTF-8?q?g=20for=20annotation=20priv=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attachment/gallery_item_component.rb | 18 ++++++-- .../attachment/gallery_item_component_spec.rb | 43 +++++++++++++++++-- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/app/components/attachment/gallery_item_component.rb b/app/components/attachment/gallery_item_component.rb index 3319ad3d9..e3d4f86e3 100644 --- a/app/components/attachment/gallery_item_component.rb +++ b/app/components/attachment/gallery_item_component.rb @@ -17,7 +17,7 @@ class Attachment::GalleryItemComponent < ApplicationComponent def gallery_demande? = @gallery_demande def libelle - if from_dossier? + if from_champ? attachment.record.libelle elsif from_messagerie? 'Pièce jointe au message' @@ -28,8 +28,10 @@ class Attachment::GalleryItemComponent < ApplicationComponent def origin case - when from_dossier? + when from_public_champ? 'Dossier usager' + when from_private_champ? + 'Annotation privée' when from_messagerie_expert? 'Messagerie (expert)' when from_messagerie_instructeur? @@ -64,7 +66,7 @@ class Attachment::GalleryItemComponent < ApplicationComponent end def updated? - from_dossier? && updated_at > attachment.record.dossier.depose_at + from_public_champ? && updated_at > attachment.record.dossier.depose_at end def updated_at @@ -80,10 +82,18 @@ class Attachment::GalleryItemComponent < ApplicationComponent private - def from_dossier? + def from_champ? attachment.record.class.in?([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp]) end + def from_public_champ? + from_champ? && !attachment.record.private? + end + + def from_private_champ? + from_champ? && attachment.record.private? + end + def from_messagerie? attachment.record.is_a?(Commentaire) end diff --git a/spec/components/attachment/gallery_item_component_spec.rb b/spec/components/attachment/gallery_item_component_spec.rb index e9fda3802..df09db179 100644 --- a/spec/components/attachment/gallery_item_component_spec.rb +++ b/spec/components/attachment/gallery_item_component_spec.rb @@ -4,9 +4,10 @@ require 'rails_helper' RSpec.describe Attachment::GalleryItemComponent, type: :component do let(:instructeur) { create(:instructeur) } - let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:procedure) { create(:procedure, :published, types_de_champ_public:, types_de_champ_private:) } let(:types_de_champ_public) { [{ type: :piece_justificative }] } - let(:dossier) { create(:dossier, :with_populated_champs, :en_construction, procedure:) } + let(:types_de_champ_private) { [{ type: :piece_justificative }] } + let(:dossier) { create(:dossier, :with_populated_champs, :with_populated_annotations, :en_construction, procedure:) } let(:filename) { attachment.blob.filename.to_s } let(:gallery_demande) { false } let(:seen_at) { nil } @@ -16,8 +17,10 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do subject { render_inline(component).to_html } - context "when attachment is from a piece justificative champ" do - let(:champ) { dossier.champs.first } + context "when attachment is from a public piece justificative champ" do + let(:champ) do + dossier.champs.where(private: false).first + end let(:libelle) { champ.libelle } let(:attachment) { champ.piece_justificative_file.attachments.first } @@ -56,6 +59,38 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do end end + context "when attachment is from a private piece justificative champ" do + let(:annotation) do + dossier.champs.where(private: true).first + end + let(:libelle) { annotation.libelle } + let(:attachment) { annotation.piece_justificative_file.attachments.first } + + # Correspond au cas standard où le blob est créé avant le dépôt du dossier + before { dossier.touch(:depose_at) } + + it "displays libelle, link, tag and renders title" do + expect(subject).to have_text(libelle) + expect(subject).to have_link(filename) + expect(subject).to have_text('Annotation privée') + expect(component.title).to eq("#{libelle} -- #{filename}") + end + + it "displays when gallery item has been added" do + expect(subject).to have_text('Ajoutée le') + expect(subject).not_to have_css('.highlighted') + expect(subject).to have_text(component.helpers.try_format_datetime(attachment.record.created_at, format: :veryshort)) + end + + context "when gallery item is in page Demande" do + let(:gallery_demande) { true } + + it "does not display libelle" do + expect(subject).not_to have_text(libelle) + end + end + end + context "when attachment is from a commentaire" do let(:commentaire) { create(:commentaire, :with_file, dossier: dossier) } let(:attachment) { commentaire.piece_jointe.first } From efef61c34cbc4500ff2ea8fa700a8c0f01e58529 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Thu, 10 Oct 2024 11:17:02 +0200 Subject: [PATCH 1428/1532] feat(gallery): add attachments from justificatif motivation --- .../attachment/gallery_item_component.rb | 8 ++++++++ .../gallery_item_component.html.haml | 4 ++-- .../instructeurs/dossiers_controller.rb | 7 ++++++- .../concerns/blob_image_processor_concern.rb | 6 +++++- .../attachment/gallery_item_component_spec.rb | 14 ++++++++++++++ 5 files changed, 35 insertions(+), 4 deletions(-) diff --git a/app/components/attachment/gallery_item_component.rb b/app/components/attachment/gallery_item_component.rb index e3d4f86e3..5c4cb8233 100644 --- a/app/components/attachment/gallery_item_component.rb +++ b/app/components/attachment/gallery_item_component.rb @@ -23,6 +23,8 @@ class Attachment::GalleryItemComponent < ApplicationComponent 'Pièce jointe au message' elsif from_avis_externe? 'Pièce jointe à l’avis' + elsif from_justificatif_motivation? + 'Pièce jointe à la décision' end end @@ -42,6 +44,8 @@ class Attachment::GalleryItemComponent < ApplicationComponent 'Avis externe (instructeur)' when from_avis_externe_expert? 'Avis externe (expert)' + when from_justificatif_motivation? + 'Justificatif de décision' end end @@ -121,4 +125,8 @@ class Attachment::GalleryItemComponent < ApplicationComponent def from_avis_externe_expert? from_avis_externe? && attachment.name == 'piece_justificative_file' end + + def from_justificatif_motivation? + attachment.name == 'justificatif_motivation' + end end diff --git a/app/components/attachment/gallery_item_component/gallery_item_component.html.haml b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml index 2428d5822..74fa9f176 100644 --- a/app/components/attachment/gallery_item_component/gallery_item_component.html.haml +++ b/app/components/attachment/gallery_item_component/gallery_item_component.html.haml @@ -9,7 +9,7 @@ Visualiser - if !gallery_demande? .fr-text--sm.fr-mt-2v.fr-mb-1v - = libelle.truncate(25) + = libelle.truncate(30) = render Attachment::ShowComponent.new(attachment:, truncate: true, new_tab: gallery_demande?) - if !gallery_demande? .fr-mt-2v.fr-mb-2v{ class: badge_updated_class } @@ -19,7 +19,7 @@ = image_tag('apercu-indisponible.png') - if !gallery_demande? .fr-text--sm.fr-mt-2v.fr-mb-1v - = libelle.truncate(25) + = libelle.truncate(30) = render Attachment::ShowComponent.new(attachment:, truncate: true, new_tab: gallery_demande?) - if !gallery_demande? .fr-mt-2v.fr-mb-2v{ class: badge_updated_class } diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index 58e911b29..ef1304a36 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -522,7 +522,12 @@ module Instructeurs .compact .map(&:id) - champs_attachments_ids + commentaires_attachments_ids + avis_attachments_ids + justificatif_motivation_id = dossier + .justificatif_motivation + &.attachment + &.id + + champs_attachments_ids + commentaires_attachments_ids + avis_attachments_ids + [justificatif_motivation_id] end @gallery_attachments = ActiveStorage::Attachment.where(id: gallery_attachments_ids) end diff --git a/app/models/concerns/blob_image_processor_concern.rb b/app/models/concerns/blob_image_processor_concern.rb index f42abeee8..7e07b17dc 100644 --- a/app/models/concerns/blob_image_processor_concern.rb +++ b/app/models/concerns/blob_image_processor_concern.rb @@ -10,7 +10,7 @@ module BlobImageProcessorConcern end def representation_required? - from_champ? || from_messagerie? || logo? || from_action_text? || from_avis? + from_champ? || from_messagerie? || logo? || from_action_text? || from_avis? || from_justificatif_motivation? end private @@ -38,4 +38,8 @@ module BlobImageProcessorConcern def watermark_required? attachments.any? { _1.record.class == Champs::TitreIdentiteChamp } end + + def from_justificatif_motivation? + attachments.any? { _1.name == 'justificatif_motivation' } + end end diff --git a/spec/components/attachment/gallery_item_component_spec.rb b/spec/components/attachment/gallery_item_component_spec.rb index df09db179..959c6a279 100644 --- a/spec/components/attachment/gallery_item_component_spec.rb +++ b/spec/components/attachment/gallery_item_component_spec.rb @@ -145,6 +145,20 @@ RSpec.describe Attachment::GalleryItemComponent, type: :component do end end + context "when attachment is from a justificatif motivation" do + let(:fake_justificatif) { fixture_file_upload('spec/fixtures/files/piece_justificative_0.pdf', 'application/pdf') } + let(:attachment) { dossier.justificatif_motivation.attachment } + + before { dossier.update!(justificatif_motivation: fake_justificatif) } + + it "displays a generic libelle, link, tag and renders title" do + expect(subject).to have_text('Justificatif de décision') + expect(subject).to have_link(filename) + expect(subject).to have_text('Pièce jointe à la décision') + expect(component.title).to eq("Pièce jointe à la décision -- #{filename}") + end + end + context "when attachment is from an avis" do context 'from an instructeur' do let(:avis) { create(:avis, :with_introduction, dossier: dossier) } From 2656ec18af13806a3ed9cb9bbf7d62a519f947b4 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 5 Nov 2024 16:57:16 +0100 Subject: [PATCH 1429/1532] chore(image): ignore invalid images files / formats --- app/jobs/image_processor_job.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/jobs/image_processor_job.rb b/app/jobs/image_processor_job.rb index aa39a687e..f112d9fc3 100644 --- a/app/jobs/image_processor_job.rb +++ b/app/jobs/image_processor_job.rb @@ -14,6 +14,10 @@ class ImageProcessorJob < ApplicationJob # (to avoid modifying the file while it is being scanned). retry_on FileNotScannedYetError, wait: :exponentially_longer, attempts: 10 + # Usually invalid image or ImageMagick decoder blocked for this format + retry_on MiniMagick::Invalid, attempts: 3 + retry_on MiniMagick::Error, attempts: 3 + rescue_from ActiveStorage::PreviewError do retry_or_discard end @@ -82,12 +86,8 @@ class ImageProcessorJob < ApplicationJob end def retry_or_discard - if executions < max_attempts + if executions < 3 retry_job wait: 5.minutes end end - - def max_attempts - 3 - end end From e2f3f236de5e29a41ee71a11e7c982aa8aac5610 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 5 Nov 2024 16:57:59 +0100 Subject: [PATCH 1430/1532] chore(image): don't try to process empty images --- app/models/concerns/attachment_image_processor_concern.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/concerns/attachment_image_processor_concern.rb b/app/models/concerns/attachment_image_processor_concern.rb index cd0e3ff65..921d1c5bf 100644 --- a/app/models/concerns/attachment_image_processor_concern.rb +++ b/app/models/concerns/attachment_image_processor_concern.rb @@ -21,6 +21,7 @@ module AttachmentImageProcessorConcern return if blob.attachments.size != 1 return if blob.attachments.last.record_type == "Export" return if !blob.content_type.in?(PROCESSABLE_TYPES) + return if blob.byte_size.zero? # some empty files may be considered as image depending on filename ImageProcessorJob.perform_later(blob) end From 8e3ca472ff7bf04fcf662981ac48a10ae7beaea4 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 5 Nov 2024 18:54:10 +0100 Subject: [PATCH 1431/1532] chore: replace scss-lint (deprecated) by prettier --- .scss-lint.yml | 264 --------------------- Gemfile | 1 - Gemfile.lock | 8 - app/assets/stylesheets/dossier_edit.scss | 2 - app/assets/stylesheets/dsfr.scss | 2 - app/assets/stylesheets/forms.scss | 7 +- app/assets/stylesheets/icons.scss | 2 - app/assets/stylesheets/layouts.scss | 4 - app/assets/stylesheets/procedure_form.scss | 2 - lib/tasks/lint.rake | 2 +- package.json | 1 + 11 files changed, 5 insertions(+), 290 deletions(-) delete mode 100644 .scss-lint.yml diff --git a/.scss-lint.yml b/.scss-lint.yml deleted file mode 100644 index 2c32e2bef..000000000 --- a/.scss-lint.yml +++ /dev/null @@ -1,264 +0,0 @@ -exclude: - - 'app/assets/stylesheets/reset.scss' - - 'app/assets/stylesheets/direct_uploads.scss' - - 'app/assets/stylesheets/dsfr_override.scss' - - 'app/assets/stylesheets/manager.scss' - -linters: - BangFormat: - enabled: true - space_before_bang: true - space_after_bang: false - - BemDepth: - enabled: false - max_elements: 1 - - BorderZero: - enabled: true - convention: none - - # To enable later - ChainedClasses: - enabled: false - - ColorKeyword: - enabled: true - - # To enable later - ColorVariable: - enabled: false - - Comment: - enabled: true - style: silent - - DebugStatement: - enabled: true - - DeclarationOrder: - enabled: true - - DisableLinterReason: - enabled: false - - DuplicateProperty: - enabled: true - - ElsePlacement: - enabled: true - style: same_line - - EmptyLineBetweenBlocks: - enabled: true - ignore_single_line_blocks: false - - EmptyRule: - enabled: true - - ExtendDirective: - enabled: false - - FinalNewline: - enabled: true - present: true - - HexLength: - enabled: true - style: long - - HexNotation: - enabled: true - style: uppercase - - HexValidation: - enabled: true - - # To enable later - IdSelector: - enabled: false - - # To enable later - ImportantRule: - enabled: false - - ImportPath: - enabled: false - leading_underscore: false - filename_extension: false - - Indentation: - enabled: true - allow_non_nested_indentation: false - character: space - width: 2 - - LeadingZero: - enabled: true - style: include_zero - - MergeableSelector: - enabled: false - force_nesting: true - - NameFormat: - enabled: true - allow_leading_underscore: false - convention: hyphenated_lowercase - - # To enable later - NestingDepth: - enabled: false - max_depth: 3 - ignore_parent_selectors: false - - # To enable later - PlaceholderInExtend: - enabled: false - - PrivateNamingConvention: - enabled: false - prefix: _ - - PropertyCount: - enabled: false - include_nested: false - max_properties: 10 - - PropertySortOrder: - enabled: false - ignore_unspecified: false - min_properties: 2 - separate_groups: false - - PropertySpelling: - enabled: true - extra_properties: - - scroll-padding - disabled_properties: [] - - # To enable later - PropertyUnits: - enabled: false - global: [ - 'ch', 'em', 'ex', 'rem', # Font-relative lengths - 'cm', 'in', 'mm', 'pc', 'pt', 'px', 'q', # Absolute lengths - 'vh', 'vw', 'vmin', 'vmax', # Viewport-percentage lengths - 'deg', 'grad', 'rad', 'turn', # Angle - 'ms', 's', # Duration - 'Hz', 'kHz', # Frequency - 'dpi', 'dpcm', 'dppx', # Resolution - '%'] # Other - properties: {} - - PseudoElement: - enabled: false # otherwise rules on ::marker fails - - # To enable later - QualifyingElement: - enabled: false - allow_element_with_attribute: false - allow_element_with_class: false - allow_element_with_id: false - - # To enable later - SelectorDepth: - enabled: false - max_depth: 3 - - SelectorFormat: - enabled: true - # hyphenated_lowercase + any dsfr selector which are not hyphenated - convention: ^(?:fr-[^A-Z]+|[^_A-Z]+)$ - - Shorthand: - enabled: false - allowed_shorthands: [1, 2, 3, 4] - - SingleLinePerProperty: - enabled: true - allow_single_line_rule_sets: false - - SingleLinePerSelector: - enabled: true - - SpaceAfterComma: - enabled: true - style: one_space - - SpaceAfterComment: - enabled: true - style: one_space - allow_empty_comments: true - - SpaceAfterPropertyColon: - enabled: true - style: one_space - - SpaceAfterPropertyName: - enabled: true - - SpaceAfterVariableColon: - enabled: true - style: one_space - - SpaceAfterVariableName: - enabled: true - - SpaceAroundOperator: - enabled: true - style: one_space - - SpaceBeforeBrace: - enabled: true - style: space - allow_single_line_padding: false - - SpaceBetweenParens: - enabled: true - spaces: 0 - - StringQuotes: - enabled: true - style: double_quotes - - TrailingSemicolon: - enabled: true - - TrailingWhitespace: - enabled: true - - TrailingZero: - enabled: true - - # To enable later - TransitionAll: - enabled: false - - UnnecessaryMantissa: - enabled: true - - UnnecessaryParentReference: - enabled: true - - UrlFormat: - enabled: true - - UrlQuotes: - enabled: true - - VariableForProperty: - enabled: false - properties: [] - - VendorPrefix: - enabled: true - identifier_list: base - additional_identifiers: [] - excluded_identifiers: [] - - ZeroUnit: - enabled: false - - Compass::*: - enabled: false diff --git a/Gemfile b/Gemfile index 3903e5db1..3e4bbe79f 100644 --- a/Gemfile +++ b/Gemfile @@ -147,7 +147,6 @@ group :development do gem 'rubocop-performance', require: false gem 'rubocop-rails', require: false gem 'rubocop-rspec', require: false - gem 'scss_lint', require: false gem 'stackprof' gem 'web-console' end diff --git a/Gemfile.lock b/Gemfile.lock index a1c162cbd..fa36c264e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -690,11 +690,6 @@ GEM sanitize (6.1.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) - sass (3.7.4) - sass-listen (~> 4.0.0) - sass-listen (4.0.0) - rb-fsevent (~> 0.9, >= 0.9.4) - rb-inotify (~> 0.9, >= 0.9.7) sassc (2.4.0) ffi (~> 1.9) sassc-rails (2.1.2) @@ -703,8 +698,6 @@ GEM sprockets (> 3.0) sprockets-rails tilt - scss_lint (0.60.0) - sass (~> 3.5, >= 3.5.5) selectize-rails (0.12.6) selenium-devtools (0.126.0) selenium-webdriver (~> 4.2) @@ -997,7 +990,6 @@ DEPENDENCIES rubocop-rspec saml_idp sassc-rails - scss_lint selenium-devtools selenium-webdriver sentry-delayed_job diff --git a/app/assets/stylesheets/dossier_edit.scss b/app/assets/stylesheets/dossier_edit.scss index 6c09c38b1..6f2ea5241 100644 --- a/app/assets/stylesheets/dossier_edit.scss +++ b/app/assets/stylesheets/dossier_edit.scss @@ -51,11 +51,9 @@ $dossier-actions-bar-border-width: 1px; } .dossier-edit-sticky-footer { - // scss-lint:disable VendorPrefix DuplicateProperty position: fixed; // Fallback for IE 11, and other browser that don't support sticky position: -webkit-sticky; // This is needed on Safari (tested on 12.1) position: sticky; - // scss-lint:enable VendorPrefix DuplicateProperty // IE 11 uses `position:fixed` – and thus needs an explicit width, content-box for better layout, etc. width: 100%; diff --git a/app/assets/stylesheets/dsfr.scss b/app/assets/stylesheets/dsfr.scss index fb1ecc189..ffecdcc40 100644 --- a/app/assets/stylesheets/dsfr.scss +++ b/app/assets/stylesheets/dsfr.scss @@ -118,7 +118,6 @@ trix-editor.fr-input { // Fix firefox < 80, Safari < 15.4, Chrome < 83 not supporting "appearance: auto" on inputs // This rule was set by DSFR for DSFR design, but broke our legacy forms. -// scss-lint:disable DuplicateProperty input[type="checkbox"] { -moz-appearance: checkbox; -moz-appearance: auto; @@ -134,7 +133,6 @@ input[type="radio"] { -webkit-appearance: radio; -webkit-appearance: auto; } -// scss-lint:enable DuplicateProperty // remove additional calendar icon on date input already handle by Firefox navigator @-moz-document url-prefix() { diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index fe0141c6c..1dfaaf189 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -52,7 +52,6 @@ // Don't cumulate margin-bottoms for inlined elements (radio...), because .fr-fieldset has already its own // This is important because of multilpe conditional hidden elements to not take additional space, // but we need the usual margin when there are an error or conditional spinner is visible. - // scss-lint:disable SingleLinePerSelector .fr-fieldset__element > .fr-fieldset:not(.fr-fieldset--error):not(:has(+ .spinner)) > .fr-fieldset__element.fr-fieldset__element--inline { @@ -208,7 +207,7 @@ } } - .drop_down_other { // scss-lint:disable SelectorFormat + .drop_down_other { label { font-weight: normal; } @@ -279,7 +278,7 @@ } } - div.field_with_errors > input { // scss-lint:disable SelectorFormat + div.field_with_errors > input { border: 1px solid $dark-red; } @@ -384,7 +383,7 @@ } } - .editable-champ-titre_identite { // scss-lint:disable SelectorFormat + .editable-champ-titre_identite { margin-bottom: 2 * $default-padding; } diff --git a/app/assets/stylesheets/icons.scss b/app/assets/stylesheets/icons.scss index 76f02d614..d8667baad 100644 --- a/app/assets/stylesheets/icons.scss +++ b/app/assets/stylesheets/icons.scss @@ -134,7 +134,6 @@ // 3. Follow the first example : create the class then add the mask-image property with data url you copied // 4. Keep this list alphabetic :) .fr-icon { - // scss-lint:disable VendorPrefix &-align-center { &:before, &:after { @@ -230,5 +229,4 @@ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M8 3V12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12V3H18V12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12V3H8ZM4 20H20V22H4V20Z' fill='currentColor'%3E%3C/path%3E%3C/svg%3E"); } } - // scss-lint:enable VendorPrefix } diff --git a/app/assets/stylesheets/layouts.scss b/app/assets/stylesheets/layouts.scss index a9961e7cd..2432b9554 100644 --- a/app/assets/stylesheets/layouts.scss +++ b/app/assets/stylesheets/layouts.scss @@ -54,17 +54,13 @@ .sticky--top { position: sticky; - // scss-lint:disable VendorPrefix position: -webkit-sticky; // This is needed on Safari (tested on 12.1) - // scss-lint:enable VendorPrefix top: 1rem; } .sticky--bottom { position: sticky; - // scss-lint:disable VendorPrefix position: -webkit-sticky; // This is needed on Safari (tested on 12.1) - // scss-lint:enable VendorPrefix bottom: 0; z-index: 10; // above DSFR btn which are at 1 diff --git a/app/assets/stylesheets/procedure_form.scss b/app/assets/stylesheets/procedure_form.scss index 68c7839c8..94ebefecd 100644 --- a/app/assets/stylesheets/procedure_form.scss +++ b/app/assets/stylesheets/procedure_form.scss @@ -1,8 +1,6 @@ @import "colors"; @import "constants"; -// scss-lint:disable SelectorFormat - .procedure-form .page-title { text-align: left; } diff --git a/lib/tasks/lint.rake b/lib/tasks/lint.rake index a3b2e0ccb..9b0653ce8 100644 --- a/lib/tasks/lint.rake +++ b/lib/tasks/lint.rake @@ -3,11 +3,11 @@ task :lint do sh "bundle exec rubocop --parallel" sh "bundle exec haml-lint app/views/ app/components/" - sh "bundle exec scss-lint app/assets/stylesheets/" sh "bundle exec i18n-tasks missing --locales fr" sh "bundle exec i18n-tasks unused --locale en" # TODO: check for all locales sh "bundle exec i18n-tasks check-consistent-interpolations" sh "bundle exec brakeman --no-pager" sh "bun lint:js" sh "bun lint:types" + sh "bun lint:css" end diff --git a/package.json b/package.json index 5ed33b493..46fe9e104 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "clean": "del tmp public/graphql && bin/vite clobber", "lint:js": "eslint --ext .js,.jsx,.ts,.tsx ./app/javascript", "lint:types": "tsc", + "lint:css": "prettier app/assets/stylesheets --check", "graphql:doc:build": "RAILS_ENV=production bin/rake graphql:schema:idl && spectaql spectaql_config.yml", "postinstall": "patch-package", "test": "vitest", From 0eb28b96077c7755f3dc294b2f68a9563bdbeeb0 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 5 Nov 2024 19:02:31 +0100 Subject: [PATCH 1432/1532] fix(css): apply prettier --- app/assets/stylesheets/01_common.scss | 4 +- app/assets/stylesheets/02_utils.scss | 65 +++++++++++++----- app/assets/stylesheets/_colors.scss | 30 ++++---- app/assets/stylesheets/_mixins.scss | 3 +- app/assets/stylesheets/_placeholders.scss | 9 +-- app/assets/stylesheets/actiontext.scss | 4 +- app/assets/stylesheets/add_instructeur.scss | 4 +- app/assets/stylesheets/agentconnect.scss | 6 +- app/assets/stylesheets/animations.scss | 2 +- app/assets/stylesheets/archive.scss | 2 +- app/assets/stylesheets/attachment.scss | 8 +-- app/assets/stylesheets/attestation.scss | 16 ++--- .../attestation_template_2_edit.scss | 2 +- .../attestation_template_edit.scss | 4 +- app/assets/stylesheets/auth.scss | 14 ++-- app/assets/stylesheets/autosave.scss | 4 +- app/assets/stylesheets/beta.scss | 4 +- app/assets/stylesheets/buttons.scss | 45 ++++++------ app/assets/stylesheets/card.scss | 9 ++- app/assets/stylesheets/card_admin.scss | 4 +- app/assets/stylesheets/carte.scss | 2 +- app/assets/stylesheets/cnaf.scss | 6 +- app/assets/stylesheets/code_blocks.scss | 2 +- app/assets/stylesheets/code_example.scss | 5 +- app/assets/stylesheets/commencer.scss | 2 +- .../stylesheets/conditions_component.scss | 7 +- app/assets/stylesheets/demande.scss | 4 +- app/assets/stylesheets/demarches_index.scss | 2 +- app/assets/stylesheets/dgfip.scss | 6 +- app/assets/stylesheets/direct_uploads.scss | 10 +-- .../dossier_annotations_privees.scss | 4 +- app/assets/stylesheets/dossier_champs.scss | 4 +- app/assets/stylesheets/dossier_edit.scss | 16 ++--- app/assets/stylesheets/dossier_link.scss | 6 +- app/assets/stylesheets/dossier_views.scss | 5 +- app/assets/stylesheets/dossiers_table.scss | 8 +-- app/assets/stylesheets/dsfr.scss | 34 +++++----- app/assets/stylesheets/errors_summary.scss | 5 +- app/assets/stylesheets/exports.scss | 10 ++- app/assets/stylesheets/flex.scss | 2 +- app/assets/stylesheets/forms.scss | 68 ++++++++++--------- app/assets/stylesheets/gallery.scss | 6 +- app/assets/stylesheets/gaps.scss | 2 +- .../groupe_gestionnaire_cards.scss | 2 +- app/assets/stylesheets/help_dropdown.scss | 4 +- app/assets/stylesheets/icons.scss | 54 +++++++-------- app/assets/stylesheets/instructeur.scss | 6 +- app/assets/stylesheets/invites_form.scss | 1 - app/assets/stylesheets/labels.scss | 6 +- app/assets/stylesheets/landing.scss | 26 ++++--- app/assets/stylesheets/layouts.scss | 18 +++-- app/assets/stylesheets/manager.scss | 12 ++-- app/assets/stylesheets/map_info.scss | 12 ++-- app/assets/stylesheets/menu_component.scss | 2 +- app/assets/stylesheets/mesri.scss | 6 +- app/assets/stylesheets/message.scss | 4 +- app/assets/stylesheets/messagerie.scss | 6 +- app/assets/stylesheets/motivation.scss | 4 +- app/assets/stylesheets/new_alert.scss | 8 +-- app/assets/stylesheets/new_footer.scss | 2 +- app/assets/stylesheets/new_header.scss | 7 +- app/assets/stylesheets/pagination.scss | 2 +- .../stylesheets/password_complexity.scss | 34 +++++++--- app/assets/stylesheets/patron.scss | 2 +- .../stylesheets/personnes_impliquees.scss | 4 +- app/assets/stylesheets/pole_emploi.scss | 6 +- app/assets/stylesheets/print.scss | 4 +- app/assets/stylesheets/procedure_admin.scss | 1 - .../stylesheets/procedure_champs_editor.scss | 8 +-- app/assets/stylesheets/procedure_context.scss | 8 +-- app/assets/stylesheets/procedure_form.scss | 8 +-- app/assets/stylesheets/procedure_list.scss | 8 +-- app/assets/stylesheets/procedure_logo.scss | 8 +-- app/assets/stylesheets/procedure_show.scss | 6 +- app/assets/stylesheets/profil.scss | 2 +- app/assets/stylesheets/rich_text.scss | 2 +- .../stylesheets/routing_rules_component.scss | 8 +-- app/assets/stylesheets/sections.scss | 14 ++-- app/assets/stylesheets/services_index.scss | 2 +- app/assets/stylesheets/site_banner.scss | 4 +- app/assets/stylesheets/spinner.scss | 2 +- app/assets/stylesheets/status_overview.scss | 8 +-- app/assets/stylesheets/sticky.scss | 4 +- app/assets/stylesheets/sub_header.scss | 4 +- app/assets/stylesheets/super_admin.scss | 2 +- app/assets/stylesheets/table.scss | 11 +-- app/assets/stylesheets/tags.scss | 32 ++++----- app/assets/stylesheets/tiptap_editor.scss | 2 +- app/assets/stylesheets/title.scss | 2 +- app/assets/stylesheets/toggle-switch.scss | 8 +-- package.json | 1 + 91 files changed, 446 insertions(+), 396 deletions(-) diff --git a/app/assets/stylesheets/01_common.scss b/app/assets/stylesheets/01_common.scss index 94a8a9eb8..850bd6c7c 100644 --- a/app/assets/stylesheets/01_common.scss +++ b/app/assets/stylesheets/01_common.scss @@ -1,4 +1,4 @@ -@import "placeholders"; +@import 'placeholders'; html, body { @@ -7,7 +7,7 @@ body { } // Forces line breaks to prevent buttons from overflowing their container -input[type="submit"] { +input[type='submit'] { white-space: normal; } diff --git a/app/assets/stylesheets/02_utils.scss b/app/assets/stylesheets/02_utils.scss index 891a06575..3d5ae5a13 100644 --- a/app/assets/stylesheets/02_utils.scss +++ b/app/assets/stylesheets/02_utils.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; // floats .pull-left { @@ -142,7 +142,6 @@ } } - // who known .highlighted { background-color: var( @@ -194,13 +193,29 @@ // using $direction.key as css modifier, $direction.values to set css properties // scale it using $steps $directions: ( - "t": ("margin-top"), - "r": ("margin-right"), - "b": ("margin-bottom"), - "l": ("margin-left"), - "x": ("margin-left", "margin-right"), - "y": ("margin-top", "margin-bottom"), - "": ("margin") + 't': ( + 'margin-top' + ), + 'r': ( + 'margin-right' + ), + 'b': ( + 'margin-bottom' + ), + 'l': ( + 'margin-left' + ), + 'x': ( + 'margin-left', + 'margin-right' + ), + 'y': ( + 'margin-top', + 'margin-bottom' + ), + '': ( + 'margin' + ) ); $steps: (0, 1, 2, 3, 4, 5, 6, 7, 8); @@ -215,13 +230,29 @@ $steps: (0, 1, 2, 3, 4, 5, 6, 7, 8); } $directions: ( - "t": ("padding-top"), - "r": ("padding-right"), - "b": ("padding-bottom"), - "l": ("padding-left"), - "x": ("padding-left", "padding-right"), - "y": ("padding-top", "padding-bottom"), - "": ("padding") + 't': ( + 'padding-top' + ), + 'r': ( + 'padding-right' + ), + 'b': ( + 'padding-bottom' + ), + 'l': ( + 'padding-left' + ), + 'x': ( + 'padding-left', + 'padding-right' + ), + 'y': ( + 'padding-top', + 'padding-bottom' + ), + '': ( + 'padding' + ) ); $steps: (0, 1, 2, 3, 4, 5, 6, 7, 8); diff --git a/app/assets/stylesheets/_colors.scss b/app/assets/stylesheets/_colors.scss index 5a3a5838f..dab8b2027 100644 --- a/app/assets/stylesheets/_colors.scss +++ b/app/assets/stylesheets/_colors.scss @@ -1,26 +1,26 @@ -$light-blue: #1C7EC9; -$lighter-blue: #C3D9FF; +$light-blue: #1c7ec9; +$lighter-blue: #c3d9ff; $black: #333333; -$white: #FFFFFF; +$white: #ffffff; $grey: #888888; -$light-grey: #F8F8F8; +$light-grey: #f8f8f8; $dark-grey: #666666; -$border-grey: #CCCCCC; -$dark-red: #A10005; +$border-grey: #cccccc; +$dark-red: #a10005; $medium-red: rgba(161, 0, 5, 0.9); -$light-red: #ED1C24; -$lighter-red: #F52A2A; -$background-red: #FFDFDF; +$light-red: #ed1c24; +$lighter-red: #f52a2a; +$background-red: #ffdfdf; $green: darken(#169862, 5%); -$old-green: #15AD70; +$old-green: #15ad70; $lighter-green: lighten($old-green, 30%); $light-green: lighten($old-green, 25%); $dark-green: darken($old-green, 20%); -$orange: #F28900; +$orange: #f28900; $orange-bg: lighten($orange, 35%); -$yellow: #FEF3B8; -$light-yellow: #FFFFDE; -$blue-france-700: #00006D; +$yellow: #fef3b8; +$light-yellow: #ffffde; +$blue-france-700: #00006d; $blue-france-500: #000091; -$blue-france-400: #7F7FC8; +$blue-france-400: #7f7fc8; $g700: #383838; diff --git a/app/assets/stylesheets/_mixins.scss b/app/assets/stylesheets/_mixins.scss index f561d6d63..8dd06bfe0 100644 --- a/app/assets/stylesheets/_mixins.scss +++ b/app/assets/stylesheets/_mixins.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; @mixin horizontal-padding($value) { padding-left: $value; @@ -22,4 +22,3 @@ background-image: image-url($image-url); } } - diff --git a/app/assets/stylesheets/_placeholders.scss b/app/assets/stylesheets/_placeholders.scss index 5488879d0..e2354708f 100644 --- a/app/assets/stylesheets/_placeholders.scss +++ b/app/assets/stylesheets/_placeholders.scss @@ -1,6 +1,6 @@ -@import "colors"; -@import "mixins"; -@import "constants"; +@import 'colors'; +@import 'mixins'; +@import 'constants'; %horizontal-list { list-style-type: none; @@ -27,7 +27,8 @@ } } -%container { // TODO: switch to new design with preview in two view not in two column https://github.com/betagouv/demarches-simplifiees.fr/issues/7882 +%container { + // TODO: switch to new design with preview in two view not in two column https://github.com/betagouv/demarches-simplifiees.fr/issues/7882 @include horizontal-padding($default-padding); max-width: $page-width + 2 * $default-padding; margin-left: auto; diff --git a/app/assets/stylesheets/actiontext.scss b/app/assets/stylesheets/actiontext.scss index 274f04774..f3c3e970e 100644 --- a/app/assets/stylesheets/actiontext.scss +++ b/app/assets/stylesheets/actiontext.scss @@ -11,9 +11,9 @@ trix-editor { min-height: 10em; - background-color: #FFFFFF; + background-color: #ffffff; } -[data-fr-theme="dark"] .trix-button-group button { +[data-fr-theme='dark'] .trix-button-group button { background: var(--background-action-high-blue-france) !important; } diff --git a/app/assets/stylesheets/add_instructeur.scss b/app/assets/stylesheets/add_instructeur.scss index ba7d7b3cf..9d63b0558 100644 --- a/app/assets/stylesheets/add_instructeur.scss +++ b/app/assets/stylesheets/add_instructeur.scss @@ -1,5 +1,5 @@ -@import "constants"; -@import "colors"; +@import 'constants'; +@import 'colors'; .instructeur-wrapper { .select-instructeurs { diff --git a/app/assets/stylesheets/agentconnect.scss b/app/assets/stylesheets/agentconnect.scss index 350f85d52..3e831839b 100644 --- a/app/assets/stylesheets/agentconnect.scss +++ b/app/assets/stylesheets/agentconnect.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; #agentconnect { .agent { @@ -10,7 +10,7 @@ } .box { - background-color: #F2F2F9; + background-color: #f2f2f9; padding: $default-padding; ul { diff --git a/app/assets/stylesheets/animations.scss b/app/assets/stylesheets/animations.scss index 4cb79aa38..2e2cc0384 100644 --- a/app/assets/stylesheets/animations.scss +++ b/app/assets/stylesheets/animations.scss @@ -1,4 +1,4 @@ -@import "placeholders"; +@import 'placeholders'; @keyframes fade-in-down { 0% { diff --git a/app/assets/stylesheets/archive.scss b/app/assets/stylesheets/archive.scss index 91da1e4ca..09bcb85c5 100644 --- a/app/assets/stylesheets/archive.scss +++ b/app/assets/stylesheets/archive.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; table.archive-table { .text-right { diff --git a/app/assets/stylesheets/attachment.scss b/app/assets/stylesheets/attachment.scss index e8b829486..dc8300d49 100644 --- a/app/assets/stylesheets/attachment.scss +++ b/app/assets/stylesheets/attachment.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .attachment-error, .attachment-upload-error { @@ -8,7 +8,7 @@ &::before { box-shadow: inset 2px 0 0 0 var(--border-plain-error); height: 100%; - content: ""; + content: ''; left: -0.75rem; position: absolute; width: 2px; @@ -26,7 +26,7 @@ } .attachment-multiple:not(.fr-downloads-group), -.attachment-multiple.fr-downloads-group[data-controller=replace-attachment] { +.attachment-multiple.fr-downloads-group[data-controller='replace-attachment'] { ul { list-style-type: none; padding-inline-start: 0; diff --git a/app/assets/stylesheets/attestation.scss b/app/assets/stylesheets/attestation.scss index 7af3840f3..925b943e2 100644 --- a/app/assets/stylesheets/attestation.scss +++ b/app/assets/stylesheets/attestation.scss @@ -1,20 +1,20 @@ @font-face { - font-family: "Marianne"; - src: url("marianne-regular.ttf"); + font-family: 'Marianne'; + src: url('marianne-regular.ttf'); font-weight: normal; font-style: normal; } @font-face { - font-family: "Marianne"; - src: url("marianne-bold.ttf"); + font-family: 'Marianne'; + src: url('marianne-bold.ttf'); font-weight: bold; font-style: normal; } @font-face { - font-family: "Marianne"; - src: url("marianne-thin.ttf"); + font-family: 'Marianne'; + src: url('marianne-thin.ttf'); font-weight: 100; // weasy print n"accepte pas lighter font-style: normal; } @@ -25,7 +25,7 @@ @bottom-center { font-size: 8pt; - content: counter(page) " / " counter(pages); + content: counter(page) ' / ' counter(pages); margin-top: 17mm; white-space: nowrap; } @@ -45,7 +45,7 @@ min-height: 29.7cm; padding: 17mm; margin: 0 auto; - background: #FFFFFF; + background: #ffffff; box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); // Optional: for better visualization position: relative; } diff --git a/app/assets/stylesheets/attestation_template_2_edit.scss b/app/assets/stylesheets/attestation_template_2_edit.scss index 74a0a5684..53103046f 100644 --- a/app/assets/stylesheets/attestation_template_2_edit.scss +++ b/app/assets/stylesheets/attestation_template_2_edit.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; #attestation-edit { .attestation-preview { diff --git a/app/assets/stylesheets/attestation_template_edit.scss b/app/assets/stylesheets/attestation_template_edit.scss index 4c5069655..9829c444c 100644 --- a/app/assets/stylesheets/attestation_template_edit.scss +++ b/app/assets/stylesheets/attestation_template_edit.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; #attestation-template-edit { .text-active { diff --git a/app/assets/stylesheets/auth.scss b/app/assets/stylesheets/auth.scss index af379dcf3..01387789d 100644 --- a/app/assets/stylesheets/auth.scss +++ b/app/assets/stylesheets/auth.scss @@ -1,7 +1,7 @@ -@import "colors"; -@import "constants"; -@import "placeholders"; -@import "mixins"; +@import 'colors'; +@import 'constants'; +@import 'placeholders'; +@import 'mixins'; #auth, #agentconnect { @@ -48,15 +48,15 @@ } .sign-in-form .form { - input[type="email"] { + input[type='email'] { margin-bottom: $default-spacer; } - input[type="password"] { + input[type='password'] { margin-bottom: $default-spacer; } - input[type="checkbox"] { + input[type='checkbox'] { margin-bottom: 0; } } diff --git a/app/assets/stylesheets/autosave.scss b/app/assets/stylesheets/autosave.scss index ed9bf6821..386bedae6 100644 --- a/app/assets/stylesheets/autosave.scss +++ b/app/assets/stylesheets/autosave.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .autosave { position: relative; diff --git a/app/assets/stylesheets/beta.scss b/app/assets/stylesheets/beta.scss index 13dba967a..2ddfd7026 100644 --- a/app/assets/stylesheets/beta.scss +++ b/app/assets/stylesheets/beta.scss @@ -6,8 +6,8 @@ right: -35px; transform: rotate(45deg); width: 150px; - background-color: #008CBA; - color: #FFFFFF; + background-color: #008cba; + color: #ffffff; padding: 5px; font-size: 15px; font-weight: 700; diff --git a/app/assets/stylesheets/buttons.scss b/app/assets/stylesheets/buttons.scss index 1c60811ad..1293eb494 100644 --- a/app/assets/stylesheets/buttons.scss +++ b/app/assets/stylesheets/buttons.scss @@ -1,6 +1,6 @@ -@import "colors"; -@import "constants"; -@import "placeholders"; +@import 'colors'; +@import 'constants'; +@import 'placeholders'; .button { @extend %outline; @@ -11,7 +11,7 @@ border: 1px solid $border-grey; font-size: 14px; line-height: 20px; - background-color: #FFFFFF; + background-color: #ffffff; color: $black; text-align: center; -webkit-appearance: none; @@ -29,7 +29,7 @@ } &.primary { - color: #FFFFFF; + color: #ffffff; border-color: $blue-france-700; background-color: $blue-france-700; @@ -41,10 +41,10 @@ &.secondary { color: $blue-france-700; border-color: $blue-france-700; - background-color: #FFFFFF; + background-color: #ffffff; &:hover:not(:disabled) { - color: #FFFFFF; + color: #ffffff; background: $blue-france-700; } } @@ -52,10 +52,10 @@ &.danger { color: $black; border-color: $border-grey; - background-color: #FFFFFF; + background-color: #ffffff; &:hover:not(:disabled) { - color: #FFFFFF; + color: #ffffff; border-color: $medium-red; background-color: $medium-red; @@ -66,35 +66,35 @@ } &.accepted { - color: #FFFFFF; + color: #ffffff; border-color: $green; background-color: $green; &:hover:not(:disabled) { color: $green; - background-color: #FFFFFF; + background-color: #ffffff; } } &.without-continuation { - color: #FFFFFF; + color: #ffffff; border-color: $black; background-color: $black; &:hover:not(:disabled) { color: $black; - background-color: #FFFFFF; + background-color: #ffffff; } } &.refused { - color: #FFFFFF; + color: #ffffff; border-color: $dark-red; background-color: $dark-red; &:hover:not(:disabled) { color: $dark-red; - background-color: #FFFFFF; + background-color: #ffffff; } } @@ -151,8 +151,8 @@ .dropdown-button { white-space: nowrap; - [aria-hidden="true"].fr-ml-2v::after { - content: "▾"; + [aria-hidden='true'].fr-ml-2v::after { + content: '▾'; } &.icon-only { @@ -172,13 +172,12 @@ } } - -[data-fr-theme="dark"] .dropdown-content { +[data-fr-theme='dark'] .dropdown-content { border: none; background: var(--background-action-low-blue-france); } -[data-fr-theme="dark"] .dropdown-items { +[data-fr-theme='dark'] .dropdown-items { li { &:not(.inactive) { &:hover, @@ -195,7 +194,7 @@ .dropdown-content { border: 1px solid $border-grey; - background: #FFFFFF; + background: #ffffff; box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); position: absolute; right: 0; @@ -343,7 +342,7 @@ ul.dropdown-items { // Make child links fill the whole clickable area > a, - .dropdown-items-link { + .dropdown-items-link { display: flex; flex-grow: 1; margin: -$default-padding; @@ -366,7 +365,7 @@ ul.dropdown-items { } p + h4, - p + p, { + p + p { margin-top: $default-spacer; } } diff --git a/app/assets/stylesheets/card.scss b/app/assets/stylesheets/card.scss index 809d4ce88..8e0459a01 100644 --- a/app/assets/stylesheets/card.scss +++ b/app/assets/stylesheets/card.scss @@ -1,8 +1,7 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; - -[data-fr-theme="dark"] .card { +[data-fr-theme='dark'] .card { background: none; border: 1px solid var(--background-action-low-blue-france); } @@ -11,7 +10,7 @@ padding: ($default-spacer * 3) ($default-spacer * 2); border: 1px solid $border-grey; margin-bottom: $default-spacer * 4; - background: #FFFFFF; + background: #ffffff; .card-title { font-weight: bold; diff --git a/app/assets/stylesheets/card_admin.scss b/app/assets/stylesheets/card_admin.scss index 8f678d3aa..122f9e734 100644 --- a/app/assets/stylesheets/card_admin.scss +++ b/app/assets/stylesheets/card_admin.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .fr-tile-subtitle { min-height: 7rem; diff --git a/app/assets/stylesheets/carte.scss b/app/assets/stylesheets/carte.scss index 372772419..355a575bb 100644 --- a/app/assets/stylesheets/carte.scss +++ b/app/assets/stylesheets/carte.scss @@ -1,4 +1,4 @@ -@import "colors"; +@import 'colors'; .areas { margin-bottom: 10px; diff --git a/app/assets/stylesheets/cnaf.scss b/app/assets/stylesheets/cnaf.scss index 9cbd7ea36..96bb8a0c2 100644 --- a/app/assets/stylesheets/cnaf.scss +++ b/app/assets/stylesheets/cnaf.scss @@ -1,5 +1,5 @@ -@import "constants"; -@import "colors"; +@import 'constants'; +@import 'colors'; table.cnaf { margin: 2 * $default-padding 0 $default-padding $default-padding; @@ -7,7 +7,7 @@ table.cnaf { caption { font-weight: bold; - margin-left: - $default-padding; + margin-left: -$default-padding; margin-bottom: $default-spacer; text-align: left; } diff --git a/app/assets/stylesheets/code_blocks.scss b/app/assets/stylesheets/code_blocks.scss index 8dedf3021..9e3d56f66 100644 --- a/app/assets/stylesheets/code_blocks.scss +++ b/app/assets/stylesheets/code_blocks.scss @@ -1,4 +1,4 @@ -@import "colors"; +@import 'colors'; .code-block { background-color: $black; diff --git a/app/assets/stylesheets/code_example.scss b/app/assets/stylesheets/code_example.scss index 93936618f..503795041 100644 --- a/app/assets/stylesheets/code_example.scss +++ b/app/assets/stylesheets/code_example.scss @@ -1,5 +1,5 @@ -@import "constants"; -@import "colors"; +@import 'constants'; +@import 'colors'; .code-example { background-color: var(--background-contrast-grey); @@ -13,7 +13,6 @@ margin-right: auto; padding: $default-padding; } - } pre { diff --git a/app/assets/stylesheets/commencer.scss b/app/assets/stylesheets/commencer.scss index e8b5f8e3a..4b5982773 100644 --- a/app/assets/stylesheets/commencer.scss +++ b/app/assets/stylesheets/commencer.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .commencer { @media (max-width: 62em) { diff --git a/app/assets/stylesheets/conditions_component.scss b/app/assets/stylesheets/conditions_component.scss index 59f8bf6b9..f1936841f 100644 --- a/app/assets/stylesheets/conditions_component.scss +++ b/app/assets/stylesheets/conditions_component.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; form.form > .conditionnel { .condition-table { @@ -37,7 +37,6 @@ form.form > .conditionnel { th { text-align: left; padding: $default-spacer; - } td { @@ -48,7 +47,7 @@ form.form > .conditionnel { margin-bottom: 0; } - input[type=text] { + input[type='text'] { display: inline-block; margin-bottom: 0; } diff --git a/app/assets/stylesheets/demande.scss b/app/assets/stylesheets/demande.scss index 92be4d4e0..b84008c18 100644 --- a/app/assets/stylesheets/demande.scss +++ b/app/assets/stylesheets/demande.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .dossier-show { .champ-row { diff --git a/app/assets/stylesheets/demarches_index.scss b/app/assets/stylesheets/demarches_index.scss index ab58d5502..ef2e1cf68 100644 --- a/app/assets/stylesheets/demarches_index.scss +++ b/app/assets/stylesheets/demarches_index.scss @@ -1,4 +1,4 @@ -@import "colors"; +@import 'colors'; #demarches-index { margin-bottom: 30px; diff --git a/app/assets/stylesheets/dgfip.scss b/app/assets/stylesheets/dgfip.scss index 2efae24bd..d4e95a4bb 100644 --- a/app/assets/stylesheets/dgfip.scss +++ b/app/assets/stylesheets/dgfip.scss @@ -1,5 +1,5 @@ -@import "constants"; -@import "colors"; +@import 'constants'; +@import 'colors'; table.dgfip { margin: 2 * $default-padding 0 $default-padding $default-padding; @@ -7,7 +7,7 @@ table.dgfip { caption { font-weight: bold; - margin-left: - $default-padding; + margin-left: -$default-padding; margin-bottom: $default-spacer; text-align: left; } diff --git a/app/assets/stylesheets/direct_uploads.scss b/app/assets/stylesheets/direct_uploads.scss index 73785f9cc..595b0ab9b 100644 --- a/app/assets/stylesheets/direct_uploads.scss +++ b/app/assets/stylesheets/direct_uploads.scss @@ -1,4 +1,4 @@ -@import "colors"; +@import 'colors'; .direct-upload { display: inline-block; @@ -20,7 +20,9 @@ left: 0; bottom: 0; background-color: var(--background-contrast-grey); - transition: width 120ms ease-out, opacity 60ms 60ms ease-in; + transition: + width 120ms ease-out, + opacity 60ms 60ms ease-in; transform: translate3d(0, 0, 0); } @@ -36,7 +38,7 @@ border-color: var(--border-plain-error); } -input[type=file][data-direct-upload-url][disabled], -input[type=file][data-auto-attach-url][disabled] { +input[type='file'][data-direct-upload-url][disabled], +input[type='file'][data-auto-attach-url][disabled] { display: none; } diff --git a/app/assets/stylesheets/dossier_annotations_privees.scss b/app/assets/stylesheets/dossier_annotations_privees.scss index 84273ddf5..dd9ea6fdd 100644 --- a/app/assets/stylesheets/dossier_annotations_privees.scss +++ b/app/assets/stylesheets/dossier_annotations_privees.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; #dossier-annotations-privees { h1 { diff --git a/app/assets/stylesheets/dossier_champs.scss b/app/assets/stylesheets/dossier_champs.scss index 86808d340..fbedbee32 100644 --- a/app/assets/stylesheets/dossier_champs.scss +++ b/app/assets/stylesheets/dossier_champs.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .table.dossier-champs { th, diff --git a/app/assets/stylesheets/dossier_edit.scss b/app/assets/stylesheets/dossier_edit.scss index 6f2ea5241..1d4459af8 100644 --- a/app/assets/stylesheets/dossier_edit.scss +++ b/app/assets/stylesheets/dossier_edit.scss @@ -1,9 +1,9 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; $dossier-actions-bar-border-width: 1px; -[data-fr-theme="dark"] .dossier-edit .dossier-edit-sticky-footer { +[data-fr-theme='dark'] .dossier-edit .dossier-edit-sticky-footer { background-color: var(--background-action-low-blue-france); border: none; } @@ -32,7 +32,7 @@ $dossier-actions-bar-border-width: 1px; align-items: baseline; .mandatory-explanation { - flex-grow: 1; // Push the "notice" button to the right + flex-grow: 1; // Push the "notice" button to the right flex-shrink: 1; // Allow the text to shrink margin-bottom: $default-spacer; // Leave space when the "notice" button wraps under the text } @@ -51,8 +51,8 @@ $dossier-actions-bar-border-width: 1px; } .dossier-edit-sticky-footer { - position: fixed; // Fallback for IE 11, and other browser that don't support sticky - position: -webkit-sticky; // This is needed on Safari (tested on 12.1) + position: fixed; // Fallback for IE 11, and other browser that don't support sticky + position: -webkit-sticky; // This is needed on Safari (tested on 12.1) position: sticky; // IE 11 uses `position:fixed` – and thus needs an explicit width, content-box for better layout, etc. @@ -70,9 +70,9 @@ $dossier-actions-bar-border-width: 1px; padding-right: $default-padding - $dossier-actions-bar-border-width; padding-left: $default-padding - $dossier-actions-bar-border-width; - background: #FFFFFF; + background: #ffffff; - border: $dossier-actions-bar-border-width solid #CCCCCC; + border: $dossier-actions-bar-border-width solid #cccccc; border-top-left-radius: 5px; border-top-right-radius: 5px; border-bottom: none; diff --git a/app/assets/stylesheets/dossier_link.scss b/app/assets/stylesheets/dossier_link.scss index ebe2c0bc5..8a09234d3 100644 --- a/app/assets/stylesheets/dossier_link.scss +++ b/app/assets/stylesheets/dossier_link.scss @@ -1,9 +1,9 @@ -@import "constants"; -@import "colors"; +@import 'constants'; +@import 'colors'; .dossier-link { .help-block > p { - margin-top: - $default-padding; + margin-top: -$default-padding; margin-bottom: 2 * $default-padding; } diff --git a/app/assets/stylesheets/dossier_views.scss b/app/assets/stylesheets/dossier_views.scss index e24706016..c5d263017 100644 --- a/app/assets/stylesheets/dossier_views.scss +++ b/app/assets/stylesheets/dossier_views.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .dossier-container { .sub-header { @@ -15,7 +15,6 @@ .header-actions { margin-bottom: $default-spacer; display: flex; - } } diff --git a/app/assets/stylesheets/dossiers_table.scss b/app/assets/stylesheets/dossiers_table.scss index de3caa57c..4cea963a4 100644 --- a/app/assets/stylesheets/dossiers_table.scss +++ b/app/assets/stylesheets/dossiers_table.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .table.dossiers-table { font-size: 14px; @@ -22,7 +22,8 @@ // In order to have identical height in the table header and the table rows, // we compensate for the height difference between the biggest element of the header // (the Personnaliser button, 38px) and the biggest cell-link element of the rows (the label, 28px) - padding: calc((2 * #{$default-spacer}) + ((38px - 28px) / 2)) $default-spacer; + padding: calc((2 * #{$default-spacer}) + ((38px - 28px) / 2)) + $default-spacer; display: block; } @@ -53,7 +54,6 @@ width: 110px; } - .follow-col { width: 450px; diff --git a/app/assets/stylesheets/dsfr.scss b/app/assets/stylesheets/dsfr.scss index ffecdcc40..6478ea3c7 100644 --- a/app/assets/stylesheets/dsfr.scss +++ b/app/assets/stylesheets/dsfr.scss @@ -1,4 +1,4 @@ -@import "colors"; +@import 'colors'; // overwrite DSFR style for SimpleFormatComponent, some user use markdown with // ordered list having paragraph between list item @@ -47,19 +47,19 @@ trix-editor.fr-input { } .fr-ds-combobox__menu { - &[data-placement=top] { + &[data-placement='top'] { --origin: translateY(8px); } - &[data-placement=bottom] { + &[data-placement='bottom'] { --origin: translateY(-8px); } - &[data-placement=right] { + &[data-placement='right'] { --origin: translateX(-8px); } - &[data-placement=left] { + &[data-placement='left'] { --origin: translateX(8px); } @@ -118,7 +118,7 @@ trix-editor.fr-input { // Fix firefox < 80, Safari < 15.4, Chrome < 83 not supporting "appearance: auto" on inputs // This rule was set by DSFR for DSFR design, but broke our legacy forms. -input[type="checkbox"] { +input[type='checkbox'] { -moz-appearance: checkbox; -moz-appearance: auto; @@ -126,7 +126,7 @@ input[type="checkbox"] { -webkit-appearance: auto; } -input[type="radio"] { +input[type='radio'] { -moz-appearance: radio; -moz-appearance: auto; @@ -136,19 +136,20 @@ input[type="radio"] { // remove additional calendar icon on date input already handle by Firefox navigator @-moz-document url-prefix() { - .fr-input[type="date"] { + .fr-input[type='date'] { background-image: none; } } -.fr-btn.fr-btn--icon-left[target="_blank"] { +.fr-btn.fr-btn--icon-left[target='_blank'] { &::after { display: none; } } // dans le DSFR il est possible d'avoir un bouton seulement avec une icone mais j'ai du surcharger ici pour eviter d'avoir des marges de l'icone. Je n'ai pas bien compris pourquoi -.fr-btns-group--sm.fr-btns-group--icon-right .fr-btn[class*=" fr-icon-"].icon-only::after { +.fr-btns-group--sm.fr-btns-group--icon-right + .fr-btn[class*=' fr-icon-'].icon-only::after { margin-left: 0; margin-right: 0; } @@ -175,11 +176,11 @@ input[type="radio"] { button.fr-tag-bug { background-color: $blue-france-500; - color: #FFFFFF; + color: #ffffff; &:hover { - background-color: #1212FF; - color: #FFFFFF; + background-color: #1212ff; + color: #ffffff; } .tag-dismiss { @@ -195,11 +196,10 @@ button.fr-tag-bug { grid-auto-flow: column; } -.fr-translate__language[aria-current]:not([aria-current="false"]) { +.fr-translate__language[aria-current]:not([aria-current='false']) { display: inline-flex; } - // on veut ajouter un gris plus clair dans le side_menu .fr-sidemenu__item .fr-sidemenu__link.custom-link-grey { color: var(--text-disabled-grey); @@ -220,11 +220,11 @@ button.fr-tag-bug { border: 2px solid var(--border-action-high-grey); } - .fr-radio-group input[type="radio"] { + .fr-radio-group input[type='radio'] { opacity: 1; } - .fr-tabs__tab[aria-selected=true]:not(:disabled) { + .fr-tabs__tab[aria-selected='true']:not(:disabled) { border: 5px solid var(--border-action-high-grey); } diff --git a/app/assets/stylesheets/errors_summary.scss b/app/assets/stylesheets/errors_summary.scss index 54843e294..e2be31dc5 100644 --- a/app/assets/stylesheets/errors_summary.scss +++ b/app/assets/stylesheets/errors_summary.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .errors-summary { background: $background-red; @@ -9,4 +9,3 @@ padding: $default-spacer; } } - diff --git a/app/assets/stylesheets/exports.scss b/app/assets/stylesheets/exports.scss index ff734fea2..4f16cb599 100644 --- a/app/assets/stylesheets/exports.scss +++ b/app/assets/stylesheets/exports.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .export-template-preview { // From https://codepen.io/myramoki/pen/xZJjrr @@ -19,7 +19,7 @@ .tree:before, .tree ul:before { - content: ""; + content: ''; display: block; width: 0; position: absolute; @@ -45,7 +45,7 @@ } .tree li:before { - content: ""; + content: ''; display: block; width: 10px; // same with indentation height: 0; @@ -61,9 +61,7 @@ } .tree li:last-child:before { - background: var( - --background-alt-blue-france - ); // same with body background + background: var(--background-alt-blue-france); // same with body background height: auto; top: 1em; // (line-height/2) bottom: 0; diff --git a/app/assets/stylesheets/flex.scss b/app/assets/stylesheets/flex.scss index 484fbda11..9e4ccf324 100644 --- a/app/assets/stylesheets/flex.scss +++ b/app/assets/stylesheets/flex.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .flex { display: flex; diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index 1dfaaf189..ce83a13bb 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -1,6 +1,6 @@ -@import "constants"; -@import "colors"; -@import "placeholders"; +@import 'constants'; +@import 'colors'; +@import 'placeholders'; .form { input.unstyled { @@ -45,7 +45,11 @@ } // Keep only bottom margin in nested (consecutive) header sections, ie. first legend for a same level - .fr-fieldset > .fr-fieldset__legend + .fr-fieldset__element > .fr-fieldset:first-of-type .header-section { + .fr-fieldset + > .fr-fieldset__legend + + .fr-fieldset__element + > .fr-fieldset:first-of-type + .header-section { margin-top: 0 !important; } @@ -81,7 +85,7 @@ &.required { &::after { color: $dark-red; - content: " *"; + content: ' *'; } } } @@ -93,7 +97,7 @@ } .notice { - margin-top: - $default-spacer; + margin-top: -$default-spacer; margin-bottom: $default-padding; color: var(--text-mention-grey); @@ -128,7 +132,7 @@ gap: 0.25rem; // Space before mandatory icon because dsfr set display:flex on checkbox label } - input[type=checkbox] { + input[type='checkbox'] { position: absolute; top: 3px; left: 0px; @@ -168,7 +172,8 @@ } label { - padding: $default-padding $default-padding $default-padding $default-spacer; + padding: $default-padding $default-padding $default-padding + $default-spacer; border: 1px solid $border-grey; border-radius: 4px; font-weight: normal; @@ -197,7 +202,7 @@ font-style: italic; } - input[type=radio] { + input[type='radio'] { margin-bottom: 0; } @@ -222,7 +227,7 @@ padding: inherit; } - input[type=password], + input[type='password'], select:not(.fr-select) { display: block; margin-bottom: 0; @@ -241,14 +246,13 @@ } } - - input[type=checkbox] { + input[type='checkbox'] { &.small-margin { margin-bottom: $default-spacer; } } - input[type=text]:not(.fr-input):not(.fr-select) { + input[type='text']:not(.fr-input):not(.fr-select) { border: solid 1px $border-grey; padding: $default-padding; @@ -282,14 +286,14 @@ border: 1px solid $dark-red; } - input[type=text], - input[type=email], - input[type=password], - input[type=date], - input[type=number], - input[type=datetime-local], + input[type='text'], + input[type='email'], + input[type='password'], + input[type='date'], + input[type='number'], + input[type='datetime-local'], textarea, - input[type=tel] { + input[type='tel'] { @media (max-width: $two-columns-breakpoint) { width: 100%; } @@ -303,17 +307,17 @@ } @media (min-width: $two-columns-breakpoint) { - input[type=email], - input[type=password], - input[type=number], - input[inputmode=numeric], - input[inputmode=decimal], - input[type=tel] { + input[type='email'], + input[type='password'], + input[type='number'], + input[inputmode='numeric'], + input[inputmode='decimal'], + input[type='tel'] { max-width: 500px; } } - input[type=date] { + input[type='date'] { max-width: 180px; } @@ -341,8 +345,8 @@ } } - input[type=checkbox], - input[type=radio] { + input[type='checkbox'], + input[type='radio'] { @extend %outline; // Firefox tends to display some controls smaller than other browsers. @@ -378,7 +382,9 @@ } } - .utils-repetition-required .row:first-child .utils-repetition-required-destroy-button { + .utils-repetition-required + .row:first-child + .utils-repetition-required-destroy-button { display: none; } } @@ -484,7 +490,7 @@ &:before, &:after { font-weight: bold; - content: "/"; + content: '/'; } } diff --git a/app/assets/stylesheets/gallery.scss b/app/assets/stylesheets/gallery.scss index 3c8331912..cf7dad075 100644 --- a/app/assets/stylesheets/gallery.scss +++ b/app/assets/stylesheets/gallery.scss @@ -95,6 +95,10 @@ } .lg-sub-html { - background-image: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1)) !important; + background-image: linear-gradient( + 180deg, + rgba(0, 0, 0, 0), + rgba(0, 0, 0, 1) + ) !important; padding: 30px 40px 0 40px !important; } diff --git a/app/assets/stylesheets/gaps.scss b/app/assets/stylesheets/gaps.scss index 0d2dc2226..34a57b9d1 100644 --- a/app/assets/stylesheets/gaps.scss +++ b/app/assets/stylesheets/gaps.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .gap-left { margin-left: $default-spacer; diff --git a/app/assets/stylesheets/groupe_gestionnaire_cards.scss b/app/assets/stylesheets/groupe_gestionnaire_cards.scss index 4ddeefdf3..17eeeaca4 100644 --- a/app/assets/stylesheets/groupe_gestionnaire_cards.scss +++ b/app/assets/stylesheets/groupe_gestionnaire_cards.scss @@ -3,7 +3,7 @@ padding-left: 20px; padding-right: 20px; position: relative; -} + } .notifications { top: 3px; diff --git a/app/assets/stylesheets/help_dropdown.scss b/app/assets/stylesheets/help_dropdown.scss index 7246675b4..46c7fabd0 100644 --- a/app/assets/stylesheets/help_dropdown.scss +++ b/app/assets/stylesheets/help_dropdown.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .help-dropdown { .dropdown-content { diff --git a/app/assets/stylesheets/icons.scss b/app/assets/stylesheets/icons.scss index d8667baad..74562860d 100644 --- a/app/assets/stylesheets/icons.scss +++ b/app/assets/stylesheets/icons.scss @@ -12,114 +12,114 @@ } &.follow { - background-image: image-url("icons/follow-folder.svg"); + background-image: image-url('icons/follow-folder.svg'); } &.unfollow { - background-image: image-url("icons/unfollow-folder.svg"); + background-image: image-url('icons/unfollow-folder.svg'); } &.standby { - background-image: image-url("icons/standby.svg"); + background-image: image-url('icons/standby.svg'); } &.unarchive { - background-image: image-url("icons/unarchive.svg"); + background-image: image-url('icons/unarchive.svg'); } &.edit { - background-image: image-url("icons/edit-folder-blue.svg"); + background-image: image-url('icons/edit-folder-blue.svg'); } &.bubble { - background-image: image-url("icons/bubble.svg"); + background-image: image-url('icons/bubble.svg'); } &.attached { - background-image: image-url("icons/attached.svg"); + background-image: image-url('icons/attached.svg'); } &.preview { - background-image: image-url("icons/preview.svg"); + background-image: image-url('icons/preview.svg'); } &.retry { - background-image: image-url("icons/retry.svg"); + background-image: image-url('icons/retry.svg'); } &.download { - background-image: image-url("icons/download.svg"); + background-image: image-url('icons/download.svg'); } &.lock { - background-image: image-url("icons/lock.svg"); + background-image: image-url('icons/lock.svg'); } &.add { - background-image: image-url("icons/add.svg"); + background-image: image-url('icons/add.svg'); margin-left: -5px; margin-right: 0px; } &.justificatif { - background-image: image-url("icons/justificatif.svg"); + background-image: image-url('icons/justificatif.svg'); } &.printer { - background-image: image-url("icons/printer.svg"); + background-image: image-url('icons/printer.svg'); } &.account { - background-image: image-url("icons/account-circle.svg"); + background-image: image-url('icons/account-circle.svg'); } &.super-admin { - background-image: image-url("icons/super-admin.svg"); + background-image: image-url('icons/super-admin.svg'); } &.mail { - background-image: image-url("icons/mail.svg"); + background-image: image-url('icons/mail.svg'); } &.reply { - background-image: image-url("icons/reply.svg"); + background-image: image-url('icons/reply.svg'); } &.search { - background-image: image-url("icons/search-blue.svg"); + background-image: image-url('icons/search-blue.svg'); } &.sign-out { - background-image: image-url("icons/sign-out.svg"); + background-image: image-url('icons/sign-out.svg'); } &.info { - background-image: image-url("icons/info-blue.svg"); + background-image: image-url('icons/info-blue.svg'); object-fit: contain; } &.help { - background-image: image-url("icons/help.svg"); + background-image: image-url('icons/help.svg'); } &.phone { - background-image: image-url("icons/phone.svg"); + background-image: image-url('icons/phone.svg'); } &.clock { - background-image: image-url("icons/clock.svg"); + background-image: image-url('icons/clock.svg'); } &.smile { - background-image: image-url("icons/smile-regular.svg"); + background-image: image-url('icons/smile-regular.svg'); } &.frown { - background-image: image-url("icons/frown-regular.svg"); + background-image: image-url('icons/frown-regular.svg'); } &.meh { - background-image: image-url("icons/meh-regular.svg"); + background-image: image-url('icons/meh-regular.svg'); } &.mandatory { diff --git a/app/assets/stylesheets/instructeur.scss b/app/assets/stylesheets/instructeur.scss index 8d95fdd70..b42345d6a 100644 --- a/app/assets/stylesheets/instructeur.scss +++ b/app/assets/stylesheets/instructeur.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .page-title { font-size: 30px; @@ -58,7 +58,7 @@ right: 0; top: 45px; font-size: 14px; - background: #FFFFFF; + background: #ffffff; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); border: 1px solid $border-grey; min-width: 270px; diff --git a/app/assets/stylesheets/invites_form.scss b/app/assets/stylesheets/invites_form.scss index b0fc6c938..16263e917 100644 --- a/app/assets/stylesheets/invites_form.scss +++ b/app/assets/stylesheets/invites_form.scss @@ -1,4 +1,3 @@ - #invites-form { @media (min-width: 48em) { min-width: 400px; diff --git a/app/assets/stylesheets/labels.scss b/app/assets/stylesheets/labels.scss index cdfa65a5e..ab6e4bd82 100644 --- a/app/assets/stylesheets/labels.scss +++ b/app/assets/stylesheets/labels.scss @@ -1,12 +1,12 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .label { display: inline-block; padding: 4px $default-spacer; background: $dark-grey; border: 1px solid transparent; - color: #FFFFFF; + color: #ffffff; border-radius: 4px; font-size: 12px; line-height: 18px; diff --git a/app/assets/stylesheets/landing.scss b/app/assets/stylesheets/landing.scss index 650e06d8f..9d42ad829 100644 --- a/app/assets/stylesheets/landing.scss +++ b/app/assets/stylesheets/landing.scss @@ -1,7 +1,7 @@ -@import "constants"; -@import "colors"; -@import "mixins"; -@import "placeholders"; +@import 'constants'; +@import 'colors'; +@import 'mixins'; +@import 'placeholders'; $landing-breakpoint: 1040px; @@ -90,7 +90,7 @@ $landing-breakpoint: 1040px; @extend %horizontal-list-item; max-width: 500px; width: 100%; - background-color: #FFFFFF; + background-color: #ffffff; box-shadow: 0 4px 16px 0 rgba(153, 153, 153, 0.2); padding: 24px; display: flex; @@ -125,7 +125,6 @@ $landing-breakpoint: 1040px; } .landing { - .numbers { @extend %horizontal-list; justify-content: space-around; @@ -167,8 +166,8 @@ $landing-breakpoint: 1040px; } } -html[lang="fr"] .landing .number-label-third::before { - content: "de "; +html[lang='fr'] .landing .number-label-third::before { + content: 'de '; } $users-breakpoint: 950px; @@ -248,7 +247,6 @@ $users-breakpoint: 950px; } } - .role-administrations-image { text-align: right; @@ -289,22 +287,22 @@ $cta-panel-button-border-size: 2px; .cta-panel-button-white { @include cta-panel-button; - border: $cta-panel-button-border-size solid #FFFFFF; - color: #FFFFFF; + border: $cta-panel-button-border-size solid #ffffff; + color: #ffffff; &:hover { - color: #FFFFFF; + color: #ffffff; text-decoration: none; background-color: rgba(255, 255, 255, 0.2); } &:focus { - color: #FFFFFF; + color: #ffffff; text-decoration: none; } &:active, &:focus { - outline: 3px solid #FFFFFF; + outline: 3px solid #ffffff; } } diff --git a/app/assets/stylesheets/layouts.scss b/app/assets/stylesheets/layouts.scss index 2432b9554..27dfa40ff 100644 --- a/app/assets/stylesheets/layouts.scss +++ b/app/assets/stylesheets/layouts.scss @@ -1,14 +1,20 @@ -@import "colors"; -@import "constants"; -@import "placeholders"; +@import 'colors'; +@import 'constants'; +@import 'placeholders'; .two-columns { - @media (min-width: $two-columns-breakpoint) { - background: linear-gradient(to right, transparent 0%, transparent 50%, var(--background-alt-blue-france) 50%, var(--background-alt-blue-france) 100%); + background: linear-gradient( + to right, + transparent 0%, + transparent 50%, + var(--background-alt-blue-france) 50%, + var(--background-alt-blue-france) 100% + ); } - .columns-container { // TODO: https://github.com/betagouv/demarches-simplifiees.fr/issues/7882, once implemented, we won't need container anymore + .columns-container { + // TODO: https://github.com/betagouv/demarches-simplifiees.fr/issues/7882, once implemented, we won't need container anymore @extend %container; display: flex; flex-direction: column; diff --git a/app/assets/stylesheets/manager.scss b/app/assets/stylesheets/manager.scss index e6fe59c3b..a81bf77d7 100644 --- a/app/assets/stylesheets/manager.scss +++ b/app/assets/stylesheets/manager.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .hidden { display: none; @@ -21,7 +21,7 @@ } .manager-mandatory { - color: #A10005; + color: #a10005; font-size: 18px; } @@ -70,19 +70,19 @@ } .fr-ds-combobox__menu { - &[data-placement=top] { + &[data-placement='top'] { --origin: translateY(8px); } - &[data-placement=bottom] { + &[data-placement='bottom'] { --origin: translateY(-8px); } - &[data-placement=right] { + &[data-placement='right'] { --origin: translateX(-8px); } - &[data-placement=left] { + &[data-placement='left'] { --origin: translateX(8px); } diff --git a/app/assets/stylesheets/map_info.scss b/app/assets/stylesheets/map_info.scss index 00f7442c7..f1c7ee819 100644 --- a/app/assets/stylesheets/map_info.scss +++ b/app/assets/stylesheets/map_info.scss @@ -1,10 +1,10 @@ -@import "colors"; +@import 'colors'; -$dep-nothing: #E3E3FD; // blue-france-925 -$dep-small: #CACAFB; // blue-france-850 -$dep-medium: #8585F6; // blue-france-625 -$dep-large: #313178; // blue-france-200 -$dep-xlarge: #272747; // blue-france-125 +$dep-nothing: #e3e3fd; // blue-france-925 +$dep-small: #cacafb; // blue-france-850 +$dep-medium: #8585f6; // blue-france-625 +$dep-large: #313178; // blue-france-200 +$dep-xlarge: #272747; // blue-france-125 #map-svg { max-width: 100%; diff --git a/app/assets/stylesheets/menu_component.scss b/app/assets/stylesheets/menu_component.scss index 32438e088..378b23163 100644 --- a/app/assets/stylesheets/menu_component.scss +++ b/app/assets/stylesheets/menu_component.scss @@ -1,4 +1,4 @@ -@import "colors"; +@import 'colors'; .menu-component-header { font-size: 12px; diff --git a/app/assets/stylesheets/mesri.scss b/app/assets/stylesheets/mesri.scss index 62f3891c1..37ac9f7dc 100644 --- a/app/assets/stylesheets/mesri.scss +++ b/app/assets/stylesheets/mesri.scss @@ -1,5 +1,5 @@ -@import "constants"; -@import "colors"; +@import 'constants'; +@import 'colors'; table.mesri { margin: 2 * $default-padding 0 $default-padding $default-padding; @@ -7,7 +7,7 @@ table.mesri { caption { font-weight: bold; - margin-left: - $default-padding; + margin-left: -$default-padding; margin-bottom: $default-spacer; text-align: left; } diff --git a/app/assets/stylesheets/message.scss b/app/assets/stylesheets/message.scss index 8595b0348..ccc3a1608 100644 --- a/app/assets/stylesheets/message.scss +++ b/app/assets/stylesheets/message.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .message { display: flex; diff --git a/app/assets/stylesheets/messagerie.scss b/app/assets/stylesheets/messagerie.scss index ded8156aa..58e6a0518 100644 --- a/app/assets/stylesheets/messagerie.scss +++ b/app/assets/stylesheets/messagerie.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .messages-list { max-height: 350px; @@ -23,7 +23,7 @@ margin-bottom: $default-spacer; } - .form input[type="file"] { + .form input[type='file'] { margin-bottom: 0; } } diff --git a/app/assets/stylesheets/motivation.scss b/app/assets/stylesheets/motivation.scss index a9199e682..caf904150 100644 --- a/app/assets/stylesheets/motivation.scss +++ b/app/assets/stylesheets/motivation.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .motivation { width: 450px; diff --git a/app/assets/stylesheets/new_alert.scss b/app/assets/stylesheets/new_alert.scss index 6ab745363..5fc14fff0 100644 --- a/app/assets/stylesheets/new_alert.scss +++ b/app/assets/stylesheets/new_alert.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .alert { padding: 15px; @@ -8,10 +8,10 @@ .alert-danger { background-color: $medium-red; - color: #FFFFFF; + color: #ffffff; a { - color: #FFFFFF; + color: #ffffff; } } diff --git a/app/assets/stylesheets/new_footer.scss b/app/assets/stylesheets/new_footer.scss index 68cfeffba..656cb0e1f 100644 --- a/app/assets/stylesheets/new_footer.scss +++ b/app/assets/stylesheets/new_footer.scss @@ -1,4 +1,4 @@ -@import "mixins"; +@import 'mixins'; .landing-footer { @include vertical-padding(72px); diff --git a/app/assets/stylesheets/new_header.scss b/app/assets/stylesheets/new_header.scss index 77f4bff98..62804bdcf 100644 --- a/app/assets/stylesheets/new_header.scss +++ b/app/assets/stylesheets/new_header.scss @@ -27,7 +27,12 @@ } // Add space between button edge and content - .fr-container .fr-header__menu-links .fr-btns-group.flex.align-center .fr-translate.fr-nav .fr-nav__item .fr-translate__btn.fr-btn { + .fr-container + .fr-header__menu-links + .fr-btns-group.flex.align-center + .fr-translate.fr-nav + .fr-nav__item + .fr-translate__btn.fr-btn { margin-right: 0; margin-left: 0; padding-right: 0.5rem; diff --git a/app/assets/stylesheets/pagination.scss b/app/assets/stylesheets/pagination.scss index 464ed74ab..b9faa9bf4 100644 --- a/app/assets/stylesheets/pagination.scss +++ b/app/assets/stylesheets/pagination.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .pagination { text-align: center; diff --git a/app/assets/stylesheets/password_complexity.scss b/app/assets/stylesheets/password_complexity.scss index ee521b0b3..6e68056ce 100644 --- a/app/assets/stylesheets/password_complexity.scss +++ b/app/assets/stylesheets/password_complexity.scss @@ -1,11 +1,11 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; -$complexity-bg: #EEEEEE; +$complexity-bg: #eeeeee; $complexity-color-0: $lighter-red; -$complexity-color-1: #FF5000; +$complexity-color-1: #ff5000; $complexity-color-2: $orange; -$complexity-color-3: #FFD000; +$complexity-color-3: #ffd000; $complexity-color-4: $green; .password-complexity { @@ -17,19 +17,35 @@ $complexity-color-4: $green; border-radius: 8px; &.complexity-0 { - background: linear-gradient(to right, $complexity-color-0 00%, $complexity-bg 20%); + background: linear-gradient( + to right, + $complexity-color-0 00%, + $complexity-bg 20% + ); } &.complexity-1 { - background: linear-gradient(to right, $complexity-color-1 20%, $complexity-bg 40%); + background: linear-gradient( + to right, + $complexity-color-1 20%, + $complexity-bg 40% + ); } &.complexity-2 { - background: linear-gradient(to right, $complexity-color-2 40%, $complexity-bg 60%); + background: linear-gradient( + to right, + $complexity-color-2 40%, + $complexity-bg 60% + ); } &.complexity-3 { - background: linear-gradient(to right, $complexity-color-3 60%, $complexity-bg 80%); + background: linear-gradient( + to right, + $complexity-color-3 60%, + $complexity-bg 80% + ); } &.complexity-4 { diff --git a/app/assets/stylesheets/patron.scss b/app/assets/stylesheets/patron.scss index 22f745a69..148340966 100644 --- a/app/assets/stylesheets/patron.scss +++ b/app/assets/stylesheets/patron.scss @@ -1,4 +1,4 @@ -@import "colors"; +@import 'colors'; .patron { .patron-section { diff --git a/app/assets/stylesheets/personnes_impliquees.scss b/app/assets/stylesheets/personnes_impliquees.scss index 42f3d07f8..ec404fcb0 100644 --- a/app/assets/stylesheets/personnes_impliquees.scss +++ b/app/assets/stylesheets/personnes_impliquees.scss @@ -1,5 +1,5 @@ -@import "constants"; -@import "colors"; +@import 'constants'; +@import 'colors'; .personnes-impliquees { padding-bottom: 50px; diff --git a/app/assets/stylesheets/pole_emploi.scss b/app/assets/stylesheets/pole_emploi.scss index c51ee0103..9a09dfa3d 100644 --- a/app/assets/stylesheets/pole_emploi.scss +++ b/app/assets/stylesheets/pole_emploi.scss @@ -1,5 +1,5 @@ -@import "constants"; -@import "colors"; +@import 'constants'; +@import 'colors'; table.pole-emploi { margin: 2 * $default-padding 0 $default-padding $default-padding; @@ -7,7 +7,7 @@ table.pole-emploi { caption { font-weight: bold; - margin-left: - $default-padding; + margin-left: -$default-padding; margin-bottom: $default-spacer; text-align: left; } diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index ec327f818..4997c6825 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -1,4 +1,4 @@ -@import "colors"; +@import 'colors'; @media print { .new-header, @@ -16,7 +16,7 @@ } body { - font-family: "Marianne"; + font-family: 'Marianne'; } .subtitle { diff --git a/app/assets/stylesheets/procedure_admin.scss b/app/assets/stylesheets/procedure_admin.scss index 47b8a0d6c..39f18528a 100644 --- a/app/assets/stylesheets/procedure_admin.scss +++ b/app/assets/stylesheets/procedure_admin.scss @@ -29,5 +29,4 @@ li { font-size: 14px; } - } diff --git a/app/assets/stylesheets/procedure_champs_editor.scss b/app/assets/stylesheets/procedure_champs_editor.scss index 0423a6faf..ab4db4560 100644 --- a/app/assets/stylesheets/procedure_champs_editor.scss +++ b/app/assets/stylesheets/procedure_champs_editor.scss @@ -1,6 +1,6 @@ -@import "colors"; -@import "constants"; -@import "placeholders"; +@import 'colors'; +@import 'constants'; +@import 'placeholders'; .types-de-champ-editor { > .types-de-champ-block { @@ -108,7 +108,7 @@ a { // Remove the icon indicating an external link (for less visual noise) - &[target="_blank"]::after { + &[target='_blank']::after { display: none; } } diff --git a/app/assets/stylesheets/procedure_context.scss b/app/assets/stylesheets/procedure_context.scss index 589e2b4bb..a07e39e37 100644 --- a/app/assets/stylesheets/procedure_context.scss +++ b/app/assets/stylesheets/procedure_context.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; $procedure-context-breakpoint: $two-columns-breakpoint; @@ -37,7 +37,7 @@ $procedure-context-breakpoint: $two-columns-breakpoint; html[data-fr-theme='dark'] & { box-sizing: content-box; padding: $default-padding / 2; - background: #FFFFFF; + background: #ffffff; } @media (min-width: $procedure-context-breakpoint) { @@ -49,7 +49,7 @@ $procedure-context-breakpoint: $two-columns-breakpoint; .procedure-context-content { @media (max-width: $procedure-context-breakpoint) { - input[type=submit] { + input[type='submit'] { margin-bottom: 2 * $default-padding; } } diff --git a/app/assets/stylesheets/procedure_form.scss b/app/assets/stylesheets/procedure_form.scss index 94ebefecd..3e3b2e0a6 100644 --- a/app/assets/stylesheets/procedure_form.scss +++ b/app/assets/stylesheets/procedure_form.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .procedure-form .page-title { text-align: left; @@ -21,7 +21,7 @@ flex: 10; padding: 0 $default-padding; - input[type=file] { + input[type='file'] { background-color: transparent; // Remove white bg set by DSFR } @@ -64,7 +64,7 @@ } } -[data-fr-theme="dark"] .procedure-form__actions { +[data-fr-theme='dark'] .procedure-form__actions { background: var(--background-action-low-blue-france); border-top: 1px solid var(--background-action-low-blue-france-hover); } diff --git a/app/assets/stylesheets/procedure_list.scss b/app/assets/stylesheets/procedure_list.scss index 3fa1094d2..d677f11cf 100644 --- a/app/assets/stylesheets/procedure_list.scss +++ b/app/assets/stylesheets/procedure_list.scss @@ -1,6 +1,6 @@ -@import "colors"; -@import "constants"; -@import "mixins"; +@import 'colors'; +@import 'constants'; +@import 'mixins'; .procedure-list { .procedure-logo-link { @@ -16,7 +16,6 @@ background-position: 95% 50%; } - .procedure-stats { list-style-type: none; padding-inline-start: 0; @@ -36,7 +35,6 @@ background-color: rgba(0, 0, 0, 0.05); } - .stats-number, .stats-legend { text-align: center; diff --git a/app/assets/stylesheets/procedure_logo.scss b/app/assets/stylesheets/procedure_logo.scss index 073e9c78f..bf709a613 100644 --- a/app/assets/stylesheets/procedure_logo.scss +++ b/app/assets/stylesheets/procedure_logo.scss @@ -1,11 +1,11 @@ -@import "colors"; -@import "constants"; -@import "mixins"; +@import 'colors'; +@import 'constants'; +@import 'mixins'; .procedure-logo { @include ie-compatible-background-image(); - background-color: #FFFFFF; // also in dark mode: logos assume transparent pixels are white + background-color: #ffffff; // also in dark mode: logos assume transparent pixels are white background-position: 95% 50%; color: #000000; // alt text when image is not loaded height: 84px; diff --git a/app/assets/stylesheets/procedure_show.scss b/app/assets/stylesheets/procedure_show.scss index bc3ec8730..84ed620fb 100644 --- a/app/assets/stylesheets/procedure_show.scss +++ b/app/assets/stylesheets/procedure_show.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .procedure-header { a.header-link { @@ -33,7 +33,7 @@ padding-right: 10px; background-color: $light-blue; border-radius: 4px; - color: #FFFFFF; + color: #ffffff; height: 36px; line-height: 36px; } diff --git a/app/assets/stylesheets/profil.scss b/app/assets/stylesheets/profil.scss index 1a3abdeb9..a7a385e9c 100644 --- a/app/assets/stylesheets/profil.scss +++ b/app/assets/stylesheets/profil.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; #profil-page { b { diff --git a/app/assets/stylesheets/rich_text.scss b/app/assets/stylesheets/rich_text.scss index 5b6d2e359..f851d75cf 100644 --- a/app/assets/stylesheets/rich_text.scss +++ b/app/assets/stylesheets/rich_text.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .rich-text:not(.piece_justificative):not(.titre_identite) { i { diff --git a/app/assets/stylesheets/routing_rules_component.scss b/app/assets/stylesheets/routing_rules_component.scss index ca5985cd3..7b2efbb6e 100644 --- a/app/assets/stylesheets/routing_rules_component.scss +++ b/app/assets/stylesheets/routing_rules_component.scss @@ -1,8 +1,7 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; #routing-rules { - .routing-rules-table { table-layout: fixed; @@ -39,7 +38,6 @@ th { text-align: left; padding: $default-spacer; - } td { @@ -50,7 +48,7 @@ margin-bottom: 0; } - input[type=text] { + input[type='text'] { display: inline-block; margin-bottom: 0; } diff --git a/app/assets/stylesheets/sections.scss b/app/assets/stylesheets/sections.scss index 7b08a1c26..8d44c8465 100644 --- a/app/assets/stylesheets/sections.scss +++ b/app/assets/stylesheets/sections.scss @@ -31,32 +31,34 @@ .header-section.section-2::before { counter-increment: h2; - content: counter(h2) ". "; + content: counter(h2) '. '; } .header-section.section-3::before { counter-increment: h3; - content: counter(h2) "." counter(h3) ". "; + content: counter(h2) '.' counter(h3) '. '; } .header-section.section-4::before { counter-increment: h4; - content: counter(h2) "." counter(h3) "." counter(h4) ". "; + content: counter(h2) '.' counter(h3) '.' counter(h4) '. '; } .header-section.section-5::before { counter-increment: h5; - content: counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) ". "; + content: counter(h2) '.' counter(h3) '.' counter(h4) '.' counter(h5) '. '; } .header-section.section-6::before { counter-increment: h6; - content: counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) "." counter(h6) ". "; + content: counter(h2) '.' counter(h3) '.' counter(h4) '.' counter(h5) '.' + counter(h6) '. '; } .header-section.section-7::before { counter-increment: h7; - content: counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) "." counter(h6) "." counter(h7) ". "; + content: counter(h2) '.' counter(h3) '.' counter(h4) '.' counter(h5) '.' + counter(h6) '.' counter(h7) '. '; } .repetition { diff --git a/app/assets/stylesheets/services_index.scss b/app/assets/stylesheets/services_index.scss index 840be9ff3..73e97f9ea 100644 --- a/app/assets/stylesheets/services_index.scss +++ b/app/assets/stylesheets/services_index.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; #services-index { h1 { diff --git a/app/assets/stylesheets/site_banner.scss b/app/assets/stylesheets/site_banner.scss index 6b4448781..47813ceb7 100644 --- a/app/assets/stylesheets/site_banner.scss +++ b/app/assets/stylesheets/site_banner.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .site-banner { width: 100%; diff --git a/app/assets/stylesheets/spinner.scss b/app/assets/stylesheets/spinner.scss index d0be31b8a..1d2f6d8cb 100644 --- a/app/assets/stylesheets/spinner.scss +++ b/app/assets/stylesheets/spinner.scss @@ -2,7 +2,7 @@ vertical-align: middle; &::before { - content: ""; + content: ''; display: inline-block; width: 1.5rem; height: 1.5rem; diff --git a/app/assets/stylesheets/status_overview.scss b/app/assets/stylesheets/status_overview.scss index 088885146..2c85570e0 100644 --- a/app/assets/stylesheets/status_overview.scss +++ b/app/assets/stylesheets/status_overview.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .status-timeline { display: inline-block; @@ -29,7 +29,7 @@ // Arrows &:not(:last-child)::after { - content: "▸"; + content: '▸'; display: inline-block; margin-left: 10px; margin-right: 10px; @@ -59,7 +59,7 @@ } blockquote { - quotes: "« " " »" "‘" "’"; + quotes: '« ' ' »' '‘' '’'; margin-bottom: $default-padding * 3; } diff --git a/app/assets/stylesheets/sticky.scss b/app/assets/stylesheets/sticky.scss index c9920a4ff..3600e41c7 100644 --- a/app/assets/stylesheets/sticky.scss +++ b/app/assets/stylesheets/sticky.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .fixed-footer { border-top: 2px solid var(--border-plain-blue-france); @@ -23,7 +23,7 @@ } } -[data-fr-theme="dark"] .fixed-footer { +[data-fr-theme='dark'] .fixed-footer { background-color: var(--background-action-low-blue-france); } diff --git a/app/assets/stylesheets/sub_header.scss b/app/assets/stylesheets/sub_header.scss index 533ae2138..70c9fb1bd 100644 --- a/app/assets/stylesheets/sub_header.scss +++ b/app/assets/stylesheets/sub_header.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; .sub-header { background-color: var(--background-alt-blue-france); diff --git a/app/assets/stylesheets/super_admin.scss b/app/assets/stylesheets/super_admin.scss index 949f1aa4d..302e57639 100644 --- a/app/assets/stylesheets/super_admin.scss +++ b/app/assets/stylesheets/super_admin.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .super-admin { margin-top: 40px; diff --git a/app/assets/stylesheets/table.scss b/app/assets/stylesheets/table.scss index aef1079e6..8a22ede37 100644 --- a/app/assets/stylesheets/table.scss +++ b/app/assets/stylesheets/table.scss @@ -1,8 +1,9 @@ -@import "colors"; -@import "constants"; -@import "mixins"; +@import 'colors'; +@import 'constants'; +@import 'mixins'; -.table { // TODO : tester de remplacer par l'élément table uniquement +.table { + // TODO : tester de remplacer par l'élément table uniquement width: 100%; tbody tr { @@ -10,7 +11,7 @@ } td, - th[scope="row"] { + th[scope='row'] { @include vertical-padding($default-spacer); vertical-align: middle; } diff --git a/app/assets/stylesheets/tags.scss b/app/assets/stylesheets/tags.scss index 5d45bfb1e..6dc4c668a 100644 --- a/app/assets/stylesheets/tags.scss +++ b/app/assets/stylesheets/tags.scss @@ -1,30 +1,22 @@ -@import "colors"; -@import "constants"; - -$colors: "green-tilleul-verveine", - "green-bourgeon", - "green-emeraude", - "green-menthe", - "blue-ecume", - "purple-glycine", - "pink-macaron", - "yellow-tournesol", - "brown-cafe-creme", - "beige-gris-galet"; +@import 'colors'; +@import 'constants'; +$colors: 'green-tilleul-verveine', 'green-bourgeon', 'green-emeraude', + 'green-menthe', 'blue-ecume', 'purple-glycine', 'pink-macaron', + 'yellow-tournesol', 'brown-cafe-creme', 'beige-gris-galet'; @each $color in $colors { .fr-tag--#{$color}, - a.fr-tag--#{$color}, - button.fr-tag--#{$color}, - input[type=button].fr-tag--#{$color}, - input[type=image].fr-tag--#{$color}, - input[type=reset].fr-tag--#{$color}, - input[type=submit].fr-tag--#{$color} { + a.fr-tag--#{$color}, + button.fr-tag--#{$color}, + input[type='button'].fr-tag--#{$color}, + input[type='image'].fr-tag--#{$color}, + input[type='reset'].fr-tag--#{$color}, + input[type='submit'].fr-tag--#{$color} { --idle: transparent; --hover: var(--background-action-low-#{$color}-hover); --active: var(--background-action-low-#{$color}-active); background-color: var(--background-action-low-#{$color}); color: var(--text-action-high-#{$color}); - } + } } diff --git a/app/assets/stylesheets/tiptap_editor.scss b/app/assets/stylesheets/tiptap_editor.scss index 9682989e5..a7a7497bd 100644 --- a/app/assets/stylesheets/tiptap_editor.scss +++ b/app/assets/stylesheets/tiptap_editor.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .tiptap-editor { // Tags diff --git a/app/assets/stylesheets/title.scss b/app/assets/stylesheets/title.scss index d02120e2f..761ee51e1 100644 --- a/app/assets/stylesheets/title.scss +++ b/app/assets/stylesheets/title.scss @@ -1,4 +1,4 @@ -@import "constants"; +@import 'constants'; .huge-title { text-align: center; diff --git a/app/assets/stylesheets/toggle-switch.scss b/app/assets/stylesheets/toggle-switch.scss index e25a5390d..bf1b6a73d 100644 --- a/app/assets/stylesheets/toggle-switch.scss +++ b/app/assets/stylesheets/toggle-switch.scss @@ -1,5 +1,5 @@ -@import "colors"; -@import "constants"; +@import 'colors'; +@import 'constants'; // Toggle-switch // The switch - the box around @@ -22,7 +22,7 @@ } // Hide default HTML checkbox -.form label.toggle-switch input[type="checkbox"] { +.form label.toggle-switch input[type='checkbox'] { opacity: 0; width: 0; height: 0; @@ -45,7 +45,7 @@ .toggle-switch-control::before { position: absolute; - content: ""; + content: ''; height: 20px; width: 20px; left: 1px; diff --git a/package.json b/package.json index 46fe9e104..c3ac81a91 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "lint:js": "eslint --ext .js,.jsx,.ts,.tsx ./app/javascript", "lint:types": "tsc", "lint:css": "prettier app/assets/stylesheets --check", + "lint:css:fix": "prettier app/assets/stylesheets --write", "graphql:doc:build": "RAILS_ENV=production bin/rake graphql:schema:idl && spectaql spectaql_config.yml", "postinstall": "patch-package", "test": "vitest", From 3ce36222b453392d5695b53de9d7832ccd8427f2 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 24 Oct 2024 11:29:54 +0200 Subject: [PATCH 1433/1532] refactor(procedure): refactor procedure publish methods --- .../administrateurs/procedures_controller.rb | 4 - .../concerns/procedure_publish_concern.rb | 129 ++++++++++++++++++ app/models/procedure.rb | 116 +--------------- 3 files changed, 130 insertions(+), 119 deletions(-) create mode 100644 app/models/concerns/procedure_publish_concern.rb diff --git a/app/controllers/administrateurs/procedures_controller.rb b/app/controllers/administrateurs/procedures_controller.rb index b29493517..38a2fdf2e 100644 --- a/app/controllers/administrateurs/procedures_controller.rb +++ b/app/controllers/administrateurs/procedures_controller.rb @@ -307,10 +307,6 @@ module Administrateurs @procedure.publish_or_reopen!(current_administrateur) - if @procedure.draft_changed? - @procedure.publish_revision! - end - if params[:old_procedure].present? && @procedure.errors.empty? current_administrateur .procedures diff --git a/app/models/concerns/procedure_publish_concern.rb b/app/models/concerns/procedure_publish_concern.rb new file mode 100644 index 000000000..85e0fe48c --- /dev/null +++ b/app/models/concerns/procedure_publish_concern.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +module ProcedurePublishConcern + extend ActiveSupport::Concern + + def publish_or_reopen!(administrateur) + Procedure.transaction do + if brouillon? + reset! + end + + other_procedure = other_procedure_with_path(path) + if other_procedure.present? && administrateur.owns?(other_procedure) + other_procedure.unpublish! + publish!(other_procedure.canonical_procedure || other_procedure) + else + publish! + end + end + end + + def publish_revision! + reset! + + transaction { publish_new_revision } + + dossiers + .state_not_termine + .find_each(&:rebase_later) + end + + def reset_draft_revision! + if published_revision.present? && draft_changed? + reset! + transaction do + draft_revision.types_de_champ.filter(&:only_present_on_draft?).each(&:destroy) + draft_revision.update(dossier_submitted_message: nil) + draft_revision.destroy + update!(draft_revision: create_new_revision(published_revision)) + end + end + end + + def reset! + if !locked? || draft_changed? + dossier_ids_to_destroy = draft_revision.dossiers.ids + if dossier_ids_to_destroy.present? + Rails.logger.info("Resetting #{dossier_ids_to_destroy.size} dossiers on procedure #{id}: #{dossier_ids_to_destroy}") + draft_revision.dossiers.destroy_all + end + end + end + + def before_publish + assign_attributes(closed_at: nil, unpublished_at: nil) + end + + def after_publish(canonical_procedure = nil) + self.canonical_procedure = canonical_procedure + touch(:published_at) + publish_new_revision + end + + def after_republish(canonical_procedure = nil) + touch(:published_at) + end + + def after_close + touch(:closed_at) + end + + def after_unpublish + touch(:unpublished_at) + end + + def create_new_revision(revision = nil) + transaction do + new_revision = (revision || draft_revision) + .deep_clone(include: [:revision_types_de_champ]) + .tap { |revision| revision.published_at = nil } + .tap(&:save!) + + move_new_children_to_new_parent_coordinate(new_revision) + + # they are not aware of the new tdcs + new_revision.types_de_champ_public.reset + new_revision.types_de_champ_private.reset + + new_revision + end + end + + private + + def publish_new_revision + cleanup_types_de_champ_options! + cleanup_types_de_champ_children! + self.published_revision = draft_revision + self.draft_revision = create_new_revision + save!(context: :publication) + published_revision.touch(:published_at) + end + + def move_new_children_to_new_parent_coordinate(new_draft) + children = new_draft.revision_types_de_champ + .includes(parent: :type_de_champ) + .where.not(parent_id: nil) + coordinates_by_stable_id = new_draft.revision_types_de_champ + .includes(:type_de_champ) + .index_by(&:stable_id) + + children.each do |child| + child.update!(parent: coordinates_by_stable_id.fetch(child.parent.stable_id)) + end + new_draft.reload + end + + def cleanup_types_de_champ_options! + draft_revision.types_de_champ.each do |type_de_champ| + type_de_champ.update!(options: type_de_champ.clean_options) + end + end + + def cleanup_types_de_champ_children! + draft_revision.types_de_champ.reject(&:repetition?).each do |type_de_champ| + draft_revision.remove_children_of(type_de_champ) + end + end +end diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 3aebfec72..376f9e88b 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -8,6 +8,7 @@ class Procedure < ApplicationRecord include ProcedureGroupeInstructeurAPIHackConcern include ProcedureSVASVRConcern include ProcedureChorusConcern + include ProcedurePublishConcern include PiecesJointesListConcern include ColumnsConcern @@ -329,39 +330,6 @@ class Procedure < ApplicationRecord dossiers.close_to_expiration.count end - def publish_or_reopen!(administrateur) - Procedure.transaction do - if brouillon? - reset! - cleanup_types_de_champ_options! - end - - other_procedure = other_procedure_with_path(path) - if other_procedure.present? && administrateur.owns?(other_procedure) - other_procedure.unpublish! - publish!(other_procedure.canonical_procedure || other_procedure) - else - publish! - end - end - end - - def reset! - if !locked? || draft_changed? - dossier_ids_to_destroy = draft_revision.dossiers.ids - if dossier_ids_to_destroy.present? - Rails.logger.info("Resetting #{dossier_ids_to_destroy.size} dossiers on procedure #{id}: #{dossier_ids_to_destroy}") - draft_revision.dossiers.destroy_all - end - end - end - - def cleanup_types_de_champ_options! - draft_revision.types_de_champ.each do |type_de_champ| - type_de_champ.update!(options: type_de_champ.clean_options) - end - end - def suggested_path(administrateur) if path_customized? return path @@ -770,23 +738,6 @@ class Procedure < ApplicationRecord "Procedure;#{id}" end - def create_new_revision(revision = nil) - transaction do - new_revision = (revision || draft_revision) - .deep_clone(include: [:revision_types_de_champ]) - .tap { |revision| revision.published_at = nil } - .tap(&:save!) - - move_new_children_to_new_parent_coordinate(new_revision) - - # they are not aware of the new tdcs - new_revision.types_de_champ_public.reset - new_revision.types_de_champ_private.reset - - new_revision - end - end - def average_dossier_weight if dossiers.termine.any? dossiers_sample = dossiers.termine.limit(100) @@ -801,32 +752,6 @@ class Procedure < ApplicationRecord end end - def publish_revision! - reset! - cleanup_types_de_champ_options! - transaction do - self.published_revision = draft_revision - self.draft_revision = create_new_revision - save!(context: :publication) - published_revision.touch(:published_at) - end - dossiers - .state_not_termine - .find_each(&:rebase_later) - end - - def reset_draft_revision! - if published_revision.present? && draft_changed? - reset! - transaction do - draft_revision.types_de_champ.filter(&:only_present_on_draft?).each(&:destroy) - draft_revision.update(dossier_submitted_message: nil) - draft_revision.destroy - update!(draft_revision: create_new_revision(published_revision)) - end - end - end - def cnaf_enabled? api_particulier_sources['cnaf'].present? end @@ -865,45 +790,6 @@ class Procedure < ApplicationRecord end end - def move_new_children_to_new_parent_coordinate(new_draft) - children = new_draft.revision_types_de_champ - .includes(parent: :type_de_champ) - .where.not(parent_id: nil) - coordinates_by_stable_id = new_draft.revision_types_de_champ - .includes(:type_de_champ) - .index_by(&:stable_id) - - children.each do |child| - child.update!(parent: coordinates_by_stable_id.fetch(child.parent.stable_id)) - end - new_draft.reload - end - - def before_publish - assign_attributes(closed_at: nil, unpublished_at: nil) - end - - def after_publish(canonical_procedure = nil) - self.canonical_procedure = canonical_procedure - self.published_revision = draft_revision - self.draft_revision = create_new_revision - save!(context: :publication) - touch(:published_at) - published_revision.touch(:published_at) - end - - def after_republish(canonical_procedure = nil) - touch(:published_at) - end - - def after_close - touch(:closed_at) - end - - def after_unpublish - touch(:unpublished_at) - end - def update_juridique_required self.juridique_required ||= (cadre_juridique.present? || deliberation.attached?) true From c0da8d1556e95943d06df5573528202540d1771b Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 4 Nov 2024 11:39:08 +0100 Subject: [PATCH 1434/1532] refactor(tdc): tdc.columns should take procedure instead of procedure_id --- app/models/concerns/addressable_column_concern.rb | 4 ++-- app/models/concerns/columns_concern.rb | 2 +- app/models/types_de_champ/carte_type_de_champ.rb | 2 +- .../types_de_champ/linked_drop_down_list_type_de_champ.rb | 8 ++++---- .../types_de_champ/piece_justificative_type_de_champ.rb | 4 ++-- app/models/types_de_champ/repetition_type_de_champ.rb | 6 +++--- app/models/types_de_champ/titre_identite_type_de_champ.rb | 4 ++-- app/models/types_de_champ/type_de_champ_base.rb | 4 ++-- spec/models/columns/champ_column_spec.rb | 2 +- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/models/concerns/addressable_column_concern.rb b/app/models/concerns/addressable_column_concern.rb index 4f10b62b5..e7ba4d11e 100644 --- a/app/models/concerns/addressable_column_concern.rb +++ b/app/models/concerns/addressable_column_concern.rb @@ -4,7 +4,7 @@ module AddressableColumnConcern extend ActiveSupport::Concern included do - def columns(procedure_id:, displayable: true, prefix: nil) + def columns(procedure:, displayable: true, prefix: nil) super.concat([ ["code postal (5 chiffres)", '$.postal_code', :text], ["commune", '$.city_name', :text], @@ -12,7 +12,7 @@ module AddressableColumnConcern ["region", '$.region_name', :enum] ].map do |(label, jsonpath, type)| Columns::JSONPathColumn.new( - procedure_id:, + procedure_id: procedure.id, stable_id:, tdc_type: type_champ, label: "#{libelle_with_prefix(prefix)} – #{label}", diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index c30074c68..e01173412 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -154,7 +154,7 @@ module ColumnsConcern end def types_de_champ_columns - all_revisions_types_de_champ.flat_map { _1.columns(procedure_id: id) } + all_revisions_types_de_champ.flat_map { _1.columns(procedure: self) } end end end diff --git a/app/models/types_de_champ/carte_type_de_champ.rb b/app/models/types_de_champ/carte_type_de_champ.rb index 5903c5a2c..fb7822353 100644 --- a/app/models/types_de_champ/carte_type_de_champ.rb +++ b/app/models/types_de_champ/carte_type_de_champ.rb @@ -30,7 +30,7 @@ class TypesDeChamp::CarteTypeDeChamp < TypesDeChamp::TypeDeChampBase def champ_blank?(champ) = champ.geo_areas.blank? - def columns(procedure_id:, displayable: true, prefix: nil) + def columns(procedure:, displayable: true, prefix: nil) [] end end diff --git a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb index cee354c02..c6396be6b 100644 --- a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb @@ -71,10 +71,10 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas (has_secondary_options_for_primary?(champ) && secondary_value(champ).blank?) end - def columns(procedure_id:, displayable: true, prefix: nil) + def columns(procedure:, displayable: true, prefix: nil) [ Columns::LinkedDropDownColumn.new( - procedure_id:, + procedure_id: procedure.id, label: libelle_with_prefix(prefix), stable_id:, tdc_type: type_champ, @@ -83,7 +83,7 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas displayable: ), Columns::LinkedDropDownColumn.new( - procedure_id:, + procedure_id: procedure.id, stable_id:, tdc_type: type_champ, label: "#{libelle_with_prefix(prefix)} (Primaire)", @@ -92,7 +92,7 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas displayable: false ), Columns::LinkedDropDownColumn.new( - procedure_id:, + procedure_id: procedure.id, stable_id:, tdc_type: type_champ, label: "#{libelle_with_prefix(prefix)} (Secondaire)", diff --git a/app/models/types_de_champ/piece_justificative_type_de_champ.rb b/app/models/types_de_champ/piece_justificative_type_de_champ.rb index cb65fb132..dbebe11f9 100644 --- a/app/models/types_de_champ/piece_justificative_type_de_champ.rb +++ b/app/models/types_de_champ/piece_justificative_type_de_champ.rb @@ -25,10 +25,10 @@ class TypesDeChamp::PieceJustificativeTypeDeChamp < TypesDeChamp::TypeDeChampBas def champ_blank?(champ) = champ.piece_justificative_file.blank? - def columns(procedure_id:, displayable: true, prefix: nil) + def columns(procedure:, displayable: true, prefix: nil) [ Columns::AttachedManyColumn.new( - procedure_id:, + procedure_id: procedure.id, stable_id:, tdc_type: type_champ, label: libelle_with_prefix(prefix), diff --git a/app/models/types_de_champ/repetition_type_de_champ.rb b/app/models/types_de_champ/repetition_type_de_champ.rb index 28db1512d..043294933 100644 --- a/app/models/types_de_champ/repetition_type_de_champ.rb +++ b/app/models/types_de_champ/repetition_type_de_champ.rb @@ -25,10 +25,10 @@ class TypesDeChamp::RepetitionTypeDeChamp < TypesDeChamp::TypeDeChampBase ActiveStorage::Filename.new(str.delete('[]*?')).sanitized end - def columns(procedure_id:, displayable: nil, prefix: nil) - @type_de_champ.procedure + def columns(procedure:, displayable: nil, prefix: nil) + procedure .all_revisions_types_de_champ(parent: @type_de_champ) - .flat_map { _1.columns(procedure_id:, displayable: false, prefix: libelle) } + .flat_map { _1.columns(procedure:, displayable: false, prefix: libelle) } end def champ_blank?(champ) = champ.dossier.repetition_row_ids(@type_de_champ).blank? diff --git a/app/models/types_de_champ/titre_identite_type_de_champ.rb b/app/models/types_de_champ/titre_identite_type_de_champ.rb index 9938808de..e72944c15 100644 --- a/app/models/types_de_champ/titre_identite_type_de_champ.rb +++ b/app/models/types_de_champ/titre_identite_type_de_champ.rb @@ -24,10 +24,10 @@ class TypesDeChamp::TitreIdentiteTypeDeChamp < TypesDeChamp::TypeDeChampBase def champ_blank?(champ) = champ.piece_justificative_file.blank? - def columns(procedure_id:, displayable: nil, prefix: nil) + def columns(procedure:, displayable: nil, prefix: nil) [ Columns::AttachedManyColumn.new( - procedure_id:, + procedure_id: procedure.id, stable_id:, tdc_type: type_champ, label: libelle_with_prefix(prefix), diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index 2c81fc430..fb3549f94 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -95,11 +95,11 @@ class TypesDeChamp::TypeDeChampBase def champ_blank?(champ) = champ.value.blank? def champ_blank_or_invalid?(champ) = champ_blank?(champ) - def columns(procedure_id:, displayable: true, prefix: nil) + def columns(procedure:, displayable: true, prefix: nil) if fillable? [ Columns::ChampColumn.new( - procedure_id:, + procedure_id: procedure.id, stable_id:, tdc_type: type_champ, label: libelle_with_prefix(prefix), diff --git a/spec/models/columns/champ_column_spec.rb b/spec/models/columns/champ_column_spec.rb index 0cbf1da91..f197af3cd 100644 --- a/spec/models/columns/champ_column_spec.rb +++ b/spec/models/columns/champ_column_spec.rb @@ -122,7 +122,7 @@ describe Columns::ChampColumn do def expect_type_de_champ_values(type, assertion) type_de_champ = types_de_champ.find { _1.type_champ == type } champ = dossier.send(:filled_champ, type_de_champ, nil) - columns = type_de_champ.columns(procedure_id: procedure.id) + columns = type_de_champ.columns(procedure:) expect(columns.map { _1.value(champ) }).to assertion end From 580002e5f5d8d52fbe84f837e19777963ca0ef02 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 4 Nov 2024 11:39:33 +0100 Subject: [PATCH 1435/1532] cleanup: remove dead code --- app/policies/champ_policy.rb | 45 ---------- app/policies/type_de_champ_policy.rb | 15 ---- spec/policies/champ_policy_spec.rb | 100 --------------------- spec/policies/type_de_champ_policy_spec.rb | 32 ------- 4 files changed, 192 deletions(-) delete mode 100644 app/policies/champ_policy.rb delete mode 100644 app/policies/type_de_champ_policy.rb delete mode 100644 spec/policies/champ_policy_spec.rb delete mode 100644 spec/policies/type_de_champ_policy_spec.rb diff --git a/app/policies/champ_policy.rb b/app/policies/champ_policy.rb deleted file mode 100644 index d2c195268..000000000 --- a/app/policies/champ_policy.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -class ChampPolicy < ApplicationPolicy - # Scope for WRITING to a champ. - # - # (If the need for a scope to READ a champ emerges, we can implement another scope - # in this file, following this example: https://github.com/varvet/pundit/issues/368#issuecomment-196111115) - class Scope < ApplicationScope - def resolve - if user.blank? - return scope.none - end - - # The join must be the same for all elements of the WHERE clause. - # - # NB: here we want to do `.left_outer_joins(dossier: [:invites, { :groupe_instructeur: :instructeurs }]))`, - # but for some reasons ActiveRecord <= 5.2 generates bogus SQL. Hence the manual version of it below. - joined_scope = scope - .joins('LEFT OUTER JOIN dossiers ON dossiers.id = champs.dossier_id') - .joins('LEFT OUTER JOIN invites ON invites.dossier_id = dossiers.id OR invites.dossier_id = dossiers.editing_fork_origin_id') - .joins('LEFT OUTER JOIN groupe_instructeurs ON groupe_instructeurs.id = dossiers.groupe_instructeur_id') - .joins('LEFT OUTER JOIN assign_tos ON assign_tos.groupe_instructeur_id = groupe_instructeurs.id') - .joins('LEFT OUTER JOIN instructeurs ON instructeurs.id = assign_tos.instructeur_id') - - # Users can access public champs on their own dossiers. - resolved_scope = joined_scope - .where('dossiers.user_id': user.id, private: false) - - # Invited users can access public champs on dossiers they are invited to - invite_clause = joined_scope - .where('invites.user_id': user.id, private: false) - resolved_scope = resolved_scope.or(invite_clause) - - if instructeur.present? - # Additionnaly, instructeurs can access private champs - # on dossiers they are allowed to instruct. - instructeur_clause = joined_scope - .where('instructeurs.id': instructeur.id, private: true) - resolved_scope = resolved_scope.or(instructeur_clause) - end - - resolved_scope.or(joined_scope.where('dossiers.for_procedure_preview': true)) - end - end -end diff --git a/app/policies/type_de_champ_policy.rb b/app/policies/type_de_champ_policy.rb deleted file mode 100644 index 36a8b07eb..000000000 --- a/app/policies/type_de_champ_policy.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -class TypeDeChampPolicy < ApplicationPolicy - class Scope < ApplicationScope - def resolve - if administrateur.present? - scope - .joins(procedure: [:administrateurs]) - .where({ administrateurs: { id: administrateur.id } }) - else - scope.none - end - end - end -end diff --git a/spec/policies/champ_policy_spec.rb b/spec/policies/champ_policy_spec.rb deleted file mode 100644 index 786956a2c..000000000 --- a/spec/policies/champ_policy_spec.rb +++ /dev/null @@ -1,100 +0,0 @@ -# frozen_string_literal: true - -describe ChampPolicy do - let(:procedure) { create(:procedure, :with_type_de_champ, :with_type_de_champ_private) } - let(:dossier) { create(:dossier, procedure: procedure, user: dossier_owner) } - let(:dossier_owner) { create(:user) } - - let(:signed_in_user) { create(:user) } - let(:account) { { user: signed_in_user } } - - subject { Pundit.policy_scope(account, Champ) } - - let(:champ) { dossier.project_champs_public.first } - let(:champ_private) { dossier.project_champs_private.first } - - shared_examples_for 'they can access a public champ' do - it { expect(subject.find_by(id: champ.id)).to eq(champ) } - end - - shared_examples_for 'they can’t access a public champ' do - it { expect(subject.find_by(id: champ.id)).to eq(nil) } - end - - shared_examples_for 'they can access a private champ' do - it { expect(subject.find_by(id: champ_private.id)).to eq(champ_private) } - end - - shared_examples_for 'they can’t access a private champ' do - it { expect(subject.find_by(id: champ_private.id)).to eq(nil) } - end - - context 'when an user only has user rights' do - context 'as the dossier owner' do - let(:signed_in_user) { dossier_owner } - - it_behaves_like 'they can access a public champ' - it_behaves_like 'they can’t access a private champ' - end - - context 'as a person invited on the dossier' do - let(:invite) { create(:invite, :with_user, dossier: dossier) } - let(:signed_in_user) { invite.user } - - it_behaves_like 'they can access a public champ' - it_behaves_like 'they can’t access a private champ' - end - - context 'as another user' do - let(:signed_in_user) { create(:user) } - - it_behaves_like 'they can’t access a public champ' - it_behaves_like 'they can’t access a private champ' - end - end - - context 'when the user also has instruction rights' do - let(:instructeur) { create(:instructeur, user: signed_in_user) } - let(:account) { { user: signed_in_user, instructeur: instructeur } } - - context 'as the dossier instructeur and owner' do - let(:signed_in_user) { dossier_owner } - before { instructeur.assign_to_procedure(dossier.procedure) } - - it_behaves_like 'they can access a public champ' - it_behaves_like 'they can access a private champ' - end - - context 'as the dossier instructeur (but not owner)' do - let(:signed_in_user) { create(:user) } - before { instructeur.assign_to_procedure(dossier.procedure) } - - it_behaves_like 'they can’t access a public champ' - it_behaves_like 'they can access a private champ' - end - - context 'as an instructeur not assigned to the procedure' do - let(:signed_in_user) { create(:user) } - - it_behaves_like 'they can’t access a public champ' - it_behaves_like 'they can’t access a private champ' - end - end - - context 'when the champ is on a forked dossier' do - let(:signed_in_user) { dossier_owner } - let(:origin) { create(:dossier, procedure: procedure, user: dossier_owner) } - let(:dossier) { origin.find_or_create_editing_fork(dossier_owner) } - - it_behaves_like 'they can access a public champ' - it_behaves_like 'they can’t access a private champ' - - context 'when the user is invited on the origin dossier' do - let(:invite) { create(:invite, :with_user, dossier: origin) } - let(:signed_in_user) { invite.user } - - it_behaves_like 'they can access a public champ' - it_behaves_like 'they can’t access a private champ' - end - end -end diff --git a/spec/policies/type_de_champ_policy_spec.rb b/spec/policies/type_de_champ_policy_spec.rb deleted file mode 100644 index 9734e4a9d..000000000 --- a/spec/policies/type_de_champ_policy_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -describe TypeDeChampPolicy do - let(:procedure) { create(:procedure) } - let!(:type_de_champ) { create(:type_de_champ_text, procedure: procedure) } - - let(:user) { create(:user) } - let(:administrateur) { nil } - - let(:account) do - { - user: user, - administrateur: administrateur - }.compact - end - - subject { Pundit.policy_scope(account, TypeDeChamp) } - - context 'when the user has only user rights' do - it 'can not access' do - expect(subject.find_by(id: type_de_champ.id)).to eq(nil) - end - end - - context 'when the user has administrateur rights' do - let(:administrateur) { procedure.administrateurs.first } - - it 'can access' do - expect(subject.find(type_de_champ.id)).to eq(type_de_champ) - end - end -end From df0dbc13212125ff10faa9ca95f09e384f167f72 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 4 Nov 2024 11:40:04 +0100 Subject: [PATCH 1436/1532] cneanup(tdc): this behavior moved --- spec/models/type_de_champ_spec.rb | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/spec/models/type_de_champ_spec.rb b/spec/models/type_de_champ_spec.rb index 33b3ccc57..3210d5465 100644 --- a/spec/models/type_de_champ_spec.rb +++ b/spec/models/type_de_champ_spec.rb @@ -80,23 +80,6 @@ describe TypeDeChamp do end end - describe 'changing the type_champ from a repetition' do - let!(:procedure) { create(:procedure) } - let(:tdc) { create(:type_de_champ_repetition, :with_types_de_champ, procedure: procedure) } - - before do - tdc.update(type_champ: target_type_champ) - end - - context 'when the target type_champ is not repetition' do - let(:target_type_champ) { TypeDeChamp.type_champs.fetch(:text) } - - it 'removes the children types de champ' do - expect(procedure.draft_revision.reload.children_of(tdc)).to be_empty - end - end - end - describe 'changing the type_champ from a drop_down_list' do let(:tdc) { create(:type_de_champ_drop_down_list) } From 5182af820af9f0c4ff28303c5cfcf3e21d9d94cf Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 4 Nov 2024 11:41:24 +0100 Subject: [PATCH 1437/1532] refactor(type_de_champ): type_de_champ should not expose revision or procedure --- .../procedure/revision_changes_component.rb | 5 ++++ .../revision_changes_component.html.haml | 2 +- .../types_de_champ_editor/champ_component.rb | 4 +-- .../types_de_champ_controller.rb | 7 +++--- app/helpers/champ_helper.rb | 8 +++--- app/models/champ.rb | 5 +++- app/models/dubious_procedure.rb | 6 ++--- app/models/procedure.rb | 10 +++++--- app/models/procedure_revision.rb | 2 +- .../procedure_revision_type_de_champ.rb | 2 +- app/models/type_de_champ.rb | 13 ---------- .../types_de_champ/prefill_type_de_champ.rb | 2 +- ...g_data_for_routing_with_dropdown_list.rake | 2 +- .../champ_component_spec.rb | 2 +- .../types_de_champ_controller_spec.rb | 2 +- spec/factories/champ.rb | 2 +- spec/models/procedure_revision_spec.rb | 2 +- spec/models/procedure_spec.rb | 8 +++--- ...draft_revision_type_de_champs_task_spec.rb | 9 +++++-- ...draft_revision_type_de_champs_task_spec.rb | 25 +++++++++++-------- 20 files changed, 63 insertions(+), 55 deletions(-) diff --git a/app/components/procedure/revision_changes_component.rb b/app/components/procedure/revision_changes_component.rb index 981a0e157..f011e3b0b 100644 --- a/app/components/procedure/revision_changes_component.rb +++ b/app/components/procedure/revision_changes_component.rb @@ -4,6 +4,7 @@ class Procedure::RevisionChangesComponent < ApplicationComponent def initialize(new_revision:, previous_revision:) @previous_revision = previous_revision @new_revision = new_revision + @procedure = new_revision.procedure @tdc_changes = previous_revision.compare_types_de_champ(new_revision) @public_move_changes, @private_move_changes = @tdc_changes.filter { _1.op == :move }.partition { !_1.private? } @@ -14,6 +15,10 @@ class Procedure::RevisionChangesComponent < ApplicationComponent private + def used_by_routing_rules?(type_de_champ) + @procedure.used_by_routing_rules?(type_de_champ) + end + def total_dossiers @total_dossiers ||= @previous_revision.dossiers .visible_by_administration diff --git a/app/components/procedure/revision_changes_component/revision_changes_component.html.haml b/app/components/procedure/revision_changes_component/revision_changes_component.html.haml index ed7f550c8..2cfed321b 100644 --- a/app/components/procedure/revision_changes_component/revision_changes_component.html.haml +++ b/app/components/procedure/revision_changes_component/revision_changes_component.html.haml @@ -77,7 +77,7 @@ - if !total_dossiers.zero? && !change.can_rebase? .fr-alert.fr-alert--warning.fr-mt-1v %p= t('.breaking_change', count: total_dossiers) - - if (removed.present? || added.present? ) && change.type_de_champ.used_by_routing_rules? + - if (removed.present? || added.present? ) && used_by_routing_rules?(change.type_de_champ) .fr-alert.fr-alert--warning.fr-mt-1v = t(".#{prefix}.update_drop_down_options_alert", label: change.label) - when :drop_down_other diff --git a/app/components/types_de_champ_editor/champ_component.rb b/app/components/types_de_champ_editor/champ_component.rb index 1e1860ab9..a0fa6a516 100644 --- a/app/components/types_de_champ_editor/champ_component.rb +++ b/app/components/types_de_champ_editor/champ_component.rb @@ -78,7 +78,7 @@ class TypesDeChampEditor::ChampComponent < ApplicationComponent def piece_justificative_template_options { attached_file: type_de_champ.piece_justificative_template, - auto_attach_url: helpers.auto_attach_url(type_de_champ), + auto_attach_url: helpers.auto_attach_url(type_de_champ, procedure_id: procedure.id), view_as: :download } end @@ -86,7 +86,7 @@ class TypesDeChampEditor::ChampComponent < ApplicationComponent def notice_explicative_options { attached_file: type_de_champ.notice_explicative, - auto_attach_url: helpers.auto_attach_url(type_de_champ), + auto_attach_url: helpers.auto_attach_url(type_de_champ, procedure_id: procedure.id), view_as: :download } end diff --git a/app/controllers/administrateurs/types_de_champ_controller.rb b/app/controllers/administrateurs/types_de_champ_controller.rb index 0fee638ec..051191af7 100644 --- a/app/controllers/administrateurs/types_de_champ_controller.rb +++ b/app/controllers/administrateurs/types_de_champ_controller.rb @@ -20,15 +20,14 @@ module Administrateurs def update type_de_champ = draft.find_and_ensure_exclusive_use(params[:stable_id]) + @coordinate = draft.coordinate_for(type_de_champ) - if type_de_champ.revision_type_de_champ.used_by_routing_rules? && changing_of_type?(type_de_champ) - coordinate = draft.coordinate_for(type_de_champ) + if @coordinate.used_by_routing_rules? && changing_of_type?(type_de_champ) errors = "« #{type_de_champ.libelle} » est utilisé pour le routage, vous ne pouvez pas modifier son type." - @morphed = [champ_component_from(coordinate, focused: false, errors:)] + @morphed = [champ_component_from(@coordinate, focused: false, errors:)] flash.alert = errors elsif type_de_champ.update(type_de_champ_update_params) reload_procedure_with_includes - @coordinate = draft.coordinate_for(type_de_champ) @morphed = champ_components_starting_at(@coordinate) else flash.alert = type_de_champ.errors.full_messages diff --git a/app/helpers/champ_helper.rb b/app/helpers/champ_helper.rb index cec15a4c5..449f9b799 100644 --- a/app/helpers/champ_helper.rb +++ b/app/helpers/champ_helper.rb @@ -9,13 +9,13 @@ module ChampHelper simple_format(auto_linked_text, {}, sanitize: false) end - def auto_attach_url(object, params = {}) + def auto_attach_url(object, procedure_id: nil) if object.is_a?(Champ) - champs_piece_justificative_url(object.dossier, object.stable_id, params.merge(row_id: object.row_id)) + champs_piece_justificative_url(object.dossier, object.stable_id, row_id: object.row_id) elsif object.is_a?(TypeDeChamp) && object.piece_justificative? - piece_justificative_template_admin_procedure_type_de_champ_url(stable_id: object.stable_id, procedure_id: object.procedure.id, **params) + piece_justificative_template_admin_procedure_type_de_champ_url(stable_id: object.stable_id, procedure_id:) elsif object.is_a?(TypeDeChamp) && object.explication? - notice_explicative_admin_procedure_type_de_champ_url(stable_id: object.stable_id, procedure_id: object.procedure.id, **params) + notice_explicative_admin_procedure_type_de_champ_url(stable_id: object.stable_id, procedure_id:) end end end diff --git a/app/models/champ.rb b/app/models/champ.rb index fb7bf56e9..8efa30748 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -74,7 +74,6 @@ class Champ < ApplicationRecord delegate :to_typed_id, :to_typed_id_for_query, to: :type_de_champ, prefix: true delegate :revision, to: :dossier, prefix: true - delegate :used_by_routing_rules?, to: :type_de_champ scope :updated_since?, -> (date) { where('champs.updated_at > ?', date) } scope :prefilled, -> { where(prefilled: true) } @@ -106,6 +105,10 @@ class Champ < ApplicationRecord type_de_champ.champ_blank?(self) end + def used_by_routing_rules? + procedure.used_by_routing_rules?(type_de_champ) + end + def search_terms [to_s] end diff --git a/app/models/dubious_procedure.rb b/app/models/dubious_procedure.rb index 6960aad6f..792b1526d 100644 --- a/app/models/dubious_procedure.rb +++ b/app/models/dubious_procedure.rb @@ -19,11 +19,11 @@ class DubiousProcedure end def self.all - procedures_with_forbidden_tdcs_sql = TypeDeChamp - .joins(:procedure) + procedures_with_forbidden_tdcs_sql = ProcedureRevisionTypeDeChamp + .joins(:procedure, :type_de_champ) .select("string_agg(types_de_champ.libelle, ' - ') as dubious_champs, procedures.id as procedure_id, procedures.libelle as procedure_libelle, procedures.aasm_state as procedure_aasm_state, procedures.hidden_at_as_template as procedure_hidden_at_as_template") .where("unaccent(types_de_champ.libelle) ~* unaccent(?)", forbidden_regexp) - .where(type_champ: [TypeDeChamp.type_champs.fetch(:text), TypeDeChamp.type_champs.fetch(:textarea)]) + .where(types_de_champ: { type_champ: [TypeDeChamp.type_champs.fetch(:text), TypeDeChamp.type_champs.fetch(:textarea)] }) .where(procedures: { closed_at: nil, whitelisted_at: nil }) .group("procedures.id") .order("procedures.id asc") diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 376f9e88b..05c3e3346 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -687,7 +687,7 @@ class Procedure < ApplicationRecord end def routing_champs - active_revision.types_de_champ_public.filter(&:used_by_routing_rules?).map(&:libelle) + active_revision.revision_types_de_champ_public.filter(&:used_by_routing_rules?).map(&:libelle) end def can_be_deleted_by_administrateur? @@ -829,8 +829,8 @@ class Procedure < ApplicationRecord end end - def stable_ids_used_by_routing_rules - @stable_ids_used_by_routing_rules ||= groupe_instructeurs.flat_map { _1.routing_rule&.sources }.compact + def used_by_routing_rules?(type_de_champ) + type_de_champ.stable_id.in?(stable_ids_used_by_routing_rules) end # We need this to unfuck administrate + aasm @@ -871,6 +871,10 @@ class Procedure < ApplicationRecord private + def stable_ids_used_by_routing_rules + @stable_ids_used_by_routing_rules ||= groupe_instructeurs.flat_map { _1.routing_rule&.sources }.compact.uniq + end + def published_revisions_types_de_champ(parent = nil) # all published revisions revision_ids = revisions.ids - [draft_revision_id] diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index 6a0631eb9..3b6127d3e 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -230,7 +230,7 @@ class ProcedureRevision < ApplicationRecord end def coordinate_for(tdc) - revision_types_de_champ.find_by!(type_de_champ: tdc) + revision_types_de_champ.find { _1.stable_id == tdc.stable_id } end def carte? diff --git a/app/models/procedure_revision_type_de_champ.rb b/app/models/procedure_revision_type_de_champ.rb index 1a0d27c01..3963ae630 100644 --- a/app/models/procedure_revision_type_de_champ.rb +++ b/app/models/procedure_revision_type_de_champ.rb @@ -75,7 +75,7 @@ class ProcedureRevisionTypeDeChamp < ApplicationRecord end def used_by_routing_rules? - stable_id.in?(procedure.stable_ids_used_by_routing_rules) + procedure.used_by_routing_rules?(type_de_champ) end def used_by_ineligibilite_rules? diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index abb5df34e..50e170baa 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -142,13 +142,9 @@ class TypeDeChamp < ApplicationRecord :header_section_level has_many :revision_types_de_champ, -> { revision_ordered }, class_name: 'ProcedureRevisionTypeDeChamp', dependent: :destroy, inverse_of: :type_de_champ - has_one :revision_type_de_champ, -> { revision_ordered }, class_name: 'ProcedureRevisionTypeDeChamp', inverse_of: false has_many :revisions, -> { ordered }, through: :revision_types_de_champ - has_one :revision, through: :revision_type_de_champ - has_one :procedure, through: :revision delegate :estimated_fill_duration, :estimated_read_duration, :tags_for_template, :libelles_for_export, :libelle_for_export, :primary_options, :secondary_options, :columns, to: :dynamic_type - delegate :used_by_routing_rules?, to: :revision_type_de_champ class WithIndifferentAccess def self.load(options) @@ -213,7 +209,6 @@ class TypeDeChamp < ApplicationRecord before_save :remove_attachment, if: -> { type_champ_changed? } before_validation :set_drop_down_list_options, if: -> { type_champ_changed? } - before_save :remove_block, if: -> { type_champ_changed? } def valid?(context = nil) super @@ -818,14 +813,6 @@ class TypeDeChamp < ApplicationRecord end end - def remove_block - if !block? && procedure.present? - procedure - .draft_revision # action occurs only on draft - .remove_children_of(self) - end - end - def normalize_libelle self.libelle&.strip! end diff --git a/app/models/types_de_champ/prefill_type_de_champ.rb b/app/models/types_de_champ/prefill_type_de_champ.rb index 1b112f094..1917f7b55 100644 --- a/app/models/types_de_champ/prefill_type_de_champ.rb +++ b/app/models/types_de_champ/prefill_type_de_champ.rb @@ -72,7 +72,7 @@ class TypesDeChamp::PrefillTypeDeChamp < SimpleDelegator link_to( I18n.t("views.prefill_descriptions.edit.possible_values.link.text"), - Rails.application.routes.url_helpers.prefill_type_de_champ_path(revision.procedure_path, self), + Rails.application.routes.url_helpers.prefill_type_de_champ_path(@revision.procedure_path, self), title: new_tab_suffix(I18n.t("views.prefill_descriptions.edit.possible_values.link.title")), **external_link_attributes ) diff --git a/lib/tasks/deployment/20230602165134_migrate_remaining_data_for_routing_with_dropdown_list.rake b/lib/tasks/deployment/20230602165134_migrate_remaining_data_for_routing_with_dropdown_list.rake index d9a03d1e9..adc865a4c 100644 --- a/lib/tasks/deployment/20230602165134_migrate_remaining_data_for_routing_with_dropdown_list.rake +++ b/lib/tasks/deployment/20230602165134_migrate_remaining_data_for_routing_with_dropdown_list.rake @@ -29,7 +29,7 @@ namespace :after_party do procedure_ids = Procedure.with_discarded .where(routing_enabled: true) .where(migrated_champ_routage: [nil, false]) - .filter { |p| p.active_revision.types_de_champ.none?(&:used_by_routing_rules?) } + .filter { |p| p.active_revision.revision_types_de_champ_public.none?(&:used_by_routing_rules?) } .filter { |p| p.groupe_instructeurs.active.count > 1 } .pluck(:id) diff --git a/spec/components/types_de_champ_editor/champ_component_spec.rb b/spec/components/types_de_champ_editor/champ_component_spec.rb index 2efc1f7cc..da52aa878 100644 --- a/spec/components/types_de_champ_editor/champ_component_spec.rb +++ b/spec/components/types_de_champ_editor/champ_component_spec.rb @@ -16,7 +16,7 @@ describe TypesDeChampEditor::ChampComponent, type: :component do describe 'tdc dropdown' do let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :drop_down_list, libelle: 'Votre ville', options: ['Paris', 'Lyon', 'Marseille'] }]) } let(:tdc) { procedure.draft_revision.types_de_champ.first } - let(:coordinate) { tdc.revision_type_de_champ } + let(:coordinate) { procedure.draft_revision.coordinate_for(tdc) } context 'drop down tdc not used for routing' do it do diff --git a/spec/controllers/administrateurs/types_de_champ_controller_spec.rb b/spec/controllers/administrateurs/types_de_champ_controller_spec.rb index aaadbcbb3..a4c3a6696 100644 --- a/spec/controllers/administrateurs/types_de_champ_controller_spec.rb +++ b/spec/controllers/administrateurs/types_de_champ_controller_spec.rb @@ -98,7 +98,7 @@ describe Administrateurs::TypesDeChampController, type: :controller do it do is_expected.to have_http_status(:ok) - expect(assigns(:coordinate)).to be_nil + expect(assigns(:coordinate)).to eq(second_coordinate) expect(flash.alert).to eq(["Le champ « Libelle » doit être rempli"]) end end diff --git a/spec/factories/champ.rb b/spec/factories/champ.rb index e3f173c0a..bab863c84 100644 --- a/spec/factories/champ.rb +++ b/spec/factories/champ.rb @@ -190,7 +190,7 @@ FactoryBot.define do end after(:build) do |champ_repetition, evaluator| - revision = champ_repetition.type_de_champ.procedure.active_revision + revision = champ_repetition.procedure.active_revision parent = revision.revision_types_de_champ.find { _1.type_de_champ == champ_repetition.type_de_champ } types_de_champ = revision.revision_types_de_champ.filter { _1.parent == parent }.map(&:type_de_champ) diff --git a/spec/models/procedure_revision_spec.rb b/spec/models/procedure_revision_spec.rb index 97e53841f..1d6856b1d 100644 --- a/spec/models/procedure_revision_spec.rb +++ b/spec/models/procedure_revision_spec.rb @@ -63,7 +63,7 @@ describe ProcedureRevision do it do expect { subject }.to change { draft.reload.types_de_champ.count }.from(4).to(5) expect(draft.children_of(type_de_champ_repetition).last).to eq(subject) - expect(draft.children_of(type_de_champ_repetition).map(&:revision_type_de_champ).map(&:position)).to eq([0, 1]) + expect(draft.children_of(type_de_champ_repetition).map { draft.coordinate_for(_1).position }).to eq([0, 1]) expect(last_coordinate.position).to eq(1) diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index ba22d25b1..25784929d 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -717,26 +717,26 @@ describe Procedure do procedure.draft_revision.types_de_champ_public.zip(subject.draft_revision.types_de_champ_public).each do |ptc, stc| expect(stc).to have_same_attributes_as(ptc) - expect(stc.revision).to eq(subject.draft_revision) + expect(stc.revisions).to include(subject.draft_revision) end public_repetition = type_de_champ_repetition cloned_public_repetition = subject.draft_revision.types_de_champ_public.repetition.first procedure.draft_revision.children_of(public_repetition).zip(subject.draft_revision.children_of(cloned_public_repetition)).each do |ptc, stc| expect(stc).to have_same_attributes_as(ptc) - expect(stc.revision).to eq(subject.draft_revision) + expect(stc.revisions).to include(subject.draft_revision) end procedure.draft_revision.types_de_champ_private.zip(subject.draft_revision.types_de_champ_private).each do |ptc, stc| expect(stc).to have_same_attributes_as(ptc) - expect(stc.revision).to eq(subject.draft_revision) + expect(stc.revisions).to include(subject.draft_revision) end private_repetition = type_de_champ_private_repetition cloned_private_repetition = subject.draft_revision.types_de_champ_private.repetition.first procedure.draft_revision.children_of(private_repetition).zip(subject.draft_revision.children_of(cloned_private_repetition)).each do |ptc, stc| expect(stc).to have_same_attributes_as(ptc) - expect(stc.revision).to eq(subject.draft_revision) + expect(stc.revisions).to include(subject.draft_revision) end expect(subject.attestation_template.title).to eq(procedure.attestation_template.title) diff --git a/spec/tasks/maintenance/delete_draft_revision_type_de_champs_task_spec.rb b/spec/tasks/maintenance/delete_draft_revision_type_de_champs_task_spec.rb index 05886ae4e..25b93ebcf 100644 --- a/spec/tasks/maintenance/delete_draft_revision_type_de_champs_task_spec.rb +++ b/spec/tasks/maintenance/delete_draft_revision_type_de_champs_task_spec.rb @@ -51,9 +51,9 @@ module Maintenance tdc = find_by_stable_id(11) expect(tdc).to be_nil - tdc = find_by_stable_id(131) + tdc, coord = find_with_coordinate_by_stable_id(131) expect(tdc).not_to be_nil - expect(tdc.revision_type_de_champ.position).to eq(0) # reindexed + expect(coord.position).to eq(0) # reindexed tdc = find_by_stable_id(132) expect(tdc).to be_nil @@ -63,5 +63,10 @@ module Maintenance def find_by_stable_id(stable_id) procedure.draft_revision.types_de_champ.find { _1.stable_id == stable_id } end + + def find_with_coordinate_by_stable_id(stable_id) + tdc = find_by_stable_id(stable_id) + [tdc, procedure.draft_revision.coordinate_for(tdc)] + end end end diff --git a/spec/tasks/maintenance/update_draft_revision_type_de_champs_task_spec.rb b/spec/tasks/maintenance/update_draft_revision_type_de_champs_task_spec.rb index 57e01ef8d..f27c199d6 100644 --- a/spec/tasks/maintenance/update_draft_revision_type_de_champs_task_spec.rb +++ b/spec/tasks/maintenance/update_draft_revision_type_de_champs_task_spec.rb @@ -40,31 +40,31 @@ module Maintenance it "updates the type de champ" do process - tdc = find_by_stable_id(12) - expect(tdc.revision_type_de_champ.position).to eq(0) + tdc, coord = find_with_coordinate_by_stable_id(12) + expect(coord.position).to eq(0) expect(tdc.libelle).to eq("[NEW] Number") expect(tdc.description).to eq("[NEW] Number desc") expect(tdc.mandatory).to eq(true) - tdc = find_by_stable_id(13) - expect(tdc.revision_type_de_champ.position).to eq(1) + tdc, coord = find_with_coordinate_by_stable_id(13) + expect(coord.position).to eq(1) expect(tdc.libelle).to eq("Bloc") expect(tdc.description).to eq("[NEW] bloc desc") expect(tdc.mandatory).to eq(false) - tdc = find_by_stable_id(132) - expect(tdc.revision_type_de_champ.position).to eq(0) + tdc, coord = find_with_coordinate_by_stable_id(132) + expect(coord.position).to eq(0) expect(tdc.libelle).to eq("[NEW] RepNum") expect(tdc.mandatory).to eq(true) - tdc = find_by_stable_id(131) - expect(tdc.revision_type_de_champ.position).to eq(1) + tdc, coord = find_with_coordinate_by_stable_id(131) + expect(coord.position).to eq(1) expect(tdc.libelle).to eq("[NEW] RepText") expect(tdc.description).to eq("") expect(tdc.mandatory).to eq(false) - tdc = find_by_stable_id(11) - expect(tdc.revision_type_de_champ.position).to eq(2) + tdc, coord = find_with_coordinate_by_stable_id(11) + expect(coord.position).to eq(2) expect(tdc.libelle).to eq("[supp] Text") expect(tdc.mandatory).to eq(false) end @@ -73,5 +73,10 @@ module Maintenance def find_by_stable_id(stable_id) procedure.draft_revision.types_de_champ.find { _1.stable_id == stable_id } end + + def find_with_coordinate_by_stable_id(stable_id) + tdc = find_by_stable_id(stable_id) + [tdc, procedure.draft_revision.coordinate_for(tdc)] + end end end From f94267578216595c95c966032f233b2a1ff0fc9c Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 10:52:23 +0100 Subject: [PATCH 1438/1532] send full messages to sentry --- app/models/assign_to.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/assign_to.rb b/app/models/assign_to.rb index cfab77484..19d1fd847 100644 --- a/app/models/assign_to.rb +++ b/app/models/assign_to.rb @@ -32,7 +32,7 @@ class AssignTo < ApplicationRecord if errors.present? Sentry.capture_message( "Destroying invalid ProcedurePresentation", - extra: { procedure_presentation_id: procedure_presentation.id, errors: } + extra: { procedure_presentation_id: procedure_presentation.id, errors: errors.full_messages } ) self.procedure_presentation = nil end From dae9a40c5cb3e28c514935dc08ea4d6868e0c5e2 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 10:55:48 +0100 Subject: [PATCH 1439/1532] fix: the option is mandatory in an enum filter --- app/components/instructeurs/column_filter_value_component.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/components/instructeurs/column_filter_value_component.rb b/app/components/instructeurs/column_filter_value_component.rb index 0fde78d38..ef83d1aa6 100644 --- a/app/components/instructeurs/column_filter_value_component.rb +++ b/app/components/instructeurs/column_filter_value_component.rb @@ -16,7 +16,8 @@ class Instructeurs::ColumnFilterValueComponent < ApplicationComponent id: 'value', name: "filters[][filter]", class: 'fr-select', - data: { no_autosubmit: true } + data: { no_autosubmit: true }, + required: true else tag.input( class: 'fr-input', From f88de46d8d76ca3889f62158f694d0107e1e7581 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 6 Nov 2024 11:59:25 +0100 Subject: [PATCH 1440/1532] fix(image_processor): a blob can be deleted at any time while processing --- app/jobs/image_processor_job.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/jobs/image_processor_job.rb b/app/jobs/image_processor_job.rb index f112d9fc3..fe5b356a4 100644 --- a/app/jobs/image_processor_job.rb +++ b/app/jobs/image_processor_job.rb @@ -10,6 +10,7 @@ class ImageProcessorJob < ApplicationJob discard_on ActiveRecord::RecordNotFound # If the file is deleted during the scan, ignore the error discard_on ActiveStorage::FileNotFoundError + discard_on ActiveRecord::InvalidForeignKey # If the file is not analyzed or scanned for viruses yet, retry later # (to avoid modifying the file while it is being scanned). retry_on FileNotScannedYetError, wait: :exponentially_longer, attempts: 10 From a9bb228f8e978a50f5bafa6e5ec13aa9818622c9 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 6 Nov 2024 12:18:40 +0100 Subject: [PATCH 1441/1532] fix(image_processor): discard job on known errors --- app/jobs/image_processor_job.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/jobs/image_processor_job.rb b/app/jobs/image_processor_job.rb index fe5b356a4..0a4d3ee85 100644 --- a/app/jobs/image_processor_job.rb +++ b/app/jobs/image_processor_job.rb @@ -11,6 +11,16 @@ class ImageProcessorJob < ApplicationJob # If the file is deleted during the scan, ignore the error discard_on ActiveStorage::FileNotFoundError discard_on ActiveRecord::InvalidForeignKey + # If the file is not an image, not in format we can process or the image is corrupted, ignore the error + DISCARDABLE_ERRORS = [ + 'improper image header', + 'width or height exceeds limit', + 'attempt to perform an operation not allowed by the security policy', + 'no decode delegate for this image format' + ] + discard_on do |_, error| + DISCARDABLE_ERRORS.any? { error.message.match?(_1) } + end # If the file is not analyzed or scanned for viruses yet, retry later # (to avoid modifying the file while it is being scanned). retry_on FileNotScannedYetError, wait: :exponentially_longer, attempts: 10 From 52ffe3c89a747a07fbda23ea69d846f91e418e4d Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Wed, 6 Nov 2024 15:18:06 +0100 Subject: [PATCH 1442/1532] fix-sorted-by-label --- app/services/dossier_filter_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/dossier_filter_service.rb b/app/services/dossier_filter_service.rb index 54e700965..982675ade 100644 --- a/app/services/dossier_filter_service.rb +++ b/app/services/dossier_filter_service.rb @@ -58,7 +58,7 @@ class DossierFilterService .uniq when 'dossier_labels' dossiers.includes(table) - .order("#{self.class.sanitized_column(table, column)} #{order}") + .order("#{sanitized_column(table, column)} #{order}") .pluck(:id) .uniq when 'self', 'user', 'individual', 'etablissement', 'groupe_instructeur' From 27f14954b689f20db87866f31bb4c039fddf74e8 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Wed, 6 Nov 2024 16:08:35 +0100 Subject: [PATCH 1443/1532] add-spec-for-sorted-by-labels --- spec/services/dossier_filter_service_spec.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/spec/services/dossier_filter_service_spec.rb b/spec/services/dossier_filter_service_spec.rb index c1dbc539a..98a80bd8e 100644 --- a/spec/services/dossier_filter_service_spec.rb +++ b/spec/services/dossier_filter_service_spec.rb @@ -273,6 +273,23 @@ describe DossierFilterService do it { is_expected.to eq([dossier_no, dossier_yes].map(&:id)) } end + context 'for labels table' do + let(:column) { procedure.find_column(label: 'Labels') } + let(:order) { 'asc' } + + let(:label_a) { Label.create(name: "a", color: 'green-bourgeon', procedure:) } + let(:label_z) { Label.create(name: "z", color: 'green-bourgeon', procedure:) } + let!(:dossier_a) { create(:dossier, procedure:) } + let!(:dossier_z) { create(:dossier, procedure:) } + let(:dossier_label_a) { DossierLabel.create(dossier: dossier_a, label: label_a) } + let(:dossier_label_z) { DossierLabel.create(dossier: dossier_z, label: label_z) } + + before do + end + + it { is_expected.to eq([dossier_a, dossier_z].map(&:id)) } + end + context 'for other tables' do # All other columns and tables work the same so it’s ok to test only one let(:column) { procedure.find_column(label: 'Établissement code postal') } From 1e99c122b720f3138abbc22976ab45df57bea0b7 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 16 Oct 2024 14:42:52 +0200 Subject: [PATCH 1444/1532] feat(instruction options management): add link to go back to procedure --- .../instructeurs_options_component.html.haml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml index 4b281219a..a06836ec8 100644 --- a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml +++ b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml @@ -66,3 +66,11 @@ = link_to 'Retour', options_admin_procedure_groupe_instructeurs_path(@procedure), class: 'fr-btn fr-btn--secondary' %li %button.fr-btn{ disabled: true, data: { 'enable-submit-if-checked-target': 'submit' } } Continuer +- if params[:state] != 'choix' + .padded-fixed-footer + .fixed-footer + .fr-container + %ul.fr-btns-group.fr-btns-group--inline-md.fr-ml-0 + %li + = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w fr-mr-2w' do + Revenir à l’écran de gestion From 5ff09d1f9e315009e85cca7ae087126fca28e03d Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 16 Oct 2024 14:44:58 +0200 Subject: [PATCH 1445/1532] clean(wording): use right character for apostrophe --- .../ineligibilite_rules_component.html.haml | 2 +- .../fixed_footer_component/fixed_footer_component.html.fr.yml | 4 ++-- app/views/administrateurs/procedures/annotations.html.haml | 2 +- app/views/administrateurs/procedures/champs.html.haml | 2 +- spec/system/administrateurs/procedure_ineligibilite_spec.rb | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml index 7cbbca2fa..a012dc339 100644 --- a/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml +++ b/app/components/conditions/ineligibilite_rules_component/ineligibilite_rules_component.html.haml @@ -44,6 +44,6 @@ .fr-col-12 %ul.fr-btns-group.fr-btns-group--inline-md %li - = link_to "Annuler et revenir à l'écran de gestion", admin_procedure_path(id: @draft_revision.procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Si vous avez fait des modifications elles ne seront pas sauvegardées.'} + = link_to "Annuler et revenir à l’écran de gestion", admin_procedure_path(id: @draft_revision.procedure), class: 'fr-btn fr-btn--secondary', data: { confirm: 'Si vous avez fait des modifications elles ne seront pas sauvegardées.'} %li = button_tag "Enregistrer", class: "fr-btn", form: 'ineligibilite_form' diff --git a/app/components/procedure/fixed_footer_component/fixed_footer_component.html.fr.yml b/app/components/procedure/fixed_footer_component/fixed_footer_component.html.fr.yml index 0c67a97f1..70525d005 100644 --- a/app/components/procedure/fixed_footer_component/fixed_footer_component.html.fr.yml +++ b/app/components/procedure/fixed_footer_component/fixed_footer_component.html.fr.yml @@ -1,4 +1,4 @@ fr: - back: Revenir à l'écran de gestion + back: Revenir à l’écran de gestion submit: Enregistrer - cancel: Annuler et revenir à l'écran de gestion + cancel: Annuler et revenir à l’écran de gestion diff --git a/app/views/administrateurs/procedures/annotations.html.haml b/app/views/administrateurs/procedures/annotations.html.haml index 579e9cac1..a8eb33b08 100644 --- a/app/views/administrateurs/procedures/annotations.html.haml +++ b/app/views/administrateurs/procedures/annotations.html.haml @@ -20,7 +20,7 @@ .fr-container %ul.fr-btns-group.fr-btns-group--inline-md.fr-ml-0 %li - = link_to "Revenir à l'écran de gestion", admin_procedure_path(@procedure), title: t('continue_annotations', scope: [:layouts, :breadcrumb]), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w fr-mr-2w' + = link_to "Revenir à l’écran de gestion", admin_procedure_path(@procedure), title: t('continue_annotations', scope: [:layouts, :breadcrumb]), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w fr-mr-2w' - if @procedure.draft_revision.revision_types_de_champ_private.count > 0 %li = link_to t('preview_annotations', scope: [:layouts, :breadcrumb]), apercu_admin_procedure_path(@procedure, params: {tab: 'annotations-privees'}), target: "_blank", rel: "noopener", class: 'fr-link fr-mb-2w' diff --git a/app/views/administrateurs/procedures/champs.html.haml b/app/views/administrateurs/procedures/champs.html.haml index e2a33167f..7fa005e67 100644 --- a/app/views/administrateurs/procedures/champs.html.haml +++ b/app/views/administrateurs/procedures/champs.html.haml @@ -26,7 +26,7 @@ %ul.fr-btns-group.fr-btns-group--inline-md.fr-ml-0 %li = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w fr-mr-2w' do - Revenir à l'écran de gestion + Revenir à l’écran de gestion - if @procedure.draft_revision.revision_types_de_champ_public.count > 0 %li = link_to t('preview', scope: [:layouts, :breadcrumb]), apercu_admin_procedure_path(@procedure), target: "_blank", rel: "noopener", class: 'fr-link fr-mb-2w' diff --git a/spec/system/administrateurs/procedure_ineligibilite_spec.rb b/spec/system/administrateurs/procedure_ineligibilite_spec.rb index e20ff40af..541822101 100644 --- a/spec/system/administrateurs/procedure_ineligibilite_spec.rb +++ b/spec/system/administrateurs/procedure_ineligibilite_spec.rb @@ -24,7 +24,7 @@ describe 'Administrateurs can edit procedures', js: true do click_on 'Ajouter un champ' select "Oui/Non" fill_in "Libellé du champ", with: "Un champ oui non" - click_on "Revenir à l'écran de gestion" + click_on "Revenir à l’écran de gestion" procedure.reload first_tdc = procedure.draft_revision.types_de_champ.first # back to procedure dashboard, explain you can set it up now From f53af82f146558edc365eadde4ddbae3473332dd Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 16 Oct 2024 15:57:18 +0200 Subject: [PATCH 1446/1532] feat(instruction routage choice): add back link instead of menu --- .../instructeurs_menu_component.html.haml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/components/procedure/instructeurs_menu_component/instructeurs_menu_component.html.haml b/app/components/procedure/instructeurs_menu_component/instructeurs_menu_component.html.haml index df138518d..ec0588d15 100644 --- a/app/components/procedure/instructeurs_menu_component/instructeurs_menu_component.html.haml +++ b/app/components/procedure/instructeurs_menu_component/instructeurs_menu_component.html.haml @@ -1,6 +1,13 @@ .container .fr-grid-row .fr-col.fr-col-12.fr-col-md-3 - = render(Dsfr::SidemenuComponent.new) do |component| - - component.with_links(links) + - if params[:state] != 'choix' + = render(Dsfr::SidemenuComponent.new) do |component| + - component.with_links(links) + - else + .fr-container + %ul.fr-btns-group.fr-btns-group--inline-md.fr-ml-0 + %li + = link_to options_admin_procedure_groupe_instructeurs_path, class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w fr-mr-2w' do + Revenir aux options .fr-col= content From b511eb2db7da83f4d149a60c018c9fb85819dfd7 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 16 Oct 2024 16:25:11 +0200 Subject: [PATCH 1447/1532] feat(instruction routage choice): update view --- .../instructeurs_options_component.html.haml | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml index a06836ec8..1ff6b031d 100644 --- a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml +++ b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml @@ -54,18 +54,22 @@ data: { controller: 'enable-submit-if-checked' }, url: wizard_admin_procedure_groupe_instructeurs_path(@procedure) do |f| - %div{ data: { 'action': "click->enable-submit-if-checked#click" } } + %h1 Configuration du routage + %h2 Choix du type de routage + + .card.fr-pb-0{ data: { 'action': "click->enable-submit-if-checked#click" } } + %p.fr-mb-0 Routage = render Dsfr::RadioButtonListComponent.new(form: f, target: :state, buttons: [ { label: 'À partir d’un champ', value: 'routage_simple', hint: 'crée les groupes en fonction d’un champ du formulaire' } , - { label: 'Avancé', value: 'routage_custom', hint: 'libre à vous de créer et de configurer les groupes' }]) do - %h1 Choix du type de routage + { label: 'Avancé', value: 'routage_custom', hint: 'libre à vous de créer et de configurer les groupes' }]) - %ul.fr-btns-group.fr-btns-group--inline-sm - %li - = link_to 'Retour', options_admin_procedure_groupe_instructeurs_path(@procedure), class: 'fr-btn fr-btn--secondary' - %li - %button.fr-btn{ disabled: true, data: { 'enable-submit-if-checked-target': 'submit' } } Continuer + + %ul.fr-btns-group.fr-btns-group--inline-sm + %li + = link_to 'Annuler', options_admin_procedure_groupe_instructeurs_path(@procedure), class: 'fr-btn fr-btn--secondary' + %li + %button.fr-btn{ disabled: true, data: { 'enable-submit-if-checked-target': 'submit' } } Continuer - if params[:state] != 'choix' .padded-fixed-footer .fixed-footer From 7caadc3b409adf98feff82dc0111de318c4aba9f Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Thu, 17 Oct 2024 09:09:28 +0200 Subject: [PATCH 1448/1532] refactor(routing): rename routage_custom to custom_routing --- .../instructeurs_options_component.html.haml | 2 +- .../administrateurs/groupe_instructeurs_controller.rb | 8 ++++---- .../groupe_instructeurs_controller_spec.rb | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml index 1ff6b031d..e4084a801 100644 --- a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml +++ b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml @@ -62,7 +62,7 @@ = render Dsfr::RadioButtonListComponent.new(form: f, target: :state, buttons: [ { label: 'À partir d’un champ', value: 'routage_simple', hint: 'crée les groupes en fonction d’un champ du formulaire' } , - { label: 'Avancé', value: 'routage_custom', hint: 'libre à vous de créer et de configurer les groupes' }]) + { label: 'Avancé', value: 'custom_routing', hint: 'libre à vous de créer et de configurer les groupes' }]) %ul.fr-btns-group.fr-btns-group--inline-sm diff --git a/app/controllers/administrateurs/groupe_instructeurs_controller.rb b/app/controllers/administrateurs/groupe_instructeurs_controller.rb index 241e0929b..544e3591f 100644 --- a/app/controllers/administrateurs/groupe_instructeurs_controller.rb +++ b/app/controllers/administrateurs/groupe_instructeurs_controller.rb @@ -27,7 +27,7 @@ module Administrateurs def options @procedure = procedure if params[:state] == 'choix' && @procedure.active_revision.simple_routable_types_de_champ.none? - configurate_routage_custom + configurate_custom_routing end end @@ -93,14 +93,14 @@ module Administrateurs end def wizard - if params[:choice][:state] == 'routage_custom' - configurate_routage_custom + if params[:choice][:state] == 'custom_routing' + configurate_custom_routing elsif params[:choice][:state] == 'routage_simple' redirect_to simple_routing_admin_procedure_groupe_instructeurs_path end end - def configurate_routage_custom + def configurate_custom_routing new_label = procedure.defaut_groupe_instructeur.label + ' bis' procedure.groupe_instructeurs .create({ label: new_label, instructeurs: [current_administrateur.instructeur] }) diff --git a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb index 668bc1873..c835721c5 100644 --- a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb +++ b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb @@ -1058,7 +1058,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do let!(:drop_down_tdc) { procedure4.draft_revision.types_de_champ.first } - before { patch :wizard, params: { procedure_id: procedure4.id, choice: { state: 'routage_custom' } } } + before { patch :wizard, params: { procedure_id: procedure4.id, choice: { state: 'custom_routing' } } } it do expect(response).to redirect_to(admin_procedure_groupe_instructeurs_path(procedure4)) From 5c53c6b86cab8361a6436f1d64a95f4323652bbc Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Thu, 17 Oct 2024 09:20:52 +0200 Subject: [PATCH 1449/1532] feat(routing): add modal once custom routing is configurated --- .../groupes_management_component.html.haml | 3 +++ .../groupe_instructeurs_controller.rb | 2 ++ app/helpers/application_helper.rb | 2 ++ .../_custom_routing_modal.html.haml | 20 +++++++++++++++++++ .../routing/rules_full_scenario_spec.rb | 5 +++++ 5 files changed, 32 insertions(+) create mode 100644 app/views/administrateurs/groupe_instructeurs/_custom_routing_modal.html.haml diff --git a/app/components/procedure/groupes_management_component/groupes_management_component.html.haml b/app/components/procedure/groupes_management_component/groupes_management_component.html.haml index 3ae0bb3f9..7548d6da0 100644 --- a/app/components/procedure/groupes_management_component/groupes_management_component.html.haml +++ b/app/components/procedure/groupes_management_component/groupes_management_component.html.haml @@ -50,3 +50,6 @@ = select_tag :defaut_groupe_instructeur_id, options_for_select(@procedure.groupe_instructeurs.pluck(:label, :id), selected: @procedure.defaut_groupe_instructeur.id), class: 'fr-select' + +- if flash[:routing_mode] == 'custom' + = render partial: 'custom_routing_modal' diff --git a/app/controllers/administrateurs/groupe_instructeurs_controller.rb b/app/controllers/administrateurs/groupe_instructeurs_controller.rb index 544e3591f..741e3a025 100644 --- a/app/controllers/administrateurs/groupe_instructeurs_controller.rb +++ b/app/controllers/administrateurs/groupe_instructeurs_controller.rb @@ -107,6 +107,8 @@ module Administrateurs procedure.toggle_routing + flash[:routing_mode] = 'custom' + redirect_to admin_procedure_groupe_instructeurs_path(procedure) end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 3fdc45795..a4dc44d5d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -44,6 +44,8 @@ module ApplicationHelper class_names << 'alert-success' when 'alert', 'error' class_names << 'alert-danger' + when 'routing_mode' + class_names << 'hidden' end if sticky class_names << 'sticky' diff --git a/app/views/administrateurs/groupe_instructeurs/_custom_routing_modal.html.haml b/app/views/administrateurs/groupe_instructeurs/_custom_routing_modal.html.haml new file mode 100644 index 000000000..cc503f514 --- /dev/null +++ b/app/views/administrateurs/groupe_instructeurs/_custom_routing_modal.html.haml @@ -0,0 +1,20 @@ +%dialog{ aria: { labelledby: "fr-modal-title-modal-1" }, role: "dialog", id: "routing-mode-modal", class: "fr-modal fr-modal--opened" } + .fr-container.fr-container--fluid.fr-container-md + .fr-grid-row.fr-grid-row--center + .fr-col-12.fr-col-md-8.fr-col-lg-6 + .fr-modal__body + .fr-modal__header + %button.fr-btn.fr-btn--close{ title: "Fermer la fenêtre modale", aria: { controls: "routing-mode-modal" } } Fermer + .fr-modal__content + %h1#fr-modal-title-modal-1.fr-modal__title + %span.fr-icon-arrow-right-line.fr-icon--lg + Routage avancé + .fr-alert.fr-alert--success + %h2.fr-alert__title + Deux groupes par défaut ont été créés + %p + Vous devez maintenant les renommer et leur attribuer des règles de routage à partir du ou des champs « routables » de votre formulaire, soit des champs de type : + %ul + - TypeDeChamp.humanized_conditionable_types_by_category.each do |category| + %li + = category.join(', ') diff --git a/spec/system/routing/rules_full_scenario_spec.rb b/spec/system/routing/rules_full_scenario_spec.rb index ee4620705..5d392b03d 100644 --- a/spec/system/routing/rules_full_scenario_spec.rb +++ b/spec/system/routing/rules_full_scenario_spec.rb @@ -53,6 +53,11 @@ describe 'The routing with rules', js: true do expect(page).to have_text('Gestion des groupes') expect(page).to have_text('règle invalide') + # close modal + expect(page).to have_selector("#routing-mode-modal", visible: true) + within("#routing-mode-modal") { click_on "Fermer" } + expect(page).to have_selector("#routing-mode-modal", visible: false) + # update defaut groupe click_on 'défaut' expect(page).to have_text('Paramètres du groupe') From 5376b70167af8d327ad374ac39abb56e2bb3d231 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Thu, 17 Oct 2024 09:44:58 +0200 Subject: [PATCH 1450/1532] feat(routing): update names of groupe instructeurs created with custom routing --- .../administrateurs/groupe_instructeurs_controller.rb | 4 ++-- .../administrateurs/groupe_instructeurs_controller_spec.rb | 2 +- spec/system/routing/rules_full_scenario_spec.rb | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/controllers/administrateurs/groupe_instructeurs_controller.rb b/app/controllers/administrateurs/groupe_instructeurs_controller.rb index 741e3a025..607749f0c 100644 --- a/app/controllers/administrateurs/groupe_instructeurs_controller.rb +++ b/app/controllers/administrateurs/groupe_instructeurs_controller.rb @@ -101,9 +101,9 @@ module Administrateurs end def configurate_custom_routing - new_label = procedure.defaut_groupe_instructeur.label + ' bis' + procedure.defaut_groupe_instructeur.update!(label: 'Groupe 1 (à renommer et configurer)') procedure.groupe_instructeurs - .create({ label: new_label, instructeurs: [current_administrateur.instructeur] }) + .create({ label: 'Groupe 2 (à renommer et configurer)', instructeurs: [current_administrateur.instructeur] }) procedure.toggle_routing diff --git a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb index c835721c5..abf329856 100644 --- a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb +++ b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb @@ -1062,7 +1062,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do it do expect(response).to redirect_to(admin_procedure_groupe_instructeurs_path(procedure4)) - expect(procedure4.groupe_instructeurs.pluck(:label)).to match_array(['défaut', 'défaut bis']) + expect(procedure4.groupe_instructeurs.pluck(:label)).to match_array(['Groupe 1 (à renommer et configurer)', 'Groupe 2 (à renommer et configurer)']) expect(procedure4.reload.routing_enabled).to be_truthy end end diff --git a/spec/system/routing/rules_full_scenario_spec.rb b/spec/system/routing/rules_full_scenario_spec.rb index 5d392b03d..b38bbba68 100644 --- a/spec/system/routing/rules_full_scenario_spec.rb +++ b/spec/system/routing/rules_full_scenario_spec.rb @@ -59,7 +59,7 @@ describe 'The routing with rules', js: true do expect(page).to have_selector("#routing-mode-modal", visible: false) # update defaut groupe - click_on 'défaut' + click_on 'Groupe 1 (à renommer et configurer)' expect(page).to have_text('Paramètres du groupe') fill_in 'Nom du groupe', with: 'littéraire' click_on 'Renommer' @@ -88,7 +88,7 @@ describe 'The routing with rules', js: true do # # add scientifique groupe click_on '3 groupes' - click_on 'défaut bis' + click_on 'Groupe 2 (à renommer et configurer)' fill_in 'Nom du groupe', with: 'scientifique' click_on 'Renommer' expect(page).to have_text('Le nom est à présent « scientifique ». ') From a0bd85f24735ccd82c4d01f926596e44dbfccde2 Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Thu, 7 Nov 2024 11:39:32 +0100 Subject: [PATCH 1451/1532] fix order by label name --- app/services/dossier_filter_service.rb | 4 ++-- spec/services/dossier_filter_service_spec.rb | 17 +++++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app/services/dossier_filter_service.rb b/app/services/dossier_filter_service.rb index 982675ade..aeb394ee1 100644 --- a/app/services/dossier_filter_service.rb +++ b/app/services/dossier_filter_service.rb @@ -57,8 +57,8 @@ class DossierFilterService .pluck(:id) .uniq when 'dossier_labels' - dossiers.includes(table) - .order("#{sanitized_column(table, column)} #{order}") + dossiers.includes(:labels) + .order("labels.name #{order}") .pluck(:id) .uniq when 'self', 'user', 'individual', 'etablissement', 'groupe_instructeur' diff --git a/spec/services/dossier_filter_service_spec.rb b/spec/services/dossier_filter_service_spec.rb index 98a80bd8e..437df287e 100644 --- a/spec/services/dossier_filter_service_spec.rb +++ b/spec/services/dossier_filter_service_spec.rb @@ -275,19 +275,24 @@ describe DossierFilterService do context 'for labels table' do let(:column) { procedure.find_column(label: 'Labels') } - let(:order) { 'asc' } let(:label_a) { Label.create(name: "a", color: 'green-bourgeon', procedure:) } let(:label_z) { Label.create(name: "z", color: 'green-bourgeon', procedure:) } - let!(:dossier_a) { create(:dossier, procedure:) } let!(:dossier_z) { create(:dossier, procedure:) } - let(:dossier_label_a) { DossierLabel.create(dossier: dossier_a, label: label_a) } - let(:dossier_label_z) { DossierLabel.create(dossier: dossier_z, label: label_z) } + let!(:dossier_a) { create(:dossier, procedure:) } + let!(:dossier_no_label) { create(:dossier, procedure:) } + let!(:dossier_label_a) { DossierLabel.create(dossier: dossier_a, label: label_a) } + let!(:dossier_label_z) { DossierLabel.create(dossier: dossier_z, label: label_z) } - before do + context 'asc' do + let(:order) { 'asc' } + it { is_expected.to eq([dossier_a, dossier_z, dossier_no_label].map(&:id)) } end - it { is_expected.to eq([dossier_a, dossier_z].map(&:id)) } + context 'desc' do + let(:order) { 'desc' } + it { is_expected.to eq([dossier_no_label, dossier_z, dossier_a].map(&:id)) } + end end context 'for other tables' do From ebf839f37b7a91520d120912841f3af86499a286 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 30 Oct 2024 15:00:08 +0100 Subject: [PATCH 1452/1532] =?UTF-8?q?fix(process=5Fimage):=20sometimes=20l?= =?UTF-8?q?ast=20is=20nil=20=F0=9F=A4=B7=E2=80=8D=E2=99=82=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/concerns/attachment_image_processor_concern.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/concerns/attachment_image_processor_concern.rb b/app/models/concerns/attachment_image_processor_concern.rb index 921d1c5bf..1457122a5 100644 --- a/app/models/concerns/attachment_image_processor_concern.rb +++ b/app/models/concerns/attachment_image_processor_concern.rb @@ -19,7 +19,7 @@ module AttachmentImageProcessorConcern def process_image return if blob.nil? return if blob.attachments.size != 1 - return if blob.attachments.last.record_type == "Export" + return if blob.attachments.any? { _1.record_type == "Export" } return if !blob.content_type.in?(PROCESSABLE_TYPES) return if blob.byte_size.zero? # some empty files may be considered as image depending on filename From 523c56f60b051f8b5ab94a95173810d1888e6f76 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Thu, 7 Nov 2024 12:05:04 +0100 Subject: [PATCH 1453/1532] fix(image processing): catch minimagick error caused by png path --- app/services/uninterlace_service.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/services/uninterlace_service.rb b/app/services/uninterlace_service.rb index 0ec100e38..72b51b63e 100644 --- a/app/services/uninterlace_service.rb +++ b/app/services/uninterlace_service.rb @@ -18,7 +18,11 @@ class UninterlaceService def interlaced?(png_path) return false if png_path.blank? - png = MiniMagick::Image.open(png_path) + begin + png = MiniMagick::Image.open(png_path) + rescue MiniMagick::Invalid + return false + end png.data["interlace"] != "None" end end From 2c8ee1f2c1b3f1a9da9c8865fd615a2cb3484b03 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 09:39:29 +0100 Subject: [PATCH 1454/1532] clean: use `groupe_instructeur?` --- app/components/instructeurs/column_filter_value_component.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/instructeurs/column_filter_value_component.rb b/app/components/instructeurs/column_filter_value_component.rb index ef83d1aa6..f8b893b64 100644 --- a/app/components/instructeurs/column_filter_value_component.rb +++ b/app/components/instructeurs/column_filter_value_component.rb @@ -48,7 +48,7 @@ class Instructeurs::ColumnFilterValueComponent < ApplicationComponent def options_for_select_of_column if column.scope.present? I18n.t(column.scope).map(&:to_a).map(&:reverse) - elsif column.table == 'groupe_instructeur' + elsif column.groupe_instructeur? current_instructeur.groupe_instructeurs.filter_map do if _1.procedure_id == procedure_id [_1.label, _1.id] From 9e5713f4707dcf6179554f1c5439e1e5df00e9ff Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 11:24:27 +0100 Subject: [PATCH 1455/1532] a champ_column can have options_for_select --- app/models/columns/champ_column.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/models/columns/champ_column.rb b/app/models/columns/champ_column.rb index c75b223ab..13aea0a27 100644 --- a/app/models/columns/champ_column.rb +++ b/app/models/columns/champ_column.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true class Columns::ChampColumn < Column - attr_reader :stable_id + attr_reader :stable_id, :options_for_select - def initialize(procedure_id:, label:, stable_id:, tdc_type:, displayable: true, filterable: true, type: :text, value_column: :value) + def initialize(procedure_id:, label:, stable_id:, tdc_type:, displayable: true, filterable: true, type: :text, value_column: :value, options_for_select: []) @stable_id = stable_id @tdc_type = tdc_type + @options_for_select = options_for_select super( procedure_id:, From 49d366144131ae9c57c97db16e8627b4505f7f8f Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 12:02:53 +0100 Subject: [PATCH 1456/1532] inject options_for_select for linked_drop_down_column --- app/models/columns/linked_drop_down_column.rb | 5 +++-- app/models/type_de_champ.rb | 6 ------ .../types_de_champ/linked_drop_down_list_type_de_champ.rb | 6 ++++-- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/app/models/columns/linked_drop_down_column.rb b/app/models/columns/linked_drop_down_column.rb index 4530bf97f..f750c1fa8 100644 --- a/app/models/columns/linked_drop_down_column.rb +++ b/app/models/columns/linked_drop_down_column.rb @@ -3,7 +3,7 @@ class Columns::LinkedDropDownColumn < Columns::ChampColumn attr_reader :path - def initialize(procedure_id:, label:, stable_id:, tdc_type:, path:, displayable:, type: :text) + def initialize(procedure_id:, label:, stable_id:, tdc_type:, path:, options_for_select: [], displayable:, type: :text) @path = path super( @@ -12,7 +12,8 @@ class Columns::LinkedDropDownColumn < Columns::ChampColumn stable_id:, tdc_type:, displayable:, - type: + type:, + options_for_select: ) end diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 50e170baa..d87836187 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -558,12 +558,6 @@ class TypeDeChamp < ApplicationRecord APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } elsif region? APIGeoService.regions.map { [_1[:name], _1[:code]] } - elsif linked_drop_down_list? - if column.path == :primary - primary_options - else - secondary_options.values.flatten - end elsif choice_type? if drop_down_list? drop_down_options diff --git a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb index c6396be6b..e15b86809 100644 --- a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb @@ -89,7 +89,8 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas label: "#{libelle_with_prefix(prefix)} (Primaire)", type: :enum, path: :primary, - displayable: false + displayable: false, + options_for_select: primary_options ), Columns::LinkedDropDownColumn.new( procedure_id: procedure.id, @@ -98,7 +99,8 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas label: "#{libelle_with_prefix(prefix)} (Secondaire)", type: :enum, path: :secondary, - displayable: false + displayable: false, + options_for_select: secondary_options.values.flatten.uniq.sort ) ] end From db2e4cf802429b8b6d62f095b3e557ab660e4e1d Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 11:28:51 +0100 Subject: [PATCH 1457/1532] inject options_for_select for json_column --- app/models/columns/json_path_column.rb | 16 +++----------- .../concerns/addressable_column_concern.rb | 21 ++++++++++++------- app/models/type_de_champ.rb | 4 +--- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/app/models/columns/json_path_column.rb b/app/models/columns/json_path_column.rb index 89e4e15f3..a60d5d872 100644 --- a/app/models/columns/json_path_column.rb +++ b/app/models/columns/json_path_column.rb @@ -3,7 +3,7 @@ class Columns::JSONPathColumn < Columns::ChampColumn attr_reader :jsonpath - def initialize(procedure_id:, label:, stable_id:, tdc_type:, jsonpath:, displayable:, type: :text) + def initialize(procedure_id:, label:, stable_id:, tdc_type:, jsonpath:, options_for_select: [], displayable:, type: :text) @jsonpath = quote_string(jsonpath) super( @@ -12,7 +12,8 @@ class Columns::JSONPathColumn < Columns::ChampColumn stable_id:, tdc_type:, displayable:, - type: + type:, + options_for_select: ) end @@ -26,17 +27,6 @@ class Columns::JSONPathColumn < Columns::ChampColumn .ids end - def options_for_select - case jsonpath.split('.').last - when 'departement_code' - APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } - when 'region_name' - APIGeoService.regions.map { [_1[:name], _1[:name]] } - else - [] - end - end - private def column_id = "type_de_champ/#{stable_id}-#{jsonpath}" diff --git a/app/models/concerns/addressable_column_concern.rb b/app/models/concerns/addressable_column_concern.rb index e7ba4d11e..76af699cb 100644 --- a/app/models/concerns/addressable_column_concern.rb +++ b/app/models/concerns/addressable_column_concern.rb @@ -5,12 +5,16 @@ module AddressableColumnConcern included do def columns(procedure:, displayable: true, prefix: nil) - super.concat([ - ["code postal (5 chiffres)", '$.postal_code', :text], - ["commune", '$.city_name', :text], - ["département", '$.departement_code', :enum], - ["region", '$.region_name', :enum] - ].map do |(label, jsonpath, type)| + departement_options = APIGeoService.departements + .map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } + region_options = APIGeoService.regions.map { [_1[:name], _1[:name]] } + + addressable_columns = [ + ["code postal (5 chiffres)", '$.postal_code', :text, []], + ["commune", '$.city_name', :text, []], + ["département", '$.departement_code', :enum, departement_options], + ["region", '$.region_name', :enum, region_options] + ].map do |(label, jsonpath, type, options_for_select)| Columns::JSONPathColumn.new( procedure_id: procedure.id, stable_id:, @@ -18,9 +22,12 @@ module AddressableColumnConcern label: "#{libelle_with_prefix(prefix)} – #{label}", jsonpath:, displayable:, + options_for_select:, type: ) - end) + end + + super.concat(addressable_columns) end end end diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index d87836187..c857ecdfe 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -553,7 +553,7 @@ class TypeDeChamp < ApplicationRecord end end - def options_for_select(column) + def options_for_select if departement? APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } elsif region? @@ -566,8 +566,6 @@ class TypeDeChamp < ApplicationRecord elsif checkbox? Champs::CheckboxChamp.options end - elsif siret? || rna? || rnf? - column.options_for_select end end From 690d2c6258f074dca9faec7c350339605f8df8af Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 11:47:10 +0100 Subject: [PATCH 1458/1532] inject options in champ_column --- app/models/types_de_champ/type_de_champ_base.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index fb3549f94..885d1be49 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -3,7 +3,7 @@ class TypesDeChamp::TypeDeChampBase include ActiveModel::Validations - delegate :description, :libelle, :mandatory, :mandatory?, :stable_id, :fillable?, :public?, :type_champ, to: :@type_de_champ + delegate :description, :libelle, :mandatory, :mandatory?, :stable_id, :fillable?, :public?, :type_champ, :options_for_select, to: :@type_de_champ FILL_DURATION_SHORT = 10.seconds FILL_DURATION_MEDIUM = 1.minute @@ -105,7 +105,8 @@ class TypesDeChamp::TypeDeChampBase label: libelle_with_prefix(prefix), type: TypeDeChamp.column_type(type_champ), value_column: TypeDeChamp.value_column(type_champ), - displayable: + displayable:, + options_for_select: ) ] else From cd3e645046b12433348824101360e1ff4ac6343b Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 12:03:22 +0100 Subject: [PATCH 1459/1532] sort departement options --- app/models/type_de_champ.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index c857ecdfe..550bdfdbc 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -555,7 +555,7 @@ class TypeDeChamp < ApplicationRecord def options_for_select if departement? - APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } + APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] }.sort elsif region? APIGeoService.regions.map { [_1[:name], _1[:code]] } elsif choice_type? From 92e29c6e1328d55fe3d105023878ef0fecd67c06 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 12:09:58 +0100 Subject: [PATCH 1460/1532] one if less deep --- app/models/type_de_champ.rb | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 550bdfdbc..f6ba0ff7d 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -558,14 +558,12 @@ class TypeDeChamp < ApplicationRecord APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] }.sort elsif region? APIGeoService.regions.map { [_1[:name], _1[:code]] } - elsif choice_type? - if drop_down_list? - drop_down_options - elsif yes_no? - Champs::YesNoChamp.options - elsif checkbox? - Champs::CheckboxChamp.options - end + elsif drop_down_list? + drop_down_options + elsif yes_no? + Champs::YesNoChamp.options + elsif checkbox? + Champs::CheckboxChamp.options end end From f8dc1d1533055fa57c082a5d18562a0a552fe8ca Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 11:55:59 +0100 Subject: [PATCH 1461/1532] use column.options_for_select --- .../instructeurs/column_filter_value_component.rb | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/app/components/instructeurs/column_filter_value_component.rb b/app/components/instructeurs/column_filter_value_component.rb index f8b893b64..556494151 100644 --- a/app/components/instructeurs/column_filter_value_component.rb +++ b/app/components/instructeurs/column_filter_value_component.rb @@ -58,18 +58,12 @@ class Instructeurs::ColumnFilterValueComponent < ApplicationComponent Procedure.find(procedure_id).labels.filter_map do [_1.name, _1.id] end + elsif column.is_a?(Columns::ChampColumn) + column.options_for_select else - find_type_de_champ(column.stable_id).options_for_select(column) + [] end end - def find_type_de_champ(stable_id) - TypeDeChamp - .joins(:revision_types_de_champ) - .where(revision_types_de_champ: { revision_id: ProcedureRevision.where(procedure_id:) }) - .order(created_at: :desc) - .find_by(stable_id:) - end - def procedure_id = @column.h_id[:procedure_id] end From f5227f960912712d6551e8c6527f3f6af6482089 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 13:48:47 +0100 Subject: [PATCH 1462/1532] move options_for_select from champ_column to column --- .../instructeurs/column_filter_value_component.rb | 4 +--- app/models/column.rb | 5 +++-- app/models/columns/champ_column.rb | 6 +++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/app/components/instructeurs/column_filter_value_component.rb b/app/components/instructeurs/column_filter_value_component.rb index 556494151..abd4c10a9 100644 --- a/app/components/instructeurs/column_filter_value_component.rb +++ b/app/components/instructeurs/column_filter_value_component.rb @@ -58,10 +58,8 @@ class Instructeurs::ColumnFilterValueComponent < ApplicationComponent Procedure.find(procedure_id).labels.filter_map do [_1.name, _1.id] end - elsif column.is_a?(Columns::ChampColumn) - column.options_for_select else - [] + column.options_for_select end end diff --git a/app/models/column.rb b/app/models/column.rb index badb669cc..c813aa674 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -8,9 +8,9 @@ class Column TYPE_DE_CHAMP_TABLE = 'type_de_champ' - attr_reader :table, :column, :label, :type, :scope, :value_column, :filterable, :displayable + attr_reader :table, :column, :label, :type, :scope, :value_column, :filterable, :displayable, :options_for_select - def initialize(procedure_id:, table:, column:, label: nil, type: :text, value_column: :value, filterable: true, displayable: true, scope: '') + def initialize(procedure_id:, table:, column:, label: nil, type: :text, value_column: :value, filterable: true, displayable: true, scope: '', options_for_select: []) @procedure_id = procedure_id @table = table @column = column @@ -20,6 +20,7 @@ class Column @value_column = value_column @filterable = filterable @displayable = displayable + @options_for_select = options_for_select end # the id is a String to be used in forms diff --git a/app/models/columns/champ_column.rb b/app/models/columns/champ_column.rb index 13aea0a27..4db5e298c 100644 --- a/app/models/columns/champ_column.rb +++ b/app/models/columns/champ_column.rb @@ -1,12 +1,11 @@ # frozen_string_literal: true class Columns::ChampColumn < Column - attr_reader :stable_id, :options_for_select + attr_reader :stable_id def initialize(procedure_id:, label:, stable_id:, tdc_type:, displayable: true, filterable: true, type: :text, value_column: :value, options_for_select: []) @stable_id = stable_id @tdc_type = tdc_type - @options_for_select = options_for_select super( procedure_id:, @@ -16,7 +15,8 @@ class Columns::ChampColumn < Column type:, value_column:, displayable:, - filterable: + filterable:, + options_for_select: ) end From 2b6cc495419d25abd788f0796418473a347d3f78 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 13:50:32 +0100 Subject: [PATCH 1463/1532] inject dossier_labels at dossier_labels_column build time --- app/components/instructeurs/column_filter_value_component.rb | 4 ---- app/models/concerns/columns_concern.rb | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/app/components/instructeurs/column_filter_value_component.rb b/app/components/instructeurs/column_filter_value_component.rb index abd4c10a9..cc1d15485 100644 --- a/app/components/instructeurs/column_filter_value_component.rb +++ b/app/components/instructeurs/column_filter_value_component.rb @@ -54,10 +54,6 @@ class Instructeurs::ColumnFilterValueComponent < ApplicationComponent [_1.label, _1.id] end end - elsif column.table == 'dossier_labels' - Procedure.find(procedure_id).labels.filter_map do - [_1.name, _1.id] - end else column.options_for_select end diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index e01173412..6a269c21e 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -91,7 +91,7 @@ module ColumnsConcern def user_france_connected_column = Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false) - def dossier_labels_column = Columns::DossierColumn.new(procedure_id: id, table: 'dossier_labels', column: 'label_id', type: :enum) + def dossier_labels_column = Columns::DossierColumn.new(procedure_id: id, table: 'dossier_labels', column: 'label_id', type: :enum, options_for_select: labels.map { [_1.name, _1.id] }) def procedure_chorus_columns ['domaine_fonctionnel', 'referentiel_prog', 'centre_de_cout'] From e2bc45dc4a92ae3ed805ecfded2eff0f101b3f72 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 16:24:29 +0100 Subject: [PATCH 1464/1532] inject dossier_state at build time --- .../instructeurs/column_filter_value_component.rb | 4 +--- app/models/concerns/columns_concern.rb | 6 +++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/components/instructeurs/column_filter_value_component.rb b/app/components/instructeurs/column_filter_value_component.rb index cc1d15485..3a313b087 100644 --- a/app/components/instructeurs/column_filter_value_component.rb +++ b/app/components/instructeurs/column_filter_value_component.rb @@ -46,9 +46,7 @@ class Instructeurs::ColumnFilterValueComponent < ApplicationComponent end def options_for_select_of_column - if column.scope.present? - I18n.t(column.scope).map(&:to_a).map(&:reverse) - elsif column.groupe_instructeur? + if column.groupe_instructeur? current_instructeur.groupe_instructeurs.filter_map do if _1.procedure_id == procedure_id [_1.label, _1.id] diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 6a269c21e..8ce24202f 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -53,7 +53,11 @@ module ColumnsConcern def dossier_id_column = Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'id', type: :number) - def dossier_state_column = Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false) + def dossier_state_column + options_for_select = I18n.t('instructeurs.dossiers.filterable_state').map(&:to_a).map(&:reverse) + + Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'state', type: :enum, options_for_select:, displayable: false) + end def notifications_column = Columns::DossierColumn.new(procedure_id: id, table: 'notifications', column: 'notifications', label: "notifications", filterable: false) From 1277500069afc262e5e87bc46d0f324250fe8942 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 20:57:51 +0100 Subject: [PATCH 1465/1532] inject groupe_instructeurs when possible --- app/models/concerns/columns_concern.rb | 7 ++++++- spec/models/concerns/columns_concern_spec.rb | 22 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 8ce24202f..99d1f8083 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -83,7 +83,12 @@ module ColumnsConcern private - def groupe_instructeurs_id_column = Columns::DossierColumn.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum) + def groupe_instructeurs_id_column + groupes = Current.user&.instructeur&.groupe_instructeurs || [] + options_for_select = groupes.filter_map { [_1.label, _1.id] if _1.procedure_id == id } + + Columns::DossierColumn.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum, options_for_select:) + end def followers_instructeurs_email_column = Columns::DossierColumn.new(procedure_id: id, table: 'followers_instructeurs', column: 'email') diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index 1f6f0b98b..dffc714ad 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -133,6 +133,28 @@ describe ColumnsConcern do it { is_expected.to include(decision_on, decision_before_field) } end + + context 'when the procedure has several groupe instructeur' do + let(:procedure) { create(:procedure, :routee) } + let(:groupe_1) { procedure.groupe_instructeurs.first } + let(:groupe_2) { procedure.groupe_instructeurs.last } + let(:groupe_instructeur_column) { procedure.find_column(label: "Groupe instructeur") } + + context 'and no instructeur is available in current' do + it { expect(groupe_instructeur_column.options_for_select).to eq([]) } + end + + context 'and instructeur is available in current' do + let(:instructeur) { create(:instructeur) } + + before do + procedure.groupe_instructeurs.each { _1.add(instructeur) } + allow(Current).to receive(:user).and_return(instructeur.user) + end + + it { expect(groupe_instructeur_column.options_for_select).to match_array([groupe_1, groupe_2].map { [_1.label, _1.id] }) } + end + end end describe 'export' do From 60947f2b975142cfc5703782141cc5a26f72e422 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 20:58:08 +0100 Subject: [PATCH 1466/1532] use it in filter_value_component --- .../column_filter_value_component.rb | 16 +-------- .../column_filter_value_component_spec.rb | 35 ++++++------------- 2 files changed, 11 insertions(+), 40 deletions(-) diff --git a/app/components/instructeurs/column_filter_value_component.rb b/app/components/instructeurs/column_filter_value_component.rb index 3a313b087..89713b985 100644 --- a/app/components/instructeurs/column_filter_value_component.rb +++ b/app/components/instructeurs/column_filter_value_component.rb @@ -12,7 +12,7 @@ class Instructeurs::ColumnFilterValueComponent < ApplicationComponent def call if column_type.in?([:enum, :enums, :boolean]) select_tag :filter, - options_for_select(options_for_select_of_column), + options_for_select(column.options_for_select), id: 'value', name: "filters[][filter]", class: 'fr-select', @@ -44,18 +44,4 @@ class Instructeurs::ColumnFilterValueComponent < ApplicationComponent 'text' end end - - def options_for_select_of_column - if column.groupe_instructeur? - current_instructeur.groupe_instructeurs.filter_map do - if _1.procedure_id == procedure_id - [_1.label, _1.id] - end - end - else - column.options_for_select - end - end - - def procedure_id = @column.h_id[:procedure_id] end diff --git a/spec/components/instructeurs/column_filter_value_component_spec.rb b/spec/components/instructeurs/column_filter_value_component_spec.rb index 11452b963..22df5dddc 100644 --- a/spec/components/instructeurs/column_filter_value_component_spec.rb +++ b/spec/components/instructeurs/column_filter_value_component_spec.rb @@ -2,34 +2,19 @@ describe Instructeurs::ColumnFilterValueComponent, type: :component do let(:component) { described_class.new(column:) } - let(:instructeur) { create(:instructeur) } - let(:procedure) { create(:procedure, instructeurs: [instructeur]) } - let(:procedure_id) { procedure.id } - before do - allow(component).to receive(:current_instructeur).and_return(instructeur) + before { render_inline(component) } + + describe 'the select case' do + let(:column) { double("Column", type: :enum, options_for_select:) } + let(:options_for_select) { ['option1', 'option2'] } + + it { expect(page).to have_select('filters[][filter]', options: options_for_select) } end - describe '.options_for_select_of_column' do - subject { component.send(:options_for_select_of_column) } + describe 'the input case' do + let(:column) { double("Column", type: :datetime) } - context "column is groupe_instructeur" do - let(:column) { double("Column", scope: nil, table: 'groupe_instructeur', h_id: { procedure_id: }) } - let!(:gi_2) { instructeur.groupe_instructeurs.create(label: 'gi2', procedure:) } - let!(:gi_3) { instructeur.groupe_instructeurs.create(label: 'gi3', procedure: create(:procedure)) } - - it { is_expected.to eq([['défaut', procedure.defaut_groupe_instructeur.id], ['gi2', gi_2.id]]) } - end - - context 'when column is dropdown' do - let(:types_de_champ_public) { [{ type: :drop_down_list, libelle: 'Votre ville', options: ['Paris', 'Lyon', 'Marseille'] }] } - let(:procedure) { create(:procedure, :published, types_de_champ_public:) } - let(:drop_down_stable_id) { procedure.active_revision.types_de_champ.first.stable_id } - let(:column) { procedure.find_column(label: 'Votre ville') } - - it 'find most recent tdc' do - is_expected.to eq(['Paris', 'Lyon', 'Marseille']) - end - end + it { expect(page).to have_selector('input[name="filters[][filter]"][type="date"]') } end end From 5532a8a48f6864c6548f36ba6f5d09965012b1e5 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 17:45:06 +0100 Subject: [PATCH 1467/1532] small cleaning in filter_value_component --- .../column_filter_value_component.rb | 18 ++++++++---------- .../column_filter_value_component_spec.rb | 6 ++++++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/app/components/instructeurs/column_filter_value_component.rb b/app/components/instructeurs/column_filter_value_component.rb index 89713b985..61bd4df4b 100644 --- a/app/components/instructeurs/column_filter_value_component.rb +++ b/app/components/instructeurs/column_filter_value_component.rb @@ -7,25 +7,23 @@ class Instructeurs::ColumnFilterValueComponent < ApplicationComponent @column = column end - def column_type = column.present? ? column.type : :text - def call - if column_type.in?([:enum, :enums, :boolean]) - select_tag :filter, + if column.nil? + tag.input(id: 'value', class: 'fr-input', disabled: true) + elsif column.type.in?([:enum, :enums, :boolean]) + select_tag 'filters[][filter]', options_for_select(column.options_for_select), id: 'value', - name: "filters[][filter]", class: 'fr-select', data: { no_autosubmit: true }, required: true else tag.input( - class: 'fr-input', - id: 'value', - type:, name: "filters[][filter]", + id: 'value', + class: 'fr-input', + type:, maxlength: FilteredColumn::FILTERS_VALUE_MAX_LENGTH, - disabled: column.nil? ? true : false, data: { no_autosubmit: true }, required: true ) @@ -35,7 +33,7 @@ class Instructeurs::ColumnFilterValueComponent < ApplicationComponent private def type - case column_type + case column.type when :datetime, :date 'date' when :integer, :decimal diff --git a/spec/components/instructeurs/column_filter_value_component_spec.rb b/spec/components/instructeurs/column_filter_value_component_spec.rb index 22df5dddc..b4147c3b6 100644 --- a/spec/components/instructeurs/column_filter_value_component_spec.rb +++ b/spec/components/instructeurs/column_filter_value_component_spec.rb @@ -17,4 +17,10 @@ describe Instructeurs::ColumnFilterValueComponent, type: :component do it { expect(page).to have_selector('input[name="filters[][filter]"][type="date"]') } end + + describe 'the column empty case' do + let(:column) { nil } + + it { expect(page).to have_selector('input[disabled]') } + end end From 9fd3a460cdcc577dccb2ac0d380888d427e18f1d Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 18:03:28 +0100 Subject: [PATCH 1468/1532] =?UTF-8?q?remove=20scope=20arg=20from=20column.?= =?UTF-8?q?new=20=F0=9F=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/column.rb | 7 +- spec/models/concerns/columns_concern_spec.rb | 86 ++++++++++---------- 2 files changed, 46 insertions(+), 47 deletions(-) diff --git a/app/models/column.rb b/app/models/column.rb index c813aa674..178b7860f 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -8,15 +8,14 @@ class Column TYPE_DE_CHAMP_TABLE = 'type_de_champ' - attr_reader :table, :column, :label, :type, :scope, :value_column, :filterable, :displayable, :options_for_select + attr_reader :table, :column, :label, :type, :value_column, :filterable, :displayable, :options_for_select - def initialize(procedure_id:, table:, column:, label: nil, type: :text, value_column: :value, filterable: true, displayable: true, scope: '', options_for_select: []) + def initialize(procedure_id:, table:, column:, label: nil, type: :text, value_column: :value, filterable: true, displayable: true, options_for_select: []) @procedure_id = procedure_id @table = table @column = column @label = label || I18n.t(column, scope: [:activerecord, :attributes, :procedure_presentation, :fields, table]) @type = type - @scope = scope @value_column = value_column @filterable = filterable @displayable = displayable @@ -34,7 +33,7 @@ class Column def to_json { - table:, column:, label:, type:, scope:, value_column:, filterable:, displayable: + table:, column:, label:, type:, value_column:, filterable:, displayable: } end diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index dffc714ad..c6c8ac4da 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -29,42 +29,42 @@ describe ColumnsConcern do let(:tdc_private_2) { procedure.active_revision.types_de_champ_private[1] } let(:expected) { [ - { label: 'Dossier ID', table: 'self', column: 'id', displayable: true, type: :number, scope: '', value_column: :value, filterable: true }, - { label: 'notifications', table: 'notifications', column: 'notifications', displayable: true, type: :text, scope: '', value_column: :value, filterable: false }, - { label: 'Date de création', table: 'self', column: 'created_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'Mis à jour le', table: 'self', column: 'updated_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'Date de dépot', table: 'self', column: 'depose_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'En construction le', table: 'self', column: 'en_construction_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'En instruction le', table: 'self', column: 'en_instruction_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'Terminé le', table: 'self', column: 'processed_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "Dernier évènement depuis", table: "self", column: "updated_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "Déposé depuis", table: "self", column: "depose_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "En construction depuis", table: "self", column: "en_construction_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "En instruction depuis", table: "self", column: "en_instruction_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "Traité depuis", table: "self", column: "processed_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "Statut", table: "self", column: "state", displayable: false, scope: 'instructeurs.dossiers.filterable_state', type: :enum, value_column: :value, filterable: true }, - { label: "Archivé", table: "self", column: "archived", displayable: false, scope: '', type: :text, value_column: :value, filterable: false }, - { label: "Motivation de la décision", table: "self", column: "motivation", displayable: false, scope: '', type: :text, value_column: :value, filterable: false }, - { label: "Date de dernière modification (usager)", table: "self", column: "last_champ_updated_at", displayable: false, scope: '', type: :text, value_column: :value, filterable: false }, - { label: 'Demandeur', table: 'user', column: 'email', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Email instructeur', table: 'followers_instructeurs', column: 'email', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Groupe instructeur', table: 'groupe_instructeur', column: 'id', displayable: true, type: :enum, scope: '', value_column: :value, filterable: true }, - { label: 'Avis oui/non', table: 'avis', column: 'question_answer', displayable: true, type: :text, scope: '', value_column: :value, filterable: false }, - { label: 'France connecté ?', table: 'self', column: 'user_from_france_connect?', displayable: false, type: :text, scope: '', value_column: :value, filterable: false }, - { label: "Labels", table: "dossier_labels", column: "label_id", displayable: true, scope: '', value_column: :value, filterable: true }, - { label: 'SIREN', table: 'etablissement', column: 'entreprise_siren', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Forme juridique', table: 'etablissement', column: 'entreprise_forme_juridique', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Nom commercial', table: 'etablissement', column: 'entreprise_nom_commercial', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Raison sociale', table: 'etablissement', column: 'entreprise_raison_sociale', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'SIRET siège social', table: 'etablissement', column: 'entreprise_siret_siege_social', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Date de création', table: 'etablissement', column: 'entreprise_date_creation', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'SIRET', table: 'etablissement', column: 'siret', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Libellé NAF', table: 'etablissement', column: 'libelle_naf', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Code postal', table: 'etablissement', column: 'code_postal', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: tdc_1.libelle, table: 'type_de_champ', column: tdc_1.stable_id.to_s, displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: tdc_2.libelle, table: 'type_de_champ', column: tdc_2.stable_id.to_s, displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: tdc_private_1.libelle, table: 'type_de_champ', column: tdc_private_1.stable_id.to_s, displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: tdc_private_2.libelle, table: 'type_de_champ', column: tdc_private_2.stable_id.to_s, displayable: true, type: :text, scope: '', value_column: :value, filterable: true } + { label: 'Dossier ID', table: 'self', column: 'id', displayable: true, type: :number, value_column: :value, filterable: true }, + { label: 'notifications', table: 'notifications', column: 'notifications', displayable: true, type: :text, value_column: :value, filterable: false }, + { label: 'Date de création', table: 'self', column: 'created_at', displayable: true, type: :date, value_column: :value, filterable: true }, + { label: 'Mis à jour le', table: 'self', column: 'updated_at', displayable: true, type: :date, value_column: :value, filterable: true }, + { label: 'Date de dépot', table: 'self', column: 'depose_at', displayable: true, type: :date, value_column: :value, filterable: true }, + { label: 'En construction le', table: 'self', column: 'en_construction_at', displayable: true, type: :date, value_column: :value, filterable: true }, + { label: 'En instruction le', table: 'self', column: 'en_instruction_at', displayable: true, type: :date, value_column: :value, filterable: true }, + { label: 'Terminé le', table: 'self', column: 'processed_at', displayable: true, type: :date, value_column: :value, filterable: true }, + { label: "Dernier évènement depuis", table: "self", column: "updated_since", displayable: false, type: :date, value_column: :value, filterable: true }, + { label: "Déposé depuis", table: "self", column: "depose_since", displayable: false, type: :date, value_column: :value, filterable: true }, + { label: "En construction depuis", table: "self", column: "en_construction_since", displayable: false, type: :date, value_column: :value, filterable: true }, + { label: "En instruction depuis", table: "self", column: "en_instruction_since", displayable: false, type: :date, value_column: :value, filterable: true }, + { label: "Traité depuis", table: "self", column: "processed_since", displayable: false, type: :date, value_column: :value, filterable: true }, + { label: "Statut", table: "self", column: "state", displayable: false, type: :enum, value_column: :value, filterable: true }, + { label: "Archivé", table: "self", column: "archived", displayable: false, type: :text, value_column: :value, filterable: false }, + { label: "Motivation de la décision", table: "self", column: "motivation", displayable: false, type: :text, value_column: :value, filterable: false }, + { label: "Date de dernière modification (usager)", table: "self", column: "last_champ_updated_at", displayable: false, type: :text, value_column: :value, filterable: false }, + { label: 'Demandeur', table: 'user', column: 'email', displayable: true, type: :text, value_column: :value, filterable: true }, + { label: 'Email instructeur', table: 'followers_instructeurs', column: 'email', displayable: true, type: :text, value_column: :value, filterable: true }, + { label: 'Groupe instructeur', table: 'groupe_instructeur', column: 'id', displayable: true, type: :enum, value_column: :value, filterable: true }, + { label: 'Avis oui/non', table: 'avis', column: 'question_answer', displayable: true, type: :text, value_column: :value, filterable: false }, + { label: 'France connecté ?', table: 'self', column: 'user_from_france_connect?', displayable: false, type: :text, value_column: :value, filterable: false }, + { label: "Labels", table: "dossier_labels", column: "label_id", displayable: true, value_column: :value, filterable: true }, + { label: 'SIREN', table: 'etablissement', column: 'entreprise_siren', displayable: true, type: :text, value_column: :value, filterable: true }, + { label: 'Forme juridique', table: 'etablissement', column: 'entreprise_forme_juridique', displayable: true, type: :text, value_column: :value, filterable: true }, + { label: 'Nom commercial', table: 'etablissement', column: 'entreprise_nom_commercial', displayable: true, type: :text, value_column: :value, filterable: true }, + { label: 'Raison sociale', table: 'etablissement', column: 'entreprise_raison_sociale', displayable: true, type: :text, value_column: :value, filterable: true }, + { label: 'SIRET siège social', table: 'etablissement', column: 'entreprise_siret_siege_social', displayable: true, type: :text, value_column: :value, filterable: true }, + { label: 'Date de création', table: 'etablissement', column: 'entreprise_date_creation', displayable: true, type: :date, value_column: :value, filterable: true }, + { label: 'SIRET', table: 'etablissement', column: 'siret', displayable: true, type: :text, value_column: :value, filterable: true }, + { label: 'Libellé NAF', table: 'etablissement', column: 'libelle_naf', displayable: true, type: :text, value_column: :value, filterable: true }, + { label: 'Code postal', table: 'etablissement', column: 'code_postal', displayable: true, type: :text, value_column: :value, filterable: true }, + { label: tdc_1.libelle, table: 'type_de_champ', column: tdc_1.stable_id.to_s, displayable: true, type: :text, value_column: :value, filterable: true }, + { label: tdc_2.libelle, table: 'type_de_champ', column: tdc_2.stable_id.to_s, displayable: true, type: :text, value_column: :value, filterable: true }, + { label: tdc_private_1.libelle, table: 'type_de_champ', column: tdc_private_1.stable_id.to_s, displayable: true, type: :text, value_column: :value, filterable: true }, + { label: tdc_private_2.libelle, table: 'type_de_champ', column: tdc_private_2.stable_id.to_s, displayable: true, type: :text, value_column: :value, filterable: true } ].map { Column.new(**_1.merge(procedure_id:)) } } @@ -102,9 +102,9 @@ describe ColumnsConcern do end context 'when the procedure is for individuals' do - let(:name_field) { Column.new(procedure_id:, label: "Prénom", table: "individual", column: "prenom", displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } - let(:surname_field) { Column.new(procedure_id:, label: "Nom", table: "individual", column: "nom", displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } - let(:gender_field) { Column.new(procedure_id:, label: "Civilité", table: "individual", column: "gender", displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } + let(:name_field) { Column.new(procedure_id:, label: "Prénom", table: "individual", column: "prenom", displayable: true, type: :text, value_column: :value, filterable: true) } + let(:surname_field) { Column.new(procedure_id:, label: "Nom", table: "individual", column: "nom", displayable: true, type: :text, value_column: :value, filterable: true) } + let(:gender_field) { Column.new(procedure_id:, label: "Civilité", table: "individual", column: "gender", displayable: true, type: :text, value_column: :value, filterable: true) } let(:procedure) { create(:procedure, :for_individual) } let(:procedure_id) { procedure.id } let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) } @@ -117,8 +117,8 @@ describe ColumnsConcern do let(:procedure_id) { procedure.id } let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) } - let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVA", table: "self", column: "sva_svr_decision_on", displayable: true, type: :date, scope: '', value_column: :value, filterable: true) } - let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVA avant", table: "self", column: "sva_svr_decision_before", displayable: false, type: :date, scope: '', value_column: :value, filterable: true) } + let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVA", table: "self", column: "sva_svr_decision_on", displayable: true, type: :date, value_column: :value, filterable: true) } + let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVA avant", table: "self", column: "sva_svr_decision_before", displayable: false, type: :date, value_column: :value, filterable: true) } it { is_expected.to include(decision_on, decision_before_field) } end @@ -128,8 +128,8 @@ describe ColumnsConcern do let(:procedure_id) { procedure.id } let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) } - let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVR", table: "self", column: "sva_svr_decision_on", displayable: true, type: :date, scope: '', value_column: :value, filterable: true) } - let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVR avant", table: "self", column: "sva_svr_decision_before", displayable: false, type: :date, scope: '', value_column: :value, filterable: true) } + let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVR", table: "self", column: "sva_svr_decision_on", displayable: true, type: :date, value_column: :value, filterable: true) } + let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVR avant", table: "self", column: "sva_svr_decision_before", displayable: false, type: :date, value_column: :value, filterable: true) } it { is_expected.to include(decision_on, decision_before_field) } end From 32d3ec2dc32801f911a6008033a3347c1853b2ff Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 21:18:31 +0100 Subject: [PATCH 1469/1532] add shortcut for DossierColumn.new --- app/models/concerns/columns_concern.rb | 48 ++++++++++++++------------ 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 99d1f8083..d52b6c403 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -51,25 +51,25 @@ module ColumnsConcern columns.filter { _1.id.in?(self.columns.map(&:id)) } end - def dossier_id_column = Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'id', type: :number) + def dossier_id_column = dossier_col(table: 'self', column: 'id', type: :number) def dossier_state_column options_for_select = I18n.t('instructeurs.dossiers.filterable_state').map(&:to_a).map(&:reverse) - Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'state', type: :enum, options_for_select:, displayable: false) + dossier_col(table: 'self', column: 'state', type: :enum, options_for_select:, displayable: false) end - def notifications_column = Columns::DossierColumn.new(procedure_id: id, table: 'notifications', column: 'notifications', label: "notifications", filterable: false) + def notifications_column = dossier_col(table: 'notifications', column: 'notifications', label: "notifications", filterable: false) def sva_svr_columns(for_export: false) scope = [:activerecord, :attributes, :procedure_presentation, :fields, :self] columns = [ - Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_on', type: :date, + dossier_col(table: 'self', column: 'sva_svr_decision_on', type: :date, label: I18n.t("#{sva_svr_decision}_decision_on", scope:, type: sva_svr_configuration.human_decision)) ] if !for_export - columns << Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false, + columns << dossier_col(table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false, label: I18n.t("#{sva_svr_decision}_decision_before", scope:)) end columns @@ -87,38 +87,38 @@ module ColumnsConcern groupes = Current.user&.instructeur&.groupe_instructeurs || [] options_for_select = groupes.filter_map { [_1.label, _1.id] if _1.procedure_id == id } - Columns::DossierColumn.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum, options_for_select:) + dossier_col(table: 'groupe_instructeur', column: 'id', type: :enum, options_for_select:) end - def followers_instructeurs_email_column = Columns::DossierColumn.new(procedure_id: id, table: 'followers_instructeurs', column: 'email') + def followers_instructeurs_email_column = dossier_col(table: 'followers_instructeurs', column: 'email') - def dossier_archived_column = Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'archived', type: :text, displayable: false, filterable: false); + def dossier_archived_column = dossier_col(table: 'self', column: 'archived', type: :text, displayable: false, filterable: false); - def dossier_motivation_column = Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'motivation', type: :text, displayable: false, filterable: false); + def dossier_motivation_column = dossier_col(table: 'self', column: 'motivation', type: :text, displayable: false, filterable: false); - def user_email_for_display_column = Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'user_email_for_display', filterable: false, displayable: false) + def user_email_for_display_column = dossier_col(table: 'self', column: 'user_email_for_display', filterable: false, displayable: false) - def user_france_connected_column = Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false) + def user_france_connected_column = dossier_col(table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false) - def dossier_labels_column = Columns::DossierColumn.new(procedure_id: id, table: 'dossier_labels', column: 'label_id', type: :enum, options_for_select: labels.map { [_1.name, _1.id] }) + def dossier_labels_column = dossier_col(table: 'dossier_labels', column: 'label_id', type: :enum, options_for_select: labels.map { [_1.name, _1.id] }) def procedure_chorus_columns ['domaine_fonctionnel', 'referentiel_prog', 'centre_de_cout'] - .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'procedure', column:, displayable: false, filterable: false) } + .map { |column| dossier_col(table: 'procedure', column:, displayable: false, filterable: false) } end def dossier_non_displayable_dates_columns ['updated_since', 'depose_since', 'en_construction_since', 'en_instruction_since', 'processed_since'] - .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'self', column:, type: :date, displayable: false) } + .map { |column| dossier_col(table: 'self', column:, type: :date, displayable: false) } end def dossier_dates_columns ['created_at', 'updated_at', 'last_champ_updated_at', 'depose_at', 'en_construction_at', 'en_instruction_at', 'processed_at'] - .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'self', column:, type: :date) } + .map { |column| dossier_col(table: 'self', column:, type: :date) } end def email_column - Columns::DossierColumn.new(procedure_id: id, table: 'user', column: 'email') + dossier_col(table: 'user', column: 'email') end def dossier_columns @@ -137,27 +137,27 @@ module ColumnsConcern user_email_for_display_column, followers_instructeurs_email_column, groupe_instructeurs_id_column, - Columns::DossierColumn.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false), + dossier_col(table: 'avis', column: 'question_answer', filterable: false), user_france_connected_column, dossier_labels_column ] end def individual_columns - ['gender', 'nom', 'prenom'].map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'individual', column:) } - .concat ['for_tiers', 'mandataire_last_name', 'mandataire_first_name'].map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'self', column:) } + ['gender', 'nom', 'prenom'].map { |column| dossier_col(table: 'individual', column:) } + .concat ['for_tiers', 'mandataire_last_name', 'mandataire_first_name'].map { |column| dossier_col(table: 'self', column:) } end def moral_columns etablissements = ['entreprise_forme_juridique', 'entreprise_siren', 'entreprise_nom_commercial', 'entreprise_raison_sociale', 'entreprise_siret_siege_social'] - .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'etablissement', column:) } + .map { |column| dossier_col(table: 'etablissement', column:) } - etablissement_dates = ['entreprise_date_creation'].map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'etablissement', column:, type: :date) } + etablissement_dates = ['entreprise_date_creation'].map { |column| dossier_col(table: 'etablissement', column:, type: :date) } for_export = ["siege_social", "naf", "adresse", "numero_voie", "type_voie", "nom_voie", "complement_adresse", "localite", "code_insee_localite", "entreprise_siren", "entreprise_capital_social", "entreprise_numero_tva_intracommunautaire", "entreprise_forme_juridique_code", "entreprise_code_effectif_entreprise", "entreprise_etat_administratif", "entreprise_nom", "entreprise_prenom", "association_rna", "association_titre", "association_objet", "association_date_creation", "association_date_declaration", "association_date_publication"] - .map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'etablissement', column:, displayable: false, filterable: false) } + .map { |column| dossier_col(table: 'etablissement', column:, displayable: false, filterable: false) } - other = ['siret', 'libelle_naf', 'code_postal'].map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'etablissement', column:) } + other = ['siret', 'libelle_naf', 'code_postal'].map { |column| dossier_col(table: 'etablissement', column:) } [etablissements, etablissement_dates, other, for_export].flatten end @@ -165,5 +165,7 @@ module ColumnsConcern def types_de_champ_columns all_revisions_types_de_champ.flat_map { _1.columns(procedure: self) } end + + def dossier_col(**args) = Columns::DossierColumn.new(**(args.merge(procedure_id: id))) end end From 055069755ad84827788c0686c132b9f5c5fa90b8 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 21:23:26 +0100 Subject: [PATCH 1470/1532] small cleaning in column_concern_spec --- spec/models/concerns/columns_concern_spec.rb | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index c6c8ac4da..32515dc10 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true describe ColumnsConcern do + let(:procedure_id) { procedure.id } + describe '#find_column' do let(:procedure) { build(:procedure) } let(:notifications_column) { procedure.notifications_column } @@ -22,7 +24,6 @@ describe ColumnsConcern do context 'when the procedure can have a SIRET number' do let(:procedure) { create(:procedure, types_de_champ_public:, types_de_champ_private:) } - let(:procedure_id) { procedure.id } let(:tdc_1) { procedure.active_revision.types_de_champ_public[0] } let(:tdc_2) { procedure.active_revision.types_de_champ_public[1] } let(:tdc_private_1) { procedure.active_revision.types_de_champ_private[0] } @@ -106,16 +107,12 @@ describe ColumnsConcern do let(:surname_field) { Column.new(procedure_id:, label: "Nom", table: "individual", column: "nom", displayable: true, type: :text, value_column: :value, filterable: true) } let(:gender_field) { Column.new(procedure_id:, label: "Civilité", table: "individual", column: "gender", displayable: true, type: :text, value_column: :value, filterable: true) } let(:procedure) { create(:procedure, :for_individual) } - let(:procedure_id) { procedure.id } - let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) } it { is_expected.to include(name_field, surname_field, gender_field) } end context 'when the procedure is sva' do - let(:procedure) { create(:procedure, :for_individual, :sva) } - let(:procedure_id) { procedure.id } - let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) } + let(:procedure) { create(:procedure, :sva) } let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVA", table: "self", column: "sva_svr_decision_on", displayable: true, type: :date, value_column: :value, filterable: true) } let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVA avant", table: "self", column: "sva_svr_decision_before", displayable: false, type: :date, value_column: :value, filterable: true) } @@ -124,9 +121,7 @@ describe ColumnsConcern do end context 'when the procedure is svr' do - let(:procedure) { create(:procedure, :for_individual, :svr) } - let(:procedure_id) { procedure.id } - let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) } + let(:procedure) { create(:procedure, :svr) } let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVR", table: "self", column: "sva_svr_decision_on", displayable: true, type: :date, value_column: :value, filterable: true) } let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVR avant", table: "self", column: "sva_svr_decision_before", displayable: false, type: :date, value_column: :value, filterable: true) } From 9d976b8d95ebe5a1f94325f0aa854ad38cd3ce14 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 21:53:47 +0100 Subject: [PATCH 1471/1532] move filtering logic inside champ_column --- app/models/columns/champ_column.rb | 13 +++++++++++++ app/services/dossier_filter_service.rb | 12 ------------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/app/models/columns/champ_column.rb b/app/models/columns/champ_column.rb index 4db5e298c..6ff99ff13 100644 --- a/app/models/columns/champ_column.rb +++ b/app/models/columns/champ_column.rb @@ -31,6 +31,19 @@ class Columns::ChampColumn < Column end end + def filtered_ids(dossiers, search_terms) + if type == :enum + dossiers.with_type_de_champ(stable_id) + .filter_enum(:champs, value_column, search_terms).ids + elsif type == :enums + dossiers.with_type_de_champ(stable_id) + .filter_array_enum(:champs, value_column, search_terms).ids + else + dossiers.with_type_de_champ(stable_id) + .filter_ilike(:champs, value_column, search_terms).ids + end + end + private def column_id = "type_de_champ/#{stable_id}" diff --git a/app/services/dossier_filter_service.rb b/app/services/dossier_filter_service.rb index 982675ade..88922bea9 100644 --- a/app/services/dossier_filter_service.rb +++ b/app/services/dossier_filter_service.rb @@ -74,7 +74,6 @@ class DossierFilterService .map do |(table, column), filters_for_column| values = filters_for_column.map(&:filter) filtered_column = filters_for_column.first.column - value_column = filtered_column.value_column if filtered_column.respond_to?(:filtered_ids) filtered_column.filtered_ids(dossiers, values) @@ -93,17 +92,6 @@ class DossierFilterService else dossiers.where("dossiers.#{column} IN (?)", values) end - when TYPE_DE_CHAMP - if filtered_column.type == :enum - dossiers.with_type_de_champ(column) - .filter_enum(:champs, value_column, values) - elsif filtered_column.type == :enums - dossiers.with_type_de_champ(column) - .filter_array_enum(:champs, value_column, values) - else - dossiers.with_type_de_champ(column) - .filter_ilike(:champs, value_column, values) - end when 'etablissement' if column == 'entreprise_date_creation' dates = values From 17531b73b7cdae8c9e9cbec4efc6d42cccc491c7 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 22:01:21 +0100 Subject: [PATCH 1472/1532] internalize value_column inside champ_column --- app/models/column.rb | 7 +- app/models/columns/champ_column.rb | 5 +- app/models/type_de_champ.rb | 8 -- .../piece_justificative_type_de_champ.rb | 1 - .../titre_identite_type_de_champ.rb | 1 - .../types_de_champ/type_de_champ_base.rb | 1 - spec/models/concerns/columns_concern_spec.rb | 86 +++++++++---------- 7 files changed, 49 insertions(+), 60 deletions(-) diff --git a/app/models/column.rb b/app/models/column.rb index 178b7860f..7f8cdc9b8 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -8,15 +8,14 @@ class Column TYPE_DE_CHAMP_TABLE = 'type_de_champ' - attr_reader :table, :column, :label, :type, :value_column, :filterable, :displayable, :options_for_select + attr_reader :table, :column, :label, :type, :filterable, :displayable, :options_for_select - def initialize(procedure_id:, table:, column:, label: nil, type: :text, value_column: :value, filterable: true, displayable: true, options_for_select: []) + def initialize(procedure_id:, table:, column:, label: nil, type: :text, filterable: true, displayable: true, options_for_select: []) @procedure_id = procedure_id @table = table @column = column @label = label || I18n.t(column, scope: [:activerecord, :attributes, :procedure_presentation, :fields, table]) @type = type - @value_column = value_column @filterable = filterable @displayable = displayable @options_for_select = options_for_select @@ -33,7 +32,7 @@ class Column def to_json { - table:, column:, label:, type:, value_column:, filterable:, displayable: + table:, column:, label:, type:, filterable:, displayable: } end diff --git a/app/models/columns/champ_column.rb b/app/models/columns/champ_column.rb index 6ff99ff13..aa7cb16d4 100644 --- a/app/models/columns/champ_column.rb +++ b/app/models/columns/champ_column.rb @@ -3,7 +3,7 @@ class Columns::ChampColumn < Column attr_reader :stable_id - def initialize(procedure_id:, label:, stable_id:, tdc_type:, displayable: true, filterable: true, type: :text, value_column: :value, options_for_select: []) + def initialize(procedure_id:, label:, stable_id:, tdc_type:, displayable: true, filterable: true, type: :text, options_for_select: []) @stable_id = stable_id @tdc_type = tdc_type @@ -13,7 +13,6 @@ class Columns::ChampColumn < Column column: stable_id.to_s, label:, type:, - value_column:, displayable:, filterable:, options_for_select: @@ -48,6 +47,8 @@ class Columns::ChampColumn < Column def column_id = "type_de_champ/#{stable_id}" + def value_column = @tdc_type.in?(['departements', 'regions']) ? :external_id : :value + def string_value(champ) = champ.public_send(value_column) def typed_value(champ) diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index f6ba0ff7d..e0e3319b3 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -545,14 +545,6 @@ class TypeDeChamp < ApplicationRecord end end - def self.value_column(type_champ) - if type_champ.in?([type_champs.fetch(:departements), type_champs.fetch(:regions)]) - :external_id - else - :value - end - end - def options_for_select if departement? APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] }.sort diff --git a/app/models/types_de_champ/piece_justificative_type_de_champ.rb b/app/models/types_de_champ/piece_justificative_type_de_champ.rb index dbebe11f9..3fef517d0 100644 --- a/app/models/types_de_champ/piece_justificative_type_de_champ.rb +++ b/app/models/types_de_champ/piece_justificative_type_de_champ.rb @@ -33,7 +33,6 @@ class TypesDeChamp::PieceJustificativeTypeDeChamp < TypesDeChamp::TypeDeChampBas tdc_type: type_champ, label: libelle_with_prefix(prefix), type: TypeDeChamp.column_type(type_champ), - value_column: TypeDeChamp.value_column(type_champ), displayable: false, filterable: false ) diff --git a/app/models/types_de_champ/titre_identite_type_de_champ.rb b/app/models/types_de_champ/titre_identite_type_de_champ.rb index e72944c15..ed929dec4 100644 --- a/app/models/types_de_champ/titre_identite_type_de_champ.rb +++ b/app/models/types_de_champ/titre_identite_type_de_champ.rb @@ -32,7 +32,6 @@ class TypesDeChamp::TitreIdentiteTypeDeChamp < TypesDeChamp::TypeDeChampBase tdc_type: type_champ, label: libelle_with_prefix(prefix), type: TypeDeChamp.column_type(type_champ), - value_column: TypeDeChamp.value_column(type_champ), displayable: false, filterable: false ) diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index 885d1be49..1cf5e66e4 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -104,7 +104,6 @@ class TypesDeChamp::TypeDeChampBase tdc_type: type_champ, label: libelle_with_prefix(prefix), type: TypeDeChamp.column_type(type_champ), - value_column: TypeDeChamp.value_column(type_champ), displayable:, options_for_select: ) diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index 32515dc10..0f046e23d 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -30,42 +30,42 @@ describe ColumnsConcern do let(:tdc_private_2) { procedure.active_revision.types_de_champ_private[1] } let(:expected) { [ - { label: 'Dossier ID', table: 'self', column: 'id', displayable: true, type: :number, value_column: :value, filterable: true }, - { label: 'notifications', table: 'notifications', column: 'notifications', displayable: true, type: :text, value_column: :value, filterable: false }, - { label: 'Date de création', table: 'self', column: 'created_at', displayable: true, type: :date, value_column: :value, filterable: true }, - { label: 'Mis à jour le', table: 'self', column: 'updated_at', displayable: true, type: :date, value_column: :value, filterable: true }, - { label: 'Date de dépot', table: 'self', column: 'depose_at', displayable: true, type: :date, value_column: :value, filterable: true }, - { label: 'En construction le', table: 'self', column: 'en_construction_at', displayable: true, type: :date, value_column: :value, filterable: true }, - { label: 'En instruction le', table: 'self', column: 'en_instruction_at', displayable: true, type: :date, value_column: :value, filterable: true }, - { label: 'Terminé le', table: 'self', column: 'processed_at', displayable: true, type: :date, value_column: :value, filterable: true }, - { label: "Dernier évènement depuis", table: "self", column: "updated_since", displayable: false, type: :date, value_column: :value, filterable: true }, - { label: "Déposé depuis", table: "self", column: "depose_since", displayable: false, type: :date, value_column: :value, filterable: true }, - { label: "En construction depuis", table: "self", column: "en_construction_since", displayable: false, type: :date, value_column: :value, filterable: true }, - { label: "En instruction depuis", table: "self", column: "en_instruction_since", displayable: false, type: :date, value_column: :value, filterable: true }, - { label: "Traité depuis", table: "self", column: "processed_since", displayable: false, type: :date, value_column: :value, filterable: true }, - { label: "Statut", table: "self", column: "state", displayable: false, type: :enum, value_column: :value, filterable: true }, - { label: "Archivé", table: "self", column: "archived", displayable: false, type: :text, value_column: :value, filterable: false }, - { label: "Motivation de la décision", table: "self", column: "motivation", displayable: false, type: :text, value_column: :value, filterable: false }, - { label: "Date de dernière modification (usager)", table: "self", column: "last_champ_updated_at", displayable: false, type: :text, value_column: :value, filterable: false }, - { label: 'Demandeur', table: 'user', column: 'email', displayable: true, type: :text, value_column: :value, filterable: true }, - { label: 'Email instructeur', table: 'followers_instructeurs', column: 'email', displayable: true, type: :text, value_column: :value, filterable: true }, - { label: 'Groupe instructeur', table: 'groupe_instructeur', column: 'id', displayable: true, type: :enum, value_column: :value, filterable: true }, - { label: 'Avis oui/non', table: 'avis', column: 'question_answer', displayable: true, type: :text, value_column: :value, filterable: false }, - { label: 'France connecté ?', table: 'self', column: 'user_from_france_connect?', displayable: false, type: :text, value_column: :value, filterable: false }, - { label: "Labels", table: "dossier_labels", column: "label_id", displayable: true, value_column: :value, filterable: true }, - { label: 'SIREN', table: 'etablissement', column: 'entreprise_siren', displayable: true, type: :text, value_column: :value, filterable: true }, - { label: 'Forme juridique', table: 'etablissement', column: 'entreprise_forme_juridique', displayable: true, type: :text, value_column: :value, filterable: true }, - { label: 'Nom commercial', table: 'etablissement', column: 'entreprise_nom_commercial', displayable: true, type: :text, value_column: :value, filterable: true }, - { label: 'Raison sociale', table: 'etablissement', column: 'entreprise_raison_sociale', displayable: true, type: :text, value_column: :value, filterable: true }, - { label: 'SIRET siège social', table: 'etablissement', column: 'entreprise_siret_siege_social', displayable: true, type: :text, value_column: :value, filterable: true }, - { label: 'Date de création', table: 'etablissement', column: 'entreprise_date_creation', displayable: true, type: :date, value_column: :value, filterable: true }, - { label: 'SIRET', table: 'etablissement', column: 'siret', displayable: true, type: :text, value_column: :value, filterable: true }, - { label: 'Libellé NAF', table: 'etablissement', column: 'libelle_naf', displayable: true, type: :text, value_column: :value, filterable: true }, - { label: 'Code postal', table: 'etablissement', column: 'code_postal', displayable: true, type: :text, value_column: :value, filterable: true }, - { label: tdc_1.libelle, table: 'type_de_champ', column: tdc_1.stable_id.to_s, displayable: true, type: :text, value_column: :value, filterable: true }, - { label: tdc_2.libelle, table: 'type_de_champ', column: tdc_2.stable_id.to_s, displayable: true, type: :text, value_column: :value, filterable: true }, - { label: tdc_private_1.libelle, table: 'type_de_champ', column: tdc_private_1.stable_id.to_s, displayable: true, type: :text, value_column: :value, filterable: true }, - { label: tdc_private_2.libelle, table: 'type_de_champ', column: tdc_private_2.stable_id.to_s, displayable: true, type: :text, value_column: :value, filterable: true } + { label: 'Dossier ID', table: 'self', column: 'id', displayable: true, type: :number, filterable: true }, + { label: 'notifications', table: 'notifications', column: 'notifications', displayable: true, type: :text, filterable: false }, + { label: 'Date de création', table: 'self', column: 'created_at', displayable: true, type: :date, filterable: true }, + { label: 'Mis à jour le', table: 'self', column: 'updated_at', displayable: true, type: :date, filterable: true }, + { label: 'Date de dépot', table: 'self', column: 'depose_at', displayable: true, type: :date, filterable: true }, + { label: 'En construction le', table: 'self', column: 'en_construction_at', displayable: true, type: :date, filterable: true }, + { label: 'En instruction le', table: 'self', column: 'en_instruction_at', displayable: true, type: :date, filterable: true }, + { label: 'Terminé le', table: 'self', column: 'processed_at', displayable: true, type: :date, filterable: true }, + { label: "Dernier évènement depuis", table: "self", column: "updated_since", displayable: false, type: :date, filterable: true }, + { label: "Déposé depuis", table: "self", column: "depose_since", displayable: false, type: :date, filterable: true }, + { label: "En construction depuis", table: "self", column: "en_construction_since", displayable: false, type: :date, filterable: true }, + { label: "En instruction depuis", table: "self", column: "en_instruction_since", displayable: false, type: :date, filterable: true }, + { label: "Traité depuis", table: "self", column: "processed_since", displayable: false, type: :date, filterable: true }, + { label: "Statut", table: "self", column: "state", displayable: false, type: :enum, filterable: true }, + { label: "Archivé", table: "self", column: "archived", displayable: false, type: :text, filterable: false }, + { label: "Motivation de la décision", table: "self", column: "motivation", displayable: false, type: :text, filterable: false }, + { label: "Date de dernière modification (usager)", table: "self", column: "last_champ_updated_at", displayable: false, type: :text, filterable: false }, + { label: 'Demandeur', table: 'user', column: 'email', displayable: true, type: :text, filterable: true }, + { label: 'Email instructeur', table: 'followers_instructeurs', column: 'email', displayable: true, type: :text, filterable: true }, + { label: 'Groupe instructeur', table: 'groupe_instructeur', column: 'id', displayable: true, type: :enum, filterable: true }, + { label: 'Avis oui/non', table: 'avis', column: 'question_answer', displayable: true, type: :text, filterable: false }, + { label: 'France connecté ?', table: 'self', column: 'user_from_france_connect?', displayable: false, type: :text, filterable: false }, + { label: "Labels", table: "dossier_labels", column: "label_id", displayable: true, filterable: true }, + { label: 'SIREN', table: 'etablissement', column: 'entreprise_siren', displayable: true, type: :text, filterable: true }, + { label: 'Forme juridique', table: 'etablissement', column: 'entreprise_forme_juridique', displayable: true, type: :text, filterable: true }, + { label: 'Nom commercial', table: 'etablissement', column: 'entreprise_nom_commercial', displayable: true, type: :text, filterable: true }, + { label: 'Raison sociale', table: 'etablissement', column: 'entreprise_raison_sociale', displayable: true, type: :text, filterable: true }, + { label: 'SIRET siège social', table: 'etablissement', column: 'entreprise_siret_siege_social', displayable: true, type: :text, filterable: true }, + { label: 'Date de création', table: 'etablissement', column: 'entreprise_date_creation', displayable: true, type: :date, filterable: true }, + { label: 'SIRET', table: 'etablissement', column: 'siret', displayable: true, type: :text, filterable: true }, + { label: 'Libellé NAF', table: 'etablissement', column: 'libelle_naf', displayable: true, type: :text, filterable: true }, + { label: 'Code postal', table: 'etablissement', column: 'code_postal', displayable: true, type: :text, filterable: true }, + { label: tdc_1.libelle, table: 'type_de_champ', column: tdc_1.stable_id.to_s, displayable: true, type: :text, filterable: true }, + { label: tdc_2.libelle, table: 'type_de_champ', column: tdc_2.stable_id.to_s, displayable: true, type: :text, filterable: true }, + { label: tdc_private_1.libelle, table: 'type_de_champ', column: tdc_private_1.stable_id.to_s, displayable: true, type: :text, filterable: true }, + { label: tdc_private_2.libelle, table: 'type_de_champ', column: tdc_private_2.stable_id.to_s, displayable: true, type: :text, filterable: true } ].map { Column.new(**_1.merge(procedure_id:)) } } @@ -103,9 +103,9 @@ describe ColumnsConcern do end context 'when the procedure is for individuals' do - let(:name_field) { Column.new(procedure_id:, label: "Prénom", table: "individual", column: "prenom", displayable: true, type: :text, value_column: :value, filterable: true) } - let(:surname_field) { Column.new(procedure_id:, label: "Nom", table: "individual", column: "nom", displayable: true, type: :text, value_column: :value, filterable: true) } - let(:gender_field) { Column.new(procedure_id:, label: "Civilité", table: "individual", column: "gender", displayable: true, type: :text, value_column: :value, filterable: true) } + let(:name_field) { Column.new(procedure_id:, label: "Prénom", table: "individual", column: "prenom", displayable: true, type: :text, filterable: true) } + let(:surname_field) { Column.new(procedure_id:, label: "Nom", table: "individual", column: "nom", displayable: true, type: :text, filterable: true) } + let(:gender_field) { Column.new(procedure_id:, label: "Civilité", table: "individual", column: "gender", displayable: true, type: :text, filterable: true) } let(:procedure) { create(:procedure, :for_individual) } it { is_expected.to include(name_field, surname_field, gender_field) } @@ -114,8 +114,8 @@ describe ColumnsConcern do context 'when the procedure is sva' do let(:procedure) { create(:procedure, :sva) } - let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVA", table: "self", column: "sva_svr_decision_on", displayable: true, type: :date, value_column: :value, filterable: true) } - let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVA avant", table: "self", column: "sva_svr_decision_before", displayable: false, type: :date, value_column: :value, filterable: true) } + let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVA", table: "self", column: "sva_svr_decision_on", displayable: true, type: :date, filterable: true) } + let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVA avant", table: "self", column: "sva_svr_decision_before", displayable: false, type: :date, filterable: true) } it { is_expected.to include(decision_on, decision_before_field) } end @@ -123,8 +123,8 @@ describe ColumnsConcern do context 'when the procedure is svr' do let(:procedure) { create(:procedure, :svr) } - let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVR", table: "self", column: "sva_svr_decision_on", displayable: true, type: :date, value_column: :value, filterable: true) } - let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVR avant", table: "self", column: "sva_svr_decision_before", displayable: false, type: :date, value_column: :value, filterable: true) } + let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVR", table: "self", column: "sva_svr_decision_on", displayable: true, type: :date, filterable: true) } + let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVR avant", table: "self", column: "sva_svr_decision_before", displayable: false, type: :date, filterable: true) } it { is_expected.to include(decision_on, decision_before_field) } end From 8599dd829a5d17637029c084f360ef388e14fb18 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 22:10:11 +0100 Subject: [PATCH 1473/1532] remove obsolete column.to_json --- app/models/column.rb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/models/column.rb b/app/models/column.rb index 7f8cdc9b8..90fbfcf49 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -30,12 +30,6 @@ class Column def ==(other) = h_id == other.h_id # using h_id instead of id to avoid inversion of keys - def to_json - { - table:, column:, label:, type:, filterable:, displayable: - } - end - def notifications? = [table, column] == ['notifications', 'notifications'] def dossier_state? = [table, column] == ['self', 'state'] def groupe_instructeur? = [table, column] == ['groupe_instructeur', 'id'] From aa8ce15c2a6fadc420cac2761a502e67c5fff986 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 6 Nov 2024 22:26:24 +0100 Subject: [PATCH 1474/1532] you know what ? value_column is a column --- app/models/columns/champ_column.rb | 13 ++++++------- app/services/dossier_filter_service.rb | 3 ++- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/models/columns/champ_column.rb b/app/models/columns/champ_column.rb index aa7cb16d4..329605a15 100644 --- a/app/models/columns/champ_column.rb +++ b/app/models/columns/champ_column.rb @@ -6,11 +6,12 @@ class Columns::ChampColumn < Column def initialize(procedure_id:, label:, stable_id:, tdc_type:, displayable: true, filterable: true, type: :text, options_for_select: []) @stable_id = stable_id @tdc_type = tdc_type + column = tdc_type.in?(['departements', 'regions']) ? :external_id : :value super( procedure_id:, table: 'type_de_champ', - column: stable_id.to_s, + column:, label:, type:, displayable:, @@ -33,13 +34,13 @@ class Columns::ChampColumn < Column def filtered_ids(dossiers, search_terms) if type == :enum dossiers.with_type_de_champ(stable_id) - .filter_enum(:champs, value_column, search_terms).ids + .filter_enum(:champs, column, search_terms).ids elsif type == :enums dossiers.with_type_de_champ(stable_id) - .filter_array_enum(:champs, value_column, search_terms).ids + .filter_array_enum(:champs, column, search_terms).ids else dossiers.with_type_de_champ(stable_id) - .filter_ilike(:champs, value_column, search_terms).ids + .filter_ilike(:champs, column, search_terms).ids end end @@ -47,9 +48,7 @@ class Columns::ChampColumn < Column def column_id = "type_de_champ/#{stable_id}" - def value_column = @tdc_type.in?(['departements', 'regions']) ? :external_id : :value - - def string_value(champ) = champ.public_send(value_column) + def string_value(champ) = champ.public_send(column) def typed_value(champ) value = string_value(champ) diff --git a/app/services/dossier_filter_service.rb b/app/services/dossier_filter_service.rb index 88922bea9..e7094ab10 100644 --- a/app/services/dossier_filter_service.rb +++ b/app/services/dossier_filter_service.rb @@ -32,8 +32,9 @@ class DossierFilterService dossiers_id_with_notification end when TYPE_DE_CHAMP + stable_id = sorted_column.column.stable_id ids = dossiers - .with_type_de_champ(column) + .with_type_de_champ(stable_id) .order("champs.value #{order}") .pluck(:id) if ids.size != count From 08fb49d176baf489a0aa63368ee7c3e8083d47c9 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 7 Nov 2024 10:27:35 +0100 Subject: [PATCH 1475/1532] factorize departements and regions options --- app/components/editable_champ/departements_component.rb | 2 +- app/components/editable_champ/regions_component.rb | 2 +- .../administrateurs/groupe_instructeurs_controller.rb | 6 +++--- app/models/concerns/addressable_column_concern.rb | 8 ++------ app/models/logic/champ_value.rb | 4 ++-- app/models/type_de_champ.rb | 4 ++-- app/services/api_geo_service.rb | 8 +++++++- app/views/layouts/all.html.haml | 2 +- .../conditions/champs_conditions_component_spec.rb | 2 +- .../groupe_instructeurs_controller_spec.rb | 4 ++-- spec/lib/tasks/re_routing_dossiers_spec.rb | 2 +- spec/services/api_geo_service_spec.rb | 5 ++--- spec/services/dossier_filter_service_spec.rb | 4 ++-- 13 files changed, 27 insertions(+), 26 deletions(-) diff --git a/app/components/editable_champ/departements_component.rb b/app/components/editable_champ/departements_component.rb index 2958a645d..6a7ef21ee 100644 --- a/app/components/editable_champ/departements_component.rb +++ b/app/components/editable_champ/departements_component.rb @@ -10,7 +10,7 @@ class EditableChamp::DepartementsComponent < EditableChamp::EditableChampBaseCom end def options - APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } + APIGeoService.departement_options end def select_options diff --git a/app/components/editable_champ/regions_component.rb b/app/components/editable_champ/regions_component.rb index 96e1efe24..05fbb16e9 100644 --- a/app/components/editable_champ/regions_component.rb +++ b/app/components/editable_champ/regions_component.rb @@ -10,7 +10,7 @@ class EditableChamp::RegionsComponent < EditableChamp::EditableChampBaseComponen private def options - APIGeoService.regions.map { [_1[:name], _1[:code]] } + APIGeoService.region_options end def select_options diff --git a/app/controllers/administrateurs/groupe_instructeurs_controller.rb b/app/controllers/administrateurs/groupe_instructeurs_controller.rb index 607749f0c..fa76101ed 100644 --- a/app/controllers/administrateurs/groupe_instructeurs_controller.rb +++ b/app/controllers/administrateurs/groupe_instructeurs_controller.rb @@ -49,16 +49,16 @@ module Administrateurs case tdc.type_champ when TypeDeChamp.type_champs.fetch(:departements) - tdc_options = APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } + tdc_options = APIGeoService.departement_options rule_operator = :ds_eq create_groups_from_territorial_tdc(tdc_options, stable_id, rule_operator) when TypeDeChamp.type_champs.fetch(:communes), TypeDeChamp.type_champs.fetch(:epci), TypeDeChamp.type_champs.fetch(:address) - tdc_options = APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } + tdc_options = APIGeoService.departement_options rule_operator = :ds_in_departement create_groups_from_territorial_tdc(tdc_options, stable_id, rule_operator) when TypeDeChamp.type_champs.fetch(:regions) rule_operator = :ds_eq - tdc_options = APIGeoService.regions.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } + tdc_options = APIGeoService.region_options create_groups_from_territorial_tdc(tdc_options, stable_id, rule_operator) when TypeDeChamp.type_champs.fetch(:pays) rule_operator = :ds_eq diff --git a/app/models/concerns/addressable_column_concern.rb b/app/models/concerns/addressable_column_concern.rb index 76af699cb..7fdf75a28 100644 --- a/app/models/concerns/addressable_column_concern.rb +++ b/app/models/concerns/addressable_column_concern.rb @@ -5,15 +5,11 @@ module AddressableColumnConcern included do def columns(procedure:, displayable: true, prefix: nil) - departement_options = APIGeoService.departements - .map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } - region_options = APIGeoService.regions.map { [_1[:name], _1[:name]] } - addressable_columns = [ ["code postal (5 chiffres)", '$.postal_code', :text, []], ["commune", '$.city_name', :text, []], - ["département", '$.departement_code', :enum, departement_options], - ["region", '$.region_name', :enum, region_options] + ["département", '$.departement_code', :enum, APIGeoService.departement_options], + ["region", '$.region_name', :enum, APIGeoService.region_options] ].map do |(label, jsonpath, type, options_for_select)| Columns::JSONPathColumn.new( procedure_id: procedure.id, diff --git a/app/models/logic/champ_value.rb b/app/models/logic/champ_value.rb index c515b5bf3..b63972089 100644 --- a/app/models/logic/champ_value.rb +++ b/app/models/logic/champ_value.rb @@ -131,9 +131,9 @@ class Logic::ChampValue < Logic::Term tdc = type_de_champ(type_de_champs) if operator_name.in?([Logic::InRegionOperator.name, Logic::NotInRegionOperator.name]) || tdc.type_champ == MANAGED_TYPE_DE_CHAMP.fetch(:regions) - APIGeoService.regions.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } + APIGeoService.region_options elsif operator_name.in?([Logic::InDepartementOperator.name, Logic::NotInDepartementOperator.name]) || tdc.type_champ.in?([MANAGED_TYPE_DE_CHAMP.fetch(:communes), MANAGED_TYPE_DE_CHAMP.fetch(:epci), MANAGED_TYPE_DE_CHAMP.fetch(:departements), MANAGED_TYPE_DE_CHAMP.fetch(:address)]) - APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } + APIGeoService.departement_options elsif tdc.type_champ == MANAGED_TYPE_DE_CHAMP.fetch(:pays) APIGeoService.countries.map { ["#{_1[:name]} – #{_1[:code]}", _1[:code]] } else diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index e0e3319b3..62121d949 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -547,9 +547,9 @@ class TypeDeChamp < ApplicationRecord def options_for_select if departement? - APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] }.sort + APIGeoService.departement_options elsif region? - APIGeoService.regions.map { [_1[:name], _1[:code]] } + APIGeoService.region_options elsif drop_down_list? drop_down_options elsif yes_no? diff --git a/app/services/api_geo_service.rb b/app/services/api_geo_service.rb index a6d051e36..19931cc19 100644 --- a/app/services/api_geo_service.rb +++ b/app/services/api_geo_service.rb @@ -27,6 +27,8 @@ class APIGeoService get_from_api_geo(:regions).sort_by { I18n.transliterate(_1[:name]) } end + def region_options = regions.map { [_1[:name], _1[:code]] } + def region_name(code) regions.find { _1[:code] == code }&.dig(:name) end @@ -42,7 +44,11 @@ class APIGeoService end def departements - [{ code: '99', name: 'Etranger' }] + get_from_api_geo(:departements).sort_by { _1[:code] } + ([{ code: '99', name: 'Etranger' }] + get_from_api_geo(:departements)).sort_by { _1[:code] } + end + + def departement_options + departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } end def departement_name(code) diff --git a/app/views/layouts/all.html.haml b/app/views/layouts/all.html.haml index b7809a62b..c8c95491e 100644 --- a/app/views/layouts/all.html.haml +++ b/app/views/layouts/all.html.haml @@ -85,7 +85,7 @@ .fr-ml-1w.hidden{ 'data-expand-target': 'content' } %div = f.select :service_departement, - APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] }, + APIGeoService.departement_options, { selected: @filter.service_departement, include_blank: ''}, id: "service_dep_select", class: 'fr-select' diff --git a/spec/components/conditions/champs_conditions_component_spec.rb b/spec/components/conditions/champs_conditions_component_spec.rb index e6e826cba..00c365b05 100644 --- a/spec/components/conditions/champs_conditions_component_spec.rb +++ b/spec/components/conditions/champs_conditions_component_spec.rb @@ -123,7 +123,7 @@ describe Conditions::ChampsConditionsComponent, type: :component do let(:regions) { create(:type_de_champ_regions) } let(:upper_tdcs) { [regions] } let(:condition) { empty_operator(champ_value(regions.stable_id), constant(true)) } - let(:region_options) { APIGeoService.regions.map { "#{_1[:code]} – #{_1[:name]}" } } + let(:region_options) { APIGeoService.regions.map { _1[:name] } } it do expect(page).to have_select('type_de_champ[condition_form][rows][][operator_name]', with_options: ['Est']) diff --git a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb index abf329856..7b4e54ba1 100644 --- a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb +++ b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb @@ -959,8 +959,8 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do it do expect(response).to redirect_to(admin_procedure_groupe_instructeurs_path(procedure3)) expect(flash.notice).to eq 'Les groupes instructeurs ont été ajoutés' - expect(procedure3.groupe_instructeurs.pluck(:label)).to include("01 – Guadeloupe") - expect(procedure3.reload.defaut_groupe_instructeur.routing_rule).to eq(ds_eq(champ_value(regions_tdc.stable_id), constant('01'))) + expect(procedure3.groupe_instructeurs.pluck(:label)).to include("Guadeloupe") + expect(procedure3.reload.defaut_groupe_instructeur.routing_rule).to eq(ds_eq(champ_value(regions_tdc.stable_id), constant('84'))) expect(procedure3.routing_enabled).to be_truthy end end diff --git a/spec/lib/tasks/re_routing_dossiers_spec.rb b/spec/lib/tasks/re_routing_dossiers_spec.rb index fa57253c2..b21fdf31f 100644 --- a/spec/lib/tasks/re_routing_dossiers_spec.rb +++ b/spec/lib/tasks/re_routing_dossiers_spec.rb @@ -16,7 +16,7 @@ describe 're_routing_dossiers' do tdc = procedure.active_revision.simple_routable_types_de_champ.first - tdc_options = APIGeoService.departements.map { ["#{_1[:code]} – #{_1[:name]}", _1[:code]] } + tdc_options = APIGeoService.departement_options rule_operator = :ds_eq diff --git a/spec/services/api_geo_service_spec.rb b/spec/services/api_geo_service_spec.rb index 4fb0b7608..733843fdc 100644 --- a/spec/services/api_geo_service_spec.rb +++ b/spec/services/api_geo_service_spec.rb @@ -32,9 +32,8 @@ describe APIGeoService do describe 'departements' do it 'return sorted results' do expect(APIGeoService.departements.size).to eq(110) - expect(APIGeoService.departements.first).to eq(code: '99', name: 'Etranger') - expect(APIGeoService.departements.second).to eq(code: '01', name: 'Ain', region_code: "84") - expect(APIGeoService.departements.last).to eq(code: '989', name: 'Île de Clipperton', region_code: "989") + expect(APIGeoService.departements.first).to eq(code: '01', name: 'Ain', region_code: "84") + expect(APIGeoService.departements.last).to eq(code: '99', name: 'Etranger') end end diff --git a/spec/services/dossier_filter_service_spec.rb b/spec/services/dossier_filter_service_spec.rb index c1dbc539a..d1d30d9d6 100644 --- a/spec/services/dossier_filter_service_spec.rb +++ b/spec/services/dossier_filter_service_spec.rb @@ -556,7 +556,7 @@ describe DossierFilterService do it 'describes column' do expect(column.type).to eq(:enum) - expect(column.options_for_select.first).to eq(["99 – Etranger", "99"]) + expect(column.options_for_select.first).to eq(["01 – Ain", "01"]) end end @@ -573,7 +573,7 @@ describe DossierFilterService do it 'describes column' do expect(column.type).to eq(:enum) - expect(column.options_for_select.first).to eq(["Auvergne-Rhône-Alpes", "Auvergne-Rhône-Alpes"]) + expect(column.options_for_select.first).to eq(["Auvergne-Rhône-Alpes", "84"]) end end end From 1424fca4698f27397975fff32abf8be3ddf18121 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Thu, 7 Nov 2024 11:20:03 +0100 Subject: [PATCH 1476/1532] other option for groupe instructeurs injection --- .../procedure_presentation_controller.rb | 10 ++++++++ app/models/column.rb | 3 ++- app/models/concerns/columns_concern.rb | 7 +----- .../procedure_presentation_controller_spec.rb | 24 +++++++++++++++++++ spec/models/concerns/columns_concern_spec.rb | 22 ----------------- 5 files changed, 37 insertions(+), 29 deletions(-) diff --git a/app/controllers/instructeurs/procedure_presentation_controller.rb b/app/controllers/instructeurs/procedure_presentation_controller.rb index 952464188..d16508391 100644 --- a/app/controllers/instructeurs/procedure_presentation_controller.rb +++ b/app/controllers/instructeurs/procedure_presentation_controller.rb @@ -17,6 +17,11 @@ module Instructeurs def refresh_column_filter # According to the html, the selected filters is the last one column = ColumnType.new.cast(params['filters'].last['id']) + + if column.groupe_instructeur? + column.options_for_select = instructeur_groupes(procedure_id: column.h_id[:procedure_id]) + end + component = Instructeurs::ColumnFilterValueComponent.new(column:) render turbo_stream: turbo_stream.replace('value', component) @@ -48,5 +53,10 @@ module Instructeurs .includes(:assign_to) .find_by!(id: params[:id], assign_to: { instructeur: current_instructeur }) end + + def instructeur_groupes(procedure_id:) + current_instructeur.groupe_instructeurs + .filter_map { [_1.label, _1.id] if _1.procedure_id == procedure_id } + end end end diff --git a/app/models/column.rb b/app/models/column.rb index 90fbfcf49..22fa01a6f 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -8,7 +8,8 @@ class Column TYPE_DE_CHAMP_TABLE = 'type_de_champ' - attr_reader :table, :column, :label, :type, :filterable, :displayable, :options_for_select + attr_reader :table, :column, :label, :type, :filterable, :displayable + attr_accessor :options_for_select def initialize(procedure_id:, table:, column:, label: nil, type: :text, filterable: true, displayable: true, options_for_select: []) @procedure_id = procedure_id diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index d52b6c403..1cf268744 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -83,12 +83,7 @@ module ColumnsConcern private - def groupe_instructeurs_id_column - groupes = Current.user&.instructeur&.groupe_instructeurs || [] - options_for_select = groupes.filter_map { [_1.label, _1.id] if _1.procedure_id == id } - - dossier_col(table: 'groupe_instructeur', column: 'id', type: :enum, options_for_select:) - end + def groupe_instructeurs_id_column = dossier_col(table: 'groupe_instructeur', column: 'id', type: :enum) def followers_instructeurs_email_column = dossier_col(table: 'followers_instructeurs', column: 'email') diff --git a/spec/controllers/instructeurs/procedure_presentation_controller_spec.rb b/spec/controllers/instructeurs/procedure_presentation_controller_spec.rb index ccc3d51cb..1fe5d09b3 100644 --- a/spec/controllers/instructeurs/procedure_presentation_controller_spec.rb +++ b/spec/controllers/instructeurs/procedure_presentation_controller_spec.rb @@ -83,4 +83,28 @@ describe Instructeurs::ProcedurePresentationController, type: :controller do end end end + + describe '#refresh_column_filter' do + subject { get :refresh_column_filter, params: { id: procedure_presentation.id, filters: [{ id: column.id }] } } + + let(:procedure) { create(:procedure, :routee) } + let(:instructeur) { create(:instructeur) } + let(:column) { procedure.find_column(label: "Groupe instructeur") } + + let(:procedure_presentation) do + procedure.groupe_instructeurs.each { _1.add(instructeur) } + instructeur.reload.assign_to.first.procedure_presentation_or_default_and_errors.first + end + + before { sign_in(instructeur.user) } + + it 'refreshes the column filter' do + subject + + expect(response).to be_successful + procedure.groupe_instructeurs.each do |gi| + expect(response.body).to include(gi.label) + end + end + end end diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index 0f046e23d..f6666517f 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -128,28 +128,6 @@ describe ColumnsConcern do it { is_expected.to include(decision_on, decision_before_field) } end - - context 'when the procedure has several groupe instructeur' do - let(:procedure) { create(:procedure, :routee) } - let(:groupe_1) { procedure.groupe_instructeurs.first } - let(:groupe_2) { procedure.groupe_instructeurs.last } - let(:groupe_instructeur_column) { procedure.find_column(label: "Groupe instructeur") } - - context 'and no instructeur is available in current' do - it { expect(groupe_instructeur_column.options_for_select).to eq([]) } - end - - context 'and instructeur is available in current' do - let(:instructeur) { create(:instructeur) } - - before do - procedure.groupe_instructeurs.each { _1.add(instructeur) } - allow(Current).to receive(:user).and_return(instructeur.user) - end - - it { expect(groupe_instructeur_column.options_for_select).to match_array([groupe_1, groupe_2].map { [_1.label, _1.id] }) } - end - end end describe 'export' do From 6f010cd2c52089a2daf37f131cdb8523777607fa Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 12 Nov 2024 09:28:00 +0100 Subject: [PATCH 1477/1532] fix: dossier projection for json column --- app/services/dossier_projection_service.rb | 2 +- spec/services/dossier_projection_service_spec.rb | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/services/dossier_projection_service.rb b/app/services/dossier_projection_service.rb index c6826dfb7..40705ed17 100644 --- a/app/services/dossier_projection_service.rb +++ b/app/services/dossier_projection_service.rb @@ -207,7 +207,7 @@ class DossierProjectionService type_de_champ = stable_ids_and_types_de_champ_by_dossier_ids.fetch(champ.dossier_id, {})[champ.stable_id] if type_de_champ.present? && type_de_champ.type_champ == champ.last_write_type_champ if column.is_a?(Columns::JSONPathColumn) - column.get_value(champ) + column.value(champ) else type_de_champ.champ_value(champ) end diff --git a/spec/services/dossier_projection_service_spec.rb b/spec/services/dossier_projection_service_spec.rb index af4969f8f..c9168eb39 100644 --- a/spec/services/dossier_projection_service_spec.rb +++ b/spec/services/dossier_projection_service_spec.rb @@ -238,6 +238,18 @@ describe DossierProjectionService do end end + context 'for a json column' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :siret, libelle: 'siret' }]) } + let(:dossier) { create(:dossier, procedure:) } + let(:label) { "siret – département" } + + before do + dossier.project_champs_public.first.update(value_json: { 'departement_code': '38' }) + end + + it { is_expected.to eq('38') } + end + context 'for dossier corrections table' do let(:table) { 'dossier_corrections' } let(:column) { 'resolved_at' } From b3fd3e65703ba8e450c99be33f44686952aaf33e Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 8 Nov 2024 14:09:52 +0100 Subject: [PATCH 1478/1532] move last call to procedure_presentation controller --- app/components/dossiers/notified_toggle_component.rb | 1 + .../notified_toggle_component.html.haml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/components/dossiers/notified_toggle_component.rb b/app/components/dossiers/notified_toggle_component.rb index dc927958a..f624dbcce 100644 --- a/app/components/dossiers/notified_toggle_component.rb +++ b/app/components/dossiers/notified_toggle_component.rb @@ -3,6 +3,7 @@ class Dossiers::NotifiedToggleComponent < ApplicationComponent def initialize(procedure:, procedure_presentation:) @procedure = procedure + @procedure_presentation = procedure_presentation @sorted_column = procedure_presentation.sorted_column end end diff --git a/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml b/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml index 272167f90..1c1d44a4c 100644 --- a/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml +++ b/app/components/dossiers/notified_toggle_component/notified_toggle_component.html.haml @@ -1,5 +1,5 @@ -= form_tag update_sort_instructeur_procedure_path(@procedure), - method: :get, data: { controller: 'autosubmit' } do += form_with model: [:instructeur, @procedure_presentation], + data: { controller: 'autosubmit' } do .fr-fieldset__element.fr-m-0 .fr-checkbox-group.fr-checkbox-group--sm = hidden_field_tag 'sorted_column[id]', @procedure.notifications_column.id From ec9a88a64e8db61f10fb7a6500d9d415f4ef6e3d Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 12 Nov 2024 09:43:27 +0100 Subject: [PATCH 1479/1532] simplify new --- app/components/dossiers/notified_toggle_component.rb | 4 ++-- app/views/instructeurs/procedures/show.html.haml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/dossiers/notified_toggle_component.rb b/app/components/dossiers/notified_toggle_component.rb index f624dbcce..bd3c3d9ec 100644 --- a/app/components/dossiers/notified_toggle_component.rb +++ b/app/components/dossiers/notified_toggle_component.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true class Dossiers::NotifiedToggleComponent < ApplicationComponent - def initialize(procedure:, procedure_presentation:) - @procedure = procedure + def initialize(procedure_presentation:) @procedure_presentation = procedure_presentation + @procedure = procedure_presentation.procedure @sorted_column = procedure_presentation.sorted_column end end diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index 8c6063308..00753ecb1 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -62,7 +62,7 @@ .flex.align-center - if @filtered_sorted_paginated_ids.present? || @current_filters.count > 0 = render partial: "dossiers_filter_dropdown", locals: { procedure: @procedure, statut: @statut, procedure_presentation: @procedure_presentation } - = render Dossiers::NotifiedToggleComponent.new(procedure: @procedure, procedure_presentation: @procedure_presentation) if @statut != 'a-suivre' + = render Dossiers::NotifiedToggleComponent.new(procedure_presentation: @procedure_presentation) if @statut != 'a-suivre' .fr-ml-auto - if @dossiers_count > 0 From 92f2d2901d7847d9f87ba1f665e237eb22c52e65 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 12 Nov 2024 11:17:18 +0100 Subject: [PATCH 1480/1532] chore(schema): add unconfirmed_email index to users --- ...0241112090128_add_unconfirmed_email_index_to_users.rb | 9 +++++++++ db/schema.rb | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20241112090128_add_unconfirmed_email_index_to_users.rb diff --git a/db/migrate/20241112090128_add_unconfirmed_email_index_to_users.rb b/db/migrate/20241112090128_add_unconfirmed_email_index_to_users.rb new file mode 100644 index 000000000..bc90708cf --- /dev/null +++ b/db/migrate/20241112090128_add_unconfirmed_email_index_to_users.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddUnconfirmedEmailIndexToUsers < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def change + add_index :users, :unconfirmed_email, algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 07f4bc83b..ca309c9b4 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[7.0].define(version: 2024_10_14_084333) do +ActiveRecord::Schema[7.0].define(version: 2024_11_12_090128) do # These are extensions that must be enabled in order to support this database enable_extension "pg_buffercache" enable_extension "pg_stat_statements" @@ -1236,6 +1236,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do t.index ["last_sign_in_at"], name: "index_users_on_last_sign_in_at" t.index ["requested_merge_into_id"], name: "index_users_on_requested_merge_into_id" t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true + t.index ["unconfirmed_email"], name: "index_users_on_unconfirmed_email" t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true end From dc6bad40fe01135a23a00fe5ecdecb115aa332b6 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 7 Nov 2024 12:38:03 +0100 Subject: [PATCH 1481/1532] refactor(type_de_champ): cleanup type predicate methods --- .../dossiers/champs_rows_show_component.rb | 2 +- .../editable_champ_component.rb | 2 +- .../types_de_champ_editor/champ_component.rb | 2 +- .../champ_component/champ_component.html.haml | 8 +- app/controllers/root_controller.rb | 2 +- app/graphql/types/champ_descriptor_type.rb | 4 +- app/helpers/champ_helper.rb | 2 +- app/models/champ.rb | 24 +--- app/models/procedure_revision.rb | 4 +- app/models/type_de_champ.rb | 135 +++--------------- .../no_empty_block_validator.rb | 2 +- .../no_empty_drop_down_validator.rb | 2 +- .../procedures/errors_summary_spec.rb | 2 +- .../api/v2/graphql_controller_spec.rb | 2 +- spec/factories/dossier.rb | 4 +- spec/models/dossier_spec.rb | 2 +- spec/models/procedure_spec.rb | 4 +- .../administrateurs/procedure_publish_spec.rb | 4 +- .../instructeurs/procedure_filters_spec.rb | 4 +- 19 files changed, 50 insertions(+), 161 deletions(-) diff --git a/app/components/dossiers/champs_rows_show_component.rb b/app/components/dossiers/champs_rows_show_component.rb index 5eba03d4a..b4d5f9e27 100644 --- a/app/components/dossiers/champs_rows_show_component.rb +++ b/app/components/dossiers/champs_rows_show_component.rb @@ -26,7 +26,7 @@ class Dossiers::ChampsRowsShowComponent < ApplicationComponent def blank_key(champ) key = ".blank_optional" - key += "_attachment" if champ.type_de_champ.piece_justificative? + key += "_attachment" if champ.type_de_champ.piece_justificative_or_titre_identite? key end diff --git a/app/components/editable_champ/editable_champ_component.rb b/app/components/editable_champ/editable_champ_component.rb index e3fa34628..20f0a7fd6 100644 --- a/app/components/editable_champ/editable_champ_component.rb +++ b/app/components/editable_champ/editable_champ_component.rb @@ -84,6 +84,6 @@ class EditableChamp::EditableChampComponent < ApplicationComponent end def autosave_enabled? - !@champ.carte? && !@champ.block? && @champ.fillable? + !@champ.carte? && !@champ.repetition? && @champ.fillable? 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 a0fa6a516..6ed8034e3 100644 --- a/app/components/types_de_champ_editor/champ_component.rb +++ b/app/components/types_de_champ_editor/champ_component.rb @@ -130,7 +130,7 @@ class TypesDeChampEditor::ChampComponent < ApplicationComponent end def has_legacy_number? - revision.types_de_champ.any?(&:legacy_number?) + revision.types_de_champ.any?(&:number?) end def options_for_character_limit diff --git a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml index 8bb488d4a..35b3c3203 100644 --- a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml +++ b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml @@ -68,7 +68,7 @@ .flex.justify-start.fr-mt-1w.flex-gap - - if type_de_champ.drop_down_list? + - if type_de_champ.any_drop_down_list? .flex.column.justify-start.width-33 .cell = form.label :drop_down_options_from_text, "Options de la liste", for: dom_id(type_de_champ, :drop_down_options_from_text) @@ -77,7 +77,7 @@ class: 'fr-input small-margin small width-100', rows: 7, id: dom_id(type_de_champ, :drop_down_options_from_text) - - if type_de_champ.simple_drop_down_list? + - if type_de_champ.drop_down_list? .cell = form.label :drop_down_other, for: dom_id(type_de_champ, :drop_down_other) do Proposer une option « autre » avec un texte libre @@ -91,7 +91,7 @@ .cell.fr-mt-1w = form.label :drop_down_secondary_description, "Description du champ secondaire (optionnel)", for: dom_id(type_de_champ, :drop_down_secondary_description) = form.text_area :drop_down_secondary_description, class: 'fr-input small-margin small width-100', rows: 3, id: dom_id(type_de_champ, :drop_down_secondary_description) - - if type_de_champ.piece_justificative? + - if type_de_champ.piece_justificative_or_titre_identite? .cell = form.label :piece_justificative_template, "Modèle", for: dom_id(type_de_champ, :piece_justificative_template) = render Attachment::EditComponent.new(**piece_justificative_template_options) @@ -120,7 +120,7 @@ Spécifier un nombre maximal conseillé de caractères : = form.select :character_limit, options_for_character_limit, {}, { id: dom_id(type_de_champ, :character_limit), class: 'fr-select' } - - if type_de_champ.block? + - if type_de_champ.repetition? .flex.justify-start.section.fr-ml-1w .editor-block.flex-grow.cell = render TypesDeChampEditor::BlockComponent.new(block: coordinate, coordinates: coordinate.revision_types_de_champ, upper_coordinates: @upper_coordinates) diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb index 3993b19d4..5409d7db2 100644 --- a/app/controllers/root_controller.rb +++ b/app/controllers/root_controller.rb @@ -62,7 +62,7 @@ class RootController < ApplicationController "option C" ] type_de_champ.save - elsif type_de_champ.drop_down_list? + elsif type_de_champ.any_drop_down_list? type_de_champ.drop_down_options = [ "option A", diff --git a/app/graphql/types/champ_descriptor_type.rb b/app/graphql/types/champ_descriptor_type.rb index 9e4a474a9..b19b91555 100644 --- a/app/graphql/types/champ_descriptor_type.rb +++ b/app/graphql/types/champ_descriptor_type.rb @@ -110,13 +110,13 @@ module Types end def champ_descriptors - if type_de_champ.block? + if type_de_champ.repetition? Loaders::Association.for(object.class, revision_types_de_champ: :type_de_champ).load(object) end end def options - if type_de_champ.drop_down_list? + if type_de_champ.any_drop_down_list? type_de_champ.drop_down_options.reject(&:empty?) end end diff --git a/app/helpers/champ_helper.rb b/app/helpers/champ_helper.rb index 449f9b799..c971be6f2 100644 --- a/app/helpers/champ_helper.rb +++ b/app/helpers/champ_helper.rb @@ -12,7 +12,7 @@ module ChampHelper def auto_attach_url(object, procedure_id: nil) if object.is_a?(Champ) champs_piece_justificative_url(object.dossier, object.stable_id, row_id: object.row_id) - elsif object.is_a?(TypeDeChamp) && object.piece_justificative? + elsif object.is_a?(TypeDeChamp) && object.piece_justificative_or_titre_identite? piece_justificative_template_admin_procedure_type_de_champ_url(stable_id: object.stable_id, procedure_id:) elsif object.is_a?(TypeDeChamp) && object.explication? notice_explicative_admin_procedure_type_de_champ_url(stable_id: object.stable_id, procedure_id:) diff --git a/app/models/champ.rb b/app/models/champ.rb index 8efa30748..2aec05e14 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -38,39 +38,21 @@ class Champ < ApplicationRecord :current_section_level, :exclude_from_export?, :exclude_from_view?, - :repetition?, - :block?, - :dossier_link?, - :departement?, - :region?, - :textarea?, - :piece_justificative?, - :titre_identite?, - :header_section?, - :checkbox?, - :simple_drop_down_list?, - :linked_drop_down_list?, :non_fillable?, :fillable?, - :cnaf?, - :dgfip?, - :pole_emploi?, - :mesri?, - :rna?, - :siret?, - :carte?, - :datetime?, :mandatory?, :prefillable?, :refresh_after_update?, :character_limit?, :character_limit, - :yes_no?, :expression_reguliere, :expression_reguliere_exemple_text, :expression_reguliere_error_message, to: :type_de_champ + delegate(*TypeDeChamp.type_champs.values.map { "#{_1}?".to_sym }, to: :type_de_champ) + delegate :piece_justificative_or_titre_identite?, :any_drop_down_list?, to: :type_de_champ + delegate :to_typed_id, :to_typed_id_for_query, to: :type_de_champ, prefix: true delegate :revision, to: :dossier, prefix: true diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb index 3b6127d3e..6ed3cce75 100644 --- a/app/models/procedure_revision.rb +++ b/app/models/procedure_revision.rb @@ -391,7 +391,7 @@ class ProcedureRevision < ApplicationRecord to_type_de_champ.condition&.to_s(to_coordinates.map(&:type_de_champ))) end - if to_type_de_champ.drop_down_list? + if to_type_de_champ.any_drop_down_list? if from_type_de_champ.drop_down_options != to_type_de_champ.drop_down_options changes << ProcedureRevisionChange::UpdateChamp.new(from_type_de_champ, :drop_down_options, @@ -425,7 +425,7 @@ class ProcedureRevision < ApplicationRecord from_type_de_champ.carte_optional_layers, to_type_de_champ.carte_optional_layers) end - elsif to_type_de_champ.piece_justificative? + elsif to_type_de_champ.piece_justificative_or_titre_identite? if from_type_de_champ.checksum_for_attachment(:piece_justificative_template) != to_type_de_champ.checksum_for_attachment(:piece_justificative_template) changes << ProcedureRevisionChange::UpdateChamp.new(from_type_de_champ, :piece_justificative_template, diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 62121d949..a35593258 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -66,7 +66,7 @@ class TypeDeChamp < ApplicationRecord expression_reguliere: STANDARD } - enum type_champs: { + enum type_champ: { engagement_juridique: 'engagement_juridique', header_section: 'header_section', @@ -323,118 +323,10 @@ class TypeDeChamp < ApplicationRecord ]) end - def drop_down_list? - type_champ.in?([ - TypeDeChamp.type_champs.fetch(:drop_down_list), - TypeDeChamp.type_champs.fetch(:multiple_drop_down_list), - TypeDeChamp.type_champs.fetch(:linked_drop_down_list) - ]) - end - - def simple_drop_down_list? - type_champ == TypeDeChamp.type_champs.fetch(:drop_down_list) - end - - def multiple_drop_down_list? - type_champ == TypeDeChamp.type_champs.fetch(:multiple_drop_down_list) - end - - def linked_drop_down_list? - type_champ == TypeDeChamp.type_champs.fetch(:linked_drop_down_list) - end - - def yes_no? - type_champ == TypeDeChamp.type_champs.fetch(:yes_no) - end - - def block? - type_champ == TypeDeChamp.type_champs.fetch(:repetition) - end - - def header_section? - type_champ == TypeDeChamp.type_champs.fetch(:header_section) - end - def exclude_from_view? type_champ == TypeDeChamp.type_champs.fetch(:explication) end - def explication? - type_champ == TypeDeChamp.type_champs.fetch(:explication) - end - - def repetition? - type_champ == TypeDeChamp.type_champs.fetch(:repetition) - end - - def dossier_link? - type_champ == TypeDeChamp.type_champs.fetch(:dossier_link) - end - - def siret? - type_champ == TypeDeChamp.type_champs.fetch(:siret) - end - - def piece_justificative? - type_champ == TypeDeChamp.type_champs.fetch(:piece_justificative) || type_champ == TypeDeChamp.type_champs.fetch(:titre_identite) - end - - def legacy_number? - type_champ == TypeDeChamp.type_champs.fetch(:number) - end - - def textarea? - type_champ == TypeDeChamp.type_champs.fetch(:textarea) - end - - def titre_identite? - type_champ == TypeDeChamp.type_champs.fetch(:titre_identite) - end - - def carte? - type_champ == TypeDeChamp.type_champs.fetch(:carte) - end - - def cnaf? - type_champ == TypeDeChamp.type_champs.fetch(:cnaf) - end - - def rna? - type_champ == TypeDeChamp.type_champs.fetch(:rna) - end - - def dgfip? - type_champ == TypeDeChamp.type_champs.fetch(:dgfip) - end - - def pole_emploi? - type_champ == TypeDeChamp.type_champs.fetch(:pole_emploi) - end - - def departement? - type_champ == TypeDeChamp.type_champs.fetch(:departements) - end - - def region? - type_champ == TypeDeChamp.type_champs.fetch(:regions) - end - - def mesri? - type_champ == TypeDeChamp.type_champs.fetch(:mesri) - end - - def datetime? - type_champ == TypeDeChamp.type_champs.fetch(:datetime) - end - - def checkbox? - type_champ == TypeDeChamp.type_champs.fetch(:checkbox) - end - - def expression_reguliere? - type_champ == TypeDeChamp.type_champs.fetch(:expression_reguliere) - end - def public? !private? end @@ -546,11 +438,11 @@ class TypeDeChamp < ApplicationRecord end def options_for_select - if departement? + if departements? APIGeoService.departement_options - elsif region? + elsif regions? APIGeoService.region_options - elsif drop_down_list? + elsif any_drop_down_list? drop_down_options elsif yes_no? Champs::YesNoChamp.options @@ -757,6 +649,21 @@ class TypeDeChamp < ApplicationRecord CHAMP_TYPE_TO_TYPE_CHAMP = type_champs.values.map { [type_champ_to_champ_class_name(_1), _1] }.to_h + def piece_justificative_or_titre_identite? + type_champ.in?([ + TypeDeChamp.type_champs.fetch(:piece_justificative), + TypeDeChamp.type_champs.fetch(:titre_identite) + ]) + end + + def any_drop_down_list? + type_champ.in?([ + TypeDeChamp.type_champs.fetch(:drop_down_list), + TypeDeChamp.type_champs.fetch(:multiple_drop_down_list), + TypeDeChamp.type_champs.fetch(:linked_drop_down_list) + ]) + end + private def castable_on_change?(from_type, to_type) @@ -780,7 +687,7 @@ class TypeDeChamp < ApplicationRecord end def remove_attachment - if !piece_justificative? && piece_justificative_template.attached? + if !piece_justificative_or_titre_identite? && piece_justificative_template.attached? piece_justificative_template.purge_later elsif !explication? && notice_explicative.attached? notice_explicative.purge_later @@ -788,7 +695,7 @@ class TypeDeChamp < ApplicationRecord end def set_drop_down_list_options - if (simple_drop_down_list? || multiple_drop_down_list?) && drop_down_options.empty? + if (drop_down_list? || multiple_drop_down_list?) && drop_down_options.empty? self.drop_down_options = ['Fromage', 'Dessert'] elsif linked_drop_down_list? && drop_down_options.none?(/^--.*--$/) self.drop_down_options = ['--Fromage--', 'bleu de sassenage', 'picodon', '--Dessert--', 'éclair', 'tarte aux pommes'] diff --git a/app/validators/types_de_champ/no_empty_block_validator.rb b/app/validators/types_de_champ/no_empty_block_validator.rb index a41b647c5..6cd39a2e4 100644 --- a/app/validators/types_de_champ/no_empty_block_validator.rb +++ b/app/validators/types_de_champ/no_empty_block_validator.rb @@ -2,7 +2,7 @@ class TypesDeChamp::NoEmptyBlockValidator < ActiveModel::EachValidator def validate_each(procedure, attribute, types_de_champ) - types_de_champ.filter(&:block?).each do |repetition| + types_de_champ.filter(&:repetition?).each do |repetition| validate_block_not_empty(procedure, attribute, repetition) end end diff --git a/app/validators/types_de_champ/no_empty_drop_down_validator.rb b/app/validators/types_de_champ/no_empty_drop_down_validator.rb index 8dda5b772..d1028bce3 100644 --- a/app/validators/types_de_champ/no_empty_drop_down_validator.rb +++ b/app/validators/types_de_champ/no_empty_drop_down_validator.rb @@ -2,7 +2,7 @@ class TypesDeChamp::NoEmptyDropDownValidator < ActiveModel::EachValidator def validate_each(procedure, attribute, types_de_champ) - types_de_champ.filter(&:drop_down_list?).each do |drop_down| + types_de_champ.filter(&:any_drop_down_list?).each do |drop_down| validate_drop_down_not_empty(procedure, attribute, drop_down) end end diff --git a/spec/components/procedures/errors_summary_spec.rb b/spec/components/procedures/errors_summary_spec.rb index 204bea9df..4317998cc 100644 --- a/spec/components/procedures/errors_summary_spec.rb +++ b/spec/components/procedures/errors_summary_spec.rb @@ -60,7 +60,7 @@ describe Procedure::ErrorsSummary, type: :component do let(:validation_context) { :types_de_champ_public_editor } before do - drop_down_public = procedure.draft_revision.types_de_champ_public.find(&:drop_down_list?) + drop_down_public = procedure.draft_revision.types_de_champ_public.find(&:any_drop_down_list?) drop_down_public.update!(drop_down_options: []) subject end diff --git a/spec/controllers/api/v2/graphql_controller_spec.rb b/spec/controllers/api/v2/graphql_controller_spec.rb index aebf599a1..d0712c670 100644 --- a/spec/controllers/api/v2/graphql_controller_spec.rb +++ b/spec/controllers/api/v2/graphql_controller_spec.rb @@ -219,7 +219,7 @@ describe API::V2::GraphqlController do description: tdc.description, required: tdc.mandatory?, champDescriptors: tdc.repetition? ? procedure.active_revision.children_of(tdc.reload).map { { id: _1.to_typed_id, __typename: format_type_champ(_1.type_champ) } } : nil, - options: tdc.drop_down_list? ? tdc.drop_down_options.reject(&:empty?) : nil + options: tdc.any_drop_down_list? ? tdc.drop_down_options.reject(&:empty?) : nil }.compact end, dossiers: { diff --git a/spec/factories/dossier.rb b/spec/factories/dossier.rb index 89ad244ea..f2a6b799f 100644 --- a/spec/factories/dossier.rb +++ b/spec/factories/dossier.rb @@ -24,7 +24,7 @@ FactoryBot.define do after(:create) do |dossier, evaluator| if evaluator.populate_champs dossier.revision.types_de_champ_public.each do |type_de_champ| - value = if type_de_champ.simple_drop_down_list? + value = if type_de_champ.drop_down_list? type_de_champ.drop_down_options.first elsif type_de_champ.multiple_drop_down_list? type_de_champ.drop_down_options.first(2).to_json @@ -36,7 +36,7 @@ FactoryBot.define do if evaluator.populate_annotations dossier.revision.types_de_champ_private.each do |type_de_champ| - value = if type_de_champ.simple_drop_down_list? + value = if type_de_champ.drop_down_list? type_de_champ.drop_down_options.first elsif type_de_champ.multiple_drop_down_list? type_de_champ.drop_down_options.first(2).to_json diff --git a/spec/models/dossier_spec.rb b/spec/models/dossier_spec.rb index 9062e9e4f..320fe595e 100644 --- a/spec/models/dossier_spec.rb +++ b/spec/models/dossier_spec.rb @@ -520,7 +520,7 @@ describe Dossier, type: :model do context 'when titre identite' do let(:types_de_champ_public) { [{ type: :titre_identite }] } - let(:champ) { dossier.project_champs_public.find(&:piece_justificative?) } + let(:champ) { dossier.project_champs_public.find(&:titre_identite?) } context 'when not visible' do let(:visible) { false } diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 25784929d..b8724a106 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -407,7 +407,7 @@ describe Procedure do end it 'validates that no drop-down type de champ is empty' do - drop_down = procedure.draft_revision.types_de_champ_public.find(&:drop_down_list?) + drop_down = procedure.draft_revision.types_de_champ_public.find(&:any_drop_down_list?) drop_down.update!(drop_down_options: []) procedure.reload.validate(:publication) @@ -440,7 +440,7 @@ describe Procedure do end it 'validates that no drop-down type de champ is empty' do - drop_down = procedure.draft_revision.types_de_champ_private.find(&:drop_down_list?) + drop_down = procedure.draft_revision.types_de_champ_private.find(&:any_drop_down_list?) drop_down.update!(drop_down_options: []) procedure.reload.validate(:publication) diff --git a/spec/system/administrateurs/procedure_publish_spec.rb b/spec/system/administrateurs/procedure_publish_spec.rb index aac700f8d..6912b9f70 100644 --- a/spec/system/administrateurs/procedure_publish_spec.rb +++ b/spec/system/administrateurs/procedure_publish_spec.rb @@ -71,9 +71,9 @@ describe 'Publishing a procedure', js: true do end before do - drop_down = procedure.draft_revision.types_de_champ_public.find(&:drop_down_list?) + drop_down = procedure.draft_revision.types_de_champ_public.find(&:any_drop_down_list?) drop_down.update!(drop_down_options: []) - drop_down = procedure.draft_revision.types_de_champ_private.find(&:drop_down_list?) + drop_down = procedure.draft_revision.types_de_champ_private.find(&:any_drop_down_list?) drop_down.update!(drop_down_options: []) end diff --git a/spec/system/instructeurs/procedure_filters_spec.rb b/spec/system/instructeurs/procedure_filters_spec.rb index 1cd23ea13..2e3f54a0e 100644 --- a/spec/system/instructeurs/procedure_filters_spec.rb +++ b/spec/system/instructeurs/procedure_filters_spec.rb @@ -122,7 +122,7 @@ describe "procedure filters" do describe 'departements' do let(:types_de_champ_public) { [{ type: :departements }] } scenario "should be able to find by departements with custom enum lookup", js: true do - departement_champ = new_unfollow_dossier.champs.find(&:departement?) + departement_champ = new_unfollow_dossier.champs.find(&:departements?) departement_champ.update!(value: 'Oise', external_id: '60') departement_champ.reload champ_select_value = "#{departement_champ.external_id} – #{departement_champ.value}" @@ -162,7 +162,7 @@ describe "procedure filters" do describe 'region' do let(:types_de_champ_public) { [{ type: :regions }] } scenario "should be able to find by region with custom enum lookup", js: true do - region_champ = new_unfollow_dossier.champs.find(&:region?) + region_champ = new_unfollow_dossier.champs.find(&:regions?) region_champ.update!(value: 'Bretagne', external_id: '53') region_champ.reload From bacdedec2e6fa14c0e11f80b77ca45d361ae49f0 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 6 Nov 2024 22:29:59 +0100 Subject: [PATCH 1482/1532] refactor(champ): add is_type? method --- app/models/champ.rb | 4 ++-- app/models/columns/champ_column.rb | 2 +- app/models/type_de_champ.rb | 4 ++-- .../multiple_drop_down_list_type_de_champ.rb | 2 +- app/services/dossier_projection_service.rb | 20 +++++++------------ spec/models/columns/champ_column_spec.rb | 12 +++++------ 6 files changed, 19 insertions(+), 25 deletions(-) diff --git a/app/models/champ.rb b/app/models/champ.rb index 8efa30748..548ce15e0 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -121,8 +121,8 @@ class Champ < ApplicationRecord TypeDeChamp::CHAMP_TYPE_TO_TYPE_CHAMP.fetch(type) end - def last_write_column_type - TypeDeChamp.column_type(last_write_type_champ) + def is_type?(type_champ) + last_write_type_champ == type_champ end def main_value_name diff --git a/app/models/columns/champ_column.rb b/app/models/columns/champ_column.rb index 329605a15..fa83db9c6 100644 --- a/app/models/columns/champ_column.rb +++ b/app/models/columns/champ_column.rb @@ -24,7 +24,7 @@ class Columns::ChampColumn < Column return if champ.nil? # nominal case - if @tdc_type == champ.last_write_type_champ + if champ.is_type?(@tdc_type) typed_value(champ) else cast_value(champ) diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 62121d949..ffc84c3bf 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -723,7 +723,7 @@ class TypeDeChamp < ApplicationRecord # no champ return true if champ.nil? # type de champ on the revision changed - if champ.last_write_type_champ == type_champ || castable_on_change?(champ.last_write_type_champ, type_champ) + if champ.is_type?(type_champ) || castable_on_change?(champ.last_write_type_champ, type_champ) dynamic_type.champ_blank?(champ) else true @@ -734,7 +734,7 @@ class TypeDeChamp < ApplicationRecord # no champ return true if champ.nil? # type de champ on the revision changed - if champ.last_write_type_champ == type_champ || castable_on_change?(champ.last_write_type_champ, type_champ) + if champ.is_type?(type_champ) || castable_on_change?(champ.last_write_type_champ, type_champ) mandatory? && dynamic_type.champ_blank_or_invalid?(champ) else true diff --git a/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb index e14bd6cfa..1be2be9d5 100644 --- a/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb @@ -20,7 +20,7 @@ class TypesDeChamp::MultipleDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampB def selected_options(champ) return [] if champ.value.blank? - if champ.last_write_type_champ == TypeDeChamp.type_champs.fetch(:drop_down_list) + if champ.is_type?(TypeDeChamp.type_champs.fetch(:drop_down_list)) [champ.value] else JSON.parse(champ.value) diff --git a/app/services/dossier_projection_service.rb b/app/services/dossier_projection_service.rb index 40705ed17..3c3f2a438 100644 --- a/app/services/dossier_projection_service.rb +++ b/app/services/dossier_projection_service.rb @@ -80,8 +80,9 @@ class DossierProjectionService fields .filter { |f| f[STABLE_ID] == stable_id } .each do |field| - field[:id_value_h] = champs.to_h { |c| [c.dossier_id, champ_value.(c, field[:original_column])] } - end + column = field[:original_column] + field[:id_value_h] = champs.to_h { [_1.dossier_id, column.is_a?(Columns::JSONPathColumn) ? column.value(_1) : champ_value.(_1)] } + end end when 'self' Dossier @@ -203,17 +204,10 @@ class DossierProjectionService .group_by(&:first) .transform_values { _1.map { |_, type_de_champ| [type_de_champ.stable_id, type_de_champ] }.to_h } stable_ids_and_types_de_champ_by_dossier_ids = revision_ids_by_dossier_ids.transform_values { stable_ids_and_types_de_champ_by_revision_ids[_1] }.compact - -> (champ, column) { - type_de_champ = stable_ids_and_types_de_champ_by_dossier_ids.fetch(champ.dossier_id, {})[champ.stable_id] - if type_de_champ.present? && type_de_champ.type_champ == champ.last_write_type_champ - if column.is_a?(Columns::JSONPathColumn) - column.value(champ) - else - type_de_champ.champ_value(champ) - end - else - '' - end + -> (champ) { + type_de_champ = stable_ids_and_types_de_champ_by_dossier_ids + .fetch(champ.dossier_id, {})[champ.stable_id] + type_de_champ&.champ_value(champ) } end end diff --git a/spec/models/columns/champ_column_spec.rb b/spec/models/columns/champ_column_spec.rb index f197af3cd..681f38ab2 100644 --- a/spec/models/columns/champ_column_spec.rb +++ b/spec/models/columns/champ_column_spec.rb @@ -48,7 +48,7 @@ describe Columns::ChampColumn do def column(label) = procedure.find_column(label:) context 'from a integer_number' do - let(:champ) { double(last_write_type_champ: 'integer_number', value: '42') } + let(:champ) { Champs::IntegerNumberChamp.new(value: '42') } it do expect(column('decimal_number').value(champ)).to eq(42.0) @@ -57,7 +57,7 @@ describe Columns::ChampColumn do end context 'from a decimal_number' do - let(:champ) { double(last_write_type_champ: 'decimal_number', value: '42.1') } + let(:champ) { Champs::DecimalNumberChamp.new(value: '42.1') } it do expect(column('integer_number').value(champ)).to eq(42) @@ -66,7 +66,7 @@ describe Columns::ChampColumn do end context 'from a date' do - let(:champ) { double(last_write_type_champ: 'date', value:) } + let(:champ) { Champs::DateChamp.new(value:) } describe 'when the value is valid' do let(:value) { '2019-07-10' } @@ -82,7 +82,7 @@ describe Columns::ChampColumn do end context 'from a datetime' do - let(:champ) { double(last_write_type_champ: 'datetime', value:) } + let(:champ) { Champs::DatetimeChamp.new(value:) } describe 'when the value is valid' do let(:value) { '1962-09-15T15:35:00+01:00' } @@ -98,7 +98,7 @@ describe Columns::ChampColumn do end context 'from a drop_down_list' do - let(:champ) { double(last_write_type_champ: 'drop_down_list', value: 'val1') } + let(:champ) { Champs::DropDownListChamp.new(value: 'val1') } it do expect(column('multiple_drop_down_list').value(champ)).to eq(['val1']) @@ -107,7 +107,7 @@ describe Columns::ChampColumn do end context 'from a multiple_drop_down_list' do - let(:champ) { double(last_write_type_champ: 'multiple_drop_down_list', value: '["val1","val2"]') } + let(:champ) { Champs::MultipleDropDownListChamp.new(value: '["val1","val2"]') } it do expect(column('simple_drop_down_list').value(champ)).to eq('val1') From 947ef3498b49c74a79051d565b47cd4b82cb4823 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 8 Nov 2024 14:14:01 +0100 Subject: [PATCH 1483/1532] clean: remove old procedure_presentation methods from procedures_controller --- .../instructeurs/procedures_controller.rb | 50 ------------------- config/routes.rb | 7 --- .../procedures_controller_spec.rb | 41 --------------- 3 files changed, 98 deletions(-) diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index d110d1434..4aababff6 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -132,44 +132,6 @@ module Instructeurs @statut = 'supprime' end - # TODO: to remove because of new procedure_presentation_controller - def update_displayed_fields - ids = (params['values'].presence || []).reject(&:empty?) - - procedure_presentation.update!(displayed_columns: ids) - - redirect_back(fallback_location: instructeur_procedure_url(procedure)) - end - - # TODO: to remove because of new procedure_presentation_controller - def update_sort - procedure_presentation.update!(sorted_column_params) - - redirect_back(fallback_location: instructeur_procedure_url(procedure)) - end - - # TODO: to remove because of new procedure_presentation_controller - def add_filter - if !procedure_presentation.update(filter_params) - # complicated way to display inner error messages - flash.alert = procedure_presentation.errors - .flat_map { _1.detail[:value].flat_map { |c| c.errors.full_messages } } - end - - redirect_back(fallback_location: instructeur_procedure_url(procedure)) - end - - # TODO: to remove because of new procedure_presentation_controller - def update_filter - @statut = statut - @procedure = procedure - @procedure_presentation = procedure_presentation - current_filter = procedure_presentation.filters_name_for(@statut) - # According to the html, the selected column is the last one - h_id = JSON.parse(params[current_filter].last[:id], symbolize_names: true) - @column = procedure.find_column(h_id:) - end - def download_export groupe_instructeurs = current_instructeur .groupe_instructeurs @@ -410,17 +372,5 @@ module Instructeurs def cookies_export_key "exports_#{@procedure.id}_seen_at" end - - # TODO: to remove because of new procedure_presentation_controller - def sorted_column_params - params.permit(sorted_column: [:order, :id]) - end - - # TODO: to remove because of new procedure_presentation_controller - def filter_params - keys = [:tous_filters, :a_suivre_filters, :suivis_filters, :traites_filters, :expirant_filters, :archives_filters, :supprimes_filters] - h = keys.index_with { [:id, :filter] } - params.permit(h) - end end end diff --git a/config/routes.rb b/config/routes.rb index 4ecc68874..518f513aa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -490,13 +490,6 @@ Rails.application.routes.draw do end end - # TODO: to remove because of new procedure_presentation_controller - patch 'update_displayed_fields' - get 'update_sort' => 'procedures#update_sort', as: 'update_sort' - post 'add_filter' - post 'update_filter' - get 'remove_filter' - get 'download_export' post 'download_export' get 'polling_last_export' diff --git a/spec/controllers/instructeurs/procedures_controller_spec.rb b/spec/controllers/instructeurs/procedures_controller_spec.rb index 8fcd18be9..d759412f9 100644 --- a/spec/controllers/instructeurs/procedures_controller_spec.rb +++ b/spec/controllers/instructeurs/procedures_controller_spec.rb @@ -905,45 +905,4 @@ describe Instructeurs::ProceduresController, type: :controller do it { is_expected.to have_http_status(:forbidden) } end end - - describe '#update_filter' do - let(:instructeur) { create(:instructeur) } - let(:procedure) { create(:procedure, :for_individual) } - def procedure_presentation = instructeur.assign_to.first.procedure_presentation_or_default_and_errors.first - - before do - create(:assign_to, instructeur:, groupe_instructeur: build(:groupe_instructeur, procedure:)) - - sign_in(instructeur.user) - end - - it 'can change order' do - column = procedure.find_column(label: "Nom") - expect { get :update_sort, params: { procedure_id: procedure.id, sorted_column: { id: column.id, order: 'asc' } } } - .to change { procedure_presentation.sorted_column } - .from(procedure.default_sorted_column) - .to(SortedColumn.new(column:, order: 'asc')) - end - end - - describe '#add_filter' do - let(:instructeur) { create(:instructeur) } - let(:procedure) { create(:procedure, :for_individual) } - - before do - create(:assign_to, instructeur:, groupe_instructeur: build(:groupe_instructeur, procedure:)) - - sign_in(instructeur.user) - end - - subject do - column = procedure.find_column(label: "Nom") - post :add_filter, params: { procedure_id: procedure.id, a_suivre_filters: [{ id: column.id, filter: "n" * 4049 }] } - end - - it 'should render the error' do - subject - expect(flash.alert[0]).to include("Le filtre « Nom » est trop long (maximum: 4048 caractères)") - end - end end From 1e7b5a56e4b71b53048e7927869f6367e3446ae7 Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Tue, 12 Nov 2024 13:26:41 +0100 Subject: [PATCH 1484/1532] Fix the hidden error: 'private method 'current_user' called for an instance of ErrorsController' --- app/controllers/errors_controller.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/controllers/errors_controller.rb b/app/controllers/errors_controller.rb index e44b5ee8b..dcdd1a7a5 100644 --- a/app/controllers/errors_controller.rb +++ b/app/controllers/errors_controller.rb @@ -23,15 +23,6 @@ class ErrorsController < ApplicationController render_error @status end - private - - def render_error(status) - respond_to do |format| - format.html { render status: } - format.json { render status:, json: { status:, name: Rack::Utils::HTTP_STATUS_CODES[status] } } - end - end - # Intercept errors in before_action when fetching user or roles # when db is unreachable so we can still display a nice 500 static page def current_user @@ -45,4 +36,13 @@ class ErrorsController < ApplicationController rescue nil end + + private + + def render_error(status) + respond_to do |format| + format.html { render status: } + format.json { render status:, json: { status:, name: Rack::Utils::HTTP_STATUS_CODES[status] } } + end + end end From 7fbe5dda9840dffcd2392872b0fc3ae216eba8df Mon Sep 17 00:00:00 2001 From: Mathieu Magnin Date: Tue, 12 Nov 2024 13:32:49 +0100 Subject: [PATCH 1485/1532] Capture exception on sentry if something is wrong --- app/controllers/errors_controller.rb | 3 ++- spec/system/errors_spec.rb | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/controllers/errors_controller.rb b/app/controllers/errors_controller.rb index dcdd1a7a5..79a394099 100644 --- a/app/controllers/errors_controller.rb +++ b/app/controllers/errors_controller.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class ErrorsController < ApplicationController - rescue_from Exception do + rescue_from StandardError do |exception| + Sentry.capture_exception(exception) # catch any error, except errors triggered by middlewares outside controller (like warden middleware) render file: Rails.public_path.join('500.html'), layout: false, status: :internal_server_error end diff --git a/spec/system/errors_spec.rb b/spec/system/errors_spec.rb index 553ab8e14..687291b61 100644 --- a/spec/system/errors_spec.rb +++ b/spec/system/errors_spec.rb @@ -3,6 +3,15 @@ describe 'Errors handling', js: false do let(:procedure) { create(:procedure) } + scenario 'not found returns 404' do + without_detailed_exceptions do + visit '/nonexistent-path' + end + + expect(page).to have_http_status(:not_found) + expect(page).to have_content('Page non trouvée') + end + scenario 'bug renders dynamic 500 page' do procedure.revisions.destroy_all # break procedure From cdc45cd6afa3db0759191361ba8cf20b4fd3592c Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 8 Nov 2024 14:19:53 +0100 Subject: [PATCH 1486/1532] clean: ignore obsolete procedure_presentation columns --- app/models/procedure_presentation.rb | 2 + spec/factories/procedure_presentation.rb | 1 - ...1_migrate_filters_to_use_stable_id_spec.rb | 62 ------------------- ...remove_migration_status_on_filters_spec.rb | 50 --------------- ...n_from_procedure_presentation.rake_spec.rb | 27 -------- ...edure_presentation_to_columns.rake_spec.rb | 56 ----------------- spec/models/procedure_presentation_spec.rb | 12 ---- ...nvalid_procedure_presentation_task_spec.rb | 31 ---------- ...procedure_presentation_naming_task_spec.rb | 31 ---------- 9 files changed, 2 insertions(+), 270 deletions(-) delete mode 100644 spec/lib/tasks/deployment/20201001161931_migrate_filters_to_use_stable_id_spec.rb delete mode 100644 spec/lib/tasks/deployment/20210720133539_remove_migration_status_on_filters_spec.rb delete mode 100644 spec/lib/tasks/deployment/20240912151317_clean_virtual_column_from_procedure_presentation.rake_spec.rb delete mode 100644 spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb delete mode 100644 spec/tasks/maintenance/clean_invalid_procedure_presentation_task_spec.rb delete mode 100644 spec/tasks/maintenance/hotfix_former_procedure_presentation_naming_task_spec.rb diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 2df4db930..45e4e2434 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ProcedurePresentation < ApplicationRecord + self.ignored_columns += ["displayed_fields", "filters", "sort"] + belongs_to :assign_to, optional: false has_many :exports, dependent: :destroy diff --git a/spec/factories/procedure_presentation.rb b/spec/factories/procedure_presentation.rb index 159678a28..cd0e3c33a 100644 --- a/spec/factories/procedure_presentation.rb +++ b/spec/factories/procedure_presentation.rb @@ -7,6 +7,5 @@ FactoryBot.define do end assign_to { association :assign_to, procedure: procedure, instructeur: procedure.instructeurs.first } - sort { { "table" => "user", "column" => "email", "order" => "asc" } } end end diff --git a/spec/lib/tasks/deployment/20201001161931_migrate_filters_to_use_stable_id_spec.rb b/spec/lib/tasks/deployment/20201001161931_migrate_filters_to_use_stable_id_spec.rb deleted file mode 100644 index 3c2aa7080..000000000 --- a/spec/lib/tasks/deployment/20201001161931_migrate_filters_to_use_stable_id_spec.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -describe '20201001161931_migrate_filters_to_use_stable_id' do - let(:rake_task) { Rake::Task['after_party:migrate_filters_to_use_stable_id'] } - - let(:procedure) { create(:procedure, :with_instructeur, :with_type_de_champ) } - let(:type_de_champ) { procedure.active_revision.types_de_champ_public.first } - let(:sort) do - { - "table" => "type_de_champ", - "column" => type_de_champ.id.to_s, - "order" => "asc" - } - end - let(:filters) do - { - 'tous' => [ - { - "label" => "test", - "table" => "type_de_champ", - "column" => type_de_champ.id.to_s, - "value" => "test" - } - ], - 'suivis' => [], - 'traites' => [], - 'a-suivre' => [], - 'archives' => [] - } - end - let(:displayed_fields) do - [ - { - "label" => "test", - "table" => "type_de_champ", - "column" => type_de_champ.id.to_s - } - ] - end - let!(:procedure_presentation) do - type_de_champ.update_column(:stable_id, 13) - procedure_presentation = create(:procedure_presentation, procedure: procedure, assign_to: procedure.groupe_instructeurs.first.assign_tos.first) - procedure_presentation.update_columns(sort: sort, filters: filters, displayed_fields: displayed_fields) - procedure_presentation - end - - before do - rake_task.invoke - procedure_presentation.reload - end - - after { rake_task.reenable } - - context "should migrate procedure_presentation" do - it "columns are updated" do - expect(procedure_presentation.sort['column']).to eq(type_de_champ.stable_id.to_s) - expect(procedure_presentation.filters['tous'][0]['column']).to eq(type_de_champ.stable_id.to_s) - expect(procedure_presentation.displayed_fields[0]['column']).to eq(type_de_champ.stable_id.to_s) - expect(procedure_presentation.filters['migrated']).to eq(true) - end - end -end diff --git a/spec/lib/tasks/deployment/20210720133539_remove_migration_status_on_filters_spec.rb b/spec/lib/tasks/deployment/20210720133539_remove_migration_status_on_filters_spec.rb deleted file mode 100644 index 1eb4e20d5..000000000 --- a/spec/lib/tasks/deployment/20210720133539_remove_migration_status_on_filters_spec.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -describe '20201001161931_migrate_filters_to_use_stable_id' do - let(:rake_task) { Rake::Task['after_party:remove_migration_status_on_filters'] } - - let(:procedure) { create(:simple_procedure) } - let(:instructeur_1) { create(:instructeur) } - let(:instructeur_2) { create(:instructeur) } - - let(:assign_to_1) { create(:assign_to, procedure: procedure, instructeur: instructeur_1) } - let(:assign_to_2) { create(:assign_to, procedure: procedure, instructeur: instructeur_2) } - - let(:procedure_presentation_with_migration) { create(:procedure_presentation, assign_to: assign_to_1, filters: filters.merge('migrated': true)) } - let(:procedure_presentation_without_migration) { create(:procedure_presentation, assign_to: assign_to_2, filters: filters) } - - let(:filters) do - { "suivis" => [{ "table" => "user", "column" => "email", "value" => "test@example.com" }] } - end - - subject(:run_task) do - procedure_presentation_with_migration - procedure_presentation_without_migration - - rake_task.invoke - - procedure_presentation_with_migration.reload - procedure_presentation_without_migration.reload - end - - after { rake_task.reenable } - - context 'when the procedure presentation has a "migrated" key' do - it 'removes the "migrated" key' do - run_task - expect(procedure_presentation_with_migration.filters).not_to have_key('migrated') - end - - it 'leaves other keys unchanged' do - run_task - expect(procedure_presentation_with_migration.filters['suivis']).to be_present - end - end - - context 'when the procedure presentation doesn’t have a "migrated" key' do - it 'leaves keys unchanged' do - run_task - expect(procedure_presentation_without_migration.filters['suivis']).to be_present - end - end -end diff --git a/spec/lib/tasks/deployment/20240912151317_clean_virtual_column_from_procedure_presentation.rake_spec.rb b/spec/lib/tasks/deployment/20240912151317_clean_virtual_column_from_procedure_presentation.rake_spec.rb deleted file mode 100644 index 803bb7fd4..000000000 --- a/spec/lib/tasks/deployment/20240912151317_clean_virtual_column_from_procedure_presentation.rake_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -describe '20240912151317_clean_virtual_column_from_procedure_presentation.rake' do - let(:rake_task) { Rake::Task['after_party:clean_virtual_column_from_procedure_presentation'] } - - let(:procedure) { create(:procedure) } - let(:instructeur) { create(:instructeur) } - let(:assign_to) { create(:assign_to, procedure:, instructeur:) } - - let!(:procedure_presentation) do - displayed_fields = [{ label: "test1", table: "user", column: "email", virtual: true }] - - create(:procedure_presentation, assign_to:, displayed_fields:) - end - - before do - rake_task.invoke - - procedure_presentation.reload - end - - after { rake_task.reenable } - - it 'removes the virtual field' do - expect(procedure_presentation.displayed_fields).to eq([{ "column" => "email", "label" => "test1", "table" => "user" }]) - end -end diff --git a/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb b/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb deleted file mode 100644 index 1e2dfcc10..000000000 --- a/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -describe '20240920130741_migrate_procedure_presentation_to_columns.rake' do - let(:rake_task) { Rake::Task['after_party:migrate_procedure_presentation_to_columns'] } - - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :text }]) } - let(:instructeur) { create(:instructeur) } - let(:assign_to) { create(:assign_to, procedure: procedure, instructeur: instructeur) } - let(:stable_id) { procedure.active_revision.types_de_champ.first.stable_id } - let!(:procedure_presentation) do - displayed_fields = [ - { "table" => "etablissement", "column" => "entreprise_raison_sociale" }, - { "table" => "type_de_champ", "column" => stable_id.to_s } - ] - - sort = { "order" => "desc", "table" => "self", "column" => "en_construction_at" } - - filters = { - "tous" => [], - "suivis" => [], - "traites" => [{ "label" => "Libellé NAF", "table" => "etablissement", "value" => "Administration publique générale", "column" => "libelle_naf", "value_column" => "value" }], - "a-suivre" => [], - "archives" => [], - "expirant" => [], - "supprimes" => [], - "supprimes_recemment" => [] - } - - create(:procedure_presentation, assign_to:, displayed_fields:, filters:, sort:) - end - - before do - rake_task.invoke - - procedure_presentation.reload - end - - it 'populates the columns' do - procedure_id = procedure.id - - expect(procedure_presentation.displayed_columns.map(&:label)).to eq(["Entreprise raison sociale", procedure.active_revision.types_de_champ.first.libelle]) - - order, column_id = procedure_presentation - .sorted_column - .then { |sorted| [sorted.order, sorted.column.h_id] } - - expect(order).to eq('desc') - expect(column_id).to eq(procedure_id: procedure_id, column_id: "self/en_construction_at") - - expect(procedure_presentation.tous_filters).to eq([]) - - traites = procedure_presentation.traites_filters.map { [_1.label, _1.filter] } - - expect(traites).to eq([["Libellé NAF", "Administration publique générale"]]) - end -end diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index 36c4197d3..aff99a030 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -25,18 +25,6 @@ describe ProcedurePresentation do def to_filter((label, filter)) = FilteredColumn.new(column: procedure.find_column(label: label), filter: filter) - describe "#displayed_fields" do - it { expect(procedure_presentation.displayed_fields).to eq([{ "label" => "test1", "table" => "user", "column" => "email" }, { "label" => "test2", "table" => "type_de_champ", "column" => first_type_de_champ_id }]) } - end - - describe "#sort" do - it { expect(procedure_presentation.sort).to eq({ "table" => "user", "column" => "email", "order" => "asc" }) } - end - - describe "#filters" do - it { expect(procedure_presentation.filters).to eq({ "a-suivre" => [], "suivis" => [{ "label" => "label1", "table" => "self", "column" => "created_at" }] }) } - end - describe 'validation' do it { expect(build(:procedure_presentation)).to be_valid } diff --git a/spec/tasks/maintenance/clean_invalid_procedure_presentation_task_spec.rb b/spec/tasks/maintenance/clean_invalid_procedure_presentation_task_spec.rb deleted file mode 100644 index ba0915b52..000000000 --- a/spec/tasks/maintenance/clean_invalid_procedure_presentation_task_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -module Maintenance - RSpec.describe CleanInvalidProcedurePresentationTask do - describe "#process" do - subject(:process) { described_class.process(element) } - let(:procedure) { create(:procedure) } - let(:groupe_instructeur) { create(:groupe_instructeur, procedure:, instructeurs: [build(:instructeur)]) } - let(:assign_to) { create(:assign_to, procedure:, instructeur: groupe_instructeur.instructeurs.first) } - let(:element) { create(:procedure_presentation, procedure:, assign_to:) } - - before { element.update_column(:filters, filters) } - - context 'when filter is valid' do - let(:filters) { { "suivis" => [{ 'table' => "self", 'column' => "id", "value" => (FilteredColumn::PG_INTEGER_MAX_VALUE - 1).to_s }] } } - it 'keeps it filters' do - expect { subject }.not_to change { element.reload.filters } - end - end - - context 'when filter is invalid, drop it' do - let(:filters) { { "suivis" => [{ 'table' => "self", 'column' => "id", "value" => (FilteredColumn::PG_INTEGER_MAX_VALUE).to_s }] } } - it 'drop invalid filters' do - expect { subject }.to change { element.reload.filters }.to({ "suivis" => [] }) - end - end - end - end -end diff --git a/spec/tasks/maintenance/hotfix_former_procedure_presentation_naming_task_spec.rb b/spec/tasks/maintenance/hotfix_former_procedure_presentation_naming_task_spec.rb deleted file mode 100644 index 4fe8dd3ec..000000000 --- a/spec/tasks/maintenance/hotfix_former_procedure_presentation_naming_task_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -# this commit : https://github.com/demarches-simplifiees/demarches-simplifiees.fr/pull/10625/commits/305b8c13c75a711a85521d0b19659293d8d92805 -# it brokes a naming convention on ProcedurePresentation.filters|displayed_fields|sort -# we adjust live data to fit new convention -module Maintenance - RSpec.describe HotfixFormerProcedurePresentationNamingTask do - let(:procedure) { create(:procedure, types_de_champ_private: [{ type: :text }]) } - let(:groupe_instructeur) { create(:groupe_instructeur, procedure: procedure, instructeurs: [build(:instructeur)]) } - let(:assign_to) { create(:assign_to, procedure: procedure, instructeur: groupe_instructeur.instructeurs.first) } - let(:procedure_presentation) { create(:procedure_presentation, procedure: procedure, assign_to: assign_to) } - - describe "#process" do - subject(:process) { described_class.process(procedure_presentation) } - - it "fix table naming" do - stable_id = procedure.draft_revision.types_de_champ.first.stable_id.to_s - procedure_presentation.update_column(:displayed_fields, [{ table: 'type_de_champ_private', column: stable_id }]) - procedure_presentation.update_column(:filters, "a-suivre" => [{ table: 'type_de_champ_private', column: stable_id }]) - procedure_presentation.update_column(:sort, table: 'type_de_champ_private', column: stable_id, order: 'asc') - subject - procedure_presentation.reload - expect(procedure_presentation.displayed_fields.map { _1['table'] }).to eq(['type_de_champ']) - expect(procedure_presentation.filters.flat_map { |_, filters| filters.map { _1['table'] } }).to eq(['type_de_champ']) - expect(procedure_presentation.sort['table']).to eq('type_de_champ') - end - end - end -end From 343ad1a81c405a1008688e7433fe15d7bb04d65f Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 12 Nov 2024 17:01:38 +0100 Subject: [PATCH 1487/1532] refactor: simplify filter_enum --- app/models/columns/champ_column.rb | 11 ++++----- .../concerns/dossier_filtering_concern.rb | 6 ----- config/brakeman.ignore | 23 ------------------- 3 files changed, 5 insertions(+), 35 deletions(-) diff --git a/app/models/columns/champ_column.rb b/app/models/columns/champ_column.rb index fa83db9c6..5419710fd 100644 --- a/app/models/columns/champ_column.rb +++ b/app/models/columns/champ_column.rb @@ -32,15 +32,14 @@ class Columns::ChampColumn < Column end def filtered_ids(dossiers, search_terms) + relation = dossiers.with_type_de_champ(stable_id) + if type == :enum - dossiers.with_type_de_champ(stable_id) - .filter_enum(:champs, column, search_terms).ids + relation.where(champs: { column => search_terms }).ids elsif type == :enums - dossiers.with_type_de_champ(stable_id) - .filter_array_enum(:champs, column, search_terms).ids + relation.filter_array_enum(:champs, column, search_terms).ids else - dossiers.with_type_de_champ(stable_id) - .filter_ilike(:champs, column, search_terms).ids + relation.filter_ilike(:champs, column, search_terms).ids end end diff --git a/app/models/concerns/dossier_filtering_concern.rb b/app/models/concerns/dossier_filtering_concern.rb index ac040f3f5..1956b27a0 100644 --- a/app/models/concerns/dossier_filtering_concern.rb +++ b/app/models/concerns/dossier_filtering_concern.rb @@ -34,12 +34,6 @@ module DossierFilteringConcern where(q, *(values.map { |value| "%#{value}%" })) } - scope :filter_enum, lambda { |table, column, values| - table_column = DossierFilterService.sanitized_column(table, column) - q = Array.new(values.count, "(#{table_column} = ?)").join(' OR ') - where(q, *(values)) - } - scope :filter_array_enum, lambda { |table, column, values| table_column = DossierFilterService.sanitized_column(table, column) q = Array.new(values.count, "(#{table_column} = ?)").join(' OR ') diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 78ea26d4c..6891674f2 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -216,29 +216,6 @@ ], "note": "" }, - { - "warning_type": "SQL Injection", - "warning_code": 0, - "fingerprint": "aaff41afa7bd5a551cd2e3a385071090cb53c95caa40fad3785cd3d68c9b939c", - "check_name": "SQL", - "message": "Possible SQL injection", - "file": "app/models/concerns/dossier_filtering_concern.rb", - "line": 40, - "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", - "code": "where(\"#{values.count} OR #{\"(#{DossierFilterService.sanitized_column(table, column)} = ?)\"}\", *values)", - "render_path": null, - "location": { - "type": "method", - "class": "DossierFilteringConcern", - "method": null - }, - "user_input": "values.count", - "confidence": "Medium", - "cwe_id": [ - 89 - ], - "note": "The table and column are escaped, which should make this safe" - }, { "warning_type": "Cross-Site Scripting", "warning_code": 2, From 50677e982c769a4a19780d5d1be18945601ca296 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 12 Nov 2024 17:40:55 +0100 Subject: [PATCH 1488/1532] refactor: simplify and sanitize filter_ilike --- .../concerns/dossier_filtering_concern.rb | 9 ++-- config/brakeman.ignore | 48 +++++++++---------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/app/models/concerns/dossier_filtering_concern.rb b/app/models/concerns/dossier_filtering_concern.rb index 1956b27a0..4baab3a0f 100644 --- a/app/models/concerns/dossier_filtering_concern.rb +++ b/app/models/concerns/dossier_filtering_concern.rb @@ -28,10 +28,11 @@ module DossierFilteringConcern end } - scope :filter_ilike, lambda { |table, column, values| + scope :filter_ilike, lambda { |table, column, search_terms| + safe_quoted_terms = search_terms.map { "%#{sanitize_sql_like(_1)}%" } table_column = DossierFilterService.sanitized_column(table, column) - q = Array.new(values.count, "(#{table_column} ILIKE ?)").join(' OR ') - where(q, *(values.map { |value| "%#{value}%" })) + + where("#{table_column} LIKE ANY (ARRAY[?])", safe_quoted_terms) } scope :filter_array_enum, lambda { |table, column, values| @@ -39,5 +40,7 @@ module DossierFilteringConcern q = Array.new(values.count, "(#{table_column} = ?)").join(' OR ') where(q, *(values. map { |value| "[\"#{value}\"]" })) } + + def sanitize_sql_like(q) = ActiveRecord::Base.sanitize_sql_like(q) end end diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 6891674f2..38482ebdd 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -136,29 +136,6 @@ ], "note": "escaped by hand" }, - { - "warning_type": "SQL Injection", - "warning_code": 0, - "fingerprint": "91ff8031e7c639c95fe6c244867349a72078ef456d8b3507deaf2bdb9bf62fe2", - "check_name": "SQL", - "message": "Possible SQL injection", - "file": "app/models/concerns/dossier_filtering_concern.rb", - "line": 34, - "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", - "code": "where(\"#{values.count} OR #{\"(#{DossierFilterService.sanitized_column(table, column)} ILIKE ?)\"}\", *values.map do\n \"%#{value}%\"\n end)", - "render_path": null, - "location": { - "type": "method", - "class": "DossierFilteringConcern", - "method": null - }, - "user_input": "values.count", - "confidence": "Medium", - "cwe_id": [ - 89 - ], - "note": "filtered by rails query params where(something: ?, values)" - }, { "warning_type": "Cross-Site Scripting", "warning_code": 2, @@ -216,6 +193,29 @@ ], "note": "" }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "afd2a1a41bd87fa62e065671670bd9bd8cc503ca4cbd3cfdb74a38a794146926", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/models/concerns/dossier_filtering_concern.rb", + "line": 35, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "where(\"#{DossierFilterService.sanitized_column(table, column)} LIKE ANY (ARRAY[?])\", search_terms.map do\n \"%#{sanitize_sql_like(_1)}%\"\n end)", + "render_path": null, + "location": { + "type": "method", + "class": "DossierFilteringConcern", + "method": null + }, + "user_input": "DossierFilterService.sanitized_column(table, column)", + "confidence": "Medium", + "cwe_id": [ + 89 + ], + "note": "The table and column are escaped, which should make this safe" + }, { "warning_type": "Cross-Site Scripting", "warning_code": 2, @@ -272,6 +272,6 @@ "note": "Current is not a model" } ], - "updated": "2024-11-04 09:56:55 +0100", + "updated": "2024-11-12 17:33:07 +0100", "brakeman_version": "6.1.2" } From 09793420fba657c773aef9bb37a246568166fe57 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 12 Nov 2024 17:39:31 +0100 Subject: [PATCH 1489/1532] fix: make enums filter work if champs.enums contains the searched value among others --- app/models/columns/champ_column.rb | 4 ++- .../concerns/dossier_filtering_concern.rb | 6 ---- config/brakeman.ignore | 25 +-------------- spec/services/dossier_filter_service_spec.rb | 32 ++++++++++++++++--- 4 files changed, 31 insertions(+), 36 deletions(-) diff --git a/app/models/columns/champ_column.rb b/app/models/columns/champ_column.rb index 5419710fd..c032b3a35 100644 --- a/app/models/columns/champ_column.rb +++ b/app/models/columns/champ_column.rb @@ -37,7 +37,9 @@ class Columns::ChampColumn < Column if type == :enum relation.where(champs: { column => search_terms }).ids elsif type == :enums - relation.filter_array_enum(:champs, column, search_terms).ids + # in a multiple drop down list, the value are stored as '["v1", "v2"]' + quoted_search_terms = search_terms.map { %{"#{_1}"} } + relation.filter_ilike(:champs, column, quoted_search_terms).ids else relation.filter_ilike(:champs, column, search_terms).ids end diff --git a/app/models/concerns/dossier_filtering_concern.rb b/app/models/concerns/dossier_filtering_concern.rb index 4baab3a0f..7883ae700 100644 --- a/app/models/concerns/dossier_filtering_concern.rb +++ b/app/models/concerns/dossier_filtering_concern.rb @@ -35,12 +35,6 @@ module DossierFilteringConcern where("#{table_column} LIKE ANY (ARRAY[?])", safe_quoted_terms) } - scope :filter_array_enum, lambda { |table, column, values| - table_column = DossierFilterService.sanitized_column(table, column) - q = Array.new(values.count, "(#{table_column} = ?)").join(' OR ') - where(q, *(values. map { |value| "[\"#{value}\"]" })) - } - def sanitize_sql_like(q) = ActiveRecord::Base.sanitize_sql_like(q) end end diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 38482ebdd..d064270c1 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -44,29 +44,6 @@ ], "note": "" }, - { - "warning_type": "SQL Injection", - "warning_code": 0, - "fingerprint": "5092b33433aef8fe42b688a780325f3791a77b39e55131256c78cebc3c14c0a3", - "check_name": "SQL", - "message": "Possible SQL injection", - "file": "app/models/concerns/dossier_filtering_concern.rb", - "line": 46, - "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", - "code": "where(\"#{values.count} OR #{\"(#{DossierFilterService.sanitized_column(table, column)} = ?)\"}\", *values.map do\n \"[\\\"#{value}\\\"]\"\n end)", - "render_path": null, - "location": { - "type": "method", - "class": "DossierFilteringConcern", - "method": null - }, - "user_input": "values.count", - "confidence": "Medium", - "cwe_id": [ - 89 - ], - "note": "filtered by rails query params where(something: ?, values)" - }, { "warning_type": "SQL Injection", "warning_code": 0, @@ -120,7 +97,7 @@ "check_name": "SQL", "message": "Possible SQL injection", "file": "app/models/columns/json_path_column.rb", - "line": 24, + "line": 26, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "code": "dossiers.with_type_de_champ(stable_id).where(\"champs.value_json @? '#{jsonpath} ? (@ like_regex \\\"#{quote_string(search_terms.join(\"|\"))}\\\" flag \\\"i\\\")'\")", "render_path": null, diff --git a/spec/services/dossier_filter_service_spec.rb b/spec/services/dossier_filter_service_spec.rb index d00ae6371..2290aad8c 100644 --- a/spec/services/dossier_filter_service_spec.rb +++ b/spec/services/dossier_filter_service_spec.rb @@ -510,20 +510,42 @@ describe DossierFilterService do end context 'with enums type_de_champ' do - let(:filter) { [type_de_champ.libelle, 'Favorable'] } - let(:types_de_champ_public) { [{ type: :multiple_drop_down_list, options: ['Favorable', 'Defavorable'] }] } + let(:filter) { [type_de_champ.libelle, search_term] } + let(:types_de_champ_public) { [{ type: :multiple_drop_down_list, options: ['champ', 'champignon'] }] } before do kept_champ = kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id) - kept_champ.value = ['Favorable'] + kept_champ.value = ['champ', 'champignon'] kept_champ.save! discarded_champ = discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id) - discarded_champ.value = ['Defavorable'] + discarded_champ.value = ['champignon'] discarded_champ.save! end - it { is_expected.to contain_exactly(kept_dossier.id) } + context 'with single value' do + let(:search_term) { 'champ' } + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'with multiple search values' do + let(:search_term) { 'champignon' } + + it { is_expected.to contain_exactly(kept_dossier.id, discarded_dossier.id) } + end + + context 'test if I could break a regex with %' do + let(:search_term) { '%' } + + it { is_expected.to be_empty } + end + + context 'test if I could break a regex with .' do + let(:search_term) { '.*' } + + it { is_expected.to be_empty } + end end end From e94fd6db4c74180c5e328ea09e20628e5e8655ad Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 13 Nov 2024 10:03:24 +0100 Subject: [PATCH 1490/1532] fix: filtering on linked_drop_down_column --- app/models/columns/linked_drop_down_column.rb | 28 ++++++- .../columns/linked_drop_down_column_spec.rb | 78 +++++++++++++++++++ 2 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 spec/models/columns/linked_drop_down_column_spec.rb diff --git a/app/models/columns/linked_drop_down_column.rb b/app/models/columns/linked_drop_down_column.rb index f750c1fa8..1d25d3eea 100644 --- a/app/models/columns/linked_drop_down_column.rb +++ b/app/models/columns/linked_drop_down_column.rb @@ -17,10 +17,28 @@ class Columns::LinkedDropDownColumn < Columns::ChampColumn ) end - def filtered_ids(dossiers, values) - dossiers.with_type_de_champ(@column) - .filter_ilike(:champs, :value, values) - .ids + def filtered_ids(dossiers, search_terms) + relation = dossiers.with_type_de_champ(@stable_id) + + case path + when :value + search_terms.flat_map do |search_term| + # when looking for "section 1 / option A", + # the value must contain both "section 1" and "option A" + primary, *secondary = search_term.split(%r{[[:space:]]*/[[:space:]]*}) + safe_terms = [primary, *secondary].map { "%#{safe_like(_1)}%" } + + relation.where("champs.value ILIKE ALL (ARRAY[?])", safe_terms).ids + end.uniq + when :primary + primary_terms = search_terms.map { |term| %{["#{safe_like(term)}","%"]} } + + relation.where("champs.value ILIKE ANY (array[?])", primary_terms).ids + when :secondary + secondary_terms = search_terms.map { |term| %{["%","#{safe_like(term)}"]} } + + relation.where("champs.value ILIKE ANY (array[?])", secondary_terms).ids + end end private @@ -44,4 +62,6 @@ class Columns::LinkedDropDownColumn < Columns::ChampColumn rescue JSON::ParserError [] end + + def safe_like(q) = ActiveRecord::Base.sanitize_sql_like(q) end diff --git a/spec/models/columns/linked_drop_down_column_spec.rb b/spec/models/columns/linked_drop_down_column_spec.rb new file mode 100644 index 000000000..0929090bb --- /dev/null +++ b/spec/models/columns/linked_drop_down_column_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +describe Columns::LinkedDropDownColumn do + describe '#filtered_ids' do + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :linked_drop_down_list, libelle: 'linked' }]) } + let(:type_de_champ) { procedure.active_revision.types_de_champ_public.first } + let(:kept_dossier) { create(:dossier, procedure: procedure) } + let(:discarded_dossier) { create(:dossier, procedure: procedure) } + + subject { column.filtered_ids(Dossier.all, search_terms) } + + context 'when path is :value' do + let(:column) { procedure.find_column(label: 'linked') } + + before do + kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id) + .update(value: %{["section 1","option A"]}) + + discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id) + .update(value: %{["section 1","option B"]}) + end + + describe 'when looking for a part' do + let(:search_terms) { ['option A'] } + + it { is_expected.to eq([kept_dossier.id]) } + end + + describe 'when looking for the aggregated value' do + let(:search_terms) { ['section 1 / option A'] } + + it { is_expected.to match_array([kept_dossier.id]) } + end + + describe 'when looking for the aggregated value or a common value' do + let(:search_terms) { ['section 1 / option A', 'section'] } + + it { is_expected.to match_array([kept_dossier.id, discarded_dossier.id]) } + end + + describe 'when looking for a shared string' do + let(:search_terms) { ['option'] } + + it { is_expected.to match_array([kept_dossier.id, discarded_dossier.id]) } + end + end + + context 'when path is not :value' do + before do + kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id) + .update(value: %{["1","2"]}) + + discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id) + .update(value: %{["2","1"]}) + end + + context 'when path is :primary' do + let(:column) { procedure.find_column(label: 'linked (Primaire)') } + + describe 'when looking kept part' do + let(:search_terms) { ['1'] } + + it { is_expected.to eq([kept_dossier.id]) } + end + end + + context 'when path is :secondary' do + let(:column) { procedure.find_column(label: 'linked (Secondaire)') } + + describe 'when looking kept part' do + let(:search_terms) { ['2'] } + + it { is_expected.to eq([kept_dossier.id]) } + end + end + end + end +end From d2c3c91fa7718d271febf68a3a5d2a07c9260872 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Wed, 13 Nov 2024 10:44:51 +0100 Subject: [PATCH 1491/1532] fix: hide sva decision before column --- app/models/procedure_presentation.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index 2df4db930..169bfe3ad 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -37,7 +37,7 @@ class ProcedurePresentation < ApplicationRecord *displayed_columns, procedure.dossier_state_column ] - columns.concat(procedure.sva_svr_columns) if procedure.sva_svr_enabled? + columns.concat(procedure.sva_svr_columns.filter(&:displayable)) if procedure.sva_svr_enabled? columns end end From 81eeff247406138efc9d0de445fbe47465c9fa38 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 4 Nov 2024 13:27:30 +0100 Subject: [PATCH 1492/1532] cleanup: remove unused code --- app/models/champs/drop_down_list_champ.rb | 8 -------- app/models/champs/linked_drop_down_list_champ.rb | 8 -------- app/models/champs/multiple_drop_down_list_champ.rb | 13 ------------- 3 files changed, 29 deletions(-) diff --git a/app/models/champs/drop_down_list_champ.rb b/app/models/champs/drop_down_list_champ.rb index 67a83f281..4c5306f6c 100644 --- a/app/models/champs/drop_down_list_champ.rb +++ b/app/models/champs/drop_down_list_champ.rb @@ -56,14 +56,6 @@ class Champs::DropDownListChamp < Champ options.include?(value) end - def remove_option(options, touch = false) - if touch - update(value: nil) - else - update_column(:value, nil) - end - end - private def value_is_in_options diff --git a/app/models/champs/linked_drop_down_list_champ.rb b/app/models/champs/linked_drop_down_list_champ.rb index 65e6c576c..c8621efc2 100644 --- a/app/models/champs/linked_drop_down_list_champ.rb +++ b/app/models/champs/linked_drop_down_list_champ.rb @@ -47,14 +47,6 @@ class Champs::LinkedDropDownListChamp < Champ options.include?(primary_value) || options.include?(secondary_value) end - def remove_option(options, touch = false) - if touch - update(value: nil) - else - update_column(:value, nil) - end - end - private def pack_value(primary, secondary) diff --git a/app/models/champs/multiple_drop_down_list_champ.rb b/app/models/champs/multiple_drop_down_list_champ.rb index 2bc715a06..46bbabfb8 100644 --- a/app/models/champs/multiple_drop_down_list_champ.rb +++ b/app/models/champs/multiple_drop_down_list_champ.rb @@ -33,15 +33,6 @@ class Champs::MultipleDropDownListChamp < Champ (selected_options - options).size != selected_options.size end - def remove_option(options, touch = false) - value = (selected_options - options).to_json - if touch - update(value:) - else - update_columns(value:) - end - end - def focusable_input_id render_as_checkboxes? ? checkbox_id(drop_down_options.first) : input_id end @@ -80,10 +71,6 @@ class Champs::MultipleDropDownListChamp < Champ end end - def render? - @champ.drop_down_options.any? - end - private def values_are_in_options From 942fc42af2113e0ff0c8fb1bee85e2490aee6bbf Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 6 Nov 2024 22:30:31 +0100 Subject: [PATCH 1493/1532] refactor(dossier): rebase should not change champ type --- app/models/champs/carte_champ.rb | 6 ++- .../champs/linked_drop_down_list_champ.rb | 12 +++--- .../concerns/champ_conditional_concern.rb | 2 +- app/models/concerns/dossier_champs_concern.rb | 42 ++++++++++++------- app/models/concerns/dossier_rebase_concern.rb | 36 +--------------- app/models/dossier_preloader.rb | 2 + app/models/routing_engine.rb | 2 +- .../drop_down_list_type_de_champ.rb | 13 ++++++ .../linked_drop_down_list_type_de_champ.rb | 15 +++++-- .../multiple_drop_down_list_type_de_champ.rb | 2 +- .../types_de_champ/type_de_champ_base.rb | 2 +- app/services/pieces_justificatives_service.rb | 7 +--- spec/factories/champ.rb | 2 +- spec/models/champ_spec.rb | 8 ++-- .../linked_drop_down_list_champ_spec.rb | 26 +++++++----- spec/models/columns/champ_column_spec.rb | 2 +- .../concerns/dossier_rebase_concern_spec.rb | 29 ++++++------- .../tags_substitution_concern_spec.rb | 4 +- .../pieces_justificatives_service_spec.rb | 3 +- .../_show.html.haml_spec.rb | 2 +- 20 files changed, 112 insertions(+), 105 deletions(-) diff --git a/app/models/champs/carte_champ.rb b/app/models/champs/carte_champ.rb index aa7e1560f..d5eb51fb2 100644 --- a/app/models/champs/carte_champ.rb +++ b/app/models/champs/carte_champ.rb @@ -16,8 +16,10 @@ class Champs::CarteChamp < Champ # We are not using scopes here as we want to access # the following collections on unsaved records. def cadastres - geo_areas.filter do |area| - area.source == GeoArea.sources.fetch(:cadastre) + if cadastres? + geo_areas.filter { _1.source == GeoArea.sources.fetch(:cadastre) } + else + [] end end diff --git a/app/models/champs/linked_drop_down_list_champ.rb b/app/models/champs/linked_drop_down_list_champ.rb index c8621efc2..9254cfca0 100644 --- a/app/models/champs/linked_drop_down_list_champ.rb +++ b/app/models/champs/linked_drop_down_list_champ.rb @@ -4,18 +4,18 @@ class Champs::LinkedDropDownListChamp < Champ delegate :primary_options, :secondary_options, to: :type_de_champ def primary_value - if value.present? - JSON.parse(value)[0] - else + if type_de_champ.champ_blank?(self) '' + else + JSON.parse(value)[0] end end def secondary_value - if value.present? - JSON.parse(value)[1] - else + if type_de_champ.champ_blank?(self) '' + else + JSON.parse(value)[1] end end diff --git a/app/models/concerns/champ_conditional_concern.rb b/app/models/concerns/champ_conditional_concern.rb index cf5963822..91fbd97be 100644 --- a/app/models/concerns/champ_conditional_concern.rb +++ b/app/models/concerns/champ_conditional_concern.rb @@ -30,7 +30,7 @@ module ChampConditionalConcern private def champs_for_condition - dossier.champs.filter { _1.row_id.nil? || _1.row_id == row_id } + dossier.filled_champs.filter { _1.row_id.nil? || _1.row_id == row_id } end end end diff --git a/app/models/concerns/dossier_champs_concern.rb b/app/models/concerns/dossier_champs_concern.rb index 82fa8e5fc..570b744d8 100644 --- a/app/models/concerns/dossier_champs_concern.rb +++ b/app/models/concerns/dossier_champs_concern.rb @@ -6,23 +6,26 @@ module DossierChampsConcern def project_champ(type_de_champ, row_id) check_valid_row_id?(type_de_champ, row_id) champ = champs_by_public_id[type_de_champ.public_id(row_id)] - if champ.nil? - type_de_champ.build_champ(dossier: self, row_id:, updated_at: depose_at || created_at) + if champ.nil? || !champ.is_type?(type_de_champ.type_champ) + value = type_de_champ.champ_blank?(champ) ? nil : champ.value + updated_at = champ&.updated_at || depose_at || created_at + rebased_at = champ&.rebased_at + type_de_champ.build_champ(dossier: self, row_id:, updated_at:, rebased_at:, value:) else champ end end def project_champs_public - revision.types_de_champ_public.map { project_champ(_1, nil) } + @project_champs_public ||= revision.types_de_champ_public.map { project_champ(_1, nil) } end def project_champs_private - revision.types_de_champ_private.map { project_champ(_1, nil) } + @project_champs_private ||= revision.types_de_champ_private.map { project_champ(_1, nil) } end def filled_champs_public - project_champs_public.flat_map do |champ| + @filled_champs_public ||= project_champs_public.flat_map do |champ| if champ.repetition? champ.rows.flatten.filter { _1.persisted? && _1.fillable? } elsif champ.persisted? && champ.fillable? @@ -34,7 +37,7 @@ module DossierChampsConcern end def filled_champs_private - project_champs_private.flat_map do |champ| + @filled_champs_private ||= project_champs_private.flat_map do |champ| if champ.repetition? champ.rows.flatten.filter { _1.persisted? && _1.fillable? } elsif champ.persisted? && champ.fillable? @@ -129,8 +132,7 @@ module DossierChampsConcern row_id = ULID.generate types_de_champ = revision.children_of(type_de_champ) self.champs += types_de_champ.map { _1.build_champ(row_id:, updated_by:) } - champs.reload if persisted? - @champs_by_public_id = nil + reload_champs_cache row_id end @@ -138,14 +140,11 @@ module DossierChampsConcern raise "Can't remove row from non-repetition type de champ" if !type_de_champ.repetition? champs.where(row_id:).destroy_all - champs.reload if persisted? - @champs_by_public_id = nil + reload_champs_cache end def reload - super.tap do - @champs_by_public_id = nil - end + super.tap { reset_champs_cache } end private @@ -156,7 +155,7 @@ module DossierChampsConcern def filled_champ(type_de_champ, row_id) champ = champs_by_public_id[type_de_champ.public_id(row_id)] - if champ.blank? || !champ.visible? + if type_de_champ.champ_blank?(champ) || !champ.visible? nil else champ @@ -188,7 +187,7 @@ module DossierChampsConcern attributes[:data] = nil end - @champs_by_public_id = nil + reset_champs_cache [champ, attributes] end @@ -202,4 +201,17 @@ module DossierChampsConcern raise "type_de_champ #{type_de_champ.stable_id} in revision #{revision_id} can not have a row_id because it is not part of a repetition" end end + + def reset_champs_cache + @champs_by_public_id = nil + @filled_champs_public = nil + @filled_champs_private = nil + @project_champs_public = nil + @project_champs_private = nil + end + + def reload_champs_cache + champs.reload if persisted? + reset_champs_cache + end end diff --git a/app/models/concerns/dossier_rebase_concern.rb b/app/models/concerns/dossier_rebase_concern.rb index b0932166b..675bd6117 100644 --- a/app/models/concerns/dossier_rebase_concern.rb +++ b/app/models/concerns/dossier_rebase_concern.rb @@ -68,7 +68,7 @@ module DossierRebaseConcern changes_by_op[:remove].each { champs_by_stable_id[_1.stable_id].destroy_all } # update champ - changes_by_op[:update].each { apply(_1, champs_by_stable_id[_1.stable_id]) } + changes_by_op[:update].each { champs_by_stable_id[_1.stable_id].update_all(rebased_at: Time.zone.now) } # update dossier revision update_column(:revision_id, target_revision.id) @@ -79,40 +79,6 @@ module DossierRebaseConcern .each { add_new_champs_for_revision(_1) } end - def apply(change, champs) - case change.attribute - when :type_champ - champs.each { purge_piece_justificative_file(_1) } - GeoArea.where(champ: champs).destroy_all - Etablissement.where(champ: champs).destroy_all - champs.update_all(type: "Champs::#{change.to.classify}Champ", - value: nil, - value_json: nil, - external_id: nil, - data: nil, - rebased_at: Time.zone.now) - when :drop_down_options - # we are removing options, we need to remove the value if it contains one of the removed options - removed_options = change.from - change.to - if removed_options.present? && champs.any? { _1.in?(removed_options) } - champs.filter { _1.in?(removed_options) }.each do - _1.remove_option(removed_options) - _1.update_column(:rebased_at, Time.zone.now) - end - end - when :carte_layers - # if we are removing cadastres layer, we need to remove cadastre geo areas - if change.from.include?(:cadastres) && !change.to.include?(:cadastres) - champs.filter { _1.cadastres.present? }.each do - _1.cadastres.each(&:destroy) - _1.update_column(:rebased_at, Time.zone.now) - end - end - else - champs.update_all(rebased_at: Time.zone.now) - end - end - def add_new_champs_for_revision(target_coordinate) if target_coordinate.child? row_ids = repetition_row_ids(target_coordinate.parent.type_de_champ) diff --git a/app/models/dossier_preloader.rb b/app/models/dossier_preloader.rb index f1056a9ad..920ef00bf 100644 --- a/app/models/dossier_preloader.rb +++ b/app/models/dossier_preloader.rb @@ -92,5 +92,7 @@ class DossierPreloader if dossier.etablissement dossier.etablissement.association(:champ).target = nil end + + dossier.send(:reset_champs_cache) end end diff --git a/app/models/routing_engine.rb b/app/models/routing_engine.rb index d5ab19b8d..6f2ceafab 100644 --- a/app/models/routing_engine.rb +++ b/app/models/routing_engine.rb @@ -5,7 +5,7 @@ module RoutingEngine return if dossier.forced_groupe_instructeur matching_groupe = dossier.procedure.groupe_instructeurs.active.reject(&:invalid_rule?).find do |gi| - gi.routing_rule&.compute(dossier.champs) + gi.routing_rule&.compute(dossier.filled_champs) end matching_groupe ||= dossier.procedure.defaut_groupe_instructeur diff --git a/app/models/types_de_champ/drop_down_list_type_de_champ.rb b/app/models/types_de_champ/drop_down_list_type_de_champ.rb index 4b9058397..039d437bb 100644 --- a/app/models/types_de_champ/drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/drop_down_list_type_de_champ.rb @@ -1,4 +1,17 @@ # frozen_string_literal: true class TypesDeChamp::DropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBase + def champ_blank?(champ) + super || !champ_value_in_options?(champ) + end + + private + + def champ_value_in_options?(champ) + champ_with_other_value?(champ) || drop_down_options.include?(champ.value) + end + + def champ_with_other_value?(champ) + drop_down_other? && champ.value_json['other'] + end end diff --git a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb index e15b86809..cecb2699b 100644 --- a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb @@ -3,7 +3,6 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBase PRIMARY_PATTERN = /^--(.*)--$/ - delegate :drop_down_options, to: :@type_de_champ validate :check_presence_of_primary_options def libelles_for_export @@ -112,9 +111,17 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas options.unshift('') end - def primary_value(champ) = unpack_value(champ.value, 0) - def secondary_value(champ) = unpack_value(champ.value, 1) - def unpack_value(value, index) = value&.then { JSON.parse(_1)[index] rescue nil } + def primary_value(champ) = unpack_value(champ.value, 0, primary_options) + def secondary_value(champ) = unpack_value(champ.value, 1, secondary_options.values.flatten) + + def unpack_value(value, index, options) + value&.then do + unpacked_value = JSON.parse(_1)[index] + unpacked_value if options.include?(unpacked_value) + rescue + nil + end + end def has_secondary_options_for_primary?(champ) primary_value(champ).present? && secondary_options[primary_value(champ)]&.any?(&:present?) diff --git a/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb index 1be2be9d5..bbca8ee52 100644 --- a/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb @@ -24,7 +24,7 @@ class TypesDeChamp::MultipleDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampB [champ.value] else JSON.parse(champ.value) - end + end.filter { drop_down_options.include?(_1) } rescue JSON::ParserError [] end diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index 1cf5e66e4..86ab644f5 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -3,7 +3,7 @@ class TypesDeChamp::TypeDeChampBase include ActiveModel::Validations - delegate :description, :libelle, :mandatory, :mandatory?, :stable_id, :fillable?, :public?, :type_champ, :options_for_select, to: :@type_de_champ + delegate :description, :libelle, :mandatory, :mandatory?, :stable_id, :fillable?, :public?, :type_champ, :options_for_select, :drop_down_options, :drop_down_other?, to: :@type_de_champ FILL_DURATION_SHORT = 10.seconds FILL_DURATION_MEDIUM = 1.minute diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 53c97811b..cdd79e99c 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -135,11 +135,8 @@ class PiecesJustificativesService end def pjs_for_champs(dossiers) - champs = dossiers.flat_map(&:champs).filter { _1.type == "Champs::PieceJustificativeChamp" } - - if !liste_documents_allows?(:with_champs_private) - champs = champs.reject(&:private?) - end + champs = liste_documents_allows?(:with_champs_private) ? dossiers.flat_map(&:filled_champs) : dossiers.flat_map(&:filled_champs_public) + champs = champs.filter { _1.piece_justificative? && _1.is_type?(_1.type_de_champ.type_champ) } champs_id_row_index = compute_champ_id_row_index(champs) diff --git a/spec/factories/champ.rb b/spec/factories/champ.rb index bab863c84..2c8796138 100644 --- a/spec/factories/champ.rb +++ b/spec/factories/champ.rb @@ -69,7 +69,7 @@ FactoryBot.define do end factory :champ_do_not_use_linked_drop_down_list, class: 'Champs::LinkedDropDownListChamp' do - value { '["categorie 1", "choix 1"]' } + value { '["primary", "secondary"]' } end factory :champ_do_not_use_pays, class: 'Champs::PaysChamp' do diff --git a/spec/models/champ_spec.rb b/spec/models/champ_spec.rb index dd4598017..0f1a01ff5 100644 --- a/spec/models/champ_spec.rb +++ b/spec/models/champ_spec.rb @@ -218,7 +218,7 @@ describe Champ do context 'when type_de_champ is multiple_drop_down_list' do let(:champ) { Champs::MultipleDropDownListChamp.new(value:, dossier: build(:dossier)) } - before { allow(champ).to receive(:type_de_champ).and_return(build(:type_de_champ_multiple_drop_down_list)) } + before { allow(champ).to receive(:type_de_champ).and_return(build(:type_de_champ_multiple_drop_down_list, drop_down_options: ["Crétinier", "Mousserie"])) } let(:value) { '["Crétinier", "Mousserie"]' } @@ -308,7 +308,7 @@ describe Champ do context 'for drop down list champ' do let(:champ) { Champs::DropDownListChamp.new(value:) } before { allow(champ).to receive(:type_de_champ).and_return(build(:type_de_champ_drop_down_list)) } - let(:value) { "HLM" } + let(:value) { "val1" } it { is_expected.to eq([value]) } end @@ -334,13 +334,15 @@ describe Champ do end context 'for linked drop down list champ' do - let(:champ) { Champs::LinkedDropDownListChamp.new(primary_value: "hello", secondary_value: "world") } + let(:champ) { Champs::LinkedDropDownListChamp.new(value: '["hello","world"]') } + before { allow(champ).to receive(:type_de_champ).and_return(build(:type_de_champ_linked_drop_down_list, drop_down_options: ['--hello--', 'world'])) } it { is_expected.to eq(["hello", "world"]) } end context 'for multiple drop down list champ' do let(:champ) { Champs::MultipleDropDownListChamp.new(value:) } + before { allow(champ).to receive(:type_de_champ).and_return(build(:type_de_champ_multiple_drop_down_list, drop_down_options: ['goodbye', 'cruel', 'world'])) } context 'when there are multiple values selected' do let(:value) { JSON.generate(['goodbye', 'cruel', 'world']) } diff --git a/spec/models/champs/linked_drop_down_list_champ_spec.rb b/spec/models/champs/linked_drop_down_list_champ_spec.rb index 9b0e194ea..c05659f26 100644 --- a/spec/models/champs/linked_drop_down_list_champ_spec.rb +++ b/spec/models/champs/linked_drop_down_list_champ_spec.rb @@ -2,24 +2,28 @@ describe Champs::LinkedDropDownListChamp do describe '#unpack_value' do - let(:champ) { Champs::LinkedDropDownListChamp.new(value: '["tata", "tutu"]') } + let(:champ) { Champs::LinkedDropDownListChamp.new(value: '["primary", "secondary"]', dossier: build(:dossier)) } + before { allow(champ).to receive(:type_de_champ).and_return(build(:type_de_champ_linked_drop_down_list)) } - it { expect(champ.primary_value).to eq('tata') } - it { expect(champ.secondary_value).to eq('tutu') } - end - - describe '#pack_value' do - let(:champ) { Champs::LinkedDropDownListChamp.new(primary_value: 'tata', secondary_value: 'tutu') } - - it { expect(champ.value).to eq('["tata","tutu"]') } + it { expect(champ.primary_value).to eq('primary') } + it { expect(champ.secondary_value).to eq('secondary') } end describe '#primary_value=' do - let(:champ) { Champs::LinkedDropDownListChamp.new(primary_value: 'tata', secondary_value: 'tutu') } + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :linked_drop_down_list }]) } + let(:dossier) { create(:dossier, procedure:) } + let(:champ) { dossier.champs.first } before { champ.primary_value = '' } - it { expect(champ.value).to eq('["",""]') } + it { + champ.primary_value = 'primary' + expect(champ.value).to eq('["primary",""]') + champ.secondary_value = 'secondary' + expect(champ.value).to eq('["primary","secondary"]') + champ.primary_value = '' + expect(champ.value).to eq('["",""]') + } end describe '#to_s' do diff --git a/spec/models/columns/champ_column_spec.rb b/spec/models/columns/champ_column_spec.rb index 681f38ab2..82c0fa2bd 100644 --- a/spec/models/columns/champ_column_spec.rb +++ b/spec/models/columns/champ_column_spec.rb @@ -30,7 +30,7 @@ describe Columns::ChampColumn do expect_type_de_champ_values('checkbox', eq([true])) expect_type_de_champ_values('drop_down_list', eq(['val1'])) expect_type_de_champ_values('multiple_drop_down_list', eq([["val1", "val2"]])) - expect_type_de_champ_values('linked_drop_down_list', eq([nil, "categorie 1", "choix 1"])) + expect_type_de_champ_values('linked_drop_down_list', eq([nil, "primary", "secondary"])) expect_type_de_champ_values('yes_no', eq([true])) expect_type_de_champ_values('annuaire_education', eq([nil])) expect_type_de_champ_values('piece_justificative', be_an_instance_of(Array)) diff --git a/spec/models/concerns/dossier_rebase_concern_spec.rb b/spec/models/concerns/dossier_rebase_concern_spec.rb index 93a1e7c43..6564c8b22 100644 --- a/spec/models/concerns/dossier_rebase_concern_spec.rb +++ b/spec/models/concerns/dossier_rebase_concern_spec.rb @@ -438,7 +438,7 @@ describe DossierRebaseConcern do tdc_to_update.update(drop_down_options: ["option", "updated", "v1"]) end - it { expect { subject }.not_to change { dossier.project_champs_public.first.value } } + it { expect { subject }.not_to change { dossier.project_champs_public.first.to_s } } end context 'when a dropdown option is removed' do @@ -450,7 +450,7 @@ describe DossierRebaseConcern do tdc_to_update.update(drop_down_options: ["option", "updated"]) end - it { expect { subject }.to change { dossier.project_champs_public.first.value }.from('v1').to(nil) } + it { expect { subject }.to change { dossier.project_champs_public.first.to_s }.from('v1').to('') } end context 'when a dropdown unused option is removed' do @@ -462,7 +462,7 @@ describe DossierRebaseConcern do tdc_to_update.update(drop_down_options: ["v1", "updated"]) end - it { expect { subject }.not_to change { dossier.project_champs_public.first.value } } + it { expect { subject }.not_to change { dossier.project_champs_public.first.to_s } } end end @@ -484,7 +484,7 @@ describe DossierRebaseConcern do tdc_to_update.update(drop_down_options: ["option", "updated", "v1"]) end - it { expect { subject }.not_to change { dossier.project_champs_public.first.value } } + it { expect { subject }.not_to change { dossier.project_champs_public.first.to_s } } end context 'when a dropdown option is removed' do @@ -496,7 +496,7 @@ describe DossierRebaseConcern do tdc_to_update.update(drop_down_options: ["option", "updated"]) end - it { expect { subject }.to change { dossier.project_champs_public.first.value }.from('["v1","option"]').to('["option"]') } + it { expect { subject }.to change { dossier.project_champs_public.first.to_s }.from('v1, option').to('option') } end context 'when a dropdown unused option is removed' do @@ -508,7 +508,7 @@ describe DossierRebaseConcern do tdc_to_update.update(drop_down_options: ["v1", "updated"]) end - it { expect { subject }.not_to change { dossier.project_champs_public.first.value } } + it { expect { subject }.not_to change { dossier.project_champs_public.first.to_s } } end end @@ -523,38 +523,38 @@ describe DossierRebaseConcern do context 'when a dropdown option is added' do before do - dossier.project_champs_public.first.update(value: '["v1",""]') + dossier.project_champs_public.first.update(value: '["titre1",""]') stable_id = procedure.draft_revision.types_de_champ.find_by(libelle: 'l1') tdc_to_update = procedure.draft_revision.find_and_ensure_exclusive_use(stable_id) tdc_to_update.update(drop_down_options: ["--titre1--", "option", "v1", "updated", "--titre2--", "option2", "v2"]) end - it { expect { subject }.not_to change { dossier.project_champs_public.first.value } } + it { expect { subject }.not_to change { dossier.project_champs_public.first.to_s } } end context 'when a dropdown option is removed' do before do - dossier.project_champs_public.first.update(value: '["v1","option2"]') + dossier.project_champs_public.first.update(value: '["titre2","option2"]') stable_id = procedure.draft_revision.types_de_champ.find_by(libelle: 'l1') tdc_to_update = procedure.draft_revision.find_and_ensure_exclusive_use(stable_id) - tdc_to_update.update(drop_down_options: ["--titre1--", "option", "updated", "--titre2--", "option2", "v2"]) + tdc_to_update.update(drop_down_options: ["--titre1--", "option", "updated", "--titre2--", "v2"]) end - it { expect { subject }.to change { dossier.project_champs_public.first.value }.from('["v1","option2"]').to(nil) } + it { expect { subject }.to change { dossier.project_champs_public.first.to_s }.from('titre2 / option2').to('titre2') } end context 'when a dropdown unused option is removed' do before do - dossier.project_champs_public.first.update(value: '["v1",""]') + dossier.project_champs_public.first.update(value: '["titre2",""]') stable_id = procedure.draft_revision.types_de_champ.find_by(libelle: 'l1') tdc_to_update = procedure.draft_revision.find_and_ensure_exclusive_use(stable_id) tdc_to_update.update(drop_down_options: ["--titre1--", "v1", "updated", "--titre2--", "option2", "v2"]) end - it { expect { subject }.not_to change { dossier.project_champs_public.first.value } } + it { expect { subject }.not_to change { dossier.project_champs_public.first.to_s } } end end @@ -650,7 +650,7 @@ describe DossierRebaseConcern do it { expect { subject }.to change { dossier.revision.types_de_champ_public.map(&:type_champ) }.from(['text', 'text']).to(['integer_number', 'text']) } it { expect { subject }.to change { first_champ.class }.from(Champs::TextChamp).to(Champs::IntegerNumberChamp) } - it { expect { subject }.to change { first_champ.value }.from('v1').to(nil) } + it { expect { subject }.to change { first_champ.to_s }.from('v1').to('') } it { expect { subject }.to change { first_champ.external_id }.from('123').to(nil) } it { expect { subject }.to change { first_champ.data }.from({ 'a' => 1 }).to(nil) } it { expect { subject }.to change { first_champ.geo_areas.count }.from(1).to(0) } @@ -730,6 +730,7 @@ describe DossierRebaseConcern do it { expect { subject }.to change { dossier.champs.filter(&:child?).count }.from(2).to(0) } it { expect { subject }.to change { Champ.count }.from(3).to(1) } + it { expect { subject }.to change { dossier.project_champs_public.find(&:repetition?)&.libelle }.from('p1').to(nil) } end end end diff --git a/spec/models/concerns/tags_substitution_concern_spec.rb b/spec/models/concerns/tags_substitution_concern_spec.rb index 10e0368a5..89f5800fd 100644 --- a/spec/models/concerns/tags_substitution_concern_spec.rb +++ b/spec/models/concerns/tags_substitution_concern_spec.rb @@ -249,7 +249,7 @@ describe TagsSubstitutionConcern, type: :model do context 'when the procedure has a linked drop down menus type de champ' do let(:type_de_champ) { procedure.draft_revision.types_de_champ.first } - let(:types_de_champ_public) { [{ type: :linked_drop_down_list, libelle: 'libelle' }] } + let(:types_de_champ_public) { [{ type: :linked_drop_down_list, libelle: 'libelle', options: ["--primo--", "secundo"] }] } let(:template) { 'tout : --libelle--, primaire : --libelle/primaire--, secondaire : --libelle/secondaire--' } context 'and the champ has no value' do @@ -275,7 +275,7 @@ describe TagsSubstitutionConcern, type: :model do context 'and the same libelle is used by a header' do let(:types_de_champ_public) do [ - { type: :linked_drop_down_list, libelle: 'libelle' }, + { type: :linked_drop_down_list, libelle: 'libelle', options: ["--primo--", "secundo"] }, { type: :header_section, libelle: 'libelle' } ] end diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index aa97ea78e..a9714f20f 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -77,6 +77,7 @@ describe PiecesJustificativesService do expect(export_template).to receive(:attachment_path) .with(dossier, second_child_attachments.first, index: 0, row_index: 1, champ: second_champ) + DossierPreloader.new(dossiers).all count = 0 callback = lambda { |*_args| count += 1 } @@ -84,7 +85,7 @@ describe PiecesJustificativesService do subject end - expect(count).to eq(10) + expect(count).to eq(0) end end end diff --git a/spec/views/shared/champs/multiple_drop_down_list/_show.html.haml_spec.rb b/spec/views/shared/champs/multiple_drop_down_list/_show.html.haml_spec.rb index eea08033d..5203eb739 100644 --- a/spec/views/shared/champs/multiple_drop_down_list/_show.html.haml_spec.rb +++ b/spec/views/shared/champs/multiple_drop_down_list/_show.html.haml_spec.rb @@ -5,7 +5,7 @@ describe 'views/shared/champs/multiple_drop_down_list/_show', type: :view do let(:dossier) { create(:dossier, :with_populated_champs, procedure:) } let(:champ) { dossier.champs.first } - before { champ.update(value: ['abc', '2, 3, 4']) } + before { champ.update(value: champ.drop_down_options) } subject { render partial: 'shared/champs/multiple_drop_down_list/show', locals: { champ: } } it 'renders the view' do From b2c3887520d60527c70684ce75c032e9c51017f5 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 14:19:56 +0200 Subject: [PATCH 1494/1532] add exported_column with its type Co-authored-by: mfo --- app/models/exported_column.rb | 12 ++++++++++ app/types/exported_column_type.rb | 40 +++++++++++++++++++++++++++++++ config/initializers/types.rb | 2 ++ 3 files changed, 54 insertions(+) create mode 100644 app/models/exported_column.rb create mode 100644 app/types/exported_column_type.rb diff --git a/app/models/exported_column.rb b/app/models/exported_column.rb new file mode 100644 index 000000000..5f754c98f --- /dev/null +++ b/app/models/exported_column.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class ExportedColumn + attr_reader :column, :libelle + + def initialize(column:, libelle:) + @column = column + @libelle = libelle + end + + def id = { id: column.id, libelle: }.to_json +end diff --git a/app/types/exported_column_type.rb b/app/types/exported_column_type.rb new file mode 100644 index 000000000..dddd35532 --- /dev/null +++ b/app/types/exported_column_type.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class ExportedColumnType < ActiveRecord::Type::Value + # form_input or setter -> type + def cast(value) + value = value.deep_symbolize_keys if value.respond_to?(:deep_symbolize_keys) + + case value + in ExportedColumn + value + in NilClass # default value + nil + # from db + in { id: String|Hash, libelle: String } => h + ExportedColumn.new(column: ColumnType.new.cast(h[:id]), libelle: h[:libelle]) + # from form + in String + h = JSON.parse(value).deep_symbolize_keys + ExportedColumn.new(column: ColumnType.new.cast(h[:id]), libelle: h[:libelle]) + end + end + + # db -> ruby + def deserialize(value) = cast(value&.then { JSON.parse(_1) }) + + # ruby -> db + def serialize(value) + case value + in NilClass + nil + in ExportedColumn + JSON.generate({ + id: value.column.h_id, + libelle: value.libelle + }) + else + raise ArgumentError, "Invalid value for ExportedColumn serialization: #{value}" + end + end +end diff --git a/config/initializers/types.rb b/config/initializers/types.rb index 46f40f05d..ecdf990d7 100644 --- a/config/initializers/types.rb +++ b/config/initializers/types.rb @@ -4,10 +4,12 @@ require Rails.root.join("app/types/column_type") require Rails.root.join("app/types/export_item_type") require Rails.root.join("app/types/sorted_column_type") require Rails.root.join("app/types/filtered_column_type") +require Rails.root.join("app/types/exported_column_type") ActiveSupport.on_load(:active_record) do ActiveRecord::Type.register(:column, ColumnType) ActiveRecord::Type.register(:export_item, ExportItemType) ActiveRecord::Type.register(:sorted_column, SortedColumnType) ActiveRecord::Type.register(:filtered_column, FilteredColumnType) + ActiveRecord::Type.register(:exported_column, ExportedColumnType) end From 94b3655ff71a694901ff7c4830ec6db029ac53a1 Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 14:21:45 +0200 Subject: [PATCH 1495/1532] add exported_columns to export template model Co-authored-by: mfo --- app/models/export_template.rb | 2 ++ .../20241015125024_add_columns_to_export_template.rb | 7 +++++++ db/schema.rb | 1 + 3 files changed, 10 insertions(+) create mode 100644 db/migrate/20241015125024_add_columns_to_export_template.rb diff --git a/app/models/export_template.rb b/app/models/export_template.rb index 1a6f070d7..0e6e503e4 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -15,6 +15,8 @@ class ExportTemplate < ApplicationRecord attribute :export_pdf, :export_item attribute :pjs, :export_item, array: true + attribute :exported_columns, :exported_column, array: true + before_validation :ensure_pjs_are_legit validates_with ExportTemplateValidator diff --git a/db/migrate/20241015125024_add_columns_to_export_template.rb b/db/migrate/20241015125024_add_columns_to_export_template.rb new file mode 100644 index 000000000..2e9e01dc8 --- /dev/null +++ b/db/migrate/20241015125024_add_columns_to_export_template.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddColumnsToExportTemplate < ActiveRecord::Migration[7.0] + def change + add_column :export_templates, :exported_columns, :jsonb, array: true, default: [], null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index ca309c9b4..8da54cc84 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -626,6 +626,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_11_12_090128) do t.datetime "created_at", null: false t.jsonb "dossier_folder", null: false t.jsonb "export_pdf", null: false + t.jsonb "exported_columns", default: [], null: false, array: true t.bigint "groupe_instructeur_id", null: false t.string "kind", null: false t.string "name", null: false From 16b89951911d438d6cc75b9a032771fc4db0bb7e Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 15:09:18 +0200 Subject: [PATCH 1496/1532] extend procedure.all_revision_types_de_champ for header section Co-authored-by: mfo --- app/models/procedure.rb | 15 ++++++------ app/models/type_de_champ.rb | 1 + spec/models/procedure_spec.rb | 43 +++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 05c3e3346..90344edc9 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -71,10 +71,11 @@ class Procedure < ApplicationRecord brouillon? ? draft_revision : published_revision end - def all_revisions_types_de_champ(parent: nil) + def all_revisions_types_de_champ(parent: nil, with_header_section: false) + types_de_champ_scope = with_header_section ? TypeDeChamp.with_header_section : TypeDeChamp.fillable if brouillon? if parent.nil? - TypeDeChamp.fillable + types_de_champ_scope .joins(:revision_types_de_champ) .where(revision_types_de_champ: { revision_id: draft_revision_id, parent_id: nil }) .order(:private, :position) @@ -82,8 +83,8 @@ class Procedure < ApplicationRecord draft_revision.children_of(parent) end else - cache_key = ['all_revisions_types_de_champ', published_revision, parent].compact - Rails.cache.fetch(cache_key, expires_in: 1.month) { published_revisions_types_de_champ(parent) } + cache_key = ['all_revisions_types_de_champ', published_revision, parent, with_header_section].compact + Rails.cache.fetch(cache_key, expires_in: 1.month) { published_revisions_types_de_champ(parent:, with_header_section:) } end end @@ -875,7 +876,7 @@ class Procedure < ApplicationRecord @stable_ids_used_by_routing_rules ||= groupe_instructeurs.flat_map { _1.routing_rule&.sources }.compact.uniq end - def published_revisions_types_de_champ(parent = nil) + def published_revisions_types_de_champ(parent: nil, with_header_section: false) # all published revisions revision_ids = revisions.ids - [draft_revision_id] # fetch all parent types de champ @@ -889,8 +890,8 @@ class Procedure < ApplicationRecord # fetch all type_de_champ.stable_id for all the revisions expect draft # and for each stable_id take the bigger (more recent) type_de_champ.id - recent_ids = TypeDeChamp - .fillable + types_de_champ_scope = with_header_section ? TypeDeChamp.with_header_section : TypeDeChamp.fillable + recent_ids = types_de_champ_scope .joins(:revision_types_de_champ) .where(revision_types_de_champ: { revision_id: revision_ids, parent_id: parent_ids }) .group(:stable_id).select('MAX(types_de_champ.id)') diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 9a4b32d99..729f23955 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -171,6 +171,7 @@ class TypeDeChamp < ApplicationRecord scope :not_repetition, -> { where.not(type_champ: type_champs.fetch(:repetition)) } scope :not_condition, -> { where(condition: nil) } scope :fillable, -> { where.not(type_champ: [type_champs.fetch(:header_section), type_champs.fetch(:explication)]) } + scope :with_header_section, -> { where.not(type_champ: TypeDeChamp.type_champs[:explication]) } scope :dubious, -> { where("unaccent(types_de_champ.libelle) ~* unaccent(?)", DubiousProcedure.forbidden_regexp) diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index b8724a106..50db50eb1 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -1885,6 +1885,49 @@ describe Procedure do end end + describe '#all_revisions_types_de_champ' do + let(:types_de_champ_public) do + [ + { type: :text }, + { type: :header_section } + ] + end + + context 'when procedure brouillon' do + let(:procedure) { create(:procedure, types_de_champ_public:) } + + it 'returns one type de champ' do + expect(procedure.all_revisions_types_de_champ.size).to eq 1 + end + + it 'returns also section type de champ' do + expect(procedure.all_revisions_types_de_champ(with_header_section: true).size).to eq 2 + end + + it "returns types de champ on draft revision" do + procedure.draft_revision.add_type_de_champ(type_champ: :text, libelle: 'onemorechamp') + expect(procedure.reload.all_revisions_types_de_champ.size).to eq 2 + end + end + + context 'when procedure is published' do + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + + it 'returns one type de champ' do + expect(procedure.all_revisions_types_de_champ.size).to eq 1 + end + + it 'returns also section type de champ' do + expect(procedure.all_revisions_types_de_champ(with_header_section: true).size).to eq 2 + end + + it "doesn't return types de champ on draft revision" do + procedure.draft_revision.add_type_de_champ(type_champ: :text, libelle: 'onemorechamp') + expect(procedure.reload.all_revisions_types_de_champ.size).to eq 1 + end + end + end + private def create_dossier_with_pj_of_size(size, procedure) From f383e1c502287a3c704495daa539a790d568b26b Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 14:36:38 +0200 Subject: [PATCH 1497/1532] prepare export template to be used for tabular template Co-authored-by: mfo --- app/models/column.rb | 3 + app/models/columns/champ_column.rb | 2 + app/models/columns/dossier_column.rb | 2 + app/models/export_template.rb | 20 ++++- app/types/export_item_type.rb | 5 +- .../instructeurs/procedures/exports.html.haml | 84 +++++++++++-------- .../locales/views/instructeurs/header/en.yml | 2 +- .../locales/views/instructeurs/header/fr.yml | 2 +- .../instructeurs/procedures/exports/en.yml | 6 ++ .../instructeurs/procedures/exports/fr.yml | 5 ++ spec/system/instructeurs/instruction_spec.rb | 2 +- 11 files changed, 95 insertions(+), 38 deletions(-) diff --git a/app/models/column.rb b/app/models/column.rb index 22fa01a6f..5131c77c6 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -47,6 +47,9 @@ class Column procedure.find_column(h_id: h_id) end + def dossier_column? = false + def champ_column? = false + private def column_id = "#{table}/#{column}" diff --git a/app/models/columns/champ_column.rb b/app/models/columns/champ_column.rb index c032b3a35..3cdb05a92 100644 --- a/app/models/columns/champ_column.rb +++ b/app/models/columns/champ_column.rb @@ -45,6 +45,8 @@ class Columns::ChampColumn < Column end end + def champ_column? = true + private def column_id = "type_de_champ/#{stable_id}" diff --git a/app/models/columns/dossier_column.rb b/app/models/columns/dossier_column.rb index 72ec97405..82730b2a9 100644 --- a/app/models/columns/dossier_column.rb +++ b/app/models/columns/dossier_column.rb @@ -15,4 +15,6 @@ class Columns::DossierColumn < Column dossier.followers_instructeurs.map(&:email).join(' ') end end + + def dossier_column? = true end diff --git a/app/models/export_template.rb b/app/models/export_template.rb index 0e6e503e4..0328e16b4 100644 --- a/app/models/export_template.rb +++ b/app/models/export_template.rb @@ -9,7 +9,7 @@ class ExportTemplate < ApplicationRecord has_one :procedure, through: :groupe_instructeur has_many :exports, dependent: :nullify - enum kind: { zip: "zip" }, _prefix: :template + enum kind: { zip: 'zip', csv: 'csv', xlsx: 'xlsx', ods: 'ods' }, _prefix: :template attribute :dossier_folder, :export_item attribute :export_pdf, :export_item @@ -30,6 +30,7 @@ class ExportTemplate < ApplicationRecord end def self.default(name: nil, kind: 'zip', groupe_instructeur:) + # TODO: remove default values for tabular export dossier_folder = ExportItem.default(prefix: 'dossier') export_pdf = ExportItem.default(prefix: 'export') pjs = groupe_instructeur.procedure.exportables_pieces_jointes.map { |tdc| ExportItem.default_pj(tdc) } @@ -37,6 +38,10 @@ class ExportTemplate < ApplicationRecord new(name:, kind:, groupe_instructeur:, dossier_folder:, export_pdf:, pjs:) end + def tabular? + kind != 'zip' + end + def tags tags_categorized.slice(:individual, :etablissement, :dossier).values.flatten end @@ -60,6 +65,19 @@ class ExportTemplate < ApplicationRecord File.join(dossier_folder.path(dossier), file_path) if file_path.present? end + def dossier_exported_columns = exported_columns.filter { _1.column.dossier_column? } + + def columns_for_stable_id(stable_id) + exported_columns + .filter { _1.column.champ_column? } + .filter { _1.column.stable_id == stable_id } + end + + def in_export?(exported_column) + @template_exported_columns ||= exported_columns.map(&:column) + @template_exported_columns.include?(exported_column.column) + end + private def ensure_pjs_are_legit diff --git a/app/types/export_item_type.rb b/app/types/export_item_type.rb index e2d1f7014..04c37c6ef 100644 --- a/app/types/export_item_type.rb +++ b/app/types/export_item_type.rb @@ -29,7 +29,10 @@ class ExportItemType < ActiveRecord::Type::Value # ruby -> db def serialize(value) - if value.is_a?(ExportItem) + case value + in NilClass + nil + in ExportItem JSON.generate({ template: value.template, enabled: value.enabled, diff --git a/app/views/instructeurs/procedures/exports.html.haml b/app/views/instructeurs/procedures/exports.html.haml index e63eb4472..3e92cd894 100644 --- a/app/views/instructeurs/procedures/exports.html.haml +++ b/app/views/instructeurs/procedures/exports.html.haml @@ -6,41 +6,59 @@ [t('.title')]] } .fr-container - %h1= t('.title') - = render Dsfr::CalloutComponent.new(title: nil) do |c| - - c.with_body do - %p= t('.export_description', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i) + .fr-tabs.mb-3 + %ul.fr-tabs__list{ role: 'tablist' } + %li{ role: 'presentation' } + %button.fr-tabs__tab.fr-tabs__tab--icon-left{ id: "tabpanel-exports", tabindex: "0", role: "tab", "aria-selected": "true", "aria-controls": "tabpanel-exports-panel" } Liste des exports + %li{ role: 'presentation' } + %button.fr-tabs__tab.fr-tabs__tab--icon-left{ id: "tabpanel-export-templates", tabindex: "-1", role: "tab", "aria-selected": "false", "aria-controls": "tabpanel-export-templates-panel" } Modèles d'export - - if @exports.present? - %div{ data: @exports.any?(&:pending?) ? { controller: "turbo-poll", turbo_poll_url_value: "", turbo_poll_interval_value: 10_000, turbo_poll_max_checks_value: 6 } : {} } - = render Dossiers::ExportLinkComponent.new(procedure: @procedure, exports: @exports, statut: @statut, count: @dossiers_count, class_btn: 'fr-btn--tertiary', export_url: method(:download_export_instructeur_procedure_path)) - - - if @exports.any?{_1.format == Export.formats.fetch(:zip)} - = render Dsfr::AlertComponent.new(title: t('.title_zip'), state: :info, extra_class_names: 'fr-mb-3w') do |c| + .fr-tabs__panel.fr-tabs__panel--selected{ id: "tabpanel-exports-panel", role: "tabpanel", "aria-labelledby": "tabpanel-exports", tabindex: "0" } + = render Dsfr::CalloutComponent.new(title: nil) do |c| - c.with_body do - %p= t('.export_description_zip_html') + %p= t('.export_description', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i) - - else - = t('.no_export_html', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i ) + - if @exports.present? + %div{ data: @exports.any?(&:pending?) ? { controller: "turbo-poll", turbo_poll_url_value: "", turbo_poll_interval_value: 10_000, turbo_poll_max_checks_value: 6 } : {} } + = render Dossiers::ExportLinkComponent.new(procedure: @procedure, exports: @exports, statut: @statut, count: @dossiers_count, class_btn: 'fr-btn--tertiary', export_url: method(:download_export_instructeur_procedure_path)) - - if @procedure.feature_enabled?(:export_template) - %h2.fr-mb-1w.fr-mt-8w - Liste des modèles d'export - %p.fr-hint-text - Un modèle d'export permet de personnaliser le nom des fichiers (pour un export au format Zip) - - if @export_templates.any? - .fr-table.fr-table--no-caption.fr-mt-5w - %table - %thead - %tr - %th{ scope: 'col' } Nom du modèle - %th{ scope: 'col' }= "Groupe instructeur" if @procedure.groupe_instructeurs.many? - %tbody - - @export_templates.each do |export_template| - %tr - %td= link_to export_template.name, [:edit, :instructeur, @procedure, export_template] - %td= export_template.groupe_instructeur.label if @procedure.groupe_instructeurs.many? + - if @exports.any?{_1.format == Export.formats.fetch(:zip)} + = render Dsfr::AlertComponent.new(title: t('.title_zip'), state: :info, extra_class_names: 'fr-mb-3w') do |c| + - c.with_body do + %p= t('.export_description_zip_html') - %p - = link_to [:new, :instructeur, @procedure, :export_template], class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line' do - Ajouter un modèle d'export + - else + = t('.no_export_html', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i ) + + .fr-tabs__panel.fr-tabs__panel{ id: "tabpanel-export-templates-panel", role: "tabpanel", "aria-labelledby": "tabpanel-export-templates", tabindex: "0" } + = render Dsfr::AlertComponent.new(state: :info) do |c| + - c.with_body do + %p= t('.export_template_list_description_html') + + + .fr-mt-5w + = link_to t('.new_zip_export_template'), new_instructeur_procedure_export_template_path(@procedure), class: "fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line fr-mr-1w" + = link_to t('.new_tabular_export_template'), new_instructeur_procedure_export_template_path(@procedure, kind: 'tabular'), class: "fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line" + + .fr-table.fr-table--bordered.fr-table--no-caption.fr-mt-5w + .fr-table__wrapper + .fr-table__container + .fr-table__content + %table + %thead + %tr + = tag.th "Nom du modèle", scope: 'col' + = tag.th "Format", scope: 'col' + = tag.th "Date de création", scope: 'col' + = tag.th "Partagé avec (groupe instructeurs)", scope: 'col' if @procedure.groupe_instructeurs.many? + = tag.th "Actions", scope: 'col' + %tbody + - @export_templates.each do |export_template| + %tr + %td= link_to export_template.name, [:edit, :instructeur, @procedure, export_template] + %td= pretty_kind(export_template.kind) + %td= l(export_template.created_at) + = tag.td export_template.groupe_instructeur.label if @procedure.groupe_instructeurs.many? + %td + = link_to "Modifier", [:edit, :instructeur, @procedure, export_template], class: "fr-btn fr-btn--icon-left fr-icon-edit-line fr-mr-1w" + = link_to "Supprimer", [:instructeur, @procedure, export_template], method: :delete, data: { confirm: "Voulez-vous vraiment supprimer ce modèle ? Il sera supprimé pour tous les instructeurs du groupe"}, class: "fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-delete-line" diff --git a/config/locales/views/instructeurs/header/en.yml b/config/locales/views/instructeurs/header/en.yml index dbec9594d..ad343adb4 100644 --- a/config/locales/views/instructeurs/header/en.yml +++ b/config/locales/views/instructeurs/header/en.yml @@ -12,7 +12,7 @@ en: button_delay_expiration: "Keep for one more month" notification_management: notification management administrators_list: administrators list - exports_list: exports list + exports_list: exports and export templates exports_notification_label: A new export is ready to download statistics: statistics instructeurs: instructors diff --git a/config/locales/views/instructeurs/header/fr.yml b/config/locales/views/instructeurs/header/fr.yml index c13a4ded0..46b2d77c1 100644 --- a/config/locales/views/instructeurs/header/fr.yml +++ b/config/locales/views/instructeurs/header/fr.yml @@ -13,7 +13,7 @@ fr: button_delay_expiration: "Conserver un mois de plus" notification_management: Gestion des notifications administrators_list: Voir les administrateurs - exports_list: Voir les exports + exports_list: Voir les exports et modèles d'export exports_notification_label: Un nouvel export est prêt à être téléchargé statistics: Statistiques instructeurs: instructeurs diff --git a/config/locales/views/instructeurs/procedures/exports/en.yml b/config/locales/views/instructeurs/procedures/exports/en.yml index 81b0dcf8e..f1f7bc753 100644 --- a/config/locales/views/instructeurs/procedures/exports/en.yml +++ b/config/locales/views/instructeurs/procedures/exports/en.yml @@ -18,3 +18,9 @@ en: no_export_html: You have no export at the moment.
    Can't find an export? It may have expired, exports are deleted after %{expiration_time} hours. + + export_template_list_description_html: | + Each instructor can configure an export template to customize exports (attachments name for a zip export, columns selection for a tabular export). It will be made available to all instructors assigned to the procedure.
    + Find out more about export template configuration + new_zip_export_template: Create zip export template + new_tabular_export_template: Create tabular export template diff --git a/config/locales/views/instructeurs/procedures/exports/fr.yml b/config/locales/views/instructeurs/procedures/exports/fr.yml index 030fa8345..fa0945652 100644 --- a/config/locales/views/instructeurs/procedures/exports/fr.yml +++ b/config/locales/views/instructeurs/procedures/exports/fr.yml @@ -17,3 +17,8 @@ fr: Vous n'arrivez pas à extraire un export au format .zip sur un réseau d'entreprise ? Essayer de renommer l'archive avec un nom plus court et ré-essayer de l'extraire. no_export_html: Vous n'avez pas d'export pour le moment.
    Vous ne trouvez pas un export ? Il a peut-être expiré, les exports sont supprimés au bout de %{expiration_time} heures. + export_template_list_description_html: | + Chaque instructeur a la possibilité de configurer un modèle d'export pour personnaliser les exports (nom des pièces jointes pour un export au format zip, sélection des colonnes pour un export tabulaire). Il sera mis à disposition de l'ensemble des instructeurs affectés à la démarche
    + En savoir plus sur la configuration des modèles d'export + new_zip_export_template: Créer un modèle d'export zip + new_tabular_export_template: Créer un modèle d'export tabulaire diff --git a/spec/system/instructeurs/instruction_spec.rb b/spec/system/instructeurs/instruction_spec.rb index 098878767..2473c352e 100644 --- a/spec/system/instructeurs/instruction_spec.rb +++ b/spec/system/instructeurs/instruction_spec.rb @@ -138,7 +138,7 @@ describe 'Instructing a dossier:', js: true do expect(page).to have_text('Nous générons cet export.') - click_on "Voir les exports" + click_on "Voir les exports et modèles d'export" expect(page).to have_text("Export .csv d’un dossier « à suivre » demandé il y a moins d'une minute") expect(page).to have_text("En préparation") From ffd1a15d91af6a3106ffcb9209000dd486049f9a Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 14:50:51 +0200 Subject: [PATCH 1498/1532] add, edit and destroy export template with exported_columns Co-authored-by: mfo Co-authored-by: LeSim --- app/assets/stylesheets/forms.scss | 19 +++++ .../export_template/champs_component.rb | 45 ++++++++++ .../champs_component.html.haml | 29 +++++++ .../export_templates_controller.rb | 27 +++++- app/helpers/export_template_helper.rb | 9 ++ .../checkbox_select_all_controller.ts | 71 ++++++++++++++++ .../repetition_type_de_champ.rb | 4 +- .../administrateurs/archives/index.html.haml | 7 +- .../_checkbox_group.html.haml | 16 ++++ .../export_templates/_form_tabular.html.haml | 57 +++++++++++++ .../export_templates/edit.html.haml | 5 +- .../export_templates/new.html.haml | 5 +- config/locales/models/export_templates/en.yml | 9 ++ config/locales/models/export_templates/fr.yml | 10 +++ .../export_template/champs_component_spec.rb | 26 ++++++ .../export_templates_controller_spec.rb | 65 ++++++++++++++ spec/models/export_template_tabular_spec.rb | 85 +++++++++++++++++++ .../procedure_export_tabular_spec.rb | 58 +++++++++++++ .../routing/rules_full_scenario_spec.rb | 2 +- 19 files changed, 539 insertions(+), 10 deletions(-) create mode 100644 app/components/export_template/champs_component.rb create mode 100644 app/components/export_template/champs_component/champs_component.html.haml create mode 100644 app/helpers/export_template_helper.rb create mode 100644 app/javascript/controllers/checkbox_select_all_controller.ts create mode 100644 app/views/instructeurs/export_templates/_checkbox_group.html.haml create mode 100644 app/views/instructeurs/export_templates/_form_tabular.html.haml create mode 100644 spec/components/export_template/champs_component_spec.rb create mode 100644 spec/models/export_template_tabular_spec.rb create mode 100644 spec/system/instructeurs/procedure_export_tabular_spec.rb diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index ce83a13bb..6073283bc 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -551,3 +551,22 @@ textarea::placeholder { .resize-y { resize: vertical; } + +.checkbox-group-bordered { + border: 1px solid var(--border-default-grey); + flex: 1 1 100%; // copied from fr-fieldset-element + max-width: 100%; // copied from fr-fieldset-element +} + +.fieldset-bordered { + position: relative; +} + +.fieldset-bordered::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + border-left: 2px solid var(--border-default-blue-france); +} diff --git a/app/components/export_template/champs_component.rb b/app/components/export_template/champs_component.rb new file mode 100644 index 000000000..e12b2d5d9 --- /dev/null +++ b/app/components/export_template/champs_component.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class ExportTemplate::ChampsComponent < ApplicationComponent + attr_reader :export_template, :title + + def initialize(title, export_template, types_de_champ) + @title = title + @export_template = export_template + @types_de_champ = types_de_champ + end + + def historical_libelle(column) + historical_exported_column = export_template.exported_columns.find { _1.column == column } + if historical_exported_column + historical_exported_column.libelle + else + column.label + end + end + + def sections + @types_de_champ + .reject { _1.header_section? && _1.header_section_level_value > 1 } + .slice_before(&:header_section?) + .filter_map do |(head, *rest)| + libelle = head.libelle if head.header_section? + columns = [head.header_section? ? nil : head, *rest].compact.map { tdc_to_columns(_1) } + { libelle:, columns: } if columns.present? + end + end + + def component_prefix + title.parameterize + end + + private + + def tdc_to_columns(type_de_champ) + prefix = type_de_champ.repetition? ? "Bloc répétable" : nil + type_de_champ.columns(procedure: export_template.procedure, prefix:).map do |column| + ExportedColumn.new(column:, + libelle: historical_libelle(column)) + end + end +end diff --git a/app/components/export_template/champs_component/champs_component.html.haml b/app/components/export_template/champs_component/champs_component.html.haml new file mode 100644 index 000000000..4ccbe41de --- /dev/null +++ b/app/components/export_template/champs_component/champs_component.html.haml @@ -0,0 +1,29 @@ +%fieldset.fr-fieldset{ id: "#{component_prefix}-fieldset", data: { controller: 'checkbox-select-all' } } + %legend.fr-fieldset__legend--regular.fr-fieldset__legend + = title + .checkbox-group-bordered.fr-mx-1w.fr-mb-2w + .fr-fieldset__element.fr-background-contrast--grey.fr-py-2w.fr-px-4w + .fr-checkbox-group + = check_box_tag "#{component_prefix}-select-all", "select-all", false, data: { "checkbox-select-all-target": 'checkboxAll' } + = label_tag "#{component_prefix}-select-all", "Tout sélectionner" + - sections.each.with_index do |section, idx| + - if section[:libelle] + .fr-fieldset__element.fr-text--bold.fr-px-4w{ class: idx > 0 ? "fr-pt-1w" : "" }= section[:libelle] + + - section[:columns].each do |grouped_columns| + - if grouped_columns.many? + .fr-fieldset__element + .fieldset-bordered.fr-ml-3v + - grouped_columns.each do |exported_column| + .fr-fieldset__element.fr-px-3v + .fr-checkbox-group + - id = sanitize_to_id(field_id('export_template', 'exported_columns', exported_column.id)) + = check_box_tag field_name('export_template', 'exported_columns', ''), exported_column.id, export_template.exported_columns.map(&:column).include?(exported_column.column), class: 'fr-checkbox', id: id, data: { "checkbox-select-all-target": 'checkbox' } + = label_tag id, historical_libelle(exported_column.column) + - else + - grouped_columns.each do |exported_column| + .fr-fieldset__element.fr-px-4w + .fr-checkbox-group + - id = sanitize_to_id(field_id('export_template', 'exported_columns', exported_column.id)) + = check_box_tag field_name('export_template', 'exported_columns', ''), exported_column.id, export_template.exported_columns.map(&:column).include?(exported_column.column), class: 'fr-checkbox', id: id, data: { "checkbox-select-all-target": 'checkbox' } + = label_tag id, historical_libelle(exported_column.column) diff --git a/app/controllers/instructeurs/export_templates_controller.rb b/app/controllers/instructeurs/export_templates_controller.rb index 49d2e3db8..453673b42 100644 --- a/app/controllers/instructeurs/export_templates_controller.rb +++ b/app/controllers/instructeurs/export_templates_controller.rb @@ -5,9 +5,10 @@ module Instructeurs before_action :set_procedure_and_groupe_instructeurs before_action :set_export_template, only: [:edit, :update, :destroy] before_action :ensure_legitimate_groupe_instructeur, only: [:create, :update] + before_action :set_types_de_champ, only: [:new, :edit] def new - @export_template = ExportTemplate.default(groupe_instructeur: @groupe_instructeurs.first) + @export_template = export_template end def create @@ -49,9 +50,29 @@ module Instructeurs private + def export_template = @export_template ||= ExportTemplate.default(groupe_instructeur: @groupe_instructeurs.first, kind:) + + def kind = params[:kind] == 'zip' ? 'zip' : 'xlsx' + + def set_types_de_champ + if export_template.tabular? + @types_de_champ_public = @procedure.all_revisions_types_de_champ(parent: nil, with_header_section: true).public_only + @types_de_champ_private = @procedure.all_revisions_types_de_champ(parent: nil, with_header_section: true).private_only + end + end + def export_template_params - params.require(:export_template) - .permit(:name, :kind, :groupe_instructeur_id, dossier_folder: [:enabled, :template], export_pdf: [:enabled, :template], pjs: [:stable_id, :enabled, :template]) + params + .require(:export_template) + .permit( + :name, + :kind, + :groupe_instructeur_id, + dossier_folder: [:enabled, :template], + export_pdf: [:enabled, :template], + pjs: [:stable_id, :enabled, :template], + exported_columns: [] + ) end def set_procedure_and_groupe_instructeurs diff --git a/app/helpers/export_template_helper.rb b/app/helpers/export_template_helper.rb new file mode 100644 index 000000000..518de7521 --- /dev/null +++ b/app/helpers/export_template_helper.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ExportTemplateHelper + def pretty_kind(kind) + icon = kind == 'zip' ? 'archive' : 'table' + pretty = tag.span nil, class: "fr-icon-#{icon}-line fr-mr-1v" + pretty + kind.upcase + end +end diff --git a/app/javascript/controllers/checkbox_select_all_controller.ts b/app/javascript/controllers/checkbox_select_all_controller.ts new file mode 100644 index 000000000..86fbe44d4 --- /dev/null +++ b/app/javascript/controllers/checkbox_select_all_controller.ts @@ -0,0 +1,71 @@ +import { ApplicationController } from './application_controller'; + +export class CheckboxSelectAll extends ApplicationController { + declare readonly hasCheckboxAllTarget: boolean; + declare readonly checkboxTargets: HTMLInputElement[]; + declare readonly checkboxAllTarget: HTMLInputElement; + + static targets: string[] = ['checkboxAll', 'checkbox']; + + initialize() { + this.toggle = this.toggle.bind(this); + this.refresh = this.refresh.bind(this); + } + + checkboxAllTargetConnected(checkbox: HTMLInputElement): void { + checkbox.addEventListener('change', this.toggle); + + this.refresh(); + } + + checkboxTargetConnected(checkbox: HTMLInputElement): void { + checkbox.addEventListener('change', this.refresh); + + this.refresh(); + } + + checkboxAllTargetDisconnected(checkbox: HTMLInputElement): void { + checkbox.removeEventListener('change', this.toggle); + + this.refresh(); + } + + checkboxTargetDisconnected(checkbox: HTMLInputElement): void { + checkbox.removeEventListener('change', this.refresh); + + this.refresh(); + } + + toggle(e: Event): void { + e.preventDefault(); + + this.checkboxTargets.forEach((checkbox) => { + // @ts-expect-error faut savoir hein + checkbox.checked = e.target.checked; + this.triggerInputEvent(checkbox); + }); + } + + refresh(): void { + const checkboxesCount = this.checkboxTargets.length; + const checkboxesCheckedCount = this.checked.length; + + this.checkboxAllTarget.checked = checkboxesCheckedCount > 0; + this.checkboxAllTarget.indeterminate = + checkboxesCheckedCount > 0 && checkboxesCheckedCount < checkboxesCount; + } + + triggerInputEvent(checkbox: HTMLInputElement): void { + const event = new Event('input', { bubbles: false, cancelable: true }); + + checkbox.dispatchEvent(event); + } + + get checked(): HTMLInputElement[] { + return this.checkboxTargets.filter((checkbox) => checkbox.checked); + } + + get unchecked(): HTMLInputElement[] { + return this.checkboxTargets.filter((checkbox) => !checkbox.checked); + } +} diff --git a/app/models/types_de_champ/repetition_type_de_champ.rb b/app/models/types_de_champ/repetition_type_de_champ.rb index 043294933..7470162fd 100644 --- a/app/models/types_de_champ/repetition_type_de_champ.rb +++ b/app/models/types_de_champ/repetition_type_de_champ.rb @@ -26,9 +26,11 @@ class TypesDeChamp::RepetitionTypeDeChamp < TypesDeChamp::TypeDeChampBase end def columns(procedure:, displayable: nil, prefix: nil) + prefix = prefix.present? ? "(#{prefix} #{libelle})" : libelle + procedure .all_revisions_types_de_champ(parent: @type_de_champ) - .flat_map { _1.columns(procedure:, displayable: false, prefix: libelle) } + .flat_map { _1.columns(procedure:, displayable: false, prefix:) } end def champ_blank?(champ) = champ.dossier.repetition_row_ids(@type_de_champ).blank? diff --git a/app/views/administrateurs/archives/index.html.haml b/app/views/administrateurs/archives/index.html.haml index b50059d6c..86cc6465e 100644 --- a/app/views/administrateurs/archives/index.html.haml +++ b/app/views/administrateurs/archives/index.html.haml @@ -4,11 +4,12 @@ ['Export et Archives']] } -.container - %h1.mb-2 +.container.flex + %h1.mb-2.mr-2 Archives -# index not renderable as administrateur flagged as manager, so render it anyway - = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_admin_procedure_exports_path)) + = render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_admin_procedure_exports_path), show_export_template_tab: false) +.container = render Dossiers::ExportLinkComponent.new(procedure: @procedure, exports: @exports, export_url: method(:download_admin_procedure_exports_path)) = render partial: "shared/archives/notice" diff --git a/app/views/instructeurs/export_templates/_checkbox_group.html.haml b/app/views/instructeurs/export_templates/_checkbox_group.html.haml new file mode 100644 index 000000000..f6cfd0c23 --- /dev/null +++ b/app/views/instructeurs/export_templates/_checkbox_group.html.haml @@ -0,0 +1,16 @@ +%fieldset.fr-fieldset{ id: "#{title.parameterize}-fieldset", data: { controller: 'checkbox-select-all' } } + %legend.fr-fieldset__legend--regular.fr-fieldset__legend + = title + + .checkbox-group-bordered.fr-mx-1w.fr-mb-2w + .fr-fieldset__element.fr-background-contrast--grey.fr-py-2w.fr-px-4w + .fr-checkbox-group + = check_box_tag "#{title.parameterize}-select-all", "select-all", false, data: { "checkbox-select-all-target": 'checkboxAll' } + = label_tag "#{title.parameterize}-select-all", "Tout sélectionner" + + - all_columns.each do |column| + .fr-fieldset__element.fr-px-4w + .fr-checkbox-group + - id = sanitize_to_id(field_id('export_template', 'exported_columns', { id: column.id, libelle: column.label, parent: nil }.to_json)) + = check_box_tag field_name('export_template', 'exported_columns', ''), { id: column.id, libelle: column.label, parent: nil }.to_json, checked_columns.map(&:column).include?(column), class: 'fr-checkbox', id: id, data: { "checkbox-select-all-target": 'checkbox' } + = label_tag id, column.label diff --git a/app/views/instructeurs/export_templates/_form_tabular.html.haml b/app/views/instructeurs/export_templates/_form_tabular.html.haml new file mode 100644 index 000000000..0da716e9e --- /dev/null +++ b/app/views/instructeurs/export_templates/_form_tabular.html.haml @@ -0,0 +1,57 @@ +#export_template-edit.fr-my-4w + .fr-mb-6w + = render Dsfr::AlertComponent.new(state: :info, title: "Nouvel éditeur de modèle d'export", heading_level: 'h3') do |c| + - c.with_body do + = t('.info_html', mailto: mail_to(CONTACT_EMAIL, subject: 'Editeur de modèle d\'export')) + +.fr-grid-row.fr-grid-row--gutters + .fr-col-12.fr-col-md-8 + = form_with model: [:instructeur, @procedure, export_template], local: true do |f| + + %h2 Paramètres de l'export + = f.hidden_field "[dossier_folder][template]", value: export_template.dossier_folder.template_json + = f.hidden_field "[export_pdf][template]", value: export_template.export_pdf.template_json + + = render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field) + + - if groupe_instructeurs.many? + .fr-input-group + = f.label :groupe_instructeur_id, class: 'fr-label' do + = f.object.class.human_attribute_name(:groupe_instructeur_id) + = render EditableChamp::AsteriskMandatoryComponent.new + %span.fr-hint-text + Avec quel groupe instructeur souhaitez-vous partager ce modèle d'export ? + = f.collection_select :groupe_instructeur_id, groupe_instructeurs, :id, :label, {}, class: 'fr-select' + - else + = f.hidden_field :groupe_instructeur_id + + %fieldset.fr-fieldset.fr-fieldset--inline + %legend#radio-inline-legend.fr-fieldset__legend.fr-text--regular + Format export + .fr-fieldset__element.fr-fieldset__element--inline + .fr-radio-group + = f.radio_button :kind, "ods", id: "ods" + %label.fr-label{ for: "ods" } ods + .fr-radio-group + = f.radio_button :kind, "xlsx", id: "xlsx" + %label.fr-label{ for: "xlsx" } xlsx + .fr-radio-group + = f.radio_button :kind, "csv", id: "csv" + %label.fr-label{ for: "csv" } csv + + %h2 Contenu de l'export + = render partial: 'checkbox_group', locals: { title: 'Colonnes Usager', all_columns: @export_template.procedure.usager_columns_for_export, checked_columns: @export_template.exported_columns } + = render partial: 'checkbox_group', locals: { title: 'Colonnes Infos dossier', all_columns: @export_template.procedure.dossier_columns_for_export, checked_columns: @export_template.exported_columns } + = render ExportTemplate::ChampsComponent.new("Informations formulaire", @export_template, @types_de_champ_public) + = render ExportTemplate::ChampsComponent.new("Informations annotations", @export_template, @types_de_champ_private) if @types_de_champ_private.any? + + .fixed-footer + .fr-container + %ul.fr-btns-group.fr-btns-group--inline-md + %li + = f.submit "Enregistrer", class: "fr-btn" + %li + = link_to "Annuler", instructeur_procedure_path(@procedure), class: "fr-btn fr-btn--secondary" + - if @export_template.persisted? + %li + = link_to "Supprimer", [:instructeur, @procedure, @export_template], method: :delete, data: { confirm: "Voulez-vous vraiment supprimer ce modèle ? Il sera supprimé pour tous les instructeurs du groupe"}, class: "fr-btn fr-btn--secondary" diff --git a/app/views/instructeurs/export_templates/edit.html.haml b/app/views/instructeurs/export_templates/edit.html.haml index ab4c5f8c7..32a80d8d6 100644 --- a/app/views/instructeurs/export_templates/edit.html.haml +++ b/app/views/instructeurs/export_templates/edit.html.haml @@ -4,4 +4,7 @@ .fr-container %h1 Mise à jour modèle d'export - = render partial: 'form', locals: { export_template: @export_template, groupe_instructeurs: @groupe_instructeurs } + - if @export_template.tabular? + = render partial: 'form_tabular', locals: { export_template: @export_template, groupe_instructeurs: @groupe_instructeurs } + - else + = render partial: 'form', locals: { export_template: @export_template, groupe_instructeurs: @groupe_instructeurs } diff --git a/app/views/instructeurs/export_templates/new.html.haml b/app/views/instructeurs/export_templates/new.html.haml index 10962b1cd..358bab190 100644 --- a/app/views/instructeurs/export_templates/new.html.haml +++ b/app/views/instructeurs/export_templates/new.html.haml @@ -3,4 +3,7 @@ [t('.title')]] } .fr-container %h1 Nouveau modèle d'export - = render partial: 'form', locals: { export_template: @export_template, groupe_instructeurs: @groupe_instructeurs } + - if @export_template.tabular? + = render partial: 'form_tabular', locals: { export_template: @export_template, groupe_instructeurs: @groupe_instructeurs } + - else + = render partial: 'form', locals: { export_template: @export_template, groupe_instructeurs: @groupe_instructeurs } diff --git a/config/locales/models/export_templates/en.yml b/config/locales/models/export_templates/en.yml index 1952e0bc0..bcc3ba867 100644 --- a/config/locales/models/export_templates/en.yml +++ b/config/locales/models/export_templates/en.yml @@ -17,3 +17,12 @@ en: dossier_number_required: "must contain dossier's number" different_templates: "Files must have different names" invalid_template: "A file name is invalid" + base: + invalid: "is invalid" + instructeurs: + export_templates: + form_tabular: + info_html: | + This page allows you to edit a tabular export template and select fields that you want to export. + Try it and let us know what you think by sending an e-mail to %{mailto}. + warning: If you modify this template, it will also be modified for all instructors who have access to this template. diff --git a/config/locales/models/export_templates/fr.yml b/config/locales/models/export_templates/fr.yml index 9d152bbc5..f2100fe33 100644 --- a/config/locales/models/export_templates/fr.yml +++ b/config/locales/models/export_templates/fr.yml @@ -17,3 +17,13 @@ fr: dossier_number_required: doit contenir le numéro du dossier different_templates: Les fichiers doivent avoir des noms différents invalid_template: Un nom de fichier est invalide + base: + invalid: "est invalide" + instructeurs: + export_templates: + form_tabular: + info_html: | + Cette page permet d'éditer un modèle d'export tabulaire et ainsi sélectionner les champs que vous souhaitez exporter. + Essayez-le et donnez-nous votre avis + en nous envoyant un email à %{mailto}. + warning: Si vous modifiez ce modèle, il sera également modifié pour tous les instructeurs qui ont accès à ce modèle. diff --git a/spec/components/export_template/champs_component_spec.rb b/spec/components/export_template/champs_component_spec.rb new file mode 100644 index 000000000..23baff354 --- /dev/null +++ b/spec/components/export_template/champs_component_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +describe ExportTemplate::ChampsComponent, type: :component do + let(:groupe_instructeur) { create(:groupe_instructeur, procedure:) } + let(:export_template) { build(:export_template, kind: 'csv', groupe_instructeur:) } + let(:procedure) { create(:procedure_with_dossiers, :published, types_de_champ_public:, for_individual:) } + let(:for_individual) { true } + let(:types_de_champ_public) do + [ + { type: :text, libelle: "Ca va ?", mandatory: true, stable_id: 1 }, + { type: :communes, libelle: "Commune", mandatory: true, stable_id: 17 }, + { type: :siret, libelle: 'Siret', stable_id: 20 }, + { type: :repetition, mandatory: true, stable_id: 7, libelle: "Amis", children: [{ type: 'text', libelle: 'Prénom', stable_id: 8 }] } + ] + end + let(:component) { described_class.new("Champs publics", export_template, procedure.all_revisions_types_de_champ(parent: nil, with_header_section: true)) } + before { render_inline(component).to_html } + + it 'renders champs within fieldset' do + procedure + expect(page).to have_unchecked_field "Ca va ?" + expect(page).to have_unchecked_field "Commune" + expect(page).to have_unchecked_field "Siret" + expect(page).to have_unchecked_field "(Bloc répétable Amis) – Prénom" + end +end diff --git a/spec/controllers/instructeurs/export_templates_controller_spec.rb b/spec/controllers/instructeurs/export_templates_controller_spec.rb index 4576090fc..32fba6ba8 100644 --- a/spec/controllers/instructeurs/export_templates_controller_spec.rb +++ b/spec/controllers/instructeurs/export_templates_controller_spec.rb @@ -86,6 +86,42 @@ describe Instructeurs::ExportTemplatesController, type: :controller do expect(ExportTemplate.last.pjs).to match_array([]) end end + + context 'with tabular params' do + let(:procedure) do + create( + :procedure, instructeurs: [instructeur], + types_de_champ_public: [{ type: :text, libelle: 'un texte', stable_id: 1 }] + ) + end + + let(:exported_columns) do + [ + { id: procedure.find_column(label: 'Demandeur').id, libelle: 'Demandeur' }, + { id: procedure.find_column(label: 'Date du dernier évènement').id, libelle: 'Date du dernier évènement' } + ].map(&:to_json) + end + + let(:create_params) do + { + name: "ExportODS", + kind: "ods", + groupe_instructeur_id: groupe_instructeur.id, + export_pdf: item_params(text: "export"), + dossier_folder: item_params(text: "dossier"), + exported_columns: + } + end + + context 'with valid params' do + it 'redirect to some page' do + subject + expect(response).to redirect_to(exports_instructeur_procedure_path(procedure)) + expect(flash.notice).to eq "Le modèle d'export ExportODS a bien été créé" + expect(ExportTemplate.last.exported_columns.map(&:libelle)).to match_array ['Demandeur', 'Date du dernier évènement'] + end + end + end end describe '#edit' do @@ -146,6 +182,35 @@ describe Instructeurs::ExportTemplatesController, type: :controller do expect(flash.alert).to be_present end end + + context 'for tabular' do + let(:exported_columns) do + [ + { id: procedure.find_column(label: 'Demandeur').id, libelle: 'Demandeur' }, + { id: procedure.find_column(label: 'Date du dernier évènement').id, libelle: 'Date du dernier évènement' } + ].map(&:to_json) + end + + let(:export_template_params) do + { + name: "ExportODS", + kind: "ods", + groupe_instructeur_id: groupe_instructeur.id, + export_pdf: item_params(text: "export"), + dossier_folder: item_params(text: "dossier"), + exported_columns: + } + end + + context 'with valid params' do + it 'redirect to some page' do + subject + expect(response).to redirect_to(exports_instructeur_procedure_path(procedure)) + expect(flash.notice).to eq "Le modèle d'export ExportODS a bien été modifié" + expect(ExportTemplate.last.exported_columns.map(&:libelle)).to match_array ['Demandeur', 'Date du dernier évènement'] + end + end + end end describe '#destroy' do diff --git a/spec/models/export_template_tabular_spec.rb b/spec/models/export_template_tabular_spec.rb new file mode 100644 index 000000000..56518ae1e --- /dev/null +++ b/spec/models/export_template_tabular_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +describe ExportTemplate do + let(:groupe_instructeur) { create(:groupe_instructeur, procedure:) } + let(:export_template) { build(:export_template, kind: 'csv', groupe_instructeur:) } + let(:tabular_export_template) { build(:tabular_export_template, groupe_instructeur:) } + let(:procedure) { create(:procedure_with_dossiers, :published, types_de_champ_public:, for_individual:) } + let(:for_individual) { true } + let(:types_de_champ_public) do + [ + { type: :text, libelle: "Ca va ?", mandatory: true, stable_id: 1 }, + { type: :communes, libelle: "Commune", mandatory: true, stable_id: 17 }, + { type: :siret, libelle: 'siret', stable_id: 20 }, + { type: :repetition, mandatory: true, stable_id: 7, libelle: "Champ répétable", children: [{ type: 'text', libelle: 'Qqchose à rajouter?', stable_id: 8 }] } + ] + end + + describe '#exported_columns=' do + it 'is assignable/readable with ExportedColumn object' do + expect do + export_template.exported_columns = [ + ExportedColumn.new(libelle: 'Ça va ?', column: procedure.find_column(label: "Ca va ?")) + ] + export_template.save! + export_template.exported_columns + end.not_to raise_error + end + it 'create exported_column' do + export_template.exported_columns = [ + ExportedColumn.new(libelle: 'Ça va ?', column: procedure.find_column(label: "Ca va ?")) + ] + export_template.save! + expect(export_template.exported_columns.size).to eq 1 + end + + context 'when there is a previous revision with a renamed tdc' do + context 'with already column in export template' do + let(:previous_tdc) { procedure.published_revision.types_de_champ_public.find_by(stable_id: 1) } + let(:changed_tdc) { { libelle: "Ca roule ?" } } + + context 'with already column in export template' do + before do + export_template.exported_columns = [ + ExportedColumn.new(libelle: 'Ça va ?', column: procedure.find_column(label: "Ca va ?")) + ] + export_template.save! + + type_de_champ = procedure.draft_revision.find_and_ensure_exclusive_use(previous_tdc.stable_id) + type_de_champ.update(changed_tdc) + procedure.publish_revision! + end + + it 'update columns with original libelle for champs with new revision' do + Current.procedure_columns = {} + procedure.reload + export_template.reload + expect(export_template.exported_columns.find { _1.column.stable_id.to_s == "1" }.libelle).to eq('Ça va ?') + end + end + end + context 'without columns in export template' do + let(:previous_tdc) { procedure.published_revision.types_de_champ_public.find_by(stable_id: 1) } + let(:changed_tdc) { { libelle: "Ca roule ?" } } + + before do + type_de_champ = procedure.draft_revision.find_and_ensure_exclusive_use(previous_tdc.stable_id) + type_de_champ.update(changed_tdc) + procedure.publish_revision! + + export_template.exported_columns = [ + ExportedColumn.new(libelle: 'Ça roule ?', column: procedure.find_column(label: "Ca roule ?")) + ] + export_template.save! + end + + it 'update columns with original libelle for champs with new revision' do + Current.procedure_columns = {} + procedure.reload + export_template.reload + expect(export_template.exported_columns.find { _1.column.stable_id.to_s == "1" }.libelle).to eq('Ça roule ?') + end + end + end + end +end diff --git a/spec/system/instructeurs/procedure_export_tabular_spec.rb b/spec/system/instructeurs/procedure_export_tabular_spec.rb new file mode 100644 index 000000000..326bdbc30 --- /dev/null +++ b/spec/system/instructeurs/procedure_export_tabular_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +describe "procedure exports" do + let(:instructeur) { create(:instructeur) } + let(:procedure) { create(:procedure, :published, types_de_champ_public:, instructeurs: [instructeur]) } + let(:types_de_champ_public) { [{ type: :text }] } + before { login_as(instructeur.user, scope: :user) } + + scenario "create an export_template tabular and u", js: true do + Flipper.enable(:export_template, procedure) + visit instructeur_procedure_path(procedure) + + click_on "Voir les exports et modèles d'export" + + click_on "Modèles d'export" + + click_on "Créer un modèle d'export tabulaire" + + fill_in "Nom du modèle", with: "Mon modèle" + + find("#informations-usager-fieldset label", text: "Tout sélectionner").click + within '#informations-usager-fieldset' do + expect(all('input[type=checkbox]').all?(&:checked?)).to be_truthy + end + + find("#informations-dossier-fieldset label", text: "Tout sélectionner").click + within '#informations-dossier-fieldset' do + expect(all('input[type=checkbox]').all?(&:checked?)).to be_truthy + end + + click_on "Enregistrer" + + find("#tabpanel-export-templates", wait: 5, visible: true) + find("#tabpanel-export-templates").click + + within 'table' do + expect(page).to have_content('Mon modèle') + end + + # check if all usager colonnes are selected + # + click_on 'Mon modèle' + + within '#informations-usager-fieldset' do + expect(all('input[type=checkbox]').all?(&:checked?)).to be_truthy + end + + within '#informations-dossier-fieldset' do + expect(all('input[type=checkbox]').all?(&:checked?)).to be_truthy + end + + # uncheck checkboxes + find("#informations-dossier-fieldset label", text: "Tout sélectionner").click + within '#informations-dossier-fieldset' do + expect(all('input[type=checkbox]').none?(&:checked?)).to be_truthy + end + end +end diff --git a/spec/system/routing/rules_full_scenario_spec.rb b/spec/system/routing/rules_full_scenario_spec.rb index b38bbba68..56e1c7942 100644 --- a/spec/system/routing/rules_full_scenario_spec.rb +++ b/spec/system/routing/rules_full_scenario_spec.rb @@ -209,7 +209,7 @@ describe 'The routing with rules', js: true do ## on the dossiers list click_on procedure.libelle expect(page).to have_current_path(instructeur_procedure_path(procedure)) - expect(find('.fr-tabs')).to have_css('span.notifications') + expect(find('nav.fr-tabs')).to have_css('span.notifications') ## on the dossier itself click_on 'suivi' From 6d074abc3fb2ac9228f3c76d2f47734e9721a48e Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 14:58:12 +0200 Subject: [PATCH 1499/1532] no validation if tabular export template Co-authored-by: mfo --- app/validators/export_template_validator.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/validators/export_template_validator.rb b/app/validators/export_template_validator.rb index 51dc51141..51099b714 100644 --- a/app/validators/export_template_validator.rb +++ b/app/validators/export_template_validator.rb @@ -2,6 +2,8 @@ class ExportTemplateValidator < ActiveModel::Validator def validate(export_template) + return if !export_template.template_zip? + validate_all_templates(export_template) return if export_template.errors.any? # no need to continue if the templates are invalid From 10706a271201cbc762c00d1811707644f734370d Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 15:00:27 +0200 Subject: [PATCH 1500/1532] add dropdown component for traditional or with export_template export Co-authored-by: mfo --- app/assets/stylesheets/instructeur.scss | 6 +- .../dossiers/export_dropdown_component.rb | 3 +- .../export_dropdown_component.html.haml | 86 ++++++++++++++----- .../controllers/menu_button_controller.ts | 12 +-- spec/system/instructeurs/instruction_spec.rb | 5 +- 5 files changed, 80 insertions(+), 32 deletions(-) diff --git a/app/assets/stylesheets/instructeur.scss b/app/assets/stylesheets/instructeur.scss index b42345d6a..1b8105aed 100644 --- a/app/assets/stylesheets/instructeur.scss +++ b/app/assets/stylesheets/instructeur.scss @@ -44,8 +44,12 @@ position: relative; } -.dropdown-export .dropdown-content { +.dropdown-export.dropdown-content { width: 450px; + + a { + text-decoration: underline; + } } .dropdown-label.dropdown-content { diff --git a/app/components/dossiers/export_dropdown_component.rb b/app/components/dossiers/export_dropdown_component.rb index c970576e3..9710c3704 100644 --- a/app/components/dossiers/export_dropdown_component.rb +++ b/app/components/dossiers/export_dropdown_component.rb @@ -3,13 +3,14 @@ class Dossiers::ExportDropdownComponent < ApplicationComponent include ApplicationHelper - def initialize(procedure:, export_templates: nil, statut: nil, count: nil, class_btn: nil, export_url: nil) + def initialize(procedure:, export_templates: nil, statut: nil, count: nil, class_btn: nil, export_url: nil, show_export_template_tab: true) @procedure = procedure @export_templates = export_templates @statut = statut @count = count @class_btn = class_btn @export_url = export_url + @show_export_template_tab = show_export_template_tab end def formats diff --git a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml index 441149066..e9c41e5be 100644 --- a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml +++ b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml @@ -1,26 +1,68 @@ -= render Dropdown::MenuComponent.new(wrapper: :span, button_options: { class: ['fr-btn--sm', @class_btn.present? ? @class_btn : 'fr-btn--secondary']}, menu_options: { id: @count.nil? ? "download_menu" : "download_all_menu", class: ['dropdown-export'] }) do |menu| - - menu.with_menu_header_html do - %p.menu-component-header.fr-px-2w.fr-pt-2w.fr-mb-0 - %span.fr-icon-info-line{ aria: { hidden: true } } - Des macros ? Lisez la - = link_to('doc', t('.macros_doc.url'), - title: t('.macros_doc.title'), - **external_link_attributes) - += render Dropdown::MenuComponent.new(wrapper: :div, button_options: { class: ['fr-btn--sm', @class_btn.present? ? @class_btn : 'fr-btn--secondary']}, menu_options: { id: @count.nil? ? "download_menu" : "download_all_menu", class: ['dropdown-export'] }) do |menu| - menu.with_button_inner_html do = @count.nil? ? t(".download_all") : t(".download", count: @count) - - formats.each do |format| - - menu.with_item do - = link_to download_export_path(export_format: format), role: 'menuitem', data: { turbo_method: :post, turbo: true } do - = t(".everything_#{format}_html") + - menu.with_form do + .fr-container + .fr-tabs.fr-my-3w + %ul.fr-tabs__list{ role: 'tablist' } + %li{ role: 'presentation' } + %button.fr-tabs__tab.fr-tabs__tab--icon-left{ id: "tabpanel-standard#{@count}", tabindex: "0", role: "tab", "aria-selected": "true", "aria-controls": "tabpanel-standard#{@count}-panel" } Standard - - if @procedure.feature_enabled?(:export_template) - - if export_templates.present? - - export_templates.each do |export_template| - - menu.with_item do - = link_to download_export_path(export_template_id: export_template.id), role: 'menuitem', data: { turbo_method: :post, turbo: true } do - = "Exporter à partir du modèle #{export_template.name}" - - menu.with_item do - = link_to [:new, :instructeur, @procedure, :export_template], role: 'menuitem' do - Ajouter un modèle d'export + - if @show_export_template_tab + %li{ role: 'presentation' } + %button.fr-tabs__tab.fr-tabs__tab--icon-left{ id: "tabpanel-template#{@count}", tabindex: "-1", role: "tab", "aria-selected": "false", "aria-controls": "tabpanel-template#{@count}-panel" } A partir d'un modèle + + .fr-tabs__panel.fr-pb-8w.fr-tabs__panel--selected{ id: "tabpanel-standard#{@count}-panel", role: "tabpanel", "aria-labelledby": "tabpanel-standard#{@count}", tabindex: "0" } + = form_with url: download_export_path, namespace: "export#{@count}", data: { turbo_method: :post, turbo: true } do |f| + = f.hidden_field :statut, value: @statut + %fieldset.fr-fieldset#radio-hint{ "aria-labelledby": "radio-hint-legend" } + %legend.fr-fieldset__legend--regular.fr-fieldset__legend#radio-hint-legend Séletionner le format de l'export + .fr-fieldset__element + .fr-radio-group + = f.radio_button :export_format, 'xlsx' + = f.label :export_format_xlsx, 'Fichier xlsx' + .fr-fieldset__element + .fr-radio-group + = f.radio_button :export_format, 'ods' + = f.label :export_format_ods, 'Fichier ods' + .fr-fieldset__element + .fr-radio-group + = f.radio_button :export_format, 'csv' + = f.label :export_format_csv do + Fichier csv + %span.fr-hint-text Uniquement les dossiers, sans les champs répétables + .fr-fieldset__element + .fr-radio-group + = f.radio_button :export_format, 'zip' + = f.label :export_format_zip do + Fichier zip + %span.fr-hint-text ne contient pas l'horodatage ni le journal de log + + .fr-fieldset__element + %ul.fr-btns-group.fr-btns-group--sm.fr-btns-group--inline + %li + %button.fr-btn.fr-btn--secondary{ type: 'button', "data-action": "click->menu-button#close" } Annuler + %li + = f.submit "Demander l'export", "data-action": "click->menu-button#close", class: 'fr-btn' + + + - if @show_export_template_tab + .fr-tabs__panel.fr-pr-3w.fr-pb-8w{ id: "tabpanel-template#{@count}-panel", role: "tabpanel", "aria-labelledby": "tabpanel-template", tabindex: "0" } + = form_with url: download_export_path, namespace: "export_template_#{@count}", data: { turbo_method: :post, turbo: true } do |f| + = f.hidden_field :statut, value: @statut + .fr-select-group + - if export_templates.present? + %label.fr-label{ for: 'select' } + Sélectionner le modèle d'export + = f.collection_select :export_template_id, export_templates, :id, :name, {}, { class: "fr-select fr-mb-2w" } + - else + %p + %i Aucun modèle configuré + %p + = link_to "Configurer les modèles d'export", exports_instructeur_procedure_path(procedure_id: params[:procedure_id]), class: 'fr-link' + %ul.fr-btns-group.fr-btns-group--sm.fr-btns-group--inline + %li + %button.fr-btn.fr-btn--secondary{ type: 'button', "data-action": "click->menu-button#close" } Annuler + %li + = f.submit "Demander l'export", "data-action": "click->menu-button#close", class: 'fr-btn' diff --git a/app/javascript/controllers/menu_button_controller.ts b/app/javascript/controllers/menu_button_controller.ts index a5edf9b53..b89b9025c 100644 --- a/app/javascript/controllers/menu_button_controller.ts +++ b/app/javascript/controllers/menu_button_controller.ts @@ -61,6 +61,12 @@ export class MenuButtonController extends ApplicationController { }); } + close() { + this.buttonTarget.setAttribute('aria-expanded', 'false'); + this.menuTarget.parentElement?.classList.remove('open'); + this.setFocusToMenuitem(null); + } + private open(focusMenuItem: 'first' | 'last' = 'first') { this.buttonTarget.setAttribute('aria-expanded', 'true'); this.menuTarget.parentElement?.classList.add('open'); @@ -75,12 +81,6 @@ export class MenuButtonController extends ApplicationController { }); } - private close() { - this.buttonTarget.setAttribute('aria-expanded', 'false'); - this.menuTarget.parentElement?.classList.remove('open'); - this.setFocusToMenuitem(null); - } - private isClickOutside(target: HTMLElement) { return ( target.isConnected && diff --git a/spec/system/instructeurs/instruction_spec.rb b/spec/system/instructeurs/instruction_spec.rb index 2473c352e..bfd9447d2 100644 --- a/spec/system/instructeurs/instruction_spec.rb +++ b/spec/system/instructeurs/instruction_spec.rb @@ -132,8 +132,9 @@ describe 'Instructing a dossier:', js: true do test_statut_bar(a_suivre: 1, tous_les_dossiers: 1) click_on "Télécharger un dossier" - within(:css, '.dossiers-export') do - click_on "Demander un export au format .csv" + within(:css, '#tabpanel-standard1-panel') do + choose "Fichier csv", allow_label_click: true + click_on "Demander l'export" end expect(page).to have_text('Nous générons cet export.') From 15cea714c160dc472f3db0a28e0ef8da4850f28e Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 15:29:03 +0200 Subject: [PATCH 1501/1532] extract methods from dossier to dossier_export_concern Co-authored-by: mfo Co-authored-by: Paul Chavard --- app/models/champs/repetition_champ.rb | 4 +- app/models/concerns/dossier_champs_concern.rb | 9 -- app/models/concerns/dossier_export_concern.rb | 124 ++++++++++++++++++ app/models/dossier.rb | 95 +------------- app/models/type_de_champ.rb | 1 - .../concerns/dossier_champs_concern_spec.rb | 4 +- spec/models/dossier_spec.rb | 18 +-- .../commune_type_de_champ_spec.rb | 5 +- 8 files changed, 140 insertions(+), 120 deletions(-) create mode 100644 app/models/concerns/dossier_export_concern.rb diff --git a/app/models/champs/repetition_champ.rb b/app/models/champs/repetition_champ.rb index e94bbf518..f8e6dfe46 100644 --- a/app/models/champs/repetition_champ.rb +++ b/app/models/champs/repetition_champ.rb @@ -40,11 +40,11 @@ class Champs::RepetitionChamp < Champ self[attribute] end - def spreadsheet_columns(types_de_champ) + def spreadsheet_columns(types_de_champ, export_template: nil) [ ['Dossier ID', :dossier_id], ['Ligne', :index] - ] + dossier.champs_for_export(types_de_champ, row_id) + ] + dossier.champ_values_for_export(types_de_champ, row_id:, export_template:) end end end diff --git a/app/models/concerns/dossier_champs_concern.rb b/app/models/concerns/dossier_champs_concern.rb index 570b744d8..f6e9baf0a 100644 --- a/app/models/concerns/dossier_champs_concern.rb +++ b/app/models/concerns/dossier_champs_concern.rb @@ -82,15 +82,6 @@ module DossierChampsConcern .map { _1.repetition? ? project_champ(_1, nil) : champ_for_update(_1, nil, updated_by: nil) } end - def champs_for_export(types_de_champ, row_id = nil) - types_de_champ.flat_map do |type_de_champ| - champ = filled_champ(type_de_champ, row_id) - type_de_champ.libelles_for_export.map do |(libelle, path)| - [libelle, type_de_champ.champ_value_for_export(champ, path)] - end - end - end - def champ_value_for_tag(type_de_champ, path = :value) champ = filled_champ(type_de_champ, nil) type_de_champ.champ_value_for_tag(champ, path) diff --git a/app/models/concerns/dossier_export_concern.rb b/app/models/concerns/dossier_export_concern.rb new file mode 100644 index 000000000..bb5449816 --- /dev/null +++ b/app/models/concerns/dossier_export_concern.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +module DossierExportConcern + extend ActiveSupport::Concern + + def spreadsheet_columns_csv(types_de_champ:, export_template: nil) + spreadsheet_columns(with_etablissement: true, types_de_champ:, export_template:) + end + + def spreadsheet_columns_xlsx(types_de_champ:, export_template: nil) + spreadsheet_columns(types_de_champ:, export_template:) + end + + def spreadsheet_columns_ods(types_de_champ:, export_template: nil) + spreadsheet_columns(types_de_champ:, export_template:) + end + + def champ_values_for_export(types_de_champ, row_id: nil, export_template: nil) + types_de_champ.flat_map do |type_de_champ| + champ = filled_champ(type_de_champ, row_id) + if export_template.present? + columns = export_template.columns_for_stable_id(type_de_champ.stable_id) + columns.map { [_1.libelle, type_de_champ.champ_value_for_export(champ, _1.column)] } + else + type_de_champ.libelles_for_export.map do |(libelle, path)| + [libelle, type_de_champ.champ_value_for_export(champ, path)] + end + end + end + end + + def spreadsheet_columns(types_de_champ:, with_etablissement: false, export_template: nil) + dossier_values_for_export(with_etablissement:, export_template:) + champ_values_for_export(types_de_champ, export_template:) + end + + private + + def dossier_values_for_export(with_etablissement: false, export_template: nil) + if export_template.present? + return export_template.dossier_exported_columns.map { [_1.libelle, _1.column.get_value(self)] } + end + + columns = [ + ['ID', id.to_s], + ['Email', user_email_for(:display)], + ['FranceConnect ?', user_from_france_connect?] + ] + + if procedure.for_individual? + columns += [ + ['Civilité', individual&.gender], + ['Nom', individual&.nom], + ['Prénom', individual&.prenom], + ['Dépôt pour un tiers', :for_tiers], + ['Nom du mandataire', :mandataire_last_name], + ['Prénom du mandataire', :mandataire_first_name] + ] + if procedure.ask_birthday + columns += [['Date de naissance', individual&.birthdate]] + end + elsif with_etablissement + columns += [ + ['Établissement SIRET', etablissement&.siret], + ['Établissement siège social', etablissement&.siege_social], + ['Établissement NAF', etablissement&.naf], + ['Établissement libellé NAF', etablissement&.libelle_naf], + ['Établissement Adresse', etablissement&.adresse], + ['Établissement numero voie', etablissement&.numero_voie], + ['Établissement type voie', etablissement&.type_voie], + ['Établissement nom voie', etablissement&.nom_voie], + ['Établissement complément adresse', etablissement&.complement_adresse], + ['Établissement code postal', etablissement&.code_postal], + ['Établissement localité', etablissement&.localite], + ['Établissement code INSEE localité', etablissement&.code_insee_localite], + ['Entreprise SIREN', etablissement&.entreprise_siren], + ['Entreprise capital social', etablissement&.entreprise_capital_social], + ['Entreprise numero TVA intracommunautaire', etablissement&.entreprise_numero_tva_intracommunautaire], + ['Entreprise forme juridique', etablissement&.entreprise_forme_juridique], + ['Entreprise forme juridique code', etablissement&.entreprise_forme_juridique_code], + ['Entreprise nom commercial', etablissement&.entreprise_nom_commercial], + ['Entreprise raison sociale', etablissement&.entreprise_raison_sociale], + ['Entreprise SIRET siège social', etablissement&.entreprise_siret_siege_social], + ['Entreprise code effectif entreprise', etablissement&.entreprise_code_effectif_entreprise], + ['Entreprise date de création', etablissement&.entreprise_date_creation], + ['Entreprise état administratif', etablissement&.entreprise_etat_administratif], + ['Entreprise nom', etablissement&.entreprise_nom], + ['Entreprise prénom', etablissement&.entreprise_prenom], + ['Association RNA', etablissement&.association_rna], + ['Association titre', etablissement&.association_titre], + ['Association objet', etablissement&.association_objet], + ['Association date de création', etablissement&.association_date_creation], + ['Association date de déclaration', etablissement&.association_date_declaration], + ['Association date de publication', etablissement&.association_date_publication] + ] + else + columns << ['Entreprise raison sociale', etablissement&.entreprise_raison_sociale] + end + if procedure.chorusable? && procedure.chorus_configuration.complete? + columns += [ + ['Domaine Fonctionnel', procedure.chorus_configuration.domaine_fonctionnel&.fetch("code") { '' }], + ['Référentiel De Programmation', procedure.chorus_configuration.referentiel_de_programmation&.fetch("code") { '' }], + ['Centre De Coût', procedure.chorus_configuration.centre_de_cout&.fetch("code") { '' }] + ] + end + columns += [ + ['Archivé', :archived], + ['État du dossier', Dossier.human_attribute_name("state.#{state}")], + ['Dernière mise à jour le', :updated_at], + ['Dernière mise à jour du dossier le', :last_champ_updated_at], + ['Déposé le', :depose_at], + ['Passé en instruction le', :en_instruction_at], + procedure.sva_svr_enabled? ? ["Date décision #{procedure.sva_svr_configuration.human_decision}", :sva_svr_decision_on] : nil, + ['Traité le', :processed_at], + ['Motivation de la décision', :motivation], + ['Instructeurs', followers_instructeurs.map(&:email).join(' ')] + ].compact + + if procedure.routing_enabled? + columns << ['Groupe instructeur', groupe_instructeur.label] + end + + columns + end +end diff --git a/app/models/dossier.rb b/app/models/dossier.rb index d4586e55d..3d4b9ab9b 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -13,6 +13,7 @@ class Dossier < ApplicationRecord include DossierStateConcern include DossierChampsConcern include DossierEmptyConcern + include DossierExportConcern enum state: { brouillon: 'brouillon', @@ -944,100 +945,6 @@ class Dossier < ApplicationRecord log_dossier_operation(avis.claimant, :demander_un_avis, avis) end - def spreadsheet_columns_csv(types_de_champ:) - spreadsheet_columns(with_etablissement: true, types_de_champ: types_de_champ) - end - - def spreadsheet_columns_xlsx(types_de_champ:) - spreadsheet_columns(types_de_champ: types_de_champ) - end - - def spreadsheet_columns_ods(types_de_champ:) - spreadsheet_columns(types_de_champ: types_de_champ) - end - - def spreadsheet_columns(with_etablissement: false, types_de_champ:) - columns = [ - ['ID', id.to_s], - ['Email', user_email_for(:display)], - ['FranceConnect ?', user_from_france_connect?] - ] - - if procedure.for_individual? - columns += [ - ['Civilité', individual&.gender], - ['Nom', individual&.nom], - ['Prénom', individual&.prenom], - ['Dépôt pour un tiers', :for_tiers], - ['Nom du mandataire', :mandataire_last_name], - ['Prénom du mandataire', :mandataire_first_name] - ] - if procedure.ask_birthday - columns += [['Date de naissance', individual&.birthdate]] - end - elsif with_etablissement - columns += [ - ['Établissement SIRET', etablissement&.siret], - ['Établissement siège social', etablissement&.siege_social], - ['Établissement NAF', etablissement&.naf], - ['Établissement libellé NAF', etablissement&.libelle_naf], - ['Établissement Adresse', etablissement&.adresse], - ['Établissement numero voie', etablissement&.numero_voie], - ['Établissement type voie', etablissement&.type_voie], - ['Établissement nom voie', etablissement&.nom_voie], - ['Établissement complément adresse', etablissement&.complement_adresse], - ['Établissement code postal', etablissement&.code_postal], - ['Établissement localité', etablissement&.localite], - ['Établissement code INSEE localité', etablissement&.code_insee_localite], - ['Entreprise SIREN', etablissement&.entreprise_siren], - ['Entreprise capital social', etablissement&.entreprise_capital_social], - ['Entreprise numero TVA intracommunautaire', etablissement&.entreprise_numero_tva_intracommunautaire], - ['Entreprise forme juridique', etablissement&.entreprise_forme_juridique], - ['Entreprise forme juridique code', etablissement&.entreprise_forme_juridique_code], - ['Entreprise nom commercial', etablissement&.entreprise_nom_commercial], - ['Entreprise raison sociale', etablissement&.entreprise_raison_sociale], - ['Entreprise SIRET siège social', etablissement&.entreprise_siret_siege_social], - ['Entreprise code effectif entreprise', etablissement&.entreprise_code_effectif_entreprise], - ['Entreprise date de création', etablissement&.entreprise_date_creation], - ['Entreprise état administratif', etablissement&.entreprise_etat_administratif], - ['Entreprise nom', etablissement&.entreprise_nom], - ['Entreprise prénom', etablissement&.entreprise_prenom], - ['Association RNA', etablissement&.association_rna], - ['Association titre', etablissement&.association_titre], - ['Association objet', etablissement&.association_objet], - ['Association date de création', etablissement&.association_date_creation], - ['Association date de déclaration', etablissement&.association_date_declaration], - ['Association date de publication', etablissement&.association_date_publication] - ] - else - columns << ['Entreprise raison sociale', etablissement&.entreprise_raison_sociale] - end - if procedure.chorusable? && procedure.chorus_configuration.complete? - columns += [ - ['Domaine Fonctionnel', procedure.chorus_configuration.domaine_fonctionnel&.fetch("code") { '' }], - ['Référentiel De Programmation', procedure.chorus_configuration.referentiel_de_programmation&.fetch("code") { '' }], - ['Centre De Coût', procedure.chorus_configuration.centre_de_cout&.fetch("code") { '' }] - ] - end - columns += [ - ['Archivé', :archived], - ['État du dossier', Dossier.human_attribute_name("state.#{state}")], - ['Dernière mise à jour le', :updated_at], - ['Dernière mise à jour du dossier le', :last_champ_updated_at], - ['Déposé le', :depose_at], - ['Passé en instruction le', :en_instruction_at], - procedure.sva_svr_enabled? ? ["Date décision #{procedure.sva_svr_configuration.human_decision}", :sva_svr_decision_on] : nil, - ['Traité le', :processed_at], - ['Motivation de la décision', :motivation], - ['Instructeurs', followers_instructeurs.map(&:email).join(' ')] - ].compact - - if procedure.routing_enabled? - columns << ['Groupe instructeur', groupe_instructeur.label] - end - columns + champs_for_export(types_de_champ) - end - def linked_dossiers_for(instructeur_or_expert) dossier_ids = filled_champs.filter(&:dossier_link?).filter_map(&:value) instructeur_or_expert.dossiers.where(id: dossier_ids) diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 729f23955..0403ac864 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -24,7 +24,6 @@ class TypeDeChamp < ApplicationRecord TYPE_DE_CHAMP_TO_CATEGORIE = { engagement_juridique: REFERENTIEL_EXTERNE, - header_section: STRUCTURE, repetition: STRUCTURE, dossier_link: STRUCTURE, diff --git a/spec/models/concerns/dossier_champs_concern_spec.rb b/spec/models/concerns/dossier_champs_concern_spec.rb index 6b864f28a..1cafd67f7 100644 --- a/spec/models/concerns/dossier_champs_concern_spec.rb +++ b/spec/models/concerns/dossier_champs_concern_spec.rb @@ -183,8 +183,8 @@ RSpec.describe DossierChampsConcern do it { row_id; subject; expect(row_id).not_to be_in(row_ids) } end - describe "#champs_for_export" do - subject { dossier.champs_for_export(dossier.revision.types_de_champ_public) } + describe "#champ_values_for_export" do + subject { dossier.champ_values_for_export(dossier.revision.types_de_champ_public) } it { expect(subject.size).to eq(4) } it { expect(subject.first).to eq(["Un champ text", nil]) } diff --git a/spec/models/dossier_spec.rb b/spec/models/dossier_spec.rb index 320fe595e..76e2160dd 100644 --- a/spec/models/dossier_spec.rb +++ b/spec/models/dossier_spec.rb @@ -1982,7 +1982,7 @@ describe Dossier, type: :model do end end - describe "champs_for_export" do + describe "champ_values_for_export" do context 'with integer_number' do let(:procedure) { create(:procedure, :published, types_de_champ_public: [{ type: :integer_number, libelle: 'c1' }]) } let(:dossier) { create(:dossier, :with_populated_champs, procedure:) } @@ -1993,7 +1993,7 @@ describe Dossier, type: :model do expect { integer_number_type_de_champ.update(type_champ: :decimal_number) procedure.update(published_revision: procedure.draft_revision, draft_revision: procedure.create_new_revision) - }.to change { dossier.reload.champs_for_export(procedure.all_revisions_types_de_champ.not_repetition.to_a) } + }.to change { dossier.reload.champ_values_for_export(procedure.all_revisions_types_de_champ.not_repetition.to_a) } .from([["c1", 42]]).to([["c1", 42.0]]) end end @@ -2020,8 +2020,8 @@ describe Dossier, type: :model do let(:repetition_second_revision_champ) { dossier_second_revision.project_champs_public.find(&:repetition?) } let(:dossier) { create(:dossier, procedure: procedure) } let(:dossier_second_revision) { create(:dossier, procedure: procedure) } - let(:dossier_champs_for_export) { dossier.champs_for_export(procedure.types_de_champ_for_procedure_export) } - let(:dossier_second_revision_champs_for_export) { dossier_second_revision.champs_for_export(procedure.types_de_champ_for_procedure_export) } + let(:dossier_champ_values_for_export) { dossier.champ_values_for_export(procedure.types_de_champ_for_procedure_export) } + let(:dossier_second_revision_champ_values_for_export) { dossier_second_revision.champ_values_for_export(procedure.types_de_champ_for_procedure_export) } context "when procedure published" do before do @@ -2040,8 +2040,8 @@ describe Dossier, type: :model do it "should have champs from all revisions" do expect(dossier.types_de_champ.map(&:libelle)).to eq([text_type_de_champ.libelle, datetime_type_de_champ.libelle, "Yes/no", explication_type_de_champ.libelle, commune_type_de_champ.libelle, repetition_type_de_champ.libelle]) expect(dossier_second_revision.types_de_champ.map(&:libelle)).to eq([datetime_type_de_champ.libelle, "Updated yes/no", explication_type_de_champ.libelle, 'Commune de naissance', "Repetition", "New text field"]) - expect(dossier_champs_for_export.map { |(libelle)| libelle }).to eq([datetime_type_de_champ.libelle, text_type_de_champ.libelle, "Updated yes/no", "Commune de naissance", "Commune de naissance (Code INSEE)", "Commune de naissance (Département)", "New text field"]) - expect(dossier_champs_for_export).to eq(dossier_second_revision_champs_for_export) + expect(dossier_champ_values_for_export.map { |(libelle)| libelle }).to eq([datetime_type_de_champ.libelle, text_type_de_champ.libelle, "Updated yes/no", "Commune de naissance", "Commune de naissance (Code INSEE)", "Commune de naissance (Département)", "New text field"]) + expect(dossier_champ_values_for_export).to eq(dossier_second_revision_champ_values_for_export) end context 'within a repetition having a type de champs commune (multiple values for export)' do @@ -2056,7 +2056,7 @@ describe Dossier, type: :model do dossier_test = create(:dossier, procedure: proc_test) type_champs = proc_test.all_revisions_types_de_champ(parent: tdc_repetition).to_a expect(type_champs.size).to eq(1) - expect(dossier.champs_for_export(type_champs).size).to eq(3) + expect(dossier.champ_values_for_export(type_champs).size).to eq(3) end end end @@ -2065,7 +2065,7 @@ describe Dossier, type: :model do let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :text }, { type: :explication }]) } it "should not contain non-exportable types de champ" do - expect(dossier_champs_for_export.map { |(libelle)| libelle }).to eq([text_type_de_champ.libelle]) + expect(dossier_champ_values_for_export.map { |(libelle)| libelle }).to eq([text_type_de_champ.libelle]) end end end @@ -2079,7 +2079,7 @@ describe Dossier, type: :model do let(:text_tdc) { procedure.active_revision.types_de_champ_public.second } let(:tdcs) { dossier.project_champs_public.map(&:type_de_champ) } - subject { dossier.champs_for_export(tdcs) } + subject { dossier.champ_values_for_export(tdcs) } before do text_tdc.update(condition: ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) diff --git a/spec/models/types_de_champ/commune_type_de_champ_spec.rb b/spec/models/types_de_champ/commune_type_de_champ_spec.rb index 18db0b2b5..ccc66bacb 100644 --- a/spec/models/types_de_champ/commune_type_de_champ_spec.rb +++ b/spec/models/types_de_champ/commune_type_de_champ_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true describe TypesDeChamp::CommuneTypeDeChamp do - let(:subject) { create(:type_de_champ_communes, libelle: 'Ma commune') } - - it { expect(subject.libelles_for_export).to match_array([['Ma commune', :value], ['Ma commune (Code INSEE)', :code], ['Ma commune (Département)', :departement]]) } + let(:tdc_commune) { create(:type_de_champ_communes, libelle: 'Ma commune') } + it { expect(tdc_commune.libelles_for_export).to match_array([['Ma commune', :value], ['Ma commune (Code INSEE)', :code], ['Ma commune (Département)', :departement]]) } end From 9530099d2362fc1f4de29c561b65fbc0f967a53e Mon Sep 17 00:00:00 2001 From: Christophe Robillard Date: Fri, 25 Oct 2024 15:32:33 +0200 Subject: [PATCH 1502/1532] improve factories Co-authored-by: mfo --- spec/factories/champ.rb | 5 +++++ spec/tasks/maintenance/populate_rna_json_value_task_spec.rb | 2 +- spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/spec/factories/champ.rb b/spec/factories/champ.rb index 2c8796138..943fc5355 100644 --- a/spec/factories/champ.rb +++ b/spec/factories/champ.rb @@ -170,15 +170,20 @@ FactoryBot.define do factory :champ_do_not_use_rna, class: 'Champs::RNAChamp' do value { 'W173847273' } + value_json { AddressProxy::ADDRESS_PARTS.index_by(&:itself) } end factory :champ_do_not_use_engagement_juridique, class: 'Champs::EngagementJuridiqueChamp' do + value { 'EJ' } end factory :champ_do_not_use_cojo, class: 'Champs::COJOChamp' do end factory :champ_do_not_use_rnf, class: 'Champs::RNFChamp' do + value { '075-FDD-00003-01' } + external_id { '075-FDD-00003-01' } + value_json { AddressProxy::ADDRESS_PARTS.index_by(&:itself) } end factory :champ_do_not_use_expression_reguliere, class: 'Champs::ExpressionReguliereChamp' do diff --git a/spec/tasks/maintenance/populate_rna_json_value_task_spec.rb b/spec/tasks/maintenance/populate_rna_json_value_task_spec.rb index bdb3fd559..df727227c 100644 --- a/spec/tasks/maintenance/populate_rna_json_value_task_spec.rb +++ b/spec/tasks/maintenance/populate_rna_json_value_task_spec.rb @@ -20,7 +20,7 @@ module Maintenance end it 'updates value_json' do expect { subject }.to change { element.reload.value_json } - .from(nil) + .from(anything) .to({ "street_number" => "33", "street_name" => "de Modagor", diff --git a/spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb b/spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb index a71eb25ad..f74ed99a1 100644 --- a/spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb +++ b/spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb @@ -53,7 +53,7 @@ module Maintenance it 'updates value_json' do expect { subject }.to change { element.reload.value_json } - .from(nil) + .from(anything) .to({ "street_number" => "16", "street_name" => "Rue du Général de Boissieu", @@ -79,7 +79,7 @@ module Maintenance it 'updates value_json' do expect { subject }.to change { element.reload.value_json } - .from(nil) + .from(anything) .to({ "street_number" => "16", "street_name" => "Rue du Général de Boissieu", From f01400654250d2dfa33693e73e5d6fd2b6099cc1 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 4 Nov 2024 16:43:39 +0100 Subject: [PATCH 1503/1532] dry(export_template_form): extract checkbox component --- .../champs_component.html.haml | 12 +--- .../export_template/checkbox_component.rb | 32 ++++++++++ .../_checkbox_group.html.haml | 6 +- .../export_templates/_form_tabular.html.haml | 24 ++++---- spec/models/export_template_tabular_spec.rb | 59 +++++++++++++++++++ 5 files changed, 108 insertions(+), 25 deletions(-) create mode 100644 app/components/export_template/checkbox_component.rb diff --git a/app/components/export_template/champs_component/champs_component.html.haml b/app/components/export_template/champs_component/champs_component.html.haml index 4ccbe41de..ea6f603cc 100644 --- a/app/components/export_template/champs_component/champs_component.html.haml +++ b/app/components/export_template/champs_component/champs_component.html.haml @@ -1,5 +1,5 @@ %fieldset.fr-fieldset{ id: "#{component_prefix}-fieldset", data: { controller: 'checkbox-select-all' } } - %legend.fr-fieldset__legend--regular.fr-fieldset__legend + %legend.fr-fieldset__legend--regular.fr-fieldset__legend.fr-h5.fr-pb-0 = title .checkbox-group-bordered.fr-mx-1w.fr-mb-2w .fr-fieldset__element.fr-background-contrast--grey.fr-py-2w.fr-px-4w @@ -16,14 +16,8 @@ .fieldset-bordered.fr-ml-3v - grouped_columns.each do |exported_column| .fr-fieldset__element.fr-px-3v - .fr-checkbox-group - - id = sanitize_to_id(field_id('export_template', 'exported_columns', exported_column.id)) - = check_box_tag field_name('export_template', 'exported_columns', ''), exported_column.id, export_template.exported_columns.map(&:column).include?(exported_column.column), class: 'fr-checkbox', id: id, data: { "checkbox-select-all-target": 'checkbox' } - = label_tag id, historical_libelle(exported_column.column) + .fr-checkbox-group= render ExportTemplate::CheckboxComponent.new(export_template:, exported_column:) - else - grouped_columns.each do |exported_column| .fr-fieldset__element.fr-px-4w - .fr-checkbox-group - - id = sanitize_to_id(field_id('export_template', 'exported_columns', exported_column.id)) - = check_box_tag field_name('export_template', 'exported_columns', ''), exported_column.id, export_template.exported_columns.map(&:column).include?(exported_column.column), class: 'fr-checkbox', id: id, data: { "checkbox-select-all-target": 'checkbox' } - = label_tag id, historical_libelle(exported_column.column) + .fr-checkbox-group= render ExportTemplate::CheckboxComponent.new(export_template:, exported_column:) diff --git a/app/components/export_template/checkbox_component.rb b/app/components/export_template/checkbox_component.rb new file mode 100644 index 000000000..c5617e71d --- /dev/null +++ b/app/components/export_template/checkbox_component.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class ExportTemplate::CheckboxComponent < ApplicationComponent + attr_reader :exported_column, :export_template + + def initialize(export_template:, exported_column:) + @export_template = export_template + @exported_column = exported_column + end + + def call + safe_join([ + check_box, + label_tag(label_id, exported_column.libelle) + ]) + end + + def check_box + check_box_tag( + 'export_template[exported_columns][]', + exported_column.id, + export_template.in_export?(exported_column), + class: 'fr-checkbox', + id: sanitize_to_id(label_id), # sanitize_to_id is used by rails in label_tag + data: { "checkbox-select-all-target": 'checkbox' } + ) + end + + def label_id + exported_column.column.id + end +end diff --git a/app/views/instructeurs/export_templates/_checkbox_group.html.haml b/app/views/instructeurs/export_templates/_checkbox_group.html.haml index f6cfd0c23..80626d857 100644 --- a/app/views/instructeurs/export_templates/_checkbox_group.html.haml +++ b/app/views/instructeurs/export_templates/_checkbox_group.html.haml @@ -1,5 +1,5 @@ %fieldset.fr-fieldset{ id: "#{title.parameterize}-fieldset", data: { controller: 'checkbox-select-all' } } - %legend.fr-fieldset__legend--regular.fr-fieldset__legend + %legend.fr-fieldset__legend--regular.fr-fieldset__legend.fr-h5.fr-pb-0 = title .checkbox-group-bordered.fr-mx-1w.fr-mb-2w @@ -11,6 +11,4 @@ - all_columns.each do |column| .fr-fieldset__element.fr-px-4w .fr-checkbox-group - - id = sanitize_to_id(field_id('export_template', 'exported_columns', { id: column.id, libelle: column.label, parent: nil }.to_json)) - = check_box_tag field_name('export_template', 'exported_columns', ''), { id: column.id, libelle: column.label, parent: nil }.to_json, checked_columns.map(&:column).include?(column), class: 'fr-checkbox', id: id, data: { "checkbox-select-all-target": 'checkbox' } - = label_tag id, column.label + = render ExportTemplate::CheckboxComponent.new(export_template:, exported_column: ExportedColumn.new(libelle: column.label, column:)) diff --git a/app/views/instructeurs/export_templates/_form_tabular.html.haml b/app/views/instructeurs/export_templates/_form_tabular.html.haml index 0da716e9e..36712f6f0 100644 --- a/app/views/instructeurs/export_templates/_form_tabular.html.haml +++ b/app/views/instructeurs/export_templates/_form_tabular.html.haml @@ -28,30 +28,30 @@ %fieldset.fr-fieldset.fr-fieldset--inline %legend#radio-inline-legend.fr-fieldset__legend.fr-text--regular Format export + = asterisk .fr-fieldset__element.fr-fieldset__element--inline - .fr-radio-group - = f.radio_button :kind, "ods", id: "ods" - %label.fr-label{ for: "ods" } ods .fr-radio-group = f.radio_button :kind, "xlsx", id: "xlsx" %label.fr-label{ for: "xlsx" } xlsx + .fr-radio-group + = f.radio_button :kind, "ods", id: "ods" + %label.fr-label{ for: "ods" } ods .fr-radio-group = f.radio_button :kind, "csv", id: "csv" %label.fr-label{ for: "csv" } csv %h2 Contenu de l'export - = render partial: 'checkbox_group', locals: { title: 'Colonnes Usager', all_columns: @export_template.procedure.usager_columns_for_export, checked_columns: @export_template.exported_columns } - = render partial: 'checkbox_group', locals: { title: 'Colonnes Infos dossier', all_columns: @export_template.procedure.dossier_columns_for_export, checked_columns: @export_template.exported_columns } - = render ExportTemplate::ChampsComponent.new("Informations formulaire", @export_template, @types_de_champ_public) - = render ExportTemplate::ChampsComponent.new("Informations annotations", @export_template, @types_de_champ_private) if @types_de_champ_private.any? + %p Sélectionnez les colonnes que vous souhaitez voir affichées dans le tableau de votre export. + + = render partial: 'checkbox_group', locals: { title: 'Informations usager', all_columns: @export_template.procedure.usager_columns_for_export, export_template: @export_template } + = render partial: 'checkbox_group', locals: { title: 'Informations dossier', all_columns: @export_template.procedure.dossier_columns_for_export, export_template: @export_template } + = render ExportTemplate::ChampsComponent.new("Formulaire usager", @export_template, @types_de_champ_public) + = render ExportTemplate::ChampsComponent.new("Annotations privées", @export_template, @types_de_champ_private) if @types_de_champ_private.any? .fixed-footer .fr-container %ul.fr-btns-group.fr-btns-group--inline-md - %li - = f.submit "Enregistrer", class: "fr-btn" %li = link_to "Annuler", instructeur_procedure_path(@procedure), class: "fr-btn fr-btn--secondary" - - if @export_template.persisted? - %li - = link_to "Supprimer", [:instructeur, @procedure, @export_template], method: :delete, data: { confirm: "Voulez-vous vraiment supprimer ce modèle ? Il sera supprimé pour tous les instructeurs du groupe"}, class: "fr-btn fr-btn--secondary" + %li + = f.submit "Enregistrer", class: "fr-btn", data: @export_template.persisted? ? { confirm: t('.warning') } : {} diff --git a/spec/models/export_template_tabular_spec.rb b/spec/models/export_template_tabular_spec.rb index 56518ae1e..4751ffdd2 100644 --- a/spec/models/export_template_tabular_spec.rb +++ b/spec/models/export_template_tabular_spec.rb @@ -82,4 +82,63 @@ describe ExportTemplate do end end end + + describe 'dossier_exported_columns' do + context 'when exported_columns is empty' do + it 'returns an empty array' do + expect(export_template.dossier_exported_columns).to eq([]) + end + end + + context 'when exported_columns is not empty' do + before do + export_template.exported_columns = [ + ExportedColumn.new(libelle: 'Colonne usager', column: procedure.find_column(label: "Email")), + ExportedColumn.new(libelle: 'Ça va ?', column: procedure.find_column(label: "Ca va ?")) + ] + end + it 'returns all columns except tdc columns' do + expect(export_template.dossier_exported_columns.size).to eq(1) # exclude tdc + expect(export_template.dossier_exported_columns.first.libelle).to eq("Colonne usager") + end + end + end + + describe 'columns_for_stable_id' do + before do + export_template.exported_columns = procedure.published_revision.types_de_champ.first.columns(procedure: procedure).map do |column| + ExportedColumn.new(libelle: column.label, column:) + end + end + context 'when procedure has a TypeDeChamp::Commune' do + let(:types_de_champ_public) do + [ + { type: :communes, libelle: "Commune", mandatory: true, stable_id: 17 } + ] + end + it 'is able to resolve stable_id' do + expect(export_template.columns_for_stable_id(17).size).to eq(3) + end + end + context 'when procedure has a TypeDeChamp::Siret' do + let(:types_de_champ_public) do + [ + { type: :siret, libelle: 'siret', stable_id: 20 } + ] + end + it 'is able to resolve stable_id' do + expect(export_template.columns_for_stable_id(20).size).to eq(5) + end + end + context 'when procedure has a TypeDeChamp::Text' do + let(:types_de_champ_public) do + [ + { type: :text, libelle: "Text", mandatory: true, stable_id: 15 } + ] + end + it 'is able to resolve stable_id' do + expect(export_template.columns_for_stable_id(15).size).to eq(1) + end + end + end end From c209cac62f8aad175abdb444bd9d8fa33f0cd889 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 4 Nov 2024 17:00:25 +0100 Subject: [PATCH 1504/1532] feat(export_template): use in export service --- app/models/concerns/dossier_export_concern.rb | 7 +- app/models/exported_column.rb | 4 + app/services/procedure_export_service.rb | 4 +- spec/models/columns/dossier_column_spec.rb | 63 ++++++ .../procedure_export_service_tabular_spec.rb | 206 ++++++++++++++++++ .../procedure_archive_and_export_spec.rb | 5 +- 6 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 spec/services/procedure_export_service_tabular_spec.rb diff --git a/app/models/concerns/dossier_export_concern.rb b/app/models/concerns/dossier_export_concern.rb index bb5449816..fe2f5bdc6 100644 --- a/app/models/concerns/dossier_export_concern.rb +++ b/app/models/concerns/dossier_export_concern.rb @@ -19,8 +19,9 @@ module DossierExportConcern types_de_champ.flat_map do |type_de_champ| champ = filled_champ(type_de_champ, row_id) if export_template.present? - columns = export_template.columns_for_stable_id(type_de_champ.stable_id) - columns.map { [_1.libelle, type_de_champ.champ_value_for_export(champ, _1.column)] } + export_template + .columns_for_stable_id(type_de_champ.stable_id) + .map { |exported_column| exported_column.libelle_with_value(champ) } else type_de_champ.libelles_for_export.map do |(libelle, path)| [libelle, type_de_champ.champ_value_for_export(champ, path)] @@ -37,7 +38,7 @@ module DossierExportConcern def dossier_values_for_export(with_etablissement: false, export_template: nil) if export_template.present? - return export_template.dossier_exported_columns.map { [_1.libelle, _1.column.get_value(self)] } + return export_template.dossier_exported_columns.map { _1.libelle_with_value(self) } end columns = [ diff --git a/app/models/exported_column.rb b/app/models/exported_column.rb index 5f754c98f..23c091c98 100644 --- a/app/models/exported_column.rb +++ b/app/models/exported_column.rb @@ -9,4 +9,8 @@ class ExportedColumn end def id = { id: column.id, libelle: }.to_json + + def libelle_with_value(champ_or_dossier) + [libelle, column.value(champ_or_dossier)] + end end diff --git a/app/services/procedure_export_service.rb b/app/services/procedure_export_service.rb index 9482cc045..03b3281de 100644 --- a/app/services/procedure_export_service.rb +++ b/app/services/procedure_export_service.rb @@ -115,7 +115,7 @@ class ProcedureExportService { sheet_name: type_de_champ_repetition.libelle_for_export, instances: rows, - spreadsheet_columns: Proc.new { |instance| instance.spreadsheet_columns(types_de_champ) } + spreadsheet_columns: Proc.new { |instance| instance.spreadsheet_columns(types_de_champ, export_template: @export_template) } } end end @@ -152,7 +152,7 @@ class ProcedureExportService types_de_champ = procedure.types_de_champ_for_procedure_export.to_a Proc.new do |instance| - instance.send(:"spreadsheet_columns_#{format}", types_de_champ: types_de_champ) + instance.send(:"spreadsheet_columns_#{format}", types_de_champ: types_de_champ, export_template: @export_template) end end end diff --git a/spec/models/columns/dossier_column_spec.rb b/spec/models/columns/dossier_column_spec.rb index 5b8651d2a..6574732c0 100644 --- a/spec/models/columns/dossier_column_spec.rb +++ b/spec/models/columns/dossier_column_spec.rb @@ -14,6 +14,9 @@ describe Columns::DossierColumn do expect(procedure.find_column(label: "Prénom").value(dossier)).to eq("Paul") expect(procedure.find_column(label: "Nom").value(dossier)).to eq("Sim") expect(procedure.find_column(label: "Civilité").value(dossier)).to eq("M.") + expect(procedure.find_column(label: "Dépôt pour un tiers").value(dossier)).to eq(true) + expect(procedure.find_column(label: "Nom du mandataire").value(dossier)).to eq("Christophe") + expect(procedure.find_column(label: "Prénom du mandataire").value(dossier)).to eq("Martin") end end @@ -22,7 +25,67 @@ describe Columns::DossierColumn do let(:dossier) { create(:dossier, :en_instruction, :with_entreprise, procedure:) } it 'retrieve entreprise information' do + expect(procedure.find_column(label: "Nº dossier").value(dossier)).to eq(dossier.id) + expect(procedure.find_column(label: "Email").value(dossier)).to eq(dossier.user_email_for(:display)) + expect(procedure.find_column(label: "France connecté ?").value(dossier)).to eq(false) + expect(procedure.find_column(label: "Entreprise forme juridique").value(dossier)).to eq("SA à conseil d'administration (s.a.i.)") + expect(procedure.find_column(label: "Entreprise SIREN").value(dossier)).to eq('440117620') + expect(procedure.find_column(label: "Entreprise nom commercial").value(dossier)).to eq('GRTGAZ') + expect(procedure.find_column(label: "Entreprise raison sociale").value(dossier)).to eq('GRTGAZ') + expect(procedure.find_column(label: "Entreprise SIRET siège social").value(dossier)).to eq('44011762001530') + expect(procedure.find_column(label: "Date de création").value(dossier)).to be_an_instance_of(ActiveSupport::TimeWithZone) + expect(procedure.find_column(label: "Établissement SIRET").value(dossier)).to eq('44011762001530') expect(procedure.find_column(label: "Libellé NAF").value(dossier)).to eq('Transports par conduites') + expect(procedure.find_column(label: "Établissement code postal").value(dossier)).to eq('92270') + expect(procedure.find_column(label: "Établissement siège social").value(dossier)).to eq(true) + expect(procedure.find_column(label: "Établissement NAF").value(dossier)).to eq('4950Z') + expect(procedure.find_column(label: "Établissement Adresse").value(dossier)).to eq("GRTGAZ\r IMMEUBLE BORA\r 6 RUE RAOUL NORDLING\r 92270 BOIS COLOMBES\r") + expect(procedure.find_column(label: "Établissement numero voie").value(dossier)).to eq('6') + expect(procedure.find_column(label: "Établissement type voie").value(dossier)).to eq('RUE') + expect(procedure.find_column(label: "Établissement nom voie").value(dossier)).to eq('RAOUL NORDLING') + expect(procedure.find_column(label: "Établissement complément adresse").value(dossier)).to eq('IMMEUBLE BORA') + expect(procedure.find_column(label: "Établissement localité").value(dossier)).to eq('BOIS COLOMBES') + expect(procedure.find_column(label: "Établissement code INSEE localité").value(dossier)).to eq('92009') + expect(procedure.find_column(label: "Entreprise SIREN").value(dossier)).to eq('440117620') + expect(procedure.find_column(label: "Entreprise capital social").value(dossier)).to eq(537_100_000) + expect(procedure.find_column(label: "Entreprise numero TVA intracommunautaire").value(dossier)).to eq('FR27440117620') + expect(procedure.find_column(label: "Entreprise forme juridique code").value(dossier)).to eq('5599') + expect(procedure.find_column(label: "Entreprise code effectif entreprise").value(dossier)).to eq('51') + expect(procedure.find_column(label: "Entreprise état administratif").value(dossier)).to eq("actif") + expect(procedure.find_column(label: "Entreprise nom").value(dossier)).to eq(nil) + expect(procedure.find_column(label: "Entreprise prénom").value(dossier)).to eq(nil) + expect(procedure.find_column(label: "Association RNA").value(dossier)).to eq(nil) + expect(procedure.find_column(label: "Association titre").value(dossier)).to eq(nil) + expect(procedure.find_column(label: "Association objet").value(dossier)).to eq(nil) + expect(procedure.find_column(label: "Association date de création").value(dossier)).to eq(nil) + expect(procedure.find_column(label: "Association date de déclaration").value(dossier)).to eq(nil) + expect(procedure.find_column(label: "Association date de publication").value(dossier)).to eq(nil) + expect(procedure.find_column(label: "Date de création").value(dossier)).to be_an_instance_of(ActiveSupport::TimeWithZone) + expect(procedure.find_column(label: "Date du dernier évènement").value(dossier)).to be_an_instance_of(ActiveSupport::TimeWithZone) + expect(procedure.find_column(label: "Date de dépot").value(dossier)).to be_an_instance_of(ActiveSupport::TimeWithZone) + expect(procedure.find_column(label: "Date de passage en construction").value(dossier)).to be_an_instance_of(ActiveSupport::TimeWithZone) + expect(procedure.find_column(label: "Date de passage en instruction").value(dossier)).to be_an_instance_of(ActiveSupport::TimeWithZone) + expect(procedure.find_column(label: "Date de traitement").value(dossier)).to eq(nil) + expect(procedure.find_column(label: "État du dossier").value(dossier)).to eq('en_instruction') + expect(procedure.find_column(label: "Archivé").value(dossier)).to eq(false) + expect(procedure.find_column(label: "Motivation de la décision").value(dossier)).to eq(nil) + expect(procedure.find_column(label: "Date de dernière modification (usager)").value(dossier)).to eq(nil) + expect(procedure.find_column(label: "Instructeurs").value(dossier)).to eq('') + end + end + + context 'when procedure for entreprise which is also an association' do + let(:procedure) { create(:procedure, for_individual: false, groupe_instructeurs: [groupe_instructeur]) } + let(:etablissement) { create(:etablissement, :is_association) } + let(:dossier) { create(:dossier, :en_instruction, procedure:, etablissement:) } + + it 'retrieve also association information' do + expect(procedure.find_column(label: "Association RNA").value(dossier)).to eq("W072000535") + expect(procedure.find_column(label: "Association titre").value(dossier)).to eq("ASSOCIATION POUR LA PROMOTION DE SPECTACLES AU CHATEAU DE ROCHEMAURE") + expect(procedure.find_column(label: "Association objet").value(dossier)).to eq("mise en oeuvre et réalisation de spectacles au chateau de rochemaure") + expect(procedure.find_column(label: "Association date de création").value(dossier)).to eq(Date.parse("1990-04-24")) + expect(procedure.find_column(label: "Association date de déclaration").value(dossier)).to eq(Date.parse("2014-11-28")) + expect(procedure.find_column(label: "Association date de publication").value(dossier)).to eq(Date.parse("1990-05-16")) end end diff --git a/spec/services/procedure_export_service_tabular_spec.rb b/spec/services/procedure_export_service_tabular_spec.rb new file mode 100644 index 000000000..6d994d22b --- /dev/null +++ b/spec/services/procedure_export_service_tabular_spec.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +require 'csv' + +describe ProcedureExportService do + let(:instructeur) { create(:instructeur) } + let(:procedure) { create(:procedure, types_de_champ_public:, for_individual:, ask_birthday: true, instructeurs: [instructeur]) } + let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) } + let(:export_template) { create(:export_template, kind:, exported_columns:, groupe_instructeur: procedure.defaut_groupe_instructeur) } + let(:for_individual) { true } + let(:types_de_champ_public) do + [ + { type: :text, libelle: "first champ", mandatory: true, stable_id: 1 }, + { type: :communes, libelle: "Commune", mandatory: true, stable_id: 17 }, + { type: :piece_justificative, libelle: "PJ", stable_id: 30 }, + { + type: :repetition, mandatory: true, stable_id: 7, libelle: "Champ répétable", children: + [ + { type: 'text', libelle: 'child first champ', stable_id: 8 }, + { type: 'text', libelle: 'child second champ', stable_id: 9 } + ] + } + ] + end + let(:exported_columns) { [] } + + describe 'to_xlsx' do + subject do + service + .to_xlsx + .open { |f| SimpleXlsxReader.open(f.path) } + end + + let(:kind) { 'xlsx' } + let(:dossiers_sheet) { subject.sheets.first } + let(:etablissements_sheet) { subject.sheets.second } + let(:avis_sheet) { subject.sheets.third } + let(:repetition_sheet) { subject.sheets.fourth } + + describe 'sheets' do + it 'should have a sheet for each record type' do + expect(subject.sheets.map(&:name)).to eq(['Dossiers', 'Etablissements', 'Avis']) + end + end + + describe 'Dossiers sheet' do + let(:exported_columns) do + [ + ExportedColumn.new(libelle: 'Date du dernier évènement', column: procedure.find_column(label: 'Date du dernier évènement')), + ExportedColumn.new(libelle: 'Email', column: procedure.find_column(label: 'Email')), + ExportedColumn.new(libelle: 'Groupe instructeur', column: procedure.find_column(label: 'Groupe instructeur')), + ExportedColumn.new(libelle: 'État du dossier', column: procedure.dossier_state_column), + ExportedColumn.new(libelle: 'first champ', column: procedure.find_column(label: 'first champ')), + ExportedColumn.new(libelle: 'Commune (Code INSEE)', column: procedure.find_column(label: 'Commune (Code INSEE)')), + ExportedColumn.new(libelle: 'PJ', column: procedure.find_column(label: 'PJ')) + ] + end + + let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } + let(:selected_headers) { ["Email", "first champ", "Commune (Code INSEE)", "Groupe instructeur", "Date du dernier évènement", "État du dossier", "PJ"] } + + it 'should have only headers from export template' do + expect(dossiers_sheet.headers).to match_array(selected_headers) + end + + it 'should have data' do + expect(procedure.dossiers.count).to eq 1 + expect(dossiers_sheet.data.size).to eq 1 + + expect(dossiers_sheet.data).to match_array([[anything, dossier.user_email_for_display, "défaut", "En instruction", "text", "60172", "toto.txt"]]) + end + + context 'with a procedure routee' do + let!(:dossier) { create(:dossier, :en_instruction, :with_individual, procedure:) } + before { create(:groupe_instructeur, label: '2', procedure:) } + + it 'find groupe instructeur data' do + expect(dossiers_sheet.headers).to include('Groupe instructeur') + expect(dossiers_sheet.data[0][dossiers_sheet.headers.index('Groupe instructeur')]).to eq('défaut') + end + end + + context 'with a dossier having multiple pjs' do + let(:procedure) { create(:procedure, :published, :for_individual, types_de_champ_public:) } + let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure:) } + let!(:dossier_2) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure:) } + before do + dossier_2.filled_champs_public + .find { _1.is_a? Champs::PieceJustificativeChamp } + .piece_justificative_file + .attach(io: StringIO.new("toto"), filename: "toto.txt", content_type: "text/plain") + end + it { expect(dossiers_sheet.data.last.last).to eq "toto.txt, toto.txt" } + end + end + + describe 'Etablissement sheet' do + let(:types_de_champ_public) { [{ type: :siret, libelle: 'siret', stable_id: 40 }] } + let(:exported_columns) do + [ + ExportedColumn.new(libelle: "Nº dossier", column: procedure.find_column(label: "Nº dossier")), + ExportedColumn.new(libelle: "Demandeur", column: procedure.find_column(label: "Demandeur")), + ExportedColumn.new(libelle: "siret", column: procedure.find_column(label: "siret")) + ] + end + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_entreprise, procedure: procedure) } + + let(:dossier_etablissement) { etablissements_sheet.data[1] } + let(:champ_etablissement) { etablissements_sheet.data[0] } + + it 'should have siret header in dossiers sheet' do + expect(dossiers_sheet.headers).to include('siret') + end + + it 'should have headers in etablissement sheet' do + expect(etablissements_sheet.headers).to eq([ + "Dossier ID", + "Champ", + "Établissement SIRET", + "Etablissement enseigne", + "Établissement siège social", + "Établissement NAF", + "Établissement libellé NAF", + "Établissement Adresse", + "Établissement numero voie", + "Établissement type voie", + "Établissement nom voie", + "Établissement complément adresse", + "Établissement code postal", + "Établissement localité", + "Établissement code INSEE localité", + "Entreprise SIREN", + "Entreprise capital social", + "Entreprise numero TVA intracommunautaire", + "Entreprise forme juridique", + "Entreprise forme juridique code", + "Entreprise nom commercial", + "Entreprise raison sociale", + "Entreprise SIRET siège social", + "Entreprise code effectif entreprise", + "Entreprise date de création", + "Entreprise état administratif", + "Entreprise nom", + "Entreprise prénom", + "Association RNA", + "Association titre", + "Association objet", + "Association date de création", + "Association date de déclaration", + "Association date de publication" + ]) + end + end + + describe 'Avis sheet' do + let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } + let!(:avis) { create(:avis, :with_answer, dossier: dossier) } + + it 'should have headers and data' do + expect(avis_sheet.headers).to eq([ + "Dossier ID", + "Introduction", + "Réponse", + "Question", + "Réponse oui/non", + "Créé le", + "Répondu le", + "Instructeur", + "Expert" + ]) + expect(avis_sheet.data.size).to eq(1) + end + end + + describe 'Repetitions sheet' do + let(:exported_columns) do + [ + ExportedColumn.new(libelle: "Champ répétable – child second champ", column: procedure.find_column(label: "Champ répétable – child second champ")) + ] + end + let!(:dossiers) do + [ + create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure), + create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) + ] + end + + describe 'sheets' do + it 'should have a sheet for repetition' do + expect(subject.sheets.map(&:name)).to eq(['Dossiers', 'Etablissements', 'Avis', '(7) Champ repetable']) + end + end + + it 'should have headers' do + expect(repetition_sheet.headers).to eq([ + "Dossier ID", "Ligne", "Champ répétable – child second champ" + ]) + end + + it 'should have data' do + expect(repetition_sheet.data.size).to eq 4 + end + end + end +end diff --git a/spec/system/administrateurs/procedure_archive_and_export_spec.rb b/spec/system/administrateurs/procedure_archive_and_export_spec.rb index 3b1b85c9c..eb868f788 100644 --- a/spec/system/administrateurs/procedure_archive_and_export_spec.rb +++ b/spec/system/administrateurs/procedure_archive_and_export_spec.rb @@ -41,7 +41,10 @@ describe 'Creating a new procedure', js: true do click_on "Télécharger tous les dossiers" expect { - click_on "Demander un export au format .xlsx" + within(:css, '#tabpanel-standard-panel') do + choose "Fichier xlsx", allow_label_click: true + click_on "Demander l'export" + end expect(page).to have_content("Nous générons cet export. Veuillez revenir dans quelques minutes pour le télécharger.") }.to have_enqueued_job(ExportJob).with(an_instance_of(Export)) end From ed3b1bc04666c3bfd38f14cde4b61f13fe0f6c7a Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 4 Nov 2024 17:01:00 +0100 Subject: [PATCH 1505/1532] feat(pj): add specialized column for PieceJustifiativeChamp --- app/models/columns/piece_justificative_column.rb | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 app/models/columns/piece_justificative_column.rb diff --git a/app/models/columns/piece_justificative_column.rb b/app/models/columns/piece_justificative_column.rb new file mode 100644 index 000000000..6b29ae35b --- /dev/null +++ b/app/models/columns/piece_justificative_column.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Columns::PieceJustificativeColumn < Column + private + + def typed_value(champ) + champ.piece_justificative_file.map { _1.blob.filename.to_s }.join(', ') + end +end From 3b5acaad4c1833173d47a730e0bc629c9d8e3f62 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 4 Nov 2024 17:01:36 +0100 Subject: [PATCH 1506/1532] feat(ExportedColumn): use dedicated formatter for exported column --- app/models/columns/champ_column.rb | 2 +- app/models/exported_column.rb | 2 +- app/services/exported_column_formatter.rb | 42 +++++++++++ .../procedure_export_service_tabular_spec.rb | 74 +++++++++++-------- 4 files changed, 88 insertions(+), 32 deletions(-) create mode 100644 app/services/exported_column_formatter.rb diff --git a/app/models/columns/champ_column.rb b/app/models/columns/champ_column.rb index 3cdb05a92..2d3859774 100644 --- a/app/models/columns/champ_column.rb +++ b/app/models/columns/champ_column.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Columns::ChampColumn < Column - attr_reader :stable_id + attr_reader :stable_id, :tdc_type def initialize(procedure_id:, label:, stable_id:, tdc_type:, displayable: true, filterable: true, type: :text, options_for_select: []) @stable_id = stable_id diff --git a/app/models/exported_column.rb b/app/models/exported_column.rb index 23c091c98..a958050a5 100644 --- a/app/models/exported_column.rb +++ b/app/models/exported_column.rb @@ -11,6 +11,6 @@ class ExportedColumn def id = { id: column.id, libelle: }.to_json def libelle_with_value(champ_or_dossier) - [libelle, column.value(champ_or_dossier)] + [libelle, ExportedColumnFormatter.format(column:, champ_or_dossier:)] end end diff --git a/app/services/exported_column_formatter.rb b/app/services/exported_column_formatter.rb new file mode 100644 index 000000000..d9793072d --- /dev/null +++ b/app/services/exported_column_formatter.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class ExportedColumnFormatter + def self.format(column:, champ_or_dossier:) + return if champ_or_dossier.nil? + + raw_value = column.value(champ_or_dossier) + + case column.type + when :attachements + format_attachments(column:, raw_value:) + when :enum + format_enum(column:, raw_value:) + when :enums + format_enums(column:, raw_values: raw_value) + else + raw_value + end + end + + private + + def self.format_attachments(column:, raw_value:) + case column.tdc_type + when TypeDeChamp.type_champs[:titre_identite] + raw_value.present? ? 'présent' : 'absent' + when TypeDeChamp.type_champs[:piece_justificative] + raw_value.map { _1.blob.filename }.join(", ") + end + end + + def self.format_enums(column:, raw_values:) + raw_values.map { format_enum(column:, raw_value: _1) }.join(', ') + end + + def self.format_enum(column:, raw_value:) + # options for select store ["trad", :enum_value] + selected_option = column.options_for_select.find { _1[1].to_s == raw_value } + + selected_option ? selected_option.first : raw_value + end +end diff --git a/spec/services/procedure_export_service_tabular_spec.rb b/spec/services/procedure_export_service_tabular_spec.rb index 6d994d22b..6981b47db 100644 --- a/spec/services/procedure_export_service_tabular_spec.rb +++ b/spec/services/procedure_export_service_tabular_spec.rb @@ -44,35 +44,42 @@ describe ProcedureExportService do end describe 'Dossiers sheet' do - let(:exported_columns) do - [ - ExportedColumn.new(libelle: 'Date du dernier évènement', column: procedure.find_column(label: 'Date du dernier évènement')), - ExportedColumn.new(libelle: 'Email', column: procedure.find_column(label: 'Email')), - ExportedColumn.new(libelle: 'Groupe instructeur', column: procedure.find_column(label: 'Groupe instructeur')), - ExportedColumn.new(libelle: 'État du dossier', column: procedure.dossier_state_column), - ExportedColumn.new(libelle: 'first champ', column: procedure.find_column(label: 'first champ')), - ExportedColumn.new(libelle: 'Commune (Code INSEE)', column: procedure.find_column(label: 'Commune (Code INSEE)')), - ExportedColumn.new(libelle: 'PJ', column: procedure.find_column(label: 'PJ')) - ] + context 'multiple columns' do + let(:exported_columns) do + [ + ExportedColumn.new(libelle: 'Date du dernier évènement', column: procedure.find_column(label: 'Date du dernier évènement')), + ExportedColumn.new(libelle: 'Email', column: procedure.find_column(label: 'Email')), + ExportedColumn.new(libelle: 'Groupe instructeur', column: procedure.find_column(label: 'Groupe instructeur')), + ExportedColumn.new(libelle: 'État du dossier', column: procedure.dossier_state_column), + ExportedColumn.new(libelle: 'first champ', column: procedure.find_column(label: 'first champ')), + ExportedColumn.new(libelle: 'Commune (Code INSEE)', column: procedure.find_column(label: 'Commune (Code INSEE)')), + ExportedColumn.new(libelle: 'PJ', column: procedure.find_column(label: 'PJ')) + ] + end + + let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } + let(:selected_headers) { ["Email", "first champ", "Commune (Code INSEE)", "Groupe instructeur", "Date du dernier évènement", "État du dossier", "PJ"] } + + it 'should have only headers from export template' do + expect(dossiers_sheet.headers).to match_array(selected_headers) + end + + it 'should have data' do + expect(procedure.dossiers.count).to eq 1 + expect(dossiers_sheet.data.size).to eq 1 + + expect(dossiers_sheet.data).to match_array([[anything, dossier.user_email_for_display, "défaut", "En instruction", "text", "60172", "toto.txt"]]) + end end - let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure: procedure) } - let(:selected_headers) { ["Email", "first champ", "Commune (Code INSEE)", "Groupe instructeur", "Date du dernier évènement", "État du dossier", "PJ"] } + context 'with a procedure having multiple groupe instructeur' do + let(:exported_columns) { [ExportedColumn.new(libelle: 'Groupe instructeur', column: procedure.find_column(label: 'Groupe instructeur'))] } + let(:types_de_champ_public) { [] } - it 'should have only headers from export template' do - expect(dossiers_sheet.headers).to match_array(selected_headers) - end - - it 'should have data' do - expect(procedure.dossiers.count).to eq 1 - expect(dossiers_sheet.data.size).to eq 1 - - expect(dossiers_sheet.data).to match_array([[anything, dossier.user_email_for_display, "défaut", "En instruction", "text", "60172", "toto.txt"]]) - end - - context 'with a procedure routee' do - let!(:dossier) { create(:dossier, :en_instruction, :with_individual, procedure:) } - before { create(:groupe_instructeur, label: '2', procedure:) } + before do + create(:groupe_instructeur, label: '2', procedure:) + create(:dossier, :en_instruction, procedure:) + end it 'find groupe instructeur data' do expect(dossiers_sheet.headers).to include('Groupe instructeur') @@ -81,17 +88,24 @@ describe ProcedureExportService do end context 'with a dossier having multiple pjs' do - let(:procedure) { create(:procedure, :published, :for_individual, types_de_champ_public:) } - let!(:dossier) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure:) } - let!(:dossier_2) { create(:dossier, :en_instruction, :with_populated_champs, :with_individual, procedure:) } + let(:types_de_champ_public) { [{ type: :piece_justificative, libelle: "PJ" }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'PJ', column: procedure.find_column(label: 'PJ'))] } before do - dossier_2.filled_champs_public + dossier = create(:dossier, :en_instruction, :with_populated_champs, procedure:) + dossier.filled_champs_public .find { _1.is_a? Champs::PieceJustificativeChamp } .piece_justificative_file .attach(io: StringIO.new("toto"), filename: "toto.txt", content_type: "text/plain") end it { expect(dossiers_sheet.data.last.last).to eq "toto.txt, toto.txt" } end + + context 'with a dossier TypeDeChamp::MutlipleDropDownList' do + let(:types_de_champ_public) { [{ type: :multiple_drop_down_list, libelle: "multiple_drop_down_list", mandatory: true }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'Date du dernier évènement', column: procedure.find_column(label: 'multiple_drop_down_list'))] } + before { create(:dossier, :with_populated_champs, procedure:) } + it { expect(dossiers_sheet.data.last.last).to eq "val1, val2" } + end end describe 'Etablissement sheet' do From 8792cb3cfd4bc35f74bd3d6526b50f2adb45dbcf Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 12 Nov 2024 17:03:31 +0100 Subject: [PATCH 1507/1532] feat(type_de_champ_commune): add custom columns for iso export feat(export.type): ensure right cast type on export Co-Authored-By: LeSim Date: Tue, 12 Nov 2024 17:34:22 +0100 Subject: [PATCH 1508/1532] feat(export.type): ensure right cast type on export. ods expecting boolean value a 0||1 --- app/models/concerns/columns_concern.rb | 7 +++--- app/models/concerns/dossier_export_concern.rb | 18 +++++++-------- app/models/exported_column.rb | 22 +++++++++++++++++-- app/services/exported_column_formatter.rb | 12 +++++++++- .../concerns/dossier_champs_concern_spec.rb | 2 +- spec/models/dossier_spec.rb | 10 ++++----- 6 files changed, 50 insertions(+), 21 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 1cf268744..c52b52353 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -87,13 +87,13 @@ module ColumnsConcern def followers_instructeurs_email_column = dossier_col(table: 'followers_instructeurs', column: 'email') - def dossier_archived_column = dossier_col(table: 'self', column: 'archived', type: :text, displayable: false, filterable: false); + def dossier_archived_column = dossier_col(table: 'self', column: 'archived', type: :boolean, displayable: false, filterable: false); def dossier_motivation_column = dossier_col(table: 'self', column: 'motivation', type: :text, displayable: false, filterable: false); def user_email_for_display_column = dossier_col(table: 'self', column: 'user_email_for_display', filterable: false, displayable: false) - def user_france_connected_column = dossier_col(table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false) + def user_france_connected_column = dossier_col(table: 'self', column: 'user_from_france_connect?', type: :boolean, filterable: false, displayable: false) def dossier_labels_column = dossier_col(table: 'dossier_labels', column: 'label_id', type: :enum, options_for_select: labels.map { [_1.name, _1.id] }) @@ -140,7 +140,8 @@ module ColumnsConcern def individual_columns ['gender', 'nom', 'prenom'].map { |column| dossier_col(table: 'individual', column:) } - .concat ['for_tiers', 'mandataire_last_name', 'mandataire_first_name'].map { |column| dossier_col(table: 'self', column:) } + .concat ['mandataire_last_name', 'mandataire_first_name'].map { |column| dossier_col(table: 'self', column:) } + .concat ['for_tiers'].map { |column| dossier_col(table: 'self', column:, type: :boolean) } end def moral_columns diff --git a/app/models/concerns/dossier_export_concern.rb b/app/models/concerns/dossier_export_concern.rb index fe2f5bdc6..887b48207 100644 --- a/app/models/concerns/dossier_export_concern.rb +++ b/app/models/concerns/dossier_export_concern.rb @@ -4,24 +4,24 @@ module DossierExportConcern extend ActiveSupport::Concern def spreadsheet_columns_csv(types_de_champ:, export_template: nil) - spreadsheet_columns(with_etablissement: true, types_de_champ:, export_template:) + spreadsheet_columns(with_etablissement: true, types_de_champ:, export_template:, format: :csv) end def spreadsheet_columns_xlsx(types_de_champ:, export_template: nil) - spreadsheet_columns(types_de_champ:, export_template:) + spreadsheet_columns(types_de_champ:, export_template:, format: :xlsx) end def spreadsheet_columns_ods(types_de_champ:, export_template: nil) - spreadsheet_columns(types_de_champ:, export_template:) + spreadsheet_columns(types_de_champ:, export_template:, format: :ods) end - def champ_values_for_export(types_de_champ, row_id: nil, export_template: nil) + def champ_values_for_export(types_de_champ, row_id: nil, export_template: nil, format:) types_de_champ.flat_map do |type_de_champ| champ = filled_champ(type_de_champ, row_id) if export_template.present? export_template .columns_for_stable_id(type_de_champ.stable_id) - .map { |exported_column| exported_column.libelle_with_value(champ) } + .map { |exported_column| exported_column.libelle_with_value(champ, format:) } else type_de_champ.libelles_for_export.map do |(libelle, path)| [libelle, type_de_champ.champ_value_for_export(champ, path)] @@ -30,15 +30,15 @@ module DossierExportConcern end end - def spreadsheet_columns(types_de_champ:, with_etablissement: false, export_template: nil) - dossier_values_for_export(with_etablissement:, export_template:) + champ_values_for_export(types_de_champ, export_template:) + def spreadsheet_columns(types_de_champ:, with_etablissement: false, export_template: nil, format: nil) + dossier_values_for_export(with_etablissement:, export_template:, format:) + champ_values_for_export(types_de_champ, export_template:, format:) end private - def dossier_values_for_export(with_etablissement: false, export_template: nil) + def dossier_values_for_export(with_etablissement: false, export_template: nil, format:) if export_template.present? - return export_template.dossier_exported_columns.map { _1.libelle_with_value(self) } + return export_template.dossier_exported_columns.map { _1.libelle_with_value(self, format:) } end columns = [ diff --git a/app/models/exported_column.rb b/app/models/exported_column.rb index a958050a5..dfedf20b3 100644 --- a/app/models/exported_column.rb +++ b/app/models/exported_column.rb @@ -10,7 +10,25 @@ class ExportedColumn def id = { id: column.id, libelle: }.to_json - def libelle_with_value(champ_or_dossier) - [libelle, ExportedColumnFormatter.format(column:, champ_or_dossier:)] + def libelle_with_value(champ_or_dossier, format:) + [libelle, ExportedColumnFormatter.format(column:, champ_or_dossier:, format:), spreadsheet_architect_type] + end + + # see: https://github.com/westonganger/spreadsheet_architect/blob/771e2e5558fbf6e0cb830e881a7214fa710e49c3/lib/spreadsheet_architect.rb#L39 + def spreadsheet_architect_type + case @column.type + when :boolean + :boolean + when :decimal + :float + when :number + :integer + when :datetime + :time + when :date + :date + else + :string + end end end diff --git a/app/services/exported_column_formatter.rb b/app/services/exported_column_formatter.rb index d9793072d..824b26d29 100644 --- a/app/services/exported_column_formatter.rb +++ b/app/services/exported_column_formatter.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true class ExportedColumnFormatter - def self.format(column:, champ_or_dossier:) + def self.format(column:, champ_or_dossier:, format:) return if champ_or_dossier.nil? raw_value = column.value(champ_or_dossier) case column.type + when :boolean + format_boolean(column:, raw_value:, format:) when :attachements format_attachments(column:, raw_value:) when :enum @@ -20,6 +22,14 @@ class ExportedColumnFormatter private + def self.format_boolean(column:, raw_value:, format:) + if format == :ods + raw_value ? 1 : 0 + else + raw_value + end + end + def self.format_attachments(column:, raw_value:) case column.tdc_type when TypeDeChamp.type_champs[:titre_identite] diff --git a/spec/models/concerns/dossier_champs_concern_spec.rb b/spec/models/concerns/dossier_champs_concern_spec.rb index 1cafd67f7..1b8a920e8 100644 --- a/spec/models/concerns/dossier_champs_concern_spec.rb +++ b/spec/models/concerns/dossier_champs_concern_spec.rb @@ -184,7 +184,7 @@ RSpec.describe DossierChampsConcern do end describe "#champ_values_for_export" do - subject { dossier.champ_values_for_export(dossier.revision.types_de_champ_public) } + subject { dossier.champ_values_for_export(dossier.revision.types_de_champ_public, format: :xlsx) } it { expect(subject.size).to eq(4) } it { expect(subject.first).to eq(["Un champ text", nil]) } diff --git a/spec/models/dossier_spec.rb b/spec/models/dossier_spec.rb index 76e2160dd..cba6e5188 100644 --- a/spec/models/dossier_spec.rb +++ b/spec/models/dossier_spec.rb @@ -1993,7 +1993,7 @@ describe Dossier, type: :model do expect { integer_number_type_de_champ.update(type_champ: :decimal_number) procedure.update(published_revision: procedure.draft_revision, draft_revision: procedure.create_new_revision) - }.to change { dossier.reload.champ_values_for_export(procedure.all_revisions_types_de_champ.not_repetition.to_a) } + }.to change { dossier.reload.champ_values_for_export(procedure.all_revisions_types_de_champ.not_repetition.to_a, format: :xlsx) } .from([["c1", 42]]).to([["c1", 42.0]]) end end @@ -2020,8 +2020,8 @@ describe Dossier, type: :model do let(:repetition_second_revision_champ) { dossier_second_revision.project_champs_public.find(&:repetition?) } let(:dossier) { create(:dossier, procedure: procedure) } let(:dossier_second_revision) { create(:dossier, procedure: procedure) } - let(:dossier_champ_values_for_export) { dossier.champ_values_for_export(procedure.types_de_champ_for_procedure_export) } - let(:dossier_second_revision_champ_values_for_export) { dossier_second_revision.champ_values_for_export(procedure.types_de_champ_for_procedure_export) } + let(:dossier_champ_values_for_export) { dossier.champ_values_for_export(procedure.types_de_champ_for_procedure_export, format: :xlsx) } + let(:dossier_second_revision_champ_values_for_export) { dossier_second_revision.champ_values_for_export(procedure.types_de_champ_for_procedure_export, format: :xlsx) } context "when procedure published" do before do @@ -2056,7 +2056,7 @@ describe Dossier, type: :model do dossier_test = create(:dossier, procedure: proc_test) type_champs = proc_test.all_revisions_types_de_champ(parent: tdc_repetition).to_a expect(type_champs.size).to eq(1) - expect(dossier.champ_values_for_export(type_champs).size).to eq(3) + expect(dossier.champ_values_for_export(type_champs, format: :xlsx).size).to eq(3) end end end @@ -2079,7 +2079,7 @@ describe Dossier, type: :model do let(:text_tdc) { procedure.active_revision.types_de_champ_public.second } let(:tdcs) { dossier.project_champs_public.map(&:type_de_champ) } - subject { dossier.champ_values_for_export(tdcs) } + subject { dossier.champ_values_for_export(tdcs, format: :xlsx) } before do text_tdc.update(condition: ds_eq(champ_value(yes_no_tdc.stable_id), constant(true))) From db053d36c9e115fcfd09f3496a4cdcfb5213f7f2 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 12 Nov 2024 18:15:40 +0100 Subject: [PATCH 1509/1532] fix: give format to repetition --- app/models/champs/repetition_champ.rb | 4 ++-- app/services/procedure_export_service.rb | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/models/champs/repetition_champ.rb b/app/models/champs/repetition_champ.rb index f8e6dfe46..0e9e8ef64 100644 --- a/app/models/champs/repetition_champ.rb +++ b/app/models/champs/repetition_champ.rb @@ -40,11 +40,11 @@ class Champs::RepetitionChamp < Champ self[attribute] end - def spreadsheet_columns(types_de_champ, export_template: nil) + def spreadsheet_columns(types_de_champ, export_template: nil, format:) [ ['Dossier ID', :dossier_id], ['Ligne', :index] - ] + dossier.champ_values_for_export(types_de_champ, row_id:, export_template:) + ] + dossier.champ_values_for_export(types_de_champ, row_id:, export_template:, format:) end end end diff --git a/app/services/procedure_export_service.rb b/app/services/procedure_export_service.rb index 03b3281de..181352d5c 100644 --- a/app/services/procedure_export_service.rb +++ b/app/services/procedure_export_service.rb @@ -18,7 +18,7 @@ class ProcedureExportService def to_xlsx @dossiers = @dossiers.downloadable_sorted_batch - tables = [:dossiers, :etablissements, :avis] + champs_repetables_options + tables = [:dossiers, :etablissements, :avis] + champs_repetables_options(format: :xlsx) # We recursively build multi page spreadsheet io = tables.reduce(nil) do |package, table| @@ -29,7 +29,7 @@ class ProcedureExportService def to_ods @dossiers = @dossiers.downloadable_sorted_batch - tables = [:dossiers, :etablissements, :avis] + champs_repetables_options + tables = [:dossiers, :etablissements, :avis] + champs_repetables_options(format: :ods) # We recursively build multi page spreadsheet io = StringIO.new(tables.reduce(nil) do |spreadsheet, table| @@ -103,7 +103,7 @@ class ProcedureExportService @avis ||= dossiers.flat_map(&:avis) end - def champs_repetables_options + def champs_repetables_options(format:) procedure .all_revisions_types_de_champ .repetition @@ -115,7 +115,7 @@ class ProcedureExportService { sheet_name: type_de_champ_repetition.libelle_for_export, instances: rows, - spreadsheet_columns: Proc.new { |instance| instance.spreadsheet_columns(types_de_champ, export_template: @export_template) } + spreadsheet_columns: Proc.new { |instance| instance.spreadsheet_columns(types_de_champ, export_template: @export_template, format:) } } end end From 1fccf0fd184e5312760a1d82816a63a3689d9b94 Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 14 Nov 2024 09:12:25 +0100 Subject: [PATCH 1510/1532] feat(export.numbers): cast to float, otherwise it is implicitly casted as string --- app/models/exported_column.rb | 5 +-- .../procedure_export_service_tabular_spec.rb | 41 +++++++++++++++---- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/app/models/exported_column.rb b/app/models/exported_column.rb index dfedf20b3..651407a71 100644 --- a/app/models/exported_column.rb +++ b/app/models/exported_column.rb @@ -14,15 +14,12 @@ class ExportedColumn [libelle, ExportedColumnFormatter.format(column:, champ_or_dossier:, format:), spreadsheet_architect_type] end - # see: https://github.com/westonganger/spreadsheet_architect/blob/771e2e5558fbf6e0cb830e881a7214fa710e49c3/lib/spreadsheet_architect.rb#L39 def spreadsheet_architect_type case @column.type when :boolean :boolean - when :decimal + when :decimal, :integer :float - when :number - :integer when :datetime :time when :date diff --git a/spec/services/procedure_export_service_tabular_spec.rb b/spec/services/procedure_export_service_tabular_spec.rb index b2eb2367d..0a20341f7 100644 --- a/spec/services/procedure_export_service_tabular_spec.rb +++ b/spec/services/procedure_export_service_tabular_spec.rb @@ -6,7 +6,6 @@ describe ProcedureExportService do let(:instructeur) { create(:instructeur) } let(:procedure) { create(:procedure, types_de_champ_public:, for_individual:, ask_birthday: true, instructeurs: [instructeur]) } let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) } - let(:export_template) { create(:export_template, kind:, exported_columns:, groupe_instructeur: procedure.defaut_groupe_instructeur) } let(:for_individual) { true } let(:types_de_champ_public) do [ @@ -25,18 +24,19 @@ describe ProcedureExportService do let(:exported_columns) { [] } describe 'to_xlsx' do + let(:kind) { 'xlsx' } + let(:export_template) { create(:export_template, kind:, exported_columns:, groupe_instructeur: procedure.defaut_groupe_instructeur) } + let(:dossiers_sheet) { subject.sheets.first } + let(:etablissements_sheet) { subject.sheets.second } + let(:avis_sheet) { subject.sheets.third } + let(:repetition_sheet) { subject.sheets.fourth } + subject do service .to_xlsx .open { |f| SimpleXlsxReader.open(f.path) } end - let(:kind) { 'xlsx' } - let(:dossiers_sheet) { subject.sheets.first } - let(:etablissements_sheet) { subject.sheets.second } - let(:avis_sheet) { subject.sheets.third } - let(:repetition_sheet) { subject.sheets.fourth } - describe 'sheets' do it 'should have a sheet for each record type' do expect(subject.sheets.map(&:name)).to eq(['Dossiers', 'Etablissements', 'Avis']) @@ -106,6 +106,33 @@ describe ProcedureExportService do before { create(:dossier, :with_populated_champs, procedure:) } it { expect(dossiers_sheet.data.last.last).to eq "val1, val2" } end + + context 'with a dossier TypeDeChamp:YesNo' do + let(:types_de_champ_public) { [{ type: :yes_no, libelle: "yes_no", mandatory: true }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'yes_no', column: procedure.find_column(label: 'yes_no'))] } + before { create(:dossier, :with_populated_champs, procedure:) } + it { expect(dossiers_sheet.data.last.last).to eq true } + end + + context 'with a dossier TypeDeChamp:Checkbox' do + let(:types_de_champ_public) { [{ type: :checkbox, libelle: "checkbox", mandatory: true }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'checkbox', column: procedure.find_column(label: 'checkbox'))] } + before { create(:dossier, :with_populated_champs, procedure:) } + it { expect(dossiers_sheet.data.last.last).to eq true } + end + + context 'with a dossier TypeDeChamp:DecimalNumber' do + let(:types_de_champ_public) { [{ type: :decimal_number, libelle: "decimal", mandatory: true }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'decimal', column: procedure.find_column(label: 'decimal'))] } + before { create(:dossier, :with_populated_champs, procedure:) } + it { expect(dossiers_sheet.data.last.last).to eq 42.1 } + end + context 'with a dossier TypeDeChamp:IntegerNumber' do + let(:types_de_champ_public) { [{ type: :integer_number, libelle: "integer", mandatory: true }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'integer', column: procedure.find_column(label: 'integer'))] } + before { create(:dossier, :with_populated_champs, procedure:) } + it { expect(dossiers_sheet.data.last.last).to eq 42.0 } + end end describe 'Etablissement sheet' do From 6cdd8013266ba2824d213ef4c5d51e9ac87542c4 Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 14 Nov 2024 09:55:10 +0100 Subject: [PATCH 1511/1532] feat(export.linked_drop_down_list): render compound value --- app/models/columns/linked_drop_down_column.rb | 4 ++-- spec/models/columns/champ_column_spec.rb | 2 +- .../procedure_export_service_tabular_spec.rb | 24 ++++++++++++------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/app/models/columns/linked_drop_down_column.rb b/app/models/columns/linked_drop_down_column.rb index 1d25d3eea..6399137b0 100644 --- a/app/models/columns/linked_drop_down_column.rb +++ b/app/models/columns/linked_drop_down_column.rb @@ -46,10 +46,10 @@ class Columns::LinkedDropDownColumn < Columns::ChampColumn def column_id = "type_de_champ/#{stable_id}->#{path}" def typed_value(champ) - return nil if path == :value - primary_value, secondary_value = unpack_values(champ.value) case path + when :value + "#{primary_value} / #{secondary_value}" when :primary primary_value when :secondary diff --git a/spec/models/columns/champ_column_spec.rb b/spec/models/columns/champ_column_spec.rb index d21bd67f2..ee5e0615c 100644 --- a/spec/models/columns/champ_column_spec.rb +++ b/spec/models/columns/champ_column_spec.rb @@ -30,7 +30,7 @@ describe Columns::ChampColumn do expect_type_de_champ_values('checkbox', eq([true])) expect_type_de_champ_values('drop_down_list', eq(['val1'])) expect_type_de_champ_values('multiple_drop_down_list', eq([["val1", "val2"]])) - expect_type_de_champ_values('linked_drop_down_list', eq([nil, "primary", "secondary"])) + expect_type_de_champ_values('linked_drop_down_list', eq(["primary / secondary", "primary", "secondary"])) expect_type_de_champ_values('yes_no', eq([true])) expect_type_de_champ_values('annuaire_education', eq([nil])) expect_type_de_champ_values('piece_justificative', be_an_instance_of(Array)) diff --git a/spec/services/procedure_export_service_tabular_spec.rb b/spec/services/procedure_export_service_tabular_spec.rb index 0a20341f7..1ebeb64f0 100644 --- a/spec/services/procedure_export_service_tabular_spec.rb +++ b/spec/services/procedure_export_service_tabular_spec.rb @@ -44,7 +44,7 @@ describe ProcedureExportService do end describe 'Dossiers sheet' do - context 'multiple columns' do + context 'with multiple columns' do let(:exported_columns) do [ ExportedColumn.new(libelle: 'Date du dernier évènement', column: procedure.find_column(label: 'Date du dernier évènement')), @@ -72,7 +72,7 @@ describe ProcedureExportService do end end - context 'with a procedure having multiple groupe instructeur' do + context 'with multiple groupe instructeur' do let(:exported_columns) { [ExportedColumn.new(libelle: 'Groupe instructeur', column: procedure.find_column(label: 'Groupe instructeur'))] } let(:types_de_champ_public) { [] } @@ -87,7 +87,7 @@ describe ProcedureExportService do end end - context 'with a dossier having multiple pjs' do + context 'with multiple pjs' do let(:types_de_champ_public) { [{ type: :piece_justificative, libelle: "PJ" }] } let(:exported_columns) { [ExportedColumn.new(libelle: 'PJ', column: procedure.find_column(label: 'PJ'))] } before do @@ -100,39 +100,47 @@ describe ProcedureExportService do it { expect(dossiers_sheet.data.last.last).to eq "toto.txt, toto.txt" } end - context 'with a dossier TypeDeChamp::MutlipleDropDownList' do + context 'with TypeDeChamp::MutlipleDropDownListTypeDeChamp' do let(:types_de_champ_public) { [{ type: :multiple_drop_down_list, libelle: "multiple_drop_down_list", mandatory: true }] } let(:exported_columns) { [ExportedColumn.new(libelle: 'Date du dernier évènement', column: procedure.find_column(label: 'multiple_drop_down_list'))] } before { create(:dossier, :with_populated_champs, procedure:) } it { expect(dossiers_sheet.data.last.last).to eq "val1, val2" } end - context 'with a dossier TypeDeChamp:YesNo' do + context 'with TypeDeChamp:YesNoTypeDeChamp' do let(:types_de_champ_public) { [{ type: :yes_no, libelle: "yes_no", mandatory: true }] } let(:exported_columns) { [ExportedColumn.new(libelle: 'yes_no', column: procedure.find_column(label: 'yes_no'))] } before { create(:dossier, :with_populated_champs, procedure:) } it { expect(dossiers_sheet.data.last.last).to eq true } end - context 'with a dossier TypeDeChamp:Checkbox' do + context 'with TypeDeChamp:CheckboxTypeDeChamp' do let(:types_de_champ_public) { [{ type: :checkbox, libelle: "checkbox", mandatory: true }] } let(:exported_columns) { [ExportedColumn.new(libelle: 'checkbox', column: procedure.find_column(label: 'checkbox'))] } before { create(:dossier, :with_populated_champs, procedure:) } it { expect(dossiers_sheet.data.last.last).to eq true } end - context 'with a dossier TypeDeChamp:DecimalNumber' do + context 'with TypeDeChamp:DecimalNumberTypeDeChamp' do let(:types_de_champ_public) { [{ type: :decimal_number, libelle: "decimal", mandatory: true }] } let(:exported_columns) { [ExportedColumn.new(libelle: 'decimal', column: procedure.find_column(label: 'decimal'))] } before { create(:dossier, :with_populated_champs, procedure:) } it { expect(dossiers_sheet.data.last.last).to eq 42.1 } end - context 'with a dossier TypeDeChamp:IntegerNumber' do + + context 'with TypeDeChamp:IntegerNumberTypeDeChamp' do let(:types_de_champ_public) { [{ type: :integer_number, libelle: "integer", mandatory: true }] } let(:exported_columns) { [ExportedColumn.new(libelle: 'integer', column: procedure.find_column(label: 'integer'))] } before { create(:dossier, :with_populated_champs, procedure:) } it { expect(dossiers_sheet.data.last.last).to eq 42.0 } end + + context 'with having TypesDeChamp::LinkedDropDownListTypeDeChamp' do + let(:types_de_champ_public) { [{ type: :linked_drop_down_list, libelle: "linked_drop_down_list", mandatory: true }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'linked_drop_down_list', column: procedure.find_column(label: 'linked_drop_down_list'))] } + before { create(:dossier, :with_populated_champs, procedure:) } + it { expect(dossiers_sheet.data.last.last).to eq "primary / secondary" } + end end describe 'Etablissement sheet' do From ac6f5ba0250fe6b1f8e451684bad5bb6d998ccab Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 14 Nov 2024 14:04:33 +0100 Subject: [PATCH 1512/1532] feat(export.datetime): ensure to cast date and time --- app/models/concerns/columns_concern.rb | 2 +- app/services/dossier_filter_service.rb | 2 +- .../procedure_export_service_tabular_spec.rb | 29 ++++++++++++++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index c52b52353..0a4a98299 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -109,7 +109,7 @@ module ColumnsConcern def dossier_dates_columns ['created_at', 'updated_at', 'last_champ_updated_at', 'depose_at', 'en_construction_at', 'en_instruction_at', 'processed_at'] - .map { |column| dossier_col(table: 'self', column:, type: :date) } + .map { |column| dossier_col(table: 'self', column:, type: :datetime) } end def email_column diff --git a/app/services/dossier_filter_service.rb b/app/services/dossier_filter_service.rb index a322dc8dd..ce32fefc2 100644 --- a/app/services/dossier_filter_service.rb +++ b/app/services/dossier_filter_service.rb @@ -81,7 +81,7 @@ class DossierFilterService else case table when 'self' - if filtered_column.type == :date + if filtered_column.type == :date || filtered_column.type == :datetime dates = values .filter_map { |v| Time.zone.parse(v).beginning_of_day rescue nil } diff --git a/spec/services/procedure_export_service_tabular_spec.rb b/spec/services/procedure_export_service_tabular_spec.rb index 1ebeb64f0..3bff4c42d 100644 --- a/spec/services/procedure_export_service_tabular_spec.rb +++ b/spec/services/procedure_export_service_tabular_spec.rb @@ -135,12 +135,39 @@ describe ProcedureExportService do it { expect(dossiers_sheet.data.last.last).to eq 42.0 } end - context 'with having TypesDeChamp::LinkedDropDownListTypeDeChamp' do + context 'with TypesDeChamp::LinkedDropDownListTypeDeChamp' do let(:types_de_champ_public) { [{ type: :linked_drop_down_list, libelle: "linked_drop_down_list", mandatory: true }] } let(:exported_columns) { [ExportedColumn.new(libelle: 'linked_drop_down_list', column: procedure.find_column(label: 'linked_drop_down_list'))] } before { create(:dossier, :with_populated_champs, procedure:) } it { expect(dossiers_sheet.data.last.last).to eq "primary / secondary" } end + + context 'with TypesDeChamp::DateTimeTypeDeChamp' do + let(:types_de_champ_public) { [{ type: :datetime, libelle: "datetime", mandatory: true }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'datetime', column: procedure.find_column(label: 'datetime'))] } + let(:dossier) { create(:dossier, :with_populated_champs, procedure:) } + before { dossier } + it do + champ_value = Time.zone.parse(dossier.champs.first.value) + offset = champ_value.utc_offset + sheet_value = Time.zone.at(dossiers_sheet.data.last.last - offset.seconds) + expect(sheet_value).to eq(champ_value.round) + end + end + + context 'with TypesDeChamp::Date' do + let(:types_de_champ_public) { [{ type: :date, libelle: "date", mandatory: true }] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'date', column: procedure.find_column(label: 'date'))] } + before { create(:dossier, :with_populated_champs, procedure:) } + it { expect(dossiers_sheet.data.last.last).to be_an_instance_of(Date) } + end + + context 'with DossierColumn as datetime' do + let(:types_de_champ_public) { [] } + let(:exported_columns) { [ExportedColumn.new(libelle: 'Date de passage en instruction', column: procedure.find_column(label: 'Date de passage en instruction'))] } + before { create(:dossier, :en_instruction, :with_populated_champs, procedure:) } + it { expect(dossiers_sheet.data.last.last).to be_an_instance_of(Time) } + end end describe 'Etablissement sheet' do From 9f2aff34596b0adb2873b8ef71b55e9298ac197c Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 14 Nov 2024 14:16:08 +0100 Subject: [PATCH 1513/1532] feat(export.dossier_id): not a number, a string. --- app/components/instructeurs/column_table_header_component.rb | 2 +- app/models/column.rb | 1 + app/models/concerns/columns_concern.rb | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/components/instructeurs/column_table_header_component.rb b/app/components/instructeurs/column_table_header_component.rb index fa4ff6f90..4cb90dd93 100644 --- a/app/components/instructeurs/column_table_header_component.rb +++ b/app/components/instructeurs/column_table_header_component.rb @@ -11,7 +11,7 @@ class Instructeurs::ColumnTableHeaderComponent < ApplicationComponent def classname(column) return 'status-col' if column.dossier_state? - return 'number-col' if column.type == :number + return 'number-col' if column.dossier_id? return 'sva-col' if column.column == 'sva_svr_decision_on' end diff --git a/app/models/column.rb b/app/models/column.rb index 5131c77c6..b02feda10 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -32,6 +32,7 @@ class Column def ==(other) = h_id == other.h_id # using h_id instead of id to avoid inversion of keys def notifications? = [table, column] == ['notifications', 'notifications'] + def dossier_id? = [table, column] == ['self', 'id'] def dossier_state? = [table, column] == ['self', 'state'] def groupe_instructeur? = [table, column] == ['groupe_instructeur', 'id'] def dossier_labels? = [table, column] == ['dossier_labels', 'label_id'] diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 0a4a98299..5d61dc982 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -51,7 +51,7 @@ module ColumnsConcern columns.filter { _1.id.in?(self.columns.map(&:id)) } end - def dossier_id_column = dossier_col(table: 'self', column: 'id', type: :number) + def dossier_id_column = dossier_col(table: 'self', column: 'id', type: :integer) def dossier_state_column options_for_select = I18n.t('instructeurs.dossiers.filterable_state').map(&:to_a).map(&:reverse) From ea5340d71a55c0d0075c656f53ec2d78def57599 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 14 Nov 2024 14:13:58 +0100 Subject: [PATCH 1514/1532] fix(champ): do not validate champs with changed type --- .../concerns/champs_validate_concern.rb | 2 +- .../concerns/champs_validate_concern_spec.rb | 71 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 spec/models/concerns/champs_validate_concern_spec.rb diff --git a/app/models/concerns/champs_validate_concern.rb b/app/models/concerns/champs_validate_concern.rb index ab5b2595b..ac8d0e1aa 100644 --- a/app/models/concerns/champs_validate_concern.rb +++ b/app/models/concerns/champs_validate_concern.rb @@ -29,7 +29,7 @@ module ChampsValidateConcern end def in_dossier_revision? - dossier.revision.types_de_champ.any? { _1.stable_id == stable_id } + dossier.revision.types_de_champ.any? { _1.stable_id == stable_id } && is_type?(type_de_champ.type_champ) end end end diff --git a/spec/models/concerns/champs_validate_concern_spec.rb b/spec/models/concerns/champs_validate_concern_spec.rb new file mode 100644 index 000000000..edf0e5224 --- /dev/null +++ b/spec/models/concerns/champs_validate_concern_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +RSpec.describe ChampsValidateConcern do + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + let(:dossier) { create(:dossier, :with_populated_champs, procedure:) } + let(:type_de_champ) { dossier.revision.types_de_champ_public.first } + + let(:types_de_champ_public) { [{ type: :email }] } + + def update_champ(value) + dossier.update_champs_attributes({ + type_de_champ.stable_id.to_s => { value: } + }, :public, updated_by: 'test') + dossier.save + end + + context 'when in revision' do + context 'valid' do + before { + update_champ('test@test.com') + dossier.validate(:champs_public_value) + } + it { + expect(dossier.champs).not_to be_empty + expect(dossier.errors).to be_empty + } + end + + context 'invalid' do + before { + update_champ('test') + dossier.validate(:champs_public_value) + } + it { + expect(dossier.champs).not_to be_empty + expect(dossier.errors).not_to be_empty + } + end + end + + context 'when not in revision' do + context 'do not validate champs not on current revision' do + before { + update_champ('test') + dossier.revision.revision_types_de_champ.delete_all + dossier.validate(:champs_public_value) + } + it { + expect(dossier.revision.revision_types_de_champ).to be_empty + expect(dossier.champs).not_to be_empty + expect(dossier.errors).to be_empty + } + end + end + + context 'when type changed' do + context 'do not validate with old champ type' do + before { + update_champ('test') + type_de_champ.update(type_champ: :text) + dossier.validate(:champs_public_value) + } + it { + expect(dossier.champs.first.last_write_type_champ).to eq('email') + expect(type_de_champ.type_champ).to eq('text') + expect(dossier.champs).not_to be_empty + expect(dossier.errors).to be_empty + } + end + end +end From 551f166873361d9797ae8329b4e9d61d62ef11ef Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Tue, 5 Nov 2024 18:43:50 +0100 Subject: [PATCH 1515/1532] style(simple routing): update view --- .../simple_routing.html.haml | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml b/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml index c1a97daa0..057b8cdfe 100644 --- a/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml @@ -4,24 +4,34 @@ ['Groupes', admin_procedure_groupe_instructeurs_path(@procedure)], ['Routage à partir d’un champ']] } -= render Procedure::InstructeursMenuComponent.new(procedure: @procedure) do - - content_for(:title, 'Routage') - %h1 Routage à partir d’un champ - = form_for :create_simple_routing, - method: :post, - data: { controller: 'enable-submit-if-checked' }, - url: create_simple_routing_admin_procedure_groupe_instructeurs_path(@procedure) do |f| +.container + .fr-grid-row + .fr-col.fr-col-12.fr-col-md-3 + .fr-container + %ul.fr-btns-group.fr-btns-group--inline-md.fr-ml-0 + %li + = link_to options_admin_procedure_groupe_instructeurs_path, class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w fr-mr-2w' do + Revenir aux options - %div{ data: { 'action': "click->enable-submit-if-checked#click" } } - .notice - Sélectionner le champ à partir duquel créer des groupes d’instructeurs - - buttons_content = @procedure.active_revision.simple_routable_types_de_champ.map { |tdc| { label: tdc.libelle, value: tdc.stable_id } } - = render Dsfr::RadioButtonListComponent.new(form: f, - target: :stable_id, - buttons: buttons_content) + .fr-col + - content_for(:title, 'Routage') + %h1 Configuration du routage + %h2 Routage à partir d’un champ + = form_for :create_simple_routing, + method: :post, + data: { controller: 'enable-submit-if-checked' }, + url: create_simple_routing_admin_procedure_groupe_instructeurs_path(@procedure) do |f| - %ul.fr-btns-group.fr-btns-group--inline-sm - %li - = link_to 'Retour', options_admin_procedure_groupe_instructeurs_path(@procedure, state: :choix), class: 'fr-btn fr-btn--secondary' - %li - %button.fr-btn{ disabled: true, data: { disable_with: 'Création des groupes…', 'enable-submit-if-checked-target': 'submit' } } Créer les groupes + .card.fr-pb-0{ data: { 'action': "click->enable-submit-if-checked#click" } } + .notice + Sélectionner le champ à partir duquel créer des groupes d’instructeurs + - buttons_content = @procedure.active_revision.simple_routable_types_de_champ.map { |tdc| { label: tdc.libelle, value: tdc.stable_id } } + = render Dsfr::RadioButtonListComponent.new(form: f, + target: :stable_id, + buttons: buttons_content) + + %ul.fr-btns-group.fr-btns-group--inline-sm + %li + = link_to 'Annuler', options_admin_procedure_groupe_instructeurs_path(@procedure, state: :choix), class: 'fr-btn fr-btn--secondary' + %li + %button.fr-btn{ disabled: true, data: { disable_with: 'Création des groupes…', 'enable-submit-if-checked-target': 'submit' } } Créer les groupes From b006a87730d4f0913a7518dc03d4cb4d4cded713 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Thu, 7 Nov 2024 09:08:22 +0100 Subject: [PATCH 1516/1532] feat(simple routing): add alert info --- .../groupe_instructeurs/simple_routing.html.haml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml b/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml index 057b8cdfe..6ef8dd4ad 100644 --- a/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml @@ -17,6 +17,16 @@ - content_for(:title, 'Routage') %h1 Configuration du routage %h2 Routage à partir d’un champ + .fr-alert.fr-alert--info.fr-mb-3w{ aria: { hidden: true } } + %p + Vous trouverez ci-dessous une suggestion (non exhaustive) de champs issus de votre formulaire. Les groupes d’instructeurs seront créés sur la base du champ que vous aurez sélectionné. + %br + Vous pourrez ensuite affiner votre routage en ajoutant des groupes sur la base de l’ensemble des champs « routables » de votre formulaire, soit des champs de type : + %ul + - TypeDeChamp.humanized_conditionable_types_by_category.each do |category| + %li + = category.join(', ') + = form_for :create_simple_routing, method: :post, data: { controller: 'enable-submit-if-checked' }, From 05b28fb75d93fd711f5fd323936f82410362d0fb Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 8 Nov 2024 15:08:12 +0100 Subject: [PATCH 1517/1532] feat(routing): add flash modal after simple routing configurated --- .../groupes_management_component.html.haml | 2 ++ .../groupe_instructeurs_controller.rb | 3 ++- .../_simple_routing_modal.html.haml | 14 ++++++++++++++ .../groupe_instructeurs_controller_spec.rb | 14 +++++++------- spec/system/routing/rules_full_scenario_spec.rb | 2 ++ 5 files changed, 27 insertions(+), 8 deletions(-) create mode 100644 app/views/administrateurs/groupe_instructeurs/_simple_routing_modal.html.haml diff --git a/app/components/procedure/groupes_management_component/groupes_management_component.html.haml b/app/components/procedure/groupes_management_component/groupes_management_component.html.haml index 7548d6da0..2e6bc12fe 100644 --- a/app/components/procedure/groupes_management_component/groupes_management_component.html.haml +++ b/app/components/procedure/groupes_management_component/groupes_management_component.html.haml @@ -53,3 +53,5 @@ - if flash[:routing_mode] == 'custom' = render partial: 'custom_routing_modal' +- elsif flash[:routing_mode] == 'simple' + = render partial: 'simple_routing_modal', locals: { procedure: @procedure } diff --git a/app/controllers/administrateurs/groupe_instructeurs_controller.rb b/app/controllers/administrateurs/groupe_instructeurs_controller.rb index fa76101ed..43ab472fe 100644 --- a/app/controllers/administrateurs/groupe_instructeurs_controller.rb +++ b/app/controllers/administrateurs/groupe_instructeurs_controller.rb @@ -88,7 +88,8 @@ module Administrateurs defaut.destroy! end - flash.notice = 'Les groupes instructeurs ont été ajoutés' + flash[:routing_mode] = 'simple' + redirect_to admin_procedure_groupe_instructeurs_path(@procedure) end diff --git a/app/views/administrateurs/groupe_instructeurs/_simple_routing_modal.html.haml b/app/views/administrateurs/groupe_instructeurs/_simple_routing_modal.html.haml new file mode 100644 index 000000000..d652983a9 --- /dev/null +++ b/app/views/administrateurs/groupe_instructeurs/_simple_routing_modal.html.haml @@ -0,0 +1,14 @@ +%dialog{ aria: { labelledby: "fr-modal-title-modal-1" }, role: "dialog", id: "routing-mode-modal", class: "fr-modal fr-modal--opened" } + .fr-container.fr-container--fluid.fr-container-md + .fr-grid-row.fr-grid-row--center + .fr-col-12.fr-col-md-8.fr-col-lg-6 + .fr-modal__body + .fr-modal__header + %button.fr-btn.fr-btn--close{ title: "Fermer la fenêtre modale", aria: { controls: "routing-mode-modal" } } Fermer + .fr-modal__content + %h1#fr-modal-title-modal-1.fr-modal__title + %span.fr-icon-arrow-right-line.fr-icon--lg + Routage à partir d’un champ + .fr-alert.fr-alert--success + %h2.fr-alert__title + Les groupes instructeurs ont été créés à partir du champ « #{procedure.routing_champs.first} » diff --git a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb index 7b4e54ba1..37c55a6d5 100644 --- a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb +++ b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb @@ -918,7 +918,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do it do expect(response).to redirect_to(admin_procedure_groupe_instructeurs_path(procedure3)) - expect(flash.notice).to eq 'Les groupes instructeurs ont été ajoutés' + expect(flash[:routing_mode]).to eq 'simple' expect(procedure3.groupe_instructeurs.pluck(:label)).to match_array(['Paris', 'Lyon', 'Marseille']) expect(procedure3.reload.defaut_groupe_instructeur.routing_rule).to eq(ds_eq(champ_value(drop_down_tdc.stable_id), constant('Lyon'))) expect(procedure3.routing_enabled).to be_truthy @@ -938,7 +938,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do it do expect(response).to redirect_to(admin_procedure_groupe_instructeurs_path(procedure3)) - expect(flash.notice).to eq 'Les groupes instructeurs ont été ajoutés' + expect(flash[:routing_mode]).to eq 'simple' expect(procedure3.groupe_instructeurs.pluck(:label)).to include("01 – Ain") expect(procedure3.reload.defaut_groupe_instructeur.routing_rule).to eq(ds_eq(champ_value(departements_tdc.stable_id), constant('01'))) expect(procedure3.routing_enabled).to be_truthy @@ -958,7 +958,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do it do expect(response).to redirect_to(admin_procedure_groupe_instructeurs_path(procedure3)) - expect(flash.notice).to eq 'Les groupes instructeurs ont été ajoutés' + expect(flash[:routing_mode]).to eq 'simple' expect(procedure3.groupe_instructeurs.pluck(:label)).to include("Guadeloupe") expect(procedure3.reload.defaut_groupe_instructeur.routing_rule).to eq(ds_eq(champ_value(regions_tdc.stable_id), constant('84'))) expect(procedure3.routing_enabled).to be_truthy @@ -978,7 +978,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do it do expect(response).to redirect_to(admin_procedure_groupe_instructeurs_path(procedure3)) - expect(flash.notice).to eq 'Les groupes instructeurs ont été ajoutés' + expect(flash[:routing_mode]).to eq 'simple' expect(procedure3.groupe_instructeurs.pluck(:label)).to include("AD – Andorre") expect(procedure3.reload.defaut_groupe_instructeur.routing_rule).to eq(ds_eq(champ_value(pays_tdc.stable_id), constant('AD'))) expect(procedure3.routing_enabled).to be_truthy @@ -998,7 +998,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do it do expect(response).to redirect_to(admin_procedure_groupe_instructeurs_path(procedure3)) - expect(flash.notice).to eq 'Les groupes instructeurs ont été ajoutés' + expect(flash[:routing_mode]).to eq 'simple' expect(procedure3.groupe_instructeurs.pluck(:label)).to include("01 – Ain") expect(procedure3.reload.defaut_groupe_instructeur.routing_rule).to eq(ds_in_departement(champ_value(communes_tdc.stable_id), constant('01'))) expect(procedure3.routing_enabled).to be_truthy @@ -1018,7 +1018,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do it do expect(response).to redirect_to(admin_procedure_groupe_instructeurs_path(procedure3)) - expect(flash.notice).to eq 'Les groupes instructeurs ont été ajoutés' + expect(flash[:routing_mode]).to eq 'simple' expect(procedure3.groupe_instructeurs.pluck(:label)).to include("01 – Ain") expect(procedure3.reload.defaut_groupe_instructeur.routing_rule).to eq(ds_in_departement(champ_value(epci_tdc.stable_id), constant('01'))) expect(procedure3.routing_enabled).to be_truthy @@ -1038,7 +1038,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do it do expect(response).to redirect_to(admin_procedure_groupe_instructeurs_path(procedure3)) - expect(flash.notice).to eq 'Les groupes instructeurs ont été ajoutés' + expect(flash[:routing_mode]).to eq 'simple' expect(procedure3.groupe_instructeurs.pluck(:label)).to include("01 – Ain") expect(procedure3.reload.defaut_groupe_instructeur.routing_rule).to eq(ds_in_departement(champ_value(address_tdc.stable_id), constant('01'))) expect(procedure3.routing_enabled).to be_truthy diff --git a/spec/system/routing/rules_full_scenario_spec.rb b/spec/system/routing/rules_full_scenario_spec.rb index 56e1c7942..094a284a7 100644 --- a/spec/system/routing/rules_full_scenario_spec.rb +++ b/spec/system/routing/rules_full_scenario_spec.rb @@ -33,6 +33,8 @@ describe 'The routing with rules', js: true do expect(page).to have_text('3 groupes') expect(page).not_to have_text('à configurer') + within("#routing-mode-modal") { click_on "Fermer" } + click_on 'littéraire' expect(page).to have_select("groupe_instructeur[condition_form][rows][][targeted_champ]", selected: "Spécialité") expect(page).to have_select("groupe_instructeur[condition_form][rows][][value]", selected: "littéraire") From 4e8200aaf7c5fcab4330592e50f1458702adabe4 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 8 Nov 2024 17:08:18 +0100 Subject: [PATCH 1518/1532] feat(routing): add types of routables champs --- .../groupe_instructeurs/simple_routing.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml b/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml index 6ef8dd4ad..bdcaa97aa 100644 --- a/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml @@ -35,7 +35,7 @@ .card.fr-pb-0{ data: { 'action': "click->enable-submit-if-checked#click" } } .notice Sélectionner le champ à partir duquel créer des groupes d’instructeurs - - buttons_content = @procedure.active_revision.simple_routable_types_de_champ.map { |tdc| { label: tdc.libelle, value: tdc.stable_id } } + - buttons_content = @procedure.active_revision.simple_routable_types_de_champ.map { |tdc| { label: tdc.libelle, value: tdc.stable_id, hint: "[#{I18n.t(tdc.type_champ, scope: 'activerecord.attributes.type_de_champ.type_champs')}]"} } = render Dsfr::RadioButtonListComponent.new(form: f, target: :stable_id, buttons: buttons_content) From 304fb9ecef0d8b4a6582f66503b5874e49d2236e Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Wed, 13 Nov 2024 09:53:56 +0100 Subject: [PATCH 1519/1532] feat(simple routing): add options of list tdcs in tooltip --- app/components/dsfr/radio_button_list_component.rb | 2 +- .../radio_button_list_component.html.haml | 12 +++++++++--- .../groupe_instructeurs/simple_routing.html.haml | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/app/components/dsfr/radio_button_list_component.rb b/app/components/dsfr/radio_button_list_component.rb index 566d36653..775b70784 100644 --- a/app/components/dsfr/radio_button_list_component.rb +++ b/app/components/dsfr/radio_button_list_component.rb @@ -18,7 +18,7 @@ class Dsfr::RadioButtonListComponent < ApplicationComponent def each_button @buttons.each do |button| - yield(*button.values_at(:label, :value, :hint), **button.except(:label, :value, :hint)) + yield(*button.values_at(:label, :value, :hint, :tooltip), **button.except(:label, :value, :hint, :tooltip)) end end end diff --git a/app/components/dsfr/radio_button_list_component/radio_button_list_component.html.haml b/app/components/dsfr/radio_button_list_component/radio_button_list_component.html.haml index 8776b11af..f0dd1c403 100644 --- a/app/components/dsfr/radio_button_list_component/radio_button_list_component.html.haml +++ b/app/components/dsfr/radio_button_list_component/radio_button_list_component.html.haml @@ -1,8 +1,9 @@ %fieldset{ class: class_names("fr-fieldset": true, "fr-fieldset--error": error?), 'aria-labelledby': 'radio-hint-element-legend radio-hint-element-messages', role: error? ? :group : nil } %legend.fr-fieldset__legend--regular.fr-fieldset__legend = content - - - each_button do |label, value, hint, **button_options| + - index = 0 + - each_button do |label, value, hint, tooltip, **button_options| + - index += 1 .fr-fieldset__element .fr-radio-group = @form.radio_button @target, value, **button_options @@ -12,7 +13,12 @@ = button_options[:after_label] if button_options[:after_label] - %span.fr-hint-text= hint if hint + - if hint.present? + .flex + .fr-hint-text= hint + - if tooltip.present? + .fr-icon-information-line.fr-icon--sm.ml-1{ 'aria-describedby': "tooltip-#{index}" } + %span.fr-tooltip.fr-placement{ id: "tooltip-#{index}", role: 'tooltip', 'aria-hidden': 'true' }= tooltip .fr-messages-group{ 'aria-live': 'assertive' } - if error? diff --git a/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml b/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml index bdcaa97aa..3fb90b18b 100644 --- a/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml @@ -35,7 +35,7 @@ .card.fr-pb-0{ data: { 'action': "click->enable-submit-if-checked#click" } } .notice Sélectionner le champ à partir duquel créer des groupes d’instructeurs - - buttons_content = @procedure.active_revision.simple_routable_types_de_champ.map { |tdc| { label: tdc.libelle, value: tdc.stable_id, hint: "[#{I18n.t(tdc.type_champ, scope: 'activerecord.attributes.type_de_champ.type_champs')}]"} } + - buttons_content = @procedure.active_revision.simple_routable_types_de_champ.map { |tdc| { label: tdc.libelle, value: tdc.stable_id, hint: "[#{I18n.t(tdc.type_champ, scope: 'activerecord.attributes.type_de_champ.type_champs')}]", tooltip: tdc.drop_down_options.join(", ")} } = render Dsfr::RadioButtonListComponent.new(form: f, target: :stable_id, buttons: buttons_content) From 61c8fa4601399f8a49684a9f4409174fd2d1ba68 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Thu, 14 Nov 2024 12:03:44 +0100 Subject: [PATCH 1520/1532] refactor(simple routing): move button index in the component --- app/components/dsfr/radio_button_list_component.rb | 4 ++-- .../radio_button_list_component.html.haml | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/components/dsfr/radio_button_list_component.rb b/app/components/dsfr/radio_button_list_component.rb index 775b70784..df84138a3 100644 --- a/app/components/dsfr/radio_button_list_component.rb +++ b/app/components/dsfr/radio_button_list_component.rb @@ -17,8 +17,8 @@ class Dsfr::RadioButtonListComponent < ApplicationComponent end def each_button - @buttons.each do |button| - yield(*button.values_at(:label, :value, :hint, :tooltip), **button.except(:label, :value, :hint, :tooltip)) + @buttons.each.with_index do |button, index| + yield(*button.values_at(:label, :value, :hint, :tooltip), **button.merge!(index:).except(:label, :value, :hint, :tooltip)) end end end diff --git a/app/components/dsfr/radio_button_list_component/radio_button_list_component.html.haml b/app/components/dsfr/radio_button_list_component/radio_button_list_component.html.haml index f0dd1c403..8c58448ad 100644 --- a/app/components/dsfr/radio_button_list_component/radio_button_list_component.html.haml +++ b/app/components/dsfr/radio_button_list_component/radio_button_list_component.html.haml @@ -1,12 +1,10 @@ %fieldset{ class: class_names("fr-fieldset": true, "fr-fieldset--error": error?), 'aria-labelledby': 'radio-hint-element-legend radio-hint-element-messages', role: error? ? :group : nil } %legend.fr-fieldset__legend--regular.fr-fieldset__legend = content - - index = 0 - each_button do |label, value, hint, tooltip, **button_options| - - index += 1 .fr-fieldset__element .fr-radio-group - = @form.radio_button @target, value, **button_options + = @form.radio_button @target, value, **button_options.except(:index) = @form.label @target, value: value, class: 'fr-label' do - capture do = label @@ -16,9 +14,9 @@ - if hint.present? .flex .fr-hint-text= hint - - if tooltip.present? - .fr-icon-information-line.fr-icon--sm.ml-1{ 'aria-describedby': "tooltip-#{index}" } - %span.fr-tooltip.fr-placement{ id: "tooltip-#{index}", role: 'tooltip', 'aria-hidden': 'true' }= tooltip + - if tooltip.present? && button_options[:index] + .fr-icon-information-line.fr-icon--sm.ml-1{ 'aria-describedby': "tooltip-#{button_options[:index]}" } + %span.fr-tooltip.fr-placement{ id: "tooltip-#{button_options[:index]}", role: 'tooltip', 'aria-hidden': 'true' }= tooltip .fr-messages-group{ 'aria-live': 'assertive' } - if error? From bccef87cbfde76ccb17b0e1e9e0bab58df74bd4c Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Thu, 14 Nov 2024 17:16:46 +0100 Subject: [PATCH 1521/1532] wording(routing): updates after UI reviews --- .../instructeurs_options_component.html.haml | 8 ++++---- app/models/logic/champ_value.rb | 2 +- app/models/type_de_champ.rb | 12 ++++++++++++ .../_custom_routing_modal.html.haml | 2 +- .../_simple_routing_modal.html.haml | 2 +- .../simple_routing.html.haml | 19 +++++++++++++------ .../groupe_instructeurs_controller_spec.rb | 2 +- spec/models/type_de_champ_spec.rb | 2 +- .../routing/rules_full_scenario_spec.rb | 12 ++++++------ 9 files changed, 40 insertions(+), 21 deletions(-) diff --git a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml index e4084a801..36f8d1665 100644 --- a/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml +++ b/app/components/procedure/instructeurs_options_component/instructeurs_options_component.html.haml @@ -55,14 +55,14 @@ url: wizard_admin_procedure_groupe_instructeurs_path(@procedure) do |f| %h1 Configuration du routage - %h2 Choix du type de routage + %h2 Choix du type de configuration .card.fr-pb-0{ data: { 'action': "click->enable-submit-if-checked#click" } } - %p.fr-mb-0 Routage + %p.fr-mb-0 Configuration = render Dsfr::RadioButtonListComponent.new(form: f, target: :state, - buttons: [ { label: 'À partir d’un champ', value: 'routage_simple', hint: 'crée les groupes en fonction d’un champ du formulaire' } , - { label: 'Avancé', value: 'custom_routing', hint: 'libre à vous de créer et de configurer les groupes' }]) + buttons: [ { label: 'Automatique', value: 'routage_simple', hint: 'crée les groupes automatiquement à partir des valeurs possibles d’un champ du formulaire usager' } , + { label: 'Manuelle', value: 'custom_routing', hint: 'libre à vous de créer et de configurer les groupes en utilisant les champs « routables » du formulaire usager' }]) %ul.fr-btns-group.fr-btns-group--inline-sm diff --git a/app/models/logic/champ_value.rb b/app/models/logic/champ_value.rb index b63972089..1d9c9858e 100644 --- a/app/models/logic/champ_value.rb +++ b/app/models/logic/champ_value.rb @@ -8,11 +8,11 @@ class Logic::ChampValue < Logic::Term :decimal_number, :drop_down_list, :multiple_drop_down_list, + :address, :communes, :epci, :departements, :regions, - :address, :pays ) diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 0403ac864..b380dc9fe 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -529,6 +529,18 @@ class TypeDeChamp < ApplicationRecord .map { |_, v| v.map { "« #{I18n.t(_1, scope: [:activerecord, :attributes, :type_de_champ, :type_champs])} »" } } end + def self.humanized_simple_routable_types_by_category + Logic::ChampValue::MANAGED_TYPE_DE_CHAMP_BY_CATEGORY + .map { |_, v| v.filter_map { "« #{I18n.t(_1, scope: [:activerecord, :attributes, :type_de_champ, :type_champs])} »" if _1.to_s.in?(SIMPLE_ROUTABLE_TYPES) } } + .reject(&:empty?) + end + + def self.humanized_custom_routable_types_by_category + Logic::ChampValue::MANAGED_TYPE_DE_CHAMP_BY_CATEGORY + .map { |_, v| v.filter_map { "« #{I18n.t(_1, scope: [:activerecord, :attributes, :type_de_champ, :type_champs])} »" if !_1.to_s.in?(SIMPLE_ROUTABLE_TYPES) } } + .reject(&:empty?) + end + def invalid_regexp? self.errors.delete(:expression_reguliere) self.errors.delete(:expression_reguliere_exemple_text) diff --git a/app/views/administrateurs/groupe_instructeurs/_custom_routing_modal.html.haml b/app/views/administrateurs/groupe_instructeurs/_custom_routing_modal.html.haml index cc503f514..4f14875e0 100644 --- a/app/views/administrateurs/groupe_instructeurs/_custom_routing_modal.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/_custom_routing_modal.html.haml @@ -8,7 +8,7 @@ .fr-modal__content %h1#fr-modal-title-modal-1.fr-modal__title %span.fr-icon-arrow-right-line.fr-icon--lg - Routage avancé + Configuration manuelle du routage .fr-alert.fr-alert--success %h2.fr-alert__title Deux groupes par défaut ont été créés diff --git a/app/views/administrateurs/groupe_instructeurs/_simple_routing_modal.html.haml b/app/views/administrateurs/groupe_instructeurs/_simple_routing_modal.html.haml index d652983a9..ff817b750 100644 --- a/app/views/administrateurs/groupe_instructeurs/_simple_routing_modal.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/_simple_routing_modal.html.haml @@ -8,7 +8,7 @@ .fr-modal__content %h1#fr-modal-title-modal-1.fr-modal__title %span.fr-icon-arrow-right-line.fr-icon--lg - Routage à partir d’un champ + Configuration automatique du routage .fr-alert.fr-alert--success %h2.fr-alert__title Les groupes instructeurs ont été créés à partir du champ « #{procedure.routing_champs.first} » diff --git a/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml b/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml index 3fb90b18b..de4deae6a 100644 --- a/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml +++ b/app/views/administrateurs/groupe_instructeurs/simple_routing.html.haml @@ -2,7 +2,7 @@ locals: { steps: [[t('.procedures'), admin_procedures_path], [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)], ['Groupes', admin_procedure_groupe_instructeurs_path(@procedure)], - ['Routage à partir d’un champ']] } + ['Configuration automatique du routage']] } .container .fr-grid-row @@ -16,14 +16,21 @@ .fr-col - content_for(:title, 'Routage') %h1 Configuration du routage - %h2 Routage à partir d’un champ + %h2 Configuration automatique .fr-alert.fr-alert--info.fr-mb-3w{ aria: { hidden: true } } %p - Vous trouverez ci-dessous une suggestion (non exhaustive) de champs issus de votre formulaire. Les groupes d’instructeurs seront créés sur la base du champ que vous aurez sélectionné. - %br - Vous pourrez ensuite affiner votre routage en ajoutant des groupes sur la base de l’ensemble des champs « routables » de votre formulaire, soit des champs de type : + Vous trouverez ci-dessous une liste de champs de votre formulaire à partir desquels configurer le routage de façon automatique. Les groupes d’instructeurs seront créés à partir des valeurs possibles du champ. + Seuls les champs suivants sont ouverts à ce mode de configuration : %ul - - TypeDeChamp.humanized_conditionable_types_by_category.each do |category| + - TypeDeChamp.humanized_simple_routable_types_by_category.each do |category| + %li + = category.join(', ') + + %p + Si besoin, vous pourrez ensuite affiner votre configuration de façon manuelle, également à partir des champs suivants : + + %ul + - TypeDeChamp.humanized_custom_routable_types_by_category.each do |category| %li = category.join(', ') diff --git a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb index 37c55a6d5..84db0d18f 100644 --- a/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb +++ b/spec/controllers/administrateurs/groupe_instructeurs_controller_spec.rb @@ -879,7 +879,7 @@ describe Administrateurs::GroupeInstructeursController, type: :controller do it do expect(response).to have_http_status(:ok) - expect(response.body).to include('Choix du type de routage') + expect(response.body).to include('Choix du type de configuration') expect(procedure.reload.routing_enabled).to be_falsey end end diff --git a/spec/models/type_de_champ_spec.rb b/spec/models/type_de_champ_spec.rb index 3210d5465..b8d4e44f3 100644 --- a/spec/models/type_de_champ_spec.rb +++ b/spec/models/type_de_champ_spec.rb @@ -462,6 +462,6 @@ describe TypeDeChamp do describe '#humanized_conditionable_types_by_category' do subject { TypeDeChamp.humanized_conditionable_types_by_category } - it { is_expected.to eq([["« Oui/Non »", "« Case à cocher seule »", "« Choix simple »", "« Choix multiple »"], ["« Nombre entier »", "« Nombre décimal »"], ["« Communes »", "« EPCI »", "« Départements »", "« Régions »", "« Adresse »", "« Pays »"]]) } + it { is_expected.to eq([["« Oui/Non »", "« Case à cocher seule »", "« Choix simple »", "« Choix multiple »"], ["« Nombre entier »", "« Nombre décimal »"], ["« Adresse »", "« Communes »", "« EPCI »", "« Départements »", "« Régions »", "« Pays »"]]) } end end diff --git a/spec/system/routing/rules_full_scenario_spec.rb b/spec/system/routing/rules_full_scenario_spec.rb index 094a284a7..682f7ed10 100644 --- a/spec/system/routing/rules_full_scenario_spec.rb +++ b/spec/system/routing/rules_full_scenario_spec.rb @@ -18,13 +18,13 @@ describe 'The routing with rules', js: true do procedure.defaut_groupe_instructeur.instructeurs << administrateur.instructeur end - scenario 'Routage à partir d’un champ' do + scenario 'Configuration automatique du routage' do steps_to_routing_configuration - choose('À partir d’un champ', allow_label_click: true) + choose('Automatique', allow_label_click: true) click_on 'Continuer' - expect(page).to have_text('Routage à partir d’un champ') + expect(page).to have_text('Configuration automatique') choose('Spécialité', allow_label_click: true) click_on 'Créer les groupes' @@ -46,10 +46,10 @@ describe 'The routing with rules', js: true do expect(page).to have_select("groupe_instructeur[condition_form][rows][][value]", selected: "scientifique") end - scenario 'Routage avancé' do + scenario 'Configuration manuelle du routage' do steps_to_routing_configuration - choose('Avancé', allow_label_click: true) + choose('Manuelle', allow_label_click: true) click_on 'Continuer' expect(page).to have_text('Gestion des groupes') @@ -333,6 +333,6 @@ describe 'The routing with rules', js: true do click_on 'Options' expect(page).to have_text('Options concernant l’instruction') click_on 'Configurer le routage' - expect(page).to have_text('Choix du type de routage') + expect(page).to have_text('Choix du type de configuration') end end From a9237bf7f1c9dc53e8bc04021f7a053e71c64211 Mon Sep 17 00:00:00 2001 From: mfo Date: Fri, 15 Nov 2024 09:20:40 +0100 Subject: [PATCH 1522/1532] feat(rnf): also store rnf title --- app/models/champs/rnf_champ.rb | 9 ++++++- spec/models/champs/rnf_champ_spec.rb | 25 ++++++++++--------- .../populate_rnf_json_value_task_spec.rb | 6 +++-- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/app/models/champs/rnf_champ.rb b/app/models/champs/rnf_champ.rb index 3b9a8fa7a..0dab98707 100644 --- a/app/models/champs/rnf_champ.rb +++ b/app/models/champs/rnf_champ.rb @@ -16,7 +16,7 @@ class Champs::RNFChamp < Champ end def update_with_external_data!(data:) - update!(data:, value_json: APIGeoService.parse_rnf_address(data[:address])) + update!(data:, value_json: extract_value_json(data:)) end def fetch_external_data? @@ -109,4 +109,11 @@ class Champs::RNFChamp < Champ address['label'] end end + + private + + def extract_value_json(data:) + h = APIGeoService.parse_rnf_address(data[:address]) + h.merge(title: data[:title]) + end end diff --git a/spec/models/champs/rnf_champ_spec.rb b/spec/models/champs/rnf_champ_spec.rb index fbca7072a..a55ffa2d6 100644 --- a/spec/models/champs/rnf_champ_spec.rb +++ b/spec/models/champs/rnf_champ_spec.rb @@ -102,18 +102,19 @@ describe Champs::RNFChamp, type: :model do describe 'update_with_external_data!' do it 'works' do value_json = { - :street_number => "16", - :street_name => "Rue du Général de Boissieu", - :street_address => "16 Rue du Général de Boissieu", - :postal_code => "75015", - :city_name => "Paris 15e Arrondissement", - :city_code => "75115", - :departement_code => "75", - :department_code => "75", - :departement_name => "Paris", - :department_name => "Paris", - :region_code => "11", - :region_name => "Île-de-France" + street_number: "16", + street_name: "Rue du Général de Boissieu", + street_address: "16 Rue du Général de Boissieu", + postal_code: "75015", + city_name: "Paris 15e Arrondissement", + city_code: "75115", + departement_code: "75", + department_code: "75", + departement_name: "Paris", + department_name: "Paris", + region_code: "11", + region_name: "Île-de-France", + title: "Fondation SFR" } expect(champ).to receive(:update!).with(data: anything, value_json:) champ.update_with_external_data!(data: subject.value!) diff --git a/spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb b/spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb index f74ed99a1..1d6b3f810 100644 --- a/spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb +++ b/spec/tasks/maintenance/populate_rnf_json_value_task_spec.rb @@ -66,7 +66,8 @@ module Maintenance "departement_name" => "Paris", "department_name" => "Paris", "region_code" => "11", - "region_name" => "Île-de-France" + "region_name" => "Île-de-France", + "title" => "Fondation SFR" }) end end @@ -92,7 +93,8 @@ module Maintenance "departement_name" => "Paris", "department_name" => "Paris", "region_code" => "11", - "region_name" => "Île-de-France" + "region_name" => "Île-de-France", + "title" => "Fondation SFR" }) end end From cfd568b98dcae27f7b5e19bf81212ccd6615cbca Mon Sep 17 00:00:00 2001 From: mfo Date: Fri, 15 Nov 2024 09:33:42 +0100 Subject: [PATCH 1523/1532] feat(rna): also store rna title --- app/models/champs/rna_champ.rb | 11 +++++++++++ .../rna_champ_association_fetchable_concern.rb | 2 +- app/tasks/maintenance/populate_rna_json_value_task.rb | 2 +- spec/controllers/champs/rna_controller_spec.rb | 3 ++- .../maintenance/populate_rna_json_value_task_spec.rb | 3 ++- 5 files changed, 17 insertions(+), 4 deletions(-) diff --git a/app/models/champs/rna_champ.rb b/app/models/champs/rna_champ.rb index 1f79cfdbd..b738922d8 100644 --- a/app/models/champs/rna_champ.rb +++ b/app/models/champs/rna_champ.rb @@ -13,6 +13,10 @@ class Champs::RNAChamp < Champ data&.dig("association_titre") end + def update_with_external_data!(data:) + update!(data:, value_json: extract_value_json(data:)) + end + def identifier title.present? ? "#{value} (#{title})" : value end @@ -41,4 +45,11 @@ class Champs::RNAChamp < Champ city_code: address["code_insee"] }.with_indifferent_access end + + private + + def extract_value_json(data:) + h = APIGeoService.parse_rna_address(data['adresse']) + h.merge(title: data['association_titre']) + end end diff --git a/app/models/concerns/rna_champ_association_fetchable_concern.rb b/app/models/concerns/rna_champ_association_fetchable_concern.rb index b1e95c97e..8a5f322e8 100644 --- a/app/models/concerns/rna_champ_association_fetchable_concern.rb +++ b/app/models/concerns/rna_champ_association_fetchable_concern.rb @@ -12,7 +12,7 @@ module RNAChampAssociationFetchableConcern return clear_association!(:invalid) unless valid_champ_value? return clear_association!(:not_found) if (data = APIEntreprise::RNAAdapter.new(rna, procedure_id).to_params).blank? - update!(data:, value_json: APIGeoService.parse_rna_address(data['adresse'])) + update_with_external_data!(data:) rescue APIEntreprise::API::Error, APIEntrepriseToken::TokenError => error if APIEntrepriseService.service_unavailable_error?(error, target: :djepva) clear_association!(:network_error) diff --git a/app/tasks/maintenance/populate_rna_json_value_task.rb b/app/tasks/maintenance/populate_rna_json_value_task.rb index 1998dea15..e366d3aee 100644 --- a/app/tasks/maintenance/populate_rna_json_value_task.rb +++ b/app/tasks/maintenance/populate_rna_json_value_task.rb @@ -14,7 +14,7 @@ module Maintenance return if champ&.dossier&.procedure&.id.blank? data = APIEntreprise::RNAAdapter.new(champ.value, champ&.dossier&.procedure&.id).to_params return if data.blank? - champ.update(value_json: APIGeoService.parse_rna_address(data['adresse'])) + champ.update_with_external_data!(data:) rescue URI::InvalidURIError # some Champs::RNAChamp contain spaces which raise this error rescue ActiveRecord::RecordNotFound diff --git a/spec/controllers/champs/rna_controller_spec.rb b/spec/controllers/champs/rna_controller_spec.rb index 9b149e8dc..3f5c59daf 100644 --- a/spec/controllers/champs/rna_controller_spec.rb +++ b/spec/controllers/champs/rna_controller_spec.rb @@ -134,7 +134,8 @@ describe Champs::RNAController, type: :controller do "region_name" => nil, "street_address" => "33 rue de Modagor", "street_name" => "de Modagor", - "street_number" => "33" + "street_number" => "33", + "title" => "LA PRÉVENTION ROUTIERE" }) end end diff --git a/spec/tasks/maintenance/populate_rna_json_value_task_spec.rb b/spec/tasks/maintenance/populate_rna_json_value_task_spec.rb index df727227c..e83600037 100644 --- a/spec/tasks/maintenance/populate_rna_json_value_task_spec.rb +++ b/spec/tasks/maintenance/populate_rna_json_value_task_spec.rb @@ -33,7 +33,8 @@ module Maintenance "departement_name" => nil, "department_name" => nil, "region_code" => nil, - "region_name" => nil + "region_name" => nil, + "title" => "LA PRÉVENTION ROUTIERE" }) end end From 9a70a9526de3529f0049b0fd541a52e073f54799 Mon Sep 17 00:00:00 2001 From: mfo Date: Fri, 15 Nov 2024 09:42:44 +0100 Subject: [PATCH 1524/1532] feat(rna/rnf): expose rnf/rna title --- .../concerns/addressable_column_concern.rb | 6 ++---- app/models/types_de_champ/rna_type_de_champ.rb | 16 ++++++++++++++++ app/models/types_de_champ/rnf_type_de_champ.rb | 16 ++++++++++++++++ app/models/types_de_champ/siret_type_de_champ.rb | 4 ++++ spec/factories/champ.rb | 4 ++-- spec/models/columns/champ_column_spec.rb | 2 ++ 6 files changed, 42 insertions(+), 6 deletions(-) diff --git a/app/models/concerns/addressable_column_concern.rb b/app/models/concerns/addressable_column_concern.rb index 7fdf75a28..b3a6edf73 100644 --- a/app/models/concerns/addressable_column_concern.rb +++ b/app/models/concerns/addressable_column_concern.rb @@ -4,8 +4,8 @@ module AddressableColumnConcern extend ActiveSupport::Concern included do - def columns(procedure:, displayable: true, prefix: nil) - addressable_columns = [ + def addressable_columns(procedure:, displayable: true, prefix: nil) + [ ["code postal (5 chiffres)", '$.postal_code', :text, []], ["commune", '$.city_name', :text, []], ["département", '$.departement_code', :enum, APIGeoService.departement_options], @@ -22,8 +22,6 @@ module AddressableColumnConcern type: ) end - - super.concat(addressable_columns) end end end diff --git a/app/models/types_de_champ/rna_type_de_champ.rb b/app/models/types_de_champ/rna_type_de_champ.rb index e60c34a89..e6c2ca915 100644 --- a/app/models/types_de_champ/rna_type_de_champ.rb +++ b/app/models/types_de_champ/rna_type_de_champ.rb @@ -10,4 +10,20 @@ class TypesDeChamp::RNATypeDeChamp < TypesDeChamp::TypeDeChampBase def champ_value_for_export(champ, path = :value) champ.identifier end + + def columns(procedure:, displayable: true, prefix: nil) + super + .concat(addressable_columns(procedure:, displayable:, prefix:)) + .concat([ + Columns::JSONPathColumn.new( + procedure_id: procedure.id, + stable_id:, + tdc_type: type_champ, + label: "#{libelle_with_prefix(prefix)} – Titre au répertoire national des associations", + type: :text, + jsonpath: '$.title', + displayable: + ) + ]) + end end diff --git a/app/models/types_de_champ/rnf_type_de_champ.rb b/app/models/types_de_champ/rnf_type_de_champ.rb index aacad9bae..e92989af3 100644 --- a/app/models/types_de_champ/rnf_type_de_champ.rb +++ b/app/models/types_de_champ/rnf_type_de_champ.rb @@ -35,6 +35,22 @@ class TypesDeChamp::RNFTypeDeChamp < TypesDeChamp::TextTypeDeChamp def champ_blank?(champ) = champ.external_id.blank? + def columns(procedure:, displayable: true, prefix: nil) + super + .concat(addressable_columns(procedure:, displayable:, prefix:)) + .concat([ + Columns::JSONPathColumn.new( + procedure_id: procedure.id, + stable_id:, + tdc_type: type_champ, + label: "#{libelle_with_prefix(prefix)} – Titre au répertoire national des fondations ", + type: :text, + jsonpath: '$.title', + displayable: + ) + ]) + end + private def paths diff --git a/app/models/types_de_champ/siret_type_de_champ.rb b/app/models/types_de_champ/siret_type_de_champ.rb index a75a091d2..5786a071f 100644 --- a/app/models/types_de_champ/siret_type_de_champ.rb +++ b/app/models/types_de_champ/siret_type_de_champ.rb @@ -8,4 +8,8 @@ class TypesDeChamp::SiretTypeDeChamp < TypesDeChamp::TypeDeChampBase end def champ_blank_or_invalid?(champ) = Siret.new(siret: champ.value).invalid? + + def columns(procedure:, displayable: true, prefix: nil) + super.concat(addressable_columns(procedure:, displayable:, prefix:)) + end end diff --git a/spec/factories/champ.rb b/spec/factories/champ.rb index 943fc5355..b84c93379 100644 --- a/spec/factories/champ.rb +++ b/spec/factories/champ.rb @@ -170,7 +170,7 @@ FactoryBot.define do factory :champ_do_not_use_rna, class: 'Champs::RNAChamp' do value { 'W173847273' } - value_json { AddressProxy::ADDRESS_PARTS.index_by(&:itself) } + value_json { AddressProxy::ADDRESS_PARTS.index_by(&:itself).merge(title: "LA PRÉVENTION ROUTIERE") } end factory :champ_do_not_use_engagement_juridique, class: 'Champs::EngagementJuridiqueChamp' do @@ -183,7 +183,7 @@ FactoryBot.define do factory :champ_do_not_use_rnf, class: 'Champs::RNFChamp' do value { '075-FDD-00003-01' } external_id { '075-FDD-00003-01' } - value_json { AddressProxy::ADDRESS_PARTS.index_by(&:itself) } + value_json { AddressProxy::ADDRESS_PARTS.index_by(&:itself).merge(title: "Fondation SFR") } end factory :champ_do_not_use_expression_reguliere, class: 'Champs::ExpressionReguliereChamp' do diff --git a/spec/models/columns/champ_column_spec.rb b/spec/models/columns/champ_column_spec.rb index ee5e0615c..a7dfa85bc 100644 --- a/spec/models/columns/champ_column_spec.rb +++ b/spec/models/columns/champ_column_spec.rb @@ -41,6 +41,8 @@ describe Columns::ChampColumn do expect_type_de_champ_values('mesri', eq([nil])) expect_type_de_champ_values('cojo', eq([nil])) expect_type_de_champ_values('expression_reguliere', eq([nil])) + expect_type_de_champ_values('rna', eq(["W173847273", "postal_code", "city_name", "departement_code", "region_name", "LA PRÉVENTION ROUTIERE"])) + expect_type_de_champ_values('rnf', eq(["075-FDD-00003-01", "postal_code", "city_name", "departement_code", "region_name", "Fondation SFR"])) end end From 5cf90eb103dabaf57edc43ba88803cbe121ae2c6 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 18 Nov 2024 09:51:06 +0100 Subject: [PATCH 1525/1532] fix(champ): no crash when drop_down_list with other champ value_json is nil --- app/models/types_de_champ/drop_down_list_type_de_champ.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/types_de_champ/drop_down_list_type_de_champ.rb b/app/models/types_de_champ/drop_down_list_type_de_champ.rb index 039d437bb..eee0c03b8 100644 --- a/app/models/types_de_champ/drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/drop_down_list_type_de_champ.rb @@ -12,6 +12,6 @@ class TypesDeChamp::DropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBase end def champ_with_other_value?(champ) - drop_down_other? && champ.value_json['other'] + drop_down_other? && champ.value_json&.fetch('other', false) end end From 2894897ad16ddd3cb81f9e2888d811e28bbf59a7 Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 18 Nov 2024 11:15:55 +0100 Subject: [PATCH 1526/1532] fix(export_template.zip): link must have kind param --- .../instructeurs/procedures/exports.html.haml | 2 +- ...procedure_export_template_tabular_spec.rb} | 2 +- .../procedure_export_template_zip_spec.rb | 29 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) rename spec/system/instructeurs/{procedure_export_tabular_spec.rb => procedure_export_template_tabular_spec.rb} (96%) create mode 100644 spec/system/instructeurs/procedure_export_template_zip_spec.rb diff --git a/app/views/instructeurs/procedures/exports.html.haml b/app/views/instructeurs/procedures/exports.html.haml index 3e92cd894..fad0a8022 100644 --- a/app/views/instructeurs/procedures/exports.html.haml +++ b/app/views/instructeurs/procedures/exports.html.haml @@ -37,7 +37,7 @@ .fr-mt-5w - = link_to t('.new_zip_export_template'), new_instructeur_procedure_export_template_path(@procedure), class: "fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line fr-mr-1w" + = link_to t('.new_zip_export_template'), new_instructeur_procedure_export_template_path(@procedure, kind: 'zip'), class: "fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line fr-mr-1w" = link_to t('.new_tabular_export_template'), new_instructeur_procedure_export_template_path(@procedure, kind: 'tabular'), class: "fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line" .fr-table.fr-table--bordered.fr-table--no-caption.fr-mt-5w diff --git a/spec/system/instructeurs/procedure_export_tabular_spec.rb b/spec/system/instructeurs/procedure_export_template_tabular_spec.rb similarity index 96% rename from spec/system/instructeurs/procedure_export_tabular_spec.rb rename to spec/system/instructeurs/procedure_export_template_tabular_spec.rb index 326bdbc30..6ed66c910 100644 --- a/spec/system/instructeurs/procedure_export_tabular_spec.rb +++ b/spec/system/instructeurs/procedure_export_template_tabular_spec.rb @@ -6,7 +6,7 @@ describe "procedure exports" do let(:types_de_champ_public) { [{ type: :text }] } before { login_as(instructeur.user, scope: :user) } - scenario "create an export_template tabular and u", js: true do + scenario "create an export_template tabular", js: true do Flipper.enable(:export_template, procedure) visit instructeur_procedure_path(procedure) diff --git a/spec/system/instructeurs/procedure_export_template_zip_spec.rb b/spec/system/instructeurs/procedure_export_template_zip_spec.rb new file mode 100644 index 000000000..44eb7b787 --- /dev/null +++ b/spec/system/instructeurs/procedure_export_template_zip_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +describe "procedure exports zip" do + let(:instructeur) { create(:instructeur) } + let(:procedure) { create(:procedure, :published, types_de_champ_public:, instructeurs: [instructeur]) } + let(:types_de_champ_public) { [{ type: :text }] } + before { login_as(instructeur.user, scope: :user) } + + scenario "create an export_template zip", chome: true do + visit instructeur_procedure_path(procedure) + + click_on "Voir les exports et modèles d'export" + + click_on "Modèles d'export" + + click_on "Créer un modèle d'export zip" + + fill_in "Nom du modèle", with: "Mon modèle" + expect(page).to have_content("Sélectionnez les fichiers que vous souhaitez exporter") + click_on "Enregistrer" + + find("#tabpanel-export-templates", wait: 5, visible: true) + find("#tabpanel-export-templates").click + + within 'table' do + expect(page).to have_content('Mon modèle') + end + end +end From babdf9536f27db9320c03042275cd25e3a7a0e4b Mon Sep 17 00:00:00 2001 From: mfo Date: Mon, 18 Nov 2024 12:35:33 +0100 Subject: [PATCH 1527/1532] fix(auto_archive_procedure_job): AutoArchiveProcedureJob may take longer than its cron delay [everyminutes], when it takes more than one minute, we re-enqueue the same mails --- .../auto_archive_procedure_dossiers_job.rb | 16 +++++++ app/jobs/cron/auto_archive_procedure_job.rb | 7 +-- ...uto_archive_procedure_dossiers_job_spec.rb | 44 +++++++++++++++++++ .../cron/auto_archive_procedure_job_spec.rb | 14 ------ 4 files changed, 62 insertions(+), 19 deletions(-) create mode 100644 app/jobs/auto_archive_procedure_dossiers_job.rb create mode 100644 spec/jobs/auto_archive_procedure_dossiers_job_spec.rb diff --git a/app/jobs/auto_archive_procedure_dossiers_job.rb b/app/jobs/auto_archive_procedure_dossiers_job.rb new file mode 100644 index 000000000..44b3387ab --- /dev/null +++ b/app/jobs/auto_archive_procedure_dossiers_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AutoArchiveProcedureDossiersJob < ApplicationJob + def perform(procedure) + procedure + .dossiers + .state_en_construction + .find_each do |d| + begin + d.passer_automatiquement_en_instruction! + rescue StandardError => e + Sentry.capture_exception(e, extra: { procedure_id: procedure.id }) + end + end + end +end diff --git a/app/jobs/cron/auto_archive_procedure_job.rb b/app/jobs/cron/auto_archive_procedure_job.rb index 130238cb9..6dd5593fb 100644 --- a/app/jobs/cron/auto_archive_procedure_job.rb +++ b/app/jobs/cron/auto_archive_procedure_job.rb @@ -2,18 +2,15 @@ class Cron::AutoArchiveProcedureJob < Cron::CronJob self.schedule_expression = "every 1 minute" + queue_as :critical def perform(*args) procedures_to_close.each do |procedure| # A buggy procedure should NEVER prevent the closing of another procedure # we therefore exceptionally add a `begin resue` block. begin - procedure - .dossiers - .state_en_construction - .find_each(&:passer_automatiquement_en_instruction!) - procedure.close! + AutoArchiveProcedureDossiersJob.perform_later(procedure) rescue StandardError => e Sentry.capture_exception(e, extra: { procedure_id: procedure.id }) end diff --git a/spec/jobs/auto_archive_procedure_dossiers_job_spec.rb b/spec/jobs/auto_archive_procedure_dossiers_job_spec.rb new file mode 100644 index 000000000..45423ea1e --- /dev/null +++ b/spec/jobs/auto_archive_procedure_dossiers_job_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +RSpec.describe AutoArchiveProcedureDossiersJob, type: :job do + let!(:procedure) { create(:procedure, :published, :with_instructeur) } + let!(:job) { AutoArchiveProcedureDossiersJob.new } + before do + procedure.auto_archive_on = 1.day.ago.to_date + procedure.save(validate: false) + end + subject { job.perform(procedure) } + + context "when procedures have auto_archive_on set on yesterday or today" do + let!(:dossier1) { create(:dossier, procedure: procedure) } + let!(:dossier2) { create(:dossier, :en_construction, procedure: procedure) } + let!(:dossier3) { create(:dossier, :en_construction, procedure: procedure) } + let!(:dossier4) { create(:dossier, :en_construction, procedure: procedure) } + let!(:dossier5) { create(:dossier, :en_instruction, procedure: procedure) } + let!(:dossier6) { create(:dossier, :accepte, procedure: procedure) } + let!(:dossier7) { create(:dossier, :refuse, procedure: procedure) } + let!(:dossier8) { create(:dossier, :sans_suite, procedure: procedure) } + let(:last_operation) { dossier2.dossier_operation_logs.last } + + before do + subject + + [dossier1, dossier2, dossier3, dossier4, dossier5, dossier6, dossier7, dossier8].each(&:reload) + + procedure.reload + end + + it { + expect(dossier1.state).to eq Dossier.states.fetch(:brouillon) + expect(dossier2.state).to eq Dossier.states.fetch(:en_instruction) + expect(last_operation.operation).to eq('passer_en_instruction') + expect(last_operation.automatic_operation?).to be_truthy + expect(dossier3.state).to eq Dossier.states.fetch(:en_instruction) + expect(dossier4.state).to eq Dossier.states.fetch(:en_instruction) + expect(dossier5.state).to eq Dossier.states.fetch(:en_instruction) + expect(dossier6.state).to eq Dossier.states.fetch(:accepte) + expect(dossier7.state).to eq Dossier.states.fetch(:refuse) + expect(dossier8.state).to eq Dossier.states.fetch(:sans_suite) + } + end +end diff --git a/spec/jobs/cron/auto_archive_procedure_job_spec.rb b/spec/jobs/cron/auto_archive_procedure_job_spec.rb index 36bf8b96f..f24789c8f 100644 --- a/spec/jobs/cron/auto_archive_procedure_job_spec.rb +++ b/spec/jobs/cron/auto_archive_procedure_job_spec.rb @@ -46,20 +46,6 @@ RSpec.describe Cron::AutoArchiveProcedureJob, type: :job do procedure_aujourdhui.reload end - it { - expect(dossier1.state).to eq Dossier.states.fetch(:brouillon) - expect(dossier2.state).to eq Dossier.states.fetch(:en_instruction) - expect(last_operation.operation).to eq('passer_en_instruction') - expect(last_operation.automatic_operation?).to be_truthy - expect(dossier3.state).to eq Dossier.states.fetch(:en_instruction) - expect(dossier4.state).to eq Dossier.states.fetch(:en_instruction) - expect(dossier5.state).to eq Dossier.states.fetch(:en_instruction) - expect(dossier6.state).to eq Dossier.states.fetch(:accepte) - expect(dossier7.state).to eq Dossier.states.fetch(:refuse) - expect(dossier8.state).to eq Dossier.states.fetch(:sans_suite) - expect(dossier9.state).to eq Dossier.states.fetch(:en_instruction) - } - it { expect(procedure_hier.close?).to eq true expect(procedure_aujourdhui.close?).to eq true From 3903593b90f64bc7d40b8ea6bf0417e23732066a Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 19 Nov 2024 10:20:18 +0100 Subject: [PATCH 1528/1532] fix(export): re-add missing geojson export --- .../export_dropdown_component.html.haml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml index e9c41e5be..cbde910f2 100644 --- a/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml +++ b/app/components/dossiers/export_dropdown_component/export_dropdown_component.html.haml @@ -38,6 +38,12 @@ = f.label :export_format_zip do Fichier zip %span.fr-hint-text ne contient pas l'horodatage ni le journal de log + - if allowed_format?({format: :json}) + .fr-fieldset__element + .fr-radio-group + = f.radio_button :export_format, 'json' + = f.label :export_format_json do + Fichier geojson .fr-fieldset__element %ul.fr-btns-group.fr-btns-group--sm.fr-btns-group--inline From fa64e8f1123628156b75f5f8c17d8a061a9e71d3 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 15 Nov 2024 16:44:32 +0100 Subject: [PATCH 1529/1532] fix: change `>` char in favor of `.` in linked_drop_column.column_id Rails `'>'.to_json` produce `\u003e` because of an html entity escaping system to work around some bugs in browser (see https://github.com/rails/rails/blob/dd8f7185faeca6ee968a6e9367f6d8601a83b8db/activesupport/lib/active_support/json/encoding.rb#L43 ) But our waf dislike `\u003e` and reject xhr request with such char --- app/models/columns/linked_drop_down_column.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/columns/linked_drop_down_column.rb b/app/models/columns/linked_drop_down_column.rb index 6399137b0..c7611168e 100644 --- a/app/models/columns/linked_drop_down_column.rb +++ b/app/models/columns/linked_drop_down_column.rb @@ -43,7 +43,7 @@ class Columns::LinkedDropDownColumn < Columns::ChampColumn private - def column_id = "type_de_champ/#{stable_id}->#{path}" + def column_id = "type_de_champ/#{stable_id}.#{path}" def typed_value(champ) primary_value, secondary_value = unpack_values(champ.value) From aa0b7f53ef847cbbbf040bfbf68b8c4ca7ff3ea8 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 15 Nov 2024 16:45:32 +0100 Subject: [PATCH 1530/1532] small comment explaining why we need to ensure used columns are present in `procedure.columns` --- app/models/concerns/columns_concern.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 5d61dc982..1e02c7370 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -37,6 +37,7 @@ module ColumnsConcern columns.concat(procedure_chorus_columns) if chorusable? && chorus_configuration.complete? # ensure the columns exist in main list + # otherwise, they will be found by the find_column method columns.filter { _1.id.in?(self.columns.map(&:id)) } end @@ -48,6 +49,7 @@ module ColumnsConcern columns.concat([groupe_instructeurs_id_column, followers_instructeurs_email_column]) # ensure the columns exist in main list + # otherwise, they will be found by the find_column method columns.filter { _1.id.in?(self.columns.map(&:id)) } end From 38e998c2792aa949d73a829917269a576f45a2c6 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 15 Nov 2024 17:00:44 +0100 Subject: [PATCH 1531/1532] fix: allow previous id with `->` to work --- app/models/concerns/columns_concern.rb | 7 +++++++ spec/models/concerns/columns_concern_spec.rb | 14 +++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index 1e02c7370..d845bd7d7 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -12,6 +12,13 @@ module ColumnsConcern column = columns.find { _1.h_id == h_id } if h_id.present? column = columns.find { _1.label == label } if label.present? + # TODO: to remove after linked_drop_down column column_id migration + if column.nil? && h_id.is_a?(Hash) && h_id[:column_id].present? + h_id[:column_id].gsub!('->', '.') + + column = columns.find { _1.h_id == h_id } + end + raise ActiveRecord::RecordNotFound.new("Column: unable to find h_id: #{h_id} or label: #{label} for procedure_id #{id}") if column.nil? column diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index f6666517f..8d68c87f2 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -4,7 +4,8 @@ describe ColumnsConcern do let(:procedure_id) { procedure.id } describe '#find_column' do - let(:procedure) { build(:procedure) } + let(:types_de_champ_public) { [{ type: :linked_drop_down_list, libelle: 'linked' }] } + let(:procedure) { create(:procedure, types_de_champ_public:) } let(:notifications_column) { procedure.notifications_column } it do @@ -16,6 +17,17 @@ describe ColumnsConcern do unknwon = 'unknown' expect { procedure.find_column(h_id: unknwon) }.to raise_error(ActiveRecord::RecordNotFound) + + value_column = procedure.find_column(label: 'linked') + + procedure_id = procedure.id + linked_tdc = procedure.active_revision.types_de_champ + .find { _1.type_champ == 'linked_drop_down_list' } + + column_id = "type_de_champ/#{linked_tdc.stable_id}->value" + + h_id = { procedure_id:, column_id: } + expect(procedure.find_column(h_id:)).to eq(value_column) end end From fca8f72cd60c00e74d7735ec13e4e3a22e8e1244 Mon Sep 17 00:00:00 2001 From: Jean-Marc GAILIS Date: Tue, 28 Nov 2023 17:45:19 +0100 Subject: [PATCH 1532/1532] Modifications DGNum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ajout des NDD usuels ENS et DGNum pour autoriser les changements d'adresse mail correction des svg des logos DGNum et DN remplacement Marianne - pour l'instant à l'arrache uniquement, logo sera repris et affiné plus tard modifs Mariannes commenter quelques bouts de code inutiles dans l'usage DGNum de DS Update logo-ds.svg and delete commented lines correction logo DN pour pages d'erreur normalianisation de DN, texte modifs sur le fichier en anglais modifs diverses vers version DN Use our logo in the header Add the logo-wide under a new name feat: Update footer feat: Remove mentions of faq.demarches-simplifiees.fr feat: Replace documentation link feat: Add analytics feat: Remove France Services logo on procedure footer --- app/assets/images/centered_marianne.svg | 265 ++++++++++++- app/assets/images/centered_marianne.svg.old | 1 + app/assets/images/dgnum.svg | 204 ++++++++++ app/assets/images/header/logo-dn-wide.png | Bin 0 -> 28181 bytes app/assets/images/header/logo-ds-narrow.svg | 335 +++++++++++++++- app/assets/images/header/logo-ds-wide.png | Bin 18553 -> 28181 bytes app/assets/images/header/logo-ds-wide.png.old | Bin 0 -> 18553 bytes app/assets/images/header/logo-ds-wide.svg | 366 +++++++++++++++++- app/assets/images/header/logo-ds-wide.svg.old | 1 + .../images/header/logo-ds-wide_source.svg | 177 +++++++++ app/assets/images/header/logo-ds.svg | 360 ++++++++++++++++- .../instructeur_mailer/logo-ds-wide.jpg | Bin 0 -> 13772 bytes .../instructeur_mailer/logo-ds-wide.png | Bin 0 -> 61436 bytes .../instructeur_mailer/logo-ds-wide.svg | 348 +++++++++++++++++ .../images/mailer/instructeur_mailer/logo.png | Bin 0 -> 12234 bytes .../mailer/instructeur_mailer/logo.png.old | Bin 0 -> 6773 bytes app/assets/images/marianne.png | Bin 508 -> 3574 bytes app/assets/images/marianne.svg | 202 +++++++++- app/assets/images/marianne.svg.old | 1 + .../images/republique-francaise-logo.svg | 204 +++++++++- .../images/republique-francaise-logo.svg.old | 1 + .../autosave_footer_component.html.haml | 4 - .../export_dropdown_component.en.yml | 2 +- .../export_dropdown_component.fr.yml | 2 +- .../administrateurs/_breadcrumbs.html.haml | 1 - .../procedures/_publication_form.html.haml | 1 - app/views/layouts/_header.haml | 5 +- app/views/layouts/application.html.haml | 2 + .../layouts/commencer/_no_procedure.html.haml | 2 - app/views/root/_footer.html.haml | 10 - app/views/root/administration.html.haml | 21 - app/views/root/landing.html.haml | 10 - app/views/root/suivi.html.haml | 4 +- .../shared/_footer_content_list.html.haml | 8 +- .../champs/siret/_etablissement.html.haml | 1 - .../help/_help_dropdown_dossier.html.haml | 3 - .../help/_help_dropdown_instructeur.html.haml | 2 - .../help/_help_dropdown_procedure.html.haml | 2 - app/views/users/_procedure_footer.html.haml | 3 - app/views/users/sessions/link_sent.html.haml | 3 - .../initializers/content_security_policy.rb | 10 +- config/initializers/images.rb | 4 +- config/initializers/legit_admin_domains.rb | 2 +- config/locales/fr.yml | 35 +- config/locales/links.en.yml | 29 +- config/locales/links.fr.yml | 39 +- .../views/administrateurs/procedures/en.yml | 4 +- .../views/administrateurs/procedures/fr.yml | 4 +- .../views/users/procedure_footer/en.yml | 4 - .../views/users/procedure_footer/fr.yml | 25 +- public/logo-ds.svg | 366 +++++++++++++++++- spec/system/help_spec.rb | 7 - spec/views/layouts/_header_spec.rb | 8 - 53 files changed, 2896 insertions(+), 192 deletions(-) create mode 100644 app/assets/images/centered_marianne.svg.old create mode 100644 app/assets/images/dgnum.svg create mode 100644 app/assets/images/header/logo-dn-wide.png create mode 100644 app/assets/images/header/logo-ds-wide.png.old create mode 100644 app/assets/images/header/logo-ds-wide.svg.old create mode 100644 app/assets/images/header/logo-ds-wide_source.svg create mode 100644 app/assets/images/mailer/instructeur_mailer/logo-ds-wide.jpg create mode 100644 app/assets/images/mailer/instructeur_mailer/logo-ds-wide.png create mode 100644 app/assets/images/mailer/instructeur_mailer/logo-ds-wide.svg create mode 100644 app/assets/images/mailer/instructeur_mailer/logo.png create mode 100644 app/assets/images/mailer/instructeur_mailer/logo.png.old create mode 100644 app/assets/images/marianne.svg.old create mode 100644 app/assets/images/republique-francaise-logo.svg.old diff --git a/app/assets/images/centered_marianne.svg b/app/assets/images/centered_marianne.svg index 5e812262d..e3ee37875 100644 --- a/app/assets/images/centered_marianne.svg +++ b/app/assets/images/centered_marianne.svg @@ -1 +1,264 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/centered_marianne.svg.old b/app/assets/images/centered_marianne.svg.old new file mode 100644 index 000000000..5e812262d --- /dev/null +++ b/app/assets/images/centered_marianne.svg.old @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/dgnum.svg b/app/assets/images/dgnum.svg new file mode 100644 index 000000000..d55a032c2 --- /dev/null +++ b/app/assets/images/dgnum.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/header/logo-dn-wide.png b/app/assets/images/header/logo-dn-wide.png new file mode 100644 index 0000000000000000000000000000000000000000..37e16e51c655bfabeaed07820c8b2509ad502ff3 GIT binary patch literal 28181 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 VXt<*oUg?H9rE5eiJmwJb{{TCFRLKAU literal 0 HcmV?d00001 diff --git a/app/assets/images/header/logo-ds-narrow.svg b/app/assets/images/header/logo-ds-narrow.svg index 437c271ee..e20eb1fd0 100644 --- a/app/assets/images/header/logo-ds-narrow.svg +++ b/app/assets/images/header/logo-ds-narrow.svg @@ -1 +1,334 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/header/logo-ds-wide.png b/app/assets/images/header/logo-ds-wide.png index a0ac0f35319574a377870e6f6150bf2d21b2e8d9..37e16e51c655bfabeaed07820c8b2509ad502ff3 100644 GIT binary patch literal 28181 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 VXt<*oUg?H9rE5eiJmwJb{{TCFRLKAU literal 18553 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 diff --git a/app/assets/images/header/logo-ds-wide.png.old b/app/assets/images/header/logo-ds-wide.png.old new file mode 100644 index 0000000000000000000000000000000000000000..a0ac0f35319574a377870e6f6150bf2d21b2e8d9 GIT binary patch literal 18553 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/header/logo-ds-wide.svg b/app/assets/images/header/logo-ds-wide.svg index 3fb67e18a..fe68f0999 100644 --- a/app/assets/images/header/logo-ds-wide.svg +++ b/app/assets/images/header/logo-ds-wide.svg @@ -1 +1,365 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/header/logo-ds-wide.svg.old b/app/assets/images/header/logo-ds-wide.svg.old new file mode 100644 index 000000000..3fb67e18a --- /dev/null +++ b/app/assets/images/header/logo-ds-wide.svg.old @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/header/logo-ds-wide_source.svg b/app/assets/images/header/logo-ds-wide_source.svg new file mode 100644 index 000000000..7cdf0acff --- /dev/null +++ b/app/assets/images/header/logo-ds-wide_source.svg @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DGNUM + Délégation GénéraleNumérique + + démarches normaliennes + + diff --git a/app/assets/images/header/logo-ds.svg b/app/assets/images/header/logo-ds.svg index eda8aa5eb..42ae8f7f7 100644 --- a/app/assets/images/header/logo-ds.svg +++ b/app/assets/images/header/logo-ds.svg @@ -1 +1,359 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/mailer/instructeur_mailer/logo-ds-wide.jpg b/app/assets/images/mailer/instructeur_mailer/logo-ds-wide.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc3ba3e411c4fb65db22c5e68ee38bda41b20385 GIT binary patch literal 13772 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 NVS`mGUOZoy{ujDNIRpRz literal 0 HcmV?d00001 diff --git a/app/assets/images/mailer/instructeur_mailer/logo-ds-wide.png b/app/assets/images/mailer/instructeur_mailer/logo-ds-wide.png new file mode 100644 index 0000000000000000000000000000000000000000..f9453b783c8e87757c46868727714b537ce08ca6 GIT binary patch literal 61436 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 IbxsLQ0QvaF!~g&Q diff --git a/app/assets/images/marianne.svg b/app/assets/images/marianne.svg index 9690583ea..3cb2174a5 100644 --- a/app/assets/images/marianne.svg +++ b/app/assets/images/marianne.svg @@ -1 +1,201 @@ - \ No newline at end of file + + diff --git a/app/assets/images/marianne.svg.old b/app/assets/images/marianne.svg.old new file mode 100644 index 000000000..9690583ea --- /dev/null +++ b/app/assets/images/marianne.svg.old @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/republique-francaise-logo.svg b/app/assets/images/republique-francaise-logo.svg index 0f1cd3d4c..377882ec5 100644 --- a/app/assets/images/republique-francaise-logo.svg +++ b/app/assets/images/republique-francaise-logo.svg @@ -1 +1,203 @@ - \ No newline at end of file + + diff --git a/app/assets/images/republique-francaise-logo.svg.old b/app/assets/images/republique-francaise-logo.svg.old new file mode 100644 index 000000000..0f1cd3d4c --- /dev/null +++ b/app/assets/images/republique-francaise-logo.svg.old @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/components/dossiers/autosave_footer_component/autosave_footer_component.html.haml b/app/components/dossiers/autosave_footer_component/autosave_footer_component.html.haml index 28039d90c..3a244233a 100644 --- a/app/components/dossiers/autosave_footer_component/autosave_footer_component.html.haml +++ b/app/components/dossiers/autosave_footer_component/autosave_footer_component.html.haml @@ -7,8 +7,6 @@ = t('.en_construction.explanation') - else = t('.brouillon.explanation') - - if !annotation? - = link_to t('.more_information'), t("links.common.faq.autosave_url"), class: 'autosave-more-infos fr-link fr-link--sm', **external_link_attributes %p.autosave-status.succeeded.fr-mb-0 = dsfr_icon('fr-icon-checkbox-circle-fill fr-text-default--success autosave-icon') @@ -19,8 +17,6 @@ = t('.en_construction.confirmation') - else = t('.brouillon.confirmation') - - if !annotation? - = link_to t('.more_information'), t("links.common.faq.autosave_url"), class: 'autosave-more-infos fr-link fr-link--sm', **external_link_attributes %p.autosave-status.failed.fr-mb-0 %span.autosave-icon ⚠️ diff --git a/app/components/dossiers/export_dropdown_component/export_dropdown_component.en.yml b/app/components/dossiers/export_dropdown_component/export_dropdown_component.en.yml index d48aa0879..b0f1c0313 100644 --- a/app/components/dossiers/export_dropdown_component/export_dropdown_component.en.yml +++ b/app/components/dossiers/export_dropdown_component/export_dropdown_component.en.yml @@ -12,4 +12,4 @@ en: other: Download %{count} files macros_doc: title: "Macros documentation" - url: "https://doc.demarches-simplifiees.fr/pour-aller-plus-loin/exports-et-macros" + url: "https://docs.dgnum.eu/s/demarches-normaliennes/doc/exports-et-macros-sOxubsFKJd" diff --git a/app/components/dossiers/export_dropdown_component/export_dropdown_component.fr.yml b/app/components/dossiers/export_dropdown_component/export_dropdown_component.fr.yml index d5646ce34..8121125e3 100644 --- a/app/components/dossiers/export_dropdown_component/export_dropdown_component.fr.yml +++ b/app/components/dossiers/export_dropdown_component/export_dropdown_component.fr.yml @@ -12,4 +12,4 @@ fr: other: Télécharger %{count} dossiers macros_doc: title: "documentation sur les macros" - url: "https://doc.demarches-simplifiees.fr/pour-aller-plus-loin/exports-et-macros" + url: "https://docs.dgnum.eu/s/demarches-normaliennes/doc/exports-et-macros-sOxubsFKJd" diff --git a/app/views/administrateurs/_breadcrumbs.html.haml b/app/views/administrateurs/_breadcrumbs.html.haml index 9264a128e..6c64f233e 100644 --- a/app/views/administrateurs/_breadcrumbs.html.haml +++ b/app/views/administrateurs/_breadcrumbs.html.haml @@ -40,7 +40,6 @@ - else %p.fr-mb-1w = t('more_info_on_test', scope: [:layouts, :breadcrumb]) - = link_to t('go_to_FAQ', scope: [:layouts, :breadcrumb]), t("url_FAQ", scope: [:layouts, :breadcrumb]), title: new_tab_suffix(t('go_to_FAQ', scope: [:layouts, :breadcrumb])) .flex %span.fr-badge.fr-badge--new.fr-mr-1w = t('draft', scope: [:layouts, :breadcrumb]) diff --git a/app/views/administrateurs/procedures/_publication_form.html.haml b/app/views/administrateurs/procedures/_publication_form.html.haml index d8d96870e..284206699 100644 --- a/app/views/administrateurs/procedures/_publication_form.html.haml +++ b/app/views/administrateurs/procedures/_publication_form.html.haml @@ -16,7 +16,6 @@ - c.with_body do %p = t('.faq_test_alert') - = link_to t('.faq_test_alert_link'), t('.faq_test_alert_link_url') = render partial: 'publication_form_inputs', locals: { procedure: procedure, closed_procedures: @closed_procedures, form: f } = render Dsfr::CalloutComponent.new(title: t('.dpd_title'), heading_level: 'h2') do |c| - c.with_body do diff --git a/app/views/layouts/_header.haml b/app/views/layouts/_header.haml index d65475e72..5b6488051 100644 --- a/app/views/layouts/_header.haml +++ b/app/views/layouts/_header.haml @@ -15,10 +15,7 @@ .fr-header__brand.fr-enlarge-link .fr-header__brand-top .fr-header__logo - %p.fr-logo{ lang: "fr" } - République - = succeed "Française" do - %br/ + %img{ :src => image_url("dgnum.svg"), alt: '', width: 105, height: 55.6, loading: 'lazy' } .fr-header__navbar - if is_search_enabled %button.fr-btn--search.fr-btn{ "aria-controls" => "search-modal", "data-fr-opened" => "false", :title => t('views.users.dossiers.search.search_file') }= t('views.users.dossiers.search.search_file') diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 91fc4699e..6c61cb438 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -9,6 +9,8 @@ %meta{ name: "format-detection", content: "telephone=no,date=no,address=no,email=no,url=no" } = csrf_meta_tags + %script{ defer: true, data: { domain: "demarches.dgnum.eu" }, src: "https://analytics.dgnum.eu/js/script.js" } + %title = content_for?(:title) ? "#{sanitize(yield(:title))} · #{Current.application_name}" : Current.application_name diff --git a/app/views/layouts/commencer/_no_procedure.html.haml b/app/views/layouts/commencer/_no_procedure.html.haml index ea58dcfe2..a3465ac22 100644 --- a/app/views/layouts/commencer/_no_procedure.html.haml +++ b/app/views/layouts/commencer/_no_procedure.html.haml @@ -2,5 +2,3 @@ = image_tag "landing/hero/dematerialiser.svg", class: "fr-responsive-img fr-mb-1v", alt: "", "aria-hidden": "true" %p.fr-m-4w= t('.text') %hr - %p= t('.are_you_new', app_name: Current.application_name) - = link_to t('views.users.sessions.new.find_procedure'), t("links.common.faq.comment_trouver_ma_demarche_url"), title: new_tab_suffix(t('views.users.sessions.new.find_procedure')), class: "fr-btn fr-btn--secondary" diff --git a/app/views/root/_footer.html.haml b/app/views/root/_footer.html.haml index 93957ae82..8c0ac4015 100644 --- a/app/views/root/_footer.html.haml +++ b/app/views/root/_footer.html.haml @@ -32,10 +32,6 @@ = link_to t("links.footer.doc.label"), t("links.footer.doc.url"), title: t("links.footer.doc.title"), class: "fr-footer__top-link", rel: "noopener noreferrer", hreflang: "fr" %li = link_to t("links.footer.api_doc.label"), t("links.footer.api_doc.url"), title: t("links.footer.api_doc.title"), class: "fr-footer__top-link", rel: "noopener noreferrer", hreflang: "fr" - %li - %a.fr-footer__top-link{ :href => t("links.common.faq.url"), :rel => "noopener noreferrer" } - %abbr{ title: t("links.common.faq.title") } - = t("links.common.faq.label") %li = link_to t("links.footer.code.label"), t("links.footer.code.url"), class: "fr-footer__top-link", rel: "noopener noreferrer", hreflang: "fr" .fr-col-12.fr-col-sm-3.fr-col-md-3 @@ -47,12 +43,6 @@ = link_to t("links.footer.security.label"), t("links.footer.security.url"), title: t("links.footer.security.title"), class: "fr-footer__top-link", rel: "noopener noreferrer", hreflang: "fr" .fr-container .fr-footer__body - .fr-footer__brand.fr-enlarge-link{ lang: "fr" } - %p.fr-logo - gouvernement - = link_to t("links.footer.dinum.url"), title: t("links.footer.dinum.title"), hreflang:'fr', class: "fr-footer__brand-link" do - = image_tag("footer/logo-dinum.svg", class: "fr-footer__logo logo-beta-gouv-fr", alt: t("links.footer.dinum.alt")) - .fr-footer__content %p.fr-footer__content-desc = t('links.footer.description_1') = link_to(t('links.footer.link_1_label'), t('links.footer.link_1_url'), title: new_tab_suffix(t('links.footer.link_1_label')), hreflang:'fr', **external_link_attributes) + "." diff --git a/app/views/root/administration.html.haml b/app/views/root/administration.html.haml index 2b7aac579..cf1687a5a 100644 --- a/app/views/root/administration.html.haml +++ b/app/views/root/administration.html.haml @@ -16,13 +16,6 @@ .fr-py-6w.fr-background-alt--blue-france .container .role-panel-wrapper.role-administrations-panel - .role-panel-70 - %h2 Est-ce fait pour mon administration ? - %p.fr-h5 Découvrez notre outil et posez nous vos questions lors de notre démonstration en ligne ou lisez notre documentation - - = link_to "Consulter notre vidéo de démonstration", DEMO_VIDEO_URL, class: "fr-btn fr-btn--lg fr-mr-1w fr-mb-2w", **external_link_attributes - = link_to "Documentation", DOC_URL, class: "fr-btn fr-btn--secondary fr-btn--lg", **external_link_attributes - .role-panel-30.role-more-info-image.fr-mt-2w %img.role-image{ :src => image_url("landing/roles/usagers.svg"), alt: "" } @@ -99,17 +92,3 @@ = render Dsfr::CardVerticalComponent.new(title: "Vous êtes prêt pour dématérialiser ?", desc: "Réduisez vos temps d’instruction de 50 %") do |c| - c.with_footer_button do = link_to("Créer votre compte administrateur", DEMANDE_INSCRIPTION_ADMIN_PAGE_URL, class: "fr-btn", **external_link_attributes) - - .fr-col-md-6.fr-col-12 - = render Dsfr::CardVerticalComponent.new(title: "Vous voulez en savoir plus ?", desc: "Participez à notre prochain Webinaire") do |c| - - c.with_footer_button do - = link_to("Inscription à notre prochain webinaire", INSCRIPTION_WEBINAIRE_URL, class: "fr-btn", **external_link_attributes) - - .fr-py-6w.fr-background-alt--blue-france - .container - .cta-panel-wrapper - %div - %h2 Une question, un problème ? - %p.fr-h5 Consultez notre FAQ - %div - = link_to "Voir la FAQ", t("links.common.faq.url"), class: "fr-btn fr-btn--lg", **external_link_attributes diff --git a/app/views/root/landing.html.haml b/app/views/root/landing.html.haml index 18e879622..564735896 100644 --- a/app/views/root/landing.html.haml +++ b/app/views/root/landing.html.haml @@ -23,7 +23,6 @@ %h2= t(".have_a_procedure") %p.fr-h5= t(".fill_procedure") - = link_to t(".how_to_find_procedure"), t("links.common.faq.comment_trouver_ma_demarche_url"), class: "fr-btn fr-btn--lg fr-mr-1w fr-mb-2w", title: new_tab_suffix(t(".how_to_find_procedure")) = link_to t("views.users.sessions.new.connection"), new_user_session_path, class: "fr-btn fr-btn--secondary fr-btn--lg" .fr-py-6w @@ -44,15 +43,6 @@ %dd.number-value = "#{number_with_delimiter(50)} %" - .fr-background-alt--blue-france.fr-py-6w - .container - .cta-panel-wrapper - %div - %h2= t(".question") - %p.fr-h5= t(".answer_in_faq") - %div - = link_to t(".online_help"), t("links.common.faq.url"), class: "fr-btn fr-btn--lg", title: t(".online_help") - .fr-py-6w .container .cta-panel-wrapper diff --git a/app/views/root/suivi.html.haml b/app/views/root/suivi.html.haml index 7ddcb9a7f..af08c0d0c 100644 --- a/app/views/root/suivi.html.haml +++ b/app/views/root/suivi.html.haml @@ -8,8 +8,6 @@ %p Ce site dépose un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le consultez. Cela nous permet de mesurer le nombre de visites et de comprendre quelles sont les pages les plus consultées. - %iframe{ :src => MATOMO_IFRAME_URL } - %h2.fr-my-4w Ce site n’affiche pas de bannière de consentement aux cookies, pourquoi ? %p C’est vrai, vous n’avez pas eu à cliquer sur un bloc qui recouvre la moitié de la page pour dire que vous êtes d’accord avec le dépôt de cookies. @@ -18,7 +16,7 @@ Rien d’exceptionnel, pas de passe-droit. Nous respectons simplement la loi, qui dit que certains outils de suivi d’audience, correctement configurés pour respecter la vie privée, sont exemptés d’autorisation préalable. %br %br - Nous utilisons pour cela Matomo, un outil libre, paramétré pour être en conformité avec la recommandation « Cookies » de la CNIL. Cela signifie que votre adresse IP, par exemple, est anonymisée avant d’être enregistrée. Il est donc impossible d’associer vos visites sur ce site à votre personne. + Nous utilisons pour cela Plausible, un outil libre, paramétré pour être en conformité avec la recommandation « Cookies » de la CNIL. Cela signifie que votre adresse IP, par exemple, est anonymisée avant d’être enregistrée. Il est donc impossible d’associer vos visites sur ce site à votre personne. %h2.fr-my-4w Comment désactiver le suivi statistique sur mon navigateur ? %p diff --git a/app/views/shared/_footer_content_list.html.haml b/app/views/shared/_footer_content_list.html.haml index af1f5f533..03d4009df 100644 --- a/app/views/shared/_footer_content_list.html.haml +++ b/app/views/shared/_footer_content_list.html.haml @@ -1,9 +1,5 @@ %ul.fr-footer__content-list %li.fr-footer__content-item - = link_to t('users.procedure_footer.official_links.legifrance.title'), t('users.procedure_footer.official_links.legifrance.url'), title: new_tab_suffix(t('users.procedure_footer.official_links.legifrance.title')), class: 'fr-footer__content-link', hreflang: 'fr', **external_link_attributes + = link_to t('users.procedure_footer.official_links.dgnum.title'), t('users.procedure_footer.official_links.dgnum.url'), title: new_tab_suffix(t('users.procedure_footer.official_links.dgnum.title')), class: 'fr-footer__content-link', **external_link_attributes %li.fr-footer__content-item - = link_to t('users.procedure_footer.official_links.gouvernement.title'), t('users.procedure_footer.official_links.gouvernement.url'), title: new_tab_suffix(t('users.procedure_footer.official_links.gouvernement.title')), class: 'fr-footer__content-link', hreflang:'fr', **external_link_attributes - %li.fr-footer__content-item - = link_to t('users.procedure_footer.official_links.service_public.title'), t('users.procedure_footer.official_links.service_public.url'), title: new_tab_suffix(t('users.procedure_footer.official_links.service_public.title')), class: 'fr-footer__content-link', hreflang:'fr', **external_link_attributes - %li.fr-footer__content-item - = link_to t('users.procedure_footer.official_links.data_gouv.title'), t('users.procedure_footer.official_links.data_gouv.url'), title: new_tab_suffix(t('users.procedure_footer.official_links.data_gouv.title')), class: 'fr-footer__content-link', hreflang:'fr', **external_link_attributes + = link_to t('users.procedure_footer.official_links.ens.title'), t('users.procedure_footer.official_links.ens.url'), title: new_tab_suffix(t('users.procedure_footer.official_links.ens.title')), class: 'fr-footer__content-link', **external_link_attributes diff --git a/app/views/shared/champs/siret/_etablissement.html.haml b/app/views/shared/champs/siret/_etablissement.html.haml index 6aec8510c..fa4c07aeb 100644 --- a/app/views/shared/champs/siret/_etablissement.html.haml +++ b/app/views/shared/champs/siret/_etablissement.html.haml @@ -8,7 +8,6 @@ - when :not_found %p.fr-error-text Nous n’avons pas trouvé d’établissement correspondant à ce numéro de SIRET. - = link_to('Plus d’informations', t("links.common.faq.erreur_siret_url")) - when :network_error %p.fr-error-text= t('errors.messages.siret_network_error') diff --git a/app/views/shared/help/_help_dropdown_dossier.html.haml b/app/views/shared/help/_help_dropdown_dossier.html.haml index acf2d247a..82fc2e87a 100644 --- a/app/views/shared/help/_help_dropdown_dossier.html.haml +++ b/app/views/shared/help/_help_dropdown_dossier.html.haml @@ -15,6 +15,3 @@ %li.flex = render partial: 'shared/help/dropdown_items/service_item', locals: { service: dossier.procedure.service, title: title } - - %li.flex - = render partial: 'shared/help/dropdown_items/faq_item' diff --git a/app/views/shared/help/_help_dropdown_instructeur.html.haml b/app/views/shared/help/_help_dropdown_instructeur.html.haml index 62ade998f..184977ba2 100644 --- a/app/views/shared/help/_help_dropdown_instructeur.html.haml +++ b/app/views/shared/help/_help_dropdown_instructeur.html.haml @@ -5,7 +5,5 @@ #help-menu.help-content.fr-collapse.fr-menu %ul.fr-menu__list - %li.flex - = render partial: 'shared/help/dropdown_items/faq_item' %li = render partial: 'shared/help/dropdown_items/email_item' diff --git a/app/views/shared/help/_help_dropdown_procedure.html.haml b/app/views/shared/help/_help_dropdown_procedure.html.haml index 8718d1ea0..5e5daa1b5 100644 --- a/app/views/shared/help/_help_dropdown_procedure.html.haml +++ b/app/views/shared/help/_help_dropdown_procedure.html.haml @@ -8,5 +8,3 @@ - if procedure.service.present? %li.flex = render partial: 'shared/help/dropdown_items/service_item', locals: { service: procedure.service, title: t('help_dropdown.procedure_title') } - %li.flex - = render partial: 'shared/help/dropdown_items/faq_item' diff --git a/app/views/users/_procedure_footer.html.haml b/app/views/users/_procedure_footer.html.haml index d4b149ec1..74a993f88 100644 --- a/app/views/users/_procedure_footer.html.haml +++ b/app/views/users/_procedure_footer.html.haml @@ -37,9 +37,6 @@ .fr-download = link_to I18n.t('users.procedure_footer.dematerialisation.title_1'), commencer_dossier_vide_for_revision_path(procedure.active_revision), download: 'true', class: 'fr-download__link' %h3.fr-footer__top-cat= I18n.t('users.procedure_footer.support.header') - .fr-footer__brand.fr-enlarge-link - = link_to t("users.procedure_footer.dematerialisation.link"), title: t("users.procedure_footer.dematerialisation.alt"), class: "fr-footer__brand-link" do - = image_tag("footer/logo-france-services.svg", class: "fr-footer__logo logo-france-service-fr", alt: t("users.procedure_footer.dematerialisation.alt")) .fr-footer__bottom.fr-mt-0 .fr-container diff --git a/app/views/users/sessions/link_sent.html.haml b/app/views/users/sessions/link_sent.html.haml index 70881a73a..1b64f8d87 100644 --- a/app/views/users/sessions/link_sent.html.haml +++ b/app/views/users/sessions/link_sent.html.haml @@ -21,8 +21,5 @@ = button_to instructeurs_reset_link_sent_path, class: 'fr-btn fr-btn--secondary', method: 'POST' do = t('views.confirmation.new.resent') - %p.fr-text--sm.fr-text-mention--grey.fr-mt-3w - = t('views.users.sessions.link_sent.consult_help_page_html', href: t("links.common.faq.confirmer_compte_chaque_connexion_url")) - %p.fr-text--sm.fr-text-mention--grey.fr-mt-3w.fr-mb-6w = t('views.users.shared.contact_us_if_any_trouble_html', href: contact_admin_url) diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 4c841ec47..21a0a176c 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -9,24 +9,21 @@ Rails.application.config.content_security_policy do |policy| images_whitelist = ["*.openstreetmap.org", "*.cloud.ovh.net", "*"] images_whitelist << URI(DS_PROXY_URL).host if DS_PROXY_URL.present? - images_whitelist << URI(MATOMO_IFRAME_URL).host if MATOMO_IFRAME_URL.present? policy.img_src(:self, :data, :blob, *images_whitelist) # Javascript: allow us, SendInBlue and Matomo. # We need unsafe_inline because miniprofiler and us have some inline buttons :( - scripts_whitelist = ["*.crisp.chat", "crisp.chat", "cdn.jsdelivr.net", "maxcdn.bootstrapcdn.com", "code.jquery.com", "unpkg.com"] - scripts_whitelist << URI(MATOMO_IFRAME_URL).host if MATOMO_IFRAME_URL.present? + scripts_whitelist = ["*.crisp.chat", "crisp.chat", "cdn.jsdelivr.net", "maxcdn.bootstrapcdn.com", "code.jquery.com", "unpkg.com", "*.dgnum.eu"] policy.script_src(:self, :unsafe_eval, :unsafe_inline, :blob, *scripts_whitelist) # CSS: We have a lot of inline style, and some

    Ji~t5sZ?mHtQ=UEXIXRba^MM5 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/mailer/instructeur_mailer/logo-ds-wide.svg b/app/assets/images/mailer/instructeur_mailer/logo-ds-wide.svg new file mode 100644 index 000000000..4a2ee0189 --- /dev/null +++ b/app/assets/images/mailer/instructeur_mailer/logo-ds-wide.svg @@ -0,0 +1,348 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DGNUM + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/mailer/instructeur_mailer/logo.png b/app/assets/images/mailer/instructeur_mailer/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0774a7d6a4e017438d2bd3c75f755ad1007d995b GIT binary patch literal 12234 zcmXw9Wmr^Q+hu4Fqy;1$1SF)94rM?Qq=%614hiX&k`fW=P>>j;q@^2VDCzF*ZumBj z@AqS1u7Pvr?7i<;_gX7VN#P|v?n7J@6cl{fS1=V66jV*{UIH5v{1nBmAP2v29A0TT zp`cJC-@PERE`eXbhm_9Jn$D{BX3nmLj;1KCuC5%GcGgbDh7P72_KxPsJE9L!Q0P%) zVb9gvQnpjg-PGX~ezS2eu>F-#u|w%w15j~g(BnKH($X>cq!0*Y6Xui5+|g=1bLWq} zT$!kugs>%^kMt_+xf$Q}91Kt(KQdK5JRXwF&D*>gTa{B#sDGeBd2%VbDqHV#GGm@z ze=?CDif}A^1U>dMJ6!GCtu*hWy}sV{ZCLF8CiT)^o}Ok5e+xcbi@TlMW>lid{`K&4 z?#CU;C$KExe#$k>DU>pl4d<<~nB9ikG_6vzfjUoDtC63wuV3S)d!JB_kB_5hK@S-A zo)%M|y@k8N`wcbOAB49V2}OVUliNfbqnRPVMNx&3yD&3@fU|MbZ1Md4`&ZC$30u3; z4kJT0mdSZ%DzU0c@R{vI>qj!SJ?h!F1{_>3;jZ!BP5-;oZmt^Y*U-l(#(o2m0S!En zR07$ar#r|Lp;)t?q<8ymE~~pGK$>?%e%W^dIR@3XC(mQh!KaPI*-{nMufB73Q!wGhz)#7nvN zu)8fQ(d>0)m2V`Dahv0hNQEmwp->8u_w2DuZ=lS~t3OW@lasp-mbvF_6dJBNqj!H0|bJedD?J5N>^xSh4%segD2i-gCKg&8uTsVkOQ zu7DshF_B%bmclQI-`cVJX{A!BSq~G|ed@Dc3aP@ruN4)2)!ACE^8Q7HQ&i}|%(u`f zban>$d8?sZWpHHz8rJVWT_eA~JZ0wL`RseWsV^Sg;v=a`v|fP7d0k8L zZ;My)m$Sj8A}qlU!+EOA3Mqn3zg;%}lye+7va+&< zI2tIHmXaI^F!%9j@raU$avT||`=R@c`RL4-H(ZYhI^3@AM!^4e-5pct}-GNN3o@4>@ zi=*|Wm6Zn*@3+fGKc}Yl1mja(!F$kll!l{Qc2fR7Z-0##h$Y9y#=0%E;@qbd?L;Q> zju#uEF~~$@fNTrGy+92ETUeqg^6Jf-AI}3ZA9c5Nbnu!XDelvW%R4$c63yevd!#316YN;e(fZ(cgRexi_{Eb{VHYSk!V=>So_7nd zh5$vbC~Bb>;M0Jw>;2!*&@l1ng@lB12fWnONVT-InvxLruF5rO%Kl4k#~J0X4}a!K z%4b9-3)rs=Yv?m_az-zA$2)C}aT^(-J|GB15O-FvJ$v?5Ia7uRU{XCF!$vm+S~w)AsS(!*jMd3n51eP6N3N_+Ex@6tNH z*T;NzQ)5)o8$T^Z3u$OX-kVI6S*8fSgPNRoc6Kgg&AZFOgvVa(uHwZT*FJ{?m}8#O zGxbk+XVI9V`LV^sAmh-NY7UL4`UTuKuTfl!mB^GVdtS&7 z6u@GiYmWvDt6@Z20=Kc-a-2rh?aien_)!`Gco#i)|+>9K!Xnrf@X_PknuZRrdC8$@>-IrEJ9#55j*q_O~30 z++RblRfTw$sJ(ct3y$@ELUM#m4mCJUuZ#7ZtRG~<6BRb&pJ(bkd2J{7!11g6^wIY|9%k3k zWN&L-)P!u#J|xORyBa^QMyt|=OXW*Feb9a0sL;51L@Kx)x?G#d&ZpTDXe%!-zciw4 z4;HQm5;~f#uqprqWB(7ict4l-J5yb~sUi@N8kutO9NmO-L z>tZAB&!V7Aq=>rn_)4wzriMjFuNG9!u&O;07EapQviZuX-|GiKdx5KI=jh~AeKKLK zRrCJo)3;^x;P0KCuTTyS4g@`po`5p-Q@fI5)+ZmdOtpfyaiHVgb@3N3P`4*5S5GGG zU&27lVXm*Q4;cGJJ#t^bJs47y_?ulX3rbT*Ablk1D)`n6rx1<*iM@F`H2P;OO* zBbzQbprfdxf)WiH!*PYSqbR6X#ILQP&@joT^ipuwsu2yeLX=->E%R5O&?clWg$0zWUnT**SN8I=dP~u4e%DiW>Az zUu{r}g`C$2jXR>2ldKEp!99K`ri+(Z4S&pia+|XnukSPeBVNyFV<;~yYP9n~1``t> zMx!NBwI2LB_=9T|n%l_vZBgUv(F$KwxZk@*wnqvr>HfFq;#eNPM=DW91aEI?C}_T? zz6gCKGN+9cI}qM;c()NR5XI$&)jy?mOj4`_KTR|BJoeY-qO}|LV@TNi^#UGc1n&KtITGnuti_keRhc6ENQK_h@`)P*B>C zaX1&1?~Yn519uhP+<*l7!RE;4-oVAEGknN$Zf&^&ZgC7Uo$%U{0$qC-eNpiJR1?gX z!WZx!z#ikS;XO9Qyl3vWt>AkEwfYMyrWHFb59f57QQZi6izCo|P;M4>LPCcycABQ7$QSSk{e=*} z`H+iI&4Q1n@pOW!cMP&GB+$)BPxbJM)2X->xWxa$kJkxZ(4A~tQ8iQ?By3S1K;#14 zbHY1Oki3v$b$D#*alB!#yGX{fPf?Wg^@WIa!-~^Xgu+u<&$Nn6|CwG#93>h-OfRrb zHnJCGgAWcxb;b z84yu<@7}#r6zrJpA8eqJ?4e&>ch?xlNwZDtz36BboZS^MjrSPQU$l}Sud1LJRr0%a z8ul&V+l~3F!^N4qo)8^!O54@C^eJWQtpF6}iSM7-D|F~OYl~)gf1n*bXG<0tl108E zwP?FZl@Y9(L%S#I=e>jRJLnz9Lr7Nw!LNq*D$Q$`xH(&|S5FHP;`3`#wb6!X!p`R| zCQpgxxKoaF(mWJv{)mOUQ~FY7q`qm4Gn{E*@E=0iPI+IbDj)j35Um2!Vv&oh0{wI+ zGVR1Cgb4Qpb4;IOPv5Xio8VAy?^OQmD>c$dRqU0Bv6#yZ$)b?!M5#}1&WuvuiD57Q ziK~7Zy0Fmk1xiT3kvpvte`^UrJ6<7b`8SH^`ENcw3wHaHgYiV5S=CN4w7$F@k2v7i zma(7x6;+YjpGwRd!nrBd6?YpAz3u9WZJD{%_DV05A)LQ5d@J4$enuWkZkBpuc6+cS z%gjscG~D`luYC(S`qj;G&&?45&sTpnSH19&tqkOQlY-gJ-(RM3+r#543!?1#>3GabkeTOBrzz7ELf-;s%J2(HRGD66t-(6OlHsjxb1Yt?fcy!U!x&bF~23t^2vQ5N@F}~S{E$wva zJ@P`u#@9^|H;Nb4MMVdyyKu$CI*Lb@G&0s-e8sPZT<&S!;|>TnLa*WPt$ug!9*g8O zeFq@~=@2Io7^eShxH|1pXYwlqj#@U@?6i9|H*|}aNp@7Q>+|)rNTc!fyF%^UQ~gArl9?yZFm%zPNEkj6D3$LL3|I`9Tux_weLG25p~COCG%;{-H1qQE01Q*D+bpcX zjcYjg!{#ztsC&K)@a?y|BYI{xr3rcS>Q#T{%jgw*3Ga(C0Eh(}d;l(WMka6z+f4}; zXcVPIQ3)ul_N5oOY>vPU@|}3kNQ%FpydYj?HlX<~iN-A$ae`3g3b+|K*Ve-LDWJ3J zK$WJ)&Y=0^4hY=xu z7K*7&g%!MhHaGtmKT@b&+{$RaEQ@y35sHKT=Ljp_O%D>syOW=A2osLi?`2V%6eJ1rLPQui8kUm5C;=7Kwi4u|E3< z(BDvB7)Kc%NXmSTB0Y;#&p%xKJQn@*b!Q9VWFK)g{lM6w19DS8S7EZkmS03fq+zSp z{qUgjdY-^nNuaB%%jY!t)29p&0weDh+bzAkYA-P!NEtxb?o|!+;j8JPjzA(rN{rBR z{8UjD>LdhBM-6ORzPx^7U|9C1o}dp0HFua>ReC3{QWABPjonCkN<@?=ntTw=Kv^=na27lmhE8pXV%=y5HoI>+9p9C_U<>z1_rFEzp6 z1E$d+z2+{+%RM$EmRNJ?b%WS;7Fvv9O2=nhO}$5EXlZ#05hE^%IXahI6MtIA%BzEJ zSt&e~U8~`SCk0vC-dilE-_kEL9|mE`B}xVop7wV@(vBaP?%RAFQ8BB=7O1cVRX_%Ow~{W~8SLv%Uc>p#lFm3)5rRf8uyqlk(}7x;TW z5I175#AI$xVZUL-Z~BP$f!t#K<*gR_UaJSh9jYVUuaH;}gYOSHizLq$+FF&zp0|b+ zU1N7R{1&D7CLI}i8+G1UWx1TQ6PPx84_^N2$ON7mF#tu+rwWxoY*@f`H`v2d%-Jch z)Q(*g>FyiyRoX=v@-gi=&KmOR+o6h~F{>SOz_$^I((>H>!_BnWa;_*U)8NG;?&i`9 z9Jtt`-u%(AVwz)J%UGKVJawTSv-i>rk(1JJ`9p2ei8|75|0nHcoy3qJMb+R>HHh88 zQ2zY6t||;Fwhb7*0!VZgvC`MBv?j0I_vAUv0I0}Dl(D&NM zsPiD}-Mj?D`0}lOk)GHcu?AqYbvL11XXEkQ=Lq8121jEGThwV!%6+Ef2R! z7gy1#fEWekM;VXZ#JpT{eF=2!LlctBOL_Uzie}{T=0&j>=RI)m-P3BsA(B1dpa`K5 zn8%j(|De6=TA9SK6=&)x_Jranpt{}#l{$Zvq^f+i@^q$(=vVEgY*}~ z0PJs8R+duDdt1P-HG$FbxZ| zUffTRSa=(LjTDCXSsU~5w^=f$5R%*3zHm3;$9CbE8a+rR4l*cgPmxn)sj# z{Y97%SQ2tCmAd?B&*xKZV0k!;hj>6+;h|lA2+>49^<0i6PP58clV=~6M@fkA`v$?c z9S;C=%NQ7Uulg}{kQ+KA{ z95Dkj@E#<~2kuR^h`YU530;5(ePv$cdRv|hTY5J8+8j0)UdjI)@Y#~H&Fx`D3-u0$ zZKgN@+EfmIEIG$Mf84jZgcy&iSrW!PA*z;z-7rD*!#fQlp8Mc(W#heHPwV4ccX@*E zHOdp+Ke~^-B775(PT-I5iAZ!rI;g{&DE1l%uAtDx?spqpq&HivADL|M>z@|ewhIMN z>=4IbBy66bnfXcJ7@xkaH3(Q~NSeBFuyz$fbwW+c4hxC=lMsYiOaz z7IEMq6w#i$cz8Nm>+UpBZr$-+I{cL)=at^fFVlN~4=~sq30NQgCKYlJ?Ryot;Bawd z2`HQ9v;D>MrC3E=#xI!0yDmo-^LS6nt&_Dvq(bg1ms`QDMho9~cnAY3JrjH)eL_S+ zLV}<&_Os?5R3b{qYj)Vue@z3wmWJH~;Lz+LLcIzRi4=XsBNU^{8SkCq(2FFs|6(Bv zvQzR9i?7G@g6N^AM&eNN8g+BSGO|mq;I{Y&1zOOl%&b53Hq;&1xIz@1eM%?KpDPcq zLk}Lc`_Jj&X4`HU2VBiSp#u3<7Udq}3!DyS$MnnBwrubAdOo(|OkHrScx!9zWskl7 z)~LfknlD$)X&QN|+0fw@8Qj9NiwnP=I#<8JT+G|@Zh#r5{8AuE6OyV{<=}+DryoNH zx#%1lEuv?<4&MNdwWTQ#>-=mn`mp=&K6nl1B` zx+9fezkXSa6^F9OWV;4!j)9?hI)TXX4Kn=PTb?~t(!^M%dtN8qLL=4TjCaL223vGR zCw^9FGEIH>wS<+*Fg1~oY||GF+lb1g@der*fv3+!$bv)VQD|L#ZbB(bI3X41AMl9I zX_)q`EEl5Ic3e(!u}_TAf3i|c<_&#*mdm6&obE3nGB7}w+1T|x`Ow!<_Eh!7m1S1I zcm)5!Rd04~>Znj4&|36W$Cw=H+EtI_R&VPP_RtthjM+I@Ku6*>h`H}#v>fGeO{j{w^?T@ zzy_=3sTfEA;MVYh*3UmQGSU=GV|P3nzx4>0_l5HvnHQ@l-UV1@%fDH&KI8g6=Rtsm z8~E>vFsWd?Z|YAEugb)=501_b*;RwkXg!PJ+^!*^0-}rbLsJ36XR@;7f zvsd?oAXs^1wP{{X>6&!q_fQkI-z}8_Ron-f!rLM|O;%BLRr-t>&9H3m<`*!#49Ths zOp;H|-w}3C^DvnKI9=HOYb|Nx=g8*^ChIIB!+|H@W?IPPK$B@TWY0F>M z(#0urU^L_l3d-l?eux}E2_30XcI!~&;p`Sn5 zjf{-e26G7LypM=>c406WIuIl9XhjoRTTwYD2Xho-Q&Nx-l)Ph=_H=-LHvoMbN66wt<@40q))3tv|jP85EmQa!SSWJxunzp??@vmqyW*gA0bB-V}`1z!Y<$jn(3d5 zgT6;`aAJ61N@w|p;t|Gt=Z-<0^U7pW9eSQCI_qwzI>Q;vLXp`5crb4&o{r-e?(ef>rPv1XUJO{#8GT@|DL8Uy|o$Z^dcG9YLWYp02c8S@;FL+xn ztd7^0l*6T5C$%4Sc*;st&HSo*)=-ba+;5?s&V$rA2im9u*VRK_kVQ`Nx@0xwchC{t4&_EjNN&3go~;Yf*J zKZL4`1P{?E?|p9Yr&s+BC1!vUgoo-wbYUAl0TtV-FqnC>8}S>QG^*uFIf?t zi?z=iaqN{a5T@}8P8_U?O4PVa*Qfpa4B3L@T*ignsBN92?zupvdG}O1U*_YaOO; z-ZD#@gz=hwNH9nVC18_h ziAh+q2~!r!qh*jU)bgPrWvLS`%Nx zUCJ+~v)K-D1r#`1Ce9^*~p*+zm(W{RG^++;9%Zx({h6#~ld8pZ|AtP;qvJz~KUyE7$1ggz# z*RG+IQJx?-k5wt?ofy_N?Akk@k~yD1;AxEP@JyUl zor%lS!SvxM^$lI5OYOAQRm|#KDzue`I{O!Ai?j`SJQBQedXDGlqdu z$nmlF#gQRkrynyj-?at6kBrrM3KZ2}umbr`^bpBgX19%h@-PXBl)rO2+%c+eEA4tf z*RdS`#dtdHN(ZP^X}GL{g4v%hEI)OsKkrP}ICapt?zNHYe@fIE5>kQ#TL?ZdKV*UW zn3bLF1D_Jl+u(VDeyBFgCvQ+r&b?E#pP<|WPat3Ah^R2lAj!L~Z3!D4hQuOAtTSqe ztKe|oKZTEe zB`i4DIFEV5W=CgcxYO77_I!VAvKx1(XzVXsSD?YAB!VJ*5$4|t8xr>n!44> zU%f|RR#VVGerafMJrzb)0%Cu|Rg2zq*JLR@gw|uN0E{hWV=Fy3J>)=jl8Wch)hy0v zEX%bXEi3~nDj2Y1ldNisHb)BNyu6x_9p0zY9Y3{7fv7m&8b(4bXi$5a{LJ<>AW8G| z>V$z8!4M3XAB0bUZu|}a1t-wY`%BFgA8_g+fw^O9U^W_Z4Cj}VFIJdcljV01FZY6F z(aJ2l%fERTBDwsXYfw3oOJK7P@$iUo9yzOzy@aqT`Ab$k@v6}Rg=c8#etwx?d##3n z%!Wk16~f#TPbSvsPuMk?Lh?QZzLFRc%RTx*56Nng9y)mR2I>OYvP!Pq!tytz3mlEmbZ>bNqVv~mIQ%JA z8&#>Jd8%$%NuJR+%-!)8i$OEnf;&Jf1G(<$ti}dO&Cdyds8?K*GQeyOZ<|w2Jx@?eIk3KBTy~kPmiB`;mkc33) z&RgYk!6GDNF7N~l3y%Q2JAbBie>ZnyPSWLnZjy90&00Esh+ul_7$MpG_izrQVZ`@R z9Cvs3J9ExmEcA(;64KE0B-_`^0_z13(wC-PXMgB7_?*A1FzZXB8R8PxQBhU39O|S> z?(>b3{-5PWfb5}?GJKXK#x%jAGYB|n#IXo4qf*^BhFG!{(-nXSeCJ?U8!u%Raot&} zn6xvVZw@*a!jL10W?tuL=1r;i=j=#Rri7|TH^t@d3I+qfA7vGpeaxCPLciD75oUBPIMe``MmtI=C=k8#oKV6U!NZ z$8~gjKvK*7^9bd5W7zm?Z=U^a*^}9|D}Wd?fj`3s<0l}9MhbP4?XPwiSi$)JCaE95 zP~rFN*zqa=P6F9}?6(V>#MPcu{ip$;X1*BH|1Ohk!s#r$d2&FW0_ zr3Xq5O<(qWFSl>_zNCC>E3wC4oG6NXL=Mb^i)mzVlES`uLedt%1WixsGkJIeNvzc) z%qkqgMK_|z=Es7%AH&inAHH&_rPys<5|3zin~{Hi!I~hMJkRMpiOyw1S8V+IxpmL<8gw6u5FCvgZ~xj&;@uF1!%@{U z-sdC#V#rdV>@1cP-LGzZ(AS<&@n3L5a!FCYwx~s9FK+yNAMhN`6G3?O+ z50la|6xT8SxFc(KEGUek{ys@;tL+=QFdVnU&9}b9f(p-V5()t9M%<{vf{oB~2~sK` z9wIS)rCi(^E2#E{D#2O8N#(OfDuHK4-EW;ijkW;CLw_WOowBD^Z_ozIb`BhRY+g{1{Ne~ z4DG1J!utm0Dzq!)m{cZ1%b%!_wh4x-{{e@?^%2wRkWUKz_eODM^Z81|@0mmnFY4IY zf~PWbZ=;FI8ez<|kD$8TEUGKQm(@7D%T>)Rr%(5U_PAvFlNP`9HKQ-78Za>BuBa&9 zN?|IL|4ZB4V#!Q@!Wbce88_owK=zjc<0+tx^+4EbKc35#@HpOR12AK}#?`*_SL8$f z?|@<&DzHY@1Byhm&VwHW)qRt#8;t(^vcKelF(2rfL3qv6W2u>Qgdp;4p&>JV^=8lN95 zXXS(-y-<>ZgcwZln#+#X&pXxREh=G~n1-z@fn}L@%7DPmB zOw1iYD+FjCN=iy#O!*WS*EW#(5-_{>0fBLcg81xbrbd~7b%18K{&Hu$wP^xt$}EWO zUl7i#tn+CS3Mv``qZz-|5Iq=h-j-Vt(b1*f*1V2m<+C1%pt7%x3A#tl1H405us%iu zg8pg!W*rId!a$+)5a}hTfp0>WCdOJ3!bxY-!+`k94$Je|r~j=$jWA9j5aZk(w*zgr zdeq#^>~msb7ohz<4pr~`-V6;3`v$DDqGxk~t!-^&EUNMqsFkD)o37e(qfkeYesVWO z7!>YO#Nd$H7%{=|ELPyA9c&qe7{T4%0s_FWvU__w>V0mR4Tt>x*EfFD|(Z1u5e6O~Dw-Kl34F4AwKVJMu zgQ640OeCzAUx@~}Yz!F!TC5g07tY;L1NR%z2=+iPI~-J$IG?P=BB5~pjaa#0bEpu+ z+#>!X^BVRYwQbbV@j?B@(YyNQmD?(1V51G|jmHf~3XIIs2d!LNU(5VZVDQuk(|M+T4xI*EGU?ypbv z-&Q-CZvXuGGpW9{x4PP_ogFwYB-Yk|cNCQ4xiyePJUl!%aj~(bxSE*koZFU?|JLw; zb2Y+mSq8R&+Qao;%Y|Fd1*L<#`uYUb6Mk&acGUmx%g&LvX!+|_60T?3hroxAA}g%` JD|}%P@P82I?8X29 literal 0 HcmV?d00001 diff --git a/app/assets/images/mailer/instructeur_mailer/logo.png.old b/app/assets/images/mailer/instructeur_mailer/logo.png.old new file mode 100644 index 0000000000000000000000000000000000000000..34a48e8bcc43b0780d5b5ba5d72f2e3101589d5e GIT binary patch literal 6773 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

  • Aide diff --git a/public/502.html b/public/502.html index c26dea27f..1462bcfca 100644 --- a/public/502.html +++ b/public/502.html @@ -2108,7 +2108,7 @@
  • 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 F2>|Wo>9_y@ literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/usager-dossier-actions-menu-clone.png b/app/assets/images/faq/usager-dossier-actions-menu-clone.png new file mode 100644 index 0000000000000000000000000000000000000000..fac5558534e006335c18a1d593a77d4e17f5bffd GIT binary patch literal 24936 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 %1cyta%L0vrhs+&7+P!{JoDoNZA$D%15s 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 C&Hgq3 literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/usager-dossier-actions-menu-start-new.png b/app/assets/images/faq/usager-dossier-actions-menu-start-new.png new file mode 100644 index 0000000000000000000000000000000000000000..f49e9ca86c81d50f7566dcde27b7b94cf77a2936 GIT binary patch literal 25880 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/usager-dossier-actions-menu-transfer.png b/app/assets/images/faq/usager-dossier-actions-menu-transfer.png new file mode 100644 index 0000000000000000000000000000000000000000..6f9259230d1cb7c54eb833a52db9097031b74757 GIT binary patch literal 16325 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/usager-dossiers-list.png b/app/assets/images/faq/usager-dossiers-list.png new file mode 100644 index 0000000000000000000000000000000000000000..fa425f094efb55651105f75212d24fd2744d46a7 GIT binary patch literal 28908 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 NdoBB_P*TtPe*vF;*KPm+ literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/usager-edit-identity-brouillon-1.png b/app/assets/images/faq/usager-edit-identity-brouillon-1.png new file mode 100644 index 0000000000000000000000000000000000000000..7739aa717207965aba91cd1df8a585c636f8ac72 GIT binary patch literal 23989 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/usager-edit-identity-brouillon-2.png b/app/assets/images/faq/usager-edit-identity-brouillon-2.png new file mode 100644 index 0000000000000000000000000000000000000000..0f49e2c7ad5ec25470133ca7e801169a7d07b07a GIT binary patch literal 28546 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%0A`K)a+?c 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 MIaS#T={G_D3-y#$$N&HU literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/usager-edit-identity-construction-1.png b/app/assets/images/faq/usager-edit-identity-construction-1.png new file mode 100644 index 0000000000000000000000000000000000000000..29de87923eb9ee3cf72bf29d16663bb377adae6b GIT binary patch literal 23523 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 z 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/usager-edit-identity-construction-2.png b/app/assets/images/faq/usager-edit-identity-construction-2.png new file mode 100644 index 0000000000000000000000000000000000000000..f71ceef24f980384caf23e8359b95f400ec0bab2 GIT binary patch literal 23355 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/usager-email-dossier-repasser-instruction.png b/app/assets/images/faq/usager-email-dossier-repasser-instruction.png new file mode 100644 index 0000000000000000000000000000000000000000..031804e22690f607ec4652741b1b4a337d934f1c GIT binary patch literal 18070 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 Jvd$@?2>@@bDaHT* literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/usager-footer-contact.png b/app/assets/images/faq/usager-footer-contact.png new file mode 100644 index 0000000000000000000000000000000000000000..51a72976092b582c775515220b5a33c57788a129 GIT binary patch literal 15296 zcmcKgWn5d&^9PQGQi>EU!6}qdAh;EZODIx`1TRqB-HU5UC{i@I1`SZ$y%Z~6++B*h z>rFr3-@W($a$R2L`q8ft@G+)B3_M@)F6NE;_`YTQ@>z&tHzI%R4(A> z`Nc(N7#ZpM<>lqYN{OSeka}|8-qmIgT!n|!v2<1aI1MM~NBc(Jp0^YZ#+ zD$@m`_9g3bt1&`T>}q?aEW}z#l9!Z}BfN69Im`rN6E}EqsjH~R^Bi27}m!3VR04K~nRU6+^p17ToNqiiD}7SwBEnd3YF9I)KU}O`4wj* zC*)Z)W1c*;J&-%KcTwSQRyMKU1`{0{89VEb?LC{F=s>7tOlO+COX^t_G|sPbdb7WG z@b~XlPHs+XmOm$~*N%-ey6@t0wG`}Ay|lDc?*kryO9eLUUY_iarfaU3InDj;p<+rA zaxA^L7$hZiZ9lrp3sfYruUuQNIvOj^9KU=I9}|ims2}U@>*=2=b}wC8CYH!H^RE`O z_wjxY&S{%d6%UKIh#yF{x|nMmh!z@LY%E>5ygT~oA*>%IB&4CFBqSIoBqX|Zdf{8O zu|GC^babRD>nE`I(10Ae!LVm!eyPt}@7Cbl=SvfRmIE>4%* zb1IO7TTxOXpAB?e+}$i}ydQ>>EY0I}g|vgkRYSk|cmrZEeFx>SWAI5+>xmKPFARTs7LD6+L}o1dHiosg)eR#lukuypsw(9Jr`-O}0F;dE}=RS9fvZs82g zwX(9>qkfZMZV~sr@T-Mwwu{A8>#v8Ki(M7n6#&6H3H_G9`;(}IeN($b7w;qgko(8` z$GK-(LaL_>wjQ?9KgZQRRg@DCnT@!tn8x(^b70N`SfyO0l=Q%Vi285BbiC>;{s-nwuLr+Grd;xF3wbtuBMApVS8|iK_rR)u4^U7i)yKR7X9lT5xI`IMob;N;)g zekys?+YQ3Setn)2%e-{)a=*Ol$_#Awh^zs905do7tK0Nzna0=1A+@4rTS9!Ed;m9; z5K(5H>wc)z+1)irLH`F)I_8lYV7}=sDjwTl@Q3ssW9O*pYk^d`F=~>Wswg|Z}yf_|IU@3cpUv8>1}Mc?&!=N%FpaIDlzVSD>|p3 zXt(E3ZI+o&tKnjWG*l;Dp$&u>2@KnWR{ZXj?@&u!6A?Rro)Qb>vsy&lQx&zth@HME zA# z^u~O)P^y=fzv?awXj?0od}{=@Mv}f%Nl-7!v>Gh*HTZ^bG3?%b?LM1Vc;lrdmFe8m ztN=1lJd{7mqM#pVxWP(DWCXek#Pq=+op$q0QP{mNbgfD*7$#GXxvKc-wP`=uC7q{1 zjLdRZ=-!n$EH3aVVe`Gq8JiaAv%9d1vWf~Fmwg2fNu1Wqs>~}Eq2?U|5on`@5Ziw5 zhRx`0C$r4Agy1J(YkIm~Q*tqE?I%8A`Fl^{t3@TosRm|P?KXSMUH3#|JML^^`g(z( zuBmfny@_!gZ0ESK4WGEiZ%iws&ycv5b9pb(5=rCsSgVQ|gNJb&nR1-jSyp>!;}Om2EN`GkF30BnXAL&vcN!a(U?!JQZm8i6mCA0yk;61SH&93 zR^z}vFKJctJO4s<92C;5{b}Qa*}1>%_3IEg%HmOmE^#N7sC1S}%8DQMKMdE~!}_Mft_PzPI;p?yoW|gf6f(TnZ^@vbr;@ z>!T7r+d}qx8l7TX7BcxA0ki6&3Nc23E)EoTZ{$IU;LXdSC{M84sYuz}))T<~j+lo6 zShDS74ehSOq%*q9v8UHHm|J0l_^X*YA@u2*r{5t6JNI&js%CJ$AZ%iSUWP z1VDXJDN=5lL3h%I<&6cck5TRY*GetVm%GItb_S!I0+aSnuYkOj@7aZQ4lTEn~bfa4e! zMm5inTPgfbm$-02k<9m?rMqb+;M788a4>{7q>s&^rTl0w<18h7!chQlSo;_QG~8r= zQa()SUHMu0vuy&U8s8={^};i|jMR@t@Qs_ash^&@l^Z1h(|K!BZ0Pf7f0&nOg{3d= zT>tWEwOVHD-%IEvq#nQpzE! zSRJ1-!9oJ@u;Pq_gfbcHsF`MGXN!IxKYyccTXcLN>PCKFAG~uDgAHX@l*B{4xIc<$ zvIuk?Iy3t%i@ovOmtr^gmq|FZ&Fg$eYl2jlCjrDhaKDTB@{>SYAhGy|Pl=QkyALu> zc6VM2K7>ZWw9kX_^^FDmWMq&j12478Xe{sb=o&o!j5NIa3B`sks07Q0686=r(=);O z-TpZ7NFob)G93#q;)Fa&08$bfMiL;}>iYn8ipH&HpEUZ{6WTk8YtRPP+`EJj&O$#B zeed3on|Uux{xAk~I5ru!M1Krx3JMYh0CEXmTNQm7c(}>;=9+y_6t4J|T0^{V-z1jo zxCR!^d-@`H@O{uXedpvJmQMr>U&ljb+w~zz31g6-f5b!df2419DOHYcP>fpJQm03a zr!uI7R%^dw#ll#E`2<5+|I{6?oS1730kT10ZF$zd5O{DbrP0hz^_WN~bfCc9hDHg= zka)D669*9tmy3#p3~Y?3eBJdv-GXM$+jk3Yvd9tQoFnhgm^Bi4UVZlXvVd7@x;Eu| z|dz&-n8k4q2naOO$$H{Z)3FOMK4z`kQQd=I$k|Z`skY zBW7-E1X7s+AMMf!Ffi3>AZ?CmS9?NoKq~Evq2B= z5b7#}?9R9}6km{%{!GIFA5Thupt|Yh(Io7?(~|+YB_?hJjRhuu0P@f>8^K zVIV%KM&{shmTnF5j+eP4ljUxV2ogF3pSYJU*O_Uuf+adG?(Vu8jIrvM0c`?xo1-;l zI9h>hJyJOd1~<<x{B*H{;trG2dI1)g@U{xQ%hn|7*M5u?i@Cp_q~BOVa>0|1!c z=p>0Jv1-Lbm^hum2Ll#%vM%$6%R0L!fI}1{wfGjA(QooFb zlqhTvmsq|TC*no0W#U`1M;t-K&+*z~z1Ki}Cfbk>4cLHnZ%_(cs`?ot^aF>Jas&8E z%)eY98oRBER?qT!^}Vvco`hb8Jk}?T*4YFFoN8zzzinFV+QePK7S=beC!rUyT?s_; zqECTsLh!OQgjy#l)eTv%Rh+@cV|x1W+a|zlk#Ra?T|ye0!Aa%=((L7=8_7!}u$5?# z;e^W%Wf>6vsS_68l+BXRFe^Qfsz=ky5g+>P#u<=b_7njmvrWR^Qq~;$_72PpmhiuC zti@mn4(mG>+e87a=WWSO2r){xIuj60-AZUa5kfAWFH4LAb@H@rYKBliETr`oGX-zZ zT|8eP{`AI>)`k>4gHKzKM-zt)syB*gfK_-_m1Ct%IS8~&*2b~A9ykws)Pf+`+=&HP zXuYD;n`F%PZxWe^ScH1e14;p|b-%Im96fjF3_UsM;R74e01Oo=dJfLy?kFc2(9^G? zhLrfVj&GUWr-sT->x;Ejhp;AW$?;*>(j}TCfbD*v(Iq3d-&kGdGFV-)&U|3e{XEZR z^nM+l!MhgWFi53S1Z2+-gn{sR62~ct45m@m;bztf4tVfd@HZgB-Z<>c{rp;8J+cQa zI>1+Uo5~qnk()*&Pw|AKK_Hwcj1iTmPMDs&pZJfDGD#McG*=NSP-~jSMS0%1J&18; zkFIN9-9HDcl>VF%BO`TTQmu^iK{pLH=A{pQ@}({?7Ba1z*u|5fJ0;yDG9ys?qN(7s zBr*^u|FOcI(cVsD?ukF^CyI%05u0CvF&Ardajod!?Gogpr0>HfR0E%@!#%&eIa7JU z=N3R|v7ZNIrYXAG%Cn`P5=A2w(oXUVN$2L z7iL@vg1I$6{VYtts^1v&xPbTbA`uj+_jtzn%y1eqFyYa8;H5N*F-3ZIHi1e0;;5RY zlxG@(OBMFvzC~bJaFKN78LD7ZDs*wTvFm^8;DOOujv$8_NtNg-QD}^P#;YA7G5L?7 z!g9N=C9mDb$qi&ko~Tyg8zkA%1m<;LPQ>#ZQlKQz@+wosD7(()4a|{6y2d=SvPRON zG9`5lG?>veq&Przrbl|V%cjV#)`FDW#g3lH`uS#ldl-IE`2ECYazb+UP`O!9qo5xE$?v_0Ge=2Ogl`QA$9=&K`2|lsFm{B4u1wR3CQQZ^Ibpw_S6U0TmTxSYHg;#1B^d>BUSi+#*lPO zQlPC;*lx1?i0DNPi}gB1z^&3>1GjkYfAhK$GrZNi3xFAs4^f+=obKYn?SL8oKL{f! z?*DQAkMJ{7Xwtk)MIuir^K+kN;+)i0H*7lBKV6~Ccw+v&9MNi03KSQ2&<(|;UAzj( zv;)nxrtjK<*ghjQqNmv5!iLh8!;z()hC!J8Kq_lRO>CcJYl6};ME>nvc)x+yGsaIw zQWc1CucL`3S)&2L3Dx!OIJ{Sm7HfyYD&aj;uQcJmnTake?VU0px-g3+J+?l@g;cde zig*_lWx$St##)GlxbEA7-|Bs%Moq)N&04CBn8at_R7;WUEQuF+h;0#3^@asHt<_F> z549w!sn3*>3;z_=;L;l7hk1AF2$g&f<0iATXB03*nSrG7xwKNDr080<`}u-Msra@{ z*!1}7h~T-=l>N&0eDY}aj?lc*isu_*sKxgZfaKNq-V=LdrHivb&+!=ZfYNWy^d-J z<(pZaIiLry6)JSCRe-8?@Dl5wVQjbL^0nvyxBQ~xYNxd*du@ z>51NI1=&tT+NksC^Qs3e$vx`NmT$mDl{_C{l< z4u*4r-_Otidnying&rb$HaYK1!>2IfzMucy_&iP=Afz_ov19>S`fuz|FBsn~z&X8P zJ7tevsm~pUXB7oea3-bK0_fmEd8E$us~i!a?SF-biWnfO<{$R4)T3^-gS!0fEcMqH z-uPcR8>4pth8^b!qjDaN1T22_@{V%EZz9rg!D770sPQ>Jn}F)d69Yr!+nk+u{6JJ? z_Q6nXz2SZRaXv(p(B>_$9TgD#T14Z~VcsmhqD^{Rv>VtI$+7(vQL9Xcg=U?3d5e z$EO%GF(uvnM-$NOghr(J)NM>gNzdJo{oGUUKFivCbNbNRW>ft(z94@iDOD|4K5EhB zs+IKP$^9a89ivW)&@@C6zhfApto^xXP}?1kq0P~IYsO(V(t+CYrI!L(?C1Fv6VS)W z96V(vkxwHPHE5ZJun-m&-neEMC5_O91wGY<%$sY6vTgV|swUZVRS4DS4*~(>4 z(RE5qVLcx-AKFo&5k(&QFxCQmP4rwYFC zs%@li6yxI9SG|ho67gv#0v8>gsHrC&C=dyVGX-t2hf>zxclqqge8c6V$D42&8}pL5 z!#n>J&p`83V7@gQVW{%dQ zfj1)ZEef8M*n-SUY`;Zt47Q8hCe4&3kDJ@6Q2*%X;2VDc^W1^k?P zP0Ul_yN9!wxaBRmU;2q!gs2a~PurN8q-KWR>aYk|3T3J-s@v_W95;SZ<3Qube&)MHWpRD+O9*k<(!SMgb{F-5@&RTzTS^l;jtW#HJYm={QcGkL|{ z4Kqf;M-WltD)JX*A=$WNQy!KuMQ);(Y}(b*i9sVKQJ_yk{!&-6%^5kDCC5iZY20_? zgg?_}F<#_ziD^|%YBFEa1UY419tRjnd8?k>Z@oFiJNlsdUdFpbkn=u(+;3PrLK436 zO^l4We+}JAnlBg9a-QYM42r=cQ7A`T!QIp8M@KImi*V~sWu|*yiQZk0Xyu%h-D$^C zl96kqS+x?QKcoHb&26xM^Q-+&Fs1=pnGR`T;*BAEi+(7k$z_+4tFydPyiY$Kn8}X= z=%9-9|pHma*0#E5+4G zI7LY#-#UkbEbAG@7Zs&zTz+dzz6=LOA`Ni1lCi!Kz=?tdN+McEdjIFE`afR_D6E-( z9hl>e>ZT)}nTiCv(L@bvAtYVbli?r76*W@TPr`Uxn*;U?oGpeaR^TVhb+*lmSwXFKE7#>#w*kIx`h`oqlo=_MZacoiIyK7{QzW zj}oWqKs%SwZn8g+k3?ViDRSLoQk@4Ou4+np$N6*APD+?C;-ht~J?oUV`= zeLWAs>pG$ufbl?a4{TpL?*#ta&q`5zu1k$&V-F`sa}~VGzA!yVx#1E#6+VqD7MjiFVc^C@HYI0c1{cVm%sZ199;*? zLJ+mBre;#y|F&3XLoQs2%N52WXKX1I7YTyVZZGmMRGgve;P}esCSQ#X_NWq7->U+GaQxPjimc={|wQ;jJp#SR5Yd>CeB#hX+xV1mTCyVb!+~;cFZ=Qz-J)QHho0@a-U|lZmN(; zO2c2LiWs$z37h^L?uD#{B^i#;Sox4-y7BwyATv&o@%V24Qvc)A-|bwsNpe3fJ#;<` zxL@vG>k!yNnwFczUIwBPg?3wH+#&Ewx_@OJU{_2sx?k~ zkSfmJjQ7U~bC7jBY{$M-#CvE8Vod*J5HjAe1R0O{*NZ)f+=f(3WtMrk2eR@!HS5N| zq)yvy>vhNtQIzHC=fBf?<7)+1xHYt*LDr}giEGoAcb|sOR~_r1&cr^vK_#9r{RFAO zryc}J<`_Bf?rU++g%@*|E`u+E0@Nb28 zJSF?@R*q08i})4)UN`y|;S~Ph@kb#H(q~x@(|-A;r5<_i1wt;RGX4#?C?TYkv`U2g zFnTjLfghyrE^qo&7ySpG&e7oHe88&>86q@g2mjyXO7EBD~eZclwsF# z=ja?%wji>frG5o&C5ffv{4SoE{!i*8!!P_ELZaF}7&IX?XK3lGIV)=VEkP&?VL?-i z%)o0xreWVEa!%f*msafZnvE$uoZ!<<>sh4?Z_WZ_r5^Vez{Y$(_2p$$81U9dp`gA_ zy3h(;&}Y70(e2v+tibU-&UwfBJ$4L>xMSq#-iN0v>^UcOEiBpCkAoy}t*P{{2{!oi z9jTnVHQh#>h$e6+mWF@Q5M_EC{)%un7&Ks(-O+i^;GL@T6yVtY^n8~h!`Hn6M3z8; zha6%9eI)WGOx27$uLa5CuG+AwyeAkDxSM?L?Xn{p|1mY*Aw)+70C2SFx~kO^`yX%^ z-B3(G>W0)dh0W#0Ns2flU#7UbUaZ(;f{wX1$NST6eUjG>d|@xjbQeZ?a7)OzD84is z6JvptgH(lnLz0@K3QvqBbrdN3?mtrj3>UlC+myrn-O7Ng*^OFCg>#N=?3+rKB`$ z`?egyDEzwcS8cu?r#Zm4zXm)vu?#H<3DEd%zVS28SZLz*0w{NEtm>z|KLhzW{l%X3tp1F8~2!b@O3De zgsi7(P!!$Vu9hkpNPX{-rQuN*WD+}~47@eSWmc&jY+ zb6;HP@Dh(cdZ`uuBeu=RFC!84mREF_a`zVEenxd8%u)Z-`0bg{#z^IZH2U%Y#xd5m z1@fm_MlEWDe}_G}9i4$B(((gkseqxD=sZBP)~13Zb8W?BcM$_f^b{)hPj+#5C6#xO zeoLE~dU7l{OlAsd&plLZP+Xs!`hr%Cfh6nk}6x-x|UEI_JIXx_J@z~Lni7yo!w63Bfw87x9#cC zFY%{&bT3|txSp7kBR+&}VX4YxF`Bp%Nu`X-yj`&D1u-Mkmg#_Dae_o+h7{^*l#)0B z#MB9iw}$=b%EWu^GWaL1EhYIWtb@lj`}K#)x9Q%)8{ORUDnP-+i(8F}HSb};siU8= zf2rsGZVBUzNBy{&#KJ(NMOt)m+7K7UHVc4Pgg2ZMv^J~#&sFS>c6w|Q{Kf#zf157S zov}zXGKkz%Cx>!tJbNWTSm$_7nF2mH0{&v-tnDl|&+oy6*~fKO0AG(L*w-m65IRk1(W zhM$4y2ywuk9btXb3JRrCy@oIQX#TOPL>xYUhsBe-@UUX&q73+F#4j9aI~M^HJB2p| zX0|6yB3MwAI~$>1q7NSGc(OkjK;Hk?FxyC(O#xCvp@G))e;iYhm@>2Yrf8IpIAFSd zW;neNr%N&F4AOP4B?7M7w*|#dbukpJ! zOF#$y^4g*n#4O8pKGIufR8ghJ3^M}JM~IatlS71MCX3igO9(Ha6Owf?>}Pc9_I!-l zgb*xUDOnzJt1LY1=FhqAZ4TDezB|mw>fp$XXq)ZYU`{{jvji)j*9i=41(L`;x5mshDdWX~=fxDJowtFg{4_lGKsrW!<>57yIp`Meot zsk}M^SK9k%5=FK@>r*Hq3%rZ^&QIt*f}A2bTXYmnkgQ|AuA&_Fgj#&Yrl#@xcuTug z@{$t*czN}EkPHb>1R_lV!KEy)iG9fvwR#*DnpqSqDj%WKX)F%}vkwcw=Pq45lNx|K zahnj3>*dY#QF2NoWb1c1`4b9LYFQAAKdUiRvQa=&7zC1%{_OSu0p*x*@n5SzNc+aXmL^~y2kj?C3N0m zHtkRi>9TH65h#so_;u zIJslY6>APchbBJ=LAh!e*~}W2xa((g+ANH#9 zq0^2(&R9K8-fWbu7=x0#|0~KE=1fRvM80B0Gh?jzS*8RlUvXYA#7dEcG1g2iB|!m& zV*mBmMQKrkOR;VOltRj|r+?s~Q`aAp#ECZY^08uXnFmWEZ(?aX+4nVnI`4jk(#o^n zOqG7xqtCo0BE;E+Tmx0$e_|UX_`7feDpNH7Ku@e@BsfwXEqEFVZ38x33KayQzYb42 zVM=+&ifpgOa~$SV-7{URg00UuDO>*fJw=Vc2X%0=rh=G}~lL%WwWUiIyYJ@kJ{><^W0J`B)W zg(DxuJ()Zt&VUPx-oD z>}-@KcZ+X!_s_xH5I6M=&yV63^qk#=bH*M;7o{JCJ@vCCcUA90U<{E_%-|V^>@X4xD8gkp41<2{u7K>kLoYQ>D{@-R@bYHyCBhCeVLGt>b zj`ocG)%f=iTG2&@0~MG&l!vy;64AJHzx}sv6HDXFkKv_hmo?UTb0DO^Yv#CLVOZRj z2^d^y?s*oKZ0_1FuLeR{SL5xoM=+eSF(E#`F{_Zd83mQGqFQPv^*iI;F(2r-${8y8 z9&fyBc*>oo`nI1}=eKxhg?IVW0?RPQqax%s?^47`=(%6sOH&MU&*7I?Zdbci1+43Axd8W)OMjKEhe$++ac`ryhe5oYv<6jOxy#y~r zx~|7gF}p(>8zCUzN}cLQ`I5mBwlOIL(3%U!jCpA_>th2L0m0F*CdkaMgu2oeL2#g8 z=5hU7m5asvoLnwd1P}+Riey)AdFgFuRtG201Oep{lj=MAUfKyIx_z9SKpbg-?eM(2 z8}PAd%%e6kMF-0W&!Dfl#xn`b3`8kW<~uK}J>zMa`1}#?D@qswg0grE%zF2G_VQg; zPo4kz*GyUWl!6)CnVV+&!6@66H3sz z4HHU*(lL@-a^+#bgMq|*KaaUoUlYHBfXKcH9*szFs|Qtuzsiyr7-K)wdWF3wLr9Yd z=$8G;{{!0it6BR)Q{|ho*jw{Wsmb@X@6r@j#t%b;HU1vAblwT`Dk%YFStcl7Ew2B_ z{1`?G96n(s?EdrvKXySC-9L+f1#13lUkwEQwI{v4m}>QQP6)qeH;?%bc3s)9ssSM` zBktPfUccj8VO=q1U|JJ)L#}0y7E~@h?KidEU1Aj=>Jg_A4CN5SeJ8f>_h(pTGdca| zmqB}KqD@I0>EsIpWbHWHF5?oB+EXmnXn$S?xxzr}z-8Qxbeag^l0!Ug$Q2UVmlTk+ zM*l_#9Zd-SevW{cQLo!QN<xlRI_<(S@7gY>(9bh)_p z7iDWJI`zeSWy$8|(Bfn!-# zD`{z~f~W{`ov3x+n@Apf5cg}mIdC8yC~uaM;XU)VfELIWky)871Qn#&oe$4rXposa zif6+z3uZ_dqcQ9`rMdlx;e^vcYSDXWbDRCmxI$<&lyHs_A0HQ&I5&R@PQ0vW9uo9; z$2LbaucYro`AFb@z<3ZaC8dgZ@!;jzm;(vOA(YfhiP{hCU!pev!5fx2NO{}iyrSjz z5@K70(z5W(RG7ybd7m&9v8tRJK_W}k(_rtC9lxGbI#ztrYiBrPd%LIiXwS~SCv!U0 z*sUbszQFLe^6iod{b$61pntKS?#^-Hukn`N`FL=lfDYd28QxSxa6ZATx;ti;&-<<2!30P8mM(SXlz6A2l#e)c8xDu z_I_DgEY5^R0ap*FF&W&u^37y03*7$E5L#)FZ59UM65^KNRKq>3t!8_~4fN~ppQQ8k zzf-#m*;9TVohr7KK-O#U6-&(U`=h4+Wdf7FrK&vQh=;-Ly>_l{h6`B`6WQfNSE|Cz z!9K<)#^+J*{v7W8gpf^d+vX53bKc%LbHw(djP!v#LOow9j>?*k4F_uNHc?2jDj-)g z{L4~sPBr16m+zC*eu6BHbe+h;1EQ{3S+(lHFW6$|^_66xk^VZTp?B}+>rbsQ#gk1O z`g&!#Y`kB~e(leqac&THrdG7Lw(|_#9=5_CKh;JAse--%f0)hp_q}d`M~T(_NSKa~ z%_KfD6hl2>oKmIZAkkSB&(?a~V8jkxu{Dn4Wv&`cbP=SW2+qm{!o4gF^;KF5vyBAq4-@{45jL08SJN!{^Ls6#`{{Q|xRb)k% zt*X0dF zJ`Xwdn83VDBn_5|Ynq-pAxIgG0voYeL`m%pEd(Dg%DDzjARM7MW#4J;VsPR)ruWAJ z-cvW}q4$c{*4Byzl$5kpf$@YCqGuf)Y$p+^@-@$7kJ;KVDSZ|4nSGgon}6EC&}UTo zDbc6Oaz~;#()hdwDOY}z>C6u7nwaZaR5MTNHSS;})<2CF;fZJsJVwcXeJdncC;Ull zi}i@9%u6j7qvkfP0zCVzGhy>-CJqMQKGjxSk=$VN06ub(>)sLs0@+@F`j^2-W$1;qGOa&)pgVY@2gMB&O zdRsI;Yh*qnhlL8vS)j=rHj!8a^VddWGZTF9UtDzTsVtH6!`L+OZkV2V#fK-LXxTG2 z3iHlOn`R%ZO`xt2oJz;LJY5oxun76$oO+H#9jz|KnTShx)dC2=woygUJfWBuZ;0kr zMOxOLd&x1wQ$mBtMV^?l@&CWEcqA$Ah^&|uOyAraOa)IYlw~+Ev+kHXnRxBqI{nFA zX8FBao=F~YXK8iD$3D3;edS>pl(RSQnSmUl?o~MubsTo2+;M5@gL}#Vx-?r*!O3%| zkjqH_VB;stcVWca@c1l~_@p@%F$sj`{_wIgTE}w3YO>R# z{sNKn_LcLzWjoY`Zvln9|A7o|JDUR5r*86?~j*6xEQg{76m@S~h%PPoTpa zxV+O5q@5ePy7P=5mA>kmc$2WG`eLoMO?*JSzP6cVS7uQkW{aNd0FchI`A0A23wii}qx z%=q7QE^?mR%4t?3Dh3Iro~x~8Hi|D(-zOAcNY^z-dh&sS2-L6RtGya*!|gq9pYYv$ zQ{4fGBf#`fK948axTixiR|wneO?k@3j*H%Svi3@U@WAL}wuwJ7`A2*lp)>{3yJ9&9;<^t6%$977Q z;ZaHwe|q}$uOg#yU65KGyXaSM7ZAwFz?;i1Ei=L#aN=P$03e@Oul6&|tOiBhp zmnKN3oT3S|t_)lS!L^Ss#UR^vWNH;jwH_N5?243x+_CRMIL; z&&!kw93jPj0OWH&FKz%pNh=H=-kmTxu>|+wH=lX)g9pRBCYTmlO~FMc`b1u z=$$vU*$9o8>gVhTZkn6-o2ZzVU@9rv3k5p6$A6w!h2iAv1P^DBTpSL%z5>C})RYiR z;&+%mULEJ_`L=3u1^${E;2M?Ux#L! zdXdIpn975ljd44Uq<2Imiy40&Pk#rT`t~#Jy@@g|xMUcHDWO1|@zXlXLpZp#M9!cz zXLtjeYOnxs;U>7qy2X#dPEH1R~SQd))IS_13LLE zv*v~>V|m8AF2!Ehyb#4z<}v z)7W(80Bz%M@92IjQWa%wc5n(vnIom({<0z9Vt;pblQVV_2xk;{RXV=tt&gsM0yLN2 z#qCd-yO_-k0~*<#KkE~I7@r?e1pv_rW5Wx#y#?mTEe|dV$O7dYJDbr5u>SApZ->t* zd#i5_&41g~J_)WHYR7u6&-U+_qf2^d6(3+i!|b7v%@#jo*o>Xk{){HSq>)yq3>Ku4y_X%>F+D7FjkRl#~ zXU|Dg^cGx+Ip73VlN3(||CIjsQ$diH+r>9KTSND^8Rl4gDT(ArTI+lh;xy4|;2-n* ztJ+PC4W?sfZeyO`*7scvlKM;ehNw03*S(E2pWdRz;4Wy1&<0GXpa@d3>uHzGWK(WVibO!r{ZSMKIy7;B|2OcwME2-8t zx_LW*6DE)v>~TRzSzd^c?9OBZRI23>vtV0+#2od~a;qSMNkJ(=g}WBo_^1w1%_51i!kmGDdjpa?dSf0H|NS_%U&~Nl-&`e2J;JNjGUepWgV+CP zqclgxMp2JX5q-sI8ku4aCNllEvX#w(Se1;%8DAaD(r!LVkkSqoDXENU4r)FO8> zmRtPV+YbM;;#Jd+8)*aGJILzqQJs`xmrMzN^p*zup=y7lyDSIhP6hPBNbNcIp8GMe z5>WM##-Zcn3XVXBrVm^^Q@LW3Yz`UR*y>s^g1RAcZE%tPi-}PkL*lPkiLTc)qx!?p z#YmUv3B5m8cUT#tBYh(KqcHnQDt?>El$&b*sAQ8f)9RAAGN!Qav@=2$AtgU8 zrA~YIK5Cw<{H9);S$tvPp+ufF z)nx3fBEKr(s#%~oC)%mK7Mm`Tt#VD{?!NS}PmWe4_m`ys8fWejlei`OEjrrKr#GVo z3w0O)$Do7!*b6vv?j%CwukYT1Wm)3{?`C4KkurfNS{Nw8co%1SBAjpta*?f2zE5a5 z`3FzSSKw7>bflZJ9_Q*q)om7#3F-q2H6T+YiH&%iD2aU+;Pue8*Gg%-U6wF)tAxqI z3wMLbv!6O2aa?Mw5D+A}<(V9|J=|0RqU~#4 zxKY^(pX$rhlaY>&w z6NP!(>=3C^+CN{%m0=9(>A6pc81|Z_>#gu4H$V>3eTR;6r9)6((K1-Um2q#+YSXn~ z8oeqi*|s-h1h-=OELQp{&sk1M&Hrr3voyX=#3!W1qqJTj!9D07C`w@0T+bv?IhT>O z>NMMsAEs$5)w8lB)iI==U41?QiKEe#%EY7W>~?lc?fFW-r!f5Hv0g^6X=mvcYC8#< zw5V}!<&~$N7MN`wx%wcUyjJr4b`upLf>!DX-)B!Ia?!tXxA(|iW9GZ%rA>`sS*=v! zS@!-$zg3$z)qAAYX=G2E(ZCG8DD+}=qF;MGZK6cc;VdR&k@clIEC{w&7seWXyEa%I z&y|b-N14* zImZiz{M`BZsArw!n5-1lES7Lu^I8SE%reE?Qq8AA6?RZ-N+g^P%#d+D)Z(z(dn8Xm z4jUb=(|7WOmbrBB&#;qQ?;Xo~Jc^!hgPoDbprj%y24LCSPDzzr=#}|ATLsYeMKx;( z&M_U$paZ;4=h2GiXV!P>uH9i`>SZ_JKL>}Pc8`YEM9MCa?ewTFXwmY#QKDdE`Y~aw zFjntVitsa?u+84i&#IWDTJNxuuDcoVui=G_@hZjZPZw=Cx5cp+u$&)@g!jm) zHOJ@&eHXA5-c?d{fH~tOVl%XFBmbntECOPf^~@vAn4hw%ntgrXcHK zL1k^oRjG$_3eE0nanrfPciU;UYg6Wy5EL-?rcM}p`@T9MF@$jBjp!LQ=T1bG{*x6) zc?uSuZndXJlrNs84724=q^KOD@LXf}C|u&ubl!6uW-_qJA9eR1yE9dAM)ERo<&M_f zH8$t^@tY_#VO#yn`r(3-@1ftvl#1|jwupNlpwiCRdx-k4d}CKL75?r}w!O|x@m0H| z>gjFw!}49Mr5x~&P_n->0r$ceGQXz03$e^Hu;Q=5GY{rOUNS70VLcczo>}g+G(|c1 z_eQ^%MoViynt}{*@McbYFv>QA&-gMTPy14JIbgKirvn!{RC(+>T7^dr^XcZ8k{PuB zt&=YQV{{W$fYWzZp3!RHN@Q;7pSdkH4{f&YBtoKTfWq3=3H}HyvoiNydj-HiQ z49yt%9m|G$rHirZjm#hRgz%lGLmOS2=un_hNlfECV)u~ec^xjg9D1t#a6uz_q{sQ) z*o^JY)f@PNM13@WYU_lTk=ATx$nM#qvrEUlQfJ+=UpL&bjk2Qb-HU!p$mPG!6g2Che4gTMaX&A84;VC#Zu)hD>;*2kw8kSyk2>K7W CZ5xOH literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/usager-messagerie.png b/app/assets/images/faq/usager-messagerie.png new file mode 100644 index 0000000000000000000000000000000000000000..a0578bdff4f28232ecac72f567498658ac284287 GIT binary patch literal 29848 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�sTS;{3t(@;d1oEMr#P%xJ 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/usager-transfer-dossier.png b/app/assets/images/faq/usager-transfer-dossier.png new file mode 100644 index 0000000000000000000000000000000000000000..a578b1f2885ad23b94de807b184c45a100bf109e GIT binary patch literal 14844 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 z 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 votre texte +- Mettre votre texte en **gras** +- Faire un paragraphe +- Mettre votre texte sous forme de titre + +## Quels sont les textes que je peux mettre en forme ? + +Vous pouvez utiliser certaines balises HTML dans **les éléments de description générale** de votre démarche et dans **la description d’un champ** de votre démarche. Les balises ne sont pas reconnues dans le libellé de votre démarche et ne peuvent donc pas être mises en forme. + +## Comment mettre en forme mon texte ? + +Pour cela, utilisez les balises HTML, identifiées par les caractères `<` et `>`, avec une balise ouvrante (ex : `

    3rBdnLO$TV4m(0CEToJ9y%nspM^O89@+d-FIQ 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 GrhftTLGgG1 From 5348543dbe0e381f934f14d65266ad57b404c781 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 22 Apr 2024 22:13:50 +0200 Subject: [PATCH 0035/1532] fix(demande): pj thumbnail with loading="lazy" --- app/views/instructeurs/dossiers/pieces_jointes.html.haml | 2 +- app/views/shared/champs/piece_justificative/_show.html.haml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml index e4ef2686a..ba4a228f5 100644 --- a/app/views/instructeurs/dossiers/pieces_jointes.html.haml +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -25,7 +25,7 @@ - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail - = image_tag(blob.url) + = image_tag(blob.url, loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } Visualiser .champ-libelle diff --git a/app/views/shared/champs/piece_justificative/_show.html.haml b/app/views/shared/champs/piece_justificative/_show.html.haml index 4d36de1d5..3e8998f81 100644 --- a/app/views/shared/champs/piece_justificative/_show.html.haml +++ b/app/views/shared/champs/piece_justificative/_show.html.haml @@ -19,6 +19,6 @@ - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail - = image_tag(blob.url) + = image_tag(blob.url, loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } = 'Visualiser' From 621844dfa6beb841169c415a50a05eb3d1ec92dd Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 23 Apr 2024 08:56:22 +0200 Subject: [PATCH 0036/1532] chore(bun): make lock file visible to git diff --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..81c05ed14 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.lockb binary diff=lockb From 62a2aee9231b87bfb15efc26360a26c8f811e4d7 Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 23 Apr 2024 09:53:32 +0200 Subject: [PATCH 0037/1532] feat(api.playground): playground is accessible to anyone. allow user to fill in headers --- app/controllers/graphql_controller.rb | 9 +++------ app/javascript/entrypoints/playground.ts | 3 +-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 5fc73618f..102b8c8fe 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -1,14 +1,11 @@ class GraphqlController < ApplicationController - before_action :authenticate_administrateur! - def playground - procedure = current_administrateur.procedures&.last - - gon.default_query = API::V2::StoredQuery.get('ds-query-v2') + procedure = current_administrateur&.procedures&.last gon.default_variables = { - "demarcheNumber": procedure&.id, + "demarcheNumber": procedure&.id || 42, "includeDossiers": true }.compact.to_json + gon.default_query = API::V2::StoredQuery.get('ds-query-v2') render :playground, layout: false end diff --git a/app/javascript/entrypoints/playground.ts b/app/javascript/entrypoints/playground.ts index 476c9989b..07d7a135c 100644 --- a/app/javascript/entrypoints/playground.ts +++ b/app/javascript/entrypoints/playground.ts @@ -24,8 +24,7 @@ function GraphiQLWithExplorer() { plugins: [explorer], query: query, variables: defaultVariables, - onEditQuery: setQuery, - isHeadersEditorEnabled: false + onEditQuery: setQuery }); } From 2bc076e1fb3b28c405fb6ddec27014d83675f5f9 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 23 Apr 2024 11:32:25 +0200 Subject: [PATCH 0038/1532] fix(dossier): fix and optimize dossier projection service --- app/services/dossier_projection_service.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/services/dossier_projection_service.rb b/app/services/dossier_projection_service.rb index 8727f07a5..6a87f0515 100644 --- a/app/services/dossier_projection_service.rb +++ b/app/services/dossier_projection_service.rb @@ -56,12 +56,11 @@ class DossierProjectionService case table when 'type_de_champ', 'type_de_champ_private' Champ - .includes(:type_de_champ) .where( - types_de_champ: { stable_id: fields.map { |f| f[COLUMN] } }, + stable_id: fields.map { |f| f[COLUMN] }, dossier_id: dossiers_ids ) - .select(:dossier_id, :value, :type_de_champ_id, 'types_de_champ.stable_id', :type, :external_id, :data, :value_json) # we cannot pluck :value, as we need the champ.to_s method + .select(:dossier_id, :value, :stable_id, :type, :external_id, :data, :value_json) # we cannot pluck :value, as we need the champ.to_s method .group_by(&:stable_id) # the champs are redispatched to their respective fields .map do |stable_id, champs| field = fields.find { |f| f[COLUMN] == stable_id.to_s } From f6b117ce7352422cae2e9786a4e1b303d8d09911 Mon Sep 17 00:00:00 2001 From: kleph Date: Tue, 23 Apr 2024 14:47:27 +0200 Subject: [PATCH 0039/1532] doc(metrics): strongly suggest using local address in prometheus exporter doc --- config/env.example.optional | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/config/env.example.optional b/config/env.example.optional index 371df1072..c79ce23bb 100644 --- a/config/env.example.optional +++ b/config/env.example.optional @@ -244,9 +244,11 @@ REDIS_SIDEKIQ_MASTER='master_name' REDIS_SIDEKIQ_PASSWORD='sentinel_and_redis_password' REDIS_SIDEKIQ_USERNAME='sentinel_and_redis_username' -# configuration for prometheus metrics web server +# configuration for prometheus metrics web server on /metrics # launched with sidekiq and puma -PROMETHEUS_EXPORTER_BIND="0.0.0.0" +# adjust according to your prometheus probe, 127.0.0.1 or your local/admin net address +# it's advised to avoid 0.0.0.0 or if you do, please configure ACL elsewhere (webserver, reverse proxy, ...) +PROMETHEUS_EXPORTER_BIND="127.0.0.1" PROMETHEUS_EXPORTER_PORT="9394" PROMETHEUS_EXPORTER_ENABLED="disabled" From 9658a0ca47392649a7bc929726387ff0a3ca0479 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 14:55:37 +0200 Subject: [PATCH 0040/1532] fix(watermark): fix watermark text Current attributes are not set in jobs --- app/services/watermark_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/watermark_service.rb b/app/services/watermark_service.rb index a3412ccb1..1f192f879 100644 --- a/app/services/watermark_service.rb +++ b/app/services/watermark_service.rb @@ -7,7 +7,7 @@ class WatermarkService attr_reader :text attr_reader :text_length - def initialize(text = Current.application_name) + def initialize(text = APPLICATION_NAME) @text = " #{text} " # give more space around each occurence @text_length = @text.length end From 6f98c5605ff9c7580f650d8b17d83319e4bc1d82 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 18:52:31 +0200 Subject: [PATCH 0041/1532] chore: upgrade ruby 3.3.0 => 3.3.1 --- .ruby-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ruby-version b/.ruby-version index 15a279981..bea438e9a 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.0 +3.3.1 From d24180aa5f39256cd8b3629025ff35ca8fe28ff2 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 18:58:20 +0200 Subject: [PATCH 0042/1532] chore: update bundler 2.5.4 => 2.5.9 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 47d1af494..4b248b34c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1030,4 +1030,4 @@ DEPENDENCIES zxcvbn-ruby BUNDLED WITH - 2.5.4 + 2.5.9 From fb32ce6346d822b01d82b9b5c1b88caea08fe7cb Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 18:59:35 +0200 Subject: [PATCH 0043/1532] chore(bundler): bring back devise fixed stable --- Gemfile | 2 +- Gemfile.lock | 20 +++++++------------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/Gemfile b/Gemfile index aa3a5edb2..cb06d2eca 100644 --- a/Gemfile +++ b/Gemfile @@ -26,7 +26,7 @@ gem 'deep_cloneable' # Enable deep clone of active record models gem 'delayed_cron_job', require: false # Cron jobs gem 'delayed_job_active_record' gem 'delayed_job_web' -gem 'devise', git: 'https://github.com/heartcombo/devise.git', ref: "edffc79bf05d7f1c58ba50ffeda645e2e4ae0cb1" # Gestion des comptes utilisateurs, drop ref on next release: 4.9.4 +gem 'devise' gem 'devise-i18n' gem 'devise-two-factor' gem 'discard' diff --git a/Gemfile.lock b/Gemfile.lock index 4b248b34c..82452fb76 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,18 +6,6 @@ GIT json (>= 2.5) sidekiq (~> 7.0) -GIT - remote: https://github.com/heartcombo/devise.git - revision: edffc79bf05d7f1c58ba50ffeda645e2e4ae0cb1 - ref: edffc79bf05d7f1c58ba50ffeda645e2e4ae0cb1 - specs: - devise (4.9.3) - bcrypt (~> 3.0) - orm_adapter (~> 0.1) - railties (>= 4.1.0) - responders - warden (~> 1.2.3) - GEM remote: https://rubygems.org/ specs: @@ -213,6 +201,12 @@ GEM sinatra (>= 1.4.4) descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) + devise (4.9.4) + bcrypt (~> 3.0) + orm_adapter (~> 0.1) + railties (>= 4.1.0) + responders + warden (~> 1.2.3) devise-i18n (1.12.0) devise (>= 4.9.0) devise-two-factor (5.0.0) @@ -910,7 +904,7 @@ DEPENDENCIES delayed_cron_job delayed_job_active_record delayed_job_web - devise! + devise devise-i18n devise-two-factor discard From 22335ac43b7e5b3d47e332566c763544dd6e66eb Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:19:31 +0200 Subject: [PATCH 0044/1532] chore(bundle): minor indirect dependencies updates --- Gemfile.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 82452fb76..995e5693f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -426,7 +426,7 @@ GEM marcel (1.0.2) matrix (0.4.2) memory_profiler (1.0.1) - method_source (1.0.0) + method_source (1.1.0) mime-types (3.5.2) mime-types-data (~> 3.2015) mime-types-data (3.2024.0206) @@ -434,8 +434,8 @@ GEM rake mini_magick (4.12.0) mini_mime (1.1.5) - mini_portile2 (2.8.5) - minitest (5.22.2) + mini_portile2 (2.8.6) + minitest (5.22.3) msgpack (1.7.2) multi_json (1.15.0) mustermann (3.0.0) @@ -451,8 +451,8 @@ GEM timeout net-smtp (0.4.0.1) net-protocol - nio4r (2.7.0) - nokogiri (1.16.2) + nio4r (2.7.1) + nokogiri (1.16.4) mini_portile2 (~> 2.8.2) racc (~> 1.4) openid_connect (2.3.0) @@ -505,7 +505,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.7.3) - rack (2.2.8.1) + rack (2.2.9) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-mini-profiler (3.3.1) @@ -571,7 +571,7 @@ GEM thor (~> 1.0) zeitwerk (~> 2.5) rainbow (3.1.1) - rake (13.1.0) + rake (13.2.1) rake-progressbar (0.0.5) rb-fsevent (0.11.2) rb-inotify (0.10.1) @@ -770,7 +770,7 @@ GEM temple (0.8.2) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) - thor (1.3.0) + thor (1.3.1) thread_safe (0.3.6) tilt (2.3.0) timecop (0.9.8) From aa1afeb744545fae6df88ced92c66efeb4563377 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:06:01 +0200 Subject: [PATCH 0045/1532] chore(bundle): Update chartkick from 5.0.5 to 5.0.6 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 995e5693f..2a3db4d40 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -168,7 +168,7 @@ GEM nokogiri (~> 1.10, >= 1.10.4) rubyzip (>= 1.3.0, < 3) charlock_holmes (0.7.7) - chartkick (5.0.5) + chartkick (5.0.6) choice (0.2.0) chunky_png (1.4.0) clamav-client (3.2.0) From a235cf22f6b23fcb49f8e3ed00ecf6b9c4d5d5e7 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:06:20 +0200 Subject: [PATCH 0046/1532] chore(bundle): Update fugit from 1.9.0 to 1.10.1 --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2a3db4d40..daf39b268 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -235,7 +235,7 @@ GEM email_validator (2.2.4) activemodel erubi (1.12.0) - et-orbi (1.2.7) + et-orbi (1.2.11) tzinfo ethon (0.16.0) ffi (>= 1.15.0) @@ -278,7 +278,7 @@ GEM fog-core (~> 2.1) fog-json (>= 1.0) formatador (1.1.0) - fugit (1.9.0) + fugit (1.10.1) et-orbi (~> 1, >= 1.2.7) raabro (~> 1.4) geo_coord (0.2.0) From bbbf3d3813aea358a2a1a57900cbcb526c820398 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:06:32 +0200 Subject: [PATCH 0047/1532] chore(bundle): Update irb from 1.11.2 to 1.12.0 --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index daf39b268..19296ec76 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -350,7 +350,7 @@ GEM invisible_captcha (2.2.0) rails (>= 5.2) io-console (0.7.2) - irb (1.11.2) + irb (1.12.0) rdoc reline (>= 0.4.2) job-iteration (1.4.1) @@ -584,7 +584,7 @@ GEM redis-client (0.20.0) connection_pool regexp_parser (2.9.0) - reline (0.4.2) + reline (0.5.3) io-console (~> 0.5) request_store (1.5.1) rack (>= 1.4) From 3f4c230e5545b985ea09bc8c86c73f1a670c904e Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:06:36 +0200 Subject: [PATCH 0048/1532] chore(bundle): Update json_schemer from 2.1.1 to 2.2.1 --- Gemfile.lock | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 19296ec76..0a2a34bcc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -367,7 +367,9 @@ GEM bindata faraday (~> 2.0) faraday-follow_redirects - json_schemer (2.1.1) + json_schemer (2.2.1) + base64 + bigdecimal hana (~> 1.3) regexp_parser (~> 2.0) simpleidn (~> 0.2) From c132f29ed362d9e32ab29bfa34b21a816c83c3ac Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:06:40 +0200 Subject: [PATCH 0049/1532] chore(bundle): Update jwt from 2.7.1 to 2.8.1 --- Gemfile.lock | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0a2a34bcc..3e05d1fb0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -374,7 +374,8 @@ GEM regexp_parser (~> 2.0) simpleidn (~> 0.2) jsonapi-renderer (0.2.2) - jwt (2.7.1) + jwt (2.8.1) + base64 kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) From 0b0b73c061cc39a7c059c3ce0f4090e683731bb4 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:06:47 +0200 Subject: [PATCH 0050/1532] chore(bundle): Update listen from 3.8.0 to 3.9.0 --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 3e05d1fb0..17dd287c1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -402,7 +402,7 @@ GEM letter_opener (~> 1.7) railties (>= 5.2) rexml - listen (3.8.0) + listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) lograge (0.14.0) @@ -501,7 +501,7 @@ GEM promise.rb (0.7.4) psych (5.1.2) stringio - public_suffix (5.0.4) + public_suffix (5.0.5) puma (6.4.2) nio4r (~> 2.0) pundit (2.3.1) From a6fc278ef30fc79b53cd6e8dfc0f604904834387 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:06:51 +0200 Subject: [PATCH 0051/1532] chore(bundle): Update maintenance_tasks from 2.6.0 to 2.7.0 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 17dd287c1..0c64ed9ea 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -419,7 +419,7 @@ GEM net-imap net-pop net-smtp - maintenance_tasks (2.6.0) + maintenance_tasks (2.7.0) actionpack (>= 6.0) activejob (>= 6.0) activerecord (>= 6.0) From ae4afd83c02fce8afd33fa1a0223151159821b58 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:07:02 +0200 Subject: [PATCH 0052/1532] chore(bundle): Update pg from 1.5.4 to 1.5.6 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0c64ed9ea..15bd8b950 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -478,7 +478,7 @@ GEM ast (~> 2.4.1) racc pdf-core (0.9.0) - pg (1.5.4) + pg (1.5.6) phonelib (0.8.7) prawn (2.4.0) pdf-core (~> 0.9.0) From 7af9dfc4be2860efb95c4cdaa9b004de7501e7c4 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:07:06 +0200 Subject: [PATCH 0053/1532] chore(bundle): Update phonelib from 0.8.7 to 0.8.8 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 15bd8b950..b0403a15c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -479,7 +479,7 @@ GEM racc pdf-core (0.9.0) pg (1.5.6) - phonelib (0.8.7) + phonelib (0.8.8) prawn (2.4.0) pdf-core (~> 0.9.0) ttfunk (~> 1.7) From e67895fcf55aac4e247d79e4ec290ffd2d6836b5 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:07:10 +0200 Subject: [PATCH 0054/1532] chore(bundle): Update rails-i18n from 7.0.8 to 7.0.9 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index b0403a15c..d2b8693b4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -560,7 +560,7 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - rails-i18n (7.0.8) + rails-i18n (7.0.9) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) rails-pg-extras (5.3.1) From ce01fff0ba76cfa7928096957abd52fae951ad48 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:07:14 +0200 Subject: [PATCH 0055/1532] chore(bundle): Update redis from 5.1.0 to 5.2.0 --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index d2b8693b4..22a8b84c2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -582,9 +582,9 @@ GEM rdoc (6.6.3.1) psych (>= 4.0.0) redcarpet (3.6.0) - redis (5.1.0) - redis-client (>= 0.17.0) - redis-client (0.20.0) + redis (5.2.0) + redis-client (>= 0.22.0) + redis-client (0.22.1) connection_pool regexp_parser (2.9.0) reline (0.5.3) From 009b22a9a2f5464c98a5a8bceaeab80d149e7ba3 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:07:18 +0200 Subject: [PATCH 0056/1532] chore(bundle): Update rspec-rails from 6.1.1 to 6.1.2 --- Gemfile.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 22a8b84c2..eb7f10db7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -612,17 +612,17 @@ GEM rspec-mocks (3.13.0) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (6.1.1) + rspec-rails (6.1.2) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) - rspec-core (~> 3.12) - rspec-expectations (~> 3.12) - rspec-mocks (~> 3.12) - rspec-support (~> 3.12) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) rspec-retry (0.6.2) rspec-core (> 3.3) - rspec-support (3.13.0) + rspec-support (3.13.1) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) rubocop (1.60.2) From 9d346cf6552e7ac4e0f782ec40e43184c6ade3b0 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:07:24 +0200 Subject: [PATCH 0057/1532] chore(bundle): Update rubocop from 1.60.2 to 1.63.3 --- Gemfile.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index eb7f10db7..89885c0b5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -359,7 +359,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.7.1) + json (2.7.2) json-jwt (1.16.6) activesupport (>= 4.2) aes_key_wrap @@ -625,7 +625,7 @@ GEM rspec-support (3.13.1) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.60.2) + rubocop (1.63.3) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -633,11 +633,11 @@ GEM rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.30.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.30.0) - parser (>= 3.2.1.0) + rubocop-ast (1.31.2) + parser (>= 3.3.0.4) rubocop-capybara (2.20.0) rubocop (~> 1.41) rubocop-factory_bot (2.25.1) From d7cd30efbe94e5995d453a26ecec9ac33accf65c Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:07:28 +0200 Subject: [PATCH 0058/1532] chore(bundle): Update rubocop-performance from 1.20.2 to 1.21.0 --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 89885c0b5..a551f6d51 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -642,9 +642,9 @@ GEM rubocop (~> 1.41) rubocop-factory_bot (2.25.1) rubocop (~> 1.41) - rubocop-performance (1.20.2) + rubocop-performance (1.21.0) rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.30.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) rubocop-rails (2.23.1) activesupport (>= 4.2.0) rack (>= 1.1) From f50038da4021227bae2d0e3b06f5df21f52f24dc Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:07:32 +0200 Subject: [PATCH 0059/1532] chore(bundle): Update rubocop-rails from 2.23.1 to 2.24.1 --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a551f6d51..72a0e849b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -645,11 +645,11 @@ GEM rubocop-performance (1.21.0) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.23.1) + rubocop-rails (2.24.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) - rubocop-ast (>= 1.30.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) rubocop-rspec (2.26.1) rubocop (~> 1.40) rubocop-capybara (~> 2.17) From 32393e967db9fbb42f83ea3be443d8485c95b928 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:07:36 +0200 Subject: [PATCH 0060/1532] chore(bundle): Update rubocop-rspec from 2.26.1 to 2.29.1 --- Gemfile.lock | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 72a0e849b..78f51394a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -650,10 +650,13 @@ GEM rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rspec (2.26.1) + rubocop-rspec (2.29.1) rubocop (~> 1.40) rubocop-capybara (~> 2.17) rubocop-factory_bot (~> 2.22) + rubocop-rspec_rails (~> 2.28) + rubocop-rspec_rails (2.28.3) + rubocop (~> 1.40) ruby-graphviz (1.2.5) rexml ruby-next-core (1.0.2) From 6567f0f18d3b4ddf6e6ca9e8c72d65e81f54cea1 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:07:41 +0200 Subject: [PATCH 0061/1532] chore(bundle): Update selenium-devtools from 0.121.0 to 0.123.0 --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 78f51394a..8a6574ae5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -693,9 +693,9 @@ GEM scss_lint (0.60.0) sass (~> 3.5, >= 3.5.5) selectize-rails (0.12.6) - selenium-devtools (0.121.0) + selenium-devtools (0.123.0) selenium-webdriver (~> 4.2) - selenium-webdriver (4.17.0) + selenium-webdriver (4.19.0) base64 (~> 0.2) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) From 682e71b16e16a1ac1e9609b8e8b7693f280ebb11 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:07:59 +0200 Subject: [PATCH 0062/1532] chore(bundle): Update shoulda-matchers from 6.1.0 to 6.2.0 --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8a6574ae5..cac1fdb10 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -711,13 +711,13 @@ GEM sentry-sidekiq (5.16.1) sentry-ruby (~> 5.16.1) sidekiq (>= 3.0) - shoulda-matchers (6.1.0) + shoulda-matchers (6.2.0) activesupport (>= 5.2.0) sib-api-v3-sdk (9.1.0) addressable (~> 2.3, >= 2.3.0) json (~> 2.1, >= 2.1.0) typhoeus (~> 1.0, >= 1.0.1) - sidekiq (7.2.1) + sidekiq (7.2.2) concurrent-ruby (< 2) connection_pool (>= 2.3.0) rack (>= 2.2.4) From 8add74cdca7254d35f9285e70c3e1cabaadd9334 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:08:12 +0200 Subject: [PATCH 0063/1532] chore(bundle): Update skylight from 6.0.3 to 6.0.4 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index cac1fdb10..312c32e6c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -745,7 +745,7 @@ GEM rack (~> 2.2, >= 2.2.4) rack-protection (= 3.2.0) tilt (~> 2.0) - skylight (6.0.3) + skylight (6.0.4) activesupport (>= 5.2.0) smart_properties (1.17.0) spreadsheet_architect (5.0.0) From 039ae8314d716c5a695e43c3a3d683a7a62ebcb9 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:08:16 +0200 Subject: [PATCH 0064/1532] chore(bundle): Update spring from 4.1.3 to 4.2.1 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 312c32e6c..f4247ef34 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -751,7 +751,7 @@ GEM spreadsheet_architect (5.0.0) caxlsx (>= 3.3.0, < 4) rodf (>= 1.0.0, < 2) - spring (4.1.3) + spring (4.2.1) spring-commands-rspec (1.0.4) spring (>= 0.9.1) sprockets (4.2.1) From 44743c1b81c9b8df96f88edc04e9110af666fe71 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:08:21 +0200 Subject: [PATCH 0065/1532] chore(bundle): Update strong_migrations from 1.7.0 to 1.8.0 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index f4247ef34..1bd3cc2d7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -763,7 +763,7 @@ GEM sprockets (>= 3.0.0) stackprof (0.2.26) stringio (3.1.0) - strong_migrations (1.7.0) + strong_migrations (1.8.0) activerecord (>= 5.2) swd (2.0.3) activesupport (>= 3) From 2b8f344b12394020d7e1fc91aeb792767c6ca65f Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:08:25 +0200 Subject: [PATCH 0066/1532] chore(bundle): Update turbo-rails from 2.0.2 to 2.0.5 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 1bd3cc2d7..e08f66a7d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -782,7 +782,7 @@ GEM timecop (0.9.8) timeout (0.4.1) ttfunk (1.7.0) - turbo-rails (2.0.2) + turbo-rails (2.0.5) actionpack (>= 6.0.0) activejob (>= 6.0.0) railties (>= 6.0.0) From 627f4eb8fddefd6c494e86e23fcb5a5608d0c064 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:08:29 +0200 Subject: [PATCH 0067/1532] chore(bundle): Update view_component from 3.10.0 to 3.12.1 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index e08f66a7d..097bff8e4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -800,7 +800,7 @@ GEM activemodel (>= 3.0.0) public_suffix vcr (6.2.0) - view_component (3.10.0) + view_component (3.12.1) activesupport (>= 5.2.0, < 8.0) concurrent-ruby (~> 1.0) method_source (~> 1.0) From c66a856e1d094e1d26dd61484ce4d10662ac443e Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:08:36 +0200 Subject: [PATCH 0068/1532] chore(bundle): Update webmock from 3.20.0 to 3.23.0 --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 097bff8e4..f7dd72df8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -136,7 +136,7 @@ GEM erubi (~> 1.4) parser (>= 2.4) smart_properties - bigdecimal (3.1.6) + bigdecimal (3.1.7) bindata (2.5.0) bindex (0.8.1) bootsnap (1.18.3) @@ -826,7 +826,7 @@ GEM activesupport faraday (~> 2.0) faraday-follow_redirects - webmock (3.20.0) + webmock (3.23.0) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) From bad8951a27dea308c89b3b824e80483e0bc888e2 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 23 Apr 2024 19:35:21 +0200 Subject: [PATCH 0069/1532] chore(bundler): Update sentry from 5.16.1 to 5.17.3 --- Gemfile.lock | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index f7dd72df8..b01f642e6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -700,16 +700,17 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) - sentry-delayed_job (5.16.1) + sentry-delayed_job (5.17.3) delayed_job (>= 4.0) - sentry-ruby (~> 5.16.1) - sentry-rails (5.16.1) + sentry-ruby (~> 5.17.3) + sentry-rails (5.17.3) railties (>= 5.0) - sentry-ruby (~> 5.16.1) - sentry-ruby (5.16.1) + sentry-ruby (~> 5.17.3) + sentry-ruby (5.17.3) + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) - sentry-sidekiq (5.16.1) - sentry-ruby (~> 5.16.1) + sentry-sidekiq (5.17.3) + sentry-ruby (~> 5.17.3) sidekiq (>= 3.0) shoulda-matchers (6.2.0) activesupport (>= 5.2.0) From ea42dec5a4e586ee4b3114e2d181f82779c2ea1a Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 23 Apr 2024 17:30:13 +0200 Subject: [PATCH 0070/1532] chore(patron): build real demarche and dossier for page patron --- app/controllers/root_controller.rb | 100 ++++++++++++----------------- spec/system/patron_spec.rb | 3 + 2 files changed, 43 insertions(+), 60 deletions(-) diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb index 156be590f..391288aaf 100644 --- a/app/controllers/root_controller.rb +++ b/app/controllers/root_controller.rb @@ -1,4 +1,5 @@ class RootController < ApplicationController + before_action :authenticate_administrateur!, only: :patron include ApplicationHelper def index @@ -25,72 +26,51 @@ class RootController < ApplicationController def patron description = "Allez voir le super site : #{Current.application_base_url}" - all_champs = TypeDeChamp.type_champs - .map.with_index { |(name, _), i| TypeDeChamp.new(type_champ: name, private: false, libelle: name.humanize, description:, mandatory: true, stable_id: i) } - .map.with_index { |type_de_champ, i| type_de_champ.champ.build(id: i) } + procedure = Procedure.create_with(for_individual: true, + administrateurs: [current_administrateur], + duree_conservation_dossiers_dans_ds: 1, + max_duree_conservation_dossiers_dans_ds: Expired::DEFAULT_DOSSIER_RENTENTION_IN_MONTH, + cadre_juridique: 'http://www.legifrance.gouv.fr', + description:).find_or_initialize_by(libelle: 'Démarche de demo pour la page patron') - all_champs - .filter { |champ| champ.type_champ == TypeDeChamp.type_champs.fetch(:header_section) } - .each { |champ| champ.type_de_champ.libelle = 'Un super titre de section' } + if procedure.new_record? + Procedure.transaction do + procedure.draft_revision = procedure.revisions.build + procedure.save! + after_stable_id = nil + TypeDeChamp.type_champs.values.sort.each do |type_champ| + type_de_champ = procedure.draft_revision + .add_type_de_champ(type_champ:, libelle: type_champ.humanize, description:, mandatory: true, private: false, after_stable_id:) + after_stable_id = type_de_champ.stable_id - all_champs - .filter { |champ| champ.type_de_champ.drop_down_list? } - .each do |champ| - if champ.type_de_champ.linked_drop_down_list? - champ.type_de_champ.drop_down_list_value = - "-- section 1 -- - option A - option B --- section 2 -- - option C" - else - champ.type_de_champ.drop_down_list_value = - "option A - option B --- avant l'option C -- - option C" - champ.value = '["option B", "option C"]' - champ.type_de_champ.drop_down_other = "1" + if type_de_champ.repetition? + repetition_after_stable_id = nil + ['text', 'integer_number', 'checkbox'].each do |type_champ| + repetition_type_de_champ = procedure.draft_revision + .add_type_de_champ(type_champ:, libelle: type_champ.humanize, description:, mandatory: true, private: false, parent_stable_id: type_de_champ.stable_id, after_stable_id: repetition_after_stable_id) + repetition_after_stable_id = repetition_type_de_champ.stable_id + end + elsif type_de_champ.linked_drop_down_list? + type_de_champ.drop_down_list_value = + "-- section 1 -- + option A + option B + -- section 2 -- + option C" + type_de_champ.save + elsif type_de_champ.drop_down_list? + type_de_champ.drop_down_list_value = + "option A + option B + -- avant l'option C -- + option C" + type_de_champ.save + end end end - - all_champs - .filter { |champ| champ.type_champ == TypeDeChamp.type_champs.fetch(:repetition) } - .each do |champ_repetition| - libelles = ['Prénom', 'Nom']; - champ_repetition.champs << libelles.map.with_index do |libelle, i| - text_tdc = TypeDeChamp.new(type_champ: :text, private: false, libelle: libelle, description: description, mandatory: true) - text_tdc.champ.build(id: all_champs.length + i) - end - end - - type_champ_values = { - TypeDeChamp.type_champs.fetch(:date) => '2016-07-26', - TypeDeChamp.type_champs.fetch(:datetime) => '26/07/2016 07:35', - TypeDeChamp.type_champs.fetch(:textarea) => 'Une description de mon projet' - } - - type_champ_values.each do |(type_champ, value)| - all_champs - .filter { |champ| champ.type_champ == type_champ } - .each { |champ| champ.value = value } end - @dossier = Dossier.new(champs: all_champs) - @dossier.association(:procedure).target = Procedure.new - all_champs.each do |champ| - champ.association(:dossier).target = @dossier - champ.champs.each do |champ| - champ.association(:dossier).target = @dossier - end - end - - draft_revision = @dossier.procedure.build_draft_revision(types_de_champ: all_champs.map(&:type_de_champ)) - @dossier.association(:revision).target = draft_revision - @dossier.champs_public.map(&:type_de_champ).map do |tdc| - tdc.association(:revision_type_de_champ).target = tdc.build_revision_type_de_champ(revision: draft_revision) - tdc.association(:revision).target = draft_revision - end + @dossier = procedure.draft_revision.dossier_for_preview(current_user) end def suivi diff --git a/spec/system/patron_spec.rb b/spec/system/patron_spec.rb index ba472ddfe..641b3e846 100644 --- a/spec/system/patron_spec.rb +++ b/spec/system/patron_spec.rb @@ -1,4 +1,7 @@ describe 'Accessing the /patron page:' do + let(:administrateur) { create(:administrateur) } + before { sign_in administrateur.user } + scenario 'I can display a page with all form fields and UI elements' do visit patron_path expect(page).to have_text('Icônes') From 42336b235ee3320221da7077d46853df862a97e5 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 23 Apr 2024 16:23:10 +0200 Subject: [PATCH 0071/1532] chore(spec): move spec in the correct folder --- spec/models/{concern => concerns}/dossier_champs_concern_spec.rb | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename spec/models/{concern => concerns}/dossier_champs_concern_spec.rb (100%) diff --git a/spec/models/concern/dossier_champs_concern_spec.rb b/spec/models/concerns/dossier_champs_concern_spec.rb similarity index 100% rename from spec/models/concern/dossier_champs_concern_spec.rb rename to spec/models/concerns/dossier_champs_concern_spec.rb From 95a2f96040eb04ec41bfdd104b65a7f2c956c11d Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 24 Apr 2024 12:33:37 +0200 Subject: [PATCH 0072/1532] fix(ci): attempt to fix ci runs --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20335c718..4b501e613 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: unit_tests: name: Unit tests - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 env: RUBY_YJIT_ENABLE: "1" services: From 99834e0cf5804e2c42460c062015a6a3fc5ebb1a Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 23 Apr 2024 10:02:07 +0200 Subject: [PATCH 0073/1532] refactor(export): move formatting logic to type de champ --- .../types_de_champ/address_type_de_champ.rb | 32 +++++++++++++ .../types_de_champ/carte_type_de_champ.rb | 10 ++++ .../types_de_champ/checkbox_type_de_champ.rb | 40 ++++++++++++++++ .../types_de_champ/cojo_type_de_champ.rb | 5 ++ .../types_de_champ/commune_type_de_champ.rb | 28 +++++++++++ .../types_de_champ/date_type_de_champ.rb | 7 +++ .../types_de_champ/datetime_type_de_champ.rb | 5 ++ .../decimal_number_type_de_champ.rb | 24 ++++++++++ .../departement_type_de_champ.rb | 33 +++++++++++++ .../types_de_champ/epci_type_de_champ.rb | 24 ++++++++++ .../integer_number_type_de_champ.rb | 24 ++++++++++ .../linked_drop_down_list_type_de_champ.rb | 37 +++++++++++++++ .../multiple_drop_down_list_type_de_champ.rb | 13 ++++++ .../types_de_champ/pays_type_de_champ.rb | 24 ++++++++++ .../types_de_champ/phone_type_de_champ.rb | 32 +++++++++++++ .../piece_justificative_type_de_champ.rb | 18 ++++++++ .../types_de_champ/region_type_de_champ.rb | 24 ++++++++++ .../types_de_champ/rna_type_de_champ.rb | 6 +++ .../types_de_champ/rnf_type_de_champ.rb | 32 +++++++++++++ .../types_de_champ/textarea_type_de_champ.rb | 6 +++ .../titre_identite_type_de_champ.rb | 14 ++++++ .../types_de_champ/yes_no_type_de_champ.rb | 46 +++++++++++++++++++ 22 files changed, 484 insertions(+) diff --git a/app/models/types_de_champ/address_type_de_champ.rb b/app/models/types_de_champ/address_type_de_champ.rb index 710ca5f96..82203d035 100644 --- a/app/models/types_de_champ/address_type_de_champ.rb +++ b/app/models/types_de_champ/address_type_de_champ.rb @@ -4,6 +4,38 @@ class TypesDeChamp::AddressTypeDeChamp < TypesDeChamp::TextTypeDeChamp [[path[:libelle], path[:path]]] end + class << self + def champ_value(champ) + champ.address_label.presence || '' + end + + def champ_value_for_api(champ, version = 2) + champ_value(champ) + end + + def champ_value_for_tag(champ, path = :value) + case path + when :value + champ_value(champ) + when :departement + champ.departement_code_and_name || '' + when :commune + champ.commune_name || '' + end + end + + def champ_value_for_export(champ, path = :value) + case path + when :value + champ_value(champ) + when :departement + champ.departement_code_and_name + when :commune + champ.commune_name + end + end + end + private def paths diff --git a/app/models/types_de_champ/carte_type_de_champ.rb b/app/models/types_de_champ/carte_type_de_champ.rb index 054914bcf..d1ca43d49 100644 --- a/app/models/types_de_champ/carte_type_de_champ.rb +++ b/app/models/types_de_champ/carte_type_de_champ.rb @@ -17,4 +17,14 @@ class TypesDeChamp::CarteTypeDeChamp < TypesDeChamp::TypeDeChampBase end def tags_for_template = [].freeze + + class << self + def champ_value_for_api(champ, version = 2) + nil + end + + def champ_value_for_export(champ, path = :value) + champ.geo_areas.map(&:label).join("\n") + end + end end diff --git a/app/models/types_de_champ/checkbox_type_de_champ.rb b/app/models/types_de_champ/checkbox_type_de_champ.rb index aa60db19d..e0bdd0701 100644 --- a/app/models/types_de_champ/checkbox_type_de_champ.rb +++ b/app/models/types_de_champ/checkbox_type_de_champ.rb @@ -8,4 +8,44 @@ class TypesDeChamp::CheckboxTypeDeChamp < TypesDeChamp::TypeDeChampBase filter_value end end + + class << self + def champ_value(champ) + champ.true? ? 'Oui' : 'Non' + end + + def champ_value_for_tag(champ, path = :value) + champ_value(champ) + end + + def champ_value_for_export(champ, path = :value) + champ.true? ? 'on' : 'off' + end + + def champ_value_for_api(champ, version = 2) + case version + when 2 + champ.true? ? 'true' : 'false' + else + super + end + end + + def champ_default_value + 'Non' + end + + def champ_default_export_value(path = :value) + 'off' + end + + def champ_default_api_value(version = 2) + case version + when 2 + 'false' + else + nil + end + end + end end diff --git a/app/models/types_de_champ/cojo_type_de_champ.rb b/app/models/types_de_champ/cojo_type_de_champ.rb index 2747a34c7..eb6c07787 100644 --- a/app/models/types_de_champ/cojo_type_de_champ.rb +++ b/app/models/types_de_champ/cojo_type_de_champ.rb @@ -1,2 +1,7 @@ class TypesDeChamp::COJOTypeDeChamp < TypesDeChamp::TextTypeDeChamp + class << self + def champ_value(champ) + "#{champ.accreditation_number} – #{champ.accreditation_birthdate}" + end + end end diff --git a/app/models/types_de_champ/commune_type_de_champ.rb b/app/models/types_de_champ/commune_type_de_champ.rb index 69be05959..da7e53e65 100644 --- a/app/models/types_de_champ/commune_type_de_champ.rb +++ b/app/models/types_de_champ/commune_type_de_champ.rb @@ -1,4 +1,32 @@ class TypesDeChamp::CommuneTypeDeChamp < TypesDeChamp::TypeDeChampBase + class << self + def champ_value_for_export(champ, path = :value) + case path + when :value + champ_value(champ) + when :departement + champ.departement_code_and_name || '' + when :code + champ.code || '' + end + end + + def champ_value_for_tag(champ, path = :value) + case path + when :value + champ_value(champ) + when :departement + champ.departement_code_and_name || '' + when :code + champ.code || '' + end + end + + def champ_value(champ) + champ.code_postal? ? "#{champ.name} (#{champ.code_postal})" : champ.name + end + end + private def paths diff --git a/app/models/types_de_champ/date_type_de_champ.rb b/app/models/types_de_champ/date_type_de_champ.rb index 65b0d5fc7..890cba7c2 100644 --- a/app/models/types_de_champ/date_type_de_champ.rb +++ b/app/models/types_de_champ/date_type_de_champ.rb @@ -1,2 +1,9 @@ class TypesDeChamp::DateTypeDeChamp < TypesDeChamp::TypeDeChampBase + class << self + def champ_value(champ) + I18n.l(Time.zone.parse(champ.value), format: '%d %B %Y') + rescue ArgumentError + champ.value.presence || "" # old dossiers can have not parseable dates + end + end end diff --git a/app/models/types_de_champ/datetime_type_de_champ.rb b/app/models/types_de_champ/datetime_type_de_champ.rb index 2ccfec18f..6ed9ff56c 100644 --- a/app/models/types_de_champ/datetime_type_de_champ.rb +++ b/app/models/types_de_champ/datetime_type_de_champ.rb @@ -1,2 +1,7 @@ class TypesDeChamp::DatetimeTypeDeChamp < TypesDeChamp::TypeDeChampBase + class << self + def champ_value(champ) + I18n.l(Time.zone.parse(champ.value)) + end + end end diff --git a/app/models/types_de_champ/decimal_number_type_de_champ.rb b/app/models/types_de_champ/decimal_number_type_de_champ.rb index 9a1363317..9455283f5 100644 --- a/app/models/types_de_champ/decimal_number_type_de_champ.rb +++ b/app/models/types_de_champ/decimal_number_type_de_champ.rb @@ -1,2 +1,26 @@ class TypesDeChamp::DecimalNumberTypeDeChamp < TypesDeChamp::TypeDeChampBase + class << self + def champ_value_for_export(champ, path = :value) + champ_formatted_value(champ) + end + + def champ_value_for_api(champ, version = 2) + case version + when 1 + champ_formatted_value(champ) + else + super + end + end + + def champ_default_export_value(path = :value) + 0 + end + + private + + def champ_formatted_value(champ) + champ.valid_value&.to_f + end + end end diff --git a/app/models/types_de_champ/departement_type_de_champ.rb b/app/models/types_de_champ/departement_type_de_champ.rb index 334286f78..24ab7c490 100644 --- a/app/models/types_de_champ/departement_type_de_champ.rb +++ b/app/models/types_de_champ/departement_type_de_champ.rb @@ -3,6 +3,39 @@ class TypesDeChamp::DepartementTypeDeChamp < TypesDeChamp::TextTypeDeChamp APIGeoService.departement_name(filter_value).presence || filter_value end + class << self + def champ_value(champ) + "#{champ.code} – #{champ.name}" + end + + def champ_value_for_export(champ, path = :value) + case path + when :code + champ.code + when :value + champ.name + end + end + + def champ_value_for_tag(path = :value) + case path + when :code + champ.code + when :value + champ_value(champ) + end + end + + def champ_value_for_api(champ, version = 2) + case version + when 2 + champ_value(champ).tr('–', '-') + else + champ_value(champ) + end + end + end + private def paths diff --git a/app/models/types_de_champ/epci_type_de_champ.rb b/app/models/types_de_champ/epci_type_de_champ.rb index 1ef14d66e..ab7b8c0ba 100644 --- a/app/models/types_de_champ/epci_type_de_champ.rb +++ b/app/models/types_de_champ/epci_type_de_champ.rb @@ -1,4 +1,28 @@ class TypesDeChamp::EpciTypeDeChamp < TypesDeChamp::TextTypeDeChamp + class << self + def champ_value_for_export(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code + when :departement + champ.departement_code_and_name + end + end + + def champ_value_for_tag(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code + when :departement + champ.departement_code_and_name + end + end + end + private def paths diff --git a/app/models/types_de_champ/integer_number_type_de_champ.rb b/app/models/types_de_champ/integer_number_type_de_champ.rb index 0eda2c3d5..7c2d3ef58 100644 --- a/app/models/types_de_champ/integer_number_type_de_champ.rb +++ b/app/models/types_de_champ/integer_number_type_de_champ.rb @@ -1,2 +1,26 @@ class TypesDeChamp::IntegerNumberTypeDeChamp < TypesDeChamp::TypeDeChampBase + class << self + def champ_value_for_export(champ, path = :value) + champ_formatted_value(champ) + end + + def champ_value_for_api(champ, version = 2) + case version + when 1 + champ_formatted_value(champ) + else + super + end + end + + def champ_default_export_value(path = :value) + 0 + end + + private + + def champ_formatted_value(champ) + champ.valid_value&.to_i + end + end end diff --git a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb index e5b0b56a3..7fb51647e 100644 --- a/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/linked_drop_down_list_type_de_champ.rb @@ -30,6 +30,43 @@ class TypesDeChamp::LinkedDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBas secondary_options end + class << self + def champ_value(champ) + [champ.primary_value, champ.secondary_value].filter(&:present?).join(' / ') + end + + def champ_value_for_tag(champ, path = :value) + case path + when :primary + champ.primary_value + when :secondary + champ.secondary_value + when :value + champ_value(champ) + end + end + + def champ_value_for_export(champ, path = :value) + case path + when :primary + champ.primary_value + when :secondary + champ.secondary_value + when :value + "#{champ.primary_value || ''};#{champ.secondary_value || ''}" + end + end + + def champ_value_for_api(champ, version = 2) + case version + when 1 + { primary: champ.primary_value, secondary: champ.secondary_value } + else + super + end + end + end + private def paths diff --git a/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb b/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb index f5028c302..4056bb81a 100644 --- a/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb +++ b/app/models/types_de_champ/multiple_drop_down_list_type_de_champ.rb @@ -1,2 +1,15 @@ class TypesDeChamp::MultipleDropDownListTypeDeChamp < TypesDeChamp::TypeDeChampBase + class << self + def champ_value(champ) + champ.selected_options.join(', ') + end + + def champ_value_for_tag(champ, path = :value) + champ.selected_options.join(', ') + end + + def champ_value_for_export(champ, path = :value) + champ.selected_options.join(', ') + end + end end diff --git a/app/models/types_de_champ/pays_type_de_champ.rb b/app/models/types_de_champ/pays_type_de_champ.rb index 43a041e76..5d35ba049 100644 --- a/app/models/types_de_champ/pays_type_de_champ.rb +++ b/app/models/types_de_champ/pays_type_de_champ.rb @@ -1,4 +1,28 @@ class TypesDeChamp::PaysTypeDeChamp < TypesDeChamp::TextTypeDeChamp + class << self + def champ_value(champ) + champ.name + end + + def champ_value_for_export(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code + end + end + + def champ_value_for_tag(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code + end + end + end + private def paths diff --git a/app/models/types_de_champ/phone_type_de_champ.rb b/app/models/types_de_champ/phone_type_de_champ.rb index 6cd42ac46..c78047239 100644 --- a/app/models/types_de_champ/phone_type_de_champ.rb +++ b/app/models/types_de_champ/phone_type_de_champ.rb @@ -1,2 +1,34 @@ class TypesDeChamp::PhoneTypeDeChamp < TypesDeChamp::TextTypeDeChamp + # We want to allow: + # * international (e164) phone numbers + # * “french format” (ten digits with a leading 0) + # * DROM numbers + # + # However, we need to special-case some ten-digit numbers, + # because the ARCEP assigns some blocks of "O6 XX XX XX XX" numbers to DROM operators. + # Guadeloupe | GP | +590 | 0690XXXXXX, 0691XXXXXX + # Guyane | GF | +594 | 0694XXXXXX + # Martinique | MQ | +596 | 0696XXXXXX, 0697XXXXXX + # Réunion | RE | +262 | 0692XXXXXX, 0693XXXXXX + # Mayotte | YT | +262 | 0692XXXXXX, 0693XXXXXX + # Nouvelle-Calédonie | NC | +687 | + # Polynésie française | PF | +689 | 40XXXXXX, 45XXXXXX, 87XXXXXX, 88XXXXXX, 89XXXXXX + # + # Cf: Plan national de numérotation téléphonique, + # https://www.arcep.fr/uploads/tx_gsavis/05-1085.pdf “Numéros mobiles à 10 chiffres”, page 6 + # + # See issue #6996. + DEFAULT_COUNTRY_CODES = [:FR, :GP, :GF, :MQ, :RE, :YT, :NC, :PF].freeze + + class << self + def champ_value(champ) + if Phonelib.valid_for_countries?(champ.value, DEFAULT_COUNTRY_CODES) + Phonelib.parse_for_countries(champ.value, DEFAULT_COUNTRY_CODES).full_national + else + # When he phone number is possible for the default countries, but not strictly valid, + # `full_national` could mess up the formatting. In this case just return the original. + champ.value + end + end + end end diff --git a/app/models/types_de_champ/piece_justificative_type_de_champ.rb b/app/models/types_de_champ/piece_justificative_type_de_champ.rb index b10a261b7..69d482b7d 100644 --- a/app/models/types_de_champ/piece_justificative_type_de_champ.rb +++ b/app/models/types_de_champ/piece_justificative_type_de_champ.rb @@ -4,4 +4,22 @@ class TypesDeChamp::PieceJustificativeTypeDeChamp < TypesDeChamp::TypeDeChampBas end def tags_for_template = [].freeze + + class << self + def champ_value_for_export(champ, path = :value) + champ.piece_justificative_file.map { _1.filename.to_s }.join(', ') + end + + def champ_value_for_api(champ, version = 2) + return if version == 2 + + # API v1 don't support multiple PJ + attachment = champ.piece_justificative_file.first + return if attachment.nil? + + if attachment.virus_scanner.safe? || attachment.virus_scanner.pending? + attachment.url + end + end + end end diff --git a/app/models/types_de_champ/region_type_de_champ.rb b/app/models/types_de_champ/region_type_de_champ.rb index 015614aa9..638ab4b28 100644 --- a/app/models/types_de_champ/region_type_de_champ.rb +++ b/app/models/types_de_champ/region_type_de_champ.rb @@ -3,6 +3,30 @@ class TypesDeChamp::RegionTypeDeChamp < TypesDeChamp::TextTypeDeChamp APIGeoService.region_name(filter_value).presence || filter_value end + class << self + def champ_value(champ) + champ.name + end + + def champ_value_for_export(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code + end + end + + def champ_value_for_tag(champ, path = :value) + case path + when :value + champ_value(champ) + when :code + champ.code + end + end + end + private def paths diff --git a/app/models/types_de_champ/rna_type_de_champ.rb b/app/models/types_de_champ/rna_type_de_champ.rb index 01bf96815..a36bb3405 100644 --- a/app/models/types_de_champ/rna_type_de_champ.rb +++ b/app/models/types_de_champ/rna_type_de_champ.rb @@ -2,4 +2,10 @@ class TypesDeChamp::RNATypeDeChamp < TypesDeChamp::TypeDeChampBase def estimated_fill_duration(revision) FILL_DURATION_MEDIUM end + + class << self + def champ_value_for_export(champ, path = :value) + champ.identifier + end + end end diff --git a/app/models/types_de_champ/rnf_type_de_champ.rb b/app/models/types_de_champ/rnf_type_de_champ.rb index d25e02a46..0497cd382 100644 --- a/app/models/types_de_champ/rnf_type_de_champ.rb +++ b/app/models/types_de_champ/rnf_type_de_champ.rb @@ -1,4 +1,36 @@ class TypesDeChamp::RNFTypeDeChamp < TypesDeChamp::TextTypeDeChamp + class << self + def champ_value_for_export(champ, path = :value) + case path + when :value + champ.rnf_id + when :departement + champ.departement_code_and_name + when :code_insee + champ.commune&.fetch(:code) + when :address + champ.full_address + when :nom + champ.title + end + end + + def champ_value_for_tag(champ, path = :value) + case path + when :value + champ.rnf_id + when :departement + champ.departement_code_and_name || '' + when :code_insee + champ.commune&.fetch(:code) || '' + when :address + champ.full_address || '' + when :nom + champ.title || '' + end + end + end + private def paths diff --git a/app/models/types_de_champ/textarea_type_de_champ.rb b/app/models/types_de_champ/textarea_type_de_champ.rb index 3c92afb1e..2cf976343 100644 --- a/app/models/types_de_champ/textarea_type_de_champ.rb +++ b/app/models/types_de_champ/textarea_type_de_champ.rb @@ -2,4 +2,10 @@ class TypesDeChamp::TextareaTypeDeChamp < TypesDeChamp::TextTypeDeChamp def estimated_fill_duration(revision) FILL_DURATION_MEDIUM end + + class << self + def champ_value_for_export(champ, path = :value) + ActionView::Base.full_sanitizer.sanitize(champ.value) + end + end end diff --git a/app/models/types_de_champ/titre_identite_type_de_champ.rb b/app/models/types_de_champ/titre_identite_type_de_champ.rb index 36ff0cd52..69c2dcb1c 100644 --- a/app/models/types_de_champ/titre_identite_type_de_champ.rb +++ b/app/models/types_de_champ/titre_identite_type_de_champ.rb @@ -7,4 +7,18 @@ class TypesDeChamp::TitreIdentiteTypeDeChamp < TypesDeChamp::TypeDeChampBase end def tags_for_template = [].freeze + + class << self + def champ_value_for_export(champ, path = :value) + champ.piece_justificative_file.attached? ? "présent" : "absent" + end + + def champ_value_for_api(champ, version = 2) + nil + end + + def champ_default_export_value(path = :value) + "absent" + end + end end diff --git a/app/models/types_de_champ/yes_no_type_de_champ.rb b/app/models/types_de_champ/yes_no_type_de_champ.rb index 25dae97bc..e924efafd 100644 --- a/app/models/types_de_champ/yes_no_type_de_champ.rb +++ b/app/models/types_de_champ/yes_no_type_de_champ.rb @@ -19,4 +19,50 @@ class TypesDeChamp::YesNoTypeDeChamp < TypesDeChamp::CheckboxTypeDeChamp human_value end end + + class << self + def champ_value(champ) + champ_formatted_value(champ) + end + + def champ_value_for_tag(champ, path = :value) + champ_formatted_value(champ) + end + + def champ_value_for_export(champ, path = :value) + champ_formatted_value(champ) + end + + def champ_value_for_api(champ, version = 2) + case version + when 2 + champ.true? ? 'true' : 'false' + else + super + end + end + + def champ_default_value + 'Non' + end + + def champ_default_export_value(path = :value) + 'Non' + end + + def champ_default_api_value(version = 2) + case version + when 2 + 'false' + else + nil + end + end + + private + + def champ_formatted_value(champ) + champ.true? ? 'Oui' : 'Non' + end + end end From 371b8b0b461b33a34d1c06df9a5d4bdcf12341f9 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 23 Apr 2024 10:02:42 +0200 Subject: [PATCH 0074/1532] refactor(export): remove old formatting code from champs --- app/models/champs/address_champ.rb | 30 ---------------- app/models/champs/boolean_champ.rb | 20 ----------- app/models/champs/carte_champ.rb | 8 ----- app/models/champs/checkbox_champ.rb | 4 --- app/models/champs/cojo_champ.rb | 4 --- app/models/champs/commune_champ.rb | 26 -------------- app/models/champs/date_champ.rb | 10 ------ app/models/champs/datetime_champ.rb | 8 ----- app/models/champs/decimal_number_champ.rb | 14 -------- app/models/champs/departement_champ.rb | 34 ------------------- app/models/champs/epci_champ.rb | 22 ------------ app/models/champs/integer_number_champ.rb | 16 --------- .../champs/linked_drop_down_list_champ.rb | 30 ---------------- .../champs/multiple_drop_down_list_champ.rb | 12 ------- app/models/champs/pays_champ.rb | 22 ------------ app/models/champs/phone_champ.rb | 34 +------------------ .../champs/piece_justificative_champ.rb | 16 --------- app/models/champs/region_champ.rb | 18 ---------- app/models/champs/rna_champ.rb | 4 --- app/models/champs/rnf_champ.rb | 30 ---------------- app/models/champs/textarea_champ.rb | 4 --- app/models/champs/titre_identite_champ.rb | 8 ----- 22 files changed, 1 insertion(+), 373 deletions(-) diff --git a/app/models/champs/address_champ.rb b/app/models/champs/address_champ.rb index 751b9844a..1c4d9e78f 100644 --- a/app/models/champs/address_champ.rb +++ b/app/models/champs/address_champ.rb @@ -38,36 +38,6 @@ class Champs::AddressChamp < Champs::TextChamp end end - def to_s - address_label.presence || '' - end - - def for_tag(path = :value) - case path - when :value - address_label - when :departement - departement_code_and_name || '' - when :commune - commune_name || '' - end - end - - def for_export(path = :value) - case path - when :value - value.present? ? address_label : nil - when :departement - departement_code_and_name - when :commune - commune_name - end - end - - def for_api - address_label - end - def code_departement if full_address? address.fetch('department_code') diff --git a/app/models/champs/boolean_champ.rb b/app/models/champs/boolean_champ.rb index 745a3536d..7d8a8d502 100644 --- a/app/models/champs/boolean_champ.rb +++ b/app/models/champs/boolean_champ.rb @@ -17,28 +17,8 @@ class Champs::BooleanChamp < Champ end end - def to_s - processed_value - end - - def for_tag(path = :value) - processed_value - end - - def for_export(path = :value) - processed_value - end - - def for_api_v2 - true? ? 'true' : 'false' - end - private - def processed_value - true? ? 'Oui' : 'Non' - end - def set_value_to_nil self.value = nil end diff --git a/app/models/champs/carte_champ.rb b/app/models/champs/carte_champ.rb index c19d2d913..ff2f3252a 100644 --- a/app/models/champs/carte_champ.rb +++ b/app/models/champs/carte_champ.rb @@ -83,14 +83,6 @@ class Champs::CarteChamp < Champ end end - def for_api - nil - end - - def for_export(path = :value) - geo_areas.map(&:label).join("\n") - end - def blank? geo_areas.blank? end diff --git a/app/models/champs/checkbox_champ.rb b/app/models/champs/checkbox_champ.rb index f58bc76d0..0032c0d05 100644 --- a/app/models/champs/checkbox_champ.rb +++ b/app/models/champs/checkbox_champ.rb @@ -1,8 +1,4 @@ class Champs::CheckboxChamp < Champs::BooleanChamp - def for_export(path = :value) - true? ? 'on' : 'off' - end - def mandatory_blank? mandatory? && (blank? || !true?) end diff --git a/app/models/champs/cojo_champ.rb b/app/models/champs/cojo_champ.rb index be2ab2311..514db2a8e 100644 --- a/app/models/champs/cojo_champ.rb +++ b/app/models/champs/cojo_champ.rb @@ -34,10 +34,6 @@ class Champs::COJOChamp < Champ 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 diff --git a/app/models/champs/commune_champ.rb b/app/models/champs/commune_champ.rb index 8dc7c9903..be62ce968 100644 --- a/app/models/champs/commune_champ.rb +++ b/app/models/champs/commune_champ.rb @@ -2,28 +2,6 @@ class Champs::CommuneChamp < Champs::TextChamp store_accessor :value_json, :code_departement, :code_postal, :code_region before_save :on_codes_change, if: :should_refresh_after_code_change? - def for_export(path = :value) - case path - when :value - to_s - when :departement - departement_code_and_name || '' - when :code - code || '' - end - end - - def for_tag(path = :value) - case path - when :value - to_s - when :departement - departement_code_and_name || '' - when :code - code || '' - end - end - def departement_name APIGeoService.departement_name(code_departement) end @@ -60,10 +38,6 @@ class Champs::CommuneChamp < Champs::TextChamp APIGeoService.safely_normalize_city_name(code_departement, code, safe_to_s) end - def to_s - code_postal? ? "#{name} (#{code_postal})" : name - end - def code external_id end diff --git a/app/models/champs/date_champ.rb b/app/models/champs/date_champ.rb index 4a9d1a215..2e2d5fdf4 100644 --- a/app/models/champs/date_champ.rb +++ b/app/models/champs/date_champ.rb @@ -6,16 +6,6 @@ class Champs::DateChamp < Champ # Text search is pretty useless for dates so we’re not including these champs end - def to_s - value.present? ? I18n.l(Time.zone.parse(value), format: '%d %B %Y') : "" - rescue ArgumentError - value.presence || "" # old dossiers can have not parseable dates - end - - def for_tag(path = :value) - to_s if path == :value - end - private def convert_to_iso8601 diff --git a/app/models/champs/datetime_champ.rb b/app/models/champs/datetime_champ.rb index 93983b5dd..c2517acce 100644 --- a/app/models/champs/datetime_champ.rb +++ b/app/models/champs/datetime_champ.rb @@ -6,14 +6,6 @@ class Champs::DatetimeChamp < Champ # Text search is pretty useless for datetimes so we’re not including these champs end - def to_s - value.present? ? I18n.l(Time.zone.parse(value)) : "" - end - - def for_tag(path = :value) - value.present? ? I18n.l(Time.zone.parse(value)) : "" - end - private def convert_to_iso8601 diff --git a/app/models/champs/decimal_number_champ.rb b/app/models/champs/decimal_number_champ.rb index 856c92a4c..57091da53 100644 --- a/app/models/champs/decimal_number_champ.rb +++ b/app/models/champs/decimal_number_champ.rb @@ -17,14 +17,6 @@ class Champs::DecimalNumberChamp < Champ } }, if: :validate_champ_value_or_prefill? - def for_export(path = :value) - processed_value - end - - def for_api - processed_value - end - private def format_value @@ -32,10 +24,4 @@ class Champs::DecimalNumberChamp < Champ self.value = value.tr(",", ".") end - - def processed_value - return unless valid_champ_value? - - value&.to_f - end end diff --git a/app/models/champs/departement_champ.rb b/app/models/champs/departement_champ.rb index d599e6249..12a1becdc 100644 --- a/app/models/champs/departement_champ.rb +++ b/app/models/champs/departement_champ.rb @@ -5,36 +5,6 @@ class Champs::DepartementChamp < Champs::TextChamp validate :external_id_in_departement_codes, if: -> { validate_champ_value_or_prefill? && !external_id.nil? } before_save :store_code_region - def for_export(path = :value) - case path - when :code - code - when :value - name - end - end - - def to_s - formatted_value - end - - def for_tag(path = :value) - case path - when :code - code - when :value - formatted_value - end - end - - def for_api - formatted_value - end - - def for_api_v2 - formatted_value.tr('–', '-') - end - def selected code end @@ -71,10 +41,6 @@ class Champs::DepartementChamp < Champs::TextChamp private - def formatted_value - blank? ? "" : "#{code} – #{name}" - end - def value_in_departement_names return if value.in?(APIGeoService.departements.pluck(:name)) diff --git a/app/models/champs/epci_champ.rb b/app/models/champs/epci_champ.rb index 6f11b462a..7168e0682 100644 --- a/app/models/champs/epci_champ.rb +++ b/app/models/champs/epci_champ.rb @@ -7,28 +7,6 @@ class Champs::EpciChamp < Champs::TextChamp validate :external_id_in_departement_epci_codes, if: -> { !(code_departement.nil? || external_id.nil?) && validate_champ_value_or_prefill? } validate :value_in_departement_epci_names, if: -> { !(code_departement.nil? || external_id.nil? || value.nil?) && validate_champ_value_or_prefill? } - def for_export(path = :value) - case path - when :value - value - when :code - code - when :departement - departement_code_and_name - end - end - - def for_tag(path = :value) - case path - when :value - value - when :code - code - when :departement - departement_code_and_name - end - end - def departement_name APIGeoService.departement_name(code_departement) end diff --git a/app/models/champs/integer_number_champ.rb b/app/models/champs/integer_number_champ.rb index 69f95adc4..39adbb5fc 100644 --- a/app/models/champs/integer_number_champ.rb +++ b/app/models/champs/integer_number_champ.rb @@ -8,20 +8,4 @@ class Champs::IntegerNumberChamp < Champ object.errors.generate_message(:value, :not_an_integer) } }, if: :validate_champ_value_or_prefill? - - def for_export(path = :value) - processed_value - end - - def for_api - processed_value - end - - private - - def processed_value - return unless valid_champ_value? - - value&.to_i - end end diff --git a/app/models/champs/linked_drop_down_list_champ.rb b/app/models/champs/linked_drop_down_list_champ.rb index 6a1edcaef..169a7aa33 100644 --- a/app/models/champs/linked_drop_down_list_champ.rb +++ b/app/models/champs/linked_drop_down_list_champ.rb @@ -37,36 +37,6 @@ class Champs::LinkedDropDownListChamp < Champ :primary_value end - def to_s - value.present? ? [primary_value, secondary_value].filter(&:present?).join(' / ') : "" - end - - def for_tag(path = :value) - case path - when :primary - primary_value - when :secondary - secondary_value - when :value - value.present? ? [primary_value, secondary_value].filter(&:present?).join(' / ') : "" - end - end - - def for_export(path = :value) - case path - when :primary - primary_value - when :secondary - secondary_value - when :value - value.present? ? "#{primary_value || ''};#{secondary_value || ''}" : nil - end - end - - def for_api - value.present? ? { primary: primary_value, secondary: secondary_value } : nil - end - def blank? primary_value.blank? || (has_secondary_options_for_primary? && secondary_value.blank?) diff --git a/app/models/champs/multiple_drop_down_list_champ.rb b/app/models/champs/multiple_drop_down_list_champ.rb index 60e8be104..cd55dbc0c 100644 --- a/app/models/champs/multiple_drop_down_list_champ.rb +++ b/app/models/champs/multiple_drop_down_list_champ.rb @@ -19,18 +19,6 @@ class Champs::MultipleDropDownListChamp < Champ value.blank? ? [] : JSON.parse(value) end - def to_s - selected_options.join(', ') - end - - def for_tag(path = :value) - selected_options.join(', ') - end - - def for_export(path = :value) - value.present? ? selected_options.join(', ') : nil - end - def render_as_checkboxes? enabled_non_empty_options.size <= THRESHOLD_NB_OPTIONS_AS_CHECKBOX end diff --git a/app/models/champs/pays_champ.rb b/app/models/champs/pays_champ.rb index 4dc5c0a6d..2b89ecde9 100644 --- a/app/models/champs/pays_champ.rb +++ b/app/models/champs/pays_champ.rb @@ -11,28 +11,6 @@ class Champs::PaysChamp < Champs::TextChamp validates :value, inclusion: APIGeoService.countries.pluck(:name), allow_nil: false, allow_blank: false end - def for_export(path = :value) - case path - when :code - code - when :value - name - end - end - - def to_s - name - end - - def for_tag(path = :value) - case path - when :code - code - when :value - name - end - end - def selected code || value end diff --git a/app/models/champs/phone_champ.rb b/app/models/champs/phone_champ.rb index e22e70682..acb603152 100644 --- a/app/models/champs/phone_champ.rb +++ b/app/models/champs/phone_champ.rb @@ -1,41 +1,9 @@ class Champs::PhoneChamp < Champs::TextChamp - # We want to allow: - # * international (e164) phone numbers - # * “french format” (ten digits with a leading 0) - # * DROM numbers - # - # However, we need to special-case some ten-digit numbers, - # because the ARCEP assigns some blocks of "O6 XX XX XX XX" numbers to DROM operators. - # Guadeloupe | GP | +590 | 0690XXXXXX, 0691XXXXXX - # Guyane | GF | +594 | 0694XXXXXX - # Martinique | MQ | +596 | 0696XXXXXX, 0697XXXXXX - # Réunion | RE | +262 | 0692XXXXXX, 0693XXXXXX - # Mayotte | YT | +262 | 0692XXXXXX, 0693XXXXXX - # Nouvelle-Calédonie | NC | +687 | - # Polynésie française | PF | +689 | 40XXXXXX, 45XXXXXX, 87XXXXXX, 88XXXXXX, 89XXXXXX - # - # Cf: Plan national de numérotation téléphonique, - # https://www.arcep.fr/uploads/tx_gsavis/05-1085.pdf “Numéros mobiles à 10 chiffres”, page 6 - # - # See issue #6996. - DEFAULT_COUNTRY_CODES = [:FR, :GP, :GF, :MQ, :RE, :YT, :NC, :PF].freeze validates :value, phone: { possible: true, allow_blank: true, message: I18n.t(:not_a_phone, scope: 'activerecord.errors.messages') }, - if: -> { !Phonelib.valid_for_countries?(value, DEFAULT_COUNTRY_CODES) && validate_champ_value_or_prefill? } - - def to_s - return '' if value.blank? - - if Phonelib.valid_for_countries?(value, DEFAULT_COUNTRY_CODES) - Phonelib.parse_for_countries(value, DEFAULT_COUNTRY_CODES).full_national - else - # When he phone number is possible for the default countries, but not strictly valid, - # `full_national` could mess up the formatting. In this case just return the original. - value - end - end + if: -> { !Phonelib.valid_for_countries?(value, TypesDeChamp::PhoneTypeDeChamp::DEFAULT_COUNTRY_CODES) && validate_champ_value_or_prefill? } end diff --git a/app/models/champs/piece_justificative_champ.rb b/app/models/champs/piece_justificative_champ.rb index 0708460e2..37f50d888 100644 --- a/app/models/champs/piece_justificative_champ.rb +++ b/app/models/champs/piece_justificative_champ.rb @@ -25,20 +25,4 @@ class Champs::PieceJustificativeChamp < Champ def blank? piece_justificative_file.blank? end - - def for_export(path = :value) - piece_justificative_file.map { _1.filename.to_s }.join(', ') - end - - def for_api - return nil unless piece_justificative_file.attached? - - # API v1 don't support multiple PJ - attachment = piece_justificative_file.first - return nil if attachment.nil? - - if attachment.virus_scanner.safe? || attachment.virus_scanner.pending? - attachment.url - end - end end diff --git a/app/models/champs/region_champ.rb b/app/models/champs/region_champ.rb index 04fab515c..60e791c47 100644 --- a/app/models/champs/region_champ.rb +++ b/app/models/champs/region_champ.rb @@ -2,24 +2,6 @@ class Champs::RegionChamp < Champs::TextChamp validate :value_in_region_names, if: -> { !value.nil? && validate_champ_value_or_prefill? } validate :external_id_in_region_codes, if: -> { !external_id.nil? && validate_champ_value_or_prefill? } - def for_export(path = :value) - case path - when :value - name - when :code - code - end - end - - def for_tag(path = :value) - case path - when :value - name - when :code - code - end - end - def selected code end diff --git a/app/models/champs/rna_champ.rb b/app/models/champs/rna_champ.rb index 5905d35fa..b1e3397c9 100644 --- a/app/models/champs/rna_champ.rb +++ b/app/models/champs/rna_champ.rb @@ -15,10 +15,6 @@ class Champs::RNAChamp < Champ title.present? ? "#{value} (#{title})" : value end - def for_export(path = :value) - identifier - end - def search_terms etablissement.present? ? etablissement.search_terms : [value] end diff --git a/app/models/champs/rnf_champ.rb b/app/models/champs/rnf_champ.rb index cb0de4e8d..e9ca751ea 100644 --- a/app/models/champs/rnf_champ.rb +++ b/app/models/champs/rnf_champ.rb @@ -25,36 +25,6 @@ class Champs::RNFChamp < Champ rnf_id.blank? end - def for_export(path = :value) - case path - when :value - rnf_id - when :departement - departement_code_and_name - when :code_insee - commune&.fetch(:code) - when :address - full_address - when :nom - title - end - end - - def for_tag(path = :value) - case path - when :value - rnf_id - when :departement - departement_code_and_name || '' - when :code_insee - commune&.fetch(:code) || '' - when :address - full_address || '' - when :nom - title || '' - end - end - def code_departement address.present? && address['departmentCode'] end diff --git a/app/models/champs/textarea_champ.rb b/app/models/champs/textarea_champ.rb index abcb2644e..44c67ece8 100644 --- a/app/models/champs/textarea_champ.rb +++ b/app/models/champs/textarea_champ.rb @@ -1,8 +1,4 @@ class Champs::TextareaChamp < Champs::TextChamp - def for_export(path = :value) - value.present? ? ActionView::Base.full_sanitizer.sanitize(value) : nil - end - def remaining_characters character_limit_base - character_count if character_count >= character_limit_threshold_75 end diff --git a/app/models/champs/titre_identite_champ.rb b/app/models/champs/titre_identite_champ.rb index ae57491bd..86df81ee3 100644 --- a/app/models/champs/titre_identite_champ.rb +++ b/app/models/champs/titre_identite_champ.rb @@ -19,12 +19,4 @@ class Champs::TitreIdentiteChamp < Champ def blank? piece_justificative_file.blank? end - - def for_export(path = :value) - piece_justificative_file.attached? ? "présent" : "absent" - end - - def for_api - nil - end end From 39364961ab3c2cdbefc66c8c8f51109d57889460 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 23 Apr 2024 10:03:46 +0200 Subject: [PATCH 0075/1532] refactor(export): expose new interface on type de champ and use it in champs --- app/models/champ.rb | 27 +++++---- app/models/concerns/dossier_champs_concern.rb | 2 +- app/models/type_de_champ.rb | 56 +++++++++++++++++-- .../types_de_champ/type_de_champ_base.rb | 40 +++++++++++++ 4 files changed, 108 insertions(+), 17 deletions(-) diff --git a/app/models/champ.rb b/app/models/champ.rb index 7fa02202b..7c98eebcf 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -109,24 +109,29 @@ class Champ < ApplicationRecord [to_s] end - def to_s - value.present? ? value.to_s : '' - end - - def for_export(path = :value) - path == :value ? value.presence : nil - end - - def for_api + def valid_value + return unless valid_champ_value? value end + def to_s + TypeDeChamp.champ_value(type_champ, self) + end + + def for_api + TypeDeChamp.champ_value_for_api(type_champ, self, 1) + end + def for_api_v2 - to_s + TypeDeChamp.champ_value_for_api(type_champ, self, 2) + end + + def for_export(path = :value) + TypeDeChamp.champ_value_for_export(type_champ, self, path) end def for_tag(path = :value) - path == :value && value.present? ? value.to_s : '' + TypeDeChamp.champ_value_for_tag(type_champ, self, path) end def main_value_name diff --git a/app/models/concerns/dossier_champs_concern.rb b/app/models/concerns/dossier_champs_concern.rb index ab5a7545e..1e5f2bc3e 100644 --- a/app/models/concerns/dossier_champs_concern.rb +++ b/app/models/concerns/dossier_champs_concern.rb @@ -25,7 +25,7 @@ module DossierChampsConcern types_de_champ.flat_map do |type_de_champ| champ = champ_for_export(type_de_champ, row_id) type_de_champ.libelles_for_export.map do |(libelle, path)| - [libelle, champ&.for_export(path)] + [libelle, TypeDeChamp.champ_value_for_export(type_de_champ.type_champ, champ, path)] end end end diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index b4ff3ac51..78f5f6bac 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -250,7 +250,7 @@ class TypeDeChamp < ApplicationRecord def params_for_champ { private: private?, - type: "Champs::#{type_champ.classify}Champ", + type: self.class.type_champ_to_champ_class_name(type_champ), stable_id:, stream: 'main' } @@ -463,10 +463,6 @@ class TypeDeChamp < ApplicationRecord !private? end - def self.type_champ_to_class_name(type_champ) - "TypesDeChamp::#{type_champ.classify}TypeDeChamp" - end - def filename_for_attachement(attachment_sym) attachment = send(attachment_sym) if attachment.attached? @@ -687,6 +683,56 @@ class TypeDeChamp < ApplicationRecord end end + class << self + def champ_value(type_champ, champ) + dynamic_type_class = type_champ_to_class_name(type_champ).constantize + # special case for linked drop down champ – it's blank implementation is not what you think + if champ.blank? && (type_champ != TypeDeChamp.type_champs.fetch(:linked_drop_down_list) || champ.nil? || champ.value.blank?) + dynamic_type_class.champ_default_value + else + dynamic_type_class.champ_value(champ) + end + end + + def champ_value_for_api(type_champ, champ, version = 2) + dynamic_type_class = type_champ_to_class_name(type_champ).constantize + # special case for linked drop down champ – it's blank implementation is not what you think + if champ.blank? && (type_champ != TypeDeChamp.type_champs.fetch(:linked_drop_down_list) || champ.nil? || champ.value.blank?) + dynamic_type_class.champ_default_api_value(version) + else + dynamic_type_class.champ_value_for_api(champ, version) + end + end + + def champ_value_for_export(type_champ, champ, path = :value) + dynamic_type_class = type_champ_to_class_name(type_champ).constantize + # special case for linked drop down champ – it's blank implementation is not what you think + if champ.blank? && (type_champ != TypeDeChamp.type_champs.fetch(:linked_drop_down_list) || champ.nil? || champ.value.blank?) + dynamic_type_class.champ_default_export_value(path) + else + dynamic_type_class.champ_value_for_export(champ, path) + end + end + + def champ_value_for_tag(type_champ, champ, path = :value) + dynamic_type_class = type_champ_to_class_name(type_champ).constantize + # special case for linked drop down champ – it's blank implementation is not what you think + if champ.blank? && (type_champ != TypeDeChamp.type_champs.fetch(:linked_drop_down_list) || champ.nil? || champ.value.blank?) + '' + else + dynamic_type_class.champ_value_for_tag(champ, path) + end + end + + def type_champ_to_champ_class_name(type_champ) + "Champs::#{type_champ.classify}Champ" + end + + def type_champ_to_class_name(type_champ) + "TypesDeChamp::#{type_champ.classify}TypeDeChamp" + end + end + private DEFAULT_EMPTY = [''] diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index db0d38d9b..81217caa2 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -56,6 +56,46 @@ class TypesDeChamp::TypeDeChampBase human_value end + class << self + def champ_value(champ) + champ.value.present? ? champ.value.to_s : champ_default_value + end + + def champ_value_for_api(champ, version = 2) + case version + when 2 + champ_value(champ) + else + champ.valid_value.presence || champ_default_api_value(version) + end + end + + def champ_value_for_export(champ, path = :value) + path == :value ? champ.valid_value.presence : champ_default_export_value(path) + end + + def champ_value_for_tag(champ, path = :value) + path == :value ? champ_value(champ) : nil + end + + def champ_default_value + '' + end + + def champ_default_export_value(path = :value) + nil + end + + def champ_default_api_value(version = 2) + case version + when 2 + '' + else + nil + end + end + end + private def paths From 244dcfcc2313dafe59b5dfc08592a52a896e7b04 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 23 Apr 2024 10:14:19 +0200 Subject: [PATCH 0076/1532] refactor(export): improuve specs --- app/models/champs/pays_champ.rb | 4 + spec/models/champ_spec.rb | 470 +++++++++--------- spec/models/champs/address_champ_spec.rb | 2 +- spec/models/champs/carte_champ_spec.rb | 2 +- spec/models/champs/departement_champ_spec.rb | 2 +- spec/models/champs/epci_champ_spec.rb | 17 +- .../linked_drop_down_list_champ_spec.rb | 20 +- spec/models/champs/pays_champ_spec.rb | 2 +- .../champs/piece_justificative_champ_spec.rb | 2 +- spec/models/champs/pole_emploi_champ_spec.rb | 2 +- spec/models/champs/region_champ_spec.rb | 6 +- spec/serializers/champ_serializer_spec.rb | 2 +- .../dossier_projection_service_spec.rb | 2 +- 13 files changed, 268 insertions(+), 265 deletions(-) diff --git a/app/models/champs/pays_champ.rb b/app/models/champs/pays_champ.rb index 2b89ecde9..bd88da4b8 100644 --- a/app/models/champs/pays_champ.rb +++ b/app/models/champs/pays_champ.rb @@ -33,6 +33,10 @@ class Champs::PaysChamp < Champs::TextChamp end end + def blank? + value.blank? && external_id.blank? + end + def code external_id || APIGeoService.country_code(value) end diff --git a/spec/models/champ_spec.rb b/spec/models/champ_spec.rb index bf22d18dc..1501b3fe7 100644 --- a/spec/models/champ_spec.rb +++ b/spec/models/champ_spec.rb @@ -142,10 +142,7 @@ describe Champ do end describe 'for_export' do - let(:type_de_champ) { create(:type_de_champ) } - let(:champ) { type_de_champ.champ.build(value: value) } - - before { champ.save } + let(:champ) { create(:champ_text, value: value) } context 'when type_de_champ is text' do let(:value) { '123' } @@ -154,14 +151,14 @@ describe Champ do end context 'when type_de_champ is textarea' do - let(:type_de_champ) { create(:type_de_champ_textarea) } + let(:champ) { create(:champ_textarea, value: value) } let(:value) { 'gras' } it { expect(champ.for_export).to eq('gras') } end context 'when type_de_champ is yes_no' do - let(:type_de_champ) { create(:type_de_champ_yes_no) } + let(:champ) { create(:champ_yes_no, value: value) } context 'if yes' do let(:value) { 'true' } @@ -182,249 +179,242 @@ describe Champ do end end - describe '#search_terms' do - let(:champ) { type_de_champ.champ.build(value: value) } - subject { champ.search_terms } - - context 'for adresse champ' do - let(:type_de_champ) { build(:type_de_champ_address) } - let(:value) { "10 rue du Pinson qui Piaille" } - - it { is_expected.to eq([value]) } - end - - context 'for checkbox champ' do - let(:libelle) { 'majeur' } - let(:type_de_champ) { build(:type_de_champ_checkbox, libelle: libelle) } - - context 'when the box is checked' do - let(:value) { 'true' } - - it { is_expected.to eq([libelle]) } - end - - context 'when the box is unchecked' do - let(:value) { 'false' } - - it { is_expected.to be_nil } - end - end - - context 'for civilite champ' do - let(:type_de_champ) { build(:type_de_champ_civilite) } - let(:value) { "M." } - - it { is_expected.to eq([value]) } - end - - context 'for date champ' do - let(:type_de_champ) { build(:type_de_champ_date) } - let(:value) { "2018-07-30" } - - it { is_expected.to be_nil } - end - - context 'for date time champ' do - let(:type_de_champ) { build(:type_de_champ_datetime) } - let(:value) { "2018-04-29 09:00" } - - it { is_expected.to be_nil } - end - - context 'for département champ' do - let(:type_de_champ) { build(:type_de_champ_departements) } - let(:value) { "69" } - - it { is_expected.to eq(['69 – Rhône']) } - end - - context 'for dossier link champ' do - let(:type_de_champ) { build(:type_de_champ_dossier_link) } - let(:value) { "9103132886" } - - it { is_expected.to eq([value]) } - end - - context 'for drop down list champ' do - let(:type_de_champ) { build(:type_de_champ_dossier_link) } - let(:value) { "HLM" } - - it { is_expected.to eq([value]) } - end - - context 'for email champ' do - let(:type_de_champ) { build(:type_de_champ_email) } - let(:value) { "machin@example.com" } - - it { is_expected.to eq([value]) } - end - - context 'for explication champ' do - let(:type_de_champ) { build(:type_de_champ_explication) } - let(:value) { nil } - - it { is_expected.to be_nil } - end - - context 'for header section champ' do - let(:type_de_champ) { build(:type_de_champ_header_section) } - let(:value) { nil } - - it { is_expected.to be_nil } - end - - context 'for linked drop down list champ' do - let(:type_de_champ) { build(:type_de_champ_linked_drop_down_list) } - let(:champ) { type_de_champ.champ.build(primary_value: "hello", secondary_value: "world") } - - it { is_expected.to eq(["hello", "world"]) } - end - - context 'for multiple drop down list champ' do - let(:type_de_champ) { build(:type_de_champ_multiple_drop_down_list) } - - context 'when there are multiple values selected' do - let(:value) { JSON.generate(['goodbye', 'cruel', 'world']) } - - it { is_expected.to eq(["goodbye", "cruel", "world"]) } - end - - context 'when there is no value selected' do - let(:value) { nil } - - it { is_expected.to eq([]) } - end - end - - context 'for number champ' do - let(:type_de_champ) { build(:type_de_champ_number) } - let(:value) { "1234" } - - it { is_expected.to eq([value]) } - end - - context 'for pays champ' do - let(:type_de_champ) { build(:type_de_champ_pays) } - let(:value) { "FR" } - - it { is_expected.to eq(['France']) } - end - - context 'for phone champ' do - let(:type_de_champ) { build(:type_de_champ_phone) } - let(:value) { "06 06 06 06 06" } - - it { is_expected.to eq([value]) } - end - - context 'for pièce justificative champ' do - let(:type_de_champ) { build(:type_de_champ_piece_justificative) } - let(:value) { nil } - - it { is_expected.to be_nil } - end - - context 'for region champ' do - let(:type_de_champ) { build(:type_de_champ_regions) } - let(:value) { "11" } - - it { is_expected.to eq(['Île-de-France']) } - end - - context 'for siret champ' do - let(:type_de_champ) { build(:type_de_champ_siret) } - - context 'when there is an etablissement' do - let(:etablissement) do - build( - :etablissement, - siret: "35130347400024", - siege_social: true, - naf: "9004Z", - libelle_naf: "Gestion de salles de spectacles", - adresse: "MAISON JEUNES CULTURE FABRIQUE\r\n98 RUE DE PARIS\r\n59200 TOURCOING\r\nFRANCE\r\n", - numero_voie: "98", - type_voie: "RUE", - nom_voie: "DE PARIS", - code_postal: "59200", - localite: "TOURCOING", - code_insee_localite: "59599", - entreprise_siren: "351303474", - entreprise_numero_tva_intracommunautaire: "FR02351303474", - entreprise_forme_juridique: "Association déclarée ", - entreprise_forme_juridique_code: "9220", - entreprise_nom_commercial: "", - entreprise_raison_sociale: "MAISON DES JEUNES ET DE LA CULTURE DE LA FABRIQUE", - entreprise_siret_siege_social: "35130347400024", - entreprise_nom: 'Martin', - entreprise_prenom: 'Guillaume', - entreprise_code_effectif_entreprise: "12", - entreprise_date_creation: "1989-07-09", - association_rna: "W595004053", - association_titre: "MAISON DES JEUNES ET DE LA CULTURE DE LA FABRIQUE", - association_objet: "Création, gestion et animation de la Maison des Jeunes et de la Culture de la Fabrique, qui constitue un élément essentiel de la vie sociale et culturelle d'un territoire de vie : pays, agglomération, ville, communauté de communes, village, quartier ...", - association_date_creation: "1962-05-23", - association_date_declaration: "2016-12-02", - association_date_publication: "1962-05-31" - ) - end - let(:champ) { type_de_champ.champ.build(value: etablissement.siret, etablissement: etablissement) } - - it { is_expected.to eq([etablissement.entreprise_siren, etablissement.entreprise_numero_tva_intracommunautaire, etablissement.entreprise_forme_juridique, etablissement.entreprise_forme_juridique_code, etablissement.entreprise_nom_commercial, etablissement.entreprise_raison_sociale, etablissement.entreprise_siret_siege_social, etablissement.entreprise_nom, etablissement.entreprise_prenom, etablissement.association_rna, etablissement.association_titre, etablissement.association_objet, etablissement.siret, etablissement.enseigne, etablissement.naf, etablissement.libelle_naf, etablissement.adresse, etablissement.code_postal, etablissement.localite, etablissement.code_insee_localite]) } - end - - context 'when there is no etablissement' do - let(:siret) { "35130347400024" } - let(:champ) { type_de_champ.champ.build(value: siret) } - - it { is_expected.to eq([siret]) } - end - end - - context 'for text champ' do - let(:type_de_champ) { build(:type_de_champ_text) } - let(:value) { "Blah" } - - it { is_expected.to eq([value]) } - end - - context 'for text area champ' do - let(:type_de_champ) { build(:type_de_champ_textarea) } - let(:value) { "Bla\nBlah de bla." } - - it { is_expected.to eq([value]) } - end - - context 'for yes/no champ' do - let(:type_de_champ) { build(:type_de_champ_yes_no, libelle: libelle) } - let(:libelle) { 'avec enfant à charge' } - - context 'when the box is checked' do - let(:value) { "true" } - - it { is_expected.to eq([libelle]) } - end - - context 'when the box is unchecked' do - let(:value) { "false" } - - it { is_expected.to be_nil } - end - end - end - context 'when type_de_champ is multiple_drop_down_list' do - let(:type_de_champ) { create(:type_de_champ_multiple_drop_down_list) } + let(:champ) { create(:champ_multiple_drop_down_list, value:) } let(:value) { '["Crétinier", "Mousserie"]' } it { expect(champ.for_export).to eq('Crétinier, Mousserie') } end end + describe '#search_terms' do + subject { champ.search_terms } + + context 'for adresse champ' do + let(:champ) { create(:champ_address, value:) } + let(:value) { "10 rue du Pinson qui Piaille" } + + it { is_expected.to eq([value]) } + end + + context 'for checkbox champ' do + let(:libelle) { champ.libelle } + let(:champ) { create(:champ_checkbox, value:) } + + context 'when the box is checked' do + let(:value) { 'true' } + + it { is_expected.to eq([libelle]) } + end + + context 'when the box is unchecked' do + let(:value) { 'false' } + + it { is_expected.to be_nil } + end + end + + context 'for civilite champ' do + let(:champ) { create(:champ_civilite, value:) } + let(:value) { "M." } + + it { is_expected.to eq([value]) } + end + + context 'for date champ' do + let(:champ) { create(:champ_date, value:) } + let(:value) { "2018-07-30" } + + it { is_expected.to be_nil } + end + + context 'for date time champ' do + let(:champ) { create(:champ_datetime, value:) } + let(:value) { "2018-04-29 09:00" } + + it { is_expected.to be_nil } + end + + context 'for département champ' do + let(:champ) { create(:champ_departements, value:) } + let(:value) { "69" } + + it { is_expected.to eq(['69 – Rhône']) } + end + + context 'for dossier link champ' do + let(:champ) { create(:champ_dossier_link, value:) } + let(:value) { "9103132886" } + + it { is_expected.to eq([value]) } + end + + context 'for drop down list champ' do + let(:champ) { create(:champ_dossier_link, value:) } + let(:value) { "HLM" } + + it { is_expected.to eq([value]) } + end + + context 'for email champ' do + let(:champ) { build(:champ_email, value:) } + let(:value) { "machin@example.com" } + + it { is_expected.to eq([value]) } + end + + context 'for explication champ' do + let(:champ) { build(:champ_explication) } + + it { is_expected.to be_nil } + end + + context 'for header section champ' do + let(:champ) { build(:champ_header_section) } + + it { is_expected.to be_nil } + end + + context 'for linked drop down list champ' do + let(:champ) { create(:champ_linked_drop_down_list, primary_value: "hello", secondary_value: "world") } + + it { is_expected.to eq(["hello", "world"]) } + end + + context 'for multiple drop down list champ' do + let(:champ) { build(:champ_multiple_drop_down_list, value:) } + + context 'when there are multiple values selected' do + let(:value) { JSON.generate(['goodbye', 'cruel', 'world']) } + + it { is_expected.to eq(["goodbye", "cruel", "world"]) } + end + + context 'when there is no value selected' do + let(:value) { nil } + + it { is_expected.to eq([]) } + end + end + + context 'for number champ' do + let(:champ) { build(:champ_number, value:) } + let(:value) { "1234" } + + it { is_expected.to eq([value]) } + end + + context 'for pays champ' do + let(:champ) { build(:champ_pays, value:) } + let(:value) { "FR" } + + it { is_expected.to eq(['France']) } + end + + context 'for phone champ' do + let(:champ) { build(:champ_phone, value:) } + let(:value) { "06 06 06 06 06" } + + it { is_expected.to eq([value]) } + end + + context 'for pièce justificative champ' do + let(:champ) { build(:champ_piece_justificative, value:) } + let(:value) { nil } + + it { is_expected.to be_nil } + end + + context 'for region champ' do + let(:champ) { build(:champ_regions, value:) } + let(:value) { "11" } + + it { is_expected.to eq(['Île-de-France']) } + end + + context 'for siret champ' do + context 'when there is an etablissement' do + let(:etablissement) do + build( + :etablissement, + siret: "35130347400024", + siege_social: true, + naf: "9004Z", + libelle_naf: "Gestion de salles de spectacles", + adresse: "MAISON JEUNES CULTURE FABRIQUE\r\n98 RUE DE PARIS\r\n59200 TOURCOING\r\nFRANCE\r\n", + numero_voie: "98", + type_voie: "RUE", + nom_voie: "DE PARIS", + code_postal: "59200", + localite: "TOURCOING", + code_insee_localite: "59599", + entreprise_siren: "351303474", + entreprise_numero_tva_intracommunautaire: "FR02351303474", + entreprise_forme_juridique: "Association déclarée ", + entreprise_forme_juridique_code: "9220", + entreprise_nom_commercial: "", + entreprise_raison_sociale: "MAISON DES JEUNES ET DE LA CULTURE DE LA FABRIQUE", + entreprise_siret_siege_social: "35130347400024", + entreprise_nom: 'Martin', + entreprise_prenom: 'Guillaume', + entreprise_code_effectif_entreprise: "12", + entreprise_date_creation: "1989-07-09", + association_rna: "W595004053", + association_titre: "MAISON DES JEUNES ET DE LA CULTURE DE LA FABRIQUE", + association_objet: "Création, gestion et animation de la Maison des Jeunes et de la Culture de la Fabrique, qui constitue un élément essentiel de la vie sociale et culturelle d'un territoire de vie : pays, agglomération, ville, communauté de communes, village, quartier ...", + association_date_creation: "1962-05-23", + association_date_declaration: "2016-12-02", + association_date_publication: "1962-05-31" + ) + end + let(:champ) { create(:champ_siret, value: etablissement.siret, etablissement:) } + + it { is_expected.to eq([etablissement.entreprise_siren, etablissement.entreprise_numero_tva_intracommunautaire, etablissement.entreprise_forme_juridique, etablissement.entreprise_forme_juridique_code, etablissement.entreprise_nom_commercial, etablissement.entreprise_raison_sociale, etablissement.entreprise_siret_siege_social, etablissement.entreprise_nom, etablissement.entreprise_prenom, etablissement.association_rna, etablissement.association_titre, etablissement.association_objet, etablissement.siret, etablissement.enseigne, etablissement.naf, etablissement.libelle_naf, etablissement.adresse, etablissement.code_postal, etablissement.localite, etablissement.code_insee_localite]) } + end + + context 'when there is no etablissement' do + let(:champ) { create(:champ_siret, value:, etablissement: nil) } + let(:value) { "35130347400024" } + + it { is_expected.to eq([value]) } + end + end + + context 'for text champ' do + let(:champ) { build(:champ_text, value:) } + let(:value) { "Blah" } + + it { is_expected.to eq([value]) } + end + + context 'for text area champ' do + let(:champ) { build(:champ_textarea, value:) } + let(:value) { "Bla\nBlah de bla." } + + it { is_expected.to eq([value]) } + end + + context 'for yes/no champ' do + let(:champ) { build(:champ_yes_no, value:) } + let(:libelle) { champ.libelle } + + context 'when the box is checked' do + let(:value) { "true" } + + it { is_expected.to eq([libelle]) } + end + + context 'when the box is unchecked' do + let(:value) { "false" } + + it { is_expected.to be_nil } + end + end + end + describe '#enqueue_virus_scan' do context 'when type_champ is type_de_champ_piece_justificative' do - let(:type_de_champ) { create(:type_de_champ_piece_justificative) } - let(:champ) { build(:champ_piece_justificative, type_de_champ: type_de_champ) } + let(:champ) { build(:champ_piece_justificative) } context 'and there is a blob' do before do diff --git a/spec/models/champs/address_champ_spec.rb b/spec/models/champs/address_champ_spec.rb index 21b8c458c..7cc27997d 100644 --- a/spec/models/champs/address_champ_spec.rb +++ b/spec/models/champs/address_champ_spec.rb @@ -1,5 +1,5 @@ describe Champs::AddressChamp do - let(:champ) { Champs::AddressChamp.new(value: value, data: data, type_de_champ: create(:type_de_champ_address)) } + let(:champ) { build(:champ_address, value:, data:) } let(:value) { '' } let(:data) { nil } diff --git a/spec/models/champs/carte_champ_spec.rb b/spec/models/champs/carte_champ_spec.rb index 71c2c3ff8..37bdadb02 100644 --- a/spec/models/champs/carte_champ_spec.rb +++ b/spec/models/champs/carte_champ_spec.rb @@ -1,5 +1,5 @@ describe Champs::CarteChamp do - let(:champ) { Champs::CarteChamp.new(geo_areas: geo_areas, type_de_champ: create(:type_de_champ_carte)) } + let(:champ) { build(:champ_carte, geo_areas:) } let(:value) { '' } let(:coordinates) { [[[2.3859214782714844, 48.87442541960633], [2.3850631713867183, 48.87273183590832], [2.3809432983398438, 48.87081237174292], [2.3859214782714844, 48.87442541960633]]] } let(:geo_json) do diff --git a/spec/models/champs/departement_champ_spec.rb b/spec/models/champs/departement_champ_spec.rb index c3cb405e6..38cbe67a8 100644 --- a/spec/models/champs/departement_champ_spec.rb +++ b/spec/models/champs/departement_champ_spec.rb @@ -61,7 +61,7 @@ describe Champs::DepartementChamp, type: :model do end describe 'value' do - let(:champ) { described_class.new } + let(:champ) { build(:champ_departements, value: nil) } it 'with code having 2 chars' do champ.value = '01' diff --git a/spec/models/champs/epci_champ_spec.rb b/spec/models/champs/epci_champ_spec.rb index b81195e86..759e07558 100644 --- a/spec/models/champs/epci_champ_spec.rb +++ b/spec/models/champs/epci_champ_spec.rb @@ -144,16 +144,19 @@ describe Champs::EpciChamp, type: :model do end describe 'value' do - let(:champ) { described_class.new } + let(:champ) { build(:champ_epci, external_id: nil, value: nil) } + let(:epci) { APIGeoService.epcis('01').first } + it 'with departement and code' do champ.code_departement = '01' - champ.value = '200042935' - expect(champ.external_id).to eq('200042935') - expect(champ.value).to eq('CA Haut - Bugey Agglomération') - expect(champ.selected).to eq('200042935') - expect(champ.code).to eq('200042935') + champ.value = epci[:code] + expect(champ.blank?).to be_falsey + expect(champ.external_id).to eq(epci[:code]) + expect(champ.value).to eq(epci[:name]) + expect(champ.selected).to eq(epci[:code]) + expect(champ.code).to eq(epci[:code]) expect(champ.departement?).to be_truthy - expect(champ.to_s).to eq('CA Haut - Bugey Agglomération') + expect(champ.to_s).to eq(epci[:name]) end end end diff --git a/spec/models/champs/linked_drop_down_list_champ_spec.rb b/spec/models/champs/linked_drop_down_list_champ_spec.rb index 75a668647..88fcd9ccb 100644 --- a/spec/models/champs/linked_drop_down_list_champ_spec.rb +++ b/spec/models/champs/linked_drop_down_list_champ_spec.rb @@ -1,13 +1,13 @@ describe Champs::LinkedDropDownListChamp do describe '#unpack_value' do - let(:champ) { described_class.new(value: '["tata", "tutu"]') } + let(:champ) { build(:champ_linked_drop_down_list, value: '["tata", "tutu"]') } it { expect(champ.primary_value).to eq('tata') } it { expect(champ.secondary_value).to eq('tutu') } end describe '#pack_value' do - let(:champ) { described_class.new(primary_value: 'tata', secondary_value: 'tutu') } + let(:champ) { build(:champ_linked_drop_down_list, primary_value: 'tata', secondary_value: 'tutu') } before { champ.save } @@ -15,7 +15,7 @@ describe Champs::LinkedDropDownListChamp do end describe '#primary_value=' do - let!(:champ) { described_class.new(primary_value: 'tata', secondary_value: 'tutu') } + let!(:champ) { build(:champ_linked_drop_down_list, primary_value: 'tata', secondary_value: 'tutu') } before { champ.primary_value = '' } @@ -23,7 +23,7 @@ describe Champs::LinkedDropDownListChamp do end describe '#to_s' do - let(:champ) { described_class.new(primary_value: primary_value, secondary_value: secondary_value) } + let(:champ) { build(:champ_linked_drop_down_list, value: [primary_value, secondary_value].to_json) } let(:primary_value) { nil } let(:secondary_value) { nil } @@ -48,22 +48,28 @@ describe Champs::LinkedDropDownListChamp do end describe 'for_export' do + let(:champ) { build(:champ_linked_drop_down_list, value:) } + let(:value) { [primary_value, secondary_value].to_json } + let(:primary_value) { nil } + let(:secondary_value) { nil } + subject { champ.for_export } context 'with no value' do - let(:champ) { described_class.new } + let(:value) { nil } it { is_expected.to be_nil } end context 'with primary value' do - let(:champ) { described_class.new(primary_value: 'primary') } + let(:primary_value) { 'primary' } it { is_expected.to eq('primary;') } end context 'with secondary value' do - let(:champ) { described_class.new(primary_value: 'primary', secondary_value: 'secondary') } + let(:primary_value) { 'primary' } + let(:secondary_value) { 'secondary' } it { is_expected.to eq('primary;secondary') } end diff --git a/spec/models/champs/pays_champ_spec.rb b/spec/models/champs/pays_champ_spec.rb index 2110261e0..4c0da34d1 100644 --- a/spec/models/champs/pays_champ_spec.rb +++ b/spec/models/champs/pays_champ_spec.rb @@ -1,5 +1,5 @@ describe Champs::PaysChamp, type: :model do - let(:champ) { described_class.new } + let(:champ) { build(:champ_pays, value: nil) } describe 'value' do it 'with code' do diff --git a/spec/models/champs/piece_justificative_champ_spec.rb b/spec/models/champs/piece_justificative_champ_spec.rb index d3462235d..36f7a663e 100644 --- a/spec/models/champs/piece_justificative_champ_spec.rb +++ b/spec/models/champs/piece_justificative_champ_spec.rb @@ -47,7 +47,7 @@ describe Champs::PieceJustificativeChamp do context 'without attached file' do before { champ_pj.piece_justificative_file.purge } - it { is_expected.to eq('') } + it { is_expected.to eq(nil) } end end diff --git a/spec/models/champs/pole_emploi_champ_spec.rb b/spec/models/champs/pole_emploi_champ_spec.rb index baf248152..be30dcb25 100644 --- a/spec/models/champs/pole_emploi_champ_spec.rb +++ b/spec/models/champs/pole_emploi_champ_spec.rb @@ -1,5 +1,5 @@ describe Champs::PoleEmploiChamp, type: :model do - let(:champ) { described_class.new } + let(:champ) { build(:champ_pole_emploi) } describe 'identifiant' do before do diff --git a/spec/models/champs/region_champ_spec.rb b/spec/models/champs/region_champ_spec.rb index 970c50613..1d012c68b 100644 --- a/spec/models/champs/region_champ_spec.rb +++ b/spec/models/champs/region_champ_spec.rb @@ -1,7 +1,7 @@ describe Champs::RegionChamp, type: :model do describe 'validations' do describe 'external link' do - let(:champ) { build(:champ_regions, external_id: external_id) } + let(:champ) { build(:champ_regions, value: nil, external_id: external_id) } subject { champ.validate(:champs_public_value) } context 'when nil' do let(:external_id) { nil } @@ -29,7 +29,7 @@ describe Champs::RegionChamp, type: :model do end describe 'value' do - let(:champ) { create(:champ_regions) } + let(:champ) { create(:champ_regions, value: nil) } subject { champ.validate(:champs_public_value) } before { champ.update_columns(value: value) } @@ -61,7 +61,7 @@ describe Champs::RegionChamp, type: :model do end describe 'value' do - let(:champ) { described_class.new } + let(:champ) { build(:champ_regions, value: nil) } it 'with code' do champ.value = '01' diff --git a/spec/serializers/champ_serializer_spec.rb b/spec/serializers/champ_serializer_spec.rb index 68710ea58..651fe6d38 100644 --- a/spec/serializers/champ_serializer_spec.rb +++ b/spec/serializers/champ_serializer_spec.rb @@ -104,7 +104,7 @@ describe ChampSerializer do context 'when type champ is siret' do let(:etablissement) { create(:etablissement) } - let(:champ) { create(:type_de_champ_siret).champ.create(etablissement: etablissement, value: etablissement.siret) } + let(:champ) { create(:champ_siret, etablissement:, value: etablissement.siret) } it { is_expected.to include(value: etablissement.siret) diff --git a/spec/services/dossier_projection_service_spec.rb b/spec/services/dossier_projection_service_spec.rb index 4d5f03667..14865a971 100644 --- a/spec/services/dossier_projection_service_spec.rb +++ b/spec/services/dossier_projection_service_spec.rb @@ -214,7 +214,7 @@ describe DossierProjectionService do let(:dossier) { create(:dossier, procedure: procedure) } let(:column) { dossier.procedure.active_revision.types_de_champ_public.first.stable_id.to_s } - before { dossier.champs_public.first.update(data: { 'label' => '18 a la bonne rue', 'departement' => 'd' }) } + before { dossier.champs_public.first.update(value: '18 a la bonne rue', data: { 'label' => '18 a la bonne rue', 'departement' => 'd' }) } it { is_expected.to eq('18 a la bonne rue') } end From ecd8ea9ee559f151638ac9fdab4ebb8fc582889f Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 24 Apr 2024 10:50:17 +0200 Subject: [PATCH 0077/1532] refactor(dossier): use new serialization in dossier project service --- app/services/dossier_projection_service.rb | 26 +++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/app/services/dossier_projection_service.rb b/app/services/dossier_projection_service.rb index 6a87f0515..85521f70f 100644 --- a/app/services/dossier_projection_service.rb +++ b/app/services/dossier_projection_service.rb @@ -49,6 +49,7 @@ class DossierProjectionService individual_last_name = { TABLE => 'individual', COLUMN => 'nom' } sva_svr_decision_on_field = { TABLE => 'self', COLUMN => 'sva_svr_decision_on' } dossier_corrections = { TABLE => 'dossier_corrections', COLUMN => 'resolved_at' } + champ_value = champ_value_formatter(dossiers_ids, fields) ([state_field, archived_field, sva_svr_decision_on_field, hidden_by_user_at_field, hidden_by_administration_at_field, for_tiers_field, individual_first_name, individual_last_name, batch_operation_field, dossier_corrections] + fields) # the view needs state and archived dossier attributes .each { |f| f[:id_value_h] = {} } .group_by { |f| f[TABLE] } # one query per table @@ -64,7 +65,7 @@ class DossierProjectionService .group_by(&:stable_id) # the champs are redispatched to their respective fields .map do |stable_id, champs| field = fields.find { |f| f[COLUMN] == stable_id.to_s } - field[:id_value_h] = champs.to_h { |c| [c.dossier_id, c.to_s] } + field[:id_value_h] = champs.to_h { |c| [c.dossier_id, champ_value.(c)] } end when 'self' Dossier @@ -159,4 +160,27 @@ class DossierProjectionService ) end end + + class << self + private + + def champ_value_formatter(dossiers_ids, fields) + stable_ids = fields.filter { _1[TABLE].in?(['type_de_champ', 'type_de_champ_private']) }.map { _1[COLUMN] } + revision_ids_by_dossier_ids = Dossier.where(id: dossiers_ids).pluck(:id, :revision_id).to_h + stable_ids_and_types_champ_by_revision_ids = ProcedureRevisionTypeDeChamp.includes(:type_de_champ) + .where(revision_id: revision_ids_by_dossier_ids.values.uniq, type_de_champ: { stable_id: stable_ids }) + .pluck(:revision_id, 'type_de_champ.stable_id', 'type_de_champ.type_champ') + .group_by(&:first) + .transform_values { _1.map { |_, stable_id, type_champ| [stable_id, type_champ] }.to_h } + stable_ids_and_types_champ_by_dossier_ids = revision_ids_by_dossier_ids.transform_values { stable_ids_and_types_champ_by_revision_ids[_1] }.compact + -> (champ) { + type_champ = stable_ids_and_types_champ_by_dossier_ids.fetch(champ.dossier_id, {})[champ.stable_id] + if type_champ.present? && TypeDeChamp.type_champ_to_champ_class_name(type_champ) == champ.type + TypeDeChamp.champ_value(type_champ, champ) + else + '' + end + } + end + end end From eba13ab1b5a0a84a5ae92ad53bbb24adf68423de Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 24 Apr 2024 11:10:30 +0200 Subject: [PATCH 0078/1532] chore(vite): use native esm modules and remove vite warning --- .eslintrc.js | 52 ----------------------------------------- .prettierrc.js | 4 ---- package.json | 59 ++++++++++++++++++++++++++++++++++++++++++++++- postcss.config.js | 2 +- vite.config.ts | 5 +++- 5 files changed, 63 insertions(+), 59 deletions(-) delete mode 100644 .eslintrc.js delete mode 100644 .prettierrc.js diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index aa165d0b6..000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,52 +0,0 @@ -module.exports = { - root: true, - parser: '@typescript-eslint/parser', - globals: { - process: true, - gon: true - }, - plugins: ['prettier', 'react-hooks'], - extends: [ - 'eslint:recommended', - 'prettier', - 'plugin:react/recommended', - 'plugin:react-hooks/recommended' - ], - env: { - es6: true, - browser: true - }, - rules: { - 'prettier/prettier': 'error', - 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': 'error', - 'react/prop-types': 'off', - 'react/no-deprecated': 'off' - }, - settings: { - react: { version: 'detect' } - }, - overrides: [ - { - files: ['.eslintrc.js', 'vite.config.ts', 'postcss.config.js'], - env: { node: true } - }, - { - files: ['**/*.ts', '**/*.tsx'], - plugins: ['@typescript-eslint'], - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react-hooks/recommended', - 'prettier' - ], - rules: { - 'prettier/prettier': 'error', - 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': 'error', - '@typescript-eslint/no-explicit-any': 'error', - '@typescript-eslint/no-unused-vars': 'error' - } - } - ] -}; diff --git a/.prettierrc.js b/.prettierrc.js deleted file mode 100644 index f0722fd99..000000000 --- a/.prettierrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - singleQuote: true, - trailingComma: 'none' -}; diff --git a/package.json b/package.json index 4186d3462..b42172e48 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,5 @@ { + "type": "module", "dependencies": { "@coldwired/actions": "^0.11.2", "@coldwired/turbo-stream": "^0.11.1", @@ -130,5 +131,61 @@ "core-js", "esbuild", "rollup" - ] + ], + "prettier": { + "singleQuote": true, + "trailingComma": "none" + }, + "eslintConfig": { + "root": true, + "parser": "@typescript-eslint/parser", + "globals": { + "process": true, + "gon": true + }, + "plugins": ["prettier", "react-hooks"], + "extends": [ + "eslint:recommended", + "prettier", + "plugin:react/recommended", + "plugin:react-hooks/recommended" + ], + "env": { + "es6": true, + "browser": true + }, + "rules": { + "prettier/prettier": "error", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "error", + "react/prop-types": "off", + "react/no-deprecated": "off" + }, + "settings": { + "react": { "version": "detect" } + }, + "overrides": [ + { + "files": [".eslintrc.js", "vite.config.ts", "postcss.config.js"], + "env": { "node": true } + }, + { + "files": ["**/*.ts", "**/*.tsx"], + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", + "prettier" + ], + "rules": { + "prettier/prettier": "error", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "error", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unused-vars": "error" + } + } + ] + } } diff --git a/postcss.config.js b/postcss.config.js index 5bfb8f628..8c589bbe1 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,4 +1,4 @@ -module.exports = { +export default { plugins: { autoprefixer: {} } diff --git a/vite.config.ts b/vite.config.ts index fb29eb6e3..30cff164b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,7 +7,10 @@ import RubyPlugin from 'vite-plugin-ruby'; const plugins = [ RubyPlugin(), ViteReact({ jsxRuntime: 'classic' }), - FullReload(['config/routes.rb', 'app/views/**/*', 'app/components/**/*.haml'], { delay: 200 }) + FullReload( + ['config/routes.rb', 'app/views/**/*', 'app/components/**/*.haml'], + { delay: 200 } + ) ]; if (shouldBuildLegacy()) { From 0f6b39d5ca7778117087674f969fb72645746f39 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 24 Apr 2024 16:22:20 +0200 Subject: [PATCH 0079/1532] fix(task): fix BackfillCommuneCodeFromNameTask --- .../maintenance/backfill_commune_code_from_name_task.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/tasks/maintenance/backfill_commune_code_from_name_task.rb b/app/tasks/maintenance/backfill_commune_code_from_name_task.rb index d5092a16d..8c50b075e 100644 --- a/app/tasks/maintenance/backfill_commune_code_from_name_task.rb +++ b/app/tasks/maintenance/backfill_commune_code_from_name_task.rb @@ -14,11 +14,11 @@ module Maintenance return if champ.external_id.present? return if champ.value.blank? - data = champ.data - return if data.blank? - return if data['code_departement'].blank? + value_json = champ.value_json + return if value_json.blank? + return if value_json['code_departement'].blank? - external_id = APIGeoService.commune_code(data['code_departement'], champ.value) + external_id = APIGeoService.commune_code(value_json['code_departement'], champ.value) if external_id.present? champ.update(external_id:) From b1e9b9539b1d72416bc02d104de45d2f487bf51d Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 24 Apr 2024 16:30:41 +0200 Subject: [PATCH 0080/1532] Revert "fix(ci): attempt to fix ci runs" This reverts commit 95a2f96040eb04ec41bfdd104b65a7f2c956c11d. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b501e613..20335c718 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: unit_tests: name: Unit tests - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest env: RUBY_YJIT_ENABLE: "1" services: From 64dcd2b0d26317017cb2808b09ffdf7019cd1cb2 Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 11 Apr 2024 11:56:42 +0200 Subject: [PATCH 0081/1532] fix(repetition): useless fieldset --- .../section_component.html.haml | 4 ++-- .../editable_champ/section_component_spec.rb | 16 +++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/app/components/editable_champ/section_component/section_component.html.haml b/app/components/editable_champ/section_component/section_component.html.haml index f7a718781..8ec44c63c 100644 --- a/app/components/editable_champ/section_component/section_component.html.haml +++ b/app/components/editable_champ/section_component/section_component.html.haml @@ -10,9 +10,9 @@ = fields_for champ.input_name, champ do |form| = render EditableChamp::EditableChampComponent.new form:, champ: - else - %fieldset.fr-fieldset.fr-my-0 + .fr-fieldset__element.fr-my-0 - if header_section - %legend.fr-fieldset__legend.fr-my-0{ class: "reset-#{tag_for_depth}" } + .fr-fieldset__legend.fr-my-0{ class: "reset-#{tag_for_depth}" } = render EditableChamp::HeaderSectionComponent.new(champ: header_section) - splitted_tail.each do |section, champ| - if section.present? diff --git a/spec/components/editable_champ/section_component_spec.rb b/spec/components/editable_champ/section_component_spec.rb index df65f5d45..7ca892679 100644 --- a/spec/components/editable_champ/section_component_spec.rb +++ b/spec/components/editable_champ/section_component_spec.rb @@ -10,8 +10,8 @@ describe EditableChamp::SectionComponent, type: :component do context 'list of champs without an header_section' do let(:types_de_champ_public) { [{ type: :text }, { type: :textarea }] } - it 'render in a fieldset' do - expect(page).to have_selector("fieldset", count: 1) + it 'does not renders within a fieldset' do + expect(page).to have_selector("fieldset", count: 0) end it 'renders champs' do @@ -37,14 +37,16 @@ describe EditableChamp::SectionComponent, type: :component do context 'list of champs without section and an header_section having champs' do let(:types_de_champ_public) { [{ type: :text }, { type: :header_section, level: 1 }, { type: :text }] } - it 'renders fieldset' do - expect(page).to have_selector("fieldset", count: 2) - expect(page).to have_selector("legend h2") + it 'renders nested champs (after an header section) within a fieldset' do + expect(page).to have_selector("fieldset", count: 1) + expect(page).to have_selector("fieldset legend h2") + expect(page).to have_selector("input[type=text]", count: 2) + expect(page).to have_selector("fieldset input[type=text]", count: 1) end - it 'renders all champs, each in its fieldset' do + it 'renders nested within its fieldset' do expect(page).to have_selector("input[type=text]", count: 2) - expect(page).to have_selector("fieldset > .fr-fieldset__element input[type=text]", count: 2) + expect(page).to have_selector("fieldset > .fr-fieldset__element input[type=text]", count: 1) end end From 91f07f86e4f04a9936363afb4e9554f168068d27 Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 9 Apr 2024 17:02:08 +0200 Subject: [PATCH 0082/1532] fix(Champ.checkbox): single checkbox should not be wrapped in `fieldset`. single checkbox uses `fr-checkbox` not `fr-radio` Co-authored-by: Corinne Durrmeyer --- .../editable_champ/checkbox_component.rb | 6 +----- .../checkbox_component.html.haml | 17 ++++++++--------- .../section_component.html.haml | 19 +++++++++---------- app/views/shared/dossiers/_edit.html.haml | 2 +- .../dossiers/_edit_annotations.html.haml | 2 +- 5 files changed, 20 insertions(+), 26 deletions(-) diff --git a/app/components/editable_champ/checkbox_component.rb b/app/components/editable_champ/checkbox_component.rb index af649c013..0e9ce577b 100644 --- a/app/components/editable_champ/checkbox_component.rb +++ b/app/components/editable_champ/checkbox_component.rb @@ -1,9 +1,5 @@ class EditableChamp::CheckboxComponent < EditableChamp::EditableChampBaseComponent - def dsfr_champ_container - :fieldset - end - def dsfr_input_classname - 'fr-radio' + 'fr-checkbox' end end diff --git a/app/components/editable_champ/checkbox_component/checkbox_component.html.haml b/app/components/editable_champ/checkbox_component/checkbox_component.html.haml index 998e25d8a..44e1afae3 100644 --- a/app/components/editable_champ/checkbox_component/checkbox_component.html.haml +++ b/app/components/editable_champ/checkbox_component/checkbox_component.html.haml @@ -1,9 +1,8 @@ -.fr-fieldset__element - .fr-checkbox-group - = @form.check_box :value, - { required: @champ.required?, id: @champ.input_id, checked: @champ.true?, aria: { describedby: @champ.describedby_id }, class: class_names('required' => @champ.required?)}, - 'true', - 'false' - %label.fr-label{ for: @champ.input_id, id: @champ.labelledby_id } - %span - = render EditableChamp::ChampLabelContentComponent.new form: @form, champ: @champ, seen_at: @seen_at +.fr-checkbox-group + = @form.check_box :value, + { required: @champ.required?, id: @champ.input_id, checked: @champ.true?, aria: { describedby: @champ.describedby_id }, class: class_names('required' => @champ.required?)}, + 'true', + 'false' + %label.fr-label{ for: @champ.input_id, id: @champ.labelledby_id } + %span + = render EditableChamp::ChampLabelContentComponent.new form: @form, champ: @champ, seen_at: @seen_at diff --git a/app/components/editable_champ/section_component/section_component.html.haml b/app/components/editable_champ/section_component/section_component.html.haml index 8ec44c63c..285f3db39 100644 --- a/app/components/editable_champ/section_component/section_component.html.haml +++ b/app/components/editable_champ/section_component/section_component.html.haml @@ -10,13 +10,12 @@ = fields_for champ.input_name, champ do |form| = render EditableChamp::EditableChampComponent.new form:, champ: - else - .fr-fieldset__element.fr-my-0 - - if header_section - .fr-fieldset__legend.fr-my-0{ class: "reset-#{tag_for_depth}" } - = render EditableChamp::HeaderSectionComponent.new(champ: header_section) - - splitted_tail.each do |section, champ| - - if section.present? - = render section - - else - = fields_for champ.input_name, champ do |form| - = render EditableChamp::EditableChampComponent.new form:, champ: + - if header_section + .fr-fieldset__legend.fr-my-0{ class: "reset-#{tag_for_depth}" } + = render EditableChamp::HeaderSectionComponent.new(champ: header_section) + - splitted_tail.each do |section, champ| + - if section.present? + = render section + - else + = fields_for champ.input_name, champ do |form| + = render EditableChamp::EditableChampComponent.new form:, champ: diff --git a/app/views/shared/dossiers/_edit.html.haml b/app/views/shared/dossiers/_edit.html.haml index 8a24a008f..d5fff3262 100644 --- a/app/views/shared/dossiers/_edit.html.haml +++ b/app/views/shared/dossiers/_edit.html.haml @@ -21,7 +21,7 @@ = render Procedure::NoticeComponent.new(procedure: dossier.procedure) - = render EditableChamp::SectionComponent.new(dossier: dossier_for_editing, types_de_champ: dossier_for_editing.revision.types_de_champ_public) + %fieldset.fr-fieldset= render EditableChamp::SectionComponent.new(dossier: dossier_for_editing, types_de_champ: dossier_for_editing.revision.types_de_champ_public) = render Dossiers::PendingCorrectionCheckboxComponent.new(dossier: dossier) diff --git a/app/views/shared/dossiers/_edit_annotations.html.haml b/app/views/shared/dossiers/_edit_annotations.html.haml index 5457cd0c7..653b75cd2 100644 --- a/app/views/shared/dossiers/_edit_annotations.html.haml +++ b/app/views/shared/dossiers/_edit_annotations.html.haml @@ -3,7 +3,7 @@ %section.counter-start-header-section = render NestedForms::FormOwnerComponent.new = form_for dossier, url: annotations_instructeur_dossier_path(dossier.procedure, dossier), html: { class: 'form', multipart: true } do |f| - = render EditableChamp::SectionComponent.new(dossier:, types_de_champ: dossier.revision.types_de_champ_private) + %fieldset.fr-fieldset= render EditableChamp::SectionComponent.new(dossier:, types_de_champ: dossier.revision.types_de_champ_private) = render Dossiers::EditFooterComponent.new(dossier: dossier, annotation: true) - else From c62141fac0ec1cbfb02da54cb3eb020241117bd8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Apr 2024 22:23:39 +0000 Subject: [PATCH 0083/1532] chore(deps): bump sidekiq from 7.2.2 to 7.2.4 Bumps [sidekiq](https://github.com/sidekiq/sidekiq) from 7.2.2 to 7.2.4. - [Changelog](https://github.com/sidekiq/sidekiq/blob/main/Changes.md) - [Commits](https://github.com/sidekiq/sidekiq/compare/v7.2.2...v7.2.4) --- updated-dependencies: - dependency-name: sidekiq dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index b01f642e6..f6b627a6a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -718,7 +718,7 @@ GEM addressable (~> 2.3, >= 2.3.0) json (~> 2.1, >= 2.1.0) typhoeus (~> 1.0, >= 1.0.1) - sidekiq (7.2.2) + sidekiq (7.2.4) concurrent-ruby (< 2) connection_pool (>= 2.3.0) rack (>= 2.2.4) From d2987582f1e4a001fa2e8a4ec158ce1c5c17c123 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Mon, 29 Apr 2024 10:06:33 +0200 Subject: [PATCH 0084/1532] chore(blob): add maintenance task to recompute blob checksum --- .../recompute_blob_checksum_task.rb | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 app/tasks/maintenance/recompute_blob_checksum_task.rb diff --git a/app/tasks/maintenance/recompute_blob_checksum_task.rb b/app/tasks/maintenance/recompute_blob_checksum_task.rb new file mode 100644 index 000000000..628ea9bd7 --- /dev/null +++ b/app/tasks/maintenance/recompute_blob_checksum_task.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Maintenance + class RecomputeBlobChecksumTask < MaintenanceTasks::Task + attribute :blob_ids, :string + validates :blob_ids, presence: true + + def collection + ids = blob_ids.split(',').map(&:strip).map(&:to_i) + ActiveStorage::Blob.where(id: ids) + end + + def process(blob) + blob.upload(StringIO.new(blob.download), identify: false) + blob.save! + end + + def count + # Optionally, define the number of rows that will be iterated over + # This is used to track the task's progress + end + end +end From 4b53808b5c52bc45d75a6899d39bedebbc654650 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Tue, 23 Apr 2024 14:51:59 +0200 Subject: [PATCH 0085/1532] fix(gallery): add variants on thumbnails --- app/models/champs/piece_justificative_champ.rb | 5 +++++ app/models/champs/titre_identite_champ.rb | 6 ++++++ app/views/instructeurs/dossiers/pieces_jointes.html.haml | 2 +- app/views/shared/champs/piece_justificative/_show.html.haml | 2 +- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/models/champs/piece_justificative_champ.rb b/app/models/champs/piece_justificative_champ.rb index 37f50d888..8d9435d17 100644 --- a/app/models/champs/piece_justificative_champ.rb +++ b/app/models/champs/piece_justificative_champ.rb @@ -1,6 +1,11 @@ class Champs::PieceJustificativeChamp < Champ FILE_MAX_SIZE = 200.megabytes + has_many_attached :piece_justificative_file do |attachable| + attachable.variant :small, resize: '300x300' + attachable.variant :medium, resize: '400x400' + end + # TODO: if: -> { validate_champ_value? || validation_context == :prefill } validates :piece_justificative_file, size: { less_than: FILE_MAX_SIZE }, diff --git a/app/models/champs/titre_identite_champ.rb b/app/models/champs/titre_identite_champ.rb index 86df81ee3..05b67e802 100644 --- a/app/models/champs/titre_identite_champ.rb +++ b/app/models/champs/titre_identite_champ.rb @@ -1,6 +1,12 @@ class Champs::TitreIdentiteChamp < Champ FILE_MAX_SIZE = 20.megabytes ACCEPTED_FORMATS = ['image/png', 'image/jpeg'] + + has_many_attached :piece_justificative_file do |attachable| + attachable.variant :small, resize: '300x300' + attachable.variant :medium, resize: '400x400' + end + # TODO: if: -> { validate_champ_value? || validation_context == :prefill } validates :piece_justificative_file, content_type: ACCEPTED_FORMATS, size: { less_than: FILE_MAX_SIZE } diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml index ba4a228f5..386a31852 100644 --- a/app/views/instructeurs/dossiers/pieces_jointes.html.haml +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -25,7 +25,7 @@ - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail - = image_tag(blob.url, loading: :lazy) + = image_tag(attachment.variant(:medium), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } Visualiser .champ-libelle diff --git a/app/views/shared/champs/piece_justificative/_show.html.haml b/app/views/shared/champs/piece_justificative/_show.html.haml index 3e8998f81..525546d8f 100644 --- a/app/views/shared/champs/piece_justificative/_show.html.haml +++ b/app/views/shared/champs/piece_justificative/_show.html.haml @@ -19,6 +19,6 @@ - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do .thumbnail - = image_tag(blob.url, loading: :lazy) + = image_tag(attachment.variant(:small), loading: :lazy) .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } = 'Visualiser' From 717166f61cfddfec97ee56995284d42a1a6214f5 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Fri, 3 May 2024 10:18:05 +0200 Subject: [PATCH 0086/1532] Fix typo --- app/views/administrateurs/procedures/index.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/administrateurs/procedures/index.html.haml b/app/views/administrateurs/procedures/index.html.haml index 60d2441da..adeb6a641 100644 --- a/app/views/administrateurs/procedures/index.html.haml +++ b/app/views/administrateurs/procedures/index.html.haml @@ -6,7 +6,7 @@ %nav.fr-tabs{ role: 'navigation', 'aria-label': t('views.users.dossiers.secondary_menu') } %ul.fr-tabs__list{ role: 'tablist' } = tab_item(t('pluralize.published', count: @procedures_publiees_count), admin_procedures_path(statut: 'publiees'), active: @statut == 'publiees', badge: number_with_html_delimiter(@procedures_publiees_count)) - = tab_item('En test', admin_procedures_path(statut: 'brouillons'), active: @statut == 'brouillons', badge: number_with_html_delimiter(@procedures_draft_count)) + = tab_item('en test', admin_procedures_path(statut: 'brouillons'), active: @statut == 'brouillons', badge: number_with_html_delimiter(@procedures_draft_count)) = tab_item(t('pluralize.closed', count: @procedures_closed_count), admin_procedures_path(statut: 'archivees'), active: @statut == 'archivees', badge: number_with_html_delimiter(@procedures_closed_count)) = tab_item(t('pluralize.deleted', count: @procedures_deleted_count), admin_procedures_path(statut: 'supprimees'), active: @statut === 'supprimees', badge: number_with_html_delimiter(@procedures_deleted_count)) From ec99ef51d648cc7e2fb88d6c6c66b701071d1ea0 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Fri, 3 May 2024 10:21:12 +0200 Subject: [PATCH 0087/1532] Standardize the pagination of procedures displayed on a single page --- config/locales/kaminari.en.yml | 4 ++-- config/locales/kaminari.fr.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/locales/kaminari.en.yml b/config/locales/kaminari.en.yml index 6e98d1c11..b64d7d40b 100644 --- a/config/locales/kaminari.en.yml +++ b/config/locales/kaminari.en.yml @@ -9,8 +9,8 @@ en: display_entries: "%{first} - %{last} in %{total} %{entry_name}" one_page: display_entries: - one: "%{count} %{entry_name}" - other: "%{count} in %{count} %{entry_name}" + one: "%{count} %{entry_name}" + other: "%{count} %{entry_name}" views: pagination: first: "« First" diff --git a/config/locales/kaminari.fr.yml b/config/locales/kaminari.fr.yml index 2647672f2..725223c58 100644 --- a/config/locales/kaminari.fr.yml +++ b/config/locales/kaminari.fr.yml @@ -9,8 +9,8 @@ fr: display_entries: "%{first} - %{last} sur %{total} %{entry_name}" one_page: display_entries: - one: "%{count} %{entry_name}" - other: "%{count} sur %{count} %{entry_name}" + one: "%{count} %{entry_name}" + other: "%{count} %{entry_name}" views: pagination: first: "« Premier" From 828ab1514ba04aa3a8368327738573b37c663a6f Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Fri, 3 May 2024 10:50:13 +0200 Subject: [PATCH 0088/1532] Update test files --- spec/system/users/list_dossiers_spec.rb | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/spec/system/users/list_dossiers_spec.rb b/spec/system/users/list_dossiers_spec.rb index 56f5064c4..5325c2cc0 100644 --- a/spec/system/users/list_dossiers_spec.rb +++ b/spec/system/users/list_dossiers_spec.rb @@ -75,7 +75,7 @@ describe 'user access to the list of their dossiers', js: true do scenario 'user filters state on tab "en-cours"' do expect(page).to have_text('7 en cours') expect(page).to have_text('3 traités') - expect(page).to have_text('7 sur 7 dossiers') + expect(page).to have_text('7 dossiers') click_on('Sélectionner un filtre') expect(page).to have_select 'Statut', selected: 'Sélectionner un statut', options: ['Sélectionner un statut', 'Brouillon', 'En construction', 'En instruction', 'À corriger'] @@ -97,7 +97,7 @@ describe 'user access to the list of their dossiers', js: true do visit dossiers_path(statut: 'traites') expect(page).to have_text('7 en cours') expect(page).to have_text('3 traités') - expect(page).to have_text('3 sur 3 dossiers') + expect(page).to have_text('3 dossiers') click_on('Sélectionner un filtre') expect(page).to have_select 'Statut', selected: 'Sélectionner un statut', options: ['Sélectionner un statut', 'Accepté', 'Refusé', 'Classé sans suite'] @@ -119,18 +119,18 @@ describe 'user access to the list of their dossiers', js: true do click_on('Sélectionner un filtre') click_on('Annuler') - expect(page).to have_text('3 sur 3 dossiers') + expect(page).to have_text('3 dossiers') expect(page).to have_select 'Statut', selected: 'Sélectionner un statut', options: ['Sélectionner un statut', 'Accepté', 'Refusé', 'Classé sans suite'] end scenario 'user filters by created_at' do dossier_en_construction.update!(created_at: Date.yesterday) - expect(page).to have_text('7 sur 7 dossiers') + expect(page).to have_text('7 dossiers') click_on('Sélectionner un filtre') fill_in 'from_created_at_date', with: Date.today click_on('Appliquer les filtres') - expect(page).to have_text('6 sur 6 dossiers') + expect(page).to have_text('6 dossiers') end scenario 'user uses multiple filters' do @@ -138,11 +138,11 @@ describe 'user access to the list of their dossiers', js: true do expect(page).to have_select 'Statut', selected: 'Sélectionner un statut', options: ['Sélectionner un statut', 'Brouillon', 'En construction', 'En instruction', 'À corriger'] - expect(page).to have_text('7 sur 7 dossiers') + expect(page).to have_text('7 dossiers') click_on('Sélectionner un filtre') fill_in 'from_created_at_date', with: Date.today click_on('Appliquer les filtres') - expect(page).to have_text('6 sur 6 dossiers') + expect(page).to have_text('6 dossiers') expect(page).to have_text('1 filtre actif') click_on('Sélectionner un filtre') @@ -154,10 +154,10 @@ describe 'user access to the list of their dossiers', js: true do click_on('Sélectionner un filtre') fill_in 'from_depose_at_date', with: Date.today click_on('Appliquer les filtres') - expect(page).to have_text('2 sur 2 dossiers') + expect(page).to have_text('2 dossiers') expect(page).to have_text('3 filtres actifs') click_on('3 filtres actifs') - expect(page).to have_text('7 sur 7 dossiers') + expect(page).to have_text('7 dossiers') expect(page).not_to have_text('5 filtres actifs') end end @@ -275,7 +275,7 @@ describe 'user access to the list of their dossiers', js: true do expect(current_path).to eq(dossiers_path) expect(page).to have_link(dossier_en_construction.procedure.libelle) expect(page).to have_link(dossier_with_champs.procedure.libelle) - expect(page).to have_text("2 sur 2 dossiers") + expect(page).to have_text("2 dossiers") end it "can be filtered by procedure and display the result - one item" do From c9fbe6d5cc84a1a7fb8b6f927a48aaf97b12b166 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 3 May 2024 15:57:48 +0200 Subject: [PATCH 0089/1532] ui: add html delimiter to procedure numbre --- app/views/administrateurs/procedures/show.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml index 2bd30c89c..a4c1f16d3 100644 --- a/app/views/administrateurs/procedures/show.html.haml +++ b/app/views/administrateurs/procedures/show.html.haml @@ -66,7 +66,7 @@ = "Un email a été envoyé pour informer les usagers le #{ l(@procedure.closed_at.to_date) }" .fr-container - %h2= "Gestion de la démarche № #{@procedure.id}" + %h2= "Gestion de la démarche № #{number_with_html_delimiter(@procedure.id)}" %h3.fr-h6 Indispensable avant publication .fr-grid-row.fr-grid-row--gutters.fr-mb-5w = render Procedure::Card::PresentationComponent.new(procedure: @procedure) From 1e9b7fbbb6ed80a5a7c259e65f2c407af5ae4c66 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Tue, 13 Feb 2024 07:03:42 +0000 Subject: [PATCH 0090/1532] components --- app/components/attachment/edit_component.rb | 48 +++++++++++-------- .../attachment/multiple_component.rb | 6 +-- .../attachment/multiple_component_spec.rb | 8 ++++ 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/app/components/attachment/edit_component.rb b/app/components/attachment/edit_component.rb index 05ddb7b18..2c3ef9a9d 100644 --- a/app/components/attachment/edit_component.rb +++ b/app/components/attachment/edit_component.rb @@ -2,6 +2,7 @@ class Attachment::EditComponent < ApplicationComponent attr_reader :champ attr_reader :attachment + attr_reader :attachments attr_reader :user_can_destroy alias user_can_destroy? user_can_destroy attr_reader :as_multiple @@ -9,24 +10,23 @@ class Attachment::EditComponent < ApplicationComponent EXTENSIONS_ORDER = ['jpeg', 'png', 'pdf', 'zip'].freeze - def initialize(champ: nil, auto_attach_url: nil, attached_file:, direct_upload: true, index: 0, as_multiple: false, view_as: :link, user_can_destroy: true, **kwargs) - @as_multiple = as_multiple - @attached_file = attached_file - @auto_attach_url = auto_attach_url + def initialize(champ: nil, auto_attach_url: nil, attached_file:, direct_upload: true, index: 0, as_multiple: false, view_as: :link, user_can_destroy: true, user_can_replace: false, attachments: [], **kwargs) @champ = champ + @attached_file = attached_file @direct_upload = direct_upload @index = index @view_as = view_as @user_can_destroy = user_can_destroy + @user_can_replace = user_can_replace + @as_multiple = as_multiple - # attachment passed by kwarg because we don't want a default (nil) value. - @attachment = if kwargs.key?(:attachment) - kwargs.delete(:attachment) - elsif attached_file.respond_to?(:attachment) - attached_file.attachment - else - fail ArgumentError, "You must pass an `attachment` kwarg when not using as single attachment like in #{attached_file.name}. Set it to nil for a new attachment." - end + # Adaptation pour la gestion des pièces jointes multiples + @attachments = attachments.presence || (kwargs.key?(:attachment) ? [kwargs.delete(:attachment)] : []) + @attachments << attached_file.attachment if attached_file.respond_to?(:attachment) && @attachments.empty? + @attachments.compact! + + # Utilisation du premier attachement comme référence pour la rétrocompatibilité + @attachment = @attachments.first # When parent form has nested attributes, pass the form builder object_name # to correctly infer the input attribute name. @@ -63,7 +63,7 @@ class Attachment::EditComponent < ApplicationComponent def file_field_options track_issue_with_missing_validators if missing_validators? - { + options = { class: class_names("fr-upload attachment-input": true, "#{attachment_input_class}": true, "hidden": persisted?), direct_upload: @direct_upload, id: input_id, @@ -71,8 +71,13 @@ class Attachment::EditComponent < ApplicationComponent data: { auto_attach_url:, turbo_force: :server - }.merge(has_file_size_validator? ? { max_file_size: } : {}) - }.merge(has_content_type_validator? ? { accept: accept_content_type } : {}) + }.merge(has_file_size_validator? ? { max_file_size: max_file_size } : {}) + } + + options.merge!(has_content_type_validator? ? { accept: accept_content_type } : {}) + options[:multiple] = true if as_multiple? + + options end def poll_url @@ -90,7 +95,8 @@ class Attachment::EditComponent < ApplicationComponent end def field_name(object_name = nil, method_name = nil, *method_names, multiple: false, index: nil) - helpers.field_name(@form_object_name || ActiveModel::Naming.param_key(@attached_file.record), attribute_name) + field_name = @form_object_name || ActiveModel::Naming.param_key(@attached_file.record) + "#{field_name}[#{attribute_name}]#{'[]' if as_multiple?}" end def attribute_name @@ -126,24 +132,24 @@ class Attachment::EditComponent < ApplicationComponent !!attachment&.persisted? end - def downloadable? + def downloadable?(attachment) return false unless @view_as == :download - viewable? + viewable?(attachment) end - def viewable? + def viewable?(attachment) return false if attachment.virus_scanner_error? return false if attachment.watermark_pending? true end - def error? + def error?(attachment) attachment.virus_scanner_error? end - def error_message + def error_message(attachment) case when attachment.virus_scanner.infected? t(".errors.virus_infected") diff --git a/app/components/attachment/multiple_component.rb b/app/components/attachment/multiple_component.rb index b5900cd13..80d2fd941 100644 --- a/app/components/attachment/multiple_component.rb +++ b/app/components/attachment/multiple_component.rb @@ -15,7 +15,7 @@ class Attachment::MultipleComponent < ApplicationComponent delegate :count, :empty?, to: :attachments, prefix: true - def initialize(champ:, attached_file:, form_object_name: nil, view_as: :link, user_can_destroy: true, max: nil) + def initialize(champ: nil, attached_file:, form_object_name: nil, view_as: :link, user_can_destroy: true, user_can_replace: false, max: nil) @champ = champ @attached_file = attached_file @form_object_name = form_object_name @@ -35,11 +35,11 @@ class Attachment::MultipleComponent < ApplicationComponent end def empty_component_id - "attachment-multiple-empty-#{champ.public_id}" + champ.present? ? "attachment-multiple-empty-#{champ.public_id}" : "attachment-multiple-empty-generic" end def auto_attach_url - helpers.auto_attach_url(champ) + champ.present? ? helpers.auto_attach_url(champ) : '#' end alias poll_url auto_attach_url diff --git a/spec/components/attachment/multiple_component_spec.rb b/spec/components/attachment/multiple_component_spec.rb index e5f72fbe7..9f4d02549 100644 --- a/spec/components/attachment/multiple_component_spec.rb +++ b/spec/components/attachment/multiple_component_spec.rb @@ -93,6 +93,14 @@ RSpec.describe Attachment::MultipleComponent, type: :component do end end + context 'when user can replace' do + let(:kwargs) { { user_can_replace: true } } + + before do + attach_to_champ(attached_file, champ) + end + end + def attach_to_champ(attached_file, champ) attached_file.attach( io: StringIO.new("x" * 2), From bec9af90e8e8dc4482c6fa1aaa468bb257e6e845 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Tue, 13 Feb 2024 07:04:07 +0000 Subject: [PATCH 0091/1532] models --- app/components/attachment/edit_component.rb | 2 +- app/models/champs/piece_justificative_champ.rb | 4 ++++ app/models/champs/titre_identite_champ.rb | 4 ++++ app/models/commentaire.rb | 10 ++-------- app/models/dossier.rb | 4 ++-- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/app/components/attachment/edit_component.rb b/app/components/attachment/edit_component.rb index 2c3ef9a9d..e096591ef 100644 --- a/app/components/attachment/edit_component.rb +++ b/app/components/attachment/edit_component.rb @@ -19,7 +19,7 @@ class Attachment::EditComponent < ApplicationComponent @user_can_destroy = user_can_destroy @user_can_replace = user_can_replace @as_multiple = as_multiple - + @auto_attach_url = auto_attach_url # Adaptation pour la gestion des pièces jointes multiples @attachments = attachments.presence || (kwargs.key?(:attachment) ? [kwargs.delete(:attachment)] : []) @attachments << attached_file.attachment if attached_file.respond_to?(:attachment) && @attachments.empty? diff --git a/app/models/champs/piece_justificative_champ.rb b/app/models/champs/piece_justificative_champ.rb index 8d9435d17..bca75c292 100644 --- a/app/models/champs/piece_justificative_champ.rb +++ b/app/models/champs/piece_justificative_champ.rb @@ -30,4 +30,8 @@ class Champs::PieceJustificativeChamp < Champ def blank? piece_justificative_file.blank? end + + def allow_multiple_attachments? + false + end end diff --git a/app/models/champs/titre_identite_champ.rb b/app/models/champs/titre_identite_champ.rb index 05b67e802..88983fa6e 100644 --- a/app/models/champs/titre_identite_champ.rb +++ b/app/models/champs/titre_identite_champ.rb @@ -25,4 +25,8 @@ class Champs::TitreIdentiteChamp < Champ def blank? piece_justificative_file.blank? end + + def allow_multiple_attachments? + false + end end diff --git a/app/models/commentaire.rb b/app/models/commentaire.rb index cab033348..8c72c29bd 100644 --- a/app/models/commentaire.rb +++ b/app/models/commentaire.rb @@ -7,7 +7,7 @@ class Commentaire < ApplicationRecord validate :messagerie_available?, on: :create, unless: -> { dossier.brouillon? } - has_one_attached :piece_jointe + has_many_attached :piece_jointe validates :body, presence: { message: "ne peut être vide" }, unless: :discarded? @@ -67,12 +67,6 @@ class Commentaire < ApplicationRecord sent_by?(connected_user) && (sent_by_instructeur? || sent_by_expert?) && !discarded? end - def file_url - if piece_jointe.attached? && piece_jointe.virus_scanner.safe? - Rails.application.routes.url_helpers.url_for(piece_jointe) - end - end - def soft_delete! transaction do discard! @@ -80,7 +74,7 @@ class Commentaire < ApplicationRecord update! body: '' end - piece_jointe.purge_later if piece_jointe.attached? + piece_jointe.each(&:purge_later) if piece_jointe.attached? end def flagged_pending_correction? diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 334d883a7..3212a4f80 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -54,7 +54,7 @@ class Dossier < ApplicationRecord has_many :prefilled_champs_public, -> { root.public_only.prefilled }, class_name: 'Champ', inverse_of: false has_many :commentaires, inverse_of: :dossier, dependent: :destroy - has_many :preloaded_commentaires, -> { includes(:dossier_correction, piece_jointe_attachment: :blob) }, class_name: 'Commentaire', inverse_of: :dossier + has_many :preloaded_commentaires, -> { includes(:dossier_correction, piece_jointe_attachments: :blob) }, class_name: 'Commentaire', inverse_of: :dossier has_many :invites, dependent: :destroy has_many :follows, -> { active }, inverse_of: :dossier @@ -294,7 +294,7 @@ class Dossier < ApplicationRecord scope :for_api, -> { with_champs .with_annotations - .includes(commentaires: { piece_jointe_attachment: :blob }, + .includes(commentaires: { piece_jointe_attachments: :blob }, justificatif_motivation_attachment: :blob, attestation: [], avis: { piece_justificative_file_attachment: :blob }, From 7092583b0a953f758a168c5ba433fd54a9667628 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Tue, 13 Feb 2024 07:04:27 +0000 Subject: [PATCH 0092/1532] layout --- .../edit_component/edit_component.html.haml | 72 ++++++++++++------- .../multiple_component.html.haml | 4 +- .../message_component.html.haml | 4 +- .../shared/dossiers/messages/_form.html.haml | 12 ++-- config/locales/views/shared/fr.yml | 1 + 5 files changed, 57 insertions(+), 36 deletions(-) diff --git a/app/components/attachment/edit_component/edit_component.html.haml b/app/components/attachment/edit_component/edit_component.html.haml index 5845e8dad..57b60297d 100644 --- a/app/components/attachment/edit_component/edit_component.html.haml +++ b/app/components/attachment/edit_component/edit_component.html.haml @@ -1,39 +1,59 @@ -.attachment.fr-upload-group{ { id: attachment ? dom_id(attachment, :edit) : nil, class: class_names("fr-mb-1w": !(as_multiple? && downloadable?)) }.compact } - - if persisted? - %div{ id: dom_id(attachment, :persisted_row) } - .flex.flex-gap-2{ class: class_names("attachment-error": attachment.virus_scanner_error?) } - - if user_can_destroy? - = render NestedForms::OwnedButtonComponent.new(formaction: destroy_attachment_path, http_method: :delete, opt: {class: "fr-btn fr-btn--tertiary fr-btn--sm fr-icon-delete-line", title: t(".delete_file", filename: attachment.filename)}) do - = t('.delete') +.attachment.fr-upload-group{ id: (attachment ? dom_id(attachment, :edit) : nil), class: class_names("fr-mb-1w": !(as_multiple? && attachments.any?(&:persisted?))) } + - if as_multiple? + - attachments.each do |attachment| + - if attachment.persisted? + %div{ id: dom_id(attachment, :persisted_row) } + .flex.flex-gap-2{ class: class_names("attachment-error": attachment.virus_scanner_error?) } + - if user_can_destroy? + = render NestedForms::OwnedButtonComponent.new(formaction: destroy_attachment_path, http_method: :delete, opt: {class: "fr-btn fr-btn--tertiary fr-btn--sm fr-icon-delete-line", title: t(".delete_file", filename: attachment.filename)}) do + = t('.delete') - - if downloadable? - = render Dsfr::DownloadComponent.new(attachment:) - - else - .fr-py-1v - %span.attachment-filename.fr-mr-1w= link_to_if(viewable?, attachment.filename.to_s, helpers.url_for(attachment.blob), title: t(".open_file", filename: attachment.filename), **helpers.external_link_attributes) + - if downloadable?(attachment) + = render Dsfr::DownloadComponent.new(attachment: attachment) + - else + .fr-py-1v + %span.attachment-filename.fr-mr-1w= link_to_if(viewable?(attachment), attachment.filename.to_s, helpers.url_for(attachment.blob), title: t(".open_file", filename: attachment.filename), **helpers.external_link_attributes) - = render Attachment::ProgressComponent.new(attachment: attachment, ignore_antivirus: true) + = render Attachment::ProgressComponent.new(attachment: attachment, ignore_antivirus: true) - - if error? - %p.fr-error-text= error_message + - if error?(attachment) + %p.fr-error-text= error_message(attachment) + - else + - if persisted? + %div{ id: dom_id(attachment, :persisted_row) } + .flex.flex-gap-2{ class: class_names("attachment-error": attachment.virus_scanner_error?) } + - if user_can_destroy? + = render NestedForms::OwnedButtonComponent.new(formaction: destroy_attachment_path, http_method: :delete, opt: {class: "fr-btn fr-btn--tertiary fr-btn--sm fr-icon-delete-line", title: t(".delete_file", filename: attachment.filename)}) do + = t('.delete') - - elsif first? + - if downloadable?(attachment) + = render Dsfr::DownloadComponent.new(attachment:) + - else + .fr-py-1v + %span.attachment-filename.fr-mr-1w= link_to_if(viewable?(attachment), attachment.filename.to_s, helpers.url_for(attachment.blob), title: t(".open_file", filename: attachment.filename), **helpers.external_link_attributes) + + = render Attachment::ProgressComponent.new(attachment: attachment, ignore_antivirus: true) + + - if error?(attachment) + %p.fr-error-text= error_message(attachment) + + - if first? && !persisted? %p.fr-hint-text.fr-mb-1w - if max_file_size.present? = t('.max_file_size', max_file_size: number_to_human_size(max_file_size)) - if allowed_formats.present? = t('.allowed_formats', formats: allowed_formats.join(', ')) - - - if !as_multiple? + - if !persisted? || champ.present? && champ.titre_identite? = file_field(champ, field_name, **file_field_options) - - if persisted? - - Attachment::PendingPollComponent.new(attachment: attachment, poll_url:, context: poll_context).then do |component| - .fr-mt-2w - = render component + - attachments.filter(&:persisted?).each do |attachment| + - if attachment.persisted? + - Attachment::PendingPollComponent.new(attachment: attachment, poll_url: poll_url, context: poll_context).then do |component| + .fr-mt-2w + = render component - .attachment-upload-error.hidden - %p.fr-error-text= t('.errors.uploading') - = button_tag(**retry_button_options) do - = t(".retry") + .attachment-upload-error.hidden + %p.fr-error-text= t('.errors.uploading') + = button_tag(**retry_button_options) do + = t(".retry") diff --git a/app/components/attachment/multiple_component/multiple_component.html.haml b/app/components/attachment/multiple_component/multiple_component.html.haml index 545387758..b5a64b044 100644 --- a/app/components/attachment/multiple_component/multiple_component.html.haml +++ b/app/components/attachment/multiple_component/multiple_component.html.haml @@ -5,10 +5,10 @@ %ul.fr-my-1v - each_attachment do |attachment, index| %li{ id: dom_id(attachment) } - = render Attachment::EditComponent.new(champ:, attached_file:, attachment:, index:, as_multiple: true, view_as:, user_can_destroy:, form_object_name:) + = render Attachment::EditComponent.new(champ:, attached_file:, attachment:, index:, view_as:, user_can_destroy:, form_object_name:) %div{ id: empty_component_id, class: class_names("hidden": !can_attach_next?), data: { turbo_force: :server } } - = render Attachment::EditComponent.new(champ:, attached_file:, attachment: nil, index: attachments_count, user_can_destroy:, form_object_name:) + = render Attachment::EditComponent.new(champ:, as_multiple: champ.present? ? champ.allow_multiple_attachments? : true, attached_file:, attachment: nil, index: attachments_count, user_can_destroy:, form_object_name:) // single poll and refresh message for all attachments = render Attachment::PendingPollComponent.new(attachments: attachments, poll_url:, context: poll_context) diff --git a/app/components/dossiers/message_component/message_component.html.haml b/app/components/dossiers/message_component/message_component.html.haml index 1f944d151..4631c5d0d 100644 --- a/app/components/dossiers/message_component/message_component.html.haml +++ b/app/components/dossiers/message_component/message_component.html.haml @@ -27,7 +27,9 @@ - if groupe_gestionnaire.nil? && commentaire.piece_jointe.attached? .fr-ml-2w - = render Attachment::ShowComponent.new(attachment: commentaire.piece_jointe.attachment, new_tab: true) + - commentaire.piece_jointe.each do |attachment| + = render Attachment::ShowComponent.new(attachment: attachment, new_tab: true) + - if show_reply_button? = button_tag type: 'button', class: 'fr-btn fr-btn--sm fr-btn--secondary fr-icon-arrow-go-back-line fr-btn--icon-left', onclick: 'document.querySelector("#commentaire_body").focus()' do diff --git a/app/views/shared/dossiers/messages/_form.html.haml b/app/views/shared/dossiers/messages/_form.html.haml index f36c6c434..0c2bf2ecb 100644 --- a/app/views/shared/dossiers/messages/_form.html.haml +++ b/app/views/shared/dossiers/messages/_form.html.haml @@ -8,12 +8,10 @@ %p.mandatory-explanation= t('asterisk_html', scope: [:utils]) = render Dsfr::InputComponent.new(form: f, attribute: :body, input_type: :text_area, opts: { rows: 5, placeholder: placeholder, title: placeholder, class: 'fr-input message-textarea'}) - - if local_assigns.has_key?(:dossier) - .fr-mt-3w{ data: { controller: "file-input-reset" } } - = render Attachment::EditComponent.new(attached_file: commentaire.piece_jointe) - %button.hidden.fr-btn.fr-btn--tertiary-no-outline.fr-btn--icon-left.fr-icon-delete-line{ data: { 'file-input-reset-target': 'reset', action: 'file-input-reset#reset' } } - = t('views.shared.messages.remove_file') + .fr-mt-3w{ data: { controller: "file-input-reset", delete_label: t('views.shared.messages.remove_file') } } + = render Attachment::MultipleComponent.new(attached_file: commentaire.piece_jointe) + %ul{ data: { 'file-input-reset-target': 'fileList' } } - .fr-mt-3w - = f.submit t('views.shared.dossiers.messages.form.send_message'), class: 'fr-btn', data: { disable: true } + .fr-mt-3w + = f.submit t('views.shared.dossiers.messages.form.send_message'), class: 'fr-btn', data: { disable: true } diff --git a/config/locales/views/shared/fr.yml b/config/locales/views/shared/fr.yml index a8af2562b..803f7c726 100644 --- a/config/locales/views/shared/fr.yml +++ b/config/locales/views/shared/fr.yml @@ -23,3 +23,4 @@ fr: signin: 'Se connecter' messages: remove_file: 'Supprimer le fichier' + remove_all: "Supprimer tous les fichiers" From 981e7ff2445137979f8625bec0d6849d0a943712 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Tue, 13 Feb 2024 07:04:51 +0000 Subject: [PATCH 0093/1532] javascript --- .../file_input_reset_controller.ts | 90 ++++++++++++++----- 1 file changed, 67 insertions(+), 23 deletions(-) diff --git a/app/javascript/controllers/file_input_reset_controller.ts b/app/javascript/controllers/file_input_reset_controller.ts index cbce6ab6b..0320c54b1 100644 --- a/app/javascript/controllers/file_input_reset_controller.ts +++ b/app/javascript/controllers/file_input_reset_controller.ts @@ -1,37 +1,81 @@ import { ApplicationController } from './application_controller'; -import { hide, show } from '@utils'; - export class FileInputResetController extends ApplicationController { - static targets = ['reset']; - - declare readonly resetTarget: HTMLElement; + static targets = ['fileList']; + declare fileListTarget: HTMLElement; connect() { - this.on('change', (event) => { - if (event.target == this.fileInput) { - this.showResetButton(); + super.connect(); + this.updateFileList(); + this.element.addEventListener('change', (event) => { + if ( + event.target instanceof HTMLInputElement && + event.target.type === 'file' + ) { + this.updateFileList(); } }); } - reset(event: Event) { - event.preventDefault(); - this.fileInput.value = ''; - hide(this.resetTarget); + updateFileList() { + const files = this.fileInput?.files ?? []; + this.fileListTarget.innerHTML = ''; + + const deleteLabel = + this.element.getAttribute('data-delete-label') || 'Delete'; + + Array.from(files).forEach((file, index) => { + const container = document.createElement('li'); + container.style.display = 'flex'; + container.style.alignItems = 'center'; + + const deleteButton = this.createDeleteButton(deleteLabel, index); + container.appendChild(deleteButton); + + const listItem = document.createElement('div'); + listItem.textContent = file.name; + listItem.style.marginLeft = '8px'; + + container.appendChild(listItem); + this.fileListTarget.appendChild(container); + }); } - showResetButton() { - show(this.resetTarget); + createDeleteButton(deleteLabel: string, index: number) { + const button = document.createElement('button'); + button.textContent = deleteLabel; + button.classList.add( + 'fr-btn', + 'fr-btn--tertiary', + 'fr-btn--sm', + 'fr-icon-delete-line' + ); + + button.addEventListener('click', (event) => { + event.preventDefault(); + this.removeFile(index); + }); + + return button; } - private get fileInput() { - const inputs = - this.element.querySelectorAll('input[type="file"]'); - if (inputs.length == 0) { - throw new Error('No file input found'); - } else if (inputs.length > 1) { - throw new Error('Multiple file inputs found'); - } - return inputs[0]; + removeFile(index: number) { + const files = this.fileInput?.files; + if (!files) return; + + const dataTransfer = new DataTransfer(); + Array.from(files).forEach((file, i) => { + if (index !== i) { + dataTransfer.items.add(file); + } + }); + + if (this.fileInput) this.fileInput.files = dataTransfer.files; + this.updateFileList(); + } + + private get fileInput(): HTMLInputElement | null { + return this.element.querySelector( + 'input[type="file"]' + ) as HTMLInputElement | null; } } From b69d8249c5b45910e9f42e1a7c7479af0b1bef0f Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Tue, 13 Feb 2024 07:09:58 +0000 Subject: [PATCH 0094/1532] controller --- app/controllers/experts/avis_controller.rb | 2 +- app/controllers/instructeurs/dossiers_controller.rb | 3 ++- app/controllers/users/dossiers_controller.rb | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/controllers/experts/avis_controller.rb b/app/controllers/experts/avis_controller.rb index a66dec92f..14999d869 100644 --- a/app/controllers/experts/avis_controller.rb +++ b/app/controllers/experts/avis_controller.rb @@ -234,7 +234,7 @@ module Experts end def commentaire_params - params.require(:commentaire).permit(:body, :piece_jointe) + params.require(:commentaire).permit(:body, piece_jointe: []) end end end diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index 212a6c8b0..8063e0438 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -256,6 +256,7 @@ module Instructeurs flash.notice = "Message envoyé" redirect_to messagerie_instructeur_dossier_path(procedure, dossier) else + @commentaire.piece_jointe.purge.reload flash.alert = @commentaire.errors.full_messages render :messagerie end @@ -393,7 +394,7 @@ module Instructeurs end def commentaire_params - params.require(:commentaire).permit(:body, :piece_jointe) + params.require(:commentaire).permit(:body, piece_jointe: []) end def champs_private_params diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index 06dd6d742..92f4d00a8 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -621,7 +621,7 @@ module Users end def commentaire_params - params.require(:commentaire).permit(:body, :piece_jointe) + params.require(:commentaire).permit(:body, piece_jointe: []) end end end From bf622eb3ed3fadfdee7941dfe3353bad52bd6007 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Tue, 13 Feb 2024 07:10:22 +0000 Subject: [PATCH 0095/1532] service and serializer --- app/serializers/commentaire_serializer.rb | 6 +----- app/services/pieces_justificatives_service.rb | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/app/serializers/commentaire_serializer.rb b/app/serializers/commentaire_serializer.rb index 3cc79c8a2..2941765e2 100644 --- a/app/serializers/commentaire_serializer.rb +++ b/app/serializers/commentaire_serializer.rb @@ -2,13 +2,9 @@ class CommentaireSerializer < ActiveModel::Serializer attributes :email, :body, :created_at, - :attachment + :piece_jointe_attachments def created_at object.created_at&.in_time_zone('UTC') end - - def attachment - object.file_url - end end diff --git a/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 6c5648597..2fece0c0b 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -161,7 +161,7 @@ class PiecesJustificativesService def pjs_for_commentaires(dossiers) commentaire_id_dossier_id = Commentaire - .joins(:piece_jointe_attachment) + .joins(:piece_jointe_attachments) .where(dossier: dossiers) .pluck(:id, :dossier_id) .to_h From be056a125803032f7b29c40c59709d09bd164434 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Tue, 13 Feb 2024 11:06:41 +0000 Subject: [PATCH 0096/1532] tasks --- app/lib/recovery/exporter.rb | 2 +- lib/tasks/recovery.rake | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/lib/recovery/exporter.rb b/app/lib/recovery/exporter.rb index 4781896f1..f414df28a 100644 --- a/app/lib/recovery/exporter.rb +++ b/app/lib/recovery/exporter.rb @@ -10,7 +10,7 @@ module Recovery :invites, :traitements, :transfer_logs, - commentaires: { piece_jointe_attachment: :blob }, + commentaires: { piece_jointe_attachments: :blob }, avis: { introduction_file_attachment: :blob, piece_justificative_file_attachment: :blob }, dossier_operation_logs: { serialized_attachment: :blob }, attestation: { pdf_attachment: :blob }, diff --git a/lib/tasks/recovery.rake b/lib/tasks/recovery.rake index 58221765a..c56586f0f 100644 --- a/lib/tasks/recovery.rake +++ b/lib/tasks/recovery.rake @@ -48,7 +48,7 @@ namespace :recovery do rake_puts "Will export #{dossier_ids}" dossiers_with_data = Dossier.where(id: dossier_ids) - .preload(commentaires: { piece_jointe_attachment: :blob }, + .preload(commentaires: { pieces_jointes_attachments: :blob }, avis: { introduction_file_attachment: :blob, piece_justificative_file_attachment: :blob }, dossier_operation_logs: { serialized_attachment: :blob }, attestation: { pdf_attachment: :blob }, @@ -67,7 +67,7 @@ namespace :recovery do blob_keys_for_dossier += dossier.commentaires.flat_map do |commentaire| commentaire_blob_key = [] if commentaire.piece_jointe.attached? - commentaire_blob_key += [commentaire.piece_jointe_attachment.blob.key] + commentaire_blob_key += [commentaire.piece_jointe_attachments.blob.key] end commentaire_blob_key end From 548806780130da18eb15d682ccb5c80369667fdf Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Wed, 21 Feb 2024 02:01:10 +0000 Subject: [PATCH 0097/1532] api --- app/graphql/types/message_type.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/graphql/types/message_type.rb b/app/graphql/types/message_type.rb index 665c2dd0b..70622647b 100644 --- a/app/graphql/types/message_type.rb +++ b/app/graphql/types/message_type.rb @@ -5,10 +5,10 @@ module Types field :body, String, null: false field :created_at, GraphQL::Types::ISO8601DateTime, null: false field :attachment, Types::File, null: true, deprecation_reason: "Utilisez le champ `attachments` à la place.", extensions: [ - { Extensions::Attachment => { attachment: :piece_jointe } } + { Extensions::Attachment => { attachments: :piece_jointe, as: :single } } ] field :attachments, [Types::File], null: false, extensions: [ - { Extensions::Attachment => { attachment: :piece_jointe, as: :multiple } } + { Extensions::Attachment => { attachments: :piece_jointe } } ] field :correction, CorrectionType, null: true From 2612b0a2d1182358b82a97a6beafc55801d77c46 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Wed, 21 Feb 2024 02:01:24 +0000 Subject: [PATCH 0098/1532] tests --- .../attachment/edit_component_spec.rb | 4 ++-- .../api/v2/graphql_controller_spec.rb | 22 +++++++++++++++---- .../experts/avis_controller_spec.rb | 4 ++-- spec/factories/commentaire.rb | 9 ++++++-- spec/services/commentaire_service_spec.rb | 15 ++++++++++--- .../pieces_justificatives_service_spec.rb | 2 +- spec/system/users/en_construction_spec.rb | 2 +- 7 files changed, 43 insertions(+), 15 deletions(-) diff --git a/spec/components/attachment/edit_component_spec.rb b/spec/components/attachment/edit_component_spec.rb index 3ce2e9535..c9298f9ff 100644 --- a/spec/components/attachment/edit_component_spec.rb +++ b/spec/components/attachment/edit_component_spec.rb @@ -66,8 +66,8 @@ RSpec.describe Attachment::EditComponent, type: :component do ) end - it 'does not render an empty file' do # (is is rendered by MultipleComponent) - expect(subject).not_to have_selector('input[type=file]') + it 'does render an empty file' do # (is is rendered by MultipleComponent) + expect(subject).to have_selector('input[type=file]') end it 'renders max size for first index' do diff --git a/spec/controllers/api/v2/graphql_controller_spec.rb b/spec/controllers/api/v2/graphql_controller_spec.rb index 4f1aef223..4805df781 100644 --- a/spec/controllers/api/v2/graphql_controller_spec.rb +++ b/spec/controllers/api/v2/graphql_controller_spec.rb @@ -432,6 +432,12 @@ describe API::V2::GraphqlController do byteSize contentType } + attachments { + filename + checksum + byteSize + contentType + } } avis { expert { @@ -519,11 +525,19 @@ describe API::V2::GraphqlController do { body: commentaire.body, attachment: { - filename: commentaire.piece_jointe.filename.to_s, - contentType: commentaire.piece_jointe.content_type, - checksum: commentaire.piece_jointe.checksum, - byteSize: commentaire.piece_jointe.byte_size + filename: commentaire.piece_jointe.first.filename.to_s, + contentType: commentaire.piece_jointe.first.content_type, + checksum: commentaire.piece_jointe.first.checksum, + byteSize: commentaire.piece_jointe.first.byte_size }, + attachments: commentaire.piece_jointe.map do |pj| + { + filename: pj.filename.to_s, + contentType: pj.content_type, + checksum: pj.checksum, + byteSize: pj.byte_size + } + end, email: commentaire.email } end diff --git a/spec/controllers/experts/avis_controller_spec.rb b/spec/controllers/experts/avis_controller_spec.rb index 5d5786ddf..7a4029eed 100644 --- a/spec/controllers/experts/avis_controller_spec.rb +++ b/spec/controllers/experts/avis_controller_spec.rb @@ -321,7 +321,7 @@ describe Experts::AvisController, type: :controller do let(:now) { Time.zone.parse("14/07/1789") } let(:avis) { avis_without_answer } - subject { post :create_commentaire, params: { id: avis.id, procedure_id:, commentaire: { body: 'commentaire body', piece_jointe: file } } } + subject { post :create_commentaire, params: { id: avis.id, procedure_id:, commentaire: { body: 'commentaire body', piece_jointe: [file] } } } before do allow(ClamavService).to receive(:safe_file?).and_return(scan_result) @@ -343,7 +343,7 @@ describe Experts::AvisController, type: :controller do it do expect { subject }.to change(Commentaire, :count).by(1) - expect(Commentaire.last.piece_jointe.filename).to eq("piece_justificative_0.pdf") + expect(Commentaire.last.piece_jointe.first.filename).to eq("piece_justificative_0.pdf") end end diff --git a/spec/factories/commentaire.rb b/spec/factories/commentaire.rb index 395859b11..13aaf74a7 100644 --- a/spec/factories/commentaire.rb +++ b/spec/factories/commentaire.rb @@ -2,11 +2,16 @@ FactoryBot.define do factory :commentaire do association :dossier, :en_construction email { generate(:user_email) } - body { 'plop' } trait :with_file do - piece_jointe { Rack::Test::UploadedFile.new('spec/fixtures/files/logo_test_procedure.png', 'image/png') } + after(:build) do |commentaire| + commentaire.piece_jointe.attach( + io: File.open('spec/fixtures/files/logo_test_procedure.png'), + filename: 'logo_test_procedure.png', + content_type: 'image/png' + ) + end end end end diff --git a/spec/services/commentaire_service_spec.rb b/spec/services/commentaire_service_spec.rb index a84bb8500..e3441b10f 100644 --- a/spec/services/commentaire_service_spec.rb +++ b/spec/services/commentaire_service_spec.rb @@ -26,11 +26,20 @@ describe CommentaireService do end end - context 'when it has a file' do - let(:file) { fixture_file_upload('spec/fixtures/files/piece_justificative_0.pdf', 'application/pdf') } + context 'when it has multiple files' do + let(:files) do + [ + fixture_file_upload('spec/fixtures/files/piece_justificative_0.pdf', 'application/pdf') + ] + end - it 'attaches the file' do + before do + commentaire.piece_jointe.attach(files) + end + + it 'attaches the files' do expect(commentaire.piece_jointe.attached?).to be_truthy + expect(commentaire.piece_jointe.count).to eq(1) end end end diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index eaf7a919d..7a212912e 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -69,7 +69,7 @@ describe PiecesJustificativesService do attach_file(witness_commentaire.piece_jointe) end - it { expect(subject).to match_array(dossier.commentaires.first.piece_jointe.attachment) } + it { expect(subject).to match_array(dossier.commentaires.first.piece_jointe.attachments) } end context 'with a pj not safe on a commentaire' do diff --git a/spec/system/users/en_construction_spec.rb b/spec/system/users/en_construction_spec.rb index 0444caba9..f0fd43acf 100644 --- a/spec/system/users/en_construction_spec.rb +++ b/spec/system/users/en_construction_spec.rb @@ -31,7 +31,7 @@ describe "Dossier en_construction" do click_on "Supprimer le fichier toto.txt" - input_selector = "#attachment-multiple-empty-#{champ.public_id} " + input_selector = "#attachment-multiple-empty-#{champ.public_id}" expect(page).to have_selector(input_selector) find(input_selector).attach_file(Rails.root.join('spec/fixtures/files/file.pdf')) From 46bec10aa2c7a0d3f05d71eb6bff90f955f58e1e Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Wed, 21 Feb 2024 21:53:14 +0000 Subject: [PATCH 0099/1532] lib --- app/lib/recovery/importer.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/lib/recovery/importer.rb b/app/lib/recovery/importer.rb index 0edc4b6a7..836917498 100644 --- a/app/lib/recovery/importer.rb +++ b/app/lib/recovery/importer.rb @@ -112,9 +112,12 @@ module Recovery end end - def import(pj) - ActiveStorage::Blob.insert(pj.blob.attributes) - ActiveStorage::Attachment.insert(pj.attributes) + def import(pjs) + attachments = pjs.respond_to?(:each) ? pjs : [pjs] + attachments.each do |pj| + ActiveStorage::Blob.insert(pj.blob.attributes) + ActiveStorage::Attachment.insert(pj.attributes) + end end end end From 5374100866ff894262d8acb982ab676ad6b14b6d Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 15 Apr 2024 12:38:50 +0200 Subject: [PATCH 0100/1532] fix(messagerie): fix submit for gestionnaires --- app/views/shared/dossiers/messages/_form.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/shared/dossiers/messages/_form.html.haml b/app/views/shared/dossiers/messages/_form.html.haml index 0c2bf2ecb..eb0c3ecd3 100644 --- a/app/views/shared/dossiers/messages/_form.html.haml +++ b/app/views/shared/dossiers/messages/_form.html.haml @@ -13,5 +13,5 @@ = render Attachment::MultipleComponent.new(attached_file: commentaire.piece_jointe) %ul{ data: { 'file-input-reset-target': 'fileList' } } - .fr-mt-3w - = f.submit t('views.shared.dossiers.messages.form.send_message'), class: 'fr-btn', data: { disable: true } + .fr-mt-3w + = f.submit t('views.shared.dossiers.messages.form.send_message'), class: 'fr-btn', data: { disable: true } From 3b7b18ef90e5bf8a737f169f79a7a5f704175874 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 15 Apr 2024 12:17:19 +0200 Subject: [PATCH 0101/1532] style(pj-messagerie): same spacing as in PJ champ --- app/assets/stylesheets/attachment.scss | 9 ++++----- .../controllers/file_input_reset_controller.ts | 4 +--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/app/assets/stylesheets/attachment.scss b/app/assets/stylesheets/attachment.scss index 3688742e7..e8b829486 100644 --- a/app/assets/stylesheets/attachment.scss +++ b/app/assets/stylesheets/attachment.scss @@ -49,9 +49,8 @@ } } -.attachment-multiple.fr-downloads-group.destroyable { - ul { - list-style-type: none; - padding-inline-start: 0; - } +.attachment-multiple.fr-downloads-group.destroyable ul, +ul[data-file-input-reset-target='fileList'] { + list-style-type: none; + padding-inline-start: 0; } diff --git a/app/javascript/controllers/file_input_reset_controller.ts b/app/javascript/controllers/file_input_reset_controller.ts index 0320c54b1..5f4070917 100644 --- a/app/javascript/controllers/file_input_reset_controller.ts +++ b/app/javascript/controllers/file_input_reset_controller.ts @@ -25,15 +25,13 @@ export class FileInputResetController extends ApplicationController { Array.from(files).forEach((file, index) => { const container = document.createElement('li'); - container.style.display = 'flex'; - container.style.alignItems = 'center'; + container.classList.add('flex', 'flex-gap-2', 'fr-mb-1w'); const deleteButton = this.createDeleteButton(deleteLabel, index); container.appendChild(deleteButton); const listItem = document.createElement('div'); listItem.textContent = file.name; - listItem.style.marginLeft = '8px'; container.appendChild(listItem); this.fileListTarget.appendChild(container); From 6748551240dc3fda83685de91218b6812daa6615 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 15 Apr 2024 12:31:20 +0200 Subject: [PATCH 0102/1532] chore(messagerie): add missing label on comment form --- app/views/shared/dossiers/messages/_form.html.haml | 9 ++++++--- config/locales/models/commentaire/en.yml | 6 ++++++ config/locales/models/commentaire/fr.yml | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 config/locales/models/commentaire/en.yml diff --git a/app/views/shared/dossiers/messages/_form.html.haml b/app/views/shared/dossiers/messages/_form.html.haml index eb0c3ecd3..bd0cfe98e 100644 --- a/app/views/shared/dossiers/messages/_form.html.haml +++ b/app/views/shared/dossiers/messages/_form.html.haml @@ -8,10 +8,13 @@ %p.mandatory-explanation= t('asterisk_html', scope: [:utils]) = render Dsfr::InputComponent.new(form: f, attribute: :body, input_type: :text_area, opts: { rows: 5, placeholder: placeholder, title: placeholder, class: 'fr-input message-textarea'}) + - if local_assigns.has_key?(:dossier) - .fr-mt-3w{ data: { controller: "file-input-reset", delete_label: t('views.shared.messages.remove_file') } } - = render Attachment::MultipleComponent.new(attached_file: commentaire.piece_jointe) - %ul{ data: { 'file-input-reset-target': 'fileList' } } + .fr-mt-3w.fr-input-group + = f.label :piece_jointe, class: "fr-label" + %div{ data: { controller: "file-input-reset", delete_label: t('views.shared.messages.remove_file') } } + = render Attachment::MultipleComponent.new(attached_file: commentaire.piece_jointe) + %ul{ data: { 'file-input-reset-target': 'fileList' } } .fr-mt-3w = f.submit t('views.shared.dossiers.messages.form.send_message'), class: 'fr-btn', data: { disable: true } diff --git a/config/locales/models/commentaire/en.yml b/config/locales/models/commentaire/en.yml new file mode 100644 index 000000000..a97b7e0e7 --- /dev/null +++ b/config/locales/models/commentaire/en.yml @@ -0,0 +1,6 @@ +en: + activerecord: + attributes: + commentaire: + body: 'Your message' + piece_jointe: "Attachment" diff --git a/config/locales/models/commentaire/fr.yml b/config/locales/models/commentaire/fr.yml index 71ed7e25e..a4c17be96 100644 --- a/config/locales/models/commentaire/fr.yml +++ b/config/locales/models/commentaire/fr.yml @@ -3,4 +3,4 @@ fr: attributes: commentaire: body: 'Votre message' - file: fichier + piece_jointe: "Pièce jointe" From 9e3bf50e612c8c3fba81dd96d79421ccd2c6353e Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 15 Apr 2024 12:34:22 +0200 Subject: [PATCH 0103/1532] chore(messagerie): mention multiple files are possible --- app/components/attachment/edit_component/edit_component.en.yml | 1 + app/components/attachment/edit_component/edit_component.fr.yml | 1 + .../attachment/edit_component/edit_component.html.haml | 2 ++ 3 files changed, 4 insertions(+) diff --git a/app/components/attachment/edit_component/edit_component.en.yml b/app/components/attachment/edit_component/edit_component.en.yml index b5ae8544f..09d8cd176 100644 --- a/app/components/attachment/edit_component/edit_component.en.yml +++ b/app/components/attachment/edit_component/edit_component.en.yml @@ -5,6 +5,7 @@ en: retry: Retry delete: Delete delete_file: Delete file %{filename} + multiple_files: Multiple files possible. replace: Replace replace_file: Replace file %{filename} open_file: Open file %{filename} diff --git a/app/components/attachment/edit_component/edit_component.fr.yml b/app/components/attachment/edit_component/edit_component.fr.yml index d4e5b6811..67e2dd519 100644 --- a/app/components/attachment/edit_component/edit_component.fr.yml +++ b/app/components/attachment/edit_component/edit_component.fr.yml @@ -5,6 +5,7 @@ fr: retry: Réessayer delete: Supprimer delete_file: Supprimer le fichier %{filename} + multiple_files: Plusieurs fichiers possibles. replace: Remplacer replace_file: Remplacer le fichier %{filename} open_file: Ouvrir le fichier %{filename} diff --git a/app/components/attachment/edit_component/edit_component.html.haml b/app/components/attachment/edit_component/edit_component.html.haml index 57b60297d..45d8ceafc 100644 --- a/app/components/attachment/edit_component/edit_component.html.haml +++ b/app/components/attachment/edit_component/edit_component.html.haml @@ -43,6 +43,8 @@ = t('.max_file_size', max_file_size: number_to_human_size(max_file_size)) - if allowed_formats.present? = t('.allowed_formats', formats: allowed_formats.join(', ')) + - if as_multiple? + = t('.multiple_files') - if !persisted? || champ.present? && champ.titre_identite? = file_field(champ, field_name, **file_field_options) From 0dd4bafdfd890ea8e4dbfb3e1db8277466dee0f5 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 25 Apr 2024 12:11:48 +0200 Subject: [PATCH 0104/1532] refactor(pj): more readable as_multiple logic --- .../multiple_component/multiple_component.html.haml | 2 +- app/models/champs/piece_justificative_champ.rb | 4 ---- app/models/champs/titre_identite_champ.rb | 4 ---- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/app/components/attachment/multiple_component/multiple_component.html.haml b/app/components/attachment/multiple_component/multiple_component.html.haml index b5a64b044..74abdf23c 100644 --- a/app/components/attachment/multiple_component/multiple_component.html.haml +++ b/app/components/attachment/multiple_component/multiple_component.html.haml @@ -8,7 +8,7 @@ = render Attachment::EditComponent.new(champ:, attached_file:, attachment:, index:, view_as:, user_can_destroy:, form_object_name:) %div{ id: empty_component_id, class: class_names("hidden": !can_attach_next?), data: { turbo_force: :server } } - = render Attachment::EditComponent.new(champ:, as_multiple: champ.present? ? champ.allow_multiple_attachments? : true, attached_file:, attachment: nil, index: attachments_count, user_can_destroy:, form_object_name:) + = render Attachment::EditComponent.new(champ:, as_multiple: champ.nil?, attached_file:, attachment: nil, index: attachments_count, user_can_destroy:, form_object_name:) // single poll and refresh message for all attachments = render Attachment::PendingPollComponent.new(attachments: attachments, poll_url:, context: poll_context) diff --git a/app/models/champs/piece_justificative_champ.rb b/app/models/champs/piece_justificative_champ.rb index bca75c292..8d9435d17 100644 --- a/app/models/champs/piece_justificative_champ.rb +++ b/app/models/champs/piece_justificative_champ.rb @@ -30,8 +30,4 @@ class Champs::PieceJustificativeChamp < Champ def blank? piece_justificative_file.blank? end - - def allow_multiple_attachments? - false - end end diff --git a/app/models/champs/titre_identite_champ.rb b/app/models/champs/titre_identite_champ.rb index 88983fa6e..05b67e802 100644 --- a/app/models/champs/titre_identite_champ.rb +++ b/app/models/champs/titre_identite_champ.rb @@ -25,8 +25,4 @@ class Champs::TitreIdentiteChamp < Champ def blank? piece_justificative_file.blank? end - - def allow_multiple_attachments? - false - end end From c6a2cb02407a213d5d4240107d1bacbac79d2d4d Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 26 Apr 2024 16:47:19 +0200 Subject: [PATCH 0105/1532] style(gallery): update gallery demande UI --- app/assets/images/apercu-indisponible.png | Bin 5870 -> 5467 bytes app/assets/stylesheets/gallery.scss | 35 +++++++++--------- .../piece_justificative/_show.html.haml | 35 ++++++++++-------- 3 files changed, 37 insertions(+), 33 deletions(-) diff --git a/app/assets/images/apercu-indisponible.png b/app/assets/images/apercu-indisponible.png index 83ed22706fb540ec63bd502381ffd732d2a51b09..6bebd9f59de194d5d5eab6847ac31e31c5d5a4d4 100644 GIT binary patch delta 4290 zcmXw6c|6nqAE)F>k;pL#B`HU~vIv`$4nno$wxrB`Eu+{zu5`Idk%UsnHFDe7ikKso zyKU}?!Yt?5Z2PT0e((R@kN5ledcThM`}umlmvm{z(;Nci{$4(R_C};IW6V5hbhuIU z7F)w%@PhBJVQWzRHQjnt%#Bb`L3m8Vbs2}fw@!z?uL*r+8C2lnn**Ld@Yt{5l`Fg{ zDm1$Ia9H9t3V||ypnA*G2H&bq_914PL1&^A6cnIRLY4h|DWR)s)#}1$h0}i=5KB)J zb^)MDlb1Y*81ts+t+2@T`Jumh_$by=2xo&KTxS;(B4?j#rj%8R9#_gU z-QC=_PkxEro;^);UPCrElbWMgiO6)Tmku|}kn^Km3F&E3=Oofj*Phs?-csEEaF^DV zhyz7e1jhDjyge?w1EhA|*lLFe*z$3RpNM$CW0|R!;xa!K1$$-GD$iObz|<^m0Ur{? zPus?PNtD)B?GT9(_-pY*Nci11!@jAQy~}S|3i=*W6&}sXlZ688J27chsS(C9oz%KI zM;2@F#Z?L&+ovWZa7D;kL_FPAcv`K~Ues9lA0ihDKq}!>NxN8~sUuC1%hM~nf9`d6 zO8+Xn-+Sua(s;9Jh+&QSqkkzD6lu?>R3Q1igv{RKR#((b38@hFB|I|Z|7kP7zcRD( ztQ8l&FdV|?ai`MlAxe`!M8q$yvey@;esyOCwS}tH2T!O9s)kA{TtOu1vnwv#0n^+%R*5oxSIM)e(6F65g&u+`lixY!Tyq*cune~JoMd1`~{aYHrvczcX* zkq1=M>Q2eAa>@mo9Wj|n;4GE57;0Lr(Nm!W9UkY1@pa(GY@9a0uK>6UD<`0=N6_u5 za_~Z`Zfs>~+MFW0&IzQ37B?bpw&|lVh3HF5C)isQ5g^XXj*wYVV-Mj(GiWTxc0a+l z-aXHmzqLD!aZT3{=4r3B`#X3QMm)M_=4L_{%EW>HX>I6gFt&A2OzJj( z1EF%%XRjbfvQ}P{K58Ox-H6h3!)a}RLz@^svpa}(<+RgB8UxDTBD*p&8M9?*hC{2t z%xl?q99-2#Cxv%Iv7mWiZ15wR9n%Tq5!v&lQ&X*vT9qlx0Q?H``M^;Na0!v!p+DCU zQmBaKTG=lz!qXW^qSh2gVU)Q*HY&#lzqy4Pwo~ndl0M1*y1PZwBxogDf;hU zpD6VUg(RvV4+;6N#gCc{s{mBb`G)<|aDcn+8b3+@;a=iy66PV&A-ayBm_&WgLQSk! zd51)0=JD2wg15JR($xSihZJcYwC4Um8)LIWow3oT0C#Kj|9!O(GEl6CLl@ee3H$Z= zPL6+gKvC_%>>(e`$nr@ZR_7F?|c$zfPbq zRkzrHjzMaw)F)Q_JUysv`}EkJA-(wRX|0!GcdLP!fci1^ovy)Ko61=Rr^08=YT-7$ zDfLIEwzlT-h<#RB`xC4`RXCqlBtx>f_v9&$0<^|X(Xpvzny*b$wxfw2Q*sI-9#$!znfc#q#x?yNs~(4KGqrZ_ z8hH)ipd-pA#lJ_6GuUPnQFkYhL2a*Rv5~(eb&p)|%3^afFtOejw0-(+$-+_zJ17?& z_%pBIP=59?_Y6AN`9WzKuOwui;DAqxH=~yDjM#s^1YXmiXna5hQ}6J;`;jUwRiM4y%6xmlfek;TBijBEb@=ym4o43=dG|eh@8O zI{pQvxRVmI9mhhv=CGSNM(X#u2D20OAd_CDiDRL~a)f1FB26bZ;kOg$(?NxAB9|ni z7WQ^K;;-F$N{hn4c^?&Q35ktuNN$yw&yoFKXde|K7zc}82Z_%`sl|J z`Hv>}C0ipz&;k1>wHxWv86bm=`W$;>ovf8JLp=l35S2+J*;A<-qjH^kWbCXzGx^mt zp9%0e$#h@3KPN?-Kg$NW-@LL*iS}`~|IQLFnd{IMj+HQ}E5-t4qqt@!#+Oy+;yTlQ z`sQM}?i-lD!(Mfcn*@DSK7!Fqq)Ig};7VL@pI!=>(asI4%+40k(-q{+hX&iME6ICh zRR0<~W`(+}9vrba7M}g;rWtEj1Z~)$NV>@MlLiSt<99Of1Q1g}jtC81 zZWTh50nz?W3na2Um0CRsRyE2E9a3$t8G85(Njf!DAm+6rp*jDm^N@4HZw^b z3t5%J!R{yd@?T!OWiA|+Sg5*sCPO3lc5YEm`Tgn9&EemD(Ay}T)NJ&|BIVb}rpY4X zc@y4)GZrzyh+Ye#wZB1=A=|5Y{H9SV@ZvwO+2c zo?>qIVb9f?RukXU<%Jaw*sj{zzMq4;D~>6x&31xn}bf zzTXUaeOo|!-JO4b|N0&=G1O0BbuEzWRo*^Gtst=d5Rq2`4P&cs#xeg46txyN`kA(> zII6k%Jv8JucfmO9r8KvxQ6j)!Su}Jv)~=*8=gQ1It4p&rD-P!EW4T|wPcGIg-{#(E zs42yEK<*v_*fG9mD>st1Xu-sTknO*%ASFkodRPh~`ihB(kNZ6m^-(c1RM4MHClB#A zNg%*(IdJSe*Ma0_;BmYyQfF7>DECE6&WV>zfFy&7Vd0z6Yv|1D_^wXy8x>My-l`!w zvPSP^Q?S0ffv!b%LWkI)=#yKk$o@J?Z|fuJM}QZo9u~JOc!#UK;4^EG5Z*n{=J@NC zuuH0Dktd_M>m$KAN`*ptf8yS}XfYaAC~F$|*BQHo=g1IF*{KsJXq)Q-Wz;^gzOxaX zY3q@m4^a@MkfVvg&#WD9LJLFo|94p<(5$WoB%L9v#%#FhM0yzQCsTjCy7GZ`kmEt% zN)LE;rVOt?d7p@OKUS{W-haTR<(>Ypqr}ug|D!D|I9^h?l>}g{?Hdp=}=p_93LzIfL4b~zIm8bXhySIN; zDy72R{~Q*Eqfrq*#ZVhlieCGX3zjQ9u7fg7%c5aWVO?%s?tN_=Vb2YFTTr=ZC)O9o zFej<^MI3AvT=-UH^j9W6ZnmaVjK1T|!+4VNRdT3(cy<9A!{st^B_WScytqq}KSuGHP(&=Cp0el_?(V8>vYQNv!WMZ+nFdlmov ze$$dG)9GX-=>vGb$$z_)t54Xxz3yNqc@HbwKeSc~=gyaBxlzU&b|V;@HvCopjf!Ng z3#^mMS-2JC^we3!6u?f0CMcltB3A_Rt?!Etj`^Lhfq zzH`k+bB&E|cv7p*CFuJGJ6Xq^H0jwmjwlWk!dMf>&gleo%Xb+2Q02eKa?73aWeZ{P z?XRq!I1WXe^I&btFu}qrd%KmE2W{nGnrO-rJ4Q6jVG34D<8*+a#TD0uNng|3%z9vKy+6M&`iJEz&<=h?fA;fn`rFMp~Ejky|lurMU1?h4@(w8{Us#oafWiPVeL1E(=RwPl7b zYSog%HhynjIhVJ8l`^&1y~u*>;xPiwr?VqFSZy0R$BzU5K-^ym_CsEpWlkx0RW8e$ z3IP+aNwbOi@Kf~>>%L(;&UyfG{3{My7rC(nDYlIXkg+Q;J@f(vL@TP+j^`tBN#`<@ z_Q0Z+zd?#^`ggoT^2FXQ9Nd^qje4>CYjt zht;i&9EC+~V`QfOk&rQx1<}K1lL_Lo=?@^W@`tUA_JSY*1jS_lqstZvL^y~_$ofA4 zQFW_24PntAuT{`4V34h?dN~62-JuGa=UxqzJ1@P z+cJ{zT?UnmdNF7!Bv$0S2lksTpJ(cIL))meF}Yeo#(DIr`=XXrk+5Y-z)@jQ$13fk zr3Y0haUj*=;PRxp@&^_0mLTpYuUsCd_N`pcDoZq8#se)Z%KjMSq`RU2%uGfV_z0T( z_?Mz;Tnxyfd*5F@jiWXL-u+Tg9g)abv}Q}N(J@Vu?ER|E@t*5^$j8@;OPxnc0^LCy zoWgoQu=S(S|Pimuy;)dU@J3|vk%G_YHl{)Ov3k__R@mP&C>7HaQ z5i)i3t>xzFL!Ex3PBG9w9+yDB43qWa!}f9bnFo@~zGb|jVC<(9jvaigA0 zhP+UJjd)mHClaf;c*=M40$IX3j$)!x5()G}t)i z#G$OQ>Jzv0tv(-@Rx|h)0Mmg1Jl{h(dW#y&f zsV1{w$8~fj5KDOD-M+q0pDj;b0!cQ5ss7d7$O`lW7}I9gQ{E!5G@gaTDHd zL@u7108@RZk=W_}sTh$2F?!}Vg=+v9rBq?;ME!oUfX{^qqBfSsV{03td7L&H64k9@ zX6qb1SzwCi^wI=>oEi_9ZcfJMm)V)~xHJKIg=TNH8p51tc(mj8h(W|(6Ofb$kR69u zT9q^+OB%Hu5O}%Epa2D!)<1@iQ4=E1m(|$O7zM@ zHy4a2kJz_nD^`Fa>N1 zR;)J@Nsxc0)qr+lY`yAfq<3Q9ulRlDtwhdj-1YwznwX>2gK_E%UQ$}+u<<<8w6c`q zZ3Y&Iq-)k;PW8AyzEdmMrlXj7jrR%_&tF24f#mz_Q$R0Er=(U5L z*kX;i0Vc*^q&ll|^D$0*R1j6P{lOom>FRfcPM$`RHSojUm^Fu<=M$D_)iq_d=4e=q>5hh$_IdN4-`$4T3|cr61&>)2b2oK9`NQAykYD zT_I_=Z`|FRH|9@9;WVdT0-ttXvsgF|Y;`q-_a}tJ1??CYJD)mJ2bmGW!+OvfKk*)0 zOhAByUV-gJl=6QSabR)>Br4HPWR1A34XB%iF3xf2ca%6{&J>dMACeUO5y8((em`xS z0)6%H))B`=B1(@?FJL2s}F z)+XQ$>fU#nmIi2H}CuHf|GelEKr%pZeX9d3H{AObnEu*=@H$G7n82> z$6ma7ip&MNOucCc7{|=kCUt8w*)Z|tj&lcfMpLa1I+EV9g6bLX!{J|(#EakGvVZ(* z7&m}@lQ?{Ju@;jWFCpzK+|s^InrxAp>I+FS;au6Ix=7?5ZAG@sVxJ@caN?sp5%D03;( zI3{R|Zx0U;3$iGhi+*d_z`P1G^qd{hGrUUN z4Xnl&Tw|61!d@Si*PW(aP%}Cl-oDxm}f35gC8J(XArI*6rQ+3x=PU0HMYhJH7&o1fEPX*hx%0=7vc;@XC z3R30V%8_Dp%T25e?R!oxn7P`WFV>%XzIC9!^3#K|#B-^h(Tu*!sUft$hIwDqj{3%h z4#_WmIoILQwlZsn|NGoWY_7Zjgsu5`uOH%AuVoy_x$Y1%_)2<4bVjVcv6(uwG~OrC zu^_F>Cu`5tb%ee8mvm*b)jGQNvav*e#qedMPKpFUfjtb(DyzBVo;n@oe`c@b_BN}J z<|}1g0fpJc8JBfz2}b*(+0!d*To|HPADMEhQPr|4TYb^77sy{g*4(=qQ%&rU=^ zZcLpQzoQ6G(|5@mR5r6U0~AfX%^O@y=-k$pTOE0X4~_(b(HesxIPvUCm6(OThX1vc z3b#OSV9f&dV3Y1l({3vQGUn=RDH@%z_w z6}|G{XlM18V|*&D5LgVhZGVNbDZ)4aKtsZeI#*Kc{5c^e6gMi%R!&=OP3SiDm3D)q zKkxFitP{h-rucMV;GiyAfBstzWNNW+-ao4N2^@OrGloIWf=$h*Xu>BW#k;{n!W=z>*#g;XsO7^Bg~qS=#6pQ4Oj!EroxL> zDgC#fl-1-HGuQi)`f64`68O4JM4S9+WA7+R<=MXzM2Y%ggDq&0(n7BK(K!`o9(f&D zHQAcbKqRrPzPY|?mzbES_V#g!3cZ`*nQyOmKV)*eT^UB6wFkZ zKmV$eKdZ$=tVgBSNB8Ph(IXclc7|I_cVwz#6+3PsYeUPz@mx#9KqD>b-m zEq%E$wth35xA`F6p1HC<;)dOm@g4&Y$hx>}63M=2WH4gVSN;GgQ(D#h+^vlDlT1b& z!5M)=Ufmatjl8Yo;wCM4=tcSHaQBmu^s*E#{9Qf$YNjJUTGM6B)yb>iGa!v`s#122 zXxV1e(xhO(_R6ERlC(bibnhq|l93Pke)3rIdtka=oN!|jCfIDd0obl!daQV^ZAG_? zol;kvC70{A)Dn@nqu~ODF&b|Phot<+g%^fqXJYga=_{u6NY1hk`e-=qX6~))F$NNk zGfX)?t6aklQctLoNd%w=9osZk`{%FkuqNmHm?CsL`C;v+arqXj%TwQ{h*)wsz4OG5 z09*N=PZdfl$nLWZU=c#Oh8O|-EqdYQYoo1Y0B@m|U&?G}5bv$#Dy*YD2)ypb4f^YW zr$ZYW)P&tn+7U`bN}5=(#9x+&162zC{QIf%=*OF>6d(j?U-<|fG&H_%xt>-Ee;TUO z8l*FqVD9#@Oum^rki1nByi-v)p``hFbZQ0WyHD+W+4A7-pN4xFmW{x4K>NFr3aV>2 zCaE)&1J5BH&Tr)srqMtRJx?&-?^7bvEnj0#bC$nLC#!Md zXofp513O`9yL{kMN*^pu+DwM-WwBV`kQ@ydC7?ZsELF4K4EQm&j*#vUf_B7;y!rd# zs%!7BtFT^b9BSkVIgOf_wxPCTy@;M<^XPg!K0vJb+56X#`L-hp?1b|i%`0YHtwDuZ zy^W}?+3bhRperwfvk65-Zv543X4*_AyA^F@&2{52-97`KPKeJ9~~)Gv_#CG>8h!m&grX^Sd+4JGrY7U&DwhBf-J}mYKga z+Sz5EyPs_`b?l5$4-$_Ig8?q~atAO;th`GL$R4gO&$TAxN8hs_3ma4Lxui9&DLv}D zi*Q1RN6iT7BIeMtJ%>@MOVY+EPrtm2g9NWxS6Hcm)$oy4rd(MGHi?Q8$bbhsQcKVo zg zcpQE}ENGX_p7%Yu4)%DF`MlCzIqacoZKGALLjQ2Z*jbTvW^IxJ|NdkK@zH?ZGk6}G znqJf7c~C=tD11V!UrxJSDuk~4;`rd{e(s%0L{FeZKia%sy*ZIL1IDHg+@h{h{W5zI%gb+ z9xvdp(BHjTH}8%i{_UPI;ApjxFa@u*Yb%(S{Ol0a5WVDbETg|MIZR5tcJO~aXLos= z!UEp|t>LBd)6ez}1-v9sr diff --git a/app/assets/stylesheets/gallery.scss b/app/assets/stylesheets/gallery.scss index 55580c607..eb2bec9ee 100644 --- a/app/assets/stylesheets/gallery.scss +++ b/app/assets/stylesheets/gallery.scss @@ -33,16 +33,18 @@ color: var(--text-active-blue-france); border: 1px solid var(--border-active-blue-france); padding: 0.25rem 0.75rem; - - &:hover { - background-color: var(--hover-tint); - } - - &:active { - background-color: var(--active-tint); - } + display: none; } } + + a.gallery-link:hover .fr-btn, + a.gallery-link:active .fr-btn { + display: flex; + } + + a.gallery-link:active .fr-btn { + background-color: var(--hover-tint); + } } .gallery-pieces-jointes { @@ -52,17 +54,16 @@ .gallery-item { margin: 0 2rem 1.5rem 0; } - - img { - height: 200px; - width: 200px; - } } .gallery-demande { - img { - height: 150px; - width: 150px; + .gallery-items-list { + display: flex; + flex-wrap: wrap; + } + + .gallery-item { + margin: 0.5rem 2rem 1rem 0; } .fr-download { @@ -71,7 +72,7 @@ .thumbnail { width: fit-content; - margin-bottom: 1rem; + margin-bottom: 0.5rem; } } diff --git a/app/views/shared/champs/piece_justificative/_show.html.haml b/app/views/shared/champs/piece_justificative/_show.html.haml index 525546d8f..50218e54b 100644 --- a/app/views/shared/champs/piece_justificative/_show.html.haml +++ b/app/views/shared/champs/piece_justificative/_show.html.haml @@ -4,21 +4,24 @@ - champ.piece_justificative_file.attachments.each do |attachment| %li= render Attachment::ShowComponent.new(attachment:, new_tab: true) - else - - champ.piece_justificative_file.attachments.each do |attachment| - %ul - %li= render Attachment::ShowComponent.new(attachment:, new_tab: true, truncate: true) - .gallery-item - - blob = attachment.blob - - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) - = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do - .thumbnail - = image_tag("pdf-placeholder.png") - .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } - = 'Visualiser' + .gallery-items-list + - champ.piece_justificative_file.attachments.each do |attachment| + .gallery-item + - blob = attachment.blob + - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) + = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do + .thumbnail + = image_tag("pdf-placeholder.png") + .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } + = 'Visualiser' - - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) - = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) + = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + .thumbnail + = image_tag(attachment.variant(:medium), loading: :lazy) + .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } + = 'Visualiser' + - else .thumbnail - = image_tag(attachment.variant(:small), loading: :lazy) - .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } - = 'Visualiser' + = image_tag('apercu-indisponible.png') + = render Attachment::ShowComponent.new(attachment:, new_tab: true, truncate: true) From 479fdb9dbee1a952775e7d569e9e13dea690f90d Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Tue, 30 Apr 2024 17:22:29 +0200 Subject: [PATCH 0106/1532] feat(gallery): can close gallery when clicking on browser back button --- app/javascript/controllers/lightbox_controller.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/javascript/controllers/lightbox_controller.ts b/app/javascript/controllers/lightbox_controller.ts index 8d4660d70..49e53ecd0 100644 --- a/app/javascript/controllers/lightbox_controller.ts +++ b/app/javascript/controllers/lightbox_controller.ts @@ -4,6 +4,7 @@ import { LightGallery } from 'lightgallery/lightgallery'; import lgThumbnail from 'lightgallery/plugins/thumbnail'; import lgZoom from 'lightgallery/plugins/zoom'; import lgRotate from 'lightgallery/plugins/rotate'; +import lgHash from 'lightgallery/plugins/hash'; import 'lightgallery/css/lightgallery-bundle.css'; export default class extends Controller { @@ -11,7 +12,7 @@ export default class extends Controller { connect(): void { const options = { - plugins: [lgZoom, lgThumbnail, lgRotate], + plugins: [lgZoom, lgThumbnail, lgRotate, lgHash], flipVertical: false, flipHorizontal: false, animateThumb: false, @@ -21,6 +22,14 @@ export default class extends Controller { selector: '.gallery-link' }; + const gallery = document.querySelector('.gallery'); + + if (gallery != null) { + gallery.addEventListener('lgBeforeOpen', () => { + window.history.pushState({}, 'Gallery opened'); + }); + } + this.lightGallery = lightGallery(this.element as HTMLElement, options); const downloadIcon = document.querySelector('.lg-download'); From db8de9e657541bf397024f4fa7c17fb846af0952 Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Fri, 3 May 2024 14:41:17 +0200 Subject: [PATCH 0107/1532] fix(gallery): display attachments tab only if attachments in dossier --- .../dossiers/_header_bottom.html.haml | 2 +- .../dossiers/pieces_jointes.html.haml | 64 +++++++++---------- 2 files changed, 31 insertions(+), 35 deletions(-) diff --git a/app/views/instructeurs/dossiers/_header_bottom.html.haml b/app/views/instructeurs/dossiers/_header_bottom.html.haml index ce922e524..0358c4aa0 100644 --- a/app/views/instructeurs/dossiers/_header_bottom.html.haml +++ b/app/views/instructeurs/dossiers/_header_bottom.html.haml @@ -7,7 +7,7 @@ instructeur_dossier_path(dossier.procedure, dossier), notification: notifications_summary[:demande]) - - if dossier.revision.types_de_champ.any?(&:piece_justificative?) + - if dossier.champs.map(&:piece_justificative_file).flatten.any? = dynamic_tab_item(t('views.instructeurs.dossiers.tab_steps.attachments'), pieces_jointes_instructeur_dossier_path(dossier.procedure, dossier)) diff --git a/app/views/instructeurs/dossiers/pieces_jointes.html.haml b/app/views/instructeurs/dossiers/pieces_jointes.html.haml index 386a31852..a0b1816a1 100644 --- a/app/views/instructeurs/dossiers/pieces_jointes.html.haml +++ b/app/views/instructeurs/dossiers/pieces_jointes.html.haml @@ -3,38 +3,34 @@ = render partial: "header", locals: { dossier: @dossier } .fr-container - - if @champs_with_pieces_jointes.map(&:piece_justificative_file).flatten.none? - .empty-text - Ce dossier ne contient pas de pièces jointes - - else - .gallery.gallery-pieces-jointes{ "data-controller": "lightbox" } - - @champs_with_pieces_jointes.each do |champ| - - champ.piece_justificative_file.each do |attachment| - .gallery-item - - blob = attachment.blob - - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) - = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do - .thumbnail - = image_tag("pdf-placeholder.png") - .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } - Visualiser - .champ-libelle - = champ.libelle.truncate(25) - = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) - - - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) - = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do - .thumbnail - = image_tag(attachment.variant(:medium), loading: :lazy) - .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } - Visualiser - .champ-libelle - = champ.libelle.truncate(25) - = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) - - - else + .gallery.gallery-pieces-jointes{ "data-controller": "lightbox" } + - @champs_with_pieces_jointes.each do |champ| + - champ.piece_justificative_file.each do |attachment| + .gallery-item + - blob = attachment.blob + - if blob.content_type.in?(AUTHORIZED_PDF_TYPES) + = link_to blob.url, id: blob.id, data: { iframe: true, src: blob.url }, class: 'gallery-link', type: blob.content_type, title: "#{champ.libelle} -- #{blob.filename}" do .thumbnail - = image_tag('apercu-indisponible.png') - .champ-libelle - = champ.libelle.truncate(25) - = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) + = image_tag("pdf-placeholder.png") + .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } + Visualiser + .champ-libelle + = champ.libelle.truncate(25) + = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) + + - elsif blob.content_type.in?(AUTHORIZED_IMAGE_TYPES) + = link_to image_url(blob.url), title: "#{champ.libelle} -- #{blob.filename}", data: { src: blob.url }, class: 'gallery-link' do + .thumbnail + = image_tag(attachment.variant(:medium), loading: :lazy) + .fr-btn.fr-btn--tertiary.fr-btn--icon-left.fr-icon-eye{ role: :button } + Visualiser + .champ-libelle + = champ.libelle.truncate(25) + = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) + + - else + .thumbnail + = image_tag('apercu-indisponible.png') + .champ-libelle + = champ.libelle.truncate(25) + = render Attachment::ShowComponent.new(attachment: attachment, truncate: true) From 3d5e0043a6649d5058e2911f2b772574bb98aefd Mon Sep 17 00:00:00 2001 From: Eric Leroy-Terquem Date: Mon, 6 May 2024 15:50:19 +0200 Subject: [PATCH 0108/1532] refacto: remove unused small variant --- app/models/champs/piece_justificative_champ.rb | 1 - app/models/champs/titre_identite_champ.rb | 1 - 2 files changed, 2 deletions(-) diff --git a/app/models/champs/piece_justificative_champ.rb b/app/models/champs/piece_justificative_champ.rb index 8d9435d17..b6dfb0bef 100644 --- a/app/models/champs/piece_justificative_champ.rb +++ b/app/models/champs/piece_justificative_champ.rb @@ -2,7 +2,6 @@ class Champs::PieceJustificativeChamp < Champ FILE_MAX_SIZE = 200.megabytes has_many_attached :piece_justificative_file do |attachable| - attachable.variant :small, resize: '300x300' attachable.variant :medium, resize: '400x400' end diff --git a/app/models/champs/titre_identite_champ.rb b/app/models/champs/titre_identite_champ.rb index 05b67e802..667feca7e 100644 --- a/app/models/champs/titre_identite_champ.rb +++ b/app/models/champs/titre_identite_champ.rb @@ -3,7 +3,6 @@ class Champs::TitreIdentiteChamp < Champ ACCEPTED_FORMATS = ['image/png', 'image/jpeg'] has_many_attached :piece_justificative_file do |attachable| - attachable.variant :small, resize: '300x300' attachable.variant :medium, resize: '400x400' end From 95de0d6239e1ed6634cbf0fb84afa59ec363eff2 Mon Sep 17 00:00:00 2001 From: mfo Date: Fri, 12 Apr 2024 17:59:50 +0200 Subject: [PATCH 0109/1532] tech(refactor): identity blocks --- .../dossiers/individual_form_component.rb | 11 +++ .../individual_form_component.en.yml | 7 ++ .../individual_form_component.fr.yml | 7 ++ .../individual_form_component.html.haml | 76 +++++++++++++++ app/controllers/users/dossiers_controller.rb | 7 ++ .../controllers/for_tiers_controller.ts | 93 ++++--------------- app/views/users/dossiers/identite.html.haml | 81 +--------------- .../users/dossiers/identite.turbo_stream.haml | 1 + config/locales/en.yml | 5 - config/locales/fr.yml | 5 - config/routes.rb | 1 + .../shared_examples_for_prefilled_dossier.rb | 4 +- spec/system/accessibilite/wcag_usager_spec.rb | 4 +- .../api_particulier/api_particulier_spec.rb | 16 +++- .../routing/rules_full_scenario_spec.rb | 4 +- spec/system/users/brouillon_spec.rb | 4 +- spec/system/users/dossier_creation_spec.rb | 20 +++- spec/system/users/dropdown_spec.rb | 4 +- spec/system/users/linked_dropdown_spec.rb | 4 +- 19 files changed, 180 insertions(+), 174 deletions(-) create mode 100644 app/components/dossiers/individual_form_component.rb create mode 100644 app/components/dossiers/individual_form_component/individual_form_component.en.yml create mode 100644 app/components/dossiers/individual_form_component/individual_form_component.fr.yml create mode 100644 app/components/dossiers/individual_form_component/individual_form_component.html.haml create mode 100644 app/views/users/dossiers/identite.turbo_stream.haml diff --git a/app/components/dossiers/individual_form_component.rb b/app/components/dossiers/individual_form_component.rb new file mode 100644 index 000000000..59ce27d9f --- /dev/null +++ b/app/components/dossiers/individual_form_component.rb @@ -0,0 +1,11 @@ +class Dossiers::IndividualFormComponent < ApplicationComponent + delegate :for_tiers?, to: :@dossier + + def initialize(dossier:) + @dossier = dossier + end + + def email_notifications?(individual) + individual.object.notification_method == Individual.notification_methods[:email] + end +end diff --git a/app/components/dossiers/individual_form_component/individual_form_component.en.yml b/app/components/dossiers/individual_form_component/individual_form_component.en.yml new file mode 100644 index 000000000..fbaffd2e5 --- /dev/null +++ b/app/components/dossiers/individual_form_component/individual_form_component.en.yml @@ -0,0 +1,7 @@ +--- +en: + self_title: Your identity + callout_text: "You are acting as a proxy for a principal, either professionally (such as accountant, lawyer, civil servant…) or personally (family). Make sure to respect the conditions of" + callout_link: Articles 1984 and following of the Civil Code. + callout_link_title: Articles 1984 and following of the Civil Code + beneficiaire_title: "Identity of the beneficiary" diff --git a/app/components/dossiers/individual_form_component/individual_form_component.fr.yml b/app/components/dossiers/individual_form_component/individual_form_component.fr.yml new file mode 100644 index 000000000..86512c87b --- /dev/null +++ b/app/components/dossiers/individual_form_component/individual_form_component.fr.yml @@ -0,0 +1,7 @@ +--- +fr: + self_title: Votre identité + callout_text: Vous agissez en tant que mandataire, soit professionnellement (comme expert-comptable, avocat, agent public…) soit personnellement (famille). Assurez-vous de respecter les conditions + callout_link: des Articles 1984 et suivants du Code civil. + callout_link_title: Articles 1984 et suivants du Code civil + beneficiaire_title: Identité du bénéficiaire diff --git a/app/components/dossiers/individual_form_component/individual_form_component.html.haml b/app/components/dossiers/individual_form_component/individual_form_component.html.haml new file mode 100644 index 000000000..ceab537b3 --- /dev/null +++ b/app/components/dossiers/individual_form_component/individual_form_component.html.haml @@ -0,0 +1,76 @@ += form_for @dossier, url: update_identite_dossier_path(@dossier), html: { id: 'identite-form', class: "form", "data-controller" => "for-tiers" } do |f| + - if for_tiers? + .fr-alert.fr-alert--info.fr-mb-2w + %p.fr-notice__text + = t('.callout_text') + = link_to(t('.callout_link'), + 'https://www.legifrance.gouv.fr/codes/section_lc/LEGITEXT000006070721/LEGISCTA000006136404/#LEGISCTA000006136404', + title: helpers.new_tab_suffix(t('.callout_link_title')), + **helpers.external_link_attributes) + + %fieldset.fr-fieldset.mandataire-infos + %legend.fr-fieldset__legend--regular.fr-fieldset__legend + %h2.fr-h4 + = t('.self_title') + + .fr-fieldset__element.fr-fieldset__element--short-text + = render Dsfr::InputComponent.new(form: f, attribute: :mandataire_first_name, opts: { autocomplete: 'given-name' }) + + .fr-fieldset__element.fr-fieldset__element--short-text + = render Dsfr::InputComponent.new(form: f, attribute: :mandataire_last_name, opts: { autocomplete: 'family-name' }) + + = f.fields_for :individual, include_id: false do |individual| + %fieldset.fr-fieldset.fr-mb-0.individual-infos + %legend.fr-fieldset__legend--regular.fr-fieldset__legend + %h2.fr-h4 + - if for_tiers? + = t('.beneficiaire_title') + - else + = t('.self_title') + + .fr-fieldset__element.fr-mb-0 + %fieldset.fr-fieldset + %legend.fr-fieldset__legend--regular.fr-fieldset__legend + = t('activerecord.attributes.individual.gender') + = render EditableChamp::AsteriskMandatoryComponent.new + + .fr-fieldset__element + .fr-radio-group + = individual.radio_button :gender, Individual::GENDER_FEMALE, required: true, id: "identite_champ_radio_#{Individual::GENDER_FEMALE}" + %label.fr-label{ for: "identite_champ_radio_#{Individual::GENDER_FEMALE}" } + = Individual.human_attribute_name('gender.female') + .fr-fieldset__element + .fr-radio-group + = individual.radio_button :gender, Individual::GENDER_MALE, required: true, id: "identite_champ_radio_#{Individual::GENDER_MALE}" + %label.fr-label{ for: "identite_champ_radio_#{Individual::GENDER_MALE}" } + = Individual.human_attribute_name('gender.male') + .fr-fieldset__element.fr-mb-0 + %fieldset.fr-fieldset.width-100 + .fr-fieldset__element.fr-fieldset__element--short-text + = render Dsfr::InputComponent.new(form: individual, attribute: :prenom, opts: { autocomplete: (for_tiers? ? false : 'given-name') }) + .fr-fieldset__element.fr-fieldset__element--short-text + = render Dsfr::InputComponent.new(form: individual, attribute: :nom, opts: { autocomplete: (for_tiers? ? false : 'family-name') }) + + - if @dossier.procedure.ask_birthday? + .fr-fieldset__element + = render Dsfr::InputComponent.new(form: individual, attribute: :birthdate, input_type: :date_field, + opts: { placeholder: 'Format : AAAA-MM-JJ', max: Date.today.iso8601, min: "1900-01-01", autocomplete: 'bday' }) + + - if for_tiers? + %fieldset.fr-fieldset + %legend.fr-fieldset__legend--regular.fr-fieldset__legend + = t('activerecord.attributes.individual.notification_method') + = render EditableChamp::AsteriskMandatoryComponent.new + + - Individual.notification_methods.each do |method, _| + .fr-fieldset__element + .fr-radio-group + = individual.radio_button :notification_method, method, id: "notification_method_#{method}", "data-action" => "for-tiers#toggleEmailInput", "data-for-tiers-target" => "notificationMethod" + %label.fr-label{ for: "notification_method_#{method}" } + = t("activerecord.attributes.individual.notification_methods.#{method}") + + + .fr-fieldset__element.fr-fieldset__element--short-text{ "data-for-tiers-target" => "emailContainer", class: class_names(hidden: !email_notifications?(individual)) } + = render Dsfr::InputComponent.new(form: individual, attribute: :email, input_type: :email_field, opts: { "data-for-tiers-target" => "emailInput" }) + + = f.submit t('views.users.dossiers.identite.continue'), class: "fr-btn" diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index 92f4d00a8..08ecd2f55 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -135,6 +135,13 @@ module Users @dossier = dossier @user = current_user @no_description = true + + respond_to do |format| + format.html + format.turbo_stream do + @dossier.update_columns(params.require(:dossier).permit(:for_tiers).to_h) + end + end end def update_identite diff --git a/app/javascript/controllers/for_tiers_controller.ts b/app/javascript/controllers/for_tiers_controller.ts index 7aa955da0..6ec3a821c 100644 --- a/app/javascript/controllers/for_tiers_controller.ts +++ b/app/javascript/controllers/for_tiers_controller.ts @@ -1,87 +1,32 @@ +import { toggle } from '@utils'; import { ApplicationController } from './application_controller'; +function onVisibleEnableInputs(element: HTMLInputElement) { + element.disabled = false; + element.required = true; +} + +function onHiddenDisableInputs(element: HTMLInputElement) { + element.disabled = true; + element.required = false; +} + export class ForTiersController extends ApplicationController { - static targets = [ - 'mandataireFirstName', - 'mandataireLastName', - 'forTiers', - 'mandataireBlock', - 'beneficiaireNotificationBlock', - 'email', - 'notificationMethod', - 'mandataireTitle', - 'beneficiaireTitle', - 'emailInput' - ]; + static targets = ['emailContainer', 'emailInput', 'notificationMethod']; - declare mandataireFirstNameTarget: HTMLInputElement; - declare mandataireLastNameTarget: HTMLInputElement; - declare forTiersTargets: NodeListOf; - declare mandataireBlockTarget: HTMLElement; - declare beneficiaireNotificationBlockTarget: HTMLElement; declare notificationMethodTargets: NodeListOf; - declare emailTarget: HTMLInputElement; - declare mandataireTitleTarget: HTMLElement; - declare beneficiaireTitleTarget: HTMLElement; - declare emailInput: HTMLInputElement; + declare emailContainerTarget: HTMLElement; + declare emailInputTarget: HTMLInputElement; - connect() { - const emailInputElement = this.emailTarget.querySelector('input'); - if (emailInputElement) { - this.emailInput = emailInputElement; - } - this.toggleFieldRequirements(); - this.addAllEventListeners(); - } - - addAllEventListeners() { - this.forTiersTargets.forEach((radio) => { - radio.addEventListener('change', () => this.toggleFieldRequirements()); - }); - this.notificationMethodTargets.forEach((radio) => { - radio.addEventListener('change', () => this.toggleEmailInput()); - }); - } - - toggleFieldRequirements() { - const forTiersSelected = this.isForTiersSelected(); - this.toggleDisplay(this.mandataireBlockTarget, forTiersSelected); - this.toggleDisplay( - this.beneficiaireNotificationBlockTarget, - forTiersSelected - ); - this.mandataireFirstNameTarget.required = forTiersSelected; - this.mandataireLastNameTarget.required = forTiersSelected; - this.mandataireTitleTarget.classList.toggle('hidden', forTiersSelected); - this.beneficiaireTitleTarget.classList.toggle('hidden', !forTiersSelected); - this.notificationMethodTargets.forEach((radio) => { - radio.required = forTiersSelected; - }); - - this.toggleEmailInput(); - } - - isForTiersSelected() { - return Array.from(this.forTiersTargets).some( - (radio) => radio.checked && radio.value === 'true' - ); - } - - toggleDisplay(element: HTMLElement, shouldDisplay: boolean) { - element.classList.toggle('hidden', !shouldDisplay); - } toggleEmailInput() { const isEmailSelected = this.isEmailSelected(); - const forTiersSelected = this.isForTiersSelected(); - if (this.emailInput) { - this.emailInput.required = forTiersSelected && isEmailSelected; + toggle(this.emailContainerTarget, isEmailSelected); - if (!isEmailSelected) { - this.emailInput.value = ''; - } - - this.toggleDisplay(this.emailTarget, forTiersSelected && isEmailSelected); + if (isEmailSelected) { + onVisibleEnableInputs(this.emailInputTarget); + } else { + onHiddenDisableInputs(this.emailInputTarget); } } diff --git a/app/views/users/dossiers/identite.html.haml b/app/views/users/dossiers/identite.html.haml index ed0f5f432..afb250715 100644 --- a/app/views/users/dossiers/identite.html.haml +++ b/app/views/users/dossiers/identite.html.haml @@ -3,7 +3,7 @@ = render partial: "shared/dossiers/submit_is_over", locals: { dossier: @dossier } - if !dossier_submission_is_closed?(@dossier) - = form_for @dossier, url: update_identite_dossier_path(@dossier), html: { class: "form", "data-controller" => "for-tiers" } do |f| + = form_for @dossier, url: identite_dossier_path(@dossier), method: :patch, html: { class: "form" }, data: {turbo: true, controller: :autosubmit} do |f| %fieldset#radio-rich-hint.fr-fieldset{ "aria-labelledby" => "radio-rich-hint-legend radio-rich-hint-messages" } %legend#radio-rich-hint-legend.fr-fieldset__legend--regular.fr-fieldset__legend @@ -11,90 +11,19 @@ .fr-fieldset__element .fr-radio-group.fr-radio-rich - = f.radio_button :for_tiers, false, required: true, id: "radio-self-manage", "data-action" => "click->for-tiers#toggleFieldRequirements", "data-for-tiers-target" => "forTiers" + = f.radio_button :for_tiers, false, required: true, id: "radio-self-manage" %label.fr-label{ for: "radio-self-manage" } = t('activerecord.attributes.dossier.for_tiers.false') .fr-radio-rich__img %span.fr-icon-user-fill .fr-fieldset__element .fr-radio-group.fr-radio-rich - = f.radio_button :for_tiers, true, required: true, id: "radio-tiers-manage", "data-action" => "click->for-tiers#toggleFieldRequirements", "data-for-tiers-target" => "forTiers" + = f.radio_button :for_tiers, true, required: true, id: "radio-tiers-manage" %label.fr-label{ for: "radio-tiers-manage" } = t('activerecord.attributes.dossier.for_tiers.true') .fr-radio-rich__img %span.fr-icon-parent-fill - .mandataire-infos{ "data-for-tiers-target" => "mandataireBlock" } - .fr-alert.fr-alert--info.fr-mb-2w - %p.fr-notice__text - = t('views.users.dossiers.identite.callout_text') - = link_to(t('views.users.dossiers.identite.callout_link'), - 'https://www.legifrance.gouv.fr/codes/section_lc/LEGITEXT000006070721/LEGISCTA000006136404/#LEGISCTA000006136404', - title: new_tab_suffix(t('views.users.dossiers.identite.callout_link_title')), - **external_link_attributes) + = f.submit t('views.users.dossiers.identite.continue'), class: 'visually-hidden' - - %fieldset.fr-fieldset - %legend.fr-fieldset__legend--regular.fr-fieldset__legend - %h2.fr-h4= t('views.users.dossiers.identite.self_title') - - .fr-fieldset__element.fr-fieldset__element--short-text - = render Dsfr::InputComponent.new(form: f, attribute: :mandataire_first_name, opts: { "data-for-tiers-target" => "mandataireFirstName" }) - - .fr-fieldset__element.fr-fieldset__element--short-text - = render Dsfr::InputComponent.new(form: f, attribute: :mandataire_last_name, opts: { "data-for-tiers-target" => "mandataireLastName" }) - - = f.fields_for :individual, include_id: false do |individual| - .individual-infos - %fieldset.fr-fieldset - %legend.fr-fieldset__legend--regular.fr-fieldset__legend{ "data-for-tiers-target" => "mandataireTitle" } - %h2.fr-h4= t('views.users.dossiers.identite.self_title') - - %legend.fr-fieldset__legend--regular.fr-fieldset__legend.hidden{ "data-for-tiers-target" => "beneficiaireTitle" } - %h2.fr-h4= t('views.users.dossiers.identite.beneficiaire_title') - - - %legend.fr-fieldset__legend--regular.fr-fieldset__legend - = t('activerecord.attributes.individual.gender') - = render EditableChamp::AsteriskMandatoryComponent.new - .fr-fieldset__element - .fr-radio-group - = individual.radio_button :gender, Individual::GENDER_FEMALE, required: true, id: "identite_champ_radio_#{Individual::GENDER_FEMALE}" - %label.fr-label{ for: "identite_champ_radio_#{Individual::GENDER_FEMALE}" } - = Individual.human_attribute_name('gender.female') - .fr-fieldset__element - .fr-radio-group - = individual.radio_button :gender, Individual::GENDER_MALE, required: true, id: "identite_champ_radio_#{Individual::GENDER_MALE}" - %label.fr-label{ for: "identite_champ_radio_#{Individual::GENDER_MALE}" } - = Individual.human_attribute_name('gender.male') - - .fr-fieldset__element.fr-fieldset__element--short-text - = render Dsfr::InputComponent.new(form: individual, attribute: :prenom, opts: { autocomplete: 'given-name' }) - - .fr-fieldset__element.fr-fieldset__element--short-text - = render Dsfr::InputComponent.new(form: individual, attribute: :nom, opts: { autocomplete: 'family-name' }) - - %fieldset.fr-fieldset{ "data-for-tiers-target" => "beneficiaireNotificationBlock" } - %legend.fr-fieldset__legend--regular.fr-fieldset__legend - = t('activerecord.attributes.individual.notification_method') - = render EditableChamp::AsteriskMandatoryComponent.new - - - Individual.notification_methods.each do |method, _| - .fr-fieldset__element - .fr-radio-group - = individual.radio_button :notification_method, method, id: "notification_method_#{method}", "data-action" => "for-tiers#toggleFieldRequirements", "data-for-tiers-target" => "notificationMethod" - %label.fr-label{ for: "notification_method_#{method}" } - = t("activerecord.attributes.individual.notification_methods.#{method}") - - - .fr-fieldset__element.fr-fieldset__element--short-text.hidden{ "data-for-tiers-target" => "email" } - = render Dsfr::InputComponent.new(form: individual, attribute: :email) - - - - if @dossier.procedure.ask_birthday? - .fr-fieldset__element - = render Dsfr::InputComponent.new(form: individual, attribute: :birthdate, input_type: :date_field, - opts: { placeholder: 'Format : AAAA-MM-JJ', max: Date.today.iso8601, min: "1900-01-01", autocomplete: 'bday' }) - - - = f.submit t('views.users.dossiers.identite.continue'), class: "fr-btn" + = render Dossiers::IndividualFormComponent.new(dossier: @dossier) diff --git a/app/views/users/dossiers/identite.turbo_stream.haml b/app/views/users/dossiers/identite.turbo_stream.haml new file mode 100644 index 000000000..b7c032d59 --- /dev/null +++ b/app/views/users/dossiers/identite.turbo_stream.haml @@ -0,0 +1 @@ += turbo_stream.replace 'identite-form', render(Dossiers::IndividualFormComponent.new(dossier: @dossier)) diff --git a/config/locales/en.yml b/config/locales/en.yml index ef9cb539a..897ee5ac0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -423,11 +423,6 @@ en: archived_dossier: "Your file will be kept %{duree_conservation_dossiers_dans_ds} more months" identite: legend: 'This file is:' - self_title: Your identity - callout_text: "You are acting as a proxy for a principal, either professionally (such as accountant, lawyer, civil servant…) or personally (family). Make sure to respect the conditions of" - callout_link: Articles 1984 and following of the Civil Code. - callout_link_title: Articles 1984 and following of the Civil Code - beneficiaire_title: "Identity of the beneficiary" identity_siret: Identify your establishment civility: Civility first_name: First Name diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 6b5dfe622..a1b2568e6 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -426,11 +426,6 @@ fr: archived_dossier: "Votre dossier sera conservé %{duree_conservation_dossiers_dans_ds} mois supplémentaire" identite: legend: 'Ce dossier est : ' - self_title: Votre identité - callout_text: Vous agissez en tant que mandataire, soit professionnellement (comme expert-comptable, avocat, agent public…) soit personnellement (famille). Assurez-vous de respecter les conditions - callout_link: des Articles 1984 et suivants du Code civil. - callout_link_title: Articles 1984 et suivants du Code civil - beneficiaire_title: Identité du bénéficiaire identity_siret: Identifier votre établissement civility: Civilité first_name: Prénom diff --git a/config/routes.rb b/config/routes.rb index caf5a3358..9256e08a2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -361,6 +361,7 @@ Rails.application.routes.draw do resources :dossiers, only: [:index, :show, :destroy, :new] do member do get 'identite' + patch 'identite' patch 'update_identite' post 'clone' get 'siret' diff --git a/spec/support/shared_examples_for_prefilled_dossier.rb b/spec/support/shared_examples_for_prefilled_dossier.rb index 4a31f3668..3ed07a18b 100644 --- a/spec/support/shared_examples_for_prefilled_dossier.rb +++ b/spec/support/shared_examples_for_prefilled_dossier.rb @@ -8,7 +8,9 @@ shared_examples "the user has got a prefilled dossier, owned by themselves" do expect(page).to have_field('Prénom', with: prenom_value) expect(page).to have_field('Nom', with: nom_value) end - click_on 'Continuer' + within "#identite-form" do + click_on 'Continuer' + end expect(page).to have_current_path(brouillon_dossier_path(dossier)) expect(page).to have_field(type_de_champ_text.libelle, with: text_value) diff --git a/spec/system/accessibilite/wcag_usager_spec.rb b/spec/system/accessibilite/wcag_usager_spec.rb index a262774c5..d8ad6fd10 100644 --- a/spec/system/accessibilite/wcag_usager_spec.rb +++ b/spec/system/accessibilite/wcag_usager_spec.rb @@ -126,7 +126,9 @@ describe 'wcag rules for usager', js: true do fill_in('Prénom', with: 'prenom') fill_in('Nom', with: 'nom') end - click_on 'Continuer' + within "#identite-form" do + click_on 'Continuer' + end expect(page).to be_axe_clean end diff --git a/spec/system/api_particulier/api_particulier_spec.rb b/spec/system/api_particulier/api_particulier_spec.rb index ecd9a1982..aa06327db 100644 --- a/spec/system/api_particulier/api_particulier_spec.rb +++ b/spec/system/api_particulier/api_particulier_spec.rb @@ -272,7 +272,9 @@ describe 'fetch API Particulier Data', js: true do fill_in('Nom', with: 'nom') end - click_button('Continuer') + within "#identite-form" do + click_on 'Continuer' + end fill_in 'Le numéro d’allocataire CAF', with: numero_allocataire fill_in 'Le code postal', with: 'wrong_code' @@ -331,7 +333,9 @@ describe 'fetch API Particulier Data', js: true do fill_in('Prénom', with: 'Georges') fill_in('Nom', with: 'Moustaki') end - click_button('Continuer') + within "#identite-form" do + click_on 'Continuer' + end fill_in "Identifiant", with: 'wrong code' @@ -405,7 +409,9 @@ describe 'fetch API Particulier Data', js: true do fill_in('Prénom', with: 'Angela Claire Louise') fill_in('Nom', with: 'Dubois') end - click_button('Continuer') + within "#identite-form" do + click_on 'Continuer' + end fill_in "INE", with: 'wrong code' @@ -469,7 +475,9 @@ describe 'fetch API Particulier Data', js: true do fill_in('Prénom', with: 'Karine') fill_in('Nom', with: 'FERRI') end - click_button('Continuer') + within "#identite-form" do + click_on 'Continuer' + end fill_in 'Le numéro fiscal', with: numero_fiscal fill_in "La référence d’avis d’imposition", with: 'wrong_code' diff --git a/spec/system/routing/rules_full_scenario_spec.rb b/spec/system/routing/rules_full_scenario_spec.rb index 4163ca067..8a94b15b4 100644 --- a/spec/system/routing/rules_full_scenario_spec.rb +++ b/spec/system/routing/rules_full_scenario_spec.rb @@ -258,7 +258,9 @@ describe 'The routing with rules', js: true do find('label', text: 'Monsieur').click fill_in('Prénom', with: 'prenom', visible: true) fill_in('Nom', with: 'Nom', visible: true) - click_button('Continuer') + within "#identite-form" do + click_button('Continuer') + end # the old system should not be present expect(page).not_to have_selector("#dossier_groupe_instructeur_id") diff --git a/spec/system/users/brouillon_spec.rb b/spec/system/users/brouillon_spec.rb index cd20a584b..97498b83b 100644 --- a/spec/system/users/brouillon_spec.rb +++ b/spec/system/users/brouillon_spec.rb @@ -665,7 +665,9 @@ describe 'The user' do find('label', text: 'Monsieur').click fill_in('Prénom', with: 'prenom', visible: true) fill_in('Nom', with: 'Nom', visible: true) - click_on 'Continuer' + within "#identite-form" do + click_on 'Continuer' + end expect(page).to have_current_path(brouillon_dossier_path(user_dossier)) end end diff --git a/spec/system/users/dossier_creation_spec.rb b/spec/system/users/dossier_creation_spec.rb index 531b0735d..a930672cd 100644 --- a/spec/system/users/dossier_creation_spec.rb +++ b/spec/system/users/dossier_creation_spec.rb @@ -28,7 +28,9 @@ describe 'Creating a new dossier:', js: true do shared_examples 'the user can create a new draft' do it do - click_button('Continuer') + within "#identite-form" do + click_button('Continuer') + end expect(page).to have_current_path(brouillon_dossier_path(procedure.dossiers.last)) expect(user.dossiers.first.individual.birthdate).to eq(expected_birthday) @@ -58,7 +60,6 @@ describe 'Creating a new dossier:', js: true do context 'when individual fill dossier for a tiers' do it 'completes the form with email notification method selected' do find('label', text: 'Pour un bénéficiaire : membre de la famille, proche, mandant, professionnel en charge du suivi du dossier…').click - within('.mandataire-infos') do fill_in('Prénom', with: 'John') fill_in('Nom', with: 'Doe') @@ -73,7 +74,15 @@ describe 'Creating a new dossier:', js: true do find('label', text: 'Par e-mail').click fill_in('dossier_individual_attributes_email', with: 'prenom.nom@mail.com') - click_button('Continuer') + find('label', text: 'Monsieur').click # force focus out + within "#identite-form" do + within '.suspect-email' do + expect(page).to have_content("Information : Voulez-vous dire ?") + click_button("Oui") + end + click_button("Continuer") + end + expect(procedure.dossiers.last.individual.notification_method == 'email') expect(page).to have_current_path(brouillon_dossier_path(procedure.dossiers.last)) end @@ -93,7 +102,10 @@ describe 'Creating a new dossier:', js: true do end find('label', text: 'Pas de notification').click - click_button('Continuer') + within "#identite-form" do + click_button('Continuer') + end + expect(procedure.dossiers.last.individual.notification_method.empty?) expect(page).to have_current_path(brouillon_dossier_path(procedure.dossiers.last)) end diff --git a/spec/system/users/dropdown_spec.rb b/spec/system/users/dropdown_spec.rb index 25645c02e..987570962 100644 --- a/spec/system/users/dropdown_spec.rb +++ b/spec/system/users/dropdown_spec.rb @@ -79,7 +79,9 @@ describe 'dropdown list with other option activated', js: true do fill_in('Prénom', with: 'prenom') fill_in('Nom', with: 'nom') end - click_on 'Continuer' + within "#identite-form" do + click_on 'Continuer' + end expect(page).to have_current_path(brouillon_dossier_path(user_dossier)) end end diff --git a/spec/system/users/linked_dropdown_spec.rb b/spec/system/users/linked_dropdown_spec.rb index 83b8fd459..c6f3b7cac 100644 --- a/spec/system/users/linked_dropdown_spec.rb +++ b/spec/system/users/linked_dropdown_spec.rb @@ -85,7 +85,9 @@ describe 'linked dropdown lists' do fill_in('Prénom', with: 'prenom') fill_in('Nom', with: 'nom') end - click_on 'Continuer' + within "#identite-form" do + click_on 'Continuer' + end expect(page).to have_current_path(brouillon_dossier_path(user_dossier)) end end From f6046d801fa6932c2e9d03e198f3c917527e32b0 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 17 Apr 2024 07:04:15 +0200 Subject: [PATCH 0110/1532] feat(individual_form_component): add missing required on notification method --- .../individual_form_component.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/dossiers/individual_form_component/individual_form_component.html.haml b/app/components/dossiers/individual_form_component/individual_form_component.html.haml index ceab537b3..97b91976c 100644 --- a/app/components/dossiers/individual_form_component/individual_form_component.html.haml +++ b/app/components/dossiers/individual_form_component/individual_form_component.html.haml @@ -65,7 +65,7 @@ - Individual.notification_methods.each do |method, _| .fr-fieldset__element .fr-radio-group - = individual.radio_button :notification_method, method, id: "notification_method_#{method}", "data-action" => "for-tiers#toggleEmailInput", "data-for-tiers-target" => "notificationMethod" + = individual.radio_button :notification_method, method, required: true, id: "notification_method_#{method}", "data-action" => "for-tiers#toggleEmailInput", "data-for-tiers-target" => "notificationMethod" %label.fr-label{ for: "notification_method_#{method}" } = t("activerecord.attributes.individual.notification_methods.#{method}") From b40cc2a54e6bdc310a45044be9e8a9aa9e55daa5 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 17 Apr 2024 07:04:51 +0200 Subject: [PATCH 0111/1532] feat(individual.validation): add missing strict_email validation --- app/models/individual.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/individual.rb b/app/models/individual.rb index c3d92b9eb..76257a20c 100644 --- a/app/models/individual.rb +++ b/app/models/individual.rb @@ -15,7 +15,7 @@ class Individual < ApplicationRecord if: -> { dossier.for_tiers? }, on: :update - validates :email, presence: true, if: -> { dossier.for_tiers? && self.email? }, on: :update + validates :email, strict_email: true, presence: true, if: -> { dossier.for_tiers? && self.email? }, on: :update GENDER_MALE = "M." GENDER_FEMALE = 'Mme' From 84c1a485e592be07cd0d05e905aca43a7f3e0af8 Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 18 Apr 2024 11:28:49 +0200 Subject: [PATCH 0112/1532] feat(Procedure.for_tiers_enabled): allow super admin to disable for_tiers --- app/dashboards/procedure_dashboard.rb | 3 ++ app/views/users/dossiers/identite.html.haml | 39 ++++++++--------- config/locales/models/procedure/en.yml | 1 + config/locales/models/procedure/fr.yml | 1 + ...d_column_for_tiers_enabled_to_procedure.rb | 5 +++ db/schema.rb | 3 +- .../users/dossiers/identite.html.haml_spec.rb | 42 +++++++++++++++---- 7 files changed, 65 insertions(+), 29 deletions(-) create mode 100644 db/migrate/20240417053843_add_column_for_tiers_enabled_to_procedure.rb diff --git a/app/dashboards/procedure_dashboard.rb b/app/dashboards/procedure_dashboard.rb index b7d889d37..134208df4 100644 --- a/app/dashboards/procedure_dashboard.rb +++ b/app/dashboards/procedure_dashboard.rb @@ -46,6 +46,7 @@ class ProcedureDashboard < Administrate::BaseDashboard max_duree_conservation_dossiers_dans_ds: Field::Number, estimated_duration_visible: Field::Boolean, piece_justificative_multiple: Field::Boolean, + for_tiers_enabled: Field::Boolean, replaced_by_procedure_id: Field::String, tags: Field::Text, template: Field::Boolean @@ -109,6 +110,7 @@ class ProcedureDashboard < Administrate::BaseDashboard :max_duree_conservation_dossiers_dans_ds, :estimated_duration_visible, :piece_justificative_multiple, + :for_tiers_enabled, :replaced_by_procedure_id ].freeze @@ -121,6 +123,7 @@ class ProcedureDashboard < Administrate::BaseDashboard :max_duree_conservation_dossiers_dans_ds, :estimated_duration_visible, :piece_justificative_multiple, + :for_tiers_enabled, :replaced_by_procedure_id ].freeze diff --git a/app/views/users/dossiers/identite.html.haml b/app/views/users/dossiers/identite.html.haml index afb250715..8f79877b4 100644 --- a/app/views/users/dossiers/identite.html.haml +++ b/app/views/users/dossiers/identite.html.haml @@ -3,27 +3,28 @@ = render partial: "shared/dossiers/submit_is_over", locals: { dossier: @dossier } - if !dossier_submission_is_closed?(@dossier) - = form_for @dossier, url: identite_dossier_path(@dossier), method: :patch, html: { class: "form" }, data: {turbo: true, controller: :autosubmit} do |f| + - if @dossier.procedure.for_tiers_enabled? + = form_for @dossier, url: identite_dossier_path(@dossier), method: :patch, html: { class: "form" }, data: {turbo: true, controller: :autosubmit} do |f| - %fieldset#radio-rich-hint.fr-fieldset{ "aria-labelledby" => "radio-rich-hint-legend radio-rich-hint-messages" } - %legend#radio-rich-hint-legend.fr-fieldset__legend--regular.fr-fieldset__legend - = t('views.users.dossiers.identite.legend') + %fieldset#radio-rich-hint.fr-fieldset{ "aria-labelledby" => "radio-rich-hint-legend radio-rich-hint-messages" } + %legend#radio-rich-hint-legend.fr-fieldset__legend--regular.fr-fieldset__legend + = t('views.users.dossiers.identite.legend') - .fr-fieldset__element - .fr-radio-group.fr-radio-rich - = f.radio_button :for_tiers, false, required: true, id: "radio-self-manage" - %label.fr-label{ for: "radio-self-manage" } - = t('activerecord.attributes.dossier.for_tiers.false') - .fr-radio-rich__img - %span.fr-icon-user-fill - .fr-fieldset__element - .fr-radio-group.fr-radio-rich - = f.radio_button :for_tiers, true, required: true, id: "radio-tiers-manage" - %label.fr-label{ for: "radio-tiers-manage" } - = t('activerecord.attributes.dossier.for_tiers.true') - .fr-radio-rich__img - %span.fr-icon-parent-fill + .fr-fieldset__element + .fr-radio-group.fr-radio-rich + = f.radio_button :for_tiers, false, required: true, id: "radio-self-manage" + %label.fr-label{ for: "radio-self-manage" } + = t('activerecord.attributes.dossier.for_tiers.false') + .fr-radio-rich__img + %span.fr-icon-user-fill + .fr-fieldset__element + .fr-radio-group.fr-radio-rich + = f.radio_button :for_tiers, true, required: true, id: "radio-tiers-manage" + %label.fr-label{ for: "radio-tiers-manage" } + = t('activerecord.attributes.dossier.for_tiers.true') + .fr-radio-rich__img + %span.fr-icon-parent-fill - = f.submit t('views.users.dossiers.identite.continue'), class: 'visually-hidden' + = f.submit t('views.users.dossiers.identite.continue'), class: 'visually-hidden' = render Dossiers::IndividualFormComponent.new(dossier: @dossier) diff --git a/config/locales/models/procedure/en.yml b/config/locales/models/procedure/en.yml index 19aca8bdf..2f1d1c8b8 100644 --- a/config/locales/models/procedure/en.yml +++ b/config/locales/models/procedure/en.yml @@ -37,6 +37,7 @@ en: lien_dpo: Link or email to contact the data protection officer (DPO) duree_conservation_dossiers_dans_ds: Duration files will be kept max_duree_conservation_dossiers_dans_ds: Max duration allowed to keep files + for_tiers_enabled: Enable a third party to submit a file aasm_state: brouillon: Draft publiee: Published diff --git a/config/locales/models/procedure/fr.yml b/config/locales/models/procedure/fr.yml index cdf2309b8..78e2bcf26 100644 --- a/config/locales/models/procedure/fr.yml +++ b/config/locales/models/procedure/fr.yml @@ -20,6 +20,7 @@ fr: organisation: Organisme duree_conservation_dossiers_dans_ds: Durée de conservation des dossiers max_duree_conservation_dossiers_dans_ds: Durée maximale de conservation des dossiers (autorisée par un super admin) + for_tiers_enabled: Activer le dépot par un tiers id: Id libelle: Titre de la démarche description: Quel est l’objet de la démarche ? diff --git a/db/migrate/20240417053843_add_column_for_tiers_enabled_to_procedure.rb b/db/migrate/20240417053843_add_column_for_tiers_enabled_to_procedure.rb new file mode 100644 index 000000000..c2e40a0d4 --- /dev/null +++ b/db/migrate/20240417053843_add_column_for_tiers_enabled_to_procedure.rb @@ -0,0 +1,5 @@ +class AddColumnForTiersEnabledToProcedure < ActiveRecord::Migration[7.0] + def change + add_column :procedures, :for_tiers_enabled, :boolean, default: true, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 1caaf322c..b936bf6b3 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[7.0].define(version: 2024_04_16_062900) do +ActiveRecord::Schema[7.0].define(version: 2024_04_17_053843) do # These are extensions that must be enabled in order to support this database enable_extension "pg_buffercache" enable_extension "pg_stat_statements" @@ -894,6 +894,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_04_16_062900) do t.boolean "euro_flag", default: false t.boolean "experts_require_administrateur_invitation", default: false t.boolean "for_individual", default: false + t.boolean "for_tiers_enabled", default: true, null: false t.datetime "hidden_at", precision: nil t.datetime "hidden_at_as_template", precision: nil t.boolean "instructeurs_self_management_enabled", default: false diff --git a/spec/views/users/dossiers/identite.html.haml_spec.rb b/spec/views/users/dossiers/identite.html.haml_spec.rb index 7c065f980..0ca066eee 100644 --- a/spec/views/users/dossiers/identite.html.haml_spec.rb +++ b/spec/views/users/dossiers/identite.html.haml_spec.rb @@ -1,5 +1,4 @@ describe 'users/dossiers/identite', type: :view do - let(:procedure) { create(:simple_procedure, :for_individual) } let(:dossier) { create(:dossier, :with_service, state: Dossier.states.fetch(:brouillon), procedure: procedure) } before do @@ -9,18 +8,43 @@ describe 'users/dossiers/identite', type: :view do subject! { render } - it 'has identity fields' do - within('.individual-infos') do - expect(rendered).to have_field(id: 'Prenom') - expect(rendered).to have_field(id: 'Nom') + context 'when procedure has for_tiers_enabled' do + let(:procedure) { create(:simple_procedure, :for_individual) } + + it 'has choice for you or a tiers' do + expect(rendered).to have_content "Pour vous" + expect(rendered).to have_content "Pour un bénéficiaire : membre de la famille, proche, mandant, professionnel en charge du suivi du dossier…" + end + + it 'has identity fields' do + within('.individual-infos') do + expect(rendered).to have_field(id: 'Prenom') + expect(rendered).to have_field(id: 'Nom') + end + end + + context 'when the demarche asks for the birthdate' do + let(:procedure) { create(:simple_procedure, for_individual: true, ask_birthday: true) } + + it 'has a birthday field' do + expect(rendered).to have_field('Date de naissance') + end end end - context 'when the demarche asks for the birthdate' do - let(:procedure) { create(:simple_procedure, for_individual: true, ask_birthday: true) } + context 'when procedure has for_tiers_enabled' do + let(:procedure) { create(:simple_procedure, :for_individual, for_tiers_enabled: false) } - it 'has a birthday field' do - expect(rendered).to have_field('Date de naissance') + it 'has choice for you or a tiers' do + expect(rendered).not_to have_content "Pour vous" + expect(rendered).not_to have_content "Pour un bénéficiaire : membre de la famille, proche, mandant, professionnel en charge du suivi du dossier…" + end + + it 'has identity fields' do + within('.individual-infos') do + expect(rendered).to have_field(id: 'Prenom') + expect(rendered).to have_field(id: 'Nom') + end end end end From 300264bf1070be9a67d3fa9e5ffe39a59c088bba Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 6 May 2024 13:12:00 +0200 Subject: [PATCH 0113/1532] fix: procedure closed details link on 'Plus d'informations' words --- app/views/users/dossiers/_dossiers_list.html.haml | 6 +++--- config/locales/en.yml | 9 ++++----- config/locales/fr.yml | 8 ++++---- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/app/views/users/dossiers/_dossiers_list.html.haml b/app/views/users/dossiers/_dossiers_list.html.haml index 556f6afc6..86293b73e 100644 --- a/app/views/users/dossiers/_dossiers_list.html.haml +++ b/app/views/users/dossiers/_dossiers_list.html.haml @@ -57,15 +57,15 @@ - if dossier.brouillon? && dossier.procedure.closing_reason_internal_procedure? = t('views.users.dossiers.dossiers_list.procedure_closed.brouillon.internal_procedure_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.procedure'), commencer_path(dossier.procedure.replaced_by_procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.closing_details'))).html_safe) - elsif dossier.brouillon? && dossier.procedure.closing_reason_other? - = t('views.users.dossiers.dossiers_list.procedure_closed.brouillon.other_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.here'), closing_details_path(dossier.procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.closing_details'))).html_safe) + = t('views.users.dossiers.dossiers_list.procedure_closed.brouillon.other_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.more_details'), closing_details_path(dossier.procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.closing_details'))).html_safe) - elsif dossier.en_construction_ou_instruction? && dossier.procedure.closing_reason_internal_procedure? = t('views.users.dossiers.dossiers_list.procedure_closed.en_cours.internal_procedure_html') - elsif dossier.en_construction_ou_instruction? && dossier.procedure.closing_reason_other? - = t('views.users.dossiers.dossiers_list.procedure_closed.en_cours.other_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.here'), closing_details_path(dossier.procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.closing_details'))).html_safe) + = t('views.users.dossiers.dossiers_list.procedure_closed.en_cours.other_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.more_details'), closing_details_path(dossier.procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.closing_details'))).html_safe) - elsif dossier.termine? && dossier.procedure.closing_reason_internal_procedure? = t('views.users.dossiers.dossiers_list.procedure_closed.termine.internal_procedure_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.this_procedure'), commencer_path(dossier.procedure.replaced_by_procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.closing_details'))).html_safe) - elsif dossier.termine? && dossier.procedure.closing_reason_other? - = t('views.users.dossiers.dossiers_list.procedure_closed.termine.other_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.here'), closing_details_path(dossier.procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.closing_details'))).html_safe) + = t('views.users.dossiers.dossiers_list.procedure_closed.termine.other_html', link: link_to(t('views.users.dossiers.dossiers_list.procedure_closed.more_details'), closing_details_path(dossier.procedure.path), **external_link_attributes, title: new_tab_suffix(t('views.users.dossiers.dossiers_list.procedure_closed.closing_details'))).html_safe) - if dossier.pending_correction? = render Dsfr::AlertComponent.new(state: :warning, size: :sm, extra_class_names: "fr-mb-2w") do |c| diff --git a/config/locales/en.yml b/config/locales/en.yml index ef9cb539a..f6c424ccb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -504,7 +504,6 @@ en: no_result_reset_search: Reset search no_result_text_with_filter: found with selected filters no_result_reset_filter: Reset filters - procedure_closed: This procedure has been closed, you will not be able to submit a file again from the procedure link, contact your administration for more information. pending_correction_html: "This file is waiting for your corrections. Consult the changes to be made in the messaging system." depose_at: First submission on %{date} created_at: Created at %{date} @@ -515,15 +514,15 @@ en: procedure_closed: brouillon: internal_procedure_html: This procedure is closed, you cannot submit this file. We invite you to submit a new one on the %{link} which replaces it - other_html: This process is closed, you cannot submit this file. More information %{link} + other_html: This process is closed, you cannot submit this file. %{link} en_cours: internal_procedure_html: This procedure is closed. Your file has been submitted and can be investigated by the administration - other_html: This procedure is closed. Your file has been submitted and can be processed by the administration. More information %{link} + other_html: This procedure is closed. Your file has been submitted and can be processed by the administration. %{link} termine: internal_procedure_html: This procedure is closed and replaced by %{link}. Your file has been processed by the administration - other_html: This process is closed, you cannot submit a new file. More information %{link} + other_html: This process is closed, you cannot submit a new file. %{link} closing_details: Details on the closed procedure - here: here + more_details: More information procedure: procedure this_procedure: this procedure transfers: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 6b5dfe622..8272980ff 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -510,15 +510,15 @@ fr: procedure_closed: brouillon: internal_procedure_html: Cette démarche est close, vous ne pouvez pas déposer ce dossier. Nous vous invitons à en déposer un nouveau sur la %{link} qui la remplace - other_html: Cette démarche est close, vous ne pouvez pas déposer ce dossier. Plus d’informations %{link} + other_html: Cette démarche est close, vous ne pouvez pas déposer ce dossier. %{link} en_cours: internal_procedure_html: Cette démarche est close. Votre dossier est bien déposé et peut être instruit par l’administration - other_html: Cette démarche est close. Votre dossier est bien déposé et peut être instruit par l'administration. Vous ne pouvez pas déposer de nouveau dossier. Plus d’informations %{link} + other_html: Cette démarche est close. Votre dossier est bien déposé et peut être instruit par l'administration. Vous ne pouvez pas déposer de nouveau dossier. %{link} termine: internal_procedure_html: Cette démarche est close et remplacée par %{link}. Votre dossier a été traité par l'administration - other_html: Cette démarche est close, vous ne pourrez pas déposer de nouveau dossier à partir du lien de la démarche. Plus d’informations %{link} + other_html: Cette démarche est close, vous ne pourrez pas déposer de nouveau dossier à partir du lien de la démarche. %{link} closing_details: Détails sur la fermeture de la démarche - here: ici + more_details: Plus d’informations procedure: démarche this_procedure: cette démarche pending_correction_html: "Ce dossier attend vos corrections. Consultez les modifications à apporter dans la messagerie." From 2e763d5e92fc25fb47e3f48bdbd01898238cf546 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 3 Apr 2024 10:21:50 +0200 Subject: [PATCH 0114/1532] perf(search): ignore search_terms columns, use raw sql instead --- app/jobs/dossier_update_search_terms_job.rb | 1 - app/models/concerns/dossier_clone_concern.rb | 1 - .../concerns/dossier_searchable_concern.rb | 10 ++-- app/models/dossier.rb | 2 +- spec/controllers/recherche_controller_spec.rb | 2 + .../dossier_update_search_terms_job_spec.rb | 22 +++++--- .../concerns/dossier_clone_concern_spec.rb | 12 ++++- .../dossier_searchable_concern_spec.rb | 21 +++++--- spec/models/dossier_spec.rb | 4 +- spec/services/dossier_search_service_spec.rb | 54 +++++++++++++------ .../routing/rules_full_scenario_spec.rb | 2 + spec/system/users/invite_spec.rb | 1 + spec/system/users/list_dossiers_spec.rb | 1 + 13 files changed, 92 insertions(+), 41 deletions(-) diff --git a/app/jobs/dossier_update_search_terms_job.rb b/app/jobs/dossier_update_search_terms_job.rb index 8b997e3f5..5de7c61c8 100644 --- a/app/jobs/dossier_update_search_terms_job.rb +++ b/app/jobs/dossier_update_search_terms_job.rb @@ -3,6 +3,5 @@ class DossierUpdateSearchTermsJob < ApplicationJob def perform(dossier) dossier.update_search_terms - dossier.save!(touch: false) end end diff --git a/app/models/concerns/dossier_clone_concern.rb b/app/models/concerns/dossier_clone_concern.rb index 537afd8d4..86a135ae7 100644 --- a/app/models/concerns/dossier_clone_concern.rb +++ b/app/models/concerns/dossier_clone_concern.rb @@ -71,7 +71,6 @@ module DossierCloneConcern touch(:last_champ_updated_at) end reload - update_search_terms_later editing_fork.destroy_editing_fork! end diff --git a/app/models/concerns/dossier_searchable_concern.rb b/app/models/concerns/dossier_searchable_concern.rb index 1bb9ef424..f810e456c 100644 --- a/app/models/concerns/dossier_searchable_concern.rb +++ b/app/models/concerns/dossier_searchable_concern.rb @@ -2,10 +2,10 @@ module DossierSearchableConcern extend ActiveSupport::Concern included do - before_save :update_search_terms + after_commit :update_search_terms_later def update_search_terms - self.search_terms = [ + search_terms = [ user&.email, *champs_public.flat_map(&:search_terms), *etablissement&.search_terms, @@ -13,7 +13,11 @@ module DossierSearchableConcern individual&.prenom ].compact_blank.join(' ') - self.private_search_terms = champs_private.flat_map(&:search_terms).compact_blank.join(' ') + private_search_terms = champs_private.flat_map(&:search_terms).compact_blank.join(' ') + + sql = "UPDATE dossiers SET search_terms = :search_terms, private_search_terms = :private_search_terms WHERE id = :id" + sanitized_sql = self.class.sanitize_sql_array([sql, search_terms:, private_search_terms:, id:]) + self.class.connection.execute(sanitized_sql) end def update_search_terms_later diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 3212a4f80..5d3c1a810 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -1,5 +1,5 @@ class Dossier < ApplicationRecord - self.ignored_columns += [:re_instructed_at] + self.ignored_columns += [:re_instructed_at, :search_terms, :private_search_terms] include DossierCloneConcern include DossierCorrectableConcern diff --git a/spec/controllers/recherche_controller_spec.rb b/spec/controllers/recherche_controller_spec.rb index 7048c2ccb..91bfe25fe 100644 --- a/spec/controllers/recherche_controller_spec.rb +++ b/spec/controllers/recherche_controller_spec.rb @@ -27,6 +27,8 @@ describe RechercheController, type: :controller do dossier_with_expert.champs_private[0].value = "Dossier B is incomplete" dossier_with_expert.champs_private[1].value = "Dossier B is invalid" dossier_with_expert.save! + + perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) end describe 'GET #index' do diff --git a/spec/jobs/dossier_update_search_terms_job_spec.rb b/spec/jobs/dossier_update_search_terms_job_spec.rb index 822826c88..3dfda131f 100644 --- a/spec/jobs/dossier_update_search_terms_job_spec.rb +++ b/spec/jobs/dossier_update_search_terms_job_spec.rb @@ -1,15 +1,21 @@ RSpec.describe DossierUpdateSearchTermsJob, type: :job do let(:dossier) { create(:dossier) } - let(:champ_public) { dossier.champs_public.first } - let(:champ_private) { dossier.champs_private.first } - subject(:perform_job) { described_class.perform_now(dossier) } + subject(:perform_job) { described_class.perform_now(dossier.reload) } - context 'with an update' do - before do - create(:champ_text, dossier: dossier, value: "un nouveau champ") - end + before do + create(:champ_text, dossier:, value: "un nouveau champ") + create(:champ_text, dossier:, value: "private champ", private: true) + end - it { expect { perform_job }.to change { dossier.reload.search_terms }.to(/un nouveau champ/) } + it "update search terms columns" do + perform_job + + sql = "SELECT search_terms, private_search_terms FROM dossiers WHERE id = :id" + sanitized_sql = Dossier.sanitize_sql_array([sql, id: dossier.id]) + result = Dossier.connection.execute(sanitized_sql).first + + expect(result['search_terms']).to match(/un nouveau champ/) + expect(result['private_search_terms']).to match(/private champ/) end end diff --git a/spec/models/concerns/dossier_clone_concern_spec.rb b/spec/models/concerns/dossier_clone_concern_spec.rb index 064d14284..f3d5ffa1c 100644 --- a/spec/models/concerns/dossier_clone_concern_spec.rb +++ b/spec/models/concerns/dossier_clone_concern_spec.rb @@ -45,11 +45,19 @@ RSpec.describe DossierCloneConcern do it { expect(new_dossier.last_champ_updated_at).to be_nil } it { expect(new_dossier.last_commentaire_updated_at).to be_nil } it { expect(new_dossier.motivation).to be_nil } - it { expect(new_dossier.private_search_terms).to eq("") } it { expect(new_dossier.processed_at).to be_nil } - it { expect(new_dossier.search_terms).to match(dossier.user.email) } it { expect(new_dossier.termine_close_to_expiration_notice_sent_at).to be_nil } it { expect(new_dossier.dossier_transfer_id).to be_nil } + + it "update search terms" do + new_dossier + perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) + sql = "SELECT search_terms, private_search_terms FROM dossiers where id = :id" + result = Dossier.connection.execute(Dossier.sanitize_sql_array([sql, id: new_dossier.id])).first + + expect(result["search_terms"]).to match(dossier.user.email) + expect(result["private_search_terms"]).to eq("") + end end context 'copies some attributes' do diff --git a/spec/models/concerns/dossier_searchable_concern_spec.rb b/spec/models/concerns/dossier_searchable_concern_spec.rb index a13f78d8a..58b6fc25e 100644 --- a/spec/models/concerns/dossier_searchable_concern_spec.rb +++ b/spec/models/concerns/dossier_searchable_concern_spec.rb @@ -13,15 +13,23 @@ describe DossierSearchableConcern do let(:france_connect_information) { build(:france_connect_information, given_name: 'Chris', family_name: 'Harrisson') } let(:user) { build(:user, france_connect_informations: [france_connect_information]) } + let(:result) do + Dossier.connection.execute( + Dossier.sanitize_sql_array(["SELECT search_terms, private_search_terms FROM dossiers WHERE id = :id", id: dossier.id]) + ).first + end + before do champ_public.update_attribute(:value, "champ public") champ_private.update_attribute(:value, "champ privé") - dossier.update_search_terms + perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) end - it { expect(dossier.search_terms).to eq("#{user.email} champ public #{etablissement.entreprise_siren} #{etablissement.entreprise_numero_tva_intracommunautaire} #{etablissement.entreprise_forme_juridique} #{etablissement.entreprise_forme_juridique_code} #{etablissement.entreprise_nom_commercial} #{etablissement.entreprise_raison_sociale} #{etablissement.entreprise_siret_siege_social} #{etablissement.entreprise_nom} #{etablissement.entreprise_prenom} #{etablissement.association_rna} #{etablissement.association_titre} #{etablissement.association_objet} #{etablissement.siret} #{etablissement.naf} #{etablissement.libelle_naf} #{etablissement.adresse} #{etablissement.code_postal} #{etablissement.localite} #{etablissement.code_insee_localite}") } - it { expect(dossier.private_search_terms).to eq('champ privé') } + it "update columns" do + expect(result["search_terms"]).to eq("#{user.email} champ public #{etablissement.entreprise_siren} #{etablissement.entreprise_numero_tva_intracommunautaire} #{etablissement.entreprise_forme_juridique} #{etablissement.entreprise_forme_juridique_code} #{etablissement.entreprise_nom_commercial} #{etablissement.entreprise_raison_sociale} #{etablissement.entreprise_siret_siege_social} #{etablissement.entreprise_nom} #{etablissement.entreprise_prenom} #{etablissement.association_rna} #{etablissement.association_titre} #{etablissement.association_objet} #{etablissement.siret} #{etablissement.naf} #{etablissement.libelle_naf} #{etablissement.adresse} #{etablissement.code_postal} #{etablissement.localite} #{etablissement.code_insee_localite}") + expect(result["private_search_terms"]).to eq('champ privé') + end context 'with an update' do before do @@ -31,11 +39,12 @@ describe DossierSearchableConcern do ) perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) - dossier.reload end - it { expect(dossier.search_terms).to include('nouvelle valeur publique') } - it { expect(dossier.private_search_terms).to include('nouvelle valeur privee') } + it "update columns" do + expect(result["search_terms"]).to include('nouvelle valeur publique') + expect(result["private_search_terms"]).to include('nouvelle valeur privee') + end end end end diff --git a/spec/models/dossier_spec.rb b/spec/models/dossier_spec.rb index f1fbe3ab5..8af8b7891 100644 --- a/spec/models/dossier_spec.rb +++ b/spec/models/dossier_spec.rb @@ -899,7 +899,7 @@ describe Dossier, type: :model do dossier.procedure.update_column(:web_hook_url, '/webhook.json') expect { - dossier.update_column(:search_terms, 'bonjour') + dossier.update_column(:conservation_extension, 'P1W') }.to_not have_enqueued_job(WebHookJob) expect { @@ -907,7 +907,7 @@ describe Dossier, type: :model do }.to have_enqueued_job(WebHookJob).with(dossier.procedure.id, dossier.id, 'en_construction', anything) expect { - dossier.update_column(:search_terms, 'bonjour2') + dossier.update_column(:conservation_extension, 'P2W') }.to_not have_enqueued_job(WebHookJob) expect { diff --git a/spec/services/dossier_search_service_spec.rb b/spec/services/dossier_search_service_spec.rb index 6528847fe..0f2baeec0 100644 --- a/spec/services/dossier_search_service_spec.rb +++ b/spec/services/dossier_search_service_spec.rb @@ -15,23 +15,33 @@ describe DossierSearchService do before do instructeur_1.assign_to_procedure(procedure_1) instructeur_2.assign_to_procedure(procedure_2) + + # create dossier before performing jobs + # because let!() syntax is executed after "before" callback + dossier_0 + dossier_1 + dossier_2 + dossier_3 + dossier_archived + + perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) end let(:procedure_1) { create(:procedure, :published, administrateur: administrateur_1) } let(:procedure_2) { create(:procedure, :published, administrateur: administrateur_2) } - let!(:dossier_0) { create(:dossier, state: Dossier.states.fetch(:brouillon), procedure: procedure_1, user: create(:user, email: 'brouillon@clap.fr')) } + let(:dossier_0) { create(:dossier, state: Dossier.states.fetch(:brouillon), procedure: procedure_1, user: create(:user, email: 'brouillon@clap.fr')) } - let!(:etablissement_1) { create(:etablissement, entreprise_raison_sociale: 'OCTO Academy', siret: '41636169600051') } - let!(:dossier_1) { create(:dossier, :en_construction, procedure: procedure_1, user: create(:user, email: 'contact@test.com'), etablissement: etablissement_1) } + let(:etablissement_1) { create(:etablissement, entreprise_raison_sociale: 'OCTO Academy', siret: '41636169600051') } + let(:dossier_1) { create(:dossier, :en_construction, procedure: procedure_1, user: create(:user, email: 'contact@test.com'), etablissement: etablissement_1) } - let!(:etablissement_2) { create(:etablissement, entreprise_raison_sociale: 'Plop octo', siret: '41816602300012') } - let!(:dossier_2) { create(:dossier, :en_construction, procedure: procedure_1, user: create(:user, email: 'plop@gmail.com'), etablissement: etablissement_2) } + let(:etablissement_2) { create(:etablissement, entreprise_raison_sociale: 'Plop octo', siret: '41816602300012') } + let(:dossier_2) { create(:dossier, :en_construction, procedure: procedure_1, user: create(:user, email: 'plop@gmail.com'), etablissement: etablissement_2) } - let!(:etablissement_3) { create(:etablissement, entreprise_raison_sociale: 'OCTO Technology', siret: '41816609600051') } - let!(:dossier_3) { create(:dossier, :en_construction, procedure: procedure_2, user: create(:user, email: 'peace@clap.fr'), etablissement: etablissement_3) } + let(:etablissement_3) { create(:etablissement, entreprise_raison_sociale: 'OCTO Technology', siret: '41816609600051') } + let(:dossier_3) { create(:dossier, :en_construction, procedure: procedure_2, user: create(:user, email: 'peace@clap.fr'), etablissement: etablissement_3) } - let!(:dossier_archived) { create(:dossier, :en_construction, procedure: procedure_1, archived: true, user: create(:user, email: 'archived@clap.fr')) } + let(:dossier_archived) { create(:dossier, :en_construction, procedure: procedure_1, archived: true, user: create(:user, email: 'archived@clap.fr')) } describe 'search is empty' do let(:terms) { '' } @@ -99,6 +109,16 @@ describe DossierSearchService do describe '#matching_dossiers_for_user' do subject { liste_dossiers } + before do + dossier_0 + dossier_0b + dossier_1 + dossier_2 + dossier_3 + dossier_archived + perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) + end + let(:liste_dossiers) do described_class.matching_dossiers_for_user(terms, user_1) end @@ -109,19 +129,19 @@ describe DossierSearchService do let(:procedure_1) { create(:procedure, :published) } let(:procedure_2) { create(:procedure, :published) } - let!(:dossier_0) { create(:dossier, state: Dossier.states.fetch(:brouillon), procedure: procedure_1, user: user_1) } - let!(:dossier_0b) { create(:dossier, state: Dossier.states.fetch(:brouillon), procedure: procedure_1, user: user_2) } + let(:dossier_0) { create(:dossier, state: Dossier.states.fetch(:brouillon), procedure: procedure_1, user: user_1) } + let(:dossier_0b) { create(:dossier, state: Dossier.states.fetch(:brouillon), procedure: procedure_1, user: user_2) } - let!(:etablissement_1) { create(:etablissement, entreprise_raison_sociale: 'OCTO Academy', siret: '41636169600051') } - let!(:dossier_1) { create(:dossier, state: Dossier.states.fetch(:en_construction), procedure: procedure_1, user: user_1, etablissement: etablissement_1) } + let(:etablissement_1) { create(:etablissement, entreprise_raison_sociale: 'OCTO Academy', siret: '41636169600051') } + let(:dossier_1) { create(:dossier, state: Dossier.states.fetch(:en_construction), procedure: procedure_1, user: user_1, etablissement: etablissement_1) } - let!(:etablissement_2) { create(:etablissement, entreprise_raison_sociale: 'Plop octo', siret: '41816602300012') } - let!(:dossier_2) { create(:dossier, state: Dossier.states.fetch(:en_construction), procedure: procedure_1, user: user_1, etablissement: etablissement_2) } + let(:etablissement_2) { create(:etablissement, entreprise_raison_sociale: 'Plop octo', siret: '41816602300012') } + let(:dossier_2) { create(:dossier, state: Dossier.states.fetch(:en_construction), procedure: procedure_1, user: user_1, etablissement: etablissement_2) } - let!(:etablissement_3) { create(:etablissement, entreprise_raison_sociale: 'OCTO Technology', siret: '41816609600051') } - let!(:dossier_3) { create(:dossier, state: Dossier.states.fetch(:en_construction), procedure: procedure_2, user: user_1, etablissement: etablissement_3) } + let(:etablissement_3) { create(:etablissement, entreprise_raison_sociale: 'OCTO Technology', siret: '41816609600051') } + let(:dossier_3) { create(:dossier, state: Dossier.states.fetch(:en_construction), procedure: procedure_2, user: user_1, etablissement: etablissement_3) } - let!(:dossier_archived) { create(:dossier, state: Dossier.states.fetch(:en_construction), procedure: procedure_1, archived: true, user: user_1) } + let(:dossier_archived) { create(:dossier, state: Dossier.states.fetch(:en_construction), procedure: procedure_1, archived: true, user: user_1) } describe 'search is empty' do let(:terms) { '' } diff --git a/spec/system/routing/rules_full_scenario_spec.rb b/spec/system/routing/rules_full_scenario_spec.rb index 4163ca067..d72fd0c46 100644 --- a/spec/system/routing/rules_full_scenario_spec.rb +++ b/spec/system/routing/rules_full_scenario_spec.rb @@ -134,6 +134,8 @@ describe 'The routing with rules', js: true do user_send_dossier(litteraire_user, 'littéraire') user_send_dossier(artistique_user, 'artistique') + perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) + # the litteraires instructeurs only manage the litteraires dossiers register_instructeur_and_log_in(victor.email) click_on procedure.libelle diff --git a/spec/system/users/invite_spec.rb b/spec/system/users/invite_spec.rb index ea83d9789..2d681a49b 100644 --- a/spec/system/users/invite_spec.rb +++ b/spec/system/users/invite_spec.rb @@ -162,6 +162,7 @@ describe 'Invitations' do before do navigate_to_invited_dossier(invite) visit dossiers_path + perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) end it "can search by id and it displays the dossier" do diff --git a/spec/system/users/list_dossiers_spec.rb b/spec/system/users/list_dossiers_spec.rb index 5325c2cc0..6fc525e04 100644 --- a/spec/system/users/list_dossiers_spec.rb +++ b/spec/system/users/list_dossiers_spec.rb @@ -268,6 +268,7 @@ describe 'user access to the list of their dossiers', js: true do context 'when it matches multiple dossiers' do let!(:dossier_with_champs) { create(:dossier, :with_populated_champs, :en_construction, user: user) } before do + perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) find('.fr-search-bar .fr-btn').click end From 5a2ddcb471003238aa064bf4cabd80776a803f42 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 3 Apr 2024 16:34:24 +0200 Subject: [PATCH 0115/1532] test: faster dossier clone spec by merging similar `it` --- .../concerns/dossier_clone_concern_spec.rb | 161 ++++++++++-------- spec/models/dossier_spec.rb | 62 +++---- 2 files changed, 120 insertions(+), 103 deletions(-) diff --git a/spec/models/concerns/dossier_clone_concern_spec.rb b/spec/models/concerns/dossier_clone_concern_spec.rb index f3d5ffa1c..2763d3bf7 100644 --- a/spec/models/concerns/dossier_clone_concern_spec.rb +++ b/spec/models/concerns/dossier_clone_concern_spec.rb @@ -19,45 +19,44 @@ RSpec.describe DossierCloneConcern do let(:types_de_champ_public) { [{}] } let(:types_de_champ_private) { [{}] } let(:fork) { false } - let(:new_dossier) { dossier.clone(fork:) } + subject(:new_dossier) { dossier.clone(fork:) } - context 'reset most attributes' do - it { expect(new_dossier.id).not_to eq(dossier.id) } - it { expect(new_dossier.api_entreprise_job_exceptions).to be_nil } - it { expect(new_dossier.archived).to be_falsey } - it { expect(new_dossier.brouillon_close_to_expiration_notice_sent_at).to be_nil } - it { expect(new_dossier.conservation_extension).to eq(0.seconds) } - it { expect(new_dossier.declarative_triggered_at).to be_nil } - it { expect(new_dossier.deleted_user_email_never_send).to be_nil } - it { expect(new_dossier.depose_at).to be_nil } - it { expect(new_dossier.en_construction_at).to be_nil } - it { expect(new_dossier.en_construction_close_to_expiration_notice_sent_at).to be_nil } - it { expect(new_dossier.en_instruction_at).to be_nil } - it { expect(new_dossier.for_procedure_preview).to be_falsey } - it { expect(new_dossier.groupe_instructeur_updated_at).to be_nil } - it { expect(new_dossier.hidden_at).to be_nil } - it { expect(new_dossier.hidden_by_administration_at).to be_nil } - it { expect(new_dossier.hidden_by_reason).to be_nil } - it { expect(new_dossier.hidden_by_user_at).to be_nil } - it { expect(new_dossier.identity_updated_at).to be_nil } - it { expect(new_dossier.last_avis_updated_at).to be_nil } - it { expect(new_dossier.last_champ_private_updated_at).to be_nil } - it { expect(new_dossier.last_champ_updated_at).to be_nil } - it { expect(new_dossier.last_commentaire_updated_at).to be_nil } - it { expect(new_dossier.motivation).to be_nil } - it { expect(new_dossier.processed_at).to be_nil } - it { expect(new_dossier.termine_close_to_expiration_notice_sent_at).to be_nil } - it { expect(new_dossier.dossier_transfer_id).to be_nil } + it 'resets most of the attributes for the cloned dossier' do + expect(new_dossier.id).not_to eq(dossier.id) + expect(new_dossier.api_entreprise_job_exceptions).to be_nil + expect(new_dossier.archived).to be_falsey + expect(new_dossier.brouillon_close_to_expiration_notice_sent_at).to be_nil + expect(new_dossier.conservation_extension).to eq(0.seconds) + expect(new_dossier.declarative_triggered_at).to be_nil + expect(new_dossier.deleted_user_email_never_send).to be_nil + expect(new_dossier.depose_at).to be_nil + expect(new_dossier.en_construction_at).to be_nil + expect(new_dossier.en_construction_close_to_expiration_notice_sent_at).to be_nil + expect(new_dossier.en_instruction_at).to be_nil + expect(new_dossier.for_procedure_preview).to be_falsey + expect(new_dossier.groupe_instructeur_updated_at).to be_nil + expect(new_dossier.hidden_at).to be_nil + expect(new_dossier.hidden_by_administration_at).to be_nil + expect(new_dossier.hidden_by_reason).to be_nil + expect(new_dossier.hidden_by_user_at).to be_nil + expect(new_dossier.identity_updated_at).to be_nil + expect(new_dossier.last_avis_updated_at).to be_nil + expect(new_dossier.last_champ_private_updated_at).to be_nil + expect(new_dossier.last_champ_updated_at).to be_nil + expect(new_dossier.last_commentaire_updated_at).to be_nil + expect(new_dossier.motivation).to be_nil + expect(new_dossier.processed_at).to be_nil + end - it "update search terms" do - new_dossier - perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) - sql = "SELECT search_terms, private_search_terms FROM dossiers where id = :id" - result = Dossier.connection.execute(Dossier.sanitize_sql_array([sql, id: new_dossier.id])).first + it "updates search terms" do + subject - expect(result["search_terms"]).to match(dossier.user.email) - expect(result["private_search_terms"]).to eq("") - end + perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) + sql = "SELECT search_terms, private_search_terms FROM dossiers where id = :id" + result = Dossier.connection.execute(Dossier.sanitize_sql_array([sql, id: new_dossier.id])).first + + expect(result["search_terms"]).to match(dossier.user.email) + expect(result["private_search_terms"]).to eq("") end context 'copies some attributes' do @@ -67,18 +66,22 @@ RSpec.describe DossierCloneConcern do end context 'when not forked' do - it { expect(new_dossier.groupe_instructeur).to be_nil } + it "copies or reset attributes" do + expect(new_dossier.groupe_instructeur).to be_nil + expect(new_dossier.autorisation_donnees).to eq(dossier.autorisation_donnees) + expect(new_dossier.revision_id).to eq(dossier.revision_id) + expect(new_dossier.user_id).to eq(dossier.user_id) + end end - it { expect(new_dossier.autorisation_donnees).to eq(dossier.autorisation_donnees) } - it { expect(new_dossier.revision_id).to eq(dossier.revision_id) } - it { expect(new_dossier.user_id).to eq(dossier.user_id) } end context 'forces some attributes' do let(:dossier) { create(:dossier, :accepte) } - it { expect(new_dossier.brouillon?).to eq(true) } - it { expect(new_dossier.parent_dossier).to eq(dossier) } + it do + expect(new_dossier.brouillon?).to eq(true) + expect(new_dossier.parent_dossier).to eq(dossier) + end context 'destroy parent' do before { new_dossier } @@ -91,22 +94,28 @@ RSpec.describe DossierCloneConcern do context 'procedure with_individual' do let(:procedure) { create(:procedure, :for_individual) } - it { expect(new_dossier.individual.slice(:nom, :prenom, :gender)).to eq(dossier.individual.slice(:nom, :prenom, :gender)) } - it { expect(new_dossier.individual.id).not_to eq(dossier.individual.id) } + it do + expect(new_dossier.individual.slice(:nom, :prenom, :gender)).to eq(dossier.individual.slice(:nom, :prenom, :gender)) + expect(new_dossier.individual.id).not_to eq(dossier.individual.id) + end end context 'procedure with etablissement' do let(:dossier) { create(:dossier, :with_entreprise) } - it { expect(new_dossier.etablissement.slice(:siret)).to eq(dossier.etablissement.slice(:siret)) } - it { expect(new_dossier.etablissement.id).not_to eq(dossier.etablissement.id) } + it do + expect(new_dossier.etablissement.slice(:siret)).to eq(dossier.etablissement.slice(:siret)) + expect(new_dossier.etablissement.id).not_to eq(dossier.etablissement.id) + end end describe 'champs' do it { expect(new_dossier.id).not_to eq(dossier.id) } context 'public are duplicated' do - it { expect(new_dossier.champs_public.count).to eq(dossier.champs_public.count) } - it { expect(new_dossier.champs_public.ids).not_to eq(dossier.champs_public.ids) } + it do + expect(new_dossier.champs_public.count).to eq(dossier.champs_public.count) + expect(new_dossier.champs_public.ids).not_to eq(dossier.champs_public.ids) + end it 'keeps champs.values' do original_first_champ = dossier.champs_public.first @@ -121,8 +130,10 @@ RSpec.describe DossierCloneConcern do let(:champ_repetition) { create(:champ_repetition, type_de_champ: type_de_champ_repetition, dossier: dossier) } before { dossier.champs_public << champ_repetition } - it { expect(Champs::RepetitionChamp.where(dossier: new_dossier).first.champs.count).to eq(4) } - it { expect(Champs::RepetitionChamp.where(dossier: new_dossier).first.champs.ids).not_to eq(champ_repetition.champs.ids) } + it do + expect(Champs::RepetitionChamp.where(dossier: new_dossier).first.champs.count).to eq(4) + expect(Champs::RepetitionChamp.where(dossier: new_dossier).first.champs.ids).not_to eq(champ_repetition.champs.ids) + end end context 'for Champs::CarteChamp with geo areas, original_champ.geo_areas are duped' do @@ -132,8 +143,10 @@ RSpec.describe DossierCloneConcern do let(:champ_carte) { create(:champ_carte, type_de_champ: type_de_champ_carte, geo_areas: [geo_area]) } before { dossier.champs_public << champ_carte } - it { expect(Champs::CarteChamp.where(dossier: new_dossier).first.geo_areas.count).to eq(1) } - it { expect(Champs::CarteChamp.where(dossier: new_dossier).first.geo_areas.ids).not_to eq(champ_carte.geo_areas.ids) } + it do + expect(Champs::CarteChamp.where(dossier: new_dossier).first.geo_areas.count).to eq(1) + expect(Champs::CarteChamp.where(dossier: new_dossier).first.geo_areas.ids).not_to eq(champ_carte.geo_areas.ids) + end end context 'for Champs::SiretChamp, original_champ.etablissement is duped' do @@ -143,8 +156,10 @@ RSpec.describe DossierCloneConcern do let(:champ_siret) { create(:champ_siret, type_de_champ: type_de_champs_siret, etablissement: create(:etablissement)) } before { dossier.champs_public << champ_siret } - it { expect(Champs::SiretChamp.where(dossier: dossier).first.etablissement).not_to be_nil } - it { expect(Champs::SiretChamp.where(dossier: new_dossier).first.etablissement.id).not_to eq(champ_siret.etablissement.id) } + it do + expect(Champs::SiretChamp.where(dossier: dossier).first.etablissement).not_to be_nil + expect(Champs::SiretChamp.where(dossier: new_dossier).first.etablissement.id).not_to eq(champ_siret.etablissement.id) + end end context 'for Champs::PieceJustificative, original_champ.piece_justificative_file is duped' do @@ -161,18 +176,19 @@ RSpec.describe DossierCloneConcern do let(:champ_address) { create(:champ_address, type_de_champ: type_de_champs_adress, external_id: 'Address', data: { city_code: '75019' }) } before { dossier.champs_public << champ_address } - it { expect(Champs::AddressChamp.where(dossier: dossier).first.data).not_to be_nil } - it { expect(Champs::AddressChamp.where(dossier: dossier).first.external_id).not_to be_nil } - it { expect(Champs::AddressChamp.where(dossier: new_dossier).first.external_id).to eq(champ_address.external_id) } - it { expect(Champs::AddressChamp.where(dossier: new_dossier).first.data).to eq(champ_address.data) } + it do + expect(Champs::AddressChamp.where(dossier: dossier).first.data).not_to be_nil + expect(Champs::AddressChamp.where(dossier: dossier).first.external_id).not_to be_nil + expect(Champs::AddressChamp.where(dossier: new_dossier).first.external_id).to eq(champ_address.external_id) + expect(Champs::AddressChamp.where(dossier: new_dossier).first.data).to eq(champ_address.data) + end end end context 'private are renewd' do - it { expect(new_dossier.champs_private.count).to eq(dossier.champs_private.count) } - it { expect(new_dossier.champs_private.ids).not_to eq(dossier.champs_private.ids) } - it 'reset champs private values' do + expect(new_dossier.champs_private.count).to eq(dossier.champs_private.count) + expect(new_dossier.champs_private.ids).not_to eq(dossier.champs_private.ids) original_first_champs_private = dossier.champs_private.first original_first_champs_private.update!(value: 'kthxbye') @@ -186,10 +202,12 @@ RSpec.describe DossierCloneConcern do let(:new_dossier) { dossier.clone(fork: true) } before { dossier.champs_public.reload } # we compare timestamps so we have to get the precision limit from the db } - it { expect(new_dossier.editing_fork_origin).to eq(dossier) } - it { expect(new_dossier.champs_public[0].id).not_to eq(dossier.champs_public[0].id) } - it { expect(new_dossier.champs_public[0].created_at).to eq(dossier.champs_public[0].created_at) } - it { expect(new_dossier.champs_public[0].updated_at).to eq(dossier.champs_public[0].updated_at) } + it do + expect(new_dossier.editing_fork_origin).to eq(dossier) + expect(new_dossier.champs_public[0].id).not_to eq(dossier.champs_public[0].id) + expect(new_dossier.champs_public[0].created_at).to eq(dossier.champs_public[0].created_at) + expect(new_dossier.champs_public[0].updated_at).to eq(dossier.champs_public[0].updated_at) + end context "piece justificative champ" do let(:types_de_champ_public) { [{ type: :piece_justificative }] } @@ -261,8 +279,10 @@ RSpec.describe DossierCloneConcern do forked_dossier.assign_to_groupe_instructeur(dossier.procedure.defaut_groupe_instructeur, DossierAssignment.modes.fetch(:manual)) } - it { is_expected.to eq(added: [], updated: [], removed: []) } - it { expect(forked_dossier.forked_with_changes?).to be_truthy } + it do + expect(subject).to eq(added: [], updated: [], removed: []) + expect(forked_dossier.forked_with_changes?).to be_truthy + end end context 'with updated champ' do @@ -270,8 +290,8 @@ RSpec.describe DossierCloneConcern do before { updated_champ.update(value: 'new value') } - it { is_expected.to eq(added: [], updated: [updated_champ], removed: []) } it 'forked_with_changes? should reflect dossier state' do + expect(subject).to eq(added: [], updated: [updated_champ], removed: []) expect(dossier.forked_with_changes?).to be_falsey expect(forked_dossier.forked_with_changes?).to be_truthy expect(updated_champ.forked_with_changes?).to be_truthy @@ -318,13 +338,10 @@ RSpec.describe DossierCloneConcern do it { expect { subject }.to change { dossier.reload.champs.size }.by(0) } it { expect { subject }.not_to change { dossier.reload.champs.order(:created_at).reject { _1.stable_id.in?([99, 994]) }.map(&:value) } } + it { expect { subject }.to have_enqueued_job(DossierUpdateSearchTermsJob).with(dossier) } it { expect { subject }.to change { dossier.reload.champs.find { _1.stable_id == 99 }.value }.from('old value').to('new value') } it { expect { subject }.to change { dossier.reload.champs.find { _1.stable_id == 994 }.value }.from('old value').to('new value in repetition') } - it 'update dossier search terms' do - expect { subject }.to have_enqueued_job(DossierUpdateSearchTermsJob).with(dossier) - end - it 'fork is hidden after merge' do subject expect(forked_dossier.reload.hidden_by_reason).to eq("stale_fork") diff --git a/spec/models/dossier_spec.rb b/spec/models/dossier_spec.rb index 8af8b7891..68c7df9e5 100644 --- a/spec/models/dossier_spec.rb +++ b/spec/models/dossier_spec.rb @@ -995,28 +995,28 @@ describe Dossier, type: :model do allow(NotificationMailer).to receive(:send_accepte_notification).and_return(double(deliver_later: true)) allow(dossier).to receive(:build_attestation).and_return(attestation) - Timecop.freeze(now) + travel_to now dossier.accepter!(instructeur: instructeur, motivation: 'motivation') dossier.reload end - after { Timecop.return } - - it { expect(dossier.traitements.last.motivation).to eq('motivation') } - it { expect(dossier.motivation).to eq('motivation') } - it { expect(dossier.traitements.last.instructeur_email).to eq(instructeur.email) } - it { expect(dossier.en_instruction_at).to eq(dossier.en_instruction_at) } - it { expect(dossier.traitements.last.processed_at).to eq(now) } - it { expect(dossier.processed_at).to eq(now) } - it { expect(dossier.state).to eq('accepte') } - it { expect(last_operation.operation).to eq('accepter') } - it { expect(last_operation.automatic_operation?).to be_falsey } - it { expect(operation_serialized['operation']).to eq('accepter') } - it { expect(operation_serialized['dossier_id']).to eq(dossier.id) } - it { expect(operation_serialized['executed_at']).to eq(last_operation.executed_at.iso8601) } - it { expect(NotificationMailer).to have_received(:send_accepte_notification).with(dossier) } - it { expect(dossier.attestation).to eq(attestation) } - it { expect(dossier.commentaires.count).to eq(1) } + it "update attributes" do + expect(dossier.traitements.last.motivation).to eq('motivation') + expect(dossier.motivation).to eq('motivation') + expect(dossier.traitements.last.instructeur_email).to eq(instructeur.email) + expect(dossier.en_instruction_at).to eq(dossier.en_instruction_at) + expect(dossier.traitements.last.processed_at).to eq(now) + expect(dossier.processed_at).to eq(now) + expect(dossier.state).to eq('accepte') + expect(last_operation.operation).to eq('accepter') + expect(last_operation.automatic_operation?).to be_falsey + expect(operation_serialized['operation']).to eq('accepter') + expect(operation_serialized['dossier_id']).to eq(dossier.id) + expect(operation_serialized['executed_at']).to eq(last_operation.executed_at.iso8601) + expect(NotificationMailer).to have_received(:send_accepte_notification).with(dossier) + expect(dossier.attestation).to eq(attestation) + expect(dossier.commentaires.count).to eq(1) + end end describe '#accepter_automatiquement!' do @@ -1642,24 +1642,24 @@ describe Dossier, type: :model do let(:last_operation) { dossier.dossier_operation_logs.last } before do - Timecop.freeze + freeze_time allow(NotificationMailer).to receive(:send_repasser_en_instruction_notification).and_return(double(deliver_later: true)) dossier.repasser_en_instruction!(instructeur: instructeur) dossier.reload end - it { expect(dossier.state).to eq('en_instruction') } - it { expect(dossier.archived).to be_falsey } - it { expect(dossier.motivation).to be_nil } - it { expect(dossier.justificatif_motivation.attached?).to be_falsey } - it { expect(dossier.attestation).to be_nil } - it { expect(dossier.sva_svr_decision_on).to be_nil } - it { expect(dossier.termine_close_to_expiration_notice_sent_at).to be_nil } - it { expect(last_operation.operation).to eq('repasser_en_instruction') } - it { expect(last_operation.data['author']['email']).to eq(instructeur.email) } - it { expect(NotificationMailer).to have_received(:send_repasser_en_instruction_notification).with(dossier) } - - after { Timecop.return } + it "update attributes" do + expect(dossier.state).to eq('en_instruction') + expect(dossier.archived).to be_falsey + expect(dossier.motivation).to be_nil + expect(dossier.justificatif_motivation.attached?).to be_falsey + expect(dossier.attestation).to be_nil + expect(dossier.sva_svr_decision_on).to be_nil + expect(dossier.termine_close_to_expiration_notice_sent_at).to be_nil + expect(last_operation.operation).to eq('repasser_en_instruction') + expect(last_operation.data['author']['email']).to eq(instructeur.email) + expect(NotificationMailer).to have_received(:send_repasser_en_instruction_notification).with(dossier) + end end describe '#notify_draft_not_submitted' do From ee465b38ffe50e305bcc5dd92c301d4a22375e4d Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 25 Apr 2024 17:44:05 +0200 Subject: [PATCH 0116/1532] feat(search): debounce update search terms --- .../concerns/dossier_searchable_concern.rb | 11 +++++++- .../dossier_searchable_concern_spec.rb | 26 +++++++++++++------ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/app/models/concerns/dossier_searchable_concern.rb b/app/models/concerns/dossier_searchable_concern.rb index f810e456c..9adec58fe 100644 --- a/app/models/concerns/dossier_searchable_concern.rb +++ b/app/models/concerns/dossier_searchable_concern.rb @@ -1,9 +1,15 @@ +# frozen_string_literal: true + module DossierSearchableConcern extend ActiveSupport::Concern included do after_commit :update_search_terms_later + SEARCH_TERMS_DEBOUNCE = 30.seconds + + kredis_flag :debounce_update_search_terms_flag + def update_search_terms search_terms = [ user&.email, @@ -21,7 +27,10 @@ module DossierSearchableConcern end def update_search_terms_later - DossierUpdateSearchTermsJob.perform_later(self) + return if debounce_update_search_terms_flag.marked? + + debounce_update_search_terms_flag.mark(expires_in: SEARCH_TERMS_DEBOUNCE) + DossierUpdateSearchTermsJob.set(wait: SEARCH_TERMS_DEBOUNCE).perform_later(self) end end end diff --git a/spec/models/concerns/dossier_searchable_concern_spec.rb b/spec/models/concerns/dossier_searchable_concern_spec.rb index 58b6fc25e..343f31795 100644 --- a/spec/models/concerns/dossier_searchable_concern_spec.rb +++ b/spec/models/concerns/dossier_searchable_concern_spec.rb @@ -2,8 +2,6 @@ describe DossierSearchableConcern do let(:champ_public) { dossier.champs_public.first } let(:champ_private) { dossier.champs_private.first } - subject { dossier } - describe '#update_search_terms' do let(:etablissement) { dossier.etablissement } let(:dossier) { create(:dossier, :with_entreprise, user: user) } @@ -19,32 +17,44 @@ describe DossierSearchableConcern do ).first end - before do + it "update columns" do champ_public.update_attribute(:value, "champ public") champ_private.update_attribute(:value, "champ privé") - perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) - end - it "update columns" do expect(result["search_terms"]).to eq("#{user.email} champ public #{etablissement.entreprise_siren} #{etablissement.entreprise_numero_tva_intracommunautaire} #{etablissement.entreprise_forme_juridique} #{etablissement.entreprise_forme_juridique_code} #{etablissement.entreprise_nom_commercial} #{etablissement.entreprise_raison_sociale} #{etablissement.entreprise_siret_siege_social} #{etablissement.entreprise_nom} #{etablissement.entreprise_prenom} #{etablissement.association_rna} #{etablissement.association_titre} #{etablissement.association_objet} #{etablissement.siret} #{etablissement.naf} #{etablissement.libelle_naf} #{etablissement.adresse} #{etablissement.code_postal} #{etablissement.localite} #{etablissement.code_insee_localite}") expect(result["private_search_terms"]).to eq('champ privé') end context 'with an update' do before do + stub_const("DossierSearchableConcern::SEARCH_TERMS_DEBOUNCE", 1.second) + end + + it "update columns" do dossier.update( champs_public_attributes: [{ id: champ_public.id, value: 'nouvelle valeur publique' }], champs_private_attributes: [{ id: champ_private.id, value: 'nouvelle valeur privee' }] ) perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) - end - it "update columns" do expect(result["search_terms"]).to include('nouvelle valeur publique') expect(result["private_search_terms"]).to include('nouvelle valeur privee') end + + it "debounce jobs" do + assert_enqueued_jobs(1, only: DossierUpdateSearchTermsJob) do + 3.times { dossier.index_search_terms_later } + end + + # wait redis key expiration + sleep 1.01.seconds + + assert_enqueued_jobs(1, only: DossierUpdateSearchTermsJob) do + dossier.update(champs_public_attributes: [{ id: champ_public.id, value: rand(10).to_s }]) + end + end end end end From 44088248820633162f2b17c0f6f582e9601e7570 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 25 Apr 2024 17:44:28 +0200 Subject: [PATCH 0117/1532] fix(dossier): update search terms when etablissement or individual changed --- app/models/etablissement.rb | 2 ++ app/models/individual.rb | 2 ++ spec/models/etablissement_spec.rb | 10 ++++++++++ spec/models/individual_spec.rb | 32 +++++++++++++++++++------------ 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/app/models/etablissement.rb b/app/models/etablissement.rb index 811a0c0a9..d57bc08b2 100644 --- a/app/models/etablissement.rb +++ b/app/models/etablissement.rb @@ -17,6 +17,8 @@ class Etablissement < ApplicationRecord fermé: "fermé" }, _prefix: true + after_commit -> { dossier&.debounce_update_search_terms } + def entreprise_raison_sociale read_attribute(:entreprise_raison_sociale).presence || raison_sociale_for_ei end diff --git a/app/models/individual.rb b/app/models/individual.rb index c3d92b9eb..6e52045b4 100644 --- a/app/models/individual.rb +++ b/app/models/individual.rb @@ -17,6 +17,8 @@ class Individual < ApplicationRecord validates :email, presence: true, if: -> { dossier.for_tiers? && self.email? }, on: :update + after_commit -> { dossier.debounce_update_search_terms }, if: -> { nom_previously_changed? || prenom_previously_changed? } + GENDER_MALE = "M." GENDER_FEMALE = 'Mme' diff --git a/spec/models/etablissement_spec.rb b/spec/models/etablissement_spec.rb index a7f38d6cd..7653c79cd 100644 --- a/spec/models/etablissement_spec.rb +++ b/spec/models/etablissement_spec.rb @@ -115,6 +115,16 @@ describe Etablissement do end end + describe 'update search terms' do + let(:etablissement) { create(:etablissement, dossier: build(:dossier)) } + + it "schedule update search terms" do + assert_enqueued_jobs(1, only: DossierUpdateSearchTermsJob) do + etablissement.update(entreprise_nom: "nom") + end + end + end + private def csv_to_array_of_hash(lines) diff --git a/spec/models/individual_spec.rb b/spec/models/individual_spec.rb index 64820f03d..a37d1e15b 100644 --- a/spec/models/individual_spec.rb +++ b/spec/models/individual_spec.rb @@ -7,41 +7,49 @@ describe Individual do describe "#save" do let(:individual) { build(:individual) } - subject { individual.save } + subject do + individual.save + individual + end context "with birthdate" do before do individual.birthdate = birthdate_from_user - subject end context "and the format is dd/mm/yyy " do let(:birthdate_from_user) { "12/11/1980" } - it { expect(individual.birthdate).to eq(Date.new(1980, 11, 12)) } + it { expect(subject.birthdate).to eq(Date.new(1980, 11, 12)) } end context "and the format is ISO" do let(:birthdate_from_user) { "1980-11-12" } - it { expect(individual.birthdate).to eq(Date.new(1980, 11, 12)) } + it { expect(subject.birthdate).to eq(Date.new(1980, 11, 12)) } end context "and the format is WTF" do let(:birthdate_from_user) { "1980 1 12" } - it { expect(individual.birthdate).to be_nil } + it { expect(subject.birthdate).to be_nil } end end - context 'when an individual has an invalid notification_method' do - let(:invalid_individual) { build(:individual, notification_method: 'invalid_method') } - - it 'raises an ArgumentError' do - expect { - invalid_individual.valid? - }.to raise_error(ArgumentError, "'invalid_method' is not a valid notification_method") + it "schedule update search terms" do + assert_enqueued_jobs(1, only: DossierUpdateSearchTermsJob) do + subject end end end + + context 'when an individual has an invalid notification_method' do + let(:invalid_individual) { build(:individual, notification_method: 'invalid_method') } + + it 'raises an ArgumentError' do + expect { + invalid_individual.valid? + }.to raise_error(ArgumentError, "'invalid_method' is not a valid notification_method") + end + end end From 797bd6b94bbb5b8c6671ef522d7e896b7e4bc10e Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 25 Apr 2024 17:51:54 +0200 Subject: [PATCH 0118/1532] fix(search): preload before updating search terms because we access all champs --- app/models/concerns/dossier_searchable_concern.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/concerns/dossier_searchable_concern.rb b/app/models/concerns/dossier_searchable_concern.rb index 9adec58fe..e26344115 100644 --- a/app/models/concerns/dossier_searchable_concern.rb +++ b/app/models/concerns/dossier_searchable_concern.rb @@ -11,6 +11,8 @@ module DossierSearchableConcern kredis_flag :debounce_update_search_terms_flag def update_search_terms + DossierPreloader.load_one(self) + search_terms = [ user&.email, *champs_public.flat_map(&:search_terms), From 39b03272373cbe209cbe7faf33f6ed8f85b5a820 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 25 Apr 2024 17:57:55 +0200 Subject: [PATCH 0119/1532] refactor(search): rename update search terms => index search terms --- app/jobs/dossier_index_search_terms_job.rb | 7 +++++++ app/jobs/dossier_update_search_terms_job.rb | 7 ------- app/models/concerns/dossier_searchable_concern.rb | 14 +++++++------- app/models/etablissement.rb | 2 +- app/models/individual.rb | 2 +- config/initializers/transition_to_sidekiq.rb | 2 +- spec/controllers/recherche_controller_spec.rb | 2 +- ...c.rb => dossier_index_search_terms_job_spec.rb} | 2 +- spec/models/concerns/dossier_clone_concern_spec.rb | 4 ++-- .../concerns/dossier_searchable_concern_spec.rb | 10 +++++----- spec/models/etablissement_spec.rb | 2 +- spec/models/individual_spec.rb | 7 ++++--- spec/services/dossier_search_service_spec.rb | 4 ++-- spec/system/routing/rules_full_scenario_spec.rb | 2 +- spec/system/users/invite_spec.rb | 2 +- spec/system/users/list_dossiers_spec.rb | 2 +- 16 files changed, 36 insertions(+), 35 deletions(-) create mode 100644 app/jobs/dossier_index_search_terms_job.rb delete mode 100644 app/jobs/dossier_update_search_terms_job.rb rename spec/jobs/{dossier_update_search_terms_job_spec.rb => dossier_index_search_terms_job_spec.rb} (92%) diff --git a/app/jobs/dossier_index_search_terms_job.rb b/app/jobs/dossier_index_search_terms_job.rb new file mode 100644 index 000000000..688171472 --- /dev/null +++ b/app/jobs/dossier_index_search_terms_job.rb @@ -0,0 +1,7 @@ +class DossierIndexSearchTermsJob < ApplicationJob + discard_on ActiveRecord::RecordNotFound + + def perform(dossier) + dossier.index_search_terms + end +end diff --git a/app/jobs/dossier_update_search_terms_job.rb b/app/jobs/dossier_update_search_terms_job.rb deleted file mode 100644 index 5de7c61c8..000000000 --- a/app/jobs/dossier_update_search_terms_job.rb +++ /dev/null @@ -1,7 +0,0 @@ -class DossierUpdateSearchTermsJob < ApplicationJob - discard_on ActiveRecord::RecordNotFound - - def perform(dossier) - dossier.update_search_terms - end -end diff --git a/app/models/concerns/dossier_searchable_concern.rb b/app/models/concerns/dossier_searchable_concern.rb index e26344115..78eb5dd02 100644 --- a/app/models/concerns/dossier_searchable_concern.rb +++ b/app/models/concerns/dossier_searchable_concern.rb @@ -4,13 +4,13 @@ module DossierSearchableConcern extend ActiveSupport::Concern included do - after_commit :update_search_terms_later + after_commit :index_search_terms_later SEARCH_TERMS_DEBOUNCE = 30.seconds - kredis_flag :debounce_update_search_terms_flag + kredis_flag :debounce_index_search_terms_flag - def update_search_terms + def index_search_terms DossierPreloader.load_one(self) search_terms = [ @@ -28,11 +28,11 @@ module DossierSearchableConcern self.class.connection.execute(sanitized_sql) end - def update_search_terms_later - return if debounce_update_search_terms_flag.marked? + def index_search_terms_later + return if debounce_index_search_terms_flag.marked? - debounce_update_search_terms_flag.mark(expires_in: SEARCH_TERMS_DEBOUNCE) - DossierUpdateSearchTermsJob.set(wait: SEARCH_TERMS_DEBOUNCE).perform_later(self) + debounce_index_search_terms_flag.mark(expires_in: SEARCH_TERMS_DEBOUNCE) + DossierIndexSearchTermsJob.set(wait: SEARCH_TERMS_DEBOUNCE).perform_later(self) end end end diff --git a/app/models/etablissement.rb b/app/models/etablissement.rb index d57bc08b2..4987c4785 100644 --- a/app/models/etablissement.rb +++ b/app/models/etablissement.rb @@ -17,7 +17,7 @@ class Etablissement < ApplicationRecord fermé: "fermé" }, _prefix: true - after_commit -> { dossier&.debounce_update_search_terms } + after_commit -> { dossier&.index_search_terms_later } def entreprise_raison_sociale read_attribute(:entreprise_raison_sociale).presence || raison_sociale_for_ei diff --git a/app/models/individual.rb b/app/models/individual.rb index 6e52045b4..47e07af22 100644 --- a/app/models/individual.rb +++ b/app/models/individual.rb @@ -17,7 +17,7 @@ class Individual < ApplicationRecord validates :email, presence: true, if: -> { dossier.for_tiers? && self.email? }, on: :update - after_commit -> { dossier.debounce_update_search_terms }, if: -> { nom_previously_changed? || prenom_previously_changed? } + after_commit -> { dossier.index_search_terms_later }, if: -> { nom_previously_changed? || prenom_previously_changed? } GENDER_MALE = "M." GENDER_FEMALE = 'Mme' diff --git a/config/initializers/transition_to_sidekiq.rb b/config/initializers/transition_to_sidekiq.rb index 419385827..a4c8e961a 100644 --- a/config/initializers/transition_to_sidekiq.rb +++ b/config/initializers/transition_to_sidekiq.rb @@ -44,7 +44,7 @@ if Rails.env.production? && SIDEKIQ_ENABLED self.queue_adapter = :sidekiq end - class DossierUpdateSearchTermsJob < ApplicationJob + class DossierIndexSearchTermsJob < ApplicationJob self.queue_adapter = :sidekiq end diff --git a/spec/controllers/recherche_controller_spec.rb b/spec/controllers/recherche_controller_spec.rb index 91bfe25fe..b8d87a0ee 100644 --- a/spec/controllers/recherche_controller_spec.rb +++ b/spec/controllers/recherche_controller_spec.rb @@ -28,7 +28,7 @@ describe RechercheController, type: :controller do dossier_with_expert.champs_private[1].value = "Dossier B is invalid" dossier_with_expert.save! - perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) + perform_enqueued_jobs(only: DossierIndexSearchTermsJob) end describe 'GET #index' do diff --git a/spec/jobs/dossier_update_search_terms_job_spec.rb b/spec/jobs/dossier_index_search_terms_job_spec.rb similarity index 92% rename from spec/jobs/dossier_update_search_terms_job_spec.rb rename to spec/jobs/dossier_index_search_terms_job_spec.rb index 3dfda131f..9cb016ec9 100644 --- a/spec/jobs/dossier_update_search_terms_job_spec.rb +++ b/spec/jobs/dossier_index_search_terms_job_spec.rb @@ -1,4 +1,4 @@ -RSpec.describe DossierUpdateSearchTermsJob, type: :job do +RSpec.describe DossierIndexSearchTermsJob, type: :job do let(:dossier) { create(:dossier) } subject(:perform_job) { described_class.perform_now(dossier.reload) } diff --git a/spec/models/concerns/dossier_clone_concern_spec.rb b/spec/models/concerns/dossier_clone_concern_spec.rb index 2763d3bf7..6f182b1c3 100644 --- a/spec/models/concerns/dossier_clone_concern_spec.rb +++ b/spec/models/concerns/dossier_clone_concern_spec.rb @@ -51,7 +51,7 @@ RSpec.describe DossierCloneConcern do it "updates search terms" do subject - perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) + perform_enqueued_jobs(only: DossierIndexSearchTermsJob) sql = "SELECT search_terms, private_search_terms FROM dossiers where id = :id" result = Dossier.connection.execute(Dossier.sanitize_sql_array([sql, id: new_dossier.id])).first @@ -338,7 +338,7 @@ RSpec.describe DossierCloneConcern do it { expect { subject }.to change { dossier.reload.champs.size }.by(0) } it { expect { subject }.not_to change { dossier.reload.champs.order(:created_at).reject { _1.stable_id.in?([99, 994]) }.map(&:value) } } - it { expect { subject }.to have_enqueued_job(DossierUpdateSearchTermsJob).with(dossier) } + it { expect { subject }.to have_enqueued_job(DossierIndexSearchTermsJob).with(dossier) } it { expect { subject }.to change { dossier.reload.champs.find { _1.stable_id == 99 }.value }.from('old value').to('new value') } it { expect { subject }.to change { dossier.reload.champs.find { _1.stable_id == 994 }.value }.from('old value').to('new value in repetition') } diff --git a/spec/models/concerns/dossier_searchable_concern_spec.rb b/spec/models/concerns/dossier_searchable_concern_spec.rb index 343f31795..ae9325d7d 100644 --- a/spec/models/concerns/dossier_searchable_concern_spec.rb +++ b/spec/models/concerns/dossier_searchable_concern_spec.rb @@ -2,7 +2,7 @@ describe DossierSearchableConcern do let(:champ_public) { dossier.champs_public.first } let(:champ_private) { dossier.champs_private.first } - describe '#update_search_terms' do + describe '#index_search_terms' do let(:etablissement) { dossier.etablissement } let(:dossier) { create(:dossier, :with_entreprise, user: user) } let(:etablissement) { build(:etablissement, entreprise_nom: 'Dupont', entreprise_prenom: 'Thomas', association_rna: '12345', association_titre: 'asso de test', association_objet: 'tests unitaires') } @@ -20,7 +20,7 @@ describe DossierSearchableConcern do it "update columns" do champ_public.update_attribute(:value, "champ public") champ_private.update_attribute(:value, "champ privé") - perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) + perform_enqueued_jobs(only: DossierIndexSearchTermsJob) expect(result["search_terms"]).to eq("#{user.email} champ public #{etablissement.entreprise_siren} #{etablissement.entreprise_numero_tva_intracommunautaire} #{etablissement.entreprise_forme_juridique} #{etablissement.entreprise_forme_juridique_code} #{etablissement.entreprise_nom_commercial} #{etablissement.entreprise_raison_sociale} #{etablissement.entreprise_siret_siege_social} #{etablissement.entreprise_nom} #{etablissement.entreprise_prenom} #{etablissement.association_rna} #{etablissement.association_titre} #{etablissement.association_objet} #{etablissement.siret} #{etablissement.naf} #{etablissement.libelle_naf} #{etablissement.adresse} #{etablissement.code_postal} #{etablissement.localite} #{etablissement.code_insee_localite}") expect(result["private_search_terms"]).to eq('champ privé') @@ -37,21 +37,21 @@ describe DossierSearchableConcern do champs_private_attributes: [{ id: champ_private.id, value: 'nouvelle valeur privee' }] ) - perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) + perform_enqueued_jobs(only: DossierIndexSearchTermsJob) expect(result["search_terms"]).to include('nouvelle valeur publique') expect(result["private_search_terms"]).to include('nouvelle valeur privee') end it "debounce jobs" do - assert_enqueued_jobs(1, only: DossierUpdateSearchTermsJob) do + assert_enqueued_jobs(1, only: DossierIndexSearchTermsJob) do 3.times { dossier.index_search_terms_later } end # wait redis key expiration sleep 1.01.seconds - assert_enqueued_jobs(1, only: DossierUpdateSearchTermsJob) do + assert_enqueued_jobs(1, only: DossierIndexSearchTermsJob) do dossier.update(champs_public_attributes: [{ id: champ_public.id, value: rand(10).to_s }]) end end diff --git a/spec/models/etablissement_spec.rb b/spec/models/etablissement_spec.rb index 7653c79cd..3b46462f2 100644 --- a/spec/models/etablissement_spec.rb +++ b/spec/models/etablissement_spec.rb @@ -119,7 +119,7 @@ describe Etablissement do let(:etablissement) { create(:etablissement, dossier: build(:dossier)) } it "schedule update search terms" do - assert_enqueued_jobs(1, only: DossierUpdateSearchTermsJob) do + assert_enqueued_jobs(1, only: DossierIndexSearchTermsJob) do etablissement.update(entreprise_nom: "nom") end end diff --git a/spec/models/individual_spec.rb b/spec/models/individual_spec.rb index a37d1e15b..6f8fb7c6f 100644 --- a/spec/models/individual_spec.rb +++ b/spec/models/individual_spec.rb @@ -36,9 +36,10 @@ describe Individual do end end - it "schedule update search terms" do - assert_enqueued_jobs(1, only: DossierUpdateSearchTermsJob) do - subject + it "schedule index search terms" do + subject.dossier.debounce_index_search_terms_flag.remove + assert_enqueued_jobs(1, only: DossierIndexSearchTermsJob) do + individual.update(nom: "new name") end end end diff --git a/spec/services/dossier_search_service_spec.rb b/spec/services/dossier_search_service_spec.rb index 0f2baeec0..759f7b60b 100644 --- a/spec/services/dossier_search_service_spec.rb +++ b/spec/services/dossier_search_service_spec.rb @@ -24,7 +24,7 @@ describe DossierSearchService do dossier_3 dossier_archived - perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) + perform_enqueued_jobs(only: DossierIndexSearchTermsJob) end let(:procedure_1) { create(:procedure, :published, administrateur: administrateur_1) } @@ -116,7 +116,7 @@ describe DossierSearchService do dossier_2 dossier_3 dossier_archived - perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) + perform_enqueued_jobs(only: DossierIndexSearchTermsJob) end let(:liste_dossiers) do diff --git a/spec/system/routing/rules_full_scenario_spec.rb b/spec/system/routing/rules_full_scenario_spec.rb index d72fd0c46..2729a71ce 100644 --- a/spec/system/routing/rules_full_scenario_spec.rb +++ b/spec/system/routing/rules_full_scenario_spec.rb @@ -134,7 +134,7 @@ describe 'The routing with rules', js: true do user_send_dossier(litteraire_user, 'littéraire') user_send_dossier(artistique_user, 'artistique') - perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) + perform_enqueued_jobs(only: DossierIndexSearchTermsJob) # the litteraires instructeurs only manage the litteraires dossiers register_instructeur_and_log_in(victor.email) diff --git a/spec/system/users/invite_spec.rb b/spec/system/users/invite_spec.rb index 2d681a49b..15c8a466b 100644 --- a/spec/system/users/invite_spec.rb +++ b/spec/system/users/invite_spec.rb @@ -162,7 +162,7 @@ describe 'Invitations' do before do navigate_to_invited_dossier(invite) visit dossiers_path - perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) + perform_enqueued_jobs(only: DossierIndexSearchTermsJob) end it "can search by id and it displays the dossier" do diff --git a/spec/system/users/list_dossiers_spec.rb b/spec/system/users/list_dossiers_spec.rb index 6fc525e04..eaa32c284 100644 --- a/spec/system/users/list_dossiers_spec.rb +++ b/spec/system/users/list_dossiers_spec.rb @@ -268,7 +268,7 @@ describe 'user access to the list of their dossiers', js: true do context 'when it matches multiple dossiers' do let!(:dossier_with_champs) { create(:dossier, :with_populated_champs, :en_construction, user: user) } before do - perform_enqueued_jobs(only: DossierUpdateSearchTermsJob) + perform_enqueued_jobs(only: DossierIndexSearchTermsJob) find('.fr-search-bar .fr-btn').click end From 6733b2884f5af7902ff6fc24c005e847ad1b652c Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 25 Apr 2024 17:46:21 +0200 Subject: [PATCH 0120/1532] feat(search): index mandataire name --- app/models/concerns/dossier_searchable_concern.rb | 4 +++- spec/models/concerns/dossier_searchable_concern_spec.rb | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/dossier_searchable_concern.rb b/app/models/concerns/dossier_searchable_concern.rb index 78eb5dd02..2b52fe668 100644 --- a/app/models/concerns/dossier_searchable_concern.rb +++ b/app/models/concerns/dossier_searchable_concern.rb @@ -18,7 +18,9 @@ module DossierSearchableConcern *champs_public.flat_map(&:search_terms), *etablissement&.search_terms, individual&.nom, - individual&.prenom + individual&.prenom, + mandataire_first_name, + mandataire_last_name ].compact_blank.join(' ') private_search_terms = champs_private.flat_map(&:search_terms).compact_blank.join(' ') diff --git a/spec/models/concerns/dossier_searchable_concern_spec.rb b/spec/models/concerns/dossier_searchable_concern_spec.rb index ae9325d7d..069a6696e 100644 --- a/spec/models/concerns/dossier_searchable_concern_spec.rb +++ b/spec/models/concerns/dossier_searchable_concern_spec.rb @@ -56,5 +56,13 @@ describe DossierSearchableConcern do end end end + + context 'mandataire' do + it "update columns" do + dossier.update(mandataire_first_name: "Chris") + perform_enqueued_jobs(only: DossierIndexSearchTermsJob) + expect(result["search_terms"]).to include("Chris") + end + end end end From b048a2b042b5c9e3f313a1fde2043b7e0a0259b6 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Mon, 6 May 2024 16:16:24 +0000 Subject: [PATCH 0121/1532] =?UTF-8?q?BUGFIX=20:=20La=20d=C3=A9marche=20mod?= =?UTF-8?q?=C3=A8le=20clon=C3=A9e=20n'est=20plus=20mod=C3=A8le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/procedure.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 827f417d5..fe7005599 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -550,6 +550,7 @@ class Procedure < ApplicationRecord procedure.closing_details = nil procedure.closing_notification_brouillon = false procedure.closing_notification_en_cours = false + procedure.template = false if !procedure.valid? procedure.errors.attribute_names.each do |attribute| From 3bebff117758aa8e48354d1dbda87de8afce9046 Mon Sep 17 00:00:00 2001 From: Kara Diaby Date: Tue, 7 May 2024 08:16:44 +0000 Subject: [PATCH 0122/1532] TEsts --- spec/models/procedure_spec.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 9834a46e5..01f937995 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -587,7 +587,8 @@ describe Procedure do types_de_champ_private: [{}, {}, { type: :drop_down_list }, { type: :repetition, children: [{}] }], api_particulier_token: '123456789012345', api_particulier_scopes: ['cnaf_famille'], - estimated_dossiers_count: 4) + estimated_dossiers_count: 4, + template: true) end let(:type_de_champ_repetition) { procedure.draft_revision.types_de_champ_public.last } let(:type_de_champ_private_repetition) { procedure.draft_revision.types_de_champ_private.last } @@ -612,6 +613,10 @@ describe Procedure do it { expect(subject.parent_procedure).to eq(procedure) } + it 'the cloned procedure should not be a template anymore' do + expect(subject.template).to be_falsey + end + describe "should keep groupe instructeurs " do it "should clone groupe instructeurs" do expect(subject.groupe_instructeurs.size).to eq(2) @@ -680,7 +685,7 @@ describe Procedure do expect(cloned_procedure).to have_same_attributes_as(procedure, except: [ "path", "draft_revision_id", "service_id", 'estimated_dossiers_count', "duree_conservation_etendue_par_ds", "duree_conservation_dossiers_dans_ds", 'max_duree_conservation_dossiers_dans_ds', - "defaut_groupe_instructeur_id" + "defaut_groupe_instructeur_id", "template" ]) end From ec1c030eaee6b8486433a71b79c1adda08e762ce Mon Sep 17 00:00:00 2001 From: mfo Date: Tue, 7 May 2024 11:18:49 +0200 Subject: [PATCH 0123/1532] feat(urls): move from gouvernement.fr to info.gouv.fr --- config/locales/views/users/procedure_footer/fr.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/locales/views/users/procedure_footer/fr.yml b/config/locales/views/users/procedure_footer/fr.yml index 55b297565..7bebc8232 100644 --- a/config/locales/views/users/procedure_footer/fr.yml +++ b/config/locales/views/users/procedure_footer/fr.yml @@ -27,8 +27,8 @@ fr: title: legifrance.gouv.fr url: "https://legifrance.gouv.fr" gouvernement: - title: gouvernement.fr - url: "https://gouvernement.fr" + title: info.gouv.fr + url: "https://info.gouv.fr" service_public: title: service-public.fr url: "https://service-public.fr" From 24a8257e4754c6d6f2da794eefe65bb918b1d4f5 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 7 May 2024 14:49:12 +0200 Subject: [PATCH 0124/1532] ux: cni are not exposed by zip or api --- .../champ_component/champ_component.html.haml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml index 1e0ad3938..c7ce05900 100644 --- a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml +++ b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml @@ -109,7 +109,9 @@ - if type_de_champ.titre_identite? = render Dsfr::AlertComponent.new(state: :info, heading_level: 'p') do |c| - c.with_body do - Dans le cadre de la RGPD, le titre d’identité sera supprimé lors de l’acceptation, du refus ou du classement sans suite du dossier. Aussi, pour des raisons de sécurité, un filigrane est automatiquement ajouté aux images. + Dans le cadre de la RGPD, le titre d’identité sera supprimé lors de l’acceptation, du refus ou du classement sans suite du dossier.
    + Aussi, pour des raisons de sécurité, un filigrane est automatiquement ajouté aux images.
    + Finalement, le titre d’identité ne sera ni disponible dans les zip de dossiers, ni téléchargeable par API. - elsif procedure.piece_justificative_multiple? %p Les usagers pourront envoyer plusieurs fichiers si nécessaire. From f0544d77e97910243865e5bb9bd5e85748475743 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Thu, 2 May 2024 11:39:05 +0200 Subject: [PATCH 0125/1532] User - Improve titles on listing pages --- app/views/users/dossiers/_deleted_dossiers_list.html.haml | 4 ++-- app/views/users/dossiers/_dossiers_list.html.haml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/users/dossiers/_deleted_dossiers_list.html.haml b/app/views/users/dossiers/_deleted_dossiers_list.html.haml index 86c8bf38c..4166ebb99 100644 --- a/app/views/users/dossiers/_deleted_dossiers_list.html.haml +++ b/app/views/users/dossiers/_deleted_dossiers_list.html.haml @@ -1,12 +1,12 @@ - if deleted_dossiers.present? - .fr-h6.fr-mb-2w + %h2.fr-h6.fr-mb-2w = page_entries_info deleted_dossiers - deleted_dossiers.each do |dossier| .card .flex.justify-between %div - %h2.card-title + %h3.card-title = dossier.procedure.libelle %p.fr-icon--sm.fr-icon-delete-line.fr-mb-0 diff --git a/app/views/users/dossiers/_dossiers_list.html.haml b/app/views/users/dossiers/_dossiers_list.html.haml index 86293b73e..877ab6f96 100644 --- a/app/views/users/dossiers/_dossiers_list.html.haml +++ b/app/views/users/dossiers/_dossiers_list.html.haml @@ -1,12 +1,12 @@ - if dossiers.present? - .fr-h6.fr-mb-2w + %h2.fr-h6.fr-mb-2w = page_entries_info dossiers - dossiers.each do |dossier| .card .flex.justify-between %div - %h2.card-title + %h3.card-title - if ["dossiers-transferes", "dossiers-supprimes-recemment"].exclude?(@statut) = link_to(url_for_dossier(dossier), class: 'cell-link') do = dossier.procedure.libelle From 2dc6e3fbc32668cb641a3ad6fb3a60b52bfd41f0 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Thu, 2 May 2024 14:39:23 +0200 Subject: [PATCH 0126/1532] Instructor - Improve titles on listing pages --- app/views/instructeurs/procedures/_list.html.haml | 5 +++-- app/views/instructeurs/procedures/_synthese.html.haml | 2 +- app/views/instructeurs/procedures/index.html.haml | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/views/instructeurs/procedures/_list.html.haml b/app/views/instructeurs/procedures/_list.html.haml index 868359274..8a906f2e2 100644 --- a/app/views/instructeurs/procedures/_list.html.haml +++ b/app/views/instructeurs/procedures/_list.html.haml @@ -5,9 +5,10 @@ .procedure-details .flex.clipboard-container - %p.fr-mb-2w + .fr-mb-2w = procedure_badge(p) - = link_to("#{p.libelle} - n°#{p.id}", instructeur_procedure_path(p), class: "fr-link fr-ml-1w") + %h3.font-weight-normal.fr-link.fr-ml-1w + = link_to("#{p.libelle} - n°#{p.id}", instructeur_procedure_path(p)) = render Dsfr::CopyButtonComponent.new(title: t('instructeurs.procedures.index.copy_link_button'), text: commencer_url(p.path)) %ul.procedure-stats.flex diff --git a/app/views/instructeurs/procedures/_synthese.html.haml b/app/views/instructeurs/procedures/_synthese.html.haml index aa2760758..fcd0293d0 100644 --- a/app/views/instructeurs/procedures/_synthese.html.haml +++ b/app/views/instructeurs/procedures/_synthese.html.haml @@ -1,6 +1,6 @@ - if procedures.length > 1 .flex.align-center.fr-mb-2w - %h2.fr-text--sm.fr-mb-1w= t('views.instructeurs.dossiers.dossier_synthesis') + %p.font-weight-bold.fr-text--sm.fr-mb-1w= t('views.instructeurs.dossiers.dossier_synthesis') - all_dossiers_counts.each_with_index do |(label, dossier_count)| - if dossier_count != 0 %span.fr-badge.fr-ml-1w.fr-mb-1w= number_with_html_delimiter(dossier_count) + ' ' + label diff --git a/app/views/instructeurs/procedures/index.html.haml b/app/views/instructeurs/procedures/index.html.haml index a3ff48798..bae861f5b 100644 --- a/app/views/instructeurs/procedures/index.html.haml +++ b/app/views/instructeurs/procedures/index.html.haml @@ -30,7 +30,7 @@ - if collection.present? - .fr-h6 + %h2.fr-h6 = page_entries_info collection %ul.procedure-list.fr-pl-0 = render partial: 'instructeurs/procedures/list', From 4a9213455a1232719c6923b43d76abad34a5fad1 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Thu, 2 May 2024 16:00:45 +0200 Subject: [PATCH 0127/1532] Admin - Improve titles on listing pages --- .../administrateurs/procedures/_procedures_list.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/administrateurs/procedures/_procedures_list.html.haml b/app/views/administrateurs/procedures/_procedures_list.html.haml index ccd0e4afc..a38cc7ba7 100644 --- a/app/views/administrateurs/procedures/_procedures_list.html.haml +++ b/app/views/administrateurs/procedures/_procedures_list.html.haml @@ -1,4 +1,4 @@ -.fr-h6 +%h2.fr-h6 = page_entries_info procedures - procedures.each do |procedure| @@ -10,7 +10,7 @@ = image_tag procedure.logo, alt: procedure.libelle, class: 'logo' %div - .card-title + %h3.card-title = link_to procedure.libelle, admin_procedure_path(procedure) = link_to commencer_url(procedure.path), commencer_url(procedure.path), class: 'fr-link fr-mb-1w' From c51801802064a7dc93dcea26c2966def7d0d0943 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Thu, 2 May 2024 16:01:16 +0200 Subject: [PATCH 0128/1532] Admin - Add main title --- app/assets/stylesheets/procedure_admin.scss | 7 ------- app/views/administrateurs/procedures/index.html.haml | 6 ++++-- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/app/assets/stylesheets/procedure_admin.scss b/app/assets/stylesheets/procedure_admin.scss index 7f9b4c8c1..326283595 100644 --- a/app/assets/stylesheets/procedure_admin.scss +++ b/app/assets/stylesheets/procedure_admin.scss @@ -13,14 +13,7 @@ } .procedure-admin-listing-container { - display: flex; - justify-content: flex-end; - padding-left: 16px; - padding-right: 16px; - max-width: 1072px; margin-left: auto; - margin-right: auto; - margin-top: 10px; } .container { diff --git a/app/views/administrateurs/procedures/index.html.haml b/app/views/administrateurs/procedures/index.html.haml index adeb6a641..ba2311111 100644 --- a/app/views/administrateurs/procedures/index.html.haml +++ b/app/views/administrateurs/procedures/index.html.haml @@ -1,6 +1,8 @@ .sub-header - .procedure-admin-listing-container - = link_to "Nouvelle Démarche", new_from_existing_admin_procedures_path, id: 'new-procedure', class: 'fr-btn' + .flex.fr-container + %h1.fr-h3 Mes démarches + .procedure-admin-listing-container.fr-mt-1v + = link_to "Nouvelle Démarche", new_from_existing_admin_procedures_path, id: 'new-procedure', class: 'fr-btn' .fr-container %nav.fr-tabs{ role: 'navigation', 'aria-label': t('views.users.dossiers.secondary_menu') } From 74f1f19a1593f67509c4c84f18f37a97d0798d89 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Thu, 2 May 2024 16:12:55 +0200 Subject: [PATCH 0129/1532] Expert : Standardize title --- app/views/experts/avis/index.html.haml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/views/experts/avis/index.html.haml b/app/views/experts/avis/index.html.haml index ca97bcc37..5dda97742 100644 --- a/app/views/experts/avis/index.html.haml +++ b/app/views/experts/avis/index.html.haml @@ -1,8 +1,10 @@ - content_for(:title, "Avis") -.container - %h1.page-title Avis +.sub-header + .fr-container + %h1.fr-h3 Avis +.fr-container %ul.procedure-list.fr-pl-0 - @avis_by_procedure.each do |p, procedure_avis| %li.flex.align-start.fr-my-3w.fr-p-2w{ id: dom_id(p) } From 907bc8d97d3daeb145324f7f2e376a714d93b71b Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Thu, 2 May 2024 16:25:00 +0200 Subject: [PATCH 0130/1532] Instructor : Remove empty link --- app/views/instructeurs/procedures/_list.html.haml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/views/instructeurs/procedures/_list.html.haml b/app/views/instructeurs/procedures/_list.html.haml index 8a906f2e2..ca839cb6d 100644 --- a/app/views/instructeurs/procedures/_list.html.haml +++ b/app/views/instructeurs/procedures/_list.html.haml @@ -1,7 +1,6 @@ %li.flex.align-start.fr-mb-5w .flex - = link_to instructeur_procedure_path(p), class: 'procedure-logo-link' do - .procedure-logo{ style: "background-image: url(#{p.logo_url})" } + .procedure-logo{ style: "background-image: url(#{p.logo_url})" } .procedure-details .flex.clipboard-container From e01f5b7993b44e1eccdf651a7d959e347e12258b Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Thu, 25 Apr 2024 18:48:14 +0200 Subject: [PATCH 0131/1532] refactor(search): index search terms only when necessary --- .../instructeurs/dossiers_controller.rb | 1 + app/models/concerns/dossier_clone_concern.rb | 2 ++ .../concerns/dossier_searchable_concern.rb | 2 +- app/models/concerns/dossier_state_concern.rb | 2 ++ .../instructeurs/dossiers_controller_spec.rb | 1 + .../concerns/dossier_clone_concern_spec.rb | 11 ++++++++-- .../dossier_searchable_concern_spec.rb | 20 ++++++++++++++++--- 7 files changed, 33 insertions(+), 6 deletions(-) diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index 8063e0438..b8a40d673 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -280,6 +280,7 @@ module Instructeurs end dossier.save(context: :champs_private_value) + dossier.index_search_terms_later respond_to do |format| format.turbo_stream do diff --git a/app/models/concerns/dossier_clone_concern.rb b/app/models/concerns/dossier_clone_concern.rb index 86a135ae7..913af3e45 100644 --- a/app/models/concerns/dossier_clone_concern.rb +++ b/app/models/concerns/dossier_clone_concern.rb @@ -71,6 +71,7 @@ module DossierCloneConcern touch(:last_champ_updated_at) end reload + index_search_terms_later editing_fork.destroy_editing_fork! end @@ -119,6 +120,7 @@ module DossierCloneConcern end end + cloned_dossier.index_search_terms_later if !fork cloned_dossier.reload end diff --git a/app/models/concerns/dossier_searchable_concern.rb b/app/models/concerns/dossier_searchable_concern.rb index 2b52fe668..24457b68b 100644 --- a/app/models/concerns/dossier_searchable_concern.rb +++ b/app/models/concerns/dossier_searchable_concern.rb @@ -4,7 +4,7 @@ module DossierSearchableConcern extend ActiveSupport::Concern included do - after_commit :index_search_terms_later + after_commit :index_search_terms_later, if: -> { previously_new_record? || user_previously_changed? || mandataire_first_name_previously_changed? || mandataire_last_name_previously_changed? } SEARCH_TERMS_DEBOUNCE = 30.seconds diff --git a/app/models/concerns/dossier_state_concern.rb b/app/models/concerns/dossier_state_concern.rb index 1c99ffb65..e90902066 100644 --- a/app/models/concerns/dossier_state_concern.rb +++ b/app/models/concerns/dossier_state_concern.rb @@ -13,6 +13,8 @@ module DossierStateConcern MailTemplatePresenterService.create_commentaire_for_state(self, Dossier.states.fetch(:en_construction)) procedure.compute_dossiers_count + + index_search_terms_later end def after_commit_passer_en_construction diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index f4f065948..458654a6f 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -1041,6 +1041,7 @@ describe Instructeurs::DossiersController, type: :controller do expect(champ_drop_down_list.value).to eq('other value') expect(dossier.reload.last_champ_private_updated_at).to eq(now) expect(response).to have_http_status(200) + assert_enqueued_jobs(1, only: DossierIndexSearchTermsJob) } it 'updates the annotations' do diff --git a/spec/models/concerns/dossier_clone_concern_spec.rb b/spec/models/concerns/dossier_clone_concern_spec.rb index 6f182b1c3..234f4de53 100644 --- a/spec/models/concerns/dossier_clone_concern_spec.rb +++ b/spec/models/concerns/dossier_clone_concern_spec.rb @@ -49,9 +49,15 @@ RSpec.describe DossierCloneConcern do end it "updates search terms" do - subject + # In spec, dossier and flag reference are created just before deep clone, + # which keep the flag reference from the original, pointing to the original id. + # We have to remove the flag reference before the clone + dossier.remove_instance_variable(:@debounce_index_search_terms_flag_kredis_flag) + + perform_enqueued_jobs(only: DossierIndexSearchTermsJob) do + subject + end - perform_enqueued_jobs(only: DossierIndexSearchTermsJob) sql = "SELECT search_terms, private_search_terms FROM dossiers where id = :id" result = Dossier.connection.execute(Dossier.sanitize_sql_array([sql, id: new_dossier.id])).first @@ -334,6 +340,7 @@ RSpec.describe DossierCloneConcern do end updated_champ.update(value: 'new value') updated_repetition_champ.update(value: 'new value in repetition') + dossier.debounce_index_search_terms_flag.remove end it { expect { subject }.to change { dossier.reload.champs.size }.by(0) } diff --git a/spec/models/concerns/dossier_searchable_concern_spec.rb b/spec/models/concerns/dossier_searchable_concern_spec.rb index 069a6696e..273a2e3fe 100644 --- a/spec/models/concerns/dossier_searchable_concern_spec.rb +++ b/spec/models/concerns/dossier_searchable_concern_spec.rb @@ -29,14 +29,22 @@ describe DossierSearchableConcern do context 'with an update' do before do stub_const("DossierSearchableConcern::SEARCH_TERMS_DEBOUNCE", 1.second) + + # dossier creation trigger a first indexation and flag, + # so we we have to remove this flag + dossier.debounce_index_search_terms_flag.remove end - it "update columns" do + it "update columns en construction" do dossier.update( champs_public_attributes: [{ id: champ_public.id, value: 'nouvelle valeur publique' }], champs_private_attributes: [{ id: champ_private.id, value: 'nouvelle valeur privee' }] ) + assert_enqueued_jobs(1, only: DossierIndexSearchTermsJob) do + dossier.passer_en_construction + end + perform_enqueued_jobs(only: DossierIndexSearchTermsJob) expect(result["search_terms"]).to include('nouvelle valeur publique') @@ -52,15 +60,21 @@ describe DossierSearchableConcern do sleep 1.01.seconds assert_enqueued_jobs(1, only: DossierIndexSearchTermsJob) do - dossier.update(champs_public_attributes: [{ id: champ_public.id, value: rand(10).to_s }]) + dossier.index_search_terms_later end end end context 'mandataire' do it "update columns" do - dossier.update(mandataire_first_name: "Chris") + dossier.debounce_index_search_terms_flag.remove + + assert_enqueued_jobs(1, only: DossierIndexSearchTermsJob) do + dossier.update!(mandataire_first_name: "Chris") + end + perform_enqueued_jobs(only: DossierIndexSearchTermsJob) + expect(result["search_terms"]).to include("Chris") end end From ea821e1eac64ccf825f77d18f76cf43f459868d5 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 13 May 2024 13:29:30 +0200 Subject: [PATCH 0132/1532] fix: departement_type_de_champ#champ_value_for_tag bad signature --- app/models/types_de_champ/departement_type_de_champ.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/types_de_champ/departement_type_de_champ.rb b/app/models/types_de_champ/departement_type_de_champ.rb index 24ab7c490..c30750de7 100644 --- a/app/models/types_de_champ/departement_type_de_champ.rb +++ b/app/models/types_de_champ/departement_type_de_champ.rb @@ -17,7 +17,7 @@ class TypesDeChamp::DepartementTypeDeChamp < TypesDeChamp::TextTypeDeChamp end end - def champ_value_for_tag(path = :value) + def champ_value_for_tag(champ, path = :value) case path when :code champ.code From 1615b8ea28be3788e710a41fd29b0800dc1ca2e4 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 13 May 2024 14:01:43 +0200 Subject: [PATCH 0133/1532] chore(export): set sentry tag procedure id --- app/jobs/export_job.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/jobs/export_job.rb b/app/jobs/export_job.rb index 0ea91c844..d8e17da37 100644 --- a/app/jobs/export_job.rb +++ b/app/jobs/export_job.rb @@ -10,6 +10,8 @@ class ExportJob < ApplicationJob def perform(export) return if export.generated? + Sentry.set_tags(procedure: export.procedure.id) + export.compute_with_safe_stale_for_purge do export.compute end From 93c7b3f817c4f5d89ab38465763c9dc51966aea3 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Fri, 3 May 2024 11:39:40 +0200 Subject: [PATCH 0134/1532] Fix typo --- .../user_procedure_filter_component.en.yml | 2 +- .../user_procedure_filter_component.html.haml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.en.yml b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.en.yml index d6e123c4a..0d24cc437 100644 --- a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.en.yml +++ b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.en.yml @@ -1,4 +1,4 @@ -fr: +en: procedures: label: Filter by procedure prompt: All procedures diff --git a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml index 570ec76ff..810f7ecc0 100644 --- a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml +++ b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml @@ -1,5 +1,5 @@ = form_with(url: dossiers_path, method: :get, data: { controller: 'autosubmit' } ) do |f| = f.hidden_field :q, value: params[:q], id: nil - = f.label :procedure_id, t('.procedure.label'), class: 'sr-only' + = f.label :procedure_id, t('.procedures.label'), class: 'sr-only' .fr-input-group = f.select :procedure_id, options_for_select(@procedures_for_select, params[:procedure_id]), { prompt: t('.procedures.prompt') }, class: 'fr-select' From ac6b552acbbca274b8a20258dffb34f3a5a8231c Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Fri, 3 May 2024 12:25:24 +0200 Subject: [PATCH 0135/1532] Modify 'Filter by procedure' label --- .../user_procedure_filter_component.en.yml | 4 ++-- .../user_procedure_filter_component.fr.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.en.yml b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.en.yml index 0d24cc437..2aaa11f4e 100644 --- a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.en.yml +++ b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.en.yml @@ -1,4 +1,4 @@ en: procedures: - label: Filter by procedure - prompt: All procedures + label: Show files by procedure + prompt: Select a procedure diff --git a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.fr.yml b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.fr.yml index ae421496a..8ddedc3ae 100644 --- a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.fr.yml +++ b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.fr.yml @@ -1,4 +1,4 @@ fr: procedures: - label: Filtrer par démarche - prompt: Toutes les démarches + label: Afficher les dossiers par démarche + prompt: Sélectionner une démarche From 32114c01329c9e25e0a0ff8c718a72be815ec321 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Fri, 3 May 2024 12:27:19 +0200 Subject: [PATCH 0136/1532] Modify 'Search a file' label --- app/views/users/dossiers/index.html.haml | 4 ++-- config/locales/en.yml | 2 ++ config/locales/fr.yml | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/views/users/dossiers/index.html.haml b/app/views/users/dossiers/index.html.haml index b481b94c3..12172d3b9 100644 --- a/app/views/users/dossiers/index.html.haml +++ b/app/views/users/dossiers/index.html.haml @@ -13,8 +13,8 @@ #search-2.fr-search-bar = form_tag dossiers_path, method: :get, :role => "search", class: "flex width-100 fr-mb-5w" do = hidden_field_tag :procedure_id, params[:procedure_id] - = label_tag "q", t('views.users.dossiers.search.search_file'), class: 'fr-label' - = text_field_tag "q", "#{@search_terms if @search_terms.present?}", placeholder: t('views.users.dossiers.search.search_file'), class: "fr-input" + = label_tag "q", t('views.users.dossiers.search.label'), class: 'fr-label' + = text_field_tag "q", "#{@search_terms if @search_terms.present?}", placeholder: t('views.users.dossiers.search.prompt'), class: "fr-input" %button.fr-btn.fr-btn--sm = t('views.users.dossiers.search.simple') - if @procedures_for_select.size > 1 diff --git a/config/locales/en.yml b/config/locales/en.yml index ed95ea081..c8810859b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -485,6 +485,8 @@ en: edit_dossier_title: "Edit my file - You can modify your file as long as it has not been sent for processing" search: search_file: Search a file (File number, keywords) + prompt: (File number, last name / first name, keywords) + label: Search a file simple: Search result_term_title: Search result for « %{search_terms} » result_procedure_title: and procedure « %{procedure_libelle} » diff --git a/config/locales/fr.yml b/config/locales/fr.yml index f9f16fcee..a86999cf0 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -488,6 +488,8 @@ fr: edit_dossier_title: "Modifier mon dossier - Vous pouvez modifier votre dossier tant qu’il n’est pas passé en instruction" search: search_file: Rechercher un dossier (N° de dossier, mots-clés) + prompt: (N° de dossier, nom / prénom, mots-clés) + label: Rechercher un dossier simple: Rechercher result_term_title: Résultat de la recherche pour « %{search_terms} » result_procedure_title: et pour la procédure « %{procedure_libelle} » From 03409a798f67ef8f0a286d21d2af4798f3f13893 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Fri, 3 May 2024 12:42:13 +0200 Subject: [PATCH 0137/1532] Display 'Filter by procedure' label --- .../user_procedure_filter_component.html.haml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml index 810f7ecc0..3b9ac3d64 100644 --- a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml +++ b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml @@ -1,5 +1,4 @@ = form_with(url: dossiers_path, method: :get, data: { controller: 'autosubmit' } ) do |f| = f.hidden_field :q, value: params[:q], id: nil - = f.label :procedure_id, t('.procedures.label'), class: 'sr-only' - .fr-input-group - = f.select :procedure_id, options_for_select(@procedures_for_select, params[:procedure_id]), { prompt: t('.procedures.prompt') }, class: 'fr-select' + = f.label :procedure_id, t('.procedures.label'), class: 'fr-label' + = f.select :procedure_id, options_for_select(@procedures_for_select, params[:procedure_id]), { prompt: t('.procedures.prompt') }, class: 'fr-select' From 6f1b41b1a7d3fb801dede0f28e730533f6832fb0 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Fri, 3 May 2024 14:54:46 +0200 Subject: [PATCH 0138/1532] Remove .fr-search-bar .fr-label styles. And unse .sr-only instead --- app/assets/stylesheets/dsfr.scss | 15 +++++++++++++++ .../groupes_search_component.html.haml | 2 +- app/views/layouts/_search_dossiers_form.html.haml | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/dsfr.scss b/app/assets/stylesheets/dsfr.scss index f1e0344d4..89e2e966b 100644 --- a/app/assets/stylesheets/dsfr.scss +++ b/app/assets/stylesheets/dsfr.scss @@ -164,3 +164,18 @@ button.fr-tag-bug { border: 2px solid var(--border-action-high-grey); } } + +// On restaure la visibilité des éléments .fr-search-bar .fr-label (en appliquant les valeurs par défaut des différentes propriétés) +// Et on utilise la classe .sr-only pour masquer les éléments souhaités au cas par cas +.fr-search-bar .fr-label { + position: initial; + width: initial; + height: initial; + padding: initial; + margin: initial; + overflow: initial; + clip: initial; + white-space: initial; + border: initial; + display: block; // Pour cette valeur spécifique, on récupère celle de .fr-label +} diff --git a/app/components/procedure/groupes_search_component/groupes_search_component.html.haml b/app/components/procedure/groupes_search_component/groupes_search_component.html.haml index 7b5308e51..569e3f247 100644 --- a/app/components/procedure/groupes_search_component/groupes_search_component.html.haml +++ b/app/components/procedure/groupes_search_component/groupes_search_component.html.haml @@ -1,7 +1,7 @@ = form_with(url: admin_procedure_groupe_instructeurs_path(@procedure), method: :get) do #header-search.fr-search-bar.fr-mb-2w{ role: "search" } - = label_tag :q, 'Rechercher par nom', class: 'fr-label' + = label_tag :q, 'Rechercher par nom', class: 'sr-only' = text_field_tag :q, @query, class: 'fr-input', type: 'search', autocomplete: 'off', placeholder: 'Rechercher par nom' %button.fr-btn{ title: "Rechercher" } Rechercher - if @query.present? diff --git a/app/views/layouts/_search_dossiers_form.html.haml b/app/views/layouts/_search_dossiers_form.html.haml index f0aab616e..959616163 100644 --- a/app/views/layouts/_search_dossiers_form.html.haml +++ b/app/views/layouts/_search_dossiers_form.html.haml @@ -3,7 +3,7 @@ %button.fr-btn--close.fr-btn{ "aria-controls" => "search-modal", :title => t('close_modal', scope: [:layouts, :header]) }= t('close_modal', scope: [:layouts, :header]) #search-473.fr-search-bar.fr-search-bar--lg = form_tag recherche_index_path, method: :get, :role => "search", class: "flex width-100" do - = label_tag "q", t('views.users.dossiers.search.search_file'), class: 'fr-label' + = label_tag "q", t('views.users.dossiers.search.search_file'), class: 'sr-only' = text_field_tag "q", "#{@search_terms if @search_terms.present?}", placeholder: t('views.users.dossiers.search.search_file'), class: "fr-input" %button.fr-btn = t('views.users.dossiers.search.simple') From df489cc688acb24391a66f1b82c4accc30961761 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Fri, 3 May 2024 16:20:37 +0200 Subject: [PATCH 0139/1532] Modify id and for values to avoid conflict due to duplicate identifiers --- .../user_procedure_filter_component.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml index 3b9ac3d64..49a652197 100644 --- a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml +++ b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml @@ -1,4 +1,4 @@ = form_with(url: dossiers_path, method: :get, data: { controller: 'autosubmit' } ) do |f| = f.hidden_field :q, value: params[:q], id: nil - = f.label :procedure_id, t('.procedures.label'), class: 'fr-label' - = f.select :procedure_id, options_for_select(@procedures_for_select, params[:procedure_id]), { prompt: t('.procedures.prompt') }, class: 'fr-select' + = f.label :procedure_id, t('.procedures.label'), class: 'fr-label', for: 'procedure_select' + = f.select :procedure_id, options_for_select(@procedures_for_select, params[:procedure_id]), { prompt: t('.procedures.prompt') }, class: 'fr-select', id: 'procedure_select' From beda1814c87267bdf837312bbb000edfc3cc17c3 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Fri, 3 May 2024 16:26:37 +0200 Subject: [PATCH 0140/1532] Improve searchbar layout --- app/views/users/dossiers/index.html.haml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/views/users/dossiers/index.html.haml b/app/views/users/dossiers/index.html.haml index 12172d3b9..b06a6deb7 100644 --- a/app/views/users/dossiers/index.html.haml +++ b/app/views/users/dossiers/index.html.haml @@ -11,12 +11,13 @@ - if current_user.dossiers.count > 2 || current_user.dossiers_invites.count > 2 .fr-col #search-2.fr-search-bar - = form_tag dossiers_path, method: :get, :role => "search", class: "flex width-100 fr-mb-5w" do + = form_tag dossiers_path, method: :get, :role => "search", class: "width-100 fr-mb-5w" do = hidden_field_tag :procedure_id, params[:procedure_id] - = label_tag "q", t('views.users.dossiers.search.label'), class: 'fr-label' - = text_field_tag "q", "#{@search_terms if @search_terms.present?}", placeholder: t('views.users.dossiers.search.prompt'), class: "fr-input" - %button.fr-btn.fr-btn--sm - = t('views.users.dossiers.search.simple') + = label_tag "q", t('views.users.dossiers.search.label'), class: 'fr-label fr-mb-1w' + .flex + = text_field_tag "q", "#{@search_terms if @search_terms.present?}", placeholder: t('views.users.dossiers.search.prompt'), class: "fr-input" + %button.fr-btn.fr-btn--sm + = t('views.users.dossiers.search.simple') - if @procedures_for_select.size > 1 .fr-col = render Dossiers::UserProcedureFilterComponent.new(procedures_for_select: @procedures_for_select) From 3c6133276b666718a1e61d36b94ce4f02801c97a Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Mon, 13 May 2024 11:30:18 +0200 Subject: [PATCH 0141/1532] Replace autosubmit by submit button --- .../user_procedure_filter_component.en.yml | 1 + .../user_procedure_filter_component.fr.yml | 1 + .../user_procedure_filter_component.html.haml | 9 ++++++--- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.en.yml b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.en.yml index 2aaa11f4e..8577aa87d 100644 --- a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.en.yml +++ b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.en.yml @@ -2,3 +2,4 @@ en: procedures: label: Show files by procedure prompt: Select a procedure + button: Show diff --git a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.fr.yml b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.fr.yml index 8ddedc3ae..5bad01303 100644 --- a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.fr.yml +++ b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.fr.yml @@ -2,3 +2,4 @@ fr: procedures: label: Afficher les dossiers par démarche prompt: Sélectionner une démarche + button: Afficher diff --git a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml index 49a652197..5dc7f9c04 100644 --- a/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml +++ b/app/components/dossiers/user_procedure_filter_component/user_procedure_filter_component.html.haml @@ -1,4 +1,7 @@ -= form_with(url: dossiers_path, method: :get, data: { controller: 'autosubmit' } ) do |f| += form_with(url: dossiers_path, method: :get ) do |f| = f.hidden_field :q, value: params[:q], id: nil - = f.label :procedure_id, t('.procedures.label'), class: 'fr-label', for: 'procedure_select' - = f.select :procedure_id, options_for_select(@procedures_for_select, params[:procedure_id]), { prompt: t('.procedures.prompt') }, class: 'fr-select', id: 'procedure_select' + = f.label :procedure_id, t('.procedures.label'), class: 'fr-label fr-mb-1w', for: 'procedure_select' + .flex + = f.select :procedure_id, options_for_select(@procedures_for_select, params[:procedure_id]), { prompt: t('.procedures.prompt') }, class: 'fr-select fr-mr-1w', id: 'procedure_select' + %button.fr-btn.fr-btn--sm{ 'aria-label': t('.procedures.label') } + = t('.procedures.button') From e4979bc1a1dcac63de91f33cfcc08babec444a48 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Mon, 13 May 2024 11:31:16 +0200 Subject: [PATCH 0142/1532] Specifie the function of the submit button --- app/views/users/dossiers/index.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/users/dossiers/index.html.haml b/app/views/users/dossiers/index.html.haml index b06a6deb7..9a50902c1 100644 --- a/app/views/users/dossiers/index.html.haml +++ b/app/views/users/dossiers/index.html.haml @@ -17,7 +17,7 @@ .flex = text_field_tag "q", "#{@search_terms if @search_terms.present?}", placeholder: t('views.users.dossiers.search.prompt'), class: "fr-input" %button.fr-btn.fr-btn--sm - = t('views.users.dossiers.search.simple') + = t('views.users.dossiers.search.label') - if @procedures_for_select.size > 1 .fr-col = render Dossiers::UserProcedureFilterComponent.new(procedures_for_select: @procedures_for_select) From f766a6fb5e252736a3f6fa9dce671ad7dcb0fc61 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 13 May 2024 15:03:28 +0200 Subject: [PATCH 0143/1532] fix(dossier): fix projected value on linked_drop_down_list --- app/models/type_de_champ.rb | 8 ++++---- spec/services/dossier_projection_service_spec.rb | 9 +++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 78f5f6bac..fbc1d9361 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -687,7 +687,7 @@ class TypeDeChamp < ApplicationRecord def champ_value(type_champ, champ) dynamic_type_class = type_champ_to_class_name(type_champ).constantize # special case for linked drop down champ – it's blank implementation is not what you think - if champ.blank? && (type_champ != TypeDeChamp.type_champs.fetch(:linked_drop_down_list) || champ.nil? || champ.value.blank?) + if (type_champ != TypeDeChamp.type_champs.fetch(:linked_drop_down_list) || champ.nil? || champ.value.blank?) && champ.blank? dynamic_type_class.champ_default_value else dynamic_type_class.champ_value(champ) @@ -697,7 +697,7 @@ class TypeDeChamp < ApplicationRecord def champ_value_for_api(type_champ, champ, version = 2) dynamic_type_class = type_champ_to_class_name(type_champ).constantize # special case for linked drop down champ – it's blank implementation is not what you think - if champ.blank? && (type_champ != TypeDeChamp.type_champs.fetch(:linked_drop_down_list) || champ.nil? || champ.value.blank?) + if (type_champ != TypeDeChamp.type_champs.fetch(:linked_drop_down_list) || champ.nil? || champ.value.blank?) && champ.blank? dynamic_type_class.champ_default_api_value(version) else dynamic_type_class.champ_value_for_api(champ, version) @@ -707,7 +707,7 @@ class TypeDeChamp < ApplicationRecord def champ_value_for_export(type_champ, champ, path = :value) dynamic_type_class = type_champ_to_class_name(type_champ).constantize # special case for linked drop down champ – it's blank implementation is not what you think - if champ.blank? && (type_champ != TypeDeChamp.type_champs.fetch(:linked_drop_down_list) || champ.nil? || champ.value.blank?) + if (type_champ != TypeDeChamp.type_champs.fetch(:linked_drop_down_list) || champ.nil? || champ.value.blank?) && champ.blank? dynamic_type_class.champ_default_export_value(path) else dynamic_type_class.champ_value_for_export(champ, path) @@ -717,7 +717,7 @@ class TypeDeChamp < ApplicationRecord def champ_value_for_tag(type_champ, champ, path = :value) dynamic_type_class = type_champ_to_class_name(type_champ).constantize # special case for linked drop down champ – it's blank implementation is not what you think - if champ.blank? && (type_champ != TypeDeChamp.type_champs.fetch(:linked_drop_down_list) || champ.nil? || champ.value.blank?) + if (type_champ != TypeDeChamp.type_champs.fetch(:linked_drop_down_list) || champ.nil? || champ.value.blank?) && champ.blank? '' else dynamic_type_class.champ_value_for_tag(champ, path) diff --git a/spec/services/dossier_projection_service_spec.rb b/spec/services/dossier_projection_service_spec.rb index 14865a971..278916762 100644 --- a/spec/services/dossier_projection_service_spec.rb +++ b/spec/services/dossier_projection_service_spec.rb @@ -3,23 +3,24 @@ describe DossierProjectionService do subject { described_class.project(dossiers_ids, fields) } context 'with multiple dossier' do - let!(:procedure) { create(:procedure, :with_type_de_champ) } + let!(:procedure) { create(:procedure, types_de_champ_public: [{}, { type: :linked_drop_down_list }]) } let!(:dossier_1) { create(:dossier, procedure: procedure) } let!(:dossier_2) { create(:dossier, :en_construction, :archived, procedure: procedure) } let!(:dossier_3) { create(:dossier, :en_instruction, procedure: procedure) } let(:dossiers_ids) { [dossier_3.id, dossier_1.id, dossier_2.id] } let(:fields) do - [ + procedure.active_revision.types_de_champ_public.map do |type_de_champ| { "table" => "type_de_champ", - "column" => procedure.active_revision.types_de_champ_public[0].stable_id.to_s + "column" => type_de_champ.stable_id.to_s } - ] + end end before do dossier_1.champs_public.first.update(value: 'champ_1') + dossier_1.champs_public.second.update(value: '["test"]') dossier_2.champs_public.first.update(value: 'champ_2') dossier_3.champs_public.first.destroy end From 8fd6f58beb38166ec9fcf72f926975db76599c9e Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Fri, 5 Apr 2024 16:26:24 +0200 Subject: [PATCH 0144/1532] feat(fix-typo) : Correct incorrect abbreviations --- .../estimated_fill_duration_component.en.yml | 2 +- config/locales/en.yml | 4 ++-- config/locales/fr.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/components/types_de_champ_editor/estimated_fill_duration_component/estimated_fill_duration_component.en.yml b/app/components/types_de_champ_editor/estimated_fill_duration_component/estimated_fill_duration_component.en.yml index 7b9aa565c..b8c59bd70 100644 --- a/app/components/types_de_champ_editor/estimated_fill_duration_component/estimated_fill_duration_component.en.yml +++ b/app/components/types_de_champ_editor/estimated_fill_duration_component/estimated_fill_duration_component.en.yml @@ -1,3 +1,3 @@ en: estimated_fill_duration: "Estimated fill time:" - estimated_fill_minutes: "%{estimated_minutes} mn" + estimated_fill_minutes: "%{estimated_minutes} mins." diff --git a/config/locales/en.yml b/config/locales/en.yml index ed95ea081..c99a17322 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -907,9 +907,9 @@ en: sva: "Silence Vaut Accord" svr: "Silence Vaut Rejet" procedure_description: - estimated_fill_duration: "Estimated fill time: %{estimated_minutes} mn" + estimated_fill_duration: "Estimated fill time: %{estimated_minutes} mins." estimated_fill_duration_title: What is the procedure estimated fill time ? - estimated_fill_duration_detail: "The fill time is etimated to %{estimated_minutes} min. This period may vary depending on the options you choose" + estimated_fill_duration_detail: "The fill time is etimated to %{estimated_minutes} mins. This period may vary depending on the options you choose" usual_traitement_time_title: What are the processing times for this procedure? pieces_jointes : What are the required attachments ? pieces_jointes_conditionnal_list_title : Attachments list according to your situation diff --git a/config/locales/fr.yml b/config/locales/fr.yml index f9f16fcee..d6a4314b9 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -978,7 +978,7 @@ fr: sva: "Silence Vaut Accord" svr: "Silence Vaut Rejet" procedure_description: - estimated_fill_duration: "Temps de remplissage estimé : %{estimated_minutes} mn" + estimated_fill_duration: "Temps de remplissage estimé : %{estimated_minutes} min" estimated_fill_duration_title: Quelle est la durée de remplissage de la démarche ? estimated_fill_duration_detail: "La durée de remplissage est estimée à %{estimated_minutes} min. Ce délai peut varier selon les options que vous choisirez." usual_traitement_time_title: Quels sont les délais d'instruction pour cette démarche ? From 71c1d7ac9ac0a3a08622c0e2c7114174cbe8ef07 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 13 May 2024 15:55:39 +0200 Subject: [PATCH 0145/1532] feat(brouillon): index search terms debounced when updating brouillon --- app/controllers/users/dossiers_controller.rb | 2 ++ spec/controllers/users/dossiers_controller_spec.rb | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index 92f4d00a8..1b9e1c05c 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -296,6 +296,8 @@ module Users @dossier = dossier_with_champs(pj_template: false) @errors = update_dossier_and_compute_errors + @dossier.index_search_terms_later if @errors.empty? + respond_to do |format| format.turbo_stream do @to_show, @to_hide, @to_update = champs_to_turbo_update(champs_public_attributes_params, dossier.champs.filter(&:public?)) diff --git a/spec/controllers/users/dossiers_controller_spec.rb b/spec/controllers/users/dossiers_controller_spec.rb index ad99422a4..e0fd3501e 100644 --- a/spec/controllers/users/dossiers_controller_spec.rb +++ b/spec/controllers/users/dossiers_controller_spec.rb @@ -770,6 +770,16 @@ describe Users::DossiersController, type: :controller do end end end + + it "debounce search terms indexation" do + # dossier creation trigger a first indexation and flag, + # so we we have to remove this flag + dossier.debounce_index_search_terms_flag.remove + + assert_enqueued_jobs(1, only: DossierIndexSearchTermsJob) do + 3.times { patch :update, params: payload, format: :turbo_stream } + end + end end describe '#update en_construction' do From 8d9b701120fab34b271eb4de62a885c0a49fd962 Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Mon, 13 May 2024 16:05:14 +0200 Subject: [PATCH 0146/1532] Replace aria_hidden by aria-hidden --- app/views/administrateurs/procedures/_informations.html.haml | 4 ++-- app/views/layouts/_display_theme_modal.html.haml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/administrateurs/procedures/_informations.html.haml b/app/views/administrateurs/procedures/_informations.html.haml index 4fc3224b8..fbc56387d 100644 --- a/app/views/administrateurs/procedures/_informations.html.haml +++ b/app/views/administrateurs/procedures/_informations.html.haml @@ -101,7 +101,7 @@ Ma démarche s’adresse à un particulier %span.fr-hint-text En choisissant cette option, l’usager devra renseigner son nom et prénom avant d’accéder au formulaire .fr-radio-rich__img - %svg.fr-artwork{ aria_hidden: "true", viewBox: "0 0 80 80", width: "80px", height: "80px" } + %svg.fr-artwork{ "aria-hidden": "true", viewBox: "0 0 80 80", width: "80px", height: "80px" } %use.fr-artwork-decorative{ href: image_path("pictograms/digital/avatar.svg#artwork-decorative") } %use.fr-artwork-minor{ href: image_path("pictograms/digital/avatar.svg#artwork-minor") } %use.fr-artwork-major{ href: image_path("pictograms/digital/avatar.svg#artwork-major") } @@ -114,7 +114,7 @@ %span.fr-hint-text En choisissant cette option, l’usager devra renseigner son n° SIRET.
    Grâce à l’API Entreprise, les informations sur la personne morale (raison sociale, adresse du siège, etc.) seront automatiquement renseignées. .fr-radio-rich__img - %svg.fr-artwork{ aria_hidden: "true", viewBox: "0 0 80 80", width: "80px", height: "80px" } + %svg.fr-artwork{ "aria-hidden": "true", viewBox: "0 0 80 80", width: "80px", height: "80px" } %use.fr-artwork-decorative{ href: image_path("pictograms/buildings/school.svg#artwork-decorative") } %use.fr-artwork-minor{ href: image_path("pictograms/buildings/school.svg#artwork-minor") } %use.fr-artwork-major{ href: image_path("pictograms/buildings/school.svg#artwork-major") } diff --git a/app/views/layouts/_display_theme_modal.html.haml b/app/views/layouts/_display_theme_modal.html.haml index 237a5f966..4d44b06b7 100644 --- a/app/views/layouts/_display_theme_modal.html.haml +++ b/app/views/layouts/_display_theme_modal.html.haml @@ -15,7 +15,7 @@ %input#fr-radios-theme-light{ name: "fr-radios-theme", type: "radio", value: "light" }/ %label.fr-label{ for: "fr-radios-theme-light" } Thème clair .fr-radio-rich__img - %svg.fr-artwork{ aria_hidden: "true", viewBox: "0 0 80 80", width: "80px", height: "80px" } + %svg.fr-artwork{ "aria-hidden": "true", viewBox: "0 0 80 80", width: "80px", height: "80px" } %use.fr-artwork-decorative{ href: image_path("pictograms/environment/sun.svg#artwork-decorative") } %use.fr-artwork-minor{ href: image_path("pictograms/environment/sun.svg#artwork-minor") } %use.fr-artwork-major{ href: image_path("pictograms/environment/sun.svg#artwork-major") } From b81c6db96dda68dba77e2486c1b3c07032bad4ba Mon Sep 17 00:00:00 2001 From: Corinne Durrmeyer Date: Mon, 13 May 2024 11:54:06 +0200 Subject: [PATCH 0147/1532] Update .rb file, fix specs --- spec/system/users/list_dossiers_spec.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/spec/system/users/list_dossiers_spec.rb b/spec/system/users/list_dossiers_spec.rb index 5325c2cc0..1ca3277ce 100644 --- a/spec/system/users/list_dossiers_spec.rb +++ b/spec/system/users/list_dossiers_spec.rb @@ -280,6 +280,7 @@ describe 'user access to the list of their dossiers', js: true do it "can be filtered by procedure and display the result - one item" do select dossier_en_construction.procedure.libelle, from: 'procedure_id' + click_on 'Afficher' expect(page).to have_link(dossier_en_construction.procedure.libelle) expect(page).not_to have_link(dossier_with_champs.procedure.libelle) expect(page).to have_text("1 dossier") @@ -287,6 +288,7 @@ describe 'user access to the list of their dossiers', js: true do it "can be filtered by procedure and display the result - no item" do select dossier_brouillon.procedure.libelle, from: 'procedure_id' + click_on 'Afficher' expect(page).not_to have_link(String(dossier_en_construction.id)) expect(page).not_to have_link(String(dossier_with_champs.id)) expect(page).to have_content("Résultat de la recherche pour « #{dossier_en_construction.champs_public.first.value} » et pour la procédure « #{dossier_brouillon.procedure.libelle} » ") @@ -301,8 +303,9 @@ describe 'user access to the list of their dossiers', js: true do it "can filter by procedure" do expect(page).to have_text('7 en cours') expect(page).to have_text('3 traités') - expect(page).to have_select('procedure_id', selected: 'Toutes les démarches') + expect(page).to have_select('procedure_id', selected: 'Sélectionner une démarche') select dossier_brouillon.procedure.libelle, from: 'procedure_id' + click_on 'Afficher' expect(page).to have_text('1 en cours') end end From a1db0896811585edd6dd8fe45c6a189c868ef2ae Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 13 May 2024 15:31:29 +0200 Subject: [PATCH 0148/1532] fix(champ): champ value formatters should check champ type --- app/models/type_de_champ.rb | 27 +++++++++++++++-------- spec/models/champ_spec.rb | 8 +++++++ spec/serializers/champ_serializer_spec.rb | 2 +- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index fbc1d9361..65c743350 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -686,8 +686,7 @@ class TypeDeChamp < ApplicationRecord class << self def champ_value(type_champ, champ) dynamic_type_class = type_champ_to_class_name(type_champ).constantize - # special case for linked drop down champ – it's blank implementation is not what you think - if (type_champ != TypeDeChamp.type_champs.fetch(:linked_drop_down_list) || champ.nil? || champ.value.blank?) && champ.blank? + if use_default_value?(type_champ, champ) dynamic_type_class.champ_default_value else dynamic_type_class.champ_value(champ) @@ -696,8 +695,7 @@ class TypeDeChamp < ApplicationRecord def champ_value_for_api(type_champ, champ, version = 2) dynamic_type_class = type_champ_to_class_name(type_champ).constantize - # special case for linked drop down champ – it's blank implementation is not what you think - if (type_champ != TypeDeChamp.type_champs.fetch(:linked_drop_down_list) || champ.nil? || champ.value.blank?) && champ.blank? + if use_default_value?(type_champ, champ) dynamic_type_class.champ_default_api_value(version) else dynamic_type_class.champ_value_for_api(champ, version) @@ -706,8 +704,7 @@ class TypeDeChamp < ApplicationRecord def champ_value_for_export(type_champ, champ, path = :value) dynamic_type_class = type_champ_to_class_name(type_champ).constantize - # special case for linked drop down champ – it's blank implementation is not what you think - if (type_champ != TypeDeChamp.type_champs.fetch(:linked_drop_down_list) || champ.nil? || champ.value.blank?) && champ.blank? + if use_default_value?(type_champ, champ) dynamic_type_class.champ_default_export_value(path) else dynamic_type_class.champ_value_for_export(champ, path) @@ -715,11 +712,10 @@ class TypeDeChamp < ApplicationRecord end def champ_value_for_tag(type_champ, champ, path = :value) - dynamic_type_class = type_champ_to_class_name(type_champ).constantize - # special case for linked drop down champ – it's blank implementation is not what you think - if (type_champ != TypeDeChamp.type_champs.fetch(:linked_drop_down_list) || champ.nil? || champ.value.blank?) && champ.blank? + if use_default_value?(type_champ, champ) '' else + dynamic_type_class = type_champ_to_class_name(type_champ).constantize dynamic_type_class.champ_value_for_tag(champ, path) end end @@ -731,6 +727,19 @@ class TypeDeChamp < ApplicationRecord def type_champ_to_class_name(type_champ) "TypesDeChamp::#{type_champ.classify}TypeDeChamp" end + + private + + def use_default_value?(type_champ, champ) + # no champ + return true if champ.nil? + # type de champ on the revision changed + return true if type_champ_to_champ_class_name(type_champ) != champ.type + # special case for linked drop down champ – it's blank implementation is not what you think + return champ.value.blank? if type_champ == TypeDeChamp.type_champs.fetch(:linked_drop_down_list) + + champ.blank? + end end private diff --git a/spec/models/champ_spec.rb b/spec/models/champ_spec.rb index 1501b3fe7..229b440a4 100644 --- a/spec/models/champ_spec.rb +++ b/spec/models/champ_spec.rb @@ -185,6 +185,14 @@ describe Champ do it { expect(champ.for_export).to eq('Crétinier, Mousserie') } end + + context 'when type_de_champ and champ.type mismatch' do + let(:champ_yes_no) { create(:champ_yes_no, value: 'true') } + let(:champ_text) { create(:champ_text, value: 'Hello') } + + it { expect(TypeDeChamp.champ_value_for_export(champ_text.type_champ, champ_yes_no)).to eq(nil) } + it { expect(TypeDeChamp.champ_value_for_export(champ_yes_no.type_champ, champ_text)).to eq('Non') } + end end describe '#search_terms' do diff --git a/spec/serializers/champ_serializer_spec.rb b/spec/serializers/champ_serializer_spec.rb index 651fe6d38..c7c902a13 100644 --- a/spec/serializers/champ_serializer_spec.rb +++ b/spec/serializers/champ_serializer_spec.rb @@ -12,7 +12,7 @@ describe ChampSerializer do end context 'when type champ is not piece justificative' do - let(:champ) { create(:champ, value: "blah") } + let(:champ) { create(:champ_text, value: "blah") } it { is_expected.to include(value: "blah") } end From 700ce205be2a0b824f8d5ab357737117710a05e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 May 2024 23:51:22 +0000 Subject: [PATCH 0149/1532] chore(deps): bump nokogiri from 1.16.4 to 1.16.5 Bumps [nokogiri](https://github.com/sparklemotion/nokogiri) from 1.16.4 to 1.16.5. - [Release notes](https://github.com/sparklemotion/nokogiri/releases) - [Changelog](https://github.com/sparklemotion/nokogiri/blob/main/CHANGELOG.md) - [Commits](https://github.com/sparklemotion/nokogiri/compare/v1.16.4...v1.16.5) --- updated-dependencies: - dependency-name: nokogiri dependency-type: indirect ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index f6b627a6a..5eef464d1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -455,7 +455,7 @@ GEM net-smtp (0.4.0.1) net-protocol nio4r (2.7.1) - nokogiri (1.16.4) + nokogiri (1.16.5) mini_portile2 (~> 2.8.2) racc (~> 1.4) openid_connect (2.3.0) From 5195c8a56f61abf2729fc6afc77906e3d627845e Mon Sep 17 00:00:00 2001 From: Lisa Durand Date: Tue, 14 May 2024 09:47:19 +0200 Subject: [PATCH 0150/1532] add explanation and desactivate links for email customisation if AR is activated --- app/assets/stylesheets/procedure_admin.scss | 1 - .../email_template_card_component.rb | 8 +++++++ .../email_template_card_component.html.haml | 6 +++++- .../mail_templates/index.html.haml | 21 +++++++++++++++++-- .../procedures/accuse_lecture.html.haml | 2 +- 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/app/assets/stylesheets/procedure_admin.scss b/app/assets/stylesheets/procedure_admin.scss index 7f9b4c8c1..d13c97e93 100644 --- a/app/assets/stylesheets/procedure_admin.scss +++ b/app/assets/stylesheets/procedure_admin.scss @@ -25,7 +25,6 @@ .container { a { - cursor: pointer; overflow-wrap: break-word; } } diff --git a/app/components/procedure/email_template_card_component.rb b/app/components/procedure/email_template_card_component.rb index 8c636d061..d13e73371 100644 --- a/app/components/procedure/email_template_card_component.rb +++ b/app/components/procedure/email_template_card_component.rb @@ -32,4 +32,12 @@ class Procedure::EmailTemplateCardComponent < ApplicationComponent def edit_path edit_admin_procedure_mail_template_path(@email_template.procedure, @email_template.class.const_get(:SLUG)) end + + def final_decision_templates + [Mails::WithoutContinuationMail.const_get(:SLUG), Mails::RefusedMail.const_get(:SLUG), Mails::ClosedMail.const_get(:SLUG)] + end + + def not_editable? + @email_template.procedure.accuse_lecture? && final_decision_templates.include?(@email_template.class.const_get(:SLUG)) + end end diff --git a/app/components/procedure/email_template_card_component/email_template_card_component.html.haml b/app/components/procedure/email_template_card_component/email_template_card_component.html.haml index c3ce89d3e..3b61c7a94 100644 --- a/app/components/procedure/email_template_card_component/email_template_card_component.html.haml +++ b/app/components/procedure/email_template_card_component/email_template_card_component.html.haml @@ -1,3 +1,7 @@ = render Dsfr::CardVerticalComponent.new(title: title, desc: desc, error: error, tags: [tag]) do |c| - c.with_footer_button do - = link_to 'Modifier', edit_path, class: 'fr-btn' + - if not_editable? + %a{ role: "link", "aria-disabled" => "true", class: 'fr-link fr-icon-arrow-right-line fr-link--icon-right' } + Modifier + - else + = link_to 'Modifier', edit_path, class: 'fr-link fr-icon-arrow-right-line fr-link--icon-right' diff --git a/app/views/administrateurs/mail_templates/index.html.haml b/app/views/administrateurs/mail_templates/index.html.haml index 07345a1fa..2aa43246d 100644 --- a/app/views/administrateurs/mail_templates/index.html.haml +++ b/app/views/administrateurs/mail_templates/index.html.haml @@ -3,8 +3,25 @@ ["#{@procedure.libelle.truncate_words(10)}", admin_procedure_path(@procedure)], ["Configuration des emails"]] } -.container - .fr-grid-row.fr-grid-row--gutters.fr-py-5w +.fr-container + .fr-grid-row.fr-grid-row--gutters + .fr-col-12 + %h1 Configuration des emails + - if @procedure.accuse_lecture? + = render Dsfr::AlertComponent.new(state: :info, size: :sm) do |c| + - c.with_body do + %p + L'accusé de lecture est activé sur cette démarche. Dans ce contexte, les emails « d’acceptation », « de rejet » et de « classement sans suite », ne sont pas modifiables afin de s'assurer que la décision finale reste masquée pour l'usager. + - @mail_templates.each do |mail_template| .fr-col-md-6.fr-col-12 = render Procedure::EmailTemplateCardComponent.new(email_template: mail_template) + + +.padded-fixed-footer + .fixed-footer + .fr-container + .fr-grid-row + .fr-col-12.fr-pb-2w + = link_to admin_procedure_path(id: @procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w' do + Revenir à la démarche diff --git a/app/views/administrateurs/procedures/accuse_lecture.html.haml b/app/views/administrateurs/procedures/accuse_lecture.html.haml index 13e998dec..42465f148 100644 --- a/app/views/administrateurs/procedures/accuse_lecture.html.haml +++ b/app/views/administrateurs/procedures/accuse_lecture.html.haml @@ -29,7 +29,7 @@ = render Dsfr::ToggleComponent.new(form: f, target: :accuse_lecture, - title: "Accusé de lecture de la démarche", + title: "Accusé de lecture de la décision par l’usager", hint: "L’accusé de lecture est à activer uniquement pour les démarches avec voies de recours car il complexifie l’accès à la décision finale pour les usagers", opt: {"checked" => @procedure.accuse_lecture}) From 592f040ada2334ef33dc8b44e9220c3d9b23eb35 Mon Sep 17 00:00:00 2001 From: Christian Lautier <15379878+maatinito@users.noreply.github.com> Date: Thu, 18 Apr 2024 08:58:33 -1000 Subject: [PATCH 0151/1532] Procedure champ editor: place the 'Move champ after' selection in the champ's header. --- app/assets/stylesheets/procedure_champs_editor.scss | 1 + .../champ_component/champ_component.html.haml | 2 +- .../select_champ_position_component.html.haml | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/procedure_champs_editor.scss b/app/assets/stylesheets/procedure_champs_editor.scss index 6f1db8ac1..b12f0ef46 100644 --- a/app/assets/stylesheets/procedure_champs_editor.scss +++ b/app/assets/stylesheets/procedure_champs_editor.scss @@ -52,6 +52,7 @@ .head { select { margin-bottom: 0px; + font-size: smaller; } } diff --git a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml index c7ce05900..26f4c5882 100644 --- a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml +++ b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml @@ -4,6 +4,7 @@ .position.flex.align-center= (@coordinate.position + 1).to_s %button.fr-btn.fr-btn--tertiary-no-outline.fr-icon-arrow-up-line.move-up{ move_button_options(:up) } %button.fr-btn.fr-btn--tertiary-no-outline.fr-icon-arrow-down-line.move-down{ move_button_options(:down) } + = render TypesDeChampEditor::SelectChampPositionComponent.new(revision:, coordinate:) .flex.right - if coordinate.used_by_routing_rules? @@ -141,4 +142,3 @@ .type-de-champ-add-button{ class: class_names(root: !coordinate.child?, flex: true) } = render TypesDeChampEditor::AddChampButtonComponent.new(revision: coordinate.revision, parent: coordinate&.parent, is_annotation: coordinate.private?, after_stable_id: type_de_champ.stable_id) - = render TypesDeChampEditor::SelectChampPositionComponent.new(revision:, coordinate:) diff --git a/app/components/types_de_champ_editor/select_champ_position_component/select_champ_position_component.html.haml b/app/components/types_de_champ_editor/select_champ_position_component/select_champ_position_component.html.haml index c51b1d287..e997bc45c 100644 --- a/app/components/types_de_champ_editor/select_champ_position_component/select_champ_position_component.html.haml +++ b/app/components/types_de_champ_editor/select_champ_position_component/select_champ_position_component.html.haml @@ -1,3 +1,3 @@ = form_with(url: move_and_morph_admin_procedure_type_de_champ_path(@coordinate.revision.procedure, @coordinate.type_de_champ.stable_id), class: 'fr-ml-3w flex', method: :patch, data: { turbo: true }) do |f| - = label_tag :target_stable_id, "Déplacer le champ après ", for: describedby_id, class: 'flex align-center flex-no-shrink fr-mr-3w' + = label_tag :target_stable_id, "Déplacer le champ après ", for: describedby_id, class: 'flex align-center flex-no-shrink fr-mr-3w fr-hint-text' = select_tag :target_stable_id, options_for_select(options), id: describedby_id, class: 'fr-select', data: { 'select-champ-position-template-target': 'select', selected: @coordinate.stable_id } From 55a4c73f4a9bb96e4a7258ae30e497cbc48325c2 Mon Sep 17 00:00:00 2001 From: Christian Lautier <15379878+maatinito@users.noreply.github.com> Date: Mon, 22 Apr 2024 07:52:37 -1000 Subject: [PATCH 0152/1532] Stick to DSFR design. --- app/assets/stylesheets/procedure_champs_editor.scss | 1 - .../select_champ_position_component.html.haml | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/assets/stylesheets/procedure_champs_editor.scss b/app/assets/stylesheets/procedure_champs_editor.scss index b12f0ef46..6f1db8ac1 100644 --- a/app/assets/stylesheets/procedure_champs_editor.scss +++ b/app/assets/stylesheets/procedure_champs_editor.scss @@ -52,7 +52,6 @@ .head { select { margin-bottom: 0px; - font-size: smaller; } } diff --git a/app/components/types_de_champ_editor/select_champ_position_component/select_champ_position_component.html.haml b/app/components/types_de_champ_editor/select_champ_position_component/select_champ_position_component.html.haml index e997bc45c..0c7f0314a 100644 --- a/app/components/types_de_champ_editor/select_champ_position_component/select_champ_position_component.html.haml +++ b/app/components/types_de_champ_editor/select_champ_position_component/select_champ_position_component.html.haml @@ -1,3 +1,3 @@ = form_with(url: move_and_morph_admin_procedure_type_de_champ_path(@coordinate.revision.procedure, @coordinate.type_de_champ.stable_id), class: 'fr-ml-3w flex', method: :patch, data: { turbo: true }) do |f| - = label_tag :target_stable_id, "Déplacer le champ après ", for: describedby_id, class: 'flex align-center flex-no-shrink fr-mr-3w fr-hint-text' + = label_tag :target_stable_id, "Déplacer le champ après ", for: describedby_id, class: 'flex align-center flex-no-shrink fr-mr-3w fr-label' = select_tag :target_stable_id, options_for_select(options), id: describedby_id, class: 'fr-select', data: { 'select-champ-position-template-target': 'select', selected: @coordinate.stable_id } From 7f25f1eba1e40c7b01abae33b8006b82f6912573 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 15 May 2024 10:10:37 +0200 Subject: [PATCH 0153/1532] chore: switch domain feature sticky to user --- app/helpers/application_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 3eb353398..71a35dafd 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -13,7 +13,7 @@ module ApplicationHelper end def switch_domain_enabled?(request) - request.params.key?(:switch_domain) || Flipper.enabled?(:switch_domain) + request.params.key?(:switch_domain) || Flipper.enabled?(:switch_domain, Current.user) end def html_lang From 50735919c8e152762e44e8453f6a71bf940a01d2 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 15 May 2024 14:36:52 +0200 Subject: [PATCH 0154/1532] fix(for_tiers): do not update for_tiers column to render the form. otherwise user could switch to dossier.for_tiers without filling mandataire required fields when he updates his identity --- .../individual_form_component.html.haml | 1 + app/controllers/users/dossiers_controller.rb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/components/dossiers/individual_form_component/individual_form_component.html.haml b/app/components/dossiers/individual_form_component/individual_form_component.html.haml index 97b91976c..38a5b2b19 100644 --- a/app/components/dossiers/individual_form_component/individual_form_component.html.haml +++ b/app/components/dossiers/individual_form_component/individual_form_component.html.haml @@ -1,4 +1,5 @@ = form_for @dossier, url: update_identite_dossier_path(@dossier), html: { id: 'identite-form', class: "form", "data-controller" => "for-tiers" } do |f| + = f.hidden_field :for_tiers - if for_tiers? .fr-alert.fr-alert--info.fr-mb-2w %p.fr-notice__text diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index 08ecd2f55..c80792af4 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -139,7 +139,7 @@ module Users respond_to do |format| format.html format.turbo_stream do - @dossier.update_columns(params.require(:dossier).permit(:for_tiers).to_h) + @dossier.for_tiers = params[:dossier][:for_tiers] end end end From 68a624be9e6d909d95bbedfb2387cade8ad32e59 Mon Sep 17 00:00:00 2001 From: mfo Date: Wed, 15 May 2024 14:42:37 +0200 Subject: [PATCH 0155/1532] fix(data): backfill invalid dossier for tiers without tiers info --- .../backfill_invalid_dossiers_for_tiers_task.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 app/tasks/maintenance/backfill_invalid_dossiers_for_tiers_task.rb diff --git a/app/tasks/maintenance/backfill_invalid_dossiers_for_tiers_task.rb b/app/tasks/maintenance/backfill_invalid_dossiers_for_tiers_task.rb new file mode 100644 index 000000000..ac99289eb --- /dev/null +++ b/app/tasks/maintenance/backfill_invalid_dossiers_for_tiers_task.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Maintenance + class BackfillInvalidDossiersForTiersTask < MaintenanceTasks::Task + def collection + Dossier.where(for_tiers: true).where(mandataire_first_name: nil) + end + + def process(element) + element.update_column(for_tiers: false) + end + end +end From bcd3f3b471c045eae5669eb5bafec0796a0a317a Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Mon, 22 Apr 2024 10:47:47 +0200 Subject: [PATCH 0156/1532] refactor(champs): change views to use new urls with stable_id and row_id --- .../editable_champ/carte_component.rb | 8 +++++ .../carte_component/carte_component.html.haml | 2 +- .../editable_champ_component.rb | 12 +++++-- .../editable_champ_component.html.haml | 5 ++- .../multiple_drop_down_list_component.rb | 8 +++++ ...ultiple_drop_down_list_component.html.haml | 2 +- .../editable_champ/rna_component.rb | 8 +++++ .../rna_component/rna_component.html.haml | 2 +- .../editable_champ/siret_component.rb | 10 +++++- .../siret_component/siret_component.html.haml | 2 +- app/controllers/champs/options_controller.rb | 1 + .../instructeurs/dossiers_controller.rb | 5 +-- app/controllers/users/dossiers_controller.rb | 5 +-- app/helpers/champ_helper.rb | 6 +++- app/javascript/components/MapEditor/hooks.ts | 14 ++++++-- app/models/champ.rb | 4 +++ .../_piece_justificative_template.html.haml | 2 +- config/initializers/flipper.rb | 3 +- config/routes.rb | 32 +++++++++---------- .../piece_justificative_controller_spec.rb | 6 ++-- .../controllers/champs/rna_controller_spec.rb | 3 +- 21 files changed, 104 insertions(+), 36 deletions(-) diff --git a/app/components/editable_champ/carte_component.rb b/app/components/editable_champ/carte_component.rb index 864887f15..792995b35 100644 --- a/app/components/editable_champ/carte_component.rb +++ b/app/components/editable_champ/carte_component.rb @@ -9,4 +9,12 @@ class EditableChamp::CarteComponent < EditableChamp::EditableChampBaseComponent @autocomplete_component = EditableChamp::ComboSearchComponent.new(**args) end + + def update_path + if Champ.update_by_stable_id? + champs_carte_features_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id) + else + champs_legacy_carte_features_path(@champ) + end + end end diff --git a/app/components/editable_champ/carte_component/carte_component.html.haml b/app/components/editable_champ/carte_component/carte_component.html.haml index ad054669d..db14600f9 100644 --- a/app/components/editable_champ/carte_component/carte_component.html.haml +++ b/app/components/editable_champ/carte_component/carte_component.html.haml @@ -4,7 +4,7 @@ = react_component("MapEditor", { featureCollection: @champ.to_feature_collection, champId: @champ.input_id, - url: champs_carte_features_path(@champ), + url: update_path, options: @champ.render_options, autocompleteAnnounceTemplateId: @autocomplete_component.announce_template_id, autocompleteScreenReaderInstructions: t("combo_search_component.screen_reader_instructions") }, diff --git a/app/components/editable_champ/editable_champ_component.rb b/app/components/editable_champ/editable_champ_component.rb index 3e3177a19..8bc6f4cac 100644 --- a/app/components/editable_champ/editable_champ_component.rb +++ b/app/components/editable_champ/editable_champ_component.rb @@ -54,9 +54,17 @@ class EditableChamp::EditableChampComponent < ApplicationComponent def turbo_poll_url_value if @champ.private? - annotation_instructeur_dossier_path(@champ.dossier.procedure, @champ.dossier, @champ) + if Champ.update_by_stable_id? + annotation_instructeur_dossier_path(@champ.dossier.procedure, @champ.dossier, @champ.stable_id, row_id: @champ.row_id, with_public_id: true) + else + annotation_instructeur_dossier_path(@champ.dossier.procedure, @champ.dossier, @champ) + end else - champ_dossier_path(@champ.dossier, @champ) + if Champ.update_by_stable_id? + champ_dossier_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id, with_public_id: true) + else + champ_dossier_path(@champ.dossier, @champ) + end end end diff --git a/app/components/editable_champ/editable_champ_component/editable_champ_component.html.haml b/app/components/editable_champ/editable_champ_component/editable_champ_component.html.haml index 84c45bb17..9608eded7 100644 --- a/app/components/editable_champ/editable_champ_component/editable_champ_component.html.haml +++ b/app/components/editable_champ/editable_champ_component/editable_champ_component.html.haml @@ -7,4 +7,7 @@ = render Dsfr::InputStatusMessageComponent.new(errors_on_attribute: champ_component.errors_on_attribute?, error_full_messages: champ_component.error_full_messages, describedby_id: @champ.describedby_id, champ: @champ) - = @form.hidden_field :id, value: @champ.id + - if Champ.update_by_stable_id? + = @form.hidden_field :with_public_id, value: 'true' + - else + = @form.hidden_field :id, value: @champ.id diff --git a/app/components/editable_champ/multiple_drop_down_list_component.rb b/app/components/editable_champ/multiple_drop_down_list_component.rb index 291ed3e77..9ba731618 100644 --- a/app/components/editable_champ/multiple_drop_down_list_component.rb +++ b/app/components/editable_champ/multiple_drop_down_list_component.rb @@ -8,4 +8,12 @@ class EditableChamp::MultipleDropDownListComponent < EditableChamp::EditableCham def dsfr_champ_container @champ.render_as_checkboxes? ? :fieldset : :div end + + def update_path(option) + if Champ.update_by_stable_id? + champs_options_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id, option:) + else + champs_legacy_options_path(@champ, option:) + end + end end diff --git a/app/components/editable_champ/multiple_drop_down_list_component/multiple_drop_down_list_component.html.haml b/app/components/editable_champ/multiple_drop_down_list_component/multiple_drop_down_list_component.html.haml index f3d8007b9..609e0d690 100644 --- a/app/components/editable_champ/multiple_drop_down_list_component/multiple_drop_down_list_component.html.haml +++ b/app/components/editable_champ/multiple_drop_down_list_component/multiple_drop_down_list_component.html.haml @@ -13,7 +13,7 @@ - if @champ.selected_options.present? .fr-mb-2w.fr-mt-2w{ "data-turbo": "true" } - @champ.selected_options.each do |option| - = render NestedForms::OwnedButtonComponent.new(formaction: champs_options_path(@champ.id, option:), http_method: :delete, opt: { aria: {pressed: true }, class: 'fr-tag fr-tag-bug fr-mb-1w fr-mr-1w', id: @champ.checkbox_id(option) }) do + = render NestedForms::OwnedButtonComponent.new(formaction: update_path(option), http_method: :delete, opt: { aria: {pressed: true }, class: 'fr-tag fr-tag-bug fr-mb-1w fr-mr-1w', id: @champ.checkbox_id(option) }) do = option - if @champ.unselected_options.present? = @form.select :value, @champ.unselected_options, { selected: '', include_blank: false, prompt: t('.prompt') }, id: @champ.input_id, aria: { describedby: @champ.describedby_id }, class: 'fr-select fr-mt-2v' diff --git a/app/components/editable_champ/rna_component.rb b/app/components/editable_champ/rna_component.rb index 1742676eb..09783147c 100644 --- a/app/components/editable_champ/rna_component.rb +++ b/app/components/editable_champ/rna_component.rb @@ -1,5 +1,13 @@ class EditableChamp::RNAComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' + end + + def update_path + if Champ.update_by_stable_id? + champs_rna_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id) + else + champs_legacy_rna_path(@champ) end + end end diff --git a/app/components/editable_champ/rna_component/rna_component.html.haml b/app/components/editable_champ/rna_component/rna_component.html.haml index 0543591ad..4d7240518 100644 --- a/app/components/editable_champ/rna_component/rna_component.html.haml +++ b/app/components/editable_champ/rna_component/rna_component.html.haml @@ -1,4 +1,4 @@ -= @form.text_field(:value, input_opts( id: @champ.input_id, aria: { describedby: @champ.describedby_id }, data: { controller: 'turbo-input', turbo_input_load_on_connect_value: @champ.prefilled? && @champ.value.present? && @champ.data.blank?, turbo_input_url_value: champs_rna_path(@champ.id) }, required: @champ.required?, pattern: "W[0-9]{9}", class: "width-33-desktop", maxlength: 10)) += @form.text_field(:value, input_opts( id: @champ.input_id, aria: { describedby: @champ.describedby_id }, data: { controller: 'turbo-input', turbo_input_load_on_connect_value: @champ.prefilled? && @champ.value.present? && @champ.data.blank?, turbo_input_url_value: update_path }, required: @champ.required?, pattern: "W[0-9]{9}", class: "width-33-desktop", maxlength: 10)) .rna-info{ id: dom_id(@champ, :rna_info) } = render 'shared/champs/rna/association', champ: @champ, error: nil diff --git a/app/components/editable_champ/siret_component.rb b/app/components/editable_champ/siret_component.rb index 8997b7c4e..800d6be5d 100644 --- a/app/components/editable_champ/siret_component.rb +++ b/app/components/editable_champ/siret_component.rb @@ -1,7 +1,7 @@ class EditableChamp::SiretComponent < EditableChamp::EditableChampBaseComponent def dsfr_input_classname 'fr-input' - end + end def hint_id dom_id(@champ, :siret_info) @@ -10,4 +10,12 @@ class EditableChamp::SiretComponent < EditableChamp::EditableChampBaseComponent def hintable? true end + + def update_path + if Champ.update_by_stable_id? + champs_siret_path(@champ.dossier, @champ.stable_id, row_id: @champ.row_id) + else + champs_legacy_siret_path(@champ) + end + end end diff --git a/app/components/editable_champ/siret_component/siret_component.html.haml b/app/components/editable_champ/siret_component/siret_component.html.haml index ae999e84a..9e0745c79 100644 --- a/app/components/editable_champ/siret_component/siret_component.html.haml +++ b/app/components/editable_champ/siret_component/siret_component.html.haml @@ -1,4 +1,4 @@ -= @form.text_field(:value, input_opts(id: @champ.input_id, aria: { describedby: @champ.describedby_id }, data: { controller: 'turbo-input', turbo_input_load_on_connect_value: @champ.prefilled? && @champ.value.present? && @champ.etablissement.blank?, turbo_input_url_value: champs_siret_path(@champ.id) }, required: @champ.required?, pattern: "[0-9]{14}", class: "width-33-desktop", maxlength: 14)) += @form.text_field(:value, input_opts(id: @champ.input_id, aria: { describedby: @champ.describedby_id }, data: { controller: 'turbo-input', turbo_input_load_on_connect_value: @champ.prefilled? && @champ.value.present? && @champ.etablissement.blank?, turbo_input_url_value: update_path }, required: @champ.required?, pattern: "[0-9]{14}", class: "width-33-desktop", maxlength: 14)) .siret-info{ id: dom_id(@champ, :siret_info) } - if @champ.etablissement.present? = render EditableChamp::EtablissementTitreComponent.new(etablissement: @champ.etablissement) diff --git a/app/controllers/champs/options_controller.rb b/app/controllers/champs/options_controller.rb index 4c864a244..cc878fc3e 100644 --- a/app/controllers/champs/options_controller.rb +++ b/app/controllers/champs/options_controller.rb @@ -3,6 +3,7 @@ class Champs::OptionsController < Champs::ChampController def remove @champ.remove_option([params[:option]].compact, true) + @champ.reload @dossier = @champ.private? ? nil : @champ.dossier champs_attributes = { @champ.public_id => params[:champ_id].present? ? { id: @champ.id } : { with_public_id: true } } @to_show, @to_hide, @to_update = champs_to_turbo_update(champs_attributes, @champ.dossier.champs) diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index 8063e0438..2f2f1b51d 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -295,11 +295,12 @@ module Instructeurs def annotation @dossier = dossier_with_champs(pj_template: false) + annotation_id_or_stable_id = params[:stable_id] annotation = if params[:with_public_id].present? - type_de_champ = @dossier.find_type_de_champ_by_stable_id(params[:annotation_id], :private) + type_de_champ = @dossier.find_type_de_champ_by_stable_id(annotation_id_or_stable_id, :private) @dossier.project_champ(type_de_champ, params[:row_id]) else - @dossier.champs_private_all.find(params[:annotation_id]) + @dossier.champs_private_all.find(annotation_id_or_stable_id) end respond_to do |format| diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index c80792af4..921a06013 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -317,11 +317,12 @@ module Users def champ @dossier = dossier_with_champs(pj_template: false) + champ_id_or_stable_id = params[:stable_id] champ = if params[:with_public_id].present? - type_de_champ = @dossier.find_type_de_champ_by_stable_id(params[:champ_id], :public) + type_de_champ = @dossier.find_type_de_champ_by_stable_id(champ_id_or_stable_id, :public) @dossier.project_champ(type_de_champ, params[:row_id]) else - @dossier.champs_public_all.find(params[:champ_id]) + @dossier.champs_public_all.find(champ_id_or_stable_id) end respond_to do |format| diff --git a/app/helpers/champ_helper.rb b/app/helpers/champ_helper.rb index 61c988788..a79aada8d 100644 --- a/app/helpers/champ_helper.rb +++ b/app/helpers/champ_helper.rb @@ -9,7 +9,11 @@ module ChampHelper def auto_attach_url(object, params = {}) if object.is_a?(Champ) - champs_attach_piece_justificative_url(object.id, params) + if Champ.update_by_stable_id? + champs_piece_justificative_url(object.dossier, object.stable_id, params.merge(row_id: object.row_id)) + else + champs_legacy_piece_justificative_url(object.id, params) + end elsif object.is_a?(TypeDeChamp) && object.piece_justificative? piece_justificative_template_admin_procedure_type_de_champ_url(stable_id: object.stable_id, procedure_id: object.procedure.id, **params) elsif object.is_a?(TypeDeChamp) && object.explication? diff --git a/app/javascript/components/MapEditor/hooks.ts b/app/javascript/components/MapEditor/hooks.ts index eae70cb52..78b6a9267 100644 --- a/app/javascript/components/MapEditor/hooks.ts +++ b/app/javascript/components/MapEditor/hooks.ts @@ -137,7 +137,7 @@ export function useFeatureCollection( for (const feature of features) { const id = feature.properties?.id; if (id) { - await httpRequest(`${url}/${id}`, { + await httpRequest(endpointWithId(url, id), { method: 'patch', json: { feature } }).json(); @@ -174,7 +174,9 @@ export function useFeatureCollection( const deletedFeatures = []; for (const feature of features) { const id = feature.properties?.id; - await httpRequest(`${url}/${id}`, { method: 'delete' }).json(); + await httpRequest(endpointWithId(url, id), { + method: 'delete' + }).json(); deletedFeatures.push(feature); } removeFeatures(deletedFeatures, external); @@ -212,3 +214,11 @@ function useError(): [string | undefined, (message: string) => void] { return [error, onError]; } + +// We need this because endoint can have query params. For example with /champs/123?row_id=abc we can't juste concatanate id. +// We want /champs/123/456?row_id=abc not /champs/123?row_id=abc/456 +function endpointWithId(endpoint: string, id: string) { + const url = new URL(endpoint, document.baseURI); + url.pathname = `${url.pathname}/${id}`; + return url.toString(); +} diff --git a/app/models/champ.rb b/app/models/champ.rb index 7c98eebcf..cbff3b54a 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -290,6 +290,10 @@ class Champ < ApplicationRecord self.value = value.delete("\u0000") end + def self.update_by_stable_id? + Flipper.enabled?(:champ_update_by_stable_id, Current.user) + end + class NotImplemented < ::StandardError def initialize(method) super(":#{method} not implemented") diff --git a/app/views/shared/_piece_justificative_template.html.haml b/app/views/shared/_piece_justificative_template.html.haml index abb90edbf..1f45bb7ab 100644 --- a/app/views/shared/_piece_justificative_template.html.haml +++ b/app/views/shared/_piece_justificative_template.html.haml @@ -1 +1 @@ -= render Dsfr::DownloadComponent.new(attachment: champ.type_de_champ.piece_justificative_template, url: champs_piece_justificative_template_path(champ), name: "Modèle à télécharger", ephemeral_link: administrateur_signed_in? ) += render Dsfr::DownloadComponent.new(attachment: champ.type_de_champ.piece_justificative_template, url: champs_piece_justificative_template_path(champ.dossier, champ.stable_id, row_id: champ.row_id), name: "Modèle à télécharger", ephemeral_link: administrateur_signed_in? ) diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb index 9b22801b9..7eedd85e7 100644 --- a/config/initializers/flipper.rb +++ b/config/initializers/flipper.rb @@ -30,7 +30,8 @@ features = [ :groupe_instructeur_api_hack, :hide_instructeur_email, :sva, - :switch_domain + :switch_domain, + :champ_update_by_stable_id ] def database_exists? diff --git a/config/routes.rb b/config/routes.rb index 9256e08a2..fb0bc9302 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -196,32 +196,32 @@ Rails.application.routes.draw do post ':dossier_id/:stable_id/repetition', to: 'repetition#add', as: :repetition delete ':dossier_id/:stable_id/repetition', to: 'repetition#remove' - get ':dossier_id/:stable_id/siret', to: 'siret#show' - get ':dossier_id/:stable_id/rna', to: 'rna#show' - delete ':dossier_id/:stable_id/options', to: 'options#remove' + get ':dossier_id/:stable_id/siret', to: 'siret#show', as: :siret + get ':dossier_id/:stable_id/rna', to: 'rna#show', as: :rna + delete ':dossier_id/:stable_id/options', to: 'options#remove', as: :options - get ':dossier_id/:stable_id/carte/features', to: 'carte#index' + get ':dossier_id/:stable_id/carte/features', to: 'carte#index', as: :carte_features post ':dossier_id/:stable_id/carte/features', to: 'carte#create' - patch ':dossier_id/:stable_id/carte/features/:id', to: 'carte#update' + patch ':dossier_id/:stable_id/carte/features/:id', to: 'carte#update', as: :carte_feature delete ':dossier_id/:stable_id/carte/features/:id', to: 'carte#destroy' - get ':dossier_id/:stable_id/piece_justificative', to: 'piece_justificative#show' + get ':dossier_id/:stable_id/piece_justificative', to: 'piece_justificative#show', as: :piece_justificative put ':dossier_id/:stable_id/piece_justificative', to: 'piece_justificative#update' - get ':dossier_id/:stable_id/piece_justificative/template', to: 'piece_justificative#template' + get ':dossier_id/:stable_id/piece_justificative/template', to: 'piece_justificative#template', as: :piece_justificative_template # TODO: remove after migration is ower - get ':champ_id/siret', to: 'siret#show', as: :siret - get ':champ_id/rna', to: 'rna#show', as: :rna - delete ':champ_id/options', to: 'options#remove', as: :options + get ':champ_id/siret', to: 'siret#show', as: :legacy_siret + get ':champ_id/rna', to: 'rna#show', as: :legacy_rna + delete ':champ_id/options', to: 'options#remove', as: :legacy_options - get ':champ_id/carte/features', to: 'carte#index', as: :carte_features + get ':champ_id/carte/features', to: 'carte#index', as: :legacy_carte_features post ':champ_id/carte/features', to: 'carte#create' patch ':champ_id/carte/features/:id', to: 'carte#update' delete ':champ_id/carte/features/:id', to: 'carte#destroy' - get ':champ_id/piece_justificative', to: 'piece_justificative#show', as: :piece_justificative - put ':champ_id/piece_justificative', to: 'piece_justificative#update', as: :attach_piece_justificative - get ':champ_id/piece_justificative/template', to: 'piece_justificative#template', as: :piece_justificative_template + get ':champ_id/piece_justificative', to: 'piece_justificative#show', as: :legacy_piece_justificative + put ':champ_id/piece_justificative', to: 'piece_justificative#update' + get ':champ_id/piece_justificative/template', to: 'piece_justificative#template' end resources :attachments, only: [:show, :destroy] @@ -373,7 +373,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 'champs/:stable_id', to: 'dossiers#champ', as: :champ get 'merci' get 'demande' get 'messagerie' @@ -497,7 +497,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 + get 'annotations/:stable_id', to: 'dossiers#annotation', as: :annotation patch 'follow' patch 'unfollow' patch 'archive' diff --git a/spec/controllers/champs/piece_justificative_controller_spec.rb b/spec/controllers/champs/piece_justificative_controller_spec.rb index 26b1a2e21..0c0eba1e1 100644 --- a/spec/controllers/champs/piece_justificative_controller_spec.rb +++ b/spec/controllers/champs/piece_justificative_controller_spec.rb @@ -11,7 +11,8 @@ describe Champs::PieceJustificativeController, type: :controller do subject do put :update, params: { position: '1', - champ_id: champ.id, + dossier_id: champ.dossier_id, + stable_id: champ.stable_id, blob_signed_id: file }.compact, format: :turbo_stream end @@ -73,7 +74,8 @@ describe Champs::PieceJustificativeController, type: :controller do subject do get :template, params: { - champ_id: champ.id + dossier_id: champ.dossier_id, + stable_id: champ.stable_id } end diff --git a/spec/controllers/champs/rna_controller_spec.rb b/spec/controllers/champs/rna_controller_spec.rb index 1c738f794..a9275e843 100644 --- a/spec/controllers/champs/rna_controller_spec.rb +++ b/spec/controllers/champs/rna_controller_spec.rb @@ -13,7 +13,8 @@ describe Champs::RNAController, type: :controller do end let(:params) do { - champ_id: champ.id, + dossier_id: champ.dossier_id, + stable_id: champ.stable_id, dossier: { champs_public_attributes: champs_public_attributes } From b12627f074443edc2e3125f44857ab33556618fc Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 15 May 2024 17:57:02 +0200 Subject: [PATCH 0157/1532] fix(dossier): fix n+1 on header sections --- app/models/type_de_champ.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 65c743350..ea2de7468 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -523,7 +523,7 @@ class TypeDeChamp < ApplicationRecord end def level_for_revision(revision) - rtdc = revision.revision_types_de_champ.find { |rtdc| rtdc.stable_id == stable_id } + rtdc = revision.revision_types_de_champ.includes(:type_de_champ, parent: :type_de_champ).find { |rtdc| rtdc.stable_id == stable_id } if rtdc.child? header_section_level_value.to_i + rtdc.parent.type_de_champ.current_section_level(revision) elsif header_section_level_value From bc0c068ed762d12655cf2e13594785fd064b7078 Mon Sep 17 00:00:00 2001 From: Mathieu HIREL Date: Tue, 19 Mar 2024 08:26:27 +0100 Subject: [PATCH 0158/1532] chore: expose postgres port --- config/database.yml | 3 +++ config/env.example | 1 + 2 files changed, 4 insertions(+) diff --git a/config/database.yml b/config/database.yml index 9612bd9fd..d30715a17 100644 --- a/config/database.yml +++ b/config/database.yml @@ -14,6 +14,7 @@ development: host: <%= ENV["DB_HOST"] %> username: <%= ENV["DB_USERNAME"] %> password: <%= ENV["DB_PASSWORD"] %> + port: <%= ENV["DB_PORT"] || 5432 %> # Workaround for https://github.com/ged/ruby-pg/issues/311 gssencmode: disable @@ -23,6 +24,7 @@ test: host: localhost username: tps_test password: tps_test + port: <%= ENV["DB_PORT"] || 5432 %> # Workaround for https://github.com/ged/ruby-pg/issues/311 gssencmode: disable @@ -32,3 +34,4 @@ production: &production host: <%= ENV["DB_HOST"] %> username: <%= ENV["DB_USERNAME"] %> password: <%= ENV["DB_PASSWORD"] %> + port: <%= ENV["DB_PORT"] || 5432 %> diff --git a/config/env.example b/config/env.example index fe8967840..190b691ad 100644 --- a/config/env.example +++ b/config/env.example @@ -22,6 +22,7 @@ DB_HOST="localhost" DB_POOL="" DB_USERNAME="tps_development" DB_PASSWORD="tps_development" +DB_PORT=5432 # Protect access to the instance with a static login/password (useful for staging environments) BASIC_AUTH_ENABLED="disabled" From 66b4a1f8b8daedfcd20d0b35b26f7741a155ca73 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 22 Apr 2024 16:37:29 +0200 Subject: [PATCH 0159/1532] chore(bundle): +front_matter_parser --- Gemfile | 1 + Gemfile.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Gemfile b/Gemfile index cb06d2eca..8d78db84d 100644 --- a/Gemfile +++ b/Gemfile @@ -37,6 +37,7 @@ gem 'flipper' gem 'flipper-active_record' gem 'flipper-active_support_cache_store' gem 'flipper-ui' +gem 'front_matter_parser' gem 'fugit' gem 'geocoder' gem 'geo_coord', require: "geo/coord" diff --git a/Gemfile.lock b/Gemfile.lock index 5eef464d1..2e54015de 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -278,6 +278,7 @@ GEM fog-core (~> 2.1) fog-json (>= 1.0) formatador (1.1.0) + front_matter_parser (1.0.1) fugit (1.10.1) et-orbi (~> 1, >= 1.2.7) raabro (~> 1.4) @@ -923,6 +924,7 @@ DEPENDENCIES flipper-active_record flipper-active_support_cache_store flipper-ui + front_matter_parser fugit geo_coord geocoder From 9af2c4f2443c3a7deaa7e0fb6b59714c2499c53a Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 22 Apr 2024 16:42:12 +0200 Subject: [PATCH 0160/1532] feat: show a simple FAQ --- app/controllers/faq_controller.rb | 27 ++++++++++++++++ app/services/faqs_loader_service.rb | 32 +++++++++++++++++++ app/views/faq/show.html.haml | 6 ++++ config/initializers/inflections.rb | 2 ++ config/locales/faqs.fr.yml | 9 ++++++ config/routes.rb | 2 ++ .../comment-creer-ma-demarche.fr.md | 16 ++++++++++ 7 files changed, 94 insertions(+) create mode 100644 app/controllers/faq_controller.rb create mode 100644 app/services/faqs_loader_service.rb create mode 100644 app/views/faq/show.html.haml create mode 100644 config/locales/faqs.fr.yml create mode 100644 doc/faqs/administrateur/comment-creer-ma-demarche.fr.md diff --git a/app/controllers/faq_controller.rb b/app/controllers/faq_controller.rb new file mode 100644 index 000000000..ba0b69a95 --- /dev/null +++ b/app/controllers/faq_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class FAQController < ApplicationController + before_action :load_faq_data, only: :show + + def show + @renderer = Redcarpet::Markdown.new( + Redcarpet::BareRenderer.new(class_names_map: { list: 'fr-ol-content--override' }) + ) + end + + private + + def loader_service + @loader_service ||= FAQsLoaderService.new + end + + def load_faq_data + path = "#{params[:category]}/#{params[:slug]}" + faq_data = loader_service.find(path) + + @content = faq_data.content + @metadata = faq_data.front_matter.symbolize_keys + rescue KeyError + raise ActionController::RoutingError.new("FAQ not found: #{path}") + end +end diff --git a/app/services/faqs_loader_service.rb b/app/services/faqs_loader_service.rb new file mode 100644 index 000000000..2665d8aa8 --- /dev/null +++ b/app/services/faqs_loader_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class FAQsLoaderService + PATH = Rails.root.join('doc', 'faqs').freeze + ORDER = ['usager', 'instructeur', 'administrateur'].freeze + + def initialize + @faqs_by_path ||= Rails.cache.fetch("faqs_data", expires_in: 1.day) do + load_faqs + end + end + + def find(path) + file_path = @faqs_by_path.fetch(path).fetch(:file_path) + + FrontMatterParser::Parser.parse_file(file_path) + end + + private + + def load_faqs + Dir.glob("#{PATH}/**/*.md").each_with_object({}) do |file_path, faqs_by_path| + parsed = FrontMatterParser::Parser.parse_file(file_path) + front_matter = parsed.front_matter.symbolize_keys + + faq_data = front_matter.slice(:slug, :title, :category, :subcategory, :locale, :keywords).merge(file_path: file_path) + + path = front_matter.fetch(:category) + '/' + front_matter.fetch(:slug) + faqs_by_path[path] = faq_data + end + end +end diff --git a/app/views/faq/show.html.haml b/app/views/faq/show.html.haml new file mode 100644 index 000000000..46dc34cd9 --- /dev/null +++ b/app/views/faq/show.html.haml @@ -0,0 +1,6 @@ +- content_for(:title, @metadata[:title]) + +.fr-container.fr-my-4w + .fr-grid-row + .fr-col-12.fr-col-md-8.fr-py-12v + = @renderer.render(@content).html_safe diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 6b101ffa2..c444fa05e 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -18,6 +18,8 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym 'URL' inflect.acronym 'SVA' inflect.acronym 'SVR' + inflect.acronym 'FAQ' + inflect.acronym 'FAQs' inflect.irregular 'type_de_champ', 'types_de_champ' inflect.irregular 'type_de_champ_private', 'types_de_champ_private' inflect.irregular 'procedure_revision_type_de_champ', 'procedure_revision_types_de_champ' diff --git a/config/locales/faqs.fr.yml b/config/locales/faqs.fr.yml new file mode 100644 index 000000000..14c82c8b9 --- /dev/null +++ b/config/locales/faqs.fr.yml @@ -0,0 +1,9 @@ +fr: + faq: + administrateur: + name: Administrateur (création d’un formulaire) + description: Informations pour les administrateurs sur la configuration de la plateforme ou la création de démarches. + subcategories: + create_procedure: + name: Je veux créer une démarche en ligne + description: Comment créer une nouvelle démarche ? diff --git a/config/routes.rb b/config/routes.rb index fb0bc9302..b5b8520d2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -715,6 +715,8 @@ Rails.application.routes.draw do resources :release_notes, only: [:index] + get '/faq/:category/:slug', to: 'faq#show', as: :faq + get '/404', to: 'errors#not_found' get '/422', to: 'errors#unprocessable_entity' get '/500', to: 'errors#internal_server_error' diff --git a/doc/faqs/administrateur/comment-creer-ma-demarche.fr.md b/doc/faqs/administrateur/comment-creer-ma-demarche.fr.md new file mode 100644 index 000000000..7dfd16b7d --- /dev/null +++ b/doc/faqs/administrateur/comment-creer-ma-demarche.fr.md @@ -0,0 +1,16 @@ +--- +category: "administrateur" +subcategory: "create_procedure" +slug: "comment-creer-ma-demarche" +locale: "fr" +keywords: "création démarche, tutoriel, guide bonnes pratiques, administrateur" +title: "Comment créer ma démarche ?" +--- + +# Comment créer ma démarche ? + +Envie de vous lancer dans l’aventure ? Bravo ! Pour vous accompagner tout au long de la création de votre démarche, nous avons préparé un petit tutoriel spécialement pour vous : + +[Tutoriel Administrateur](https://doc.demarches-simplifiees.fr/tutoriels/tutoriel-administrateur) + +Pour compléter cette prise en main de l’outil, n’hésitez pas à consulter [notre guide de bonnes pratiques](https://456404736-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-L7_aKvpAJdAIEfxHudA%2Fuploads%2FGJm7S7LVjHPKVlMCE36e%2FGuide%20des%20bonnes%20pratiques%20démarches-simplifiees.pdf?alt=media&token=228e63c7-a168-4656-9cda-3f53a10645c2). From 1e3c70feb8ff3f1ce45da165fd880bdd994f1897 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 22 Apr 2024 16:43:04 +0200 Subject: [PATCH 0161/1532] chore(faq): show more simple FAQ --- config/locales/faqs.fr.yml | 30 +++++++ ...ue-puis-je-indiquer-pour-ma-demarche.fr.md | 20 +++++ ...-n-ont-pas-recu-d-email-d-invitation.fr.md | 20 +++++ ...firmer-mon-compte-a-chaque-connexion.fr.md | 36 +++++++++ ...x-dossiers-que-je-souhaite-instruire.fr.md | 27 +++++++ .../erreur-siret-lors-depot-de-dossier.fr.md | 79 +++++++++++++++++++ ...n-dossier-mais-je-ne-le-retrouve-pas.fr.md | 24 ++++++ .../j-ai-un-autre-probleme-technique.fr.md | 23 ++++++ ...une-demande-car-je-n-ai-pas-de-SIRET.fr.md | 26 ++++++ .../usager/je-ne-recois-pas-d-email.fr.md | 17 ++++ ...ives-aux-installations-de-combustion.fr.md | 16 ++++ ...re-a-l-epreuve-du-permis-de-conduire.fr.md | 24 ++++++ ...e-obtenir-un-duplicata-cerfa-02-neph.fr.md | 27 +++++++ .../je-veux-changer-mon-mot-de-passe.fr.md | 27 +++++++ 14 files changed, 396 insertions(+) create mode 100644 doc/faqs/administrateur/je-suis-une-collectivite-quelle-cadre-juridique-puis-je-indiquer-pour-ma-demarche.fr.md create mode 100644 doc/faqs/administrateur/les-instructeurs-n-ont-pas-recu-d-email-d-invitation.fr.md create mode 100644 doc/faqs/instructeur/je-dois-confirmer-mon-compte-a-chaque-connexion.fr.md create mode 100644 doc/faqs/instructeur/je-n-arrive-pas-a-acceder-aux-dossiers-que-je-souhaite-instruire.fr.md create mode 100644 doc/faqs/usager/erreur-siret-lors-depot-de-dossier.fr.md create mode 100644 doc/faqs/usager/j-ai-depose-moi-meme-mon-dossier-mais-je-ne-le-retrouve-pas.fr.md create mode 100644 doc/faqs/usager/j-ai-un-autre-probleme-technique.fr.md create mode 100644 doc/faqs/usager/je-ne-peux-pas-faire-une-demande-car-je-n-ai-pas-de-SIRET.fr.md create mode 100644 doc/faqs/usager/je-ne-recois-pas-d-email.fr.md create mode 100644 doc/faqs/usager/je-souhaite-declarer-les-informations-relatives-aux-installations-de-combustion.fr.md create mode 100644 doc/faqs/usager/je-souhaite-m-inscrire-a-l-epreuve-du-permis-de-conduire.fr.md create mode 100644 doc/faqs/usager/je-souhaite-obtenir-un-duplicata-cerfa-02-neph.fr.md create mode 100644 doc/faqs/usager/je-veux-changer-mon-mot-de-passe.fr.md diff --git a/config/locales/faqs.fr.yml b/config/locales/faqs.fr.yml index 14c82c8b9..155a815f2 100644 --- a/config/locales/faqs.fr.yml +++ b/config/locales/faqs.fr.yml @@ -1,9 +1,39 @@ fr: faq: + categories: + usager: + name: Usager (dépôt d’un dossier) + description: Aide pour les usagers sur la soumission et le suivi des dossiers, incluant la résolution des problèmes courants. + instructeur: + name: Instructeur (traitement des dossiers) + description: À destination des instructeurs sur l’accès et la gestion des dossiers. administrateur: name: Administrateur (création d’un formulaire) description: Informations pour les administrateurs sur la configuration de la plateforme ou la création de démarches. subcategories: + dossier_technical_issue: + name: Je rencontre un problème technique avec ma démarche + find_dossier: + name: Je veux retrouver mon dossier + description: Quelles sont les solutions si je ne retrouve pas mon dossier ? + fill_dossier: + name: Je veux faire une démarche + dossier_state: + name: Je veux suivre l’instruction de ma démarche + account: + name: Gestion de mon compte + instructeur_account: + name: Connexion à mon espace + instruction: + name: Instruction create_procedure: name: Je veux créer une démarche en ligne description: Comment créer une nouvelle démarche ? + general: + name: Général + administrateur_departure: + name: Je quitte mon poste + description: Les bonnes pratiques à anticiper avant mon départ + procedure_test: + name: Comment tester ma démarche + description: Comment tester une nouvelle demarche ? diff --git a/doc/faqs/administrateur/je-suis-une-collectivite-quelle-cadre-juridique-puis-je-indiquer-pour-ma-demarche.fr.md b/doc/faqs/administrateur/je-suis-une-collectivite-quelle-cadre-juridique-puis-je-indiquer-pour-ma-demarche.fr.md new file mode 100644 index 000000000..7769a2a86 --- /dev/null +++ b/doc/faqs/administrateur/je-suis-une-collectivite-quelle-cadre-juridique-puis-je-indiquer-pour-ma-demarche.fr.md @@ -0,0 +1,20 @@ +--- +category: "administrateur" +subcategory: "create_procedure" +slug: "je-suis-une-collectivite-quelle-cadre-juridique-puis-je-indiquer-pour-ma-demarche" +locale: "fr" +keywords: "collectivité, cadre juridique, compétence, dématérialisation, arrêté 2013" +title: "Je suis une collectivité, quel cadre juridique puis-je indiquer pour ma démarche ?" +--- + +# Je suis une collectivité, quel cadre juridique puis-je indiquer pour ma démarche ? + +Lors de la création d’une démarche, nous vous demandons d’indiquer le cadre juridique sur lequel elle repose. + +Pour les collectivités, ce cadre juridique peut notamment faire référence à leur compétence générale, +mais il peut s’avérer difficile de trouver un texte correspondant. +Dans cette optique, l’arrêté du 4 juillet 2013 regroupe un nombre important de domaines +pour lesquels les collectivités sont compétentes et peuvent dématérialiser. + +Nous vous invitons donc à [consulter cet arrêté sur LegiFrance](https://www.legifrance.gouv.fr/affichTexte.do?cidTexte=JORFTEXT000027697207&dateTexte=20190715) +et à le citer le cas échéant. diff --git a/doc/faqs/administrateur/les-instructeurs-n-ont-pas-recu-d-email-d-invitation.fr.md b/doc/faqs/administrateur/les-instructeurs-n-ont-pas-recu-d-email-d-invitation.fr.md new file mode 100644 index 000000000..063eed6af --- /dev/null +++ b/doc/faqs/administrateur/les-instructeurs-n-ont-pas-recu-d-email-d-invitation.fr.md @@ -0,0 +1,20 @@ +--- +category: "administrateur" +subcategory: "create_procedure" +slug: "les-instructeurs-n-ont-pas-recu-d-email-d-invitation" +locale: "fr" +keywords: "instructeurs, email, invitation, erreur, saisie, démarche" +title: "Les instructeurs n’ont pas reçu d’email d’invitation" +--- + +# Les instructeurs n’ont pas reçu d’email d’invitation + +En tant qu’administrateur, si après avoir nommé des instructeurs sur votre +démarche ces derniers ne reçoivent pas d’email d’invitation à se connecter à demarches.gouv.fr, +alors vous vous trouvez peut-être dans la situation suivante : + +- Vous avez fait une erreur dans la saisie des adresses email en nommant les instructeurs. + +- Les instructeurs sont déjà nommés sur une autre démarche. Leur compte étant déjà validé, ils ne reçoivent plus de nouvel email. + +- Vous ne vous trouvez dans aucune des situations mentionnées ? Nous vous prions de nous [contacter par email](mailto:contact@demarches-simplifiees.fr). diff --git a/doc/faqs/instructeur/je-dois-confirmer-mon-compte-a-chaque-connexion.fr.md b/doc/faqs/instructeur/je-dois-confirmer-mon-compte-a-chaque-connexion.fr.md new file mode 100644 index 000000000..2fc7b044c --- /dev/null +++ b/doc/faqs/instructeur/je-dois-confirmer-mon-compte-a-chaque-connexion.fr.md @@ -0,0 +1,36 @@ +--- +category: "instructeur" +subcategory: "instructeur_account" +slug: "je-dois-confirmer-mon-compte-a-chaque-connexion" +locale: "fr" +keywords: "sécurité, navigateur, authentification, cookies, configuration" +title: "Je dois confirmer mon compte à chaque connexion" +--- + +# Je dois confirmer mon compte à chaque connexion + +Afin de sécuriser votre compte, demarches.gouv.fr vous demande tous les mois d’authentifier votre navigateur. Il vous faut alors cliquer sur le lien de confirmation envoyé par email. + +Ce processus peut parfois vous être demandé à chaque connexion, nous avons identifié deux raisons possibles : + +- Une mauvaise configuration de votre navigateur +- Le navigateur authentifié n’est pas celui que vous utilisez + +Finalement, le lien reçu par email est valide une semaine et peut-être utilisé plusieurs fois. Vous pouvez donc probablement le réutiliser pour authentifier votre navigateur sans attendre un nouvel email. + +## Mauvaise configuration de notre navigateur + +Ce problème apparaît lorsque votre navigateur est configuré de manière très sécurisée et efface les données provenant de demarches.gouv.fr à chaque fermeture. + +Solution : Pour corriger ce problème, configurez votre navigateur pour accepter les cookies du domaine demarches.gouv.fr : + +- pour Firefox [https://support.mozilla.org/fr/kb/sites-disent-cookies-bloques-les-debloquer](https://support.mozilla.org/fr/kb/sites-disent-cookies-bloques-les-debloquer), +- pour Chrome [https://support.google.com/accounts/answer/61416?co=GENIE.Platform%3DDesktop&hl=fr](https://support.google.com/accounts/answer/61416?co=GENIE.Platform%3DDesktop&hl=fr). + +Si vous n’avez pas les droits suffisant pour modifier cette configuration, contactez votre support informatique en nous mettant en copie : contact@demarches-simplifiees.fr + +## Le navigateur authentifié n’est pas celui que vous utilisez + +Il est possible que lorsque vous cliquez sur le lien de l’email, celui-ci ouvre le navigateur par défaut, la plupart du temps Internet Explorer. Or, le navigateur que vous utilisez pour aller sur demarches.gouv.fr est, par exemple, Firefox. Donc, le lendemain, lorsque vous ouvrez Firefox, le navigateur n’est toujours pas authentifié et vous devez à nouveau cliquer sur le lien de connexion. + +Solution : copiez le lien de l’email et ouvrez-le avec le navigateur que vous utilisez habituellement pour aller sur demarches.gouv.fr. diff --git a/doc/faqs/instructeur/je-n-arrive-pas-a-acceder-aux-dossiers-que-je-souhaite-instruire.fr.md b/doc/faqs/instructeur/je-n-arrive-pas-a-acceder-aux-dossiers-que-je-souhaite-instruire.fr.md new file mode 100644 index 000000000..3db88ca52 --- /dev/null +++ b/doc/faqs/instructeur/je-n-arrive-pas-a-acceder-aux-dossiers-que-je-souhaite-instruire.fr.md @@ -0,0 +1,27 @@ +--- +title: "Je n’arrive pas à accéder aux dossiers que je souhaite instruire" +category: instructeur +subcategory: instructeur_account +slug: "je-n-arrive-pas-a-acceder-aux-dossiers-que-je-souhaite-instruire" +locale: "fr" +keywords: "acces dossiers, administrateur demarche, affectation instructeur, +contact administrateur" +--- + +# Je n’arrive pas à accéder aux dossiers que je souhaite instruire + +Cela signifie que l’administrateur de la démarche qui vous intéresse ne vous a +pas affecté à celle-ci. En effet, seul l’administrateur est en mesure d’ajouter +ou de retirer l’affectation d’une démarche à un instructeur. Vous devez donc +lui envoyer un email pour lui demander l’accès. + +## Vous ne savez pas qui est l’administrateur de la démarche ? + +Si c’est le cas, envoyez un email à [contact@demarches.gouv.fr](mailto:contact@demarches.gouv.fr), +en décrivant précisément la démarche à laquelle vous voulez +être affecté (intitulé, organisation, région, ou département…) +afin que nous puissions retrouver +cette démarche et vous communiquer l’adresse email de l’administrateur. + +N’hésitez pas également à vous rapprocher de vos collègues déjà instructeurs +de la démarche concernée pour obtenir de l’aide. diff --git a/doc/faqs/usager/erreur-siret-lors-depot-de-dossier.fr.md b/doc/faqs/usager/erreur-siret-lors-depot-de-dossier.fr.md new file mode 100644 index 000000000..d14b3af1e --- /dev/null +++ b/doc/faqs/usager/erreur-siret-lors-depot-de-dossier.fr.md @@ -0,0 +1,79 @@ +--- +title: "Erreur SIRET lors d’un dépôt de dossier sur demarches.gouv.fr" +category: usager +subcategory: dossier_technical_issue +slug: "erreur-siret-lors-d-un-depot-de-dossier-sur-demarches-gouv-fr" +locale: "fr" +keywords: "erreur SIRET, dépôt de dossier, numéro SIRET, identification entreprise, URSSAF, INSEE, entreprise.data.gouv.fr" +--- + +# Erreur SIRET lors d’un dépôt de dossier sur demarches.gouv.fr + +Cet article s’adresse exclusivement aux utilisateurs de demarches.gouv.fr, +rencontrant un problème relatif à l’identification par numéro de SIRET lors d’un +dépôt de dossiers. + +Si votre problème n’est pas relatif à l’utilisation de la plateforme +demarches.gouv.fr, vous pouvez consulter la page suivante : +[Service-public.fr pour signaler un problème](https://www.service-public.fr/professionnels-entreprises/vosdroits/R17969/signaler-un-probleme) + +Si vous rencontrez le message « Erreur SIRET » lorsque vous identifiez votre +entreprise ou association lors d’un nouveau dépôt de dossier, le problème peut +se résoudre de trois manières : + +## Vérifiez que le numéro est bien le numéro SIRET + +Le numéro SIRET contient 14 chiffres, et il est facilement confondu avec le +numéro SIREN, qui lui n’en contient que 9. Assurez-vous que le numéro que vous +indiquez est bien le numéro SIRET. + +## Cas du SIRET transmis par l’URSSAF + +Pour les jeunes structures, il est possible que l’URSSAF vous ait transmis un +numéro de SIRET provisoire. Seuls les numéros de SIRET transmis par l’INSEE sont +pris en compte sur notre site. + +## Vérifiez la validité de votre numéro SIRET + +Il est possible que le SIRET de votre entreprise ou association ait changé (suite +à un déménagement par exemple, qui entraîne la fermeture de l’ancien +établissement et rend le numéro de SIRET invalide), et ne fonctionne donc plus. + +Pour vous assurer de la validité et du caractère public de votre SIRET, allez sur +[entreprise.data.gouv.fr](https://entreprise.data.gouv.fr), rentrez votre numéro +de SIRET dans le champ de recherche, et vérifiez si votre SIRET est encore valide +ou non. S’il ne l’est plus, la page affichée vous indiquera alors le nouveau +numéro de SIRET. + +Après l’immatriculation de votre entreprise, il faut compter quelques jours avant +que les informations relatives à celle-ci ne soient disponibles et récupérables +depuis entreprise.data.gouv.fr. + +## Vérifiez que les informations concernant votre entreprise sont publiques + +Certaines entreprises demandent à ce que leurs informations ne soient pas +accessibles dans la base publique des SIRET. Si c’est le cas, nous ne pouvons pas +récupérer les informations associées. + +Pour vous assurer que les informations de votre entreprises ne sont pas privées, +allez sur l’Annuaire des Entreprises, et rentrez votre numéro de SIRET dans le +champ de recherche. + +## Lorsque les informations SIRET sont temporairement indisponibles + +Si vous voyez un message _« Désolé, la récupération des informations SIRET est +temporairement indisponible. Veuillez réessayer dans quelques instants. »_, cela +signifie que le service externe de l’INSEE qui donne les informations d’entreprise +a une panne temporaire. + +La plupart du temps, le problème est résolu en quelques heures maximum. + +Pour plus d’information, vous pouvez consulter l’état du service SIRET. Si une des +lignes est rouge, c’est probablement la cause du problème. + +## Contactez-nous + +Il est également possible que notre prestataire technique qui nous permet de faire +de l’identification via un numéro SIRET rencontre des problèmes techniques. Si +votre numéro SIRET est valide et que vous rencontrez toujours la même erreur, +contactez-nous par email. diff --git a/doc/faqs/usager/j-ai-depose-moi-meme-mon-dossier-mais-je-ne-le-retrouve-pas.fr.md b/doc/faqs/usager/j-ai-depose-moi-meme-mon-dossier-mais-je-ne-le-retrouve-pas.fr.md new file mode 100644 index 000000000..6a5e304ad --- /dev/null +++ b/doc/faqs/usager/j-ai-depose-moi-meme-mon-dossier-mais-je-ne-le-retrouve-pas.fr.md @@ -0,0 +1,24 @@ +--- +title: "J'ai déposé moi-même mon dossier mais je ne le retrouve pas" +category: usager +subcategory: find_dossier +slug: "j-ai-depose-moi-meme-mon-dossier-mais-je-ne-le-retrouve-pas" +locale: "fr" +keywords: "dossier perdu, retrouver dossier, email confirmation, attestation depot, +contact service instructeur" +--- + +# J'ai déposé moi-même mon dossier mais je ne le retrouve pas + +Il se peut que vous ayez déposé votre dossier en utilisant une autre adresse email. + +Si c'est le cas, vous avez 3 solutions pour le retrouver : + +- Recherchez parmi les emails reçus lors de la création de votre compte ou lors +du dépôt de votre dossier sur demarches.gouv.fr. L'adresse email associée à +votre dossier est mentionnée dans l'entête de ces emails. +- Consultez le corps de l'attestation de dépôt du dossier, où l'adresse email +utilisée est également indiquée. +- Contactez le service instructeur de la démarche pour qu'il vous aide dans la +procédure de transfert de votre dossier. Les coordonnées de ce service sont +disponibles en bas de la page de présentation de la démarche. diff --git a/doc/faqs/usager/j-ai-un-autre-probleme-technique.fr.md b/doc/faqs/usager/j-ai-un-autre-probleme-technique.fr.md new file mode 100644 index 000000000..9dc3b1554 --- /dev/null +++ b/doc/faqs/usager/j-ai-un-autre-probleme-technique.fr.md @@ -0,0 +1,23 @@ +--- +title: "J’ai un autre problème technique" +category: usager +subcategory: dossier_technical_issue +slug: "j-ai-un-autre-probleme-technique" +locale: "fr" +keywords: "problème technique, connexion, erreur formulaire, support technique" +--- + +# J’ai un autre problème technique + +Vous rencontrez une difficulté technique ? + +Par exemple, un problème pour vous connecter ; ou une erreur au moment d’enregistrer le formulaire ? + +Utilisez notre [page de contact](/contact) pour écrire au support technique. +Nous ferons de notre mieux pour vous répondre le plus rapidement possible. + +**Pour être clair : le support technique ne s’occupe que des questions techniques liées à l’utilisation de demarches.gouv.fr**. +Il ne pourra pas répondre aux questions concernant votre dossier ou le traitement de votre demande. + +Pour une question administrative, contactez plutôt l’administration en charge de votre dossier, +en cliquant sur le bouton « Aide » en haut à droite de la page ou depuis la messagerie de votre dossier. diff --git a/doc/faqs/usager/je-ne-peux-pas-faire-une-demande-car-je-n-ai-pas-de-SIRET.fr.md b/doc/faqs/usager/je-ne-peux-pas-faire-une-demande-car-je-n-ai-pas-de-SIRET.fr.md new file mode 100644 index 000000000..6a9e29681 --- /dev/null +++ b/doc/faqs/usager/je-ne-peux-pas-faire-une-demande-car-je-n-ai-pas-de-SIRET.fr.md @@ -0,0 +1,26 @@ +--- +title: "Je ne peux pas faire une demande car je n’ai pas de SIRET" +category: usager +subcategory: dossier_technical_issue +slug: "je-ne-peux-pas-faire-une-demande-car-je-n-ai-pas-de-SIRET" +locale: "fr" +keywords: "demande sans SIRET, service de l’État SIRET, association RNA, particulier procédure, INSEE SIRET" +--- + +# Je ne peux pas faire une demande car je n’ai pas de SIRET + +## Si vous êtes un service de l’État + +Vous avez normalement un numéro SIRET. Recherchez le nom de votre service sur l’[annuaire des entreprises](https://annuaire-entreprises.data.gouv.fr/), +vous devriez alors le trouver ainsi que le numéro SIRET associé. + +## Si vous êtes une association + +Vous avez sans doute un numéro au Registre National des Associations, mais pas forcément de numéro SIRET. +Il n’est pour l’instant malheureusement pas possible d’utiliser demarches.gouv.fr avec uniquement un numéro de RNA. + +La demande d’un numéro de SIRET à l’INSEE peut se faire assez simplement par email, en envoyant un message à *sirene-associations@insee.fr*. La procédure est décrite en détail sur cette page, avec un exemple de message-type : [https://www.service-public.fr/associations/vosdroits/F1926](https://www.service-public.fr/associations/vosdroits/F1926) + +## Si vous êtes un particulier + +Il est probable que la procédure sur laquelle vous voulez déposer une demande ne soit ouverte qu’aux entreprises. Si vous pensez que vous êtes bien la cible de cette procédure et que vous devriez pouvoir y déposer une demande, **contactez le service en charge de la démarche pour le signaler**. diff --git a/doc/faqs/usager/je-ne-recois-pas-d-email.fr.md b/doc/faqs/usager/je-ne-recois-pas-d-email.fr.md new file mode 100644 index 000000000..2262875c4 --- /dev/null +++ b/doc/faqs/usager/je-ne-recois-pas-d-email.fr.md @@ -0,0 +1,17 @@ +--- +title: "Je ne reçois pas d'email" +category: usager +subcategory: dossier_technical_issue +slug: "je-ne-recois-pas-d-email" +locale: "fr" +--- + +# Je ne reçois pas d’email + +Si vous ne recevez pas d’email, vous vous trouvez peut-être dans la situation suivante : + +- **Le mail est arrivé dans vos courriers indésirables.** Avez-vous vérifié dedans ? +- **Votre compte est associé à une autre adresse email.** Avez-vous bien vérifié qu'il s'agit de la bonne adresse mail de dépôt de dossier ? +- **Vous avez fait une erreur dans la saisie de votre adresse email.** Vous pouvez [créer à nouveau un compte](/users/sign_up), avec la bonne adresse. +- **Vous utilisez un outil de gestion des spams** (type MailInBlack) qui empêche la réception des emails. Il faut donc autoriser la réception des emails depuis demarches.gouv.fr. +- **Vous ne vous trouvez dans aucune des situations mentionnées** auquel cas nous vous prions de [nous contacter par email](/contact). diff --git a/doc/faqs/usager/je-souhaite-declarer-les-informations-relatives-aux-installations-de-combustion.fr.md b/doc/faqs/usager/je-souhaite-declarer-les-informations-relatives-aux-installations-de-combustion.fr.md new file mode 100644 index 000000000..8219a67cf --- /dev/null +++ b/doc/faqs/usager/je-souhaite-declarer-les-informations-relatives-aux-installations-de-combustion.fr.md @@ -0,0 +1,16 @@ +--- +title: "Je souhaite déclarer les informations relatives aux installations de combustion" +category: usager +subcategory: fill_dossier +slug: "je-souhaite-declarer-les-informations-relatives-aux-installations-de-combustion" +locale: "fr" +keywords: "installations combustion, arrêté 2019, recueil données, démarche environnementale" +--- + +# Je souhaite déclarer les informations relatives aux installations de combustion + +Dans le cadre de l'arrêté du 2 janvier 2019 - JO du 18 janvier 2019 - précisant +les modalités de recueil de données relatives aux installations +de combustion moyennes, vous pouvez remplir la démarche suivante : + +[https://demarches.gouv.fr/commencer/installations-de-combustion-moyennes-mcp-recueil-d](/commencer/installations-de-combustion-moyennes-mcp-recueil-d) diff --git a/doc/faqs/usager/je-souhaite-m-inscrire-a-l-epreuve-du-permis-de-conduire.fr.md b/doc/faqs/usager/je-souhaite-m-inscrire-a-l-epreuve-du-permis-de-conduire.fr.md new file mode 100644 index 000000000..dcdb584f9 --- /dev/null +++ b/doc/faqs/usager/je-souhaite-m-inscrire-a-l-epreuve-du-permis-de-conduire.fr.md @@ -0,0 +1,24 @@ +--- +category: "usager" +subcategory: "fill_dossier" +slug: "je-souhaite-m-inscrire-a-l-epreuve-du-permis-de-conduire" +locale: "fr" +keywords: "inscription, permis de conduire, épreuve pratique, démarches, préfecture" +title: "Je souhaite m’inscrire à l’épreuve du permis de conduire" +--- + +# Je souhaite m’inscrire à l’épreuve du permis de conduire + +Pour vous inscrire à l’épreuve pratique du permis de conduire, rendez-vous sur la page dédiée où vous trouverez les départements qui permettent d’utiliser demarches.gouv.fr pour vous inscrire. + +Par ailleurs, comme chaque administration choisit d’utiliser cette plateforme ou non, il n’est pas obligatoire que la démarche recherchée soit dématérialisée sur notre site. + +En ce sens, nous vous invitons à consulter le site de la préfecture pour prendre connaissance des modalités de dépôt de dossiers. + +Pour retrouver votre démarche ou être accompagné par un agent, nous vous invitons à consulter les sites suivants : + +- [Service-public.fr](https://www.service-public.fr), site public de renseignement administratif. + +- [Cartographie de l’inclusion numérique](https://cartographie.societenumerique.gouv.fr/orientation/besoin), cartographie permettant d’orienter les usagers vers les lieux d’inclusion numérique qui sauront répondre à vos besoins. + +- [France services](https://www.france-services.gouv.fr/demarches-et-services), pour vous aider dans l’accomplissement de vos démarches en ligne. diff --git a/doc/faqs/usager/je-souhaite-obtenir-un-duplicata-cerfa-02-neph.fr.md b/doc/faqs/usager/je-souhaite-obtenir-un-duplicata-cerfa-02-neph.fr.md new file mode 100644 index 000000000..3e72746a3 --- /dev/null +++ b/doc/faqs/usager/je-souhaite-obtenir-un-duplicata-cerfa-02-neph.fr.md @@ -0,0 +1,27 @@ +--- +category: "usager" +subcategory: "fill_dossier" +slug: "je-souhaite-obtenir-un-duplicata-cerfa-02-neph" +locale: "fr" +keywords: "duplicata, CERFA 02, NEPH, préfecture, France services" +title: "Je souhaite obtenir un duplicata CERFA 02 - NEPH" +--- + +# Je souhaite obtenir un duplicata CERFA 02 - NEPH + +Pour obtenir un duplicata du CERFA 02, rendez-vous sur la page dédiée où vous trouverez les départements qui permettent d’utiliser demarches.gouv.fr pour réactiver son numéro NEPH. + +Pour savoir comment remplir votre démarche, vous pouvez consulter le tutoriel usager. + +Comme chaque administration choisit d’utiliser cette plateforme ou non, **il n’est pas obligatoire que la démarche recherchée soit dématérialisée sur demarches.gouv.fr**. +En ce sens, nous vous invitons à contacter votre préfecture ou à consulter leur site internet. + +*Comme chaque administration choisit d’utiliser cette plateforme ou non, il n’est pas obligatoire que la démarche recherchée soit dématérialisée sur notre site.* + +Pour retrouver votre démarche ou être accompagné(e) par un agent, nous vous invitons à consulter les sites suivants : + +- [Service-public.fr](https://www.service-public.fr), site public de renseignement administratif. + +- [Cartographie de l’inclusion numérique](https://cartographie.societenumerique.gouv.fr/orientation/besoin), cartographie permettant d’orienter les usagers vers les lieux d’inclusion numérique qui sauront répondre à vos besoins. + +- Ou encore le site [France services](https://www.france-services.gouv.fr/demarches-et-services) afin de vous aider dans l’accomplissement de vos démarches en ligne. diff --git a/doc/faqs/usager/je-veux-changer-mon-mot-de-passe.fr.md b/doc/faqs/usager/je-veux-changer-mon-mot-de-passe.fr.md new file mode 100644 index 000000000..e36837714 --- /dev/null +++ b/doc/faqs/usager/je-veux-changer-mon-mot-de-passe.fr.md @@ -0,0 +1,27 @@ +--- +title: "Je veux changer mon mot de passe" +category: usager +subcategory: account +slug: "je-veux-changer-mon-mot-de-passe" +locale: "fr" +keywords: "mot de passe, changement mot de passe, réinitialisation mot de passe, lien réinitialisation" +--- + +# Je veux changer mon mot de passe + +Si vous souhaitez modifier le mot de passe de votre compte, vous pouvez demander à le changer. + +Pour cela : + +1. Ouvrez la page de [réinitialisation de mot de passe](/users/password/new) +2. Indiquez l’adresse email de votre compte demarches.gouv.fr +3. Vous recevrez par email un lien pour réinitialiser votre mot de passe. +4. Cliquez sur ce lien, et rentrez le nouveau mot de passe que vous souhaitez utiliser. + +## Vous n’avez pas reçu de mail de réinitialisation du mot de passe : + +Vérifiez que le message ne se trouve pas dans les spams ou indésirables. +L’email peut mettre quelques minutes avant que vous le receviez. Réitérez la demande éventuellement. + +Si ce n’est pas le cas, vous pouvez nous contacter par [notre formulaire de contact](/contact) +ou par email à l’adresse *contact@demarches-simplifiees.fr*. From ae78224baccbec8a5425821d818e7065401d914d Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 22 Apr 2024 16:43:57 +0200 Subject: [PATCH 0162/1532] feat(faq): link siblings FAQs of same category --- app/controllers/faq_controller.rb | 2 ++ app/services/faqs_loader_service.rb | 6 ++++++ app/views/faq/_sidebar.html.haml | 20 ++++++++++++++++++++ app/views/faq/show.html.haml | 2 ++ config/locales/faqs.fr.yml | 1 + 5 files changed, 31 insertions(+) create mode 100644 app/views/faq/_sidebar.html.haml diff --git a/app/controllers/faq_controller.rb b/app/controllers/faq_controller.rb index ba0b69a95..7d19499c3 100644 --- a/app/controllers/faq_controller.rb +++ b/app/controllers/faq_controller.rb @@ -7,6 +7,8 @@ class FAQController < ApplicationController @renderer = Redcarpet::Markdown.new( Redcarpet::BareRenderer.new(class_names_map: { list: 'fr-ol-content--override' }) ) + + @siblings = loader_service.faqs_for_category(@metadata[:category]) end private diff --git a/app/services/faqs_loader_service.rb b/app/services/faqs_loader_service.rb index 2665d8aa8..95db7b63d 100644 --- a/app/services/faqs_loader_service.rb +++ b/app/services/faqs_loader_service.rb @@ -16,6 +16,12 @@ class FAQsLoaderService FrontMatterParser::Parser.parse_file(file_path) end + def faqs_for_category(category) + @faqs_by_path.values + .filter { |faq| faq[:category] == category } + .group_by { |faq| faq[:subcategory] } + end + private def load_faqs diff --git a/app/views/faq/_sidebar.html.haml b/app/views/faq/_sidebar.html.haml new file mode 100644 index 000000000..d6055490a --- /dev/null +++ b/app/views/faq/_sidebar.html.haml @@ -0,0 +1,20 @@ +%nav.fr-sidemenu.fr-sidemenu--sticky{ role: "navigation", 'aria-labelledby': "fr-sidemenu-title" } + .fr-sidemenu__inner + %button.fr-sidemenu__btn{ 'aria-controls': "fr-sidemenu-wrapper", 'aria-expanded': "false" } + = t(:sidebar_button, scope: [:faq]) + .fr-collapse#fr-sidemenu-wrapper + .fr-sidemenu__title#fr-sidemenu-title + = t(:name, scope: [:faq, :categories, current[:category]]) + %ul.fr-sidemenu__list + - faqs.each_with_index do |(subcategory, faqs), index| + %li{ class: class_names("fr-sidemenu__item", "fr-sidemenu__item--active" => subcategory == current[:subcategory]) } + %button.fr-sidemenu__btn{ aria: { 'expanded': subcategory == current[:subcategory] ? "true" : "false", + 'controls': "fr-sidemenu-item-#{index}", + 'current' => subcategory == current[:subcategory] ? "true" : nil } } + = t(:name, scope: [:faq, :subcategories, subcategory]) + .fr-collapse{ id: "fr-sidemenu-item-#{index}" } + %ul.fr-sidemenu__list + - faqs.each do |faq| + %li{ class: class_names("fr-sidemenu__item", "fr-sidemenu__item--active" => faq[:slug] == current[:slug]) } + = link_to faq[:title], faq_path(category: faq[:category], slug: faq[:slug]), + class: 'fr-sidemenu__link', target: "_self", "aria-current" => current[:slug] == faq[:slug] ? "page" : nil diff --git a/app/views/faq/show.html.haml b/app/views/faq/show.html.haml index 46dc34cd9..b72b8c8f0 100644 --- a/app/views/faq/show.html.haml +++ b/app/views/faq/show.html.haml @@ -2,5 +2,7 @@ .fr-container.fr-my-4w .fr-grid-row + .fr-col-12.fr-col-md-4 + = render partial: "sidebar", locals: { faqs: @siblings, current: @metadata } .fr-col-12.fr-col-md-8.fr-py-12v = @renderer.render(@content).html_safe diff --git a/config/locales/faqs.fr.yml b/config/locales/faqs.fr.yml index 155a815f2..f57a355c4 100644 --- a/config/locales/faqs.fr.yml +++ b/config/locales/faqs.fr.yml @@ -1,5 +1,6 @@ fr: faq: + sidebar_button: Dans cette Foire Aux Questions categories: usager: name: Usager (dépôt d’un dossier) From 08c237c028038cc5f498e02340d3707872f6c470 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Mon, 22 Apr 2024 16:44:27 +0200 Subject: [PATCH 0163/1532] feat(faq): list FAQs --- app/controllers/faq_controller.rb | 4 ++++ app/services/faqs_loader_service.rb | 10 ++++++++++ app/views/faq/index.html.haml | 25 +++++++++++++++++++++++++ config/locales/faqs.fr.yml | 3 +++ config/routes.rb | 1 + 5 files changed, 43 insertions(+) create mode 100644 app/views/faq/index.html.haml diff --git a/app/controllers/faq_controller.rb b/app/controllers/faq_controller.rb index 7d19499c3..a6b28d7ed 100644 --- a/app/controllers/faq_controller.rb +++ b/app/controllers/faq_controller.rb @@ -3,6 +3,10 @@ class FAQController < ApplicationController before_action :load_faq_data, only: :show + def index + @faqs = loader_service.all + end + def show @renderer = Redcarpet::Markdown.new( Redcarpet::BareRenderer.new(class_names_map: { list: 'fr-ol-content--override' }) diff --git a/app/services/faqs_loader_service.rb b/app/services/faqs_loader_service.rb index 95db7b63d..19acd97fb 100644 --- a/app/services/faqs_loader_service.rb +++ b/app/services/faqs_loader_service.rb @@ -22,6 +22,16 @@ class FAQsLoaderService .group_by { |faq| faq[:subcategory] } end + def all + @faqs_by_path.values + .group_by { |faq| faq.fetch(:category) } + .sort_by { |category, _| ORDER.index(category) || ORDER.size } + .to_h + .transform_values do |faqs| + faqs.group_by { |faq| faq.fetch(:subcategory) } + end + end + private def load_faqs diff --git a/app/views/faq/index.html.haml b/app/views/faq/index.html.haml new file mode 100644 index 000000000..e0c311e18 --- /dev/null +++ b/app/views/faq/index.html.haml @@ -0,0 +1,25 @@ +- content_for(:title, t('.meta_title')) + +.fr-container.fr-my-4w + .fr-grid-row + .fr-col-12.fr-col-md-10 + %h1= t('.title', app_name: Current.application_name) + + - @faqs.each do |category, subcategories| + %h2= t(:name, scope: [:faq, :categories, category], raise: true) + %p= t(:description, scope: [:faq, :categories, category], raise: true) + + .fr-accordions-group.fr-mb-6w + - subcategories.each_with_index do |(subcategory, faqs), index| + %section.fr-accordion + %h3.fr-accordion__title + %button.fr-accordion__btn{ 'aria-expanded': "false", 'aria-controls': "accordion-#{category}-#{index}" } + = t(:name, scope: [:faq, :subcategories, subcategory], raise: true) + + .fr-collapse{ id: "accordion-#{category}-#{index}" } + - description = t(:description, scope: [:faq, :subcategories, subcategory], default: nil) + %p= description if description.present? + + %ul + - faqs.each do |faq| + %li= link_to faq[:title], faq_path(category: faq[:category], slug: faq[:slug]), class: "fr-link" diff --git a/config/locales/faqs.fr.yml b/config/locales/faqs.fr.yml index f57a355c4..bf647efa8 100644 --- a/config/locales/faqs.fr.yml +++ b/config/locales/faqs.fr.yml @@ -1,5 +1,8 @@ fr: faq: + index: + meta_title: "Foire aux Questions" + title: Foire aux Questions (FAQ) de %{app_name} sidebar_button: Dans cette Foire Aux Questions categories: usager: diff --git a/config/routes.rb b/config/routes.rb index b5b8520d2..cb068da33 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -715,6 +715,7 @@ Rails.application.routes.draw do resources :release_notes, only: [:index] + resources :faq, only: [:index] get '/faq/:category/:slug', to: 'faq#show', as: :faq get '/404', to: 'errors#not_found' From 7a5cb7dbd2c21817b787ee261f9c80d3ff69fc55 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 12 Mar 2024 16:49:49 +0100 Subject: [PATCH 0164/1532] feat(faq): can render images --- .../faq/usager-confirm-update-email.png | Bin 0 -> 9208 bytes app/assets/images/faq/usager-dropdown.png | Bin 0 -> 14830 bytes app/assets/images/faq/usager-edit-email.png | Bin 0 -> 8264 bytes app/assets/stylesheets/markdown-content.scss | 20 +++++++++ app/controllers/faq_controller.rb | 4 +- app/lib/redcarpet/bare_renderer.rb | 2 +- app/lib/redcarpet/trusted_renderer.rb | 41 ++++++++++++++++++ app/views/faq/show.html.haml | 3 +- .../je-veux-changer-mon-adresse-email.fr.md | 41 ++++++++++++++++++ spec/lib/redcarpet/trusted_renderer_spec.rb | 35 +++++++++++++++ 10 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 app/assets/images/faq/usager-confirm-update-email.png create mode 100644 app/assets/images/faq/usager-dropdown.png create mode 100644 app/assets/images/faq/usager-edit-email.png create mode 100644 app/assets/stylesheets/markdown-content.scss create mode 100644 app/lib/redcarpet/trusted_renderer.rb create mode 100644 doc/faqs/usager/je-veux-changer-mon-adresse-email.fr.md create mode 100644 spec/lib/redcarpet/trusted_renderer_spec.rb diff --git a/app/assets/images/faq/usager-confirm-update-email.png b/app/assets/images/faq/usager-confirm-update-email.png new file mode 100644 index 0000000000000000000000000000000000000000..b93ff568070c30e508dc14dc7e28ee7ed803c3fb GIT binary patch literal 9208 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 IoLT7q0TE}FzyJUM literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/usager-dropdown.png b/app/assets/images/faq/usager-dropdown.png new file mode 100644 index 0000000000000000000000000000000000000000..6fd5f65a27db73ac55a06cf2041c2cf2555b8cee GIT binary patch literal 14830 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/usager-edit-email.png b/app/assets/images/faq/usager-edit-email.png new file mode 100644 index 0000000000000000000000000000000000000000..6a8b0b48d6e4a4d04f57fd779aca55f6cdcfc20c GIT binary patch literal 8264 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 n(*NOfucXxoUQqoHDg3t literal 0 HcmV?d00001 diff --git a/app/assets/stylesheets/markdown-content.scss b/app/assets/stylesheets/markdown-content.scss new file mode 100644 index 000000000..c166078e8 --- /dev/null +++ b/app/assets/stylesheets/markdown-content.scss @@ -0,0 +1,20 @@ +.markdown-content { + img { + max-width: 100%; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); + + display: block; + + // In markdown img are always wrapped in p, + // which already contains vertical margin. + // We only add margin when there are siblings. + // NOTE: CSS consider the img is only-child even + // when there are only text node siblings, but it's still fine for us. + margin: 1.5rem auto; + + &:only-child { + margin-top: 0; + margin-bottom: 0; + } + } +} diff --git a/app/controllers/faq_controller.rb b/app/controllers/faq_controller.rb index a6b28d7ed..6c28f2725 100644 --- a/app/controllers/faq_controller.rb +++ b/app/controllers/faq_controller.rb @@ -8,9 +8,7 @@ class FAQController < ApplicationController end def show - @renderer = Redcarpet::Markdown.new( - Redcarpet::BareRenderer.new(class_names_map: { list: 'fr-ol-content--override' }) - ) + @renderer = Redcarpet::Markdown.new(Redcarpet::TrustedRenderer.new(view_context), autolink: true) @siblings = loader_service.faqs_for_category(@metadata[:category]) end diff --git a/app/lib/redcarpet/bare_renderer.rb b/app/lib/redcarpet/bare_renderer.rb index d8944374c..39bfc9342 100644 --- a/app/lib/redcarpet/bare_renderer.rb +++ b/app/lib/redcarpet/bare_renderer.rb @@ -33,7 +33,7 @@ module Redcarpet when :url link(link, nil, link) when :email - # NOTE: As of Redcarpet 3.6.0, autolinking email containing is broken https://github.com/vmg/redcarpet/issues/402 + # NOTE: As of Redcarpet 3.6.0, autolinking email containing underscore is broken https://github.com/vmg/redcarpet/issues/402 content_tag(:a, link, { href: "mailto:#{link}" }) else link diff --git a/app/lib/redcarpet/trusted_renderer.rb b/app/lib/redcarpet/trusted_renderer.rb new file mode 100644 index 000000000..f0ee50129 --- /dev/null +++ b/app/lib/redcarpet/trusted_renderer.rb @@ -0,0 +1,41 @@ +module Redcarpet + class TrustedRenderer < Redcarpet::Render::HTML + include ActionView::Helpers::TagHelper + include Sprockets::Rails::Helper + include ApplicationHelper + + attr_reader :view_context + + def initialize(view_context, extensions = {}) + @view_context = view_context + + super extensions + end + + def link(href, title, content) + html_options = { + href: href + } + + unless href.starts_with?('/') + html_options.merge!(title: new_tab_suffix(title), **external_link_attributes) + end + + content_tag(:a, content, html_options, false) + end + + def autolink(link, link_type) + case link_type + when :url + link(link, nil, link) + when :email + # NOTE: As of Redcarpet 3.6.0, autolinking email containing underscore is broken https://github.com/vmg/redcarpet/issues/402 + content_tag(:a, link, { href: "mailto:#{link}" }) + end + end + + def image(link, title, alt) + view_context.image_tag(link, title:, alt:, loading: :lazy) + end + end +end diff --git a/app/views/faq/show.html.haml b/app/views/faq/show.html.haml index b72b8c8f0..cca79a4f0 100644 --- a/app/views/faq/show.html.haml +++ b/app/views/faq/show.html.haml @@ -5,4 +5,5 @@ .fr-col-12.fr-col-md-4 = render partial: "sidebar", locals: { faqs: @siblings, current: @metadata } .fr-col-12.fr-col-md-8.fr-py-12v - = @renderer.render(@content).html_safe + .markdown-content + = @renderer.render(@content).html_safe diff --git a/doc/faqs/usager/je-veux-changer-mon-adresse-email.fr.md b/doc/faqs/usager/je-veux-changer-mon-adresse-email.fr.md new file mode 100644 index 000000000..eb0ab61b9 --- /dev/null +++ b/doc/faqs/usager/je-veux-changer-mon-adresse-email.fr.md @@ -0,0 +1,41 @@ + +--- +category: "usager" +subcategory: "account" +slug: "je-veux-changer-mon-adresse-email" +locale: "fr" +keywords: "adresse email, compte usager, sécurité, changement, profil" +title: "Je veux changer mon adresse email" +--- + +# Je veux changer mon adresse email + +Si vous disposez d’un compte usager sur demarches.gouv.fr, il est possible de changer l’adresse email associée à celui-ci. + +**Attention** : pour des raisons de sécurité, les comptes instructeur et administrateur sur demarches.gouv.fr doivent nous contacter à contact@demarches.gouv.fr pour demander ce changement. + +Cette adresse correspond à l’identifiant avec lequel vous vous connectez à demarches.gouv.fr. C’est également à cette adresse que nous envoyons les messages concernant l’avancement de votre dossier. + +## Changer mon adresse email + +Pour changer l’adresse email associée à votre compte, suivez les étapes suivantes : + +1. [Connectez-vous](/users/sign_in) à votre compte sur demarches.gouv.fr ; +2. Cliquez sur le menu contenant votre adresse email en haut à droite de la page, puis sur _« Voir mon profil »_, ou [suivez directement ce lien si vous êtes déjà connecté(e)](/profil). +![Menu Usager avec lien Voir mon profil](faq/usager-dropdown.png) + +3. Dans l’encadré _« Coordonnées »_, renseignez la nouvelle adresse email que vous souhaitez utiliser. Puis cliquez sur _« Changer mon adresse »_. **Attention** : Cette adresse ne doit pas être déjà utilisée par un autre compte sur demarches.gouv.fr. +![Section Coordonées avec formulaire de modification d’email](faq/usager-edit-email.png) + +4. Ouvrez la boîte email de votre nouvelle adresse, et cliquez sur le lien de confirmation que nous vous avons envoyé.
    + ![Capture d’écran de l’email de confirmation de changement d’adresse email](faq/usager-confirm-update-email.png) + +## Si l’adresse est déjà utilisée par un autre compte + +La nouvelle adresse email ne doit pas être déjà utilisée par un compte existant sur demarches.gouv.fr. + +**Si la nouvelle adresse est déjà utilisée, vous recevrez un email vous informant que le changement d’adresse ne peut pas être pris en compte.** + +Dans ce cas, revenez sur la page _« Profil »_, et choisissez une autre adresse email disponible. + +Par ailleurs, vous pouvez également transférer plusieurs dossiers depuis votre profil, cela vous permet tout de même de conserver votre compte. diff --git a/spec/lib/redcarpet/trusted_renderer_spec.rb b/spec/lib/redcarpet/trusted_renderer_spec.rb new file mode 100644 index 000000000..046472026 --- /dev/null +++ b/spec/lib/redcarpet/trusted_renderer_spec.rb @@ -0,0 +1,35 @@ +RSpec.describe Redcarpet::TrustedRenderer do + let(:view_context) { ActionController::Base.new.view_context } + subject(:renderer) { Redcarpet::Markdown.new(described_class.new(view_context), autolink: true) } + + context 'when rendering links' do + it 'renders internal links without target and rel attributes' do + markdown = "[Click here](/internal)" + expect(renderer.render(markdown)).to include('Click here') + end + + it 'renders external links with target="_blank" and rel="noopener noreferrer"' do + markdown = "[Visit](http://example.com)" + expect(renderer.render(markdown)).to include('Visit') + end + end + + context 'when rendering images' do + it 'renders an image tag with lazy loading' do + markdown = "![A cute cat](http://example.com/cat.jpg)" + expect(renderer.render(markdown)).to include('A cute cat') + end + end + + context 'when autolinking' do + it 'autolinks URLs' do + markdown = "Visit http://example.com" + expect(renderer.render(markdown)).to include('Visit http://example.com') + end + + it 'autolinks email addresses with mailto' do + markdown = "Email user@example.com" + expect(renderer.render(markdown)).to include('user@example.com') + end + end +end From a01bbd68c7ed8a97c2dd8c302acde22edca5e956 Mon Sep 17 00:00:00 2001 From: mfo Date: Thu, 16 May 2024 09:48:05 +0200 Subject: [PATCH 0165/1532] fix(backfill_invalid_dossiers): not tested, typo --- .../maintenance/backfill_invalid_dossiers_for_tiers_task.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/tasks/maintenance/backfill_invalid_dossiers_for_tiers_task.rb b/app/tasks/maintenance/backfill_invalid_dossiers_for_tiers_task.rb index ac99289eb..b7c1149ea 100644 --- a/app/tasks/maintenance/backfill_invalid_dossiers_for_tiers_task.rb +++ b/app/tasks/maintenance/backfill_invalid_dossiers_for_tiers_task.rb @@ -7,7 +7,7 @@ module Maintenance end def process(element) - element.update_column(for_tiers: false) + element.update_column(:for_tiers, false) end end end From d855c94fbfb82b4f0abcbb7ff8c7a3ba6f48ae4e Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Tue, 12 Mar 2024 17:18:18 +0100 Subject: [PATCH 0166/1532] feat(faq): breadcrumb --- app/views/faq/_breadcrumb.html.haml | 13 +++++++++++++ app/views/faq/index.html.haml | 1 + app/views/faq/show.html.haml | 5 ++++- config/locales/faqs.fr.yml | 3 +++ config/locales/views/layouts/_breadcrumb.en.yml | 1 + config/locales/views/layouts/_breadcrumb.fr.yml | 1 + 6 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 app/views/faq/_breadcrumb.html.haml diff --git a/app/views/faq/_breadcrumb.html.haml b/app/views/faq/_breadcrumb.html.haml new file mode 100644 index 000000000..205b5effb --- /dev/null +++ b/app/views/faq/_breadcrumb.html.haml @@ -0,0 +1,13 @@ +%nav.fr-breadcrumb{ role: "navigation", 'aria-label': t('you_are_here', scope: [:layouts, :breadcrumb]) } + %button.fr-breadcrumb__button{ 'aria-expanded' => "false", 'aria-controls' => "breadcrumb-1" } + = t('show', scope: [:layouts, :breadcrumb]) + .fr-collapse#breadcrumb-1 + %ol.fr-breadcrumb__list + %li= link_to t('root', scope: [:layouts, :breadcrumb]), root_path, class: 'fr-breadcrumb__link' + + %li + %a.fr-breadcrumb__link{ **(defined?(faq_title) ? { href: faq_index_path } : { "aria-current": "page" }) }= t('faq', scope: [:layouts, :breadcrumb]) + + - if defined?(faq_title) + %li + %a.fr-breadcrumb__link{ 'aria-current' => "page" }= faq_title diff --git a/app/views/faq/index.html.haml b/app/views/faq/index.html.haml index e0c311e18..a2026b203 100644 --- a/app/views/faq/index.html.haml +++ b/app/views/faq/index.html.haml @@ -1,6 +1,7 @@ - content_for(:title, t('.meta_title')) .fr-container.fr-my-4w + = render partial: "breadcrumb" .fr-grid-row .fr-col-12.fr-col-md-10 %h1= t('.title', app_name: Current.application_name) diff --git a/app/views/faq/show.html.haml b/app/views/faq/show.html.haml index cca79a4f0..b2ff97df6 100644 --- a/app/views/faq/show.html.haml +++ b/app/views/faq/show.html.haml @@ -1,9 +1,12 @@ - content_for(:title, @metadata[:title]) .fr-container.fr-my-4w + .fr-grid-row .fr-col-12.fr-col-md-4 = render partial: "sidebar", locals: { faqs: @siblings, current: @metadata } - .fr-col-12.fr-col-md-8.fr-py-12v + .fr-col-12.fr-col-md-8 + = render partial: "breadcrumb", locals: { faq_title: "#{t(:short_name, scope: [:faq, :categories, @metadata[:category]])} : #{@metadata[:title]}" } + .markdown-content = @renderer.render(@content).html_safe diff --git a/config/locales/faqs.fr.yml b/config/locales/faqs.fr.yml index bf647efa8..b7bf89765 100644 --- a/config/locales/faqs.fr.yml +++ b/config/locales/faqs.fr.yml @@ -6,12 +6,15 @@ fr: sidebar_button: Dans cette Foire Aux Questions categories: usager: + short_name: Usager name: Usager (dépôt d’un dossier) description: Aide pour les usagers sur la soumission et le suivi des dossiers, incluant la résolution des problèmes courants. instructeur: + short_name: Instructeur name: Instructeur (traitement des dossiers) description: À destination des instructeurs sur l’accès et la gestion des dossiers. administrateur: + short_name: Administrateur name: Administrateur (création d’un formulaire) description: Informations pour les administrateurs sur la configuration de la plateforme ou la création de démarches. subcategories: diff --git a/config/locales/views/layouts/_breadcrumb.en.yml b/config/locales/views/layouts/_breadcrumb.en.yml index 167f44f30..03328421e 100644 --- a/config/locales/views/layouts/_breadcrumb.en.yml +++ b/config/locales/views/layouts/_breadcrumb.en.yml @@ -16,3 +16,4 @@ en: more_info_on_test: "For more information on test stage" go_to_FAQ: "read FAQ" url_FAQ: "https://faq.demarches-simplifiees.fr/category/49-comment-tester-ma-demarche" + faq: Frequently Asked Questions diff --git a/config/locales/views/layouts/_breadcrumb.fr.yml b/config/locales/views/layouts/_breadcrumb.fr.yml index 27086e424..f67542e4e 100644 --- a/config/locales/views/layouts/_breadcrumb.fr.yml +++ b/config/locales/views/layouts/_breadcrumb.fr.yml @@ -16,3 +16,4 @@ fr: more_info_on_test: "Pour plus d’information sur la phase de test" go_to_FAQ: "consulter la FAQ" url_FAQ: "https://faq.demarches-simplifiees.fr/category/49-comment-tester-ma-demarche" + faq: Foire aux Questions From 472a9ae2ef2af9d458bee2e174771ffe10085eb2 Mon Sep 17 00:00:00 2001 From: Colin Darie Date: Wed, 13 Mar 2024 19:32:25 +0100 Subject: [PATCH 0167/1532] chore(faq): more faq --- .../faq/administrateur-add-administrateur.png | Bin 0 -> 15502 bytes .../faq/administrateur-all-procedures.png | Bin 0 -> 40702 bytes .../administrateur-button-copy-procedure.png | Bin 0 -> 8806 bytes .../faq/administrateur-create-declarative.png | Bin 0 -> 63501 bytes .../administrateur-example-markup-preview.png | Bin 0 -> 11794 bytes .../faq/administrateur-example-markup.png | Bin 0 -> 7304 bytes .../administrateur-link-all-procedures.png | Bin 0 -> 12630 bytes .../administrateur-list-champs-repetition.png | Bin 0 -> 48741 bytes .../administrateur-procedure-action-close.png | Bin 0 -> 11890 bytes .../administrateur-procedure-auto-archive.png | Bin 0 -> 11654 bytes ...administrateur-procedure-close-message.png | Bin 0 -> 13919 bytes ...administrateur-procedure-close-replace.png | Bin 0 -> 16128 bytes .../administrateur-procedure-test-button.png | Bin 0 -> 26269 bytes ...dministrateur-procedure-test-commencer.png | Bin 0 -> 26102 bytes .../administrateur-procedure-test-link.png | Bin 0 -> 21163 bytes .../administrateur-procedure-test-publish.png | Bin 0 -> 28455 bytes .../administrateur-procedure-test-thanks.png | Bin 0 -> 11761 bytes .../administrateur-procedure-test-usager.png | Bin 0 -> 32062 bytes .../administrateur-procedures-list-header.png | Bin 0 -> 11736 bytes .../faq/administrateur-procedures-list.png | Bin 0 -> 21280 bytes .../faq/administrateur-profile-switch.png | Bin 0 -> 23269 bytes .../faq/administrateur-repetition-create.png | Bin 0 -> 21996 bytes ...istrateur-repetition-view-usager-empty.png | Bin 0 -> 3452 bytes ...nistrateur-repetition-view-usager-fill.png | Bin 0 -> 15863 bytes .../administrateur-set-auto-close-date.png | Bin 0 -> 10680 bytes ...trateur-test-instruction-dossiers-list.png | Bin 0 -> 30141 bytes .../instructeur-accepter-add-justificatif.png | Bin 0 -> 29197 bytes .../faq/instructeur-dossiers-list-header.png | Bin 0 -> 21267 bytes .../images/faq/instructeur-filtres-and.png | Bin 0 -> 4558 bytes .../images/faq/instructeur-filtres-date.png | Bin 0 -> 4295 bytes .../faq/instructeur-filtres-dropdown.png | Bin 0 -> 8878 bytes .../images/faq/instructeur-filtres-list.png | Bin 0 -> 30466 bytes .../images/faq/instructeur-filtres-or.png | Bin 0 -> 4331 bytes .../faq/instructeur-procedure-header.png | Bin 0 -> 15939 bytes .../instructeur-procedure-notifications.png | Bin 0 -> 25740 bytes .../images/faq/instructeur-procedure-show.png | Bin 0 -> 76422 bytes app/assets/images/faq/sign-in-page.png | Bin 0 -> 39124 bytes .../faq/usager-dossier-accepte-summary.png | Bin 0 -> 17508 bytes .../faq/usager-dossier-actions-menu-clone.png | Bin 0 -> 24936 bytes .../usager-dossier-actions-menu-start-new.png | Bin 0 -> 25880 bytes .../usager-dossier-actions-menu-transfer.png | Bin 0 -> 16325 bytes .../faq/usager-dossier-cloned-draft.png | Bin 0 -> 26336 bytes .../images/faq/usager-dossiers-list.png | Bin 0 -> 28908 bytes .../faq/usager-edit-identity-brouillon-1.png | Bin 0 -> 23989 bytes .../faq/usager-edit-identity-brouillon-2.png | Bin 0 -> 28546 bytes .../usager-edit-identity-construction-1.png | Bin 0 -> 23523 bytes .../usager-edit-identity-construction-2.png | Bin 0 -> 23355 bytes ...ger-email-dossier-repasser-instruction.png | Bin 0 -> 18070 bytes .../images/faq/usager-footer-contact.png | Bin 0 -> 15296 bytes .../images/faq/usager-form-footer-submit.png | Bin 0 -> 3364 bytes app/assets/images/faq/usager-messagerie.png | Bin 0 -> 29848 bytes .../usager-procedure-close-focus-contact.png | Bin 0 -> 42145 bytes .../images/faq/usager-transfer-dossier.png | Bin 0 -> 14844 bytes ...dministrateurs-sur-une-meme-demarche.fr.md | 25 +++++ .../comment-clore-une-demarche.fr.md | 37 +++++++ ...e-mon-tableau-de-bord-administrateur.fr.md | 32 ++++++ ...mment-creer-une-demarche-declarative.fr.md | 27 +++++ ...t-limiter-une-demarche-dans-le-temps.fr.md | 27 +++++ ...tre-en-forme-le-texte-de-ma-demarche.fr.md | 60 ++++++++++ ...t-modifier-une-demarche-deja-publiee.fr.md | 31 ++++++ ...ent-obtenir-un-compte-administrateur.fr.md | 18 +++ ...-tester-une-demarche-par-un-collegue.fr.md | 23 ++++ ...s-pratiques-pour-tester-une-demarche.fr.md | 104 ++++++++++++++++++ ...firmer-mon-compte-a-chaque-connexion.fr.md | 36 ++++++ ...arche-dans-le-catalogue-de-demarches.fr.md | 35 ++++++ .../administrateur/les-blocs-repetables.fr.md | 39 +++++++ ...r-des-demarches-les-bonnes-pratiques.fr.md | 20 ++++ .../qu-est-ce-qu-un-administrateur.fr.md | 16 +++ ...s-differentes-categories-de-dossiers.fr.md | 23 ++++ ...f-lors-de-l-acceptation-d-un-dossier.fr.md | 22 ++++ ...omment-filtrer-la-liste-des-dossiers.fr.md | 52 +++++++++ ...chaque-fois-qu-un-dossier-est-depose.fr.md | 28 +++++ ...t-repasser-un-dossier-en-instruction.fr.md | 22 ++++ ...quels-sont-les-navigateurs-supportes.fr.md | 32 ++++++ ...autre-dossier-pour-une-meme-demarche.fr.md | 14 +++ ...ent-dupliquer-un-dossier-deja-depose.fr.md | 28 +++++ .../usager/comment-trouver-ma-demarche.fr.md | 63 +++++++++++ ...-le-service-en-charge-de-ma-demarche.fr.md | 29 +++++ ...rmulaire-pour-le-reprendre-plus-tard.fr.md | 31 ++++++ ...-en-est-l-instruction-de-ma-demarche.fr.md | 28 +++++ ...l-identite-du-demandeur-d-un-dossier.fr.md | 40 +++++++ ...ar-un-tiers-et-je-souhaite-y-acceder.fr.md | 21 ++++ ...quels-sont-les-navigateurs-supportes.fr.md | 22 ++++ 83 files changed, 985 insertions(+) create mode 100644 app/assets/images/faq/administrateur-add-administrateur.png create mode 100644 app/assets/images/faq/administrateur-all-procedures.png create mode 100644 app/assets/images/faq/administrateur-button-copy-procedure.png create mode 100644 app/assets/images/faq/administrateur-create-declarative.png create mode 100644 app/assets/images/faq/administrateur-example-markup-preview.png create mode 100644 app/assets/images/faq/administrateur-example-markup.png create mode 100644 app/assets/images/faq/administrateur-link-all-procedures.png create mode 100644 app/assets/images/faq/administrateur-list-champs-repetition.png create mode 100644 app/assets/images/faq/administrateur-procedure-action-close.png create mode 100644 app/assets/images/faq/administrateur-procedure-auto-archive.png create mode 100644 app/assets/images/faq/administrateur-procedure-close-message.png create mode 100644 app/assets/images/faq/administrateur-procedure-close-replace.png create mode 100644 app/assets/images/faq/administrateur-procedure-test-button.png create mode 100644 app/assets/images/faq/administrateur-procedure-test-commencer.png create mode 100644 app/assets/images/faq/administrateur-procedure-test-link.png create mode 100644 app/assets/images/faq/administrateur-procedure-test-publish.png create mode 100644 app/assets/images/faq/administrateur-procedure-test-thanks.png create mode 100644 app/assets/images/faq/administrateur-procedure-test-usager.png create mode 100644 app/assets/images/faq/administrateur-procedures-list-header.png create mode 100644 app/assets/images/faq/administrateur-procedures-list.png create mode 100644 app/assets/images/faq/administrateur-profile-switch.png create mode 100644 app/assets/images/faq/administrateur-repetition-create.png create mode 100644 app/assets/images/faq/administrateur-repetition-view-usager-empty.png create mode 100644 app/assets/images/faq/administrateur-repetition-view-usager-fill.png create mode 100644 app/assets/images/faq/administrateur-set-auto-close-date.png create mode 100644 app/assets/images/faq/administrateur-test-instruction-dossiers-list.png create mode 100644 app/assets/images/faq/instructeur-accepter-add-justificatif.png create mode 100644 app/assets/images/faq/instructeur-dossiers-list-header.png create mode 100644 app/assets/images/faq/instructeur-filtres-and.png create mode 100644 app/assets/images/faq/instructeur-filtres-date.png create mode 100644 app/assets/images/faq/instructeur-filtres-dropdown.png create mode 100644 app/assets/images/faq/instructeur-filtres-list.png create mode 100644 app/assets/images/faq/instructeur-filtres-or.png create mode 100644 app/assets/images/faq/instructeur-procedure-header.png create mode 100644 app/assets/images/faq/instructeur-procedure-notifications.png create mode 100644 app/assets/images/faq/instructeur-procedure-show.png create mode 100644 app/assets/images/faq/sign-in-page.png create mode 100644 app/assets/images/faq/usager-dossier-accepte-summary.png create mode 100644 app/assets/images/faq/usager-dossier-actions-menu-clone.png create mode 100644 app/assets/images/faq/usager-dossier-actions-menu-start-new.png create mode 100644 app/assets/images/faq/usager-dossier-actions-menu-transfer.png create mode 100644 app/assets/images/faq/usager-dossier-cloned-draft.png create mode 100644 app/assets/images/faq/usager-dossiers-list.png create mode 100644 app/assets/images/faq/usager-edit-identity-brouillon-1.png create mode 100644 app/assets/images/faq/usager-edit-identity-brouillon-2.png create mode 100644 app/assets/images/faq/usager-edit-identity-construction-1.png create mode 100644 app/assets/images/faq/usager-edit-identity-construction-2.png create mode 100644 app/assets/images/faq/usager-email-dossier-repasser-instruction.png create mode 100644 app/assets/images/faq/usager-footer-contact.png create mode 100644 app/assets/images/faq/usager-form-footer-submit.png create mode 100644 app/assets/images/faq/usager-messagerie.png create mode 100644 app/assets/images/faq/usager-procedure-close-focus-contact.png create mode 100644 app/assets/images/faq/usager-transfer-dossier.png create mode 100644 doc/faqs/administrateur/ajouter-plusieurs-administrateurs-sur-une-meme-demarche.fr.md create mode 100644 doc/faqs/administrateur/comment-clore-une-demarche.fr.md create mode 100644 doc/faqs/administrateur/comment-comprendre-mon-tableau-de-bord-administrateur.fr.md create mode 100644 doc/faqs/administrateur/comment-creer-une-demarche-declarative.fr.md create mode 100644 doc/faqs/administrateur/comment-limiter-une-demarche-dans-le-temps.fr.md create mode 100644 doc/faqs/administrateur/comment-mettre-en-forme-le-texte-de-ma-demarche.fr.md create mode 100644 doc/faqs/administrateur/comment-modifier-une-demarche-deja-publiee.fr.md create mode 100644 doc/faqs/administrateur/comment-obtenir-un-compte-administrateur.fr.md create mode 100644 doc/faqs/administrateur/faire-tester-une-demarche-par-un-collegue.fr.md create mode 100644 doc/faqs/administrateur/guide-de-bonnes-pratiques-pour-tester-une-demarche.fr.md create mode 100644 doc/faqs/administrateur/je-dois-confirmer-mon-compte-a-chaque-connexion.fr.md create mode 100644 doc/faqs/administrateur/je-ne-trouve-pas-ma-demarche-dans-le-catalogue-de-demarches.fr.md create mode 100644 doc/faqs/administrateur/les-blocs-repetables.fr.md create mode 100644 doc/faqs/administrateur/nommer-un-nouvel-administrateur-des-demarches-les-bonnes-pratiques.fr.md create mode 100644 doc/faqs/administrateur/qu-est-ce-qu-un-administrateur.fr.md create mode 100644 doc/faqs/instructeur/a-quoi-correspondent-les-differentes-categories-de-dossiers.fr.md create mode 100644 doc/faqs/instructeur/comment-ajouter-un-justificatif-lors-de-l-acceptation-d-un-dossier.fr.md create mode 100644 doc/faqs/instructeur/comment-filtrer-la-liste-des-dossiers.fr.md create mode 100644 doc/faqs/instructeur/comment-recevoir-un-email-chaque-fois-qu-un-dossier-est-depose.fr.md create mode 100644 doc/faqs/instructeur/comment-repasser-un-dossier-en-instruction.fr.md create mode 100644 doc/faqs/instructeur/quels-sont-les-navigateurs-supportes.fr.md create mode 100644 doc/faqs/usager/comment-deposer-un-autre-dossier-pour-une-meme-demarche.fr.md create mode 100644 doc/faqs/usager/comment-dupliquer-un-dossier-deja-depose.fr.md create mode 100644 doc/faqs/usager/comment-trouver-ma-demarche.fr.md create mode 100644 doc/faqs/usager/je-veux-contacter-le-service-en-charge-de-ma-demarche.fr.md create mode 100644 doc/faqs/usager/je-veux-enregistrer-mon-formulaire-pour-le-reprendre-plus-tard.fr.md create mode 100644 doc/faqs/usager/je-veux-savoir-ou-en-est-l-instruction-de-ma-demarche.fr.md create mode 100644 doc/faqs/usager/modification-de-l-identite-du-demandeur-d-un-dossier.fr.md create mode 100644 doc/faqs/usager/mon-dossier-a-ete-depose-par-un-tiers-et-je-souhaite-y-acceder.fr.md create mode 100644 doc/faqs/usager/quels-sont-les-navigateurs-supportes.fr.md diff --git a/app/assets/images/faq/administrateur-add-administrateur.png b/app/assets/images/faq/administrateur-add-administrateur.png new file mode 100644 index 0000000000000000000000000000000000000000..bb8e5d30de0c27e18e7150b9308127ef20a0d8be GIT binary patch literal 15502 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/administrateur-all-procedures.png b/app/assets/images/faq/administrateur-all-procedures.png new file mode 100644 index 0000000000000000000000000000000000000000..db6ef0bc87b5e6b4cf8eadfb8c1d46205ef4d9c9 GIT binary patch literal 40702 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 BPD}s* literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/administrateur-button-copy-procedure.png b/app/assets/images/faq/administrateur-button-copy-procedure.png new file mode 100644 index 0000000000000000000000000000000000000000..2d2fbcdb3489e4c78d6ed1f8924ae787312fa59f GIT binary patch literal 8806 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 LmQw9YtMLBWK049S)_#0^+|@he7x3r&{Cs|H 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 Cx-PT; literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/administrateur-example-markup.png b/app/assets/images/faq/administrateur-example-markup.png new file mode 100644 index 0000000000000000000000000000000000000000..7890881a7cf0ab942e173297f0987ea3953e160f GIT binary patch literal 7304 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 literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/administrateur-link-all-procedures.png b/app/assets/images/faq/administrateur-link-all-procedures.png new file mode 100644 index 0000000000000000000000000000000000000000..350d6c8119babb05eb87c1c3a80d5700aa36a8c7 GIT binary patch literal 12630 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 Hap3<013;=- literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/administrateur-list-champs-repetition.png b/app/assets/images/faq/administrateur-list-champs-repetition.png new file mode 100644 index 0000000000000000000000000000000000000000..2f8c88beaef5be22dcddb21a88f0fb8c84cbd5e5 GIT binary patch literal 48741 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 C!gl8X literal 0 HcmV?d00001 diff --git a/app/assets/images/faq/administrateur-procedure-action-close.png b/app/assets/images/faq/administrateur-procedure-action-close.png new file mode 100644 index 0000000000000000000000000000000000000000..e9aa0c1f7389a4986452bc62857132ea01db7728 GIT binary patch literal 11890 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