Merge pull request #10217 from demarches-simplifiees/9827-export-rename

ETQ instructeur, je peux renommer le contenu de mon export zip
This commit is contained in:
krichtof 2024-05-23 09:34:22 +00:00 committed by GitHub
commit 2761d18de0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 1780 additions and 256 deletions

View file

@ -20,7 +20,7 @@
min-height: 400px; min-height: 400px;
} }
.editor { .tiptap-editor {
// Visual zones // Visual zones
.header .flex-1, .header .flex-1,
h1 { h1 {
@ -63,17 +63,6 @@
li p { li p {
margin-bottom: 0; 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 // scss-lint:disable SelectorFormat

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -1,8 +1,9 @@
class Dossiers::ExportDropdownComponent < ApplicationComponent class Dossiers::ExportDropdownComponent < ApplicationComponent
include ApplicationHelper 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 @procedure = procedure
@export_templates = export_templates
@statut = statut @statut = statut
@count = count @count = count
@class_btn = class_btn @class_btn = class_btn
@ -21,10 +22,15 @@ class Dossiers::ExportDropdownComponent < ApplicationComponent
item.fetch(:format) != :json || @procedure.active_revision.carte? item.fetch(:format) != :json || @procedure.active_revision.carte?
end 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_url.call(@procedure,
export_format: export_format, export_format:,
export_template_id:,
statut: @statut, statut: @statut,
no_progress_notification: no_progress_notification) no_progress_notification: no_progress_notification)
end end
def export_templates
@export_templates
end
end end

View file

@ -14,3 +14,13 @@
- menu.with_item do - menu.with_item do
= link_to download_export_path(export_format: format), role: 'menuitem', data: { turbo_method: :post, turbo: true } do = link_to download_export_path(export_format: format), role: 'menuitem', data: { turbo_method: :post, turbo: true } do
= t(".everything_#{format}_html") = 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

View file

@ -1,14 +1,15 @@
- each_category do |category, tags, can_toggle_nullable| - each_category do |category, tags, can_toggle_nullable|
.flex - if category.present?
%p.fr-label.fr-text--sm.fr-text--bold.fr-mb-1w= t(category, scope: ".categories") .flex
%p.fr-label.fr-text--sm.fr-text--bold.fr-mb-1w= t(category, scope: ".categories")
- if can_toggle_nullable - if can_toggle_nullable
.fr-fieldset__element.fr-ml-4w .fr-fieldset__element.fr-ml-4w
.fr-checkbox-group.fr-checkbox-group--sm .fr-checkbox-group.fr-checkbox-group--sm
= check_box_tag("show_maybe_null", 1, false, data: { "no-autosubmit" => true, action: "change->attestation#toggleMaybeNull"}) = 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 = label_tag "show_maybe_null", for: :show_maybe_null do
Voir les champs facultatifs Voir les champs facultatifs
%span.hidden.fr-hint-text Un champ non rempli restera vide dans lattestation. %span.hidden.fr-hint-text Un champ non rempli restera vide dans lattestation.
%ul.fr-tags-group{ data: { category: category } } %ul.fr-tags-group{ data: { category: category } }
- tags.each do |tag| - tags.each do |tag|

View file

@ -34,7 +34,11 @@ module Administrateurs
private private
def export_format 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 end
def export_options def export_options

View file

@ -2,7 +2,7 @@ class API::V2::DossiersController < API::V2::BaseController
before_action :ensure_dossier_present before_action :ensure_dossier_present
def pdf 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]) render(template: 'dossiers/show', formats: [:pdf])
end end

View file

@ -45,7 +45,7 @@ module Instructeurs
@is_dossier_in_batch_operation = dossier.batch_operation.present? @is_dossier_in_batch_operation = dossier.batch_operation.present?
respond_to do |format| respond_to do |format|
format.pdf do 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]) render(template: 'dossiers/show', formats: [:pdf])
end end
format.all format.all

View file

@ -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

View file

@ -245,6 +245,7 @@ module Instructeurs
def exports def exports
@procedure = procedure @procedure = procedure
@exports = Export.for_groupe_instructeurs(groupe_instructeur_ids).ante_chronological @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] = { cookies.encrypted[cookies_export_key] = {
value: DateTime.current, value: DateTime.current,
expires: Export::MAX_DUREE_GENERATION + Export::MAX_DUREE_CONSERVATION_EXPORT expires: Export::MAX_DUREE_GENERATION + Export::MAX_DUREE_CONSERVATION_EXPORT
@ -324,13 +325,18 @@ module Instructeurs
end end
def export_format 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 end
def export_options def export_options
@export_options ||= { @export_options ||= {
time_span_type: params[:time_span_type], time_span_type: params[:time_span_type],
statut: params[:statut], statut: params[:statut],
export_template:,
procedure_presentation: params[:statut].present? ? procedure_presentation : nil procedure_presentation: params[:statut].present? ? procedure_presentation : nil
}.compact }.compact
end end

View file

@ -88,7 +88,7 @@ module Users
end end
def show 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| respond_to do |format|
format.pdf do format.pdf do
@dossier = dossier_with_champs(pj_template: false) @dossier = dossier_with_champs(pj_template: false)

View file

@ -1,10 +1,15 @@
require 'fog/openstack' require 'fog/openstack'
class ActiveStorage::DownloadableFile class ActiveStorage::DownloadableFile
def self.create_list_from_dossiers(dossiers:, user_profile:) def self.create_list_from_dossiers(dossiers:, user_profile:, export_template: nil)
pj_service = PiecesJustificativesService.new(user_profile:) 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 end
def self.cleanup_list_from_dossier(files) def self.cleanup_list_from_dossier(files)

View file

@ -18,7 +18,7 @@ module Recovery
etablissement: :exercices, etablissement: :exercices,
revision: :procedure) revision: :procedure)
@dossiers = DossierPreloader.new(dossier_with_data, @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 includes_for_etablissement: [:exercices]).all
@file_path = file_path @file_path = file_path
end end

View file

@ -91,6 +91,10 @@ class Champ < ApplicationRecord
parent_id.present? parent_id.present?
end end
def stable_id_with_row
[row_id, stable_id].compact
end
# used for the `required` html attribute # used for the `required` html attribute
# check visibility to avoid hidden required input # check visibility to avoid hidden required input
# which prevent the form from being sent. # which prevent the form from being sent.

View file

@ -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

View file

@ -61,6 +61,15 @@ module TagsSubstitutionConcern
end end
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 = [ DOSSIER_TAGS = [
{ {
id: 'dossier_motivation', id: 'dossier_motivation',
@ -98,13 +107,6 @@ module TagsSubstitutionConcern
lambda: -> (d) { d.procedure.libelle }, lambda: -> (d) { d.procedure.libelle },
available_for_states: Dossier::SOUMIS 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', id: 'dossier_service_name',
libelle: 'nom du service', libelle: 'nom du service',
@ -112,7 +114,7 @@ module TagsSubstitutionConcern
lambda: -> (d) { d.procedure.organisation_name || '' }, lambda: -> (d) { d.procedure.organisation_name || '' },
available_for_states: Dossier::SOUMIS available_for_states: Dossier::SOUMIS
} }
] ].push(DOSSIER_ID_TAG)
DOSSIER_TAGS_FOR_MAIL = [ DOSSIER_TAGS_FOR_MAIL = [
{ {
@ -152,21 +154,21 @@ module TagsSubstitutionConcern
id: 'individual_gender', id: 'individual_gender',
libelle: 'civilité', libelle: 'civilité',
description: 'M., Mme', description: 'M., Mme',
target: :gender, lambda: -> (d) { d.individual&.gender },
available_for_states: Dossier::SOUMIS available_for_states: Dossier::SOUMIS
}, },
{ {
id: 'individual_last_name', id: 'individual_last_name',
libelle: 'nom', libelle: 'nom',
description: "nom de l'usager", description: "nom de l'usager",
target: :nom, lambda: -> (d) { d.individual&.nom },
available_for_states: Dossier::SOUMIS available_for_states: Dossier::SOUMIS
}, },
{ {
id: 'individual_first_name', id: 'individual_first_name',
libelle: 'prénom', libelle: 'prénom',
description: "prénom de l'usager", description: "prénom de l'usager",
target: :prenom, lambda: -> (d) { d.individual&.prenom },
available_for_states: Dossier::SOUMIS available_for_states: Dossier::SOUMIS
} }
] ]
@ -176,35 +178,35 @@ module TagsSubstitutionConcern
id: 'entreprise_siren', id: 'entreprise_siren',
libelle: 'SIREN', libelle: 'SIREN',
description: '', description: '',
target: :siren, lambda: -> (d) { d.etablissement&.entreprise&.siren },
available_for_states: Dossier::SOUMIS available_for_states: Dossier::SOUMIS
}, },
{ {
id: 'entreprise_numero_tva_intracommunautaire', id: 'entreprise_numero_tva_intracommunautaire',
libelle: 'numéro de TVA intracommunautaire', libelle: 'numéro de TVA intracommunautaire',
description: '', description: '',
target: :numero_tva_intracommunautaire, lambda: -> (d) { d.etablissement&.entreprise&.numero_tva_intracommunautaire },
available_for_states: Dossier::SOUMIS available_for_states: Dossier::SOUMIS
}, },
{ {
id: 'entreprise_siret_siege_social', id: 'entreprise_siret_siege_social',
libelle: 'SIRET du siège social', libelle: 'SIRET du siège social',
description: '', description: '',
target: :siret_siege_social, lambda: -> (d) { d.etablissement&.entreprise&.siret_siege_social },
available_for_states: Dossier::SOUMIS available_for_states: Dossier::SOUMIS
}, },
{ {
id: 'entreprise_raison_sociale', id: 'entreprise_raison_sociale',
libelle: 'raison sociale', libelle: 'raison sociale',
description: '', description: '',
target: :raison_sociale, lambda: -> (d) { d.etablissement&.entreprise&.raison_sociale },
available_for_states: Dossier::SOUMIS available_for_states: Dossier::SOUMIS
}, },
{ {
id: 'entreprise_adresse', id: 'entreprise_adresse',
libelle: 'adresse', libelle: 'adresse',
description: '', description: '',
target: :inline_adresse, lambda: -> (d) { d.etablissement&.entreprise&.inline_adresse },
available_for_states: Dossier::SOUMIS 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 } used_tags_and_libelle_for(text).map { _1.first.nil? ? _1.second : _1.first }
end end
def tags_substitutions(tags_and_libelles, dossier, escape: true) def tags_substitutions(tags_and_libelles, dossier, escape: true, memoize: false)
# NOTE: # NOTE:
# - tags_and_libelles est un simple Set de couples (tag_id, libelle) (pas la même structure que dans replace_tags) # - 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, # - 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 @escape_unsafe_tags = escape
flat_tags = tags_and_datas_list(dossier).each_with_object({}) do |(tags, data), result| flat_tags = if memoize && @flat_tags.present?
next if data.nil? @flat_tags
else
valid_tags = tags_for_dossier_state(tags) available_tags(dossier)
.flatten
valid_tags.each do |tag| .then { tags_for_dossier_state(_1) }
result[tag[:id]] = [tag, data] .index_by { _1[:id] }
end
end end
@flat_tags = flat_tags if memoize
tags_and_libelles.each_with_object({}) do |(tag_id, libelle), substitutions| tags_and_libelles.each_with_object({}) do |(tag_id, libelle), substitutions|
substitutions[tag_id] = case flat_tags[tag_id] substitutions[tag_id] = if flat_tags[tag_id].present?
in tag, data replace_tag(flat_tags[tag_id], dossier)
replace_tag(tag, data)
else # champ not in dossier, for example during preview on draft revision else # champ not in dossier, for example during preview on draft revision
libelle libelle
end end
@ -370,8 +372,8 @@ module TagsSubstitutionConcern
tokens = parse_tags(text) tokens = parse_tags(text)
tags_and_datas = tags_and_datas_list(dossier).filter_map do |(tags, data)| tags_and_datas = available_tags(dossier).filter_map do |tags|
data && [tags_for_dossier_state(tags).index_by { _1[:id] }, data] dossier && [tags_for_dossier_state(tags).index_by { _1[:id] }, dossier]
end end
tags_and_datas.reduce(tokens) do |tokens, (tags, data)| tags_and_datas.reduce(tokens) do |tokens, (tags, data)|
@ -397,12 +399,8 @@ module TagsSubstitutionConcern
end.join('') end.join('')
end end
def replace_tag(tag, data) def replace_tag(tag, dossier)
value = if tag.key?(:target) value = instance_exec(dossier, &tag[:lambda])
data.public_send(tag[:target])
else
instance_exec(data, &tag[:lambda])
end
if escape_unsafe_tags? && tag.fetch(:escapable, true) if escape_unsafe_tags? && tag.fetch(:escapable, true)
escape_once(value) escape_once(value)
@ -449,14 +447,14 @@ module TagsSubstitutionConcern
end end
end end
def tags_and_datas_list(dossier) def available_tags(dossier)
[ [
[champ_public_tags(dossier:), dossier], champ_public_tags(dossier:),
[champ_private_tags(dossier:), dossier], champ_private_tags(dossier:),
[dossier_tags, dossier], dossier_tags,
[ROUTAGE_TAGS, dossier], ROUTAGE_TAGS,
[INDIVIDUAL_TAGS, dossier.individual], INDIVIDUAL_TAGS,
[ENTREPRISE_TAGS, dossier.etablissement&.entreprise] ENTREPRISE_TAGS
] ]
end end
end end

View file

@ -1,10 +1,10 @@
class DossierPreloader class DossierPreloader
DEFAULT_BATCH_SIZE = 2000 DEFAULT_BATCH_SIZE = 2000
def initialize(dossiers, includes_for_dossier: [], includes_for_etablissement: []) def initialize(dossiers, includes_for_champ: [], includes_for_etablissement: [])
@dossiers = dossiers @dossiers = dossiers
@includes_for_etablissement = includes_for_etablissement @includes_for_etablissement = includes_for_etablissement
@includes_for_dossier = includes_for_dossier @includes_for_champ = includes_for_champ
end end
def in_batches(size = DEFAULT_BATCH_SIZE) def in_batches(size = DEFAULT_BATCH_SIZE)
@ -13,6 +13,16 @@ class DossierPreloader
dossiers dossiers
end 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) def all(pj_template: false)
dossiers = @dossiers.to_a dossiers = @dossiers.to_a
load_dossiers(dossiers, pj_template:) load_dossiers(dossiers, pj_template:)
@ -37,7 +47,7 @@ class DossierPreloader
end end
def load_dossiers(dossiers, pj_template: false) 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] to_include << [piece_justificative_file_attachments: :blob]
if pj_template if pj_template

View file

@ -31,6 +31,7 @@ class Export < ApplicationRecord
belongs_to :procedure_presentation, optional: true belongs_to :procedure_presentation, optional: true
belongs_to :instructeur, optional: true belongs_to :instructeur, optional: true
belongs_to :user_profile, polymorphic: true, optional: true belongs_to :user_profile, polymorphic: true, optional: true
belongs_to :export_template, optional: true
has_one_attached :file has_one_attached :file
@ -66,9 +67,10 @@ class Export < ApplicationRecord
procedure_presentation_id.present? procedure_presentation_id.present?
end 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 = { attributes = {
format:, format:,
export_template:,
time_span_type:, time_span_type:,
statut:, statut:,
key: generate_cache_key(groupe_instructeurs.map(&:id), procedure_presentation) key: generate_cache_key(groupe_instructeurs.map(&:id), procedure_presentation)
@ -147,7 +149,7 @@ class Export < ApplicationRecord
end end
def blob 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 case format.to_sym
when :csv when :csv

View file

@ -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

View file

@ -9,6 +9,7 @@ class GroupeInstructeur < ApplicationRecord
has_many :batch_operations, through: :dossiers, source: :batch_operations has_many :batch_operations, through: :dossiers, source: :batch_operations
has_many :assignments, class_name: 'DossierAssignment', dependent: :nullify, inverse_of: :groupe_instructeur 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 :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_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 has_one :defaut_procedure, -> { with_discarded }, class_name: 'Procedure', foreign_key: :defaut_groupe_instructeur_id, dependent: :nullify, inverse_of: :defaut_groupe_instructeur

View file

@ -14,6 +14,7 @@ class Instructeur < ApplicationRecord
has_many :batch_operations, dependent: :nullify has_many :batch_operations, dependent: :nullify
has_many :assign_to_with_email_notifications, -> { with_email_notifications }, class_name: 'AssignTo', inverse_of: :instructeur 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 :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 :commentaires, inverse_of: :instructeur, dependent: :nullify
has_many :dossiers, -> { state_not_brouillon }, through: :unordered_groupe_instructeurs 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 agent_connect_information.order(updated_at: :desc).first
end end
def export_templates_for(procedure)
procedure.export_templates.where(groupe_instructeur: groupe_instructeurs).order(:name)
end
private private
def annotations_hash(demande, annotations_privees, avis, messagerie) def annotations_hash(demande, annotations_privees, avis, messagerie)

View file

@ -5,6 +5,7 @@ class Procedure < ApplicationRecord
include ProcedureGroupeInstructeurAPIHackConcern include ProcedureGroupeInstructeurAPIHackConcern
include ProcedureSVASVRConcern include ProcedureSVASVRConcern
include ProcedureChorusConcern include ProcedureChorusConcern
include PiecesJointesListConcern
include Discard::Model include Discard::Model
self.discard_column = :hidden_at 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 :administrateurs, through: :administrateurs_procedures, after_remove: -> (procedure, _admin) { procedure.validate! }
has_many :groupe_instructeurs, -> { order(:label) }, inverse_of: :procedure, dependent: :destroy has_many :groupe_instructeurs, -> { order(:label) }, inverse_of: :procedure, dependent: :destroy
has_many :instructeurs, through: :groupe_instructeurs 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 :active_groupe_instructeurs, -> { active }, class_name: 'GroupeInstructeur', inverse_of: false
has_many :closed_groupe_instructeurs, -> { closed }, 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
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 def toggle_routing
update!(routing_enabled: self.groupe_instructeurs.active.many?) update!(routing_enabled: self.groupe_instructeurs.active.many?)
end end
@ -1024,22 +1010,6 @@ class Procedure < ApplicationRecord
private 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 def validate_auto_archive_on_in_the_future
return if auto_archive_on.nil? return if auto_archive_on.nil?
return if auto_archive_on.future? return if auto_archive_on.future?

View file

@ -1,9 +1,10 @@
class DownloadableFileService class DownloadableFileService
ARCHIVE_CREATION_DIR = ENV.fetch('ARCHIVE_CREATION_DIR') { '/tmp' } ARCHIVE_CREATION_DIR = ENV.fetch('ARCHIVE_CREATION_DIR') { '/tmp' }
EXPORT_DIRNAME = 'export'
def self.download_and_zip(procedure, attachments, filename, &block) def self.download_and_zip(procedure, attachments, filename, &block)
Dir.mktmpdir(nil, ARCHIVE_CREATION_DIR) do |tmp_dir| 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") zip_path = File.join(ARCHIVE_CREATION_DIR, "#{filename}.zip")
begin begin
@ -15,7 +16,7 @@ class DownloadableFileService
Dir.chdir(tmp_dir) do Dir.chdir(tmp_dir) do
File.delete(zip_path) if File.exist?(zip_path) 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 end
yield(zip_path) yield(zip_path)
ensure ensure

View file

@ -1,27 +1,24 @@
class PiecesJustificativesService class PiecesJustificativesService
def initialize(user_profile:) def initialize(user_profile:, export_template:)
@user_profile = user_profile @user_profile = user_profile
@export_template = export_template
end end
def liste_documents(dossiers) def liste_documents(dossiers)
bill_ids = [] bill_ids = []
docs = dossiers.in_batches.flat_map do |batch| docs = pjs_for_champs(dossiers) +
pjs = pjs_for_champs(batch) + pjs_for_commentaires(dossiers) +
pjs_for_commentaires(batch) + pjs_for_dossier(dossiers) +
pjs_for_dossier(batch) + pjs_for_avis(dossiers)
pjs_for_avis(batch)
if liste_documents_allows?(:with_bills) if liste_documents_allows?(:with_bills)
# some bills are shared among operations # some bills are shared among operations
# so first, all the bill_ids are fetched # so first, all the bill_ids are fetched
operation_logs, some_bill_ids = operation_logs_and_signature_ids(batch) operation_logs, some_bill_ids = operation_logs_and_signature_ids(dossiers)
pjs += operation_logs docs += operation_logs
bill_ids += some_bill_ids bill_ids += some_bill_ids
end
pjs
end end
if liste_documents_allows?(:with_bills) if liste_documents_allows?(:with_bills)
@ -32,14 +29,12 @@ class PiecesJustificativesService
docs docs
end end
def generate_dossiers_export(dossiers) def generate_dossiers_export(dossiers) # TODO: renommer generate_dossier_export sans s
return [] if dossiers.empty? return [] if dossiers.empty?
pdfs = [] pdfs = []
procedure = dossiers.first.procedure 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| dossiers.each do |dossier|
dossier.association(:procedure).target = procedure dossier.association(:procedure).target = procedure
@ -49,7 +44,6 @@ class PiecesJustificativesService
acls: acl_for_dossier_export(procedure), acls: acl_for_dossier_export(procedure),
dossier: dossier dossier: dossier
}) })
a = ActiveStorage::FakeAttachment.new( a = ActiveStorage::FakeAttachment.new(
file: StringIO.new(pdf), file: StringIO.new(pdf),
filename: "export-#{dossier.id}.pdf", filename: "export-#{dossier.id}.pdf",
@ -58,7 +52,11 @@ class PiecesJustificativesService
created_at: dossier.updated_at 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 end
pdfs pdfs
@ -137,26 +135,25 @@ class PiecesJustificativesService
end end
def pjs_for_champs(dossiers) def pjs_for_champs(dossiers)
champs = Champ champs = dossiers.flat_map(&:champs).filter { _1.type == "Champs::PieceJustificativeChamp" }
.joins(:piece_justificative_file_attachments)
.where(type: "Champs::PieceJustificativeChamp", dossier: dossiers)
if !liste_documents_allows?(:with_champs_private) if !liste_documents_allows?(:with_champs_private)
champs = champs.where(private: false) champs = champs.reject(&:private?)
end end
champ_id_dossier_id = champs champs_id_row_index = compute_champ_id_row_index(champs)
.pluck(:id, :dossier_id)
.to_h
ActiveStorage::Attachment champs.flat_map do |champ|
.includes(:blob) champ.piece_justificative_file_attachments.filter { |a| safe_attachment(a) }.map.with_index do |attachment, index|
.where(record_type: "Champ", record_id: champ_id_dossier_id.keys) row_index = champs_id_row_index[champ.id]
.filter { |a| safe_attachment(a) }
.map do |a| if @export_template
dossier_id = champ_id_dossier_id[a.record_id] @export_template.attachment_and_path(champ.dossier, attachment, index:, row_index:, champ:)
ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a) else
ActiveStorage::DownloadableFile.pj_and_path(champ.dossier_id, attachment)
end
end end
end
end end
def pjs_for_commentaires(dossiers) def pjs_for_commentaires(dossiers)
@ -300,4 +297,26 @@ class PiecesJustificativesService
.blob .blob
.virus_scan_result == ActiveStorage::VirusScanner::SAFE .virus_scan_result == ActiveStorage::VirusScanner::SAFE
end 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 end

View file

@ -1,10 +1,11 @@
class ProcedureExportService class ProcedureExportService
attr_reader :procedure, :dossiers attr_reader :procedure, :dossiers
def initialize(procedure, dossiers, user_profile) def initialize(procedure, dossiers, user_profile, export_template)
@procedure = procedure @procedure = procedure
@dossiers = dossiers @dossiers = dossiers
@user_profile = user_profile @user_profile = user_profile
@export_template = export_template
end end
def to_csv def to_csv
@ -36,7 +37,7 @@ class ProcedureExportService
end end
def to_zip 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| DownloadableFileService.download_and_zip(procedure, attachments, base_filename) do |zip_filepath|
ArchiveUploader.new(procedure: procedure, filename: filename(:zip), filepath: zip_filepath).blob ArchiveUploader.new(procedure: procedure, filename: filename(:zip), filepath: zip_filepath).blob

View file

@ -5,6 +5,12 @@ class TiptapService
children(node[:content], substitutions, 0) children(node[:content], substitutions, 0)
end end
def to_path(node, substitutions = {})
return '' if node.nil?
children_path(node[:content], substitutions)
end
# NOTE: node must be deep symbolized keys # NOTE: node must be deep symbolized keys
def used_tags_and_libelle_for(node, tags = Set.new) def used_tags_and_libelle_for(node, tags = Set.new)
case node case node
@ -25,6 +31,21 @@ class TiptapService
@body_started = false @body_started = false
end 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) def children(content, substitutions, level)
content.map { node_to_html(_1, substitutions, level) }.join content.map { node_to_html(_1, substitutions, level) }.join
end end

View file

@ -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

View file

@ -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 } } %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 = 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' } = 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)) } .fr-error-text{ id: dom_id(f.object, "json-body-messages"), class: class_names("hidden" => !f.object.errors.include?(:json_body)) }

View file

@ -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 }

View file

@ -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

View file

@ -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 }

View file

@ -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 }

View file

@ -11,7 +11,7 @@
.procedure-actions .procedure-actions
- if @can_download_dossiers - 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, .fr-container.flex= render partial: "tabs", locals: { procedure: @procedure,
statut: @statut, statut: @statut,

View file

@ -2,10 +2,10 @@
- if @can_download_dossiers - if @can_download_dossiers
- if @statut.nil? - if @statut.nil?
= turbo_stream.update_all '.procedure-actions' do = 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 - else
= turbo_stream.update_all '.dossiers-export' do = 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 = turbo_stream.update "last-export-alert" do
= render partial: "last_export_alert", locals: { export: @last_export, statut: @statut } = render partial: "last_export_alert", locals: { export: @last_export, statut: @statut }

View file

@ -22,3 +22,25 @@
- else - else
= t('.no_export_html', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i ) = 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

View file

@ -11,7 +11,7 @@
.procedure-actions .procedure-actions
- if @can_download_dossiers - 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, .fr-container.flex= render partial: "tabs", locals: { procedure: @procedure,
statut: @statut, statut: @statut,
@ -72,7 +72,7 @@
- if @dossiers_count > 0 - if @dossiers_count > 0
%span.dossiers-export %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 - if @filtered_sorted_paginated_ids.present? || @current_filters.count > 0
= render partial: "dossiers_filter_tags", locals: { procedure: @procedure, procedure_presentation: @procedure_presentation, current_filters: @current_filters, statut: @statut } = render partial: "dossiers_filter_tags", locals: { procedure: @procedure, procedure_presentation: @procedure_presentation, current_filters: @current_filters, statut: @statut }

View file

@ -48,21 +48,23 @@
#accordion-116.fr-collapse #accordion-116.fr-collapse
= h render SimpleFormatComponent.new(procedure.description_pj, allow_a: true) = h render SimpleFormatComponent.new(procedure.description_pj, allow_a: true)
- elsif procedure.pieces_jointes_list? - else
%section.fr-accordion.pieces_jointes - pj_without_condition, pj_with_condition = procedure.public_wrapped_partionned_pjs
%h2.fr-accordion__title - if pj_without_condition.present? || pj_with_condition.present?
%button.fr-accordion__btn{ "aria-controls" => "accordion-116", "aria-expanded" => "false" } %section.fr-accordion.pieces_jointes
= t('shared.procedure_description.pieces_jointes') %h2.fr-accordion__title
#accordion-116.fr-collapse %button.fr-accordion__btn{ "aria-controls" => "accordion-116", "aria-expanded" => "false" }
- if procedure.pieces_jointes_list_without_conditionnal.present? = t('shared.procedure_description.pieces_jointes')
%ul #accordion-116.fr-collapse
= render partial: "shared/procedure_pieces_jointes_list", collection: procedure.pieces_jointes_list_without_conditionnal, as: :pj - 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? - if pj_with_condition.present?
%h3.fr-text--sm.fr-mb-0.fr-mt-2w %h3.fr-text--sm.fr-mb-0.fr-mt-2w
= t('shared.procedure_description.pieces_jointes_conditionnal_list_title') = t('shared.procedure_description.pieces_jointes_conditionnal_list_title')
%ul %ul
= render partial: "shared/procedure_pieces_jointes_list", collection: procedure.pieces_jointes_list_with_conditionnal, as: :pj = render partial: "shared/procedure_pieces_jointes_list", collection: pj_with_condition, as: :pj
- estimated_delay_component = Procedure::EstimatedDelayComponent.new(procedure: procedure) - estimated_delay_component = Procedure::EstimatedDelayComponent.new(procedure: procedure)
- if estimated_delay_component.render? - if estimated_delay_component.render?

View file

@ -25,6 +25,7 @@ features = [
:dossier_pdf_vide, :dossier_pdf_vide,
:engagement_juridique_type_de_champ, :engagement_juridique_type_de_champ,
:export_order_by_revision, :export_order_by_revision,
:export_template,
:expression_reguliere_type_de_champ, :expression_reguliere_type_de_champ,
:gallery_demande, :gallery_demande,
:groupe_instructeur_api_hack, :groupe_instructeur_api_hack,

View file

@ -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"

View file

@ -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

View file

@ -0,0 +1,8 @@
fr:
instructeurs:
export_templates:
new:
title: Nouveau modèle d'export
edit:
title: Modèle d'export

View file

@ -450,6 +450,11 @@ Rails.application.routes.draw do
resources :procedures, only: [:index, :show], param: :procedure_id do resources :procedures, only: [:index, :show], param: :procedure_id do
member do member do
resources :archives, only: [:index, :create] 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 resources :groupes, only: [:index, :show], controller: 'groupe_instructeurs' do
resource :contact_information resource :contact_information

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,5 @@
class AddExportTemplateFk < ActiveRecord::Migration[7.0]
def change
add_foreign_key :exports, :export_templates, validate: false
end
end

View file

@ -0,0 +1,5 @@
class ValidateExportTemplateFk < ActiveRecord::Migration[7.0]
def change
validate_foreign_key :exports, :export_templates
end
end

View file

@ -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" t.index ["procedure_id"], name: "index_experts_procedures_on_procedure_id"
end 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| create_table "exports", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false
t.integer "dossiers_count" t.integer "dossiers_count"
t.bigint "export_template_id"
t.string "format", null: false t.string "format", null: false
t.bigint "instructeur_id" t.bigint "instructeur_id"
t.string "job_status", default: "pending", null: false 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.datetime "updated_at", precision: nil, null: false
t.bigint "user_profile_id" t.bigint "user_profile_id"
t.string "user_profile_type" 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 ["instructeur_id"], name: "index_exports_on_instructeur_id"
t.index ["key"], name: "index_exports_on_key" t.index ["key"], name: "index_exports_on_key"
t.index ["procedure_presentation_id"], name: "index_exports_on_procedure_presentation_id" 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", "users"
add_foreign_key "experts_procedures", "experts" add_foreign_key "experts_procedures", "experts"
add_foreign_key "experts_procedures", "procedures" 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 "exports", "instructeurs"
add_foreign_key "france_connect_informations", "users" add_foreign_key "france_connect_informations", "users"
add_foreign_key "geo_areas", "champs" add_foreign_key "geo_areas", "champs"

View file

@ -121,7 +121,7 @@ describe Experts::AvisController, type: :controller do
context 'with a valid avis' do context 'with a valid avis' do
it do it do
service = instance_double(PiecesJustificativesService) 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(:generate_dossiers_export).with(Dossier.where(id: dossier)).and_return([])
expect(service).to receive(:liste_documents).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) is_expected.to have_http_status(:success)

View file

@ -936,7 +936,7 @@ describe Instructeurs::DossiersController, type: :controller do
subject subject
end 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(assigns(:is_dossier_in_batch_operation)).to eq(false) }
it { expect(response).to render_template 'dossiers/show' } it { expect(response).to render_template 'dossiers/show' }

View file

@ -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

View file

@ -736,6 +736,18 @@ describe Instructeurs::ProceduresController, type: :controller do
end end
it { expect { subject }.to change { Export.where(user_profile: instructeur).count }.by(1) } 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 end
context 'when the export is not ready' do context 'when the export is not ready' do

View file

@ -1142,7 +1142,7 @@ describe Users::DossiersController, type: :controller do
end end
context 'when the dossier has been submitted' do 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') } it { expect(response).to render_template('dossiers/show') }
end end
end end

View file

@ -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

View file

@ -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

View file

@ -109,6 +109,14 @@ RSpec.describe Export, type: :model do
end end
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 context 'with existing matching export' do
def find_or_create = 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) 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)

View file

@ -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

View file

@ -1746,32 +1746,6 @@ describe Procedure do
end end
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 describe "#attestation_template" do
let(:procedure) { create(:procedure) } let(:procedure) { create(:procedure) }

View file

@ -1,9 +1,98 @@
describe PiecesJustificativesService do 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 describe '.liste_documents' do
let(:dossier) { create(:dossier, procedure: procedure) } let(:dossier) { create(:dossier, procedure: procedure) }
let(:dossiers) { Dossier.where(id: dossier.id) } let(:dossiers) { Dossier.where(id: dossier.id) }
let(:export_template) { nil }
subject do subject do
PiecesJustificativesService.new(user_profile:).liste_documents(dossiers).map(&:first) PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:first)
end end
context 'no acl' do context 'no acl' do
@ -19,6 +108,11 @@ describe PiecesJustificativesService do
end end
it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) } 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 end
context 'with a multiple attachments' do 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(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :piece_justificative }] }]) }
let(:dossier) { create(:dossier, :with_populated_champs, procedure: procedure) } let(:dossier) { create(:dossier, :with_populated_champs, procedure: procedure) }
let(:dossiers) { Dossier.where(id: dossier.id) } 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 it "doesn't update dossier" do
expect { subject }.not_to change { dossier.updated_at } 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!(:not_confidentiel_avis) { create(:avis, :not_confidentiel, dossier: dossier) }
let!(:expert_avis) { create(:avis, :confidentiel, dossier: dossier, expert: user_profile) } 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 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([]) expect_any_instance_of(Dossier).to receive(:avis_for_expert).with(user_profile).and_return([])
subject subject
@ -323,6 +417,67 @@ describe PiecesJustificativesService do
end end
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) def attach_file_to_champ(champ, safe = true)
attach_file(champ.piece_justificative_file, safe) attach_file(champ.piece_justificative_file, safe)
end end

View file

@ -33,11 +33,11 @@ describe ProcedureArchiveService do
files = ZipTricks::FileReader.read_zip_structure(io: f) files = ZipTricks::FileReader.read_zip_structure(io: f)
structure = [ structure = [
"#{service.send(:zip_root_folder, archive)}/", "export/",
"#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/", "export/dossier-#{dossier.id}/",
"#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/pieces_justificatives/", "export/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", "export/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/dossier-#{dossier.id}/export-#{dossier.id}-05-03-2021-00-00-#{dossier.id % 10000}.pdf"
] ]
expect(files.map(&:filename)).to match_array(structure) expect(files.map(&:filename)).to match_array(structure)
end end
@ -53,11 +53,11 @@ describe ProcedureArchiveService do
archive.file.open do |f| archive.file.open do |f|
files = ZipTricks::FileReader.read_zip_structure(io: f) files = ZipTricks::FileReader.read_zip_structure(io: f)
structure = [ structure = [
"#{service.send(:zip_root_folder, archive)}/", "export/",
"#{service.send(:zip_root_folder, archive)}/-LISTE-DES-FICHIERS-EN-ERREURS.txt", "export/-LISTE-DES-FICHIERS-EN-ERREURS.txt",
"#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/", "export/dossier-#{dossier.id}/",
"#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/pieces_justificatives/", "export/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/dossier-#{dossier.id}/export-#{dossier.id}-05-03-2021-00-00-#{dossier.id % 10000}.pdf"
] ]
expect(files.map(&:filename)).to match_array(structure) expect(files.map(&:filename)).to match_array(structure)
end end
@ -100,12 +100,12 @@ describe ProcedureArchiveService do
archive.file.open do |f| archive.file.open do |f|
zip_entries = ZipTricks::FileReader.read_zip_structure(io: f) zip_entries = ZipTricks::FileReader.read_zip_structure(io: f)
structure = [ structure = [
"#{service.send(:zip_root_folder, archive)}/", "export/",
"#{service.send(:zip_root_folder, archive)}/-LISTE-DES-FICHIERS-EN-ERREURS.txt", "export/-LISTE-DES-FICHIERS-EN-ERREURS.txt",
"#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/", "export/dossier-#{dossier.id}/",
"#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/export-dossier-05-03-2020-00-00-1.pdf", "export/dossier-#{dossier.id}/export-dossier-05-03-2020-00-00-1.pdf",
"#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/pieces_justificatives/", "export/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/dossier-#{dossier.id}/export-#{dossier.id}-05-03-2021-00-00-#{dossier.id % 10000}.pdf"
] ]
expect(zip_entries.map(&:filename)).to match_array(structure) expect(zip_entries.map(&:filename)).to match_array(structure)
zip_entries.map do |entry| zip_entries.map do |entry|
@ -134,15 +134,15 @@ describe ProcedureArchiveService do
archive.file.open do |f| archive.file.open do |f|
files = ZipTricks::FileReader.read_zip_structure(io: f) files = ZipTricks::FileReader.read_zip_structure(io: f)
structure = [ structure = [
"#{service.send(:zip_root_folder, archive)}/", "export/",
"#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/", "export/dossier-#{dossier.id}/",
"#{service.send(:zip_root_folder, archive)}/dossier-#{dossier.id}/pieces_justificatives/", "export/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", "export/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", "export/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}/", "export/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", "export/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/", "export/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/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) expect(files.map(&:filename)).to match_array(structure)
end end

View file

@ -2,8 +2,9 @@ require 'csv'
describe ProcedureExportService do describe ProcedureExportService do
let(:instructeur) { create(:instructeur) } let(:instructeur) { create(:instructeur) }
let(:procedure) { create(:procedure, :published, :for_individual, :with_all_champs, instructeurs: [instructeur]) } let(:procedure) { create(:procedure, :published, :for_individual, :with_all_champs) }
let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur) } let(:service) { ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template) }
let(:export_template) { nil }
describe 'to_xlsx' do describe 'to_xlsx' do
subject do subject do
@ -243,7 +244,7 @@ describe ProcedureExportService do
context 'as csv' do context 'as csv' do
subject do subject do
ProcedureExportService.new(procedure, procedure.dossiers, instructeur) ProcedureExportService.new(procedure, procedure.dossiers, instructeur, export_template)
.to_csv .to_csv
.open { |f| CSV.read(f.path) } .open { |f| CSV.read(f.path) }
end end
@ -519,38 +520,68 @@ describe ProcedureExportService do
end end
end end
context 'generate_dossiers_export' do describe 'generate_dossiers_export' do
it 'include_infos_administration (so it includes avis, champs privés)' 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 subject
end end
end
context 'with files (and http calls)' do context 'with export_template' do
let!(:dossier) { create(:dossier, :accepte, :with_populated_champs, :with_individual, procedure: procedure) } 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)) } let(:dossier_exports) { PiecesJustificativesService.new(user_profile: instructeur, export_template:).generate_dossiers_export(Dossier.where(id: dossier)) }
before do let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur).tap(&:set_default_values) }
allow_any_instance_of(ActiveStorage::Attachment).to receive(:url).and_return("https://opengraph.githubassets.com/d0e7862b24d8026a3c03516d865b28151eb3859029c6c6c2e86605891fbdcd7a/socketry/async-io") 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 end
it 'returns a blob with valid files' do context 'with files (and http calls)' do
VCR.use_cassette('archive/new_file_to_get_200') do let!(:dossier) { create(:dossier, :accepte, :with_populated_champs, :with_individual, procedure: procedure) }
subject 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') it 'returns a blob with valid files' do
File.open('tmp.zip') do |fd| VCR.use_cassette('archive/new_file_to_get_200') do
files = ZipTricks::FileReader.read_zip_structure(io: fd) subject
structure = [
"#{service.send(:base_filename)}/", File.write('tmp.zip', subject.download, mode: 'wb')
"#{service.send(:base_filename)}/dossier-#{dossier.id}/", File.open('tmp.zip') do |fd|
"#{service.send(:base_filename)}/dossier-#{dossier.id}/pieces_justificatives/", files = ZipTricks::FileReader.read_zip_structure(io: fd)
"#{service.send(:base_filename)}/dossier-#{dossier.id}/#{ActiveStorage::DownloadableFile.timestamped_filename(ActiveStorage::Attachment.where(record_type: "Champ").first)}", base_fn = 'export'
"#{service.send(:base_filename)}/dossier-#{dossier.id}/#{ActiveStorage::DownloadableFile.timestamped_filename(dossier_exports.first.first)}" structure = [
] "#{base_fn}/",
expect(files.size).to eq(structure.size) "#{base_fn}/dossier-#{dossier.id}/",
expect(files.map(&:filename)).to match_array(structure) "#{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 end
FileUtils.remove_entry_secure('tmp.zip')
end end
end end
end end

View file

@ -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

View file

@ -192,4 +192,20 @@ RSpec.describe TiptapService do
expect(described_class.new.used_tags_and_libelle_for(json)).to eq(Set.new([['name', 'Nom']])) expect(described_class.new.used_tags_and_libelle_for(json)).to eq(Set.new([['name', 'Nom']]))
end end
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 end

View file

@ -110,7 +110,7 @@ describe 'shared/_procedure_description', type: :view do
context 'caching', caching: true do context 'caching', caching: true do
it "works" 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: } } 2.times { render partial: 'shared/procedure_description', locals: { procedure: } }
end end