Merge pull request #10875 from demarches-simplifiees/add_id_to_column_third_part

Utilisation des colonnes dans l'affichage et le filtrage
This commit is contained in:
LeSim 2024-10-16 14:53:30 +00:00 committed by GitHub
commit 6862f6c6bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1512 additions and 1499 deletions

View file

@ -30,7 +30,7 @@ class Dossiers::ExportLinkComponent < ApplicationComponent
end
def export_title(export)
if export.procedure_presentation_id.nil?
if !export.built_from_procedure_presentation?
t(".export_title_everything", export_format: export.format)
elsif export.tous?
t(".export_title", export_format: export.format, count: export.count)

View file

@ -30,7 +30,7 @@ class Instructeurs::ColumnFilterComponent < ApplicationComponent
{
selected_key: column.present? ? column.id : '',
items: filterable_columns_options,
name: :column,
name: "#{prefix}[id]",
id: 'search-filter',
'aria-describedby': 'instructeur-filter-combo-label',
form: 'filter-component',
@ -39,13 +39,20 @@ class Instructeurs::ColumnFilterComponent < ApplicationComponent
end
def filterable_columns_options
procedure.columns.filter_map do |column|
next if column.filterable == false
[column.label, column.id]
end
@procedure.columns.filter(&:filterable).map { [_1.label, _1.id] }
end
def current_filter_tags
@procedure_presentation.filters_for(@statut).flat_map do
[
hidden_field_tag("#{prefix}[id]", _1.column.id, id: nil),
hidden_field_tag("#{prefix}[filter]", _1.filter, id: nil)
]
end.reduce(&:concat)
end
def prefix = "#{procedure_presentation.filters_name_for(@statut)}[]"
private
def find_type_de_champ(column)

View file

@ -1,4 +1,6 @@
= form_tag add_filter_instructeur_procedure_path(procedure), method: :post, class: 'dropdown-form large', id: 'filter-component', data: { turbo: true, controller: 'autosubmit' } do
= current_filter_tags
.fr-select-group
= label_tag :column, t('.column'), class: 'fr-label fr-m-0', id: 'instructeur-filter-combo-label', for: 'search-filter'
%react-fragment
@ -8,9 +10,9 @@
= label_tag :value, t('.value'), for: 'value', class: 'fr-label'
- if column_type == :enum
= select_tag :value, options_for_select(options_for_select_of_column), id: 'value', name: 'value', class: 'fr-select', data: { no_autosubmit: true }
= select_tag :filter, options_for_select(options_for_select_of_column), id: 'value', name: "#{prefix}[filter]", class: 'fr-select', data: { no_autosubmit: true }
- else
%input#value.fr-input{ type: column_type, name: :value, maxlength: ProcedurePresentation::FILTERS_VALUE_MAX_LENGTH, disabled: column.nil? ? true : false, data: { no_autosubmit: true } }
%input#value.fr-input{ type: column_type, name: "#{prefix}[filter]", maxlength: FilteredColumn::FILTERS_VALUE_MAX_LENGTH, disabled: column.nil? ? true : false, data: { no_autosubmit: true } }
= hidden_field_tag :statut, statut
= submit_tag t('.add_filter'), class: 'fr-btn fr-btn--secondary fr-mt-2w'

View file

@ -12,7 +12,7 @@ class Instructeurs::ColumnPickerComponent < ApplicationComponent
def displayable_columns_for_select
[
procedure.columns.filter(&:displayable).map { |column| [column.label, column.id] },
procedure_presentation.displayed_fields.map { Column.new(**_1.deep_symbolize_keys.merge(procedure_id: procedure.id)).id }
procedure_presentation.displayed_columns.map(&:id)
]
end
end

View file

@ -9,6 +9,12 @@ class Instructeurs::ColumnTableHeaderComponent < ApplicationComponent
private
def classname(column)
return 'status-col' if column.dossier_state?
return 'number-col' if column.type == :number
return 'sva-col' if column.column == 'sva_svr_decision_on'
end
def update_sort_path(column)
id = column.id
order = opposite_order_for(column)

View file

@ -1,3 +1,3 @@
- @columns.each do |column|
%th{ aria_sort(column), scope: "col", class: column.classname }
%th{ aria_sort(column), scope: "col", class: classname(column) }
= link_to label_and_arrow(column), update_sort_path(column)

View file

@ -8,7 +8,7 @@ module Administrateurs
helper_method :create_archive_url
def index
@exports = Export.ante_chronological.by_key(all_groupe_instructeurs.map(&:id), nil)
@exports = Export.ante_chronological.by_key(all_groupe_instructeurs.map(&:id))
@average_dossier_weight = @procedure.average_dossier_weight
@count_dossiers_termines_by_month = @procedure.dossiers.processed_by_month(all_groupe_instructeurs).count
@archives = Archive.for_groupe_instructeur(all_groupe_instructeurs).to_a

View file

@ -73,7 +73,7 @@ module Instructeurs
# Setting it here to make clear that it is used by the view
@procedure_presentation = procedure_presentation
@current_filters = current_filters
@current_filters = procedure_presentation.filters_for(statut)
@counts = current_instructeur
.dossiers_count_summary(groupe_instructeur_ids)
.symbolize_keys
@ -95,7 +95,7 @@ module Instructeurs
@has_export_notification = notify_exports?
@last_export = last_export_for(statut)
@filtered_sorted_ids = procedure_presentation.filtered_sorted_ids(dossiers, statut, count: dossiers_count)
@filtered_sorted_ids = DossierFilterService.filtered_sorted_ids(dossiers, statut, procedure_presentation.filters_for(statut), procedure_presentation.sorted_column, current_instructeur, count: dossiers_count)
page = params[:page].presence || 1
@dossiers_count = @filtered_sorted_ids.size
@ -104,7 +104,7 @@ module Instructeurs
.page(page)
.per(ITEMS_PER_PAGE)
@projected_dossiers = DossierProjectionService.project(@filtered_sorted_paginated_ids, procedure_presentation.displayed_fields)
@projected_dossiers = DossierProjectionService.project(@filtered_sorted_paginated_ids, procedure_presentation.displayed_columns)
@disable_checkbox_all = @projected_dossiers.all? { _1.batch_operation_id.present? }
@batch_operations = BatchOperation.joins(:groupe_instructeurs)
@ -133,9 +133,9 @@ module Instructeurs
end
def update_displayed_fields
values = (params['values'].presence || []).reject(&:empty?)
ids = (params['values'].presence || []).reject(&:empty?)
procedure_presentation.update_displayed_fields(values)
procedure_presentation.update!(displayed_columns: ids)
redirect_back(fallback_location: instructeur_procedure_url(procedure))
end
@ -147,8 +147,10 @@ module Instructeurs
end
def add_filter
if !procedure_presentation.add_filter(statut, params[:column], params[:value])
flash.alert = procedure_presentation.errors.full_messages
if !procedure_presentation.update(filter_params)
# complicated way to display inner error messages
flash.alert = procedure_presentation.errors
.flat_map { _1.detail[:value].flat_map { |c| c.errors.full_messages } }
end
redirect_back(fallback_location: instructeur_procedure_url(procedure))
@ -158,13 +160,10 @@ module Instructeurs
@statut = statut
@procedure = procedure
@procedure_presentation = procedure_presentation
@column = procedure.find_column(h_id: JSON.parse(params[:column], symbolize_names: true))
end
def remove_filter
procedure_presentation.remove_filter(statut, params[:column], params[:value])
redirect_back(fallback_location: instructeur_procedure_url(procedure))
current_filter = procedure_presentation.filters_name_for(@statut)
# According to the html, the selected column is the last one
h_id = JSON.parse(params[current_filter].last[:id], symbolize_names: true)
@column = procedure.find_column(h_id:)
end
def download_export
@ -383,10 +382,6 @@ module Instructeurs
end
end
def current_filters
@current_filters ||= procedure_presentation.filters.fetch(statut, [])
end
def bulk_message_params
params.require(:bulk_message).permit(:body)
end
@ -415,5 +410,11 @@ module Instructeurs
def sorted_column_params
params.permit(sorted_column: [:order, :id])
end
def filter_params
keys = [:tous_filters, :a_suivre_filters, :suivis_filters, :traites_filters, :expirant_filters, :archives_filters, :supprimes_filters]
h = keys.index_with { [:id, :filter] }
params.permit(h)
end
end
end

View file

@ -3,10 +3,14 @@
class RechercheController < ApplicationController
before_action :authenticate_logged_user!
ITEMS_PER_PAGE = 25
# the columns are generally procedure specific
# but in the search context, we are looking for dossiers from multiple procedures
# so we are faking the columns with a random procedure_id
PROJECTIONS = [
{ "table" => 'procedure', "column" => 'libelle' },
{ "table" => 'user', "column" => 'email' },
{ "table" => 'procedure', "column" => 'procedure_id' }
Column.new(procedure_id: 666, table: 'procedure', column: 'libelle'),
Column.new(procedure_id: 666, table: 'user', column: 'email'),
Column.new(procedure_id: 666, table: 'procedure', column: 'procedure_id')
]
def nav_bar_profile

View file

@ -17,7 +17,6 @@ class ExportDashboard < Administrate::BaseDashboard
job_status: Field::Select.with_options(searchable: false, collection: -> (field) { field.resource.class.send(field.attribute.to_s.pluralize).keys }),
key: Field::Text,
procedure_presentation: IdField,
procedure_presentation_snapshot: Field::String.with_options(searchable: false),
statut: Field::Select.with_options(searchable: false, collection: -> (field) { field.resource.class.send(field.attribute.to_s.pluralize).keys }),
time_span_type: Field::Select.with_options(searchable: false, collection: -> (field) { field.resource.class.send(field.attribute.to_s.pluralize).keys }),
created_at: Field::DateTime.with_options(format: "%d/%m %H:%M:%S"),
@ -34,7 +33,7 @@ class ExportDashboard < Administrate::BaseDashboard
# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = [:id, :procedure, :job_status, :format, :statut, :file, :groupe_instructeurs, :key, :procedure_presentation, :procedure_presentation_snapshot, :time_span_type, :created_at, :updated_at].freeze
SHOW_PAGE_ATTRIBUTES = [:id, :procedure, :job_status, :format, :statut, :file, :groupe_instructeurs, :key, :procedure_presentation, :time_span_type, :created_at, :updated_at].freeze
# FORM_ATTRIBUTES
# an array of attributes that will be displayed

View file

@ -10,28 +10,31 @@ class AssignTo < ApplicationRecord
def procedure_presentation_or_default_and_errors
errors = reset_procedure_presentation_if_invalid
if self.procedure_presentation.nil?
self.procedure_presentation = build_procedure_presentation
self.procedure_presentation.save if procedure_presentation.valid? && !procedure_presentation.persisted?
self.procedure_presentation = create_procedure_presentation!
end
[self.procedure_presentation, errors]
end
private
def reset_procedure_presentation_if_invalid
if procedure_presentation&.invalid?
# This is a last defense against invalid `ProcedurePresentation`s persistently
# hindering instructeurs. Whenever this gets triggered, it means that there is
# a bug somewhere else that we need to fix.
errors = begin
procedure_presentation.errors if procedure_presentation&.invalid?
rescue ActiveRecord::RecordNotFound => e
[e.message]
end
errors = procedure_presentation.errors
if errors.present?
Sentry.capture_message(
"Destroying invalid ProcedurePresentation",
extra: { procedure_presentation: procedure_presentation.as_json }
extra: { procedure_presentation_id: procedure_presentation.id, errors: }
)
self.procedure_presentation = nil
errors
end
errors
end
end

View file

@ -1,16 +1,20 @@
# frozen_string_literal: true
class Column
# include validations to enable procedure_presentation.validate_associate,
# which enforces the deserialization of columns in the displayed_columns attribute
# and raises an error if a column is not found
include ActiveModel::Validations
TYPE_DE_CHAMP_TABLE = 'type_de_champ'
attr_reader :table, :column, :label, :classname, :type, :scope, :value_column, :filterable, :displayable
attr_reader :table, :column, :label, :type, :scope, :value_column, :filterable, :displayable
def initialize(procedure_id:, table:, column:, label: nil, type: :text, value_column: :value, filterable: true, displayable: true, classname: '', scope: '')
def initialize(procedure_id:, table:, column:, label: nil, type: :text, value_column: :value, filterable: true, displayable: true, scope: '')
@procedure_id = procedure_id
@table = table
@column = column
@label = label || I18n.t(column, scope: [:activerecord, :attributes, :procedure_presentation, :fields, table])
@classname = classname
@type = type
@scope = scope
@value_column = value_column
@ -29,15 +33,21 @@ class Column
def to_json
{
table:, column:, label:, classname:, type:, scope:, value_column:, filterable:, displayable:
table:, column:, label:, type:, scope:, value_column:, filterable:, displayable:
}
end
def notifications?
table == 'notifications' && column == 'notifications'
end
def notifications? = [table, column] == ['notifications', 'notifications']
def dossier_state? = [table, column] == ['self', 'state']
def self.find(h_id)
Procedure.with_discarded.find(h_id[:procedure_id]).find_column(h_id:)
begin
procedure = Procedure.with_discarded.find(h_id[:procedure_id])
rescue ActiveRecord::RecordNotFound
raise ActiveRecord::RecordNotFound.new("Column: unable to find procedure #{h_id[:procedure_id]} from h_id #{h_id}")
end
procedure.find_column(h_id: h_id)
end
end

View file

@ -12,7 +12,7 @@ module ColumnsConcern
column = columns.find { _1.h_id == h_id } if h_id.present?
column = columns.find { _1.label == label } if label.present?
raise ActiveRecord::RecordNotFound if column.nil?
raise ActiveRecord::RecordNotFound.new("Column: unable to find h_id: #{h_id} or label: #{label} for procedure_id #{id}") if column.nil?
column
end
@ -30,7 +30,11 @@ module ColumnsConcern
end
def dossier_id_column
Column.new(procedure_id: id, table: 'self', column: 'id', classname: 'number-col', type: :number)
Column.new(procedure_id: id, table: 'self', column: 'id', type: :number)
end
def dossier_state_column
Column.new(procedure_id: id, table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false)
end
def notifications_column
@ -46,25 +50,23 @@ module ColumnsConcern
non_displayable_dates = ['updated_since', 'depose_since', 'en_construction_since', 'en_instruction_since', 'processed_since']
.map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date, displayable: false) }
states = [Column.new(procedure_id: id, table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false)]
states = [dossier_state_column]
[common, dates, sva_svr_columns(for_filters: true), non_displayable_dates, states].flatten.compact
[common, dates, sva_svr_columns, non_displayable_dates, states].flatten.compact
end
def sva_svr_columns(for_filters: false)
def sva_svr_columns
return if !sva_svr_enabled?
scope = [:activerecord, :attributes, :procedure_presentation, :fields, :self]
columns = [
Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_on', type: :date,
label: I18n.t("#{sva_svr_decision}_decision_on", scope:), classname: for_filters ? '' : 'sva-col')
label: I18n.t("#{sva_svr_decision}_decision_on", scope:))
]
if for_filters
columns << Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false,
label: I18n.t("#{sva_svr_decision}_decision_before", scope:))
end
columns << Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false,
label: I18n.t("#{sva_svr_decision}_decision_before", scope:))
columns
end
@ -73,11 +75,17 @@ module ColumnsConcern
SortedColumn.new(column: notifications_column, order: 'desc')
end
def default_displayed_columns = [email_column]
private
def email_column
Column.new(procedure_id: id, table: 'user', column: 'email')
end
def standard_columns
[
Column.new(procedure_id: id, table: 'user', column: 'email'),
email_column,
Column.new(procedure_id: id, table: 'followers_instructeurs', column: 'email'),
Column.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum),
Column.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false) # not filterable ?

View file

@ -29,13 +29,13 @@ module DossierFilteringConcern
}
scope :filter_ilike, lambda { |table, column, values|
table_column = ProcedurePresentation.sanitized_column(table, column)
table_column = DossierFilterService.sanitized_column(table, column)
q = Array.new(values.count, "(#{table_column} ILIKE ?)").join(' OR ')
where(q, *(values.map { |value| "%#{value}%" }))
}
scope :filter_enum, lambda { |table, column, values|
table_column = ProcedurePresentation.sanitized_column(table, column)
table_column = DossierFilterService.sanitized_column(table, column)
q = Array.new(values.count, "(#{table_column} = ?)").join(' OR ')
where(q, *(values))
}

View file

@ -3,6 +3,8 @@
class Export < ApplicationRecord
include TransientModelsWithPurgeableJobConcern
self.ignored_columns += ["procedure_presentation_snapshot"]
MAX_DUREE_CONSERVATION_EXPORT = 32.hours
MAX_DUREE_GENERATION = 16.hours
@ -37,6 +39,9 @@ class Export < ApplicationRecord
has_one_attached :file
attribute :sorted_column, :sorted_column
attribute :filtered_columns, :filtered_column, array: true
validates :format, :groupe_instructeurs, :key, presence: true
scope :ante_chronological, -> { order(updated_at: :desc) }
@ -56,7 +61,6 @@ class Export < ApplicationRecord
def compute
self.dossiers_count = dossiers_for_export.count
load_snapshot!
file.attach(blob.signed_id) # attaching a blob directly might run identify/virus scanner and wipe it
end
@ -65,17 +69,16 @@ class Export < ApplicationRecord
time_span_type == Export.time_span_types.fetch(:monthly) ? 30.days.ago : nil
end
def filtered?
procedure_presentation_id.present?
end
def self.find_or_create_fresh_export(format, groupe_instructeurs, user_profile, time_span_type: time_span_types.fetch(:everything), statut: statuts.fetch(:tous), procedure_presentation: nil, export_template: nil)
filtered_columns = Array.wrap(procedure_presentation&.filters_for(statut))
sorted_column = procedure_presentation&.sorted_column
attributes = {
format:,
export_template:,
time_span_type:,
statut:,
key: generate_cache_key(groupe_instructeurs.map(&:id), procedure_presentation)
key: generate_cache_key(groupe_instructeurs.map(&:id), filtered_columns, sorted_column)
}
recent_export = pending
@ -87,36 +90,30 @@ class Export < ApplicationRecord
create!(**attributes, groupe_instructeurs:,
user_profile:,
procedure_presentation:,
procedure_presentation_snapshot: procedure_presentation&.snapshot)
filtered_columns:,
sorted_column:)
end
def self.for_groupe_instructeurs(groupe_instructeurs_ids)
joins(:groupe_instructeurs).where(groupe_instructeurs: groupe_instructeurs_ids).distinct(:id)
end
def self.by_key(groupe_instructeurs_ids, procedure_presentation)
where(key: [
generate_cache_key(groupe_instructeurs_ids),
generate_cache_key(groupe_instructeurs_ids, procedure_presentation)
])
def self.by_key(groupe_instructeurs_ids)
where(key: generate_cache_key(groupe_instructeurs_ids))
end
def self.generate_cache_key(groupe_instructeurs_ids, procedure_presentation = nil)
if procedure_presentation.present?
[
groupe_instructeurs_ids.sort.join('-'),
procedure_presentation.id,
Digest::MD5.hexdigest(procedure_presentation.snapshot.slice(:filters, :sort).to_s)
].join('--')
else
groupe_instructeurs_ids.sort.join('-')
end
def self.generate_cache_key(groupe_instructeurs_ids, filtered_columns = [], sorted_column = nil)
columns_key = ([sorted_column] + filtered_columns).compact.map(&:id).sort.join
[
groupe_instructeurs_ids.sort.join('-'),
Digest::MD5.hexdigest(columns_key)
].join('--')
end
def count
return dossiers_count if !dossiers_count.nil? # export generated
return dossiers_for_export.count if procedure_presentation_id.present?
return dossiers_for_export.count if built_from_procedure_presentation?
nil
end
@ -125,23 +122,21 @@ class Export < ApplicationRecord
groupe_instructeurs.first.procedure
end
private
def load_snapshot!
if procedure_presentation_snapshot.present?
procedure_presentation.attributes = procedure_presentation_snapshot
end
def built_from_procedure_presentation?
sorted_column.present? # hack has we know that procedure_presentation always has a sorted_column
end
private
def dossiers_for_export
@dossiers_for_export ||= begin
dossiers = Dossier.where(groupe_instructeur: groupe_instructeurs)
if since.present?
dossiers.visible_by_administration.where('dossiers.depose_at > ?', since)
elsif procedure_presentation.present?
filtered_sorted_ids = procedure_presentation
.filtered_sorted_ids(dossiers, statut)
elsif filtered_columns.present? || sorted_column.present?
instructeur = instructeur_from(user_profile)
filtered_sorted_ids = DossierFilterService.filtered_sorted_ids(dossiers, statut, filtered_columns, sorted_column, instructeur)
dossiers.where(id: filtered_sorted_ids)
else
@ -150,6 +145,15 @@ class Export < ApplicationRecord
end
end
def instructeur_from(user_profile)
case user_profile
when Administrateur
user_profile.instructeur
when Instructeur
user_profile
end
end
def blob
service = ProcedureExportService.new(procedure, dossiers_for_export, user_profile, export_template)

View file

@ -0,0 +1,46 @@
# frozen_string_literal: true
class FilteredColumn
include ActiveModel::Validations
FILTERS_VALUE_MAX_LENGTH = 4048
# https://www.postgresql.org/docs/current/datatype-numeric.html
PG_INTEGER_MAX_VALUE = 2147483647
attr_reader :column, :filter
delegate :label, to: :column
validate :check_filter_max_length
validate :check_filter_max_integer
def initialize(column:, filter:)
@column = column
@filter = filter
end
def ==(other)
other&.column == column && other.filter == filter
end
def id
column.h_id.merge(filter:).sort.to_json
end
private
def check_filter_max_length
if @filter.present? && @filter.length.to_i > FILTERS_VALUE_MAX_LENGTH
errors.add(
:base,
"Le filtre #{label} est trop long (maximum: #{FILTERS_VALUE_MAX_LENGTH} caractères)"
)
end
end
def check_filter_max_integer
if @column.column == 'id' && @filter.to_i > PG_INTEGER_MAX_VALUE
errors.add(:base, "Le filtre #{label} n'est pas un numéro de dossier possible")
end
end
end

View file

@ -1,38 +1,31 @@
# frozen_string_literal: true
class ProcedurePresentation < ApplicationRecord
TABLE = 'table'
COLUMN = 'column'
ORDER = 'order'
SLASH = '/'
TYPE_DE_CHAMP = 'type_de_champ'
FILTERS_VALUE_MAX_LENGTH = 4048
# https://www.postgresql.org/docs/current/datatype-numeric.html
PG_INTEGER_MAX_VALUE = 2147483647
belongs_to :assign_to, optional: false
has_many :exports, dependent: :destroy
delegate :procedure, :instructeur, to: :assign_to
validate :check_allowed_displayed_fields
validate :check_allowed_filter_columns
validate :check_filters_max_length
validate :check_filters_max_integer
attribute :displayed_columns, :column, array: true
attribute :sorted_column, :sorted_column
def sorted_column = super || procedure.default_sorted_column # Dummy override to set default value
attribute :a_suivre_filters, :jsonb, array: true
attribute :suivis_filters, :jsonb, array: true
attribute :traites_filters, :jsonb, array: true
attribute :tous_filters, :jsonb, array: true
attribute :supprimes_filters, :jsonb, array: true
attribute :supprimes_recemment_filters, :jsonb, array: true
attribute :expirant_filters, :jsonb, array: true
attribute :archives_filters, :jsonb, array: true
attribute :a_suivre_filters, :filtered_column, array: true
attribute :suivis_filters, :filtered_column, array: true
attribute :traites_filters, :filtered_column, array: true
attribute :tous_filters, :filtered_column, array: true
attribute :supprimes_filters, :filtered_column, array: true
attribute :supprimes_recemment_filters, :filtered_column, array: true
attribute :expirant_filters, :filtered_column, array: true
attribute :archives_filters, :filtered_column, array: true
before_create { self.displayed_columns = procedure.default_displayed_columns }
validates_associated :displayed_columns, :sorted_column, :a_suivre_filters, :suivis_filters,
:traites_filters, :tous_filters, :supprimes_filters, :expirant_filters, :archives_filters
def filters_for(statut)
send(filters_name_for(statut))
@ -42,46 +35,35 @@ class ProcedurePresentation < ApplicationRecord
def displayed_fields_for_headers
[
Column.new(procedure_id: procedure.id, table: 'self', column: 'id', classname: 'number-col'),
*displayed_fields.map { Column.new(**_1.deep_symbolize_keys.merge(procedure_id: procedure.id)) },
Column.new(procedure_id: procedure.id, table: 'self', column: 'state', classname: 'state-col'),
procedure.dossier_id_column,
*displayed_columns,
procedure.dossier_state_column,
*procedure.sva_svr_columns
]
end
def filtered_sorted_ids(dossiers, statut, count: nil)
dossiers_by_statut = dossiers.by_statut(statut, instructeur)
dossiers_sorted_ids = self.sorted_ids(dossiers_by_statut, count || dossiers_by_statut.size)
if filters[statut].present?
dossiers_sorted_ids.intersection(filtered_ids(dossiers_by_statut, statut))
else
dossiers_sorted_ids
end
end
def human_value_for_filter(filter)
if filter[TABLE] == TYPE_DE_CHAMP
find_type_de_champ(filter[COLUMN]).dynamic_type.filter_to_human(filter['value'])
elsif filter['column'] == 'state'
if filter['value'] == 'pending_correction'
def human_value_for_filter(filtered_column)
if filtered_column.column.table == TYPE_DE_CHAMP
find_type_de_champ(filtered_column.column.column).dynamic_type.filter_to_human(filtered_column.filter)
elsif filtered_column.column.column == 'state'
if filtered_column.filter == 'pending_correction'
Dossier.human_attribute_name("pending_correction.for_instructeur")
else
Dossier.human_attribute_name("state.#{filter['value']}")
Dossier.human_attribute_name("state.#{filtered_column.filter}")
end
elsif filter['table'] == 'groupe_instructeur' && filter['column'] == 'id'
elsif filtered_column.column.table == 'groupe_instructeur' && filtered_column.column.column == 'id'
instructeur.groupe_instructeurs
.find { _1.id == filter['value'].to_i }&.label || filter['value']
.find { _1.id == filtered_column.filter.to_i }&.label || filtered_column.filter
else
column = procedure.columns.find { _1.table == filter[TABLE] && _1.column == filter[COLUMN] }
column = procedure.columns.find { _1.table == filtered_column.column.table && _1.column == filtered_column.column.column }
if column.type == :date
parsed_date = safe_parse_date(filter['value'])
parsed_date = safe_parse_date(filtered_column.filter)
return parsed_date.present? ? I18n.l(parsed_date) : nil
end
filter['value']
filtered_column.filter
end
end
@ -91,179 +73,8 @@ class ProcedurePresentation < ApplicationRecord
nil
end
def add_filter(statut, column_id, value)
h_id = JSON.parse(column_id, symbolize_names: true)
if value.present?
column = procedure.find_column(h_id:)
case column.table
when TYPE_DE_CHAMP
value = find_type_de_champ(column.column).dynamic_type.human_to_filter(value)
end
updated_filters = filters.dup
updated_filters[statut] << {
'label' => column.label,
TABLE => column.table,
COLUMN => column.column,
'value_column' => column.value_column,
'value' => value
}
filters_for(statut) << { id: h_id, filter: value }
update(filters: updated_filters)
end
end
def remove_filter(statut, column_id, value)
h_id = JSON.parse(column_id, symbolize_names: true)
column = procedure.find_column(h_id:)
updated_filters = filters.dup
updated_filters[statut] = filters[statut].reject do |filter|
filter.values_at(TABLE, COLUMN, 'value') == [column.table, column.column, value]
end
collection = filters_for(statut)
collection.delete(collection.find { sym_h = _1.deep_symbolize_keys; sym_h[:id] == h_id && sym_h[:filter] == value })
update!(filters: updated_filters)
end
def update_displayed_fields(column_ids)
h_ids = Array.wrap(column_ids).map { |id| JSON.parse(id, symbolize_names: true) }
columns = h_ids.map { |h_id| procedure.find_column(h_id:) }
update!(
displayed_fields: columns,
displayed_columns: columns.map(&:h_id)
)
if !sorted_column.column.in?(columns)
update(sorted_column: nil)
end
end
def snapshot
slice(:filters, :sort, :displayed_fields)
end
private
def sorted_ids(dossiers, count)
table = sorted_column.column.table
column = sorted_column.column.column
order = sorted_column.order
case table
when 'notifications'
dossiers_id_with_notification = dossiers.merge(instructeur.followed_dossiers).with_notifications.ids
if order == 'desc'
dossiers_id_with_notification +
(dossiers.order('dossiers.updated_at desc').ids - dossiers_id_with_notification)
else
(dossiers.order('dossiers.updated_at asc').ids - dossiers_id_with_notification) +
dossiers_id_with_notification
end
when TYPE_DE_CHAMP
ids = dossiers
.with_type_de_champ(column)
.order("champs.value #{order}")
.pluck(:id)
if ids.size != count
rest = dossiers.where.not(id: ids).order(id: order).pluck(:id)
order == 'asc' ? ids + rest : rest + ids
else
ids
end
when 'followers_instructeurs'
assert_supported_column(table, column)
# LEFT OUTER JOIN allows to keep dossiers without assigned instructeurs yet
dossiers
.includes(:followers_instructeurs)
.joins('LEFT OUTER JOIN users instructeurs_users ON instructeurs_users.id = instructeurs.user_id')
.order("instructeurs_users.email #{order}")
.pluck(:id)
.uniq
when 'avis'
dossiers.includes(table)
.order("#{self.class.sanitized_column(table, column)} #{order}")
.pluck(:id)
.uniq
when 'self', 'user', 'individual', 'etablissement', 'groupe_instructeur'
(table == 'self' ? dossiers : dossiers.includes(table))
.order("#{self.class.sanitized_column(table, column)} #{order}")
.pluck(:id)
end
end
def filtered_ids(dossiers, statut)
filters.fetch(statut)
.group_by { |filter| filter.values_at(TABLE, COLUMN) }
.map do |(table, column), filters|
values = filters.pluck('value')
value_column = filters.pluck('value_column').compact.first || :value
dossier_column = procedure.find_column(h_id: { procedure_id: procedure.id, column_id: "#{table}/#{column}" }) # hack to find json path columns
if dossier_column.is_a?(Columns::JSONPathColumn)
dossier_column.filtered_ids(dossiers, values)
else
case table
when 'self'
if dossier_column.type == :date
dates = values
.filter_map { |v| Time.zone.parse(v).beginning_of_day rescue nil }
dossiers.filter_by_datetimes(column, dates)
elsif dossier_column.column == "state" && values.include?("pending_correction")
dossiers.joins(:corrections).where(corrections: DossierCorrection.pending)
elsif dossier_column.column == "state" && values.include?("en_construction")
dossiers.where("dossiers.#{column} IN (?)", values).includes(:corrections).where.not(corrections: DossierCorrection.pending)
else
dossiers.where("dossiers.#{column} IN (?)", values)
end
when TYPE_DE_CHAMP
if dossier_column.type == :enum
dossiers.with_type_de_champ(column)
.filter_enum(:champs, value_column, values)
else
dossiers.with_type_de_champ(column)
.filter_ilike(:champs, value_column, values)
end
when 'etablissement'
if column == 'entreprise_date_creation'
dates = values
.filter_map { |v| v.to_date rescue nil }
dossiers
.includes(table)
.where(table.pluralize => { column => dates })
else
dossiers
.includes(table)
.filter_ilike(table, column, values)
end
when 'followers_instructeurs'
assert_supported_column(table, column)
dossiers
.includes(:followers_instructeurs)
.joins('INNER JOIN users instructeurs_users ON instructeurs_users.id = instructeurs.user_id')
.filter_ilike('instructeurs_users', :email, values) # ilike OK, user may want to search by *@domain
when 'user', 'individual' # user_columns: [email], individual_columns: ['nom', 'prenom', 'gender']
dossiers
.includes(table)
.filter_ilike(table, column, values) # ilike or where column == 'value' are both valid, we opted for ilike
when 'groupe_instructeur'
assert_supported_column(table, column)
dossiers
.joins(:groupe_instructeur)
.where(groupe_instructeur_id: values)
end.pluck(:id)
end
end.reduce(:&)
end
def find_type_de_champ(column)
TypeDeChamp
.joins(:revision_types_de_champ)
@ -271,83 +82,4 @@ class ProcedurePresentation < ApplicationRecord
.order(created_at: :desc)
.find_by(stable_id: column)
end
def check_allowed_displayed_fields
displayed_fields.each do |field|
check_allowed_field(:displayed_fields, field)
end
end
def check_allowed_filter_columns
filters.each do |key, columns|
return true if key == 'migrated'
columns.each do |column|
check_allowed_field(:filters, column)
end
end
end
def check_allowed_field(kind, field, extra_columns = {})
table, column = field.values_at(TABLE, COLUMN)
if !valid_column?(table, column, extra_columns)
errors.add(kind, "#{table}.#{column} nest pas une colonne permise")
end
end
def check_filters_max_length
filters.values.flatten.each do |filter|
next if !filter.is_a?(Hash)
next if filter['value']&.length.to_i <= FILTERS_VALUE_MAX_LENGTH
errors.add(:base, "Le filtre #{filter['label']} est trop long (maximum: #{FILTERS_VALUE_MAX_LENGTH} caractères)")
end
end
def check_filters_max_integer
filters.values.flatten.each do |filter|
next if !filter.is_a?(Hash)
next if filter['column'] != 'id'
next if filter['value']&.to_i&. < PG_INTEGER_MAX_VALUE
errors.add(:base, "Le filtre #{filter['label']} n'est pas un numéro de dossier possible")
end
end
def valid_column?(table, column, extra_columns = {})
valid_columns_for_table(table).include?(column) ||
extra_columns[table]&.include?(column)
end
def valid_columns_for_table(table)
@column_whitelist ||= procedure.columns
.group_by(&:table)
.transform_values { |columns| Set.new(columns.map(&:column)) }
@column_whitelist[table] || []
end
def self.sanitized_column(association, column)
table = if association == 'self'
Dossier.table_name
elsif (association_reflection = Dossier.reflect_on_association(association))
association_reflection.klass.table_name
else
# Allow filtering on a joined table alias (which doesnt exist
# in the ActiveRecord domain).
association
end
[table, column]
.map { |name| ActiveRecord::Base.connection.quote_column_name(name) }
.join('.')
end
def assert_supported_column(table, column)
if table == 'followers_instructeurs' && column != 'email'
raise ArgumentError, 'Table `followers_instructeurs` only supports the `email` column.'
end
if table == 'groupe_instructeur' && (column != 'label' && column != 'id')
raise ArgumentError, 'Table `groupe_instructeur` only supports the `label` or `id` column.'
end
end
end

View file

@ -1,6 +1,11 @@
# frozen_string_literal: true
class SortedColumn
# include validations to enable procedure_presentation.validate_associate,
# which enforces the deserialization of columns in the sorted_column attribute
# and raises an error if a column is not found
include ActiveModel::Validations
attr_reader :column, :order
def initialize(column:, order:)
@ -19,4 +24,8 @@ class SortedColumn
def sort_by_notifications?
@column.notifications? && @order == 'desc'
end
def id
column.h_id.merge(order:).sort.to_json
end
end

View file

@ -54,10 +54,6 @@ class TypesDeChamp::TypeDeChampBase
filter_value
end
def human_to_filter(human_value)
human_value
end
class << self
def champ_value(champ)
champ.value.present? ? champ.value.to_s : champ_default_value

View file

@ -11,17 +11,6 @@ class TypesDeChamp::YesNoTypeDeChamp < TypesDeChamp::CheckboxTypeDeChamp
end
end
def human_to_filter(human_value)
downcased = human_value.downcase
if downcased == "oui"
"true"
elsif downcased == "non"
"false"
else
downcased
end
end
class << self
def champ_value(champ)
champ_formatted_value(champ)

View file

@ -0,0 +1,157 @@
# frozen_string_literal: true
class DossierFilterService
TYPE_DE_CHAMP = 'type_de_champ'
def self.filtered_sorted_ids(dossiers, statut, filters, sorted_column, instructeur, count: nil)
dossiers_by_statut = dossiers.by_statut(statut, instructeur)
dossiers_sorted_ids = self.sorted_ids(dossiers_by_statut, sorted_column, instructeur, count || dossiers_by_statut.size)
if filters.present?
dossiers_sorted_ids.intersection(filtered_ids(dossiers_by_statut, filters))
else
dossiers_sorted_ids
end
end
private
def self.sorted_ids(dossiers, sorted_column, instructeur, count)
table = sorted_column.column.table
column = sorted_column.column.column
order = sorted_column.order
case table
when 'notifications'
dossiers_id_with_notification = dossiers.merge(instructeur.followed_dossiers).with_notifications.ids
if order == 'desc'
dossiers_id_with_notification +
(dossiers.order('dossiers.updated_at desc').ids - dossiers_id_with_notification)
else
(dossiers.order('dossiers.updated_at asc').ids - dossiers_id_with_notification) +
dossiers_id_with_notification
end
when TYPE_DE_CHAMP
ids = dossiers
.with_type_de_champ(column)
.order("champs.value #{order}")
.pluck(:id)
if ids.size != count
rest = dossiers.where.not(id: ids).order(id: order).pluck(:id)
order == 'asc' ? ids + rest : rest + ids
else
ids
end
when 'followers_instructeurs'
assert_supported_column(table, column)
# LEFT OUTER JOIN allows to keep dossiers without assigned instructeurs yet
dossiers
.includes(:followers_instructeurs)
.joins('LEFT OUTER JOIN users instructeurs_users ON instructeurs_users.id = instructeurs.user_id')
.order("instructeurs_users.email #{order}")
.pluck(:id)
.uniq
when 'avis'
dossiers.includes(table)
.order("#{sanitized_column(table, column)} #{order}")
.pluck(:id)
.uniq
when 'self', 'user', 'individual', 'etablissement', 'groupe_instructeur'
(table == 'self' ? dossiers : dossiers.includes(table))
.order("#{sanitized_column(table, column)} #{order}")
.pluck(:id)
end
end
def self.filtered_ids(dossiers, filters)
filters
.group_by { |filter| filter.column.then { [_1.table, _1.column] } }
.map do |(table, column), filters_for_column|
values = filters_for_column.map(&:filter)
filtered_column = filters_for_column.first.column
value_column = filtered_column.value_column
if filtered_column.is_a?(Columns::JSONPathColumn)
filtered_column.filtered_ids(dossiers, values)
else
case table
when 'self'
if filtered_column.type == :date
dates = values
.filter_map { |v| Time.zone.parse(v).beginning_of_day rescue nil }
dossiers.filter_by_datetimes(column, dates)
elsif filtered_column.column == "state" && values.include?("pending_correction")
dossiers.joins(:corrections).where(corrections: DossierCorrection.pending)
elsif filtered_column.column == "state" && values.include?("en_construction")
dossiers.where("dossiers.#{column} IN (?)", values).includes(:corrections).where.not(corrections: DossierCorrection.pending)
else
dossiers.where("dossiers.#{column} IN (?)", values)
end
when TYPE_DE_CHAMP
if filtered_column.type == :enum
dossiers.with_type_de_champ(column)
.filter_enum(:champs, value_column, values)
else
dossiers.with_type_de_champ(column)
.filter_ilike(:champs, value_column, values)
end
when 'etablissement'
if column == 'entreprise_date_creation'
dates = values
.filter_map { |v| v.to_date rescue nil }
dossiers
.includes(table)
.where(table.pluralize => { column => dates })
else
dossiers
.includes(table)
.filter_ilike(table, column, values)
end
when 'followers_instructeurs'
assert_supported_column(table, column)
dossiers
.includes(:followers_instructeurs)
.joins('INNER JOIN users instructeurs_users ON instructeurs_users.id = instructeurs.user_id')
.filter_ilike('instructeurs_users', :email, values) # ilike OK, user may want to search by *@domain
when 'user', 'individual' # user_columns: [email], individual_columns: ['nom', 'prenom', 'gender']
dossiers
.includes(table)
.filter_ilike(table, column, values) # ilike or where column == 'value' are both valid, we opted for ilike
when 'groupe_instructeur'
assert_supported_column(table, column)
dossiers
.joins(:groupe_instructeur)
.where(groupe_instructeur_id: values)
end.pluck(:id)
end
end.reduce(:&)
end
def self.sanitized_column(association, column)
table = if association == 'self'
Dossier.table_name
elsif (association_reflection = Dossier.reflect_on_association(association))
association_reflection.klass.table_name
else
# Allow filtering on a joined table alias (which doesnt exist
# in the ActiveRecord domain).
association
end
[table, column]
.map { |name| ActiveRecord::Base.connection.quote_column_name(name) }
.join('.')
end
def self.assert_supported_column(table, column)
if table == 'followers_instructeurs' && column != 'email'
raise ArgumentError, 'Table `followers_instructeurs` only supports the `email` column.'
end
if table == 'groupe_instructeur' && (column != 'label' && column != 'id')
raise ArgumentError, 'Table `groupe_instructeur` only supports the `label` or `id` column.'
end
end
end

View file

@ -40,8 +40,10 @@ class DossierProjectionService
# Those hashes are needed because:
# - the order of the intermediary query results are unknown
# - some values can be missing (if a revision added or removed them)
def self.project(dossiers_ids, fields)
fields = fields.deep_dup
def self.project(dossiers_ids, columns)
fields = columns.map { |c| { TABLE => c.table, COLUMN => c.column } }
champ_value = champ_value_formatter(dossiers_ids, fields)
state_field = { TABLE => 'self', COLUMN => 'state' }
archived_field = { TABLE => 'self', COLUMN => 'archived' }
batch_operation_field = { TABLE => 'self', COLUMN => 'batch_operation_id' }
@ -53,7 +55,7 @@ class DossierProjectionService
individual_last_name = { TABLE => 'individual', COLUMN => 'nom' }
sva_svr_decision_on_field = { TABLE => 'self', COLUMN => 'sva_svr_decision_on' }
dossier_corrections = { TABLE => 'dossier_corrections', COLUMN => 'resolved_at' }
champ_value = champ_value_formatter(dossiers_ids, fields)
([state_field, archived_field, sva_svr_decision_on_field, hidden_by_user_at_field, hidden_by_administration_at_field, hidden_by_reason_field, for_tiers_field, individual_first_name, individual_last_name, batch_operation_field, dossier_corrections] + fields)
.each { |f| f[:id_value_h] = {} }
.group_by { |f| f[TABLE] } # one query per table

View file

@ -2,7 +2,7 @@
module Maintenance
# PR: 10774
# why: postgres does not support integer greater than ProcedurePresentation::PG_INTEGER_MAX_VALUE)
# why: postgres does not support integer greater than FilteredColumn::PG_INTEGER_MAX_VALUE)
# it occures when user copypaste the dossier id twice (like missed copy paste,paste)
# once this huge integer is saved on procedure presentation, page with this filter can't be loaded
# when: run this migration when it appears in your maintenance tasks list, this file fix the data and we added some validations too
@ -16,7 +16,7 @@ module Maintenance
filters_by_status.reject do |filter|
filter.is_a?(Hash) &&
filter['column'] == 'id' &&
(filter['value']&.to_i&. >= ProcedurePresentation::PG_INTEGER_MAX_VALUE)
(filter['value']&.to_i&. >= FilteredColumn::PG_INTEGER_MAX_VALUE)
end
end
element.save

View file

@ -0,0 +1,36 @@
# frozen_string_literal: true
class FilteredColumnType < ActiveRecord::Type::Value
# form_input or setter -> type
def cast(value)
value = value.deep_symbolize_keys if value.respond_to?(:deep_symbolize_keys)
case value
in FilteredColumn
value
in NilClass # default value
nil
# from form (id is a string) or from db (id is a hash)
in { id: String|Hash, filter: String } => h
FilteredColumn.new(column: ColumnType.new.cast(h[:id]), filter: h[:filter])
end
end
# db -> ruby
def deserialize(value) = cast(value&.then { JSON.parse(_1) })
# ruby -> db
def serialize(value)
case value
in NilClass
nil
in FilteredColumn
JSON.generate({
id: value.column.h_id,
filter: value.filter
})
else
raise ArgumentError, "Invalid value for FilteredColumn serialization: #{value}"
end
end
end

View file

@ -3,4 +3,4 @@
= t('views.instructeurs.dossiers.filters.title')
- menu.with_form do
= render Instructeurs::ColumnFilterComponent.new(procedure:, procedure_presentation: @procedure_presentation, statut:)
= render Instructeurs::ColumnFilterComponent.new(procedure:, procedure_presentation:, statut:)

View file

@ -1,11 +1,17 @@
- if current_filters.count > 0
.fr-mb-2w
- current_filters.group_by { |filter| filter['table'] }.each_with_index do |(table, filters), i|
- current_filters.group_by { |filter| filter.column.table }.each_with_index do |(table, filters), i|
- if i > 0
= " et "
- filters.each_with_index do |filter, i|
- if i > 0
= " ou "
= link_to remove_filter_instructeur_procedure_path(procedure, { statut: statut, column: { procedure_id: procedure.id, column_id: filter['table'] + "/" + filter['column'] }.to_json, value: filter['value'] }),
class: "fr-tag fr-tag--dismiss fr-my-1w", aria: { label: "Retirer le filtre #{filter['column']}" } do
= "#{filter['label'].truncate(50)} : #{procedure_presentation.human_value_for_filter(filter)}"
= form_tag(add_filter_instructeur_procedure_path(procedure), class: 'inline') do
- prefix = procedure_presentation.filters_name_for(statut)
= hidden_field_tag "#{prefix}[]", ''
- (current_filters - [filter]).each do |f|
= hidden_field_tag "#{prefix}[][id]", f.column.id
= hidden_field_tag "#{prefix}[][filter]", f.filter
= button_tag "#{filter.column.label.truncate(50)} : #{procedure_presentation.human_value_for_filter(filter)}",
class: 'fr-tag fr-tag--dismiss fr-my-1w'

View file

@ -61,7 +61,7 @@
%hr
.flex.align-center
- if @filtered_sorted_paginated_ids.present? || @current_filters.count > 0
= render partial: "dossiers_filter_dropdown", locals: { procedure: @procedure, statut: @statut}
= render partial: "dossiers_filter_dropdown", locals: { procedure: @procedure, statut: @statut, procedure_presentation: @procedure_presentation }
= render Dossiers::NotifiedToggleComponent.new(procedure: @procedure, procedure_presentation: @procedure_presentation) if @statut != 'a-suivre'
.fr-ml-auto

View file

@ -44,29 +44,6 @@
],
"note": ""
},
{
"warning_type": "SQL Injection",
"warning_code": 0,
"fingerprint": "31693060072e27c02ca4f884f2a07f4f1c1247b7a6f5cc5c724e88e6ca9b4873",
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/concerns/dossier_filtering_concern.rb",
"line": 40,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "where(\"#{values.count} OR #{\"(#{ProcedurePresentation.sanitized_column(table, column)} = ?)\"}\", *values)",
"render_path": null,
"location": {
"type": "method",
"class": "DossierFilteringConcern",
"method": null
},
"user_input": "values.count",
"confidence": "Medium",
"cwe_id": [
89
],
"note": "filtered by rails query params where(something: ?, values)"
},
{
"warning_type": "SQL Injection",
"warning_code": 0,
@ -136,6 +113,29 @@
],
"note": ""
},
{
"warning_type": "SQL Injection",
"warning_code": 0,
"fingerprint": "91ff8031e7c639c95fe6c244867349a72078ef456d8b3507deaf2bdb9bf62fe2",
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/concerns/dossier_filtering_concern.rb",
"line": 34,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "where(\"#{values.count} OR #{\"(#{DossierFilterService.sanitized_column(table, column)} ILIKE ?)\"}\", *values.map do\n \"%#{value}%\"\n end)",
"render_path": null,
"location": {
"type": "method",
"class": "DossierFilteringConcern",
"method": null
},
"user_input": "values.count",
"confidence": "Medium",
"cwe_id": [
89
],
"note": "filtered by rails query params where(something: ?, values)"
},
{
"warning_type": "Cross-Site Scripting",
"warning_code": 2,
@ -196,13 +196,13 @@
{
"warning_type": "SQL Injection",
"warning_code": 0,
"fingerprint": "bd1df30f95135357b646e21a03d95498874faffa32e3804fc643e9b6b957ee14",
"fingerprint": "aaff41afa7bd5a551cd2e3a385071090cb53c95caa40fad3785cd3d68c9b939c",
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/concerns/dossier_filtering_concern.rb",
"line": 34,
"line": 40,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "where(\"#{values.count} OR #{\"(#{ProcedurePresentation.sanitized_column(table, column)} ILIKE ?)\"}\", *values.map do\n \"%#{value}%\"\n end)",
"code": "where(\"#{values.count} OR #{\"(#{DossierFilterService.sanitized_column(table, column)} = ?)\"}\", *values)",
"render_path": null,
"location": {
"type": "method",
@ -272,6 +272,6 @@
"note": "Current is not a model"
}
],
"updated": "2024-09-24 20:56:24 +0200",
"updated": "2024-10-15 15:57:27 +0200",
"brakeman_version": "6.1.2"
}

View file

@ -3,9 +3,11 @@
require Rails.root.join("app/types/column_type")
require Rails.root.join("app/types/export_item_type")
require Rails.root.join("app/types/sorted_column_type")
require Rails.root.join("app/types/filtered_column_type")
ActiveSupport.on_load(:active_record) do
ActiveRecord::Type.register(:column, ColumnType)
ActiveRecord::Type.register(:export_item, ExportItemType)
ActiveRecord::Type.register(:sorted_column, SortedColumnType)
ActiveRecord::Type.register(:filtered_column, FilteredColumnType)
end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
class AddFilteredAndSortedColumnToExportsTable < ActiveRecord::Migration[7.0]
def change
add_column :exports, :filtered_columns, :jsonb, array: true, default: [], null: false
add_column :exports, :sorted_column, :jsonb
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_09_29_141825) do
ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_buffercache"
enable_extension "pg_stat_statements"
@ -628,12 +628,14 @@ ActiveRecord::Schema[7.0].define(version: 2024_09_29_141825) do
t.datetime "created_at", precision: nil, null: false
t.integer "dossiers_count"
t.bigint "export_template_id"
t.jsonb "filtered_columns", default: [], null: false, array: true
t.string "format", null: false
t.bigint "instructeur_id"
t.string "job_status", default: "pending", null: false
t.text "key", null: false
t.bigint "procedure_presentation_id"
t.jsonb "procedure_presentation_snapshot"
t.jsonb "sorted_column"
t.string "statut", default: "tous"
t.string "time_span_type", default: "everything", null: false
t.datetime "updated_at", precision: nil, null: false

View file

@ -36,9 +36,9 @@ RSpec.describe Dossiers::ExportLinkComponent, type: :component do
end
end
context 'when export is for a presentation' do
context 'when export is from a presentation' do
before do
export.update!(procedure_presentation: procedure_presentation)
export.update!(sorted_column: procedure.default_sorted_column)
end
it 'display the persisted dossiers count' do
@ -48,7 +48,7 @@ RSpec.describe Dossiers::ExportLinkComponent, type: :component do
end
context "when the export is not available" do
let(:export) { create(:export, :pending, groupe_instructeurs: [groupe_instructeur], procedure_presentation: procedure_presentation, created_at: 10.minutes.ago) }
let(:export) { create(:export, :pending, groupe_instructeurs: [groupe_instructeur], sorted_column: procedure.default_sorted_column, created_at: 10.minutes.ago) }
before do
create_list(:dossier, 3, :en_construction, procedure: procedure, groupe_instructeur: groupe_instructeur)

View file

@ -8,27 +8,22 @@ describe Instructeurs::ColumnFilterComponent, type: :component do
let(:procedure_id) { procedure.id }
let(:procedure_presentation) { nil }
let(:statut) { nil }
let(:column) { nil }
before do
allow(component).to receive(:current_instructeur).and_return(instructeur)
end
describe ".filterable_columns_options" do
context 'filders' do
let(:column) { nil }
let(:included_displayable_field) do
[
Column.new(procedure_id:, label: 'email', table: 'user', column: 'email'),
Column.new(procedure_id:, label: "depose_since", table: "self", column: "depose_since", displayable: false)
]
end
let(:filterable_column) { Column.new(procedure_id:, label: 'email', table: 'user', column: 'email') }
let(:non_filterable_column) { Column.new(procedure_id:, label: 'depose_since', table: 'self', column: 'depose_since', filterable: false) }
let(:mocked_columns) { [filterable_column, non_filterable_column] }
before { allow(procedure).to receive(:columns).and_return(included_displayable_field) }
before { allow(procedure).to receive(:columns).and_return(mocked_columns) }
subject { component.filterable_columns_options }
subject { component.filterable_columns_options }
it { is_expected.to eq([["email", included_displayable_field.first.id], ["depose_since", included_displayable_field.second.id]]) }
end
it { is_expected.to eq([[filterable_column.label, filterable_column.id]]) }
end
describe '.options_for_select_of_column' do

View file

@ -905,8 +905,8 @@ describe Instructeurs::ProceduresController, type: :controller do
end
subject do
column = procedure.find_column(label: "Nom").id
post :add_filter, params: { procedure_id: procedure.id, column:, value: "n" * 4100, statut: "a-suivre" }
column = procedure.find_column(label: "Nom")
post :add_filter, params: { procedure_id: procedure.id, a_suivre_filters: [{ id: column.id, filter: "n" * 4049 }] }
end
it 'should render the error' do

View file

@ -8,7 +8,10 @@ FactoryBot.define do
groupe_instructeurs { [association(:groupe_instructeur)] }
after(:build) do |export, _evaluator|
export.key = Export.generate_cache_key(export.groupe_instructeurs.map(&:id), export.procedure_presentation)
procedure_presentation = export.procedure_presentation
filters = Array.wrap(procedure_presentation&.filters_for(export.statut))
sorted_column = procedure_presentation&.sorted_column
export.key = Export.generate_cache_key(export.groupe_instructeurs.map(&:id), filters, sorted_column)
export.user_profile = export.groupe_instructeurs.first&.instructeurs&.first if export.user_profile.nil?
export.dossiers_count = 10 if !export.pending?
end

View file

@ -38,10 +38,7 @@ describe '20240920130741_migrate_procedure_presentation_to_columns.rake' do
it 'populates the columns' do
procedure_id = procedure.id
expect(procedure_presentation.displayed_columns).to eq([
{ "procedure_id" => procedure_id, "column_id" => "etablissement/entreprise_raison_sociale" },
{ "procedure_id" => procedure_id, "column_id" => "type_de_champ/#{stable_id}" }
])
expect(procedure_presentation.displayed_columns.map(&:label)).to eq(["Raison sociale", procedure.active_revision.types_de_champ.first.libelle])
order, column_id = procedure_presentation
.sorted_column
@ -52,9 +49,8 @@ describe '20240920130741_migrate_procedure_presentation_to_columns.rake' do
expect(procedure_presentation.tous_filters).to eq([])
traites = procedure_presentation.traites_filters
.map { [_1['id'], _1['filter']] }
traites = procedure_presentation.traites_filters.map { [_1.label, _1.filter] }
expect(traites).to eq([[{ "column_id" => "etablissement/libelle_naf", "procedure_id" => procedure_id }, "Administration publique générale"]])
expect(traites).to eq([["Libellé NAF", "Administration publique générale"]])
end
end

View file

@ -9,32 +9,43 @@ describe AssignTo, type: :model do
let(:procedure_presentation_or_default) { procedure_presentation_and_errors.first }
let(:errors) { procedure_presentation_and_errors.second }
context "without a procedure_presentation" do
it { expect(procedure_presentation_or_default).to be_persisted }
it { expect(procedure_presentation_or_default).to be_valid }
it { expect(errors).to be_nil }
context "without a preexisting procedure_presentation" do
it 'creates a default pp' do
expect(procedure_presentation_or_default).to be_persisted
expect(procedure_presentation_or_default).to be_valid
expect(errors).to be_nil
end
end
context "with a procedure_presentation" do
let!(:procedure_presentation) { ProcedurePresentation.create(assign_to: assign_to) }
context "with a preexisting procedure_presentation" do
let!(:procedure_presentation) { ProcedurePresentation.create(assign_to:) }
it { expect(procedure_presentation_or_default).to eq(procedure_presentation) }
it { expect(procedure_presentation_or_default).to be_valid }
it { expect(errors).to be_nil }
it 'returns the preexisting pp' do
expect(procedure_presentation_or_default).to eq(procedure_presentation)
expect(procedure_presentation_or_default).to be_valid
expect(errors).to be_nil
end
end
context "with an invalid procedure_presentation" do
let!(:procedure_presentation) do
pp = ProcedurePresentation.new(assign_to: assign_to, displayed_fields: [{ 'table' => 'invalid', 'column' => 'random' }])
pp.save(validate: false)
pp
pp = ProcedurePresentation.create(assign_to: assign_to)
sql = <<-SQL.squish
UPDATE procedure_presentations
SET displayed_columns = ARRAY['{\"procedure_id\":666}'::jsonb]
WHERE id = #{pp.id} ;
SQL
pp.class.connection.execute(sql)
assign_to.reload
end
it { expect(procedure_presentation_or_default).to be_persisted }
it { expect(procedure_presentation_or_default).to be_valid }
it { expect(errors).to be_present }
it do
procedure_presentation_or_default
expect(procedure_presentation_or_default).to be_persisted
expect(procedure_presentation_or_default).to be_valid
expect(errors).to be_present
expect(assign_to.procedure_presentation).not_to be(procedure_presentation)
end
end

View file

@ -29,37 +29,37 @@ describe ColumnsConcern do
let(:tdc_private_2) { procedure.active_revision.types_de_champ_private[1] }
let(:expected) {
[
{ label: 'Nº dossier', table: 'self', column: 'id', classname: 'number-col', displayable: true, type: :number, scope: '', value_column: :value, filterable: true },
{ label: 'Nº dossier', table: 'self', column: 'id', displayable: true, type: :number, scope: '', value_column: :value, filterable: true },
{ label: 'notifications', table: 'notifications', column: 'notifications', displayable: true, type: :text, scope: '', value_column: :value, filterable: false },
{ label: 'Créé le', table: 'self', column: 'created_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true },
{ label: 'Mis à jour le', table: 'self', column: 'updated_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true },
{ label: 'Déposé le', table: 'self', column: 'depose_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true },
{ label: 'En construction le', table: 'self', column: 'en_construction_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true },
{ label: 'En instruction le', table: 'self', column: 'en_instruction_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true },
{ label: 'Terminé le', table: 'self', column: 'processed_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true },
{ label: "Mis à jour depuis", table: "self", column: "updated_since", classname: "", displayable: false, type: :date, scope: '', value_column: :value, filterable: true },
{ label: "Déposé depuis", table: "self", column: "depose_since", classname: "", displayable: false, type: :date, scope: '', value_column: :value, filterable: true },
{ label: "En construction depuis", table: "self", column: "en_construction_since", classname: "", displayable: false, type: :date, scope: '', value_column: :value, filterable: true },
{ label: "En instruction depuis", table: "self", column: "en_instruction_since", classname: "", displayable: false, type: :date, scope: '', value_column: :value, filterable: true },
{ label: "Terminé depuis", table: "self", column: "processed_since", classname: "", displayable: false, type: :date, scope: '', value_column: :value, filterable: true },
{ label: "Statut", table: "self", column: "state", classname: "", displayable: false, scope: 'instructeurs.dossiers.filterable_state', type: :enum, value_column: :value, filterable: true },
{ label: 'Demandeur', table: 'user', column: 'email', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Email instructeur', table: 'followers_instructeurs', column: 'email', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Groupe instructeur', table: 'groupe_instructeur', column: 'id', classname: '', displayable: true, type: :enum, scope: '', value_column: :value, filterable: true },
{ label: 'Avis oui/non', table: 'avis', column: 'question_answer', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: false },
{ label: 'SIREN', table: 'etablissement', column: 'entreprise_siren', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Forme juridique', table: 'etablissement', column: 'entreprise_forme_juridique', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Nom commercial', table: 'etablissement', column: 'entreprise_nom_commercial', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Raison sociale', table: 'etablissement', column: 'entreprise_raison_sociale', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'SIRET siège social', table: 'etablissement', column: 'entreprise_siret_siege_social', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Date de création', table: 'etablissement', column: 'entreprise_date_creation', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true },
{ label: 'SIRET', table: 'etablissement', column: 'siret', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Libellé NAF', table: 'etablissement', column: 'libelle_naf', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Code postal', table: 'etablissement', column: 'code_postal', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: tdc_1.libelle, table: 'type_de_champ', column: tdc_1.stable_id.to_s, classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: tdc_2.libelle, table: 'type_de_champ', column: tdc_2.stable_id.to_s, classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: tdc_private_1.libelle, table: 'type_de_champ', column: tdc_private_1.stable_id.to_s, classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: tdc_private_2.libelle, table: 'type_de_champ', column: tdc_private_2.stable_id.to_s, classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }
{ label: 'Créé le', table: 'self', column: 'created_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true },
{ label: 'Mis à jour le', table: 'self', column: 'updated_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true },
{ label: 'Déposé le', table: 'self', column: 'depose_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true },
{ label: 'En construction le', table: 'self', column: 'en_construction_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true },
{ label: 'En instruction le', table: 'self', column: 'en_instruction_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true },
{ label: 'Terminé le', table: 'self', column: 'processed_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true },
{ label: "Mis à jour depuis", table: "self", column: "updated_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true },
{ label: "Déposé depuis", table: "self", column: "depose_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true },
{ label: "En construction depuis", table: "self", column: "en_construction_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true },
{ label: "En instruction depuis", table: "self", column: "en_instruction_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true },
{ label: "Terminé depuis", table: "self", column: "processed_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true },
{ label: "Statut", table: "self", column: "state", displayable: false, scope: 'instructeurs.dossiers.filterable_state', type: :enum, value_column: :value, filterable: true },
{ label: 'Demandeur', table: 'user', column: 'email', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Email instructeur', table: 'followers_instructeurs', column: 'email', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Groupe instructeur', table: 'groupe_instructeur', column: 'id', displayable: true, type: :enum, scope: '', value_column: :value, filterable: true },
{ label: 'Avis oui/non', table: 'avis', column: 'question_answer', displayable: true, type: :text, scope: '', value_column: :value, filterable: false },
{ label: 'SIREN', table: 'etablissement', column: 'entreprise_siren', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Forme juridique', table: 'etablissement', column: 'entreprise_forme_juridique', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Nom commercial', table: 'etablissement', column: 'entreprise_nom_commercial', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Raison sociale', table: 'etablissement', column: 'entreprise_raison_sociale', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'SIRET siège social', table: 'etablissement', column: 'entreprise_siret_siege_social', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Date de création', table: 'etablissement', column: 'entreprise_date_creation', displayable: true, type: :date, scope: '', value_column: :value, filterable: true },
{ label: 'SIRET', table: 'etablissement', column: 'siret', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Libellé NAF', table: 'etablissement', column: 'libelle_naf', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Code postal', table: 'etablissement', column: 'code_postal', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: tdc_1.libelle, table: 'type_de_champ', column: tdc_1.stable_id.to_s, displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: tdc_2.libelle, table: 'type_de_champ', column: tdc_2.stable_id.to_s, displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: tdc_private_1.libelle, table: 'type_de_champ', column: tdc_private_1.stable_id.to_s, displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: tdc_private_2.libelle, table: 'type_de_champ', column: tdc_private_2.stable_id.to_s, displayable: true, type: :text, scope: '', value_column: :value, filterable: true }
].map { Column.new(**_1.merge(procedure_id:)) }
}
@ -84,9 +84,9 @@ describe ColumnsConcern do
end
context 'when the procedure is for individuals' do
let(:name_field) { Column.new(procedure_id:, label: "Prénom", table: "individual", column: "prenom", classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true) }
let(:surname_field) { Column.new(procedure_id:, label: "Nom", table: "individual", column: "nom", classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true) }
let(:gender_field) { Column.new(procedure_id:, label: "Civilité", table: "individual", column: "gender", classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true) }
let(:name_field) { Column.new(procedure_id:, label: "Prénom", table: "individual", column: "prenom", displayable: true, type: :text, scope: '', value_column: :value, filterable: true) }
let(:surname_field) { Column.new(procedure_id:, label: "Nom", table: "individual", column: "nom", displayable: true, type: :text, scope: '', value_column: :value, filterable: true) }
let(:gender_field) { Column.new(procedure_id:, label: "Civilité", table: "individual", column: "gender", displayable: true, type: :text, scope: '', value_column: :value, filterable: true) }
let(:procedure) { create(:procedure, :for_individual) }
let(:procedure_id) { procedure.id }
let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) }
@ -99,8 +99,8 @@ describe ColumnsConcern do
let(:procedure_id) { procedure.id }
let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) }
let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVA", table: "self", column: "sva_svr_decision_on", classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true) }
let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVA avant", table: "self", column: "sva_svr_decision_before", classname: '', displayable: false, type: :date, scope: '', value_column: :value, filterable: true) }
let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVA", table: "self", column: "sva_svr_decision_on", displayable: true, type: :date, scope: '', value_column: :value, filterable: true) }
let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVA avant", table: "self", column: "sva_svr_decision_before", displayable: false, type: :date, scope: '', value_column: :value, filterable: true) }
it { is_expected.to include(decision_on, decision_before_field) }
end
@ -110,8 +110,8 @@ describe ColumnsConcern do
let(:procedure_id) { procedure.id }
let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) }
let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVR", table: "self", column: "sva_svr_decision_on", classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true) }
let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVR avant", table: "self", column: "sva_svr_decision_before", classname: '', displayable: false, type: :date, scope: '', value_column: :value, filterable: true) }
let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVR", table: "self", column: "sva_svr_decision_on", displayable: true, type: :date, scope: '', value_column: :value, filterable: true) }
let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVR avant", table: "self", column: "sva_svr_decision_before", displayable: false, type: :date, scope: '', value_column: :value, filterable: true) }
it { is_expected.to include(decision_on, decision_before_field) }
end

View file

@ -61,31 +61,18 @@ RSpec.describe Export, type: :model do
it { expect(groupe_instructeur.reload).to be_present }
end
describe '.find_by groupe_instructeurs' do
describe '.by_key groupe_instructeurs' do
let!(:procedure) { create(:procedure) }
let!(:gi_1) { create(:groupe_instructeur, procedure: procedure, instructeurs: [create(:instructeur)]) }
let!(:gi_2) { create(:groupe_instructeur, procedure: procedure, instructeurs: [create(:instructeur)]) }
let!(:gi_3) { create(:groupe_instructeur, procedure: procedure, instructeurs: [create(:instructeur)]) }
context 'without procedure_presentation' do
context 'when an export is made for one groupe instructeur' do
let!(:export) { create(:export, groupe_instructeurs: [gi_1, gi_2]) }
context 'when an export is made for one groupe instructeur' do
let!(:export) { create(:export, groupe_instructeurs: [gi_1, gi_2]) }
it { expect(Export.by_key([gi_1.id], nil)).to be_empty }
it { expect(Export.by_key([gi_2.id, gi_1.id], nil)).to eq([export]) }
it { expect(Export.by_key([gi_1.id, gi_2.id, gi_3.id], nil)).to be_empty }
end
end
context 'with procedure_presentation and without' do
let!(:export_global) { create(:export, statut: Export.statuts.fetch(:tous), groupe_instructeurs: [gi_1, gi_2], procedure_presentation: nil) }
let!(:export_with_filter) { create(:export, statut: Export.statuts.fetch(:suivis), groupe_instructeurs: [gi_1, gi_2], procedure_presentation: create(:procedure_presentation, procedure: procedure, assign_to: gi_1.instructeurs.first.assign_to.first)) }
let!(:procedure_presentation) { create(:procedure_presentation, procedure: gi_1.procedure) }
it 'find global exports as well as filtered one' do
expect(Export.by_key([gi_2.id, gi_1.id], export_with_filter.procedure_presentation))
.to contain_exactly(export_with_filter, export_global)
end
it { expect(Export.by_key([gi_1.id])).to be_empty }
it { expect(Export.by_key([gi_2.id, gi_1.id])).to eq([export]) }
it { expect(Export.by_key([gi_1.id, gi_2.id, gi_3.id])).to be_empty }
end
end
@ -94,7 +81,9 @@ RSpec.describe Export, type: :model do
let(:instructeur) { create(:instructeur) }
let!(:gi_1) { create(:groupe_instructeur, procedure: procedure, instructeurs: [instructeur]) }
let!(:pp) { gi_1.instructeurs.first.procedure_presentation_and_errors_for_procedure_id(procedure.id).first }
before { pp.add_filter('tous', procedure.find_column(label: 'Créé le').id, '10/12/2021') }
let(:created_at_column) { FilteredColumn.new(column: procedure.find_column(label: 'Créé le'), filter: '10/12/2021') }
before { pp.update(tous_filters: [created_at_column]) }
context 'with procedure_presentation having different filters' do
it 'works once' do
@ -105,7 +94,10 @@ RSpec.describe Export, type: :model do
it 'works once, changes procedure_presentation, recreate a new' do
expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) }
.to change { Export.count }.by(1)
pp.add_filter('tous', procedure.find_column(label: 'Mis à jour le').id, '10/12/2021')
update_at_column = FilteredColumn.new(column: procedure.find_column(label: 'Mis à jour le'), filter: '10/12/2021')
pp.update(tous_filters: [created_at_column, update_at_column])
expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) }
.to change { Export.count }.by(1)
end
@ -162,10 +154,16 @@ RSpec.describe Export, type: :model do
let!(:dossier_accepte) { create(:dossier, :accepte, procedure: procedure) }
let(:export) do
create(:export,
groupe_instructeurs: [procedure.groupe_instructeurs.first],
procedure_presentation: procedure_presentation,
statut: statut)
groupe_instructeurs = [procedure.groupe_instructeurs.first]
user_profile = groupe_instructeurs.first.instructeurs.first
Export.find_or_create_fresh_export(
:csv,
groupe_instructeurs,
user_profile,
procedure_presentation:,
statut:
)
end
context 'without procedure_presentation or since' do
@ -179,17 +177,28 @@ RSpec.describe Export, type: :model do
end
end
context 'with procedure_presentation and statut supprimes' do
let(:statut) { 'supprimes' }
let(:procedure_presentation) do
create(:procedure_presentation,
procedure: procedure,
assign_to: procedure.groupe_instructeurs.first.assign_tos.first)
end
let!(:dossier_supprime) { create(:dossier, :accepte, procedure: procedure, hidden_by_administration_at: 2.days.ago) }
context 'with procedure_presentation and statut tous and filter en_construction' do
let(:statut) { 'tous' }
it 'includes supprimes' do
expect(export.send(:dossiers_for_export)).to include(dossier_supprime)
let(:procedure_presentation) do
statut_column = procedure.find_column(label: 'Statut')
en_construction_filter = FilteredColumn.new(column: statut_column, filter: 'en_construction')
create(:procedure_presentation,
procedure:,
assign_to: procedure.groupe_instructeurs.first.assign_tos.first,
tous_filters: [en_construction_filter])
end
before do
# ensure the export is generated
export
# change the procedure presentation
procedure_presentation.update(tous_filters: [])
end
it 'only includes the en_construction' do
expect(export.send(:dossiers_for_export)).to eq([dossier_en_construction])
end
end
end

View file

@ -0,0 +1,59 @@
# frozen_string_literal: true
describe FilteredColumn do
describe '#check_filters_max_length' do
let(:column) { Column.new(procedure_id: 1, table: 'table', column: 'column', label: 'label') }
let(:filtered_column) { described_class.new(column:, filter:) }
before { filtered_column.valid? }
context 'when the filter is too long' do
let(:filter) { 'a' * (FilteredColumn::FILTERS_VALUE_MAX_LENGTH + 1) }
it 'adds an error' do
expect(filtered_column.errors.map(&:message)).to include(/Le filtre label est trop long/)
end
end
context 'when then filter is not too long' do
let(:filter) { 'a' * FilteredColumn::FILTERS_VALUE_MAX_LENGTH }
it 'does not add an error' do
expect(filtered_column.errors).to be_empty
end
end
context 'when the filter is empty' do
let(:filter) { nil }
it 'does not add an error' do
expect(filtered_column.errors).to be_empty
end
end
end
describe '#check_filters_max_integer' do
context 'when the target column is an id column' do
let(:column) { Column.new(procedure_id: 1, table: 'table', column: 'id', label: 'label') }
let(:filtered_column) { described_class.new(column:, filter:) }
before { filtered_column.valid? }
context 'when the filter is too high' do
let(:filter) { (FilteredColumn::PG_INTEGER_MAX_VALUE + 1).to_s }
it 'adds an error' do
expect(filtered_column.errors.map(&:message)).to include(/Le filtre label n'est pas un numéro de dossier possible/)
end
end
context 'when the filter is not too high' do
let(:filter) { FilteredColumn::PG_INTEGER_MAX_VALUE.to_s }
it 'does not add an error' do
expect(filtered_column.errors).to be_empty
end
end
end
end
end

View file

@ -167,20 +167,6 @@ describe Instructeur, type: :model do
it { expect(errors).to be_nil }
end
context 'with invalid presentation' do
let(:procedure_id) { procedure.id }
before do
pp = ProcedurePresentation.create(assign_to: procedure_assign, displayed_fields: [{ 'table' => 'invalid', 'column' => 'random' }])
pp.save(:validate => false)
end
it 'recreates a valid prsentation' do
expect(procedure_presentation).to be_persisted
end
it { expect(procedure_presentation).to be_valid }
it { expect(errors).to be_present }
end
context 'with default presentation' do
let(:procedure_id) { procedure_2.id }

View file

@ -4,14 +4,15 @@ describe ProcedurePresentation do
include ActiveSupport::Testing::TimeHelpers
let(:procedure) { create(:procedure, :published, types_de_champ_public:, types_de_champ_private: [{}]) }
let(:procedure_id) { procedure.id }
let(:types_de_champ_public) { [{}] }
let(:instructeur) { create(:instructeur) }
let(:assign_to) { create(:assign_to, procedure: procedure, instructeur: instructeur) }
let(:assign_to) { create(:assign_to, procedure:, instructeur:) }
let(:first_type_de_champ) { assign_to.procedure.active_revision.types_de_champ_public.first }
let(:first_type_de_champ_id) { first_type_de_champ.stable_id.to_s }
let(:procedure_presentation) {
create(:procedure_presentation,
assign_to: assign_to,
assign_to:,
displayed_fields: [
{ label: "test1", table: "user", column: "email" },
{ label: "test2", table: "type_de_champ", column: first_type_de_champ_id }
@ -22,6 +23,8 @@ describe ProcedurePresentation do
let(:procedure_presentation_id) { procedure_presentation.id }
let(:filters) { { "a-suivre" => [], "suivis" => [{ "label" => "label1", "table" => "self", "column" => "created_at" }] } }
def to_filter((label, filter)) = FilteredColumn.new(column: procedure.find_column(label: label), filter: filter)
describe "#displayed_fields" do
it { expect(procedure_presentation.displayed_fields).to eq([{ "label" => "test1", "table" => "user", "column" => "email" }, { "label" => "test2", "table" => "type_de_champ", "column" => first_type_de_champ_id }]) }
end
@ -37,744 +40,27 @@ describe ProcedurePresentation do
describe 'validation' do
it { expect(build(:procedure_presentation)).to be_valid }
context 'of displayed fields' do
it { expect(build(:procedure_presentation, displayed_fields: [{ table: "user", column: "reset_password_token", "order" => "asc" }])).to be_invalid }
context 'of displayed columns' do
it do
pp = build(:procedure_presentation, displayed_columns: [{ table: "user", column: "reset_password_token", procedure_id: }])
expect { pp.displayed_columns }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'of filters' do
it do
expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "user", column: "reset_password_token", "order" => "asc" }] })).to be_invalid
expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "user", column: "email", "value" => "exceedingly long filter value" * 1000 }] })).to be_invalid
end
describe 'check_filters_max_integer' do
it do
expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "self", column: "id", "value" => ProcedurePresentation::PG_INTEGER_MAX_VALUE.to_s }] })).to be_invalid
expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "self", column: "id", "value" => (ProcedurePresentation::PG_INTEGER_MAX_VALUE - 1).to_s }] })).to be_valid
end
end
end
end
describe '#sorted_ids' do
let(:instructeur) { create(:instructeur) }
let(:assign_to) { create(:assign_to, procedure:, instructeur:) }
let(:sorted_column) { SortedColumn.new(column:, order:) }
let(:procedure_presentation) { create(:procedure_presentation, assign_to:, sorted_column:) }
subject { procedure_presentation.send(:sorted_ids, procedure.dossiers, procedure.dossiers.count) }
context 'for notifications table' do
let(:column) { procedure.notifications_column }
let!(:notified_dossier) { create(:dossier, :en_construction, procedure:) }
let!(:recent_dossier) { create(:dossier, :en_construction, procedure:) }
let!(:older_dossier) { create(:dossier, :en_construction, procedure:) }
before do
notified_dossier.update!(last_champ_updated_at: Time.zone.local(2018, 9, 20))
create(:follow, instructeur: instructeur, dossier: notified_dossier, demande_seen_at: Time.zone.local(2018, 9, 10))
notified_dossier.touch(time: Time.zone.local(2018, 9, 20))
recent_dossier.touch(time: Time.zone.local(2018, 9, 25))
older_dossier.touch(time: Time.zone.local(2018, 5, 13))
end
context 'in ascending order' do
let(:order) { 'asc' }
it { is_expected.to eq([older_dossier, recent_dossier, notified_dossier].map(&:id)) }
end
context 'in descending order' do
let(:order) { 'desc' }
it { is_expected.to eq([notified_dossier, recent_dossier, older_dossier].map(&:id)) }
end
context 'with a dossier terminé' do
let!(:notified_dossier) { create(:dossier, :accepte, procedure:) }
let(:order) { 'desc' }
it { is_expected.to eq([notified_dossier, recent_dossier, older_dossier].map(&:id)) }
end
end
context 'for self table' do
let(:order) { 'asc' } # Desc works the same, no extra test required
context 'for created_at column' do
let!(:column) { procedure.find_column(label: 'Créé le') }
let!(:recent_dossier) { Timecop.freeze(Time.zone.local(2018, 10, 17)) { create(:dossier, procedure: procedure) } }
let!(:older_dossier) { Timecop.freeze(Time.zone.local(2003, 11, 11)) { create(:dossier, procedure: procedure) } }
it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) }
end
context 'for en_construction_at column' do
let!(:column) { procedure.find_column(label: 'En construction le') }
let!(:recent_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 17)) }
let!(:older_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2013, 1, 1)) }
it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) }
end
context 'for updated_at column' do
let(:column) { procedure.find_column(label: 'Mis à jour le') }
let(:recent_dossier) { create(:dossier, procedure: procedure) }
let(:older_dossier) { create(:dossier, procedure: procedure) }
before do
recent_dossier.touch(time: Time.zone.local(2018, 9, 25))
older_dossier.touch(time: Time.zone.local(2018, 5, 13))
end
it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) }
end
end
context 'for type_de_champ table' do
context 'with no revisions' do
let(:table) { 'type_de_champ' }
let(:column) { procedure.find_column(label: first_type_de_champ.libelle) }
let(:beurre_dossier) { create(:dossier, procedure:) }
let(:tartine_dossier) { create(:dossier, procedure:) }
before do
beurre_dossier.project_champs_public.first.update(value: 'beurre')
tartine_dossier.project_champs_public.first.update(value: 'tartine')
end
context 'asc' do
let(:order) { 'asc' }
it { is_expected.to eq([beurre_dossier, tartine_dossier].map(&:id)) }
end
context 'desc' do
let(:order) { 'desc' }
it { is_expected.to eq([tartine_dossier, beurre_dossier].map(&:id)) }
end
end
context 'with a revision adding a new type_de_champ' do
let!(:tdc) { { type_champ: :text, libelle: 'nouveau champ' } }
let(:column) { procedure.find_column(label: 'nouveau champ') }
let!(:nothing_dossier) { create(:dossier, procedure:) }
let!(:beurre_dossier) { create(:dossier, procedure:) }
let!(:tartine_dossier) { create(:dossier, procedure:) }
before do
nothing_dossier
procedure.draft_revision.add_type_de_champ(tdc)
procedure.publish_revision!
beurre_dossier.project_champs_public.last.update(value: 'beurre')
tartine_dossier.project_champs_public.last.update(value: 'tartine')
end
context 'asc' do
let(:order) { 'asc' }
it { is_expected.to eq([nothing_dossier, beurre_dossier, tartine_dossier].map(&:id)) }
end
context 'desc' do
let(:order) { 'desc' }
it { is_expected.to eq([tartine_dossier, beurre_dossier, nothing_dossier].map(&:id)) }
end
end
end
context 'for type_de_champ_private table' do
context 'with no revisions' do
let(:column) { procedure.find_column(label: procedure.active_revision.types_de_champ_private.first.libelle) }
let(:biere_dossier) { create(:dossier, procedure: procedure) }
let(:vin_dossier) { create(:dossier, procedure: procedure) }
before do
biere_dossier.project_champs_private.first.update(value: 'biere')
vin_dossier.project_champs_private.first.update(value: 'vin')
end
context 'asc' do
let(:order) { 'asc' }
it { is_expected.to eq([biere_dossier, vin_dossier].map(&:id)) }
end
context 'desc' do
let(:order) { 'desc' }
it { is_expected.to eq([vin_dossier, biere_dossier].map(&:id)) }
end
end
end
context 'for individual table' do
let(:order) { 'asc' } # Desc works the same, no extra test required
let(:procedure) { create(:procedure, :for_individual) }
let!(:first_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'M', prenom: 'Alain', nom: 'Antonelli')) }
let!(:last_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'Mme', prenom: 'Zora', nom: 'Zemmour')) }
context 'for gender column' do
let(:column) { procedure.find_column(label: 'Civilité') }
it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) }
end
context 'for prenom column' do
let(:column) { procedure.find_column(label: 'Prénom') }
it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) }
end
context 'for nom column' do
let(:column) { procedure.find_column(label: 'Nom') }
it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) }
end
end
context 'for followers_instructeurs table' do
let(:order) { 'asc' } # Desc works the same, no extra test required
let!(:dossier_z) { create(:dossier, :en_construction, procedure: procedure) }
let!(:dossier_a) { create(:dossier, :en_construction, procedure: procedure) }
let!(:dossier_without_instructeur) { create(:dossier, :en_construction, procedure: procedure) }
before do
create(:follow, dossier: dossier_z, instructeur: create(:instructeur, email: 'zythum@exemple.fr'))
create(:follow, dossier: dossier_a, instructeur: create(:instructeur, email: 'abaca@exemple.fr'))
create(:follow, dossier: dossier_a, instructeur: create(:instructeur, email: 'abaca2@exemple.fr'))
end
context 'for email column' do
let(:column) { procedure.find_column(label: 'Email instructeur') }
it { is_expected.to eq([dossier_a, dossier_z, dossier_without_instructeur].map(&:id)) }
end
end
context 'for avis table' do
let(:column) { procedure.find_column(label: 'Avis oui/non') }
let(:order) { 'asc' }
let!(:dossier_yes) { create(:dossier, procedure:) }
let!(:dossier_no) { create(:dossier, procedure:) }
before do
create_list(:avis, 2, dossier: dossier_yes, question_answer: true)
create(:avis, dossier: dossier_no, question_answer: true)
create(:avis, dossier: dossier_no, question_answer: false)
end
it { is_expected.to eq([dossier_no, dossier_yes].map(&:id)) }
end
context 'for other tables' do
# All other columns and tables work the same so its ok to test only one
let(:column) { procedure.find_column(label: 'Code postal') }
let(:order) { 'asc' } # Desc works the same, no extra test required
let!(:huitieme_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '75008')) }
let!(:vingtieme_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '75020')) }
it { is_expected.to eq([huitieme_dossier, vingtieme_dossier].map(&:id)) }
end
end
describe '#filtered_ids' do
let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to, filters: { "suivis" => filter }) }
subject { procedure_presentation.send(:filtered_ids, procedure.dossiers.joins(:user), 'suivis') }
context 'for self table' do
context 'for created_at column' do
let(:filter) { [{ 'table' => 'self', 'column' => 'created_at', 'value' => '18/9/2018' }] }
let!(:kept_dossier) { create(:dossier, procedure: procedure, created_at: Time.zone.local(2018, 9, 18, 14, 28)) }
let!(:discarded_dossier) { create(:dossier, procedure: procedure, created_at: Time.zone.local(2018, 9, 17, 23, 59)) }
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'for en_construction_at column' do
let(:filter) { [{ 'table' => 'self', 'column' => 'en_construction_at', 'value' => '17/10/2018' }] }
let!(:kept_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 17)) }
let!(:discarded_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2013, 1, 1)) }
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'for updated_at column' do
let(:filter) { [{ 'table' => 'self', 'column' => 'updated_at', 'value' => '18/9/2018' }] }
let(:kept_dossier) { create(:dossier, procedure: procedure) }
let(:discarded_dossier) { create(:dossier, procedure: procedure) }
before do
kept_dossier.touch(time: Time.zone.local(2018, 9, 18, 14, 28))
discarded_dossier.touch(time: Time.zone.local(2018, 9, 17, 23, 59))
end
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'for updated_since column' do
let(:filter) { [{ 'table' => 'self', 'column' => 'updated_since', 'value' => '18/9/2018' }] }
let(:kept_dossier) { create(:dossier, procedure: procedure) }
let(:later_dossier) { create(:dossier, procedure: procedure) }
let(:discarded_dossier) { create(:dossier, procedure: procedure) }
before do
kept_dossier.touch(time: Time.zone.local(2018, 9, 18, 14, 28))
later_dossier.touch(time: Time.zone.local(2018, 9, 19, 14, 28))
discarded_dossier.touch(time: Time.zone.local(2018, 9, 17, 14, 28))
end
it { is_expected.to match_array([kept_dossier.id, later_dossier.id]) }
end
context 'for sva_svr_decision_before column' do
before do
travel_to Time.zone.local(2023, 6, 10, 10)
end
let(:procedure) { create(:procedure, :published, :sva, types_de_champ_public: [{}], types_de_champ_private: [{}]) }
let(:filter) { [{ 'table' => 'self', 'column' => 'sva_svr_decision_before', 'value' => '15/06/2023' }] }
let!(:kept_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current) }
let!(:later_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current + 2.days) }
let!(:discarded_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current + 10.days) }
let!(:en_construction_dossier) { create(:dossier, :en_construction, procedure:, sva_svr_decision_on: Date.current + 2.days) }
let!(:accepte_dossier) { create(:dossier, :accepte, procedure:, sva_svr_decision_on: Date.current + 2.days) }
it { is_expected.to match_array([kept_dossier.id, later_dossier.id, en_construction_dossier.id]) }
end
context 'ignore time of day' do
let(:filter) { [{ 'table' => 'self', 'column' => 'en_construction_at', 'value' => '17/10/2018 19:30' }] }
let!(:kept_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 17, 15, 56)) }
let!(:discarded_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 18, 5, 42)) }
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'for a malformed date' do
context 'when its a string' do
let(:filter) { [{ 'table' => 'self', 'column' => 'updated_at', 'value' => 'malformed date' }] }
it { is_expected.to match([]) }
end
context 'when its a number' do
let(:filter) { [{ 'table' => 'self', 'column' => 'updated_at', 'value' => '177500' }] }
it { is_expected.to match([]) }
end
end
context 'with multiple search values' do
let(:filter) do
[
{ 'table' => 'self', 'column' => 'en_construction_at', 'value' => '17/10/2018' },
{ 'table' => 'self', 'column' => 'en_construction_at', 'value' => '19/10/2018' }
]
end
let!(:kept_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 17)) }
let!(:other_kept_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 19)) }
let!(:discarded_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2013, 1, 1)) }
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
context 'with multiple state filters' do
let(:filter) do
[
{ 'table' => 'self', 'column' => 'state', 'value' => 'en_construction' },
{ 'table' => 'self', 'column' => 'state', 'value' => 'en_instruction' }
]
end
let!(:kept_dossier) { create(:dossier, :en_construction, procedure: procedure) }
let!(:other_kept_dossier) { create(:dossier, :en_instruction, procedure: procedure) }
let!(:discarded_dossier) { create(:dossier, :accepte, procedure: procedure) }
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
context 'with en_construction state filters' do
let(:filter) do
[
{ 'table' => 'self', 'column' => 'state', 'value' => 'en_construction' }
]
end
let!(:en_construction) { create(:dossier, :en_construction, procedure: procedure) }
let!(:en_construction_with_correction) { create(:dossier, :en_construction, procedure: procedure) }
let!(:correction) { create(:dossier_correction, dossier: en_construction_with_correction) }
it 'excludes dossier en construction with pending correction' do
is_expected.to contain_exactly(en_construction.id)
end
end
end
context 'for type_de_champ table' do
let(:filter) { [{ 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value' => 'keep' }] }
let(:kept_dossier) { create(:dossier, procedure: procedure) }
let(:discarded_dossier) { create(:dossier, procedure: procedure) }
let(:type_de_champ) { procedure.active_revision.types_de_champ_public.first }
context 'with single value' do
before do
kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'keep me')
discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'discard me')
end
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'with multiple search values' do
let(:filter) do
[
{ 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value' => 'keep' },
{ 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value' => 'and' }
]
end
let(:other_kept_dossier) { create(:dossier, procedure: procedure) }
before do
kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'keep me')
discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'discard me')
other_kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'and me too')
end
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
context 'with yes_no type_de_champ' do
let(:filter) { [{ 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value' => 'true' }] }
let(:types_de_champ_public) { [{ type: :yes_no }] }
before do
kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'true')
discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'false')
end
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'with departement type_de_champ' do
let(:filter) { [{ 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value_column' => :external_id, 'value' => '13' }] }
let(:types_de_champ_public) { [{ type: :departements }] }
before do
kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: '13')
discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: '69')
end
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'with enum type_de_champ' do
let(:filter_value) { 'Favorable' }
let(:filter) { [{ 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value_column' => :value, 'value' => filter_value }] }
let(:types_de_champ_public) { [{ type: :drop_down_list, options: ['Favorable', 'Defavorable'] }] }
before do
kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'Favorable')
discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: 'Defavorable')
end
it { is_expected.to contain_exactly(kept_dossier.id) }
end
end
context 'for type_de_champ_private table' do
let(:filter) { [{ 'table' => 'type_de_champ', 'column' => type_de_champ_private.stable_id.to_s, 'value' => 'keep' }] }
let(:kept_dossier) { create(:dossier, procedure: procedure) }
let(:discarded_dossier) { create(:dossier, procedure: procedure) }
let(:type_de_champ_private) { procedure.active_revision.types_de_champ_private.first }
before do
kept_dossier.champs.find_by(stable_id: type_de_champ_private.stable_id).update(value: 'keep me')
discarded_dossier.champs.find_by(stable_id: type_de_champ_private.stable_id).update(value: 'discard me')
end
it { is_expected.to contain_exactly(kept_dossier.id) }
context 'with multiple search values' do
let(:filter) do
[
{ 'table' => 'type_de_champ', 'column' => type_de_champ_private.stable_id.to_s, 'value' => 'keep' },
{ 'table' => 'type_de_champ', 'column' => type_de_champ_private.stable_id.to_s, 'value' => 'and' }
]
end
let(:other_kept_dossier) { create(:dossier, procedure: procedure) }
before do
other_kept_dossier.champs.find_by(stable_id: type_de_champ_private.stable_id).update(value: 'and me too')
end
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
end
context 'for type_de_champ using AddressableColumnConcern' do
let(:types_de_champ_public) { [{ type: :rna, stable_id: 1 }] }
let(:type_de_champ) { procedure.active_revision.types_de_champ.first }
let(:available_columns) { type_de_champ.columns(procedure_id: procedure.id) }
let(:column) { available_columns.find { _1.value_column == value_column_searched } }
let(:filter) { [column.to_json.merge({ "value" => value })] }
let(:kept_dossier) { create(:dossier, procedure: procedure) }
context "when searching by postal_code (text)" do
let(:value) { "60580" }
let(:value_column_searched) { ['postal_code'] }
before do
kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "postal_code" => value })
create(:dossier, procedure: procedure).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "postal_code" => "unknown" })
end
it { is_expected.to contain_exactly(kept_dossier.id) }
it 'describes column' do
expect(column.type).to eq(:text)
expect(column.options_for_select).to eq([])
end
end
context "when searching by departement_code (enum)" do
let(:value) { "99" }
let(:value_column_searched) { ['departement_code'] }
before do
kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "departement_code" => value })
create(:dossier, procedure: procedure).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "departement_code" => "unknown" })
end
it { is_expected.to contain_exactly(kept_dossier.id) }
it 'describes column' do
expect(column.type).to eq(:enum)
expect(column.options_for_select.first).to eq(["99 Etranger", "99"])
end
end
context "when searching by region_name" do
let(:value) { "60" }
let(:value_column_searched) { ['region_name'] }
before do
kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "region_name" => value })
create(:dossier, procedure: procedure).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "region_name" => "unknown" })
end
it { is_expected.to contain_exactly(kept_dossier.id) }
it 'describes column' do
expect(column.type).to eq(:enum)
expect(column.options_for_select.first).to eq(["Auvergne-Rhône-Alpes", "Auvergne-Rhône-Alpes"])
end
end
end
context 'for etablissement table' do
context 'for entreprise_date_creation column' do
let(:filter) { [{ 'table' => 'etablissement', 'column' => 'entreprise_date_creation', 'value' => '21/6/2018' }] }
let!(:kept_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2018, 6, 21))) }
let!(:discarded_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2008, 6, 21))) }
it { is_expected.to contain_exactly(kept_dossier.id) }
context 'with multiple search values' do
let(:filter) do
[
{ 'table' => 'etablissement', 'column' => 'entreprise_date_creation', 'value' => '21/6/2016' },
{ 'table' => 'etablissement', 'column' => 'entreprise_date_creation', 'value' => '21/6/2018' }
]
end
let!(:other_kept_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2016, 6, 21))) }
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
end
context 'for code_postal column' do
# All columns except entreprise_date_creation work exacly the same, just testing one
let(:filter) { [{ 'table' => 'etablissement', 'column' => 'code_postal', 'value' => '75017' }] }
let!(:kept_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '75017')) }
let!(:discarded_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '25000')) }
it { is_expected.to contain_exactly(kept_dossier.id) }
context 'with multiple search values' do
let(:filter) do
[
{ 'table' => 'etablissement', 'column' => 'code_postal', 'value' => '75017' },
{ 'table' => 'etablissement', 'column' => 'code_postal', 'value' => '88100' }
]
end
let!(:other_kept_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '88100')) }
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
end
end
context 'for user table' do
let(:filter) { [{ 'table' => 'user', 'column' => 'email', 'value' => 'keepmail' }] }
let!(:kept_dossier) { create(:dossier, procedure: procedure, user: create(:user, email: 'me@keepmail.com')) }
let!(:discarded_dossier) { create(:dossier, procedure: procedure, user: create(:user, email: 'me@discard.com')) }
it { is_expected.to contain_exactly(kept_dossier.id) }
context 'with multiple search values' do
let(:filter) do
[
{ 'table' => 'user', 'column' => 'email', 'value' => 'keepmail' },
{ 'table' => 'user', 'column' => 'email', 'value' => 'beta.gouv.fr' }
]
end
let!(:other_kept_dossier) { create(:dossier, procedure: procedure, user: create(:user, email: 'bazinga@beta.gouv.fr')) }
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
end
context 'for individual table' do
let(:procedure) { create(:procedure, :for_individual) }
let!(:kept_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'Mme', prenom: 'Josephine', nom: 'Baker')) }
let!(:discarded_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'M', prenom: 'Jean', nom: 'Tremblay')) }
context 'for gender column' do
let(:filter) { [{ 'table' => 'individual', 'column' => 'gender', 'value' => 'Mme' }] }
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'for prenom column' do
let(:filter) { [{ 'table' => 'individual', 'column' => 'prenom', 'value' => 'Josephine' }] }
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'for nom column' do
let(:filter) { [{ 'table' => 'individual', 'column' => 'nom', 'value' => 'Baker' }] }
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'with multiple search values' do
let(:filter) do
[
{ 'table' => 'individual', 'column' => 'prenom', 'value' => 'Josephine' },
{ 'table' => 'individual', 'column' => 'prenom', 'value' => 'Romuald' }
]
end
let!(:other_kept_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'M', prenom: 'Romuald', nom: 'Pistis')) }
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
end
context 'for followers_instructeurs table' do
let(:filter) { [{ 'table' => 'followers_instructeurs', 'column' => 'email', 'value' => 'keepmail' }] }
let!(:kept_dossier) { create(:dossier, procedure: procedure) }
let!(:discarded_dossier) { create(:dossier, procedure: procedure) }
before do
create(:follow, dossier: kept_dossier, instructeur: create(:instructeur, email: 'me@keepmail.com'))
create(:follow, dossier: discarded_dossier, instructeur: create(:instructeur, email: 'me@discard.com'))
end
it { is_expected.to contain_exactly(kept_dossier.id) }
context 'with multiple search values' do
let(:filter) do
[
{ 'table' => 'followers_instructeurs', 'column' => 'email', 'value' => 'keepmail' },
{ 'table' => 'followers_instructeurs', 'column' => 'email', 'value' => 'beta.gouv.fr' }
]
end
let(:other_kept_dossier) { create(:dossier, procedure: procedure) }
before do
create(:follow, dossier: other_kept_dossier, instructeur: create(:instructeur, email: 'bazinga@beta.gouv.fr'))
end
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
end
context 'for groupe_instructeur table' do
let(:filter) { [{ 'table' => 'groupe_instructeur', 'column' => 'id', 'value' => procedure.defaut_groupe_instructeur.id.to_s }] }
let!(:gi_2) { create(:groupe_instructeur, label: 'gi2', procedure: procedure) }
let!(:gi_3) { create(:groupe_instructeur, label: 'gi3', procedure: procedure) }
let!(:kept_dossier) { create(:dossier, :en_construction, procedure: procedure) }
let!(:discarded_dossier) { create(:dossier, :en_construction, procedure: procedure, groupe_instructeur: gi_2) }
it { is_expected.to contain_exactly(kept_dossier.id) }
context 'with multiple search values' do
let(:filter) do
[
{ 'table' => 'groupe_instructeur', 'column' => 'id', 'value' => procedure.defaut_groupe_instructeur.id.to_s },
{ 'table' => 'groupe_instructeur', 'column' => 'id', 'value' => gi_3.id.to_s }
]
end
let!(:other_kept_dossier) { create(:dossier, procedure: procedure, groupe_instructeur: gi_3) }
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
it 'validates the filter_column objects' do
expect(build(:procedure_presentation, "suivis_filters": [{ id: { column_id: "user/email", procedure_id: }, "filter": "not so long filter value" }])).to be_valid
expect(build(:procedure_presentation, "suivis_filters": [{ id: { column_id: "user/email", procedure_id: }, "filter": "exceedingly long filter value" * 400 }])).to be_invalid
end
end
end
describe "#human_value_for_filter" do
let(:filters) { { "suivis" => [{ label: "label1", table: "type_de_champ", column: first_type_de_champ_id, "value" => "true" }] } }
let(:filtered_column) { to_filter([first_type_de_champ.libelle, "true"]) }
subject { procedure_presentation.human_value_for_filter(procedure_presentation.filters["suivis"].first) }
subject do
procedure_presentation.human_value_for_filter(filtered_column)
end
context 'when type_de_champ text' do
it 'should passthrough value' do
@ -791,7 +77,7 @@ describe ProcedurePresentation do
end
context 'when filter is state' do
let(:filters) { { "suivis" => [{ table: "self", column: "state", "value" => "en_construction" }] } }
let(:filtered_column) { to_filter(['Statut', "en_construction"]) }
it 'should get i18n value' do
expect(subject).to eq("En construction")
@ -799,7 +85,7 @@ describe ProcedurePresentation do
end
context 'when filter is a date' do
let(:filters) { { "suivis" => [{ table: "self", column: "en_instruction_at", "value" => "15/06/2023" }] } }
let(:filtered_column) { to_filter(['Créé le', "15/06/2023"]) }
it 'should get formatted value' do
expect(subject).to eq("15/06/2023")
@ -807,129 +93,10 @@ describe ProcedurePresentation do
end
end
describe "#add_filter" do
let(:filters) { { "suivis" => [] } }
context 'when type_de_champ yes_no' do
let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :yes_no, libelle: 'oui ou non' }]) }
it 'should downcase and transform value' do
column_id = procedure.find_column(label: 'oui ou non').id
procedure_presentation.add_filter("suivis", column_id, "Oui")
expect(procedure_presentation.filters).to eq({
"suivis" =>
[
{ "label" => first_type_de_champ.libelle, "table" => "type_de_champ", "column" => first_type_de_champ_id, "value" => "true", "value_column" => "value" }
]
})
suivis = procedure_presentation.suivis_filters.map { [_1['id'], _1['filter']] }
expect(suivis).to eq([[{ "column_id" => "type_de_champ/#{first_type_de_champ_id}", "procedure_id" => procedure.id }, "true"]])
end
end
context 'when type_de_champ text' do
let(:filters) { { "suivis" => [] } }
let(:column_id) { procedure.find_column(label: first_type_de_champ.libelle).id }
it 'should passthrough value' do
procedure_presentation.add_filter("suivis", column_id, "Oui")
expect(procedure_presentation.filters).to eq({
"suivis" => [
{ "label" => first_type_de_champ.libelle, "table" => "type_de_champ", "column" => first_type_de_champ_id, "value" => "Oui", "value_column" => "value" }
]
})
end
end
context 'when type_de_champ departements' do
let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :departements }]) }
let(:column_id) { procedure.find_column(label: first_type_de_champ.libelle).id }
let(:filters) { { "suivis" => [] } }
it 'should set value_column' do
procedure_presentation.add_filter("suivis", column_id, "13")
expect(procedure_presentation.filters).to eq({
"suivis" => [
{ "label" => first_type_de_champ.libelle, "table" => "type_de_champ", "column" => first_type_de_champ_id, "value" => "13", "value_column" => "external_id" }
]
})
end
end
end
describe "#remove_filter" do
let(:filters) { { "suivis" => [] } }
let(:email_column_id) { procedure.find_column(label: 'Demandeur').id }
before do
procedure_presentation.add_filter("suivis", email_column_id, "a@a.com")
end
it 'should remove filter' do
expect(procedure_presentation.filters).to eq({ "suivis" => [{ "column" => "email", "label" => "Demandeur", "table" => "user", "value" => "a@a.com", "value_column" => "value" }] })
expect(procedure_presentation.suivis_filters).to eq([{ "filter" => "a@a.com", "id" => { "column_id" => "user/email", "procedure_id" => procedure.id } }])
procedure_presentation.remove_filter("suivis", email_column_id, "a@a.com")
procedure_presentation.reload
expect(procedure_presentation.filters).to eq({ "suivis" => [] })
expect(procedure_presentation.suivis_filters).to eq([])
end
end
describe '#filtered_sorted_ids' do
let(:procedure_presentation) { create(:procedure_presentation, assign_to:) }
let(:dossier_1) { create(:dossier) }
let(:dossier_2) { create(:dossier) }
let(:dossier_3) { create(:dossier) }
let(:dossiers) { Dossier.where(id: [dossier_1, dossier_2, dossier_3].map(&:id)) }
let(:sorted_ids) { [dossier_2, dossier_3, dossier_1].map(&:id) }
let(:statut) { 'tous' }
subject { procedure_presentation.filtered_sorted_ids(dossiers, statut) }
context 'with no filters' do
let(:statut) { 'suivis' }
let(:dossiers) { procedure.dossiers }
before do
create(:follow, dossier: en_construction_dossier, instructeur: procedure_presentation.instructeur)
create(:follow, dossier: accepte_dossier, instructeur: procedure_presentation.instructeur)
end
let(:en_construction_dossier) { create(:dossier, :en_construction, procedure:) }
let(:accepte_dossier) { create(:dossier, :accepte, procedure:) }
it { is_expected.to contain_exactly(en_construction_dossier.id) }
end
context 'with mocked sorted_ids' do
before do
expect(procedure_presentation).to receive(:sorted_ids).and_return(sorted_ids)
end
it { is_expected.to eq(sorted_ids) }
context 'when a filter is present' do
let(:filtered_ids) { [dossier_1, dossier_2, dossier_3].map(&:id) }
before do
procedure_presentation.filters['tous'] = 'some_filter'
expect(procedure_presentation).to receive(:filtered_ids).and_return(filtered_ids)
end
it { is_expected.to eq(sorted_ids) }
end
end
end
describe '#update_displayed_fields' do
let(:en_construction_column) { procedure.find_column(label: 'En construction le') }
let(:mise_a_jour_column) { procedure.find_column(label: 'Mis à jour le') }
let(:procedure_presentation) do
create(:procedure_presentation, assign_to:).tap do |pp|
pp.update(sorted_column: SortedColumn.new(column: procedure.find_column(label: 'Demandeur'), order: 'desc'))
@ -937,24 +104,19 @@ describe ProcedurePresentation do
end
subject do
procedure_presentation.update_displayed_fields([
procedure.find_column(label: 'En construction le').id,
procedure.find_column(label: 'Mis à jour le').id
procedure_presentation.update(displayed_columns: [
en_construction_column.id, mise_a_jour_column.id
])
end
it 'should update displayed_fields' do
expect(procedure_presentation.displayed_columns).to eq([])
expect(procedure_presentation.displayed_columns).to eq(procedure.default_displayed_columns)
subject
expect(procedure_presentation.displayed_columns).to eq([
{ "column_id" => "self/en_construction_at", "procedure_id" => procedure.id },
{ "column_id" => "self/updated_at", "procedure_id" => procedure.id }
en_construction_column, mise_a_jour_column
])
expect(procedure_presentation.sorted_column).to eq(procedure.default_sorted_column)
expect(procedure_presentation.sorted_column.order).to eq('desc')
end
end
end

View file

@ -0,0 +1,709 @@
# frozen_string_literal: true
describe DossierFilterService do
def to_filter((label, filter)) = FilteredColumn.new(column: procedure.find_column(label:), filter:)
describe '.filtered_sorted_ids' do
let(:procedure) { create(:procedure) }
let(:instructeur) { create(:instructeur) }
let(:dossiers) { procedure.dossiers }
let(:statut) { 'suivis' }
let(:filters) { [] }
let(:sorted_columns) { procedure.default_sorted_column }
subject { described_class.filtered_sorted_ids(dossiers, statut, filters, sorted_columns, instructeur) }
context 'with no filters' do
let(:en_construction_dossier) { create(:dossier, :en_construction, procedure:) }
let(:accepte_dossier) { create(:dossier, :accepte, procedure:) }
before do
create(:follow, dossier: en_construction_dossier, instructeur:)
create(:follow, dossier: accepte_dossier, instructeur:)
end
it { is_expected.to contain_exactly(en_construction_dossier.id) }
end
context 'with mocked sorted_ids' do
let(:dossier_1) { create(:dossier) }
let(:dossier_2) { create(:dossier) }
let(:dossier_3) { create(:dossier) }
let(:dossiers) { Dossier.where(id: [dossier_1, dossier_2, dossier_3].map(&:id)) }
let(:sorted_ids) { [dossier_2, dossier_3, dossier_1].map(&:id) }
before do
expect(described_class).to receive(:sorted_ids).and_return(sorted_ids)
end
it { is_expected.to eq(sorted_ids) }
context 'when a filter is present' do
let(:filtered_ids) { [dossier_1, dossier_2, dossier_3].map(&:id) }
let(:filters) { [to_filter(['Statut', 'en_construction'])] }
before do
expect(described_class).to receive(:filtered_ids).and_return(filtered_ids)
end
it { is_expected.to eq(sorted_ids) }
end
end
end
describe '#sorted_ids' do
let(:procedure) { create(:procedure, :published, types_de_champ_public:, types_de_champ_private: [{}]) }
let(:types_de_champ_public) { [{}] }
let(:first_type_de_champ) { assign_to.procedure.active_revision.types_de_champ_public.first }
let(:dossiers) { procedure.dossiers }
let(:instructeur) { create(:instructeur) }
let(:assign_to) { create(:assign_to, procedure:, instructeur:) }
let(:sorted_column) { SortedColumn.new(column:, order:) }
subject { described_class.send(:sorted_ids, dossiers, sorted_column, instructeur, dossiers.count) }
context 'for notifications table' do
let(:column) { procedure.notifications_column }
let!(:notified_dossier) { create(:dossier, :en_construction, procedure:) }
let!(:recent_dossier) { create(:dossier, :en_construction, procedure:) }
let!(:older_dossier) { create(:dossier, :en_construction, procedure:) }
before do
notified_dossier.update!(last_champ_updated_at: Time.zone.local(2018, 9, 20))
create(:follow, instructeur: instructeur, dossier: notified_dossier, demande_seen_at: Time.zone.local(2018, 9, 10))
notified_dossier.touch(time: Time.zone.local(2018, 9, 20))
recent_dossier.touch(time: Time.zone.local(2018, 9, 25))
older_dossier.touch(time: Time.zone.local(2018, 5, 13))
end
context 'in ascending order' do
let(:order) { 'asc' }
it { is_expected.to eq([older_dossier, recent_dossier, notified_dossier].map(&:id)) }
end
context 'in descending order' do
let(:order) { 'desc' }
it { is_expected.to eq([notified_dossier, recent_dossier, older_dossier].map(&:id)) }
end
context 'with a dossier terminé' do
let!(:notified_dossier) { create(:dossier, :accepte, procedure:) }
let(:order) { 'desc' }
it { is_expected.to eq([notified_dossier, recent_dossier, older_dossier].map(&:id)) }
end
end
context 'for self table' do
let(:order) { 'asc' } # Desc works the same, no extra test required
context 'for created_at column' do
let!(:column) { procedure.find_column(label: 'Créé le') }
let!(:recent_dossier) { Timecop.freeze(Time.zone.local(2018, 10, 17)) { create(:dossier, procedure:) } }
let!(:older_dossier) { Timecop.freeze(Time.zone.local(2003, 11, 11)) { create(:dossier, procedure:) } }
it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) }
end
context 'for en_construction_at column' do
let!(:column) { procedure.find_column(label: 'En construction le') }
let!(:recent_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17)) }
let!(:older_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2013, 1, 1)) }
it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) }
end
context 'for updated_at column' do
let(:column) { procedure.find_column(label: 'Mis à jour le') }
let(:recent_dossier) { create(:dossier, procedure:) }
let(:older_dossier) { create(:dossier, procedure:) }
before do
recent_dossier.touch(time: Time.zone.local(2018, 9, 25))
older_dossier.touch(time: Time.zone.local(2018, 5, 13))
end
it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) }
end
end
context 'for type_de_champ table' do
context 'with no revisions' do
let(:column) { procedure.find_column(label: first_type_de_champ.libelle) }
let(:beurre_dossier) { create(:dossier, procedure:) }
let(:tartine_dossier) { create(:dossier, procedure:) }
before do
beurre_dossier.project_champs_public.first.update(value: 'beurre')
tartine_dossier.project_champs_public.first.update(value: 'tartine')
end
context 'asc' do
let(:order) { 'asc' }
it { is_expected.to eq([beurre_dossier, tartine_dossier].map(&:id)) }
end
context 'desc' do
let(:order) { 'desc' }
it { is_expected.to eq([tartine_dossier, beurre_dossier].map(&:id)) }
end
end
context 'with a revision adding a new type_de_champ' do
let!(:tdc) { { type_champ: :text, libelle: 'nouveau champ' } }
let(:column) { procedure.find_column(label: 'nouveau champ') }
let!(:nothing_dossier) { create(:dossier, procedure:) }
let!(:beurre_dossier) { create(:dossier, procedure:) }
let!(:tartine_dossier) { create(:dossier, procedure:) }
before do
nothing_dossier
procedure.draft_revision.add_type_de_champ(tdc)
procedure.publish_revision!
beurre_dossier.project_champs_public.last.update(value: 'beurre')
tartine_dossier.project_champs_public.last.update(value: 'tartine')
end
context 'asc' do
let(:order) { 'asc' }
it { is_expected.to eq([nothing_dossier, beurre_dossier, tartine_dossier].map(&:id)) }
end
context 'desc' do
let(:order) { 'desc' }
it { is_expected.to eq([tartine_dossier, beurre_dossier, nothing_dossier].map(&:id)) }
end
end
end
context 'for type_de_champ_private table' do
context 'with no revisions' do
let(:column) { procedure.find_column(label: procedure.active_revision.types_de_champ_private.first.libelle) }
let(:biere_dossier) { create(:dossier, procedure:) }
let(:vin_dossier) { create(:dossier, procedure:) }
before do
biere_dossier.project_champs_private.first.update(value: 'biere')
vin_dossier.project_champs_private.first.update(value: 'vin')
end
context 'asc' do
let(:order) { 'asc' }
it { is_expected.to eq([biere_dossier, vin_dossier].map(&:id)) }
end
context 'desc' do
let(:order) { 'desc' }
it { is_expected.to eq([vin_dossier, biere_dossier].map(&:id)) }
end
end
end
context 'for individual table' do
let(:order) { 'asc' } # Desc works the same, no extra test required
let(:procedure) { create(:procedure, :for_individual) }
let!(:first_dossier) { create(:dossier, procedure:, individual: build(:individual, gender: 'M', prenom: 'Alain', nom: 'Antonelli')) }
let!(:last_dossier) { create(:dossier, procedure:, individual: build(:individual, gender: 'Mme', prenom: 'Zora', nom: 'Zemmour')) }
context 'for gender column' do
let(:column) { procedure.find_column(label: 'Civilité') }
it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) }
end
context 'for prenom column' do
let(:column) { procedure.find_column(label: 'Prénom') }
it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) }
end
context 'for nom column' do
let(:column) { procedure.find_column(label: 'Nom') }
it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) }
end
end
context 'for followers_instructeurs table' do
let(:order) { 'asc' } # Desc works the same, no extra test required
let!(:dossier_z) { create(:dossier, :en_construction, procedure:) }
let!(:dossier_a) { create(:dossier, :en_construction, procedure:) }
let!(:dossier_without_instructeur) { create(:dossier, :en_construction, procedure:) }
before do
create(:follow, dossier: dossier_z, instructeur: create(:instructeur, email: 'zythum@exemple.fr'))
create(:follow, dossier: dossier_a, instructeur: create(:instructeur, email: 'abaca@exemple.fr'))
create(:follow, dossier: dossier_a, instructeur: create(:instructeur, email: 'abaca2@exemple.fr'))
end
context 'for email column' do
let(:column) { procedure.find_column(label: 'Email instructeur') }
it { is_expected.to eq([dossier_a, dossier_z, dossier_without_instructeur].map(&:id)) }
end
end
context 'for avis table' do
let(:column) { procedure.find_column(label: 'Avis oui/non') }
let(:order) { 'asc' }
let!(:dossier_yes) { create(:dossier, procedure:) }
let!(:dossier_no) { create(:dossier, procedure:) }
before do
create_list(:avis, 2, dossier: dossier_yes, question_answer: true)
create(:avis, dossier: dossier_no, question_answer: true)
create(:avis, dossier: dossier_no, question_answer: false)
end
it { is_expected.to eq([dossier_no, dossier_yes].map(&:id)) }
end
context 'for other tables' do
# All other columns and tables work the same so its ok to test only one
let(:column) { procedure.find_column(label: 'Code postal') }
let(:order) { 'asc' } # Desc works the same, no extra test required
let!(:huitieme_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '75008')) }
let!(:vingtieme_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '75020')) }
it { is_expected.to eq([huitieme_dossier, vingtieme_dossier].map(&:id)) }
end
end
describe '#filtered_ids' do
let(:procedure) { create(:procedure, types_de_champ_public:, types_de_champ_private:) }
let(:types_de_champ_public) { [{}] }
let(:types_de_champ_private) { [{}] }
let(:dossiers) { procedure.dossiers }
let(:filtered_columns) { filters.map { to_filter(_1) } }
let(:filters) { [filter] }
subject { described_class.send(:filtered_ids, dossiers.joins(:user), filtered_columns) }
context 'for self table' do
context 'for created_at column' do
let(:filter) { ['Créé le', '18/9/2018'] }
let!(:kept_dossier) { create(:dossier, procedure:, created_at: Time.zone.local(2018, 9, 18, 14, 28)) }
let!(:discarded_dossier) { create(:dossier, procedure:, created_at: Time.zone.local(2018, 9, 17, 23, 59)) }
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'for en_construction_at column' do
let(:filter) { ['En construction le', '17/10/2018'] }
let!(:kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17)) }
let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2013, 1, 1)) }
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'for updated_at column' do
let(:filter) { ['Mis à jour le', '18/9/2018'] }
let(:kept_dossier) { create(:dossier, procedure:) }
let(:discarded_dossier) { create(:dossier, procedure:) }
before do
kept_dossier.touch(time: Time.zone.local(2018, 9, 18, 14, 28))
discarded_dossier.touch(time: Time.zone.local(2018, 9, 17, 23, 59))
end
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'for updated_since column' do
let(:filter) { ['Mis à jour depuis', '18/9/2018'] }
let(:kept_dossier) { create(:dossier, procedure:) }
let(:later_dossier) { create(:dossier, procedure:) }
let(:discarded_dossier) { create(:dossier, procedure:) }
before do
kept_dossier.touch(time: Time.zone.local(2018, 9, 18, 14, 28))
later_dossier.touch(time: Time.zone.local(2018, 9, 19, 14, 28))
discarded_dossier.touch(time: Time.zone.local(2018, 9, 17, 14, 28))
end
it { is_expected.to match_array([kept_dossier.id, later_dossier.id]) }
end
context 'for sva_svr_decision_before column' do
before do
travel_to Time.zone.local(2023, 6, 10, 10)
end
let(:procedure) { create(:procedure, :published, :sva, types_de_champ_public: [{}], types_de_champ_private: [{}]) }
let(:filter) { ['Date décision SVA avant', '15/06/2023'] }
let!(:kept_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current) }
let!(:later_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current + 2.days) }
let!(:discarded_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current + 10.days) }
let!(:en_construction_dossier) { create(:dossier, :en_construction, procedure:, sva_svr_decision_on: Date.current + 2.days) }
let!(:accepte_dossier) { create(:dossier, :accepte, procedure:, sva_svr_decision_on: Date.current + 2.days) }
it { is_expected.to match_array([kept_dossier.id, later_dossier.id, en_construction_dossier.id]) }
end
context 'ignore time of day' do
let(:filter) { ['En construction le', '17/10/2018 19:30'] }
let!(:kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17, 15, 56)) }
let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 18, 5, 42)) }
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'for a malformed date' do
context 'when its a string' do
let(:filter) { ['Mis à jour le', 'malformed date'] }
it { is_expected.to match([]) }
end
context 'when its a number' do
let(:filter) { ['Mis à jour le', '177500'] }
it { is_expected.to match([]) }
end
end
context 'with multiple search values' do
let(:filters) { [['En construction le', '17/10/2018'], ['En construction le', '19/10/2018']] }
let!(:kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17)) }
let!(:other_kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 19)) }
let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2013, 1, 1)) }
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
context 'with multiple state filters' do
let(:filters) { [['Statut', 'en_construction'], ['Statut', 'en_instruction']] }
let!(:kept_dossier) { create(:dossier, :en_construction, procedure:) }
let!(:other_kept_dossier) { create(:dossier, :en_instruction, procedure:) }
let!(:discarded_dossier) { create(:dossier, :accepte, procedure:) }
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
context 'with en_construction state filters' do
let(:filter) { ['Statut', 'en_construction'] }
let!(:en_construction) { create(:dossier, :en_construction, procedure:) }
let!(:en_construction_with_correction) { create(:dossier, :en_construction, procedure:) }
let!(:correction) { create(:dossier_correction, dossier: en_construction_with_correction) }
it 'excludes dossier en construction with pending correction' do
is_expected.to contain_exactly(en_construction.id)
end
end
end
context 'for type_de_champ table' do
let(:filter) { [type_de_champ.libelle, 'keep'] }
let(:kept_dossier) { create(:dossier, procedure:) }
let(:discarded_dossier) { create(:dossier, procedure:) }
let(:type_de_champ) { procedure.active_revision.types_de_champ_public.first }
context 'with single value' do
before do
kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'keep me')
discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'discard me')
end
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'with multiple search values' do
let(:filters) { [[type_de_champ.libelle, 'keep'], [type_de_champ.libelle, 'and']] }
let(:other_kept_dossier) { create(:dossier, procedure:) }
before do
kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'keep me')
discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'discard me')
other_kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'and me too')
end
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
context 'with yes_no type_de_champ' do
let(:filter) { [type_de_champ.libelle, 'true'] }
let(:types_de_champ_public) { [{ type: :yes_no }] }
before do
kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'true')
discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'false')
end
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'with departement type_de_champ' do
let(:filter) { [type_de_champ.libelle, '13'] }
let(:types_de_champ_public) { [{ type: :departements }] }
before do
kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: '13')
discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: '69')
end
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'with enum type_de_champ' do
let(:filter) { [type_de_champ.libelle, 'Favorable'] }
let(:types_de_champ_public) { [{ type: :drop_down_list, options: ['Favorable', 'Defavorable'] }] }
before do
kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'Favorable')
discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: 'Defavorable')
end
it { is_expected.to contain_exactly(kept_dossier.id) }
end
end
context 'for type_de_champ_private table' do
let(:filter) { [type_de_champ_private.libelle, 'keep'] }
let(:kept_dossier) { create(:dossier, procedure:) }
let(:discarded_dossier) { create(:dossier, procedure:) }
let(:type_de_champ_private) { procedure.active_revision.types_de_champ_private.first }
before do
kept_dossier.champs.find_by(stable_id: type_de_champ_private.stable_id).update(value: 'keep me')
discarded_dossier.champs.find_by(stable_id: type_de_champ_private.stable_id).update(value: 'discard me')
end
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'for type_de_champ using AddressableColumnConcern' do
let(:column) { filtered_columns.first.column }
let(:types_de_champ_public) { [{ type: :rna, stable_id: 1, libelle: 'rna' }] }
let(:type_de_champ) { procedure.active_revision.types_de_champ.first }
let(:kept_dossier) { create(:dossier, procedure:) }
context "when searching by postal_code (text)" do
let(:value) { "60580" }
let(:filter) { ["rna code postal (5 chiffres)", value] }
before do
kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "postal_code" => value })
create(:dossier, procedure:).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "postal_code" => "unknown" })
end
it { is_expected.to contain_exactly(kept_dossier.id) }
it 'describes column' do
expect(column.type).to eq(:text)
expect(column.options_for_select).to eq([])
end
end
context "when searching by departement_code (enum)" do
let(:value) { "99" }
let(:filter) { ["rna département", value] }
before do
kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "departement_code" => value })
create(:dossier, procedure:).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "departement_code" => "unknown" })
end
it { is_expected.to contain_exactly(kept_dossier.id) }
it 'describes column' do
expect(column.type).to eq(:enum)
expect(column.options_for_select.first).to eq(["99 Etranger", "99"])
end
end
context "when searching by region_name" do
let(:value) { "60" }
let(:filter) { ["rna region", value] }
before do
kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "region_name" => value })
create(:dossier, procedure:).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "region_name" => "unknown" })
end
it { is_expected.to contain_exactly(kept_dossier.id) }
it 'describes column' do
expect(column.type).to eq(:enum)
expect(column.options_for_select.first).to eq(["Auvergne-Rhône-Alpes", "Auvergne-Rhône-Alpes"])
end
end
end
context 'for etablissement table' do
context 'for entreprise_date_creation column' do
let(:filter) { ['Date de création', '21/6/2018'] }
let!(:kept_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2018, 6, 21))) }
let!(:discarded_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2008, 6, 21))) }
it { is_expected.to contain_exactly(kept_dossier.id) }
context 'with multiple search values' do
let(:filters) { [['Date de création', '21/6/2016'], ['Date de création', '21/6/2018']] }
let!(:other_kept_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2016, 6, 21))) }
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
end
context 'for code_postal column' do
# All columns except entreprise_date_creation work exacly the same, just testing one
let(:filter) { ['Code postal', '75017'] }
let!(:kept_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '75017')) }
let!(:discarded_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '25000')) }
it { is_expected.to contain_exactly(kept_dossier.id) }
context 'with multiple search values' do
let(:filters) { [['Code postal', '75017'], ['Code postal', '88100']] }
let!(:other_kept_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '88100')) }
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
end
end
context 'for user table' do
let(:filter) { ['Demandeur', 'keepmail'] }
let!(:kept_dossier) { create(:dossier, procedure:, user: create(:user, email: 'me@keepmail.com')) }
let!(:discarded_dossier) { create(:dossier, procedure:, user: create(:user, email: 'me@discard.com')) }
it { is_expected.to contain_exactly(kept_dossier.id) }
context 'with multiple search values' do
let(:filters) { [['Demandeur', 'keepmail'], ['Demandeur', 'beta.gouv.fr']] }
let!(:other_kept_dossier) { create(:dossier, procedure:, user: create(:user, email: 'bazinga@beta.gouv.fr')) }
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
end
context 'for individual table' do
let(:procedure) { create(:procedure, :for_individual) }
let!(:kept_dossier) { create(:dossier, procedure:, individual: build(:individual, gender: 'Mme', prenom: 'Josephine', nom: 'Baker')) }
let!(:discarded_dossier) { create(:dossier, procedure:, individual: build(:individual, gender: 'M', prenom: 'Jean', nom: 'Tremblay')) }
context 'for gender column' do
let(:filter) { ['Civilité', 'Mme'] }
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'for prenom column' do
let(:filter) { ['Prénom', 'Josephine'] }
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'for nom column' do
let(:filter) { ['Nom', 'Baker'] }
it { is_expected.to contain_exactly(kept_dossier.id) }
end
context 'with multiple search values' do
let(:filters) { [['Prénom', 'Josephine'], ['Prénom', 'Romuald']] }
let!(:other_kept_dossier) { create(:dossier, procedure:, individual: build(:individual, gender: 'M', prenom: 'Romuald', nom: 'Pistis')) }
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
end
context 'for followers_instructeurs table' do
let(:filter) { ['Email instructeur', 'keepmail'] }
let!(:kept_dossier) { create(:dossier, procedure:) }
let!(:discarded_dossier) { create(:dossier, procedure:) }
before do
create(:follow, dossier: kept_dossier, instructeur: create(:instructeur, email: 'me@keepmail.com'))
create(:follow, dossier: discarded_dossier, instructeur: create(:instructeur, email: 'me@discard.com'))
end
it { is_expected.to contain_exactly(kept_dossier.id) }
context 'with multiple search values' do
let(:filters) { [['Email instructeur', 'keepmail'], ['Email instructeur', 'beta.gouv.fr']] }
let(:other_kept_dossier) { create(:dossier, procedure:) }
before do
create(:follow, dossier: other_kept_dossier, instructeur: create(:instructeur, email: 'bazinga@beta.gouv.fr'))
end
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
end
context 'for groupe_instructeur table' do
let(:filter) { ['Groupe instructeur', procedure.defaut_groupe_instructeur.id.to_s] }
let!(:gi_2) { create(:groupe_instructeur, label: 'gi2', procedure:) }
let!(:gi_3) { create(:groupe_instructeur, label: 'gi3', procedure:) }
let!(:kept_dossier) { create(:dossier, :en_construction, procedure:) }
let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, groupe_instructeur: gi_2) }
it { is_expected.to contain_exactly(kept_dossier.id) }
context 'with multiple search values' do
let(:filters) { [['Groupe instructeur', procedure.defaut_groupe_instructeur.id.to_s], ['Groupe instructeur', gi_3.id.to_s]] }
let!(:other_kept_dossier) { create(:dossier, procedure:, groupe_instructeur: gi_3) }
it 'returns every dossier that matches any of the search criteria for a given column' do
is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id)
end
end
end
end
end

View file

@ -2,7 +2,7 @@
describe DossierProjectionService do
describe '#project' do
subject { described_class.project(dossiers_ids, fields) }
subject { described_class.project(dossiers_ids, columns) }
context 'with multiple dossier' do
let!(:procedure) { create(:procedure, types_de_champ_public: [{}, { type: :linked_drop_down_list }]) }
@ -11,12 +11,9 @@ describe DossierProjectionService do
let!(:dossier_3) { create(:dossier, :en_instruction, procedure: procedure) }
let(:dossiers_ids) { [dossier_3.id, dossier_1.id, dossier_2.id] }
let(:fields) do
let(:columns) do
procedure.active_revision.types_de_champ_public.map do |type_de_champ|
{
"table" => "type_de_champ",
"column" => type_de_champ.stable_id.to_s
}
procedure.find_column(label: type_de_champ.libelle)
end
end
@ -55,12 +52,9 @@ describe DossierProjectionService do
let!(:dossier) { create(:dossier, procedure:) }
let(:dossiers_ids) { [dossier.id] }
let(:fields) do
let(:columns) do
[
{
"table" => "type_de_champ",
"column" => procedure.active_revision.types_de_champ_public[0].stable_id.to_s
}
procedure.find_column(label: procedure.active_revision.types_de_champ_public[0].libelle)
]
end
@ -78,38 +72,37 @@ describe DossierProjectionService do
end
context 'attributes by attributes' do
let(:fields) { [{ "table" => table, "column" => column }] }
let(:procedure) { create(:procedure) }
let(:columns) { [procedure.find_column(label:)] }
let(:dossiers_ids) { [dossier.id] }
subject { super()[0].columns[0] }
context 'for self table' do
let(:table) { 'self' }
context 'for created_at column' do
let(:column) { 'created_at' }
let(:dossier) { Timecop.freeze(Time.zone.local(1992, 3, 22)) { create(:dossier) } }
let(:label) { 'Créé le' }
let(:dossier) { Timecop.freeze(Time.zone.local(1992, 3, 22)) { create(:dossier, procedure:) } }
it { is_expected.to eq('22/03/1992') }
end
context 'for en_construction_at column' do
let(:column) { 'en_construction_at' }
let(:dossier) { create(:dossier, :en_construction, en_construction_at: Time.zone.local(2018, 10, 17)) }
let(:label) { 'En construction le' }
let(:dossier) { create(:dossier, :en_construction, en_construction_at: Time.zone.local(2018, 10, 17), procedure:) }
it { is_expected.to eq('17/10/2018') }
end
context 'for depose_at column' do
let(:column) { 'depose_at' }
let(:dossier) { create(:dossier, :en_construction, depose_at: Time.zone.local(2018, 10, 17)) }
let(:label) { 'Déposé le' }
let(:dossier) { create(:dossier, :en_construction, depose_at: Time.zone.local(2018, 10, 17), procedure:) }
it { is_expected.to eq('17/10/2018') }
end
context 'for updated_at column' do
let(:column) { 'updated_at' }
let(:dossier) { create(:dossier) }
let(:label) { 'Mis à jour le' }
let(:dossier) { create(:dossier, procedure:) }
before { dossier.touch(time: Time.zone.local(2018, 9, 25)) }
@ -118,61 +111,56 @@ describe DossierProjectionService do
end
context 'for user table' do
let(:table) { 'user' }
let(:column) { 'email' }
let(:label) { 'Demandeur' }
let(:dossier) { create(:dossier, user: create(:user, email: 'bla@yopmail.com')) }
let(:dossier) { create(:dossier, user: create(:user, email: 'bla@yopmail.com'), procedure:) }
it { is_expected.to eq('bla@yopmail.com') }
end
context 'for individual table' do
let(:table) { 'individual' }
let(:procedure) { create(:procedure, :for_individual, :with_type_de_champ, :with_type_de_champ_private) }
let(:dossier) { create(:dossier, procedure: procedure, individual: build(:individual, nom: 'Martin', prenom: 'Jacques', gender: 'M.')) }
let(:dossier) { create(:dossier, procedure:, individual: build(:individual, nom: 'Martin', prenom: 'Jacques', gender: 'M.')) }
context 'for prenom column' do
let(:column) { 'prenom' }
let(:label) { 'Prénom' }
it { is_expected.to eq('Jacques') }
end
context 'for nom column' do
let(:column) { 'nom' }
let(:label) { 'Nom' }
it { is_expected.to eq('Martin') }
end
context 'for gender column' do
let(:column) { 'gender' }
let(:label) { 'Civilité' }
it { is_expected.to eq('M.') }
end
end
context 'for etablissement table' do
let(:table) { 'etablissement' }
let(:column) { 'code_postal' } # All other columns work the same, no extra test required
let(:label) { 'Code postal' }
let!(:dossier) { create(:dossier, etablissement: create(:etablissement, code_postal: '75008')) }
let!(:dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '75008')) }
it { is_expected.to eq('75008') }
end
context 'for groupe_instructeur table' do
let(:table) { 'groupe_instructeur' }
let(:column) { 'label' }
let(:label) { 'Groupe instructeur' }
let!(:dossier) { create(:dossier) }
let!(:dossier) { create(:dossier, procedure:) }
it { is_expected.to eq('défaut') }
end
context 'for followers_instructeurs table' do
let(:table) { 'followers_instructeurs' }
let(:column) { 'email' }
let(:label) { 'Email instructeur' }
let(:dossier) { create(:dossier) }
let(:dossier) { create(:dossier, procedure:) }
let!(:follow1) { create(:follow, dossier: dossier, instructeur: create(:instructeur, email: 'b@host.fr')) }
let!(:follow2) { create(:follow, dossier: dossier, instructeur: create(:instructeur, email: 'a@host.fr')) }
let!(:follow3) { create(:follow, dossier: dossier, instructeur: create(:instructeur, email: 'c@host.fr')) }
@ -181,19 +169,21 @@ describe DossierProjectionService do
end
context 'for type_de_champ table' do
let(:table) { 'type_de_champ' }
let(:dossier) { create(:dossier) }
let(:column) { dossier.procedure.active_revision.types_de_champ_public.first.stable_id.to_s }
let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :text }]) }
let(:dossier) { create(:dossier, procedure:) }
let(:label) { dossier.procedure.active_revision.types_de_champ_public.first.libelle }
before { dossier.project_champs_public.first.update(value: 'kale') }
before do
dossier.project_champs_public.first.update(value: 'kale')
end
it { is_expected.to eq('kale') }
end
context 'for type_de_champ_private table' do
let(:table) { 'type_de_champ_private' }
let(:dossier) { create(:dossier) }
let(:column) { dossier.procedure.active_revision.types_de_champ_private.first.stable_id.to_s }
let(:procedure) { create(:procedure, types_de_champ_private: [{ type: :text }]) }
let(:dossier) { create(:dossier, procedure:) }
let(:label) { dossier.procedure.active_revision.types_de_champ_private.first.libelle }
before { dossier.project_champs_private.first.update(value: 'quinoa') }
@ -201,10 +191,9 @@ describe DossierProjectionService do
end
context 'for type_de_champ table and value to.s' do
let(:table) { 'type_de_champ' }
let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :yes_no }]) }
let(:dossier) { create(:dossier, procedure: procedure) }
let(:column) { dossier.procedure.active_revision.types_de_champ_public.first.stable_id.to_s }
let(:dossier) { create(:dossier, procedure:) }
let(:label) { dossier.procedure.active_revision.types_de_champ_public.first.libelle }
before { dossier.project_champs_public.first.update(value: 'true') }
@ -212,10 +201,9 @@ describe DossierProjectionService do
end
context 'for type_de_champ table and value to.s which needs data field' do
let(:table) { 'type_de_champ' }
let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :address }]) }
let(:dossier) { create(:dossier, procedure: procedure) }
let(:column) { dossier.procedure.active_revision.types_de_champ_public.first.stable_id.to_s }
let(:dossier) { create(:dossier, procedure:) }
let(:label) { dossier.procedure.active_revision.types_de_champ_public.first.libelle }
before { dossier.project_champs_public.first.update(value: '18 a la bonne rue', data: { 'label' => '18 a la bonne rue', 'departement' => 'd' }) }
@ -223,10 +211,9 @@ describe DossierProjectionService do
end
context 'for type_de_champ table: type_de_champ pays which needs external_id field' do
let(:table) { 'type_de_champ' }
let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :pays }]) }
let(:dossier) { create(:dossier, procedure: procedure) }
let(:column) { dossier.procedure.active_revision.types_de_champ_public.first.stable_id.to_s }
let(:dossier) { create(:dossier, procedure:) }
let(:label) { dossier.procedure.active_revision.types_de_champ_public.first.libelle }
around do |example|
I18n.with_locale(:fr) do
@ -254,8 +241,10 @@ describe DossierProjectionService do
context 'for dossier corrections table' do
let(:table) { 'dossier_corrections' }
let(:column) { 'resolved_at' }
let(:dossier) { create(:dossier, :en_construction) }
subject { described_class.project(dossiers_ids, fields)[0] }
let(:procedure) { create(:procedure) }
let(:columns) { [Column.new(procedure_id: procedure.id, table:, column:)] } # should somehow be present in column concern
let(:dossier) { create(:dossier, :en_construction, procedure:) }
subject { described_class.project(dossiers_ids, columns)[0] }
context "when dossier has pending correction" do
before { create(:dossier_correction, dossier:) }

View file

@ -224,7 +224,7 @@ describe "procedure filters" do
end
def remove_filter(filter_value)
click_link text: filter_value
click_button text: filter_value
end
def add_column(column_name)

View file

@ -14,14 +14,14 @@ module Maintenance
before { element.update_column(:filters, filters) }
context 'when filter is valid' do
let(:filters) { { "suivis" => [{ 'table' => "self", 'column' => "id", "value" => (ProcedurePresentation::PG_INTEGER_MAX_VALUE - 1).to_s }] } }
let(:filters) { { "suivis" => [{ 'table' => "self", 'column' => "id", "value" => (FilteredColumn::PG_INTEGER_MAX_VALUE - 1).to_s }] } }
it 'keeps it filters' do
expect { subject }.not_to change { element.reload.filters }
end
end
context 'when filter is invalid, drop it' do
let(:filters) { { "suivis" => [{ 'table' => "self", 'column' => "id", "value" => (ProcedurePresentation::PG_INTEGER_MAX_VALUE).to_s }] } }
let(:filters) { { "suivis" => [{ 'table' => "self", 'column' => "id", "value" => (FilteredColumn::PG_INTEGER_MAX_VALUE).to_s }] } }
it 'drop invalid filters' do
expect { subject }.to change { element.reload.filters }.to({ "suivis" => [] })
end

View file

@ -0,0 +1,65 @@
# frozen_string_literal: true
describe FilteredColumnType do
let(:type) { FilteredColumnType.new }
describe 'cast' do
it 'from FilteredColumn' do
column = Column.new(procedure_id: 1, table: 'table', column: 'column')
filtered_column = FilteredColumn.new(column:, filter: 'filter')
expect(type.cast(filtered_column)).to eq(filtered_column)
end
it 'from nil' do
expect(type.cast(nil)).to eq(nil)
end
describe 'from form' do
it 'with valid column id' do
column = Column.new(procedure_id: 1, table: 'table', column: 'column')
h = { filter: 'filter', id: column.id }
expect(Column).to receive(:find).with(column.h_id).and_return(column)
expect(type.cast(h)).to eq(FilteredColumn.new(column:, filter: 'filter'))
end
it 'with invalid column id' do
h = { filter: 'filter', id: 'invalid' }
expect { type.cast(h) }.to raise_error(JSON::ParserError)
h = { filter: 'filter', id: { procedure_id: 'invalid', column_id: 'nop' }.to_json }
expect { type.cast(h) }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
describe 'deserialize' do
context 'with valid value' do
it 'works' do
column = Column.new(procedure_id: 1, table: 'table', column: 'column')
expect(Column).to receive(:find).with(column.h_id).and_return(column)
expect(type.deserialize({ id: column.h_id, filter: 'filter' }.to_json)).to eq(FilteredColumn.new(column: column, filter: 'filter'))
end
end
context 'with nil' do
it { expect(type.deserialize(nil)).to eq(nil) }
end
end
describe 'serialize' do
it 'with FilteredColumn' do
column = Column.new(procedure_id: 1, table: 'table', column: 'column')
sorted_column = FilteredColumn.new(column: column, filter: 'filter')
expect(type.serialize(sorted_column)).to eq({ id: column.h_id, filter: 'filter' }.to_json)
end
it 'with nil' do
expect(type.serialize(nil)).to eq(nil)
end
it 'with invalid value' do
expect { type.serialize('invalid') }.to raise_error(ArgumentError)
end
end
end