Add last_month export

This commit is contained in:
Paul Chavard 2021-06-16 11:46:25 +02:00
parent f5f4aa9bf2
commit f238710044
14 changed files with 125 additions and 50 deletions

View file

@ -146,6 +146,7 @@ module Instructeurs
def download_export def download_export
export_format = params[:export_format] export_format = params[:export_format]
time_span_type = params[:time_span_type] || Export.time_span_types.fetch(:everything)
groupe_instructeurs = current_instructeur groupe_instructeurs = current_instructeur
.groupe_instructeurs .groupe_instructeurs
.where(procedure: procedure) .where(procedure: procedure)
@ -155,11 +156,11 @@ module Instructeurs
.fetch_values('tous', 'archives') .fetch_values('tous', 'archives')
.sum .sum
export = Export.find_or_create_export(export_format, groupe_instructeurs) export = Export.find_or_create_export(export_format, time_span_type, groupe_instructeurs)
if export.ready? && export.old? && params[:force_export] if export.ready? && export.old? && params[:force_export]
export.destroy export.destroy
export = Export.find_or_create_export(export_format, groupe_instructeurs) export = Export.find_or_create_export(export_format, time_span_type, groupe_instructeurs)
end end
if export.ready? if export.ready?
@ -221,7 +222,7 @@ module Instructeurs
end end
def assign_exports def assign_exports
@xlsx_export, @csv_export, @ods_export = Export.find_for_groupe_instructeurs(groupe_instructeur_ids) @exports = Export.find_for_groupe_instructeurs(groupe_instructeur_ids)
end end
def assign_to def assign_to

View file

@ -107,4 +107,10 @@ module DossierHelper
return base_url if siren.blank? return base_url if siren.blank?
"#{base_url}/entreprise/#{siren}" "#{base_url}/entreprise/#{siren}"
end end
def exports_list(exports)
Export::FORMATS.map do |(format, time_span_type)|
[format, time_span_type, exports[format] && exports[format][time_span_type]]
end
end
end end

View file

@ -2,11 +2,12 @@
# #
# Table name: exports # Table name: exports
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# format :string not null # format :string not null
# key :text not null # key :text not null
# created_at :datetime not null # time_span_type :string default("everything"), not null
# updated_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null
# #
class Export < ApplicationRecord class Export < ApplicationRecord
MAX_DUREE_CONSERVATION_EXPORT = 3.hours MAX_DUREE_CONSERVATION_EXPORT = 3.hours
@ -17,6 +18,11 @@ class Export < ApplicationRecord
xlsx: 'xlsx' xlsx: 'xlsx'
} }
enum time_span_type: {
everything: 'everything',
monthly: 'monthly'
}
has_and_belongs_to_many :groupe_instructeurs has_and_belongs_to_many :groupe_instructeurs
has_one_attached :file has_one_attached :file
@ -27,13 +33,19 @@ class Export < ApplicationRecord
after_create_commit :compute_async 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]
end
end
def compute_async def compute_async
ExportJob.perform_later(self) ExportJob.perform_later(self)
end end
def compute def compute
file.attach( file.attach(
io: io, io: io(since: since),
filename: filename, filename: filename,
content_type: content_type, content_type: content_type,
# We generate the exports ourselves, so they are safe # We generate the exports ourselves, so they are safe
@ -41,6 +53,10 @@ class Export < ApplicationRecord
) )
end end
def since
time_span_type == Export.time_span_types.fetch(:monthly) ? 30.days.ago : nil
end
def ready? def ready?
file.attached? file.attached?
end end
@ -49,16 +65,24 @@ class Export < ApplicationRecord
updated_at < 20.minutes.ago updated_at < 20.minutes.ago
end end
def self.find_or_create_export(format, groupe_instructeurs) def self.find_or_create_export(format, time_span_type, groupe_instructeurs)
create_with(groupe_instructeurs: groupe_instructeurs) create_with(groupe_instructeurs: groupe_instructeurs)
.create_or_find_by(format: format, key: generate_cache_key(groupe_instructeurs.map(&:id))) .create_or_find_by(format: format,
time_span_type: time_span_type,
key: generate_cache_key(groupe_instructeurs.map(&:id)))
end end
def self.find_for_groupe_instructeurs(groupe_instructeurs_ids) def self.find_for_groupe_instructeurs(groupe_instructeurs_ids)
exports = where(key: generate_cache_key(groupe_instructeurs_ids)) exports = where(key: generate_cache_key(groupe_instructeurs_ids))
['xlsx', 'csv', 'ods'] [:xlsx, :csv, :ods].map do |format|
.map { |format| exports.find { |export| export.format == 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
end end
def self.generate_cache_key(groupe_instructeurs_ids) def self.generate_cache_key(groupe_instructeurs_ids)
@ -72,8 +96,11 @@ class Export < ApplicationRecord
"dossiers_#{procedure_identifier}_#{Time.zone.now.strftime('%Y-%m-%d_%H-%M')}.#{format}" "dossiers_#{procedure_identifier}_#{Time.zone.now.strftime('%Y-%m-%d_%H-%M')}.#{format}"
end end
def io def io(since: nil)
dossiers = Dossier.where(groupe_instructeur: groupe_instructeurs) dossiers = Dossier.where(groupe_instructeur: groupe_instructeurs)
if since.present?
dossiers = dossiers.where('dossiers.en_construction_at > ?', since)
end
service = ProcedureExportService.new(procedure, dossiers) service = ProcedureExportService.new(procedure, dossiers)
case format.to_sym case format.to_sym

View file

@ -2,20 +2,20 @@
%span.dropdown %span.dropdown
%button.button.dropdown-button{ 'aria-expanded' => 'false', 'aria-controls' => 'download-menu' } %button.button.dropdown-button{ 'aria-expanded' => 'false', 'aria-controls' => 'download-menu' }
Télécharger tous les dossiers Télécharger tous les dossiers
#download-menu.dropdown-content.fade-in-down{ style: 'width: 330px' } #download-menu.dropdown-content.fade-in-down{ style: 'width: 450px' }
%ul.dropdown-items %ul.dropdown-items
- [[xlsx_export, :xlsx], [ods_export, :ods], [csv_export, :csv]].each do |(export, format)| - exports_list(exports).each do |(format, time_span_type, export)|
%li %li
- if export.nil? - if export.nil?
= link_to t("#{format}_html", scope: [:instructeurs, :procedure, :export_stale]), download_export_instructeur_procedure_path(procedure, export_format: format), remote: true = 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
- elsif export.ready? - elsif export.ready?
= link_to t(:export_ready_html, export_time: time_ago_in_words(export.updated_at), export_format: ".#{format}", scope: [:instructeurs, :procedure]), export.file.service_url, target: "_blank", rel: "noopener" = link_to t("export_#{time_span_type}_ready_html", export_time: time_ago_in_words(export.updated_at), export_format: ".#{format}", scope: [:instructeurs, :procedure]), export.file.service_url, target: "_blank", rel: "noopener"
- if export.old? - if export.old?
= button_to download_export_instructeur_procedure_path(procedure, export_format: format, force_export: true), class: "button small", style: "padding-right: 2px", title: t(:short, export_format: ".#{format}", scope: [:instructeurs, :procedure, :export_stale]), remote: true, method: :get, params: { export_format: format, force_export: true } do = 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 .icon.retry
- else - 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, no_progress_notification: true) }
= t(:export_pending_html, export_time: time_ago_in_words(export.created_at), export_format: ".#{format}", scope: [:instructeurs, :procedure]) = t("export_#{time_span_type}_pending_html", export_time: time_ago_in_words(export.created_at), export_format: ".#{format}", scope: [:instructeurs, :procedure])
- if procedure.feature_enabled?(:archive_zip_globale) - if procedure.feature_enabled?(:archive_zip_globale)
%li %li
= link_to t(:download_archive, scope: [:instructeurs, :procedure]), instructeur_archives_path(procedure) = link_to t(:download_archive, scope: [:instructeurs, :procedure]), instructeur_archives_path(procedure)

View file

@ -1,9 +1,10 @@
<%= render_to_element('.procedure-actions', partial: "download_dossiers", <%= render_to_element('.procedure-actions', partial: "download_dossiers", locals: { procedure: @procedure, exports: @exports, dossier_count: @dossier_count }) %>
locals: { procedure: @procedure, xlsx_export: @xlsx_export, csv_export: @csv_export, ods_export: @ods_export, dossier_count: @dossier_count }) %>
<% [[@xlsx_export, :xlsx], [@csv_export, :csv], [@ods_export, :ods]].each do |(export, format)| %> <% @exports.each do |format, exports| %>
<% if export && !export.ready? %> <% exports.each do |time_span_type, export| %>
<%= fire_event('export:update', { url: download_export_instructeur_procedure_path(@procedure, export_format: format, no_progress_notification: true) }.to_json ) %> <% 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) %>
<% end %>
<% end %> <% end %>
<% end %> <% end %>

View file

@ -49,8 +49,7 @@
badge: number_with_html_delimiter(@archives_count)) badge: number_with_html_delimiter(@archives_count))
.procedure-actions .procedure-actions
= render partial: "download_dossiers", = render partial: "download_dossiers", locals: { procedure: @procedure, exports: @exports, dossier_count: @tous_count + @archives_count }
locals: { procedure: @procedure, xlsx_export: @xlsx_export, csv_export: @csv_export, ods_export: @ods_export, dossier_count: @tous_count + @archives_count }
.container .container
- if @statut == 'a-suivre' - if @statut == 'a-suivre'

View file

@ -7,7 +7,7 @@
"check_name": "SQL", "check_name": "SQL",
"message": "Possible SQL injection", "message": "Possible SQL injection",
"file": "app/models/procedure_presentation.rb", "file": "app/models/procedure_presentation.rb",
"line": 111, "line": 114,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "dossiers.with_type_de_champ_private(column).order(\"champs.value #{order}\")", "code": "dossiers.with_type_de_champ_private(column).order(\"champs.value #{order}\")",
"render_path": null, "render_path": null,
@ -35,7 +35,7 @@
"type": "controller", "type": "controller",
"class": "Users::DossiersController", "class": "Users::DossiersController",
"method": "merci", "method": "merci",
"line": 195, "line": 188,
"file": "app/controllers/users/dossiers_controller.rb", "file": "app/controllers/users/dossiers_controller.rb",
"rendered": { "rendered": {
"name": "users/dossiers/merci", "name": "users/dossiers/merci",
@ -54,20 +54,20 @@
{ {
"warning_type": "Redirect", "warning_type": "Redirect",
"warning_code": 18, "warning_code": 18,
"fingerprint": "8b22d0fa97c6b32921a3383a60dd63f1d2c0723c48f30bdc2d4abe41fe4abccc", "fingerprint": "7e27a03f04576569601d7ec70bddd05c21c4f2de17448e6e093f76844c59e0a0",
"check_name": "Redirect", "check_name": "Redirect",
"message": "Possible unprotected redirect", "message": "Possible unprotected redirect",
"file": "app/controllers/instructeurs/procedures_controller.rb", "file": "app/controllers/instructeurs/procedures_controller.rb",
"line": 176, "line": 175,
"link": "https://brakemanscanner.org/docs/warning_types/redirect/", "link": "https://brakemanscanner.org/docs/warning_types/redirect/",
"code": "redirect_to(Export.find_or_create_export(params[:export_format], current_instructeur.groupe_instructeurs.where(:procedure => procedure)).file.service_url)", "code": "redirect_to(Export.find_or_create_export(params[:export_format], (params[:time_span_type] or Export.time_span_types.fetch(:everything)), current_instructeur.groupe_instructeurs.where(:procedure => procedure)).file.service_url)",
"render_path": null, "render_path": null,
"location": { "location": {
"type": "method", "type": "method",
"class": "Instructeurs::ProceduresController", "class": "Instructeurs::ProceduresController",
"method": "download_export" "method": "download_export"
}, },
"user_input": "Export.find_or_create_export(params[:export_format], current_instructeur.groupe_instructeurs.where(:procedure => procedure)).file.service_url", "user_input": "Export.find_or_create_export(params[:export_format], (params[:time_span_type] or Export.time_span_types.fetch(:everything)), current_instructeur.groupe_instructeurs.where(:procedure => procedure)).file.service_url",
"confidence": "High", "confidence": "High",
"note": "" "note": ""
}, },
@ -98,7 +98,7 @@
"check_name": "SQL", "check_name": "SQL",
"message": "Possible SQL injection", "message": "Possible SQL injection",
"file": "app/models/procedure_presentation.rb", "file": "app/models/procedure_presentation.rb",
"line": 106, "line": 109,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "dossiers.with_type_de_champ(column).order(\"champs.value #{order}\")", "code": "dossiers.with_type_de_champ(column).order(\"champs.value #{order}\")",
"render_path": null, "render_path": null,
@ -118,7 +118,7 @@
"check_name": "SQL", "check_name": "SQL",
"message": "Possible SQL injection", "message": "Possible SQL injection",
"file": "app/models/procedure_presentation.rb", "file": "app/models/procedure_presentation.rb",
"line": 123, "line": 127,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "((\"self\" == \"self\") ? (dossiers) : (dossiers.includes(\"self\"))).order(\"#{self.class.sanitized_column(\"self\", column)} #{order}\")", "code": "((\"self\" == \"self\") ? (dossiers) : (dossiers.includes(\"self\"))).order(\"#{self.class.sanitized_column(\"self\", column)} #{order}\")",
"render_path": null, "render_path": null,
@ -138,7 +138,7 @@
"check_name": "SQL", "check_name": "SQL",
"message": "Possible SQL injection", "message": "Possible SQL injection",
"file": "app/models/procedure_presentation.rb", "file": "app/models/procedure_presentation.rb",
"line": 119, "line": 122,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "dossiers.includes(:followers_instructeurs).joins(\"LEFT OUTER JOIN users instructeurs_users ON instructeurs_users.instructeur_id = instructeurs.id\").order(\"instructeurs_users.email #{order}\")", "code": "dossiers.includes(:followers_instructeurs).joins(\"LEFT OUTER JOIN users instructeurs_users ON instructeurs_users.instructeur_id = instructeurs.id\").order(\"instructeurs_users.email #{order}\")",
"render_path": null, "render_path": null,
@ -152,6 +152,6 @@
"note": "`table`, `column` and `order` come from the model, which is validated to prevent injection attacks. Furthermore, `table` and `column` are escaped." "note": "`table`, `column` and `order` come from the model, which is validated to prevent injection attacks. Furthermore, `table` and `column` are escaped."
} }
], ],
"updated": "2020-09-17 13:28:51 +0200", "updated": "2021-06-17 09:26:40 +0200",
"brakeman_version": "4.8.1" "brakeman_version": "5.0.0"
} }

View file

@ -2,12 +2,18 @@ fr:
instructeurs: instructeurs:
procedure: procedure:
export_stale: export_stale:
short: Demander un export au format %{export_format} everything_short: Demander un export au format %{export_format}
csv_html: Demander un export au format .csv<br>(uniquement les dossiers, sans les champs répétables) everything_csv_html: Demander un export au format .csv<br>(uniquement les dossiers, sans les champs répétables)
xlsx_html: Demander un export au format .xlsx everything_xlsx_html: Demander un export au format .xlsx
ods_html: Demander un export au format .ods everything_ods_html: Demander un export au format .ods
export_ready_html: Télécharger lexport au format %{export_format}<br>(généré il y a %{export_time}) monthly_short: Demander un export des 30 derniers jours au format %{export_format}
export_pending_html: Un export au format %{export_format} est en train dêtre généré<br>(demandé il y a %{export_time}) monthly_csv_html: Demander un export des 30 derniers jours au format .csv<br>(uniquement les dossiers, sans les champs répétables)
monthly_xlsx_html: Demander un export des 30 derniers jours au format .xlsx
monthly_ods_html: Demander un export des 30 derniers jours au format .ods
export_everything_ready_html: Télécharger lexport au format %{export_format}<br>(généré il y a %{export_time})
export_everything_pending_html: Un export au format %{export_format} est en train dêtre généré<br>(demandé il y a %{export_time})
export_monthly_ready_html: Télécharger lexport des 30 derniers jours au format %{export_format}<br>(généré il y a %{export_time})
export_monthly_pending_html: Un export des 30 derniers jours au format %{export_format} est en train dêtre généré<br>(demandé il y a %{export_time})
download_archive: Télécharger une archive au format .zip de tous les dossiers et leurs pièces jointes download_archive: Télécharger une archive au format .zip de tous les dossiers et leurs pièces jointes
archive_pending_html: Archive en cours de création<br>(demandée il y a %{created_period}) archive_pending_html: Archive en cours de création<br>(demandée il y a %{created_period})
archive_ready_html: Télécharger larchive<br>(demandée il y a %{generated_period}) archive_ready_html: Télécharger larchive<br>(demandée il y a %{generated_period})

View file

@ -0,0 +1,7 @@
class AddTimeSpanTypeToExports < ActiveRecord::Migration[6.1]
def change
add_column :exports, :time_span_type, :string, default: 'everything', null: false
remove_index :exports, [:format, :key]
add_index :exports, [:format, :time_span_type, :key], unique: true
end
end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2021_06_04_095054) do ActiveRecord::Schema.define(version: 2021_06_05_095054) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -374,7 +374,8 @@ ActiveRecord::Schema.define(version: 2021_06_04_095054) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.text "key", null: false t.text "key", null: false
t.index ["format", "key"], name: "index_exports_on_format_and_key", unique: true t.string "time_span_type", default: "everything", null: false
t.index ["format", "time_span_type", "key"], name: "index_exports_on_format_and_time_span_type_and_key", unique: true
end end
create_table "exports_groupe_instructeurs", force: :cascade do |t| create_table "exports_groupe_instructeurs", force: :cascade do |t|

View file

@ -1,6 +1,7 @@
FactoryBot.define do FactoryBot.define do
factory :export do factory :export do
format { :csv } format { :csv }
time_span_type { Export.time_span_types.fetch(:everything) }
groupe_instructeurs { [association(:groupe_instructeur)] } groupe_instructeurs { [association(:groupe_instructeur)] }
after(:build) do |export, _evaluator| after(:build) do |export, _evaluator|

View file

@ -99,6 +99,32 @@ feature 'Instructing a dossier:', js: true do
expect(page).to have_text('Aucun dossier') expect(page).to have_text('Aucun dossier')
end end
scenario 'A instructeur can request an export' do
log_in(instructeur.email, password)
click_on procedure.libelle
test_statut_bar(a_suivre: 1, tous_les_dossiers: 1)
assert_performed_jobs 1
click_on "Télécharger tous les dossiers"
click_on "Demander un export au format .xlsx"
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é')
click_on "Télécharger tous les dossiers"
click_on "Demander un export des 30 derniers jours au format .xlsx"
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é')
perform_enqueued_jobs(only: ExportJob)
assert_performed_jobs 3
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')
end
scenario 'A instructeur can see the personnes impliquées' do scenario 'A instructeur can see the personnes impliquées' do
instructeur2 = create(:instructeur, password: password) instructeur2 = create(:instructeur, password: password)

View file

@ -48,9 +48,9 @@ RSpec.describe Export, type: :model do
context 'when an export is made for one groupe instructeur' do context 'when an export is made for one groupe instructeur' do
let!(:export) { create(:export, groupe_instructeurs: [gi_1, gi_2]) } let!(:export) { create(:export, groupe_instructeurs: [gi_1, gi_2]) }
it { expect(Export.find_for_groupe_instructeurs([gi_1.id])[1]).to eq(nil) } 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])[1]).to eq(export) } 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])[1]).to eq(nil) } it { expect(Export.find_for_groupe_instructeurs([gi_1.id, gi_2.id, gi_3.id])).to eq({}) }
end end
end end
end end

View file

@ -3,7 +3,7 @@ describe 'instructeurs/procedures/_download_dossiers.html.haml', type: :view do
let(:procedure) { create(:procedure) } let(:procedure) { create(:procedure) }
let(:dossier_count) { 0 } let(:dossier_count) { 0 }
subject { render 'instructeurs/procedures/download_dossiers.html.haml', procedure: procedure, dossier_count: dossier_count, xlsx_export: nil, csv_export: nil, ods_export: nil } subject { render 'instructeurs/procedures/download_dossiers.html.haml', procedure: procedure, dossier_count: dossier_count, exports: {} }
context "when procedure has 0 dossier" do context "when procedure has 0 dossier" do
it { is_expected.not_to include("Télécharger tous les dossiers") } it { is_expected.not_to include("Télécharger tous les dossiers") }