Skip to content
3 changes: 3 additions & 0 deletions app/contracts/work_packages/base_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion app/models/activities/fetcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 15 additions & 7 deletions app/models/journal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions app/models/queries/work_packages.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,7 +62,7 @@ def ar_object_filter?
end

def where
target_versions_where
versions_where
end

def value_objects
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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
Comment thread
thykel marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 3 additions & 1 deletion app/models/queries/work_packages/filter/version_filter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
23 changes: 23 additions & 0 deletions app/models/queries/work_packages/selects/property_select.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
43 changes: 43 additions & 0 deletions app/models/work_package/exports/formatters/observed_in_versions.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions app/models/work_package/journalized.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions app/models/work_package/versions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions app/services/journals/create_service/work_package_version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions app/services/work_packages/activities_tab/paginator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions config/initializers/export_formats.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions docs/api/apiv3/components/schemas/work_package_model.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading