From ea20c95530ff69b7f5d9718d5bdbdc6fa8d3671e Mon Sep 17 00:00:00 2001 From: Martin Schneider Date: Wed, 2 Nov 2022 09:41:43 +0100 Subject: [PATCH] Enhance versioning + revert function Make revert function work (#780) - Update version list after reverting a change - Fix issue /w big svgs - Make reverting molfile / svg work - Fix merge issues - Replace passing local variables /w using state Add missing versioning for Screens, Wellplates, and Research plans Change the way versions are displayed in the version table. Previously we had one version per database update. Now we group versions by the request id. These changes are now merged to make it more intuitive for the user. Implement revert functionality /w fetchers, reverters, and serializers Implement a new revert UI with one button per version and one modal to select revertible values. Delete old revert button. Co-authored-by: Martin Schneider Co-authored-by: VadimKeller --- app/api/chemotion/version_api.rb | 136 +++---- .../components/svg_with_popver.scss | 3 + app/assets/stylesheets/history.scss | 126 +++++++ app/assets/stylesheets/version.scss | 24 -- app/models/attachment.rb | 3 +- app/models/concerns/versionable.rb | 215 ----------- app/models/container.rb | 3 +- app/models/container_hierarchy.rb | 2 + app/models/elemental_composition.rb | 2 +- app/models/reaction.rb | 2 +- app/models/reactions_sample.rb | 3 +- app/models/research_plan.rb | 1 + app/models/research_plan_metadata.rb | 1 + app/models/residue.rb | 2 +- app/models/sample.rb | 233 ++++++------ app/models/screen.rb | 1 + .../mydb/elements/details/VersionsTable.js | 80 ++-- .../elements/details/VersionsTableChanges.js | 214 ++--------- .../elements/details/VersionsTableFields.js | 352 ++++++++++++++++++ .../elements/details/VersionsTableModal.js | 207 ++++++++++ .../elements/details/VersionsTableTime.js | 8 +- app/packs/src/fetchers/VersionsFetcher.js | 18 +- app/packs/src/models/Change.js | 11 - app/packs/src/models/HistoryChange.js | 13 + app/packs/src/models/HistoryField.js | 15 + app/packs/src/models/HistoryVersion.js | 12 + app/packs/src/models/ResearchPlan.js | 15 +- app/packs/src/models/Version.js | 16 - .../src/stores/alt/actions/ElementActions.js | 76 ++-- app/services/versioning/fetcher.rb | 30 ++ .../versioning/fetchers/reaction_fetcher.rb | 60 +++ .../fetchers/research_plan_fetcher.rb | 42 +++ .../versioning/fetchers/sample_fetcher.rb | 36 ++ .../versioning/fetchers/screen_fetcher.rb | 34 ++ app/services/versioning/merger.rb | 38 ++ app/services/versioning/reverter.rb | 40 ++ .../reverters/attachment_reverter.rb | 7 + .../versioning/reverters/base_reverter.rb | 43 +++ .../reverters/container_reverter.rb | 49 +++ .../elemental_composition_reverter.rb | 7 + .../versioning/reverters/reaction_reverter.rb | 7 + .../reverters/reactions_sample_reverter.rb | 34 ++ .../research_plan_metadata_reverter.rb | 7 + .../reverters/research_plan_reverter.rb | 7 + .../versioning/reverters/residue_reverter.rb | 7 + .../versioning/reverters/sample_reverter.rb | 26 ++ .../versioning/reverters/screen_reverter.rb | 7 + .../serializers/attachment_serializer.rb | 24 ++ .../versioning/serializers/base_serializer.rb | 128 +++++++ .../serializers/container_serializer.rb | 90 +++++ .../elemental_composition_serializer.rb | 33 ++ .../serializers/reaction_serializer.rb | 88 +++++ .../reactions_sample_serializer.rb | 62 +++ .../research_plan_metadata_serializer.rb | 124 ++++++ .../serializers/research_plan_serializer.rb | 129 +++++++ .../serializers/residue_serializer.rb | 68 ++++ .../serializers/sample_serializer.rb | 167 +++++++++ .../serializers/screen_serializer.rb | 41 ++ config/initializers/logidze.rb | 2 +- config/profile_default.yml.example | 123 +++++- .../20190617153000_convert_analysis_type.rb | 6 +- ...213121641_add_logidze_to_research_plans.rb | 17 + ...0_add_logidze_to_research_plan_metadata.rb | 17 + ...efault_for_reactions_samples_timestamps.rb | 6 + .../20221219194720_add_logidze_to_screens.rb | 17 + ...0120134152_add_deleted_at_to_containers.rb | 5 + db/schema.rb | 15 +- .../logidze_on_research_plan_metadata_v01.sql | 6 + db/triggers/logidze_on_research_plans_v01.sql | 6 + ...gidze_on_research_plans_wellplates_v01.sql | 6 + db/triggers/logidze_on_screens_v01.sql | 6 + db/triggers/logidze_on_wellplates_v01.sql | 6 + yarn.lock | 50 ++- 73 files changed, 2768 insertions(+), 749 deletions(-) create mode 100644 app/assets/stylesheets/components/svg_with_popver.scss create mode 100644 app/assets/stylesheets/history.scss delete mode 100644 app/assets/stylesheets/version.scss delete mode 100644 app/models/concerns/versionable.rb create mode 100644 app/models/container_hierarchy.rb create mode 100644 app/packs/src/apps/mydb/elements/details/VersionsTableFields.js create mode 100644 app/packs/src/apps/mydb/elements/details/VersionsTableModal.js delete mode 100644 app/packs/src/models/Change.js create mode 100644 app/packs/src/models/HistoryChange.js create mode 100644 app/packs/src/models/HistoryField.js create mode 100644 app/packs/src/models/HistoryVersion.js delete mode 100644 app/packs/src/models/Version.js create mode 100644 app/services/versioning/fetcher.rb create mode 100644 app/services/versioning/fetchers/reaction_fetcher.rb create mode 100644 app/services/versioning/fetchers/research_plan_fetcher.rb create mode 100644 app/services/versioning/fetchers/sample_fetcher.rb create mode 100644 app/services/versioning/fetchers/screen_fetcher.rb create mode 100644 app/services/versioning/merger.rb create mode 100644 app/services/versioning/reverter.rb create mode 100644 app/services/versioning/reverters/attachment_reverter.rb create mode 100644 app/services/versioning/reverters/base_reverter.rb create mode 100644 app/services/versioning/reverters/container_reverter.rb create mode 100644 app/services/versioning/reverters/elemental_composition_reverter.rb create mode 100644 app/services/versioning/reverters/reaction_reverter.rb create mode 100644 app/services/versioning/reverters/reactions_sample_reverter.rb create mode 100644 app/services/versioning/reverters/research_plan_metadata_reverter.rb create mode 100644 app/services/versioning/reverters/research_plan_reverter.rb create mode 100644 app/services/versioning/reverters/residue_reverter.rb create mode 100644 app/services/versioning/reverters/sample_reverter.rb create mode 100644 app/services/versioning/reverters/screen_reverter.rb create mode 100644 app/services/versioning/serializers/attachment_serializer.rb create mode 100644 app/services/versioning/serializers/base_serializer.rb create mode 100644 app/services/versioning/serializers/container_serializer.rb create mode 100644 app/services/versioning/serializers/elemental_composition_serializer.rb create mode 100644 app/services/versioning/serializers/reaction_serializer.rb create mode 100644 app/services/versioning/serializers/reactions_sample_serializer.rb create mode 100644 app/services/versioning/serializers/research_plan_metadata_serializer.rb create mode 100644 app/services/versioning/serializers/research_plan_serializer.rb create mode 100644 app/services/versioning/serializers/residue_serializer.rb create mode 100644 app/services/versioning/serializers/sample_serializer.rb create mode 100644 app/services/versioning/serializers/screen_serializer.rb create mode 100644 db/migrate/20221213121641_add_logidze_to_research_plans.rb create mode 100644 db/migrate/20221213123310_add_logidze_to_research_plan_metadata.rb create mode 100644 db/migrate/20221216222832_remove_default_for_reactions_samples_timestamps.rb create mode 100644 db/migrate/20221219194720_add_logidze_to_screens.rb create mode 100644 db/migrate/20230120134152_add_deleted_at_to_containers.rb create mode 100644 db/triggers/logidze_on_research_plan_metadata_v01.sql create mode 100644 db/triggers/logidze_on_research_plans_v01.sql create mode 100644 db/triggers/logidze_on_research_plans_wellplates_v01.sql create mode 100644 db/triggers/logidze_on_screens_v01.sql create mode 100644 db/triggers/logidze_on_wellplates_v01.sql diff --git a/app/api/chemotion/version_api.rb b/app/api/chemotion/version_api.rb index d9080ca990..eecb11faaf 100644 --- a/app/api/chemotion/version_api.rb +++ b/app/api/chemotion/version_api.rb @@ -17,39 +17,8 @@ class VersionAPI < Grape::API route_param :id do get do - # find specific sample and load only required data - sample = Sample.select(:id, :name, :log_data, :updated_at).find(params[:id]) - - analyses = sample.analyses.flat_map { |analysis| analysis.self_and_descendants.select(:id, :name, :updated_at, :log_data) } - - # create cache key for sample - timestamp = [ - sample.updated_at, - analyses.map(&:updated_at).max, - Attachment.where(attachable_id: analyses.map(&:id), attachable_type: 'Container').maximum(:updated_at) - ].reject(&:nil?).max.to_i - cache_key = "versions/samples/#{sample.id}/#{timestamp}" - - # cache processed and sorted versions to speed up pagination - versions = Rails.cache.fetch cache_key do - all_versions = sample.versions_hash - all_versions += sample.residues.select(:sample_id, :log_data).flat_map do |residue| - residue.versions_hash(sample.name) - end - all_versions += sample.elemental_compositions.select(:sample_id, :log_data).flat_map do |elemental_composition| - elemental_composition.versions_hash(sample.name) - end - - analyses.each do |analysis| - all_versions += analysis.versions_hash - all_versions += analysis.attachments.select(:attachable_id, :attachable_type, :filename, :log_data).flat_map do |attachment| - attachment.versions_hash(attachment.filename) - end - end - - all_versions.sort_by! { |version| -version['t'].to_i } # sort versions with the latest changes in the first place - .each_with_index { |record, index| record['v'] = all_versions.length - index } # adjust v to be uniq and in right order - end + sample = Sample.with_log_data.find(params[:id]) + versions = Versioning::Fetcher.call(sample) { versions: paginate(Kaminari.paginate_array(versions)) } end @@ -67,60 +36,63 @@ class VersionAPI < Grape::API route_param :id do get do - # find specific sample and load only required data - reaction = Reaction.select(:id, :name, :log_data, :updated_at).find(params[:id]) - - analyses = ( - reaction.analyses + - reaction.samples.includes(:container).pluck('containers.id').flat_map { |container_id| Container.analyses_for_root(container_id) } - ).flat_map { |analysis| analysis.self_and_descendants.select(:id, :name, :updated_at, :log_data) } - - # create cache key for reaction - timestamp = [ - reaction.updated_at, - reaction.samples.with_deleted.maximum(:updated_at), - reaction.reactions_samples.with_deleted.maximum(:updated_at), - analyses.map(&:updated_at).max, - Attachment.where(attachable_id: analyses.map(&:id), attachable_type: 'Container').maximum(:updated_at) - ].reject(&:nil?).max.to_i - cache_key = "versions/reactions/#{reaction.id}/#{timestamp}" - - # cache processed and sorted versions of all reaction dependent records and merge them into one list to speed up pagination - versions = Rails.cache.fetch cache_key do - all_versions = reaction.versions_hash - - analyses.each do |analysis| - all_versions += analysis.versions_hash - all_versions += analysis.attachments.select(:attachable_id, :attachable_type, :filename, :log_data).flat_map do |attachment| - attachment.versions_hash(attachment.filename) - end - end - - samples = reaction.samples.with_deleted.select('samples.id, samples.name, samples.log_data') - samples.each do |sample| - all_versions += sample.versions_hash - all_versions += sample.residues.select(:sample_id, :log_data).flat_map do |residue| - residue.versions_hash(sample.name) - end - all_versions += sample.elemental_compositions.select(:sample_id, :log_data).flat_map do |elemental_composition| - elemental_composition.versions_hash(sample.name) - end - end - - reactions_samples = reaction.reactions_samples.with_deleted.select(:sample_id, :log_data, :type) - all_versions += reactions_samples.flat_map do |reactions_sample| - sample = samples.detect { |s| s.id == reactions_sample.sample_id } - reactions_sample.versions_hash(sample.name) - end - - all_versions.sort_by! { |version| -version['t'].to_i } # sort versions with the latest changes in the first place - .each_with_index { |record, index| record['v'] = all_versions.length - index } # adjust v to be uniq and in right order - end + reaction = Reaction.with_log_data.find(params[:id]) + versions = Versioning::Fetcher.call(reaction) { versions: paginate(Kaminari.paginate_array(versions)) } end end end + + resource :research_plans do + desc 'Return versions of the given research plan' + + params do + requires :id, type: Integer, desc: 'Research plan id' + end + + paginate per_page: 10, offset: 0, max_per_page: 100 + + route_param :id do + get do + research_plan = ResearchPlan.with_log_data.find(params[:id]) + versions = Versioning::Fetcher.call(research_plan) + + { versions: paginate(Kaminari.paginate_array(versions)) } + end + end + end + + resource :screens do + desc 'Return versions of the given screen' + + params do + requires :id, type: Integer, desc: 'Screen id' + end + + paginate per_page: 10, offset: 0, max_per_page: 100 + + route_param :id do + get do + screen = Screen.with_log_data.find(params[:id]) + versions = Versioning::Fetcher.call(screen) + + { versions: paginate(Kaminari.paginate_array(versions)) } + end + end + end + + resource :revert do + desc 'Revert selected changes' + + params do + requires :changes, type: JSON, desc: 'Changes hash' + end + + post do + Versioning::Reverter.call(params[:changes]) + end + end end end end diff --git a/app/assets/stylesheets/components/svg_with_popver.scss b/app/assets/stylesheets/components/svg_with_popver.scss new file mode 100644 index 0000000000..176f1638ca --- /dev/null +++ b/app/assets/stylesheets/components/svg_with_popver.scss @@ -0,0 +1,3 @@ +.image-with-full-width { + width: 100%; +} \ No newline at end of file diff --git a/app/assets/stylesheets/history.scss b/app/assets/stylesheets/history.scss new file mode 100644 index 0000000000..4d8461f1de --- /dev/null +++ b/app/assets/stylesheets/history.scss @@ -0,0 +1,126 @@ +.history-legend { + display: flex; + justify-content: flex-end; + list-style: none; + padding: 0; + margin: 2px 2px 10px 0; + + &__item { + font-size: 10px; + padding-left: .5em; + + &::before { + content: "\f0c8"; + font-family: FontAwesome; + display: inline-block; + padding: 0 0.2em; + } + + &--old { + &::before { + color: #f2dede; + } + } + + &--new { + &::before { + color: #dff0d8; + } + } + + &--current { + &::before { + color: #f5f5f5; + } + } + } +} + +.history-modal .modal-dialog { + top: 0; + transform: translate(-50%, 0) !important; +} + +.history-alert { + margin-bottom: 0; + margin-top: 20px; +} + +.history-checkbox-label { + margin-bottom: 0; + display: flex; + gap: 5px; + cursor: pointer; + + input { + margin-top: 0 !important; + } +} + +.history-table { + &__caret { + transition: transform .1s ease-out; + } + + &__row { + cursor: pointer; + + &.active { + .history-table__caret { + transform: rotate(90deg); + } + } + } + + &-breadcrumb { + font-size: 13px; + padding: 8px 15px; + margin: 0; + list-style: none; + background-color: #f5f5f5; + border-radius: 4px; + + &:not(:first-child) { + margin-top: 10px; + } + + &__element { + display: inline-block; + } + + &__element + &__element:before { + padding: 0 5px; + color: #ccc; + content: "/ "; + } + } + + .bg-current { + background-color: #f5f5f5; + } + + .row-change { + display: flex; + flex-wrap: wrap; + font-size: 10px; + margin: 10px 0 0; + word-break: break-all; + + & > [class*="col-"] { + padding: 6px; + } + + .ql-container { + font-size: 10px; + } + + .ql-editor { + padding: 0; + } + + .ql-tooltip.ql-hidden { + height: 0; + padding-top: 10px; + } + } +} diff --git a/app/assets/stylesheets/version.scss b/app/assets/stylesheets/version.scss deleted file mode 100644 index 65cef6b42d..0000000000 --- a/app/assets/stylesheets/version.scss +++ /dev/null @@ -1,24 +0,0 @@ -.row.row-version-history { // overwrite bootstrap - display: flex; - flex-wrap: wrap; - font-size: 10px; - margin: 0; - word-break: break-all; - - & + .row-version-history { - margin-top: 15px; - } - - & > [class*="col-"] { - padding: 10px; - } - - .ql-editor { - padding: 0; - } - - .ql-tooltip.ql-hidden { - height: 0; - padding-top: 10px; - } -} \ No newline at end of file diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 3d3cd463dd..ca98802681 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -33,8 +33,7 @@ # class Attachment < ApplicationRecord # rubocop:disable Metrics/ClassLength - include Versionable - + has_logidze include AttachmentJcampAasm include AttachmentJcampProcess include Labimotion::AttachmentConverter diff --git a/app/models/concerns/versionable.rb b/app/models/concerns/versionable.rb deleted file mode 100644 index 7c977fbd77..0000000000 --- a/app/models/concerns/versionable.rb +++ /dev/null @@ -1,215 +0,0 @@ -# frozen_string_literal: true - -# Versionable module -module Versionable - extend ActiveSupport::Concern - - BLACKLISTED_ATTRIBUTES = %w[ - id - created_at - updated_at - parent_id - parent_type - container_type - attachable_id - attachable_type - sample_id - reaction_id - molecule_id - molecule_name_id - type - ].freeze - - included do - has_logidze - end - - def versions_hash(record_name = name) - return [] if log_data.nil? - - result = [] # result data - base = {} # track current version data - log_data.versions.each do |version| - changes = version.changes # get changes for current version - changes_comparison_hash = {} # hash for changes comparison - changes.each do |key, value| - next if key.in?(BLACKLISTED_ATTRIBUTES) # ignore uneeded keys - next if value == base[key] # ignore if value is same as in last version - next if base[key].blank? && value.blank? # ignore if value is empty or nil - - # parse value if needed - old_value = version_value(key, base[key]) - new_value = version_value(key, value) - - if (old_value.is_a?(Hash) || new_value.is_a?(Hash)) && key != 'temperature' - # fix nil cases - old_value ||= {} - new_value ||= {} - base = old_value.merge(new_value) # hash with contains all keys - label = version_label(key, base) # labels for hash - kind = version_kind(key, base) # kinds of hash (numrange, date, string) - - base.each_key do |key| - next if old_value[key] == new_value[key] # ignore if value is same as in last version - next if old_value[key].blank? && new_value[key].blank? # ignore if value is empty or nil - - changes_comparison_hash[key] = { - o: old_value[key], - n: new_value[key], - l: label[key], - k: kind[key] - } - end - else - changes_comparison_hash[key] = { - o: old_value, - n: new_value, - l: version_label(key), # label for attribute, - k: version_kind(key) # kind of attribute (numrange, date, string) - } - end - end - base.merge!(changes) # merge changes with last version data for next iteration - next if changes_comparison_hash.empty? - - result << { - 'k' => version_entity, # record kind (sampe, reaction, ...) - 'n' => record_name, # record name (uses as default the name attribute but in case the model doesn't have a name field or you want to change it) - 't' => Time.at(version.data['ts'] / 1000), # timestamp of the change - 'u' => version_user_names_lookup[version.data.dig('m', '_r')], # user - 'c' => changes_comparison_hash # changes hash - } - end - - result - end - - private - - def version_user_names_lookup - @version_user_names_lookup ||= begin - ids = {} - - log_data.versions.each do |v| - ids[v.data.dig('m', '_r')] ||= 1 - ids[v.changes['created_by']] ||= 1 if v.changes.key?('created_by') - ids[v.changes['created_for']] ||= 1 if v.changes.key?('created_for') - end - - User.with_deleted.where(id: ids.keys).map { |u| [u.id, u.name] }.to_h - end - end - - def version_value(attribute, value) - return if value.nil? - - if self.class.name == 'Reaction' && attribute.in?(%w[description observation]) - YAML.load(value).to_json - elsif attribute.in?(%w[created_by created_for]) - version_user_names_lookup[value] - elsif self.class.name == 'Attachment' && attribute == 'aasm_state' - value.humanize - elsif self.class.name == 'ElementalComposition' && attribute == 'composition_type' - ElementalComposition::TYPES[value.to_sym] - elsif self.class.name == 'Sample' && attribute.in?(%w[boiling_point melting_point]) - value - else - @attributes[attribute].type.deserialize(value) - end - end - - def version_label(attribute, value_hash = {}) - case attribute - when 'timestamp_start' - 'Start' - when 'timestamp_stop' - 'Stop' - when 'observation' - 'Additional information for publication and purification details' - when 'temperature' - 'Temperature' - when 'solvent' - 'Solvent' - else - if self.class.columns_hash[attribute].type.in?(%i[hstore jsonb]) - label_hash = {} - - value_hash.each_key do |key| - value = if self.class.name == 'Container' && key == 'report' - 'Add to Report' - elsif self.class.name == 'Sample' && attribute == 'stereo' - "#{attribute} #{key}".humanize - else - key.underscore.humanize - end - - label_hash.merge!(key => value) - end - - label_hash - else - attribute.underscore.humanize - end - end - end - - def version_kind(attribute, value_hash = {}) - if value_hash.present? - kind_hash = {} - - value_hash.each_key do |key| - value = if self.class.name == 'Container' && key == 'content' || self.class.name == 'Reaction' && key == 'ops' - :quill - elsif self.class.name == 'Container' && key == 'kind' - :treeselect - elsif self.class.name == 'Sample' && key == 'sample_svg_file' - :svg - else - :string - end - - kind_hash.merge!(key => value) - end - - kind_hash - elsif self.class.name == 'Reaction' && attribute.in?(%w[description observation]) - :quill - elsif self.class.name == 'Reaction' && attribute == 'rxno' - :treeselect - else - case attribute - when 'created_at', 'updated_at', 'deleted_at' - :date - when 'melting_point', 'boiling_point' - :numrange - when 'solvent' - :solvent - when 'sample_svg_file' - :svg - when 'temperature' - :temperature - else - :string - end - end - end - - def version_entity - case self.class.name - when 'ReactionsStartingMaterialSample' - 'Starting material' - when 'ReactionsReactantSample' - 'Reactant' - when 'ReactionsSolventSample' - 'Solvent' - when 'ReactionsPurificationSolventSample' - 'Purification solvent' - when 'ReactionsProductSample' - 'Product' - when 'Container' - 'Analysis' - else - self.class.name.underscore.humanize - end - end -end \ No newline at end of file diff --git a/app/models/container.rb b/app/models/container.rb index fd48ca722d..82a1e5bbfb 100644 --- a/app/models/container.rb +++ b/app/models/container.rb @@ -23,9 +23,10 @@ # class Container < ApplicationRecord + has_logidze + acts_as_paranoid include ElementCodes include Labimotion::Datasetable - include Versionable belongs_to :containable, polymorphic: true, optional: true has_many :attachments, as: :attachable diff --git a/app/models/container_hierarchy.rb b/app/models/container_hierarchy.rb new file mode 100644 index 0000000000..03bf50ce4d --- /dev/null +++ b/app/models/container_hierarchy.rb @@ -0,0 +1,2 @@ +class ContainerHierarchy < ApplicationRecord +end diff --git a/app/models/elemental_composition.rb b/app/models/elemental_composition.rb index b798a40a4b..3b33efa6ea 100644 --- a/app/models/elemental_composition.rb +++ b/app/models/elemental_composition.rb @@ -16,7 +16,7 @@ # class ElementalComposition < ApplicationRecord - include Versionable + has_logidze belongs_to :sample diff --git a/app/models/reaction.rb b/app/models/reaction.rb index 18fa302be2..7104bc7567 100644 --- a/app/models/reaction.rb +++ b/app/models/reaction.rb @@ -48,6 +48,7 @@ # rubocop:disable Metrics/ClassLength class Reaction < ApplicationRecord + has_logidze acts_as_paranoid include ElementUIStateScopes include PgSearch::Model @@ -56,7 +57,6 @@ class Reaction < ApplicationRecord include Taggable include ReactionRinchi include Labimotion::Segmentable - include Versionable serialize :description, Hash serialize :observation, Hash diff --git a/app/models/reactions_sample.rb b/app/models/reactions_sample.rb index d723c5619f..add51064ec 100644 --- a/app/models/reactions_sample.rb +++ b/app/models/reactions_sample.rb @@ -23,8 +23,7 @@ # class ReactionsSample < ApplicationRecord - include Versionable - + has_logidze acts_as_paranoid belongs_to :reaction, optional: true belongs_to :sample, optional: true diff --git a/app/models/research_plan.rb b/app/models/research_plan.rb index 27c5b47a04..03a8d32884 100644 --- a/app/models/research_plan.rb +++ b/app/models/research_plan.rb @@ -12,6 +12,7 @@ # class ResearchPlan < ApplicationRecord + has_logidze acts_as_paranoid include ElementUIStateScopes include Collectable diff --git a/app/models/research_plan_metadata.rb b/app/models/research_plan_metadata.rb index 09b080477c..73ea14e37e 100644 --- a/app/models/research_plan_metadata.rb +++ b/app/models/research_plan_metadata.rb @@ -44,6 +44,7 @@ class ResearchPlanMetadata < ApplicationRecord + has_logidze self.inheritance_column = nil acts_as_paranoid DATA_CITE_PREFIX = ENV['DATA_CITE_PREFIX'] diff --git a/app/models/residue.rb b/app/models/residue.rb index 8ca53b4a53..ab77f37a62 100644 --- a/app/models/residue.rb +++ b/app/models/residue.rb @@ -15,7 +15,7 @@ # class Residue < ApplicationRecord - include Versionable + has_logidze belongs_to :sample, optional: true validate :loading_present diff --git a/app/models/sample.rb b/app/models/sample.rb index d16818f03d..b790deca56 100644 --- a/app/models/sample.rb +++ b/app/models/sample.rb @@ -59,6 +59,7 @@ class Sample < ApplicationRecord attr_accessor :skip_inventory_label_update + has_logidze acts_as_paranoid include ElementUIStateScopes include PgSearch::Model @@ -68,43 +69,42 @@ class Sample < ApplicationRecord include UnitConvertable include Taggable include Labimotion::Segmentable - include Versionable STEREO_ABS = ['any', 'rac', 'meso', 'delta', 'lambda', '(S)', '(R)', '(Sp)', '(Rp)', '(Sa)', '(Ra)'].freeze - STEREO_REL = ['any', 'syn', 'anti', 'p-geminal', 'p-ortho', 'p-meta', 'p-para', 'cis', 'trans', 'fac', 'mer'].freeze + STEREO_REL = %w[any syn anti p-geminal p-ortho p-meta p-para cis trans fac mer].freeze STEREO_DEF = { 'abs' => 'any', 'rel' => 'any' }.freeze - multisearchable against: [ - :name, :short_label, :external_label, :molecule_sum_formular, - :molecule_iupac_name, :molecule_inchistring, :molecule_inchikey, :molecule_cano_smiles, - :sample_xref_cas + multisearchable against: %i[ + name short_label external_label molecule_sum_formular + molecule_iupac_name molecule_inchistring molecule_inchikey molecule_cano_smiles + sample_xref_cas ] # search scopes for exact matching - pg_search_scope :search_by_sum_formula, against: :sum_formula, associated_against: { - molecule: :sum_formular + pg_search_scope :search_by_sum_formula, against: :sum_formula, associated_against: { + molecule: :sum_formular, } pg_search_scope :search_by_iupac_name, associated_against: { - molecule: :iupac_name + molecule: :iupac_name, } pg_search_scope :search_by_inchistring, associated_against: { - molecule: :inchistring + molecule: :inchistring, } pg_search_scope :search_by_inchikey, associated_against: { - molecule: :inchikey + molecule: :inchikey, } pg_search_scope :search_by_cano_smiles, associated_against: { - molecule: :cano_smiles + molecule: :cano_smiles, } pg_search_scope :search_by_substring, against: %i[ name short_label external_label ], associated_against: { - molecule: %i[sum_formular iupac_name inchistring inchikey cano_smiles] + molecule: %i[sum_formular iupac_name inchistring inchikey cano_smiles], }, using: { trigram: { threshold: 0.0001 } } pg_search_scope :search_by_sample_name, against: :name @@ -113,45 +113,59 @@ class Sample < ApplicationRecord pg_search_scope :search_by_cas, against: { xref: 'cas' } # scopes for suggestions - scope :by_residues_custom_info, ->(info, val) { joins(:residues).where("residues.custom_info -> '#{info}' ILIKE ?", "%#{sanitize_sql_like(val)}%")} + scope :by_residues_custom_info, lambda { |info, val| + joins(:residues).where("residues.custom_info -> '#{info}' ILIKE ?", "%#{sanitize_sql_like(val)}%") + } scope :by_name, ->(query) { where('name ILIKE ?', "%#{sanitize_sql_like(query)}%") } scope :by_sample_xref_cas, ->(query) { where("xref ? 'cas'").where("xref ->> 'cas' ILIKE ?", "%#{sanitize_sql_like(query)}%") } - scope :by_exact_name, ->(query) { where('lower(name) ~* lower(?) or lower(external_label) ~* lower(?)', "^([a-zA-Z0-9]+-)?#{sanitize_sql_like(query)}(-?[a-zA-Z])$", "^([a-zA-Z0-9]+-)?#{sanitize_sql_like(query)}(-?[a-zA-Z])$") } + scope :by_exact_name, lambda { |query| + where('lower(name) ~* lower(?) or lower(external_label) ~* lower(?)', "^([a-zA-Z0-9]+-)?#{sanitize_sql_like(query)}(-?[a-zA-Z])$", "^([a-zA-Z0-9]+-)?#{sanitize_sql_like(query)}(-?[a-zA-Z])$") + } scope :by_short_label, ->(query) { where('short_label ILIKE ?', "%#{sanitize_sql_like(query)}%") } scope :by_external_label, ->(query) { where('external_label ILIKE ?', "%#{sanitize_sql_like(query)}%") } - scope :by_molecule_sum_formular, ->(query) { + scope :by_molecule_sum_formular, lambda { |query| decoupled_collection = where(decoupled: true).where('sum_formula ILIKE ?', "%#{sanitize_sql_like(query)}%") coupled_collection = where(decoupled: false).joins(:molecule).where('molecules.sum_formular ILIKE ?', "%#{sanitize_sql_like(query)}%") where(id: decoupled_collection + coupled_collection) } - scope :with_reactions, -> { + scope :with_reactions, lambda { joins(:reactions_samples) } - scope :with_wellplates, -> { + scope :with_wellplates, lambda { joins(:well) } scope :by_wellplate_ids, ->(ids) { joins(:wellplates).where('wellplates.id in (?)', ids) } - scope :by_reaction_reactant_ids, ->(ids) { joins(:reactions_reactant_samples).where('reactions_samples.reaction_id in (?)', ids) } - scope :by_reaction_product_ids, ->(ids) { joins(:reactions_product_samples).where('reactions_samples.reaction_id in (?)', ids) } - scope :by_reaction_material_ids, ->(ids) { joins(:reactions_starting_material_samples).where('reactions_samples.reaction_id in (?)', ids) } - scope :by_reaction_solvent_ids, ->(ids) { joins(:reactions_solvent_samples).where('reactions_samples.reaction_id in (?)', ids) } - scope :by_reaction_ids, ->(ids) { joins(:reactions_samples).where('reactions_samples.reaction_id in (?)', ids) } + scope :by_reaction_reactant_ids, lambda { |ids| + joins(:reactions_reactant_samples).where('reactions_samples.reaction_id in (?)', ids) + } + scope :by_reaction_product_ids, lambda { |ids| + joins(:reactions_product_samples).where('reactions_samples.reaction_id in (?)', ids) + } + scope :by_reaction_material_ids, lambda { |ids| + joins(:reactions_starting_material_samples).where('reactions_samples.reaction_id in (?)', ids) + } + scope :by_reaction_solvent_ids, lambda { |ids| + joins(:reactions_solvent_samples).where('reactions_samples.reaction_id in (?)', ids) + } + scope :by_reaction_ids, lambda { |ids| + joins(:reactions_samples).where('reactions_samples.reaction_id in (?)', ids) + } scope :by_literature_ids, ->(ids) { joins(:literals).where(literals: { literature_id: ids }) } scope :includes_for_list_display, -> { includes(:molecule_name, :tag, :comments, molecule: :tag) } scope :product_only, -> { joins(:reactions_samples).where("reactions_samples.type = 'ReactionsProductSample'") } - scope :sample_or_startmat_or_products, -> { - joins("left join reactions_samples rs on rs.sample_id = samples.id").where("rs.id isnull or rs.\"type\" in ('ReactionsProductSample', 'ReactionsStartingMaterialSample')") + scope :sample_or_startmat_or_products, lambda { + joins('left join reactions_samples rs on rs.sample_id = samples.id').where("rs.id isnull or rs.\"type\" in ('ReactionsProductSample', 'ReactionsStartingMaterialSample')") } - scope :search_by_fingerprint_sim, ->(molfile, threshold = 0.01) { + scope :search_by_fingerprint_sim, lambda { |molfile, threshold = 0.01| joins(:fingerprint).merge( - Fingerprint.search_similar(nil, threshold, false, molfile) + Fingerprint.search_similar(nil, threshold, false, molfile), ).order('tanimoto desc') } - scope :search_by_fingerprint_sub, ->(molfile, as_array = false) { + scope :search_by_fingerprint_sub, lambda { |molfile, as_array = false| fp_vector = Chemotion::OpenBabelService.bin_fingerprint_from_molfile(molfile) smarts_query = Chemotion::OpenBabelService.get_smiles_from_molfile(molfile) samples = joins(:fingerprint).merge(Fingerprint.screen_sub(fp_vector)) @@ -163,7 +177,6 @@ class Sample < ApplicationRecord Sample.where(id: samples.map(&:id)) } - before_save :auto_set_molfile_to_molecules_molfile before_save :find_or_create_molecule_based_on_inchikey before_save :update_molecule_name @@ -174,8 +187,8 @@ class Sample < ApplicationRecord before_save :auto_set_short_label before_create :check_molecule_name before_create :set_boiling_melting_points - after_save :update_counter after_create :create_root_container + after_save :update_counter after_save :update_equivalent_for_reactions after_save :update_gas_material after_save :update_svg_for_reactions, unless: :skip_reaction_svg_update? @@ -230,7 +243,8 @@ class Sample < ApplicationRecord accepts_nested_attributes_for :residues, :elemental_compositions, :container, :tag, allow_destroy: true - validates :purity, :numericality => { :greater_than_or_equal_to => 0.0, :less_than_or_equal_to => 1.0, :allow_nil => true } + validates :purity, + numericality: { greater_than_or_equal_to: 0.0, less_than_or_equal_to: 1.0, allow_nil: true } validate :has_collections validates :creator, presence: true @@ -252,7 +266,7 @@ def molecule_sum_formular end def molecule_iupac_name - self.molecule ? self.molecule.iupac_name : '' + molecule ? molecule.iupac_name : '' end def molecule_molecular_weight @@ -260,7 +274,7 @@ def molecule_molecular_weight end def molecule_inchistring - self.molecule ? self.molecule.inchistring : '' + molecule ? molecule.inchistring : '' end def sample_xref_cas @@ -268,19 +282,19 @@ def sample_xref_cas end def molecule_inchikey - self.molecule ? self.molecule.inchikey : '' + molecule ? molecule.inchikey : '' end def molecule_cano_smiles - self.molecule ? self.molecule.cano_smiles : '' + molecule ? molecule.cano_smiles : '' end def analyses - self.container ? self.container.analyses : Container.none + container ? container.analyses : Container.none end def self.associated_by_user_id_and_reaction_ids(user_id, reaction_ids) - (for_user(user_id).by_reaction_ids(reaction_ids)).distinct + for_user(user_id).by_reaction_ids(reaction_ids).distinct end def self.associated_by_user_id_and_wellplate_ids(user_id, wellplate_ids) @@ -351,31 +365,32 @@ def create_chemical_entry_for_subsample(sample_id, subsample_id, type) # rubocop:disable Metrics/PerceivedComplexity # rubocop:disable Style/MethodDefParentheses # rubocop:disable Style/OptionalBooleanParameter - # rubocop:disable Layout/TrailingWhitespace def create_subsample user, collection_ids, copy_ea = false, type = nil - subsample = self.dup + subsample = dup subsample.xref['inventory_label'] = nil subsample.skip_inventory_label_update = true - subsample.name = self.name if self.name.present? - subsample.external_label = self.external_label if self.external_label.present? + subsample.name = name if name.present? + subsample.external_label = external_label if external_label.present? # Ex(p|t)ensive method to get a proper counter: # take into consideration sample children that have been hard/soft deleted - children_count = self.children.with_deleted.count - last_child_label = self.children.with_deleted.order('created_at') - .where('short_label LIKE ?', "#{self.short_label}-%").last&.short_label - last_child_counter = last_child_label && - last_child_label.match(/^#{self.short_label}-(\d+)/) && $1.to_i || 0 + children_count = children.with_deleted.count + last_child_label = children.with_deleted.order('created_at') + .where('short_label LIKE ?', "#{short_label}-%").last&.short_label + last_child_counter = (last_child_label && + last_child_label.match(/^#{short_label}-(\d+)/) && ::Regexp.last_match(1).to_i) || 0 counter = [last_child_counter, children_count].max - subsample.short_label = "#{self.short_label}-#{counter + 1}" + subsample.short_label = "#{short_label}-#{counter + 1}" subsample.parent = self subsample.created_by = user.id - subsample.residues_attributes = self.residues.select(:custom_info, :residue_type).as_json - subsample.elemental_compositions_attributes = self.elemental_compositions.select( - :composition_type, :data, :loading - ).as_json if copy_ea + subsample.residues_attributes = residues.select(:custom_info, :residue_type).as_json + if copy_ea + subsample.elemental_compositions_attributes = elemental_compositions.select( + :composition_type, :data, :loading + ).as_json + end # associate to arg collections and creator's All collection collections = ( @@ -389,19 +404,18 @@ def create_subsample user, collection_ids, copy_ea = false, type = nil create_chemical_entry_for_subsample(id, subsample.id, type) unless type.nil? subsample end + # rubocop:enable Metrics/AbcSize # rubocop:enable Metrics/CyclomaticComplexity # rubocop:enable Metrics/PerceivedComplexity # rubocop:enable Style/MethodDefParentheses # rubocop:enable Style/OptionalBooleanParameter - # rubocop:enable Layout/TrailingWhitespace - def reaction_description reactions.first.try(:description) end def auto_set_molfile_to_molecules_molfile - self.molfile = self.molfile.presence || molecule&.molfile + self.molfile = molfile.presence || molecule&.molfile end def validate_stereo(_stereo = {}) @@ -423,23 +437,22 @@ def find_or_create_molecule_based_on_inchikey is_partial = babel_info[:is_partial] molfile_version = babel_info[:version] - if molecule&.inchikey != inchikey || molecule.is_partial != is_partial - self.molecule = Molecule.find_or_create_by_molfile(molfile, babel_info) - end + return unless molecule&.inchikey != inchikey || molecule.is_partial != is_partial + + self.molecule = Molecule.find_or_create_by_molfile(molfile, babel_info) end def find_or_create_fingerprint return unless molecule_id_changed? || molfile_changed? || fingerprint_id.nil? + self.fingerprint_id = Fingerprint.find_or_create_by_molfile(molfile.clone)&.id end def get_svg_path - if self.sample_svg_file.present? - "/images/samples/#{self.sample_svg_file}" - elsif self.molecule&.molecule_svg_file&.present? - "/images/molecules/#{self.molecule.molecule_svg_file}" - else - nil + if sample_svg_file.present? + "/images/samples/#{sample_svg_file}" + elsif molecule&.molecule_svg_file&.present? + "/images/molecules/#{molecule.molecule_svg_file}" end end @@ -455,7 +468,7 @@ def svg_text_path end def loading - self.residues[0] && self.residues[0].custom_info['loading'].to_f + residues[0] && residues[0].custom_info['loading'].to_f end def attach_svg(svg = sample_svg_file) @@ -463,27 +476,27 @@ def attach_svg(svg = sample_svg_file) svg_file_name = "#{SecureRandom.hex(64)}.svg" - if svg =~ /\ATMPFILE[0-9a-f]{64}.svg\z/ + if /\ATMPFILE[0-9a-f]{64}.svg\z/.match?(svg) src = full_svg_path(svg.to_s) return unless File.file?(src) svg = File.read(src) FileUtils.remove(src) end - if svg.start_with?(/\s*\<\?xml/, /\s*\ 20 + if (tag = preferred_label) && tag && tag.length > 20 tag[0, 20] + '...' else tag @@ -601,35 +612,35 @@ def update_inventory_label(inventory_label, collection_id = nil) private def has_collections - if self.collections_samples.blank? - errors.add(:base, 'must have least one collection') - end + return if collections_samples.present? + + errors.add(:base, 'must have least one collection') end - def set_elem_composition_data d_type, d_values, loading = nil + def set_elem_composition_data(d_type, d_values, loading = nil) attrs = { composition_type: d_type, data: d_values, - loading: loading + loading: loading, } - if item = self.elemental_compositions.find{|i| i.composition_type == d_type} + if item = elemental_compositions.find { |i| i.composition_type == d_type } item.assign_attributes attrs else - self.elemental_compositions << ElementalComposition.new(attrs) + elemental_compositions << ElementalComposition.new(attrs) end end def check_molfile_polymer_section return if decoupled - return unless self.molfile.include? 'R#' + return unless molfile.include? 'R#' - lines = self.molfile.lines + lines = molfile.lines polymers = [] m_end_index = nil lines[4..-1].each_with_index do |line, index| polymers << index if line.include? 'R#' - (m_end_index = index) && break if line.match /M\s+END/ + (m_end_index = index) && break if /M\s+END/.match?(line) end reg = /(> [\W\w.\n]+[\d]+)/m @@ -647,10 +658,10 @@ def check_molfile_polymer_section end def set_loading_from_ea - return unless residue = self.residues.first + return unless residue = residues.first # select from cached attributes, don't make a SQL query - return unless el_composition = self.elemental_compositions.find do |i| + return unless el_composition = elemental_compositions.find do |i| i.composition_type == 'found' end @@ -661,7 +672,9 @@ def update_equivalent_for_reactions rel_reaction_id = reactions_samples.first&.reaction_id return unless rel_reaction_id - ReactionsSample.where(reaction_id: rel_reaction_id, type: %w[ReactionsProductSample ReactionsReactantSample ReactionsStartingMaterialSample]).each(&:update_equivalent) + ReactionsSample.where(reaction_id: rel_reaction_id, + type: %w[ReactionsProductSample ReactionsReactantSample + ReactionsStartingMaterialSample]).each(&:update_equivalent) end def update_svg_for_reactions @@ -674,38 +687,38 @@ def update_svg_for_reactions def auto_set_short_label sh_label = self['short_label'] - return if sh_label =~ /solvents?|reactants?/ + return if /solvents?|reactants?/.match?(sh_label) return if short_label && !short_label_changed? if sh_label && (Sample.find_by(short_label: sh_label) || sh_label.eql?('NEW SAMPLE')) if parent && !((parent_label = parent.short_label) =~ /solvents?|reactants?/) self.short_label = "#{parent_label}-#{parent.children.count.to_i.succ}" elsif creator && creator.counters['samples'] - abbr = self.creator.name_abbreviation - self.short_label = "#{abbr}-#{self.creator.counters['samples'].to_i.succ}" + abbr = creator.name_abbreviation + self.short_label = "#{abbr}-#{creator.counters['samples'].to_i.succ}" end elsif !sh_label && creator && creator.counters['samples'] - abbr = self.creator.name_abbreviation - self.short_label = "#{abbr}-#{self.creator.counters['samples'].to_i.succ}" + abbr = creator.name_abbreviation + self.short_label = "#{abbr}-#{creator.counters['samples'].to_i.succ}" end end - # rubocop: enable Metrics/AbcSize # rubocop: enable Metrics/CyclomaticComplexity # rubocop: enable Metrics/PerceivedComplexity def update_counter - return if short_label =~ /solvents?|reactants?/ || self.parent + return if short_label =~ /solvents?|reactants?/ || parent return unless saved_change_to_short_label? - return unless short_label =~ /^#{self.creator.name_abbreviation}-\d+$/ - self.creator.increment_counter 'samples' + return unless /^#{creator.name_abbreviation}-\d+$/.match?(short_label) + + creator.increment_counter 'samples' end def create_root_container - if self.container == nil - self.container = Container.create_root_container - end + return unless container.nil? + + self.container = Container.create_root_container end def assign_molecule_name @@ -721,7 +734,8 @@ def assign_molecule_name end def check_molecule_name - return unless molecule_name_id.blank? + return if molecule_name_id.present? + assign_molecule_name end @@ -732,6 +746,7 @@ def set_boiling_melting_points def update_molecule_name return unless molecule_id_changed? && molecule_name&.molecule_id != molecule_id + assign_molecule_name end diff --git a/app/models/screen.rb b/app/models/screen.rb index 8447ac74b8..e1d1f037ec 100644 --- a/app/models/screen.rb +++ b/app/models/screen.rb @@ -21,6 +21,7 @@ # class Screen < ApplicationRecord + has_logidze acts_as_paranoid include ElementUIStateScopes include PgSearch::Model diff --git a/app/packs/src/apps/mydb/elements/details/VersionsTable.js b/app/packs/src/apps/mydb/elements/details/VersionsTable.js index ea90ddefcb..35267358c9 100644 --- a/app/packs/src/apps/mydb/elements/details/VersionsTable.js +++ b/app/packs/src/apps/mydb/elements/details/VersionsTable.js @@ -1,4 +1,3 @@ -/* eslint-disable react/forbid-prop-types */ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { Pager } from 'react-bootstrap'; @@ -6,6 +5,8 @@ import BootstrapTable from 'react-bootstrap-table-next'; import VersionsFetcher from 'src/fetchers/VersionsFetcher'; import VersionsTableTime from 'src/apps/mydb/elements/details/VersionsTableTime'; import VersionsTableChanges from 'src/apps/mydb/elements/details/VersionsTableChanges'; +import { elementShowOrNew } from 'src/utilities/routesUtils'; +import DetailActions from 'src/stores/alt/actions/DetailActions'; export default class VersionsTable extends Component { constructor(props) { @@ -16,8 +17,6 @@ export default class VersionsTable extends Component { page: 1, pages: 1, }; - - this.updateParent = this.updateParent.bind(this); } componentDidMount() { @@ -36,11 +35,29 @@ export default class VersionsTable extends Component { } }; + reloadEntity = () => { + const { id, type, element } = this.props; + const entityType = type.slice(0, -1); + + if (entityType === 'reaction') { + DetailActions.close(element, true); + } + + elementShowOrNew({ + type: entityType, + params: { [`${entityType}ID`]: id } + }); + }; + + handleRevert = (changes) => VersionsFetcher.revert(changes) + .then(() => this.fetchVersions()) + .then(() => this.reloadEntity()); + fetchVersions() { const { type, id } = this.props; const { page } = this.state; - VersionsFetcher.fetch({ + return VersionsFetcher.fetch({ type, id, page }).then((result) => { if (!result) return false; @@ -53,13 +70,8 @@ export default class VersionsTable extends Component { }); } - updateParent(name, kind, value) { - this.props.updateGrandparent(name, kind, value); - } - render() { const { versions, page, pages } = this.state; - const { type } = this.props; const pagination = () => ( @@ -69,7 +81,7 @@ export default class VersionsTable extends Component { onClick={() => this.handlePagerClick('prev')} disabled={page >= pages} > - ← Previous Page + ← Older Versions this.handlePagerClick('next')} disabled={page <= 1} > - Next Page → + Newer Versions → ); const columns = [ + { + dataField: 'caret', + text: '', + isDummyField: true, + // eslint-disable-next-line react/no-unstable-nested-components + formatter: () => , + }, { dataField: 'id', text: '#', }, { dataField: 'createdAt', - text: 'Created', + text: 'Modified on', // eslint-disable-next-line react/no-unstable-nested-components formatter: (cell) => ( ), }, - { - dataField: 'klass', - text: 'Entity', - }, - { - dataField: 'name', - text: 'Name', - }, { dataField: 'userName', text: 'Author', @@ -112,8 +123,12 @@ export default class VersionsTable extends Component { const expandRow = { onlyOneExpanding: true, parentClassName: 'active', - renderer: row => ( - + renderer: (row) => ( + ), }; @@ -121,18 +136,24 @@ export default class VersionsTable extends Component { <> +
    +
  • before
  • +
  • after
  • +
  • current value
  • +
{pagination()} @@ -143,4 +164,9 @@ export default class VersionsTable extends Component { VersionsTable.propTypes = { type: PropTypes.string.isRequired, id: PropTypes.number.isRequired, + element: PropTypes.object, +}; + +VersionsTable.defaultProps = { + element: {} }; diff --git a/app/packs/src/apps/mydb/elements/details/VersionsTableChanges.js b/app/packs/src/apps/mydb/elements/details/VersionsTableChanges.js index 6ffd59cf24..0a2f0f24e9 100644 --- a/app/packs/src/apps/mydb/elements/details/VersionsTableChanges.js +++ b/app/packs/src/apps/mydb/elements/details/VersionsTableChanges.js @@ -1,204 +1,52 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Row, Col, FormControl, Button, Table } from 'react-bootstrap'; -import moment from 'moment'; -import QuillViewer from 'src/components/QuillViewer'; -import SVG from 'react-inlinesvg'; -import ReactJson from 'react-json-view'; -import EditableCell from './lineChart/EditableCell' - -const SolventDetails = ({ solvent }) => { - if (!solvent) { - return (<>) - } - - return ( - - - - - - - - - - - - ) -}; +import VersionsTableFields from 'src/apps/mydb/elements/details/VersionsTableFields'; +import VersionsTableModal from 'src/apps/mydb/elements/details/VersionsTableModal'; +import { Alert } from 'react-bootstrap'; function VersionsTableChanges(props) { - const { changes } = props; - - const date = (input) => ( - input ? moment(input).format('YYYY-MM-DD HH:mm') : '' - ); + const { + id, changes, handleRevert + } = props; - const quill = (input) => ( - input ? : '' - ); + const revertable = () => { + let filteredFields = []; - const numrange = input => ( - input ? `${input.slice(1, -1).split(',')[0]} - ${input.slice(1, -1).split(',')[1]}`: '' - ); - - const treeselect = (input) => ( - (input || '').split(' | ', 2)[1] || input - ); + changes.forEach(({ fields }) => { + filteredFields = filteredFields.concat( + fields.filter((field) => (field.currentValue !== field.oldValue && field.revert.length > 0)) + ); + }); - const svg = input => ( - input ? : '' - ); - - const solvent = input => { - let contents = [] - if (input) { - input.forEach((solv) => { - contents.push(( - - )) - }) - } - - return input ? (
- - - - - - - - - - {contents.map(item => item)} - -
LabelRatio -
-
) : <>; + return filteredFields.length > 0; }; - const temperature = input => { - if (input) { - var rows = [] - var data = input.data; - for (let i = 0; i < data.length; i = i + 1) { - let row = ( - - - - - -
-
- -
-
- - - ) - rows.push(row) - } - - return input ? (
- Temperature: {input.userText} {input.valueUnit} - - - - - - - - - {rows} - -
Time (hh:mm:ss) Temperature ({input.valueUnit})
-
) : <>; - } - } - - const handleRevert = (name, kind, value) => { - props.updateParent(name, kind, value); - } - - const renderRevertButton = (name, kind, oldValue) => { - if (['location', 'name', 'external_label', 'real_amount_value', 'description', 'solvent', - 'real_amount_unit', 'showed_name', 'target_amount_unit', 'target_amount_value', 'boiling_point', - 'melting_point', 'short_label', 'purity', 'density', 'molarity_value', 'data', 'temperature'].includes(name)) { - return (); - } - } - - const formatValue = (kind, value) => { - const formatters = { - date, - quill, - numrange, - treeselect, - svg, - solvent, - temperature, - string: () => JSON.stringify(value), - }; - - return ( - formatters[kind] || formatters.string - )(value); - }; + const change = changes.map(({ name, fields }, index) => ( + // eslint-disable-next-line react/no-array-index-key + +
    + {name.map((item) => ( +
  1. + {item} +
  2. + ))} +
+ +
+ )); return ( <> - { - changes.map(({ - name, label, kind, oldValue, newValue - }) => ( -
- - - {label} - {renderRevertButton(name, kind, oldValue)} - - - - - {formatValue(kind, oldValue)} - - - {formatValue(kind, newValue)} - - -
- )) - } + {change} + {revertable() ? : You cannot undo these changes. Either the changes are up to date, it is the first version or all changes are irreversible.} ); } VersionsTableChanges.propTypes = { + id: PropTypes.number.isRequired, changes: PropTypes.arrayOf(PropTypes.object).isRequired, + handleRevert: PropTypes.func.isRequired, }; export default VersionsTableChanges; diff --git a/app/packs/src/apps/mydb/elements/details/VersionsTableFields.js b/app/packs/src/apps/mydb/elements/details/VersionsTableFields.js new file mode 100644 index 0000000000..5496e61e96 --- /dev/null +++ b/app/packs/src/apps/mydb/elements/details/VersionsTableFields.js @@ -0,0 +1,352 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Row, Col, FormControl, Table +} from 'react-bootstrap'; +import moment from 'moment'; +import SvgWithPopover from 'src/components/common/SvgWithPopover'; +import QuillViewer from 'src/components/QuillViewer'; + +function SolventDetails({ solvent }) { + if (!solvent) { + return (null); + } + + return ( + + + + + + + + + + + ); +} + +SolventDetails.propTypes = { + solvent: PropTypes.shape({ + label: PropTypes.string.isRequired, + ratio: PropTypes.string.isRequired, + }).isRequired, +}; + +function VersionsTableFields(props) { + const { + fields, renderRevertView, handleRevertFieldToggle, revertSelectedFields, revertGroupIndex + } = props; + + const date = (input) => ( + input ? moment(input).format('YYYY-MM-DD HH:mm') : '' + ); + + const quill = (input) => ( + input ? : '' + ); + + const numrange = (input) => ( + input ? `${input.slice(1, -1).split(',')[0]} - ${input.slice(1, -1).split(',')[1]}` : '' + ); + + const treeselect = (input) => ( + (input || '').split(' | ', 2)[1] || input + ); + + const image = (input, title) => (input ? ( + + ) : ( + '' + )); + + const solvent = (input) => { + const contents = []; + if (input) { + input.forEach((solv, index) => { + contents.push(( + + )); + }); + } + + return input ? ( +
+ + + + + + + + + {contents.map((item) => item)} + +
+ LabelRatio +
+
+ ) : null; + }; + + const temperature = (input) => (input ? ( +
+

+ {input.userText} + {' '} + {input.valueUnit} +

+ {input.data.length > 0 && ( + + + + + + + + + {input.data.map(({ time, value }, index) => ( + // eslint-disable-next-line react/no-array-index-key + + + + + ))} + +
Time (hh:mm:ss) + {' '} + Temperature ( + {input.valueUnit} + ) + {' '} +
{time}{value}
+ )} +
+ ) : ( + null + )); + + const table = (input) => { + const { columns, rows } = input; + + // eslint-disable-next-line react/no-array-index-key + const th = columns.map((column, index) => {column.headerName}); + + const tr = rows.map((row, i) => { + const td = columns.map((column, j) => ( + // eslint-disable-next-line react/no-array-index-key + + {row[column.colId]} + + )); + + return ( + // eslint-disable-next-line react/no-array-index-key + + {td} + + ); + }); + + return ( + + + + {th} + + + + {tr} + +
+ ); + }; + + const string = (input) => ( + input + .toString() + .split('\n') + .map((line, index) => ( + // eslint-disable-next-line react/no-array-index-key + + {line} +
+
+ )) + ); + + const boolean = (input) => ( + + ); + + const json = (input) => { + const array = Array.isArray(input) ? input : [input]; + const formatters = { + quill, + image, + table, + string, + }; + + return array.map((jsonObject, i) => ( + // eslint-disable-next-line react/no-array-index-key +
+ {jsonObject.map((object, j) => { + const { title, content, kind } = object; + + return ( + // eslint-disable-next-line react/no-array-index-key + + {title} +
+
+ {(formatters[kind] || formatters.string)(content, title)} +
+
+ ); + })} +
+ )); + }; + + const formatValue = (kind, value, label) => { + if (value == null) return ''; + + const formatters = { + date, + quill, + numrange, + treeselect, + solvent, + temperature, + string, + image, + boolean, + json, + }; + + return ( + formatters[kind] || formatters.string + )(value, label); + }; + + return ( + <> + { + fields.filter((field) => (field.kind !== 'hidden')).map((field, index) => { + if (renderRevertView) { + const key = `${revertGroupIndex}${index}`; + return ( + // eslint-disable-next-line react/no-array-index-key + + + + + + {formatValue(field.kind, field.currentValue, field.label)} + + + {formatValue(field.kind, field.oldValue, field.label)} + + + ); + } + return ( + // eslint-disable-next-line react/no-array-index-key + + + {field.label} + + + {formatValue(field.kind, field.oldValue, field.label)} + + + {formatValue(field.kind, field.newValue, field.label)} + + + {formatValue(field.kind, field.currentValue, field.label)} + + + ); + }) + } + + ); +} + +VersionsTableFields.defaultProps = { + handleRevertFieldToggle: () => {}, + revertSelectedFields: [], + revertGroupIndex: 0, +}; + +VersionsTableFields.propTypes = { + fields: PropTypes.arrayOf(PropTypes.object).isRequired, + renderRevertView: PropTypes.bool.isRequired, + handleRevertFieldToggle: PropTypes.func, + revertSelectedFields: PropTypes.arrayOf(PropTypes.string), + revertGroupIndex: PropTypes.number, +}; + +export default VersionsTableFields; diff --git a/app/packs/src/apps/mydb/elements/details/VersionsTableModal.js b/app/packs/src/apps/mydb/elements/details/VersionsTableModal.js new file mode 100644 index 0000000000..bb7d3482d8 --- /dev/null +++ b/app/packs/src/apps/mydb/elements/details/VersionsTableModal.js @@ -0,0 +1,207 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Row, Col, Modal, Button, Alert +} from 'react-bootstrap'; +import VersionsTableFields from 'src/apps/mydb/elements/details/VersionsTableFields'; + +function VersionsTableModal(props) { + const { name, changes, handleRevert } = props; + const [show, setshow] = useState(false); + const [selectedFields, setSelectedFields] = useState([]); + const handleClose = () => setshow(false); + const handleShow = () => setshow(true); + + const fieldIndexDictionary = () => { + const dictionary = {}; + + changes.map(({ fields }, groupIndex) => fields + .filter( + (field) => field.kind !== 'hidden' + && field.currentValue !== field.oldValue + && field.revert.length > 0 + ) + .forEach((field, index) => { + dictionary[`${groupIndex}${index}`] = field; + })); + + return dictionary; + }; + + const allFieldIndexes = () => Object.keys(fieldIndexDictionary()); + + const toggleAllFieldsCheckbox = (element) => { + if (element.target.checked) { + setSelectedFields(allFieldIndexes()); + } else { + setSelectedFields([]); + } + }; + + const toggleFieldCheckbox = (element) => { + const selectedField = fieldIndexDictionary()[element]; + const affectedIndexes = []; + changes.forEach(({ fields }, groupIndex) => { + if (fields.includes(selectedField)) { + fields + .filter( + (field) => field.kind !== 'hidden' + && field.currentValue !== field.oldValue + && field.revert.length > 0 + ) + .forEach((field, index) => { + if (selectedField.revert.includes(field.name)) { + affectedIndexes.push(`${groupIndex}${index}`); + } + }); + } + }); + + if (selectedFields.includes(element)) { + setSelectedFields( + selectedFields.filter((index) => !affectedIndexes.includes(index)) + ); + } else { + setSelectedFields(selectedFields.concat(affectedIndexes)); + } + }; + + const change = changes.map((historyChange, index) => { + const filteredFields = historyChange.fields.filter( + (field) => field.currentValue !== field.oldValue && field.revert.length > 0 + ); + + if (filteredFields.length > 0) { + return ( + // eslint-disable-next-line react/no-array-index-key + +
    + {historyChange.name.map((item) => ( +
  1. + {item} +
  2. + ))} +
+ +
+ ); + } + + return ''; + }); + + const reversibleChanges = () => { + const result = []; + const dictionary = fieldIndexDictionary(); + + changes.forEach((historyChange) => { + const affectedChangeFields = []; + + selectedFields.forEach((fieldIndex) => { + const selectedField = dictionary[fieldIndex]; + + if (historyChange.fields.includes(selectedField)) { + historyChange.fields + .filter((f) => selectedField.revert.includes(f.name)) + .forEach((field) => { + if (!affectedChangeFields.includes(field)) { + affectedChangeFields.push(field); + } + }); + } + }); + + if (affectedChangeFields.length > 0) { + result.push({ + db_id: historyChange.db_id, + klass_name: historyChange.klass_name, + fields: affectedChangeFields.map((field) => ({ + value: field.revertibleValue, + name: field.name, + })), + }); + } + }); + + console.log(result); + + return result; + }; + + return ( + <> + + + + {name} + +
+ + + + + +
    +
  • + current value +
  • +
  • + before/new value +
  • +
+ +
+
+ +
{change}
+ + Only reversible changes are are shown. + +
+ + + + +
+
+ + ); +} + +VersionsTableModal.propTypes = { + name: PropTypes.string.isRequired, + changes: PropTypes.arrayOf(PropTypes.object).isRequired, + handleRevert: PropTypes.func.isRequired, +}; + +export default VersionsTableModal; diff --git a/app/packs/src/apps/mydb/elements/details/VersionsTableTime.js b/app/packs/src/apps/mydb/elements/details/VersionsTableTime.js index bee14eaf0d..e0b3b034f9 100644 --- a/app/packs/src/apps/mydb/elements/details/VersionsTableTime.js +++ b/app/packs/src/apps/mydb/elements/details/VersionsTableTime.js @@ -6,9 +6,13 @@ import moment from 'moment'; function VersionsTableTime(props) { const { dateTime } = props; + const formattedTime = () => moment(dateTime).format('YYYY-MM-DD HH:mm:ss'); + + const timeFromNow = () => moment(dateTime).fromNow(); + const renderTooltip = () => ( - {moment(dateTime).format('YYYY-MM-DD HH:mm')} + {timeFromNow()} ); @@ -17,7 +21,7 @@ function VersionsTableTime(props) { placement="top" overlay={renderTooltip(dateTime)} > - {moment(dateTime).fromNow()} + {formattedTime()} ); } diff --git a/app/packs/src/fetchers/VersionsFetcher.js b/app/packs/src/fetchers/VersionsFetcher.js index 8ada77e13d..80e03ba146 100644 --- a/app/packs/src/fetchers/VersionsFetcher.js +++ b/app/packs/src/fetchers/VersionsFetcher.js @@ -1,5 +1,5 @@ import 'whatwg-fetch'; -import Version from 'src/models/Version'; +import HistoryVersion from 'src/models/HistoryVersion'; export default class VersionsFetcher { static fetch({ @@ -15,7 +15,7 @@ export default class VersionsFetcher { credentials: 'same-origin' }).then((response) => ( response.json().then((json) => ({ - elements: json.versions.map((v) => (new Version(v))), + elements: json.versions.map((v) => (new HistoryVersion(v))), totalElements: parseInt(response.headers.get('X-Total'), 10), page: parseInt(response.headers.get('X-Page'), 10), pages: parseInt(response.headers.get('X-Total-Pages'), 10), @@ -23,4 +23,18 @@ export default class VersionsFetcher { })) )).catch((errorMessage) => { console.log(errorMessage); }); } + + static revert(json) { + return fetch('/api/v1/versions/revert', { + credentials: 'same-origin', + method: 'post', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + changes: json + }) + }); + } } diff --git a/app/packs/src/models/Change.js b/app/packs/src/models/Change.js deleted file mode 100644 index ea5ae603ac..0000000000 --- a/app/packs/src/models/Change.js +++ /dev/null @@ -1,11 +0,0 @@ -export default class Change { - constructor([name, { - l, k, o, n, - }]) { - this.name = name; - this.label = l; - this.kind = k; - this.oldValue = o; - this.newValue = n; - } -} diff --git a/app/packs/src/models/HistoryChange.js b/app/packs/src/models/HistoryChange.js new file mode 100644 index 0000000000..97000f5332 --- /dev/null +++ b/app/packs/src/models/HistoryChange.js @@ -0,0 +1,13 @@ +/* eslint-disable camelcase */ +import HistoryField from 'src/models/HistoryField'; + +export default class HistoryChange { + constructor({ + name, fields, db_id, klass_name, + }) { + this.name = name; + this.db_id = db_id; + this.klass_name = klass_name; + this.fields = Object.entries(fields).map((field) => (new HistoryField(field))); + } +} diff --git a/app/packs/src/models/HistoryField.js b/app/packs/src/models/HistoryField.js new file mode 100644 index 0000000000..49df4f7f18 --- /dev/null +++ b/app/packs/src/models/HistoryField.js @@ -0,0 +1,15 @@ +/* eslint-disable camelcase */ +export default class HistoryField { + constructor([name, { + label, kind, old_value, new_value, current_value, revert, revertible_value + }]) { + this.name = name; + this.label = label; + this.kind = kind; + this.oldValue = old_value; + this.newValue = new_value; + this.currentValue = current_value; + this.revert = revert; + this.revertibleValue = revertible_value; + } +} diff --git a/app/packs/src/models/HistoryVersion.js b/app/packs/src/models/HistoryVersion.js new file mode 100644 index 0000000000..6acb4c1491 --- /dev/null +++ b/app/packs/src/models/HistoryVersion.js @@ -0,0 +1,12 @@ +import HistoryChange from 'src/models/HistoryChange'; + +export default class HistoryVersion { + constructor({ + id, time, user, changes, + }) { + this.id = id; + this.createdAt = new Date(time); + this.userName = user; + this.changes = changes.map((change) => (new HistoryChange(change))); + } +} diff --git a/app/packs/src/models/ResearchPlan.js b/app/packs/src/models/ResearchPlan.js index f89034b06a..853f5e4969 100644 --- a/app/packs/src/models/ResearchPlan.js +++ b/app/packs/src/models/ResearchPlan.js @@ -204,6 +204,10 @@ export default class ResearchPlan extends Element { (attachmentInResearchPlan) => attachmentInResearchPlan.identifier ); + if (!attachmentsToAdd) { + return + } + attachmentsToAdd .filter((attachment) => idsOfAttachmentsInResearchPlan.includes(attachment.identifier)) .map((source) => { @@ -233,11 +237,14 @@ export default class ResearchPlan extends Element { removeFieldFromBody(fieldId) { const index = this.body.findIndex((field) => field.id === fieldId); if (index === -1) { return; } - let { identifier } = this.body[index].value; - if (!identifier) { - identifier = this.body[index].value.public_name; + + if (this.body[index].value) { + let { identifier } = this.body[index].value; + if (!identifier) { + identifier = this.body[index].value.public_name; + } + this.markAttachmentAsDeleted(identifier); } - this.markAttachmentAsDeleted(identifier); this.body.splice(index, 1); this.changed = true; } diff --git a/app/packs/src/models/Version.js b/app/packs/src/models/Version.js deleted file mode 100644 index bd2d074ec6..0000000000 --- a/app/packs/src/models/Version.js +++ /dev/null @@ -1,16 +0,0 @@ -import Change from 'src/models/Change'; - -export default class Version { - constructor({ - v, k, n, t, u, c, - }) { - const changes = Object.entries(c).map((change) => (new Change(change))); - - this.id = v; - this.klass = k; - this.name = n; - this.createdAt = new Date(t); - this.userName = u; - this.changes = changes; - } -} diff --git a/app/packs/src/stores/alt/actions/ElementActions.js b/app/packs/src/stores/alt/actions/ElementActions.js index 4541c36d64..82b29346cd 100644 --- a/app/packs/src/stores/alt/actions/ElementActions.js +++ b/app/packs/src/stores/alt/actions/ElementActions.js @@ -369,19 +369,19 @@ class ElementActions { createSample(params, closeView = false) { return (dispatch) => { - SamplesFetcher.create(params) - .then((result) => { - dispatch({ element: result, closeView }) - }); + return SamplesFetcher.create(params) + .then((result) => { + dispatch({ element: result, closeView }) + }); }; } createSampleForReaction(sample, reaction, materialGroup) { return (dispatch) => { - SamplesFetcher.create(sample) - .then((newSample) => { - dispatch({ newSample, reaction, materialGroup }) - }); + return SamplesFetcher.create(sample) + .then((newSample) => { + dispatch({ newSample, reaction, materialGroup }) + }); }; } @@ -425,25 +425,25 @@ class ElementActions { updateSampleForReaction(sample, reaction, closeView = true) { return (dispatch) => { - SamplesFetcher.update(sample) - .then((newSample) => { - reaction.updateMaterial(newSample); - reaction.changed = true; - dispatch({ reaction, sample: newSample, closeView }) - }).catch((errorMessage) => { - console.log(errorMessage); - }); + return SamplesFetcher.update(sample) + .then((newSample) => { + reaction.updateMaterial(newSample); + reaction.changed = true; + dispatch({ reaction, sample: newSample, closeView }) + }).catch((errorMessage) => { + console.log(errorMessage); + }); }; } updateSample(params, closeView = false) { return (dispatch) => { - SamplesFetcher.update(params) - .then((result) => { - dispatch({ element: result, closeView }) - }).catch((errorMessage) => { - console.log(errorMessage); - }); + return SamplesFetcher.update(params) + .then((result) => { + dispatch({ element: result, closeView }) + }).catch((errorMessage) => { + console.log(errorMessage); + }); }; } @@ -604,21 +604,21 @@ class ElementActions { createReaction(params) { return (dispatch) => { - ReactionsFetcher.create(params) - .then((result) => { - dispatch(result) - }); + return ReactionsFetcher.create(params) + .then((result) => { + dispatch(result) + }); }; } updateReaction(params, closeView = false) { return (dispatch) => { - ReactionsFetcher.update(params) - .then((result) => { - dispatch({ element: result, closeView }) - }).catch((errorMessage) => { - console.log(errorMessage); - }); + return ReactionsFetcher.update(params) + .then((result) => { + dispatch({ element: result, closeView }) + }).catch((errorMessage) => { + console.log(errorMessage); + }); }; } @@ -748,12 +748,12 @@ class ElementActions { updateSampleForWellplate(sample, wellplate) { return (dispatch) => { - SamplesFetcher.update(sample) - .then((newSample) => { - dispatch(wellplate) - }).catch((errorMessage) => { - console.log(errorMessage); - }); + return SamplesFetcher.update(sample) + .then((newSample) => { + dispatch(wellplate) + }).catch((errorMessage) => { + console.log(errorMessage); + }); }; } diff --git a/app/services/versioning/fetcher.rb b/app/services/versioning/fetcher.rb new file mode 100644 index 0000000000..4b3a2139c5 --- /dev/null +++ b/app/services/versioning/fetcher.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Versioning::Fetcher + include ActiveModel::Model + + attr_accessor :record + + def self.call(record) + new(record: record).call + end + + def call + Versioning::Merger.call(versions: versions) + end + + private + + def versions + case record + when ::Sample + Versioning::Fetchers::SampleFetcher.call(sample: record) + when ::Reaction + Versioning::Fetchers::ReactionFetcher.call(reaction: record) + when ::ResearchPlan + Versioning::Fetchers::ResearchPlanFetcher.call(research_plan: record) + when ::Screen + Versioning::Fetchers::ScreenFetcher.call(screen: record) + end + end +end diff --git a/app/services/versioning/fetchers/reaction_fetcher.rb b/app/services/versioning/fetchers/reaction_fetcher.rb new file mode 100644 index 0000000000..3e30e090bc --- /dev/null +++ b/app/services/versioning/fetchers/reaction_fetcher.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Versioning::Fetchers::ReactionFetcher + include ActiveModel::Model + + attr_accessor :reaction + + def self.call(**args) + new(**args).call + end + + def call + versions = Versioning::Serializers::ReactionSerializer.call(reaction) + + reaction.reactions_samples.with_deleted.with_log_data.each do |reactions_sample| + sample = Sample.with_log_data.with_deleted.find(reactions_sample.sample_id) + + sample_type = label_for_sample_type(reactions_sample.type) + sample_name = (!sample_type.in?(%w[Reactant Solvent]) && sample.short_label).presence || sample.name.presence || sample.external_label.presence || sample.molecule.iupac_name || '-' + + versions += Versioning::Serializers::ReactionsSampleSerializer.call(reactions_sample, ["#{sample_type}: #{sample_name}"]) + versions += Versioning::Serializers::SampleSerializer.call(sample, ["#{sample_type}: #{sample_name}", 'Properties']) + + versions += sample.residues.with_log_data.flat_map do |residue| + Versioning::Serializers::ResidueSerializer.call(residue, ["#{sample_type}: #{sample_name} Polymer section"]) + end + + versions += sample.elemental_compositions.with_log_data.flat_map do |elemental_composition| + Versioning::Serializers::ElementalCompositionSerializer.call(elemental_composition, ["#{sample_type}: #{sample_name}", 'Elemental composition']) + end + + analyses_container = sample.container.children.where(container_type: :analyses).first + analyses_container.children.where(container_type: :analysis).with_deleted.with_log_data.each do |analysis| + versions += Versioning::Serializers::ContainerSerializer.call(analysis, ["#{sample_type}: #{sample_name}", "Analysis: #{analysis.name}"]) + + analysis.children.with_deleted.with_log_data.each do |dataset| + versions += Versioning::Serializers::ContainerSerializer.call(dataset, ["#{sample_type}: #{sample_name}", "Analysis: #{analysis.name}", "Dataset: #{dataset.name}"]) + + versions += dataset.attachments.with_log_data.flat_map do |attachment| + Versioning::Serializers::AttachmentSerializer.call(attachment, ["#{sample_type}: #{sample_name}", "Analysis: #{analysis.name}", "Dataset: #{dataset.name}", "Attachment: #{attachment.filename}"]) + end + end + end + end + + versions + end + + private + + def label_for_sample_type(type) + { + ReactionsStartingMaterialSample: 'Starting material', + ReactionsReactantSample: 'Reactant', + ReactionsSolventSample: 'Solvent', + ReactionsPurificationSolventSample: 'Purification solvent', + ReactionsProductSample: 'Product', + }[type.to_sym] + end +end diff --git a/app/services/versioning/fetchers/research_plan_fetcher.rb b/app/services/versioning/fetchers/research_plan_fetcher.rb new file mode 100644 index 0000000000..fea8f7eb8c --- /dev/null +++ b/app/services/versioning/fetchers/research_plan_fetcher.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class Versioning::Fetchers::ResearchPlanFetcher + include ActiveModel::Model + + attr_accessor :research_plan, :prefix + + def self.call(**args) + new(**args).call + end + + def call + research_plan_name = prefix.present? ? [prefix] : ['Research plan'] + versions = Versioning::Serializers::ResearchPlanSerializer.call(research_plan, research_plan_name) + + research_plan_metadata = research_plan.research_plan_metadata + if research_plan_metadata + research_plan_metadata.reload_log_data + research_plan_metadata_name = prefix.present? ? [prefix, 'Metadata'] : ['Metadata'] + versions += Versioning::Serializers::ResearchPlanMetadataSerializer.call(research_plan_metadata, research_plan_metadata_name) + end + + research_plan.attachments.with_log_data.each do |attachment| + versions += Versioning::Serializers::AttachmentSerializer.call(attachment, [prefix, "Attachment: #{attachment.filename}"].compact) + end + + analyses_container = research_plan.container.children.where(container_type: :analyses).first + analyses_container.children.where(container_type: :analysis).with_deleted.with_log_data.each do |analysis| + versions += Versioning::Serializers::ContainerSerializer.call(analysis, [prefix, "Analysis: #{analysis.name}"].compact) + + analysis.children.with_deleted.with_log_data.each do |dataset| + versions += Versioning::Serializers::ContainerSerializer.call(dataset, [prefix, "Analysis: #{analysis.name}", "Dataset: #{dataset.name}"].compact) + + dataset.attachments.with_log_data.each do |attachment| + versions += Versioning::Serializers::AttachmentSerializer.call(attachment, [prefix, "Analysis: #{analysis.name}", "Dataset: #{dataset.name}", "Attachment: #{attachment.filename}"].compact) + end + end + end + + versions + end +end diff --git a/app/services/versioning/fetchers/sample_fetcher.rb b/app/services/versioning/fetchers/sample_fetcher.rb new file mode 100644 index 0000000000..2afba2eef5 --- /dev/null +++ b/app/services/versioning/fetchers/sample_fetcher.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class Versioning::Fetchers::SampleFetcher + include ActiveModel::Model + + attr_accessor :sample + + def self.call(**args) + new(**args).call + end + + def call + versions = Versioning::Serializers::SampleSerializer.call(sample) + versions += sample.residues.with_log_data.flat_map do |residue| + Versioning::Serializers::ResidueSerializer.call(residue) + end + versions += sample.elemental_compositions.with_log_data.flat_map do |elemental_composition| + Versioning::Serializers::ElementalCompositionSerializer.call(elemental_composition) + end + + analyses_container = sample.container.children.where(container_type: :analyses).first + analyses_container.children.where(container_type: :analysis).with_deleted.with_log_data.each do |analysis| + versions += Versioning::Serializers::ContainerSerializer.call(analysis, ["Analysis: #{analysis.name}"]) + + analysis.children.with_deleted.with_log_data.each do |dataset| + versions += Versioning::Serializers::ContainerSerializer.call(dataset, ["Analysis: #{analysis.name}", "Dataset: #{dataset.name}"]) + + versions += dataset.attachments.with_log_data.flat_map do |attachment| + Versioning::Serializers::AttachmentSerializer.call(attachment, ["Analysis: #{analysis.name}", "Dataset: #{dataset.name}", "Attachment: #{attachment.filename}"]) + end + end + end + + versions + end +end diff --git a/app/services/versioning/fetchers/screen_fetcher.rb b/app/services/versioning/fetchers/screen_fetcher.rb new file mode 100644 index 0000000000..c2d657c387 --- /dev/null +++ b/app/services/versioning/fetchers/screen_fetcher.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Versioning::Fetchers::ScreenFetcher + include ActiveModel::Model + + attr_accessor :screen + + def self.call(**args) + new(**args).call + end + + def call + versions = Versioning::Serializers::ScreenSerializer.call(screen) + + analyses_container = screen.container.children.where(container_type: :analyses).first + analyses_container.children.where(container_type: :analysis).with_deleted.with_log_data.each do |analysis| + versions += Versioning::Serializers::ContainerSerializer.call(analysis, ["Analysis: #{analysis.name}"]) + + analysis.children.with_deleted.with_log_data.each do |dataset| + versions += Versioning::Serializers::ContainerSerializer.call(dataset, ["Analysis: #{analysis.name}", "Dataset: #{dataset.name}"]) + + dataset.attachments.with_log_data.each do |attachment| + versions += Versioning::Serializers::AttachmentSerializer.call(attachment, ["Analysis: #{analysis.name}", "Dataset: #{dataset.name}", "Attachment: #{attachment.filename}"]) + end + end + end + + screen.research_plans.each do |research_plan| + versions += Versioning::Fetchers::ResearchPlanFetcher.call(research_plan: research_plan, prefix: "Research Plan: #{research_plan.name}") + end + + versions + end +end diff --git a/app/services/versioning/merger.rb b/app/services/versioning/merger.rb new file mode 100644 index 0000000000..5bed545791 --- /dev/null +++ b/app/services/versioning/merger.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Versioning::Merger + include ActiveModel::Model + + attr_accessor :versions + + def self.call(**args) + new(**args).call + end + + def call + # sort versions with the latest changes in the first place + versions.sort_by! { |version| -version[:time].to_i } + + grouped_versions = versions.group_by { |version| version[:uuid] } + + merged_versions = [] + grouped_versions.each_with_index do |(_, v), index| + changes = v.map do |v| + { + db_id: v[:db_id], + klass_name: v[:klass_name], + name: v[:name], + fields: v[:changes], + } + end + + merged_versions << { + id: grouped_versions.length - index, + time: v.dig(0, :time), + user: v.dig(0, :user), + changes: changes, + } + end + merged_versions + end +end diff --git a/app/services/versioning/reverter.rb b/app/services/versioning/reverter.rb new file mode 100644 index 0000000000..a08734142e --- /dev/null +++ b/app/services/versioning/reverter.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class Versioning::Reverter + include ActiveModel::Model + + attr_accessor :changes + + def self.call(changes) + new(changes: changes).call + end + + def call + changes.each do |change| + case change['klass_name'] + when 'Attachment' + Versioning::Reverters::AttachmentReverter.call(change) + when 'Container' + Versioning::Reverters::ContainerReverter.call(change) + when 'ElementalComposition' + Versioning::Reverters::ElementalCompositionReverter.call(change) + when 'Reaction' + Versioning::Reverters::ReactionReverter.call(change) + when 'ReactionsSample' + Versioning::Reverters::ReactionsSampleReverter.call(change) + when 'ResearchPlan' + Versioning::Reverters::ResearchPlanReverter.call(change) + when 'ResearchPlanMetadata' + Versioning::Reverters::ResearchPlanMetadataReverter.call(change) + when 'ResearchPlan' + Versioning::Reverters::ResearchPlanReverter.call(change) + when 'Residue' + Versioning::Reverters::ResidueReverter.call(change) + when 'Sample' + Versioning::Reverters::SampleReverter.call(change) + when 'Screen' + Versioning::Reverters::ScreenReverter.call(change) + end + end + end +end diff --git a/app/services/versioning/reverters/attachment_reverter.rb b/app/services/versioning/reverters/attachment_reverter.rb new file mode 100644 index 0000000000..66f90bcb5c --- /dev/null +++ b/app/services/versioning/reverters/attachment_reverter.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Versioning::Reverters::AttachmentReverter < Versioning::Reverters::BaseReverter + def self.scope + Attachment + end +end diff --git a/app/services/versioning/reverters/base_reverter.rb b/app/services/versioning/reverters/base_reverter.rb new file mode 100644 index 0000000000..79540392ba --- /dev/null +++ b/app/services/versioning/reverters/base_reverter.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class Versioning::Reverters::BaseReverter + include ActiveModel::Model + + attr_accessor :record, :fields + + class << self + def call(change) + new( + record: scope.find(change['db_id']), + fields: change['fields'], + ).call + end + end + + def call + attributes = { updated_at: Time.current } + + fields.each do |field| + name = field['name'] + value = field['value'] + + field_definition = field_definitions[name] + + if field_definition + attributes[name] = field_definition.call(value) + elsif name.include?('.') + name, key = name.split('.') + + attributes[name] ||= record[name] + attributes[name][key] = value + else + attributes[name] = value + end + end + record.update_columns(attributes) # rubocop:disable Rails/SkipsModelValidations + end + + def field_definitions + {} + end +end diff --git a/app/services/versioning/reverters/container_reverter.rb b/app/services/versioning/reverters/container_reverter.rb new file mode 100644 index 0000000000..19a6e3d9f6 --- /dev/null +++ b/app/services/versioning/reverters/container_reverter.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class Versioning::Reverters::ContainerReverter < Versioning::Reverters::BaseReverter + def self.scope + Container.with_deleted + end + + def field_definitions + { + deleted_at: restore_container, + }.with_indifferent_access + end + + private + + def restore_container + lambda do |value| + return value if value.present? + + case record.container_type + when 'analysis' + generations = 2 + ancestor_id = record.parent.parent_id + when 'dataset' + analysis = Container.with_deleted.find(record.parent_id) + # if analysis.deleted? + # hierarchy = ContainerHierarchy.find_or_initialize_by( + # ancestor_id: analysis.parent.parent_id, + # descendant_id: analysis.id, + # generations: 2, + # ) + # hierarchy.save(validate: false) + # analysis.update_columns(deleted_at: nil) # rubocop:disable Rails/SkipsModelValidations + # end + generations = 3 + ancestor_id = analysis.parent.parent_id + end + + hierarchy = ContainerHierarchy.find_or_initialize_by( + ancestor_id: ancestor_id, + descendant_id: record.id, + generations: generations, + ) + hierarchy.save(validate: false) + + value + end + end +end diff --git a/app/services/versioning/reverters/elemental_composition_reverter.rb b/app/services/versioning/reverters/elemental_composition_reverter.rb new file mode 100644 index 0000000000..84deda4bad --- /dev/null +++ b/app/services/versioning/reverters/elemental_composition_reverter.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Versioning::Reverters::ElementalCompositionReverter < Versioning::Reverters::BaseReverter + def self.scope + ElementalComposition + end +end diff --git a/app/services/versioning/reverters/reaction_reverter.rb b/app/services/versioning/reverters/reaction_reverter.rb new file mode 100644 index 0000000000..1003279d3f --- /dev/null +++ b/app/services/versioning/reverters/reaction_reverter.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Versioning::Reverters::ReactionReverter < Versioning::Reverters::BaseReverter + def self.scope + Reaction.with_deleted + end +end diff --git a/app/services/versioning/reverters/reactions_sample_reverter.rb b/app/services/versioning/reverters/reactions_sample_reverter.rb new file mode 100644 index 0000000000..5913ee8335 --- /dev/null +++ b/app/services/versioning/reverters/reactions_sample_reverter.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Versioning::Reverters::ReactionsSampleReverter < Versioning::Reverters::BaseReverter + def self.scope + ReactionsSample.with_deleted + end + + def call + super + record.reaction.save # Update svg file + end + + def field_definitions + { + deleted_at: restore_sample, + }.with_indifferent_access + end + + private + + def restore_sample + lambda do |value| + return value if value.present? + + sample = Sample.with_deleted.find(record.sample_id) + + sample.update_columns(deleted_at: nil) # rubocop:disable Rails/SkipsModelValidations + sample.collections.with_deleted.update_all(deleted_at: nil) # rubocop:disable Rails/SkipsModelValidations + sample.collections_samples.with_deleted.update_all(deleted_at: nil) # rubocop:disable Rails/SkipsModelValidations + + value + end + end +end diff --git a/app/services/versioning/reverters/research_plan_metadata_reverter.rb b/app/services/versioning/reverters/research_plan_metadata_reverter.rb new file mode 100644 index 0000000000..b655c232f2 --- /dev/null +++ b/app/services/versioning/reverters/research_plan_metadata_reverter.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Versioning::Reverters::ResearchPlanMetadataReverter < Versioning::Reverters::BaseReverter + def self.scope + ResearchPlanMetadata.with_deleted + end +end diff --git a/app/services/versioning/reverters/research_plan_reverter.rb b/app/services/versioning/reverters/research_plan_reverter.rb new file mode 100644 index 0000000000..9082d1bc87 --- /dev/null +++ b/app/services/versioning/reverters/research_plan_reverter.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Versioning::Reverters::ResearchPlanReverter < Versioning::Reverters::BaseReverter + def self.scope + ResearchPlan.with_deleted + end +end diff --git a/app/services/versioning/reverters/residue_reverter.rb b/app/services/versioning/reverters/residue_reverter.rb new file mode 100644 index 0000000000..173381ccbd --- /dev/null +++ b/app/services/versioning/reverters/residue_reverter.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Versioning::Reverters::ResidueReverter < Versioning::Reverters::BaseReverter + def self.scope + Residue + end +end diff --git a/app/services/versioning/reverters/sample_reverter.rb b/app/services/versioning/reverters/sample_reverter.rb new file mode 100644 index 0000000000..aca69b59af --- /dev/null +++ b/app/services/versioning/reverters/sample_reverter.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Versioning::Reverters::SampleReverter < Versioning::Reverters::BaseReverter + def self.scope + Sample.with_deleted + end + + def field_definitions + { + boiling_point: range, + melting_point: range, + }.with_indifferent_access + end + + private + + def range + lambda do |range_string| + lower, upper = range_string[1...-1].split(',') + Range.new( + (lower.presence || -Float::INFINITY).to_f, + (upper.presence || Float::INFINITY).to_f, + ) + end + end +end diff --git a/app/services/versioning/reverters/screen_reverter.rb b/app/services/versioning/reverters/screen_reverter.rb new file mode 100644 index 0000000000..b8aaad81d2 --- /dev/null +++ b/app/services/versioning/reverters/screen_reverter.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Versioning::Reverters::ScreenReverter < Versioning::Reverters::BaseReverter + def self.scope + Screen.with_deleted + end +end diff --git a/app/services/versioning/serializers/attachment_serializer.rb b/app/services/versioning/serializers/attachment_serializer.rb new file mode 100644 index 0000000000..8f50d5bc3a --- /dev/null +++ b/app/services/versioning/serializers/attachment_serializer.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Versioning::Serializers::AttachmentSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + filename: { + label: 'Filename', + }, + id: { + label: 'Preview', + kind: :image, + formatter: ->(_key, value) { value ? "/api/v1/attachments/image/#{value}" : '' }, + }, + }.with_indifferent_access + end +end diff --git a/app/services/versioning/serializers/base_serializer.rb b/app/services/versioning/serializers/base_serializer.rb new file mode 100644 index 0000000000..f3ec8dff0e --- /dev/null +++ b/app/services/versioning/serializers/base_serializer.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +class Versioning::Serializers::BaseSerializer + include ActiveModel::Model + + attr_accessor :record, :name + + def call + Rails.cache.fetch("versions/#{record.cache_key}", version: record.cache_version) do + result = [] # result data + base = {} # track current version data + + return [] unless log_data + + log_data.versions.group_by { |version| version.data.dig('m', 'uuid') }.each do |uuid, versions| + user_id = versions.first.data.dig('m', '_r') + time = versions.first.data['ts'] / 1000 + + changes = versions.each_with_object({}) do |version, hash| + hash.merge!(version.changes) + end + + changes_comparison_hash = {} # hash for changes comparison + + revertible = changes.none? { |key, _v| key == 'created_at' } + changes.each do |key, value| + next if value == base[key] # ignore if value is same as in last version + next if blank?(base[key]) && blank?(value) # ignore if value is empty + + fields = field_definitions[key] + next if fields.nil? + + fields = [fields] unless fields.is_a?(Array) + fields.each do |field| + formatter = field[:formatter] || default_formatter + revertible_value_formatter = field[:revertible_value_formatter] || formatter + old_value = formatter.call(key, base[key]) + new_value = formatter.call(key, value) + current_value = formatter.call(key, record.read_attribute_before_type_cast(key)) + revertible_value = revertible_value_formatter.call(key, base[key]) + + next if old_value == new_value # ignore if value is same as in last version + next if old_value.blank? && new_value.blank? # ignore if value is empty or nil + + changes_comparison_hash[field[:name] || key] = { + label: field[:label], + old_value: old_value, + new_value: new_value, + current_value: current_value, + kind: field[:kind] || :string, + revert: (revertible && field[:revert]) || [], + revertible_value: revertible_value, + } + end + end + base.merge!(changes) # merge changes with last version data for next iteration + next if changes_comparison_hash.empty? + + result << { + db_id: record.id, + klass_name: klass_name, # record class (sampe, reaction, ...) + name: name, # record name (uses as default the name attribute but in case the model doesn't have a name field or you want to change it) + time: Time.zone.at(time), # timestamp of the change + user: version_user_names_lookup[user_id], # user + uuid: uuid, # request group + changes: changes_comparison_hash, # changes hash + } + end + + result + end + end + + private + + def klass_name + record.class.to_s + end + + def blank?(value) + value.blank? || value.in?(['(,)', '{}', []]) + end + + def version_user_names_lookup + @version_user_names_lookup ||= begin + ids = Set.new + + record.log_data.versions.each do |v| + ids << v.data.dig('m', '_r') + ids << v.changes['created_by'] if v.changes.key?('created_by') + ids << v.changes['created_for'] if v.changes.key?('created_for') + end + + User.with_deleted.where(id: ids).to_h { |u| [u.id, u.name] } + end + end + + delegate :log_data, to: :record + + def default_formatter + ->(key, value) { record.instance_variable_get(:@attributes)[key].type.deserialize(value) } + end + + def user_formatter + ->(_key, value) { version_user_names_lookup[value] } + end + + def jsonb_formatter(*attributes) + ->(key, value) { default_formatter.call(key, value)&.dig(*attributes) } + end + + def non_formatter + ->(_key, value) { value } + end + + def fix_malformed_value_formatter + ->(key, value) { (value || '').start_with?('{') ? YAML.safe_load(value) : default_formatter.call(key, value) } + end + + def svg_path_formatter(entity) + lambda do |key, value| + result = default_formatter.call(key, value) + return result if result.blank? + + "/images/#{entity}/#{result}" + end + end +end diff --git a/app/services/versioning/serializers/container_serializer.rb b/app/services/versioning/serializers/container_serializer.rb new file mode 100644 index 0000000000..8925c675cb --- /dev/null +++ b/app/services/versioning/serializers/container_serializer.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +class Versioning::Serializers::ContainerSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + deleted_at: { + label: 'Deleted', + kind: :boolean, + formatter: ->(_key, value) { value.present? }, + revert: %i[deleted_at], + revertible_value_formatter: default_formatter, + }, + name: { + label: 'Name', + revert: %i[name], + }, + description: { + label: 'Description', + revert: %i[description], + }, + extended_metadata: [ + { + name: 'extended_metadata.content', + label: 'Content', + kind: :quill, + formatter: lambda { |key, value| + value = fix_malformed_value_formatter.call(key, value) + JSON.parse(jsonb_formatter('content').call(key, value) || '{}') + }, + revert: %i[extended_metadata.content], + revertible_value_formatter: lambda { |key, value| + value = fix_malformed_value_formatter.call(key, value) + jsonb_formatter('content').call(key, value) || '{}' + }, + }, + # buggy + # { + # name: 'extended_metadata.index', + # label: 'Position', + # formatter: jsonb_formatter('index'), + # }, + { + name: 'extended_metadata.report', + label: 'Add to Report', + kind: :boolean, + revert: %i[extended_metadata.report], + formatter: ->(key, value) { jsonb_formatter('report').call(key, value) == 'true' }, + }, + { + name: 'extended_metadata.status', + label: 'Status', + revert: %i[extended_metadata.status], + formatter: jsonb_formatter('status'), + }, + { + name: 'extended_metadata.kind', + label: 'Type', + revert: %i[extended_metadata.kind], + formatter: jsonb_formatter('kind'), + }, + { + name: 'extended_metadata.hyperlinks', + label: 'Hyperlinks', + formatter: lambda { |key, value| + result = jsonb_formatter('hyperlinks').call(key, value) + return '' if result.blank? + + JSON.parse(result).join("\n") + }, + revert: %i[extended_metadata.hyperlinks], + revertible_value_formatter: ->(key, value) { JSON.parse(jsonb_formatter('hyperlinks').call(key, value) || '[]') }, + }, + { + name: 'extended_metadata.instrument', + label: 'Instrument', + revert: %i[extended_metadata.instrument], + formatter: jsonb_formatter('instrument'), + }, + ], + }.with_indifferent_access + end +end diff --git a/app/services/versioning/serializers/elemental_composition_serializer.rb b/app/services/versioning/serializers/elemental_composition_serializer.rb new file mode 100644 index 0000000000..cd7baa8f4d --- /dev/null +++ b/app/services/versioning/serializers/elemental_composition_serializer.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class Versioning::Serializers::ElementalCompositionSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name = ['Elemental composition']) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + data: { + label: ::ElementalComposition::TYPES[record.composition_type.to_sym], + formatter: data_formatter, + revert: (record.composition_type == 'found' ? %i[data] : []), + revertible_value_formatter: default_formatter, + }, + }.with_indifferent_access + end + + private + + def data_formatter + lambda do |key, value| + value = fix_malformed_value_formatter.call(key, value) + return '' if value.blank? + + value.map { |k, v| "#{k}: #{v}" }.join(', ') + end + end +end diff --git a/app/services/versioning/serializers/reaction_serializer.rb b/app/services/versioning/serializers/reaction_serializer.rb new file mode 100644 index 0000000000..0e79e33782 --- /dev/null +++ b/app/services/versioning/serializers/reaction_serializer.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +class Versioning::Serializers::ReactionSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name = ['Properties']) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + name: { + label: 'Name', + revert: %i[name], + }, + status: { + label: 'Status', + revert: %i[status], + }, + temperature: { + label: 'Temperature', + kind: :temperature, + revert: %i[temperature], + }, + reaction_svg_file: { + label: 'Structural formula', + kind: :image, + formatter: svg_path_formatter('reactions'), + }, + rxno: { + label: 'Type (Name Reaction Ontology)', + revert: %i[rxno], + formatter: ->(key, value) { default_formatter.call(key, value).to_s.split(' | ', 2)[1] }, + revertible_value_formatter: default_formatter, + }, + role: { + label: 'Role', + revert: %i[role], + }, + dangerous_products: { + label: 'Dangerous Products', + revert: %i[dangerous_products], + formatter: ->(key, value) { (default_formatter.call(key, value) || []).join(', ') }, + revertible_value_formatter: default_formatter, + }, + rf_value: { + label: 'Rf-Value', + revert: %i[rf_value], + }, + tlc_solvents: { + label: 'Solvents (parts)', + revert: %i[tlc_solvents], + }, + tlc_description: { + label: 'TLC-Description', + revert: %i[tlc_description], + }, + description: { + label: 'Description', + revert: %i[description], + kind: :quill, + }, + purification: { + label: 'Purification', + revert: %i[purification], + }, + observation: { + label: 'Additional information for publication and purification details', + revert: %i[observation], + kind: :quill, + }, + duration: { + label: 'Duration', + revert: %i[duration], + }, + timestamp_start: { + label: 'Start', + revert: %i[timestamp_start], + }, + timestamp_stop: { + label: 'Stop', + revert: %i[timestamp_stop], + }, + }.with_indifferent_access + end +end diff --git a/app/services/versioning/serializers/reactions_sample_serializer.rb b/app/services/versioning/serializers/reactions_sample_serializer.rb new file mode 100644 index 0000000000..0e6e80659a --- /dev/null +++ b/app/services/versioning/serializers/reactions_sample_serializer.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class Versioning::Serializers::ReactionsSampleSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + deleted_at: { + label: 'Deleted', + kind: :boolean, + formatter: ->(_key, value) { value.present? }, + revert: %i[deleted_at], + revertible_value_formatter: default_formatter, + }, + show_label: { + label: 'L/S', + kind: :boolean, + revert: %i[show_label], + }, + position: { + label: 'Position', + revert: %i[position], + formatter: ->(_key, value) { (value && (value + 1)) || '' }, + revertible_formatter: default_formatter, + }, + coefficient: { + label: 'Coeff', + revert: %i[coefficient], + }, + equivalent: { + label: product? ? 'Yield' : 'Equiv', + revert: %i[equivalent], + }, + reference: { + label: 'Ref', + revert: %i[reference], + kind: :boolean, + }, + waste: { + label: product? ? 'Waste' : 'Recyclable', + revert: %i[waste], + kind: :boolean, + }, + }.with_indifferent_access + end + + private + + def klass_name + 'ReactionsSample' + end + + def product? + record.type == 'ReactionsProductSample' + end +end diff --git a/app/services/versioning/serializers/research_plan_metadata_serializer.rb b/app/services/versioning/serializers/research_plan_metadata_serializer.rb new file mode 100644 index 0000000000..43f6705e94 --- /dev/null +++ b/app/services/versioning/serializers/research_plan_metadata_serializer.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +class Versioning::Serializers::ResearchPlanMetadataSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + doi: { + label: 'DOI', + revert: %i[doi], + }, + url: { + label: 'URL', + revert: %i[url], + }, + landing_page: { + label: 'Landing Page', + revert: %i[landing_page], + }, + title: { + label: 'Title', + revert: %i[title], + }, + subject: { + label: 'Subject', + revert: %i[subject], + }, + data_cite_state: { + label: 'State', + revert: %i[data_cite_state], + }, + format: { + label: 'Format', + revert: %i[format], + }, + version: { + label: 'Version', + revert: %i[version], + }, + alternate_identifier: { + label: 'Alternate Identifiers', + revert: %i[alternate_identifier], + kind: :json, + formatter: json_array_formatter_with_parsed_keys, + }, + description: { + label: 'Descriptions', + revert: %i[description], + kind: :json, + formatter: json_array_formatter_with_parsed_keys, + }, + geo_location: { + label: 'Geolocations', + revert: %i[geo_location], + kind: :json, + formatter: lambda { |key, value| + result = default_formatter.call(key, value) || [] + + result.flat_map do |json| + json.values.map do |array| + array.map do |k, v| + { + title: k.capitalize, + content: v, + kind: :string, + } + end + end + end + }, + }, + funding_reference: { + label: 'Funding References', + revert: %i[funding_reference], + kind: :json, + formatter: json_array_formatter_with_parsed_keys, + }, + related_identifier: { + label: 'Related Identifiers', + revert: %i[related_identifier], + kind: :json, + formatter: json_array_formatter_with_parsed_keys, + }, + }.with_indifferent_access + end + + private + + def json_array_formatter_with_parsed_keys + lambda do |key, value| + result = default_formatter.call(key, value) || [] + + result.map do |json| + json.map do |k, v| + { + title: json_keys_dictionary[k], + content: v, + kind: :string, + } + end + # json.transform_keys { |k| json_keys_dictionary[k] } + end + end + end + + def json_keys_dictionary + { + 'alternateIdentifier' => 'Alternate Identifier', + 'alternateIdentifierType' => 'Type', + 'description' => 'Description', + 'descriptionType' => 'Type', + 'funderName' => 'Funder Name', + 'funderIdentifier' => 'Funder Identifier', + 'relatedIdentifier' => 'Related Identifier', + 'relatedIdentifierType' => 'Type', + } + end +end diff --git a/app/services/versioning/serializers/research_plan_serializer.rb b/app/services/versioning/serializers/research_plan_serializer.rb new file mode 100644 index 0000000000..23d24b726a --- /dev/null +++ b/app/services/versioning/serializers/research_plan_serializer.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +class Versioning::Serializers::ResearchPlanSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + name: { + label: 'Name', + revert: %i[name], + }, + created_by: { + label: 'Created by', + formatter: user_formatter, + }, + body: { + label: 'Content', + formatter: body_formatter, + revertible_value_formatter: default_formatter, + revert: %i[body], + kind: :json, + }, + }.with_indifferent_access + end + + private + + def body_formatter + lambda do |key, value| + result = default_formatter.call(key, value) || [] + + result.map do |item| + response = [] + + response << { + title: body_content_label(item), + content: body_content_content(item), + kind: body_content_kind(item), + } + + if item['type'] == 'image' + response << { + title: 'Zoom', + content: item.dig('value', 'zoom') || '', + kind: 'string', + } + end + + response + end + end + end + + def body_content_label(item) + { + richtext: item['title'], + ketcher: 'Ketcher schema', + image: 'Image', + table: 'Table', + sample: 'Sample', + reaction: 'Reaction', + }.with_indifferent_access[item['type']] || '' + end + + def body_content_kind(item) + { + richtext: 'quill', + ketcher: 'image', + image: 'image', + table: 'table', + sample: 'image', + reaction: 'image', + }.with_indifferent_access[item['type']] || 'string' + end + + def body_content_content(item) + case item['type'] + when 'ketcher' + "/images/research_plans/#{item.dig('value', 'svg_file')}" + when 'image' + id = lookups[:attachments][item.dig('value', 'public_name')] + id ? "/api/v1/attachments/image/#{id}" : '' + when 'sample' + "/images/samples/#{lookups[:samples][item.dig('value', 'sample_id')]}" + when 'reaction' + "/images/reactions/#{lookups[:reactions][item.dig('value', 'reaction_id')]}" + else + item['value'] + end + end + + def lookups + @lookups ||= begin + lookups = { + sample_ids: Set.new, + reaction_ids: Set.new, + identifiers: Set.new, + } + + record.log_data.versions.each do |v| + next unless v.changes['body'] + + changes = v.changes['body'].is_a?(String) ? JSON.parse(v.changes['body']) : v.changes['body'] + changes.each do |change| + case change['type'] + when 'sample' + lookups[:sample_ids] << change.dig('value', 'sample_id') + when 'reaction' + lookups[:reaction_ids] << change.dig('value', 'reaction_id') + when 'image' + lookups[:identifiers] << change.dig('value', 'public_name') + end + end + end + + { + samples: Sample.with_deleted.where(id: lookups[:sample_ids]).to_h { |s| [s.id, s.sample_svg_file] }, + reactions: Reaction.with_deleted.where(id: lookups[:reaction_ids]).to_h { |s| [s.id, s.reaction_svg_file] }, + attachments: Attachment.where(identifier: lookups[:identifiers]).to_h { |s| [s.identifier, s.id] }, + } + end + end +end diff --git a/app/services/versioning/serializers/residue_serializer.rb b/app/services/versioning/serializers/residue_serializer.rb new file mode 100644 index 0000000000..4651255666 --- /dev/null +++ b/app/services/versioning/serializers/residue_serializer.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class Versioning::Serializers::ResidueSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name = ['Polymer section']) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + custom_info: [ + { + name: 'custom_info.polymer_type', + label: 'Polymer type', + formatter: jsonb_formatter('polymer_type'), + revert: %i[custom_info.polymer_type custom_info.surface_type], + }, + { + name: 'custom_info.surface_type', + label: 'Surface type', + formatter: jsonb_formatter('surface_type'), + revert: %i[custom_info.surface_type custom_info.polymer_type], + }, + { + name: 'custom_info.cross_linkage', + label: 'Cross-linkage', + formatter: jsonb_formatter('cross_linkage'), + revert: %i[custom_info.cross_linkage], + }, + { + name: 'custom_info.formula', + label: 'Cross-linkage', + formatter: jsonb_formatter('formula'), + revert: %i[custom_info.formula], + }, + { + name: 'custom_info.loading', + label: 'Loading (mmol/g)', + formatter: jsonb_formatter('loading'), + revert: %i[custom_info.loading], + }, + { + name: 'custom_info.loading_type', + label: 'Loading according to', + formatter: loading_type_formatter, + revert: %i[custom_info.loading_type], + }, + ], + }.with_indifferent_access + end + + private + + def loading_type_formatter + lambda do |key, value| + value = jsonb_formatter('loading_type').call(key, value) + { + 'mass_diff' => 'Mass difference', + 'full_conv' => '100% conversion', + 'found' => 'Elemental analyses', + 'external' => 'External estimation', + }[value] + end + end +end diff --git a/app/services/versioning/serializers/sample_serializer.rb b/app/services/versioning/serializers/sample_serializer.rb new file mode 100644 index 0000000000..127e662260 --- /dev/null +++ b/app/services/versioning/serializers/sample_serializer.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +class Versioning::Serializers::SampleSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name = ['Properties']) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + name: { + label: 'Name', + revert: %i[name], + }, + description: { + label: 'Description', + revert: %i[description], + }, + created_by: { + label: 'Created by', + formatter: user_formatter, + }, + sample_svg_file: { + label: 'Structural formula', + kind: :image, + formatter: svg_path_formatter('samples'), + revertible_value_formatter: default_formatter, + revert: %i[sample_svg_file molfile molecule_id fingerprint_id], + }, + stereo: [ + { + name: 'stereo.abs', + label: 'Stereo Abs', + formatter: jsonb_formatter('abs'), + revert: %i[stereo.abs], + }, + { + name: 'stereo.rel', + label: 'Stereo Rel', + formatter: jsonb_formatter('rel'), + revert: %i[stereo.rel], + }, + ], + is_top_secret: { + label: 'Top secret', + kind: :boolean, + revert: %i[is_top_secret], + }, + external_label: { + label: 'External label', + revert: %i[external_label], + }, + boiling_point: { + label: 'Boiling point', + kind: :numrange, + revert: %i[boiling_point], + formatter: non_formatter, + }, + melting_point: { + label: 'Melting point', + kind: :numrange, + revert: %i[melting_point], + formatter: non_formatter, + }, + purity: { + label: 'Purity/Concentration', + revert: %i[purity density], + }, + density: { + label: 'Density', + revert: %i[density purity], + }, + molarity_value: { + label: 'Molarity', + revert: %i[molarity_value], + }, + target_amount_value: { + label: 'Amount', + revert: %i[target_amount_value target_amount_unit], + }, + target_amount_unit: { + label: 'Target amount unit', + revert: %i[target_amount_unit target_amount_value], + }, + location: { + label: 'Location', + revert: %i[location], + }, + molfile: { + label: 'Molfile', + }, + metrics: { + label: 'Amount metrics', + revert: %i[metrics], + formatter: metrics_formatter, + revertible_value_formatter: default_formatter, + }, + xref: [ + { + name: 'xref.cas', + label: 'CAS', + revert: %i[xref.cas], + formatter: jsonb_formatter('cas', 'value'), + }, + ], + solvent: { + label: 'Solvent', + kind: :solvent, + revert: %i[solvent], + }, + molecule_name_id: { + label: 'Molecule', + formatter: ->(_key, value) { molecule_names_lookup[value] }, + revert: %i[molecule_name_id], + revertible_value_formatter: default_formatter, + }, + molecule_id: { + kind: :hidden, + }, + fingerprint_id: { + kind: :hidden, + }, + }.with_indifferent_access + end + + private + + def molecule_names_lookup + @molecule_names_lookup ||= begin + ids = Set.new + + record.log_data.versions.each do |v| + ids << v.changes['molecule_name_id'] if v.changes.key?('molecule_name_id') + end + + MoleculeName.with_deleted.where(id: ids).to_h { |u| [u.id, u.name] } + end + end + + def metrics_formatter + lambda do |_key, value| + return '' unless value + + first = { + 'm' => 'mg', + 'n' => 'g', + 'u' => 'μg', + }[value[0]] + + second = { + 'm' => 'ml', + 'n' => 'l', + 'u' => 'μl', + }[value[1]] + + third = { + 'm' => 'mmol', + 'n' => 'mol', + }[value[2]] + + [first, second, third].join(', ') + end + end +end diff --git a/app/services/versioning/serializers/screen_serializer.rb b/app/services/versioning/serializers/screen_serializer.rb new file mode 100644 index 0000000000..a5d151d6c4 --- /dev/null +++ b/app/services/versioning/serializers/screen_serializer.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Versioning::Serializers::ScreenSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name = ['Properties']) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + name: { + label: 'Name', + revert: %i[name], + }, + collaborator: { + label: 'Collaborator', + revert: %i[collaborator], + }, + requirements: { + label: 'Requirements', + revert: %i[requirements], + }, + conditions: { + label: 'Conditions', + revert: %i[conditions], + }, + result: { + label: 'Result', + revert: %i[result], + }, + description: { + label: 'Description', + kind: :quill, + revert: %i[description], + }, + }.with_indifferent_access + end +end diff --git a/config/initializers/logidze.rb b/config/initializers/logidze.rb index 417954c662..bbb2b1b255 100644 --- a/config/initializers/logidze.rb +++ b/config/initializers/logidze.rb @@ -7,7 +7,7 @@ module Meta def with_responsible!(responsible_id) return if responsible_id.nil? - meta = { Logidze::History::Version::META_RESPONSIBLE => responsible_id } + meta = { Logidze::History::Version::META_RESPONSIBLE => responsible_id, 'uuid' => SecureRandom.uuid } PermanentMetaWithTransaction.wrap_with(meta, &proc {}) end diff --git a/config/profile_default.yml.example b/config/profile_default.yml.example index 23616443dd..0a533148ee 100644 --- a/config/profile_default.yml.example +++ b/config/profile_default.yml.example @@ -1,11 +1,11 @@ -default: &default +development: :layout: :layout: :sample: 1 :reaction: 2 :wellplate: 3 :screen: 4 - :research_plan: + :research_plan: 5 :cell_line: -1000 :layout_detail_research_plan: :research_plan: @@ -27,8 +27,6 @@ default: &default 4 :results: 5 - :history: - 6 :layout_detail_reaction: :scheme: 1 @@ -42,8 +40,6 @@ default: &default 5 :variations: 6 - :history: - 7 :layout_detail_wellplate: :properties: 1 @@ -58,12 +54,117 @@ default: &default 1 :analyses: 2 -development: - <<: *default - production: - <<: *default + :layout: + :layout: + :sample: 1 + :reaction: 2 + :wellplate: 3 + :screen: 4 + :research_plan: 5 + :cell_line: -1000 + :layout_detail_research_plan: + :research_plan: + 1 + :analyses: + 2 + :references: + 3 + :attachments: + 4 + :layout_detail_sample: + :properties: + 1 + :analyses: + 2 + :qc_curation: + 3 + :references: + 4 + :results: + 5 + :layout_detail_reaction: + :scheme: + 1 + :properties: + 2 + :analyses: + 3 + :references: + 4 + :green_chemistry: + 5 + :variations: + 6 + :layout_detail_wellplate: + :properties: + 1 + :analyses: + 2 + :designer: + 3 + :list: + 4 + :layout_detail_screen: + :properties: + 1 + :analyses: + 2 test: - <<: *default + :layout: + :layout: + :sample: 1 + :reaction: 2 + :wellplate: 3 + :screen: 4 + :research_plan: 5 + :cell_line: -1000 + :layout_detail_research_plan: + :research_plan: + 1 + :analyses: + 2 + :references: + 3 + :attachments: + 4 + :layout_detail_sample: + :properties: + 1 + :analyses: + 2 + :qc_curation: + 3 + :references: + 4 + :results: + 5 + :layout_detail_reaction: + :scheme: + 1 + :properties: + 2 + :analyses: + 3 + :references: + 4 + :green_chemistry: + 5 + :variations: + 6 + :layout_detail_wellplate: + :properties: + 1 + :analyses: + 2 + :designer: + 3 + :list: + 4 + :layout_detail_screen: + :properties: + 1 + :analyses: + 2 diff --git a/db/migrate/20190617153000_convert_analysis_type.rb b/db/migrate/20190617153000_convert_analysis_type.rb index 7adf17ad04..1646200cf1 100644 --- a/db/migrate/20190617153000_convert_analysis_type.rb +++ b/db/migrate/20190617153000_convert_analysis_type.rb @@ -22,7 +22,7 @@ class ConvertAnalysisType < ActiveRecord::Migration[4.2] def up # convert CONV.each do |c| - list = Container.where('extended_metadata->\'kind\' = (?) and container_type = \'analysis\' ', c[:kind]) + list = Container.unscoped.where('extended_metadata->\'kind\' = (?) and container_type = \'analysis\' ', c[:kind]) list.each do |rs| meta = rs.extended_metadata meta["kind"] = c[:ols] @@ -32,7 +32,7 @@ def up end EXCP.each do |c| - list = Container.where('extended_metadata->\'kind\' = (?) and container_type = \'analysis\' ', c[:kind]) + list = Container.unscoped.where('extended_metadata->\'kind\' = (?) and container_type = \'analysis\' ', c[:kind]) list.each do |rs| meta = rs.extended_metadata meta["kind"] = c[:ols] @@ -45,7 +45,7 @@ def up def down # revert CONV.each do |c| - list = Container.where('extended_metadata->\'kind\' = (?) and container_type = \'analysis\' ', c[:ols]) + list = Container.unscoped.where('extended_metadata->\'kind\' = (?) and container_type = \'analysis\' ', c[:ols]) list.each do |rs| meta = rs.extended_metadata meta["kind"] = c[:kind] diff --git a/db/migrate/20221213121641_add_logidze_to_research_plans.rb b/db/migrate/20221213121641_add_logidze_to_research_plans.rb new file mode 100644 index 0000000000..8f2f45e634 --- /dev/null +++ b/db/migrate/20221213121641_add_logidze_to_research_plans.rb @@ -0,0 +1,17 @@ +class AddLogidzeToResearchPlans < ActiveRecord::Migration[6.1] + def change + add_column :research_plans, :log_data, :jsonb + + reversible do |dir| + dir.up do + create_trigger :logidze_on_research_plans, on: :research_plans + end + + dir.down do + execute <<~SQL + DROP TRIGGER IF EXISTS "logidze_on_research_plans" on "research_plans"; + SQL + end + end + end +end diff --git a/db/migrate/20221213123310_add_logidze_to_research_plan_metadata.rb b/db/migrate/20221213123310_add_logidze_to_research_plan_metadata.rb new file mode 100644 index 0000000000..ed0983d4ba --- /dev/null +++ b/db/migrate/20221213123310_add_logidze_to_research_plan_metadata.rb @@ -0,0 +1,17 @@ +class AddLogidzeToResearchPlanMetadata < ActiveRecord::Migration[6.1] + def change + add_column :research_plan_metadata, :log_data, :jsonb + + reversible do |dir| + dir.up do + create_trigger :logidze_on_research_plan_metadata, on: :research_plan_metadata + end + + dir.down do + execute <<~SQL + DROP TRIGGER IF EXISTS "logidze_on_research_plan_metadata" on "research_plan_metadata"; + SQL + end + end + end +end diff --git a/db/migrate/20221216222832_remove_default_for_reactions_samples_timestamps.rb b/db/migrate/20221216222832_remove_default_for_reactions_samples_timestamps.rb new file mode 100644 index 0000000000..78174af525 --- /dev/null +++ b/db/migrate/20221216222832_remove_default_for_reactions_samples_timestamps.rb @@ -0,0 +1,6 @@ +class RemoveDefaultForReactionsSamplesTimestamps < ActiveRecord::Migration[6.1] + def change + change_column_default(:reactions_samples, :created_at, from: '2021-10-1T00:00:00', to: nil) + change_column_default(:reactions_samples, :updated_at, from: '2021-10-1T00:00:00', to: nil) + end +end diff --git a/db/migrate/20221219194720_add_logidze_to_screens.rb b/db/migrate/20221219194720_add_logidze_to_screens.rb new file mode 100644 index 0000000000..cbde62143b --- /dev/null +++ b/db/migrate/20221219194720_add_logidze_to_screens.rb @@ -0,0 +1,17 @@ +class AddLogidzeToScreens < ActiveRecord::Migration[6.1] + def change + add_column :screens, :log_data, :jsonb + + reversible do |dir| + dir.up do + create_trigger :logidze_on_screens, on: :screens + end + + dir.down do + execute <<~SQL + DROP TRIGGER IF EXISTS "logidze_on_screens" on "screens"; + SQL + end + end + end +end diff --git a/db/migrate/20230120134152_add_deleted_at_to_containers.rb b/db/migrate/20230120134152_add_deleted_at_to_containers.rb new file mode 100644 index 0000000000..1a849280f0 --- /dev/null +++ b/db/migrate/20230120134152_add_deleted_at_to_containers.rb @@ -0,0 +1,5 @@ +class AddDeletedAtToContainers < ActiveRecord::Migration[6.1] + def change + add_column :containers, :deleted_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index b7b91efe07..055254bd4a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -70,9 +70,9 @@ t.string "attachable_type" t.string "aasm_state" t.bigint "filesize" - t.jsonb "log_data" t.jsonb "attachment_data" t.integer "con_state" + t.jsonb "log_data" t.index ["attachable_type", "attachable_id"], name: "index_attachments_on_attachable_type_and_attachable_id" t.index ["identifier"], name: "index_attachments_on_identifier", unique: true end @@ -343,6 +343,7 @@ t.integer "parent_id" t.text "plain_text_content" t.jsonb "log_data" + t.datetime "deleted_at" t.index ["containable_type", "containable_id"], name: "index_containers_on_containable" end @@ -1072,6 +1073,7 @@ t.text "subject" t.jsonb "alternate_identifier" t.jsonb "related_identifier" + t.jsonb "log_data" t.index ["deleted_at"], name: "index_research_plan_metadata_on_deleted_at" t.index ["research_plan_id"], name: "index_research_plan_metadata_on_research_plan_id" end @@ -1092,6 +1094,7 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.jsonb "body" + t.jsonb "log_data" end create_table "research_plans_screens", force: :cascade do |t| @@ -1220,6 +1223,7 @@ t.datetime "deleted_at" t.jsonb "component_graph_data", default: {} t.text "plain_text_description" + t.jsonb "log_data" t.index ["deleted_at"], name: "index_screens_on_deleted_at" end @@ -2101,6 +2105,9 @@ create_trigger :logidze_on_samples, sql_definition: <<-SQL CREATE TRIGGER logidze_on_samples BEFORE INSERT OR UPDATE ON public.samples FOR EACH ROW WHEN ((COALESCE(current_setting('logidze.disabled'::text, true), ''::text) <> 'on'::text)) EXECUTE FUNCTION logidze_logger('null', 'updated_at') SQL + create_trigger :logidze_on_screens, sql_definition: <<-SQL + CREATE TRIGGER logidze_on_screens BEFORE INSERT OR UPDATE ON public.screens FOR EACH ROW WHEN ((COALESCE(current_setting('logidze.disabled'::text, true), ''::text) <> 'on'::text)) EXECUTE FUNCTION logidze_logger('null', 'updated_at') + SQL create_trigger :logidze_on_residues, sql_definition: <<-SQL CREATE TRIGGER logidze_on_residues BEFORE INSERT OR UPDATE ON public.residues FOR EACH ROW WHEN ((COALESCE(current_setting('logidze.disabled'::text, true), ''::text) <> 'on'::text)) EXECUTE FUNCTION logidze_logger('null', 'updated_at') SQL @@ -2113,12 +2120,18 @@ create_trigger :logidze_on_attachments, sql_definition: <<-SQL CREATE TRIGGER logidze_on_attachments BEFORE INSERT OR UPDATE ON public.attachments FOR EACH ROW WHEN ((COALESCE(current_setting('logidze.disabled'::text, true), ''::text) <> 'on'::text)) EXECUTE FUNCTION logidze_logger('null', 'updated_at') SQL + create_trigger :logidze_on_research_plans, sql_definition: <<-SQL + CREATE TRIGGER logidze_on_research_plans BEFORE INSERT OR UPDATE ON public.research_plans FOR EACH ROW WHEN ((COALESCE(current_setting('logidze.disabled'::text, true), ''::text) <> 'on'::text)) EXECUTE FUNCTION logidze_logger('null', 'updated_at') + SQL create_trigger :logidze_on_reactions_samples, sql_definition: <<-SQL CREATE TRIGGER logidze_on_reactions_samples BEFORE INSERT OR UPDATE ON public.reactions_samples FOR EACH ROW WHEN ((COALESCE(current_setting('logidze.disabled'::text, true), ''::text) <> 'on'::text)) EXECUTE FUNCTION logidze_logger('null', 'updated_at') SQL create_trigger :update_users_matrix_trg, sql_definition: <<-SQL CREATE TRIGGER update_users_matrix_trg AFTER INSERT OR UPDATE ON public.matrices FOR EACH ROW EXECUTE FUNCTION update_users_matrix() SQL + create_trigger :logidze_on_research_plan_metadata, sql_definition: <<-SQL + CREATE TRIGGER logidze_on_research_plan_metadata BEFORE INSERT OR UPDATE ON public.research_plan_metadata FOR EACH ROW WHEN ((COALESCE(current_setting('logidze.disabled'::text, true), ''::text) <> 'on'::text)) EXECUTE FUNCTION logidze_logger('null', 'updated_at') + SQL create_view "v_samples_collections", sql_definition: <<-SQL SELECT cols.id AS cols_id, diff --git a/db/triggers/logidze_on_research_plan_metadata_v01.sql b/db/triggers/logidze_on_research_plan_metadata_v01.sql new file mode 100644 index 0000000000..1e4d4cd4b9 --- /dev/null +++ b/db/triggers/logidze_on_research_plan_metadata_v01.sql @@ -0,0 +1,6 @@ +CREATE TRIGGER "logidze_on_research_plan_metadata" +BEFORE UPDATE OR INSERT ON "research_plan_metadata" FOR EACH ROW +WHEN (coalesce(current_setting('logidze.disabled', true), '') <> 'on') +-- Parameters: history_size_limit (integer), timestamp_column (text), filtered_columns (text[]), +-- include_columns (boolean), debounce_time_ms (integer) +EXECUTE PROCEDURE logidze_logger(null, 'updated_at'); diff --git a/db/triggers/logidze_on_research_plans_v01.sql b/db/triggers/logidze_on_research_plans_v01.sql new file mode 100644 index 0000000000..9d00d471d7 --- /dev/null +++ b/db/triggers/logidze_on_research_plans_v01.sql @@ -0,0 +1,6 @@ +CREATE TRIGGER "logidze_on_research_plans" +BEFORE UPDATE OR INSERT ON "research_plans" FOR EACH ROW +WHEN (coalesce(current_setting('logidze.disabled', true), '') <> 'on') +-- Parameters: history_size_limit (integer), timestamp_column (text), filtered_columns (text[]), +-- include_columns (boolean), debounce_time_ms (integer) +EXECUTE PROCEDURE logidze_logger(null, 'updated_at'); diff --git a/db/triggers/logidze_on_research_plans_wellplates_v01.sql b/db/triggers/logidze_on_research_plans_wellplates_v01.sql new file mode 100644 index 0000000000..a9b2606155 --- /dev/null +++ b/db/triggers/logidze_on_research_plans_wellplates_v01.sql @@ -0,0 +1,6 @@ +CREATE TRIGGER "logidze_on_research_plans_wellplates" +BEFORE UPDATE OR INSERT ON "research_plans_wellplates" FOR EACH ROW +WHEN (coalesce(current_setting('logidze.disabled', true), '') <> 'on') +-- Parameters: history_size_limit (integer), timestamp_column (text), filtered_columns (text[]), +-- include_columns (boolean), debounce_time_ms (integer) +EXECUTE PROCEDURE logidze_logger(null, 'updated_at'); diff --git a/db/triggers/logidze_on_screens_v01.sql b/db/triggers/logidze_on_screens_v01.sql new file mode 100644 index 0000000000..4904dbd7c0 --- /dev/null +++ b/db/triggers/logidze_on_screens_v01.sql @@ -0,0 +1,6 @@ +CREATE TRIGGER "logidze_on_screens" +BEFORE UPDATE OR INSERT ON "screens" FOR EACH ROW +WHEN (coalesce(current_setting('logidze.disabled', true), '') <> 'on') +-- Parameters: history_size_limit (integer), timestamp_column (text), filtered_columns (text[]), +-- include_columns (boolean), debounce_time_ms (integer) +EXECUTE PROCEDURE logidze_logger(null, 'updated_at'); diff --git a/db/triggers/logidze_on_wellplates_v01.sql b/db/triggers/logidze_on_wellplates_v01.sql new file mode 100644 index 0000000000..6ac6dd7263 --- /dev/null +++ b/db/triggers/logidze_on_wellplates_v01.sql @@ -0,0 +1,6 @@ +CREATE TRIGGER "logidze_on_wellplates" +BEFORE UPDATE OR INSERT ON "wellplates" FOR EACH ROW +WHEN (coalesce(current_setting('logidze.disabled', true), '') <> 'on') +-- Parameters: history_size_limit (integer), timestamp_column (text), filtered_columns (text[]), +-- include_columns (boolean), debounce_time_ms (integer) +EXECUTE PROCEDURE logidze_logger(null, 'updated_at'); diff --git a/yarn.lock b/yarn.lock index 11131729a2..dc57ecf084 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2137,14 +2137,30 @@ resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.2.tgz#329c7a06735e144a506bdb2cad0268b7f46f4ad8" integrity sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw== dependencies: - "@babel/code-frame" "^7.22.13" - "@babel/generator" "^7.23.0" - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-function-name" "^7.23.0" - "@babel/helper-hoist-variables" "^7.22.5" - "@babel/helper-split-export-declaration" "^7.22.6" - "@babel/parser" "^7.23.0" - "@babel/types" "^7.23.0" + "@babel/code-frame" "^7.16.7" + "@babel/generator" "^7.16.8" + "@babel/helper-environment-visitor" "^7.16.7" + "@babel/helper-function-name" "^7.16.7" + "@babel/helper-hoist-variables" "^7.16.7" + "@babel/helper-split-export-declaration" "^7.16.7" + "@babel/parser" "^7.16.10" + "@babel/types" "^7.16.8" + debug "^4.1.0" + globals "^11.1.0" + +"@babel/traverse@^7.20.1": + version "7.20.1" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.20.1.tgz#9b15ccbf882f6d107eeeecf263fbcdd208777ec8" + integrity sha512-d3tN8fkVJwFLkHkBN479SOsw4DMZnz8cdbL/gvuDuzy3TS6Nfw80HuQqhw1pITbIruHyh7d1fMA47kWzmcUEGA== + dependencies: + "@babel/code-frame" "^7.18.6" + "@babel/generator" "^7.20.1" + "@babel/helper-environment-visitor" "^7.18.9" + "@babel/helper-function-name" "^7.19.0" + "@babel/helper-hoist-variables" "^7.18.6" + "@babel/helper-split-export-declaration" "^7.18.6" + "@babel/parser" "^7.20.1" + "@babel/types" "^7.20.0" debug "^4.1.0" globals "^11.1.0" @@ -13634,10 +13650,10 @@ react-transition-group@^2.0.0, react-transition-group@^2.2.1: prop-types "^15.6.2" react-lifecycles-compat "^3.0.4" -react-transition-group@^4.2.0, react-transition-group@^4.4.0, react-transition-group@^4.4.5: - version "4.4.5" - resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" - integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== +react-transition-group@^4.2.0: + version "4.4.2" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.2.tgz#8b59a56f09ced7b55cbd53c36768b922890d5470" + integrity sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg== dependencies: "@babel/runtime" "^7.5.5" dom-helpers "^5.0.1" @@ -13654,6 +13670,16 @@ react-transition-group@^4.3.0: loose-envify "^1.4.0" prop-types "^15.6.2" +react-transition-group@^4.4.0, react-transition-group@^4.4.5: + version "4.4.5" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" + integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== + dependencies: + "@babel/runtime" "^7.5.5" + dom-helpers "^5.0.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-ui-tree@3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/react-ui-tree/-/react-ui-tree-3.1.0.tgz"