diff --git a/app/models/activities/fetcher.rb b/app/models/activities/fetcher.rb index 7058d022373d..53c63f64582f 100644 --- a/app/models/activities/fetcher.rb +++ b/app/models/activities/fetcher.rb @@ -123,7 +123,7 @@ def journals_of_event_set(events) journal_ids = events.map(&:event_id) Journal - .includes(:data, :customizable_journals, :attachable_journals, :target_version_journals, + .includes(:data, :customizable_journals, :attachable_journals, :target_version_journals, :label_journals, :observed_in_version_journals, :bcf_comment) .find(journal_ids) .then { |journals| ::API::V3::Activities::ActivityEagerLoadingWrapper.wrap(journals) } diff --git a/app/models/journable/with_historic_attributes.rb b/app/models/journable/with_historic_attributes.rb index d18fefd33a40..4a136c61e43e 100644 --- a/app/models/journable/with_historic_attributes.rb +++ b/app/models/journable/with_historic_attributes.rb @@ -263,6 +263,7 @@ def changes_at_timestamp(timestamp) merge_target_versions_changes!(changes, historic_journable) merge_observed_in_versions_changes!(changes, historic_journable) + merge_labels_changes!(changes, historic_journable) changes end @@ -275,12 +276,12 @@ def merge_target_versions_changes!(changes, historic_journable) end def target_versions_changes(historic_journable) - old_ids = sorted_version_ids(historic_journable, :target_versions) - new_ids = sorted_version_ids(__getobj__, :target_versions) + old_ids = sorted_association_ids(historic_journable, :target_versions) + new_ids = sorted_association_ids(__getobj__, :target_versions) {}.tap do |changes| changes["version_id"] = [old_ids.first, new_ids.first] if old_ids.first != new_ids.first - changes["target_versions"] = [old_ids, new_ids].map { joined_version_ids(it) } if old_ids != new_ids + changes["target_versions"] = [old_ids, new_ids].map { joined_ids(it) } if old_ids != new_ids end end @@ -291,19 +292,34 @@ def merge_observed_in_versions_changes!(changes, historic_journable) end def observed_in_versions_changes(historic_journable) - old_ids = sorted_version_ids(historic_journable, :observed_in_versions) - new_ids = sorted_version_ids(__getobj__, :observed_in_versions) + old_ids = sorted_association_ids(historic_journable, :observed_in_versions) + new_ids = sorted_association_ids(__getobj__, :observed_in_versions) {}.tap do |changes| - changes["observed_in_versions"] = [old_ids, new_ids].map { joined_version_ids(it) } if old_ids != new_ids + changes["observed_in_versions"] = [old_ids, new_ids].map { joined_ids(it) } if old_ids != new_ids end end - def sorted_version_ids(work_package, association) + def merge_labels_changes!(changes, historic_journable) + return unless __getobj__.respond_to?(:labels) + + changes.merge!(labels_changes(historic_journable)) + end + + def labels_changes(historic_journable) + old_ids = sorted_association_ids(historic_journable, :labels) + new_ids = sorted_association_ids(__getobj__, :labels) + + {}.tap do |changes| + changes["labels"] = [old_ids, new_ids].map { joined_ids(it) } if old_ids != new_ids + end + end + + def sorted_association_ids(work_package, association) work_package.public_send(association).map(&:id).sort end - def joined_version_ids(ids) + def joined_ids(ids) ids.join(",").presence end diff --git a/app/models/journable/with_historic_attributes/loader.rb b/app/models/journable/with_historic_attributes/loader.rb index d4ddb2969b6b..c06864bf7e02 100644 --- a/app/models/journable/with_historic_attributes/loader.rb +++ b/app/models/journable/with_historic_attributes/loader.rb @@ -68,6 +68,7 @@ def load_journal_associations(journalized = journables) load_custom_value_associations(journalized, journal_ids) load_versions_associations(journalized, journal_ids, kind: "target", association: :target_versions) load_versions_associations(journalized, journal_ids, kind: "observed_in", association: :observed_in_versions) + load_labels_association(journalized, journal_ids) journalized end @@ -90,7 +91,20 @@ def load_versions_associations(journalized, journal_ids, kind:, association:) journalized.each do |work_package| version_journals = Array(version_journals_by_journal_id[work_package.journal_id]) - set_versions_association_from_journal!(work_package:, version_journals:, association:) + historic_versions = version_journals.filter_map(&:version).sort_by(&:id) + set_association_from_journal!(work_package:, records: historic_versions, association:) + end + end + + def load_labels_association(journalized, journal_ids) + return unless journalized_class.method_defined?(:labels) + + label_journals_by_journal_id = load_label_journals_by_journal_id(journal_ids) + + journalized.each do |work_package| + label_journals = Array(label_journals_by_journal_id[work_package.journal_id]) + historic_labels = label_journals.filter_map(&:label).sort_by(&:id) + set_association_from_journal!(work_package:, records: historic_labels, association: :labels) end end @@ -143,6 +157,13 @@ def load_version_journals_by_journal_id(journal_ids, kind:) .group_by(&:journal_id) end + def load_label_journals_by_journal_id(journal_ids) + Journal::LabelJournal + .where(journal_id: journal_ids) + .includes(:label) + .group_by(&:journal_id) + end + def set_custom_value_association_from_journal!(work_package:, customizable_journals:) # Build the associated customizable_journals as custom values, this way the historic work packages # will behave just as the normal ones. Additionally set the reverse customized association @@ -155,11 +176,9 @@ def set_custom_value_association_from_journal!(work_package:, customizable_journ work_package.association(:custom_values).target = historic_custom_values end - def set_versions_association_from_journal!(work_package:, version_journals:, association:) - historic_versions = version_journals.filter_map(&:version).sort_by(&:id) - + def set_association_from_journal!(work_package:, records:, association:) work_package.association(association).loaded! - work_package.association(association).target = historic_versions + work_package.association(association).target = records end attr_accessor :journables diff --git a/app/models/journal.rb b/app/models/journal.rb index 97684afdf8c4..4d43339bdd80 100644 --- a/app/models/journal.rb +++ b/app/models/journal.rb @@ -56,6 +56,7 @@ class Journal < ApplicationRecord register_journal_formatter OpenProject::JournalFormatter::Diff register_journal_formatter OpenProject::JournalFormatter::FileLink register_journal_formatter OpenProject::JournalFormatter::IgnoreNonWorkingDays + register_journal_formatter OpenProject::JournalFormatter::Labels register_journal_formatter OpenProject::JournalFormatter::MeetingStartTime register_journal_formatter OpenProject::JournalFormatter::MeetingState register_journal_formatter OpenProject::JournalFormatter::MeetingWorkPackageId @@ -136,6 +137,7 @@ class Journal < ApplicationRecord has_many :custom_comment_journals, class_name: "Journal::CustomCommentJournal", dependent: :delete_all has_many :project_phase_journals, class_name: "Journal::ProjectPhaseJournal", dependent: :delete_all has_many :storable_journals, class_name: "Journal::StorableJournal", dependent: :delete_all + has_many :label_journals, class_name: "Journal::LabelJournal", dependent: :delete_all has_many :work_package_version_journals, class_name: "Journal::WorkPackageVersionJournal", dependent: :delete_all # Row lifecycle is owned by work_package_version_journals above. # rubocop:disable Rails/HasManyOrHasOneDependent diff --git a/app/models/journal/label_journal.rb b/app/models/journal/label_journal.rb new file mode 100644 index 000000000000..f83523b30bb7 --- /dev/null +++ b/app/models/journal/label_journal.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Journal::LabelJournal < Journal::AssociatedJournal + self.table_name = "label_journals" + + belongs_to :label +end diff --git a/app/models/work_package/journalized.rb b/app/models/work_package/journalized.rb index 4a1c4edf92af..05aa4b1d7c03 100644 --- a/app/models/work_package/journalized.rb +++ b/app/models/work_package/journalized.rb @@ -107,6 +107,7 @@ def self.event_url register_journal_formatted_fields "project_phase_definition_id", formatter_key: :project_phase_definition register_journal_formatted_fields "target_versions", formatter_key: :target_versions register_journal_formatted_fields "observed_in_versions", formatter_key: :observed_in_versions + register_journal_formatted_fields "labels", formatter_key: :labels # Joined register_journal_formatted_fields :parent_id, :project_id, diff --git a/app/services/journals/create_service/association.rb b/app/services/journals/create_service/association.rb index 964c4ecae338..eb1295de190b 100644 --- a/app/services/journals/create_service/association.rb +++ b/app/services/journals/create_service/association.rb @@ -33,7 +33,7 @@ class Association # Core associations are defined here. Module-specific associations can be defined in engines # using `Journals::CreateService::Association.register`. - @registry = Set.new(%i[Attachable CustomComment Customizable ProjectPhase WorkPackageVersion]) + @registry = Set.new(%i[Attachable CustomComment Customizable Labelable ProjectPhase WorkPackageVersion]) class << self def register(*names) diff --git a/app/services/journals/create_service/labelable.rb b/app/services/journals/create_service/labelable.rb new file mode 100644 index 000000000000..60fa1e70badb --- /dev/null +++ b/app/services/journals/create_service/labelable.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Journals::CreateService + class Labelable < Association + def associated? + journable.respond_to?(:labels) + end + + def cleanup_predecessor(predecessor, notes, cause) + cleanup_predecessor_for(predecessor, + notes, + cause, + "label_journals", + :journal_id, + :id) + end + + def insert_sql + sanitize(<<~SQL.squish, journable_id:, labelable_type: journable_class_name) + INSERT INTO + label_journals ( + journal_id, + label_id + ) + SELECT + #{id_from_inserted_journal_sql}, + labelings.label_id + FROM labelings + WHERE + #{only_if_created_sql} + AND labelings.labelable_type = :labelable_type + AND labelings.labelable_id = :journable_id + SQL + end + + def changes_sql + sanitize(<<~SQL.squish, journable_id:, labelable_type: journable_class_name) + SELECT + :journable_id AS journable_id + FROM + ( + SELECT ARRAY_AGG(label_id ORDER BY label_id) AS labels + FROM labelings + WHERE labelings.labelable_type = :labelable_type + AND labelings.labelable_id = :journable_id + ) current_labels + CROSS JOIN + ( + SELECT ARRAY_AGG(label_id ORDER BY label_id) AS labels + FROM label_journals + WHERE journal_id IN (SELECT id FROM max_journals) + ) journal_labels + WHERE + current_labels.labels IS DISTINCT FROM journal_labels.labels + SQL + end + end +end diff --git a/app/services/work_packages/activities_tab/paginator.rb b/app/services/work_packages/activities_tab/paginator.rb index 07978d7a180e..d116ebdedf91 100644 --- a/app/services/work_packages/activities_tab/paginator.rb +++ b/app/services/work_packages/activities_tab/paginator.rb @@ -179,7 +179,7 @@ def with_changesets(scope) def page_journals(page_relation) page_relation .includes(:user, :customizable_journals, :attachable_journals, :storable_journals, :target_version_journals, - :observed_in_version_journals, :notifications, :attachments) + :label_journals, :observed_in_version_journals, :notifications, :attachments) .to_a end diff --git a/app/services/work_packages/activities_tab/paginator/journal_changes_filter.rb b/app/services/work_packages/activities_tab/paginator/journal_changes_filter.rb index e8561f37a8d0..09074e404f78 100644 --- a/app/services/work_packages/activities_tab/paginator/journal_changes_filter.rb +++ b/app/services/work_packages/activities_tab/paginator/journal_changes_filter.rb @@ -35,6 +35,7 @@ # * Custom field changes (compares customizable_journals with predecessor) # * File link changes (compares storages_file_links_journals with predecessor) # * Target/observed-in version changes (compares work_package_version_journals with predecessor) +# * Label changes (compares label_journals with predecessor) # * Cause metadata (system-triggered changes) # * Attribute/data changes (compares work_package_journals columns with immediate predecessor) # @@ -87,6 +88,7 @@ def changes_condition_sql OR EXISTS (#{custom_field_changes_condition_sql}) OR EXISTS (#{file_link_changes_condition_sql}) OR EXISTS (#{version_changes_condition_sql}) + OR EXISTS (#{label_changes_condition_sql}) SQL end @@ -136,6 +138,14 @@ def version_changes_condition_sql ) end + def label_changes_condition_sql + association_changes_condition_sql( + table: Journal::LabelJournal.table_name, + join_columns: ["label_id"], + value_columns: [] + ) + end + def data_changes_condition_sql data_change_columns = Journal::WorkPackageJournal.column_names - ["id"] diff --git a/config/locales/en.yml b/config/locales/en.yml index b37c902ad40f..7e436b902052 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1208,6 +1208,9 @@ en: issue_priority: one: "Priority" other: "Priorities" + label: + one: "Label" + other: "Labels" ldap_auth_source: one: "LDAP connection" other: "LDAP connections" @@ -4046,6 +4049,7 @@ en: label_deleted_custom_field: "(deleted custom field)" label_deleted_custom_item: "(deleted item)" label_deleted_custom_option: "(deleted option)" + label_deleted_label: "(deleted label)" label_deleted_version: "(deleted version)" label_departments: "Organization" label_departments_description_html: > diff --git a/db/migrate/20261021120000_create_label_journals.rb b/db/migrate/20261021120000_create_label_journals.rb new file mode 100644 index 000000000000..8af04d0e0b16 --- /dev/null +++ b/db/migrate/20261021120000_create_label_journals.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class CreateLabelJournals < ActiveRecord::Migration[8.1] + def change + create_table :label_journals do |t| # rubocop:disable Rails/CreateTableWithTimestamps + t.belongs_to :journal, null: false, foreign_key: true + # No foreign key: journal snapshots must outlive deleted labels. + t.belongs_to :label, null: false + end + end +end diff --git a/lib/api/v3/activities/activity_eager_loading_wrapper.rb b/lib/api/v3/activities/activity_eager_loading_wrapper.rb index 0f992c2e148c..34f507388c44 100644 --- a/lib/api/v3/activities/activity_eager_loading_wrapper.rb +++ b/lib/api/v3/activities/activity_eager_loading_wrapper.rb @@ -150,7 +150,7 @@ def predecessor_journals(journals) SQL ) .includes(:attachable_journals, :customizable_journals, :storable_journals, :target_version_journals, - :observed_in_version_journals) + :label_journals, :observed_in_version_journals) end end end diff --git a/lib/api/v3/work_packages/eager_loading/historic_attributes.rb b/lib/api/v3/work_packages/eager_loading/historic_attributes.rb index 0321d87f0b59..bd0cb6d7ef02 100644 --- a/lib/api/v3/work_packages/eager_loading/historic_attributes.rb +++ b/lib/api/v3/work_packages/eager_loading/historic_attributes.rb @@ -105,16 +105,17 @@ def timestamps def override_attributes(work_package, source) work_package.attributes = source.attributes.except("timestamp", "journal_id") - override_target_versions(work_package, source) + override_association(work_package, source, :target_versions) + override_association(work_package, source, :labels) work_package.clear_changes_information work_package.readonly! end - def override_target_versions(work_package, source) - return unless work_package.respond_to?(:target_versions) + def override_association(work_package, source, name) + return unless work_package.respond_to?(name) - work_package.association(:target_versions).loaded! - work_package.association(:target_versions).target = source.target_versions.to_a + work_package.association(name).loaded! + work_package.association(name).target = source.public_send(name).to_a end def set_timestamp_attributes(work_package, source, timestamp) diff --git a/lib/api/v3/work_packages/work_package_at_timestamp_representer.rb b/lib/api/v3/work_packages/work_package_at_timestamp_representer.rb index 63045e32118f..7175501e6c1d 100644 --- a/lib/api/v3/work_packages/work_package_at_timestamp_representer.rb +++ b/lib/api/v3/work_packages/work_package_at_timestamp_representer.rb @@ -54,9 +54,12 @@ class WorkPackageAtTimestampRepresenter < WorkPackageRepresenter version target_versions observed_in_versions + labels parent ].freeze + MULTI_VALUED_LINK_PROPERTIES = %w[target_versions observed_in_versions labels].freeze + SUPPORTED_CUSTOM_PROPERTIES = [/^custom_field_\d+$/].freeze SUPPORTED_PROPERTIES = (SUPPORTED_NON_LINK_PROPERTIES + SUPPORTED_LINK_PROPERTIES).freeze @@ -104,7 +107,7 @@ def rendered_properties # contains the lower camel-cased names. def rendered_properties_for_links @rendered_properties_for_links ||= rendered_properties.map do |property| - if property.starts_with?("custom_field_") || %w[target_versions observed_in_versions].include?(property) + if property.starts_with?("custom_field_") || MULTI_VALUED_LINK_PROPERTIES.include?(property) API::Utilities::PropertyNameConverter.from_ar_name(property) else property diff --git a/lib/open_project/journal_formatter/joined_association.rb b/lib/open_project/journal_formatter/joined_association.rb new file mode 100644 index 000000000000..84dbd4ce44e4 --- /dev/null +++ b/lib/open_project/journal_formatter/joined_association.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class OpenProject::JournalFormatter::JoinedAssociation < JournalFormatter::NamedAssociation + private + + def format_values(values, key) + klass = class_from_field(key) + + values.map do |value| + next if value.blank? || klass.nil? + + value.to_s.split(",") + .map { |id| name_or_placeholder(associated_object(klass, id.to_i)) } + .join(", ") + end + end +end diff --git a/lib/open_project/journal_formatter/joined_versions.rb b/lib/open_project/journal_formatter/joined_versions.rb index bf2a4904c0cb..8d4a41584de3 100644 --- a/lib/open_project/journal_formatter/joined_versions.rb +++ b/lib/open_project/journal_formatter/joined_versions.rb @@ -27,26 +27,9 @@ # See COPYRIGHT and LICENSE files for more details. #++ -# Base for formatters rendering the change to a set of versions referenced by a -# work package. Each value is the sorted, comma-joined version ids (see -# JournalChanges); every id is resolved to the version's name, with a -# placeholder for versions that have been deleted or are no longer visible to -# the reader. -class OpenProject::JournalFormatter::JoinedVersions < JournalFormatter::NamedAssociation +class OpenProject::JournalFormatter::JoinedVersions < OpenProject::JournalFormatter::JoinedAssociation private - def format_values(values, key) - klass = class_from_field(key) - - values.map do |value| - next if value.blank? || klass.nil? - - value.to_s.split(",") - .map { |id| name_or_placeholder(associated_object(klass, id.to_i)) } - .join(", ") - end - end - def name_or_placeholder(object) return I18n.t(:label_deleted_version) if object.nil? diff --git a/lib/open_project/journal_formatter/labels.rb b/lib/open_project/journal_formatter/labels.rb new file mode 100644 index 000000000000..ce89795284fa --- /dev/null +++ b/lib/open_project/journal_formatter/labels.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class OpenProject::JournalFormatter::Labels < OpenProject::JournalFormatter::JoinedAssociation + private + + def name_or_placeholder(object) + return I18n.t(:label_deleted_label) if object.nil? + + super + end +end diff --git a/lib_static/plugins/acts_as_journalized/lib/journal_changes.rb b/lib_static/plugins/acts_as_journalized/lib/journal_changes.rb index 16085802f4eb..e782cb32997f 100644 --- a/lib_static/plugins/acts_as_journalized/lib/journal_changes.rb +++ b/lib_static/plugins/acts_as_journalized/lib/journal_changes.rb @@ -47,6 +47,7 @@ def all_changes get_project_phases_changes, get_target_versions_changes, get_observed_in_versions_changes, + get_labels_changes, get_file_links_changes, get_participants_changes, get_agenda_items_changes @@ -155,6 +156,16 @@ def get_observed_in_versions_changes { observed_in_versions: [old_value, new_value] } end + def get_labels_changes + return unless journable.respond_to?(:labels) + + old_value = predecessor && joined_label_ids(predecessor) + new_value = joined_label_ids(self) + return if old_value == new_value + + { labels: [old_value, new_value] } + end + def get_file_links_changes return unless has_file_links? @@ -216,6 +227,10 @@ def joined_observed_in_version_ids(journal) journal.observed_in_version_journals.map(&:version_id).sort.join(",").presence end + def joined_label_ids(journal) + journal.label_journals.map(&:label_id).sort.join(",").presence + end + def participant_baseline_journal journals = journable.journals.to_a current_index = journals.index { |entry| entry.id == id } diff --git a/spec/factories/journal/label_journal_factory.rb b/spec/factories/journal/label_journal_factory.rb new file mode 100644 index 000000000000..207d4091c9eb --- /dev/null +++ b/spec/factories/journal/label_journal_factory.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +FactoryBot.define do + factory :journal_label_journal, class: "Journal::LabelJournal" +end diff --git a/spec/lib/api/v3/work_packages/work_package_at_timestamp_representer_rendering_spec.rb b/spec/lib/api/v3/work_packages/work_package_at_timestamp_representer_rendering_spec.rb index 0f25cf18a788..3754c7a98bce 100644 --- a/spec/lib/api/v3/work_packages/work_package_at_timestamp_representer_rendering_spec.rb +++ b/spec/lib/api/v3/work_packages/work_package_at_timestamp_representer_rendering_spec.rb @@ -50,6 +50,9 @@ let(:version) { build_stubbed(:version) } let(:target_versions) { [version] } let(:observed_in_versions) { [] } + let(:label_a) { build_stubbed(:label) } + let(:label_b) { build_stubbed(:label) } + let(:labels) { [label_a, label_b] } let(:parent) do build_stubbed(:work_package).tap do |wp| allow(wp) @@ -99,7 +102,8 @@ .to receive_messages(available_custom_fields:, custom_field_values: [custom_value], effective_target_versions: target_versions, - effective_observed_in_versions: observed_in_versions) + effective_observed_in_versions: observed_in_versions, + effective_labels: labels) end end let(:timestamp) { Timestamp.new(1.day.ago) } @@ -323,6 +327,46 @@ end end + context "with only the labels changed", with_flag: { work_package_labels: true } do + let(:attributes_changed_to_baseline) { %w[labels] } + + let(:expected_json) do + { + "_meta" => { + "matchesFilters" => true, + "exists" => true, + "timestamp" => timestamp.to_s + }, + "_links" => { + "labels" => [ + { "href" => api_v3_paths.label(label_a.id), "title" => label_a.name }, + { "href" => api_v3_paths.label(label_b.id), "title" => label_b.name } + ], + "self" => { + "href" => api_v3_paths.work_package(work_package.id, timestamps: timestamp), + "title" => work_package.subject + }, + "schema" => { + "href" => api_v3_paths.work_package_schema(work_package.project_id, work_package.type_id) + } + } + }.to_json + end + + it "renders as expected" do + expect(subject) + .to be_json_eql(expected_json) + end + end + + context "with the labels unchanged", with_flag: { work_package_labels: true } do + let(:attributes_changed_to_baseline) { %w[subject] } + + it "does not render the labels" do + expect(subject).not_to have_json_path("_links/labels") + end + end + context "with only the observed in versions changed" do let(:observed_in_versions) { [version] } let(:attributes_changed_to_baseline) { %w[observed_in_versions] } diff --git a/spec/lib/open_project/journal_formatter/labels_spec.rb b/spec/lib/open_project/journal_formatter/labels_spec.rb new file mode 100644 index 000000000000..8f528ac37b8e --- /dev/null +++ b/spec/lib/open_project/journal_formatter/labels_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe OpenProject::JournalFormatter::Labels do + describe "#render" do + let(:label) { build_stubbed(:label, name: "A") } + let(:other_label) { build_stubbed(:label, name: "B") } + let(:work_package) { build_stubbed(:work_package) } + let(:journal) { build_stubbed(:work_package_journal, journable: work_package) } + let(:instance) { described_class.new(journal) } + let(:label_text) { "Labels" } + + before do + allow(Label).to receive(:find_by).and_return(nil) + + [label, other_label].each do |l| + allow(Label).to receive(:find_by).with(id: l.id).and_return(l) + end + end + + context "when setting labels" do + it "renders the label names as the new value" do + expect(instance.render(:labels, [nil, "#{label.id},#{other_label.id}"])) + .to eq(I18n.t(:text_journal_set_to, label: label_text, value: "A, B")) + end + end + + context "when changing labels" do + it "renders the old and new label names" do + expect(instance.render(:labels, [label.id.to_s, other_label.id.to_s])) + .to eq(I18n.t(:text_journal_changed_plain, + label: label_text, + linebreak: nil, + old: "A", + new: "B")) + end + end + + context "when removing all labels" do + it "renders the old label names as deleted" do + expect(instance.render(:labels, ["#{label.id},#{other_label.id}", nil])) + .to eq(I18n.t(:text_journal_deleted, label: label_text, old: "A, B")) + end + end + + context "with a label that no longer exists" do + it "renders a placeholder next to the existing label names" do + expect(instance.render(:labels, [nil, "#{label.id},99999"])) + .to eq(I18n.t(:text_journal_set_to, label: label_text, value: "A, #{I18n.t(:label_deleted_label)}")) + end + end + + context "when setting labels that no longer exist" do + it "renders a placeholder for each missing label" do + placeholder = I18n.t(:label_deleted_label) + + expect(instance.render(:labels, [nil, "99999,99998"])) + .to eq(I18n.t(:text_journal_set_to, label: label_text, value: "#{placeholder}, #{placeholder}")) + end + end + + context "with html: false" do + it "renders plain text" do + expect(instance.render(:labels, [label.id.to_s, other_label.id.to_s], html: false)) + .to eq(I18n.t(:text_journal_changed_plain, + label: "Labels", + linebreak: nil, + old: "A", + new: "B")) + end + end + end +end diff --git a/spec/models/journable/with_historic_attributes_spec.rb b/spec/models/journable/with_historic_attributes_spec.rb index a112800b19f1..0dace097f1af 100644 --- a/spec/models/journable/with_historic_attributes_spec.rb +++ b/spec/models/journable/with_historic_attributes_spec.rb @@ -819,4 +819,107 @@ def update_observed_in_versions(*versions) end end end + + describe "labels" do + shared_let(:label_a) { create(:label) } + shared_let(:label_b) { create(:label) } + + let(:work_package) do + create(:work_package, + subject: "The labelled work package", + project:, + journals: { + created_at => {}, + 1.day.ago => {} + }) + end + + subject { described_class.wrap(work_package, timestamps:) } + + def update_labels(*labels) + work_package.labels = labels + work_package.save! + end + + def seed_baseline_label_journal(label) + create(:journal_label_journal, journal: work_package.journals.first, label:) + end + + describe "#at_timestamp" do + it "returns the labels from the journal snapshot, not the current ones" do + seed_baseline_label_journal(label_a) + update_labels(label_a, label_b) + + expect(subject.at_timestamp(Timestamp.parse("2022-01-01T00:00:00Z")).labels) + .to contain_exactly(label_a) + expect(work_package.reload.labels) + .to contain_exactly(label_a, label_b) + end + + it "omits snapshotted labels that were deleted since" do + update_labels(label_a, label_b) + label_b.destroy! + + # 1 hour in the future so the journal written by the update above is + # unambiguously in the past, regardless of sub-second timing. + expect(subject.at_timestamp(Timestamp.parse(1.hour.from_now.iso8601)).labels) + .to contain_exactly(label_a) + end + end + + describe "#changed_at_timestamp" do + context "when a label was added" do + it "marks the labels as changed" do + update_labels(label_a, label_b) + + expect(subject.changed_at_timestamp(Timestamp.parse("2022-01-01T00:00:00Z"))) + .to contain_exactly("labels") + end + end + + context "when one of several labels was removed" do + it "marks only the labels as changed" do + # Journal rows are stamped by the database clock, so a past removal + # can't be journalled directly; seed the baseline snapshot instead. + seed_baseline_label_journal(label_a) + seed_baseline_label_journal(label_b) + update_labels(label_a) + + expect(subject.changed_at_timestamp(Timestamp.parse("2022-01-01T00:00:00Z"))) + .to contain_exactly("labels") + expect(subject.send(:changes_at_timestamp, Timestamp.parse("2022-01-01T00:00:00Z"))["labels"]) + .to eq([[label_a.id, label_b.id].sort.join(","), label_a.id.to_s]) + end + end + + context "when all labels were removed" do + it "marks the labels as changed" do + seed_baseline_label_journal(label_a) + update_labels + + expect(subject.changed_at_timestamp(Timestamp.parse("2022-01-01T00:00:00Z"))) + .to contain_exactly("labels") + expect(subject.send(:changes_at_timestamp, Timestamp.parse("2022-01-01T00:00:00Z"))["labels"]) + .to eq([label_a.id.to_s, nil]) + end + end + + context "when the label set is unchanged" do + it "reports no label change" do + expect(subject.changed_at_timestamp(Timestamp.parse("2022-01-01T00:00:00Z"))) + .to be_empty + end + end + end + + describe "#changes_at_timestamp" do + it "reports the joined old and new label ids" do + seed_baseline_label_journal(label_a) + update_labels(label_a, label_b) + + expect(subject.send(:changes_at_timestamp, Timestamp.parse("2022-01-01T00:00:00Z"))["labels"]) + .to eq([label_a.id.to_s, [label_a.id, label_b.id].sort.join(",")]) + end + end + end end diff --git a/spec/models/journal/label_journal_spec.rb b/spec/models/journal/label_journal_spec.rb new file mode 100644 index 000000000000..26852b0242c1 --- /dev/null +++ b/spec/models/journal/label_journal_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Journal::LabelJournal do + it "keeps its rows when the label is destroyed" do + journal = create(:work_package_journal) + label = create(:label) + label_journal = described_class.create!(journal:, label:) + + label.destroy! + + expect(label_journal.reload.label_id).to eq(label.id) + end +end diff --git a/spec/models/work_package/work_package_acts_as_journalized_spec.rb b/spec/models/work_package/work_package_acts_as_journalized_spec.rb index 0df4ac2555af..e03f40996e7e 100644 --- a/spec/models/work_package/work_package_acts_as_journalized_spec.rb +++ b/spec/models/work_package/work_package_acts_as_journalized_spec.rb @@ -575,6 +575,154 @@ def set_target_versions(versions) end end + context "on label changes", with_settings: { journal_aggregation_time_minutes: 0 } do + shared_let(:label) { create(:label) } + shared_let(:other_label) { create(:label) } + + shared_let(:journable) do + create(:work_package) + end + + def set_labels(labels) + journable.labelings.destroy_all + labels.each { journable.labelings.create!(label: it) } + journable.save! + end + + context "when setting labels" do + it "creates a new journal listing the labels in the details" do + expect { set_labels([label, other_label]) } + .to change { journable.journals.count }.by(1) + + expect(journable.last_journal.details["labels"]) + .to eq([nil, [label.id, other_label.id].sort.join(",")]) + end + + it "journals labels assigned through label_id_replacements in the same save" do + journable.label_id_replacements = [label.id, other_label.id] + + expect { journable.save! } + .to change { journable.journals.count }.by(1) + + expect(journable.last_journal.details["labels"]) + .to eq([nil, [label.id, other_label.id].sort.join(",")]) + expect(Journal::LabelJournal.where(journal: journable.last_journal).pluck(:label_id)) + .to contain_exactly(label.id, other_label.id) + end + + it "touches the journable to match the journal's timestamp" do + expect { set_labels([label]) } + .to change { journable.reload.updated_at } + + expect(journable.updated_at).to eq(journable.last_journal.updated_at) + end + end + + context "on work package creation" do + it "creates an initial journal listing the labels in the details" do + work_package = create(:work_package, labels: [label]) + + expect(work_package.journals.count).to eq(1) + expect(work_package.last_journal.details["labels"]) + .to eq([nil, label.id.to_s]) + expect(Journal::LabelJournal.where(journal_id: work_package.last_journal.id).count) + .to eq(1) + end + end + + context "when replacing a label" do + before do + set_labels([label]) + end + + it "creates a new journal with the old and new labels in the details" do + expect { set_labels([other_label]) } + .to change { journable.journals.count }.by(1) + + expect(journable.last_journal.details["labels"]) + .to eq([label.id.to_s, other_label.id.to_s]) + end + end + + context "when removing all labels" do + before do + set_labels([label]) + end + + it "creates a new journal with an empty new value in the details" do + expect { set_labels([]) } + .to change { journable.journals.count }.by(1) + + expect(journable.last_journal.details["labels"]) + .to eq([label.id.to_s, nil]) + end + end + + context "when saving with unchanged labels" do + before do + set_labels([label]) + end + + it "creates no journal and does not touch the journable" do + expect { set_labels([label]) } + .to not_change { journable.journals.count } + .and(not_change { journable.reload.updated_at }) + end + end + + context "when another labelable shares the work package's id" do + it "journals only the work package's own labels" do + Labeling.create!(label:, labelable_type: "Project", labelable_id: journable.id) + + expect { set_labels([other_label]) } + .to change { journable.journals.count }.by(1) + + expect(journable.last_journal.details["labels"]) + .to eq([nil, other_label.id.to_s]) + end + end + + context "within aggregation time", with_settings: { journal_aggregation_time_minutes: 5 } do + # The initial journal lies outside the aggregation window so that only + # the journals created by the examples can aggregate with each other. + shared_let(:journable) do + create(:work_package, + subject: "Initial subject", + journals: { 10.minutes.ago => { user: } }) + end + + context "when changing labels again" do + before do + set_labels([label]) + end + + it "aggregates into the previous journal with cumulative details" do + expect { set_labels([label, other_label]) } + .not_to change { journable.journals.count } + + expect(journable.last_journal.details["labels"]) + .to eq([nil, [label.id, other_label.id].sort.join(",")]) + end + end + + context "when changing labels shortly after another change" do + before do + journable.update!(subject: "Changed subject") + end + + it "aggregates into one journal capturing both changes" do + expect { set_labels([label]) } + .not_to change { journable.journals.count } + + expect(journable.last_journal.details["subject"]) + .to eq(["Initial subject", "Changed subject"]) + expect(journable.last_journal.details["labels"]) + .to eq([nil, label.id.to_s]) + end + end + end + end + context "on observed in version changes", with_settings: { journal_aggregation_time_minutes: 0 } do shared_let(:journable) do create(:work_package) diff --git a/spec/requests/api/v3/work_packages/index_resource_spec.rb b/spec/requests/api/v3/work_packages/index_resource_spec.rb index af33db81785a..3940a0212e2c 100644 --- a/spec/requests/api/v3/work_packages/index_resource_spec.rb +++ b/spec/requests/api/v3/work_packages/index_resource_spec.rb @@ -464,6 +464,48 @@ def create_customizable_journal(journal:, custom_field:, value:) end end + context "when the labels changed and the last timestamp is historic", + with_flag: { work_package_labels: true } do + let(:timestamps) { [Timestamp.parse("2015-01-01T00:00:00Z")] } + let(:label_a) { create(:label) } + let(:label_b) { create(:label) } + let(:label_hrefs) do + JSON.parse(subject.body).dig("_embedded", "elements", 0, "_links", "labels").pluck("href") + end + + before do + create(:labeling, labelable: work_package, label: label_a) + create(:labeling, labelable: work_package, label: label_b) + create(:journal_label_journal, journal: current_journal, label: label_a) + create(:journal_label_journal, journal: current_journal, label: label_b) + end + + it "renders the historic labels on the work package" do + create(:journal_label_journal, journal: original_journal, label: label_a) + + expect(label_hrefs) + .to contain_exactly(api_v3_paths.label(label_a.id)) + end + + it "renders no labels when the work package had none at the timestamp" do + expect(label_hrefs).to eq([]) + end + + context "when comparing the baseline with the current state" do + let(:timestamps) { [Timestamp.parse("2015-01-01T00:00:00Z"), Timestamp.now] } + + it "embeds the historic labels in the baseline entry only" do + create(:journal_label_journal, journal: original_journal, label: label_a) + + expect(subject.body) + .to be_json_eql([{ href: api_v3_paths.label(label_a.id), title: label_a.name }].to_json) + .at_path("_embedded/elements/0/_embedded/attributesByTimestamp/0/_links/labels") + expect(subject.body) + .not_to have_json_path("_embedded/elements/0/_embedded/attributesByTimestamp/1/_links/labels") + end + end + end + context "when a custom value changes" do before do custom_value diff --git a/spec/services/journals/create_service/association_spec.rb b/spec/services/journals/create_service/association_spec.rb index e4162aeeba3c..3249b128c11b 100644 --- a/spec/services/journals/create_service/association_spec.rb +++ b/spec/services/journals/create_service/association_spec.rb @@ -64,11 +64,20 @@ allow(journable).to receive(:respond_to?).with(:file_links).and_return(false) allow(journable).to receive(:respond_to?).with(:agenda_items).and_return(false) allow(journable).to receive(:respond_to?).with(:phases).and_return(false) + allow(journable).to receive(:respond_to?).with(:labels).and_return(false) associations = described_class.for(journable) expect(associations.map(&:class)).to include(Journals::CreateService::Attachable) end + it "includes Labelable for a journable that responds to labels" do + journable = instance_double(WorkPackage, customizable?: false, respond_to?: false) + allow(journable).to receive(:respond_to?).with(:labels).and_return(true) + + associations = described_class.for(journable) + expect(associations.map(&:class)).to include(Journals::CreateService::Labelable) + end + it "excludes associations whose #associated? returns false" do journable = instance_double(WorkPackage, customizable?: false, respond_to?: false) allow(journable).to receive(:respond_to?).and_return(false) diff --git a/spec/services/work_packages/activities_tab/paginator_spec.rb b/spec/services/work_packages/activities_tab/paginator_spec.rb index 4e33ddbdb997..b401faed8b9a 100644 --- a/spec/services/work_packages/activities_tab/paginator_spec.rb +++ b/spec/services/work_packages/activities_tab/paginator_spec.rb @@ -822,6 +822,54 @@ end end + context "with label changes" do + let(:label_a) { create(:label, name: "alpha") } + let(:label_b) { create(:label, name: "beta") } + + def save_labels(*labels) + work_package.reload.label_id_replacements = labels.map(&:id) + work_package.save! + work_package.journals.order(:version).last + end + + it "includes journal where a label was added" do + journal = save_labels(label_a) + + _pagy, records = paginator.call + expect(records.map(&:id)).to include(journal.id) + end + + it "includes journal where a label was replaced" do + save_labels(label_a) + journal = save_labels(label_b) + + _pagy, records = paginator.call + expect(records.map(&:id)).to include(journal.id) + end + + it "includes journal where a label was removed" do + save_labels(label_a) + journal = save_labels + + _pagy, records = paginator.call + expect(records.map(&:id)).to include(journal.id) + end + + it "excludes journal with only a label snapshot" do + save_labels(label_a) + work_package.reload.add_journal(notes: "Unrelated change") + work_package.save! + snapshot = work_package.journals.order(:version).last + + _pagy, records = paginator.call + expect(snapshot.reload.label_journals.count).to eq(1) + changes = snapshot.reload.get_changes + expect(changes).to eq({}) + + expect(records.map(&:id)).not_to include(snapshot.id) + end + end + context "with target version changes" do let!(:journal_with_target_version_added) do version = create(:version, project:)