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:
commit
78c85ee8c4
56 changed files with 1202 additions and 118 deletions
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -49,3 +49,4 @@ fieldset {
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -19,4 +19,8 @@
|
|||
background-image: none; // remove DSFR underline
|
||||
}
|
||||
}
|
||||
|
||||
.fr-btns-group .fr-btn {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,3 +51,17 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.force-table-100 {
|
||||
width: calc(100vw);
|
||||
}
|
||||
|
||||
.fr-table--bordered {
|
||||
.table {
|
||||
&.hoverable {
|
||||
tbody tr:hover {
|
||||
background: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
14
app/components/dossiers/batch_alert_component.rb
Normal file
14
app/components/dossiers/batch_alert_component.rb
Normal 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
|
|
@ -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.
|
|
@ -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.
|
|
@ -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')
|
23
app/components/dossiers/batch_operation_component.rb
Normal file
23
app/components/dossiers/batch_operation_component.rb
Normal 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
|
|
@ -0,0 +1,3 @@
|
|||
fr:
|
||||
operations:
|
||||
archiver: 'Archive selected files'
|
|
@ -0,0 +1,3 @@
|
|||
fr:
|
||||
operations:
|
||||
archiver: 'Archiver les dossiers sélectionnés'
|
|
@ -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]}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
31
app/controllers/instructeurs/batch_operations_controller.rb
Normal file
31
app/controllers/instructeurs/batch_operations_controller.rb
Normal 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 n’avez pas accès à cette démarche"
|
||||
redirect_to root_path
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
49
app/javascript/controllers/batch_operation_controller.ts
Normal file
49
app/javascript/controllers/batch_operation_controller.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
5
app/jobs/batch_operation_enqueue_all_job.rb
Normal file
5
app/jobs/batch_operation_enqueue_all_job.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class BatchOperationEnqueueAllJob < ApplicationJob
|
||||
def perform(batch_operation)
|
||||
batch_operation.enqueue_all
|
||||
end
|
||||
end
|
16
app/jobs/batch_operation_process_one_job.rb
Normal file
16
app/jobs/batch_operation_process_one_job.rb
Normal 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
|
8
app/jobs/cron/purge_stale_batch_operation_job.rb
Normal file
8
app/jobs/cron/purge_stale_batch_operation_job.rb
Normal 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
|
128
app/models/batch_operation.rb
Normal file
128
app/models/batch_operation.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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é
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
%td.folder-col
|
||||
%td.text-center
|
||||
%p.cell-link
|
||||
%span.icon.folder
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
14
db/migrate/20221118133711_create_table_batch_operation.rb
Normal file
14
db/migrate/20221118133711_create_table_batch_operation.rb
Normal 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
|
|
@ -0,0 +1,5 @@
|
|||
class AddBatchOperationIdToDossiers < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :dossiers, :batch_operation_id, :bigint
|
||||
end
|
||||
end
|
|
@ -0,0 +1,5 @@
|
|||
class AddForeignKeyToBatchOperationId < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_foreign_key "dossiers", "batch_operations", validate: false
|
||||
end
|
||||
end
|
|
@ -0,0 +1,5 @@
|
|||
class ValidateForeighKeyToBatchOperationId < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
validate_foreign_key "dossiers", "batch_operations"
|
||||
end
|
||||
end
|
|
@ -0,0 +1,5 @@
|
|||
class AddForeignKeyToBatchOperationInstructeur < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_foreign_key "batch_operations", "instructeurs", validate: false
|
||||
end
|
||||
end
|
|
@ -0,0 +1,5 @@
|
|||
class AddValidateKeyToBatchBoperationInstructeur < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
validate_foreign_key "batch_operations", "instructeurs"
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
class AddSeenAtToBatchOperations < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :batch_operations, :seen_at, :datetime
|
||||
end
|
||||
end
|
26
db/schema.rb
26
db/schema.rb
|
@ -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"
|
||||
|
|
58
spec/components/dossiers/batch_alert_component_spec.rb
Normal file
58
spec/components/dossiers/batch_alert_component_spec.rb
Normal 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
|
24
spec/components/dossiers/batch_operation_component_spec.rb
Normal file
24
spec/components/dossiers/batch_operation_component_spec.rb
Normal 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
|
|
@ -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
|
|
@ -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 n’est 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
|
||||
|
|
|
@ -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
|
||||
|
|
21
spec/factories/batch_operation.rb
Normal file
21
spec/factories/batch_operation.rb
Normal 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
|
49
spec/jobs/batch_operation_process_one_job_spec.rb
Normal file
49
spec/jobs/batch_operation_process_one_job_spec.rb
Normal 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
|
188
spec/models/batch_operation_spec.rb
Normal file
188
spec/models/batch_operation_spec.rb
Normal 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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
80
spec/system/instructeurs/batch_operation_spec.rb
Normal file
80
spec/system/instructeurs/batch_operation_spec.rb
Normal 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
|
Loading…
Reference in a new issue