Merge branch 'main' into feature/prefill_repetible

This commit is contained in:
Damien Le Thiec 2023-02-11 22:40:56 +01:00
commit dbb92e7fd3
154 changed files with 1498 additions and 557 deletions

View file

@ -179,7 +179,7 @@ GEM
activerecord (>= 3.1.0, < 7)
delayed_cron_job (0.7.4)
delayed_job (>= 4.1)
delayed_job (4.1.10)
delayed_job (4.1.11)
activesupport (>= 3.0, < 8.0)
delayed_job_active_record (4.1.5)
activerecord (>= 3.0, < 6.2)
@ -249,15 +249,16 @@ GEM
faraday-patron (1.0.0)
faraday-rack (1.0.0)
ffi (1.15.5)
flipper (0.24.1)
flipper-active_record (0.24.1)
flipper (0.26.0)
concurrent-ruby (< 2)
flipper-active_record (0.26.0)
activerecord (>= 4.2, < 8)
flipper (~> 0.24.1)
flipper-ui (0.24.1)
flipper (~> 0.26.0)
flipper-ui (0.26.0)
erubi (>= 1.0.0, < 2.0.0)
flipper (~> 0.24.1)
flipper (~> 0.26.0)
rack (>= 1.4, < 3)
rack-protection (>= 1.5.3, <= 2.2.0)
rack-protection (>= 1.5.3, <= 4.0.0)
sanitize (< 7)
fog-core (2.2.3)
builder
@ -424,7 +425,7 @@ GEM
msgpack (1.4.2)
multi_json (1.15.0)
multipart-post (2.1.1)
mustermann (1.1.1)
mustermann (3.0.0)
ruby2_keywords (~> 0.0.1)
net-imap (0.2.3)
digest
@ -441,7 +442,7 @@ GEM
net-protocol
netrc (0.11.0)
nio4r (2.5.8)
nokogiri (1.14.0)
nokogiri (1.14.1)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
open4 (1.3.4)
@ -506,7 +507,7 @@ GEM
httpclient
json-jwt (>= 1.11.0)
rack (>= 2.1.0)
rack-protection (2.2.0)
rack-protection (3.0.5)
rack
rack-proxy (0.7.4)
rack
@ -683,10 +684,10 @@ GEM
simple_xlsx_reader (1.0.4)
nokogiri
rubyzip
sinatra (2.2.0)
mustermann (~> 1.0)
rack (~> 2.2)
rack-protection (= 2.2.0)
sinatra (3.0.5)
mustermann (~> 3.0)
rack (~> 2.2, >= 2.2.4)
rack-protection (= 3.0.5)
tilt (~> 2.0)
skylight (5.3.4)
activesupport (>= 5.2.0)
@ -729,7 +730,7 @@ GEM
railties (>= 6.0.0)
typhoeus (1.4.0)
ethon (>= 0.9.0)
tzinfo (2.0.5)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
ulid-ruby (1.0.2)
unf (0.1.4)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -173,6 +173,7 @@
input[type=email],
input[type=password],
input[type=date],
input[type=datetime-local],
input[type=number],
input[type=tel],
textarea,
@ -207,6 +208,7 @@
input[type=date],
input[type=number],
input[type=tel],
input[type=datetime-local],
textarea,
select {
border-radius: 4px;
@ -248,6 +250,7 @@
input[type=password],
input[type=date],
input[type=number],
input[type=datetime-local],
input[type=tel],
textarea {
@media (max-width: $two-columns-breakpoint) {
@ -284,7 +287,8 @@
&:not([size]) {
&[type='date'],
&[type='tel'],
&[type='number'] {
&[type='number'],
&[type='datetime-local'] {
width: 33.33%;
}

View file

@ -26,6 +26,7 @@
.container {
a {
cursor: pointer;
overflow-wrap: break-word;
}
}

View file

@ -28,7 +28,7 @@
&.hoverable {
tbody tr:hover {
background: $light-grey;
background-color: $light-grey;
}
}
@ -60,7 +60,7 @@
.table {
&.hoverable {
tbody tr:hover {
background: $white;
background-color: $white;
}
}
}

View file

@ -0,0 +1,35 @@
class Dossiers::BatchSelectMoreComponent < ApplicationComponent
def initialize(dossiers_count:, filtered_sorted_ids:)
@dossiers_count = dossiers_count
@filtered_sorted_ids = filtered_sorted_ids
end
def not_selected_button_data
{
action: "batch-operation#onSelectMore",
dossiers: @filtered_sorted_ids.first(Instructeurs::ProceduresController::BATCH_SELECTION_LIMIT).join(',')
}
end
def selected_button_data
{
action: "batch-operation#onDeleteSelection"
}
end
def not_selected_text
if @dossiers_count <= Instructeurs::ProceduresController::BATCH_SELECTION_LIMIT
t(".select_all", dossiers_count: @dossiers_count)
else
t(".select_all_limit", dossiers_count: @dossiers_count, limit: Instructeurs::ProceduresController::BATCH_SELECTION_LIMIT)
end
end
def selected_text
if @dossiers_count <= Instructeurs::ProceduresController::BATCH_SELECTION_LIMIT
t(".selected_all", dossiers_count: @dossiers_count)
else
t(".selected_all_limit", limit: Instructeurs::ProceduresController::BATCH_SELECTION_LIMIT)
end
end
end

View file

@ -0,0 +1,7 @@
en:
pagination_files_selected_html: All <span id='dynamic_number'>n</span> files from this page are selected.
select_all: "Select all %{dossiers_count} files."
select_all_limit: "Select first %{limit} files on %{dossiers_count}"
selected_all: "All %{dossiers_count} files are selected."
selected_all_limit: "%{limit} files are selected."
delete_selection: "Delete selection"

View file

@ -0,0 +1,7 @@
fr:
pagination_files_selected_html: Les <span id='dynamic_number'>n</span> dossiers de cette page sont sélectionnés.
select_all: "Sélectionner tous les %{dossiers_count} dossiers."
select_all_limit: "Sélectionner les %{limit} premiers dossiers sur les %{dossiers_count}"
selected_all: "Tous les %{dossiers_count} dossiers sont sélectionnés."
selected_all_limit: "%{limit} dossiers sont sélectionnés."
delete_selection: "Effacer la sélection"

View file

@ -0,0 +1,15 @@
%tr#js_batch_select_more.fr-background-alt--blue-france.hidden
%td.fr-py-2w.text-center{ colspan: 100 }
#not_selected
%p
= t('.pagination_files_selected_html')
%button.fr-btn.fr-btn--sm.fr-btn--tertiary-no-outline{ data: not_selected_button_data }
= not_selected_text
#selected.hidden
%p
= selected_text
%button.fr-btn.fr-btn--sm.fr-btn--tertiary-no-outline{ data: selected_button_data }
= t(".delete_selection")
= hidden_field_tag :"batch_operation[dossier_ids][]", "", form: dom_id(BatchOperation.new), id: dom_id(BatchOperation.new, "input_multiple_ids")

View file

@ -7,4 +7,4 @@
= render EditableChamp::ChampLabelContentComponent.new champ: @champ, seen_at: @seen_at
- if @champ.description.present?
.notice{ id: @champ.describedby_id }= string_to_html(@champ.description)
.notice{ id: @champ.describedby_id }= string_to_html(@champ.description, allow_a: true)

View file

@ -1,9 +1,10 @@
class EditableChamp::DatetimeComponent < EditableChamp::EditableChampBaseComponent
def datetime_start_year(date)
if date == nil || date.year == 0 || date.year >= Date.today.year - 1
Date.today.year - 1
def formatted_value_for_datetime_locale
if @champ.valid? && @champ.value.present?
# convert to a format that the datetime-local input can understand
DateTime.iso8601(@champ.value).strftime('%Y-%m-%dT%H:%M')
else
date.year
@champ.value
end
end
end

View file

@ -1,4 +1,2 @@
- parsed_value = @champ.value.present? ? Time.zone.parse(@champ.value) : nil
.datetime
= @form.datetime_select(:value, id: @champ.input_id, aria: { describedby: @champ.describedby_id }, selected: parsed_value, start_year: datetime_start_year(parsed_value), end_year: Date.today.year + 50, minute_step: 5, include_blank: true)
= @form.datetime_field(:value, value: formatted_value_for_datetime_locale, id: @champ.input_id, aria: { describedby: @champ.describedby_id })

View file

@ -2,7 +2,7 @@
- if @champ.block?
%h3.header-subsection= @champ.libelle
- if @champ.description.present?
%p.notice= string_to_html(@champ.description, false)
%p.notice= string_to_html(@champ.description, false, allow_a: true)
- elsif has_label?(@champ)
= render EditableChamp::ChampLabelComponent.new form: @form, champ: @champ, seen_at: @seen_at

View file

@ -1,7 +1,7 @@
= render Dsfr::CalloutComponent.new(title: @champ.libelle, extra_class_names: ['fr-mb-2w', 'fr-callout--blue-cumulus']) do |c|
- c.with_body do
= string_to_html(@champ.description)
= string_to_html(@champ.description, allow_a: true)
- if @champ.collapsible_explanation_enabled? && @champ.collapsible_explanation_text.present?
%div

View file

@ -8,7 +8,7 @@
= @form.label :secondary_value, for: "#{@champ.input_id}-secondary" do
- sanitize((@champ.drop_down_secondary_libelle.presence || "Valeur secondaire dépendant de la première") + (@champ.type_de_champ.mandatory? ? tag.span(' *', class: 'mandatory') : ''))
- if @champ.drop_down_secondary_description.present?
.notice{ id: "#{@champ.describedby_id}-secondary" }= string_to_html(@champ.drop_down_secondary_description)
.notice{ id: "#{@champ.describedby_id}-secondary" }= string_to_html(@champ.drop_down_secondary_description, allow_a: true)
= @form.select :secondary_value,
@champ.secondary_options[@champ.primary_value],
{},

View file

@ -31,6 +31,7 @@ class TypesDeChampEditor::ConditionsErrorsComponent < ApplicationComponent
def humanize(error)
case error
in { type: :not_available }
in { type: :incompatible, stable_id: nil }
t('not_available', scope: '.errors')
in { type: :unmanaged, stable_id: stable_id }
targeted_champ = @upper_tdcs.find { |tdc| tdc.stable_id == stable_id }

View file

@ -10,6 +10,10 @@ class TypesDeChampEditor::EstimatedFillDurationComponent < ApplicationComponent
@is_annotation
end
def render?
@revision.procedure.estimated_duration_visible?
end
def show?
!annotations? && @revision.types_de_champ_public.present?
end

View file

@ -7,6 +7,10 @@ module Administrateurs
id = params[:procedure_id] || params[:id]
@procedure = current_administrateur.procedures.find(id)
Sentry.configure_scope do |scope|
scope.set_tags(procedure: @procedure.id)
end
rescue ActiveRecord::RecordNotFound
flash.alert = 'Démarche inexistante'
redirect_to admin_procedures_path, status: 404

View file

@ -123,10 +123,6 @@ module Administrateurs
instructeurs.each { groupe_instructeur.add(_1) }
flash[:notice] = if procedure.routing_enabled?
GroupeInstructeurMailer
.add_instructeurs(groupe_instructeur, instructeurs, current_administrateur.email)
.deliver_later
t('.assignment',
count: instructeurs.size,
emails: instructeurs.map(&:email).join(', '),
@ -196,7 +192,7 @@ module Administrateurs
end
def import
if procedure.publiee?
if procedure.publiee_or_close?
if !CSV_ACCEPTED_CONTENT_TYPES.include?(group_csv_file.content_type) && !CSV_ACCEPTED_CONTENT_TYPES.include?(marcel_content_type)
flash[:alert] = "Importation impossible : veuillez importer un fichier CSV"

View file

@ -40,7 +40,7 @@ module Administrateurs
@dossier = dossier
@logo_url = procedure.logo_url
@service = procedure.service
@rendered_template = sanitize(mail_template.body_for_dossier(dossier))
@rendered_template = sanitize(mail_template.body_for_dossier(dossier), scrubber: Sanitizers::MailScrubber.new)
@actions = mail_template.actions_for_dossier(dossier)
render(template: 'notification_mailer/send_notification', layout: 'mailers/notifications_layout')

View file

@ -1,6 +1,4 @@
class API::V2::GraphqlController < API::V2::BaseController
include GraphqlOperationLogConcern
def execute
result = API::V2::Schema.execute(query,
variables: variables,
@ -24,7 +22,8 @@ class API::V2::GraphqlController < API::V2::BaseController
super
payload.merge!({
graphql_operation: operation_log(query(fallback: ''), params[:operationName], to_unsafe_hash(params[:variables]))
graphql_query: query(fallback: params[:queryId]),
graphql_variables: to_unsafe_hash(params[:variables]).to_json
})
end

View file

@ -198,7 +198,9 @@ class ApplicationController < ActionController::Base
payload.merge!({
user_agent: request.user_agent,
user_id: current_user&.id,
user_roles: current_user_roles
user_roles: current_user_roles,
client_ip: request.headers['X-Forwarded-For'],
request_id: request.headers['X-Request-ID']
}.compact)
if browser.known?

View file

@ -1,62 +0,0 @@
module GraphqlOperationLogConcern
extend ActiveSupport::Concern
# This method parses GraphQL query and creates a short description of the query. It is useful for logging.
def operation_log(query, operation_name, variables)
return "NoQuery" if query.nil?
operation = parse_graphql_query(query, operation_name)
return "InvalidQuery" if operation.nil?
return "IntrospectionQuery" if operation.name == "IntrospectionQuery"
message = "#{operation.operation_type}: "
message += if operation.name.present?
"#{operation.name} { "
else
"{ "
end
message += operation.selections.map(&:name).join(', ')
message += " } "
message += if variables.present?
variables.flat_map do |(name, value)|
format_graphql_variable(name, value)
end
else
operation.selections.flat_map(&:arguments).flat_map do |argument|
format_graphql_variable(argument.name, argument.value)
end
end.join(', ')
message.strip
end
private
def parse_graphql_query(query, operation_name)
operations = GraphQL.parse(query).children.filter do |node|
node.is_a?(GraphQL::Language::Nodes::OperationDefinition)
end
if operations.size == 1
operations.first
else
operations.find { |node| node.name == operation_name }
end
rescue
nil
end
def format_graphql_variable(name, value)
if value.is_a?(Hash)
value.map do |(name, value)|
format_graphql_variable(name, value)
end
elsif value.is_a?(GraphQL::Language::Nodes::InputObject)
value.arguments.map do |argument|
format_graphql_variable(argument.name, argument.value)
end
else
"#{name}: \"#{value.to_s.truncate(10)}\""
end
end
end

View file

@ -1,17 +1,31 @@
module QueryParamsStoreConcern
extend ActiveSupport::Concern
included do
helper_method :stored_query_params?
end
def store_query_params
# Don't override already stored params, because we could do goings and comings with authentication, and
# lost previously stored params
return if session[:stored_params].present? || request.query_parameters.empty?
return if stored_query_params? || filtered_query_params.empty?
session[:stored_params] = request.query_parameters.to_json
session[:stored_params] = filtered_query_params.to_json
end
def retrieve_and_delete_stored_query_params
return {} if session[:stored_params].blank?
return {} unless stored_query_params?
JSON.parse(session.delete(:stored_params))
end
def stored_query_params?
session[:stored_params].present?
end
private
def filtered_query_params
request.query_parameters.except(:locale, "locale")
end
end

View file

@ -14,17 +14,12 @@ module Instructeurs
end
end
def revive
def remind
avis = Avis.find(params[:id])
if avis.revivable_by?(current_instructeur)
if avis.answer.blank?
AvisMailer.avis_invitation(avis).deliver_later
flash.notice = "Un mail de relance a été envoyé à #{avis.expert.email}"
redirect_back(fallback_location: avis_instructeur_dossier_path(avis.procedure, avis.dossier))
else
flash.alert = "#{avis.expert.email} a déjà donné son avis"
redirect_back(fallback_location: avis_instructeur_dossier_path(avis.procedure, avis.dossier))
end
if avis.remind_by!(current_instructeur)
AvisMailer.avis_invitation(avis).deliver_later
flash.notice = "Un mail de relance a été envoyé à #{avis.expert.email}"
redirect_back(fallback_location: avis_instructeur_dossier_path(avis.procedure, avis.dossier))
end
end
end

View file

@ -14,6 +14,7 @@ module Instructeurs
def batch_operation_params
params.require(:batch_operation)
.permit(:operation, :motivation, :justificatif_motivation, dossier_ids: [])
.merge(dossier_ids: params['batch_operation']['dossier_ids'].join(',').split(',').uniq)
.merge(instructeur: current_instructeur)
end

View file

@ -22,9 +22,6 @@ module Instructeurs
else
groupe_instructeur.add(instructeur)
flash[:notice] = "Linstructeur « #{instructeur_email} » a été affecté au groupe."
GroupeInstructeurMailer
.add_instructeurs(groupe_instructeur, [instructeur], current_user.email)
.deliver_later
end
redirect_to instructeur_groupe_path(procedure, groupe_instructeur)

View file

@ -4,6 +4,7 @@ module Instructeurs
before_action :ensure_not_super_admin!, only: [:download_export]
ITEMS_PER_PAGE = 25
BATCH_SELECTION_LIMIT = 500
def index
@procedures = current_instructeur
@ -77,13 +78,13 @@ module Instructeurs
@has_termine_notifications = notifications[:termines].present?
@not_archived_notifications_dossier_ids = notifications[:en_cours] + notifications[:termines]
filtered_sorted_ids = procedure_presentation.filtered_sorted_ids(dossiers, statut, count: dossiers_count)
@filtered_sorted_ids = procedure_presentation.filtered_sorted_ids(dossiers, statut, count: dossiers_count)
page = params[:page].presence || 1
@dossiers_count = filtered_sorted_ids.size
@dossiers_count = @filtered_sorted_ids.size
@filtered_sorted_paginated_ids = Kaminari
.paginate_array(filtered_sorted_ids)
.paginate_array(@filtered_sorted_ids)
.page(page)
.per(ITEMS_PER_PAGE)

View file

@ -30,6 +30,13 @@ module Manager
redirect_to manager_user_path(user)
end
def resend_reset_password_instructions
user = User.find(params[:id])
user.send_reset_password_instructions
flash[:notice] = "L'email de réinitialisation du mot de passe a été renvoyé."
redirect_to manager_user_path(user)
end
def enable_feature
user = User.find(params[:id])

View file

@ -19,9 +19,7 @@ module Users
end
def destroy
transfer = DossierTransfer
.joins(:dossiers)
.find_by!(id: params[:id], dossiers: { user: current_user })
transfer = DossierTransfer.find_by!(id: params[:id], email: current_user.email)
transfer.destroy_and_nullify
redirect_to dossiers_path

View file

@ -38,6 +38,7 @@ class ProcedureDashboard < Administrate::BaseDashboard
procedure_expires_when_termine_enabled: Field::Boolean,
duree_conservation_dossiers_dans_ds: Field::Number,
max_duree_conservation_dossiers_dans_ds: Field::Number,
estimated_duration_visible: Field::Boolean,
tags: Field::Text
}.freeze
@ -88,7 +89,8 @@ class ProcedureDashboard < Administrate::BaseDashboard
:attestation_template,
:procedure_expires_when_termine_enabled,
:duree_conservation_dossiers_dans_ds,
:max_duree_conservation_dossiers_dans_ds
:max_duree_conservation_dossiers_dans_ds,
:estimated_duration_visible
].freeze
# FORM_ATTRIBUTES
@ -97,7 +99,8 @@ class ProcedureDashboard < Administrate::BaseDashboard
FORM_ATTRIBUTES = [
:procedure_expires_when_termine_enabled,
:duree_conservation_dossiers_dans_ds,
:max_duree_conservation_dossiers_dans_ds
:max_duree_conservation_dossiers_dans_ds,
:estimated_duration_visible
].freeze
# Overwrite this method to customize how procedures are displayed

View file

@ -12,7 +12,7 @@ module Loaders
private
def query(keys)
::Dossier.visible_by_administration.where(id: keys)
::Dossier.visible_by_administration.for_api_v2.where(id: keys)
end
end
end

View file

@ -26,12 +26,6 @@ module Mutations
result[:warnings] = [warning]
end
if groupe_instructeur.procedure.routing_enabled? && instructeurs.present?
GroupeInstructeurMailer
.add_instructeurs(groupe_instructeur, instructeurs, current_administrateur.email)
.deliver_later
end
result
end
end

View file

@ -76,7 +76,7 @@ module Types
end
def groupe_instructeur
Loaders::Record.for(GroupeInstructeur).load(object.groupe_instructeur_id)
Loaders::Record.for(GroupeInstructeur, includes: [:procedure]).load(object.groupe_instructeur_id)
end
def demandeur

View file

@ -1,8 +1,15 @@
module StringToHtmlHelper
def string_to_html(str, wrapper_tag = 'p')
def string_to_html(str, wrapper_tag = 'p', allow_a: false)
return nil if str.blank?
html_formatted = simple_format(str, {}, { wrapper_tag: wrapper_tag })
with_links = Anchored::Linker.auto_link(html_formatted, target: '_blank', rel: 'noopener')
sanitize(with_links, attributes: ['target', 'rel', 'href'])
tags = if allow_a
Rails.configuration.action_view.sanitized_allowed_tags + ['a']
else
Rails.configuration.action_view.sanitized_allowed_tags
end
sanitize(with_links, tags:, attributes: ['target', 'rel', 'href'])
end
end

View file

@ -1,5 +1,5 @@
import { ApplicationController } from './application_controller';
import { disable, enable } from '@utils';
import { disable, enable, show, hide } from '@utils';
import invariant from 'tiny-invariant';
export class BatchOperationController extends ApplicationController {
@ -11,6 +11,7 @@ export class BatchOperationController extends ApplicationController {
onCheckOne() {
this.toggleSubmitButtonWhenNeeded();
deleteSelection();
}
onCheckAll(event: Event) {
@ -18,6 +19,38 @@ export class BatchOperationController extends ApplicationController {
this.inputTargets.forEach((e) => (e.checked = target.checked));
this.toggleSubmitButtonWhenNeeded();
const pagination = document.querySelector('tfoot .pagination');
if (pagination) {
displayNotice(this.inputTargets);
}
}
onSelectMore(event: {
preventDefault: () => void;
target: HTMLInputElement;
}) {
event.preventDefault();
const target = event.target as HTMLInputElement;
const dossierIds = target.getAttribute('data-dossiers');
const hidden_input_multiple_ids = document.querySelector<HTMLInputElement>(
'#input_multiple_ids_batch_operation'
);
if (hidden_input_multiple_ids) {
hidden_input_multiple_ids.value = dossierIds || '';
}
hide(document.querySelector('#not_selected'));
show(document.querySelector('#selected'));
}
onDeleteSelection(event: { preventDefault: () => void }) {
event.preventDefault();
emptyCheckboxes();
deleteSelection();
this.toggleSubmitButtonWhenNeeded();
}
toggleSubmitButtonWhenNeeded() {
@ -63,3 +96,44 @@ function switchButton(button: HTMLButtonElement, flag: boolean) {
button.querySelectorAll('button').forEach((button) => disable(button));
}
}
function displayNotice(inputs: HTMLInputElement[]) {
const checkbox_all = document.querySelector<HTMLInputElement>(
'#checkbox_all_batch_operation'
);
if (checkbox_all) {
if (checkbox_all.checked) {
show(document.querySelector('#js_batch_select_more'));
hide(document.querySelector('#selected'));
show(document.querySelector('#not_selected'));
} else {
hide(document.querySelector('#js_batch_select_more'));
deleteSelection();
}
}
const dynamic_number = document.querySelector('#dynamic_number');
if (dynamic_number) {
dynamic_number.textContent = inputs.length.toString();
}
}
function deleteSelection() {
const hidden_input_multiple_ids = document.querySelector<HTMLInputElement>(
'#input_multiple_ids_batch_operation'
);
if (hidden_input_multiple_ids) {
hidden_input_multiple_ids.value = '';
}
hide(document.querySelector('#js_batch_select_more'));
}
function emptyCheckboxes() {
const inputs = document.querySelectorAll<HTMLInputElement>(
'div[data-controller="batch-operation"] input[type=checkbox]'
);
inputs.forEach((e) => (e.checked = false));
}

View file

@ -154,6 +154,13 @@ export class MenuButtonController extends ApplicationController {
switch (event.key) {
case ' ':
case 'Enter':
if (this.isOpen) {
this.close();
} else {
this.open();
}
stopPropagation = true;
break;
case 'ArrowDown':
case 'Down':
this.open();

View file

@ -83,7 +83,8 @@ export class AutoUpload {
if (error.failureReason == FAILURE_CONNECTIVITY) {
return {
title:
'Le fichier na pas pu être envoyé. Vérifiez votre connexion à Internet, puis ré-essayez.',
'Le fichier na pas pu être envoyé. Vérifiez votre connexion à Internet, puis ré-essayez. Vérifiez aussi que le pare-feu de votre appareil ou votre réseau autorise lenvoi de fichier vers ' +
window.location.host,
retry: true
};
} else if (error.code == ERROR_CODE_READ) {

View file

@ -10,6 +10,7 @@ if (enabled && key) {
Sentry.init({
dsn: key,
environment,
tracesSampleRate: 0.1,
ignoreErrors: [
// Ignore errors generated by a Microsoft crawler.
// See https://forum.sentry.io/t/unhandledrejection-non-error-promise-rejection-captured-with-value/14062

View file

@ -0,0 +1,7 @@
class Cron::PurgeOldEmailEventJob < Cron::CronJob
self.schedule_expression = "every week at 3:00"
def perform
EmailEvent.outdated.destroy_all
end
end

View file

@ -3,7 +3,13 @@ class ExportJob < ApplicationJob
discard_on ActiveRecord::RecordNotFound
before_perform do |job|
Sentry.set_tags(procedure_id: job.arguments.first.procedure.id)
end
def perform(export)
return if export.generated?
export.compute_with_safe_stale_for_purge do
export.compute
end

View file

@ -0,0 +1,17 @@
class Migrations::BackfillDossierRepetitionJob < ApplicationJob
def perform(dossier_ids)
Dossier.where(id: dossier_ids)
.includes(:champs, revision: :types_de_champ)
.find_each do |dossier|
dossier
.revision
.types_de_champ
.filter do |type_de_champ|
type_de_champ.type_champ == 'repetition' && dossier.champs.none? { _1.type_de_champ_id == type_de_champ.id }
end
.each do |type_de_champ|
dossier.champs << type_de_champ.champ.build
end
end
end
end

View file

@ -47,8 +47,12 @@ class BalancerDeliveryMethod
def delivery_method(mail)
return mail[FORCE_DELIVERY_METHOD_HEADER].value.to_sym if force_delivery_method?(mail)
@delivery_methods
compatible_delivery_methods_for(mail)
.flat_map { |delivery_method, weight| [delivery_method] * weight }
.sample(random: self.class.random)
end
def compatible_delivery_methods_for(mail)
@delivery_methods.reject { |delivery_method, _weight| delivery_method.to_s == 'dolist_api' && !Dolist::API.sendable?(mail) }
end
end

View file

@ -1,8 +1,15 @@
require "support/jsv"
class Dolist::API
CONTACT_URL = "https://apiv9.dolist.net/v1/contacts/read?AccountID=%{account_id}"
EMAIL_LOGS_URL = "https://apiv9.dolist.net/v1/statistics/email/sendings/transactional/search?AccountID=%{account_id}"
EMAIL_KEY = 7
DOLIST_WEB_DASHBOARD = "https://campaign.dolist.net/#/%{account_id}/contacts/%{contact_id}/sendings"
EMAIL_MESSAGES_ADRESSES_REPLIES = "https://apiv9.dolist.net/v1/email/messages/addresses/replies?AccountID=%{account_id}"
EMAIL_MESSAGES_ADRESSES_PACKSENDERS = "https://apiv9.dolist.net/v1/email/messages/addresses/packsenders?AccountID=%{account_id}"
EMAIL_SENDING_TRANSACTIONAL = "https://apiv9.dolist.net/v1/email/sendings/transactional?AccountID=%{account_id}"
EMAIL_SENDING_TRANSACTIONAL_ATTACHMENT = "https://apiv9.dolist.net/v1/email/sendings/transactional/attachment?AccountID=%{account_id}"
EMAIL_SENDING_TRANSACTIONAL_SEARCH = "https://apiv9.dolist.net/v1/email/sendings/transactional/search?AccountID=%{account_id}"
class_attribute :limit_remaining, :limit_reset_at
@ -23,12 +30,77 @@ class Dolist::API
sleep (limit_reset_at - Time.zone.now).ceil
end
def sendable?(mail)
return false if mail.to.blank? # recipient are mandatory
return false if mail.bcc.present? # no bcc support
# Mail having attachments are not yet supported in our account
mail.attachments.none? { !_1.inline? }
end
end
def properly_configured?
client_key.present?
end
def send_email(mail)
if mail.attachments.any? { !_1.inline? }
return send_email_with_attachment(mail)
end
body = { "TransactionalSending": prepare_mail_body(mail) }
url = format_url(EMAIL_SENDING_TRANSACTIONAL)
post(url, body.to_json)
end
def send_email_with_attachment(mail)
uri = URI(format_url(EMAIL_SENDING_TRANSACTIONAL_ATTACHMENT))
request = Net::HTTP::Post.new(uri)
default_headers.each do |key, value|
next if key.to_s == "Content-Type"
request[key] = value
end
boundary = "---011000010111000001101001" # any random string not present in the body
request.content_type = "multipart/form-data; boundary=#{boundary}"
body = "--#{boundary}\r\n"
base64_files(mail.attachments).each do |file|
body << "Content-Disposition: form-data; name=\"#{file.field_name}\"; filename=\"#{file.filename}\"\r\n"
body << "Content-Type: #{file.mime_type}\r\n"
body << "\r\n"
body << file.content
body << "\r\n"
end
body << "\r\n--#{boundary}\r\n"
body << "Content-Disposition: form-data; name=\"TransactionalSending\"\r\n"
body << "Content-Type: text/plain; charset=utf-8\r\n"
body << "\r\n"
body << prepare_mail_body(mail).to_jsv
body << "\r\n--#{boundary}--\r\n"
body << "\r\n"
request.body = body
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
response = http.request(request)
if response.body.empty?
fail "Dolist API returned an empty response"
else
JSON.parse(response.body)
end
end
def sent_mails(email_address)
contact_id = fetch_contact_id(email_address)
if contact_id.nil?
@ -44,9 +116,47 @@ class Dolist::API
[]
end
def senders
get format_url(EMAIL_MESSAGES_ADRESSES_PACKSENDERS)
end
def replies
get format_url(EMAIL_MESSAGES_ADRESSES_REPLIES)
end
private
def headers
def format_url(base)
format(base, account_id: account_id)
end
def sender_id
Rails.cache.fetch("dolist_api_sender_id", expires_in: 1.hour) do
senders.dig("ItemList", 0, "Sender", "ID")
end
end
def get(url)
response = Typhoeus.get(url, headers: default_headers).tap do
self.class.save_rate_limit_headers(_1.headers)
end
JSON.parse(response.response_body)
end
def post(url, body)
response = Typhoeus.post(url, body:, headers: default_headers).tap do
self.class.save_rate_limit_headers(_1.headers)
end
if response.response_body.empty?
fail "Empty response from Dolist API"
else
JSON.parse(response.response_body)
end
end
def default_headers
{
"Content-Type": 'application/json',
"Accept": 'application/json',
@ -82,12 +192,32 @@ class Dolist::API
post(url, body)["ItemList"]
end
def post(url, body)
response = Typhoeus.post(url, body:, headers:).tap do
self.class.save_rate_limit_headers(_1.headers)
end
JSON.parse(response.response_body)
def prepare_mail_body(mail)
{
"Type": "TransactionalService",
"Contact": {
"FieldList": [
{
"ID": EMAIL_KEY,
"Value": mail.to.first
}
]
},
"Message": {
"Name": mail['X-Dolist-Message-Name'].value,
"Subject": mail.subject,
"SenderID": sender_id,
"ForceHttp": true,
"Format": "html",
"DisableOpenTracking": true,
"IsTrackingValidated": true
},
"MessageContent": {
"SourceCode": mail_source_code(mail),
"EncodingType": "UTF8",
"EnableTrackingDetection": false
}
}
end
def to_sent_mail(email_address, contact_id, dolist_message)
@ -112,4 +242,24 @@ class Dolist::API
status
end
end
def mail_source_code(mail)
if mail.html_part.nil? && mail.text_part.nil?
mail.decoded
else
mail.html_part.body.decoded
end
end
def base64_files(attachments)
attachments.map do |attachment|
raise ArgumentError, "Dolist API does not support non PDF attachments. Given #{attachment.filename} which has mime_type=#{attachment.mime_type}" unless attachment.mime_type == "application/pdf"
field_name = File.basename(attachment.filename, File.extname(attachment.filename))
attachment_content = attachment.body.decoded
attachment_base64 = Base64.strict_encode64(attachment_content)
Dolist::Base64File.new(field_name:, filename: attachment.filename, mime_type: attachment.mime_type, content: attachment_base64)
end
end
end

View file

@ -0,0 +1,3 @@
module Dolist
Base64File = Struct.new(:field_name, :filename, :mime_type, :content, keyword_init: true)
end

View file

@ -3,4 +3,10 @@
# so it would be shallowed otherwise.
#
# TODO: add a test which verify that the error will permit the job to retry
class MailDeliveryError < Exception; end # rubocop:disable Lint/InheritException
class MailDeliveryError < Exception # rubocop:disable Lint/InheritException
def initialize(original_exception)
super(original_exception.message)
set_backtrace(original_exception.backtrace)
end
end

View file

@ -0,0 +1,12 @@
module Sanitizers
class MailScrubber < Rails::Html::PermitScrubber
def initialize
super
self.tags = Rails.application.config.action_view.sanitized_allowed_tags + ['a']
end
def skip_node?(node)
node.text?
end
end
end

View file

@ -1,17 +1,6 @@
class GroupeInstructeurMailer < ApplicationMailer
layout 'mailers/layout'
def add_instructeurs(group, new_instructeurs, current_instructeur_email)
@new_instructeur_emails = new_instructeurs.map(&:email)
@group = group
@current_instructeur_email = current_instructeur_email
subject = "Ajout dun instructeur dans le groupe \"#{group.label}\""
emails = @group.instructeurs.map(&:email)
mail(bcc: emails, subject: subject)
end
def remove_instructeurs(group, removed_instructeurs, current_instructeur_email)
@removed_instructeur_emails = removed_instructeurs.map(&:email)
@group = group
@ -22,15 +11,4 @@ class GroupeInstructeurMailer < ApplicationMailer
emails = @group.instructeurs.map(&:email)
mail(bcc: emails, subject: subject)
end
def remove_instructeur(group, instructeur, current_instructeur_email)
@email = instructeur.email
@group = group
@current_instructeur_email = current_instructeur_email
subject = "Suppression dun instructeur dans le groupe \"#{group.label}\""
emails = @group.instructeurs.map(&:email)
mail(bcc: emails, subject: subject)
end
end

View file

@ -9,6 +9,7 @@ class NotificationMailer < ApplicationMailer
include ActionView::Helpers::SanitizeHelper
before_action :set_dossier
before_action :set_services_publics_plus, only: :send_notification
after_action :create_commentaire_for_notification
helper ServiceHelper
@ -20,7 +21,7 @@ class NotificationMailer < ApplicationMailer
def send_notification
@service = @dossier.procedure.service
@logo_url = attach_logo(@dossier.procedure)
@rendered_template = sanitize(@body)
@rendered_template = sanitize(@body, scrubber: Sanitizers::MailScrubber.new)
attachments[@attachment[:filename]] = @attachment[:content] if @attachment.present?
I18n.with_locale(@dossier.user_locale) do
@ -50,6 +51,12 @@ class NotificationMailer < ApplicationMailer
private
def set_services_publics_plus
return unless Dossier::TERMINE.include?(params[:state])
@services_publics_plus_url = ENV['SERVICES_PUBLICS_PLUS_URL'].presence
end
def set_dossier
@dossier = params[:dossier]

View file

@ -84,8 +84,8 @@ class Avis < ApplicationRecord
revoked_at.present?
end
def revivable_by?(reviver)
revokable_by?(reviver)
def remindable_by?(reminder)
revokable_by?(reminder)
end
def revokable_by?(revocator)
@ -101,4 +101,9 @@ class Avis < ApplicationRecord
destroy!
end
end
def remind_by!(revocator)
return false if !remindable_by?(revocator) || answer.present?
update!(reminded_at: Time.zone.now)
end
end

View file

@ -95,7 +95,7 @@ class BatchOperation < ApplicationRecord
def track_processed_dossier(success, dossier)
dossiers.delete(dossier)
touch(:run_at) if called_for_first_time?
touch(:finished_at) if called_for_last_time?(dossier)
touch(:finished_at)
if success
dossier_operation(dossier).done!
@ -124,10 +124,6 @@ class BatchOperation < ApplicationRecord
run_at.nil?
end
def called_for_last_time?(dossier_to_ignore)
dossiers.count.zero?
end
def total_count
dossier_operations.size
end
@ -144,6 +140,10 @@ class BatchOperation < ApplicationRecord
dossier_operations.error.present?
end
def finished_at
dossiers.empty? ? super : nil
end
private
def dossier_operation(dossier)

View file

@ -36,10 +36,6 @@ class Champs::DatetimeChamp < Champ
value.present? ? I18n.l(Time.zone.parse(value)) : ""
end
def html_label?
false
end
private
def convert_to_iso8601

View file

@ -33,7 +33,7 @@ class Champs::RepetitionChamp < Champ
transaction do
row_id = ULID.generate
revision.children_of(type_de_champ).each do |type_de_champ|
added_champs << type_de_champ.champ.build(row_id:)
added_champs << type_de_champ.build_champ(row_id:)
end
self.champs << added_champs
end

View file

@ -14,12 +14,12 @@ module DossierRebaseConcern
end
def can_rebase?
revision != procedure.published_revision &&
procedure.published_revision.present? && revision != procedure.published_revision &&
(brouillon? || accepted_en_construction_changes? || accepted_en_instruction_changes?)
end
def pending_changes
revision.compare(procedure.published_revision)
procedure.published_revision.present? ? revision.compare(procedure.published_revision) : []
end
def can_rebase_mandatory_change?(stable_id)
@ -58,25 +58,30 @@ module DossierRebaseConcern
.joins(:type_de_champ)
.group_by(&:stable_id)
.transform_values { Champ.where(id: _1) }
.tap { _1.default = Champ.none }
# add champ
changes_by_op[:add]
.each { add_new_champs_for_revision(target_coordinates_by_stable_id[_1.stable_id]) }
.map { target_coordinates_by_stable_id[_1.stable_id] }
# add parent champs first so we can then add children
.sort_by { _1.child? ? 1 : 0 }
.each { add_new_champs_for_revision(_1) }
# remove champ
changes_by_op[:remove]
.each { champs_by_stable_id[_1.stable_id].destroy_all }
changes_by_op[:remove].each { champs_by_stable_id[_1.stable_id].destroy_all }
# update champ
if brouillon?
changes_by_op[:update]
.each { apply(_1, champs_by_stable_id[_1.stable_id]) }
changes_by_op[:update].each { apply(_1, champs_by_stable_id[_1.stable_id]) }
end
# due to repetition tdc clone on update or erase
# we must reassign tdc to the latest version
champs_by_stable_id
.filter_map { |stable_id, champs| [target_coordinates_by_stable_id[stable_id].type_de_champ_id, champs] if champs.present? }
.each { |type_de_champ_id, champs| champs.update_all(type_de_champ_id:) }
champs_by_stable_id.each do |stable_id, champs|
if target_coordinates_by_stable_id[stable_id].present? && champs.present?
champs.update_all(type_de_champ_id: target_coordinates_by_stable_id[stable_id].type_de_champ_id)
end
end
# update dossier revision
update_column(:revision_id, target_revision.id)
@ -120,13 +125,14 @@ module DossierRebaseConcern
if target_coordinate.child?
# If this type de champ is a child, we create a new champ for each row of the parent
parent_stable_id = target_coordinate.parent.stable_id
champs_repetition = champs
.includes(:champs, :type_de_champ)
.where(type_de_champ: { stable_id: parent_stable_id })
champs_repetition.each do |champ_repetition|
champ_repetition.champs.map(&:row_id).uniq.each do |row_id|
create_champ(target_coordinate, champ_repetition, row_id:)
champs.filter { _1.stable_id == parent_stable_id }.each do |champ_repetition|
if champ_repetition.champs.present?
champ_repetition.champs.map(&:row_id).uniq.each do |row_id|
create_champ(target_coordinate, champ_repetition, row_id:)
end
elsif champ_repetition.mandatory?
create_champ(target_coordinate, champ_repetition, row_id: ULID.generate)
end
end
else
@ -135,10 +141,9 @@ module DossierRebaseConcern
end
def create_champ(target_coordinate, parent, row_id: nil)
params = { revision: target_coordinate.revision, rebased_at: Time.zone.now, row_id: }.compact
champ = target_coordinate
.type_de_champ
.build_champ(params)
.build_champ(rebased_at: Time.zone.now, row_id:)
parent.champs << champ
end

View file

@ -147,7 +147,7 @@ class Dossier < ApplicationRecord
belongs_to :user, optional: true
belongs_to :parent_dossier, class_name: 'Dossier', optional: true
belongs_to :batch_operation, optional: true
has_many :dossier_batch_operations
has_many :dossier_batch_operations, dependent: :destroy
has_many :batch_operations, through: :dossier_batch_operations
has_one :france_connect_information, through: :user
@ -507,6 +507,12 @@ class Dossier < ApplicationRecord
revision.build_champs_private.each do |champ|
champs_private << champ
end
champs_public.filter { _1.repetition? && _1.mandatory? }.each do |champ|
champ.add_row(revision)
end
champs_private.filter(&:repetition?).each do |champ|
champ.add_row(revision)
end
end
def build_default_individual

View file

@ -10,15 +10,21 @@
# to :string not null
# created_at :datetime not null
# updated_at :datetime not null
# message_id :string
#
class EmailEvent < ApplicationRecord
RETENTION_DURATION = 1.month
enum status: {
dispatched: 'dispatched',
dispatch_error: 'dispatch_error'
}
scope :dolist, -> { where(method: 'dolist') }
scope :dolist, -> { dolist_smtp.or(dolist_api) }
scope :dolist_smtp, -> { where(method: 'dolist_smtp') }
scope :dolist_api, -> { where(method: 'dolist_api') }
scope :sendinblue, -> { where(method: 'sendinblue') }
scope :outdated, -> { where("created_at < ?", RETENTION_DURATION.ago) }
class << self
def create_from_message!(message, status:)
@ -30,6 +36,7 @@ class EmailEvent < ApplicationRecord
subject: message.subject || "",
processed_at: message.date,
method: ActionMailer::Base.delivery_methods.key(message.delivery_method.class),
message_id: message.message_id,
status:
)
rescue StandardError => error

View file

@ -163,6 +163,10 @@ class Export < ApplicationRecord
end
end
def procedure
groupe_instructeurs.first.procedure
end
private
def load_snapshot!
@ -204,8 +208,4 @@ class Export < ApplicationRecord
service.to_geo_json
end
end
def procedure
groupe_instructeurs.first.procedure
end
end

View file

@ -18,6 +18,7 @@
# duree_conservation_dossiers_dans_ds :integer
# duree_conservation_etendue_par_ds :boolean default(FALSE)
# encrypted_api_particulier_token :string
# estimated_duration_visible :boolean default(TRUE), not null
# euro_flag :boolean default(FALSE)
# experts_require_administrateur_invitation :boolean default(FALSE)
# for_individual :boolean default(FALSE)
@ -822,9 +823,13 @@ class Procedure < ApplicationRecord
published_at || created_at
end
def publiee_or_close?
publiee? || close?
end
def self.tags
unnest = Arel::Nodes::NamedFunction.new('UNNEST', [self.arel_table[:tags]])
query = self.select(unnest.as('tags')).publiees_ou_closes.distinct.order('tags')
query = self.select(unnest.as('tags')).publiees.distinct.order('tags')
self.connection.query(query.to_sql).flatten
end

View file

@ -34,12 +34,12 @@ class ProcedureRevision < ApplicationRecord
def build_champs_public
# reload: it can be out of sync in test if some tdcs are added wihtout using add_tdc
types_de_champ_public.reload.map { |tdc| tdc.build_champ(revision: self) }
types_de_champ_public.reload.map(&:build_champ)
end
def build_champs_private
# reload: it can be out of sync in test if some tdcs are added wihtout using add_tdc
types_de_champ_private.reload.map { |tdc| tdc.build_champ(revision: self) }
types_de_champ_private.reload.map(&:build_champ)
end
def add_type_de_champ(params)
@ -272,7 +272,7 @@ class ProcedureRevision < ApplicationRecord
.map { [from_h[_1], to_h[_1]] }
.flat_map { |from, to| compare_type_de_champ(from.type_de_champ, to.type_de_champ, from_coordinates, to_coordinates) }
(removed + added + moved + changed).sort_by { _1.op == :remove ? from_sids[_1.stable_id] : to_sids[_1.stable_id] }
(removed + added + moved + changed).sort_by { _1.op == :remove ? from_sids.index(_1.stable_id) : to_sids.index(_1.stable_id) }
end
end

View file

@ -178,12 +178,10 @@ class TypeDeChamp < ApplicationRecord
has_many :champ, inverse_of: :type_de_champ, dependent: :destroy do
def build(params = {})
params.delete(:revision)
super(params.merge(proxy_association.owner.params_for_champ))
end
def create(params = {})
params.delete(:revision)
super(params.merge(proxy_association.owner.params_for_champ))
end
end
@ -231,8 +229,8 @@ class TypeDeChamp < ApplicationRecord
}
end
def build_champ(params)
dynamic_type.build_champ(params)
def build_champ(params = {})
champ.build(params)
end
def check_mandatory

View file

@ -1,11 +1,4 @@
class TypesDeChamp::RepetitionTypeDeChamp < TypesDeChamp::TypeDeChampBase
def build_champ(params)
revision = params[:revision]
champ = super
champ.add_row(revision)
champ
end
def estimated_fill_duration(revision)
estimated_rows_in_repetition = 2.5

View file

@ -51,10 +51,6 @@ class TypesDeChamp::TypeDeChampBase
(words / READ_WORDS_PER_SECOND).round.seconds
end
def build_champ(params)
@type_de_champ.champ.build(params)
end
def filter_to_human(filter_value)
filter_value
end

View file

@ -24,7 +24,7 @@
= f.submit t('.button.add_group'), class: "button primary send"
- csv_max_size = Administrateurs::GroupeInstructeursController::CSV_MAX_SIZE
- if procedure.publiee?
- if procedure.publiee_or_close?
= form_tag import_admin_procedure_groupe_instructeurs_path(procedure), method: :post, multipart: true, class: "mt-4 form" do
= label_tag t('.csv_import.title')
%p.notice

View file

@ -11,6 +11,8 @@
%td= procedure.administrateurs.count
%td= t procedure.aasm_state, scope: 'activerecord.attributes.procedure.aasm_state'
%td= l(procedure.published_at, format: :message_date_without_time)
%td= link_to('Cloner', admin_procedure_clone_path(procedure.id, from_new_from_existing: true), 'data-method' => :put, class: 'fr-btn fr-btn--tertiary fr-btn--sm')
- if show_detail
%tr.procedure{ id: "procedure_detail_#{procedure.id}" }

View file

@ -100,18 +100,18 @@
%p.explication
Si votre démarche sadresse indifféremment à une personne morale ou un particulier, choisissez l'option « Particuliers ». Vous pourrez ajouter un champ SIRET directement dans le formulaire.
%h3.header-subsection Ajouter des tags
%p.explication Les tags sont des mots ou des expressions que vous attribuez aux démarches pour décrire leur contenu et pour les retrouver. Les tags sont partagés avec la communauté, ce qui vous permet de voir les tags attribués aux démarches créées par les autres administrateurs.
= hidden_field_tag 'procedure[tags]', nil
= react_component("ComboMultiple",
options: Procedure.tags,
selected: @procedure.tags,
disabled: [],
label: 'Tags',
group: '.procedure-form__column--form',
name: 'tags',
describedby: 'procedure-tags',
acceptNewValues: true)
%h3.header-subsection Ajouter des tags
%p.explication Les tags sont des mots ou des expressions que vous attribuez aux démarches pour décrire leur contenu et pour les retrouver. Les tags sont partagés avec la communauté, ce qui vous permet de voir les tags attribués aux démarches créées par les autres administrateurs.
= hidden_field_tag 'procedure[tags]', nil
= react_component("ComboMultiple",
options: Procedure.tags,
selected: @procedure.tags,
disabled: [],
label: 'Tags',
group: '.procedure-form__column--form',
name: 'tags',
describedby: 'procedure-tags',
acceptNewValues: true)
%details.procedure-form__options-details
%summary.procedure-form__options-summary

View file

@ -15,7 +15,7 @@
.fr-table.fr-table--bordered
%table#all-demarches
%caption
= "#{@procedures.total_count} démarches"
= "#{@procedures.total_count} #{t('pluralize.procedures', count: @procedures.total_count)}"
%span.hidden.spinner{ 'aria-hidden': 'true', 'data-turbo-target': 'spinner' }
- if @filter.libelle
.selected-query.fr-mb-2w
@ -40,10 +40,11 @@
%tr
%th{ scope: 'col' }
%th{ scope: 'col' } Démarche
%th{ scope: 'col' }
%th{ scope: 'col' }
%th{ scope: 'col' } Administrateurs
%th{ scope: 'col' } Statut
%th{ scope: 'col' } Date
%th{ scope: 'col' } Action
%tbody{ 'data-turbo': 'true' }
- @procedures.each do |procedure|
= render partial: 'detail', locals: { procedure: procedure, show_detail: false }

View file

@ -5,7 +5,7 @@
= link_to "Nouvelle Démarche", new_from_existing_admin_procedures_path, id: 'new-procedure', class: 'fr-btn'
.fr-container
%nav.tabs
%nav.tabs{ role: 'navigation', 'aria-label': t('views.users.dossiers.secondary_menu') }
%ul
= tab_item(t('pluralize.published', count: @procedures_publiees.count), admin_procedures_path(statut: 'publiees'), active: @statut == 'publiees', badge: number_with_html_delimiter(@procedures_publiees_count))
= tab_item('En test', admin_procedures_path(statut: 'brouillons'), active: @statut == 'brouillons', badge: number_with_html_delimiter(@procedures_draft_count))

View file

@ -2,5 +2,6 @@
- if @champ_id
= turbo_stream.show "attachment-multiple-empty-#{@champ_id}"
= turbo_stream.focus_all "#attachment-multiple-empty-#{@champ_id} input"
= turbo_stream.show_all ".attachment-input-#{@attachment.id}"

View file

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

View file

@ -1,3 +1,3 @@
= fields_for @champ.input_name, @champ do |form|
= turbo_stream.append dom_id(@champ, :rows), render(EditableChamp::RepetitionRowComponent.new(form: form, champ: @champ, row: @champs))
- if @champs.present?
= fields_for @champ.input_name, @champ do |form|
= turbo_stream.append dom_id(@champ, :rows), render(EditableChamp::RepetitionRowComponent.new(form: form, champ: @champ, row: @champs))

View file

@ -16,15 +16,18 @@
- drafts = dossiers.merge(Dossier.state_brouillon)
- not_drafts = dossiers.merge(Dossier.state_not_brouillon)
- if dossiers.empty?
= link_to t('views.commencer.show.start_procedure'), url_for_new_dossier(@revision), class: 'fr-btn fr-btn--lg fr-my-2w'
- elsif @prefilled_dossier
- if @prefilled_dossier
%h2.huge-title= t('views.commencer.show.prefilled_draft')
%p
= t('views.commencer.show.prefilled_draft_detail_html', time_ago: time_ago_in_words(@prefilled_dossier.created_at), procedure: @prefilled_dossier.procedure.libelle)
= link_to t('views.commencer.show.continue_file'), brouillon_dossier_path(@prefilled_dossier), class: 'fr-btn fr-btn--lg fr-my-2w'
= link_to t('views.commencer.show.start_new_file'), url_for_new_dossier(@revision), class: 'fr-btn fr-btn--lg fr-btn--secondary fr-my-2w'
%p= t('views.commencer.show.prefilled_draft_detail_html', time_ago: time_ago_in_words(@prefilled_dossier.created_at), procedure: @procedure.libelle)
= link_to t('views.commencer.show.go_to_prefilled_file'), brouillon_dossier_path(@prefilled_dossier), class: 'fr-btn fr-btn--lg fr-my-2w'
- elsif stored_query_params?
%h2.huge-title= t('views.commencer.show.prefilled_draft')
%p= t('views.commencer.show.prefill_dossier_detail_html', procedure: @procedure.libelle)
= link_to t('views.commencer.show.go_to_prefilled_file'), url_for_new_dossier(@revision), class: 'fr-btn fr-btn--lg fr-my-2w'
- elsif dossiers.empty?
= link_to t('views.commencer.show.start_procedure'), url_for_new_dossier(@revision), class: 'fr-btn fr-btn--lg fr-my-2w'
- elsif drafts.size == 1 && not_drafts.empty?
- dossier = drafts.first

View file

@ -5,6 +5,8 @@
= round_button 'Changer mon mot de passe', edit_password_url(@resource, reset_password_token: @token), :primary
%p Cet email invalide les emails similaires que vous avez pu demander précédemment.
%p
Si vous navez pas effectué une telle demande, merci dignorer cet email. Votre mot de passe ne sera pas changé.

View file

@ -10,7 +10,8 @@
- content_for(:title, "Instructeurs de la démarche #{@procedure.libelle}")
= render partial: 'administrateurs/breadcrumbs',
locals: { steps: [link_to(@procedure.libelle, instructeur_procedure_path(@procedure)), 'Instructeurs'] }
locals: { steps: [[@procedure.libelle, instructeur_procedure_path(@procedure)],
['Instructeurs']] }
.container.groupe-instructeur
%h1

View file

@ -99,9 +99,10 @@
%div{ data: batch_operation_component.render? ? { controller: 'batch-operation' } : {} }
.flex.align-center.fr-mt-2w
%span.fr-h6.fr-mb-0.fr-mr-2w
= t('views.instructeurs.dossiers.dossiers_count', count: @dossiers_count)
= page_entries_info @filtered_sorted_paginated_ids
= render batch_operation_component
.fr-table.fr-table--bordered
%table.table.dossiers-table.hoverable
%thead
@ -124,6 +125,8 @@
%tr
%tbody
= render Dossiers::BatchSelectMoreComponent.new(dossiers_count: @dossiers_count, filtered_sorted_ids: @filtered_sorted_ids)
- @projected_dossiers.each do |p|
- path = instructeur_dossier_path(@procedure, p.dossier_id)
%tr{ class: [p.hidden_by_user_at.present? && "file-hidden-by-user"] }

View file

@ -36,10 +36,13 @@
%span.fr-text--xs.fr-text-mention--grey
= t('en_attente', scope: 'views.shared.avis')
|
%span= link_to(t('revive', scope: 'helpers.label'), revive_instructeur_avis_path(avis.procedure, avis), data: { confirm: t('revive', scope: 'helpers.confirmation', email: avis.expert.email) })
%span= link_to(t('remind', scope: 'helpers.label'), remind_instructeur_avis_path(avis.procedure, avis), data: { confirm: t('remind', scope: 'helpers.confirmation', email: avis.expert.email) })
- if avis.revokable_by?(current_instructeur)
|
= link_to(t('revoke', scope: 'helpers.label'), revoquer_instructeur_avis_path(avis.procedure, avis), data: { confirm: t('revoke', scope: 'helpers.confirmation', email: avis.expert.email) }, method: :patch)
- if avis.reminded_at
%span.date.fr-text--xs.fr-text-mention--grey{ class: highlight_if_unseen_class(avis_seen_at, avis.reminded_at) }
= t('relance_effectuee_le', scope: 'views.shared.avis', date: l(avis.reminded_at, format: '%d/%m/%y à %H:%M'))
- if avis.introduction_file.attached?
= render Attachment::ShowComponent.new(attachment: avis.introduction_file.attachment)
.answer-body.mb-3

View file

@ -11,4 +11,4 @@
- else
= t('views.invites.dropdown.invite_to_edit')
- menu.with_form do
= render partial: "invites/form", locals: { dossier: dossier, invites: invites }
= render partial: "invites/form", locals: { dossier: dossier, invites: invites, morphing: morphing }

View file

@ -1,12 +1,14 @@
#invites-form
- if invites.present?
%h4= t('views.invites.form.invite_to_participate')
%ul
- invites.each do |invite|
%li
= invite.email
%small{ 'data-turbo': 'true' }
= link_to t('views.invites.form.withdraw_permission'), invite_path(invite), data: { turbo_method: :delete, turbo_confirm: t('views.invites.form.want_to_withdraw_permission') }
#invite-list{ morphing ? { tabindex: "-1" } : {} }
%h4= t('views.invites.form.invite_to_participate')
%ul
- invites.each do |invite|
%li
= invite.email
%small{ 'data-turbo': 'true' }
= link_to t('views.invites.form.withdraw_permission'), invite_path(invite), data: { turbo_method: :delete, turbo_confirm: t('views.invites.form.want_to_withdraw_permission') }
%p= t('views.invites.form.edit_dossier')
- if dossier.brouillon?
%p= t('views.invites.form.submit_dossier_yourself')

View file

@ -1,2 +1,2 @@
= turbo_stream.replace_all '.invite-user-action', partial: 'invites/dropdown', locals: { dossier: @dossier }
= turbo_stream.focus 'invite_email'
= turbo_stream.replace_all '.invite-user-action', partial: 'invites/dropdown', locals: { dossier: @dossier, morphing: true }
= turbo_stream.focus 'invite-list'

View file

@ -1,6 +1,6 @@
- if @dossier.present?
= turbo_stream.replace_all '.invite-user-action', partial: 'invites/dropdown', locals: { dossier: @dossier }
= turbo_stream.replace_all '.invite-user-action', partial: 'invites/dropdown', locals: { dossier: @dossier, morphing: true }
- if @dossier.invites.empty?
= turbo_stream.focus 'invite_email'
= turbo_stream.focus 'invite-list'
- else
= turbo_stream.focus_all '#invites-form ul a:first-child'
= turbo_stream.focus 'invite_email'

View file

@ -72,7 +72,7 @@
= render partial: 'layouts/search_dossiers_form', locals: { search_endpoint: recherche_dossiers_path }
- has_header = [is_instructeur_context, is_expert_context, is_user_context]
#burger-menu.fr-header__menu.fr-modal{ "aria-labelledby" => "burger_button" }
#burger-menu.fr-header__menu.fr-modal{ "aria-label" => t('layouts.header.label_modal') }
.fr-container
%button#burger_button.fr-btn--close.fr-btn{ "aria-controls" => "burger-menu", :title => t('close_modal', scope: [:layouts, :header]) }= t('close_modal', scope: [:layouts, :header])
.fr-header__menu-links

View file

@ -1,9 +1,9 @@
#search-modal.fr-header__search.fr-modal
#search-modal.fr-header__search.fr-modal{ "aria-label": t('views.users.dossiers.search.search_file') }
.fr-container.fr-container-lg--fluid
%button.fr-btn--close.fr-btn{ "aria-controls" => "search-modal", :title => t('close_modal', scope: [:layouts, :header]) }= t('close_modal', scope: [:layouts, :header])
#search-473.fr-search-bar
#search-473.fr-search-bar.fr-search-bar--lg
= form_tag "#{search_endpoint}", method: :get, :role => "search", class: "flex width-100" do
= label_tag "q", t('views.users.dossiers.search.search_file'), class: 'fr-label'
= text_field_tag "q", "#{@search_terms if @search_terms.present?}", placeholder: t('views.users.dossiers.search.placeholder'), class: "fr-input"
%button.fr-btn{ title: t('views.users.dossiers.search.search_file') }
= t('views.users.dossiers.search.search_file')
= text_field_tag "q", "#{@search_terms if @search_terms.present?}", placeholder: t('views.users.dossiers.search.search_file'), class: "fr-input"
%button.fr-btn
= t('views.users.dossiers.search.simple')

View file

@ -0,0 +1,16 @@
= vertical_margin(20)
%div{ style: 'background-color: #F5F5FE; padding: 20px;' }
%table{ width: "100%", border: "0", cellspacing: "0", cellpadding: "0" }
%tr
%td{ width: "70%", valign: "top", align: "center" }
%p{ style: 'margin: 0' }
Jaide les services publics à saméliorer :
%br
= link_to 'Je donne mon avis avec Services Publics +', @services_publics_plus_url, target: '_blank', rel: 'noopener noreferrer'
%td{ width: "30%" }
= link_to @services_publics_plus_url, target: '_blank', rel: 'noopener noreferrer' do
= image_tag('mailer/logo-services-plus.png', height: 39, width: 121, style: 'display:block; vertical-align: middle', alt: "Services Publics +")
= vertical_margin(20)

View file

@ -108,6 +108,7 @@
<div class="mj-column-per-100 outlook-group-fix" style="vertical-align:top;display:inline-block;direction:ltr;font-size:13px;text-align:left;width:100%;">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<%= yield(:procedure_logo) %>
<tr>
<td style="word-wrap:break-word;font-size:0px;padding:0px 25px 0px 25px;padding-top:0px;padding-bottom:0px;" align="left">
<div class="" style="cursor:auto;color:#55575d;font-family:Helvetica, Arial, sans-serif;font-size:13px;line-height:22px;text-align:left;">

View file

@ -108,6 +108,11 @@ https://www.demarches-simplifiees.fr/users/password/new
Cordialement</pre>
<% end %>
<p><strong>Mot de passe perdu ?</strong> Vous pouvez <%= link_to('renvoyer lemail de réinitialisation', [:resend_reset_password_instructions, namespace, :user], method: :post, class: 'button') %>
<small>Attention au téléscopage: cet email invalide les liens des emails similaires précédents.</small></p>
<p>
<strong>Compte <a href="https://app-smtp.sendinblue.com/block">bloqué</a> chez Sendinblue ?</strong>
Vous pouvez le <%= link_to('débloquer', manager_user_unblock_email_path(@user), method: :put, class: 'button') %>

View file

@ -6,5 +6,8 @@
- if @actions.present?
= render 'notification_mailer/actions', actions: @actions, dossier: @dossier
- if @services_publics_plus_url.present?
= render 'layouts/mailers/services_publics_plus'
- content_for :footer do
= render 'layouts/mailers/service_footer', service: @service, dossier: @dossier

View file

@ -8,7 +8,7 @@
%h1.procedure-title
= procedure.libelle
- if procedure.persisted?
- if procedure.persisted? && procedure.estimated_duration_visible?
%p.procedure-configuration.procedure-configuration--fill-duration
%span.icon.clock
= t('shared.procedure_description.estimated_fill_duration', estimated_minutes: estimated_fill_duration_minutes(procedure))
@ -23,5 +23,5 @@
.procedure-description
.procedure-description-body.read-more-enabled.read-more-collapsed
= h string_to_html(procedure.description)
= h string_to_html(procedure.description, allow_a: true)
= button_tag "Afficher la description complète", class: 'button read-more-button'

View file

@ -12,7 +12,7 @@
= render(partial: 'users/dossiers/procedure_removed_banner', locals: { dossier: dossier })
- elsif current_user.owns?(dossier)
.header-actions
= render partial: 'invites/dropdown', locals: { dossier: dossier }
= render partial: 'invites/dropdown', locals: { dossier: dossier, morphing: false }
- unless dossier.read_only?
= render partial: 'users/dossiers/identity_dropdown', locals: { dossier: dossier }

View file

@ -14,7 +14,7 @@
- else
%h1.page-title= t('views.users.dossiers.index.dossiers')
%nav.tabs
%nav.tabs{ role: 'navigation', 'aria-label': t('views.users.dossiers.secondary_menu') }
%ul
- if @user_dossiers.present?
= tab_item(t('pluralize.en_cours', count: @user_dossiers.count),

View file

@ -16,7 +16,7 @@
= render(partial: 'users/dossiers/procedure_removed_banner', locals: { dossier: dossier })
- elsif current_user.owns?(dossier)
.header-actions
= render partial: 'invites/dropdown', locals: { dossier: dossier }
= render partial: 'invites/dropdown', locals: { dossier: dossier, morphing: false }
- if dossier.can_be_updated_by_user? && !current_page?(modifier_dossier_path(dossier))
= link_to t('views.users.dossiers.show.header.edit_dossier'), modifier_dossier_path(dossier), class: 'fr-btn fr-btn-sm',
title: { label: t('views.users.dossiers.show.header.edit_dossier_title') }

View file

@ -41,7 +41,7 @@ module TPS
config.assets.precompile += ['.woff']
default_allowed_tags = ActionView::Base.sanitized_allowed_tags
config.action_view.sanitized_allowed_tags = default_allowed_tags + ['u'] - ['img']
config.action_view.sanitized_allowed_tags = default_allowed_tags + ['u'] - ['img', 'a']
# ActionDispatch's IP spoofing detection is quite limited, and often rejects
# legitimate requests from misconfigured proxies (such as mobile telcos).

View file

@ -163,3 +163,6 @@ DOLIST_LOGIN_URL="https://clientpreprod.dolist.net"
SUPPORT_WEBHOOK_URL=""
# rappel web de sendinblue
SIB_WEBHOOK_URL=""
# ServicesPublics+ tracking url shown to user when dossier is terminated.
SERVICES_PUBLICS_PLUS_URL=""

View file

@ -52,7 +52,7 @@ Rails.application.configure do
# Use the lowest log level to ensure availability of diagnostic information
# when problems arise.
config.log_level = :debug
config.log_level = ENV["DS_ENV"] == "staging" ? :debug : :info
# Prepend all log lines with the following tags.
# config.log_tags = [ :subdomain, :uuid ]
@ -80,12 +80,13 @@ Rails.application.configure do
else
sendinblue_weigth = ENV.fetch('SENDINBLUE_BALANCING_VALUE') { 0 }.to_i
dolist_weigth = ENV.fetch('DOLIST_BALANCING_VALUE') { 0 }.to_i
dolist_api_weight = ENV.fetch('DOLIST_API_BALANCING_VALUE') { 0 }.to_i
ActionMailer::Base.add_delivery_method :balancer, BalancerDeliveryMethod
config.action_mailer.balancer_settings = {
sendinblue: sendinblue_weigth,
dolist: dolist_weigth,
mailjet: 100 - (sendinblue_weigth + dolist_weigth)
dolist_smtp: dolist_weigth,
dolist_api: dolist_api_weight,
mailjet: 100 - (sendinblue_weigth + dolist_weigth + dolist_api_weight)
}
config.action_mailer.delivery_method = :balancer
end

View file

@ -109,6 +109,7 @@ ignore_unused:
- 'time.formats.default'
- 'instructeurs.dossiers.filterable_state.*'
- 'views.prefill_descriptions.edit.possible_values.*'
- 'helpers.page_entries_info.*'
# - '{devise,kaminari,will_paginate}.*'
# - 'simple_form.{yes,no}'
# - 'simple_form.{placeholders,hints,labels}.*'

View file

@ -5,17 +5,29 @@ ActiveSupport.on_load(:action_mailer) do
mail.from(ENV['DOLIST_NO_REPLY_EMAIL'])
mail.sender(ENV['DOLIST_NO_REPLY_EMAIL'])
mail['X-ACCOUNT-ID'] = Rails.application.secrets.dolist[:account_id]
mail['X-Dolist-Sending-Type'] = 'TransactionalService' # send even if the target is not active
super(mail)
end
end
class ApiSender
def initialize(mail); end
def deliver!(mail)
response = Dolist::API.new.send_email(mail)
if response&.dig("Result")
mail.message_id = response.dig("Result")
else
fail "DoList delivery error. Body: #{response}"
end
end
end
end
ActionMailer::Base.add_delivery_method :dolist, Dolist::SMTP
ActionMailer::Base.dolist_settings = {
ActionMailer::Base.add_delivery_method :dolist_smtp, Dolist::SMTP
ActionMailer::Base.dolist_smtp_settings = {
user_name: Rails.application.secrets.dolist[:username],
password: Rails.application.secrets.dolist[:password],
address: 'smtp.dolist.net',
@ -23,4 +35,6 @@ ActiveSupport.on_load(:action_mailer) do
authentication: 'plain',
enable_starttls_auto: true
}
ActionMailer::Base.add_delivery_method :dolist_api, Dolist::ApiSender
end

View file

@ -14,10 +14,13 @@ Rails.application.configure do
user_email: event.payload[:user_email],
user_roles: event.payload[:user_roles],
user_agent: event.payload[:user_agent],
graphql_operation: event.payload[:graphql_operation],
graphql_query: event.payload[:graphql_query],
graphql_variables: event.payload[:graphql_variables],
browser: event.payload[:browser],
browser_version: event.payload[:browser_version],
platform: event.payload[:platform]
platform: event.payload[:platform],
client_ip: event.payload[:client_ip],
request_id: event.payload[:request_id]
}.compact
end

View file

@ -68,6 +68,7 @@ en:
are_you_new: First time on %{app_name}?
my_account: My account
header:
label_modal: "Burger menu"
close_modal: 'Close'
back: "Back"
back_title: "Revenir sur le site de mon administration"
@ -92,11 +93,13 @@ en:
existing_dossiers: You already have files for this procedure
show_dossiers: View my current files
prefilled_draft: "You have a prefilled file"
prefilled_draft_detail_html: "You prefilled a file for the \"%{procedure}\" procedure <strong>%{time_ago} ago</strong>"
prefilled_draft_detail_html: "You are ready to continue a prefilled file for the \"%{procedure}\" procedure, started <strong>%{time_ago} ago</strong>."
prefill_dossier_detail_html: "You are ready to continue a prefilled file for the \"%{procedure}\" procedure."
already_draft: "You already started to fill a file"
already_draft_detail_html: "You started to fill a file for the \"%{procedure}\" procedure <strong>%{time_ago} ago</strong>"
already_not_draft: "You already submitted a file"
already_not_draft_detail_html: "You submitted a file for the \"%{procedure}\" procedure <strong>%{time_ago} ago</strong>."
go_to_prefilled_file: 'Continue to fill my prefilled file'
continue_file: "Continue to fill my file"
start_new_file: "Start a new file"
show_my_submitted_file: 'Show my submitted file'
@ -238,10 +241,6 @@ en:
batch_operation:
enabled: "Add this file to the selection for the bulk operation"
disabled: "Impossible to add this file to the selection because it is already in a bulk operation"
dossiers_count:
zero: 0 file
one: 1 file
other: "%{count} files"
personalize: Personalize the table
show_deleted_dossiers: Show deleted files
follow_file: Follow-up the file
@ -318,8 +317,9 @@ en:
demande:
edit_dossier: "Edit file"
search:
placeholder: Search a file
search_file: Search
search_file: Search a file
simple: Search
secondary_menu: Secondary menu
index:
dossiers: "Files"
dossiers_list:

View file

@ -59,6 +59,7 @@ fr:
are_you_new: Vous êtes nouveau sur %{app_name} ?
my_account: Mon compte
header:
label_modal: "Menu en-tête de page"
close_modal: 'Fermer'
back: "Revenir en arrière"
back_title: "Revenir sur le site de mon administration"
@ -83,11 +84,13 @@ fr:
existing_dossiers: Vous avez déjà des dossiers pour cette démarche
show_dossiers: Voir mes dossiers en cours
prefilled_draft: "Vous avez un dossier prérempli"
prefilled_draft_detail_html: "Il y a <strong>%{time_ago}</strong>, vous avez prérempli un dossier sur la démarche « %{procedure} »."
prefilled_draft_detail_html: "Vous êtes prêt·e à poursuivre un dossier prérempli sur la démarche « %{procedure} », commencé il y a <strong>%{time_ago}</strong>."
prefill_dossier_detail_html: "Vous êtes prêt·e à poursuivre un dossier prérempli sur la démarche « %{procedure} »."
already_draft: "Vous avez déjà commencé à remplir un dossier"
already_draft_detail_html: "Il y a <strong>%{time_ago}</strong>, vous avez commencé à remplir un dossier sur la démarche « %{procedure} »."
already_not_draft: "Vous avez déjà déposé un dossier"
already_not_draft_detail_html: "Il y a <strong>%{time_ago}</strong>, vous avez déposé un dossier sur la démarche « %{procedure} »."
go_to_prefilled_file: 'Poursuivre mon dossier prérempli'
continue_file: 'Continuer à remplir mon dossier'
start_new_file: 'Commencer un nouveau dossier'
show_my_submitted_file: 'Voir mon dossier déposé'
@ -233,10 +236,6 @@ fr:
batch_operation:
enabled: "Ajouter ce dossier à la selection pour un traitement de masse"
disabled: "Impossible d'ajouter ce dossier à la selection car il est déjà dans un traitement de masse"
dossiers_count:
zero: 0 dossier
one: 1 dossier
other: "%{count} dossiers"
show_deleted_dossiers: Afficher les dossiers supprimés
personalize: Personnaliser le tableau
download: Télécharger un dossier
@ -314,8 +313,9 @@ fr:
demande:
edit_dossier: "Modifier le dossier"
search:
placeholder: Rechercher un dossier
search_file: Rechercher
search_file: Rechercher un dossier
simple: Rechercher
secondary_menu: Menu secondaire
index:
dossiers: "Dossiers"
dossiers_list:
@ -545,6 +545,9 @@ fr:
deleted:
one: Supprimée
other: Supprimées
procedures:
one: Démarche
other: Démarches
users:
dossiers:
test_procedure: "Ce dossier est déposé sur une démarche en test. Toute modification de la démarche par ladministrateur (ajout dun champ, publication de la démarche...) entraînera sa suppression."

View file

@ -0,0 +1,20 @@
en:
helpers:
page_entries_info:
entry:
zero: "file"
one: "file"
other: "files"
more_pages:
display_entries: "%{first} - %{last} <span class='fr-text--sm'>in %{total} %{entry_name}</span>"
one_page:
display_entries:
one: "<b>%{count}</b> %{entry_name}"
other: "%{count} <span class='fr-text--sm'>in %{count} %{entry_name}</span>"
views:
pagination:
first: "&laquo; First"
last: Last &raquo;
next: Next &rsaquo;
previous: "&lsaquo; Prev"
truncate: "&hellip;"

View file

@ -0,0 +1,20 @@
fr:
helpers:
page_entries_info:
entry:
zero: "dossier"
one: "dossier"
other: "dossiers"
more_pages:
display_entries: "%{first} - %{last} <span class='fr-text--sm'>sur %{total} %{entry_name}</span>"
one_page:
display_entries:
one: "<b>%{count}</b> %{entry_name}"
other: "%{count} <span class='fr-text--sm'>sur %{count} %{entry_name}</span>"
views:
pagination:
first: "&laquo; Premier"
last: Dernier &raquo;
next: Suivant &rsaquo;
previous: "&lsaquo; Précédent"
truncate: "&hellip;"

View file

@ -13,9 +13,9 @@ fr:
one: Inviter aussi lexpert sur le dossier lié n° %{ids}
other: Inviter aussi lexpert sur les dossiers liés n° %{ids}
revoke: Révoquer la demande davis
revive: Relancer lexpert
remind: Relancer lexpert
hint:
confidentiel: "Cet avis nest pas affiché avec les autres experts consultés"
confirmation:
revoke: "Souhaitez-vous révoquer la demande davis à %{email} ?"
revive: "Souhaitez-vous relancer %{email} ?"
remind: "Souhaitez-vous relancer %{email} ?"

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