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/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/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/components/dossiers/export_dropdown_component.rb b/app/components/dossiers/export_dropdown_component.rb index 098dae369..91a3de116 100644 --- a/app/components/dossiers/export_dropdown_component.rb +++ b/app/components/dossiers/export_dropdown_component.rb @@ -1,8 +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 = 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..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 @@ -14,3 +14,13 @@ - 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") + + - 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}" + - 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/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/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 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/export_templates_controller.rb b/app/controllers/instructeurs/export_templates_controller.rb new file mode 100644 index 000000000..64ef44a4e --- /dev/null +++ b/app/controllers/instructeurs/export_templates_controller.rb @@ -0,0 +1,100 @@ +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 + + 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 + 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 + + 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.exportables_pieces_jointes + 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/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index 8caf08fee..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 @@ -324,13 +325,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..e42c19b54 100644 --- a/app/lib/active_storage/downloadable_file.rb +++ b/app/lib/active_storage/downloadable_file.rb @@ -1,10 +1,15 @@ 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) + 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 + + files end def self.cleanup_list_from_dossier(files) 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/champ.rb b/app/models/champ.rb index 2cb0bb4ec..74b5fd1e9 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -91,6 +91,10 @@ 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. 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..05ba4b2da --- /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(public_only: true, wrap_with_parent: true) + .partition { |(pj, _)| pj.condition.nil? } + end + + def exportables_pieces_jointes + pieces_jointes(exclude_titre_identite: true) + end + + private + + def pieces_jointes( + 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/concerns/tags_substitution_concern.rb b/app/models/concerns/tags_substitution_concern.rb index 8bdc51705..373ad3018 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: '', + lambda: -> (d) { d.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 = [ { @@ -152,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 } ] @@ -176,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 } ] @@ -273,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, @@ -281,20 +283,20 @@ module TagsSubstitutionConcern @escape_unsafe_tags = escape - flat_tags = tags_and_datas_list(dossier).each_with_object({}) do |(tags, data), result| - next if data.nil? - - valid_tags = tags_for_dossier_state(tags) - - valid_tags.each do |tag| - result[tag[:id]] = [tag, data] - end + 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] = case flat_tags[tag_id] - in tag, data - replace_tag(tag, data) + 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 @@ -370,8 +372,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 = available_tags(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)| @@ -397,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) @@ -449,14 +447,14 @@ module TagsSubstitutionConcern end end - def tags_and_datas_list(dossier) + def available_tags(dossier) [ - [champ_public_tags(dossier:), dossier], - [champ_private_tags(dossier:), dossier], - [dossier_tags, dossier], - [ROUTAGE_TAGS, dossier], - [INDIVIDUAL_TAGS, dossier.individual], - [ENTREPRISE_TAGS, dossier.etablissement&.entreprise] + champ_public_tags(dossier:), + champ_private_tags(dossier:), + dossier_tags, + ROUTAGE_TAGS, + INDIVIDUAL_TAGS, + ENTREPRISE_TAGS ] end end diff --git a/app/models/dossier_preloader.rb b/app/models/dossier_preloader.rb index 2f541ad93..c1a3e614e 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) @@ -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:) @@ -37,7 +47,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 diff --git a/app/models/export.rb b/app/models/export.rb index 3a7a1ac34..d9b29d409 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 @@ -66,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) @@ -147,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/models/export_template.rb b/app/models/export_template.rb new file mode 100644 index 000000000..f5ed164b9 --- /dev/null +++ b/app/models/export_template.rb @@ -0,0 +1,155 @@ +class ExportTemplate < ApplicationRecord + include TagsSubstitutionConcern + + 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) + + def set_default_values + content["default_dossier_directory"] = tiptap_json("dossier-") + content["pdf_name"] = tiptap_json("export_") + + content["pjs"] = [] + procedure.exportables_pieces_jointes.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 + + 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 content_for_pj(pj) + content_for_pj_id(pj.stable_id)&.to_json + end + + 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, champ: nil) + [ + attachment, + path(dossier, attachment, index, row_index, champ) + ] + 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, 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, 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, memoize: true) + substitutions['original-filename'] = attachment.filename.base if attachment + tiptap.to_path(content_for.deep_symbolize_keys, substitutions) + end + + def specific_tags + 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) + 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 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, champ) + 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, champ) + end + + File.join(folder(dossier), dir_path, attachment.filename.to_s) + end + + 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)) + else + File.join(folder(dossier), "erreur_renommage", attachment.filename.to_s) + end + end + + def suffix(attachment, index, row_index) + suffix = "-#{index + 1}" + suffix += "-#{row_index + 1}" if row_index.present? + + 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..5933df7b7 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 @@ -302,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/models/procedure.rb b/app/models/procedure.rb index fe7005599..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 @@ -153,6 +154,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 @@ -981,22 +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_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 @@ -1024,22 +1010,6 @@ class Procedure < ApplicationRecord private - def pieces_jointes_list - scope = yield active_revision.revision_types_de_champ_public - .includes(:type_de_champ, revision_types_de_champ: :type_de_champ) - .where(types_de_champ: { type_champ: ['repetition', 'piece_justificative', 'titre_identite'] }) - - 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? - 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/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/app/services/pieces_justificatives_service.rb b/app/services/pieces_justificatives_service.rb index 2fece0c0b..0fdbc2be9 100644 --- a/app/services/pieces_justificatives_service.rb +++ b/app/services/pieces_justificatives_service.rb @@ -1,27 +1,24 @@ 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) 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) @@ -32,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 @@ -49,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", @@ -58,7 +52,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 @@ -137,26 +135,25 @@ 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 + champs_id_row_index = compute_champ_id_row_index(champs) - ActiveStorage::Attachment - .includes(:blob) - .where(record_type: "Champ", record_id: champ_id_dossier_id.keys) - .filter { |a| safe_attachment(a) } - .map do |a| - dossier_id = champ_id_dossier_id[a.record_id] - ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) + champs.flat_map do |champ| + 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:) + else + ActiveStorage::DownloadableFile.pj_and_path(champ.dossier_id, attachment) + end end + end end def pjs_for_commentaires(dossiers) @@ -300,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/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/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/app/validators/export_template_validator.rb b/app/validators/export_template_validator.rb new file mode 100644 index 000000000..2a1158ed6 --- /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.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 + + 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/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 new file mode 100644 index 000000000..00813d9b6 --- /dev/null +++ b/app/views/instructeurs/export_templates/_form.html.haml @@ -0,0 +1,78 @@ +#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-grid-row.fr-grid-row--gutters + .fr-col-12.fr-col-md-8 + = 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) + + - 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" 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' } + .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" 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' } + .fr-mt-2w= render TagsButtonListComponent.new(tags: { nil => @export_template.specific_tags }) + + - 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" + .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' } + .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 + %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? + %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 + = 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..124abc2c1 --- /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")}.pdf + - procedure.exportables_pieces_jointes.each do |pj| + %li{ id: "preview_pj_#{pj.stable_id}" } #{export_template.tiptap_convert_pj(dossier, pj.stable_id)}-1.jpg + %ul + %li + %span messagerie/ + %ul + %li un-autre-fichier.png 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/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/exports.html.haml b/app/views/instructeurs/procedures/exports.html.haml index ed2f67fa8..0986a977a 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 ) + + - 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 + %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 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 } 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/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, 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 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..3905bb24f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -450,6 +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, :destroy] do + collection do + get 'preview' + end + end resources :groupes, only: [:index, :show], controller: 'groupe_instructeurs' do resource :contact_information 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/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 b936bf6b3..22ee0febd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -593,9 +593,20 @@ 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" + t.bigint "export_template_id" t.string "format", null: false t.bigint "instructeur_id" t.string "job_status", default: "pending", null: false @@ -607,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" @@ -1224,6 +1236,8 @@ 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", "export_templates" add_foreign_key "exports", "instructeurs" add_foreign_key "france_connect_informations", "users" add_foreign_key "geo_areas", "champs" 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/export_templates_controller_spec.rb b/spec/controllers/instructeurs/export_templates_controller_spec.rb new file mode 100644 index 000000000..8b8d73b82 --- /dev/null +++ b/spec/controllers/instructeurs/export_templates_controller_spec.rb @@ -0,0 +1,133 @@ +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 + + 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 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/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/concerns/pieces_jointes_list_concern_spec.rb b/spec/models/concerns/pieces_jointes_list_concern_spec.rb new file mode 100644 index 000000000..0356a52c9 --- /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.exportables_pieces_jointes.map(&:libelle)).to match_array([pj1, pj2, pjcond, pj5, pjcond2, pj6].map(&:libelle)) + end + 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/models/export_template_spec.rb b/spec/models/export_template_spec.rb new file mode 100644 index 000000000..f0602597a --- /dev/null +++ b/spec/models/export_template_spec.rb @@ -0,0 +1,317 @@ +describe ExportTemplate do + let(:groupe_instructeur) { create(:groupe_instructeur, procedure:) } + 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" => { + "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" => "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 + + describe 'new' do + let(:export_template) { build(:export_template, groupe_instructeur: groupe_instructeur) } + 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" => "3", + "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 + 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 '#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" => "original-filename", "label" => "nom original du fichier" } }, { "text" => " _justif", "type" => "text" }] } + ] + }.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 + + 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, champ: champ_pj)).to eq([attachment, "DOSSIER_#{dossier.id}/superpj_justif-1.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, champ: champ_pj)).to eq([attachment, "DOSSIER_#{dossier.id}/pj_repet_#{dossier.id}-1.png"]) + end + 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 '#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" } + 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 + 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 + end + end + + 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 tiptap_default_dossier_directory" do + expect(subject.valid?).to be_falsey + 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 + + context 'with valid pdf name' do + it 'has no error for pdf name' do + expect(subject.valid?).to be_truthy + expect(subject.errors[:tiptap_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[:tiptap_pdf_name]).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.full_messages).to include "Le champ « Nom du dossier au format pdf » 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 + 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.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 diff --git a/spec/models/procedure_spec.rb b/spec/models/procedure_spec.rb index 01f937995..0589985bb 100644 --- a/spec/models/procedure_spec.rb +++ b/spec/models/procedure_spec.rb @@ -1746,32 +1746,6 @@ describe Procedure do end end - describe '#pieces_jointes_list' do - include Logic - let(:procedure) { create(:procedure, types_de_champ_public:) } - 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 }] } - ] - 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 } } - - it "returns the list of pieces jointes without conditional" do - expect(procedure.pieces_jointes_list_without_conditionnal).to match_array([[pj1], [pj2, repetition]]) - end - - it "returns the list of pieces jointes having conditional" do - expect(procedure.pieces_jointes_list_with_conditionnal).to match_array([[pjcond]]) - end - end - describe "#attestation_template" do let(:procedure) { create(:procedure) } diff --git a/spec/services/pieces_justificatives_service_spec.rb b/spec/services/pieces_justificatives_service_spec.rb index 7a212912e..cef564981 100644 --- a/spec/services/pieces_justificatives_service_spec.rb +++ b/spec/services/pieces_justificatives_service_spec.rb @@ -1,9 +1,98 @@ 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(10) + end + end + end + 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 +108,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 +397,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 +409,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 @@ -323,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 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..b463675be 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,38 +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}-1.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) - 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)}" - ] - 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 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..0daced35e --- /dev/null +++ b/spec/services/procedure_export_service_zip_spec.rb @@ -0,0 +1,86 @@ +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(: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 <= 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) + 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}/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) + 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 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 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