diff --git a/app/jobs/destroy_record_later_job.rb b/app/jobs/destroy_record_later_job.rb new file mode 100644 index 000000000..6b9def26f --- /dev/null +++ b/app/jobs/destroy_record_later_job.rb @@ -0,0 +1,7 @@ +class DestroyRecordLaterJob < ApplicationJob + discard_on ActiveRecord::RecordNotFound + + def perform(record) + record.destroy + end +end diff --git a/app/models/champ.rb b/app/models/champ.rb index 14793de0e..15ed9079c 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -5,7 +5,7 @@ # id :integer not null, primary key # data :jsonb # fetch_external_data_exceptions :string is an Array -# prefilled :boolean default(FALSE) +# prefilled :boolean # private :boolean default(FALSE), not null # rebased_at :datetime # type :string @@ -111,6 +111,10 @@ class Champ < ApplicationRecord parent_id.present? end + def stable_id_with_row + [row_id, stable_id].compact + end + def sections @sections ||= dossier.sections_for(self) end @@ -226,10 +230,10 @@ class Champ < ApplicationRecord update!(data: data) end - def clone + def clone(fork = false) champ_attributes = [:parent_id, :private, :row_id, :type, :type_de_champ_id] - value_attributes = private? ? [] : [:value, :value_json, :data, :external_id] - relationships = private? ? [] : [:etablissement, :geo_areas] + value_attributes = fork || !private? ? [:value, :value_json, :data, :external_id] : [] + relationships = fork || !private? ? [:etablissement, :geo_areas] : [] deep_clone(only: champ_attributes + value_attributes, include: relationships) do |original, kopy| PiecesJustificativesService.clone_attachments(original, kopy) diff --git a/app/models/concerns/dossier_clone_concern.rb b/app/models/concerns/dossier_clone_concern.rb new file mode 100644 index 000000000..2b2d4f41c --- /dev/null +++ b/app/models/concerns/dossier_clone_concern.rb @@ -0,0 +1,169 @@ +module DossierCloneConcern + extend ActiveSupport::Concern + + included do + belongs_to :parent_dossier, class_name: 'Dossier', optional: true + has_many :cloned_dossiers, class_name: 'Dossier', foreign_key: :parent_dossier_id, dependent: :nullify, inverse_of: :parent_dossier + + belongs_to :editing_fork_origin, class_name: 'Dossier', optional: true + has_many :editing_forks, class_name: 'Dossier', foreign_key: :editing_fork_origin_id, dependent: :destroy, inverse_of: :editing_fork_origin + end + + def find_or_create_editing_fork(user) + find_editing_fork(user) || clone(user:, fork: true) + end + + def find_editing_fork(user) + editing_forks.find_by(user:)&.tap(&:rebase!) + end + + def owner_editing_fork + find_or_create_editing_fork(user).tap { DossierPreloader.load_one(_1) } + end + + def reset_editing_fork! + if editing_fork? && forked_with_changes? + destroy_editing_fork! + end + end + + def destroy_editing_fork! + if editing_fork? + DestroyRecordLaterJob.perform_later(self) + end + end + + def editing_fork? + editing_fork_origin_id.present? + end + + def make_diff(editing_fork) + origin_champs_index = champs_public_all.index_by(&:stable_id_with_row) + forked_champs_index = editing_fork.champs_public_all.index_by(&:stable_id_with_row) + updated_champs_index = editing_fork + .champs_public_all + .filter { _1.updated_at > editing_fork.created_at } + .index_by(&:stable_id_with_row) + + added = forked_champs_index.keys - origin_champs_index.keys + removed = origin_champs_index.keys - forked_champs_index.keys + updated = updated_champs_index.keys - added + + { + added: added.map { forked_champs_index[_1] }, + updated: updated.map { forked_champs_index[_1] }, + removed: removed.map { origin_champs_index[_1] } + } + end + + def merge_fork(editing_fork) + return false if invalid? || editing_fork.invalid? + return false if revision_id > editing_fork.revision_id + + diff = make_diff(editing_fork) + + transaction do + apply_diff(diff) + update(revision_id: editing_fork.revision_id, last_champ_updated_at: Time.zone.now) + assign_to_groupe_instructeur(editing_fork.groupe_instructeur) + end + reload + editing_fork.destroy_editing_fork! + end + + def clone(user: nil, fork: false) + dossier_attributes = [:autorisation_donnees, :revision_id, :groupe_instructeur_id] + relationships = [:individual, :etablissement] + + cloned_champs = champs + .index_by(&:id) + .transform_values { [_1, _1.clone(fork)] } + + cloned_dossier = deep_clone(only: dossier_attributes, include: relationships) do |original, kopy| + PiecesJustificativesService.clone_attachments(original, kopy) + + if original.is_a?(Dossier) + if fork + kopy.editing_fork_origin = original + else + kopy.parent_dossier = original + end + + kopy.user = user || original.user + kopy.state = Dossier.states.fetch(:brouillon) + + kopy.champs = cloned_champs.values.map do |(_, champ)| + champ.dossier = kopy + champ.parent = cloned_champs[champ.parent_id].second if champ.child? + champ + end + end + end + + transaction do + cloned_dossier.save! + + if fork + cloned_champs.values.each do |(original, champ)| + champ.update_columns(created_at: original.created_at, updated_at: original.updated_at) + end + cloned_dossier.rebase! + end + end + + cloned_dossier.reload + end + + def forked_with_changes? + if forked_diff.present? + forked_diff.values.any?(&:present?) || forked_groupe_instructeur_changed? + end + end + + def champ_forked_with_changes?(champ) + if forked_diff.present? + forked_diff.values.any? { _1.include?(champ) } + end + end + + private + + def forked_diff + @forked_diff ||= editing_fork? ? editing_fork_origin.make_diff(self) : nil + end + + def forked_groupe_instructeur_changed? + editing_fork_origin.groupe_instructeur_id != groupe_instructeur_id + end + + def apply_diff(diff) + champs_index = (champs + diff[:added]).index_by(&:stable_id_with_row) + + diff[:added].each do |champ| + if champ.child? + champ.update_columns(dossier_id: id, parent_id: champs_index[champ.parent.stable_id_with_row].id) + else + champ.update_column(:dossier_id, id) + end + end + + champs_to_remove = [] + diff[:updated].each do |champ| + old_champ = champs_index[champ.stable_id_with_row] + champs_to_remove << old_champ + + if champ.child? + # we need to do that in order to avoid a foreign key constraint + old_champ.update(row_id: nil) + champ.update_columns(dossier_id: id, parent_id: champs_index[champ.parent.stable_id_with_row].id) + else + champ.update_column(:dossier_id, id) + end + end + + champs_to_remove += diff[:removed] + champs_to_remove + .filter { !_1.child? || !champs_to_remove.include?(_1.parent) } + .each(&:destroy!) + end +end diff --git a/app/models/dossier.rb b/app/models/dossier.rb index 2f12ade8e..581adc0ff 100644 --- a/app/models/dossier.rb +++ b/app/models/dossier.rb @@ -40,6 +40,7 @@ # updated_at :datetime # batch_operation_id :bigint # dossier_transfer_id :bigint +# editing_fork_origin_id :bigint # groupe_instructeur_id :bigint # parent_dossier_id :bigint # revision_id :bigint @@ -50,6 +51,7 @@ class Dossier < ApplicationRecord include DossierPrefillableConcern include DossierRebaseConcern include DossierSectionsConcern + include DossierCloneConcern enum state: { brouillon: 'brouillon', @@ -148,7 +150,6 @@ class Dossier < ApplicationRecord belongs_to :groupe_instructeur, optional: true 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_many :dossier_batch_operations, dependent: :destroy has_many :batch_operations, through: :dossier_batch_operations @@ -161,7 +162,6 @@ class Dossier < ApplicationRecord belongs_to :transfer, class_name: 'DossierTransfer', foreign_key: 'dossier_transfer_id', optional: true, inverse_of: :dossiers has_many :transfer_logs, class_name: 'DossierTransferLog', dependent: :destroy - has_many :cloned_dossiers, class_name: 'Dossier', foreign_key: 'parent_dossier_id', dependent: :nullify, inverse_of: :parent_dossier accepts_nested_attributes_for :champs accepts_nested_attributes_for :champs_public @@ -236,7 +236,7 @@ class Dossier < ApplicationRecord scope :prefilled, -> { where(prefilled: true) } scope :hidden_by_user, -> { where.not(hidden_by_user_at: nil) } scope :hidden_by_administration, -> { where.not(hidden_by_administration_at: nil) } - scope :visible_by_user, -> { where(for_procedure_preview: false).or(where(for_procedure_preview: nil)).where(hidden_by_user_at: nil) } + scope :visible_by_user, -> { where(for_procedure_preview: false).or(where(for_procedure_preview: nil)).where(hidden_by_user_at: nil, editing_fork_origin_id: nil) } scope :visible_by_administration, -> { state_not_brouillon .where(hidden_by_administration_at: nil) @@ -247,6 +247,7 @@ class Dossier < ApplicationRecord state_not_brouillon.hidden_by_administration.or(state_en_construction.hidden_by_user) } scope :for_procedure_preview, -> { where(for_procedure_preview: true) } + scope :for_editing_fork, -> { where.not(editing_fork_origin_id: nil) } scope :order_by_updated_at, -> (order = :desc) { order(updated_at: order) } scope :order_by_created_at, -> (order = :asc) { order(depose_at: order, created_at: order, id: order) } @@ -453,7 +454,7 @@ class Dossier < ApplicationRecord delegate :siret, :siren, to: :etablissement, allow_nil: true delegate :france_connect_information, to: :user, allow_nil: true - before_save :build_default_champs_for_new_dossier, if: Proc.new { revision_id_was.nil? && parent_dossier_id.nil? } + before_save :build_default_champs_for_new_dossier, if: Proc.new { revision_id_was.nil? && parent_dossier_id.nil? && editing_fork_origin_id.nil? } before_save :update_search_terms after_save :send_web_hook @@ -556,7 +557,7 @@ class Dossier < ApplicationRecord end def can_transition_to_en_construction? - brouillon? && procedure.dossier_can_transition_to_en_construction? && !for_procedure_preview? + brouillon? && procedure.dossier_can_transition_to_en_construction? && !for_procedure_preview? && !editing_fork? end def can_terminer? @@ -1238,32 +1239,6 @@ class Dossier < ApplicationRecord termine_expired_to_delete.find_each(&:purge_discarded) end - def clone - dossier_attributes = [:autorisation_donnees, :user_id, :revision_id, :groupe_instructeur_id] - relationships = [:individual, :etablissement] - - cloned_dossier = deep_clone(only: dossier_attributes, include: relationships) do |original, kopy| - PiecesJustificativesService.clone_attachments(original, kopy) - - if original.is_a?(Dossier) - kopy.parent_dossier_id = original.id - kopy.state = Dossier.states.fetch(:brouillon) - cloned_champs = original.champs - .index_by(&:id) - .transform_values(&:clone) - - kopy.champs = cloned_champs.values.map do |champ| - champ.dossier = kopy - champ.parent = cloned_champs[champ.parent_id] if champ.child? - champ - end - end - end - - transaction { cloned_dossier.save! } - cloned_dossier.reload - end - def find_champs_by_stable_ids(stable_ids) return [] if stable_ids.compact.empty? diff --git a/db/migrate/20230113165022_add_editing_forks_to_dossiers.rb b/db/migrate/20230113165022_add_editing_forks_to_dossiers.rb new file mode 100644 index 000000000..fafbb46b0 --- /dev/null +++ b/db/migrate/20230113165022_add_editing_forks_to_dossiers.rb @@ -0,0 +1,7 @@ +class AddEditingForksToDossiers < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def change + add_belongs_to :dossiers, :editing_fork_origin, null: true, index: { algorithm: :concurrently } + end +end diff --git a/db/schema.rb b/db/schema.rb index d3fc165be..bf9aae542 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -362,6 +362,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_05_02_160046) do t.string "deleted_user_email_never_send" t.datetime "depose_at", precision: 6 t.bigint "dossier_transfer_id" + t.bigint "editing_fork_origin_id" t.datetime "en_construction_at", precision: 6 t.datetime "en_construction_close_to_expiration_notice_sent_at", precision: 6 t.datetime "en_instruction_at", precision: 6 @@ -393,6 +394,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_05_02_160046) do 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 ["editing_fork_origin_id"], name: "index_dossiers_on_editing_fork_origin_id" t.index ["groupe_instructeur_id"], name: "index_dossiers_on_groupe_instructeur_id" t.index ["hidden_at"], name: "index_dossiers_on_hidden_at" t.index ["prefill_token"], name: "index_dossiers_on_prefill_token", unique: true diff --git a/spec/models/concern/dossier_clone_concern_spec.rb b/spec/models/concern/dossier_clone_concern_spec.rb new file mode 100644 index 000000000..c39b2a9cc --- /dev/null +++ b/spec/models/concern/dossier_clone_concern_spec.rb @@ -0,0 +1,118 @@ +RSpec.describe DossierCloneConcern do + let(:procedure) do + create(:procedure, types_de_champ_public: [ + { type: :text, libelle: "Un champ text", stable_id: 99 }, + { type: :text, libelle: "Un autre champ text", stable_id: 991 }, + { type: :yes_no, libelle: "Un champ yes no", stable_id: 992 }, + { type: :repetition, libelle: "Un champ répétable", stable_id: 993, mandatory: true, children: [{ type: :text, libelle: 'Nom', stable_id: 994 }] } + ]) + end + let(:dossier) { create(:dossier, procedure:) } + let(:forked_dossier) { dossier.find_or_create_editing_fork(dossier.user) } + + before { procedure.publish! } + + describe '#make_diff' do + subject { dossier.make_diff(forked_dossier) } + + context 'with no changes' do + it { is_expected.to eq(added: [], updated: [], removed: []) } + end + + context 'with updated groupe instructeur' do + before { + dossier.update(groupe_instructeur: nil) + forked_dossier.assign_to_groupe_instructeur(dossier.procedure.defaut_groupe_instructeur) + } + + it { is_expected.to eq(added: [], updated: [], removed: []) } + it { expect(forked_dossier.forked_with_changes?).to be_truthy } + end + + context 'with updated champ' do + let(:updated_champ) { forked_dossier.champs.find { _1.stable_id == 99 } } + + before { updated_champ.update(value: 'new value') } + + it { is_expected.to eq(added: [], updated: [updated_champ], removed: []) } + it 'forked_with_changes? should reflect dossier state' do + expect(dossier.forked_with_changes?).to be_falsey + expect(forked_dossier.forked_with_changes?).to be_truthy + expect(updated_champ.forked_with_changes?).to be_truthy + end + end + + context 'with new revision' do + let(:added_champ) { forked_dossier.champs.find { _1.libelle == "Un nouveau champ text" } } + let(:removed_champ) { dossier.champs.find { _1.stable_id == 99 } } + + before do + procedure.draft_revision.add_type_de_champ({ + type_champ: TypeDeChamp.type_champs.fetch(:text), + libelle: "Un nouveau champ text" + }) + procedure.draft_revision.remove_type_de_champ(removed_champ.stable_id) + procedure.publish_revision! + end + + it { + expect(dossier.revision_id).to eq(procedure.revisions.first.id) + expect(forked_dossier.revision_id).to eq(procedure.published_revision_id) + is_expected.to eq(added: [added_champ], updated: [], removed: [removed_champ]) + } + end + end + + describe '#merge_fork' do + subject { dossier.merge_fork(forked_dossier) } + + context 'with updated champ' do + let(:updated_champ) { forked_dossier.champs.find { _1.stable_id == 99 } } + let(:updated_repetition_champ) { forked_dossier.champs.find { _1.stable_id == 994 } } + + before do + dossier.champs.each do |champ| + champ.update(value: 'old value') + end + updated_champ.update(value: 'new value') + updated_repetition_champ.update(value: 'new value in repetition') + end + + it { expect { subject }.to change { dossier.reload.champs.size }.by(0) } + it { expect { subject }.not_to change { dossier.reload.champs.order(:created_at).reject { _1.stable_id.in?([99, 994]) }.map(&:value) } } + it { expect { subject }.to change { dossier.reload.champs.find { _1.stable_id == 99 }.value }.from('old value').to('new value') } + it { expect { subject }.to change { dossier.reload.champs.find { _1.stable_id == 994 }.value }.from('old value').to('new value in repetition') } + end + + context 'with new revision' do + let(:added_champ) { forked_dossier.champs.find { _1.libelle == "Un nouveau champ text" } } + let(:removed_champ) { dossier.champs.find { _1.stable_id == 99 } } + + before do + dossier.champs.each do |champ| + champ.update(value: 'old value') + end + procedure.draft_revision.add_type_de_champ({ + type_champ: TypeDeChamp.type_champs.fetch(:text), + libelle: "Un nouveau champ text" + }) + procedure.draft_revision.remove_type_de_champ(removed_champ.stable_id) + procedure.publish_revision! + end + + it { expect { subject }.to change { dossier.reload.champs.size }.by(0) } + it { expect { subject }.to change { dossier.reload.champs.order(:created_at).map(&:to_s) }.from(['old value', 'old value', 'Non', 'old value', 'old value']).to(['old value', 'Non', 'old value', 'old value', '']) } + + it "dossier after merge should be on last published revision" do + expect(dossier.revision_id).to eq(procedure.revisions.first.id) + expect(forked_dossier.revision_id).to eq(procedure.published_revision_id) + + subject + perform_enqueued_jobs only: DestroyRecordLaterJob + + expect(dossier.revision_id).to eq(procedure.published_revision_id) + expect(Dossier.exists?(forked_dossier.id)).to be_falsey + end + end + end +end