Merge pull request #9473 from demarches-simplifiees/create-export-page-V2-ldu

[Export] Créer une page d'export et sortir les liens des dropdowns
This commit is contained in:
Colin Darie 2023-09-28 17:15:31 +00:00 committed by GitHub
commit f942610d32
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 577 additions and 256 deletions

View file

@ -1,73 +0,0 @@
class Dossiers::ExportComponent < ApplicationComponent
include ApplicationHelper
def initialize(procedure:, exports:, statut: nil, count: nil, class_btn: nil, export_url: nil)
@procedure = procedure
@exports = exports
@statut = statut
@count = count
@class_btn = class_btn
@export_url = export_url
end
def exports
if @statut
Export::FORMATS.filter(&method(:allowed_format?)).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
def allowed_format?(item)
item.fetch(:format) != :json || @procedure.active_revision.carte?
end
def download_export_path(export_format:, force_export: false, no_progress_notification: nil)
@export_url.call(@procedure,
export_format: export_format,
statut: @statut,
force_export: force_export,
no_progress_notification: no_progress_notification)
end
def refresh_button_options(export)
{
title: t(".everything_short", export_format: ".#{export.format}"),
class: "button small",
style: "padding-right: 2px"
}
end
def ready_link_label(export)
t(".everything_ready_html",
export_time: helpers.time_ago_in_words(export.updated_at),
export_format: ".#{export.format}")
end
def pending_label(export)
t(".everything_pending_html",
export_time: time_ago_in_words(export.created_at),
export_format: ".#{export.format}")
end
def poll_controller_options(export)
{
controller: 'turbo-poll',
turbo_poll_url_value: download_export_path(export_format: export.format, no_progress_notification: true),
turbo_poll_interval_value: 6000,
turbo_poll_max_checks_value: 10
}
end
end

View file

@ -1,29 +0,0 @@
= render Dropdown::MenuComponent.new(wrapper: :span, button_options: { class: ['fr-btn--sm', @class_btn.present? ? @class_btn : 'fr-btn--secondary']}, menu_options: { id: @count.nil? ? "download_menu" : "download_all_menu", class: ['dropdown-export'] }) do |menu|
- menu.with_menu_header_html do
%p.menu-component-header.fr-px-2w.fr-pt-2w.fr-mb-0
%span.fr-icon-info-line{ aria: { hidden: true } }
Des macros ? Lisez la
= link_to('doc', t('.macros_doc.url'),
title: t('.macros_doc.title'),
**external_link_attributes)
- menu.with_button_inner_html do
= @count.nil? ? t(".download_all") : t(".download", count: @count)
- exports.each do |item|
- export = item[:export]
- if export.nil?
- menu.with_item do
= link_to download_export_path(export_format: item[:format]), role: 'menuitem', data: { turbo_method: :post, turbo: true } do
= t(".everything_#{item[:format]}_html")
- elsif export.available?
- menu.with_item do
%div
= link_to ready_link_label(export), export.file.url, target: "_blank", rel: "noopener", role: 'menuitem'
- if export.old?
= button_to download_export_path(export_format: export.format, force_export: true), refresh_button_options(export).merge(role: 'menuitem') do
.icon.retry
- else
- menu.with_item(aria: {disabled:"true"}, class: 'selected') do
%span{ data: poll_controller_options(export) }
= pending_label(export)

View file

@ -0,0 +1,30 @@
class Dossiers::ExportDropdownComponent < ApplicationComponent
include ApplicationHelper
def initialize(procedure:, statut: nil, count: nil, class_btn: nil, export_url: nil)
@procedure = procedure
@statut = statut
@count = count
@class_btn = class_btn
@export_url = export_url
end
def formats
if @statut
Export::FORMATS.filter(&method(:allowed_format?))
else
Export::FORMATS_WITH_TIME_SPAN
end.map { _1[:format] }
end
def allowed_format?(item)
item.fetch(:format) != :json || @procedure.active_revision.carte?
end
def download_export_path(export_format:, no_progress_notification: nil)
@export_url.call(@procedure,
export_format: export_format,
statut: @statut,
no_progress_notification: no_progress_notification)
end
end

View file

@ -6,8 +6,6 @@ en:
everything_zip_html: Request an export in .zip format<br>(does not contains timestamp nor operation logs)
everything_json_html: Request an export in .json format (GeoJSON)
everything_short: Request an export in %{export_format} format
everything_pending_html: An export in %{export_format} format is being generated<br>(ask %{export_time} ago)
everything_ready_html: Download the export in %{export_format} format<br>(generated %{export_time} ago)
download_all: Download all files
download:
one: Download a file

View file

@ -6,8 +6,6 @@ fr:
everything_zip_html: Demander un export au format .zip<br>(ne contient pas l'horodatage ni le journal de log)
everything_json_html: Demander un export au format .json (GeoJSON)
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_all: Télécharger tous les dossiers
download:
one: Télécharger un dossier

View file

@ -0,0 +1,16 @@
= render Dropdown::MenuComponent.new(wrapper: :span, button_options: { class: ['fr-btn--sm', @class_btn.present? ? @class_btn : 'fr-btn--secondary']}, menu_options: { id: @count.nil? ? "download_menu" : "download_all_menu", class: ['dropdown-export'] }) do |menu|
- menu.with_menu_header_html do
%p.menu-component-header.fr-px-2w.fr-pt-2w.fr-mb-0
%span.fr-icon-info-line{ aria: { hidden: true } }
Des macros ? Lisez la
= link_to('doc', t('.macros_doc.url'),
title: t('.macros_doc.title'),
**external_link_attributes)
- menu.with_button_inner_html do
= @count.nil? ? t(".download_all") : t(".download", count: @count)
- formats.each do |format|
- menu.with_item do
= link_to download_export_path(export_format: format), role: 'menuitem', data: { turbo_method: :post, turbo: true } do
= t(".everything_#{format}_html")

View file

@ -0,0 +1,70 @@
class Dossiers::ExportLinkComponent < ApplicationComponent
include ApplicationHelper
include TabsHelper
def initialize(procedure:, exports:, statut: nil, count: nil, class_btn: nil, export_url: nil)
@procedure = procedure
@exports = exports
@statut = statut
@count = count
@class_btn = class_btn
@export_url = export_url
end
def download_export_path(export_format:, statut:, no_progress_notification: nil)
@export_url.call(@procedure,
export_format: export_format,
statut: statut,
no_progress_notification: no_progress_notification)
end
def time_info(export)
if export.available?
t(".ready_link_label_time_info", export_time: helpers.time_ago_in_words(export.updated_at))
else
t(".not_ready_link_label_time_info", export_time: helpers.time_ago_in_words(export.created_at))
end
end
def export_title(export)
if export.procedure_presentation_id.nil?
t(".export_title_everything", export_format: export.format)
elsif export.tous?
t(".export_title", export_format: export.format, count: export.count)
else
t(".export_title_with_tab", export_tabs: human_export_status(export), export_format: export.format, count: export.count)
end
end
def human_export_status(export)
key = tab_i18n_key_from_status(export.statut)
t(key, count: export.count) || export.statut
end
def badge(export)
if export.available?
content_tag(:span, t(".success_label"), { class: "fr-badge fr-badge--success fr-text-right" })
elsif export.failed?
content_tag(:span, t(".failed_label"), { class: "fr-badge fr-badge--warning fr-text-right" })
else
content_tag(:span, t(".pending_label"), { class: "fr-badge fr-badge--info fr-text-right" })
end
end
def export_button(export)
if export.available?
render Dsfr::DownloadComponent.new(attachment: export.file, name: t('.download_export'))
elsif export.pending?
content_tag(:a, t('.refresh_page'), { href: "", class: 'fr-btn fr-btn--sm fr-btn--tertiary' })
end
end
def refresh_button_options(export)
{
title: t(".refresh_old_export"),
"aria-label" => t(".refresh_old_export"),
class: "fr-btn fr-btn--sm fr-icon-refresh-line fr-btn--tertiary fr-btn--icon-left"
}
end
end

View file

@ -0,0 +1,17 @@
---
en:
download_export: Download export
refresh_old_export: Regenerate this export
success_label: Ready
failed_label: Failed
pending_label: In progress
refresh_page: Refresh page
export_title_everything: Export .%{export_format} of all files
export_title_with_tab:
one: Export .%{export_format} of 1 file « %{export_tabs} »
other: Export .%{export_format} of %{count} files « %{export_tabs} »
export_title:
one: Export .%{export_format} of 1 file
other: Export .%{export_format} of %{count} files
ready_link_label_time_info: " generated %{export_time} ago"
not_ready_link_label_time_info: " asked %{export_time} ago"

View file

@ -0,0 +1,17 @@
---
fr:
download_export: Télécharger lexport
refresh_old_export: Regénérer cet export
success_label: Prêt
failed_label: Erreur
pending_label: En préparation
refresh_page: Recharger la page
export_title_everything: "Export .%{export_format} de tous les dossiers"
export_title_with_tab:
one: "Export .%{export_format} dun dossier « %{export_tabs} »"
other: "Export .%{export_format} de %{count} dossiers « %{export_tabs} »"
export_title:
one: "Export .%{export_format} dun dossier"
other: "Export .%{export_format} de %{count} dossiers"
ready_link_label_time_info: " généré il y a %{export_time}"
not_ready_link_label_time_info: " demandé il y a %{export_time}"

View file

@ -0,0 +1,18 @@
%ul#exports-list.fr-raw-list
- @exports.each do |export|
%li.fr-mb-3w
.flex
%span
%strong
= export_title(export)
%span.fr-text-mention--grey.fr-mb-1w
= time_info(export)
.fr-ml-auto
= badge(export)
.flex.flex-gap-2
= export_button(export)
- if export.failed?
= button_to refresh_button_options(export)[:title], download_export_path(export_format: export.format, statut: export.statut), refresh_button_options(export)

View file

@ -6,7 +6,7 @@ module Administrateurs
helper_method :create_archive_url
def index
@exports = Export.find_for_groupe_instructeurs(all_groupe_instructeurs.map(&:id), nil)
@exports = Export.ante_chronological.by_key(all_groupe_instructeurs.map(&:id), nil)
@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

@ -4,14 +4,13 @@ module Administrateurs
before_action :ensure_not_super_admin!
def download
export = Export.find_or_create_export(export_format, all_groupe_instructeurs, force: force_export?, **export_options)
export = Export.find_or_create_fresh_export(export_format, all_groupe_instructeurs, **export_options)
@dossiers_count = export.count
assign_exports
if export.available?
respond_to do |format|
format.turbo_stream do
flash.notice = export.flash_message
flash.notice = t('administrateurs.exports.export_available_html', file_format: export.format, file_url: export.file.url)
end
format.html do
@ -22,11 +21,11 @@ module Administrateurs
respond_to do |format|
format.turbo_stream do
if !params[:no_progress_notification]
flash.notice = export.flash_message
flash.notice = t('administrateurs.exports.export_pending')
end
end
format.html do
redirect_to admin_procedure_archives_url(@procedure), notice: export.flash_message
redirect_to admin_procedure_archives_url(@procedure), notice: t('administrateurs.exports.export_pending')
end
end
end
@ -38,10 +37,6 @@ module Administrateurs
@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],
@ -53,9 +48,5 @@ module Administrateurs
def all_groupe_instructeurs
@procedure.groupe_instructeurs
end
def assign_exports
@exports = Export.find_for_groupe_instructeurs(all_groupe_instructeurs.map(&:id), nil)
end
end
end

View file

@ -1,7 +1,7 @@
module Instructeurs
class ProceduresController < InstructeurController
before_action :ensure_ownership!, except: [:index]
before_action :ensure_not_super_admin!, only: [:download_export]
before_action :ensure_not_super_admin!, only: [:download_export, :exports]
ITEMS_PER_PAGE = 25
BATCH_SELECTION_LIMIT = 500
@ -90,6 +90,8 @@ module Instructeurs
@has_termine_notifications = notifications[:termines].present?
@not_archived_notifications_dossier_ids = notifications[:en_cours] + notifications[:termines]
@has_export_notification = notify_exports?
@filtered_sorted_ids = procedure_presentation.filtered_sorted_ids(dossiers, statut, count: dossiers_count)
page = params[:page].presence || 1
@ -103,7 +105,6 @@ module Instructeurs
@projected_dossiers = DossierProjectionService.project(@filtered_sorted_paginated_ids, procedure_presentation.displayed_fields)
@disable_checkbox_all = @projected_dossiers.all? { _1.batch_operation_id.present? }
assign_exports
@batch_operations = BatchOperation.joins(:groupe_instructeurs)
.where(groupe_instructeurs: current_instructeur.groupe_instructeurs.where(procedure_id: @procedure.id))
.where(seen_at: nil)
@ -127,8 +128,6 @@ module Instructeurs
@has_termine_notifications = notifications[:termines].present?
@statut = 'supprime'
assign_exports
end
def update_displayed_fields
@ -173,17 +172,16 @@ module Instructeurs
.visible_by_administration
.exists?(groupe_instructeur_id: groupe_instructeur_ids) && !instructeur_as_manager?
export = Export.find_or_create_export(export_format, groupe_instructeurs, force: force_export?, **export_options)
export = Export.find_or_create_fresh_export(export_format, groupe_instructeurs, **export_options)
@procedure = procedure
@statut = export_options[:statut]
@dossiers_count = export.count
assign_exports
if export.available?
respond_to do |format|
format.turbo_stream do
flash.notice = export.flash_message
flash.notice = t('instructeurs.procedures.export_available_html', file_format: export.format, file_url: export.file.url)
end
format.html do
@ -194,11 +192,11 @@ module Instructeurs
respond_to do |format|
format.turbo_stream do
if !params[:no_progress_notification]
flash.notice = export.flash_message
flash.notice = t('instructeurs.procedures.export_pending_html', url: exports_instructeur_procedure_path(procedure))
end
end
format.html do
redirect_to instructeur_procedure_url(procedure), notice: export.flash_message
redirect_to exports_instructeur_procedure_path(procedure), notice: t('instructeurs.procedures.export_pending_html', url: exports_instructeur_procedure_path(procedure))
end
end
end
@ -226,6 +224,20 @@ module Instructeurs
@usual_traitement_time_by_month = @procedure.stats_usual_traitement_time_by_month_in_days
end
def exports
@procedure = procedure
@exports = Export.for_groupe_instructeurs(groupe_instructeur_ids).ante_chronological
cookies.encrypted[cookies_export_key] = {
value: DateTime.current,
expires: Export::MAX_DUREE_GENERATION + Export::MAX_DUREE_CONSERVATION_EXPORT
}
respond_to do |format|
format.turbo_stream
format.html
end
end
def email_usagers
@procedure = procedure
@bulk_messages = BulkMessage.includes(:groupe_instructeurs).where(groupe_instructeurs: { procedure: procedure })
@ -277,10 +289,6 @@ module Instructeurs
.permit(:instant_expert_avis_email_notifications_enabled, :instant_email_dossier_notifications_enabled, :instant_email_message_notifications_enabled, :daily_email_notifications_enabled, :weekly_email_notifications_enabled)
end
def assign_exports
@exports = Export.find_for_groupe_instructeurs(groupe_instructeur_ids, procedure_presentation)
end
def assign_tos
@assign_tos ||= current_instructeur
.assign_to
@ -301,10 +309,6 @@ module Instructeurs
@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],
@ -356,5 +360,22 @@ module Instructeurs
def bulk_message_params
params.require(:bulk_message).permit(:body)
end
def notify_exports?
last_seen_at = begin
DateTime.parse(cookies.encrypted[cookies_export_key])
rescue
nil
end
scope = Export.generated.for_groupe_instructeurs(groupe_instructeur_ids)
scope = scope.where(updated_at: last_seen_at...) if last_seen_at
scope.exists?
end
def cookies_export_key
"exports_#{@procedure.id}_seen_at"
end
end
end

View file

@ -1,4 +1,25 @@
module TabsHelper
def tab_i18n_key_from_status(status)
case status
when 'a-suivre'
'views.instructeurs.dossiers.tab_steps.to_follow' # i18n-tasks-use t('views.instructeurs.dossiers.tab_steps.to_follow')
when 'suivis'
'pluralize.followed'
when 'traites'
'pluralize.processed'
when 'tous'
'views.instructeurs.dossiers.tab_steps.total' # i18n-tasks-use t('views.instructeurs.dossiers.tab_steps.total')
when 'supprimes_recemment'
'pluralize.dossiers_supprimes_recemment'
when 'expirant'
'pluralize.dossiers_close_to_expiration'
when 'archives'
'pluralize.archived'
else
fail ArgumentError, "Unknown tab status: #{status}"
end
end
def tab_item(label, url, active: false, badge: nil, notification: false)
render partial: 'shared/tab_item', locals: {
label: label,

View file

@ -34,6 +34,8 @@ class Export < ApplicationRecord
validates :format, :groupe_instructeurs, :key, presence: true
scope :ante_chronological, -> { order(updated_at: :desc) }
after_create_commit :compute_async
FORMATS_WITH_TIME_SPAN = [:xlsx, :ods, :csv].flat_map do |format|
@ -57,77 +59,39 @@ class Export < ApplicationRecord
time_span_type == Export.time_span_types.fetch(:monthly) ? 30.days.ago : nil
end
def old?
updated_at < 10.minutes.ago || filters_changed?
end
def filters_changed?
procedure_presentation&.snapshot != procedure_presentation_snapshot
end
def filtered?
procedure_presentation_id.present?
end
def flash_message
if available?
"Lexport au format \"#{format}\" est prêt. Vous pouvez le <a href=\"#{file.url}\">télécharger</a>"
else
"Nous générons cet export. Veuillez revenir dans quelques minutes pour le télécharger."
end
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, force: false)
export = create_or_find_export(format, groupe_instructeurs, time_span_type: time_span_type, statut: statut, procedure_presentation: procedure_presentation)
if export.available? && export.old? && force
export.destroy
create_or_find_export(format, groupe_instructeurs, time_span_type: time_span_type, statut: statut, procedure_presentation: procedure_presentation)
else
export
end
end
def self.find_for_groupe_instructeurs(groupe_instructeurs_ids, procedure_presentation)
exports = if procedure_presentation.present?
where(key: generate_cache_key(groupe_instructeurs_ids, procedure_presentation))
.or(where(key: generate_cache_key(groupe_instructeurs_ids)))
else
where(key: generate_cache_key(groupe_instructeurs_ids))
end
filtered, not_filtered = exports.partition(&:filtered?)
{
xlsx: {
time_span_type: not_filtered.filter(&:format_xlsx?).index_by(&:time_span_type),
statut: filtered.filter(&:format_xlsx?).index_by(&:statut)
},
ods: {
time_span_type: not_filtered.filter(&:format_ods?).index_by(&:time_span_type),
statut: filtered.filter(&:format_ods?).index_by(&:statut)
},
csv: {
time_span_type: not_filtered.filter(&:format_csv?).index_by(&:time_span_type),
statut: filtered.filter(&:format_csv?).index_by(&:statut)
},
zip: {
time_span_type: {},
statut: filtered.filter(&:format_zip?).index_by(&:statut)
},
json: {
time_span_type: {},
statut: filtered.filter(&:format_json?).index_by(&:statut)
}
def self.find_or_create_fresh_export(format, groupe_instructeurs, time_span_type: time_span_types.fetch(:everything), statut: statuts.fetch(:tous), procedure_presentation: nil)
attributes = {
format:,
time_span_type:,
statut:,
key: generate_cache_key(groupe_instructeurs.map(&:id), procedure_presentation)
}
recent_export = pending
.or(generated.where(updated_at: (5.minutes.ago)..))
.includes(:procedure_presentation)
.find_by(attributes)
return recent_export if recent_export.present?
create!(**attributes, groupe_instructeurs:,
procedure_presentation:,
procedure_presentation_snapshot: procedure_presentation&.snapshot)
end
def self.create_or_find_export(format, groupe_instructeurs, time_span_type:, statut:, procedure_presentation:)
create_with(groupe_instructeurs: groupe_instructeurs, procedure_presentation: procedure_presentation, procedure_presentation_snapshot: procedure_presentation&.snapshot)
.includes(:procedure_presentation)
.create_or_find_by(format: format,
time_span_type: time_span_type,
statut: statut,
key: generate_cache_key(groupe_instructeurs.map(&:id), procedure_presentation))
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)
])
end
def self.generate_cache_key(groupe_instructeurs_ids, procedure_presentation = nil)
@ -144,7 +108,7 @@ class Export < ApplicationRecord
def count
if procedure_presentation_id.present?
dossiers_for_export.size
dossiers_for_export.count
end
end

View file

@ -8,7 +8,8 @@
%h1.mb-2
Archives
-# index not renderable as administrateur flagged as manager, so render it anyway
= render Dossiers::ExportComponent.new(procedure: @procedure, exports: @exports, export_url: method(:download_admin_procedure_exports_path))
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_admin_procedure_exports_path))
= render Dossiers::ExportLinkComponent.new(procedure: @procedure, exports: @exports, export_url: method(:download_admin_procedure_exports_path))
= render partial: "shared/archives/notice"
= render partial: "shared/archives/table", locals: {count_dossiers_termines_by_month: @count_dossiers_termines_by_month, archives: @archives, average_dossier_weight: @average_dossier_weight, procedure: @procedure }

View file

@ -1,4 +1,4 @@
-# not renderable as administrateur flagged as manager, so render it anyway
- if @can_download_dossiers
= turbo_stream.update_all '.procedure-actions' do
= render Dossiers::ExportComponent.new(procedure: @procedure, exports: @exports, count: @dossiers_count, export_url: method(:admin_procedure_exports_path))
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, count: @dossiers_count, export_url: method(:admin_procedure_exports_path))

View file

@ -22,3 +22,7 @@
|
= link_to t('instructeurs.dossiers.header.banner.administrators_list'), administrateurs_instructeur_procedure_path(procedure), class: 'header-link'
|
= link_to t('instructeurs.dossiers.header.banner.exports_list'), exports_instructeur_procedure_path(procedure), class: 'header-link'
- if @has_export_notification
%span.notifications{ 'aria-label': t('instructeurs.dossiers.header.banner.exports_notification_label') }

View file

@ -1,40 +1,39 @@
%nav.tabs.mt-3
%ul
= tab_item(t('views.instructeurs.dossiers.tab_steps.to_follow'),
= tab_item(t(tab_i18n_key_from_status('a-suivre')),
instructeur_procedure_path(procedure, statut: 'a-suivre'),
active: statut == 'a-suivre',
badge: number_with_html_delimiter(a_suivre_count))
= tab_item(t('pluralize.followed', count: suivis_count),
= tab_item(t(tab_i18n_key_from_status('suivis'), count: suivis_count),
instructeur_procedure_path(procedure, statut: 'suivis'),
active: statut == 'suivis',
badge: number_with_html_delimiter(suivis_count),
notification: has_en_cours_notifications)
= tab_item(t('pluralize.processed', count: traites_count),
= tab_item(t(tab_i18n_key_from_status('traites'), count: traites_count),
instructeur_procedure_path(procedure, statut: 'traites'),
active: statut == 'traites',
badge: number_with_html_delimiter(traites_count),
notification: has_termine_notifications)
= tab_item(t('views.instructeurs.dossiers.tab_steps.total'),
= tab_item(t(tab_i18n_key_from_status('tous')),
instructeur_procedure_path(procedure, statut: 'tous'),
active: statut == 'tous',
badge: number_with_html_delimiter(tous_count))
= tab_item(t('pluralize.dossiers_supprimes_recemment', count: supprimes_recemment_count),
= tab_item(t(tab_i18n_key_from_status('supprimes_recemment'), count: supprimes_recemment_count),
instructeur_procedure_path(procedure, statut: 'supprimes_recemment'),
active: statut == 'supprimes_recemment',
badge: number_with_html_delimiter(supprimes_recemment_count))
- if procedure.procedure_expires_when_termine_enabled
= tab_item(t('pluralize.dossiers_close_to_expiration', count: expirant_count),
= tab_item(t(tab_i18n_key_from_status('expirant'), count: expirant_count),
instructeur_procedure_path(procedure, statut: 'expirant'),
active: statut == 'expirant',
badge: number_with_html_delimiter(expirant_count))
= tab_item(t('pluralize.archived', count: archives_count),
= tab_item(t(tab_i18n_key_from_status('archives'), count: archives_count),
instructeur_procedure_path(procedure, statut: 'archives'),
active: statut == 'archives',
badge: number_with_html_delimiter(archives_count))

View file

@ -11,7 +11,7 @@
.procedure-actions
- if @can_download_dossiers
= render Dossiers::ExportComponent.new(procedure: @procedure, exports: @exports, export_url: method(:download_export_instructeur_procedure_path))
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, exports: @exports, export_url: method(:download_export_instructeur_procedure_path))
.fr-container.flex= render partial: "tabs", locals: { procedure: @procedure,
statut: @statut,

View file

@ -2,7 +2,7 @@
- if @can_download_dossiers
- if @statut.nil?
= turbo_stream.update_all '.procedure-actions' do
= render Dossiers::ExportComponent.new(procedure: @procedure, exports: @exports, export_url: method(:download_export_instructeur_procedure_path))
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_export_instructeur_procedure_path))
- else
= turbo_stream.update_all '.dossiers-export' do
= render Dossiers::ExportComponent.new(procedure: @procedure, exports: @exports, statut: @statut, count: @dossiers_count, export_url: method(:download_export_instructeur_procedure_path))
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, statut: @statut, count: @dossiers_count, export_url: method(:download_export_instructeur_procedure_path))

View file

@ -0,0 +1,19 @@
- title = "Exports · #{@procedure.libelle}"
- content_for(:title, title)
= render partial: 'administrateurs/breadcrumbs',
locals: { steps: [[@procedure.libelle.truncate_words(10), instructeur_procedure_path(@procedure)],
[t('.title')]] }
.fr-container
%h1= t('.title')
= render Dsfr::CalloutComponent.new(title: nil) do |c|
- c.with_body do
%p= t('.export_description', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i)
- if @exports.present?
%div{ data: @exports.any?(&:pending?) ? { controller: "turbo-poll", turbo_poll_url_value: "", turbo_poll_interval_value: 10_000, turbo_poll_max_checks_value: 6 } : {} }
= render Dossiers::ExportLinkComponent.new(procedure: @procedure, exports: @exports, statut: @statut, count: @dossiers_count, class_btn: 'fr-btn--tertiary', export_url: method(:download_export_instructeur_procedure_path))
- else
= t('.no_export_html', expiration_time: Export::MAX_DUREE_CONSERVATION_EXPORT.in_hours.to_i )

View file

@ -0,0 +1,3 @@
= turbo_stream.replace "exports-list" do
- if @exports.present?
= render Dossiers::ExportLinkComponent.new(procedure: @procedure, exports: @exports, statut: @statut, count: @dossiers_count, class_btn: 'fr-btn--tertiary', export_url: method(:download_export_instructeur_procedure_path))

View file

@ -11,7 +11,7 @@
.procedure-actions
- if @can_download_dossiers
= render Dossiers::ExportComponent.new(procedure: @procedure, exports: @exports, export_url: method(:download_export_instructeur_procedure_path))
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, export_url: method(:download_export_instructeur_procedure_path))
.fr-container.flex= render partial: "tabs", locals: { procedure: @procedure,
statut: @statut,
@ -72,7 +72,7 @@
- if @dossiers_count > 0
%span.dossiers-export
= render Dossiers::ExportComponent.new(procedure: @procedure, exports: @exports, statut: @statut, count: @dossiers_count, class_btn: 'fr-btn--tertiary', export_url: method(:download_export_instructeur_procedure_path))
= render Dossiers::ExportDropdownComponent.new(procedure: @procedure, statut: @statut, count: @dossiers_count, class_btn: 'fr-btn--tertiary', export_url: method(:download_export_instructeur_procedure_path))
- if @filtered_sorted_paginated_ids.present? || @current_filters.count > 0
= render partial: "dossiers_filter_tags", locals: { procedure: @procedure, procedure_presentation: @procedure_presentation, current_filters: @current_filters, statut: @statut }

View file

@ -1,5 +1,6 @@
- if flash.any?
= turbo_stream.replace 'flash_messages', partial: 'layouts/flash_messages'
= turbo_stream.show 'flash_messages'
= turbo_stream.hide 'flash_messages', delay: 30000
- flash.clear

View file

@ -13,19 +13,20 @@
%li
%span.fr-badge des répertoires dossier-{id}/
Les répertoires de chaque dossiers terminés (acceptés, refusés et classés sans suite). Chacun de ces répertoires est structuré ainsi :
%ul
%li
%span.fr-badge un repertoire horodatage/
contenant le journal d'outil de chaque opération (un fichier terminant en .json) effectuée sur ce dossier
%li
%span.fr-badge un répertoire dossier/
contenant l'attestation de la décision finale sur ce dossier
%li
%span.fr-badge un répertoire pieces_justificatives/
contenant les pièces justificatives associées à ce dossier
%li
%span.fr-badge le dossier
exporté au format PDF
%li.list-style-type-none
%ul
%li
%span.fr-badge un repertoire horodatage/
contenant le journal d'outil de chaque opération (un fichier terminant en .json) effectuée sur ce dossier
%li
%span.fr-badge un répertoire dossier/
contenant l'attestation de la décision finale sur ce dossier
%li
%span.fr-badge un répertoire pieces_justificatives/
contenant les pièces justificatives associées à ce dossier
%li
%span.fr-badge le dossier
exporté au format PDF
%li
%span.fr-badge un répertoire bills/
contenant l'horodatage signé de toute la plateforme pour chaque jour où une opération a été effectuée sur l'un des dossiers présent dans l'archive

View file

@ -0,0 +1,5 @@
en:
administrateurs:
exports:
export_available_html: The export in %{file_format} format is ready. You can <a href="%{file_url}">download</a>
export_pending: We generate this export. Please come back in a few minutes to download it.

View file

@ -0,0 +1,5 @@
fr:
administrateurs:
exports:
export_available_html: Lexport au format %{file_format} est prêt. Vous pouvez le <a href="%{file_url}">télécharger</a>
export_pending: Nous générons cet export. Veuillez revenir dans quelques minutes pour le télécharger.

View file

@ -12,6 +12,8 @@ en:
button_delay_expiration: "Keep for one more month"
notification_management: notification management
administrators_list: administrators list
exports_list: exports list
exports_notification_label: A new export is ready to download
statistics: statistics
instructeurs: instructors
contact_users: contact users (draft)

View file

@ -12,6 +12,8 @@ fr:
button_delay_expiration: "Conserver un mois de plus"
notification_management: gestion des notifications
administrators_list: voir les administrateurs
exports_list: voir les exports
exports_notification_label: Un nouvel export est prêt à être téléchargé
statistics: statistiques
instructeurs: instructeurs
contact_users: contacter les usagers (brouillon)

View file

@ -23,3 +23,5 @@ en:
title: "%{procedure_libelle} - n°%{procedure_id} - administrators"
stats:
title: Statistics
exports:
title: Exports

View file

@ -0,0 +1,11 @@
en:
instructeurs:
procedures:
export_available_html: The export in %{file_format} format is ready. You can <a href="%{file_url}">download</a>
export_pending_html: We generate this export. You will be able to download it in a few minutes from <a href="%{url}">the exports list</a>.
exports:
title: Exports list
export_description: |
This list of exports contains the last exports you requested as well as those requested by instructors belonging to the same group.
They are available for %{expiration_time} hours after generation.
no_export_html: You have no export at the moment. <br> Can't find an export? It may have expired, exports are deleted after %{expiration_time} hours.

View file

@ -0,0 +1,11 @@
fr:
instructeurs:
procedures:
export_available_html: Lexport au format %{file_format} est prêt. Vous pouvez le <a href="%{file_url}">télécharger</a>
export_pending_html: Nous générons cet export. Vous pourrez le télécharger dans quelques minutes depuis <a href="%{url}">la liste des exports</a>.
exports:
title: Liste des exports
export_description: |
Cette liste d'exports contient les derniers exports que vous avez demandés ainsi que ceux demandés par les instructeurs appartenant au même groupe.
Ils sont disponibles pendant %{expiration_time} heures après leur génération.
no_export_html: Vous n'avez pas d'export pour le moment. <br> Vous ne trouvez pas un export ? Il a peut-être expiré, les exports sont supprimés au bout de %{expiration_time} heures.

View file

@ -415,6 +415,7 @@ Rails.application.routes.draw do
get 'download_export'
post 'download_export'
get 'stats'
get 'exports'
get 'email_notifications'
get 'administrateurs'
patch 'update_email_notifications'

View file

@ -0,0 +1,9 @@
class RemoveExportsUnicityConstraint < ActiveRecord::Migration[7.0]
disable_ddl_transaction!
def change
remove_index :exports, ["format", "time_span_type", "statut", "key"], unique: true
add_index :exports, "key", unique: false, algorithm: :concurrently
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: 2023_08_14_091648) do
ActiveRecord::Schema[7.0].define(version: 2023_09_28_083809) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -539,7 +539,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_14_091648) do
t.string "statut", default: "tous"
t.string "time_span_type", default: "everything", null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["format", "time_span_type", "statut", "key"], name: "index_exports_on_format_and_time_span_type_and_statut_and_key", unique: true
t.index ["key"], name: "index_exports_on_key"
t.index ["procedure_presentation_id"], name: "index_exports_on_procedure_presentation_id"
end

View file

@ -0,0 +1,55 @@
RSpec.describe Dossiers::ExportLinkComponent, type: :component do
let(:procedure) { create(:procedure) }
let(:groupe_instructeur) { create(:groupe_instructeur, procedure: procedure) }
let(:export) { create(:export, groupe_instructeurs: [groupe_instructeur], updated_at: 5.minutes.ago, created_at: 10.minutes.ago) }
let(:export_url) { double("ExportUrl", call: "/some/fake/path") }
let(:component) { described_class.new(procedure:, exports: [export], export_url:) }
describe "rendering" do
subject { render_inline(component).to_html }
context "when the export is available" do
before do
allow(export).to receive(:available?).and_return(true)
attachment = ActiveStorage::Attachment.new(name: "export", record: export, blob: ActiveStorage::Blob.new(byte_size: 10.kilobytes, content_type: "text/csv", filename: "export.csv"))
allow(export).to receive(:file).and_return(attachment)
end
it "displays the time info" do
expect(subject).to include("généré il y a 5 minutes")
end
it "displays the download button with the correct label" do
expect(subject).to include("Télécharger")
expect(subject).to include("CSV")
expect(subject).to include("10 ko")
end
end
context "when the export is not available" do
before do
allow(export).to receive(:available?).and_return(false)
allow(export).to receive(:failed?).and_return(false)
end
it "displays the pending label" do
expect(subject).to include("demandé il y a 10 minutes")
end
it "displays a refresh page button" do
expect(subject).to include("Recharger")
end
end
context "when the export has failed" do
before do
allow(export).to receive(:failed?).and_return(true)
end
it "displays the refresh old export button" do
expect(subject).to include("Regénérer")
end
end
end
end

View file

@ -466,6 +466,48 @@ describe Instructeurs::ProceduresController, type: :controller do
it { expect(assigns(:filtered_sorted_paginated_ids)).to match_array([archived_dossier].map(&:id)) }
end
end
context 'exports notification' do
context 'without generated export' do
before do
create(:export, :pending, groupe_instructeurs: [gi_2])
subject
end
it { expect(assigns(:has_export_notification)).to be(false) }
end
context 'with generated export' do
render_views
before do
create(:export, :generated, groupe_instructeurs: [gi_2], updated_at: 1.minute.ago)
if exports_seen_at
cookies.encrypted["exports_#{procedure.id}_seen_at"] = exports_seen_at.to_datetime.to_s
end
subject
end
context 'without cookie' do
let(:exports_seen_at) { nil }
it { expect(assigns(:has_export_notification)).to be(true) }
end
context 'with cookie in past' do
let(:exports_seen_at) { 1.hour.ago }
it { expect(assigns(:has_export_notification)).to be(true) }
it { expect(response.body).to match(/Un nouvel export est prêt/) }
end
context 'with cookie set after last generated export' do
let(:exports_seen_at) { 10.seconds.ago }
it { expect(assigns(:has_export_notification)).to be(false) }
end
end
end
end
end
@ -586,9 +628,9 @@ describe Instructeurs::ProceduresController, type: :controller do
get :download_export, params: { export_format: :csv, procedure_id: procedure.id }
end
context 'when the export is does not exist' do
context 'when the export does not exist' do
it 'displays an notice' do
is_expected.to redirect_to(instructeur_procedure_url(procedure))
is_expected.to redirect_to(exports_instructeur_procedure_url(procedure))
expect(flash.notice).to be_present
end
@ -601,7 +643,7 @@ describe Instructeurs::ProceduresController, type: :controller do
end
it 'displays an notice' do
is_expected.to redirect_to(instructeur_procedure_url(procedure))
is_expected.to redirect_to(exports_instructeur_procedure_url(procedure))
expect(flash.notice).to be_present
end
end
@ -627,7 +669,7 @@ describe Instructeurs::ProceduresController, type: :controller do
end
it 'displays an notice' do
is_expected.to redirect_to(instructeur_procedure_url(procedure))
is_expected.to redirect_to(exports_instructeur_procedure_url(procedure))
expect(flash.notice).to be_present
end
end
@ -650,4 +692,41 @@ describe Instructeurs::ProceduresController, type: :controller do
it { is_expected.to have_http_status(:forbidden) }
end
end
describe '#exports' do
let(:instructeur) { create(:instructeur) }
let!(:procedure) { create(:procedure) }
let!(:assign_to) { create(:assign_to, instructeur: instructeur, groupe_instructeur: build(:groupe_instructeur, procedure: procedure), manager: manager) }
let!(:gi_0) { assign_to.groupe_instructeur }
let!(:gi_1) { create(:groupe_instructeur, label: 'gi_1', procedure: procedure, instructeurs: [instructeur]) }
let(:manager) { false }
before { sign_in(instructeur.user) }
subject do
get :exports, params: { procedure_id: procedure.id }
end
context 'when there is one export in the instructeurs group' do
let!(:export) { create(:export, groupe_instructeurs: [gi_1]) }
it 'retrieves the export' do
subject
expect(assigns(:exports)).to eq([export])
end
end
context 'when there is one export in another instructeurs group' do
let!(:instructeur_2) { create(:instructeur) }
let!(:gi_2) { create(:groupe_instructeur, label: 'gi_2', procedure: procedure, instructeurs: [instructeur_2]) }
let!(:export) { create(:export, groupe_instructeurs: [gi_2]) }
it 'does not retrieved the export' do
subject
expect(assigns(:exports)).to eq([])
end
end
context 'when logged in through super admin' do
let(:manager) { true }
it { is_expected.to have_http_status(:forbidden) }
end
end
end

View file

@ -61,9 +61,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], nil)).to eq({ csv: { statut: {}, time_span_type: {} }, xlsx: { statut: {}, time_span_type: {} }, ods: { statut: {}, time_span_type: {} }, zip: { statut: {}, time_span_type: {} }, json: { 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: {} }, zip: { statut: {}, time_span_type: {} }, json: { 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: {} }, zip: { statut: {}, time_span_type: {} }, json: { statut: {}, time_span_type: {} } }) }
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
@ -73,19 +73,13 @@ RSpec.describe Export, type: :model do
let!(:procedure_presentation) { create(:procedure_presentation, procedure: gi_1.procedure) }
it 'find global exports as well as filtered one' do
expect(Export.find_for_groupe_instructeurs([gi_2.id, gi_1.id], export_with_filter.procedure_presentation))
.to eq({
csv: { statut: { Export.statuts.fetch(:suivis) => export_with_filter }, time_span_type: { 'everything' => export_global } },
xlsx: { statut: {}, time_span_type: {} },
ods: { statut: {}, time_span_type: {} },
zip: { statut: {}, time_span_type: {} },
json: { statut: {}, time_span_type: {} }
})
expect(Export.by_key([gi_2.id, gi_1.id], export_with_filter.procedure_presentation))
.to contain_exactly(export_with_filter, export_global)
end
end
end
describe '.find_or_create_export' do
describe '.find_or_create_fresh_export' do
let!(:procedure) { create(:procedure) }
let!(:gi_1) { create(:groupe_instructeur, procedure: procedure, instructeurs: [create(:instructeur)]) }
let!(:pp) { gi_1.instructeurs.first.procedure_presentation_and_errors_for_procedure_id(procedure.id).first }
@ -93,18 +87,50 @@ RSpec.describe Export, type: :model do
context 'with procedure_presentation having different filters' do
it 'works once' do
expect { Export.find_or_create_export(:zip, [gi_1], time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) }
expect { Export.find_or_create_fresh_export(:zip, [gi_1], time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) }
.to change { Export.count }.by(1)
end
it 'works once, changes procedure_presentation, recreate a new' do
expect { Export.find_or_create_export(:zip, [gi_1], time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) }
expect { Export.find_or_create_fresh_export(:zip, [gi_1], 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', 'self/updated_at', '10/12/2021')
expect { Export.find_or_create_export(:zip, [gi_1], time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) }
expect { Export.find_or_create_fresh_export(:zip, [gi_1], time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) }
.to change { Export.count }.by(1)
end
end
context 'with existing matching export' do
def find_or_create =
Export.find_or_create_fresh_export(:zip, [gi_1], time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp)
context 'freshly generate export' do
before { find_or_create.update!(job_status: :generated, updated_at: 1.second.ago) }
it 'returns current pending export' do
current_export = find_or_create
expect(find_or_create).to eq(current_export)
end
end
context 'old generated export' do
before { find_or_create.update!(job_status: :generated, updated_at: 1.hour.ago) }
it 'returns a new export' do
expect { find_or_create }.to change { Export.count }.by(1)
end
end
context 'pending export' do
before { find_or_create.update!(updated_at: 1.hour.ago) }
it 'returns current pending export' do
current_export = find_or_create
expect(find_or_create).to eq(current_export)
end
end
end
end
describe '.dossiers_for_export' do
@ -147,4 +173,28 @@ RSpec.describe Export, type: :model do
end
end
end
describe '.for_groupe_instructeurs' do
let!(:groupe_instructeur1) { create(:groupe_instructeur) }
let!(:groupe_instructeur2) { create(:groupe_instructeur) }
let!(:groupe_instructeur3) { create(:groupe_instructeur) }
let!(:export1) { create(:export, groupe_instructeurs: [groupe_instructeur1, groupe_instructeur2]) }
let!(:export2) { create(:export, groupe_instructeurs: [groupe_instructeur2]) }
let!(:export3) { create(:export, groupe_instructeurs: [groupe_instructeur3]) }
it 'returns exports for the specified groupe instructeurs' do
expect(Export.for_groupe_instructeurs([groupe_instructeur1.id, groupe_instructeur2.id]))
.to match_array([export1, export2])
end
it 'does not return exports not associated with the specified groupe instructeurs' do
expect(Export.for_groupe_instructeurs([groupe_instructeur1.id])).not_to include(export2, export3)
end
it 'returns unique exports even if they belong to multiple matching groupe instructeurs' do
results = Export.for_groupe_instructeurs([groupe_instructeur1.id])
expect(results.count).to eq(1)
end
end
end

View file

@ -120,15 +120,17 @@ describe 'Instructing a dossier:', js: true, retry: 3 do
end
expect(page).to have_text('Nous générons cet export.')
click_on "Télécharger un dossier"
expect(page).to have_text('Un export au format .csv est en train dêtre généré')
click_on "voir les exports"
expect(page).to have_text("Export .csv dun dossier « à suivre » demandé il y a moins d'une minute")
expect(page).to have_text("En préparation")
assert_performed_jobs 2 do
perform_enqueued_jobs(only: ExportJob)
end
page.driver.browser.navigate.refresh
click_on "Télécharger un dossier"
expect(page).to have_text('Télécharger lexport au format .csv')
page.driver.browser.navigate.refresh
expect(page).to have_text('Télécharger lexport')
end
scenario 'A instructeur can see the personnes impliquées' do