diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index 7dbdc2365..73d70eda6 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -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 = "L’export au format \"#{export_format}\" est prêt. Vous pouvez le télécharger" 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 diff --git a/app/helpers/dossier_helper.rb b/app/helpers/dossier_helper.rb index 8a2582100..a0d9f1905 100644 --- a/app/helpers/dossier_helper.rb +++ b/app/helpers/dossier_helper.rb @@ -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 diff --git a/app/models/export.rb b/app/models/export.rb index 05d8f04a7..f97673248 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -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) - groupe_instructeurs_ids.sort.join('-') + 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) - end - service = ProcedureExportService.new(procedure, dossiers) + def filename + procedure_identifier = procedure.path || "procedure-#{procedure.id}" + "dossiers_#{procedure_identifier}_#{statut}_#{Time.zone.now.strftime('%Y-%m-%d_%H-%M')}.#{format}" + end + + def io + service = ProcedureExportService.new(procedure, dossiers_for_export) case format.to_sym when :csv diff --git a/app/views/instructeurs/procedures/_dossiers_export.html.haml b/app/views/instructeurs/procedures/_dossiers_export.html.haml new file mode 100644 index 000000000..45f0754b5 --- /dev/null +++ b/app/views/instructeurs/procedures/_dossiers_export.html.haml @@ -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}") diff --git a/app/views/instructeurs/procedures/_dossiers_filter.html.haml b/app/views/instructeurs/procedures/_dossiers_filter.html.haml new file mode 100644 index 000000000..ac7c88839 --- /dev/null +++ b/app/views/instructeurs/procedures/_dossiers_filter.html.haml @@ -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)}" diff --git a/app/views/instructeurs/procedures/_download_dossiers.html.haml b/app/views/instructeurs/procedures/_download_dossiers.html.haml index c07b51811..11e83b75e 100644 --- a/app/views/instructeurs/procedures/_download_dossiers.html.haml +++ b/app/views/instructeurs/procedures/_download_dossiers.html.haml @@ -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) diff --git a/app/views/instructeurs/procedures/download_export.js.erb b/app/views/instructeurs/procedures/download_export.js.erb index aab5dbf9f..ab489f92d 100644 --- a/app/views/instructeurs/procedures/download_export.js.erb +++ b/app/views/instructeurs/procedures/download_export.js.erb @@ -1,11 +1,22 @@ <% if @can_download_dossiers %> - <%= render_to_element('.procedure-actions', partial: "download_dossiers", locals: { procedure: @procedure, exports: @exports }) %> + <% 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| %> - <% 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) %> +<% @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: export.format, time_span_type: export.time_span_type, no_progress_notification: true) }.to_json) %> + <% end %> <% end %> <% end %> <% end %> diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index ac166b19d..213c33151 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -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 diff --git a/config/brakeman.ignore b/config/brakeman.ignore index ac201cf2d..692e07c58 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -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" } diff --git a/config/locales/views/instructeurs/procedures/fr.yml b/config/locales/views/instructeurs/procedures/fr.yml new file mode 100644 index 000000000..9cf758507 --- /dev/null +++ b/config/locales/views/instructeurs/procedures/fr.yml @@ -0,0 +1,13 @@ +fr: + instructeurs: + procedures: + dossiers_export: + everything_csv_html: Demander un export au format .csv
(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é
(demandé il y a %{export_time}) + everything_ready_html: Télécharger l’export au format %{export_format}
(généré il y a %{export_time}) + download: + one: Télécharger un dossier + other: Télécharger %{count} dossiers diff --git a/spec/models/export_spec.rb b/spec/models/export_spec.rb index e074999b7..2bfdec18a 100644 --- a/spec/models/export_spec.rb +++ b/spec/models/export_spec.rb @@ -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 diff --git a/spec/system/instructeurs/instruction_spec.rb b/spec/system/instructeurs/instruction_spec.rb index 032397f8e..9af7c53aa 100644 --- a/spec/system/instructeurs/instruction_spec.rb +++ b/spec/system/instructeurs/instruction_spec.rb @@ -105,7 +105,9 @@ describe 'Instructing a dossier:', js: true do assert_performed_jobs 1 click_on "Télécharger tous les dossiers" - click_on "Demander un export au format .xlsx" + 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 l’export au format .xlsx') expect(page).to have_text('Télécharger l’export 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 l’export au format .xlsx') end scenario 'A instructeur can see the personnes impliquées' do