diff --git a/app/jobs/withdraw_restore_job.rb b/app/jobs/withdraw_restore_job.rb index 94276a6d6..4a6befe88 100644 --- a/app/jobs/withdraw_restore_job.rb +++ b/app/jobs/withdraw_restore_job.rb @@ -7,7 +7,7 @@ class WithdrawRestoreJob < ApplicationJob def perform(user_version:) druid = user_version.repository_object_version.repository_object.external_identifier version = user_version.version - if user_version.withdrawn + if user_version.withdrawn? PurlFetcher::Client::Withdraw.withdraw(druid:, version:) else PurlFetcher::Client::Withdraw.restore(druid:, version:) diff --git a/app/models/user_version.rb b/app/models/user_version.rb index ae0a23ec6..4aa631a1f 100644 --- a/app/models/user_version.rb +++ b/app/models/user_version.rb @@ -5,9 +5,12 @@ class UserVersion < ApplicationRecord belongs_to :repository_object_version before_save :set_version + enum :state, withdrawn: 'withdrawn', available: 'available', permanently_withdrawn: 'permanently_withdrawn' + validate :repository_object_version_is_closed validate :repository_object_version_has_cocina validate :can_withdraw + validate :when_permanently_withdrawn def repository_object_version_is_closed # Validate that the repository object version is closed @@ -21,14 +24,19 @@ def repository_object_version_has_cocina def can_withdraw # Validate that the user version can be withdrawn or restored - errors.add(:repository_object_version, 'head version cannot be withdrawn') if withdrawn && (head? || version.nil?) + errors.add(:repository_object_version, 'head version cannot be withdrawn') if withdrawn? && (head? || version.nil?) + end + + def when_permanently_withdrawn + # Validate that the user version state cannot be changed from permanently withdrawn + errors.add(:repository_object_version, 'cannot set user version state when permanently withdrawn') if changed_attributes['state'] == 'permanently_withdrawn' end def as_json { userVersion: version, version: repository_object_version.version, - withdrawn:, + withdrawn: withdrawn?, withdrawable: withdrawable?, restorable: restorable?, head: head? @@ -36,11 +44,11 @@ def as_json end def withdrawable? - !withdrawn && !head? + available? && !head? end def restorable? - withdrawn + withdrawn? end def head? diff --git a/app/services/user_version_service.rb b/app/services/user_version_service.rb index b92e19505..4e045cef6 100644 --- a/app/services/user_version_service.rb +++ b/app/services/user_version_service.rb @@ -29,7 +29,7 @@ def self.create(druid:, version:) # @raise [UserVersionService::UserVersioningError] RepositoryObjectVersion not found for the version def self.withdraw(druid:, user_version:, withdraw: true) user_version = user_version_for(druid:, user_version:) - user_version.update!(withdrawn: withdraw) + user_version.update!(state: withdraw ? 'withdrawn' : 'available') WithdrawRestoreJob.perform_later(user_version:) EventFactory.create(druid:, event_type: 'user_version_withdrawn', data: { version: user_version.to_s, withdrawn: withdraw }) user_version @@ -76,6 +76,24 @@ def self.object_version_for(druid:, user_version:) user_version_for(druid:, user_version:).repository_object_version.version end + # Mark all UserVersions other than the latest as permanently withdrawn + # @param [String] druid of the item + # @raise [UserVersionService::UserVersioningError] RepositoryObject not found for the druid + def self.permanently_withdraw_previous_user_versions(druid:) + # No need to notify Purl-Fetcher; it will delete the user versions because the object is being made dark. + repository_object = repository_object(druid:) + latest_user_version = latest_user_version(druid:) + RepositoryObject.transaction do + repository_object.user_versions.each do |user_version| + next if user_version.version == latest_user_version + next if user_version.permanently_withdrawn? + + user_version.permanently_withdrawn! + EventFactory.create(druid:, event_type: 'user_version_permanently_withdrawn', data: { version: user_version.to_s }) + end + end + end + # private below # @return [RepositoryObject] The repository object for the druid diff --git a/app/services/version_service.rb b/app/services/version_service.rb index 620bf01ca..c41487932 100644 --- a/app/services/version_service.rb +++ b/app/services/version_service.rb @@ -118,6 +118,7 @@ def close(description:, user_name:, start_accession: true, user_version_mode: DE EventFactory.create(druid:, event_type: 'version_close', data: { who: user_name, version: version.to_s }) update_user_version(user_version_mode:, repository_object:) + update_previous_user_versions(repository_object:) end # Determines whether a version can be closed for an object. @@ -212,5 +213,14 @@ def check_version!(current_version:) raise VersionService::VersioningError, "Version from Preservation is out of sync. Preservation expects #{preservation_version} but current version is #{current_version}" end + + def update_previous_user_versions(repository_object:) + return unless repository_object.user_versions.length > 1 + + cocina_object = repository_object.to_cocina + return unless cocina_object.access.view == 'dark' + + UserVersionService.permanently_withdraw_previous_user_versions(druid:) + end end # rubocop:enable Metrics/ClassLength diff --git a/db/migrate/20240807210223_change_user_version_state.rb b/db/migrate/20240807210223_change_user_version_state.rb new file mode 100644 index 000000000..b861dd37e --- /dev/null +++ b/db/migrate/20240807210223_change_user_version_state.rb @@ -0,0 +1,11 @@ +class ChangeUserVersionState < ActiveRecord::Migration[7.1] + def change + add_column :user_versions, :state, :string, default: 'available', null: false + + execute <<~SQL + UPDATE user_versions SET state = 'withdrawn' WHERE withdrawn = true; + SQL + + remove_column :user_versions, :withdrawn + end +end diff --git a/db/structure.sql b/db/structure.sql index 26ed8d45c..4a7c4a07a 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -9,6 +9,13 @@ SET xmloption = content; SET client_min_messages = warning; SET row_security = off; +-- +-- Name: public; Type: SCHEMA; Schema: -; Owner: - +-- + +-- *not* creating schema, since initdb creates it + + -- -- Name: background_job_result_status; Type: TYPE; Schema: public; Owner: - -- @@ -340,10 +347,10 @@ ALTER SEQUENCE public.tag_labels_id_seq OWNED BY public.tag_labels.id; CREATE TABLE public.user_versions ( id bigint NOT NULL, version integer NOT NULL, - withdrawn boolean DEFAULT false NOT NULL, created_at timestamp(6) without time zone NOT NULL, updated_at timestamp(6) without time zone NOT NULL, - repository_object_version_id bigint NOT NULL + repository_object_version_id bigint NOT NULL, + state character varying DEFAULT 'available'::character varying NOT NULL ); @@ -719,6 +726,7 @@ ALTER TABLE ONLY public.repository_objects SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20240807210223'), ('20240531122304'), ('20240522142556'), ('20240430144139'), diff --git a/spec/factories/repository_object_versions.rb b/spec/factories/repository_object_versions.rb index a00ecd39a..f97b22743 100644 --- a/spec/factories/repository_object_versions.rb +++ b/spec/factories/repository_object_versions.rb @@ -7,7 +7,7 @@ is_member_of { [] } source_id { "sul:#{SecureRandom.uuid}" } end - version { 1 } + sequence(:version) version_description { 'Best version ever' } lock { 2 } cocina_version { Cocina::Models::VERSION } diff --git a/spec/factories/user_versions.rb b/spec/factories/user_versions.rb index 1ebb20257..faf9ec149 100644 --- a/spec/factories/user_versions.rb +++ b/spec/factories/user_versions.rb @@ -3,6 +3,7 @@ FactoryBot.define do factory :user_version do sequence(:version) - withdrawn { false } + state { 'available' } + repository_object_version end end diff --git a/spec/jobs/withdraw_restore_job_spec.rb b/spec/jobs/withdraw_restore_job_spec.rb index 8a7991337..e03b5a114 100644 --- a/spec/jobs/withdraw_restore_job_spec.rb +++ b/spec/jobs/withdraw_restore_job_spec.rb @@ -8,11 +8,11 @@ end let(:druid) { 'druid:mk420bs7601' } - let(:user_version) { create(:user_version, withdrawn:, repository_object_version:) } + let(:user_version) { create(:user_version, state:, repository_object_version:) } let(:repository_object_version) { create(:repository_object_version, :with_repository_object, external_identifier: druid, closed_at: Time.current) } context 'when the version is withdrawn' do - let(:withdrawn) { true } + let(:state) { 'withdrawn' } before do allow(PurlFetcher::Client::Withdraw).to receive(:withdraw) @@ -25,7 +25,7 @@ end context 'when the version is not withdrawn' do - let(:withdrawn) { false } + let(:state) { 'available' } before do allow(PurlFetcher::Client::Withdraw).to receive(:restore) diff --git a/spec/models/user_version_spec.rb b/spec/models/user_version_spec.rb index 33e87bbd8..c9517aff9 100644 --- a/spec/models/user_version_spec.rb +++ b/spec/models/user_version_spec.rb @@ -10,7 +10,6 @@ let(:attrs) do { - version: 1, version_description: 'My new version', closed_at: Time.current } @@ -32,7 +31,6 @@ context 'when the repository object version is open' do let(:attrs) do { - version: 1, version_description: 'My new version' } end @@ -47,9 +45,68 @@ end context 'when the user version cannot be withdrawn' do - let(:user_version) { build(:user_version, repository_object_version:, version: nil, withdrawn: true) } + let(:user_version) { build(:user_version, repository_object_version:, version: nil, state: 'withdrawn') } it { is_expected.to include 'Repository object version head version cannot be withdrawn' } end + + context 'when the user version is permanently withdrawn' do + let(:user_version) do + user_version = create(:user_version, repository_object_version:, state: 'permanently_withdrawn') + user_version.state = 'available' + user_version + end + + it { is_expected.to include 'Repository object version cannot set user version state when permanently withdrawn' } + end + end + + describe '#as_json' do + let(:user_version) { build(:user_version, repository_object_version:, state:) } + + context 'when the user version is withdrawn' do + let(:state) { 'withdrawn' } + + it 'returns the user version as JSON' do + expect(user_version.as_json).to eq( + userVersion: user_version.version, + version: user_version.repository_object_version.version, + withdrawn: true, + withdrawable: false, + restorable: true, + head: false + ) + end + end + + context 'when the user version is permanently withdrawn' do + let(:state) { 'permanently_withdrawn' } + + it 'returns the user version as JSON' do + expect(user_version.as_json).to eq( + userVersion: user_version.version, + version: user_version.repository_object_version.version, + withdrawn: false, + withdrawable: false, + restorable: false, + head: false + ) + end + end + + context 'when the user version is available' do + let(:state) { 'available' } + + it 'returns the user version as JSON' do + expect(user_version.as_json).to eq( + userVersion: user_version.version, + version: user_version.repository_object_version.version, + withdrawn: false, + withdrawable: true, + restorable: false, + head: false + ) + end + end end end diff --git a/spec/requests/create_user_version_spec.rb b/spec/requests/create_user_version_spec.rb index c19a7cb32..0b27a4ad1 100644 --- a/spec/requests/create_user_version_spec.rb +++ b/spec/requests/create_user_version_spec.rb @@ -14,9 +14,9 @@ params: { version: repository_object_version.version }.to_json expect(response).to have_http_status(:created) - expect(response.parsed_body).to eq({ 'userVersion' => 1, 'version' => 1, 'withdrawn' => false, 'withdrawable' => false, 'restorable' => false, 'head' => true }) - expect(repository_object_version.reload.user_versions.count).to eq(1) + user_version = repository_object_version.user_versions.first + expect(response.parsed_body).to eq({ 'userVersion' => user_version.version, 'version' => repository_object_version.version, 'withdrawn' => false, 'withdrawable' => false, 'restorable' => false, 'head' => true }) end end diff --git a/spec/requests/update_user_version_spec.rb b/spec/requests/update_user_version_spec.rb index d5493e383..d538b684c 100644 --- a/spec/requests/update_user_version_spec.rb +++ b/spec/requests/update_user_version_spec.rb @@ -4,7 +4,7 @@ RSpec.describe 'Update user version' do let(:repository_object) { repository_object_version1.repository_object } - let(:repository_object_version1) { create(:repository_object_version, :with_repository_object, closed_at: Time.zone.now) } + let(:repository_object_version1) { create(:repository_object_version, :with_repository_object, closed_at: Time.zone.now, version: 1) } let!(:repository_object_version2) { create(:repository_object_version, version: 2, repository_object:, closed_at: Time.zone.now) } let(:user_version) { create(:user_version, repository_object_version: repository_object_version1, version: 1) } @@ -34,7 +34,7 @@ expect(response).to have_http_status(:ok) expect(response.parsed_body).to eq({ 'userVersion' => user_version.version, 'version' => 1, 'withdrawn' => true, 'withdrawable' => false, 'restorable' => true, 'head' => false }) - expect(user_version.reload.withdrawn).to be(true) + expect(user_version.reload.withdrawn?).to be(true) end end diff --git a/spec/services/migrators/remove_release_tags_spec.rb b/spec/services/migrators/remove_release_tags_spec.rb index a309706db..8affe21d7 100644 --- a/spec/services/migrators/remove_release_tags_spec.rb +++ b/spec/services/migrators/remove_release_tags_spec.rb @@ -5,8 +5,8 @@ RSpec.describe Migrators::RemoveReleaseTags do subject(:migrator) { described_class.new(repository_object) } - let(:repository_object) { create(:repository_object, :with_repository_object_version, repository_object_version:) } - let(:repository_object_version) { build(:repository_object_version, administrative:) } + let(:repository_object) { repository_object_version.repository_object } + let(:repository_object_version) { build(:repository_object_version, :with_repository_object, administrative:) } let(:administrative) { { hasAdminPolicy: 'druid:hy787xj5878', releaseTags: [] } } describe '#migrate?' do diff --git a/spec/services/user_version_service_spec.rb b/spec/services/user_version_service_spec.rb index 5ae1f699f..c0c007270 100644 --- a/spec/services/user_version_service_spec.rb +++ b/spec/services/user_version_service_spec.rb @@ -5,10 +5,14 @@ RSpec.describe UserVersionService do let(:druid) { repository_object.external_identifier } let(:repository_object) { repository_object_version1.repository_object } - let(:repository_object_version1) { create(:repository_object_version, :with_repository_object, closed_at: Time.zone.now) } + let(:repository_object_version1) { create(:repository_object_version, :with_repository_object, closed_at: Time.zone.now, version: 1) } let!(:repository_object_version2) { create(:repository_object_version, version: 2, repository_object:, closed_at: Time.zone.now) } let(:object_type) { 'dro' } + before do + allow(EventFactory).to receive(:create) + end + describe '.create' do subject(:user_version_service_create) { described_class.create(druid:, version: 1) } @@ -55,16 +59,16 @@ end it 'withdraws the user version' do - expect(user_version.withdrawn).to be false + expect(user_version.withdrawn?).to be false user_version_service_withdraw - expect(user_version.reload.withdrawn).to be true + expect(user_version.reload.withdrawn?).to be true expect(WithdrawRestoreJob).to have_received(:perform_later).with(user_version:) end end context 'when the user version cannot be withdrawn' do it 'raises' do - expect(user_version.withdrawn).to be false + expect(user_version.withdrawn?).to be false expect { user_version_service_withdraw }.to raise_error UserVersionService::UserVersioningError, 'Validation failed: Repository object version head version cannot be withdrawn' expect(WithdrawRestoreJob).not_to have_received(:perform_later) end @@ -97,4 +101,21 @@ expect(user_version_service_exist?).to be false end end + + describe '.permanently_withdraw_previous_user_versions' do + subject(:user_version_service_permanently_withdraw) { described_class.permanently_withdraw_previous_user_versions(druid:) } + + let!(:user_version1) { UserVersion.create!(version: 1, repository_object_version: repository_object_version1, state: 'permanently_withdrawn') } + let!(:user_version2) { UserVersion.create!(version: 2, repository_object_version: repository_object_version1) } + let!(:user_version3) { UserVersion.create!(version: 3, repository_object_version: repository_object_version1) } + + it 'permanently withdraws the previous user versions' do + user_version_service_permanently_withdraw + expect(user_version1.reload.permanently_withdrawn?).to be true + expect(user_version2.reload.permanently_withdrawn?).to be true + expect(user_version3.reload.available?).to be true + + expect(EventFactory).to have_received(:create).once + end + end end diff --git a/spec/services/version_service_spec.rb b/spec/services/version_service_spec.rb index 9be87de0a..9ee7e534a 100644 --- a/spec/services/version_service_spec.rb +++ b/spec/services/version_service_spec.rb @@ -226,6 +226,7 @@ repository_object.head_version.update!(version: 2, version_description: 'A Second Version') allow(WorkflowClientFactory).to receive(:build).and_return(workflow_client) allow(workflow_client).to receive(:create_workflow_by_name) + allow(UserVersionService).to receive(:permanently_withdraw_previous_user_versions) end context 'when description and user_name are passed in' do @@ -479,6 +480,58 @@ expect(workflow_client).to have_received(:create_workflow_by_name).with(druid, 'accessionWF', version: '2') end end + + context 'when multiple user versions and access is dark' do + let(:version) { 3 } + + before do + allow(workflow_state_service).to receive_messages(assembling?: false, accessioning?: false) + repository_object.close_version! + repository_object.open_version!(description: 'A new version') + repository_object.head_version.update!(access: { view: 'dark', download: 'none' }) + create(:user_version, repository_object_version: repository_object.versions.first) + create(:user_version, repository_object_version: repository_object.versions.first) + end + + it 'permanently withdraws previous user versions' do + close + expect(UserVersionService).to have_received(:permanently_withdraw_previous_user_versions).with(druid:) + end + end + + context 'when multiple user versions and access is not dark' do + let(:version) { 3 } + + before do + allow(workflow_state_service).to receive_messages(assembling?: false, accessioning?: false) + repository_object.close_version! + repository_object.open_version!(description: 'A new version') + create(:user_version, repository_object_version: repository_object.versions.first) + create(:user_version, repository_object_version: repository_object.versions.first) + end + + it 'does not permanently withdraw' do + close + expect(UserVersionService).not_to have_received(:permanently_withdraw_previous_user_versions) + end + end + + context 'when single user versions and access is dark' do + let(:version) { 3 } + + before do + allow(workflow_state_service).to receive_messages(assembling?: false, accessioning?: false) + repository_object.head_version.update!(access: { view: 'dark', download: 'none' }) + repository_object.close_version! + repository_object.open_version!(description: 'A new version') + create(:user_version, repository_object_version: repository_object.versions.first) + end + + it 'does not permanently withdraw' do + close + expect(UserVersionService).not_to have_received(:permanently_withdraw_previous_user_versions) + end + end end describe '.can_close?' do