diff --git a/app/models/access_template.rb b/app/models/access_template.rb new file mode 100644 index 0000000000..1f31db0031 --- /dev/null +++ b/app/models/access_template.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class AccessTemplate < ApplicationModel + define_attribute_methods :license, :copyright, :use_statement, :view, :download + + attribute :license + attribute :copyright + attribute :use_statement + attribute :view + attribute :download + + def initialize(cocina_model = Cocina::Models::AdminPolicyAccessTemplate.new) + super + end + + # When the object is initialized, copy the properties from the cocina model to the entity: + def setup_properties! + self.license = model.license + self.copyright = model.copyright + self.use_statement = model.useAndReproductionStatement + self.view = model.view + self.download = model.download + end +end diff --git a/app/models/admin_policy.rb b/app/models/admin_policy.rb new file mode 100644 index 0000000000..5855e0860e --- /dev/null +++ b/app/models/admin_policy.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class AdminPolicy < ApplicationModel + define_attribute_methods :id, :version, :label, :admin_policy_id, + :registration_workflows, :collections_for_registration, :access_template, :roles + + attribute :id + attribute :version + attribute :label + attribute :admin_policy_id + attribute :registration_workflows + attribute :collections_for_registration + attribute :access_template + attribute :roles + + # When the object is initialized, copy the properties from the cocina model to the entity: + def setup_properties! + self.id = model.externalIdentifier + self.version = model.version + self.label = model.label + self.admin_policy_id = model.administrative.hasAdminPolicy + self.registration_workflows = model.administrative.registrationWorkflow + self.collections_for_registration = model.administrative.collectionsForRegistration + self.access_template = AccessTemplate.new(model.administrative.accessTemplate) + self.roles = model.administrative.roles + end + + def save + raise 'not implemented' + # @model = AdminPolicyChangeSetPersister.update(model, self) + end + + def self.model_name + ::ActiveModel::Name.new(nil, nil, 'AdminPolicy') + end +end diff --git a/app/models/application_model.rb b/app/models/application_model.rb new file mode 100644 index 0000000000..baeebe4dde --- /dev/null +++ b/app/models/application_model.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# @abstract +class ApplicationModel + include ActiveModel::Dirty + include ActiveModel::API + + def self.attribute(name) + define_method name do + instance_variable_get(:"@#{name}") + end + + define_method :"#{name}=" do |val| + send(:"#{name}_will_change!") unless val == instance_variable_get(:"@#{name}") + instance_variable_set(:"@#{name}", val) + end + end + + def initialize(cocina = nil) + @model = cocina + setup_properties! + clear_changes_information + end + + # The original cocina data + attr_reader :model + + def persisted? + id.present? + end +end diff --git a/app/models/collection.rb b/app/models/collection.rb new file mode 100644 index 0000000000..c0030eda38 --- /dev/null +++ b/app/models/collection.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class Collection < ApplicationModel + define_attribute_methods :id, :version, :admin_policy_id, :catkeys, :copyright, + :license, :source_id, :use_statement, :view_access + + attribute :id + attribute :version + attribute :admin_policy_id + attribute :catkeys + attribute :copyright + attribute :license + attribute :source_id + attribute :use_statement + attribute :view_access + + # When the object is initialized, copy the properties from the cocina model to the form: + def setup_properties! + self.id = model.externalIdentifier + self.version = model.version + self.admin_policy_id = model.administrative.hasAdminPolicy + + self.catkeys = Catkey.symphony_links(model) if model.identification + self.copyright = model.access.copyright + self.use_statement = model.access.useAndReproductionStatement + self.license = model.access.license + self.source_id = model.identification&.sourceId + + self.view_access = model.access.view + end + + def save + @model = CollectionPersister.update(model, self) + end + + def self.model_name + ::ActiveModel::Name.new(nil, nil, 'Collection') + end +end diff --git a/app/models/embargo.rb b/app/models/embargo.rb new file mode 100644 index 0000000000..c7fead0531 --- /dev/null +++ b/app/models/embargo.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Embargo < ApplicationModel + define_attribute_methods :release_date, :view_access, :download_access, :access_location + + attribute :release_date + attribute :view_access + attribute :download_access + attribute :access_location + + # When the object is initialized, copy the properties from the cocina model to the entity: + def setup_properties! + self.release_date = model.releaseDate + self.view_access = model.view + self.download_access = model.download + self.access_location = model.location + end +end diff --git a/app/models/file_set.rb b/app/models/file_set.rb new file mode 100644 index 0000000000..a94e88ab30 --- /dev/null +++ b/app/models/file_set.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class FileSet < ApplicationModel + define_attribute_methods :files, :type, :label + + attribute :files + attribute :type + attribute :label + + # When the object is initialized, copy the properties from the cocina model to the entity: + def setup_properties! + self.type = model.type + self.label = model.label + self.files = model.structural.contains.map { |cocina| ManagedFile.new(cocina) } + end + + # has the collection or any of its members changed? + def changed? + super || files.any?(&:changed?) + end +end diff --git a/app/models/item.rb b/app/models/item.rb new file mode 100644 index 0000000000..c1bbdc4e3d --- /dev/null +++ b/app/models/item.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +class Item < ApplicationModel + define_attribute_methods :id, :version, :type, :admin_policy_id, :catkeys, + :collection_ids, :copyright, :embargo, :license, + :source_id, :use_statement, :barcode, + :view_access, :download_access, :access_location, :controlled_digital_lending, + :file_sets, :members, :release_tags + + attribute :id + attribute :version + attribute :type + attribute :admin_policy_id + attribute :catkeys + attribute :collection_ids + attribute :copyright + attribute :embargo + attribute :license + attribute :source_id + attribute :use_statement + attribute :barcode + attribute :view_access + attribute :download_access + attribute :access_location + attribute :controlled_digital_lending + attribute :file_sets + attribute :members + attribute :release_tags + + # When the object is initialized, copy the properties from the cocina model to the entity: + def setup_properties! + self.id = model.externalIdentifier + self.version = model.version + self.type = model.type + self.admin_policy_id = model.administrative.hasAdminPolicy + + self.catkeys = Catkey.symphony_links(model) + self.barcode = model.identification.barcode + self.source_id = model.identification.sourceId + + setup_acccess_properties! + + self.collection_ids = Array(model.structural&.isMemberOf) + self.file_sets = model.structural.contains.map { |cocina| FileSet.new(cocina) } + self.members = Array(model.structural.hasMemberOrders&.first&.members) + self.release_tags = model.administrative.releaseTags + end + + def setup_acccess_properties! + self.copyright = model.access.copyright + self.use_statement = model.access.useAndReproductionStatement + self.license = model.access.license + + self.view_access = model.access.view + self.download_access = model.access.download + self.access_location = model.access.location + self.controlled_digital_lending = model.access.controlledDigitalLending + self.embargo = Embargo.new(model.access.embargo) if model.access.embargo + end + + def save + @model = ItemPersister.update(model, self) + end + + # This checks to see if the embargo or any of the properties of the embargo changed + def embargo_changed? + super || embargo&.changed? + end + + # has the collection or any of its members changed? + def file_sets_changed? + super || file_sets.any?(&:changed?) + end + + def self.model_name + ::ActiveModel::Name.new(nil, nil, 'Item') + end +end diff --git a/app/models/managed_file.rb b/app/models/managed_file.rb new file mode 100644 index 0000000000..be50acf232 --- /dev/null +++ b/app/models/managed_file.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class ManagedFile < ApplicationModel + define_attribute_methods :view_access, :download_access, :access_location, + :controlled_digital_lending, :publish, :shelve, :preserve, + :filename, :mime_type, :size, :use, :height, :width + + attribute :view_access + attribute :download_access + attribute :access_location + attribute :controlled_digital_lending + attribute :publish + attribute :shelve + attribute :preserve + attribute :filename + attribute :mime_type + attribute :size + attribute :use + attribute :height + attribute :width + + # When the object is initialized, copy the properties from the cocina model to the entity: + def setup_properties! + self.filename = model.filename + self.mime_type = model.hasMimeType + self.size = model.size + self.use = model.use + + self.view_access = model.access.view + self.download_access = model.access.download + self.access_location = model.access.location + self.controlled_digital_lending = model.access.controlledDigitalLending + + self.publish = model.administrative.publish + self.shelve = model.administrative.shelve + self.preserve = model.administrative.sdrPreserve + + self.height = model.presentation&.height + self.width = model.presentation&.width + end + + def administrative_changed? + publish_changed? || shelve_changed? || preserve_changed? + end + + # Assigns the correct access and ensures publsh and shelve are false + def dark! + citation_only! + self.publish = false + self.shelve = false + end + + # Assigns the correct access so the object shows on PURL, but doesn't reveal any files + def citation_only! + self.view_access = 'dark' + self.download_access = 'none' + self.controlled_digital_lending = false + self.access_location = nil + end +end diff --git a/app/services/collection_persister.rb b/app/services/collection_persister.rb new file mode 100644 index 0000000000..0bed2ed1ad --- /dev/null +++ b/app/services/collection_persister.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# Writes updates to Cocina collections +class CollectionPersister + # @param [Cocina::Models::Collection] model the orignal state of the collection + # @param [Collection] change_set the values to update. + # @return [Cocina::Models::Collection] the model with updates applied + def self.update(model, change_set) + new(model, change_set).update + end + + def initialize(model, change_set) + @model = model + @change_set = change_set + end + + def update + updated_model = update_identification(model) + .then { |updated| updated_access(updated) } + .then { |updated| updated_administrative(updated) } + + Repository.store(updated_model) + end + + private + + # The map between the change set fields and the Cocina field names + ACCESS_FIELDS = { + copyright: :copyright, + license: :license, + use_statement: :useAndReproductionStatement, + view_access: :access + }.freeze + + attr_reader :model, :change_set + + delegate :admin_policy_id, :catkeys, *ACCESS_FIELDS.keys, + :source_id_changed?, :catkeys_changed?, :admin_policy_id_changed?, + :copyright_changed?, :license_changed?, :use_statement_changed?, + :view_access_changed?, to: :change_set + + def access_changed? + ACCESS_FIELDS.keys.any? { |field| public_send("#{field}_changed?") } + end + + def updated_access(updated) + return updated unless access_changed? + + updated.new(access: updated.access.new(updated_access_properties)) + end + + def updated_access_properties + {}.tap do |access_properties| + ACCESS_FIELDS.each do |field, cocina_field| + access_properties[cocina_field] = public_send(field).presence if public_send("#{field}_changed?") + end + end + end + + def update_identification(updated) + return updated unless source_id_changed? || catkeys_changed? + + identification_props = updated.identification&.to_h || {} + identification_props[:catalogLinks] = Catkey.serialize(model, catkeys) if catkeys_changed? + updated.new(identification: identification_props.compact.presence) + end + + def updated_administrative(updated) + return updated unless admin_policy_id_changed? + + updated_administrative = updated.administrative.new(hasAdminPolicy: admin_policy_id) + updated.new(administrative: updated_administrative) + end +end diff --git a/app/services/item_persister.rb b/app/services/item_persister.rb new file mode 100644 index 0000000000..49776f1c87 --- /dev/null +++ b/app/services/item_persister.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +# Writes updates Cocina Models +class ItemPersister # rubocop:disable Metrics/ClassLength + # @param [Cocina::Models::DRO] model the orignal state of the model + # @param [Item] change_set the values to update. + # @return [Cocina::Models::DRO] the model with updates applied + def self.update(model, change_set) + new(model, change_set).update + end + + def initialize(model, change_set) + @model = model + @change_set = change_set + end + + # @raises [Dor::Services::Client::BadRequestError] when the server doesn't accept the request + def update + updated_model = update_type(model) + .then { |updated| update_structural(updated) } + .then { |updated| update_identification(updated) } + .then { |updated| updated_administrative(updated) } + .then { |updated| updated_access(updated) } + Repository.store(updated_model) + end + + private + + # The map between the change set fields and the Cocina field names + ACCESS_FIELDS = { + copyright: :copyright, + license: :license, + use_statement: :useAndReproductionStatement, + view_access: :view, + download_access: :download, + access_location: :location, + controlled_digital_lending: :controlledDigitalLending + }.freeze + + attr_reader :model, :change_set + + delegate :admin_policy_id, :barcode, :catkeys, :collection_ids, + :embargo, :source_id, + *ACCESS_FIELDS.keys, + :collection_ids_changed?, :source_id_changed?, + :catkeys_changed?, :barcode_changed?, :admin_policy_id_changed?, + :copyright_changed?, :license_changed?, :use_statement_changed?, + :embargo_changed?, :file_sets_changed?, :members_changed?, + :file_sets, :members, + :view_access_changed?, :download_access_changed?, + :access_location_changed?, :controlled_digital_lending_changed?, + :type, :type_changed?, to: :change_set + + def update_type(updated) + return updated unless type_changed? + + updated.new(type: type) + end + + def updated_administrative(updated) + return updated unless admin_policy_id_changed? + + updated_administrative = updated.administrative.new(hasAdminPolicy: admin_policy_id) + updated.new(administrative: updated_administrative) + end + + def update_structural(updated) + return updated unless structural_changed? + + updated_structural = updated.structural.new(isMemberOf: Array(collection_ids)) + updated_structural = update_file_sets(updated_structural) if file_sets_changed? + if members_changed? + orders = updated_structural.hasMemberOrders || [] + first_order = orders.first || Cocina::Models::Sequence + new_order = first_order.new(members: members) + updated_structural = updated_structural.new(hasMemberOrders: [new_order]) + end + + updated.new(structural: updated_structural) + end + + def update_file_sets(updated_structural) + contains = file_sets.map do |file_set| + new_files = file_set.files.map do |file| + update_file(file) + end + new_struct = file_set.model.structural.new(contains: new_files) + file_set.model.new(structural: new_struct) + end + updated_structural.new(contains: contains) + end + + def update_file(file) + new_access = file.model.access.new(view: file.view_access, download: file.download_access, location: file.access_location, + controlledDigitalLending: file.controlled_digital_lending) + new_file = file.model.new(access: new_access) + new_file = new_file.new(filename: file.filename) if file.filename_changed? + + if file.administrative_changed? + admin = file.model.administrative + admin = admin.new(publish: file.publish) if file.publish_changed? + admin = admin.new(shelve: file.shelve) if file.shelve_changed? + admin = admin.new(sdrPreserve: file.preserve) if file.preserve_changed? + new_file = new_file.new(administrative: admin) + end + new_file + end + + def access_changed? + embargo_changed? || ACCESS_FIELDS.keys.any? { |field| public_send("#{field}_changed?".to_sym) } + end + + def identification_changed? + source_id_changed? || catkeys_changed? || barcode_changed? + end + + def structural_changed? + collection_ids_changed? || file_sets_changed? || members_changed? + end + + # rubocop:disable Metrics/PerceivedComplexity + # rubocop:disable Metrics/CyclomaticComplexity + def updated_access(updated) + return updated unless access_changed? + + access_properties = {} + access_properties[:copyright] = copyright.presence if copyright_changed? + access_properties[:license] = license.presence if license_changed? + access_properties[:useAndReproductionStatement] = use_statement if use_statement_changed? + access_properties[:embargo] = embargo_props if embargo_changed? + access_properties[:view] = view_access if view_access_changed? + access_properties[:download] = download_access if download_access_changed? + access_properties[:location] = access_location if access_location_changed? + access_properties[:controlledDigitalLending] = controlled_digital_lending if controlled_digital_lending_changed? + updated.new(access: updated.access.new(access_properties)) + end + # rubocop:enable Metrics/PerceivedComplexity + # rubocop:enable Metrics/CyclomaticComplexity + + def embargo_props + embargo = change_set.embargo + updated_model = embargo.model + updated_model = updated_model.new(releaseDate: embargo.release_date) if embargo.release_date_changed? + updated_model = updated_model.new(view: embargo.view_access) if embargo.view_access_changed? + updated_model = updated_model.new(download: embargo.download_access) if embargo.download_access_changed? + updated_model = updated_model.new(accessLocation: embargo.accessLocation) if embargo.access_location_changed? + + updated_model + end + + def update_identification(updated) + return updated unless identification_changed? + + identification_props = updated.identification&.to_h || {} + identification_props[:sourceId] = source_id if source_id_changed? + identification_props[:barcode] = barcode.presence if barcode_changed? + identification_props[:catalogLinks] = Catkey.serialize(model, catkeys) if catkeys_changed? + updated.new(identification: identification_props.presence) + end +end diff --git a/spec/models/collection_spec.rb b/spec/models/collection_spec.rb new file mode 100644 index 0000000000..d04ee18610 --- /dev/null +++ b/spec/models/collection_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Collection do + let(:druid) { 'druid:dc243mg0841' } + + describe '#save' do + subject(:collection) do + described_class.new(cocina_model) + end + + let(:object_client) { instance_double(Dor::Services::Client::Object, find: cocina_model, update: true) } + let(:updated_model) do + cocina_model.new( + { + 'identification' => { + 'catalogLinks' => [{ catalog: 'symphony', catalogRecordId: '12345', refresh: true }] + } + } + ) + end + let(:cocina_model) do + Cocina::Models.build({ + 'label' => 'My ETD', + 'version' => 1, + 'type' => Cocina::Models::ObjectType.collection, + 'externalIdentifier' => druid, + 'description' => { + 'title' => [{ 'value' => 'My ETD' }], + 'purl' => "https://purl.stanford.edu/#{druid.delete_prefix('druid:')}" + }, + 'access' => { + 'view' => 'world' + }, + 'identification' => {}, + 'administrative' => { hasAdminPolicy: 'druid:cg532dg5405' } + }) + end + + before do + allow(Dor::Services::Client).to receive(:object).and_return(object_client) + end + + it 'updates the catkeys' do + collection.catkeys = ['12345'] + collection.save + expect(object_client).to have_received(:update) + .with(params: updated_model) + end + end +end diff --git a/spec/models/item_spec.rb b/spec/models/item_spec.rb new file mode 100644 index 0000000000..7218851810 --- /dev/null +++ b/spec/models/item_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Item do + describe '#save' do + subject(:item) do + described_class.new(cocina_model) + end + + let(:object_client) { instance_double(Dor::Services::Client::Object, find: cocina_model, update: true) } + let(:updated_model) do + cocina_model.new( + { + 'identification' => { + sourceId: 'sul:12312', + 'catalogLinks' => [{ catalog: 'symphony', catalogRecordId: '12345', refresh: true }] + } + } + ) + end + let(:cocina_model) do + Cocina::Models.build({ + 'label' => 'My ETD', + 'version' => 1, + 'type' => Cocina::Models::ObjectType.object, + 'externalIdentifier' => druid, + 'description' => { + 'title' => [{ 'value' => 'My ETD' }], + 'purl' => "https://purl.stanford.edu/#{druid.delete_prefix('druid:')}" + }, + 'access' => {}, + 'administrative' => { hasAdminPolicy: 'druid:cg532dg5405' }, + 'structural' => {}, + 'identification' => { + sourceId: 'sul:12312' + } + }) + end + let(:druid) { 'druid:dc243mg0841' } + + before do + allow(Dor::Services::Client).to receive(:object).and_return(object_client) + end + + it 'updates the catkeys' do + item.catkeys = ['12345'] + item.save + expect(object_client).to have_received(:update) + .with(params: updated_model) + end + end +end