Merge branch 'main' of github.com:betagouv/demarches-simplifiees.fr into user-dashboard/add-alert-WIP-last-dossier

This commit is contained in:
Lisa Durand 2023-04-25 13:01:40 +02:00
commit 26ff82dbe4
456 changed files with 7265 additions and 1491 deletions

View file

@ -14,6 +14,7 @@ AllCops:
TargetRubyVersion: 3.1
DisabledByDefault: true
SuggestExtensions: false
NewCops: enable
Include:
- "app/**/*.prawn"
Exclude:
@ -683,9 +684,18 @@ Performance/UriDefaultParser:
Rails:
Enabled: true
Rails/ActionControllerFlashBeforeRender:
Enabled: true
Rails/ActionControllerTestCase:
Enabled: true
Rails/ActionFilter:
Enabled: true
Rails/ActionOrder:
Enabled: false
Rails/ActiveRecordAliases:
Enabled: true
@ -695,18 +705,24 @@ Rails/ActiveRecordCallbacksOrder:
Rails/ActiveSupportAliases:
Enabled: true
Rails/ActiveSupportOnLoad:
Enabled: true
Rails/AddColumnIndex:
Enabled: true
Rails/AfterCommitOverride:
Enabled: true
Rails/ApplicationController:
Enabled: false
Rails/ApplicationJob:
Enabled: true
Rails/ApplicationRecord:
Enabled: true
Rails/ApplicationController:
Enabled: false
Rails/AttributeDefaultBlockValue:
Enabled: true
@ -716,6 +732,9 @@ Rails/Blank:
Rails/BulkChangeTable:
Enabled: false
Rails/CompactBlank:
Enabled: true
Rails/CreateTableWithTimestamps:
Enabled: true
Exclude:
@ -734,10 +753,28 @@ Rails/Delegate:
Rails/DelegateAllowBlank:
Enabled: true
Rails/DeprecatedActiveModelErrorsMethods:
Enabled: false # re-enable in rails 7
Rails/DotSeparatedKeys:
Enabled: true
Rails/DuplicateAssociation:
Enabled: true
Rails/DuplicateScope:
Enabled: true
Rails/DurationArithmetic:
Enabled: true
Rails/DynamicFindBy:
Enabled: true
Exclude:
- "spec/system/**/*.rb"
- spec/system/**/*.rb
Rails/EagerEvaluationLogMessage:
Enabled: true
Rails/EnumUniqueness:
Enabled: true
@ -748,6 +785,9 @@ Rails/EnvironmentComparison:
Rails/Exit:
Enabled: true
Rails/ExpandedDateRange:
Enabled: true
Rails/FilePath:
Enabled: false
@ -760,6 +800,9 @@ Rails/FindById:
Rails/FindEach:
Enabled: true
Rails/FreezeTime:
Enabled: true
Rails/HasAndBelongsToMany:
Enabled: false
@ -772,6 +815,18 @@ Rails/HttpPositionalArguments:
Rails/HttpStatus:
Enabled: false
Rails/I18nLazyLookup:
Enabled: true
Rails/I18nLocaleAssignment:
Enabled: true
Rails/I18nLocaleTexts:
Enabled: false
Rails/IgnoredColumnsAssignment:
Enabled: true
Rails/Inquiry:
Enabled: true
@ -787,6 +842,9 @@ Rails/MailerName:
Rails/MatchRoute:
Enabled: true
Rails/MigrationClassName:
Enabled: true
Rails/NegateInclude:
Enabled: false
@ -823,9 +881,15 @@ Rails/ReadWriteAttribute:
Rails/RedundantAllowNil:
Enabled: false
Rails/RedundantPresenceValidationOnBelongsTo:
Enabled: true
Rails/RedundantReceiverInWithOptions:
Enabled: true
Rails/RedundantTravelBack:
Enabled: true
Rails/RelativeDateConstant:
Enabled: true
@ -838,9 +902,21 @@ Rails/RenderPlainText:
Rails/RequestReferer:
Enabled: true
Rails/ResponseParsedBody:
Enabled: true
Rails/ReversibleMigration:
Enabled: false
Rails/RootJoinChain:
Enabled: true
Rails/RootPathnameMethods:
Enabled: true
Rails/RootPublicPath:
Enabled: true
Rails/SaveBang:
Enabled: false
@ -856,9 +932,37 @@ Rails/SkipsModelValidations:
Rails/SquishedSQLHeredocs:
Enabled: true
Rails/StripHeredoc:
Enabled: true
Rails/ThreeStateBooleanColumn:
Enabled: true
Exclude:
- db/migrate/2019*
- db/migrate/2020*
- db/migrate/2021*
- db/migrate/2022*
- db/migrate/202301*
- db/migrate/202303*
Rails/TimeZone:
EnforcedStyle: strict
Rails/TimeZoneAssignment:
Enabled: true
Rails/ToFormattedS:
Enabled: true
Rails/ToSWithArgument:
Enabled: true
Rails/TopLevelHashWithIndifferentAccess:
Enabled: true
Rails/TransactionExitStatement:
Enabled: true
Rails/UniqBeforePluck:
Enabled: true
@ -868,6 +972,9 @@ Rails/UniqueValidationWithoutIndex:
Rails/UnknownEnv:
Enabled: false
Rails/UnusedIgnoredColumns:
Enabled: true
Rails/Validation:
Enabled: true
@ -877,9 +984,15 @@ Rails/WhereEquals:
Rails/WhereExists:
Enabled: true
Rails/WhereMissing:
Enabled: true
Rails/WhereNot:
Enabled: true
Rails/WhereNotWithMultipleConditions:
Enabled: true
RSpec/Focus:
Enabled: true
@ -1039,6 +1152,12 @@ Style/EvenOdd:
Style/ExpandPathArguments:
Enabled: true
Style/FileRead:
Enabled: true
Style/FileWrite:
Enabled: true
Style/For:
Enabled: true

View file

@ -458,7 +458,7 @@ GEM
net-protocol
netrc (0.11.0)
nio4r (2.5.8)
nokogiri (1.14.2)
nokogiri (1.14.3)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
open4 (1.3.4)
@ -473,9 +473,9 @@ GEM
validate_url
webfinger (>= 1.0.1)
orm_adapter (0.5.0)
parallel (1.22.1)
parallel (1.23.0)
parsby (1.1.1)
parser (3.1.2.1)
parser (3.2.2.0)
ast (~> 2.4.1)
pdf-core (0.9.0)
pg (1.2.3)
@ -577,7 +577,7 @@ GEM
rb-inotify (0.10.1)
ffi (~> 1.0)
redcarpet (3.6.0)
regexp_parser (2.7.0)
regexp_parser (2.8.0)
request_store (1.5.0)
rack (>= 1.4)
responders (3.0.1)
@ -621,31 +621,33 @@ GEM
rspec-support (3.10.2)
rspec_junit_formatter (0.4.1)
rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.36.0)
rubocop (1.50.2)
json (~> 2.3)
parallel (~> 1.10)
parser (>= 3.1.2.1)
parser (>= 3.2.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.20.1, < 2.0)
rubocop-ast (>= 1.28.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.21.0)
parser (>= 3.1.1.0)
rubocop-performance (1.9.2)
rubocop (>= 0.90.0, < 2.0)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.28.0)
parser (>= 3.2.1.0)
rubocop-capybara (2.17.1)
rubocop (~> 1.41)
rubocop-performance (1.17.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-rails (2.9.1)
rubocop-rails (2.19.1)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 0.90.0, < 2.0)
rubocop-rspec (2.4.0)
rubocop (~> 1.0)
rubocop-ast (>= 1.1.0)
rubocop (>= 1.33.0, < 2.0)
rubocop-rspec (2.20.0)
rubocop (~> 1.33)
rubocop-capybara (~> 2.17)
ruby-graphviz (1.2.5)
rexml
ruby-progressbar (1.11.0)
ruby-progressbar (1.13.0)
ruby-vips (2.1.4)
ffi (~> 1.12)
ruby2_keywords (0.0.5)
@ -756,7 +758,7 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.7.7)
unicode-display_width (2.3.0)
unicode-display_width (2.4.2)
uri_template (0.7.0)
validate_email (0.1.6)
activemodel (>= 3.0)

View file

@ -1,17 +1,7 @@
@import "colors";
@import "constants";
.conditionnel {
.condition-error {
background: $background-red;
margin: ($default-spacer) (-$default-spacer);
ul {
padding: $default-spacer;
}
}
form.form > .conditionnel {
.condition-table {
table-layout: fixed;

View file

@ -0,0 +1,12 @@
@import "colors";
@import "constants";
.errors-summary {
background: $background-red;
margin: ($default-spacer) (-$default-spacer);
ul {
padding: $default-spacer;
}
}

View file

@ -438,14 +438,6 @@
}
}
.header-section {
display: inline-block;
color: $blue-france-500;
font-size: 30px;
margin-bottom: 3 * $default-padding;
border-bottom: 3px solid $blue-france-500;
}
.header-subsection {
font-size: 22px;
color: $blue-france-500;

View file

@ -1,6 +1,11 @@
.groupe-instructeur {
.actions {
width: 200px;
.setup {
text-align: center;
width: 100px;
}
.actions {
text-align: center;
width: 250px;
}
}

View file

@ -5,6 +5,11 @@
.types-de-champ-editor {
> .types-de-champ-block {
padding-bottom: 50px;
.types-de-champ-errors {
background-color: $background-red;
padding: $default-padding;
}
}
.type-de-champ {

View file

@ -22,7 +22,6 @@
.dossiers-table {
margin-top: $default-spacer;
margin-bottom: 3 * $default-spacer;
overflow: visible; // remove DSFR hidden overflow because of dropdown
}
.procedure-actions {

View file

@ -0,0 +1,72 @@
@import "colors";
@import "constants";
#routing-rules {
.routing-rules-table {
table-layout: fixed;
.far-left {
width: 200px;
}
.if {
width: 30px;
}
.target {
width: 350px;
select {
width: 100%;
}
}
.operator {
text-align: center;
width: 100px;
}
.value {
width: 200px;
select {
width: 100%;
}
}
}
th {
text-align: left;
padding: $default-spacer;
}
td {
padding: $default-spacer;
input,
select {
margin-bottom: 0;
}
input[type=text] {
display: inline-block;
margin-bottom: 0;
}
input.alert,
select.alert {
border-color: $dark-red;
}
}
.form.defaut-groupe {
padding: $default-spacer;
label {
width: 600px;
font-size: 16px;
}
}
}

View file

@ -1,11 +1,63 @@
.counter-start-header-section {
counter-reset: headerSectionCounter;
}
counter-reset: h1 h2 h3 h4 h5 h6;
.header-section {
counter-increment: headerSectionCounter;
.reset-h1 {
counter-reset: h2;
}
&.header-section-counter::before {
content: counter(headerSectionCounter) ". ";
.reset-h2 {
counter-reset: h3;
}
.reset-h3 {
counter-reset: h4;
}
.reset-h4 {
counter-reset: h5;
}
.reset-h5 {
counter-reset: h6;
}
.header-section.fr-h1::before {
counter-increment: h1;
content: counter(h1) ". ";
}
.header-section.fr-h2::before {
counter-increment: h2;
content: counter(h1) "."counter(h2) ". ";
}
.header-section.fr-h3::before {
counter-increment: h3;
content: counter(h1) "."counter(h2) "." counter(h3) ". ";
}
.header-section.fr-h4::before {
counter-increment: h4;
content: counter(h1) "."counter(h2) "." counter(h3) "." counter(h4) ". ";
}
.header-section.fr-h5::before {
counter-increment: h5;
content: counter(h1) "."counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) ". ";
}
.header-section.fr-h6::before {
counter-increment: h6;
content: counter(h1) "."counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) "." counter(h6) ". ";
}
.repetition {
counter-reset: repetition;
.block-id::after {
counter-increment: repetition;
content: counter(repetition);
}
}
}

View file

@ -1,8 +1,8 @@
%label.notice{ for: code_postal_input_id }= t('.postal_code')
= @form.text_field :code_postal, required: @champ.required?, id: code_postal_input_id, class: "width-33-desktop width-100-mobile small-margin"
- if @champ.code_postal_with_fallback?
- if @champ.code_postal?
- if commune_options.empty?
.fr-error-text.mb-4= t('.not_found', postal_code: @champ.code_postal_with_fallback)
.fr-error-text.mb-4= t('.not_found', postal_code: @champ.code_postal)
- elsif commune_options.size <= 3
%fieldset.radios
- commune_options.each.with_index do |(option, value), index|

View file

@ -8,12 +8,12 @@
- if !@champ.mandatory?
%label.blank-radio
= @form.radio_button :value, ''
= @form.radio_button :value, '', checked: @champ.value.blank? && !@champ.other?
Non renseigné
- if @champ.drop_down_other?
%label
= @form.radio_button :value, Champs::DropDownListChamp::OTHER, checked: @champ.other_value_present?
= @form.radio_button :value, Champs::DropDownListChamp::OTHER, checked: @champ.other?
Autre
- else
= @form.select :value, @champ.options_without_empty_value_when_mandatory(@champ.options), { selected: @champ.selected }, required: @champ.required?, id: @champ.input_id, aria: { describedby: @champ.describedby_id }

View file

@ -1,2 +1,5 @@
class EditableChamp::DropDownOtherInputComponent < EditableChamp::EditableChampBaseComponent
def render?
@champ.other?
end
end

View file

@ -1,4 +1,4 @@
.drop_down_other{ class: @champ.other_value_present? ? '' : 'hidden' }
.drop_down_other
.notice
%label{ for: dom_id(@champ, :value_other) } Veuillez saisir votre autre choix
= @form.text_field :value_other, maxlength: 200, size: nil, id: dom_id(@champ, :value_other), disabled: !@champ.other_value_present?
= @form.text_field :value_other, maxlength: 200, size: nil, id: dom_id(@champ, :value_other)

View file

@ -6,7 +6,11 @@ class EditableChamp::EditableChampComponent < ApplicationComponent
private
def has_label?(champ)
types_without_label = [TypeDeChamp.type_champs.fetch(:header_section), TypeDeChamp.type_champs.fetch(:explication)]
types_without_label = [
TypeDeChamp.type_champs.fetch(:header_section),
TypeDeChamp.type_champs.fetch(:explication),
TypeDeChamp.type_champs.fetch(:repetition)
]
!types_without_label.include?(@champ.type_champ)
end
@ -30,11 +34,6 @@ class EditableChamp::EditableChampComponent < ApplicationComponent
# This is an editable champ. Lets find what controllers it might need.
controllers = ['autosave']
# This is a dropdown champ. Activate special behaviours it might have.
if @champ.simple_drop_down_list? || @champ.linked_drop_down_list?
controllers << 'champ-dropdown'
end
controllers.join(' ')
end
end

View file

@ -1,10 +1,5 @@
.editable-champ{ html_options }
- if @champ.block?
%h3.header-subsection= @champ.libelle
- if @champ.description.present?
.notice= render SimpleFormatComponent.new(@champ.description, allow_a: true)
- elsif has_label?(@champ)
- if has_label?(@champ)
= render EditableChamp::ChampLabelComponent.new form: @form, champ: @champ, seen_at: @seen_at
- if @champ.titre_identite?
%p.notice= t('.titre_identite_notice')

View file

@ -1,2 +1,25 @@
class EditableChamp::HeaderSectionComponent < EditableChamp::EditableChampBaseComponent
class EditableChamp::HeaderSectionComponent < ApplicationComponent
def initialize(form: nil, champ:, seen_at: nil)
@champ = champ
end
def level
@champ.level
end
def libelle
@champ.libelle
end
def header_section_classnames
class_names(
"fr-h#{level}": true,
'header-section': @champ.dossier.auto_numbering_section_headers_for?(@champ),
'hidden': !@champ.visible?
)
end
def tag_for_depth
"h#{level + 1}"
end
end

View file

@ -1,2 +1,2 @@
%h2.header-section{ class: @champ.dossier.auto_numbering_section_headers_for?(@champ) ? "header-section-counter" : nil }
= @champ.libelle
= tag.send(tag_for_depth, class: header_section_classnames, id: @champ.input_group_id) do
= libelle

View file

@ -1,2 +1,15 @@
class EditableChamp::LinkedDropDownListComponent < EditableChamp::EditableChampBaseComponent
private
def secondary_label
secondary_label_text + secondary_label_mandatory
end
def secondary_label_text
@champ.drop_down_secondary_libelle.presence || "Valeur secondaire dépendant de la première"
end
def secondary_label_mandatory
@champ.mandatory? ? tag.span(' *', class: 'mandatory') : ''
end
end

View file

@ -1,16 +1,12 @@
- if @champ.options?
= @form.select :primary_value,
@champ.primary_options,
{},
{ data: { secondary_options: @champ.secondary_options }, required: @champ.required?, id: @champ.input_id, aria: { describedby: @champ.describedby_id } }
.secondary{ class: @champ.has_secondary_options_for_primary? ? '' : 'hidden' }
= @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.mandatory? ? tag.span(' *', class: 'mandatory') : ''))
- if @champ.drop_down_secondary_description.present?
.notice{ id: "#{@champ.describedby_id}-secondary" }= render SimpleFormatComponent.new(@champ.drop_down_secondary_description, allow_a: true)
= @form.select :secondary_value,
@champ.secondary_options[@champ.primary_value],
{},
{ data: { secondary: true }, disabled: !@champ.has_secondary_options_for_primary?, required: @champ.required?, id: "#{@champ.input_id}-secondary", aria: { describedby: "#{@champ.describedby_id}-secondary" } }
= @form.hidden_field :secondary_value, value: '', disabled: @champ.has_secondary_options_for_primary?
= @form.select :primary_value, @champ.primary_options, {}, required: @champ.required?, id: @champ.input_id, aria: { describedby: @champ.describedby_id }
- if @champ.has_secondary_options_for_primary?
.secondary
= @form.label :secondary_value, for: "#{@champ.input_id}-secondary" do
- sanitize(secondary_label)
- if @champ.drop_down_secondary_description.present?
.notice{ id: "#{@champ.describedby_id}-secondary" }
= render SimpleFormatComponent.new(@champ.drop_down_secondary_description, allow_a: true)
= @form.select :secondary_value, @champ.secondary_options[@champ.primary_value], {}, required: @champ.required?, id: "#{@champ.input_id}-secondary", aria: { describedby: "#{@champ.describedby_id}-secondary" }
- else
= @form.hidden_field :secondary_value, value: ''

View file

@ -1,2 +1,9 @@
class EditableChamp::RepetitionComponent < EditableChamp::EditableChampBaseComponent
def legend_params
@champ.description.present? ? { describedby: dom_id(@champ, :repetition) } : {}
end
def notice_params
@champ.description.present? ? { id: dom_id(@champ, :repetition) } : {}
end
end

View file

@ -1,7 +1,13 @@
.repetition{ id: dom_id(@champ, :rows) }
- @champ.rows.each do |champs|
= render EditableChamp::RepetitionRowComponent.new(form: @form, champ: @champ, row: champs, seen_at: @seen_at)
%fieldset
%legend.header-subsection{ legend_params }= @champ.libelle
- if @champ.description.present?
.notice{ notice_params }= render SimpleFormatComponent.new(@champ.description, allow_a: true)
.actions{ 'data-turbo': 'true' }
= render NestedForms::OwnedButtonComponent.new(formaction: champs_repetition_path(@champ.id), http_method: :create, opt: { class: "fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-circle-line fr-mb-3w", title: t(".add_title", libelle: @champ.libelle), id: dom_id(@champ, :create_repetition)}) do
= t(".add", libelle: @champ.libelle)
.repetition{ id: dom_id(@champ, :rows) }
- @champ.rows.each do |champs|
= render EditableChamp::RepetitionRowComponent.new(form: @form, champ: @champ, row: champs, seen_at: @seen_at)
.actions{ 'data-turbo': 'true' }
= render NestedForms::OwnedButtonComponent.new(formaction: champs_repetition_path(@champ.id), http_method: :create, opt: { class: "fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-circle-line fr-mb-3w", title: t(".add_title", libelle: @champ.libelle), id: dom_id(@champ, :create_repetition)}) do
= t(".add", libelle: @champ.libelle)

View file

@ -1,8 +1,11 @@
- row_id = "safe-row-selector-#{@row.first.row_id}"
.row{ id: row_id }
- @row.each do |champ|
= fields_for champ.input_name, champ do |form|
= render EditableChamp::EditableChampComponent.new form: form, champ: champ, seen_at: @seen_at
- if @row.size > 1
%fieldset
%legend.block-id= "#{@champ.libelle} "
= render EditableChamp::SectionComponent.new(champs: @row)
- else
= render EditableChamp::SectionComponent.new(champs: @row)
.flex.row-reverse{ 'data-turbo': 'true' }
= render NestedForms::OwnedButtonComponent.new(formaction: champs_repetition_path(@champ.id, row_id: @row.first.row_id), http_method: :delete, opt: { class: "fr-btn fr-btn--sm fr-btn--tertiary fr-text-action-high--red-marianne", title: t(".delete_title", row_number: @champ.rows.find_index(@row))}) do

View file

@ -0,0 +1,64 @@
class EditableChamp::SectionComponent < ApplicationComponent
include ApplicationHelper
include TreeableConcern
def initialize(nodes: nil, champs: nil)
if (nodes.nil?)
nodes = to_tree(champs:)
end
@nodes = to_fieldset(nodes:)
end
def render_within_fieldset?
first_champ_is_an_header_section? && any_champ_fillable?
end
def header_section
return @nodes.first if @nodes.first.is_a?(Champs::HeaderSectionChamp)
end
def splitted_tail
tail.map { split_section_champ(_1) }
end
def tail
return @nodes if !first_champ_is_an_header_section?
_, *rest_of_champ = @nodes
rest_of_champ
end
def tag_for_depth
"h#{header_section.level + 1}"
end
# if two headers follows each others [h1, [h2, c]]
# the first one must not be contained in fieldset
# so we make the tree not fillable
def fillable?
false
end
def split_section_champ(node)
case node
when EditableChamp::SectionComponent
[node, nil]
else
[nil, node]
end
end
private
def to_fieldset(nodes:)
nodes.map { _1.is_a?(Array) ? EditableChamp::SectionComponent.new(nodes: _1) : _1 }
end
def first_champ_is_an_header_section?
header_section.present?
end
def any_champ_fillable?
tail.any? { _1&.fillable? }
end
end

View file

@ -0,0 +1,19 @@
- if render_within_fieldset?
= tag.fieldset(class: "reset-#{tag_for_depth}") do
= tag.legend do
= render EditableChamp::HeaderSectionComponent.new(champ: header_section)
- splitted_tail.each do |section, champ|
- if section.present?
= render section
- else
= fields_for champ.input_name, champ do |form|
= render EditableChamp::EditableChampComponent.new form: ,champ:
- else
- if header_section
= render EditableChamp::HeaderSectionComponent.new(champ: header_section)
- splitted_tail.each do |section, champ|
- if section.present?
= render section
- else
= fields_for champ.input_name, champ do |form|
= render EditableChamp::EditableChampComponent.new form: ,champ:

View file

@ -4,18 +4,27 @@ class Procedure::RoutingRulesComponent < ApplicationComponent
def initialize(revision:, groupe_instructeurs:)
@revision = revision
@groupe_instructeurs = groupe_instructeurs
@procedure_id = revision.procedure_id
end
def rows
@groupe_instructeurs.active.map do |gi|
[gi.routing_rule&.left, gi.routing_rule&.right, gi]
if gi.routing_rule.present?
[gi.routing_rule.left, gi.routing_rule.right, gi]
else
[empty, empty, gi]
end
end
end
def can_route?
available_targets_for_select.present?
end
def targeted_champ_tag(targeted_champ, row_index)
select_tag(
'targeted_champ',
options_for_select(targeted_champs_for_select, selected: targeted_champ&.stable_id),
options_for_select(targeted_champs_for_select, selected: targeted_champ.to_json),
id: input_id_for('targeted_champ', row_index)
)
end
@ -23,7 +32,10 @@ class Procedure::RoutingRulesComponent < ApplicationComponent
def value_tag(targeted_champ, value, row_index)
select_tag(
'value',
options_for_select(values_for_select(targeted_champ), selected: value),
options_for_select(
values_for_select(targeted_champ, row_index),
selected: value.to_json
),
id: input_id_for('value', row_index)
)
end
@ -48,16 +60,20 @@ class Procedure::RoutingRulesComponent < ApplicationComponent
def available_targets_for_select
@revision.types_de_champ_public
.filter { |tdc| [:drop_down_list].include?(tdc.type_champ.to_sym) }
.map { |tdc| [tdc.libelle, tdc.stable_id] }
.map { |tdc| [tdc.libelle, champ_value(tdc.stable_id).to_json] }
end
def available_values_for_select(targeted_champ)
return [] if targeted_champ.nil?
targeted_champ.options(@revision.types_de_champ_public)
return [] if targeted_champ.is_a?(Logic::Empty)
targeted_champ
.options(@revision.types_de_champ_public)
.map { |tdc| [tdc.first, constant(tdc.first).to_json] }
end
def values_for_select(targeted_champ)
empty_target_for_select + available_values_for_select(targeted_champ)
def values_for_select(targeted_champ, row_index)
(empty_target_for_select + available_values_for_select(targeted_champ))
# add id to help morph render selected option
.map { |(libelle, json)| [libelle, json, { id: "#{row_index}-option-#{libelle}" }] }
end
def input_id_for(name, row_index)

View file

@ -1,8 +1,11 @@
---
fr:
select: Sélectionner
apply_routing_rules: Appliquer des règles de routage
routing_rules_notice: |
Ajoutez des règles de routage à partir de champs créés dans le formulaire.
Si les mêmes règles de routage sont appliquées à plusieurs groupes,
les dossiers seront routés vers le premier groupe affiché dans la liste.
apply_routing_rules: Règles de routage
routing_rules_notice_html: |
<p>Ajoutez des règles de routage à partir de champs « choix simple » créés dans le <a href="%{path}">formulaire</a>.</p>
<p>Les dossiers seront routées vers le premier groupe affiché dont la règle correspond.</p>
routing_rules_warning_html: |
<p>Pour appliquer des règles de routage, votre formulaire doit comporter
au moins un champ « choix simple ».</p>
<p>Ajoutez ce champ dans la page <a href="%{path}">« Configuration des champs »</a>.</p>

View file

@ -1,26 +1,40 @@
.card#routing-rules
.card-title
= t('.apply_routing_rules')
%p.notice
= t('.routing_rules_notice')
.conditionnel.mt-2.width-100
%table.condition-table.mt-2.width-100
%thead
%tr
%th.far-left
%th.target Champ cible du routage
%th.operator Opérateur
%th.value Valeur
%th.delete-column
.conditionnel.mt-2.width-100
- rows.each.with_index do |(targeted_champ, value, groupe_instructeur), row_index|
= form_tag admin_procedure_routing_rules_path, method: :post, class: "form width-100 gi-#{groupe_instructeur.id}" do
%table.condition-table.mt-2.width-100
%tbody
%tr{ data: { controller: 'autosave' } }
%td.far-left Router vers « #{groupe_instructeur.label} » si
%td.target= targeted_champ_tag(targeted_champ, row_index)
%td.operator Est égal à
%td.value= value_tag(targeted_champ, value, row_index)
%td.delete-column
= hidden_groupe_instructeur_tag(groupe_instructeur.id)
%h2.card-title= t('.apply_routing_rules')
- if can_route?
.notice
= t('.routing_rules_notice_html', path: champs_admin_procedure_path(@procedure_id))
.mt-2.width-100
%table.routing-rules-table.mt-2.width-100
%thead
%tr
%th.far-left Router vers
%th.if
%th.target Champ cible du routage
%th.operator
%th.value Valeur
.mt-2.width-100
- rows.each.with_index do |(targeted_champ, value, groupe_instructeur), row_index|
= form_tag admin_procedure_routing_rules_path(@procedure_id),
method: :post,
class: "form width-100 gi-#{groupe_instructeur.id}",
data: { controller: 'autosave' } do
= hidden_groupe_instructeur_tag(groupe_instructeur.id)
%table.routing-rules-table.condition-table.mt-2.width-100
%tbody
%tr
%td.far-left= groupe_instructeur.label
%td.if si
%td.target= targeted_champ_tag(targeted_champ, row_index)
%td.operator est égal à
%td.value= value_tag(targeted_champ, value, row_index)
= form_tag admin_procedure_update_defaut_groupe_instructeur_path(@procedure_id),
class: 'form flex align-baseline defaut-groupe',
data: { controller: 'autosave' } do
= label_tag :defaut_groupe_instructeur_id, 'Et si aucune règle ne correspond, router vers :'
= select_tag :defaut_groupe_instructeur_id,
options_for_select(@groupe_instructeurs.pluck(:label, :id), selected: @revision.procedure.defaut_groupe_instructeur.id),
class: 'width-100'
- else
.notice= t('.routing_rules_warning_html', path: champs_admin_procedure_path(@procedure_id))

View file

@ -21,12 +21,22 @@ class SimpleFormatComponent < ApplicationComponent
no_images: true
}
SIMPLE_URL_REGEX = %r{https?://\S+}
EMAIL_IN_TEXT_REGEX = Regexp.new(Devise.email_regexp.source.gsub(/\\A|\\z/, '\b'))
def initialize(text, allow_a: true, class_names_map: {})
@allow_a = allow_a
@text = (text || "").gsub(/\R/, "\n\n") # force double \n otherwise a single one won't split paragraph
.split("\n\n") #
.map(&:lstrip) # this block prevent redcarpet to consider " text" as block code by lstriping
.join("\n\n") #
@allow_a = allow_a
.join("\n\n")
.gsub(EMAIL_IN_TEXT_REGEX) { _1.gsub('_', '\\_') } # Workaround for redcarpet bug on autolink email having _. Cf tests
if !@allow_a
@text = @text.gsub(SIMPLE_URL_REGEX) { _1.gsub('_', '\\_') } # Escape underscores in URLs
end
@renderer = Redcarpet::Markdown.new(
Redcarpet::BareRenderer.new(class_names_map:),
REDCARPET_EXTENSIONS.merge(autolink: @allow_a)

View file

@ -1,10 +1,11 @@
class TypesDeChampEditor::ChampComponent < ApplicationComponent
attr_reader :coordinate, :upper_coordinates
def initialize(coordinate:, upper_coordinates:, focused: false)
def initialize(coordinate:, upper_coordinates:, focused: false, errors: '')
@coordinate = coordinate
@focused = focused
@upper_coordinates = upper_coordinates
@errors = errors
end
private

View file

@ -1,10 +1,21 @@
%li.type-de-champ.flex.column.justify-start{ html_options }
.flex.justify-start.section.head.hr
.flex.justify-between.section.head.hr
.handle.small.icon-only.icon.move-handle{ title: "Déplacer le champ vers le haut ou vers le bas" }
.flex.justify-start.delete
= button_to type_de_champ_path, class: 'button small icon-only danger', method: :delete, form: { data: { turbo_confirm: 'Êtes vous sûr de vouloir supprimer ce champ ?' } } do
.icon.delete
%span.sr-only Supprimer
- if coordinate.used_by_routing_rules?
.flex.align-center
%span
utilisé pour
= link_to('le routage', admin_procedure_groupe_instructeurs_path(revision.procedure_id, anchor: 'routing-rules'))
- else
.flex.justify-start.delete
= button_to type_de_champ_path, class: 'button small icon-only danger', method: :delete, form: { data: { turbo_confirm: 'Êtes vous sûr de vouloir supprimer ce champ ?' } } do
.icon.delete
%span.sr-only Supprimer
- if @errors.present?
.types-de-champ-errors
= @errors
.flex.justify-start.section.ml-1
= form_for(type_de_champ, form_options) do |form|
@ -19,7 +30,7 @@
%span.sr-only Déplacer le champ vers le bas
.cell.flex.justify-start.column.flex-grow
= form.label :type_champ, "Type de champ", for: dom_id(type_de_champ, :type_champ)
= form.select :type_champ, grouped_options_for_select(types_of_type_de_champ, type_de_champ.type_champ), {}, class: 'small-margin small inline width-100', id: dom_id(type_de_champ, :type_champ)
= form.select :type_champ, grouped_options_for_select(types_of_type_de_champ, type_de_champ.type_champ), {}, class: 'small-margin small inline width-100', id: dom_id(type_de_champ, :type_champ), disabled: coordinate.used_by_routing_rules?
.flex.column.justify-start.flex-grow
.cell
.flex.align-center
@ -37,6 +48,11 @@
.cell.mt-1
= form.label :description, "Description du champ (optionnel)", for: dom_id(type_de_champ, :description)
= form.text_area :description, class: 'small-margin small width-100', rows: 3, id: dom_id(type_de_champ, :description)
- if type_de_champ.header_section?
.cell.mt-1
= render TypesDeChampEditor::HeaderSectionComponent.new(form: form, tdc: type_de_champ, upper_tdcs: @upper_coordinates.map(&:type_de_champ))
.flex.justify-start.mt-1
- if type_de_champ.drop_down_list?

View file

@ -1,4 +1,4 @@
.flex.justify-start.section{ id: dom_id(@tdc.stable_self, :conditions) }
.flex.justify-start.section{ id: dom_id(@tdc.stable_self, :condition) }
= form_tag admin_procedure_condition_path(@procedure_id, @tdc.stable_id), method: :patch, class: 'form width-100' do
.conditionnel.mt-2.width-100
.flex

View file

@ -1,2 +1,2 @@
.condition-error
.errors-summary
= errors

View file

@ -3,16 +3,32 @@ class TypesDeChampEditor::ErrorsSummary < ApplicationComponent
@revision = revision
end
def invalid?
@revision.invalid?
end
def condition_errors?
@revision.errors.include?(:condition)
end
def header_section_errors?
@revision.errors.include?(:header_section)
end
private
def error_message
@revision.errors
def errors_for(key)
@revision.errors.filter { _1.attribute == key }
end
def error_message_for(key)
errors_for(key)
.map { |error| error.options[:type_de_champ] }
.map { |tdc| tag.li(tdc_anchor(tdc)) }
.map { |tdc| tag.li(tdc_anchor(tdc, key)) }
.then { |lis| tag.ul(lis.reduce(&:+)) }
end
def tdc_anchor(tdc)
tag.a(tdc.libelle, href: champs_admin_procedure_path(@revision.procedure_id, anchor: dom_id(tdc.stable_self, :conditions)), data: { turbo: false })
def tdc_anchor(tdc, key)
tag.a(tdc.libelle, href: champs_admin_procedure_path(@revision.procedure_id, anchor: dom_id(tdc.stable_self, key)), data: { turbo: false })
end
end

View file

@ -1,5 +1,9 @@
fr:
fix:
one: 'Corrigez le champ suivant :'
other: 'Corrigez les champs suivants :'
fix_conditional:
one: 'La logique conditionnelle du champ suivant est invalide, veuillez la corriger :'
other: 'La logique conditionnelle des champs suivants sont invalides, veuillez les corriger :'
fix_header_section:
one: 'Le titre de section suivant est invalide, veuillez le corriger :'
other: 'Les titres de section suivants sont invalides, veuillez les corriger :'

View file

@ -1,7 +1,12 @@
#errors-summary
- if @revision.invalid?
- if invalid?
.card.warning
.card-title La logique conditionnelle est devenue invalide
.card-title Le formulaire contient des erreurs
%p.mb-2= t('.fix', count: @revision.errors.count)
= error_message
- if condition_errors?
%p.mb-2= t('.fix_conditional', count: errors_for(:condition).size)
= error_message_for(:condition)
- if header_section_errors?
%p.mb-2= t('.fix_header_section', count: errors_for(:header_section).size)
= error_message_for(:header_section)

View file

@ -0,0 +1,42 @@
class TypesDeChampEditor::HeaderSectionComponent < ApplicationComponent
MAX_LEVEL = 3
def initialize(form:, tdc:, upper_tdcs:)
@form = form
@tdc = tdc
@upper_tdcs = upper_tdcs
end
def header_section_options_for_select
closest_level = @tdc.previous_section_level(@upper_tdcs)
next_level = [closest_level + 1, MAX_LEVEL].min
available_levels = (1..next_level).map(&method(:option_for_level))
disabled_levels = errors? ? (next_level + 1..MAX_LEVEL).map(&method(:option_for_level)) : []
options_for_select(
available_levels + disabled_levels,
disabled: disabled_levels.map(&:second),
selected: @tdc.header_section_level_value
)
end
def errors
@tdc.check_coherent_header_level(@upper_tdcs)
end
private
def option_for_level(level)
[translate(".select_option", level: level), level]
end
def errors?
!errors.empty?
end
def to_html_list(messages)
messages
.map { |message| tag.li(message) }
.then { |lis| tag.ul(lis.reduce(&:+)) }
end
end

View file

@ -0,0 +1,3 @@
---
en:
select_option: "Header section %{level}"

View file

@ -0,0 +1,3 @@
---
fr:
select_option: "Titre de niveau %{level}"

View file

@ -0,0 +1,5 @@
%div{ id: dom_id(@tdc.stable_self, :header_section) }
- if errors?
.errors-summary= to_html_list(errors)
= @form.label :header_section_level, "Niveau du titre", for: dom_id(@tdc, :header_section_level)
= @form.select :header_section_level, header_section_options_for_select, {}, id: dom_id(@tdc, :header_section_level)

View file

@ -48,7 +48,10 @@ module Administrateurs
def update
@groupe_instructeur = groupe_instructeur
if @groupe_instructeur.update(groupe_instructeur_params)
if closed_params? && @groupe_instructeur.id == procedure.defaut_groupe_instructeur.id
redirect_to admin_procedure_groupe_instructeur_path(procedure, groupe_instructeur),
alert: "Il est impossible de désactiver le groupe dinstructeurs par défaut."
elsif @groupe_instructeur.update(groupe_instructeur_params)
redirect_to admin_procedure_groupe_instructeur_path(procedure, groupe_instructeur),
notice: "Le nom est à présent « #{@groupe_instructeur.label} »."
else
@ -68,6 +71,8 @@ module Administrateurs
flash[:alert] = "Impossible de supprimer un groupe avec des dossiers. Il faut le réaffecter avant"
elsif procedure.groupe_instructeurs.one?
flash[:alert] = "Suppression impossible : il doit y avoir au moins un groupe instructeur sur chaque procédure"
elsif @groupe_instructeur.id == procedure.defaut_groupe_instructeur.id
flash[:alert] = "Suppression impossible : le groupe « #{@groupe_instructeur.label} » est le groupe par défaut."
else
@groupe_instructeur.destroy!
if procedure.groupe_instructeurs.active.one?
@ -258,6 +263,10 @@ module Administrateurs
private
def closed_params?
groupe_instructeur_params[:closed] == "1"
end
def procedure
current_administrateur
.procedures

View file

@ -219,7 +219,7 @@ module Administrateurs
def restore
procedure = current_administrateur.procedures.with_discarded.discarded.find(params[:id])
procedure.restore_procedure(current_administrateur)
procedure.restore(current_administrateur)
flash.notice = t('administrateurs.index.restored', procedure_id: procedure.id)
redirect_to admin_procedures_path
end

View file

@ -5,34 +5,42 @@ module Administrateurs
before_action :retrieve_procedure
def update
left = champ_value(targeted_champ)
right = parsed_value
left = targeted_champ
@procedure.groupe_instructeurs.find(groupe_instructeur_id).update!(routing_rule: ds_eq(left, right))
right = targeted_champ_changed? ? empty : value
groupe_instructeur.update!(routing_rule: ds_eq(left, right))
end
def update_defaut_groupe_instructeur
new_defaut = @procedure.groupe_instructeurs.find(defaut_groupe_instructeur_id)
@procedure.update!(defaut_groupe_instructeur: new_defaut)
end
private
def targeted_champ_changed?
targeted_champ != groupe_instructeur.routing_rule&.left
end
def targeted_champ
routing_params[:targeted_champ].to_i
Logic.from_json(params[:targeted_champ])
end
def value
routing_params[:value]
Logic.from_json(params[:value])
end
def parsed_value
term = Logic.from_json(value) rescue nil
term.presence || constant(value)
def groupe_instructeur
@groupe_instructeur ||= @procedure.groupe_instructeurs.find(groupe_instructeur_id)
end
def groupe_instructeur_id
routing_params[:groupe_instructeur_id]
params[:groupe_instructeur_id]
end
def routing_params
params.permit(:targeted_champ, :value, :groupe_instructeur_id)
def defaut_groupe_instructeur_id
params[:defaut_groupe_instructeur_id]
end
end
end

View file

@ -1,7 +1,7 @@
module Administrateurs
class TypesDeChampController < AdministrateurController
before_action :retrieve_procedure
after_action :reset_procedure, only: [:create, :update, :destroy]
after_action :reset_procedure, only: [:create, :update, :destroy, :piece_justificative_template]
def create
type_de_champ = draft.add_type_de_champ(type_de_champ_create_params)
@ -20,7 +20,12 @@ module Administrateurs
def update
type_de_champ = draft.find_and_ensure_exclusive_use(params[:stable_id])
if type_de_champ.update(type_de_champ_update_params)
if type_de_champ.revision_type_de_champ.used_by_routing_rules? && changing_of_type?(type_de_champ)
coordinate = draft.coordinate_for(type_de_champ)
errors = "« #{type_de_champ.libelle} » est utilisé pour le routage, vous ne pouvez pas modifier son type."
@morphed = [champ_component_from(coordinate, focused: false, errors:)]
flash.alert = errors
elsif type_de_champ.update(type_de_champ_update_params)
@coordinate = draft.coordinate_for(type_de_champ)
@morphed = champ_components_starting_at(@coordinate)
@ -67,17 +72,29 @@ module Administrateurs
end
def destroy
@coordinate = draft.remove_type_de_champ(params[:stable_id])
flash.notice = "Formulaire enregistré"
coordinate, type_de_champ = draft.coordinate_and_tdc(params[:stable_id])
if @coordinate.present?
@destroyed = @coordinate
@morphed = champ_components_starting_at(@coordinate)
if coordinate.used_by_routing_rules?
errors = "« #{type_de_champ.libelle} » est utilisé pour le routage, vous ne pouvez pas le supprimer."
@morphed = [champ_component_from(coordinate, focused: false, errors:)]
flash.alert = errors
else
@coordinate = draft.remove_type_de_champ(params[:stable_id])
flash.notice = "Formulaire enregistré"
if @coordinate.present?
@destroyed = @coordinate
@morphed = champ_components_starting_at(@coordinate)
end
end
end
private
def changing_of_type?(type_de_champ)
type_de_champ_update_params['type_champ'].present? && (type_de_champ_update_params['type_champ'] != type_de_champ.type_champ)
end
def champ_components_starting_at(coordinate, offset = 0)
coordinate
.siblings_starting_at(offset)
@ -85,11 +102,12 @@ module Administrateurs
.map { |c| champ_component_from(c) }
end
def champ_component_from(coordinate, focused: false)
def champ_component_from(coordinate, focused: false, errors: '')
TypesDeChampEditor::ChampComponent.new(
coordinate: coordinate,
coordinate:,
upper_coordinates: coordinate.upper_siblings,
focused: focused
focused: focused,
errors:
)
end
@ -101,27 +119,28 @@ module Administrateurs
def type_de_champ_update_params
params.required(:type_de_champ).permit(:type_champ,
:libelle,
:description,
:mandatory,
:drop_down_list_value,
:drop_down_other,
:drop_down_secondary_libelle,
:drop_down_secondary_description,
:collapsible_explanation_enabled,
:collapsible_explanation_text,
editable_options: [
:cadastres,
:unesco,
:arretes_protection,
:conservatoire_littoral,
:reserves_chasse_faune_sauvage,
:reserves_biologiques,
:reserves_naturelles,
:natura_2000,
:zones_humides,
:znieff
])
:libelle,
:description,
:mandatory,
:drop_down_list_value,
:drop_down_other,
:drop_down_secondary_libelle,
:drop_down_secondary_description,
:collapsible_explanation_enabled,
:collapsible_explanation_text,
:header_section_level,
editable_options: [
:cadastres,
:unesco,
:arretes_protection,
:conservatoire_littoral,
:reserves_chasse_faune_sauvage,
:reserves_biologiques,
:reserves_naturelles,
:natura_2000,
:zones_humides,
:znieff
])
end
def draft

View file

@ -14,7 +14,7 @@ class API::Public::V1::StatsController < API::Public::V1::BaseController
private
def retrieve_procedure
@procedure = Procedure.publiees_ou_brouillons.opendata.find_by(id: params[:id])
@procedure = Procedure.opendata.find_by(id: params[:id])
render_not_found("procedure", params[:id]) if @procedure.blank?
end
end

View file

@ -14,6 +14,13 @@ class API::V2::DossiersController < API::V2::BaseController
private
def append_info_to_payload(payload)
super
if dossier.present?
payload.merge!(ds_dossier_id: dossier.id.to_s, ds_procedure_id: dossier.procedure.id.to_s)
end
end
def ensure_dossier_present
if dossier.blank?
head :unauthorized

View file

@ -5,7 +5,9 @@ class API::V2::GraphqlController < API::V2::BaseController
render json: result
rescue GraphQL::ParseError, JSON::ParserError => exception
handle_parse_error(exception)
handle_parse_error(exception, :graphql_parse_failed)
rescue ArgumentError => exception
handle_parse_error(exception, :bad_request)
rescue => exception
if Rails.env.production?
handle_error_in_production(exception)
@ -33,7 +35,12 @@ class API::V2::GraphqlController < API::V2::BaseController
rescue ActionDispatch::Http::Parameters::ParseError => exception
render json: {
errors: [
{ message: exception.cause.message }
{
message: exception.cause.message,
extensions: {
code: :bad_request
}
}
],
data: nil
}, status: 400
@ -75,10 +82,13 @@ class API::V2::GraphqlController < API::V2::BaseController
end
end
def handle_parse_error(exception)
def handle_parse_error(exception, code)
render json: {
errors: [
{ message: exception.message }
{
message: exception.message,
extensions: { code: }
}
],
data: nil
}, status: 400
@ -90,22 +100,32 @@ class API::V2::GraphqlController < API::V2::BaseController
render json: {
errors: [
{ message: exception.message, backtrace: exception.backtrace }
{
message: exception.message,
extensions: {
code: :internal_server_error,
backtrace: exception.backtrace
}
}
],
data: nil
}, status: 500
end
def handle_error_in_production(exception)
extra = { exception_id: SecureRandom.uuid }
Sentry.capture_exception(exception, extra:)
exception_id = SecureRandom.uuid
Sentry.with_scope do |scope|
scope.set_tags(exception_id:)
Sentry.capture_exception(exception)
end
render json: {
errors: [
{
message: "Internal Server Error",
extensions: {
exception: { id: extra[:exception_id] }
code: :internal_server_error,
exception_id:
}
}
],

View file

@ -326,7 +326,7 @@ module Instructeurs
def champs_private_params
champs_params = params.require(:dossier).permit(champs_private_attributes: [
:id, :primary_value, :secondary_value, :piece_justificative_file, :value_other, :external_id, :numero_allocataire, :code_postal, :code_departement, :value, value: [],
:id, :value, :primary_value, :secondary_value, :piece_justificative_file, :value_other, :external_id, :numero_allocataire, :code_postal, :code_departement, value: [],
champs_attributes: [:id, :_destroy, :value, :primary_value, :secondary_value, :piece_justificative_file, :value_other, :external_id, :numero_allocataire, :code_postal, :code_departement, value: []]
])
champs_params[:champs_private_all_attributes] = champs_params.delete(:champs_private_attributes) || {}

View file

@ -89,6 +89,7 @@ module Instructeurs
.per(ITEMS_PER_PAGE)
@projected_dossiers = DossierProjectionService.project(@filtered_sorted_paginated_ids, procedure_presentation.displayed_fields)
@disable_checkbox_all = @projected_dossiers.all? { _1.batch_operation_id.present? }
assign_exports
@batch_operations = BatchOperation.joins(:groupe_instructeurs)

View file

@ -1,7 +1,7 @@
class PingController < ApplicationController
def index
Rails.logger.silence do
status_code = if File.file?(Rails.root.join("maintenance"))
status_code = if Rails.root.join("maintenance").file?
# See https://cbonte.github.io/haproxy-dconv/2.0/configuration.html#4.2-http-check%20disable-on-404
:not_found
elsif (ActiveRecord::Base.connection.execute('select 1 as test;').first['test'] == 1)

View file

@ -83,7 +83,12 @@ class RootController < ApplicationController
end
end
@dossier.association(:revision).target = @dossier.procedure.build_draft_revision
draft_revision = @dossier.procedure.build_draft_revision(types_de_champ_public: all_champs.map(&:type_de_champ))
@dossier.association(:revision).target = draft_revision
@dossier.champs_public.map(&:type_de_champ).map do |tdc|
tdc.association(:revision_type_de_champ).target = tdc.build_revision_type_de_champ(revision: draft_revision)
tdc.association(:revision).target = draft_revision
end
end
def suivi

View file

@ -73,7 +73,7 @@ class SupportController < ApplicationController
[params[:tags], params[:type]].flatten.compact
.map { |tag| tag.split(',') }
.flatten
.reject(&:blank?).uniq
.compact_blank.uniq
end
def browser_name

View file

@ -11,6 +11,7 @@ class ProcedureDashboard < Administrate::BaseDashboard
published_types_de_champ_public: TypesDeChampCollectionField,
published_types_de_champ_private: TypesDeChampCollectionField,
path: ProcedureLinkField,
aasm_state: ProcedureStateField,
dossiers: Field::HasMany,
administrateurs: Field::HasMany,
id: Field::Number.with_options(searchable: true),
@ -40,6 +41,7 @@ class ProcedureDashboard < Administrate::BaseDashboard
max_duree_conservation_dossiers_dans_ds: Field::Number,
estimated_duration_visible: Field::Boolean,
piece_justificative_multiple: Field::Boolean,
replaced_by_procedure_id: Field::String,
tags: Field::Text
}.freeze
@ -56,7 +58,7 @@ class ProcedureDashboard < Administrate::BaseDashboard
:service,
:dossiers,
:published_at,
:unpublished_at
:aasm_state
].freeze
# SHOW_PAGE_ATTRIBUTES
@ -64,6 +66,7 @@ class ProcedureDashboard < Administrate::BaseDashboard
SHOW_PAGE_ATTRIBUTES = [
:id,
:path,
:aasm_state,
:administrateurs,
:libelle,
:description,
@ -78,6 +81,7 @@ class ProcedureDashboard < Administrate::BaseDashboard
:whitelisted_at,
:hidden_at,
:closed_at,
:unpublished_at,
:published_types_de_champ_public,
:published_types_de_champ_private,
:for_individual,
@ -92,7 +96,8 @@ class ProcedureDashboard < Administrate::BaseDashboard
:duree_conservation_dossiers_dans_ds,
:max_duree_conservation_dossiers_dans_ds,
:estimated_duration_visible,
:piece_justificative_multiple
:piece_justificative_multiple,
:replaced_by_procedure_id
].freeze
# FORM_ATTRIBUTES
@ -103,7 +108,8 @@ class ProcedureDashboard < Administrate::BaseDashboard
:duree_conservation_dossiers_dans_ds,
:max_duree_conservation_dossiers_dans_ds,
:estimated_duration_visible,
:piece_justificative_multiple
:piece_justificative_multiple,
:replaced_by_procedure_id
].freeze
# Overwrite this method to customize how procedures are displayed

View file

@ -0,0 +1,7 @@
require "administrate/field/base"
class ProcedureStateField < Administrate::Field::String
def name
"Statut"
end
end

View file

@ -25,8 +25,6 @@ class API::V2::Schema < GraphQL::Schema
def self.object_from_id(id, ctx)
ApplicationRecord.record_from_typed_id(id)
rescue => e
raise GraphQL::ExecutionError.new(e.message, extensions: { code: :not_found })
end
def self.resolve_type(type_definition, object, ctx)
@ -50,7 +48,7 @@ class API::V2::Schema < GraphQL::Schema
when GroupeInstructeur
Types::GroupeInstructeurType
else
raise GraphQL::ExecutionError.new("Unexpected object: #{object}")
type_definition
end
end
@ -129,8 +127,13 @@ class API::V2::Schema < GraphQL::Schema
super
end
rescue_from(ActiveRecord::RecordNotFound) do |_error, _object, _args, _ctx, field|
raise GraphQL::ExecutionError.new("#{field.type.unwrap.graphql_name} not found", extensions: { code: :not_found })
end
class Timeout < GraphQL::Schema::Timeout
def handle_timeout(error, query)
error.extensions = { code: :timeout }
Sentry.capture_exception(error, extra: query.context.query_info)
end
end

View file

@ -8,7 +8,7 @@ class API::V2::StoredQuery
when 'introspection'
GraphQL::Introspection::INTROSPECTION_QUERY
else
raise GraphQL::ExecutionError.new("No query with id \"#{query_id}\"")
raise GraphQL::ExecutionError.new("No query with id \"#{query_id}\"", extensions: { code: :bad_request })
end
end
@ -23,18 +23,18 @@ class API::V2::StoredQuery
$revision: ID
$createdSince: ISO8601DateTime
$updatedSince: ISO8601DateTime
$deletedOrder: Order
$deletedFirst: Int
$deletedAfter: String
$deletedSince: ISO8601DateTime
$pendingDeletedOrder: Order
$pendingDeletedFirst: Int
$pendingDeletedAfter: String
$pendingDeletedSince: ISO8601DateTime
$deletedOrder: Order
$deletedFirst: Int
$deletedAfter: String
$deletedSince: ISO8601DateTime
$includeGroupeInstructeurs: Boolean = false
$includeDossiers: Boolean = false
$includeDeletedDossiers: Boolean = false
$includePendingDeletedDossiers: Boolean = false
$includeDeletedDossiers: Boolean = false
$includeRevision: Boolean = false
$includeService: Boolean = false
$includeChamps: Boolean = true
@ -118,11 +118,16 @@ class API::V2::StoredQuery
$revision: ID
$createdSince: ISO8601DateTime
$updatedSince: ISO8601DateTime
$pendingDeletedOrder: Order
$pendingDeletedFirst: Int
$pendingDeletedAfter: String
$pendingDeletedSince: ISO8601DateTime
$deletedOrder: Order
$deletedFirst: Int
$deletedAfter: String
$deletedSince: ISO8601DateTime
$includeDossiers: Boolean = false
$includePendingDeletedDossiers: Boolean = false
$includeDeletedDossiers: Boolean = false
$includeChamps: Boolean = true
$includeAnotations: Boolean = true
@ -157,6 +162,19 @@ class API::V2::StoredQuery
...DossierFragment
}
}
pendingDeletedDossiers(
order: $pendingDeletedOrder
first: $pendingDeletedFirst
after: $pendingDeletedAfter
deletedSince: $pendingDeletedSince
) @include(if: $includePendingDeletedDossiers) {
pageInfo {
...PageInfoFragment
}
nodes {
...DeletedDossierFragment
}
}
deletedDossiers(
order: $deletedOrder
first: $deletedFirst
@ -240,7 +258,7 @@ class API::V2::StoredQuery
...FileFragment
}
pdf {
url
...FileFragment
}
usager {
email
@ -250,14 +268,9 @@ class API::V2::StoredQuery
}
demandeur {
__typename
... on PersonnePhysique {
civilite
nom
prenom
dateDeNaissance
}
... on PersonneMoraleIncomplete { siret }
...PersonnePhysiqueFragment
...PersonneMoraleFragment
...PersonneMoraleIncompleteFragment
}
demarche {
revision {
@ -368,24 +381,6 @@ class API::V2::StoredQuery
collapsibleExplanationEnabled
collapsibleExplanationText
}
... on PaysChampDescriptor {
options {
name
code
}
}
... on RegionChampDescriptor {
options {
name
code
}
}
... on DepartementChampDescriptor {
options {
name
code
}
}
}
fragment AvisFragment on Avis {
@ -502,8 +497,7 @@ class API::V2::StoredQuery
}
... on EpciChamp {
epci {
name
code
...EpciFragment
}
departement {
...DepartementFragment
@ -524,14 +518,12 @@ class API::V2::StoredQuery
}
... on RegionChamp {
region {
name
code
...RegionFragment
}
}
... on PaysChamp {
pays {
name
code
...PaysFragment
}
}
... on SiretChamp {
@ -579,6 +571,17 @@ class API::V2::StoredQuery
}
}
fragment PersonneMoraleIncompleteFragment on PersonneMoraleIncomplete {
siret
}
fragment PersonnePhysiqueFragment on PersonnePhysique {
civilite
nom
prenom
}
fragment FileFragment on File {
filename
contentType
@ -602,11 +605,26 @@ class API::V2::StoredQuery
regionCode
}
fragment PaysFragment on Pays {
name
code
}
fragment RegionFragment on Region {
name
code
}
fragment DepartementFragment on Departement {
name
code
}
fragment EpciFragment on Epci {
name
code
}
fragment CommuneFragment on Commune {
name
code

View file

@ -13,7 +13,7 @@ module Loaders
def perform(keys)
query(keys).each { |record| fulfill(record.stable_id, [record].compact) }
keys.each { |key| fulfill(key, nil) unless fulfilled?(key) }
keys.each { |key| fulfill(key, []) unless fulfilled?(key) }
end
private

View file

@ -21,7 +21,7 @@ module Loaders
fulfilled_value = @array ? [record].compact : record
fulfill(record.public_send(@column), fulfilled_value)
end
keys.each { |key| fulfill(key, nil) unless fulfilled?(key) }
keys.each { |key| fulfill(key, @array ? [] : nil) unless fulfilled?(key) }
end
private

View file

@ -1070,7 +1070,7 @@ enum DemarcheState {
close
"""
Depubliee
Dépubliée
"""
depubliee
@ -1921,12 +1921,12 @@ enum DossierState {
accepte
"""
En construction
En construction
"""
en_construction
"""
En instruction
En instruction
"""
en_instruction
@ -1936,7 +1936,7 @@ enum DossierState {
refuse
"""
Classé sans suite
Classé sans suite
"""
sans_suite
}
@ -2478,6 +2478,41 @@ type GroupeInstructeurWithDossiers {
Le numero du groupe instructeur.
"""
number: Int!
"""
Liste de tous les dossiers en attente de suppression définitive dun groupe instructeur.
"""
pendingDeletedDossiers(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Dossiers en attente de suppression depuis la date.
"""
deletedSince: ISO8601DateTime
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
Lordre des dossiers en attente de suppression.
"""
order: Order = ASC
): DeletedDossierConnection!
}
type HeaderSectionChampDescriptor implements ChampDescriptor {

View file

@ -1,5 +1,11 @@
module Types
class BaseObject < GraphQL::Schema::Object
field_class BaseField
class InvalidNullError < GraphQL::InvalidNullError
def to_h
super.merge(extensions: { code: :invalid_null })
end
end
end
end

View file

@ -143,7 +143,10 @@ module Types
{
filename: "dossier-#{object.id}.pdf",
content_type: 'application/pdf',
url: Rails.application.routes.url_helpers.api_v2_dossier_pdf_url(id: sgid)
url: Rails.application.routes.url_helpers.api_v2_dossier_pdf_url(id: sgid),
byte_size: 0,
byte_size_big_int: '0',
checksum: ''
}
end
@ -152,7 +155,10 @@ module Types
{
filename: "dossier-#{object.id}-features.json",
content_type: 'application/json',
url: Rails.application.routes.url_helpers.api_v2_dossier_geojson_url(id: sgid)
url: Rails.application.routes.url_helpers.api_v2_dossier_geojson_url(id: sgid),
byte_size: 0,
byte_size_big_int: '0',
checksum: ''
}
end

View file

@ -18,6 +18,11 @@ module Types
argument :deleted_since, GraphQL::Types::ISO8601DateTime, required: false, description: "Dossiers supprimés depuis la date."
end
field :pending_deleted_dossiers, Types::DeletedDossierType.connection_type, "Liste de tous les dossiers en attente de suppression définitive dun groupe instructeur.", null: false do
argument :order, Types::Order, default_value: :asc, required: false, description: "Lordre des dossiers en attente de suppression."
argument :deleted_since, GraphQL::Types::ISO8601DateTime, required: false, description: "Dossiers en attente de suppression depuis la date."
end
def dossiers(updated_since: nil, created_since: nil, state: nil, archived: nil, revision: nil, max_revision: nil, min_revision: nil, order:, lookahead:)
dossiers = object
.dossiers
@ -70,5 +75,15 @@ module Types
dossiers.order(deleted_at: order)
end
def pending_deleted_dossiers(deleted_since: nil, order:)
dossiers = object.dossiers.hidden_for_administration
if deleted_since.present?
dossiers = dossiers.hidden_since(deleted_since)
end
dossiers.order(hidden_by_user_at: order, hidden_by_administration_at: order)
end
end
end

View file

@ -26,13 +26,11 @@ module Types
demarche_number = demarche.number.presence || ApplicationRecord.id_from_typed_id(demarche.id)
Procedure
.includes(draft_revision: :procedure, published_revision: :procedure)
.find_by(id: demarche_number)
.find(demarche_number)
end
def demarche(number:)
Procedure.for_api_v2.find(number)
rescue => e
raise GraphQL::ExecutionError.new(e.message, extensions: { code: :not_found })
end
def dossier(number:)
@ -42,14 +40,10 @@ module Types
Dossier.visible_by_administration.for_api_v2.find(number)
end
DossierPreloader.load_one(dossier)
rescue => e
raise GraphQL::ExecutionError.new(e.message, extensions: { code: :not_found })
end
def groupe_instructeur(number:)
GroupeInstructeur.for_api_v2.find(number)
rescue => e
raise GraphQL::ExecutionError.new(e.message, extensions: { code: :not_found })
end
def self.accessible?(context)

View file

@ -84,10 +84,10 @@ module DossierHelper
def deletion_reason_badge(reason)
if reason.present?
status_text = I18n.t(reason, scope: [:activerecord, :attributes, :deleted_dossier, :reason])
status_text = I18n.t(reason, scope: 'activerecord.attributes.deleted_dossier.reason')
status_class = reason.tr('_', '-')
else
status_text = I18n.t(:unknown, scope: [:activerecord, :attributes, :deleted_dossier, :reason])
status_text = I18n.t('activerecord.attributes.deleted_dossier.reason.unknown')
status_class = 'unknown'
end

View file

@ -20,7 +20,7 @@ export class BatchOperationController extends ApplicationController {
this.inputTargets.forEach((e) => (e.checked = target.checked));
this.toggleSubmitButtonWhenNeeded();
const pagination = document.querySelector('tfoot .pagination');
const pagination = document.querySelector('tfoot .fr-pagination');
if (pagination) {
displayNotice(this.inputTargets);
}

View file

@ -1,101 +0,0 @@
import {
isSelectElement,
isCheckboxOrRadioInputElement,
show,
hide,
enable,
disable
} from '@utils';
import { z } from 'zod';
import { ApplicationController } from './application_controller';
export class ChampDropdownController extends ApplicationController {
connect() {
this.on('change', (event) => this.onChange(event));
}
private onChange(event: Event) {
const target = event.target as HTMLInputElement;
if (!target.disabled) {
if (isSelectElement(target) || isCheckboxOrRadioInputElement(target)) {
this.toggleOtherInput(target);
this.toggleLinkedSelect(target);
}
}
}
private toggleOtherInput(target: HTMLSelectElement | HTMLInputElement) {
const parent = target.closest('.editable-champ-drop_down_list');
const inputGroup = parent?.querySelector<HTMLElement>('.drop_down_other');
if (inputGroup) {
const input = inputGroup.querySelector('input');
if (input) {
if (target.value == '__other__') {
show(inputGroup);
input.disabled = false;
input.focus();
} else {
hide(inputGroup);
input.disabled = true;
}
}
}
}
private toggleLinkedSelect(target: HTMLSelectElement | HTMLInputElement) {
const secondaryOptions = target.dataset.secondaryOptions;
if (isSelectElement(target) && secondaryOptions) {
const parent = target.closest('.editable-champ-linked_drop_down_list');
const secondary = parent?.querySelector<HTMLSelectElement>(
'select[data-secondary]'
);
if (secondary) {
const options = parseOptions(secondaryOptions);
this.setSecondaryOptions(secondary, options[target.value]);
}
}
}
private setSecondaryOptions(
secondarySelectElement: HTMLSelectElement,
options: string[]
) {
const wrapper = secondarySelectElement.closest('.secondary');
const hidden = wrapper?.nextElementSibling as HTMLInputElement | null;
secondarySelectElement.innerHTML = '';
if (options.length) {
disable(hidden);
if (secondarySelectElement.required) {
secondarySelectElement.appendChild(makeOption(''));
}
for (const option of options) {
secondarySelectElement.appendChild(makeOption(option));
}
secondarySelectElement.selectedIndex = 0;
enable(secondarySelectElement);
show(wrapper);
} else {
hide(wrapper);
disable(secondarySelectElement);
enable(hidden);
}
}
}
const SecondaryOptions = z.record(z.string().array());
function parseOptions(options: string) {
return SecondaryOptions.parse(JSON.parse(options));
}
function makeOption(option: string) {
const element = document.createElement('option');
element.textContent = option;
element.value = option;
return element;
}

View file

@ -14,6 +14,6 @@ class Cron::Datagouv::AccountByMonthJob < Cron::CronJob
end
def data
User.where(created_at: 1.month.ago.beginning_of_month..1.month.ago.end_of_month).count
User.where(created_at: 1.month.ago.all_month).count
end
end

View file

@ -14,6 +14,6 @@ class Cron::Datagouv::AdministrateurByMonthJob < Cron::CronJob
end
def data
Administrateur.where(created_at: 1.month.ago.beginning_of_month..1.month.ago.end_of_month).count
Administrateur.where(created_at: 1.month.ago.all_month).count
end
end

View file

@ -14,6 +14,6 @@ class Cron::Datagouv::FileByMonthJob < Cron::CronJob
end
def data
Dossier.where(created_at: 1.month.ago.beginning_of_month..1.month.ago.end_of_month).count
Dossier.where(created_at: 1.month.ago.all_month).count
end
end

View file

@ -14,6 +14,6 @@ class Cron::Datagouv::FileDeposeByMonthJob < Cron::CronJob
end
def data
Dossier.where(depose_at: 1.month.ago.beginning_of_month..1.month.ago.end_of_month).count
Dossier.where(depose_at: 1.month.ago.all_month).count
end
end

View file

@ -14,6 +14,6 @@ class Cron::Datagouv::InstructeurByMonthJob < Cron::CronJob
end
def data
Instructeur.where(created_at: 1.month.ago.beginning_of_month..1.month.ago.end_of_month).count
Instructeur.where(created_at: 1.month.ago.all_month).count
end
end

View file

@ -14,6 +14,6 @@ class Cron::Datagouv::InstructeurConnectedByMonthJob < Cron::CronJob
end
def data
Instructeur.joins(:user).where(user: { last_sign_in_at: 1.month.ago.beginning_of_month..1.month.ago.end_of_month }).count
Instructeur.joins(:user).where(user: { last_sign_in_at: 1.month.ago.all_month }).count
end
end

View file

@ -14,6 +14,6 @@ class Cron::Datagouv::ProcedureByMonthJob < Cron::CronJob
end
def data
Procedure.where(created_at: 1.month.ago.beginning_of_month..1.month.ago.end_of_month).count
Procedure.where(created_at: 1.month.ago.all_month).count
end
end

View file

@ -14,6 +14,6 @@ class Cron::Datagouv::ProcedureClosedByMonthJob < Cron::CronJob
end
def data
Procedure.where(closed_at: 1.month.ago.beginning_of_month..1.month.ago.end_of_month).count
Procedure.where(closed_at: 1.month.ago.all_month).count
end
end

View file

@ -14,6 +14,6 @@ class Cron::Datagouv::ProcedureDeletedByMonthJob < Cron::CronJob
end
def data
Procedure.where(hidden_at: 1.month.ago.beginning_of_month..1.month.ago.end_of_month).count
Procedure.where(hidden_at: 1.month.ago.all_month).count
end
end

View file

@ -14,6 +14,6 @@ class Cron::Datagouv::UserConnectedWithFranceConnectByMonthJob < Cron::CronJob
end
def data
User.where(created_at: 1.month.ago.beginning_of_month..1.month.ago.end_of_month, loged_in_with_france_connect: "particulier").count
User.where(created_at: 1.month.ago.all_month, loged_in_with_france_connect: "particulier").count
end
end

View file

@ -0,0 +1,14 @@
class Cron::StalledDeclarativeProceduresJob < Cron::CronJob
self.schedule_expression = "every 10 minute"
def perform(*args)
Procedure.declarative.find_each do |procedure|
begin
procedure.process_stalled_dossiers!
rescue => e
Sentry.set_tags(procedure: procedure.id)
Sentry.capture_exception(e)
end
end
end
end

View file

@ -0,0 +1,41 @@
class Migrations::NormalizeCommunesJob < ApplicationJob
def perform(ids)
Champs::CommuneChamp.where(id: ids).find_each do |champ|
next if champ.external_id.blank?
value_json = champ.value_json || {}
if !champ.departement?
metro_code = champ.external_id[0..1]
drom_com_code = champ.external_id[0..2]
if metro_code == '97' || metro_code == '98'
value_json[:code_departement] = drom_com_code
else
value_json[:code_departement] = metro_code
end
end
if !champ.code_postal? && code_postal_with_fallback(champ).present?
value_json[:code_postal] = code_postal_with_fallback(champ)
end
if value_json.present?
champ.update_column(:value_json, value_json)
end
end
end
private
# We try to extract the postal code from the value, which is the name of the commune and the
# postal code in brackets.
def code_postal_with_fallback(champ)
if champ.value.present?
match = champ.value.match(/[^(]\(([^\)]*)\)$/)
match[1] if match.present?
else
nil
end
end
end

View file

@ -4,6 +4,7 @@ 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
STATUS_KEY = 72
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}"
@ -13,6 +14,18 @@ class Dolist::API
class_attribute :limit_remaining, :limit_reset_at
# those code are just undocumented
IGNORABLE_API_ERROR_CODE = [
"458",
"402"
]
# see: https://usercampaign.dolist.net/wp-content/uploads/2022/12/Comprendre-les-Opt-out-tableau-v2.pdf
IGNORABLE_CONTACT_STATUSES = [
"4", # Le serveur distant n'accepte pas le mail car il identifie que ladresse e-mail est en erreur.
"7" # Suite à un envoi, le serveur distant accepte le mail dans un premier temps mais envoie une erreur définitive car ladresse e-mail est en erreur. L'adresse e-mail nexiste pas ou n'existe plus.
]
class << self
def save_rate_limit_headers(headers)
self.limit_remaining = headers["X-Rate-Limit-Remaining"].to_i
@ -124,8 +137,43 @@ class Dolist::API
get format_url(EMAIL_MESSAGES_ADRESSES_REPLIES)
end
# Une adresse e-mail peut ne pas être adressable pour différentes raisons (injoignable, plainte pour spam, blocage dun FAI).
# Dans ce cas lAPI denvoi transactionnel renvoie différentes erreurs.
# Pour connaitre exactement le statut dune adresse, je vous invite à récupérer le champ 72 du contact à partir de son adresse e-mail avec la méthode https://api.dolist.com/documentation/index.html#/40e7751d00dc3-rechercher-un-contact
#
# La liste des différents statuts est disponible sur https://usercampaign.dolist.net/wp-content/uploads/2022/12/Comprendre-les-Opt-out-tableau-v2.pdf
def fetch_contact_status(email_address)
url = format(Dolist::API::CONTACT_URL, account_id: account_id)
body = {
Query: {
FieldValueList: [{ ID: 7, Value: email_address }],
OutputFieldIDList: [72]
}
}.to_json
post(url, body)["FieldList"].find { _1['ID'] == 72 }['Value']
end
def ignorable_error?(response, mail)
error_code = response&.dig("ResponseStatus", "ErrorCode")
invalid_contact_status = if ignorable_api_error_code?(error_code)
fetch_contact_status(mail.to.first)
else
nil
end
[error_code, invalid_contact_status]
end
private
def ignorable_api_error_code?(api_error_code)
IGNORABLE_API_ERROR_CODE.include?(api_error_code)
end
def ignorable_contact_status?(contact_status)
IGNORABLE_CONTACT_STATUSES.include?(contact_status)
end
def format_url(base)
format(base, account_id: account_id)
end
@ -192,6 +240,7 @@ class Dolist::API
post(url, body)["ItemList"]
end
# see: https://api.dolist.com/documentation/index.html#/7edc2948ba01f-creer-un-envoi-transactionnel
def prepare_mail_body(mail)
{
"Type": "TransactionalService",
@ -207,15 +256,15 @@ class Dolist::API
"Name": mail['X-Dolist-Message-Name'].value,
"Subject": mail.subject,
"SenderID": sender_id,
"ForceHttp": true,
"ForceHttp": false, # ForceHttp : force le tracking http non sécurisé (True/False).
"Format": "html",
"DisableOpenTracking": true,
"IsTrackingValidated": true
"DisableOpenTracking": true, # DisableOpenTracking : désactivation du tracking d'ouverture (True/False).
"IsTrackingValidated": true # IsTrackingValidated : est-ce que le tracking de ce message est validé ? (True/False). Passez la valeur True pour un envoi transactionnel.
},
"MessageContent": {
"SourceCode": mail_source_code(mail),
"EncodingType": "UTF8",
"EnableTrackingDetection": false
"EnableTrackingDetection": false # EnableTrackingDetection : booléen pour lactivation du tracking personnalisé des liens des messages utilisés lors des précédents envois (True/False).
}
}
end

View file

@ -4,7 +4,6 @@ module Redcarpet
include ApplicationHelper
# won't use rubocop tag method because it is missing output buffer
# rubocop:disable Rails/ContentTag
def list(content, list_type)
tag = list_type == :ordered ? :ol : :ul
content_tag(tag, content, { class: @options[:class_names_map].fetch(:list) {} }, false)
@ -27,12 +26,11 @@ module Redcarpet
when :url
link(link, nil, link)
when :email
# NOTE: As of Redcarpet 3.6.0, autolinking email containing is broken https://github.com/vmg/redcarpet/issues/402
content_tag(:a, link, { href: "mailto:#{link}" })
else
link
end
end
# rubocop:enable Rails/ContentTag
end
end

View file

@ -19,12 +19,18 @@ module MailerMonitoringConcern
end
end
rescue_from Dolist::IgnorableError, with: :log_delivery_error
def log_and_raise_delivery_error(exception)
EmailEvent.create_from_message!(message, status: "dispatch_error")
log_delivery_error(exception)
Sentry.capture_exception(exception, extra: { to: message.to, subject: message.subject })
# re-raise another error so job will retry later
raise MailDeliveryError.new(exception)
end
def log_delivery_error(exception)
EmailEvent.create_from_message!(message, status: "dispatch_error")
end
end
end

View file

@ -83,6 +83,8 @@ class Administrateur < ApplicationRecord
end
procedures.with_discarded.each do |procedure|
next if procedure.service.nil?
next_administrateur = procedure.administrateurs.where.not(id: self.id).first
procedure.service.update(administrateur: next_administrateur)
end

View file

@ -42,7 +42,6 @@ class Avis < ApplicationRecord
size: { less_than: FILE_MAX_SIZE }
validates :email, format: { with: Devise.email_regexp, message: "n'est pas valide" }, allow_nil: true
validates :claimant, presence: true
validates :question_answer, inclusion: { in: [true, false] }, on: :update, if: -> { question_label.present? }
validates :piece_justificative_file, size: { less_than: FILE_MAX_SIZE }
validates :introduction_file, size: { less_than: FILE_MAX_SIZE }

View file

@ -32,7 +32,7 @@ class Champ < ApplicationRecord
# here because otherwise we can't easily use includes in our queries.
has_many :geo_areas, -> { order(:created_at) }, dependent: :destroy, inverse_of: :champ
belongs_to :etablissement, optional: true, dependent: :destroy
has_many :champs, -> { ordered }, foreign_key: :parent_id, inverse_of: :parent, dependent: :destroy
has_many :champs, -> { ordered }, foreign_key: :parent_id, inverse_of: :parent
delegate :procedure, to: :dossier
@ -48,6 +48,8 @@ class Champ < ApplicationRecord
:drop_down_secondary_description,
:collapsible_explanation_enabled?,
:collapsible_explanation_text,
:header_section_level_value,
:current_section_level,
:exclude_from_export?,
:exclude_from_view?,
:repetition?,

View file

@ -52,6 +52,10 @@ class Champs::CommuneChamp < Champs::TextChamp
code_postal.present?
end
def code_postal=(value)
super(value&.gsub(/[[:space:]]/, ''))
end
alias postal_code code_postal
def name
@ -64,7 +68,7 @@ class Champs::CommuneChamp < Champs::TextChamp
def to_s
if code?
"#{APIGeoService.commune_name(code_departement, code)} (#{code_postal_with_fallback})"
"#{APIGeoService.commune_name(code_departement, code)} (#{code_postal})"
else
value.present? ? value.to_s : ''
end
@ -79,15 +83,15 @@ class Champs::CommuneChamp < Champs::TextChamp
end
def communes
if code_postal_with_fallback?
APIGeoService.communes_by_postal_code(code_postal_with_fallback)
if code_postal?
APIGeoService.communes_by_postal_code(code_postal)
else
[]
end
end
def value=(code)
if code.blank? || !code_postal_with_fallback?
if code.blank? || !code_postal?
self.code_departement = nil
self.external_id = nil
super(nil)
@ -105,23 +109,6 @@ class Champs::CommuneChamp < Champs::TextChamp
end
end
def code_postal_with_fallback?
code_postal_with_fallback.present?
end
# We try to extract the postal code from the value, which is the name of the commune and the
# postal code in brackets. This is temporary until we do a full data migration.
def code_postal_with_fallback
if code_postal?
code_postal
elsif value.present?
match = value.match(/[^(]\(([^\)]*)\)$/)
match[1] if match.present?
else
nil
end
end
private
def on_code_postal_change

View file

@ -21,6 +21,7 @@
# type_de_champ_id :integer
#
class Champs::DropDownListChamp < Champ
store_accessor :value_json, :other
THRESHOLD_NB_OPTIONS_AS_RADIO = 5
OTHER = '__other__'
delegate :options_without_empty_value_when_mandatory, to: :type_de_champ
@ -44,7 +45,7 @@ class Champs::DropDownListChamp < Champ
end
def selected
other_value_present? ? OTHER : value
other? ? OTHER : value
end
def disabled_options
@ -55,22 +56,28 @@ class Champs::DropDownListChamp < Champ
drop_down_list_enabled_non_empty_options
end
def other_value_present?
drop_down_other? && value.present? && drop_down_list_options.exclude?(value)
def other?
drop_down_other? && (other || (value.present? && drop_down_list_options.exclude?(value)))
end
def value=(value)
if value != OTHER
if value == OTHER
self.other = true
write_attribute(:value, nil)
else
self.other = false
write_attribute(:value, value)
end
end
def value_other=(value)
write_attribute(:value, value)
if other?
write_attribute(:value, value)
end
end
def value_other
other_value_present? ? value : ""
other? ? value : ""
end
def in?(options)

View file

@ -21,6 +21,16 @@
# type_de_champ_id :integer
#
class Champs::HeaderSectionChamp < Champ
def level
if parent.present?
header_section_level_value.to_i + parent.current_section_level
elsif header_section_level_value
header_section_level_value.to_i
else
0
end
end
def search_terms
# The user cannot enter any information here so it doesnt make much sense to search
end

View file

@ -118,7 +118,7 @@ class Champs::MultipleDropDownListChamp < Champ
private
def values_are_in_options
json = selected_options.reject(&:blank?)
json = selected_options.compact_blank
return if json.empty?
return if (json - enabled_non_empty_options).empty?

View file

@ -15,7 +15,6 @@
class Commentaire < ApplicationRecord
include Discard::Model
self.ignored_columns = [:user_id]
belongs_to :dossier, inverse_of: :commentaires, touch: true, optional: false
belongs_to :instructeur, inverse_of: :commentaires, optional: true

View file

@ -2,7 +2,7 @@ module BlobVirusScannerConcern
extend ActiveSupport::Concern
included do
self.ignored_columns = [:lock_version]
self.ignored_columns += [:lock_version]
before_create :set_pending
end

View file

@ -108,7 +108,7 @@ module ProcedureStatsConcern
end
def last_considered_processed_at
(Time.zone.now - 1.month).end_of_month
(1.month.ago).end_of_month
end
def convert_seconds_in_days(seconds)

View file

@ -0,0 +1,37 @@
module TreeableConcern
extend ActiveSupport::Concern
MAX_DEPTH = 6 # deepest level for header_sections is 3.
# but a repetition can be nested an header_section, so 3+3=6=MAX_DEPTH
included do
# as we progress in the list of ordered champs
# we keep a reference to each level of nesting (walk)
# when we encounter an header_section, it depends of its own depth of nesting minus 1, ie:
# h1 belongs to prior (rooted_tree)
# h2 belongs to prior h1
# h3 belongs to prior h2
# h1 belongs to prior (rooted_tree)
# then, each and every champs which are not an header_section
# are added to the current_tree
# given a root_depth at 0, we build a full tree
# given a root_depth > 0, we build a partial tree (aka, a repetition)
def to_tree(champs:)
rooted_tree = []
walk = Array.new(MAX_DEPTH)
walk[0] = rooted_tree
current_tree = rooted_tree
champs.each do |champ|
if champ.header_section?
new_tree = [champ]
walk[champ.header_section_level_value - 1].push(new_tree)
current_tree = walk[champ.header_section_level_value] = new_tree
else
current_tree.push(champ)
end
end
rooted_tree
end
end
end

View file

@ -46,7 +46,6 @@
# user_id :integer
#
class Dossier < ApplicationRecord
self.ignored_columns = [:en_construction_conservation_extension]
include DossierFilteringConcern
include DossierPrefillableConcern
include DossierRebaseConcern
@ -86,8 +85,11 @@ class Dossier < ApplicationRecord
has_one_attached :justificatif_motivation
has_many :champs
has_many :champs_public, -> { root.public_ordered }, class_name: 'Champ', inverse_of: false, dependent: :destroy
has_many :champs_private, -> { root.private_ordered }, class_name: 'Champ', inverse_of: false, dependent: :destroy
# We have to remove champs in a particular order - champs with a reference to a parent have to be
# removed first, otherwise we get a foreign key constraint error.
has_many :champs_to_destroy, -> { order(:parent_id) }, class_name: 'Champ', inverse_of: false, dependent: :destroy
has_many :champs_public, -> { root.public_ordered }, class_name: 'Champ', inverse_of: false
has_many :champs_private, -> { root.private_ordered }, class_name: 'Champ', inverse_of: false
has_many :champs_public_all, -> { public_only }, class_name: 'Champ', inverse_of: false
has_many :champs_private_all, -> { private_only }, class_name: 'Champ', inverse_of: false
has_many :prefilled_champs_public, -> { root.public_only.prefilled }, class_name: 'Champ', inverse_of: false
@ -278,13 +280,13 @@ class Dossier < ApplicationRecord
scope :processed_in_month, -> (date) do
date = date.to_datetime
state_termine
.where(processed_at: date.beginning_of_month..date.end_of_month)
.where(processed_at: date.all_month)
end
scope :ordered_for_export, -> {
order(depose_at: 'asc')
}
scope :en_cours, -> { not_archived.state_en_construction_ou_instruction }
scope :without_followers, -> { left_outer_joins(:follows).where(follows: { id: nil }) }
scope :without_followers, -> { where.missing(:follows) }
scope :with_followers, -> { left_outer_joins(:follows).where.not(follows: { id: nil }) }
scope :with_champs, -> {
includes(champs_public: [

View file

@ -144,7 +144,7 @@ class Etablissement < ApplicationRecord
"#{numero_voie} #{type_voie} #{nom_voie}",
complement_adresse,
"#{code_postal} #{localite}"
].reject(&:blank?).join(', ').squeeze(' ')
].compact_blank.join(', ').squeeze(' ')
end
def association?

View file

@ -21,6 +21,8 @@ class GroupeInstructeur < ApplicationRecord
has_and_belongs_to_many :exports, dependent: :destroy
has_and_belongs_to_many :bulk_messages, dependent: :destroy
has_one :defaut_procedure, -> { with_discarded }, class_name: 'Procedure', foreign_key: :defaut_groupe_instructeur_id, dependent: :nullify, inverse_of: :defaut_groupe_instructeur
validates :label, presence: true, allow_nil: false
validates :label, uniqueness: { scope: :procedure }
validates :closed, acceptance: { accept: [false] }, if: -> { closed_changed? && self.procedure.groupe_instructeurs.active.one? }

View file

@ -208,7 +208,7 @@ class Instructeur < ApplicationRecord
h = {
nb_en_construction: groupe.dossiers.visible_by_administration.en_construction.count,
nb_en_instruction: groupe.dossiers.visible_by_administration.en_instruction.count,
nb_accepted: Traitement.where(dossier: groupe.dossiers.accepte, processed_at: Time.zone.yesterday.beginning_of_day..Time.zone.yesterday.end_of_day).count,
nb_accepted: Traitement.where(dossier: groupe.dossiers.accepte, processed_at: Time.zone.yesterday.all_day).count,
nb_notification: nb_notification
}

View file

@ -50,6 +50,7 @@
# created_at :datetime not null
# updated_at :datetime not null
# canonical_procedure_id :bigint
# defaut_groupe_instructeur_id :bigint
# draft_revision_id :bigint
# parent_procedure_id :bigint
# published_revision_id :bigint
@ -64,7 +65,7 @@ class Procedure < ApplicationRecord
include Discard::Model
self.discard_column = :hidden_at
self.ignored_columns = [:direction, :durees_conservation_required, :cerfa_flag, :test_started_at, :lien_demarche]
self.ignored_columns += [:direction, :durees_conservation_required, :cerfa_flag, :test_started_at, :lien_demarche]
default_scope -> { kept }
@ -207,7 +208,7 @@ class Procedure < ApplicationRecord
has_one :refused_mail, class_name: "Mails::RefusedMail", dependent: :destroy
has_one :without_continuation_mail, class_name: "Mails::WithoutContinuationMail", dependent: :destroy
has_one :defaut_groupe_instructeur, -> { active.order(id: :asc) }, class_name: 'GroupeInstructeur', inverse_of: false
belongs_to :defaut_groupe_instructeur, class_name: 'GroupeInstructeur', inverse_of: false, optional: true
has_one_attached :logo
has_one_attached :notice
@ -452,6 +453,10 @@ class Procedure < ApplicationRecord
publiee? || brouillon?
end
def replaced_by_procedure?
replaced_by_procedure_id.present?
end
def dossier_can_transition_to_en_construction?
accepts_new_dossiers? || depubliee?
end
@ -478,6 +483,23 @@ class Procedure < ApplicationRecord
end
end
def process_stalled_dossiers!
case declarative_with_state
when Procedure.declarative_with_states.fetch(:en_instruction)
dossiers
.state_en_construction
.where(declarative_triggered_at: nil)
.find_each(&:passer_automatiquement_en_instruction!)
when Procedure.declarative_with_states.fetch(:accepte)
dossiers
.state_en_construction
.where(declarative_triggered_at: nil)
.find_each do |dossier|
dossier.accepter_automatiquement! if dossier.can_accepter_automatiquement?
end
end
end
def feature_enabled?(feature)
Flipper.enabled?(feature, self)
end
@ -558,6 +580,9 @@ class Procedure < ApplicationRecord
procedure.draft_revision.types_de_champ_public.each { |tdc| tdc.options&.delete(:old_pj) }
end
new_defaut_groupe = procedure.groupe_instructeurs.find_by(label: defaut_groupe_instructeur.label)
procedure.update!(defaut_groupe_instructeur: new_defaut_groupe)
procedure
end
@ -740,15 +765,6 @@ class Procedure < ApplicationRecord
end
end
def restore_procedure(author)
if discarded?
undiscard
self.dossiers.hidden_by_administration.each do |dossier|
dossier.restore(author)
end
end
end
def flipper_id
"Procedure;#{id}"
end
@ -797,6 +813,7 @@ class Procedure < ApplicationRecord
end
def publish_revision!
reset!
transaction do
self.published_revision = draft_revision
self.draft_revision = create_new_revision
@ -810,8 +827,8 @@ class Procedure < ApplicationRecord
def reset_draft_revision!
if published_revision.present? && draft_changed?
reset!
transaction do
reset!
draft_revision.types_de_champ.filter(&:only_present_on_draft?).each(&:destroy)
draft_revision.update(dossier_submitted_message: nil)
draft_revision.destroy
@ -916,7 +933,21 @@ class Procedure < ApplicationRecord
def ensure_defaut_groupe_instructeur
if self.groupe_instructeurs.empty?
groupe_instructeurs.create(label: GroupeInstructeur::DEFAUT_LABEL)
gi = groupe_instructeurs.create(label: GroupeInstructeur::DEFAUT_LABEL)
self.update(defaut_groupe_instructeur_id: gi.id)
end
end
def stable_ids_used_by_routing_rules
@stable_ids_used_by_routing_rules ||= groupe_instructeurs.flat_map { _1.routing_rule&.sources }.compact
end
# We need this to unfuck administrate + aasm
def self.human_attribute_name(attribute, options = {})
if attribute == :aasm_state
'Statut'
else
super
end
end
end

View file

@ -31,6 +31,7 @@ class ProcedureRevision < ApplicationRecord
scope :ordered, -> { order(:created_at) }
validate :conditions_are_valid?
validate :header_sections_are_valid?
delegate :path, to: :procedure, prefix: true
@ -225,6 +226,16 @@ class ProcedureRevision < ApplicationRecord
types_de_champ_public.any?(&:carte?)
end
def coordinate_and_tdc(stable_id)
return [nil, nil] if stable_id.blank?
coordinate = revision_types_de_champ
.joins(:type_de_champ)
.find_by(type_de_champ: { stable_id: stable_id })
[coordinate, coordinate&.type_de_champ]
end
private
def compute_estimated_fill_duration
@ -245,16 +256,6 @@ class ProcedureRevision < ApplicationRecord
end
end
def coordinate_and_tdc(stable_id)
return [nil, nil] if stable_id.blank?
coordinate = revision_types_de_champ
.joins(:type_de_champ)
.find_by(type_de_champ: { stable_id: stable_id })
[coordinate, coordinate&.type_de_champ]
end
def renumber(siblings)
siblings.to_a.compact.each.with_index do |sibling, position|
sibling.update_column(:position, position)
@ -401,4 +402,24 @@ class ProcedureRevision < ApplicationRecord
.filter { |_tdc, errors| errors.present? }
.each { |tdc, message| errors.add(:condition, message, type_de_champ: tdc) }
end
def header_sections_are_valid?
public_tdcs = types_de_champ_public.to_a
root_tdcs_errors = errors_for_header_sections_order(public_tdcs)
repetition_tdcs_errors = public_tdcs
.filter_map { _1.repetition? ? children_of(_1) : nil }
.map { errors_for_header_sections_order(_1) }
repetition_tdcs_errors + root_tdcs_errors
end
def errors_for_header_sections_order(tdcs)
tdcs
.map.with_index
.filter_map { |tdc, i| tdc.header_section? ? [tdc, i] : nil }
.map { |tdc, i| [tdc, tdc.check_coherent_header_level(tdcs.take(i))] }
.filter { |_tdc, errors| errors.present? }
.each { |tdc, message| errors.add(:header_section, message, type_de_champ: tdc) }
end
end

View file

@ -69,4 +69,8 @@ class ProcedureRevisionTypeDeChamp < ApplicationRecord
revision
end
end
def used_by_routing_rules?
stable_id.in?(procedure.stable_ids_used_by_routing_rules)
end
end

View file

@ -22,7 +22,8 @@ class Traitement < ApplicationRecord
includes(:dossier)
.termine
.where(dossier: procedure.dossiers.visible_by_administration)
.where.not('dossiers.depose_at' => nil, processed_at: nil)
.where.not('dossiers.depose_at' => nil)
.where.not(processed_at: nil)
.order(:processed_at)
end
end

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