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;
}
.editor {
.tiptap-editor {
// Visual zones
.header .flex-1,
h1 {
@ -63,17 +63,6 @@
li p {
margin-bottom: 0;
}
// Tags
.fr-menu__list {
max-height: 500px;
}
.fr-tag:not(.fr-menu .fr-tag) {
// style span rendered by tiptap like a button/link tag
color: var(--text-action-high-blue-france);
background-color: var(--background-action-low-blue-france);
}
}
// scss-lint:disable SelectorFormat

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

View file

@ -14,3 +14,13 @@
- menu.with_item do
= link_to download_export_path(export_format: format), role: 'menuitem', data: { turbo_method: :post, turbo: true } do
= t(".everything_#{format}_html")
- if export_templates.present?
- export_templates.each do |export_template|
- menu.with_item do
= link_to download_export_path(export_template_id: export_template.id), role: 'menuitem', data: { turbo_method: :post, turbo: true } do
= "Exporter à partir du modèle #{export_template.name}"
- if feature_enabled?(:export_template)
- menu.with_item do
= link_to new_instructeur_export_template_path(procedure_id: params[:procedure_id]), role: 'menuitem' do
Ajouter un modèle d'export

View file

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

View file

@ -34,7 +34,11 @@ module Administrateurs
private
def export_format
@export_format ||= params[:export_format]
@export_format ||= params[:export_format].presence || export_template&.kind
end
def export_template
@export_template ||= ExportTemplate.find(params[:export_template_id]) if params[:export_template_id].present?
end
def export_options

View file

@ -2,7 +2,7 @@ class API::V2::DossiersController < API::V2::BaseController
before_action :ensure_dossier_present
def pdf
@acls = PiecesJustificativesService.new(user_profile: Administrateur.new).acl_for_dossier_export(dossier.procedure)
@acls = PiecesJustificativesService.new(user_profile: Administrateur.new, export_template: nil).acl_for_dossier_export(dossier.procedure)
render(template: 'dossiers/show', formats: [:pdf])
end

View file

@ -45,7 +45,7 @@ module Instructeurs
@is_dossier_in_batch_operation = dossier.batch_operation.present?
respond_to do |format|
format.pdf do
@acls = PiecesJustificativesService.new(user_profile: current_instructeur).acl_for_dossier_export(dossier.procedure)
@acls = PiecesJustificativesService.new(user_profile: current_instructeur, export_template: nil).acl_for_dossier_export(dossier.procedure)
render(template: 'dossiers/show', formats: [:pdf])
end
format.all

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
@procedure = procedure
@exports = Export.for_groupe_instructeurs(groupe_instructeur_ids).ante_chronological
@export_templates = current_instructeur.export_templates_for(@procedure).includes(:groupe_instructeur)
cookies.encrypted[cookies_export_key] = {
value: DateTime.current,
expires: Export::MAX_DUREE_GENERATION + Export::MAX_DUREE_CONSERVATION_EXPORT
@ -324,13 +325,18 @@ module Instructeurs
end
def export_format
@export_format ||= params[:export_format]
@export_format ||= params[:export_format].presence || export_template&.kind
end
def export_template
@export_template ||= ExportTemplate.find(params[:export_template_id]) if params[:export_template_id].present?
end
def export_options
@export_options ||= {
time_span_type: params[:time_span_type],
statut: params[:statut],
export_template:,
procedure_presentation: params[:statut].present? ? procedure_presentation : nil
}.compact
end

View file

@ -88,7 +88,7 @@ module Users
end
def show
pj_service = PiecesJustificativesService.new(user_profile: current_user)
pj_service = PiecesJustificativesService.new(user_profile: current_user, export_template: nil)
respond_to do |format|
format.pdf do
@dossier = dossier_with_champs(pj_template: false)

View file

@ -1,10 +1,15 @@
require 'fog/openstack'
class ActiveStorage::DownloadableFile
def self.create_list_from_dossiers(dossiers:, user_profile:)
pj_service = PiecesJustificativesService.new(user_profile:)
def self.create_list_from_dossiers(dossiers:, user_profile:, export_template: nil)
pj_service = PiecesJustificativesService.new(user_profile:, export_template:)
pj_service.generate_dossiers_export(dossiers) + pj_service.liste_documents(dossiers)
files = []
DossierPreloader.new(dossiers).in_batches_with_block do |loaded_dossiers|
files += pj_service.generate_dossiers_export(loaded_dossiers) + pj_service.liste_documents(loaded_dossiers)
end
files
end
def self.cleanup_list_from_dossier(files)

View file

@ -18,7 +18,7 @@ module Recovery
etablissement: :exercices,
revision: :procedure)
@dossiers = DossierPreloader.new(dossier_with_data,
includes_for_dossier: [:geo_areas, etablissement: :exercices],
includes_for_champ: [:geo_areas, etablissement: :exercices],
includes_for_etablissement: [:exercices]).all
@file_path = file_path
end

View file

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

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

View file

@ -1,10 +1,10 @@
class DossierPreloader
DEFAULT_BATCH_SIZE = 2000
def initialize(dossiers, includes_for_dossier: [], includes_for_etablissement: [])
def initialize(dossiers, includes_for_champ: [], includes_for_etablissement: [])
@dossiers = dossiers
@includes_for_etablissement = includes_for_etablissement
@includes_for_dossier = includes_for_dossier
@includes_for_champ = includes_for_champ
end
def in_batches(size = DEFAULT_BATCH_SIZE)
@ -13,6 +13,16 @@ class DossierPreloader
dossiers
end
def in_batches_with_block(size = DEFAULT_BATCH_SIZE, &block)
@dossiers.in_batches(of: size) do |batch|
data = Dossier.where(id: batch.ids).includes(:individual, :traitement, :etablissement, user: :france_connect_informations, avis: :expert, commentaires: [:instructeur, :expert], revision: :revision_types_de_champ)
dossiers = data.to_a
load_dossiers(dossiers)
yield(dossiers)
end
end
def all(pj_template: false)
dossiers = @dossiers.to_a
load_dossiers(dossiers, pj_template:)
@ -37,7 +47,7 @@ class DossierPreloader
end
def load_dossiers(dossiers, pj_template: false)
to_include = @includes_for_dossier.dup
to_include = @includes_for_champ.dup
to_include << [piece_justificative_file_attachments: :blob]
if pj_template

View file

@ -31,6 +31,7 @@ class Export < ApplicationRecord
belongs_to :procedure_presentation, optional: true
belongs_to :instructeur, optional: true
belongs_to :user_profile, polymorphic: true, optional: true
belongs_to :export_template, optional: true
has_one_attached :file
@ -66,9 +67,10 @@ class Export < ApplicationRecord
procedure_presentation_id.present?
end
def self.find_or_create_fresh_export(format, groupe_instructeurs, user_profile, time_span_type: time_span_types.fetch(:everything), statut: statuts.fetch(:tous), procedure_presentation: nil)
def self.find_or_create_fresh_export(format, groupe_instructeurs, user_profile, time_span_type: time_span_types.fetch(:everything), statut: statuts.fetch(:tous), procedure_presentation: nil, export_template: nil)
attributes = {
format:,
export_template:,
time_span_type:,
statut:,
key: generate_cache_key(groupe_instructeurs.map(&:id), procedure_presentation)
@ -147,7 +149,7 @@ class Export < ApplicationRecord
end
def blob
service = ProcedureExportService.new(procedure, dossiers_for_export, user_profile)
service = ProcedureExportService.new(procedure, dossiers_for_export, user_profile, export_template)
case format.to_sym
when :csv

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 :assignments, class_name: 'DossierAssignment', dependent: :nullify, inverse_of: :groupe_instructeur
has_many :previous_assignments, class_name: 'DossierAssignment', dependent: :nullify, inverse_of: :previous_groupe_instructeur
has_many :export_templates
has_and_belongs_to_many :exports, dependent: :destroy
has_one :defaut_procedure, -> { with_discarded }, class_name: 'Procedure', foreign_key: :defaut_groupe_instructeur_id, dependent: :nullify, inverse_of: :defaut_groupe_instructeur

View file

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

View file

@ -5,6 +5,7 @@ class Procedure < ApplicationRecord
include ProcedureGroupeInstructeurAPIHackConcern
include ProcedureSVASVRConcern
include ProcedureChorusConcern
include PiecesJointesListConcern
include Discard::Model
self.discard_column = :hidden_at
@ -153,6 +154,7 @@ class Procedure < ApplicationRecord
has_many :administrateurs, through: :administrateurs_procedures, after_remove: -> (procedure, _admin) { procedure.validate! }
has_many :groupe_instructeurs, -> { order(:label) }, inverse_of: :procedure, dependent: :destroy
has_many :instructeurs, through: :groupe_instructeurs
has_many :export_templates, through: :groupe_instructeurs
has_many :active_groupe_instructeurs, -> { active }, class_name: 'GroupeInstructeur', inverse_of: false
has_many :closed_groupe_instructeurs, -> { closed }, class_name: 'GroupeInstructeur', inverse_of: false
@ -981,22 +983,6 @@ class Procedure < ApplicationRecord
end
end
def pieces_jointes_list?
pieces_jointes_list_without_conditionnal.present? || pieces_jointes_list_with_conditionnal.present?
end
def pieces_jointes_list_without_conditionnal
pieces_jointes_list do |base_scope|
base_scope.where(types_de_champ: { condition: nil })
end
end
def pieces_jointes_list_with_conditionnal
pieces_jointes_list do |base_scope|
base_scope.where.not(types_de_champ: { condition: nil })
end
end
def toggle_routing
update!(routing_enabled: self.groupe_instructeurs.active.many?)
end
@ -1024,22 +1010,6 @@ class Procedure < ApplicationRecord
private
def pieces_jointes_list
scope = yield active_revision.revision_types_de_champ_public
.includes(:type_de_champ, revision_types_de_champ: :type_de_champ)
.where(types_de_champ: { type_champ: ['repetition', 'piece_justificative', 'titre_identite'] })
scope.each_with_object([]) do |rtdc, list|
if rtdc.type_de_champ.repetition?
rtdc.revision_types_de_champ.each do |rtdc_in_repetition|
list << [rtdc_in_repetition.type_de_champ, rtdc.type_de_champ] if rtdc_in_repetition.type_de_champ.piece_justificative?
end
else
list << [rtdc.type_de_champ]
end
end
end
def validate_auto_archive_on_in_the_future
return if auto_archive_on.nil?
return if auto_archive_on.future?

View file

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

View file

@ -1,27 +1,24 @@
class PiecesJustificativesService
def initialize(user_profile:)
def initialize(user_profile:, export_template:)
@user_profile = user_profile
@export_template = export_template
end
def liste_documents(dossiers)
bill_ids = []
docs = dossiers.in_batches.flat_map do |batch|
pjs = pjs_for_champs(batch) +
pjs_for_commentaires(batch) +
pjs_for_dossier(batch) +
pjs_for_avis(batch)
docs = pjs_for_champs(dossiers) +
pjs_for_commentaires(dossiers) +
pjs_for_dossier(dossiers) +
pjs_for_avis(dossiers)
if liste_documents_allows?(:with_bills)
# some bills are shared among operations
# so first, all the bill_ids are fetched
operation_logs, some_bill_ids = operation_logs_and_signature_ids(batch)
if liste_documents_allows?(:with_bills)
# some bills are shared among operations
# so first, all the bill_ids are fetched
operation_logs, some_bill_ids = operation_logs_and_signature_ids(dossiers)
pjs += operation_logs
bill_ids += some_bill_ids
end
pjs
docs += operation_logs
bill_ids += some_bill_ids
end
if liste_documents_allows?(:with_bills)
@ -32,14 +29,12 @@ class PiecesJustificativesService
docs
end
def generate_dossiers_export(dossiers)
def generate_dossiers_export(dossiers) # TODO: renommer generate_dossier_export sans s
return [] if dossiers.empty?
pdfs = []
procedure = dossiers.first.procedure
dossiers = dossiers.includes(:individual, :traitement, :etablissement, user: :france_connect_informations, avis: :expert, commentaires: [:instructeur, :expert])
dossiers = DossierPreloader.new(dossiers).in_batches
dossiers.each do |dossier|
dossier.association(:procedure).target = procedure
@ -49,7 +44,6 @@ class PiecesJustificativesService
acls: acl_for_dossier_export(procedure),
dossier: dossier
})
a = ActiveStorage::FakeAttachment.new(
file: StringIO.new(pdf),
filename: "export-#{dossier.id}.pdf",
@ -58,7 +52,11 @@ class PiecesJustificativesService
created_at: dossier.updated_at
)
pdfs << ActiveStorage::DownloadableFile.pj_and_path(dossier.id, a)
if @export_template
pdfs << @export_template.attachment_and_path(dossier, a)
else
pdfs << ActiveStorage::DownloadableFile.pj_and_path(dossier.id, a)
end
end
pdfs
@ -137,26 +135,25 @@ class PiecesJustificativesService
end
def pjs_for_champs(dossiers)
champs = Champ
.joins(:piece_justificative_file_attachments)
.where(type: "Champs::PieceJustificativeChamp", dossier: dossiers)
champs = dossiers.flat_map(&:champs).filter { _1.type == "Champs::PieceJustificativeChamp" }
if !liste_documents_allows?(:with_champs_private)
champs = champs.where(private: false)
champs = champs.reject(&:private?)
end
champ_id_dossier_id = champs
.pluck(:id, :dossier_id)
.to_h
champs_id_row_index = compute_champ_id_row_index(champs)
ActiveStorage::Attachment
.includes(:blob)
.where(record_type: "Champ", record_id: champ_id_dossier_id.keys)
.filter { |a| safe_attachment(a) }
.map do |a|
dossier_id = champ_id_dossier_id[a.record_id]
ActiveStorage::DownloadableFile.pj_and_path(dossier_id, a)
champs.flat_map do |champ|
champ.piece_justificative_file_attachments.filter { |a| safe_attachment(a) }.map.with_index do |attachment, index|
row_index = champs_id_row_index[champ.id]
if @export_template
@export_template.attachment_and_path(champ.dossier, attachment, index:, row_index:, champ:)
else
ActiveStorage::DownloadableFile.pj_and_path(champ.dossier_id, attachment)
end
end
end
end
def pjs_for_commentaires(dossiers)
@ -300,4 +297,26 @@ class PiecesJustificativesService
.blob
.virus_scan_result == ActiveStorage::VirusScanner::SAFE
end
# given
# repet_0 (stable_id: r0)
# # row_0
# # # pj_champ_0 (stable_id: 0)
# # row_1
# # # pj_champ_1 (stable_id: 0)
# repet_1 (stable_id: r1)
# # row_0
# # # pj_champ_2 (stable_id: 1)
# # # pj_champ_3 (stable_id: 2)
# # row_1
# # # pj_champ_4 (stable_id: 1)
# # # pj_champ_5 (stable_id: 2)
# it returns { pj_0.id => 0, pj_1.id => 1, pj_2.id => 0, pj_3.id => 0, pj_4.id => 1, pj_5.id => 1 }
def compute_champ_id_row_index(champs)
champs.filter(&:child?).group_by(&:dossier_id).values.each_with_object({}) do |children_for_dossier, hash|
children_for_dossier.group_by(&:stable_id).values.each do |champs_for_stable_id|
champs_for_stable_id.sort_by(&:row_id).each.with_index { |c, index| hash[c.id] = index }
end
end
end
end

View file

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

View file

@ -5,6 +5,12 @@ class TiptapService
children(node[:content], substitutions, 0)
end
def to_path(node, substitutions = {})
return '' if node.nil?
children_path(node[:content], substitutions)
end
# NOTE: node must be deep symbolized keys
def used_tags_and_libelle_for(node, tags = Set.new)
case node
@ -25,6 +31,21 @@ class TiptapService
@body_started = false
end
def children_path(content, substitutions)
content.map { node_to_path(_1, substitutions) }.join
end
def node_to_path(node, substitutions)
case node
in type: 'paragraph', content:
children_path(content, substitutions)
in type: 'text', text:, **rest
text.strip
in type: 'mention', attrs: { id: }, **rest
substitutions.fetch(id) { "--#{id}--" }
end
end
def children(content, substitutions, level)
content.map { node_to_html(_1, substitutions, level) }.join
end

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 } }
= label
#editor.editor{ data: { tiptap_target: 'editor' }, aria: { describedby: dom_id(f.object, "json-body-messages")} }
#editor.tiptap-editor{ data: { tiptap_target: 'editor' }, aria: { describedby: dom_id(f.object, "json-body-messages")} }
= f.hidden_field :tiptap_body, data: { tiptap_target: 'input' }
.fr-error-text{ id: dom_id(f.object, "json-body-messages"), class: class_names("hidden" => !f.object.errors.include?(:json_body)) }

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
- if @can_download_dossiers
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_export_instructeur_procedure_path))
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_templates: current_instructeur.export_templates_for(@procedure), export_url: method(:download_export_instructeur_procedure_path))
.fr-container.flex= render partial: "tabs", locals: { procedure: @procedure,
statut: @statut,

View file

@ -2,10 +2,10 @@
- if @can_download_dossiers
- if @statut.nil?
= turbo_stream.update_all '.procedure-actions' do
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_export_instructeur_procedure_path))
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_templates: current_instructeur.export_templates_for(@procedure), export_url: method(:download_export_instructeur_procedure_path))
- else
= turbo_stream.update_all '.dossiers-export' do
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, statut: @statut, count: @dossiers_count, export_url: method(:download_export_instructeur_procedure_path))
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_templates: current_instructeur.export_templates_for(@procedure), statut: @statut, count: @dossiers_count, export_url: method(:download_export_instructeur_procedure_path))
= turbo_stream.update "last-export-alert" do
= render partial: "last_export_alert", locals: { export: @last_export, statut: @statut }

View file

@ -22,3 +22,25 @@
- else
= t('.no_export_html', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i )
- if feature_enabled?(:export_template)
%h2.fr-mb-1w.fr-mt-8w
Liste des modèles d'export
%p.fr-hint-text
Un modèle d'export permet de personnaliser le nom des fichiers (pour un export au format Zip)
- if @export_templates.any?
.fr-table.fr-table--no-caption.fr-mt-5w
%table
%thead
%tr
%th{ scope: 'col' } Nom du modèle
%th{ scope: 'col' }= "Groupe instructeur" if @procedure.groupe_instructeurs.many?
%tbody
- @export_templates.each do |export_template|
%tr
%td= link_to export_template.name, edit_instructeur_export_template_path(export_template, procedure_id: @procedure.id)
%td= export_template.groupe_instructeur.label if @procedure.groupe_instructeurs.many?
%p
= link_to new_instructeur_export_template_path(procedure_id: params[:procedure_id]), class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-line' do
Ajouter un modèle d'export

View file

@ -11,7 +11,7 @@
.procedure-actions
- if @can_download_dossiers
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_export_instructeur_procedure_path))
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_templates: current_instructeur.export_templates_for(@procedure), export_url: method(:download_export_instructeur_procedure_path))
.fr-container.flex= render partial: "tabs", locals: { procedure: @procedure,
statut: @statut,
@ -72,7 +72,7 @@
- if @dossiers_count > 0
%span.dossiers-export
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, statut: @statut, count: @dossiers_count, class_btn: 'fr-btn--tertiary', export_url: method(:download_export_instructeur_procedure_path))
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_templates: current_instructeur.export_templates_for(@procedure), statut: @statut, count: @dossiers_count, class_btn: 'fr-btn--tertiary', export_url: method(:download_export_instructeur_procedure_path))
- if @filtered_sorted_paginated_ids.present? || @current_filters.count > 0
= render partial: "dossiers_filter_tags", locals: { procedure: @procedure, procedure_presentation: @procedure_presentation, current_filters: @current_filters, statut: @statut }

View file

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

View file

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

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
member do
resources :archives, only: [:index, :create]
resources :export_templates, only: [:new, :create, :edit, :update, :destroy] do
collection do
get 'preview'
end
end
resources :groupes, only: [:index, :show], controller: 'groupe_instructeurs' do
resource :contact_information

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"
end
create_table "export_templates", force: :cascade do |t|
t.jsonb "content", default: {}
t.datetime "created_at", null: false
t.bigint "groupe_instructeur_id", null: false
t.string "kind", null: false
t.string "name", null: false
t.datetime "updated_at", null: false
t.index ["groupe_instructeur_id"], name: "index_export_templates_on_groupe_instructeur_id"
end
create_table "exports", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.integer "dossiers_count"
t.bigint "export_template_id"
t.string "format", null: false
t.bigint "instructeur_id"
t.string "job_status", default: "pending", null: false
@ -607,6 +618,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_04_17_053843) do
t.datetime "updated_at", precision: nil, null: false
t.bigint "user_profile_id"
t.string "user_profile_type"
t.index ["export_template_id"], name: "index_exports_on_export_template_id"
t.index ["instructeur_id"], name: "index_exports_on_instructeur_id"
t.index ["key"], name: "index_exports_on_key"
t.index ["procedure_presentation_id"], name: "index_exports_on_procedure_presentation_id"
@ -1224,6 +1236,8 @@ ActiveRecord::Schema[7.0].define(version: 2024_04_17_053843) do
add_foreign_key "experts", "users"
add_foreign_key "experts_procedures", "experts"
add_foreign_key "experts_procedures", "procedures"
add_foreign_key "export_templates", "groupe_instructeurs"
add_foreign_key "exports", "export_templates"
add_foreign_key "exports", "instructeurs"
add_foreign_key "france_connect_informations", "users"
add_foreign_key "geo_areas", "champs"

View file

@ -121,7 +121,7 @@ describe Experts::AvisController, type: :controller do
context 'with a valid avis' do
it do
service = instance_double(PiecesJustificativesService)
expect(PiecesJustificativesService).to receive(:new).with(user_profile: expert).and_return(service)
expect(PiecesJustificativesService).to receive(:new).with(user_profile: expert, export_template: nil).and_return(service)
expect(service).to receive(:generate_dossiers_export).with(Dossier.where(id: dossier)).and_return([])
expect(service).to receive(:liste_documents).with(Dossier.where(id: dossier)).and_return([])
is_expected.to have_http_status(:success)

View file

@ -936,7 +936,7 @@ describe Instructeurs::DossiersController, type: :controller do
subject
end
it { expect(assigns(:acls)).to eq(PiecesJustificativesService.new(user_profile: instructeur).acl_for_dossier_export(dossier.procedure)) }
it { expect(assigns(:acls)).to eq(PiecesJustificativesService.new(user_profile: instructeur, export_template: nil).acl_for_dossier_export(dossier.procedure)) }
it { expect(assigns(:is_dossier_in_batch_operation)).to eq(false) }
it { expect(response).to render_template 'dossiers/show' }

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
it { expect { subject }.to change { Export.where(user_profile: instructeur).count }.by(1) }
context 'with an export template' do
let(:export_template) { create(:export_template) }
subject do
get :download_export, params: { export_template_id: export_template.id, procedure_id: procedure.id }
end
it 'displays an notice' do
is_expected.to redirect_to(exports_instructeur_procedure_url(procedure))
expect(flash.notice).to be_present
end
end
end
context 'when the export is not ready' do

View file

@ -1142,7 +1142,7 @@ describe Users::DossiersController, type: :controller do
end
context 'when the dossier has been submitted' do
it { expect(assigns(:acls)).to eq(PiecesJustificativesService.new(user_profile: user).acl_for_dossier_export(dossier.procedure)) }
it { expect(assigns(:acls)).to eq(PiecesJustificativesService.new(user_profile: user, export_template: nil).acl_for_dossier_export(dossier.procedure)) }
it { expect(response).to render_template('dossiers/show') }
end
end

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
context 'with export template' do
let(:export_template) { build(:export_template) }
it 'creates new export' do
expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, export_template: export_template, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) }
.to change { Export.count }.by(1)
end
end
context 'with existing matching export' do
def find_or_create =
Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp)

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
describe '#pieces_jointes_list' do
include Logic
let(:procedure) { create(:procedure, types_de_champ_public:) }
let(:types_de_champ_public) do
[
{ type: :integer_number, stable_id: 900 },
{ type: :piece_justificative, libelle: "PJ", mandatory: true, stable_id: 910 },
{ type: :piece_justificative, libelle: "PJ-cond", mandatory: true, stable_id: 911, condition: ds_eq(champ_value(900), constant(1)) },
{ type: :repetition, libelle: "Répétition", stable_id: 920, children: [{ type: :piece_justificative, libelle: "PJ2", stable_id: 921 }] }
]
end
let(:pj1) { procedure.active_revision.types_de_champ.find { _1.stable_id == 910 } }
let(:pjcond) { procedure.active_revision.types_de_champ.find { _1.stable_id == 911 } }
let(:repetition) { procedure.active_revision.types_de_champ.find { _1.stable_id == 920 } }
let(:pj2) { procedure.active_revision.types_de_champ.find { _1.stable_id == 921 } }
it "returns the list of pieces jointes without conditional" do
expect(procedure.pieces_jointes_list_without_conditionnal).to match_array([[pj1], [pj2, repetition]])
end
it "returns the list of pieces jointes having conditional" do
expect(procedure.pieces_jointes_list_with_conditionnal).to match_array([[pjcond]])
end
end
describe "#attestation_template" do
let(:procedure) { create(:procedure) }

View file

@ -1,9 +1,98 @@
describe PiecesJustificativesService do
describe 'pjs_for_champs' do
let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :piece_justificative }, { type: :repetition, children: [{ type: :piece_justificative }] }]) }
let(:dossier) { create(:dossier, procedure: procedure) }
let(:dossiers) { Dossier.where(id: dossier.id) }
let(:witness) { create(:dossier, procedure: procedure) }
let(:export_template) { double('ExportTemplate') }
let(:pj_service) { PiecesJustificativesService.new(user_profile:, export_template:) }
let(:user_profile) { build(:administrateur) }
def pj_champ(d) = d.champs_public.find_by(type: 'Champs::PieceJustificativeChamp')
def repetition(d) = d.champs.find_by(type: "Champs::RepetitionChamp")
def attachments(champ) = champ.piece_justificative_file.attachments
before { attach_file_to_champ(pj_champ(witness)) }
subject { pj_service.send(:pjs_for_champs, dossiers) }
context 'without any attachment' do
it { expect(subject).to be_empty }
end
context 'with a single attachment' do
let(:champ) { pj_champ(dossier) }
before { attach_file_to_champ(champ) }
it do
expect(export_template).to receive(:attachment_and_path)
.with(dossier, attachments(pj_champ(dossier)).first, index: 0, row_index: nil, champ:)
subject
end
end
context 'with multiple attachments' do
let(:champ) { pj_champ(dossier) }
before do
attach_file_to_champ(champ)
attach_file_to_champ(champ)
end
it do
expect(export_template).to receive(:attachment_and_path)
.with(dossier, attachments(pj_champ(dossier)).first, index: 0, row_index: nil, champ:)
expect(export_template).to receive(:attachment_and_path)
.with(dossier, attachments(pj_champ(dossier)).second, index: 1, row_index: nil, champ:)
subject
end
end
context 'with a repetition' do
let(:first_champ) { repetition(dossier).champs.first }
let(:second_champ) { repetition(dossier).champs.second }
before do
repetition(dossier).add_row(dossier.revision)
attach_file_to_champ(first_champ)
attach_file_to_champ(first_champ)
repetition(dossier).add_row(dossier.revision)
attach_file_to_champ(second_champ)
end
it do
first_child_attachments = attachments(repetition(dossier).champs.first)
second_child_attachments = attachments(repetition(dossier).champs.second)
expect(export_template).to receive(:attachment_and_path)
.with(dossier, first_child_attachments.first, index: 0, row_index: 0, champ: first_champ)
expect(export_template).to receive(:attachment_and_path)
.with(dossier, first_child_attachments.second, index: 1, row_index: 0, champ: first_champ)
expect(export_template).to receive(:attachment_and_path)
.with(dossier, second_child_attachments.first, index: 0, row_index: 1, champ: second_champ)
count = 0
callback = lambda { |*_args| count += 1 }
ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
subject
end
expect(count).to eq(10)
end
end
end
describe '.liste_documents' do
let(:dossier) { create(:dossier, procedure: procedure) }
let(:dossiers) { Dossier.where(id: dossier.id) }
let(:export_template) { nil }
subject do
PiecesJustificativesService.new(user_profile:).liste_documents(dossiers).map(&:first)
PiecesJustificativesService.new(user_profile:, export_template:).liste_documents(dossiers).map(&:first)
end
context 'no acl' do
@ -19,6 +108,11 @@ describe PiecesJustificativesService do
end
it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) }
context 'with export_template' do
let(:export_template) { create(:export_template, groupe_instructeur: procedure.defaut_groupe_instructeur).tap(&:set_default_values) }
it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) }
end
end
context 'with a multiple attachments' do
@ -303,7 +397,7 @@ describe PiecesJustificativesService do
let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :repetition, children: [{ type: :piece_justificative }] }]) }
let(:dossier) { create(:dossier, :with_populated_champs, procedure: procedure) }
let(:dossiers) { Dossier.where(id: dossier.id) }
subject { PiecesJustificativesService.new(user_profile:).generate_dossiers_export(dossiers) }
subject { PiecesJustificativesService.new(user_profile:, export_template: nil).generate_dossiers_export(dossiers) }
it "doesn't update dossier" do
expect { subject }.not_to change { dossier.updated_at }
@ -315,7 +409,7 @@ describe PiecesJustificativesService do
let!(:not_confidentiel_avis) { create(:avis, :not_confidentiel, dossier: dossier) }
let!(:expert_avis) { create(:avis, :confidentiel, dossier: dossier, expert: user_profile) }
subject { PiecesJustificativesService.new(user_profile:).generate_dossiers_export(dossiers) }
subject { PiecesJustificativesService.new(user_profile:, export_template: nil).generate_dossiers_export(dossiers) }
it "includes avis not confidentiel as well as expert's avis" do
expect_any_instance_of(Dossier).to receive(:avis_for_expert).with(user_profile).and_return([])
subject
@ -323,6 +417,67 @@ describe PiecesJustificativesService do
end
end
describe '#compute_champ_id_row_index' do
let(:user_profile) { build(:administrateur) }
let(:types_de_champ_public) do
[
{ type: :repetition, children: [{ type: :piece_justificative }] },
{ type: :repetition, children: [{ type: :piece_justificative }, { type: :piece_justificative }] }
]
end
let(:procedure) { create(:procedure, types_de_champ_public:) }
let(:dossier_1) { create(:dossier, procedure:) }
let(:champs) { dossier_1.champs }
def pj_champ(d) = d.champs_public.find_by(type: 'Champs::PieceJustificativeChamp')
def repetition(d, index:) = d.champs_public.filter(&:repetition?)[index]
subject { PiecesJustificativesService.new(user_profile:, export_template: nil).send(:compute_champ_id_row_index, champs) }
before do
pj_champ(dossier_1)
# repet_0 (stable_id: r0)
# # row_0
# # # pj_champ_0 (stable_id: 0)
# # row_1
# # # pj_champ_1 (stable_id: 0)
# repet_1 (stable_id: r1)
# # row_0
# # # pj_champ_2 (stable_id: 1)
# # # pj_champ_3 (stable_id: 2)
# # row_1
# # # pj_champ_4 (stable_id: 1)
# # # pj_champ_5 (stable_id: 2)
repet_0 = repetition(dossier_1, index: 0)
repet_1 = repetition(dossier_1, index: 1)
repet_0.add_row(dossier_1.revision)
repet_0.add_row(dossier_1.revision)
repet_1.add_row(dossier_1.revision)
repet_1.add_row(dossier_1.revision)
end
it do
champs = dossier_1.champs_public
repet_0 = champs[0]
pj_0 = repet_0.rows.first.first
pj_1 = repet_0.rows.second.first
repet_1 = champs[1]
pj_2 = repet_1.rows.first.first
pj_3 = repet_1.rows.first.second
pj_4 = repet_1.rows.second.first
pj_5 = repet_1.rows.second.second
is_expected.to eq({ pj_0.id => 0, pj_1.id => 1, pj_2.id => 0, pj_3.id => 0, pj_4.id => 1, pj_5.id => 1 })
end
end
def attach_file_to_champ(champ, safe = true)
attach_file(champ.piece_justificative_file, safe)
end

View file

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

View file

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

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']]))
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

View file

@ -110,7 +110,7 @@ describe 'shared/_procedure_description', type: :view do
context 'caching', caching: true do
it "works" do
expect(procedure).to receive(:pieces_jointes_list?).once
expect(procedure).to receive(:public_wrapped_partionned_pjs).once
2.times { render partial: 'shared/procedure_description', locals: { procedure: } }
end