feat(exports): add ability to create exports with filters

This commit is contained in:
Paul Chavard 2022-04-05 15:57:19 +02:00
parent be1a2f916d
commit e82dc9c8b5
12 changed files with 234 additions and 82 deletions

View file

@ -63,7 +63,7 @@ module Instructeurs
@can_download_dossiers = (@counts[:tous] + @counts[:archives]) > 0
dossiers = Dossier.where(groupe_instructeur_id: groupe_instructeur_ids)
dossiers_count = @counts[statut.underscore.to_sym]
@dossiers_count = @counts[statut.underscore.to_sym]
@followed_dossiers_id = current_instructeur
.followed_dossiers
@ -76,7 +76,7 @@ module Instructeurs
@has_termine_notifications = notifications[:termines].present?
@not_archived_notifications_dossier_ids = notifications[:en_cours] + notifications[:termines]
filtered_sorted_ids = procedure_presentation.filtered_sorted_ids(dossiers, dossiers_count, statut)
filtered_sorted_ids = procedure_presentation.filtered_sorted_ids(dossiers, @dossiers_count, statut)
page = params[:page].presence || 1
@ -137,8 +137,6 @@ module Instructeurs
end
def download_export
export_format = params[:export_format]
time_span_type = params[:time_span_type] || Export.time_span_types.fetch(:everything)
groupe_instructeurs = current_instructeur
.groupe_instructeurs
.where(procedure: procedure)
@ -148,17 +146,19 @@ module Instructeurs
.visible_by_administration
.exists?(groupe_instructeur_id: groupe_instructeur_ids)
export = Export.find_or_create_export(export_format, time_span_type, groupe_instructeurs)
export = Export.find_or_create_export(export_format, groupe_instructeurs, **export_options)
if export.ready? && export.old? && params[:force_export]
if export.ready? && export.old? && force_export?
export.destroy
export = Export.find_or_create_export(export_format, time_span_type, groupe_instructeurs)
export = Export.find_or_create_export(export_format, groupe_instructeurs, **export_options)
end
if export.ready?
respond_to do |format|
format.js do
@procedure = procedure
@statut = export_options[:statut]
@dossiers_count = export.count
assign_exports
flash.notice = "Lexport au format \"#{export_format}\" est prêt. Vous pouvez le <a href=\"#{export.file.service_url}\">télécharger</a>"
end
@ -173,6 +173,8 @@ module Instructeurs
format.js do
@procedure = procedure
@statut = export_options[:statut]
@dossiers_count = export.count
assign_exports
if !params[:no_progress_notification]
flash.notice = notice_message
@ -261,7 +263,7 @@ module Instructeurs
end
def assign_exports
@exports = Export.find_for_groupe_instructeurs(groupe_instructeur_ids)
@exports = Export.find_for_groupe_instructeurs(groupe_instructeur_ids, procedure_presentation)
end
def assign_tos
@ -280,6 +282,22 @@ module Instructeurs
@statut ||= (params[:statut].presence || 'a-suivre')
end
def export_format
@export_format ||= params[:export_format]
end
def force_export?
@force_export ||= params[:force_export].present?
end
def export_options
@export_options ||= {
time_span_type: params[:time_span_type],
statut: params[:statut],
procedure_presentation: params[:statut].present? ? procedure_presentation : nil
}.compact
end
def procedure_id
params[:procedure_id]
end

View file

@ -109,9 +109,23 @@ module DossierHelper
"#{base_url}/entreprise/#{siren}"
end
def exports_list(exports)
Export::FORMATS.map do |(format, time_span_type)|
[format, time_span_type, exports[format] && exports[format][time_span_type]]
def exports_list(exports, statut = nil)
if statut
Export::FORMATS.map do |item|
export = exports
.fetch(item.fetch(:format))
.fetch(:statut)
.fetch(statut, nil)
item.merge(export: export)
end
else
Export::FORMATS_WITH_TIME_SPAN.map do |item|
export = exports
.fetch(item.fetch(:format))
.fetch(:time_span_type)
.fetch(item.fetch(:time_span_type), nil)
item.merge(export: export)
end
end
end
end

View file

@ -46,11 +46,14 @@ class Export < ApplicationRecord
after_create_commit :compute_async
FORMATS = [:xlsx, :ods, :csv].flat_map do |format|
Export.time_span_types.values.map do |time_span_type|
[format, time_span_type]
FORMATS_WITH_TIME_SPAN = [:xlsx, :ods, :csv].flat_map do |format|
time_span_types.keys.map do |time_span_type|
{ format: format.to_sym, time_span_type: time_span_type }
end
end
FORMATS = [:xlsx, :ods, :csv].map do |format|
{ format: format.to_sym }
end
def compute_async
ExportJob.perform_later(self)
@ -58,7 +61,7 @@ class Export < ApplicationRecord
def compute
file.attach(
io: io(since: since),
io: io,
filename: filename,
content_type: content_type,
# We generate the exports ourselves, so they are safe
@ -78,44 +81,96 @@ class Export < ApplicationRecord
updated_at < 20.minutes.ago
end
def self.find_or_create_export(format, time_span_type, groupe_instructeurs)
create_with(groupe_instructeurs: groupe_instructeurs)
def filtered?
procedure_presentation_id.present?
end
def xlsx?
format == self.class.formats.fetch(:xlsx)
end
def ods?
format == self.class.formats.fetch(:ods)
end
def csv?
format == self.class.formats.fetch(:csv)
end
def self.find_or_create_export(format, groupe_instructeurs, time_span_type: time_span_types.fetch(:everything), statut: statuts.fetch(:tous), procedure_presentation: nil)
create_with(groupe_instructeurs: groupe_instructeurs, procedure_presentation: procedure_presentation)
.includes(:procedure_presentation)
.create_or_find_by(format: format,
time_span_type: time_span_type,
key: generate_cache_key(groupe_instructeurs.map(&:id)))
statut: statut,
key: generate_cache_key(groupe_instructeurs.map(&:id), procedure_presentation&.id))
end
def self.find_for_groupe_instructeurs(groupe_instructeurs_ids)
exports = where(key: generate_cache_key(groupe_instructeurs_ids))
def self.find_for_groupe_instructeurs(groupe_instructeurs_ids, procedure_presentation)
exports = if procedure_presentation.present?
where(key: generate_cache_key(groupe_instructeurs_ids))
.or(where(key: generate_cache_key(groupe_instructeurs_ids, procedure_presentation.id)))
else
where(key: generate_cache_key(groupe_instructeurs_ids))
end
filtered, not_filtered = exports.partition(&:filtered?)
[:xlsx, :csv, :ods].map do |format|
[
format,
Export.time_span_types.values.map do |time_span_type|
[time_span_type, exports.find { |export| export.format == format.to_s && export.time_span_type == time_span_type }]
end.filter { |(_, export)| export.present? }.to_h
]
end.filter { |(_, exports)| exports.present? }.to_h
{
xlsx: {
time_span_type: not_filtered.filter(&:xlsx?).index_by(&:time_span_type),
statut: filtered.filter(&:xlsx?).index_by(&:statut)
},
ods: {
time_span_type: not_filtered.filter(&:ods?).index_by(&:time_span_type),
statut: filtered.filter(&:ods?).index_by(&:statut)
},
csv: {
time_span_type: not_filtered.filter(&:csv?).index_by(&:time_span_type),
statut: filtered.filter(&:csv?).index_by(&:statut)
}
}
end
def self.generate_cache_key(groupe_instructeurs_ids)
def self.generate_cache_key(groupe_instructeurs_ids, procedure_presentation_id = nil)
if procedure_presentation_id.present?
"#{groupe_instructeurs_ids.sort.join('-')}--#{procedure_presentation_id}"
else
groupe_instructeurs_ids.sort.join('-')
end
end
def count
if procedure_presentation_id.present?
dossiers_for_export.size
end
end
private
def filename
procedure_identifier = procedure.path || "procedure-#{procedure.id}"
"dossiers_#{procedure_identifier}_#{Time.zone.now.strftime('%Y-%m-%d_%H-%M')}.#{format}"
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, dossiers.size, statut)
dossiers.where(id: filtered_sorted_ids)
else
dossiers
end
end
end
def io(since: nil)
dossiers = Dossier.visible_by_administration
.where(groupe_instructeur: groupe_instructeurs)
if since.present?
dossiers = dossiers.where('dossiers.depose_at > ?', since)
def filename
procedure_identifier = procedure.path || "procedure-#{procedure.id}"
"dossiers_#{procedure_identifier}_#{statut}_#{Time.zone.now.strftime('%Y-%m-%d_%H-%M')}.#{format}"
end
service = ProcedureExportService.new(procedure, dossiers)
def io
service = ProcedureExportService.new(procedure, dossiers_for_export)
case format.to_sym
when :csv

View file

@ -0,0 +1,19 @@
%span.dropdown
%button.button.dropdown-button{ 'aria-expanded' => 'false', 'aria-controls' => 'download-menu' }
= t(".download", count: count)
#download-menu.dropdown-content.fade-in-down{ style: 'width: 450px' }
%ul.dropdown-items
- exports_list(exports, statut).each do |item|
- format = item[:format]
- export = item[:export]
%li
- if export.nil?
= link_to t(".everything_#{format}_html"), download_export_instructeur_procedure_path(procedure, statut: statut, export_format: format), remote: true
- elsif export.ready?
= link_to t(".everything_ready_html", export_time: time_ago_in_words(export.updated_at), export_format: ".#{format}"), export.file.service_url, target: "_blank", rel: "noopener"
- if export.old?
= button_to download_export_instructeur_procedure_path(procedure, export_format: format, statut: statut, force_export: true), class: "button small", style: "padding-right: 2px", title: t(".everything_short", export_format: ".#{format}"), remote: true, method: :get, params: { export_format: format, statut: statut, force_export: true } do
.icon.retry
- else
%span{ 'data-export-poll-url': download_export_instructeur_procedure_path(procedure, export_format: format, statut: statut, no_progress_notification: true) }
= t(".everything_pending_html", export_time: time_ago_in_words(export.created_at), export_format: ".#{format}")

View file

@ -0,0 +1,24 @@
%span.dropdown
%button.button.dropdown-button{ 'aria-expanded' => 'false', 'aria-controls' => 'filter-menu' }
Filtrer
#filter-menu.dropdown-content.left-aligned.fade-in-down
= form_tag add_filter_instructeur_procedure_path(procedure), method: :post, class: 'dropdown-form large' do
= label_tag :field, "Colonne"
= select_tag :field, options_for_select(displayed_fields_options)
%br
= label_tag :value, "Valeur"
= text_field_tag :value, nil, maxlength: ProcedurePresentation::FILTERS_VALUE_MAX_LENGTH
= hidden_field_tag :statut, statut
%br
= submit_tag "Ajouter le filtre", class: 'button'
- current_filters.group_by { |filter| filter['table'] }.each_with_index do |(table, filters), i|
- if i > 0
et
- filters.each_with_index do |filter, i|
- if i > 0
ou
%span.filter
= link_to remove_filter_instructeur_procedure_path(procedure, { statut: statut, field: "#{filter['table']}/#{filter['column']}", value: filter['value'] }) do
%img.close-icon{ src: image_url("close.svg") }
= "#{filter['label'].truncate(50)} : #{procedure_presentation.human_value_for_filter(filter)}"

View file

@ -3,7 +3,10 @@
Télécharger tous les dossiers
#download-menu.dropdown-content.fade-in-down{ style: 'width: 450px' }
%ul.dropdown-items
- exports_list(exports).each do |(format, time_span_type, export)|
- exports_list(exports).each do |item|
- format = item[:format]
- time_span_type = item[:time_span_type]
- export = item[:export]
%li
- if export.nil?
= link_to t("#{time_span_type}_#{format}_html", scope: [:instructeurs, :procedure, :export_stale]), download_export_instructeur_procedure_path(procedure, time_span_type: time_span_type, export_format: format), remote: true
@ -13,7 +16,7 @@
= button_to download_export_instructeur_procedure_path(procedure, export_format: format, time_span_type: time_span_type, force_export: true), class: "button small", style: "padding-right: 2px", title: t("#{time_span_type}_short", export_format: ".#{format}", scope: [:instructeurs, :procedure, :export_stale]), remote: true, method: :get, params: { export_format: format, time_span_type: time_span_type, force_export: true } do
.icon.retry
- else
%span{ 'data-export-poll-url': download_export_instructeur_procedure_path(procedure, export_format: format, no_progress_notification: true) }
%span{ 'data-export-poll-url': download_export_instructeur_procedure_path(procedure, export_format: format, time_span_type: time_span_type, no_progress_notification: true) }
= t("export_#{time_span_type}_pending_html", export_time: time_ago_in_words(export.created_at), export_format: ".#{format}", scope: [:instructeurs, :procedure])
%li
= link_to t(:download_archive, scope: [:instructeurs, :procedure]), instructeur_archives_path(procedure)

View file

@ -1,11 +1,22 @@
<% if @can_download_dossiers %>
<% if @statut.present? %>
<%= render_to_element('.dossiers-export', partial: "dossiers_export", locals: { procedure: @procedure, exports: @exports, statut: @statut, count: @dossiers_count }) %>
<% else %>
<%= render_to_element('.procedure-actions', partial: "download_dossiers", locals: { procedure: @procedure, exports: @exports }) %>
<% end %>
<% end %>
<% @exports.each do |format, exports| %>
<% exports.each do |time_span_type, export| %>
<% @exports.values.each do |exports| %>
<% if @statut.present? %>
<% export = exports[:statut][@statut] %>
<% if export && !export.ready? %>
<%= fire_event('export:update', { url: download_export_instructeur_procedure_path(@procedure, export_format: export.format, statut: export.statut, no_progress_notification: true) }.to_json) %>
<% end %>
<% else %>
<% exports[:time_span_type].values.each do |export| %>
<% if !export.ready? %>
<%= fire_event('export:update', { url: download_export_instructeur_procedure_path(@procedure, export_format: format, time_span_type: time_span_type, no_progress_notification: true) }.to_json) %>
<%= fire_event('export:update', { url: download_export_instructeur_procedure_path(@procedure, export_format: export.format, time_span_type: export.time_span_type, no_progress_notification: true) }.to_json) %>
<% end %>
<% end %>
<% end %>
<% end %>

View file

@ -52,34 +52,15 @@
- if @statut == 'expirant'
%p.explication-onglet Les dossiers n'expireront pas avant la période de conservation des données.
- if @filtered_sorted_paginated_ids.present? || @current_filters.count > 0
- pagination = paginate @filtered_sorted_paginated_ids
= pagination
%span.dropdown
%button.button.dropdown-button{ 'aria-expanded' => 'false', 'aria-controls' => 'filter-menu' }
Filtrer
#filter-menu.dropdown-content.left-aligned.fade-in-down
= form_tag add_filter_instructeur_procedure_path(@procedure), method: :post, class: 'dropdown-form large' do
= label_tag :field, "Colonne"
= select_tag :field, options_for_select(@displayed_fields_options)
%br
= label_tag :value, "Valeur"
= text_field_tag :value, nil, maxlength: ProcedurePresentation::FILTERS_VALUE_MAX_LENGTH
= hidden_field_tag :statut, @statut
%br
= submit_tag "Ajouter le filtre", class: 'button'
.flex
.flex-grow
= render partial: "dossiers_filter", locals: { procedure: @procedure, procedure_presentation: @procedure_presentation, current_filters: @current_filters, statut: @statut, displayed_fields_options: @displayed_fields_options }
.dossiers-export
= render partial: "dossiers_export", locals: { procedure: @procedure, exports: @exports, statut: @statut, count: @dossiers_count }
- @current_filters.group_by { |filter| filter['table'] }.each_with_index do |(table, filters), i|
- if i > 0
et
- filters.each_with_index do |filter, i|
- if i > 0
ou
%span.filter
= link_to remove_filter_instructeur_procedure_path(@procedure, { statut: @statut, field: "#{filter['table']}/#{filter['column']}", value: filter['value'] }) do
%img.close-icon{ src: image_url("close.svg") }
= "#{filter['label'].truncate(50)} : #{@procedure_presentation.human_value_for_filter(filter)}"
%table.table.dossiers-table.hoverable
%thead
%tr

View file

@ -15,7 +15,7 @@
"type": "controller",
"class": "Users::DossiersController",
"method": "merci",
"line": 196,
"line": 201,
"file": "app/controllers/users/dossiers_controller.rb",
"rendered": {
"name": "users/dossiers/merci",
@ -115,24 +115,24 @@
{
"warning_type": "Redirect",
"warning_code": 18,
"fingerprint": "c46b5c9cd6474ffae789f39a2280ba6b5a5a74d3ffa8a38cf8a409f9a027ed0e",
"fingerprint": "eae88be293b3849e07be81a18de99c04b08c7b2c045cc4a43ca3624ea178d965",
"check_name": "Redirect",
"message": "Possible unprotected redirect",
"file": "app/controllers/instructeurs/procedures_controller.rb",
"line": 202,
"line": 191,
"link": "https://brakemanscanner.org/docs/warning_types/redirect/",
"code": "redirect_to(Export.find_or_create_export(params[:export_format], (params[:time_span_type] or \"everything\"), current_instructeur.groupe_instructeurs.where(:procedure => procedure)).file.service_url)",
"code": "redirect_to(Export.find_or_create_export(export_format, current_instructeur.groupe_instructeurs.where(:procedure => procedure), **export_options).file.service_url)",
"render_path": null,
"location": {
"type": "method",
"class": "Instructeurs::ProceduresController",
"method": "download_export"
},
"user_input": "Export.find_or_create_export(params[:export_format], (params[:time_span_type] or \"everything\"), current_instructeur.groupe_instructeurs.where(:procedure => procedure)).file.service_url",
"user_input": "Export.find_or_create_export(export_format, current_instructeur.groupe_instructeurs.where(:procedure => procedure), **export_options).file.service_url",
"confidence": "High",
"note": ""
}
],
"updated": "2022-02-22 15:46:39 +0100",
"updated": "2022-04-05 14:21:07 +0200",
"brakeman_version": "5.1.1"
}

View file

@ -0,0 +1,13 @@
fr:
instructeurs:
procedures:
dossiers_export:
everything_csv_html: Demander un export au format .csv<br>(uniquement les dossiers, sans les champs répétables)
everything_xlsx_html: Demander un export au format .xlsx
everything_ods_html: Demander un export au format .ods
everything_short: Demander un export au format %{export_format}
everything_pending_html: Un export au format %{export_format} est en train dêtre généré<br>(demandé il y a %{export_time})
everything_ready_html: Télécharger lexport au format %{export_format}<br>(généré il y a %{export_time})
download:
one: Télécharger un dossier
other: Télécharger %{count} dossiers

View file

@ -48,9 +48,9 @@ RSpec.describe Export, type: :model do
context 'when an export is made for one groupe instructeur' do
let!(:export) { create(:export, groupe_instructeurs: [gi_1, gi_2]) }
it { expect(Export.find_for_groupe_instructeurs([gi_1.id])).to eq({}) }
it { expect(Export.find_for_groupe_instructeurs([gi_2.id, gi_1.id])).to eq({ csv: { 'everything' => export } }) }
it { expect(Export.find_for_groupe_instructeurs([gi_1.id, gi_2.id, gi_3.id])).to eq({}) }
it { expect(Export.find_for_groupe_instructeurs([gi_1.id], nil)).to eq({ csv: { statut: {}, time_span_type: {} }, xlsx: { statut: {}, time_span_type: {} }, ods: { statut: {}, time_span_type: {} } }) }
it { expect(Export.find_for_groupe_instructeurs([gi_2.id, gi_1.id], nil)).to eq({ csv: { statut: {}, time_span_type: { 'everything' => export } }, xlsx: { statut: {}, time_span_type: {} }, ods: { statut: {}, time_span_type: {} } }) }
it { expect(Export.find_for_groupe_instructeurs([gi_1.id, gi_2.id, gi_3.id], nil)).to eq({ csv: { statut: {}, time_span_type: {} }, xlsx: { statut: {}, time_span_type: {} }, ods: { statut: {}, time_span_type: {} } }) }
end
end
end

View file

@ -105,7 +105,9 @@ describe 'Instructing a dossier:', js: true do
assert_performed_jobs 1
click_on "Télécharger tous les dossiers"
within(:css, '.procedure-actions') do
click_on "Demander un export au format .xlsx"
end
expect(page).to have_text('Nous générons cet export.')
expect(page).to have_text('Un export au format .xlsx est en train dêtre généré')
@ -114,13 +116,25 @@ describe 'Instructing a dossier:', js: true do
expect(page).to have_text('Nous générons cet export.')
expect(page).to have_text('Un export des 30 derniers jours au format .xlsx est en train dêtre généré')
click_on "Télécharger un dossier"
within(:css, '.dossiers-export') do
click_on "Demander un export au format .csv"
end
expect(page).to have_text('Nous générons cet export.')
expect(page).to have_text('Un export au format .csv est en train dêtre généré')
perform_enqueued_jobs(only: ExportJob)
assert_performed_jobs 3
assert_performed_jobs 4
page.driver.browser.navigate.refresh
click_on "Télécharger tous les dossiers"
expect(page).to have_text('Télécharger lexport au format .xlsx')
expect(page).to have_text('Télécharger lexport des 30 derniers jours au format .xlsx')
# close dropdown menu
click_on "Télécharger tous les dossiers"
click_on "Télécharger un dossier"
expect(page).to have_text('Télécharger lexport au format .xlsx')
end
scenario 'A instructeur can see the personnes impliquées' do