Merge pull request #8081 from mfo/US/poc-mutliple

amélioration(instructeur/dossiers): ETQ instructeur je peux archiver en masse des dossiers
This commit is contained in:
mfo 2022-12-06 11:38:20 +01:00 committed by GitHub
commit 78c85ee8c4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 1202 additions and 118 deletions

View file

@ -5,7 +5,6 @@
font-size: 14px;
th {
vertical-align: top;
padding: (2 * $default-spacer) $default-spacer;
}
@ -54,7 +53,7 @@
white-space: nowrap;
}
.folder-col {
.text-center {
text-align: center;
}

View file

@ -49,3 +49,4 @@ fieldset {
display: none;
}
}

View file

@ -19,4 +19,8 @@
background-image: none; // remove DSFR underline
}
}
.fr-btns-group .fr-btn {
margin-bottom: 0;
}
}

View file

@ -51,3 +51,17 @@
}
}
}
.force-table-100 {
width: calc(100vw);
}
.fr-table--bordered {
.table {
&.hoverable {
tbody tr:hover {
background: $white;
}
}
}
}

View file

@ -0,0 +1,14 @@
class Dossiers::BatchAlertComponent < ApplicationComponent
attr_reader :batch
def initialize(batch:, procedure:)
@batch = batch
@procedure = procedure
set_seen_at! if batch.finished_at.present?
end
def set_seen_at!
@batch.seen_at = Time.zone.now
@batch.save
end
end

View file

@ -0,0 +1,13 @@
en:
finish:
title: The bulk action is finished
text:
one: 1/1 file has been archived
other: "%{success_count}/%{count} files have been archived"
in_progress:
title: A bulk action is processing
text_success:
one: 1/1 is being archived
other: "%{progress_count}/%{count} files have been archived"
link_text: Refresh this webpage
after_link_text: to check if the process is over.

View file

@ -0,0 +1,13 @@
fr:
finish:
title: L'action de masse est terminée
text_success:
one: 1 dossier a été archivé
other: "%{success_count}/%{count} dossiers ont été archivés"
in_progress:
title: Une action de masse est en cours
text_success:
one: 1 dossier sera archivé
other: "%{progress_count}/%{count} dossiers ont été archivés"
link_text: Recharger la page
after_link_text: pour voir si l'opération est finie.

View file

@ -0,0 +1,16 @@
.fr-mb-5v
- if @batch.finished_at.present?
= render Dsfr::AlertComponent.new(title: t('.finish.title'), state: (@batch.failed_dossier_ids.size.positive? ? :warning : :success), heading_level: 'h2') do |c|
- c.body do
%p
= t('.finish.text_success', count: @batch.total_count, success_count: @batch.success_dossier_ids.size)
- else
= render Dsfr::AlertComponent.new(title: t('.in_progress.title'), state: :info, heading_level: 'h2') do |c|
- c.body do
%p= t('.in_progress.text_success', count: @batch.total_count, progress_count: @batch.progress_count)
%p
= link_to t('.link_text'), instructeur_procedure_path(@procedure, statut: params["statut"]), data: { action: 'turbo-poll#refresh' }
= t('.after_link_text')

View file

@ -0,0 +1,23 @@
class Dossiers::BatchOperationComponent < ApplicationComponent
attr_reader :statut, :procedure
def initialize(statut:, procedure:)
@statut = statut
@procedure = procedure
end
def render?
@statut == 'traites'
end
def available_operations
options = []
case @statut
when 'traites' then
options.push [t(".operations.archiver"), BatchOperation.operations.fetch(:archiver)]
else
end
options
end
end

View file

@ -0,0 +1,3 @@
fr:
operations:
archiver: 'Archive selected files'

View file

@ -0,0 +1,3 @@
fr:
operations:
archiver: 'Archiver les dossiers sélectionnés'

View file

@ -0,0 +1,4 @@
= form_for(BatchOperation.new, url: instructeur_batch_operations_path(procedure_id: procedure.id), method: :post, id: dom_id(BatchOperation.new), html: { class: 'flex justify-end' }, data: { "batch-operation-target" => "form"}) do |form|
.flex.align-center
- available_operations.each do |opt|
= form.button opt[0], class: "fr-btn fr-btn--icon-left fr-icon-folder-2-line", disabled: :disabled, name: "#{form.object_name}[operation]", data: { "batch-operation-target" => "submit", "submitter-operation" => opt[1]}

View file

@ -14,11 +14,12 @@ class Dsfr::AlertComponent < ApplicationComponent
private
def initialize(state:, title:)
def initialize(state:, title:, heading_level: 'h3')
@state = state
@title = title
@block = block
@heading_level = heading_level
end
attr_reader :state, :title, :block
attr_reader :state, :title, :block, :heading_level
end

View file

@ -1,3 +1,4 @@
%div{ class: "fr-alert fr-alert--#{state}" }
%h3.fr-alert__title= "#{prefix_for_state}#{title}"
= content_tag(heading_level, class: 'fr-alert__title') do
= "#{prefix_for_state}#{title}"
= body

View file

@ -0,0 +1,31 @@
module Instructeurs
class BatchOperationsController < ApplicationController
before_action :set_procedure
before_action :ensure_ownership!
def create
BatchOperation.safe_create!(batch_operation_params)
redirect_back(fallback_location: instructeur_procedure_url(@procedure.id))
end
private
def batch_operation_params
params.require(:batch_operation)
.permit(:operation, dossier_ids: [])
.merge(instructeur: current_instructeur)
.merge(groupe_instructeurs: current_instructeur.groupe_instructeurs.where(procedure_id: @procedure.id))
end
def set_procedure
@procedure = Procedure.find(params[:procedure_id])
end
def ensure_ownership!
if !current_instructeur.procedures.exists?(@procedure.id)
flash[:alert] = "Vous navez pas accès à cette démarche"
redirect_to root_path
end
end
end
end

View file

@ -9,7 +9,9 @@ module Instructeurs
include Zipline
before_action :redirect_on_dossier_not_found, only: :show
before_action :redirect_on_dossier_in_batch_operation, only: [:archive, :unarchive, :follow, :unfollow, :passer_en_instruction, :repasser_en_construction, :repasser_en_instruction, :terminer, :restore, :destroy, :extend_conservation]
after_action :mark_demande_as_read, only: :show
after_action :mark_messagerie_as_read, only: [:messagerie, :create_commentaire]
after_action :mark_avis_as_read, only: [:avis, :create_avis]
after_action :mark_annotations_privees_as_read, only: [:annotations_privees, :update_annotations]
@ -45,6 +47,7 @@ module Instructeurs
def show
@demande_seen_at = current_instructeur.follows.find_by(dossier: dossier_with_champs)&.demande_seen_at
@is_dossier_in_batch_operation = dossier.batch_operation.present?
respond_to do |format|
format.pdf do
@ -320,5 +323,17 @@ module Instructeurs
redirect_to instructeur_procedure_path(procedure)
end
end
def redirect_on_dossier_in_batch_operation
dossier_in_batch = begin
dossier
rescue ActiveRecord::RecordNotFound
current_instructeur.dossiers.find(params[:dossier_id])
end
if dossier_in_batch.batch_operation.present?
flash.alert = "Votre action n'a pas été effectuée, ce dossier fait parti d'un traitement de masse."
redirect_back(fallback_location: instructeur_dossier_path(procedure, dossier_in_batch))
end
end
end
end

View file

@ -90,6 +90,11 @@ module Instructeurs
@projected_dossiers = DossierProjectionService.project(@filtered_sorted_paginated_ids, procedure_presentation.displayed_fields)
assign_exports
@batch_operations = BatchOperation.joins(:groupe_instructeurs)
.where(groupe_instructeurs: current_instructeur.groupe_instructeurs.where(procedure_id: @procedure.id))
.where(seen_at: nil)
.distinct
end
def deleted_dossiers

View file

@ -0,0 +1,49 @@
import { ApplicationController } from './application_controller';
export class BatchOperationController extends ApplicationController {
static targets = ['form', 'input', 'submit'];
declare readonly formTarget: HTMLFormElement;
declare readonly submitTarget: HTMLInputElement;
declare readonly inputTargets: HTMLInputElement[];
connect() {
this.formTarget.addEventListener(
'submit',
this.interceptFormSubmit.bind(this)
);
}
// DSFR recommends a <input type="submit" /> or <button type="submit" /> a form (not a <select>)
// but we have many actions on the same form (archive all, accept all, ...)
// so we intercept the form submit, and set the BatchOperation.operation by hand using the Event.submitter
interceptFormSubmit(event: SubmitEvent) {
const submitter = event.submitter as HTMLInputElement;
submitter.setAttribute('value', submitter.dataset.submitterOperation || '');
return event;
}
onCheckOne(event: Event) {
this.toggleSubmitButtonWhenNeeded();
return event;
}
onCheckAll(event: Event) {
const target = event.target as HTMLInputElement;
this.inputTargets.forEach((e) => (e.checked = target.checked));
this.toggleSubmitButtonWhenNeeded();
return event;
}
toggleSubmitButtonWhenNeeded() {
const available = this.inputTargets.some((e) => e.checked);
if (available) {
this.submitTarget.removeAttribute('disabled');
} else {
this.submitTarget.setAttribute('disabled', 'disabled');
}
}
}

View file

@ -0,0 +1,5 @@
class BatchOperationEnqueueAllJob < ApplicationJob
def perform(batch_operation)
batch_operation.enqueue_all
end
end

View file

@ -0,0 +1,16 @@
class BatchOperationProcessOneJob < ApplicationJob
retry_on StandardError, attempts: 1
def perform(batch_operation, dossier)
dossier = batch_operation.dossiers_safe_scope.find(dossier.id)
begin
batch_operation.process_one(dossier)
batch_operation.track_processed_dossier(true, dossier)
rescue => error
batch_operation.track_processed_dossier(false, dossier)
raise error
end
rescue ActiveRecord::RecordNotFound
dossier.update(batch_operation_id: nil)
end
end

View file

@ -0,0 +1,8 @@
class Cron::PurgeStaleBatchOperationJob < Cron::CronJob
self.schedule_expression = "every 5 minutes"
def perform
BatchOperation.stale.destroy_all
BatchOperation.stuck.destroy_all
end
end

View file

@ -0,0 +1,128 @@
# == Schema Information
#
# Table name: batch_operations
#
# id :bigint not null, primary key
# failed_dossier_ids :bigint default([]), not null, is an Array
# finished_at :datetime
# operation :string not null
# payload :jsonb not null
# run_at :datetime
# seen_at :datetime
# success_dossier_ids :bigint default([]), not null, is an Array
# created_at :datetime not null
# updated_at :datetime not null
# instructeur_id :bigint not null
#
class BatchOperation < ApplicationRecord
enum operation: {
archiver: 'archiver'
}
has_many :dossiers, dependent: :nullify
has_and_belongs_to_many :groupe_instructeurs
belongs_to :instructeur
validates :operation, presence: true
RETENTION_DURATION = 4.hours
MAX_DUREE_GENERATION = 24.hours
scope :stale, lambda {
where.not(finished_at: nil)
.where('updated_at < ?', (Time.zone.now - RETENTION_DURATION))
}
scope :stuck, lambda {
where(finished_at: nil)
.where('updated_at < ?', (Time.zone.now - MAX_DUREE_GENERATION))
}
def dossiers_safe_scope(dossier_ids = self.dossier_ids)
query = Dossier.joins(:procedure)
.where(procedure: { id: instructeur.procedures.ids })
.where(id: dossier_ids)
.visible_by_administration
case operation
when BatchOperation.operations.fetch(:archiver) then
query.not_archived.state_termine
end
end
def enqueue_all
dossiers_safe_scope # later in batch .
.map { |dossier| BatchOperationProcessOneJob.perform_later(self, dossier) }
end
def process_one(dossier)
case operation
when BatchOperation.operations.fetch(:archiver)
dossier.archiver!(instructeur)
end
end
# use Arel::UpdateManager for array_append/array_remove (inspired by atomic_append)
# see: https://www.rubydoc.info/gems/arel/Arel/UpdateManager
# we use this approach to ensure atomicity
def track_processed_dossier(success, dossier)
transaction do
dossier.update(batch_operation: nil)
manager = Arel::UpdateManager.new.table(arel_table).where(arel_table[:id].eq(id))
values = []
values.push([arel_table[:run_at], Time.zone.now]) if called_for_first_time?
values.push([arel_table[:finished_at], Time.zone.now]) if called_for_last_time?(dossier)
values.push([arel_table[:updated_at], Time.zone.now])
if success
values.push([arel_table[:success_dossier_ids], Arel::Nodes::NamedFunction.new('array_append', [arel_table[:success_dossier_ids], dossier.id])])
values.push([arel_table[:failed_dossier_ids], Arel::Nodes::NamedFunction.new('array_remove', [arel_table[:failed_dossier_ids], dossier.id])])
else
values.push([arel_table[:failed_dossier_ids], Arel::Nodes::NamedFunction.new('array_append', [arel_table[:failed_dossier_ids], dossier.id])])
end
manager.set(values)
ActiveRecord::Base.connection.update(manager.to_sql)
end
end
# when an instructeur want to create a batch from his interface,
# another one might have run something on one of the dossier
# we use this approach to create a batch with given dossiers safely
def self.safe_create!(params)
transaction do
instance = new(params)
instance.dossiers = instance.dossiers_safe_scope(params[:dossier_ids])
.not_having_batch_operation
instance.save!
BatchOperationEnqueueAllJob.perform_later(instance)
instance
end
end
def called_for_first_time?
run_at.nil?
end
# beware, must be reloaded first
def called_for_last_time?(dossier_to_ignore)
dossiers.where.not(id: dossier_to_ignore.id).empty?
end
def total_count
total = failed_dossier_ids.size + success_dossier_ids.size
if finished_at.blank?
total += dossiers.count
end
total
end
def progress_count
failed_dossier_ids.size + success_dossier_ids.size
end
private
def arel_table
BatchOperation.arel_table
end
end

View file

@ -35,6 +35,7 @@
# termine_close_to_expiration_notice_sent_at :datetime
# created_at :datetime
# updated_at :datetime
# batch_operation_id :bigint
# dossier_transfer_id :bigint
# groupe_instructeur_id :bigint
# parent_dossier_id :bigint
@ -134,7 +135,7 @@ class Dossier < ApplicationRecord
belongs_to :revision, class_name: 'ProcedureRevision', optional: false
belongs_to :user, optional: true
belongs_to :parent_dossier, class_name: 'Dossier', optional: true
belongs_to :batch_operation, optional: true
has_one :france_connect_information, through: :user
has_one :procedure, through: :revision
@ -414,6 +415,7 @@ class Dossier < ApplicationRecord
end
end
scope :not_having_batch_operation, -> { where(batch_operation_id: nil) }
accepts_nested_attributes_for :individual
delegate :siret, :siren, to: :etablissement, allow_nil: true

View file

@ -18,6 +18,7 @@ class GroupeInstructeur < ApplicationRecord
has_many :deleted_dossiers
has_and_belongs_to_many :exports, dependent: :destroy
has_and_belongs_to_many :bulk_messages, dependent: :destroy
has_and_belongs_to_many :batch_operations, dependent: :destroy
validates :label, presence: true, allow_nil: false
validates :label, uniqueness: { scope: :procedure }

View file

@ -18,7 +18,7 @@ class Instructeur < ApplicationRecord
has_many :groupe_instructeurs, -> { order(:label) }, through: :assign_to
has_many :unordered_groupe_instructeurs, through: :assign_to, source: :groupe_instructeur
has_many :procedures, -> { distinct }, through: :unordered_groupe_instructeurs
has_many :batch_operations, dependent: :nullify
has_many :assign_to_with_email_notifications, -> { with_email_notifications }, class_name: 'AssignTo', inverse_of: :instructeur
has_many :groupe_instructeur_with_email_notifications, through: :assign_to_with_email_notifications, source: :groupe_instructeur

View file

@ -1,5 +1,5 @@
class DossierProjectionService
class DossierProjection < Struct.new(:dossier_id, :state, :archived, :hidden_by_user_at, :hidden_by_administration_at, :columns)
class DossierProjection < Struct.new(:dossier_id, :state, :archived, :hidden_by_user_at, :hidden_by_administration_at, :batch_operation_id, :columns)
end
TABLE = 'table'
@ -20,9 +20,10 @@ class DossierProjectionService
def self.project(dossiers_ids, fields)
state_field = { TABLE => 'self', COLUMN => 'state' }
archived_field = { TABLE => 'self', COLUMN => 'archived' }
batch_operation_field = { TABLE => 'self', COLUMN => 'batch_operation_id' }
hidden_by_user_at_field = { TABLE => 'self', COLUMN => 'hidden_by_user_at' }
hidden_by_administration_at_field = { TABLE => 'self', COLUMN => 'hidden_by_administration_at' }
([state_field, archived_field, hidden_by_user_at_field, hidden_by_administration_at_field] + fields) # the view needs state and archived dossier attributes
([state_field, archived_field, hidden_by_user_at_field, hidden_by_administration_at_field, batch_operation_field] + fields) # the view needs state and archived dossier attributes
.each { |f| f[:id_value_h] = {} }
.group_by { |f| f[TABLE] } # one query per table
.each do |table, fields|
@ -46,7 +47,7 @@ class DossierProjectionService
.pluck(:id, *fields.map { |f| f[COLUMN].to_sym })
.each do |id, *columns|
fields.zip(columns).each do |field, value|
if [state_field, archived_field, hidden_by_user_at_field, hidden_by_administration_at_field].include?(field)
if [state_field, archived_field, hidden_by_user_at_field, hidden_by_administration_at_field, batch_operation_field].include?(field)
field[:id_value_h][id] = value
else
field[:id_value_h][id] = value&.strftime('%d/%m/%Y') # other fields are datetime
@ -101,6 +102,7 @@ class DossierProjectionService
archived_field[:id_value_h][dossier_id],
hidden_by_user_at_field[:id_value_h][dossier_id],
hidden_by_administration_at_field[:id_value_h][dossier_id],
batch_operation_field[:id_value_h][dossier_id],
fields.map { |f| f[:id_value_h][dossier_id] }
)
end

View file

@ -1,3 +1,10 @@
- if @is_dossier_in_batch_operation
= render Dsfr::NoticeComponent.new(closable: false) do |c|
- c.with_title do
Un traitement de masse est en cours sur ce dossier, vous ne pouvez pas le modifier.
= link_to "Recharger la page", instructeur_dossier_path
pour voir si l'opération est finie.
.sub-header
.container
.flex.justify-between

View file

@ -2,8 +2,8 @@
= link_to restore_instructeur_dossier_path(procedure_id, dossier_id), method: :patch, class: "fr-btn fr-btn--secondary" do
= t('views.instructeurs.dossiers.restore')
- elsif close_to_expiration || Dossier::TERMINE.include?(state)
.dropdown.user-dossier-actions{ data: { controller: 'menu-button' } }
%button.fr-btn.dropdown-button{ data: { menu_button_target: 'button' } }
%li.dropdown.user-dossier-actions{ data: { controller: 'menu-button' } }
%button.fr-btn.fr-mb-0.dropdown-button{ data: { menu_button_target: 'button' } }
Actions
.dropdown-content.fade-in-down{ data: { menu_button_target: 'menu' }, id: "dossier_#{dossier_id}_actions_menu" }
%ul.dropdown-items
@ -11,23 +11,23 @@
%li
= link_to repousser_expiration_instructeur_dossier_path(procedure_id, dossier_id), method: :post do
%span.icon.standby
.dropdown-description= t('instructeurs.dossiers.header.banner.button_delay_expiration')
%span.dropdown-description= t('instructeurs.dossiers.header.banner.button_delay_expiration')
- if archived
%li
= link_to unarchive_instructeur_dossier_path(procedure_id, dossier_id), method: :patch do
%span.icon.unarchive
.dropdown-description
%span.dropdown-description
Désarchiver le dossier
- else
%li
= link_to archive_instructeur_dossier_path(procedure_id, dossier_id), method: :patch do
%span.icon.archive
.dropdown-description
%span.dropdown-description
Archiver le dossier
%li.danger
= link_to instructeur_dossier_path(procedure_id, dossier_id), method: :delete do
%span.icon.delete
.dropdown-description
%span.dropdown-description
= t('views.instructeurs.dossiers.delete_dossier')
- elsif Dossier::EN_CONSTRUCTION_OU_INSTRUCTION.include?(state)

View file

@ -45,7 +45,7 @@
%tbody
- @deleted_dossiers.each do |deleted_dossier|
%tr
%td.folder-col
%td.text-center
%span.icon.folder
%td.number-col
= deleted_dossier.dossier_id
@ -56,4 +56,3 @@
= paginate @deleted_dossiers
- else
Aucun dossier supprimé

View file

@ -58,8 +58,6 @@
%p.explication-onglet
= t('views.instructeurs.dossiers.tab_explainations.expirant')
- if @filtered_sorted_paginated_ids.present? || @current_filters.count > 0
- pagination = paginate @filtered_sorted_paginated_ids
= pagination
.flex
.flex-grow
= render partial: "dossiers_filter", locals: { procedure: @procedure, procedure_presentation: @procedure_presentation, current_filters: @current_filters, statut: @statut, filterable_fields_for_select: @filterable_fields_for_select }
@ -68,83 +66,109 @@
- if @dossiers_count > 0
.dossiers-export
= render Dossiers::ExportComponent.new(procedure: @procedure, exports: @exports, statut: @statut, count: @dossiers_count, export_url: method(:download_export_instructeur_procedure_path))
%hr.fr-mt-5v
%table.table.dossiers-table.hoverable
%thead
%tr
- if @statut.in? %w(suivis traites tous)
= render partial: "header_field", locals: { field: { "label" => "●", "table" => "notifications", "column" => "notifications" }, classname: "notification-col" }
- else
%th.notification-col
- batch_operation_component = Dossiers::BatchOperationComponent.new(statut: @statut, procedure: @procedure)
%div{ data: batch_operation_component.render? ? { controller: 'batch-operation' } : {} }
- @procedure_presentation.displayed_fields_for_headers.each do |field|
= render partial: "header_field", locals: { field: field, classname: field['classname'] }
- if @batch_operations.present?
- @batch_operations.each do |batch_operation|
= render Dossiers::BatchAlertComponent.new(batch: batch_operation, procedure: @procedure)
%th.action-col.follow-col
%span.dropdown{ data: { controller: 'menu-button', popover: 'true' } }
%button.button.dropdown-button{ data: { menu_button_target: 'button' } }
= t('views.instructeurs.dossiers.personalize')
#custom-menu.dropdown-content.fade-in-down{ data: { menu_button_target: 'menu' } }
= form_tag update_displayed_fields_instructeur_procedure_path(@procedure), method: :patch, class: 'dropdown-form large columns-form' do
= hidden_field_tag :values, nil
= react_component("ComboMultiple",
options: @displayable_fields_for_select,
selected: @displayable_fields_selected,
disabled: [],
label: 'Colonne à afficher',
group: '.columns-form',
name: 'values')
= submit_tag t('views.instructeurs.dossiers.save'), class: 'button'
%tbody
- @projected_dossiers.each do |p|
- path = instructeur_dossier_path(@procedure, p.dossier_id)
%tr{ class: [p.hidden_by_user_at.present? && "file-hidden-by-user"] }
%td.folder-col
- if p.hidden_by_administration_at.present?
%span.cell-link
%span.icon.folder
.flex
.flex-grow= render batch_operation_component
.fr-table.fr-table--bordered
%table.table.dossiers-table.hoverable
%thead
%tr
- if batch_operation_component.render?
%th.text-center
%input{ type: "checkbox", data: { "batch-operation-target" => "all","action" => "batch-operation#onCheckAll"}, id: dom_id(BatchOperation.new, :checkbox_all), aria: { label: t('views.instructeurs.dossiers.select_all') } }
- else
%a.cell-link{ href: path }
%span.icon.folder
- if @not_archived_notifications_dossier_ids.include?(p.dossier_id)
%span.notifications{ 'aria-label': 'notifications' }
%td.number-col
- if p.hidden_by_administration_at.present?
%span.cell-link= p.dossier_id
- else
%a.cell-link{ href: path }= p.dossier_id
- p.columns.each do |column|
%td
- if p.hidden_by_administration_at.present?
%span.cell-link
= column
= "- #{t('views.instructeurs.dossiers.deleted_by_user')}" if p.hidden_by_user_at.present?
- if @statut.in? %w(suivis traites tous)
= render partial: "header_field", locals: { field: { "label" => "●", "table" => "notifications", "column" => "notifications" }, classname: "notification-col text-center" }
- else
%a.cell-link{ href: path }
= column
= "- #{t('views.instructeurs.dossiers.deleted_by_user')}" if p.hidden_by_user_at.present?
%th.notification-col
%td.status-col
- if p.hidden_by_administration_at.present?
%span.cell-link= status_badge(p.state)
- else
%a.cell-link{ href: path }= status_badge(p.state)
- @procedure_presentation.displayed_fields_for_headers.each do |field|
= render partial: "header_field", locals: { field: field, classname: field['classname'] }
%td.action-col.follow-col
%ul.inline.fr-btns-group.fr-btns-group--sm.fr-btns-group--inline.fr-btns-group--icon-right
= render partial: 'dossier_actions', locals: { procedure_id: @procedure.id,
dossier_id: p.dossier_id,
state: p.state,
archived: p.archived,
dossier_is_followed: @followed_dossiers_id.include?(p.dossier_id),
close_to_expiration: @statut == 'expirant',
hidden_by_administration: @statut == 'supprimes_recemment' }
%th.action-col.follow-col
%span.dropdown{ data: { controller: 'menu-button', popover: 'true' } }
%button.button.dropdown-button{ data: { menu_button_target: 'button' } }
= t('views.instructeurs.dossiers.personalize')
#custom-menu.dropdown-content.fade-in-down{ data: { menu_button_target: 'menu' } }
= form_tag update_displayed_fields_instructeur_procedure_path(@procedure), method: :patch, class: 'dropdown-form large columns-form' do
= hidden_field_tag :values, nil
= react_component("ComboMultiple",
options: @displayable_fields_for_select,
selected: @displayable_fields_selected,
disabled: [],
label: 'Colonne à afficher',
group: '.columns-form',
name: 'values')
= submit_tag t('views.instructeurs.dossiers.save'), class: 'button'
%tr
%tbody
- @projected_dossiers.each do |p|
- path = instructeur_dossier_path(@procedure, p.dossier_id)
%tr{ class: [p.hidden_by_user_at.present? && "file-hidden-by-user"] }
%td.text-center
- if batch_operation_component.render?
- if p.batch_operation_id.present?
= check_box_tag :"batch_operation[dossier_ids][]", p.dossier_id, true, disabled: true, id: dom_id(BatchOperation.new, "checkbox_#{p.dossier_id}"), aria: {label: t('views.instructeurs.dossiers.batch_operation.disabled')}
- else
= check_box_tag :"batch_operation[dossier_ids][]", p.dossier_id, false, data: { "batch-operation-target" => "input", "action" => "batch-operation#onCheckOne"}, form: dom_id(BatchOperation.new), id: dom_id(BatchOperation.new, "checkbox_#{p.dossier_id}"), aria: {label: t('views.instructeurs.dossiers.batch_operation.enabled')}
- else
- if p.hidden_by_administration_at.present?
%span.cell-link
%span.icon.folder
- else
%a.cell-link{ href: path }
%span.icon.folder
- if @not_archived_notifications_dossier_ids.include?(p.dossier_id)
%span.notifications{ 'aria-label': 'notifications' }
%td.number-col
- if p.hidden_by_administration_at.present?
%span.cell-link= p.dossier_id
- else
%a.cell-link{ href: path }= p.dossier_id
- p.columns.each do |column|
%td
- if p.hidden_by_administration_at.present?
%span.cell-link
= column
= "- #{t('views.instructeurs.dossiers.deleted_by_user')}" if p.hidden_by_user_at.present?
- else
%a.cell-link{ href: path }
= column
= "- #{t('views.instructeurs.dossiers.deleted_by_user')}" if p.hidden_by_user_at.present?
%td.status-col
- if p.hidden_by_administration_at.present?
%span.cell-link= status_badge(p.state)
- else
%a.cell-link{ href: path }= status_badge(p.state)
%td.action-col.follow-col
%ul.inline.fr-btns-group.fr-btns-group--sm.fr-btns-group--inline.fr-btns-group--icon-right
= render partial: 'dossier_actions', locals: { procedure_id: @procedure.id,
dossier_id: p.dossier_id,
state: p.state,
archived: p.archived,
dossier_is_followed: @followed_dossiers_id.include?(p.dossier_id),
close_to_expiration: @statut == 'expirant',
hidden_by_administration: @statut == 'supprimes_recemment' }
%tfoot
%tr
%td.force-table-100{ colspan: @procedure_presentation.displayed_fields_for_headers.size + 2 }= paginate @filtered_sorted_paginated_ids
= pagination
- else
%h2.empty-text
= t('views.instructeurs.dossiers.no_file')

View file

@ -1,4 +1,4 @@
%td.folder-col
%td.text-center
%p.cell-link
%span.icon.folder

View file

@ -32,7 +32,7 @@
%tr{ class: [p.hidden_by_administration_at.present? && "file-hidden-by-user"] }
- if instructeur_and_expert_dossier
%td.folder-col.cell-link
%td.text-center.cell-link
%span.icon.folder
%td.number-col
.cell-link= p.dossier_id
@ -48,7 +48,7 @@
- else
%td.folder-col
%td.text-center
%a.cell-link{ href: path }
%span.icon.folder

View file

@ -176,6 +176,10 @@ en:
restore: "Restore"
filters:
title: Filter
select_all: Select all
batch_operation:
enabled: "Add this file to the selection for the bulk operation"
disabled: "Impossible to add this file to the selection because it is already in a bulk operation"
personalize: Personalize
follow_file: Follow-up the file
save: Save

View file

@ -171,6 +171,10 @@ fr:
restore: "Restaurer"
filters:
title: Filtrer
select_all: Tout selectionner
batch_operation:
enabled: "Ajouter ce dossier à la selection pour un traitement de masse"
disabled: "Impossible d'ajouter ce dossier à la selection car il est déjà dans un traitement de masse"
personalize: Personnaliser
download: Télécharger un dossier
follow_file: Suivre le dossier

View file

@ -414,6 +414,8 @@ Rails.application.routes.draw do
get 'telecharger_pjs' => 'dossiers#telecharger_pjs'
end
end
resources :batch_operations, only: [:create]
end
end
end

View file

@ -0,0 +1,14 @@
class CreateTableBatchOperation < ActiveRecord::Migration[6.1]
def change
create_table :batch_operations do |t|
t.bigint :instructeur_id, null: false
t.string :operation, null: false
t.jsonb :payload, default: {}, null: false
t.bigint :failed_dossier_ids, array: true, default: [], null: false
t.bigint :success_dossier_ids, array: true, default: [], null: false
t.datetime :run_at
t.datetime :finished_at
t.timestamps
end
end
end

View file

@ -0,0 +1,5 @@
class AddBatchOperationIdToDossiers < ActiveRecord::Migration[6.1]
def change
add_column :dossiers, :batch_operation_id, :bigint
end
end

View file

@ -0,0 +1,5 @@
class AddForeignKeyToBatchOperationId < ActiveRecord::Migration[6.1]
def change
add_foreign_key "dossiers", "batch_operations", validate: false
end
end

View file

@ -0,0 +1,5 @@
class ValidateForeighKeyToBatchOperationId < ActiveRecord::Migration[6.1]
def change
validate_foreign_key "dossiers", "batch_operations"
end
end

View file

@ -0,0 +1,5 @@
class AddForeignKeyToBatchOperationInstructeur < ActiveRecord::Migration[6.1]
def change
add_foreign_key "batch_operations", "instructeurs", validate: false
end
end

View file

@ -0,0 +1,5 @@
class AddValidateKeyToBatchBoperationInstructeur < ActiveRecord::Migration[6.1]
def change
validate_foreign_key "batch_operations", "instructeurs"
end
end

View file

@ -0,0 +1,7 @@
class AddIndexToDossiersBatchOperationId < ActiveRecord::Migration[6.1]
include Database::MigrationHelpers
disable_ddl_transaction!
def up
add_concurrent_index :dossiers, [:batch_operation_id]
end
end

View file

@ -0,0 +1,12 @@
class CreateBatchOperationGroupeInstructeurJoinTable < ActiveRecord::Migration[6.1]
def change
safety_assured do
create_table "batch_operations_groupe_instructeurs", force: :cascade do |t|
t.bigint "batch_operation_id", null: false
t.bigint "groupe_instructeur_id", null: false
t.timestamps
end
end
end
end

View file

@ -0,0 +1,5 @@
class AddSeenAtToBatchOperations < ActiveRecord::Migration[6.1]
def change
add_column :batch_operations, :seen_at, :datetime
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_11_30_113745) do
ActiveRecord::Schema.define(version: 2022_12_01_091658) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
@ -158,6 +158,26 @@ ActiveRecord::Schema.define(version: 2022_11_30_113745) do
t.index ["experts_procedure_id"], name: "index_avis_on_experts_procedure_id"
end
create_table "batch_operations", force: :cascade do |t|
t.datetime "created_at", precision: 6, null: false
t.bigint "failed_dossier_ids", default: [], null: false, array: true
t.datetime "finished_at"
t.bigint "instructeur_id", null: false
t.string "operation", null: false
t.jsonb "payload", default: {}, null: false
t.datetime "run_at"
t.datetime "seen_at"
t.bigint "success_dossier_ids", default: [], null: false, array: true
t.datetime "updated_at", precision: 6, null: false
end
create_table "batch_operations_groupe_instructeurs", force: :cascade do |t|
t.bigint "batch_operation_id", null: false
t.datetime "created_at", precision: 6, null: false
t.bigint "groupe_instructeur_id", null: false
t.datetime "updated_at", precision: 6, null: false
end
create_table "bill_signatures", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "digest"
@ -307,6 +327,7 @@ ActiveRecord::Schema.define(version: 2022_11_30_113745) do
t.datetime "archived_at"
t.string "archived_by"
t.boolean "autorisation_donnees"
t.bigint "batch_operation_id"
t.datetime "brouillon_close_to_expiration_notice_sent_at"
t.interval "conservation_extension", default: "PT0S"
t.datetime "created_at"
@ -340,6 +361,7 @@ ActiveRecord::Schema.define(version: 2022_11_30_113745) do
t.datetime "updated_at"
t.integer "user_id"
t.index ["archived"], name: "index_dossiers_on_archived"
t.index ["batch_operation_id"], name: "index_dossiers_on_batch_operation_id"
t.index ["dossier_transfer_id"], name: "index_dossiers_on_dossier_transfer_id"
t.index ["groupe_instructeur_id"], name: "index_dossiers_on_groupe_instructeur_id"
t.index ["hidden_at"], name: "index_dossiers_on_hidden_at"
@ -913,6 +935,7 @@ ActiveRecord::Schema.define(version: 2022_11_30_113745) do
add_foreign_key "attestations", "dossiers"
add_foreign_key "avis", "dossiers"
add_foreign_key "avis", "experts_procedures"
add_foreign_key "batch_operations", "instructeurs"
add_foreign_key "bulk_messages_groupe_instructeurs", "bulk_messages"
add_foreign_key "bulk_messages_groupe_instructeurs", "groupe_instructeurs"
add_foreign_key "champs", "champs", column: "parent_id"
@ -925,6 +948,7 @@ ActiveRecord::Schema.define(version: 2022_11_30_113745) do
add_foreign_key "commentaires", "instructeurs"
add_foreign_key "dossier_operation_logs", "bill_signatures"
add_foreign_key "dossier_transfer_logs", "dossiers"
add_foreign_key "dossiers", "batch_operations"
add_foreign_key "dossiers", "dossier_transfers"
add_foreign_key "dossiers", "dossiers", column: "parent_dossier_id"
add_foreign_key "dossiers", "groupe_instructeurs"

View file

@ -0,0 +1,58 @@
RSpec.describe Dossiers::BatchAlertComponent, type: :component do
let(:component) do
described_class.new(
batch: batch_operation,
procedure: procedure
)
end
let(:instructeur) { create(:instructeur) }
let(:procedure) { create(:procedure) }
let!(:dossier) { create(:dossier, :accepte, procedure: procedure) }
let!(:dossier_2) { create(:dossier, :accepte, procedure: procedure) }
let!(:batch_operation) { create(:batch_operation, operation: :archiver, dossiers: [dossier, dossier_2], instructeur: instructeur) }
subject { render_inline(component).to_html }
context 'in_progress' do
before {
batch_operation.track_processed_dossier(true, dossier)
batch_operation.reload
}
it { is_expected.to have_selector('.fr-alert--info') }
it { is_expected.to have_text("Une action de masse est en cours") }
it { is_expected.to have_text("1/2 dossiers ont été archivés") }
end
context 'finished and success' do
before {
batch_operation.track_processed_dossier(true, dossier)
batch_operation.track_processed_dossier(true, dossier_2)
batch_operation.reload
}
it { is_expected.to have_selector('.fr-alert--success') }
it { is_expected.to have_text("L'action de masse est terminée") }
it { is_expected.to have_text("2 dossiers ont été archivés") }
it { expect(batch_operation.seen_at).to eq(nil) }
end
context 'finished and fail' do
before {
batch_operation.track_processed_dossier(false, dossier)
batch_operation.track_processed_dossier(true, dossier_2)
batch_operation.reload
}
it { is_expected.to have_selector('.fr-alert--warning') }
it { is_expected.to have_text("L'action de masse est terminée") }
it { is_expected.to have_text("1/2 dossiers ont été archivés") }
it { expect(batch_operation.seen_at).to eq(nil) }
it 'does not display alert on the next render' do
render_inline(component).to_html
expect(batch_operation.seen_at).not_to eq(nil)
expect(subject).not_to have_text("1 dossier n'a pas été archivé")
end
end
end

View file

@ -0,0 +1,24 @@
RSpec.describe Dossiers::BatchOperationComponent, type: :component do
include ActionView::Context
include ActionView::Helpers::FormHelper
include ActionView::Helpers::FormOptionsHelper
let(:component) do
cmp = nil
form_for(BatchOperation.new, url: Rails.application.routes.url_helpers.instructeur_batch_operations_path(procedure_id: 1), method: :post, data: { controller: 'batch-operation' }) do |_form|
cmp = described_class.new(statut: statut, procedure: create(:procedure))
end
cmp
end
subject { render_inline(component).to_html }
context 'statut traite' do
let(:statut) { 'traites' }
it { is_expected.to have_selector('button') }
end
context 'statut tous' do
let(:statut) { 'tous' }
it { is_expected.not_to have_selector('button') }
end
end

View file

@ -0,0 +1,47 @@
# frozen_string_literal: true
describe Instructeurs::BatchOperationsController, type: :controller do
let(:instructeur) { create(:instructeur) }
let(:procedure) { create(:simple_procedure, instructeurs: [instructeur]) }
let(:dossier) { create(:dossier, :accepte, :with_individual, procedure: procedure) }
let(:params) do
{
procedure_id: procedure.id,
batch_operation: {
operation: BatchOperation.operations.fetch(:archiver),
dossier_ids: [dossier.id]
}
}
end
describe '#POST create' do
before { sign_in(instructeur.user) }
subject { post :create, params: params }
context 'ACL' do
let(:params) do
{ procedure_id: create(:procedure).id }
end
it 'fails when procedure does not belongs to instructeur' do
expect(subject).to have_http_status(302)
end
end
context 'success with valid dossier_ids' do
it 'creates a batch operation for our signed in instructeur' do
expect { subject }.to change { instructeur.batch_operations.count }.by(1)
end
it 'created a batch operation contains dossiers, instructeur, groupe_instructeur' do
subject
batch_operation = BatchOperation.first
expect(batch_operation.dossiers).to include(dossier)
expect(batch_operation.instructeur).to eq(instructeur)
expect(batch_operation.groupe_instructeurs.to_a).to eq(instructeur.groupe_instructeurs.to_a)
end
it 'enqueues a BatchOperationJob' do
expect { subject }.to have_enqueued_job(BatchOperationEnqueueAllJob).with(BatchOperation.last)
end
end
end
end

View file

@ -50,17 +50,28 @@ describe Instructeurs::DossiersController, type: :controller do
end
describe '#follow' do
let(:batch_operation) {}
before do
batch_operation
patch :follow, params: { procedure_id: procedure.id, dossier_id: dossier.id }
end
it { expect(instructeur.followed_dossiers).to match([dossier]) }
it { expect(flash.notice).to eq('Dossier suivi') }
it { expect(response).to redirect_to(instructeur_procedure_path(dossier.procedure)) }
context 'with dossier in batch_operation' do
let(:batch_operation) { create(:batch_operation, operation: :archiver, dossiers: [dossier], instructeur: instructeur) }
it { expect(instructeur.followed_dossiers).to eq([]) }
it { expect(response).to redirect_to(instructeur_dossier_path(dossier.procedure, dossier)) }
it { expect(flash.alert).to eq("Votre action n'a pas été effectuée, ce dossier fait parti d'un traitement de masse.") }
end
end
describe '#unfollow' do
let(:batch_operation) {}
before do
batch_operation
instructeur.followed_dossiers << dossier
patch :unfollow, params: { procedure_id: procedure.id, dossier_id: dossier.id }
instructeur.reload
@ -69,35 +80,58 @@ describe Instructeurs::DossiersController, type: :controller do
it { expect(instructeur.followed_dossiers).to match([]) }
it { expect(flash.notice).to eq("Vous ne suivez plus le dossier nº #{dossier.id}") }
it { expect(response).to redirect_to(instructeur_procedure_path(dossier.procedure)) }
context 'with dossier in batch_operation' do
let(:batch_operation) { create(:batch_operation, operation: :archiver, dossiers: [dossier], instructeur: instructeur) }
it { expect(instructeur.followed_dossiers).to eq([dossier]) }
it { expect(response).to redirect_to(instructeur_dossier_path(dossier.procedure, dossier)) }
it { expect(flash.alert).to eq("Votre action n'a pas été effectuée, ce dossier fait parti d'un traitement de masse.") }
end
end
describe '#archive' do
let(:batch_operation) {}
before do
instructeur.follow(dossier)
batch_operation
patch :archive, params: { procedure_id: procedure.id, dossier_id: dossier.id }
dossier.reload
instructeur.reload
instructeur.follow(dossier)
end
it { expect(dossier.archived).to be true }
it { expect(dossier.archived).to eq(true) }
it { expect(response).to redirect_to(instructeur_procedure_path(dossier.procedure)) }
context 'with dossier in batch_operation' do
let(:batch_operation) { create(:batch_operation, operation: :archiver, dossiers: [dossier], instructeur: instructeur) }
it { expect(dossier.archived).to eq(false) }
it { expect(response).to redirect_to(instructeur_dossier_path(dossier.procedure, dossier)) }
it { expect(flash.alert).to eq("Votre action n'a pas été effectuée, ce dossier fait parti d'un traitement de masse.") }
end
end
describe '#unarchive' do
let(:batch_operation) {}
before do
batch_operation
dossier.update(archived: true)
patch :unarchive, params: { procedure_id: procedure.id, dossier_id: dossier.id }
dossier.reload
end
it { expect(dossier.archived).to be false }
it { expect(dossier.reload.archived).to eq(false) }
it { expect(response).to redirect_to(instructeur_procedure_path(dossier.procedure)) }
context 'with dossier in batch_operation' do
let!(:batch_operation) { create(:batch_operation, operation: :archiver, dossiers: [dossier], instructeur: instructeur) }
it { expect(dossier.reload.archived).to eq(true) }
it { expect(response).to redirect_to(instructeur_dossier_path(dossier.procedure, dossier)) }
it { expect(flash.alert).to eq("Votre action n'a pas été effectuée, ce dossier fait parti d'un traitement de masse.") }
end
end
describe '#passer_en_instruction' do
let(:dossier) { create(:dossier, :en_construction, procedure: procedure) }
let(:batch_operation) {}
before do
batch_operation
sign_in(instructeur.user)
post :passer_en_instruction, params: { procedure_id: procedure.id, dossier_id: dossier.id }, format: :turbo_stream
end
@ -129,12 +163,20 @@ describe Instructeurs::DossiersController, type: :controller do
expect(response.body).to include('Le dossier est en ce moment accepté : il nest pas possible de le passer en instruction.')
end
end
context 'with dossier in batch_operation' do
let(:batch_operation) { create(:batch_operation, operation: :archiver, dossiers: [dossier], instructeur: instructeur) }
it { expect(response).to redirect_to(instructeur_dossier_path(dossier.procedure, dossier)) }
it { expect(flash.alert).to eq("Votre action n'a pas été effectuée, ce dossier fait parti d'un traitement de masse.") }
end
end
describe '#repasser_en_construction' do
let(:dossier) { create(:dossier, :en_instruction, procedure: procedure) }
let(:batch_operation) {}
before do
batch_operation
sign_in(instructeur.user)
post :repasser_en_construction,
params: { procedure_id: procedure.id, dossier_id: dossier.id },
@ -154,25 +196,29 @@ describe Instructeurs::DossiersController, type: :controller do
expect(response.body).to include('Le dossier est déjà en construction.')
end
end
context 'with dossier in batch_operation' do
let(:batch_operation) { create(:batch_operation, operation: :archiver, dossiers: [dossier], instructeur: instructeur) }
it { expect(dossier.reload.state).to eq('en_instruction') }
it { expect(response).to redirect_to(instructeur_dossier_path(dossier.procedure, dossier)) }
it { expect(flash.alert).to eq("Votre action n'a pas été effectuée, ce dossier fait parti d'un traitement de masse.") }
end
end
describe '#repasser_en_instruction' do
let(:dossier) { create(:dossier, :refuse, procedure: procedure) }
let(:batch_operation) {}
let(:current_user) { instructeur.user }
subject do
before do
sign_in current_user
batch_operation
post :repasser_en_instruction,
params: { procedure_id: procedure.id, dossier_id: dossier.id },
format: :turbo_stream
end
before do
sign_in current_user
end
context 'when the dossier is refuse' do
before { subject }
it { expect(dossier.reload.state).to eq(Dossier.states.fetch(:en_instruction)) }
it { expect(response).to have_http_status(:ok) }
it { expect(response.body).to include('.header-actions') }
@ -181,8 +227,6 @@ describe Instructeurs::DossiersController, type: :controller do
context 'when the dossier has already been put en_instruction' do
let(:dossier) { create(:dossier, :en_instruction, procedure: procedure) }
before { subject }
it 'warns about the error' do
expect(dossier.reload.state).to eq(Dossier.states.fetch(:en_instruction))
expect(response).to have_http_status(:ok)
@ -193,8 +237,6 @@ describe Instructeurs::DossiersController, type: :controller do
context 'when the dossier is accepte' do
let(:dossier) { create(:dossier, :accepte, procedure: procedure) }
before { subject }
it 'it is possible to go back to en_instruction as instructeur' do
expect(dossier.reload.state).to eq(Dossier.states.fetch(:en_instruction))
expect(response).to have_http_status(:ok)
@ -202,18 +244,20 @@ describe Instructeurs::DossiersController, type: :controller do
end
context 'when the dossier is done and the user delete it' do
let!(:dossier) { create(:dossier, :accepte, procedure: procedure, user: current_user) }
before do
dossier.update!(hidden_by_user_at: Time.zone.now)
subject
end
let!(:dossier) { create(:dossier, :accepte, procedure: procedure, user: current_user, hidden_by_user_at: Time.zone.now) }
it 'reveals the dossier' do
expect(dossier.reload.state).to eq(Dossier.states.fetch(:en_instruction))
expect(dossier.reload.hidden_by_user_at).to be_nil
end
end
context 'with dossier in batch_operation' do
let(:batch_operation) { create(:batch_operation, operation: :archiver, dossiers: [dossier], instructeur: instructeur) }
it { expect(dossier.reload.state).to eq('refuse') }
it { expect(response).to redirect_to(instructeur_dossier_path(dossier.procedure, dossier)) }
it { expect(flash.alert).to eq("Votre action n'a pas été effectuée, ce dossier fait parti d'un traitement de masse.") }
end
end
describe '#terminer' do
@ -256,6 +300,18 @@ describe Instructeurs::DossiersController, type: :controller do
it { expect(subject.body).to include('.header-actions') }
end
context 'with dossier in batch_operation' do
let!(:batch_operation) { create(:batch_operation, operation: :archiver, dossiers: [dossier], instructeur: instructeur) }
subject { post :terminer, params: { process_action: "refuser", procedure_id: procedure.id, dossier_id: dossier.id }, format: :turbo_stream }
it { expect { subject }.not_to change { dossier.reload.state } }
it { is_expected.to redirect_to(instructeur_dossier_path(dossier.procedure, dossier)) }
it 'flashes message' do
subject
expect(flash.alert).to eq("Votre action n'a pas été effectuée, ce dossier fait parti d'un traitement de masse.")
end
end
end
context "with classer_sans_suite" do
@ -654,8 +710,17 @@ describe Instructeurs::DossiersController, type: :controller do
end
it { expect(assigns(:include_infos_administration)).to eq(true) }
it { expect(assigns(:is_dossier_in_batch_operation)).to eq(false) }
it { expect(response).to render_template 'dossiers/show' }
end
context 'with dossier in batch_operation' do
let!(:batch_operation) { create(:batch_operation, operation: :archiver, dossiers: [dossier], instructeur: instructeur) }
it 'assigns variable with true value' do
get :show, params: { procedure_id: procedure.id, dossier_id: dossier.id }
expect(assigns(:is_dossier_in_batch_operation)).to eq(true)
end
end
end
describe "#update_annotations" do
@ -801,7 +866,9 @@ describe Instructeurs::DossiersController, type: :controller do
end
describe "#destroy" do
let(:batch_operation) {}
subject do
batch_operation
delete :destroy, params: {
procedure_id: procedure.id,
dossier_id: dossier.id
@ -881,6 +948,16 @@ describe Instructeurs::DossiersController, type: :controller do
expect(DeletedDossier.where(dossier_id: dossier.id).count).to eq(0)
end
end
context 'with dossier in batch_operation' do
let(:batch_operation) { create(:batch_operation, operation: :archiver, dossiers: [dossier], instructeur: instructeur) }
it { expect { subject }.not_to change { dossier.reload.hidden_at } }
it { is_expected.to redirect_to(instructeur_dossier_path(dossier.procedure, dossier)) }
it 'flashes message' do
subject
expect(flash.alert).to eq("Votre action n'a pas été effectuée, ce dossier fait parti d'un traitement de masse.")
end
end
end
describe '#extend_conservation' do
@ -900,6 +977,16 @@ describe Instructeurs::DossiersController, type: :controller do
expect(flash[:notice]).to eq(I18n.t('views.instructeurs.dossiers.archived_dossier'))
end
end
context 'with dossier in batch_operation' do
let!(:batch_operation) { create(:batch_operation, operation: :archiver, dossiers: [dossier], instructeur: instructeur) }
it { expect { subject }.not_to change { dossier.reload.conservation_extension } }
it { is_expected.to redirect_to(instructeur_dossier_path(dossier.procedure, dossier)) }
it 'flashes message' do
subject
expect(flash.alert).to eq("Votre action n'a pas été effectuée, ce dossier fait parti d'un traitement de masse.")
end
end
end
describe '#restore' do
@ -907,9 +994,10 @@ describe Instructeurs::DossiersController, type: :controller do
let!(:gi_p1_1) { GroupeInstructeur.create(label: '1', procedure: procedure) }
let!(:procedure) { create(:procedure, :published, :for_individual, instructeurs: [instructeur]) }
let!(:dossier) { create(:dossier, :accepte, :with_individual, procedure: procedure, groupe_instructeur: procedure.groupe_instructeurs.first, hidden_by_administration_at: 1.hour.ago) }
let(:batch_operation) {}
before do
sign_in(instructeur.user)
batch_operation
instructeur.groupe_instructeurs << gi_p1_1
patch :restore,
params: {
@ -921,5 +1009,12 @@ describe Instructeurs::DossiersController, type: :controller do
it "puts hidden_by_administration_at to nil" do
expect(dossier.reload.hidden_by_administration_at).to eq(nil)
end
context 'with dossier in batch_operation' do
let(:batch_operation) { create(:batch_operation, operation: :archiver, dossiers: [dossier], instructeur: instructeur) }
it { expect(dossier.hidden_by_administration_at).not_to eq(nil) }
it { expect(response).to redirect_to(instructeur_dossier_path(dossier.procedure, dossier)) }
it { expect(flash.alert).to eq("Votre action n'a pas été effectuée, ce dossier fait parti d'un traitement de masse.") }
end
end
end

View file

@ -341,6 +341,24 @@ describe Instructeurs::ProceduresController, type: :controller do
it { expect(assigns(:filtered_sorted_paginated_ids)).to match_array([termine_dossier, termine_dossier_on_gi_2].map(&:id)) }
end
context 'with batch operations' do
let!(:batch_operation) { create(:batch_operation, operation: :archiver, dossiers: [termine_dossier], instructeur: instructeur, groupe_instructeurs: instructeur.groupe_instructeurs) }
let!(:termine_dossier_2) { create(:dossier, :accepte, procedure: procedure) }
let!(:batch_operation_2) { create(:batch_operation, operation: :archiver, dossiers: [termine_dossier_2], instructeur: instructeur, groupe_instructeurs: instructeur.groupe_instructeurs) }
before { subject }
it { expect(assigns(:batch_operations)).to match_array([batch_operation, batch_operation_2]) }
end
context 'with a batch operation not attached to the instructeur' do
let(:instructeur_2) { create(:instructeur) }
let!(:batch_operation) { create(:batch_operation, operation: :archiver, dossiers: [termine_dossier], instructeur: instructeur_2) }
before { subject }
it { expect(assigns(:batch_operations)).to eq([]) }
end
end
context 'with an archived dossier' do

View file

@ -0,0 +1,21 @@
FactoryBot.define do
factory :batch_operation do
transient do
invalid_instructeur { nil }
end
association :instructeur
trait :archiver do
operation { BatchOperation.operations.fetch(:archiver) }
after(:build) do |batch_operation, evaluator|
procedure = create(:simple_procedure, :published, instructeurs: [evaluator.invalid_instructeur.presence || batch_operation.instructeur], administrateurs: [create(:administrateur)])
batch_operation.dossiers = [
create(:dossier, :with_individual, :accepte, procedure: procedure),
create(:dossier, :with_individual, :refuse, procedure: procedure),
create(:dossier, :with_individual, :sans_suite, procedure: procedure)
]
end
end
end
end

View file

@ -0,0 +1,49 @@
describe BatchOperationProcessOneJob, type: :job do
describe 'perform' do
let(:batch_operation) do
create(:batch_operation, :archiver,
options.merge(instructeur: create(:instructeur)))
end
let(:dossier_job) { batch_operation.dossiers.first }
subject { BatchOperationProcessOneJob.new(batch_operation, dossier_job) }
let(:options) { {} }
it 'when it works' do
allow_any_instance_of(BatchOperation).to receive(:process_one).with(dossier_job).and_return(true)
expect { subject.perform_now }
.to change { batch_operation.reload.success_dossier_ids }
.from([])
.to([dossier_job.id])
end
it 'when it fails for an "unknown" reason' do
allow_any_instance_of(BatchOperation).to receive(:process_one).with(dossier_job).and_raise("boom")
expect { subject.perform_now }.to raise_error('boom')
expect(batch_operation.reload.failed_dossier_ids).to eq([dossier_job.id])
end
context 'when the dossier is out of sync (ie: someone applied a transition somewhere we do not know)' do
let(:instructeur) { create(:instructeur) }
let(:procedure) { create(:simple_procedure, instructeurs: [instructeur]) }
let(:dossier) { create(:dossier, :accepte, :with_individual, archived: true, procedure: procedure) }
let(:batch_operation) { create(:batch_operation, operation: :archiver, instructeur: instructeur, dossiers: [dossier]) }
it 'does run process_one' do
allow(batch_operation).to receive(:process_one).and_raise("should have been prevented")
subject.perform_now
end
it 'when it fails from dossiers_safe_scope.find' do
scope = double
expect(scope).to receive(:find).with(dossier_job.id).and_raise(ActiveRecord::RecordNotFound)
expect_any_instance_of(BatchOperation).to receive(:dossiers_safe_scope).and_return(scope)
subject.perform_now
expect(batch_operation.reload.failed_dossier_ids).to eq([])
expect(batch_operation.dossiers).not_to include(dossier_job)
end
end
end
end

View file

@ -0,0 +1,188 @@
describe BatchOperation, type: :model do
describe 'association' do
it { is_expected.to have_many(:dossiers) }
it { is_expected.to belong_to(:instructeur) }
it { is_expected.to have_and_belong_to_many(:groupe_instructeurs) }
end
describe 'attributes' do
subject { BatchOperation.new }
it { expect(subject.payload).to eq({}) }
it { expect(subject.failed_dossier_ids).to eq([]) }
it { expect(subject.success_dossier_ids).to eq([]) }
it { expect(subject.run_at).to eq(nil) }
it { expect(subject.finished_at).to eq(nil) }
it { expect(subject.operation).to eq(nil) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:operation) }
end
describe '#enqueue_all' do
context 'given dossier_ids in instructeur procedures' do
subject do
create(:batch_operation, :archiver, instructeur: create(:instructeur))
end
it 'enqueues as many BatchOperationProcessOneJob as dossiers_ids' do
expect { subject.enqueue_all() }
.to have_enqueued_job(BatchOperationProcessOneJob)
.with(subject, subject.dossiers.first)
.with(subject, subject.dossiers.second)
.with(subject, subject.dossiers.third)
end
it 'pass through dossiers_safe_scope' do
expect(subject).to receive(:dossiers_safe_scope).and_return(subject.dossiers)
subject.enqueue_all
end
end
end
describe '#track_processed_dossier' do
let(:instructeur) { create(:instructeur) }
let(:procedure) { create(:simple_procedure, instructeurs: [instructeur]) }
let(:dossier) { create(:dossier, :accepte, :with_individual, archived: true, procedure: procedure) }
let(:batch_operation) { create(:batch_operation, operation: :archiver, instructeur: instructeur, dossiers: [dossier]) }
it 'unlock the dossier' do
expect { batch_operation.track_processed_dossier(true, dossier) }
.to change { dossier.reload.batch_operation }
.from(batch_operation)
.to(nil)
end
context 'when it succeed' do
it 'pushes dossier_job id to batch_operation.success_dossier_ids' do
expect { batch_operation.track_processed_dossier(true, dossier) }
.to change { batch_operation.reload.success_dossier_ids }
.from([])
.to([dossier.id])
end
end
context 'when it succeed after a failure' do
let(:batch_operation) { create(:batch_operation, operation: :archiver, instructeur: instructeur, dossiers: [dossier], failed_dossier_ids: [dossier.id]) }
it 'remove former dossier id from failed_dossier_ids' do
expect { batch_operation.track_processed_dossier(true, dossier) }
.to change { batch_operation.reload.failed_dossier_ids }
.from([dossier.id])
.to([])
end
end
context 'when it fails' do
it 'pushes dossier_job id to batch_operation.failed_dossier_ids' do
expect { batch_operation.track_processed_dossier(false, dossier) }
.to change { batch_operation.reload.failed_dossier_ids }
.from([])
.to([dossier.id])
end
end
context 'when it is the first job' do
it 'sets run_at at first' do
expect { batch_operation.track_processed_dossier(false, dossier) }
.to change { batch_operation.reload.run_at }
.from(nil)
.to(anything)
end
end
context 'when it is the second job (meaning run_at was already set) but not the last' do
let(:batch_operation) { create(:batch_operation, operation: :archiver, instructeur: instructeur, dossiers: [dossier], run_at: 2.days.ago) }
it 'does not change run_at' do
expect { batch_operation.track_processed_dossier(true, dossier) }
.not_to change { batch_operation.reload.run_at }
end
end
context 'when it is the last job' do
it 'sets finished_at' do
expect { batch_operation.track_processed_dossier(true, dossier) }
.to change { batch_operation.reload.finished_at }
.from(nil)
.to(anything)
end
end
end
describe '#dossiers_safe_scope (with archiver)' do
let(:instructeur) { create(:instructeur) }
let(:procedure) { create(:simple_procedure, instructeurs: [instructeur]) }
let(:batch_operation) { create(:batch_operation, operation: :archiver, instructeur: instructeur, dossiers: [dossier]) }
context 'when dossier is valid' do
let(:dossier) { create(:dossier, :accepte, :with_individual, procedure: procedure) }
it 'find dosssier' do
expect(batch_operation.dossiers_safe_scope).to include(dossier)
end
end
context 'when dossier is already arcvhied' do
let(:dossier) { create(:dossier, :accepte, :with_individual, archived: true, procedure: procedure) }
it 'skips dosssier is already archived' do
expect(batch_operation.dossiers_safe_scope).not_to include(dossier)
end
end
context 'when dossier is not in state termine' do
let(:dossier) { create(:dossier, :en_instruction, :with_individual, procedure: procedure) }
it 'does not enqueue any job' do
expect(batch_operation.dossiers_safe_scope).not_to include(dossier)
end
end
context 'when dossier is not in instructeur procedures' do
let(:dossier) { create(:dossier, :accepte, :with_individual, procedure: create(:simple_procedure)) }
it 'does not enqueues any BatchOperationProcessOneJob' do
expect(batch_operation.dossiers_safe_scope).not_to include(dossier)
end
end
end
describe '#safe_create!' do
let(:instructeur) { create(:instructeur) }
let(:procedure) { create(:simple_procedure, instructeurs: [instructeur]) }
subject { BatchOperation.safe_create!(instructeur: instructeur, operation: :archiver, dossier_ids: [dossier.id]) }
context 'success with divergent list of dossier_ids' do
let(:dossier) { create(:dossier, :accepte, :with_individual, archived: true, procedure: procedure) }
it 'does not keep archived dossier within batch_operation.dossiers' do
expect(subject.dossiers).not_to include(dossier)
end
it 'enqueue a BatchOperationEnqueueAllJob' do
expect { subject }.to have_enqueued_job(BatchOperationEnqueueAllJob)
end
end
context 'with dossier already in a batch batch_operation' do
let(:dossier) { create(:dossier, :accepte, :with_individual, batch_operation: create(:batch_operation, :archiver, instructeur: instructeur), procedure: procedure) }
it 'does not keep dossier in batch_operation' do
expect(subject.dossiers).not_to include(dossier)
end
end
end
describe 'stale' do
let(:finished_at) { 6.hours.ago }
let(:staled_batch_operation) { create(:batch_operation, operation: :archiver, finished_at: 2.days.ago, updated_at: 2.days.ago) }
it 'finds stale jobs' do
expect(BatchOperation.stale).to match_array(staled_batch_operation)
end
end
describe 'stuck' do
let(:stuck_batch_operation) { create(:batch_operation, operation: :archiver, finished_at: nil, updated_at: 2.days.ago) }
it 'finds stale jobs' do
expect(BatchOperation.stuck).to match_array(stuck_batch_operation)
end
end
end

View file

@ -1855,6 +1855,11 @@ describe Dossier do
end
end
describe 'BatchOperation' do
subject { build(:dossier) }
it { is_expected.to belong_to(:batch_operation).optional }
end
private
def count_for_month(processed_by_month, month)

View file

@ -14,6 +14,7 @@ describe Instructeur, type: :model do
describe 'associations' do
it { is_expected.to have_and_belong_to_many(:administrateurs) }
it { is_expected.to have_many(:batch_operations) }
end
describe 'follow' do

View file

@ -0,0 +1,80 @@
describe 'BatchOperation a dossier:', js: true do
include ActionView::RecordIdentifier
include ActiveJob::TestHelper
let(:password) { 'demarches-simplifiees' }
let(:instructeur) { create(:instructeur, password: password) }
let(:procedure) { create(:simple_procedure, :published, instructeurs: [instructeur], administrateurs: [create(:administrateur)]) }
context 'with an instructeur' do
scenario 'create a BatchOperation' do
dossier_1 = create(:dossier, :accepte, procedure: procedure)
dossier_2 = create(:dossier, :accepte, procedure: procedure)
dossier_3 = create(:dossier, :accepte, procedure: procedure)
log_in(instructeur.email, password)
visit instructeur_procedure_path(procedure, statut: 'traites')
# check a11y with enabled checkbox
expect(page).to be_axe_clean
# ensure button is disabled by default
expect(page).to have_button("Archiver les dossiers sélectionnés", disabled: true)
checkbox_id = dom_id(BatchOperation.new, "checkbox_#{dossier_1.id}")
# batch one dossier
check(checkbox_id)
expect(page).to have_button("Archiver les dossiers sélectionnés")
# ensure batch is created
expect { click_on "Archiver les dossiers sélectionnés" }
.to change { BatchOperation.count }
.from(0).to(1)
# ensure batched dossier is disabled
expect(page).to have_selector("##{checkbox_id}[disabled]")
# check a11y with disabled checkbox
expect(page).to be_axe_clean
# ensure alert is present
expect(page).to have_content("Information : Une action de masse est en cours")
expect(page).to have_content("1 dossier sera archivé")
# ensure jobs are queued
perform_enqueued_jobs(only: [BatchOperationEnqueueAllJob])
expect { perform_enqueued_jobs(only: [BatchOperationProcessOneJob]) }
.to change { dossier_1.reload.archived }
.from(false).to(true)
# ensure alert updates when jobs are run
click_on "Recharger la page"
expect(page).to have_content("L'action de masse est terminée")
expect(page).to have_content("1 dossier a été archivé")
# clean alert after reload
visit instructeur_procedure_path(procedure, statut: 'traites')
expect(page).not_to have_content("L'action de masse est terminée")
# try checkall
find("##{dom_id(BatchOperation.new, :checkbox_all)}").check
[dossier_2, dossier_3].map do |dossier|
dossier_checkbox_id = dom_id(BatchOperation.new, "checkbox_#{dossier.id}")
expect(page).to have_selector("##{dossier_checkbox_id}:checked")
end
# submnit checkall
expect { click_on "Archiver les dossiers sélectionnés" }
.to change { BatchOperation.count }
.from(1).to(2)
expect(BatchOperation.last.dossiers).to match_array([dossier_2, dossier_3])
end
end
def log_in(email, password, check_email: true)
visit new_user_session_path
expect(page).to have_current_path(new_user_session_path)
sign_in_with(email, password, check_email)
expect(page).to have_current_path(instructeur_procedures_path)
end
end