From c56c2f1ddab278acfa74280f1d113b0df7c61003 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Wed, 5 Aug 2026 00:46:51 +0200 Subject: [PATCH 1/9] Add effective_observed_in_versions to the work package model Mirrors #effective_target_versions so that pending, not yet persisted overrides of the observed version set are visible to readers such as the APIv3 representer. Unlike the target kind there is no deprecated single-value column to reconcile, so only the override is considered. Co-Authored-By: Claude Opus 5 --- app/contracts/work_packages/base_contract.rb | 3 +++ app/models/work_package/versions.rb | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/app/contracts/work_packages/base_contract.rb b/app/contracts/work_packages/base_contract.rb index 2cb0ebb6bbdd..917cbece33cf 100644 --- a/app/contracts/work_packages/base_contract.rb +++ b/app/contracts/work_packages/base_contract.rb @@ -54,6 +54,9 @@ class BaseContract < ::ModelContract permission: :assign_versions do validate_target_versions_are_assignable end + # Observed versions have no deprecated single-value counterpart to coexist + # with, so unlike target versions they are always offered and always + # multi-valued. attribute :observed_in_versions, permission: :assign_versions do validate_observed_in_versions_are_assignable diff --git a/app/models/work_package/versions.rb b/app/models/work_package/versions.rb index 6c5df559c231..33980513ecfc 100644 --- a/app/models/work_package/versions.rb +++ b/app/models/work_package/versions.rb @@ -187,6 +187,18 @@ def effective_target_versions target_version_ids_replacements.filter_map { |id| versions_by_id[id] } end + # List of observed in versions, but takes into account pending overrides that + # were not written yet. + # + # There is no deprecated single-value column mirroring this kind, so unlike + # #effective_target_versions only the override has to be considered. + def effective_observed_in_versions + return observed_in_versions if observed_in_version_ids_replacements.nil? + + versions_by_id = Version.where(id: observed_in_version_ids_replacements).index_by(&:id) + observed_in_version_ids_replacements.filter_map { |id| versions_by_id[id] } + end + # An override can also originate from the system, e.g. when versions that are # not shared with the (new) project are cleared on a project change. Such # overrides are marked here so that contracts don't attribute them to the From 8bc83d1bbc6bf5b12093f55bf87682eda4683c98 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Wed, 5 Aug 2026 00:47:04 +0200 Subject: [PATCH 2/9] Expose observedInVersions in the APIv3 work package resource Adds the link, embed and setter to the work package representer and the matching schema property, whose allowed values come from the contract's assignable_observed_in_versions (all shared versions, closed ones included). The attribute is always a collection, so options.multiple is unconditionally true - there is no single-valued field it stands in for. Co-Authored-By: Claude Opus 5 --- .../schema/specific_work_package_schema.rb | 1 + .../schema/work_package_schema_representer.rb | 16 +++++++++++ .../work_packages/work_package_representer.rb | 28 ++++++++++++++++++- .../pdf_export/work_package_to_pdf_spec.rb | 1 + 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/lib/api/v3/work_packages/schema/specific_work_package_schema.rb b/lib/api/v3/work_packages/schema/specific_work_package_schema.rb index a62908b970a3..ad86dc0b534d 100644 --- a/lib/api/v3/work_packages/schema/specific_work_package_schema.rb +++ b/lib/api/v3/work_packages/schema/specific_work_package_schema.rb @@ -56,6 +56,7 @@ def initialize(work_package:) :assignable_priorities, :assignable_versions, :assignable_target_versions, + :assignable_observed_in_versions, :assignable_budgets, :assignable_project_phases, to: :contract diff --git a/lib/api/v3/work_packages/schema/work_package_schema_representer.rb b/lib/api/v3/work_packages/schema/work_package_schema_representer.rb index 79c9d7427051..504dddec0cf9 100644 --- a/lib/api/v3/work_packages/schema/work_package_schema_representer.rb +++ b/lib/api/v3/work_packages/schema/work_package_schema_representer.rb @@ -334,6 +334,22 @@ def initialize(schema, self_link:, **context) required: false, options: -> { { multiple: Setting::WorkPackageMultipleVersions.active? } } + # Unlike target versions, observed versions have no single-valued + # predecessor to stand in for, so the attribute is always offered and + # always a collection. + schema_with_allowed_collection :observed_in_versions, + type: "[]Version", + value_representer: Versions::VersionRepresenter, + link_factory: ->(version) { + { + href: api_v3_paths.version(version.id), + title: version.name + } + }, + writable: ->(*) { represented.writable?(:observed_in_versions) }, + required: false, + options: -> { { multiple: true } } + schema_with_allowed_collection :priority, value_representer: Priorities::PriorityRepresenter, link_factory: ->(priority) { diff --git a/lib/api/v3/work_packages/work_package_representer.rb b/lib/api/v3/work_packages/work_package_representer.rb index d7dac7f07d58..fcd237dfe1a0 100644 --- a/lib/api/v3/work_packages/work_package_representer.rb +++ b/lib/api/v3/work_packages/work_package_representer.rb @@ -606,6 +606,31 @@ def self_v3_path(*) represented.target_version_ids = parse_link_ids_from_fragment(fragment, :version).compact end + associated_resources :observed_in_versions, + v3_path: :version, + representer: ::API::V3::Versions::VersionRepresenter, + getter: ->(*) { + next unless embed_link?(:observed_in_versions) + + represented.effective_observed_in_versions.map do |version| + ::API::V3::Versions::VersionRepresenter.create(version, current_user:) + end + }, + link: ->(*) { + represented.effective_observed_in_versions.map do |version| + ::API::Decorators::LinkObject + .new(version, + property_name: :itself, + path: :version, + getter: :id, + title_attribute: :name) + .to_hash + end + }, + setter: ->(fragment:, **) do + represented.observed_in_version_ids = parse_link_ids_from_fragment(fragment, :version).compact + end + associated_resource :parent, v3_path: :work_package, representer: ::API::V3::WorkPackages::WorkPackageRepresenter, @@ -830,7 +855,8 @@ def ordered_custom_actions watchers attachments budget - target_versions] + target_versions + observed_in_versions] # The dynamic class generation introduced because of the custom fields interferes with # the class naming as well as prevents calls to super diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb index 2b3bb51b9854..7af9388313dd 100644 --- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb +++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb @@ -250,6 +250,7 @@ "Priority", "Normal", *(work_package.sprint.present? ? ["Sprint", work_package.sprint] : ["Sprint"]), *(work_package.backlog_bucket.present? ? ["Backlog bucket", work_package.backlog_bucket] : ["Backlog bucket"]), + "Observed versions", "Version", work_package.version, "Category", work_package.category, "Project phase", From 78e6f5138144fdf8a8a9cac294948d37e6f7bb74 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Wed, 5 Aug 2026 00:47:04 +0200 Subject: [PATCH 3/9] Fold every version kind into the work package cache checksum observedInVersions is now part of the representation, so a change to an observed version has to invalidate the cached JSON. The correlated subquery drops its kind restriction and aggregates the kind alongside the version, so that moving a version between kinds is detected as well. Co-Authored-By: Claude Opus 5 --- .../work_packages/eager_loading/checksum.rb | 21 ++++++++++--------- .../cache_checksum_integration_spec.rb | 4 ++-- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/lib/api/v3/work_packages/eager_loading/checksum.rb b/lib/api/v3/work_packages/eager_loading/checksum.rb index c7bb0c0e707e..f94bed8601a2 100644 --- a/lib/api/v3/work_packages/eager_loading/checksum.rb +++ b/lib/api/v3/work_packages/eager_loading/checksum.rb @@ -54,19 +54,20 @@ def fetch_checksums_for(work_packages) protected - # The target versions associated via work_package_versions are a - # has_many, which the left_joins/pluck design above cannot express - # (it would multiply rows), so they enter the checksum as an - # aggregated correlated subquery. This also covers versions beyond - # the first, which the deprecated version association could not see. - # Only "target" rows are folded in: they are what the representer - # renders (as `version` and `targetVersions`); observed_in versions - # do not appear in the representation and must not bust its cache. + # The versions associated via work_package_versions are a has_many, + # which the left_joins/pluck design above cannot express (it would + # multiply rows), so they enter the checksum as an aggregated + # correlated subquery. This also covers versions beyond the first, + # which the deprecated version association could not see. + # Every kind is folded in, since the representer renders both the + # target (as `version` and `targetVersions`) and the observed_in + # rows (as `observedInVersions`). The kind is part of the aggregated + # value so that moving a version between kinds busts the cache too. VERSIONS_CHECKSUM_SQL = <<~SQL.squish - (SELECT COALESCE(STRING_AGG(CONCAT(v.id, v.updated_at), ',' ORDER BY v.id), '') + (SELECT COALESCE(STRING_AGG(CONCAT(wpv.kind, v.id, v.updated_at), ',' ORDER BY wpv.kind, v.id), '') FROM work_package_versions wpv INNER JOIN versions v ON v.id = wpv.version_id - WHERE wpv.work_package_id = work_packages.id AND wpv.kind = 'target') + WHERE wpv.work_package_id = work_packages.id) SQL def md5_concat diff --git a/spec/lib/api/v3/work_packages/eager_loading/cache_checksum_integration_spec.rb b/spec/lib/api/v3/work_packages/eager_loading/cache_checksum_integration_spec.rb index cab3e50536db..43ac9482935d 100644 --- a/spec/lib/api/v3/work_packages/eager_loading/cache_checksum_integration_spec.rb +++ b/spec/lib/api/v3/work_packages/eager_loading/cache_checksum_integration_spec.rb @@ -145,12 +145,12 @@ .not_to eql orig_checksum end - it "produces the same checksum on changes to an observed_in version" do + it "produces a different checksum on changes to an observed_in version" do other_version = create(:version, project:) work_package.work_package_versions.create!(version: other_version, kind: "observed_in") expect(new_checksum) - .to eql orig_checksum + .not_to eql orig_checksum end it "produces a different checksum on changes to the type id" do From 71e44336ee8868bdb69a03054a5439a8d889d42e Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Wed, 5 Aug 2026 00:47:15 +0200 Subject: [PATCH 4/9] Add an observed versions work package query column Sortable and groupable through the same aggregated subqueries the target versions column uses, restricted to the observed_in kind. Co-Authored-By: Claude Opus 5 --- .../work_packages/selects/property_select.rb | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/models/queries/work_packages/selects/property_select.rb b/app/models/queries/work_packages/selects/property_select.rb index 57c16655c41e..bd7f3ab1e625 100644 --- a/app/models/queries/work_packages/selects/property_select.rb +++ b/app/models/queries/work_packages/selects/property_select.rb @@ -144,6 +144,29 @@ def caption WHERE wpv.work_package_id = work_packages.id AND wpv.kind = 'target') SQL }, + observed_in_versions: { + sortable: [ + <<~SQL.squish, + (SELECT STRING_AGG(LOWER(v.name), ' ' ORDER BY LOWER(v.name), wpv.version_id) + FROM work_package_versions wpv + INNER JOIN versions v ON v.id = wpv.version_id + WHERE wpv.work_package_id = work_packages.id AND wpv.kind = 'observed_in') + SQL + <<~SQL.squish + (SELECT STRING_AGG(wpv.version_id::text, '.' ORDER BY LOWER(v.name), wpv.version_id) + FROM work_package_versions wpv + INNER JOIN versions v ON v.id = wpv.version_id + WHERE wpv.work_package_id = work_packages.id AND wpv.kind = 'observed_in') + SQL + ], + groupable: + <<~SQL.squish + (SELECT STRING_AGG(wpv.version_id::text, '.' ORDER BY LOWER(v.name), wpv.version_id) + FROM work_package_versions wpv + INNER JOIN versions v ON v.id = wpv.version_id + WHERE wpv.work_package_id = work_packages.id AND wpv.kind = 'observed_in') + SQL + }, start_date: { sortable: "#{WorkPackage.table_name}.start_date" }, From e5590a1717b96636b44c68b40fd1564c66ed33c4 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Wed, 5 Aug 2026 00:47:15 +0200 Subject: [PATCH 5/9] Add an observed versions work package query filter FilterOnTargetVersionsMixin becomes FilterOnWorkPackageVersionsMixin, parameterised over the work_package_versions kind its including filter declares, so the new filter shares the operator handling of the existing ones. The APIv3 filter dependency representer is a subclass of the version one, whose allowed values are identical; a dedicated class is needed only because the factory resolves representers by filter class name. Co-Authored-By: Claude Opus 5 --- app/models/queries/work_packages.rb | 1 + ... filter_on_work_package_versions_mixin.rb} | 37 ++- .../filter/observed_in_versions_filter.rb | 39 +++ .../filter/target_versions_filter.rb | 4 +- .../work_packages/filter/version_filter.rb | 4 +- ..._versions_filter_dependency_representer.rb | 46 +++ ...ions_filter_dependency_representer_spec.rb | 165 ++++++++++ .../observed_in_versions_filter_spec.rb | 281 ++++++++++++++++++ 8 files changed, 559 insertions(+), 18 deletions(-) rename app/models/queries/work_packages/filter/{filter_on_target_versions_mixin.rb => filter_on_work_package_versions_mixin.rb} (78%) create mode 100644 app/models/queries/work_packages/filter/observed_in_versions_filter.rb create mode 100644 lib/api/v3/queries/schemas/observed_in_versions_filter_dependency_representer.rb create mode 100644 spec/lib/api/v3/queries/schemas/observed_in_versions_filter_dependency_representer_spec.rb create mode 100644 spec/models/queries/work_packages/filter/observed_in_versions_filter_spec.rb diff --git a/app/models/queries/work_packages.rb b/app/models/queries/work_packages.rb index 26b3589eb3c8..1f648f49d273 100644 --- a/app/models/queries/work_packages.rb +++ b/app/models/queries/work_packages.rb @@ -59,6 +59,7 @@ module Queries::WorkPackages filter Filter::UpdatedAtFilter filter Filter::VersionFilter filter Filter::TargetVersionsFilter + filter Filter::ObservedInVersionsFilter filter Filter::WatcherFilter filter Filter::DatesIntervalFilter filter Filter::ParentFilter diff --git a/app/models/queries/work_packages/filter/filter_on_target_versions_mixin.rb b/app/models/queries/work_packages/filter/filter_on_work_package_versions_mixin.rb similarity index 78% rename from app/models/queries/work_packages/filter/filter_on_target_versions_mixin.rb rename to app/models/queries/work_packages/filter/filter_on_work_package_versions_mixin.rb index 8a16f5e388c1..c9da6ec352bc 100644 --- a/app/models/queries/work_packages/filter/filter_on_target_versions_mixin.rb +++ b/app/models/queries/work_packages/filter/filter_on_work_package_versions_mixin.rb @@ -28,7 +28,12 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module Queries::WorkPackages::Filter::FilterOnTargetVersionsMixin +# Filters work packages on the versions referenced through +# work_package_versions. +# +# Including filters must define #version_kind, returning the +# work_package_versions kind they match on. +module Queries::WorkPackages::Filter::FilterOnWorkPackageVersionsMixin STATUS_BY_OPERATOR = { "o" => "open", "c" => "closed", "l" => "locked" }.freeze def allowed_values @@ -57,7 +62,7 @@ def ar_object_filter? end def where - target_versions_where + versions_where end def value_objects @@ -90,40 +95,40 @@ def versions end end - def target_versions_where + def versions_where case operator when "!" # is not - "NOT (#{target_version_matching_values})" + "NOT (#{version_matching_values})" when "!*" # empty - "NOT (#{any_target_version_associated})" + "NOT (#{any_version_associated})" when "*" # not empty - any_target_version_associated + any_version_associated when "o", "c", "l" # version status - target_version_with_status(STATUS_BY_OPERATOR[operator]) + version_with_status(STATUS_BY_OPERATOR[operator]) else # "=" is (or) - target_version_matching_values + version_matching_values end end - def any_target_version_associated - "EXISTS (#{target_associations.select(1).to_sql})" + def any_version_associated + "EXISTS (#{kind_associations.select(1).to_sql})" end - def target_version_matching_values - "EXISTS (#{target_associations.where(version_id: values).select(1).to_sql})" + def version_matching_values + "EXISTS (#{kind_associations.where(version_id: values).select(1).to_sql})" end - def target_version_with_status(status) - sub = target_associations + def version_with_status(status) + sub = kind_associations .joins(:version) .where(Version.table_name => { status: }) .select(1) "EXISTS (#{sub.to_sql})" end - def target_associations + def kind_associations WorkPackageVersion - .where(kind: "target") + .where(kind: version_kind) .where("#{WorkPackageVersion.table_name}.work_package_id = #{WorkPackage.table_name}.id") end end diff --git a/app/models/queries/work_packages/filter/observed_in_versions_filter.rb b/app/models/queries/work_packages/filter/observed_in_versions_filter.rb new file mode 100644 index 000000000000..6200a3db3127 --- /dev/null +++ b/app/models/queries/work_packages/filter/observed_in_versions_filter.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 Queries::WorkPackages::Filter::ObservedInVersionsFilter < + Queries::WorkPackages::Filter::WorkPackageFilter + include ::Queries::WorkPackages::Filter::FilterOnWorkPackageVersionsMixin + + def version_kind = "observed_in" + + def self.key = :observed_in_version_id + def human_name = WorkPackage.human_attribute_name("observed_in_versions") +end diff --git a/app/models/queries/work_packages/filter/target_versions_filter.rb b/app/models/queries/work_packages/filter/target_versions_filter.rb index 00ae4f35c814..806496dcd85f 100644 --- a/app/models/queries/work_packages/filter/target_versions_filter.rb +++ b/app/models/queries/work_packages/filter/target_versions_filter.rb @@ -30,7 +30,9 @@ class Queries::WorkPackages::Filter::TargetVersionsFilter < Queries::WorkPackages::Filter::WorkPackageFilter - include ::Queries::WorkPackages::Filter::FilterOnTargetVersionsMixin + include ::Queries::WorkPackages::Filter::FilterOnWorkPackageVersionsMixin + + def version_kind = "target" def self.key = :target_version_id def human_name = WorkPackage.human_attribute_name("target_versions") diff --git a/app/models/queries/work_packages/filter/version_filter.rb b/app/models/queries/work_packages/filter/version_filter.rb index 3593510bdc5e..f7d49d912202 100644 --- a/app/models/queries/work_packages/filter/version_filter.rb +++ b/app/models/queries/work_packages/filter/version_filter.rb @@ -32,7 +32,9 @@ class Queries::WorkPackages::Filter::VersionFilter < Queries::WorkPackages::Filter::WorkPackageFilter # Filters on `target_versions` as it is replacing # the legacy `work_packages.version_id` column. - include ::Queries::WorkPackages::Filter::FilterOnTargetVersionsMixin + include ::Queries::WorkPackages::Filter::FilterOnWorkPackageVersionsMixin + + def version_kind = "target" def human_name WorkPackage.human_attribute_name("version") diff --git a/lib/api/v3/queries/schemas/observed_in_versions_filter_dependency_representer.rb b/lib/api/v3/queries/schemas/observed_in_versions_filter_dependency_representer.rb new file mode 100644 index 000000000000..9935428d2c31 --- /dev/null +++ b/lib/api/v3/queries/schemas/observed_in_versions_filter_dependency_representer.rb @@ -0,0 +1,46 @@ +# 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. +#++ + +module API + module V3 + module Queries + module Schemas + # The allowed values of the observed versions filter are the same + # collection of versions the single version filter offers, so the + # dependency representation is identical. A dedicated class is still + # needed, as FilterDependencyRepresenterFactory resolves the representer + # from the filter's class name. + class ObservedInVersionsFilterDependencyRepresenter < + VersionFilterDependencyRepresenter + end + end + end + end +end diff --git a/spec/lib/api/v3/queries/schemas/observed_in_versions_filter_dependency_representer_spec.rb b/spec/lib/api/v3/queries/schemas/observed_in_versions_filter_dependency_representer_spec.rb new file mode 100644 index 000000000000..ada73f80c039 --- /dev/null +++ b/spec/lib/api/v3/queries/schemas/observed_in_versions_filter_dependency_representer_spec.rb @@ -0,0 +1,165 @@ +# 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 API::V3::Queries::Schemas::ObservedInVersionsFilterDependencyRepresenter do + include API::V3::Utilities::PathHelper + + let(:project) { build_stubbed(:project) } + let(:query) { build_stubbed(:query, project:) } + let(:filter) { Queries::WorkPackages::Filter::ObservedInVersionsFilter.create!(context: query) } + let(:form_embedded) { false } + + let(:instance) do + described_class.new(filter, + operator, + form_embedded:) + end + + subject(:generated) { instance.to_json } + + describe "generation" do + describe "properties" do + describe "values" do + let(:path) { "values" } + let(:type) { "[]Version" } + let(:order) { "sortBy=#{CGI.escape(JSON.dump([%i(name asc)]))}&pageSize=-1" } + + context "for operator 'Queries::Operators::All'" do + let(:operator) { Queries::Operators::All } + + it_behaves_like "filter dependency empty" + end + + context "for operator 'Queries::Operators::None'" do + let(:operator) { Queries::Operators::None } + + it_behaves_like "filter dependency empty" + end + + context "within project" do + let(:href) do + "#{api_v3_paths.versions_by_workspace(project.id)}?#{order}" + end + + context "for operator 'Queries::Operators::Equals'" do + let(:operator) { Queries::Operators::Equals } + + it_behaves_like "filter dependency with allowed link" + end + + context "for operator 'Queries::Operators::NotEquals'" do + let(:operator) { Queries::Operators::NotEquals } + + it_behaves_like "filter dependency with allowed link" + end + end + + context "without a project" do + let(:project) { nil } + let(:href) do + "#{api_v3_paths.versions}?#{order}" + end + + context "for operator 'Queries::Operators::Equals'" do + let(:operator) { Queries::Operators::Equals } + + it_behaves_like "filter dependency with allowed link" + end + + context "for operator 'Queries::Operators::NotEquals'" do + let(:operator) { Queries::Operators::NotEquals } + + it_behaves_like "filter dependency with allowed link" + end + end + end + end + + describe "caching" do + let(:operator) { Queries::Operators::Equals } + let(:other_project) { build_stubbed(:project) } + + before do + allow(instance).to receive(:to_hash).and_call_original + + # fill the cache + instance.to_json + end + + it "is cached" do + instance.to_json + + expect(instance) + .to have_received(:to_hash).once + end + + it "busts the cache on a different operator" do + instance.send(:operator=, Queries::Operators::NotEquals) + + instance.to_json + + expect(instance) + .to have_received(:to_hash).twice + end + + it "busts the cache on a different project" do + query.project = other_project + + instance.to_json + + expect(instance) + .to have_received(:to_hash).twice + end + + it "busts the cache on changes to the locale" do + I18n.with_locale(:de) do + instance.to_json + end + + expect(instance) + .to have_received(:to_hash).twice + end + + it "busts the cache on different form_embedded" do + embedded_instance = described_class.new(filter, + operator, + form_embedded: !form_embedded) + allow(embedded_instance).to receive(:to_hash).and_call_original + + embedded_instance.to_json + + expect(embedded_instance) + .to have_received(:to_hash).once + end + end + end +end diff --git a/spec/models/queries/work_packages/filter/observed_in_versions_filter_spec.rb b/spec/models/queries/work_packages/filter/observed_in_versions_filter_spec.rb new file mode 100644 index 000000000000..c1193fe5c377 --- /dev/null +++ b/spec/models/queries/work_packages/filter/observed_in_versions_filter_spec.rb @@ -0,0 +1,281 @@ +# 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 Queries::WorkPackages::Filter::ObservedInVersionsFilter do + let(:actual_project) { create(:project) } + let(:version) { create(:version, project: actual_project) } + let(:other_project_version) { create(:version, project: create(:project)) } + + let(:role) { create(:project_role, permissions: %i[view_work_packages]) } + let(:user) { create(:user, member_with_roles: { actual_project => role }) } + + before { login_as(user) } + + it_behaves_like "basic query filter" do + let(:project) { actual_project } + let(:type) { :list_optional } + let(:class_key) { :observed_in_version_id } + let(:values) { [version.id.to_s] } + let(:name) { WorkPackage.human_attribute_name("observed_in_versions") } + + # Unlike the target versions filter, this one has no deprecated + # single-valued counterpart to alternate with, so it is offered regardless + # of the multiple versions feature. + describe "#available?" do + context "with the feature flag and the setting enabled", + with_flag: { work_package_multiple_versions: true }, + with_settings: { work_package_multiple_versions: true } do + it "is available" do + expect(instance).to be_available + end + end + + context "with the feature flag and the setting disabled", + with_flag: { work_package_multiple_versions: false }, + with_settings: { work_package_multiple_versions: false } do + it "is still available" do + expect(instance).to be_available + end + end + end + + describe "#valid?" do + context "within a project" do + context "and the version belongs to the project" do + it "is valid" do + expect(instance).to be_valid + end + end + + context "and the version is from another project" do + let(:values) { [other_project_version.id.to_s] } + + it "is not valid" do + expect(instance).not_to be_valid + end + end + end + + context "without a project" do + let(:project) { nil } + + context "and the version is visible to the user" do + it "is valid" do + expect(instance).to be_valid + end + end + + context "and the version does not exist" do + let(:values) { ["12345"] } + + it "is not valid" do + expect(instance).not_to be_valid + end + end + end + + context "with a version status operator and no values" do + let(:operator) { "o" } + let(:values) { [] } + + it "is valid" do + expect(instance).to be_valid + end + end + end + + describe "#allowed_values" do + context "within a project" do + it "returns the project's shared versions" do + expect(instance.allowed_values) + .to contain_exactly([version.id.to_s, version.id.to_s]) + end + end + + context "without a project" do + let(:project) { nil } + + it "returns only versions visible to the current user" do + other_project_version + + expect(instance.allowed_values) + .to contain_exactly([version.id.to_s, version.id.to_s]) + end + end + end + + describe "#value_objects" do + let!(:other_version) { create(:version, project: actual_project) } + + it "returns the Version records matching the filter values" do + expect(instance.value_objects).to contain_exactly(version) + end + end + + describe "#available_operators" do + it "includes the version status operators" do + expect(instance.available_operators).to include( + Queries::Operators::Versions::OpenStatus, + Queries::Operators::Versions::ClosedStatus, + Queries::Operators::Versions::LockedStatus + ) + end + end + + describe "#operator_strategy" do + context 'for "o"' do + let(:operator) { "o" } + + it "is the open status operator" do + expect(instance.operator_strategy).to eq(Queries::Operators::Versions::OpenStatus) + end + end + + context 'for "c"' do + let(:operator) { "c" } + + it "is the closed status operator" do + expect(instance.operator_strategy).to eq(Queries::Operators::Versions::ClosedStatus) + end + end + + context 'for "l"' do + let(:operator) { "l" } + + it "is the locked status operator" do + expect(instance.operator_strategy).to eq(Queries::Operators::Versions::LockedStatus) + end + end + + context 'for "="' do + it "is the equals operator" do + expect(instance.operator_strategy).to eq(Queries::Operators::EqualsOr) + end + end + end + + describe "#where" do + let(:open_version) { version } + let(:closed_version) { create(:version, project: actual_project, status: "closed") } + let(:locked_version) { create(:version, project: actual_project, status: "locked") } + + let!(:wp_observing_open) do + create(:work_package, project: actual_project).tap do |wp| + create(:work_package_version, work_package: wp, version: open_version, kind: :observed_in) + end + end + let!(:wp_observing_closed) do + create(:work_package, project: actual_project).tap do |wp| + create(:work_package_version, work_package: wp, version: closed_version, kind: :observed_in) + end + end + let!(:wp_observing_locked) do + create(:work_package, project: actual_project).tap do |wp| + create(:work_package_version, work_package: wp, version: locked_version, kind: :observed_in) + end + end + # The mirror of the target filter's guard: a target row on the same + # version must never be mistaken for an observed one. + let!(:wp_targeting_only) do + create(:work_package, project: actual_project).tap do |wp| + create(:work_package_version, work_package: wp, version: open_version, kind: :target) + end + end + let!(:wp_without_versions) { create(:work_package, project: actual_project) } + + subject(:result) { WorkPackage.where(instance.where) } + + context 'for "=" with a version' do + let(:values) { [open_version.id.to_s] } + + it "returns work packages observed in that version" do + expect(result).to contain_exactly(wp_observing_open) + end + end + + context 'for "!" with a version' do + let(:operator) { "!" } + let(:values) { [open_version.id.to_s] } + + it "returns work packages not observed in that version, including ones without observed versions" do + expect(result).to contain_exactly(wp_observing_closed, wp_observing_locked, wp_targeting_only, + wp_without_versions) + end + end + + context 'for "*" (any observed version)' do + let(:operator) { "*" } + let(:values) { [] } + + it "returns work packages with at least one observed version" do + expect(result).to contain_exactly(wp_observing_open, wp_observing_closed, wp_observing_locked) + end + end + + context 'for "!*" (no observed version)' do + let(:operator) { "!*" } + let(:values) { [] } + + it "returns work packages without any observed version" do + expect(result).to contain_exactly(wp_targeting_only, wp_without_versions) + end + end + + context 'for "o" (open version)' do + let(:operator) { "o" } + let(:values) { [] } + + it "returns work packages observed in an open version" do + expect(result).to contain_exactly(wp_observing_open) + end + end + + context 'for "c" (closed version)' do + let(:operator) { "c" } + let(:values) { [] } + + it "returns work packages observed in a closed version" do + expect(result).to contain_exactly(wp_observing_closed) + end + end + + context 'for "l" (locked version)' do + let(:operator) { "l" } + let(:values) { [] } + + it "returns work packages observed in a locked version" do + expect(result).to contain_exactly(wp_observing_locked) + end + end + end + end +end From 9029d69f546485d5b71bc838dc53992a767c49d2 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Wed, 5 Aug 2026 00:47:29 +0200 Subject: [PATCH 6/9] Journal observed version changes and render them in the activity Adds observed_in to the journaled kinds, an observed_in_version_journals association to read them back, and a journal change that diffs the whole set as one value - the same shape the target versions change uses. The rendering logic both formatters share moves into a JoinedVersions base class. Activity fetchers eager load the new association alongside the target one. Co-Authored-By: Claude Opus 5 --- app/models/activities/fetcher.rb | 3 +- app/models/journal.rb | 22 ++-- app/models/work_package/journalized.rb | 1 + .../create_service/work_package_version.rb | 6 +- .../work_packages/activities_tab/paginator.rb | 4 +- .../activity_eager_loading_wrapper.rb | 3 +- .../journal_formatter/joined_versions.rb | 50 ++++++++ .../journal_formatter/observed_in_versions.rb | 34 ++++++ .../journal_formatter/target_versions.rb | 21 +--- .../lib/journal_changes.rb | 47 +++++--- .../observed_in_versions_spec.rb | 108 ++++++++++++++++++ 11 files changed, 252 insertions(+), 47 deletions(-) create mode 100644 lib/open_project/journal_formatter/joined_versions.rb create mode 100644 lib/open_project/journal_formatter/observed_in_versions.rb create mode 100644 spec/lib/open_project/journal_formatter/observed_in_versions_spec.rb diff --git a/app/models/activities/fetcher.rb b/app/models/activities/fetcher.rb index f31e07c4c6d4..a50dfde3dc5f 100644 --- a/app/models/activities/fetcher.rb +++ b/app/models/activities/fetcher.rb @@ -124,7 +124,8 @@ def journals_of_event_set(events) journal_ids = events.map(&:event_id) Journal - .includes(:data, :customizable_journals, :attachable_journals, :target_version_journals, :bcf_comment) + .includes(:data, :customizable_journals, :attachable_journals, :work_package_version_journals, + :bcf_comment) .find(journal_ids) .then { |journals| ::API::V3::Activities::ActivityEagerLoadingWrapper.wrap(journals) } .index_by(&:id) diff --git a/app/models/journal.rb b/app/models/journal.rb index ff0b3475e5bb..84b9bae66e67 100644 --- a/app/models/journal.rb +++ b/app/models/journal.rb @@ -60,6 +60,7 @@ class Journal < ApplicationRecord register_journal_formatter OpenProject::JournalFormatter::MeetingStartTime register_journal_formatter OpenProject::JournalFormatter::MeetingState register_journal_formatter OpenProject::JournalFormatter::MeetingWorkPackageId + register_journal_formatter OpenProject::JournalFormatter::ObservedInVersions register_journal_formatter OpenProject::JournalFormatter::ParticipantChange register_journal_formatter OpenProject::JournalFormatter::ProjectPhaseActive register_journal_formatter OpenProject::JournalFormatter::ProjectPhaseDates @@ -122,13 +123,6 @@ class Journal < ApplicationRecord 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 :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 - has_many :target_version_journals, - -> { where(kind: "target") }, - class_name: "Journal::WorkPackageVersionJournal", - inverse_of: :journal - # rubocop:enable Rails/HasManyOrHasOneDependent has_many :notifications, dependent: :destroy @@ -146,6 +140,20 @@ class Journal < ApplicationRecord alias_attribute :internal, :restricted + # The snapshotted versions of a work package, split by the kind that + # references them. + # + # Deliberately derived in memory instead of being declared as kind-scoped + # associations: as associations, each kind would issue its own query, and + # every caller eager loads all of them together anyway. + def target_version_journals + work_package_version_journals.select { |journal| journal.kind == "target" } + end + + def observed_in_version_journals + work_package_version_journals.select { |journal| journal.kind == "observed_in" } + end + # In conjunction with the included Comparable module, allows comparison of journal records # based on their corresponding version numbers, creation timestamps and IDs. def <=>(other) diff --git a/app/models/work_package/journalized.rb b/app/models/work_package/journalized.rb index 9261067e9d25..d50d9d1f17fa 100644 --- a/app/models/work_package/journalized.rb +++ b/app/models/work_package/journalized.rb @@ -102,6 +102,7 @@ def self.event_url register_journal_formatted_fields /\Afile_links_?\d+\z/, formatter_key: :file_link 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 # Joined register_journal_formatted_fields :parent_id, :project_id, diff --git a/app/services/journals/create_service/work_package_version.rb b/app/services/journals/create_service/work_package_version.rb index d2474ba34ac7..db3eb0a55d06 100644 --- a/app/services/journals/create_service/work_package_version.rb +++ b/app/services/journals/create_service/work_package_version.rb @@ -30,11 +30,9 @@ class Journals::CreateService # Journals the version associations of a work package. Only the kinds listed - # in JOURNALED_KINDS are snapshotted: observed_in versions stay unjournaled - # until the legacy version_id column stops being journaled, as they would - # otherwise render alongside it. + # in JOURNALED_KINDS are snapshotted. class WorkPackageVersion < Association - JOURNALED_KINDS = %w[target].freeze + JOURNALED_KINDS = %w[target observed_in].freeze def associated? journable.respond_to?(:target_versions) diff --git a/app/services/work_packages/activities_tab/paginator.rb b/app/services/work_packages/activities_tab/paginator.rb index f1176b8b78a8..f003ad2d3bee 100644 --- a/app/services/work_packages/activities_tab/paginator.rb +++ b/app/services/work_packages/activities_tab/paginator.rb @@ -178,8 +178,8 @@ def with_changesets(scope) def page_journals(page_relation) page_relation - .includes(:user, :customizable_journals, :attachable_journals, :storable_journals, :target_version_journals, - :notifications, :attachments) + .includes(:user, :customizable_journals, :attachable_journals, :storable_journals, + :work_package_version_journals, :notifications, :attachments) .to_a end diff --git a/lib/api/v3/activities/activity_eager_loading_wrapper.rb b/lib/api/v3/activities/activity_eager_loading_wrapper.rb index ad191acdbc3d..f3f96777d312 100644 --- a/lib/api/v3/activities/activity_eager_loading_wrapper.rb +++ b/lib/api/v3/activities/activity_eager_loading_wrapper.rb @@ -150,7 +150,8 @@ def predecessor_journals(journals) ) AS journals SQL ) - .includes(:attachable_journals, :customizable_journals, :storable_journals, :target_version_journals) + .includes(:attachable_journals, :customizable_journals, :storable_journals, + :work_package_version_journals) end end end diff --git a/lib/open_project/journal_formatter/joined_versions.rb b/lib/open_project/journal_formatter/joined_versions.rb new file mode 100644 index 000000000000..e9a8e681019f --- /dev/null +++ b/lib/open_project/journal_formatter/joined_versions.rb @@ -0,0 +1,50 @@ +# 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. +#++ + +# Renders 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, dropping versions that have been deleted in +# the meantime. +class OpenProject::JournalFormatter::JoinedVersions < JournalFormatter::NamedAssociation + private + + def format_values(values, key, cache:) + klass = class_from_field(key) + + values.map do |value| + next if value.blank? || klass.nil? + + value.to_s.split(",") + .filter_map { |id| associated_object(klass, id.to_i, cache:)&.name } + .join(", ") + .presence + end + end +end diff --git a/lib/open_project/journal_formatter/observed_in_versions.rb b/lib/open_project/journal_formatter/observed_in_versions.rb new file mode 100644 index 000000000000..f99881cf4ae8 --- /dev/null +++ b/lib/open_project/journal_formatter/observed_in_versions.rb @@ -0,0 +1,34 @@ +# 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. +#++ + +# Renders the change to the set of versions a work package was observed in +# (see JournalChanges#get_observed_in_versions_changes). +class OpenProject::JournalFormatter::ObservedInVersions < OpenProject::JournalFormatter::JoinedVersions +end diff --git a/lib/open_project/journal_formatter/target_versions.rb b/lib/open_project/journal_formatter/target_versions.rb index 81cb1676f91b..38a552689e6a 100644 --- a/lib/open_project/journal_formatter/target_versions.rb +++ b/lib/open_project/journal_formatter/target_versions.rb @@ -28,11 +28,9 @@ # See COPYRIGHT and LICENSE files for more details. #++ -# Renders the change to the set of target versions. Each value is the sorted, -# comma-joined version ids (see JournalChanges#get_target_versions_changes); -# every id is resolved to the version's name, dropping versions that have -# been deleted in the meantime. -class OpenProject::JournalFormatter::TargetVersions < JournalFormatter::NamedAssociation +# Renders the change to the set of target versions +# (see JournalChanges#get_target_versions_changes). +class OpenProject::JournalFormatter::TargetVersions < OpenProject::JournalFormatter::JoinedVersions private # While the multiple versions feature is inactive, the rest of the UI still @@ -44,17 +42,4 @@ def label(key) super("version") end end - - def format_values(values, key, cache:) - klass = class_from_field(key) - - values.map do |value| - next if value.blank? || klass.nil? - - value.to_s.split(",") - .filter_map { |id| associated_object(klass, id.to_i, cache:)&.name } - .join(", ") - .presence - end - 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 b933ca03c73e..4e4bf75925f2 100644 --- a/lib_static/plugins/acts_as_journalized/lib/journal_changes.rb +++ b/lib_static/plugins/acts_as_journalized/lib/journal_changes.rb @@ -33,20 +33,7 @@ def get_changes return @changes if @changes return {} if data.nil? - changes = [ - get_cause_changes, - get_data_changes, - get_attachments_changes, - get_custom_comments_changes, - get_custom_fields_changes, - get_project_phases_changes, - get_target_versions_changes, - get_file_links_changes, - get_participants_changes, - get_agenda_items_changes - ].compact - - merged = changes.reduce({}.with_indifferent_access, :merge!) + merged = all_changes.reduce({}.with_indifferent_access, :merge!) @changes = suppress_mirrored_version_change(merged) end @@ -143,6 +130,18 @@ def get_target_versions_changes { target_versions: [old_value, new_value] } end + # Diffed as a single value just like the target versions above: one + # "Observed versions" line with the old and the new list. + def get_observed_in_versions_changes + return unless journable.respond_to?(:observed_in_versions) + + old_value = predecessor && joined_observed_in_version_ids(predecessor) + new_value = joined_observed_in_version_ids(self) + return if old_value == new_value + + { observed_in_versions: [old_value, new_value] } + end + def get_file_links_changes return unless has_file_links? @@ -187,6 +186,22 @@ def get_participants_changes private + def all_changes + [ + get_cause_changes, + get_data_changes, + get_attachments_changes, + get_custom_comments_changes, + get_custom_fields_changes, + get_project_phases_changes, + get_target_versions_changes, + get_observed_in_versions_changes, + get_file_links_changes, + get_participants_changes, + get_agenda_items_changes + ].compact + end + # While the deprecated version_id column mirrors the target versions, a # version change diffs under both keys; only the target_versions # representation is rendered. Historical journals render the same way, @@ -201,6 +216,10 @@ def joined_target_version_ids(journal) journal.target_version_journals.map(&:version_id).sort.join(",").presence end + def joined_observed_in_version_ids(journal) + journal.observed_in_version_journals.map(&:version_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/lib/open_project/journal_formatter/observed_in_versions_spec.rb b/spec/lib/open_project/journal_formatter/observed_in_versions_spec.rb new file mode 100644 index 000000000000..52f0dcb4d06c --- /dev/null +++ b/spec/lib/open_project/journal_formatter/observed_in_versions_spec.rb @@ -0,0 +1,108 @@ +# 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::ObservedInVersions do + describe "#render" do + let(:version) { build_stubbed(:version, name: "Alpha") } + let(:other_version) { build_stubbed(:version, name: "Beta") } + let(:work_package) { build_stubbed(:work_package) } + let(:journal) { build_stubbed(:work_package_journal, journable: work_package) } + let(:instance) { described_class.new(journal) } + # Unlike the target versions formatter, the label never falls back to the + # single-version wording, the attribute having no such counterpart. + let(:label) { "Observed versions" } + + before do + allow(Version).to receive(:find_by).and_return(nil) + + [version, other_version].each do |v| + allow(Version).to receive(:find_by).with(id: v.id).and_return(v) + end + end + + context "when setting observed versions" do + it "renders the version names as the new value" do + expect(instance.render(:observed_in_versions, [nil, "#{version.id},#{other_version.id}"])) + .to eq(I18n.t(:text_journal_set_to, label:, value: "Alpha, Beta")) + end + end + + context "when changing observed versions" do + it "renders the old and new version names" do + expect(instance.render(:observed_in_versions, [version.id.to_s, other_version.id.to_s])) + .to eq(I18n.t(:text_journal_changed_plain, + label:, + linebreak: nil, + old: "Alpha", + new: "Beta")) + end + end + + context "when removing all observed versions" do + it "renders the old version names as deleted" do + expect(instance.render(:observed_in_versions, ["#{version.id},#{other_version.id}", nil])) + .to eq(I18n.t(:text_journal_deleted, label:, old: "Alpha, Beta")) + end + end + + context "with a version that no longer exists" do + it "renders only the existing version names" do + expect(instance.render(:observed_in_versions, [nil, "#{version.id},99999"])) + .to eq(I18n.t(:text_journal_set_to, label:, value: "Alpha")) + end + end + + context "when the multiple versions feature is active", + with_flag: { work_package_multiple_versions: true }, + with_settings: { work_package_multiple_versions: true } do + it "keeps the same label" do + expect(instance.render(:observed_in_versions, [version.id.to_s, other_version.id.to_s])) + .to eq(I18n.t(:text_journal_changed_plain, + label:, + linebreak: nil, + old: "Alpha", + new: "Beta")) + end + end + + context "with html: false" do + it "renders plain text" do + expect(instance.render(:observed_in_versions, [version.id.to_s, other_version.id.to_s], html: false)) + .to eq(I18n.t(:text_journal_changed_plain, + label: "Observed versions", + linebreak: nil, + old: "Alpha", + new: "Beta")) + end + end + end +end From 4e5a3a4f3f8867c4d952972ce8d17580bfffb6da Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Wed, 5 Aug 2026 00:47:29 +0200 Subject: [PATCH 7/9] Export observed versions Adds the export formatter that renders the version names, so the column works in CSV, XLS and PDF exports as well as in workPackageValue macros, and eager loads the association in the XLS exporter. Co-Authored-By: Claude Opus 5 --- .../formatters/observed_in_versions.rb | 43 +++++++++++++++++++ config/initializers/export_formats.rb | 1 + .../xls_export/work_package/exporter/xls.rb | 2 +- 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 app/models/work_package/exports/formatters/observed_in_versions.rb diff --git a/app/models/work_package/exports/formatters/observed_in_versions.rb b/app/models/work_package/exports/formatters/observed_in_versions.rb new file mode 100644 index 000000000000..aa0879aa4ce0 --- /dev/null +++ b/app/models/work_package/exports/formatters/observed_in_versions.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. +#++ + +module WorkPackage::Exports + module Formatters + class ObservedInVersions < ::Exports::Formatters::Default + def self.apply?(attribute, _export_format) + attribute.to_sym == :observed_in_versions + end + + def retrieve_value(object) + object.observed_in_versions.map(&:name) + end + end + end +end diff --git a/config/initializers/export_formats.rb b/config/initializers/export_formats.rb index 793e3563e99d..e1015bce9c4e 100644 --- a/config/initializers/export_formats.rb +++ b/config/initializers/export_formats.rb @@ -52,6 +52,7 @@ formatter WorkPackage, WorkPackage::Exports::Formatters::ProjectPhase formatter WorkPackage, WorkPackage::Exports::Formatters::SpentUnits formatter WorkPackage, WorkPackage::Exports::Formatters::TargetVersions + formatter WorkPackage, WorkPackage::Exports::Formatters::ObservedInVersions list Project, Projects::Exports::CSV list Project, Projects::Exports::PDF diff --git a/modules/xls_export/app/models/xls_export/work_package/exporter/xls.rb b/modules/xls_export/app/models/xls_export/work_package/exporter/xls.rb index 5d40e69bf447..0a996853a0cb 100644 --- a/modules/xls_export/app/models/xls_export/work_package/exporter/xls.rb +++ b/modules/xls_export/app/models/xls_export/work_package/exporter/xls.rb @@ -35,7 +35,7 @@ class XLS < WorkPackage::Exports::QueryExporter def records work_packages - .includes(:assigned_to, :type, :priority, :category, :version, :target_versions) + .includes(:assigned_to, :type, :priority, :category, :version, :target_versions, :observed_in_versions) end def spreadsheet_title From 815f0cb46b2e3501eb0f48d39c7994924a0a5a34 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Wed, 5 Aug 2026 00:47:41 +0200 Subject: [PATCH 8/9] Use the versions edit field for observedInVersions Registers the attribute with VersionsEditFieldComponent so it gets the project grouping and inline version creation the target versions field has. The empty collection stripping on create, which the API rejects as an empty array of links, is generalised over both version attributes. Co-Authored-By: Claude Opus 5 --- .../components/wp-edit/work-package-changeset.ts | 15 ++++++++++----- .../fields/edit/edit-field.initializer.ts | 2 +- .../field-types/versions-edit-field.component.ts | 13 +++++++------ 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/features/work-packages/components/wp-edit/work-package-changeset.ts b/frontend/src/app/features/work-packages/components/wp-edit/work-package-changeset.ts index dd948d7ad67f..18a893cbab52 100644 --- a/frontend/src/app/features/work-packages/components/wp-edit/work-package-changeset.ts +++ b/frontend/src/app/features/work-packages/components/wp-edit/work-package-changeset.ts @@ -62,13 +62,18 @@ export class WorkPackageChangeset extends ResourceChangeset delete (payload as { subject?:string }).subject; } - // Explicitly exclude the targetVersions if it's empty + // Explicitly exclude the version collections if they are empty if (isNewResource(this.pristineResource)) { - const links = (payload as { _links?:{ targetVersions?:unknown[] } })._links; - const targetVersions = links?.targetVersions; + const links = (payload as { _links?:Record })._links; - if (links && (!Array.isArray(targetVersions) || targetVersions.length === 0)) { - delete links.targetVersions; + if (links) { + ['targetVersions', 'observedInVersions'].forEach((attribute) => { + const value = links[attribute]; + + if (!Array.isArray(value) || value.length === 0) { + delete links[attribute]; + } + }); } } diff --git a/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts b/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts index 01967f873396..8318d2b66339 100644 --- a/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts +++ b/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts @@ -143,7 +143,7 @@ export function initializeCoreEditFields(editFieldService:EditFieldService, sele 'WorkPackage', VersionsEditFieldComponent, 'versions', - ['targetVersions'], + ['targetVersions', 'observedInVersions'], ) .addSpecificFieldType('Project', ProjectStatusEditFieldComponent, 'status', ['status']) .addSpecificFieldType('Portfolio', ProjectStatusEditFieldComponent, 'status', ['status']) diff --git a/frontend/src/app/shared/components/fields/edit/field-types/versions-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/versions-edit-field.component.ts index 22ca2a1ae82f..b1d6569c614b 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/versions-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/versions-edit-field.component.ts @@ -39,13 +39,14 @@ import { CurrentProjectService } from 'core-app/core/current-project/current-pro import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; /** - * Edit field for the targetVersions attribute of work packages. + * Edit field for the version collection attributes of work packages + * (targetVersions, observedInVersions). * - * The attribute always reads and writes a collection, but as long as the - * multiple versions setting is inactive, the schema restricts it to a single - * value (options.multiple). In that mode the field mimics the single select - * fields it stands in for: an explicit "-" option, no save/cancel controls, - * saving right on selection. + * The attributes always read and write a collection, but the schema may + * restrict one to a single value (options.multiple) — targetVersions does so as + * long as the multiple versions setting is inactive. In that mode the field + * mimics the single select fields it stands in for: an explicit "-" option, no + * save/cancel controls, saving right on selection. * * Versions can be created from within the field when the user is allowed to * (mirroring VersionAutocompleterComponent). From f9383444e068022867ed8a378d6de0832678d171 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Wed, 5 Aug 2026 00:47:41 +0200 Subject: [PATCH 9/9] Add the observed versions label and APIv3 documentation Co-Authored-By: Claude Opus 5 --- config/locales/en.yml | 1 + .../components/schemas/work_package_model.yml | 16 ++++++++++++++++ .../schemas/work_package_schema_model.yml | 2 ++ .../schemas/work_package_write_model.yml | 13 +++++++++++++ docs/api/apiv3/tags/work_packages.yml | 1 + 5 files changed, 33 insertions(+) diff --git a/config/locales/en.yml b/config/locales/en.yml index 84e0b983f81f..da1d422a670e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -462,6 +462,7 @@ en: true: "include non-working days" journal_internal: Internal Journal notify: "Notify" # used in custom actions + observed_in_versions: "Observed versions" parent: "Parent" parent_issue: "Parent" parent_work_package: "Parent" diff --git a/docs/api/apiv3/components/schemas/work_package_model.yml b/docs/api/apiv3/components/schemas/work_package_model.yml index 648709496d3e..49d9e9f692cc 100644 --- a/docs/api/apiv3/components/schemas/work_package_model.yml +++ b/docs/api/apiv3/components/schemas/work_package_model.yml @@ -590,6 +590,19 @@ allOf: - Transitioning from single to multiple version support - (Temporary) Only allows a single value for compatibility with the version field - (Temporary) Must not be written together with `version` in the same request + observedInVersions: + type: array + items: + $ref: "./link.yml" + description: |- + List of versions the work package has been observed in + + **Resource**: Collection of Version + + # Conditions + + - Unlike `targetVersions`, closed versions may be assigned + - Always multi-valued, independently of multiple version support watchers: allOf: - $ref: "./link.yml" @@ -671,6 +684,9 @@ example: targetVersions: - href: "/api/v3/versions/1" title: Version 1 + observedInVersions: + - href: "/api/v3/versions/2" + title: Version 2 availableWatchers: href: "/api/v3/work_packages/1528/available_watchers" watch: diff --git a/docs/api/apiv3/components/schemas/work_package_schema_model.yml b/docs/api/apiv3/components/schemas/work_package_schema_model.yml index 97ae25c0f939..828110344355 100644 --- a/docs/api/apiv3/components/schemas/work_package_schema_model.yml +++ b/docs/api/apiv3/components/schemas/work_package_schema_model.yml @@ -90,6 +90,8 @@ properties: $ref: './schema_property_model.yml' targetVersions: $ref: './schema_property_model.yml' + observedInVersions: + $ref: './schema_property_model.yml' priority: $ref: './schema_property_model.yml' _links: diff --git a/docs/api/apiv3/components/schemas/work_package_write_model.yml b/docs/api/apiv3/components/schemas/work_package_write_model.yml index 483596c9689f..2efc036fb0a8 100644 --- a/docs/api/apiv3/components/schemas/work_package_write_model.yml +++ b/docs/api/apiv3/components/schemas/work_package_write_model.yml @@ -126,6 +126,19 @@ properties: - Transitioning from single to multiple version support - (Temporary) Only allows a single value for compatibility with the version field + observedInVersions: + type: array + items: + $ref: './link.yml' + description: |- + List of versions the work package has been observed in + + **Resource**: Collection of Version + + # Conditions + + - Unlike `targetVersions`, closed versions may be assigned + - Always multi-valued, independently of multiple version support parent: allOf: - $ref: './link.yml' diff --git a/docs/api/apiv3/tags/work_packages.yml b/docs/api/apiv3/tags/work_packages.yml index bf37263b099b..3e29ebf95623 100644 --- a/docs/api/apiv3/tags/work_packages.yml +++ b/docs/api/apiv3/tags/work_packages.yml @@ -50,6 +50,7 @@ description: |- | type | The type of the work package | Type | not null | READ / WRITE | | | version | The version associated to the work package | Version | | READ / WRITE | | | targetVersions | List of versions associated to the work package | []Version | | READ / WRITE | | + | observedInVersions | List of versions the work package has been observed in | []Version | | READ / WRITE | | | watchers | All users that are currently watching this work package | Collection | | READ | **Permission** view work package watchers | ## Local Properties