Merge pull request #7958 from colinux/feat-pj-multiple

feat(dossier): piece justificative allows multiple attachments
This commit is contained in:
Colin Darie 2022-12-05 11:24:42 +01:00 committed by GitHub
commit 6f5cd5a2ad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
102 changed files with 1290 additions and 478 deletions

View file

@ -1,59 +1,37 @@
@import "colors";
@import "constants";
.attachment-actions {
display: flex;
margin-bottom: $default-spacer;
}
.attachment-error,
.attachment-upload-error {
position: relative;
.attachment-action {
margin-right: $default-spacer;
.button {
text-transform: lowercase;
background-image: none; // remove DSFR underline, TODO: switch to DSFR download links https://github.com/betagouv/demarches-simplifiees.fr/issues/7883
&::before {
box-shadow: inset 2px 0 0 0 var(--border-plain-error);
height: 100%;
content: "";
left: -0.75rem;
position: absolute;
width: 2px;
}
}
.attachment-link {
a:not(:hover) {
background-image: none; // remove DSFR underline
.attachment-filename {
color: var(--text-default-error);
}
}
.attachment-error {
display: flex;
width: max-content;
max-width: 100%;
align-items: center;
margin-bottom: $default-padding;
padding: $default-padding;
background: $background-red;
&.hidden {
display: none;
.fr-error-text {
margin-top: 0.5rem;
}
}
.attachment-error-message {
display: inline-block;
margin-right: $default-padding;
color: $medium-red;
}
.attachment-multiple:not(.fr-downloads-group) {
ul {
list-style-type: none;
padding-inline-start: 0;
}
.attachment-error-title {
font-weight: bold;
}
.attachment-error-retry {
white-space: nowrap;
&.hidden {
display: none;
li {
padding-bottom: 0;
}
}
.attachment-input.hidden {
display: none;
}

View file

@ -59,7 +59,7 @@
}
}
ul {
:not(.fr-downloads-group) > ul {
list-style-type: disc;
list-style-position: inside;
padding-left: $default-padding;

View file

@ -77,6 +77,8 @@ $dossier-actions-bar-border-width: 1px;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
border-bottom: none;
z-index: 10; // above DSFR btn which are at 1
}
.send-dossier-actions-bar {

View file

@ -1,3 +1,5 @@
@import "constants";
.flex {
display: flex;
@ -49,3 +51,7 @@
.flex-no-shrink {
flex-shrink: 0;
}
.flex-gap-2 {
gap: 2 * $default-spacer; // scss-lint:disable PropertySpelling
}

View file

@ -170,8 +170,7 @@
input[type=number],
input[type=tel],
textarea,
select,
.attachment {
select {
display: block;
margin-bottom: $default-fields-spacer;
@ -386,6 +385,10 @@
}
}
.editable-champ-titre_identite { // scss-lint:disable SelectorFormat
margin-bottom: 2 * $default-padding;
}
.cnaf-inputs,
.dgfip-inputs,
.pole-emploi-inputs,

View file

@ -67,6 +67,8 @@
position: -webkit-sticky; // This is needed on Safari (tested on 12.1)
// scss-lint:enable VendorPrefix
bottom: 0;
z-index: 10; // above DSFR btn which are at 1
}
html.scroll-margins-for-sticky-footer {

View file

@ -29,10 +29,6 @@
float: right;
}
.attachment-link {
margin-top: $default-spacer;
}
.message-answer-button {
margin-left: auto;
}

View file

@ -24,6 +24,10 @@
padding: 0 $default-padding;
background-color: $light-grey;
input[type=file] {
background-color: transparent; // Remove white bg set by DSFR
}
&.no-background {
background-color: transparent;
}

View file

@ -1,6 +1,6 @@
@import "constants";
.rich-text {
.rich-text:not(.piece_justificative):not(.titre_identite) {
i {
font-style: italic;
}

View file

@ -2,8 +2,11 @@ class ApplicationComponent < ViewComponent::Base
include ViewComponent::Translatable
include FlipperHelper
# Takes a Hash of { class_name: boolean }.
# Returns truthy class names in an array. Array can be passed as-it in rails helpers,
# and is still manipulable if needed.
def class_names(class_names)
class_names.to_a.filter_map { |(class_name, flag)| class_name if flag }.join(' ')
class_names.filter { _2 }.keys
end
def current_user

View file

@ -1,113 +1,184 @@
# Display a widget for uploading, editing and deleting a file attachment
class Attachment::EditComponent < ApplicationComponent
def initialize(form:, attached_file:, template: nil, user_can_destroy: false, direct_upload: true, id: nil)
@form = form
attr_reader :champ
attr_reader :attachment
attr_reader :user_can_download
alias user_can_download? user_can_download
attr_reader :user_can_destroy
alias user_can_destroy? user_can_destroy
attr_reader :as_multiple
alias as_multiple? as_multiple
EXTENSIONS_ORDER = ['jpeg', 'png', 'pdf', 'zip'].freeze
def initialize(champ: nil, auto_attach_url: nil, attached_file:, direct_upload: true, index: 0, as_multiple: false, user_can_download: false, user_can_destroy: true, **kwargs)
@as_multiple = as_multiple
@attached_file = attached_file
@template = template
@user_can_destroy = user_can_destroy
@auto_attach_url = auto_attach_url
@champ = champ
@direct_upload = direct_upload
@id = id
@index = index
@user_can_download = user_can_download
@user_can_destroy = user_can_destroy
# attachment passed by kwarg because we don't want a default (nil) value.
@attachment = if kwargs.key?(:attachment)
kwargs.delete(:attachment)
elsif attached_file.respond_to?(:attachment)
attached_file.attachment
else
fail ArgumentError, "You must pass an `attachment` kwarg when not using as single attachment like in #{attached_file.name}. Set it to nil for a new attachment."
end
# When parent form has nested attributes, pass the form builder object_name
# to correctly infer the input attribute name.
@form_object_name = kwargs.delete(:form_object_name)
verify_initialization!(kwargs)
end
attr_reader :template, :form
def first?
@index.zero?
end
def max_file_size
return if file_size_validator.nil?
file_size_validator.options[:less_than]
end
def user_can_destroy?
@user_can_destroy
end
def attachment
@attached_file.attachment
end
def attachment_path
helpers.attachment_path attachment.id, { signed_id: attachment.blob.signed_id }
end
def attachment_id
@attachment_id ||= attachment ? attachment.id : SecureRandom.uuid
@attachment_id ||= (attachment&.id || SecureRandom.uuid)
end
def attachment_path(**args)
helpers.attachment_path attachment.id, args.merge(signed_id: attachment.blob.signed_id)
end
def destroy_attachment_path
attachment_path(champ_id: champ&.id)
end
def attachment_input_class
"attachment-input-#{attachment_id}"
end
def persisted?
attachment&.persisted?
end
def champ
@form.object.is_a?(Champ) ? @form.object : nil
end
def file_field_options
track_issue_with_missing_validators if missing_validators?
{
class: "attachment-input #{attachment_input_class} #{'hidden' if persisted?}",
class: "fr-upload attachment-input #{attachment_input_class} #{persisted? ? 'hidden' : ''}",
direct_upload: @direct_upload,
id: input_id(@id),
id: input_id,
aria: { describedby: champ&.describedby_id },
data: {
auto_attach_url: helpers.auto_attach_url(form.object)
}.merge(has_file_size_validator? ? { max_file_size: max_file_size } : {})
auto_attach_url:
}.merge(has_file_size_validator? ? { max_file_size: } : {})
}.merge(has_content_type_validator? ? { accept: accept_content_type } : {})
end
def input_id(given_id)
[given_id, champ&.input_id, file_field_name].reject(&:blank?).compact.first
def poll_url
if champ.present?
auto_attach_url
else
attachment_path(user_can_edit: true, user_can_download: @user_can_download, auto_attach_url: @auto_attach_url)
end
end
def file_field_name
def field_name
helpers.field_name(@form_object_name || ActiveModel::Naming.param_key(@attached_file.record), attribute_name)
end
def attribute_name
@attached_file.name
end
def remove_button_options
{
role: 'button',
class: 'button small danger',
data: { turbo_method: :delete }
data: { turbo: "true", turbo_method: :delete }
}
end
def retry_button_options
{
type: 'button',
class: 'button attachment-error-retry',
class: 'fr-btn fr-btn--sm fr-btn--tertiary fr-mt-1w attachment-upload-error-retry',
data: { input_target: ".#{attachment_input_class}", action: 'autosave#onClickRetryButton' }
}
end
def replace_button_options
{
type: 'button',
class: 'button small',
data: { toggle_target: ".#{attachment_input_class}" }
}
def persisted?
!!attachment&.persisted?
end
def downloadable?
return false unless user_can_download?
return false if attachment.virus_scanner_error?
return false if attachment.watermark_pending?
true
end
def error?
attachment.virus_scanner_error?
end
def error_message
case
when attachment.virus_scanner.infected?
t(".errors.virus_infected")
when attachment.virus_scanner.corrupt?
t(".errors.corrupted_file")
end
end
private
def input_id
if champ.present?
# There is always a single input by champ, its id must match the label "for" attribute.
return champ.input_id
end
helpers.field_id(@form_object_name || @attached_file.record, attribute_name)
end
def auto_attach_url
return @auto_attach_url if @auto_attach_url.present?
return helpers.auto_attach_url(@champ) if @champ.present?
nil
end
def file_size_validator
@attached_file.record
._validators[file_field_name.to_sym]
._validators[attribute_name.to_sym]
.find { |validator| validator.class == ActiveStorageValidations::SizeValidator }
end
def content_type_validator
@attached_file.record
._validators[file_field_name.to_sym]
._validators[attribute_name.to_sym]
.find { |validator| validator.class == ActiveStorageValidations::ContentTypeValidator }
end
def accept_content_type
list = content_type_validator.options[:in]
if list.include?("application/octet-stream")
list.push(".acidcsa")
end
list = content_type_validator.options[:in].dup
list << ".acidcsa" if list.include?("application/octet-stream")
list.join(', ')
end
def allowed_formats
return nil unless champ&.titre_identite?
@allowed_formats ||= begin
content_type_validator.options[:in].filter_map do |content_type|
MiniMime.lookup_by_content_type(content_type)&.extension
end.uniq.sort_by { EXTENSIONS_ORDER.index(_1) || 999 }
end
end
def has_content_type_validator?
!content_type_validator.nil?
end
@ -122,12 +193,16 @@ class Attachment::EditComponent < ApplicationComponent
return false
end
def verify_initialization!(kwargs)
fail ArgumentError, "Unknown kwarg #{kwargs.keys.join(', ')}" unless kwargs.empty?
end
def track_issue_with_missing_validators
Sentry.capture_message(
"Strange case of missing validator",
extra: {
champ: champ,
file_field_name: file_field_name,
field_name: field_name,
attachment_id: attachment_id
}
)

View file

@ -1,3 +1,10 @@
---
en:
max_file_size: "File size limit : %{max_file_size}."
allowed_formats: "Supported formats : %{formats}"
retry: Retry
delete: Delete
errors:
uploading: "An error occurred while sending the file."
virus_infected: "Virus detected, please send another file."
corrupted_file: "The file is corrupted, please send another file."

View file

@ -1,3 +1,11 @@
---
fr:
max_file_size: "Taille maximale : %{max_file_size}."
allowed_formats: "Formats supportés : %{formats}"
retry: Réessayer
delete: Supprimer
errors:
uploading: "Une erreur sest produite pendant lenvoi du fichier."
virus_infected: "Virus détecté, merci denvoyer un autre fichier."
corrupted_file: "Le fichier est corrompu, merci denvoyer un autre fichier."

View file

@ -1,37 +1,39 @@
.attachment
- if template&.attached?
%p.mb-1
Veuillez télécharger, remplir et joindre
= link_to(url_for(template), download: "", class: "fr-link fr-link--icon-right fr-icon-download-line") do
le modèle suivant
- if helpers.administrateur_signed_in?
%span.ml-2.fr-text--xs.fr-text-mention--grey.visible-on-previous-hover
%span.fr-text-action-high--blue-france.fr-icon-questionnaire-line{ "aria-hidden": "true" }
= t('shared.ephemeral_link')
.attachment.fr-upload-group{ { id: attachment ? dom_id(attachment, :edit) : nil, class: class_names("fr-mb-2w": !(as_multiple? && downloadable?)) }.compact }
- if persisted?
.attachment-actions{ id: dom_id(attachment, :actions) }
.attachment-action
= render Attachment::ShowComponent.new(attachment: attachment, user_can_upload: true)
- if user_can_destroy?
.attachment-action{ "data-turbo": "true" }
= link_to('Supprimer', attachment_path, **remove_button_options)
.attachment-action
= button_tag('Remplacer', **replace_button_options)
%div{ id: dom_id(attachment, :persisted_row) }
.flex.flex-gap-2{ class: class_names("attachment-error": attachment.virus_scanner_error?) }
- if user_can_destroy?
= link_to(t('.delete'), destroy_attachment_path, **remove_button_options, class: "fr-btn fr-btn--tertiary fr-btn--sm fr-icon-delete-line", title: "Supprimer le fichier #{attachment.filename}")
.attachment-error.hidden
.attachment-error-message
%p.attachment-error-title
Une erreur sest produite pendant lenvoi du fichier.
%p.attachment-error-description
Une erreur inconnue s'est produite pendant l'envoi du fichier
= button_tag(**retry_button_options) do
%span.icon.retry
Ré-essayer
.fr-py-1v
- if downloadable?
= render Dsfr::DownloadComponent.new(attachment:)
- else
%span.attachment-filename.fr-mr-1w-= attachment.filename.to_s
= render Attachment::ProgressComponent.new(attachment: attachment)
- if error?
%p.fr-error-text= error_message
- elsif first?
%p.fr-text--sm.fr-text-mention--grey.fr-mb-1w
- if max_file_size.present?
= t('.max_file_size', max_file_size: number_to_human_size(max_file_size))
- if allowed_formats
= t('.allowed_formats', formats: allowed_formats.join(', '))
%label.text-sm.font-weight-normal{ for: file_field_options[:id] }
= t('.max_file_size', max_file_size: number_to_human_size(max_file_size))
- if !as_multiple?
= file_field(champ, field_name, **file_field_options)
- if persisted?
- Attachment::PendingPollComponent.new(attachment: attachment, poll_url:).then do |component|
.fr-mt-2w
= render component
.attachment-upload-error.hidden
%p.fr-error-text= t('.errors.uploading')
= button_tag(**retry_button_options) do
= t(".retry")
= form.file_field(file_field_name, **file_field_options)

View file

@ -0,0 +1,45 @@
# Display a widget for uploading, editing and deleting a file attachment
class Attachment::MultipleComponent < ApplicationComponent
DEFAULT_MAX_ATTACHMENTS = 10
renders_one :template
attr_reader :attached_file
attr_reader :attachments
attr_reader :champ
attr_reader :form_object_name
attr_reader :max
attr_reader :user_can_destroy
attr_reader :user_can_download
alias user_can_download? user_can_download
delegate :count, :empty?, to: :attachments, prefix: true
def initialize(champ:, attached_file:, form_object_name: nil, user_can_download: false, user_can_destroy: true, max: nil)
@champ = champ
@attached_file = attached_file
@form_object_name = form_object_name
@user_can_download = user_can_download
@user_can_destroy = user_can_destroy
@max = max || DEFAULT_MAX_ATTACHMENTS
@attachments = attached_file.attachments || []
end
def each_attachment(&block)
@attachments.each_with_index(&block)
end
def can_attach_next?
@attachments.count < @max
end
def empty_component_id
"attachment-multiple-empty-#{champ.id}"
end
def auto_attach_url
helpers.auto_attach_url(champ)
end
alias poll_url auto_attach_url
end

View file

@ -0,0 +1,13 @@
.fr-mb-4w.attachment-multiple{ class: class_names("fr-downloads-group": user_can_download?) }
= template
%ul
- each_attachment do |attachment, index|
%li{ id: dom_id(attachment) }
= render Attachment::EditComponent.new(champ:, attached_file:, attachment:, index:, as_multiple: true, user_can_destroy:, user_can_download:, form_object_name:)
%div{ id: empty_component_id, class: class_names("hidden": !can_attach_next?) }
= render Attachment::EditComponent.new(champ:, attached_file:, attachment: nil, index: attachments_count, user_can_destroy:, form_object_name:)
// single poll and refresh message for all attachments
= render Attachment::PendingPollComponent.new(attachments: attachments, poll_url:)

View file

@ -0,0 +1,33 @@
class Attachment::PendingPollComponent < ApplicationComponent
def initialize(poll_url:, attachment: nil, attachments: nil)
@poll_url = poll_url
@attachments = if attachment.present?
[attachment]
else
attachments
end
end
def render?
@attachments.any? { pending_attachment?(_1) }
end
def long_pending?
@attachments.any? do
pending_attachment?(_1) && _1.created_at < 30.seconds.ago
end
end
def poll_controller_options
{
controller: 'turbo-poll',
turbo_poll_url_value: @poll_url
}
end
private
def pending_attachment?(attachment)
attachment.virus_scanner.pending? || attachment.watermark_pending?
end
end

View file

@ -0,0 +1,4 @@
---
en:
reload: Reload
explanation: Scanning for viruses and processing your attachment(s) takes longer than expected.

View file

@ -0,0 +1,4 @@
---
fr:
reload: Recharger
explanation: Lanalyse antivirus et le traitement de votre ou de vos pièces jointes prend plus de temps que prévu.

View file

@ -0,0 +1,7 @@
%div{ data: poll_controller_options }
- if long_pending?
= render Dsfr::CalloutComponent.new(title: nil) do |c|
- c.with_body do
= t(".explanation")
- c.with_bottom do
= button_tag t(".reload"), type: "button", class: "fr-btn", data: { action: 'click->turbo-poll#refresh' }

View file

@ -0,0 +1,20 @@
class Attachment::ProgressComponent < ApplicationComponent
attr_reader :attachment
def initialize(attachment:)
@attachment = attachment
end
def progress_label
case
when attachment.virus_scanner.pending?
t(".antivirus_pending")
when attachment.watermark_pending?
t(".watermark_pending")
end
end
def render?
progress_label.present?
end
end

View file

@ -0,0 +1,4 @@
---
en:
antivirus_pending: "Antivirus scanning in progress…"
watermark_pending: "Processing in progress…"

View file

@ -0,0 +1,4 @@
---
fr:
antivirus_pending: "Analyse antivirus en cours…"
watermark_pending: "Traitement en cours…"

View file

@ -0,0 +1,2 @@
%p.fr-badge.fr-badge--info.fr-badge--sm.fr-badge--no-icon
= progress_label

View file

@ -1,27 +1,24 @@
class Attachment::ShowComponent < ApplicationComponent
def initialize(attachment:, user_can_upload: false)
def initialize(attachment:)
@attachment = attachment
@user_can_upload = user_can_upload
end
attr_reader :attachment
def user_can_upload?
@user_can_upload
end
def should_display_link?
(attachment.virus_scanner.safe? || !attachment.virus_scanner.started?) && !attachment.watermark_pending?
end
def attachment_path
helpers.attachment_path(attachment.id, { signed_id: attachment.blob.signed_id, user_can_upload: user_can_upload? })
def error_message
case
when attachment.virus_scanner.infected?
t(".errors.virus_infected")
when attachment.virus_scanner.corrupt?
t(".errors.corrupted_file")
end
end
def poll_controller_options
{
controller: 'turbo-poll',
turbo_poll_url_value: attachment_path
}
def error?
attachment.virus_scanner_error?
end
end

View file

@ -1,2 +1,6 @@
---
en:
virus_not_analyzed: this file has not been scanned by our antivirus, download it with care
errors:
virus_infected: Virus detected, download is blocked.
corrupted_file: The file is corrupted, the download is blocked.

View file

@ -1,2 +1,6 @@
---
fr:
virus_not_analyzed: ce fichier na pas été analysé par notre antivirus, téléchargez-le avec précaution
errors:
virus_infected: "Virus détecté, le téléchargement est bloqué."
corrupted_file: "Le fichier est corrompu, le téléchargement est bloqué."

View file

@ -1,30 +1,14 @@
.attachment-link{ id: dom_id(attachment, :show) }
%div{ id: dom_id(attachment, :show), class: class_names("attachment-error": error?, "fr-mb-2w": !should_display_link?) }
- if should_display_link?
= link_to url_for(attachment.blob), target: '_blank', rel: 'noopener', title: "Télécharger la pièce jointe" do
%span.icon.attached
= attachment.filename.to_s
- if !attachment.virus_scanner.started?
(ce fichier na pas été analysé par notre antivirus, téléchargez-le avec précaution)
= render Dsfr::DownloadComponent.new(attachment: attachment) do |c|
- if !attachment.virus_scanner.started?
- c.right do
= "(#{t(".virus_not_analyzed")})"
- else
%span{ data: poll_controller_options }
= attachment.filename.to_s
- if attachment.virus_scanner.pending?
(analyse antivirus en cours
= link_to "rafraichir", attachment_path, data: { action: 'turbo-poll#refresh' }
)
- elsif attachment.watermark_pending?
(traitement de la pièce en cours
= link_to "rafraichir", attachment_path, data: { action: 'turbo-poll#refresh' }
)
- elsif attachment.virus_scanner.infected?
- if user_can_upload?
(virus détecté, merci denvoyer un autre fichier)
- else
(virus détecté, le téléchargement de ce fichier est bloqué)
- elsif attachment.virus_scanner.corrupt?
- if user_can_upload?
(le fichier est corrompu, merci denvoyer un autre fichier)
- else
(le fichier est corrompu, le téléchargement est bloqué)
.attachment-filename.fr-mb-1w.fr-mr-1w= attachment.filename.to_s
= render Attachment::ProgressComponent.new(attachment: attachment)
- if error?
%p.fr-error-text= error_message

View file

@ -17,7 +17,7 @@
= t('.delete_button')
- if commentaire.piece_jointe.attached?
.attachment-link
.fr-ml-2w
= render Attachment::ShowComponent.new(attachment: commentaire.piece_jointe.attachment)
- if show_reply_button?

View file

@ -1,4 +1,5 @@
%div{ class: callout_class }
%h3.fr-callout__title= title
- if title.present?
%h3.fr-callout__title= title
%p.fr-callout__text= body
= bottom

View file

@ -0,0 +1,16 @@
class Dsfr::DownloadComponent < ApplicationComponent
renders_one :right
attr_reader :attachment
attr_reader :html_class
attr_reader :name
def initialize(attachment:, name: nil)
@attachment = attachment
@name = name || attachment.filename.to_s
end
def title
t(".title", filename: attachment.filename.to_s)
end
end

View file

@ -0,0 +1,3 @@
---
en:
title: "Download file %{filename}"

View file

@ -0,0 +1,3 @@
---
fr:
title: "Télécharger le fichier %{filename}"

View file

@ -0,0 +1,8 @@
.fr-download
%p
= link_to url_for(attachment.blob), download: "", class: "fr-download__link", title: title do
= name
%span.fr-download__detail
= helpers.download_details(attachment)
= right

View file

@ -18,7 +18,10 @@ class EditableChamp::EditableChampComponent < ApplicationComponent
def html_options
{
class: "editable-champ-#{@champ.type_champ} #{'hidden' if !@champ.visible?}",
class: class_names(
"editable-champ-#{@champ.type_champ}": true,
"hidden": !@champ.visible?
),
id: @champ.input_group_id,
data: { controller: stimulus_controller, block: @champ.block? }
}

View file

@ -7,7 +7,7 @@
- elsif has_label?(@champ)
= render EditableChamp::ChampLabelComponent.new form: @form, champ: @champ, seen_at: @seen_at
- if @champ.type_champ == "titre_identite"
%p.notice Carte nationale didentité (uniquement le recto), passeport, titre de séjour ou autre justificatif didentité. Formats acceptés : jpg/png
%p.notice Carte nationale didentité (uniquement le recto), passeport, titre de séjour ou autre justificatif didentité.
= @form.hidden_field :id, value: @champ.id, data: @champ.block? ? { id: true } : {}
= render component_class.new(form: @form, champ: @champ, seen_at: @seen_at)

View file

@ -1,2 +1,7 @@
- user_can_destroy = !@champ.mandatory? || @champ.dossier.brouillon?
= render Attachment::EditComponent.new(form: @form, attached_file: @champ.piece_justificative_file, template: @champ.type_de_champ.piece_justificative_template, user_can_destroy: user_can_destroy)
- user_can_download = !@champ.dossier.brouillon?
- max = [true, nil].include?(@champ.procedure&.piece_justificative_multiple?) ? Attachment::MultipleComponent::DEFAULT_MAX_ATTACHMENTS : 1
= render Attachment::MultipleComponent.new(champ: @champ, attached_file: @champ.piece_justificative_file, form_object_name: @form.object_name, user_can_destroy:, user_can_download:, max:) do |c|
- if @champ.type_de_champ.piece_justificative_template&.attached?
- c.with_template do
= render partial: "shared/piece_justificative_template", locals: { attachment: @champ.type_de_champ.piece_justificative_template }

View file

@ -1,2 +1,5 @@
- user_can_destroy = !@champ.mandatory? || @champ.dossier.brouillon?
= render Attachment::EditComponent.new(form: @form, attached_file: @champ.piece_justificative_file, user_can_destroy: user_can_destroy)
- if @champ.type_de_champ.piece_justificative_template&.attached?
= render partial: "shared/piece_justificative_template", locals: { attachment: @champ.type_de_champ.piece_justificative_template }
= render Attachment::EditComponent.new(champ: @form.object, attached_file: @champ.piece_justificative_file, attachment: @champ.piece_justificative_file[0], form_object_name: @form.object_name, user_can_destroy:)

View file

@ -38,8 +38,7 @@ class TypesDeChampEditor::ChampComponent < ApplicationComponent
def form_options
{
url: admin_procedure_type_de_champ_path(procedure, type_de_champ.stable_id),
multipart: true,
html: { id: nil, class: 'form width-100' }
html: { multipart: true, id: nil, class: 'form width-100' }
}
end
@ -74,12 +73,11 @@ class TypesDeChampEditor::ChampComponent < ApplicationComponent
end
end
def piece_justificative_options(form)
def piece_justificative_template_options
{
form: form,
attached_file: type_de_champ.piece_justificative_template,
user_can_destroy: true,
id: dom_id(type_de_champ, :piece_justificative_template)
auto_attach_url: helpers.auto_attach_url(type_de_champ),
user_can_download: true
}
end

View file

@ -57,11 +57,12 @@
- if type_de_champ.piece_justificative?
.cell
= form.label :piece_justificative_template, "Modèle", for: dom_id(type_de_champ, :piece_justificative_template)
= render Attachment::EditComponent.new(**piece_justificative_options(form))
- if type_de_champ.titre_identite?
.cell
%p
Dans le cadre de la RGPD, le titre didentité sera supprimé lors de lacceptation du dossier
= render Attachment::EditComponent.new(**piece_justificative_template_options)
- if type_de_champ.titre_identite?
%p Dans le cadre de la RGPD, le titre didentité sera supprimé lors de lacceptation du dossier
- elsif procedure.piece_justificative_multiple?
%p Les usagers pourront envoyer plusieurs fichiers si nécessaire.
- if type_de_champ.carte?
- type_de_champ.editable_options.each do |slice|
.cell

View file

@ -396,6 +396,9 @@ module Administrateurs
:procedure_expires_when_termine_enabled,
:tags
]
editable_params << :piece_justificative_multiple if @procedure && !@procedure.piece_justificative_multiple?
permited_params = if @procedure&.locked?
params.require(:procedure).permit(*editable_params)
else

View file

@ -375,4 +375,8 @@ class ApplicationController < ActionController::Base
end
end
end
def cast_bool(value)
ActiveRecord::Type::Boolean.new.deserialize(value)
end
end

View file

@ -4,7 +4,10 @@ class AttachmentsController < ApplicationController
def show
@attachment = @blob.attachments.find(params[:id])
@user_can_upload = params[:user_can_upload]
@user_can_edit = cast_bool(params[:user_can_edit])
@user_can_download = cast_bool(params[:user_can_download])
@auto_attach_url = params[:auto_attach_url]
respond_to do |format|
format.turbo_stream
@ -17,6 +20,8 @@ class AttachmentsController < ApplicationController
@attachment.purge_later
flash.notice = 'La pièce jointe a bien été supprimée.'
@champ_id = params[:champ_id]
respond_to do |format|
format.turbo_stream
format.html { redirect_back(fallback_location: root_url) }

View file

@ -1,5 +1,13 @@
class Champs::PieceJustificativeController < ApplicationController
before_action :authenticate_logged_user!
before_action :set_champ
def show
respond_to do |format|
format.turbo_stream
format.html { redirect_back(fallback_location: root_url) }
end
end
def update
if attach_piece_justificative_or_retry
@ -11,8 +19,11 @@ class Champs::PieceJustificativeController < ApplicationController
private
def attach_piece_justificative
def set_champ
@champ = policy_scope(Champ).find(params[:champ_id])
end
def attach_piece_justificative
@champ.piece_justificative_file.attach(params[:blob_signed_id])
save_succeed = @champ.save
@champ.dossier.update(last_champ_updated_at: Time.zone.now.utc) if save_succeed

View file

@ -6,14 +6,18 @@ module Extensions
attr_reader :attachment_assoc
def apply
# Here we try to define the attachment name:
# Here we try to define the ActiveRecord association name:
# - it could be set explicitly via extension options
# - or we imply that is the same as the field name w/o "_url"
# suffix (e.g., "avatar_url" => "avatar")
attachment = options&.[](:attachment) || field.original_name.to_s.sub(/_url$/, "")
# that's the name of the Active Record association
@attachment_assoc = "#{attachment}_attachment"
@attachment_assoc = if options.key?(:attachment)
"#{options[:attachment]}_attachment"
elsif options.key?(:attachments)
"#{options[:attachments]}_attachments"
else
attachment = field.original_name.to_s.sub(/_url$/, "")
"#{attachment}_attachment"
end
end
# This method resolves (as it states) the field itself
@ -28,8 +32,26 @@ module Extensions
# This method is called if the result of the `resolve`
# is a lazy value (e.g., a Promise like in our case)
def after_resolve(value:, **_rest)
if value&.virus_scanner&.safe? || value&.virus_scanner&.pending?
value
if value.respond_to?(:map)
attachments = value.map { after_resolve_attachment(_1) }
if options[:flat_first]
attachments.first
else
attachments
end
else
after_resolve_attachment(value)
end
end
private
def after_resolve_attachment(attachment)
return unless attachment
if attachment.virus_scanner.safe? || attachment.virus_scanner.pending?
attachment
end
end
end

View file

@ -2028,7 +2028,8 @@ type PersonnePhysique implements Demandeur {
}
type PieceJustificativeChamp implements Champ {
file: File
file: File @deprecated(reason: "Utilisez le champ `files` à la place.")
files: [File!]
id: ID!
"""
@ -2429,4 +2430,4 @@ type ValidationError {
A description of the error
"""
message: String!
}
}

View file

@ -2,8 +2,12 @@ module Types::Champs
class PieceJustificativeChampType < Types::BaseObject
implements Types::ChampType
field :file, Types::File, null: true, extensions: [
{ Extensions::Attachment => { attachment: :piece_justificative_file } }
field :file, Types::File, null: true, deprecation_reason: "Utilisez le champ `files` à la place.", extensions: [
{ Extensions::Attachment => { attachments: :piece_justificative_file, flat_first: true } }
]
field :files, [Types::File], null: true, extensions: [
{ Extensions::Attachment => { attachments: :piece_justificative_file } }
]
end
end

View file

@ -9,7 +9,7 @@ module ChampHelper
def auto_attach_url(object)
if object.is_a?(Champ)
champs_piece_justificative_url(object.id)
champs_attach_piece_justificative_url(object.id)
elsif object.is_a?(TypeDeChamp)
piece_justificative_template_admin_procedure_type_de_champ_url(stable_id: object.stable_id, procedure_id: object.procedure.id)
end

View file

@ -0,0 +1,39 @@
module FormTagHelper
# from Rails 7 ActionView::Helpers::FormTagHelper
# https://api.rubyonrails.org/classes/ActionView/Helpers/FormTagHelper.html#method-i-field_id
# Should be removed when we upgrade to Rails 7
def field_id(object_name, method_name, *suffixes, index: nil, namespace: nil)
if object_name.respond_to?(:model_name)
object_name = object_name.model_name.singular
end
sanitized_object_name = object_name.to_s.gsub(/\]\[|[^-a-zA-Z0-9:.]/, "_").delete_suffix("_")
sanitized_method_name = method_name.to_s.delete_suffix("?")
[
namespace,
sanitized_object_name.presence,
(index unless sanitized_object_name.empty?),
sanitized_method_name,
*suffixes
].tap(&:compact!).join("_")
end
# from Rails 7 ActionView::Helpers::FormTagHelper
# https://api.rubyonrails.org/classes/ActionView/Helpers/FormTagHelper.html#method-i-field_name
# Should be removed when we upgrade to Rails 7
def field_name(object_name, method_name, *method_names, multiple: false, index: nil)
names = method_names.map! { |name| "[#{name}]" }.join
# a little duplication to construct fewer strings
case
when object_name.blank?
"#{method_name}#{names}#{multiple ? "[]" : ""}"
when index
"#{object_name}[#{index}][#{method_name}]#{names}#{multiple ? "[]" : ""}"
else
"#{object_name}[#{method_name}]#{names}#{multiple ? "[]" : ""}"
end
end
end

View file

@ -10,7 +10,6 @@ import {
type ErrorMessage = {
title: string;
description: string;
retry: boolean;
};
@ -69,6 +68,8 @@ export class AutoUpload {
const message = this.messageFromError(error);
this.displayErrorMessage(message);
this.#input.classList.toggle('fr-text-default--error', true);
}
private done() {
@ -81,20 +82,19 @@ export class AutoUpload {
if (error.failureReason == FAILURE_CONNECTIVITY) {
return {
title: 'Le fichier na pas pu être envoyé.',
description: 'Vérifiez votre connexion à Internet, puis ré-essayez.',
title:
'Le fichier na pas pu être envoyé. Vérifiez votre connexion à Internet, puis ré-essayez.',
retry: true
};
} else if (error.code == ERROR_CODE_READ) {
return {
title: 'Nous narrivons pas à lire ce fichier sur votre appareil.',
description: 'Essayez à nouveau, ou sélectionnez un autre fichier.',
title:
'Nous narrivons pas à lire ce fichier sur votre appareil. Essayez à nouveau, ou sélectionnez un autre fichier.',
retry: false
};
} else {
return {
title: 'Le fichier na pas pu être envoyé.',
description: message,
title: message,
retry: !!canRetry
};
}
@ -105,7 +105,6 @@ export class AutoUpload {
if (errorElement) {
show(errorElement);
this.errorTitleElement.textContent = message.title || '';
this.errorDescriptionElement.textContent = message.description || '';
toggle(this.errorRetryButton, message.retry);
}
}
@ -118,30 +117,21 @@ export class AutoUpload {
}
get errorElement() {
return this.#input.parentElement?.querySelector<HTMLElement>(
'.attachment-error'
);
return this.#input
.closest('.attachment')
?.querySelector<HTMLElement>('.attachment-upload-error');
}
get errorTitleElement() {
const element = this.errorElement?.querySelector<HTMLElement>(
'.attachment-error-title'
);
const element =
this.errorElement?.querySelector<HTMLElement>('.fr-error-text');
invariant(element, 'Could not find the error title element.');
return element;
}
get errorDescriptionElement() {
const element = this.errorElement?.querySelector<HTMLElement>(
'.attachment-error-description'
);
invariant(element, 'Could not find the error description element.');
return element;
}
get errorRetryButton() {
const element = this.errorElement?.querySelector<HTMLButtonElement>(
'.attachment-error-retry'
'.attachment-upload-error-retry'
);
invariant(element, 'Could not find the error retry button element.');
return element;

View file

@ -19,6 +19,7 @@ class VirusScannerJob < ApplicationJob
if blob.virus_scanner.done? then return end
metadata = extract_metadata_via_virus_scanner(blob)
VirusScannerJob.merge_and_update_metadata(blob, metadata)
end

View file

@ -23,7 +23,7 @@ class Champ < ApplicationRecord
belongs_to :dossier, inverse_of: false, touch: true, optional: false
belongs_to :type_de_champ, inverse_of: :champ, optional: false
belongs_to :parent, class_name: 'Champ', optional: true
has_one_attached :piece_justificative_file
has_many_attached :piece_justificative_file
# We declare champ specific relationships (Champs::CarteChamp, Champs::SiretChamp and Champs::RepetitionChamp)
# here because otherwise we can't easily use includes in our queries.

View file

@ -43,12 +43,16 @@ class Champs::PieceJustificativeChamp < Champ
end
def for_export
piece_justificative_file.filename.to_s if piece_justificative_file.attached?
piece_justificative_file.map { _1.filename.to_s }
end
def for_api
if piece_justificative_file.attached? && (piece_justificative_file.virus_scanner.safe? || piece_justificative_file.virus_scanner.pending?)
piece_justificative_file.service_url
return nil unless piece_justificative_file.attached?
piece_justificative_file.filter_map do |attachment|
if attachment.virus_scanner.safe? || attachment.virus_scanner.pending?
attachment.service_url
end
end
end
end

View file

@ -13,6 +13,13 @@ module BlobVirusScannerConcern
VirusScannerJob.perform_later(self)
end
def virus_scanner_error?
return true if virus_scanner.infected?
return true if virus_scanner.corrupt?
false
end
private
def set_pending

View file

@ -261,16 +261,16 @@ class Dossier < ApplicationRecord
includes(champs_public: [
:type_de_champ,
:geo_areas,
piece_justificative_file_attachment: :blob,
champs: [:type_de_champ, piece_justificative_file_attachment: :blob]
piece_justificative_file_attachments: :blob,
champs: [:type_de_champ, piece_justificative_file_attachments: :blob]
])
}
scope :with_annotations, -> {
includes(champs_private: [
:type_de_champ,
:geo_areas,
piece_justificative_file_attachment: :blob,
champs: [:type_de_champ, piece_justificative_file_attachment: :blob]
piece_justificative_file_attachments: :blob,
champs: [:type_de_champ, piece_justificative_file_attachments: :blob]
])
}
scope :for_api, -> {
@ -279,7 +279,7 @@ class Dossier < ApplicationRecord
.includes(commentaires: { piece_jointe_attachment: :blob },
justificatif_motivation_attachment: :blob,
attestation: [],
avis: { piece_justificative_file_attachment: :blob },
avis: { piece_justificative_file_attachments: :blob },
traitement: [],
etablissement: [],
individual: [],

View file

@ -35,7 +35,7 @@ class DossierPreloader
end
def load_dossiers(dossiers, pj_template: false)
to_include = [piece_justificative_file_attachment: :blob]
to_include = [piece_justificative_file_attachments: :blob]
if pj_template
to_include << { type_de_champ: { piece_justificative_template_attachment: :blob } }

View file

@ -34,6 +34,7 @@
# opendata :boolean default(TRUE)
# organisation :string
# path :string not null
# piece_justificative_multiple :boolean default(TRUE), not null
# procedure_expires_when_termine_enabled :boolean default(TRUE)
# published_at :datetime
# routing_criteria_name :text default("Votre ville")
@ -248,9 +249,9 @@ class Procedure < ApplicationRecord
:groupe_instructeurs,
dossiers: {
champs_public: [
piece_justificative_file_attachment: :blob,
piece_justificative_file_attachments: :blob,
champs: [
piece_justificative_file_attachment: :blob
piece_justificative_file_attachments: :blob
]
]
}
@ -776,7 +777,7 @@ class Procedure < ApplicationRecord
if dossiers.termine.any?
dossiers_sample = dossiers.termine.limit(100)
total_size = Champ
.includes(piece_justificative_file_attachment: :blob)
.includes(piece_justificative_file_attachments: :blob)
.where(type: Champs::PieceJustificativeChamp.to_s, dossier: dossiers_sample)
.sum('active_storage_blobs.byte_size')

View file

@ -46,19 +46,23 @@ class PiecesJustificativesService
def self.serialize_champs_as_pjs(dossier)
dossier.champs_public.filter { |champ| champ.type_de_champ.old_pj }.map do |champ|
{
created_at: champ.created_at&.in_time_zone('UTC'),
type_de_piece_justificative_id: champ.type_de_champ.old_pj[:stable_id],
content_url: champ.for_api,
user: champ.dossier.user
}
end
champ.for_api&.map do |content_url|
{
created_at: champ.created_at&.in_time_zone('UTC'),
type_de_piece_justificative_id: champ.type_de_champ.old_pj[:stable_id],
content_url:,
user: champ.dossier.user
}
end
end.flatten
end
def self.clone_attachments(original, kopy)
case original
when Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp
clone_attachment(original.piece_justificative_file, kopy.piece_justificative_file)
original.piece_justificative_file.attachments.each do |attachment|
kopy.piece_justificative_file.attach(attachment.blob)
end
when TypeDeChamp
clone_attachment(original.piece_justificative_template, kopy.piece_justificative_template)
when Procedure
@ -113,7 +117,7 @@ class PiecesJustificativesService
def self.pjs_for_champs(dossiers, for_expert = false)
champs = Champ
.joins(:piece_justificative_file_attachment)
.joins(:piece_justificative_file_attachments)
.where(type: "Champs::PieceJustificativeChamp", dossier: dossiers)
if for_expert

View file

@ -25,7 +25,7 @@
= tag[:description]
%h3.header-subsection Logo de l'attestation
= render Attachment::EditComponent.new(form: f, attached_file: @attestation_template.logo, direct_upload: false, user_can_destroy: true)
= render Attachment::EditComponent.new(attached_file: @attestation_template.logo, direct_upload: false)
%p.notice
Formats acceptés : JPG / JPEG / PNG.
@ -33,7 +33,7 @@
Dimensions conseillées : au minimum 500 px de largeur ou de hauteur, poids maximum : 0,5 Mo.
%h3.header-subsection Tampon de l'attestation
= render Attachment::EditComponent.new(form: f, attached_file: @attestation_template.signature, direct_upload: false, user_can_destroy: true)
= render Attachment::EditComponent.new(attached_file: @attestation_template.signature, direct_upload: false)
%p.notice
Formats acceptés : JPG / JPEG / PNG.

View file

@ -9,8 +9,7 @@
.procedure-form__columns.container
= form_for @attestation_template,
url: admin_procedure_attestation_template_path(@procedure),
multipart: true,
html: { class: 'form procedure-form__column--form' } do |f|
html: { multipart: true, class: 'form procedure-form__column--form' } do |f|
%h1.page-title
Délivrance dattestation

View file

@ -25,7 +25,7 @@
- csv_max_size = Administrateurs::GroupeInstructeursController::CSV_MAX_SIZE
- if procedure.publiee?
= form_tag import_admin_procedure_groupe_instructeurs_path(procedure), method: :post, multipart: true, class: "mt-4 form" do
= form_tag import_admin_procedure_groupe_instructeurs_path(procedure), method: :post, html: { multipart: true, class: "mt-4 form" } do
= label_tag t('.csv_import.title')
%p.notice
= t('.csv_import.notice_1')

View file

@ -14,7 +14,7 @@
= f.text_area :description, rows: '6', placeholder: 'Description de la démarche, destinataires, etc. ', class: 'form-control'
%h3.header-subsection Logo de la démarche
= render Attachment::EditComponent.new(form: f, attached_file: @procedure.logo, direct_upload: true, user_can_destroy: true)
= render Attachment::EditComponent.new(attached_file: @procedure.logo, user_can_download: true)
%h3.header-subsection Conservation des données
= f.label :duree_conservation_dossiers_dans_ds do
@ -52,7 +52,7 @@
= f.text_field :cadre_juridique, class: 'form-control', placeholder: 'https://www.legifrance.gouv.fr/'
= f.label :deliberation, 'Importer le texte'
= render Attachment::EditComponent.new(form: f, attached_file: @procedure.deliberation, user_can_destroy: true)
= render Attachment::EditComponent.new(attached_file: @procedure.deliberation, user_can_download: true)
%h3.header-subsection
RGPD
@ -81,7 +81,7 @@
= f.label :notice, 'Notice'
%p.notice
Formats acceptés : .doc, .odt, .pdf, .ppt, .pptx
= render Attachment::EditComponent.new(form: f, attached_file: @procedure.notice, user_can_destroy: true)
= render Attachment::EditComponent.new(attached_file: @procedure.notice, user_can_download: true)
- if !@procedure.locked?
%h3.header-subsection À qui sadresse ma démarche ?
@ -142,3 +142,12 @@
Dans une démarche déclarative, une fois déposé, un dossier ne peut plus être modifié.
Soit il passe immédiatement « en instruction » pour être traité soit il est immédiatement « accepté ».
= f.select :declarative_with_state, Procedure.declarative_attributes_for_select, { include_blank: 'Non' }, class: 'form-control'
- if !@procedure.piece_justificative_multiple?
.fr-checkbox-group
= f.check_box :piece_justificative_multiple
= f.label :piece_justificative_multiple, class: 'fr-label' do
Champ “Pièce justificative” avec multiples fichiers
%p.notice
Autorise les usagers à envoyer plusieurs fichiers pour les champs de type “Pièce justificative”. L'activation de cette option est irréversible et peut nécessiter des modifications si vous utilisez des systèmes automatisés pour traiter les dossiers.

View file

@ -8,8 +8,8 @@
.procedure-form__columns.container
= form_for @procedure,
url: url_for({ controller: 'administrateurs/procedures', action: :update, id: @procedure.id }),
multipart: true,
html: { class: 'form procedure-form__column--form' } do |f|
html: { class: 'form procedure-form__column--form',
multipart: true } do |f|
%h1.page-title Description

View file

@ -9,7 +9,7 @@
.container
%h1
= form_for @procedure, url: url_for({ controller: 'administrateurs/procedures', action: :update_monavis }), multipart: true, html: { class: 'form' } do |f|
= form_for @procedure, url: url_for({ controller: 'administrateurs/procedures', action: :update_monavis }), html: { class: 'form', multipart: true } do |f|
= render partial: 'monavis', locals: { f: f }
.text-right
= f.button 'Enregistrer', class: 'button primary send'

View file

@ -8,8 +8,7 @@
.procedure-form__columns.container
= form_for @procedure,
url: url_for({ controller: 'administrateurs/procedures', action: :create, id: @procedure.id }),
multipart: true,
html: { class: 'form procedure-form__column--form' } do |f|
html: { class: 'form procedure-form__column--form', multipart: true } do |f|
%h1.page-title Nouvelle démarche

View file

@ -7,8 +7,7 @@
.container
= form_for @procedure,
url: url_for({ controller: 'administrateurs/procedures', action: :update, id: @procedure.id }),
multipart: true,
html: { class: 'form' } do |f|
html: { multipart: true, class: 'form' } do |f|
%h1.page-title Zones

View file

@ -1,2 +1,6 @@
= turbo_stream.remove dom_id(@attachment, :actions)
= turbo_stream.remove dom_id(@attachment, :persisted_row)
- if @champ_id
= turbo_stream.show "attachment-multiple-empty-#{@champ_id}"
= turbo_stream.show_all ".attachment-input-#{@attachment.id}"

View file

@ -1,2 +1,5 @@
= turbo_stream.replace dom_id(@attachment, :show) do
= render Attachment::ShowComponent.new(attachment: @attachment, user_can_upload: @user_can_upload)
= turbo_stream.replace dom_id(@attachment, :edit) do
- if @user_can_edit
= render Attachment::EditComponent.new(attachment: @attachment, attached_file: @attachment.record.public_send(@attachment.name), auto_attach_url: @auto_attach_url, user_can_download: @user_can_download)
- else
= render Attachment::ShowComponent.new(attachment: @attachment)

View file

@ -2,6 +2,5 @@
= turbo_stream.morph @champ.input_group_id do
= render EditableChamp::EditableChampComponent.new champ: @champ, form: form
- if @champ.piece_justificative_file.attached?
- attachment = @champ.piece_justificative_file.attachment
- @champ.piece_justificative_file.attachments.each do |attachment|
= turbo_stream.focus_all "button[data-toggle-target=\".attachment-input-#{attachment.id}\"]"

View file

@ -15,9 +15,9 @@
= render Attachment::ShowComponent.new(attachment: @avis.introduction_file.attachment)
%br/
= form_for @avis, url: expert_avis_path(@avis.procedure, @avis), html: { class: 'form', data: { controller: 'persisted-form', persisted_form_key_value: dom_id(@avis) } } do |f|
= form_for @avis, url: expert_avis_path(@avis.procedure, @avis), html: { class: 'form', data: { controller: 'persisted-form', persisted_form_key_value: dom_id(@avis) }, multipart: true } do |f|
= f.text_area :answer, rows: 3, placeholder: 'Votre avis', required: true
= render Attachment::EditComponent.new(form: f, attached_file: @avis.piece_justificative_file, user_can_destroy: true)
= render Attachment::EditComponent.new(attached_file: @avis.piece_justificative_file, user_can_download: true)
.flex.justify-between.align-baseline
%p.confidentiel.flex

View file

@ -7,7 +7,7 @@
= f.text_area :introduction, rows: 3, value: avis.introduction || 'Bonjour, merci de me donner votre avis sur ce dossier.', required: true
%p.tab-title Ajouter une pièce jointe
.form-group
= render Attachment::EditComponent.new(form: f, attached_file: avis.introduction_file, user_can_destroy: true)
= render Attachment::EditComponent.new(attached_file: avis.introduction_file)
- if linked_dossiers.present?
= f.check_box :invite_linked_dossiers, {}, true, false

View file

@ -3,7 +3,7 @@
%h1.tab-title Inviter des personnes à donner leur avis
%p.avis-notice Les invités pourront consulter le dossier, donner un avis et contribuer au fil de messagerie. Ils ne pourront pas modifier le dossier.
= form_for avis, url: url, html: { class: 'form', data: { controller: 'persisted-form', persisted_form_key_value: dom_id(@avis.dossier, :avis_by_expert) } } do |f|
= form_for avis, url: url, html: { class: 'form', multipart: true, data: { controller: 'persisted-form', persisted_form_key_value: dom_id(@avis.dossier, :avis_by_expert) } } do |f|
= hidden_field_tag 'avis[emails]', nil
= react_component("ComboMultiple",
options: [], selected: [], disabled: [],
@ -14,7 +14,7 @@
= f.text_area :introduction, rows: 3, value: avis.introduction || 'Bonjour, merci de me donner votre avis sur ce dossier.', required: true
%p.tab-title Ajouter une pièce jointe
.form-group
= render Attachment::EditComponent.new(form: f, attached_file: avis.introduction_file, user_can_destroy: true)
= render Attachment::EditComponent.new(attached_file: avis.introduction_file)
- if linked_dossiers.present?
= f.check_box :invite_linked_dossiers, {}, true, false

View file

@ -17,7 +17,7 @@
= form_for @avis, url: instructeur_avis_path(@avis.procedure, @avis), html: { class: 'form' } do |f|
= f.text_area :answer, rows: 3, placeholder: 'Votre avis', required: true
= render Attachment::EditComponent.new(form: f, attached_file: @avis.piece_justificative_file, user_can_destroy: true)
= render Attachment::EditComponent.new(attached_file: @avis.piece_justificative_file)
.flex.justify-between.align-baseline
%p.confidentiel.flex

View file

@ -3,7 +3,7 @@
%span.icon{ class: popup_class }
#{popup_title}
= form_tag(terminer_instructeur_dossier_path(dossier.procedure, dossier), data: { turbo: true, turbo_confirm: confirm }, method: :post, multipart: true, class: 'form') do
= form_tag(terminer_instructeur_dossier_path(dossier.procedure, dossier), data: { turbo: true, turbo_confirm: confirm }, method: :post, html: { multipart: true, class: 'form' }) do
- if title == 'Accepter'
= text_area :dossier, :motivation, class: 'motivation-text-area', placeholder: placeholder, required: false
- if dossier.attestation_template&.activated?

View file

@ -8,7 +8,7 @@
%p#avis-emails-description.avis-notice
Entrez les adresses email des experts à qui vous souhaitez demander un avis
= form_for avis, url: url, html: { class: 'form', data: { controller: 'persisted-form', persisted_form_key_value: dom_id(@dossier, :avis_by_instructeur) } } do |f|
= form_for avis, url: url, html: { class: 'form', multipart: true, data: { controller: 'persisted-form', persisted_form_key_value: dom_id(@dossier, :avis_by_instructeur) } } do |f|
= hidden_field_tag 'avis[emails]', nil
= react_component("ComboMultiple",
options: @dossier.procedure.experts_require_administrateur_invitation ? @experts_emails : [],
@ -21,7 +21,7 @@
= f.text_area :introduction, rows: 3, value: avis.introduction || 'Bonjour, merci de me donner votre avis sur ce dossier.', required: true
%p.tab-title Ajouter une pièce jointe
.form-group
= render Attachment::EditComponent.new(form: f, attached_file: avis.introduction_file, user_can_destroy: true)
= render Attachment::EditComponent.new(attached_file: avis.introduction_file)
- if linked_dossiers.present?
= f.check_box :invite_linked_dossiers, {}, true, false

View file

@ -348,3 +348,53 @@
Des marges verticales ont ici été rajoutées.
.container
%h1.fr-mt-4w Attachment::EditComponent
%span.fr-hint-text Note: direct upload, suppression ne marchent pas comme attendu ici.
- champ = @dossier.champs_public.first
- tdc = @dossier.champs_public.find { _1.type_champ == TypeDeChamp.type_champs.fetch(:piece_justificative) }.type_de_champ
- avis = Avis.new
- if attachment = ActiveStorage::Attachment.last
- attachment.update(created_at: 1.second.ago)
%h3 New attachment
= render Attachment::EditComponent.new(champ:, attached_file: champ.piece_justificative_file, attachment: nil)
%h3.fr-mt-4w Existing attachment
= render Attachment::EditComponent.new(champ:, attached_file: champ.piece_justificative_file, attachment:)
%h3.fr-mt-4w Existing attachment, user can not destroy
= render Attachment::EditComponent.new(champ:, attached_file: champ.piece_justificative_file, attachment:, user_can_destroy: false)
%h3.fr-mt-4w Existing attachment, antivirus in progress
- attachment.blob.metadata[:virus_scan_result] = ActiveStorage::VirusScanner::PENDING
- attachment.created_at = Time.zone.now
= render Attachment::EditComponent.new(champ:, attached_file: Champ.new.piece_justificative_file, attachment:)
%h3.fr-mt-4w Existing attachment, antivirus in progress since long time
- attachment.blob.metadata[:virus_scan_result] = ActiveStorage::VirusScanner::PENDING
- attachment.created_at = 2.minutes.ago
= render Attachment::EditComponent.new(champ:, attached_file: Champ.new.piece_justificative_file, attachment:)
%h3.fr-mt-4w Existing attachment, error
- attachment.blob.metadata[:virus_scan_result] = ActiveStorage::VirusScanner::INFECTED
= render Attachment::EditComponent.new(champ:, attached_file: Champ.new.piece_justificative_file, attachment:)
%h3.fr-mt-4w New attachment on TypeDeChamp
= render Attachment::EditComponent.new(auto_attach_url: "/some-auto-attach-path", attached_file: tdc.piece_justificative_template, attachment: nil)
%h3.fr-mt-4w Existing attachment on TypeDeChamp
= render Attachment::EditComponent.new(auto_attach_url: "/some-auto-attach-path", attached_file: tdc.piece_justificative_template, attachment: attachment.reload)
%h3.fr-mt-4w Existing attachment on TypeDeChamp can download
= render Attachment::EditComponent.new(auto_attach_url: "/some-auto-attach-path", attached_file: tdc.piece_justificative_template, attachment: attachment.reload, user_can_download: true)
%h3.fr-mt-4w New attachment on generic object
= render Attachment::EditComponent.new(attached_file: avis.introduction_file)
%h3.fr-mt-4w Existing attachment on generic object, can download
= render Attachment::EditComponent.new(attached_file: avis.introduction_file, attachment: attachment.reload, user_can_download: true)

View file

@ -0,0 +1,7 @@
= render Dsfr::DownloadComponent.new(attachment: attachment, name: "Modèle à télécharger") do |c|
- if administrateur_signed_in?
- c.with_right do
%span.fr-ml-2w.fr-text--xs.fr-text-mention--grey.visible-on-previous-hover
%span.fr-text-action-high--blue-france.fr-icon-questionnaire-line{ "aria-hidden": "true" }
= t('shared.ephemeral_link')

View file

@ -1,5 +1,8 @@
- pj = champ.piece_justificative_file
- if pj.attached?
= render Attachment::ShowComponent.new(attachment: pj.attachment)
.fr-downloads-group
%ul
- pj.attachments.each do |attachment|
%li= render Attachment::ShowComponent.new(attachment:)
- else
Pièce justificative non fournie

View file

@ -15,7 +15,7 @@
- else
%td.libelle{ class: repetition ? 'padded' : '' }
= "#{c.libelle} :"
%td.rich-text
%td.rich-text{ class: c.type_champ }
%div{ class: highlight_if_unseen_class(demande_seen_at, c.updated_at) }
- case c.type_champ
- when TypeDeChamp.type_champs.fetch(:carte)

View file

@ -1,4 +1,4 @@
= form_for(commentaire, url: form_url, html: { class: 'form', data: { controller: 'persisted-form', persisted_form_key_value: @dossier.present? ? dom_id(@dossier) : dom_id(@procedure, :bulk_message) } }) do |f|
= form_for(commentaire, url: form_url, html: { class: 'form', multipart: true, data: { controller: 'persisted-form', persisted_form_key_value: @dossier.present? ? dom_id(@dossier) : dom_id(@procedure, :bulk_message) } }) do |f|
- dossier = commentaire.dossier
- placeholder = t('views.shared.dossiers.messages.form.write_message_to_administration_placeholder')
- if instructeur_signed_in? || administrateur_signed_in? || expert_signed_in?
@ -8,7 +8,7 @@
- disable_piece_jointe = defined?(disable_piece_jointe) ? disable_piece_jointe : false
%div
- if !disable_piece_jointe
= render Attachment::EditComponent.new(form: f, attached_file: commentaire.piece_jointe)
= render Attachment::EditComponent.new(attached_file: commentaire.piece_jointe)
%div
= f.submit t('views.shared.dossiers.messages.form.send_message'), class: 'button primary send', data: { disable: true }

View file

@ -166,7 +166,8 @@ Rails.application.routes.draw do
patch ':champ_id/carte/features/:id', to: 'carte#update'
delete ':champ_id/carte/features/:id', to: 'carte#destroy'
put ':champ_id/piece_justificative', to: 'piece_justificative#update', as: :piece_justificative
get ':champ_id/piece_justificative', to: 'piece_justificative#show', as: :piece_justificative
put ':champ_id/piece_justificative', to: 'piece_justificative#update', as: :attach_piece_justificative
end
resources :attachments, only: [:show, :destroy]

View file

@ -0,0 +1,9 @@
class AddPieceJustificativeMultipleOnProcedures < ActiveRecord::Migration[6.1]
def change
safety_assured do
# Only new procedures will have multiple enabled by default
add_column :procedures, :piece_justificative_multiple, :boolean, default: false, null: false
change_column_default :procedures, :piece_justificative_multiple, from: false, to: true
end
end
end

View file

@ -664,6 +664,7 @@ ActiveRecord::Schema.define(version: 2022_11_30_113745) do
t.string "organisation"
t.bigint "parent_procedure_id"
t.string "path", null: false
t.boolean "piece_justificative_multiple", default: true, null: false
t.boolean "procedure_expires_when_termine_enabled", default: true
t.datetime "published_at"
t.bigint "published_revision_id"

View file

@ -0,0 +1,176 @@
RSpec.describe Attachment::EditComponent, type: :component do
let(:champ) { create(:champ_titre_identite, dossier: create(:dossier)) }
let(:attached_file) { champ.piece_justificative_file }
let(:attachment) { attached_file.attachments.first }
let(:filename) { attachment.filename.to_s }
let(:kwargs) { {} }
let(:component) do
described_class.new(
champ:,
attached_file:,
attachment:,
**kwargs
)
end
subject { render_inline(component).to_html }
context 'when there is no attachment yet' do
let(:attachment) { nil }
it 'renders a form field for uploading a file' do
expect(subject).to have_selector('input[type=file]:not(.hidden)')
end
it 'renders max size' do
expect(subject).to have_content(/Taille maximale :\s+20 Mo/)
end
it 'renders allowed formats' do
expect(subject).to have_content(/Formats supportés : jpeg, png/)
end
end
context 'when there is an attachment' do
it 'renders the filename' do
expect(subject).to have_content(attachment.filename.to_s)
end
it 'hides the file field by default' do
expect(subject).to have_selector('input[type=file].hidden')
end
it 'shows the Delete button by default' do
expect(subject).to have_selector('[title^="Supprimer le fichier"]')
end
end
context 'when the user cannot destroy the attachment' do
let(:kwargs) { { user_can_destroy: false } }
it 'hides the Delete button' do
expect(subject).not_to have_selector("[title^='Supprimer le fichier']")
end
end
context 'within multiple attachments' do
let(:index) { 0 }
let(:component) do
described_class.new(
champ:,
attached_file:,
attachment: nil,
as_multiple: true,
index:
)
end
it 'does not render an empty file' do # (is is rendered by MultipleComponent)
expect(subject).not_to have_selector('input[type=file]')
end
it 'renders max size for first index' do
expect(subject).to have_content(/Taille maximale :\s+20 Mo/)
end
context 'when index is not 0' do
let(:index) { 1 }
it 'renders max size for first index' do
expect(subject).not_to have_content('Taille maximale')
end
end
end
context 'when user can download' do
let(:kwargs) { { user_can_download: true } }
it 'renders a link to download the file' do
expect(subject).to have_link(filename)
end
context 'when watermark is pending' do
let(:champ) { create(:champ_titre_identite) }
let(:kwargs) { { user_can_download: true } }
it 'displays the filename, but doesnt allow to download the file' do
expect(attachment.watermark_pending?).to be_truthy
expect(subject).to have_text(filename)
expect(subject).to have_link('Supprimer')
expect(subject).to have_no_link(text: filename) # don't match "Delete" link which also include filename in title attribute
expect(subject).to have_text('Traitement en cours')
end
end
end
context 'with non nominal or final antivirus status' do
before do
champ.piece_justificative_file[0].blob.update(metadata: attachment.blob.metadata.merge(virus_scan_result: virus_scan_result))
end
context 'when the anti-virus scan is pending' do
let(:virus_scan_result) { ActiveStorage::VirusScanner::PENDING }
it 'displays the filename, but doesnt allow to download the file' do
expect(subject).to have_text(filename)
expect(subject).to have_no_link(text: filename)
expect(subject).to have_text('Analyse antivirus en cours')
end
it 'setup polling' do
expect(subject).to have_selector('[data-controller=turbo-poll]')
end
context "when used as multiple context" do
let(:kwargs) { { as_multiple: true } }
it 'does not setup polling' do
expect(subject).to have_no_selector('[data-controller=turbo-poll]')
end
end
end
context 'when the file is scanned and safe' do
let(:virus_scan_result) { ActiveStorage::VirusScanner::SAFE }
it 'allows to download the file' do
expect(subject).to have_link(filename)
end
end
context 'when the file is scanned and infected' do
let(:virus_scan_result) { ActiveStorage::VirusScanner::INFECTED }
it 'displays the filename, but doesnt allow to download the file' do
expect(subject).to have_text(champ.piece_justificative_file[0].filename.to_s)
expect(subject).to have_no_link(text: filename)
expect(subject).to have_text('Virus détecté')
end
end
context 'when the file is corrupted' do
let(:virus_scan_result) { ActiveStorage::VirusScanner::INTEGRITY_ERROR }
it 'displays the filename, but doesnt allow to download the file' do
expect(subject).to have_text(filename)
expect(subject).to have_no_link(text: filename)
expect(subject).to have_text('corrompu')
end
end
end
describe 'field name inference' do
it "by default generate input name directly form attached file object" do
expect(subject).to have_selector("input[name='champs_titre_identite_champ[piece_justificative_file]']")
end
context "when a form object_name is provided" do
let(:kwargs) { { form_object_name: "my_form" } }
it "generate input name from form object name and attached file object" do
expect(subject).to have_selector("input[name='my_form[piece_justificative_file]']")
end
end
end
end

View file

@ -0,0 +1,101 @@
RSpec.describe Attachment::MultipleComponent, type: :component do
let(:champ) { create(:champ_titre_identite) }
let(:attached_file) { champ.piece_justificative_file }
let(:kwargs) { {} }
let(:component) do
described_class.new(
champ:,
attached_file:,
**kwargs
)
end
subject { render_inline(component).to_html }
context 'when there is no attachment yet' do
let(:champ) { create(:champ_titre_identite, skip_default_attachment: true) }
it 'renders a form field for uploading a file' do
expect(subject).to have_no_selector('.hidden input[type=file]')
expect(subject).to have_selector('input[type=file]:not(.hidden)')
end
it 'renders max size' do
expect(subject).to have_content(/Taille maximale :\s+20 Mo/)
end
end
context 'when there is a template' do
before do
component.with_template { "the template to render" }
end
it 'renders the template' do
expect(subject).to have_text("the template to render")
end
end
context 'when there is an attachment' do
before do
attached_file.attach(
io: StringIO.new("x" * 2),
filename: "me.jpg",
content_type: "image/jpeg",
metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE }
)
champ.save!
end
it 'renders the filenames' do
expect(subject).to have_content(attached_file.attachments[0].filename.to_s)
expect(subject).to have_content(attached_file.attachments[1].filename.to_s)
end
it 'shows the Delete button by default' do
expect(subject).to have_link(title: "Supprimer le fichier #{attached_file.attachments[0].filename}")
expect(subject).to have_link(title: "Supprimer le fichier #{attached_file.attachments[1].filename}")
end
it 'renders a form field for uploading a new file' do
expect(subject).to have_selector('input[type=file]:not(.hidden)')
end
it 'does not renders max size anymore' do
expect(subject).to have_no_content(/Taille maximale/)
end
end
context 'when the user cannot destroy the attachment' do
let(:kwargs) { { user_can_destroy: false } }
it 'hides the Delete button' do
expect(subject).to have_no_link(title: "Supprimer le fichier #{attached_file.attachments[0].filename}")
end
it 'still renders the filename' do
expect(subject).to have_content(attached_file.attachments[0].filename.to_s)
end
end
context 'max attachments' do
let(:kwargs) { { max: 1 } }
it 'does not render visible input file where max attachments has been reached' do
expect(subject).to have_selector('.hidden input[type=file]')
end
end
context 'attachment process in progress' do
let(:created_at) { 1.second.ago }
before do
attached_file.attachments[0].blob.update(metadata: { virus_scan_result: ActiveStorage::VirusScanner::PENDING })
attached_file.attachments[0].update!(created_at:)
end
it 'setup polling' do
expect(subject).to have_selector('[data-controller=turbo-poll]')
end
end
end

View file

@ -0,0 +1,59 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Attachment::PendingPollComponent, type: :component do
let(:champ) { create(:champ_titre_identite) }
let(:attachment) { champ.piece_justificative_file.attachments.first }
let(:component) {
described_class.new(poll_url: "poll-here", attachment:)
}
subject {
render_inline(component).to_html
}
context "when watermark is pending" do
it "renders turbo poll attributes" do
expect(subject).to have_selector("[data-controller='turbo-poll'][data-turbo-poll-url-value='poll-here']")
end
it "renders" do
expect(component).to be_render
end
it "does not render manual reload" do
expect(component).not_to have_content("Recharger")
end
context "when watermark is pending for a long time" do
before do
attachment.created_at = 5.minutes.ago
end
it "renders manual reload" do
expect(subject).to have_content("Recharger")
end
end
end
context "when waterkmark is done" do
before do
attachment.blob[:metadata] = { watermark: true }
end
it "does not render" do
expect(component).not_to be_render
end
context "when antivirus is in progress" do
before do
attachment.blob[:metadata] = { virus_scan_result: ActiveStorage::VirusScanner::PENDING }
end
it "renders" do
expect(component).to be_render
end
end
end
end

View file

@ -0,0 +1,67 @@
RSpec.describe Attachment::ShowComponent, type: :component do
let(:champ) { create(:champ_piece_justificative) }
let(:virus_scan_result) { nil }
let(:attachment) {
champ.piece_justificative_file.attachments.first
}
let(:filename) { attachment.filename.to_s }
let(:component) do
described_class.new(attachment:)
end
subject { render_inline(component).to_html }
before do
champ.piece_justificative_file[0].blob.update(metadata: champ.piece_justificative_file[0].blob.metadata.merge(virus_scan_result: virus_scan_result))
end
context 'when there is no anti-virus scan' do
let(:virus_scan_result) { nil }
it 'allows to download the file' do
expect(subject).to have_link(filename)
expect(subject).to have_text('ce fichier na pas été analysé par notre antivirus')
end
end
context 'when the anti-virus scan is pending' do
let(:virus_scan_result) { ActiveStorage::VirusScanner::PENDING }
it 'displays the filename, but doesnt allow to download the file' do
expect(subject).to have_text(filename)
expect(subject).not_to have_link(filename)
expect(subject).to have_text('Analyse antivirus en cours')
end
end
context 'when the file is scanned and safe' do
let(:virus_scan_result) { ActiveStorage::VirusScanner::SAFE }
it 'allows to download the file' do
expect(subject).to have_link(filename)
end
end
context 'when the file is scanned and infected' do
let(:virus_scan_result) { ActiveStorage::VirusScanner::INFECTED }
it 'displays the filename, but doesnt allow to download the file' do
expect(subject).to have_text(filename)
expect(subject).not_to have_link(filename)
expect(subject).to have_text('Virus détecté')
end
end
context 'when the file is corrupted' do
let(:virus_scan_result) { ActiveStorage::VirusScanner::INTEGRITY_ERROR }
it 'displays the filename, but doesnt allow to download the file' do
expect(subject).to have_text(filename)
expect(subject).not_to have_link(filename)
expect(subject).to have_text('corrompu')
end
end
end

View file

@ -0,0 +1,32 @@
describe EditableChamp::PieceJustificativeComponent, type: :component do
let(:champ) { create(:champ_piece_justificative, dossier: create(:dossier)) }
let(:component) {
described_class.new(form: instance_double(ActionView::Helpers::FormBuilder, object_name: "dossier[champs_public_attributes]"), champ:)
}
let(:subject) {
render_inline(component).to_html
}
context 'when there is a template' do
let(:template) { champ.type_de_champ.piece_justificative_template }
let(:profil) { :user }
before do
allow_any_instance_of(ApplicationController).to receive(:administrateur_signed_in?).and_return(profil == :administrateur)
end
it 'renders a link to template' do
expect(subject).to have_link('Modèle à télécharger')
expect(subject).not_to have_text("éphémère")
end
context 'as an administrator' do
let(:profil) { :administrateur }
it 'warn about ephemeral template url' do
expect(subject).to have_link('Modèle à télécharger')
expect(subject).to have_text("éphémère")
end
end
end
end

View file

@ -755,11 +755,11 @@ describe API::V2::GraphqlController do
end
end
describe "champ" do
describe "champ piece_justificative" do
let(:champ) { create(:champ_piece_justificative, dossier: dossier) }
let(:byte_size) { 2712286911 }
context "byteSize" do
context "with deprecated file field" do
let(:query) do
"{
dossier(number: #{dossier.id}) {
@ -778,9 +778,28 @@ describe API::V2::GraphqlController do
}
end
context "byteSize" do
let(:query) do
"{
dossier(number: #{dossier.id}) {
champs(id: \"#{champ.to_typed_id}\") {
... on PieceJustificativeChamp {
files { byteSize }
}
}
}
}"
end
it {
expect(gql_errors).to be_nil
expect(gql_data).to eq(dossier: { champs: [{ files: [{ byteSize: 4 }] }] })
}
end
context "when the file is really big" do
before do
champ.piece_justificative_file.blob.update(byte_size: byte_size)
champ.piece_justificative_file.first.blob.update(byte_size: byte_size)
end
context "byteSize" do
@ -789,7 +808,7 @@ describe API::V2::GraphqlController do
dossier(number: #{dossier.id}) {
champs(id: \"#{champ.to_typed_id}\") {
... on PieceJustificativeChamp {
file { byteSize }
files { byteSize }
}
}
}
@ -807,7 +826,7 @@ describe API::V2::GraphqlController do
dossier(number: #{dossier.id}) {
champs(id: \"#{champ.to_typed_id}\") {
... on PieceJustificativeChamp {
file { byteSizeBigInt }
files { byteSizeBigInt }
}
}
}
@ -816,7 +835,7 @@ describe API::V2::GraphqlController do
it {
expect(gql_errors).to be_nil
expect(gql_data).to eq(dossier: { champs: [{ file: { byteSizeBigInt: '2712286911' } }] })
expect(gql_data).to eq(dossier: { champs: [{ files: [{ byteSizeBigInt: '2712286911' }] }] })
}
end
end

View file

@ -1,6 +1,6 @@
describe AttachmentsController, type: :controller do
let(:user) { create(:user) }
let(:attachment) { champ.piece_justificative_file.attachment }
let(:attachment) { champ.piece_justificative_file.attachments.first }
let(:dossier) { create(:dossier, user: user) }
let(:champ) { create(:champ_piece_justificative, dossier_id: dossier.id) }
let(:signed_id) { attachment.blob.signed_id }
@ -45,7 +45,7 @@ describe AttachmentsController, type: :controller do
describe '#destroy' do
render_views
let(:attachment) { champ.piece_justificative_file.attachment }
let(:attachment) { champ.piece_justificative_file.attachments.first }
let(:dossier) { create(:dossier, user: user) }
let(:champ) { create(:champ_piece_justificative, dossier_id: dossier.id) }
let(:signed_id) { attachment.blob.signed_id }

View file

@ -23,7 +23,7 @@ describe Champs::PieceJustificativeController, type: :controller do
subject
champ.reload
expect(champ.piece_justificative_file.attached?).to be true
expect(champ.piece_justificative_file.filename).to eq('piece_justificative_0.pdf')
expect(champ.piece_justificative_file[0].filename).to eq('piece_justificative_0.pdf')
end
it 'renders the attachment template as Javascript' do

View file

@ -162,8 +162,13 @@ FactoryBot.define do
factory :champ_titre_identite, class: 'Champs::TitreIdentiteChamp' do
type_de_champ { association :type_de_champ_titre_identite, procedure: dossier.procedure }
transient do
skip_default_attachment { false }
end
after(:build) do |champ, evaluator|
next if evaluator.skip_default_attachment
after(:build) do |champ, _evaluator|
champ.piece_justificative_file.attach(
io: StringIO.new("toto"),
filename: "toto.png",

View file

@ -183,6 +183,16 @@ RSpec.describe Types::DossierType, type: :graphql do
}
end
describe 'dossier with motivation attachment' do
let(:dossier) { create(:dossier, :accepte, :with_motivation, :with_justificatif) }
let(:query) { DOSSIER_WITH_MOTIVATION_QUERY }
let(:variables) { { number: dossier.id } }
it {
expect(data[:dossier][:motivationAttachment][:url]).not_to be_nil
}
end
DOSSIER_QUERY = <<-GRAPHQL
query($number: Int!) {
dossier(number: $number) {
@ -219,6 +229,16 @@ RSpec.describe Types::DossierType, type: :graphql do
}
GRAPHQL
DOSSIER_WITH_MOTIVATION_QUERY = <<-GRAPHQL
query($number: Int!) {
dossier(number: $number) {
motivationAttachment {
url
}
}
}
GRAPHQL
DOSSIER_WITH_CHAMPS_QUERY = <<-GRAPHQL
query($number: Int!) {
dossier(number: $number) {

View file

@ -452,13 +452,13 @@ describe Champ do
end
it 'marks the file as pending virus scan' do
expect(subject.piece_justificative_file.virus_scanner.started?).to be_truthy
expect(subject.piece_justificative_file.first.virus_scanner.started?).to be_truthy
end
it 'marks the file as safe once the scan completes' do
subject
perform_enqueued_jobs
expect(champ.reload.piece_justificative_file.virus_scanner.safe?).to be_truthy
expect(champ.reload.piece_justificative_file.first.virus_scanner.safe?).to be_truthy
end
end
end
@ -467,7 +467,7 @@ describe Champ do
describe '#enqueue_watermark_job' do
context 'when type_champ is type_de_champ_titre_identite' do
let(:type_de_champ) { create(:type_de_champ_titre_identite) }
let(:champ) { build(:champ_titre_identite, type_de_champ: type_de_champ) }
let(:champ) { build(:champ_titre_identite, type_de_champ: type_de_champ, skip_default_attachment: true) }
before do
allow(ClamavService).to receive(:safe_file?).and_return(true)
@ -480,14 +480,14 @@ describe Champ do
end
it 'marks the file as needing watermarking' do
expect(subject.piece_justificative_file.watermark_pending?).to be_truthy
expect(subject.piece_justificative_file.first.watermark_pending?).to be_truthy
end
it 'watermarks the file' do
subject
perform_enqueued_jobs
expect(champ.reload.piece_justificative_file.watermark_pending?).to be_falsy
expect(champ.reload.piece_justificative_file.blob.watermark_done?).to be_truthy
expect(champ.reload.piece_justificative_file.first.watermark_pending?).to be_falsy
expect(champ.reload.piece_justificative_file.first.blob.watermark_done?).to be_truthy
end
end
end

View file

@ -43,35 +43,35 @@ describe Champs::PieceJustificativeChamp do
let(:champ_pj) { create(:champ_piece_justificative) }
subject { champ_pj.for_export }
it { is_expected.to eq('toto.txt') }
it { is_expected.to match_array(['toto.txt']) }
context 'without attached file' do
before { champ_pj.piece_justificative_file.purge }
it { is_expected.to eq(nil) }
it { is_expected.to eq([]) }
end
end
describe '#for_api' do
let(:champ_pj) { create(:champ_piece_justificative) }
let(:metadata) { champ_pj.piece_justificative_file.blob.metadata }
let(:metadata) { champ_pj.piece_justificative_file.first.blob.metadata }
before { champ_pj.piece_justificative_file.blob.update(metadata: metadata.merge(virus_scan_result: status)) }
before { champ_pj.piece_justificative_file.first.blob.update(metadata: metadata.merge(virus_scan_result: status)) }
subject { champ_pj.for_api }
context 'when file is safe' do
let(:status) { ActiveStorage::VirusScanner::SAFE }
it { is_expected.to include("/rails/active_storage/disk/") }
it { is_expected.to match_array([include("/rails/active_storage/disk/")]) }
end
context 'when file is not scanned' do
let(:status) { ActiveStorage::VirusScanner::PENDING }
it { is_expected.to include("/rails/active_storage/disk/") }
it { is_expected.to match_array([include("/rails/active_storage/disk/")]) }
end
context 'when file is infected' do
let(:status) { ActiveStorage::VirusScanner::INFECTED }
it { is_expected.to be_nil }
it { is_expected.to eq([]) }
end
end
end

View file

@ -1783,7 +1783,7 @@ describe Dossier do
let(:dossier) { create(:dossier) }
let(:champ_piece_justificative) { create(:champ_piece_justificative, dossier_id: dossier.id) }
before { dossier.champs_public << champ_piece_justificative }
it { expect(Champs::PieceJustificativeChamp.where(dossier: new_dossier).first.piece_justificative_file.blob).to eq(champ_piece_justificative.piece_justificative_file.blob) }
it { expect(Champs::PieceJustificativeChamp.where(dossier: new_dossier).first.piece_justificative_file.first.blob).to eq(champ_piece_justificative.piece_justificative_file.first.blob) }
end
end

View file

@ -6,7 +6,9 @@ describe ChampSerializer do
context 'when type champ is piece justificative' do
let(:champ) { create(:champ_piece_justificative) }
it { expect(subject[:value]).to match('/rails/active_storage/disk/') }
it {
expect(subject[:value]).to match_array([a_string_matching('/rails/active_storage/disk/')])
}
end
context 'when type champ is not piece justificative' do

View file

@ -20,7 +20,18 @@ describe PiecesJustificativesService do
attach_file_to_champ(pj_champ.call(witness))
end
it { expect(subject).to match_array([pj_champ.call(dossier).piece_justificative_file.attachment]) }
context 'with a single attachment' do
it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) }
end
context 'with a multiple attachments' do
before do
attach_file_to_champ(pj_champ.call(dossier))
end
it { expect(subject.count).to eq(2) }
it { expect(subject).to match_array(pj_champ.call(dossier).piece_justificative_file.attachments) }
end
end
context 'with a pj not safe on a champ' do
@ -46,7 +57,7 @@ describe PiecesJustificativesService do
attach_file_to_champ(private_pj_champ.call(witness))
end
it { expect(subject).to match_array([private_pj_champ.call(dossier).piece_justificative_file.attachment]) }
it { expect(subject).to match_array(private_pj_champ.call(dossier).piece_justificative_file.attachments) }
context 'for expert' do
let(:for_expert) { true }

View file

@ -93,7 +93,7 @@ describe 'The user' do
check_selected_value('communes', with: 'Ambléon (01300)')
expect(page).to have_field('dossier_link', with: '123')
expect(page).to have_text('file.pdf')
expect(page).to have_text('analyse antivirus en cours')
expect(page).to have_text('Analyse antivirus en cours')
end
let(:procedure_with_repetition) do
@ -184,7 +184,7 @@ describe 'The user' do
find_field('Pièce justificative 2').attach_file(Rails.root + 'spec/fixtures/files/RIB.pdf')
# Expect the files to be uploaded immediately
expect(page).to have_text('analyse antivirus en cours', count: 2, wait: 5)
expect(page).to have_text('Analyse antivirus en cours', count: 2, wait: 5)
expect(page).to have_text('file.pdf')
expect(page).to have_text('RIB.pdf')
@ -206,7 +206,7 @@ describe 'The user' do
# Test invalid file type
attach_file('Pièce justificative 1', Rails.root + 'spec/fixtures/files/invalid_file_format.json')
expect(page).to have_no_text('La pièce justificative nest pas dun type accepté')
expect(page).to have_text('analyse antivirus en cours', count: 1, wait: 5)
expect(page).to have_text('Analyse antivirus en cours', count: 1, wait: 5)
end
scenario 'retry on transcient upload error', js: true do
@ -216,18 +216,18 @@ describe 'The user' do
# Test auto-upload failure
# Make the subsequent auto-upload request fail
allow_any_instance_of(Champs::PieceJustificativeController).to receive(:update) do |instance|
instance.render json: { errors: ['Error'] }, status: :bad_request
instance.render json: { errors: ["Une erreur est survenue"] }, status: :bad_request
end
attach_file('Pièce justificative 1', Rails.root + 'spec/fixtures/files/file.pdf')
expect(page).to have_css('p', text: 'Le fichier na pas pu être envoyé', visible: :visible, wait: 5)
expect(page).to have_button('Ré-essayer', visible: true)
expect(page).to have_css('p', text: "Une erreur est survenue", visible: :visible, wait: 5)
expect(page).to have_button('Réessayer', visible: true)
expect(page).to have_button('Déposer le dossier', disabled: false)
allow_any_instance_of(Champs::PieceJustificativeController).to receive(:update).and_call_original
# Test that retrying after a failure works
click_on('Ré-essayer', visible: true, wait: 5)
expect(page).to have_text('analyse antivirus en cours', wait: 5)
click_on('Réessayer', visible: true, wait: 5)
expect(page).to have_text('Analyse antivirus en cours', wait: 5)
expect(page).to have_text('file.pdf')
expect(page).to have_button('Déposer le dossier', disabled: false)
@ -238,6 +238,53 @@ describe 'The user' do
expect(page).to have_text('file.pdf')
end
scenario "upload multiple pieces justificatives on same champ", js: true do
log_in(user, procedure_with_pjs)
fill_individual
attach_file('Pièce justificative 1', Rails.root + 'spec/fixtures/files/file.pdf')
expect(page).to have_text('file.pdf')
expect(page).to have_text('Analyse antivirus en cours')
attach_file('Pièce justificative 1', Rails.root + 'spec/fixtures/files/white.png')
expect(page).to have_text('white.png')
click_on("Supprimer le fichier file.pdf")
expect(page).to have_no_text('file.pdf')
expect(page).to have_text("La pièce jointe a bien été supprimée")
attach_file('Pièce justificative 1', Rails.root + 'spec/fixtures/files/black.png')
# Mark all attachments as safe to test turbo poll
# They are not immediately attached in db, so we have to wait a bit before continuing
# NOTE: we'res using files not used in other tests to avoid conflicts
attachments = Timeout.timeout(5) do
filenames = ['white.png', 'black.png']
attachments = ActiveStorage::Attachment.where(name: "piece_justificative_file").includes(:blob).filter do |attachment|
filenames.include?(attachment.filename.to_s)
end
fail ActiveRecord::RecordNotFound, "Not all attachments where found yet" unless attachments.count == filenames.count
attachments
rescue ActiveRecord::RecordNotFound
sleep 0.2
retry
end
attachments.each {
_1.blob.metadata = { virus_scan_result: ActiveStorage::VirusScanner::SAFE }
_1.save!
}
expect(page).not_to have_text('Analyse antivirus en cours', wait: 10)
visit current_path
expect(page).to have_no_text('file.pdf')
expect(page).to have_text('white.png')
expect(page).to have_text('black.png')
end
context 'with condition' do
include Logic

View file

@ -1,68 +0,0 @@
describe 'shared/attachment/_show.html.haml', type: :view do
let(:champ) { create(:champ_piece_justificative) }
let(:virus_scan_result) { nil }
before do
champ.piece_justificative_file.blob.update(metadata: champ.piece_justificative_file.blob.metadata.merge(virus_scan_result: virus_scan_result))
end
subject { render Attachment::ShowComponent.new(attachment: champ.piece_justificative_file.attachment) }
context 'when there is no anti-virus scan' do
let(:virus_scan_result) { nil }
it 'allows to download the file' do
expect(subject).to have_link(champ.piece_justificative_file.filename.to_s)
expect(subject).to have_text('ce fichier na pas été analysé par notre antivirus')
end
end
context 'when the anti-virus scan is pending' do
let(:virus_scan_result) { ActiveStorage::VirusScanner::PENDING }
it 'displays the filename, but doesnt allow to download the file' do
expect(subject).to have_text(champ.piece_justificative_file.filename.to_s)
expect(subject).not_to have_link(champ.piece_justificative_file.filename.to_s)
expect(subject).to have_text('analyse antivirus en cours')
end
end
context 'when watermark is pending' do
let(:champ) { create(:champ_titre_identite) }
it 'displays the filename, but doesnt allow to download the file' do
pp champ.piece_justificative_file.attachment.watermark_pending?
expect(subject).to have_text(champ.piece_justificative_file.filename.to_s)
expect(subject).not_to have_link(champ.piece_justificative_file.filename.to_s)
expect(subject).to have_text('traitement de la pièce en cours')
end
end
context 'when the file is scanned and safe' do
let(:virus_scan_result) { ActiveStorage::VirusScanner::SAFE }
it 'allows to download the file' do
expect(subject).to have_link(champ.piece_justificative_file.filename.to_s)
end
end
context 'when the file is scanned and infected' do
let(:virus_scan_result) { ActiveStorage::VirusScanner::INFECTED }
it 'displays the filename, but doesnt allow to download the file' do
expect(subject).to have_text(champ.piece_justificative_file.filename.to_s)
expect(subject).not_to have_link(champ.piece_justificative_file.filename.to_s)
expect(subject).to have_text('virus détecté')
end
end
context 'when the file is corrupted' do
let(:virus_scan_result) { ActiveStorage::VirusScanner::INTEGRITY_ERROR }
it 'displays the filename, but doesnt allow to download the file' do
expect(subject).to have_text(champ.piece_justificative_file.filename.to_s)
expect(subject).not_to have_link(champ.piece_justificative_file.filename.to_s)
expect(subject).to have_text('corrompu')
end
end
end

Some files were not shown because too many files have changed in this diff Show more