diff --git a/app/models/journable/with_historic_attributes.rb b/app/models/journable/with_historic_attributes.rb new file mode 100644 index 000000000000..a63515eb4e51 --- /dev/null +++ b/app/models/journable/with_historic_attributes.rb @@ -0,0 +1,237 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2022 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. +#++ + +# This class is used to wrap a Journable and provide access to its attributes at given timestamps. +# It is used to provide the old and new values of a journable in the journables's payload. +# https://github.com/opf/openproject/pull/11783 +# +# Usage: +# +# # Wrap single work package +# timestamps = [Timestamp.parse("2022-01-01T00:00:00Z"), Timestamp.parse("PT0S")] +# work_package = WorkPackage.find(1) +# work_package = Journable::WithHistoricAttributes.wrap(work_package, timestamps:) +# +# # Wrap multiple work packages +# timestamps = query.timestamps +# work_packages = query.results.work_packages +# work_packages = Journable::WithHistoricAttributes.wrap_multiple(work_packages, timestamps:) +# +# # Access historic attributes at timestamps after wrapping +# work_package = Journable::WithHistoricAttributes.wrap(work_package, timestamps:) +# work_package.subject # => "Subject at PT0S (current time)" +# work_package.attributes_by_timestamp["2022-01-01T00:00:00Z"].subject # => "Subject at 2022-01-01 (baseline time)" +# +# # Check at which timestamps the work package matches query filters after wrapping +# query.timestamps # => [, ] +# work_package = Journable::WithHistoricAttributes.wrap(work_package, query:) +# work_package.matches_query_filters_at_timestamps # => [] +# +# # Include only changed attributes in payload +# # i.e. only historic attributes that differ from the work_package's attributes +# timestamps = [Timestamp.parse("2022-01-01T00:00:00Z"), Timestamp.parse("PT0S")] +# work_package = Journable::WithHistoricAttributes.wrap(work_package, timestamps:, include_only_changed_attributes: true) +# work_package.attributes_by_timestamp["2022-01-01T00:00:00Z"].subject # => "Subject at 2022-01-01 (baseline time)" +# work_package.attributes_by_timestamp["PT0S"].subject # => nil +# +# # Simplified interface for two timestamps +# query.timestamps # => [, ] +# work_package = Journable::WithHistoricAttributes.wrap(work_package, query:) +# work_package.baseline_timestamp # => [] +# work_package.current_timestamp # => [] +# work_package.matches_query_filters_at_baseline_timestamp? +# work_package.matches_query_filters_at_current_timestamp? +# work_package.baseline_attributes.subject # => "Subject at 2022-01-01 (baseline time)" +# work_package.subject # => "Subject at PT0S (current time)" +# +class Journable::WithHistoricAttributes < SimpleDelegator + attr_accessor :timestamps, :query, :include_only_changed_attributes, :attributes_by_timestamp, + :matches_query_filters_at_timestamps, :exists_at_timestamps + + def initialize(journable, timestamps: nil, query: nil, include_only_changed_attributes: false) + super(journable) + + if query and not journable.is_a? WorkPackage + raise Journable::NotImplementedError, "Journable::WithHistoricAttributes with query " \ + "is only implemented for WorkPackages at the moment " \ + "because Query objects currently only support work packages." + end + + self.query = query + self.timestamps = timestamps || query.try(:timestamps) || [] + self.include_only_changed_attributes = include_only_changed_attributes + + self.attributes_by_timestamp = {} + self.matches_query_filters_at_timestamps = [] + self.exists_at_timestamps = [] + end + + def self.wrap(journable_or_journables, timestamps: nil, query: nil, include_only_changed_attributes: false) + case journable_or_journables + when Array, ActiveRecord::Relation + wrap_multiple(journable_or_journables, timestamps:, query:, include_only_changed_attributes:) + else + wrap_one(journable_or_journables, timestamps:, query:, include_only_changed_attributes:) + end + end + + def self.wrap_one(journable, timestamps: nil, query: nil, include_only_changed_attributes: false) + timestamps ||= query.try(:timestamps) || [] + journable = journable.at_timestamp(timestamps.last) if timestamps.last.try(:historic?) + journable = new(journable, timestamps:, query:, include_only_changed_attributes:) + timestamps.each do |timestamp| + journable.assign_historic_attributes( + timestamp:, + historic_journable: journable.try(:at_timestamp, timestamp), + matching_journable: (query_work_packages(query:, timestamp:).find_by(id: journable.id) if query) + ) + end + journable + end + + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/PerceivedComplexity + def self.wrap_multiple(journables, timestamps: nil, query: nil, include_only_changed_attributes: false) + timestamps ||= query.try(:timestamps) || [] + journables = journables.map { |j| j.at_timestamp(timestamps.last) } if timestamps.last.try(:historic?) + journables = journables.map { |j| new(j, timestamps:, query:, include_only_changed_attributes:) } + timestamps.each do |timestamp| + assign_historic_attributes_to( + journables, + timestamp:, + historic_journables: WorkPackage.at_timestamp(timestamp).where(id: journables.map(&:id)), + matching_journables: (query_work_packages(query:, timestamp:) if query), + query: + ) + end + journables + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/PerceivedComplexity + + def assign_historic_attributes(timestamp:, historic_journable:, matching_journable:) + attributes_by_timestamp[timestamp.to_s] = extract_historic_attributes_from(historic_journable:) if historic_journable + matches_query_filters_at_timestamps << timestamp if matching_journable + exists_at_timestamps << timestamp if historic_journable + end + + def self.assign_historic_attributes_to(journables, timestamp:, historic_journables:, matching_journables:, query:) + journables.each do |journable| + if journable + historic_journable = historic_journables.find_by(id: journable.id) + matching_journable = matching_journables.find_by(id: journable.id) if query + journable.assign_historic_attributes(timestamp:, historic_journable:, matching_journable:) + end + end + end + + def extract_historic_attributes_from(historic_journable:) + convert_attributes_hash_to_struct( + historic_journable.attributes.select do |key, value| + not include_only_changed_attributes \ + or not respond_to?(key) \ + or value != send(key) + end + ) + end + + # This allows us to use the historic attributes in the same way as the current attributes + # using methods rather than hash keys. + # + # Example: + # work_package.baseline_attributes.subject + # work_package.baseline_attributes["subject"] + # + # Rubocop complains about OpenStruct because it is slightly slower than Struct. + # https://docs.rubocop.org/rubocop/cops_style.html#styleopenstructuse + # + # However, I prefer OpenStruct here because it makes it easier to deal with the + # non existing attributes when using `include_only_changed_attributes: true`. + # + # We need to patch the `as_json` method because OpenStruct's `as_json` would + # wrap everything into a "table" hash. + # + # rubocop:disable Style/OpenStructUse + # + def convert_attributes_hash_to_struct(attributes) + Class.new(OpenStruct) do + def as_json(options = nil) + to_h.as_json(options) + end + end.new(attributes) + end + # rubocop:enable Style/OpenStructUse + + def baseline_timestamp + timestamps.first + end + + def baseline_attributes + attributes_by_timestamp[baseline_timestamp.to_s] + end + + def matches_query_filters_at_baseline_timestamp? + query && matches_query_filters_at_timestamps.include?(baseline_timestamp) + end + + def current_timestamp + timestamps.last + end + + def matches_query_filters_at_current_timestamp? + query && matches_query_filters_at_timestamps.include?(current_timestamp) + end + + def matches_query_filters_at_timestamp?(timestamp) + query && matches_query_filters_at_timestamps.include?(timestamp) + end + + def self.query_work_packages(query:, timestamp: nil) + query = query.dup + query.timestamps = [timestamp] if timestamp + query.results.work_packages + end + + def id + __getobj__.try(:id) + end + + def attributes + __getobj__.try(:attributes) + end + + def to_ary + __getobj__.send(:to_ary) + end + + def inspect + __getobj__.inspect.gsub(/#<(.+)>/m, "#<#{self.class.name} \\1>") + end + + class NotImplemented < StandardError; end +end diff --git a/app/models/timestamp.rb b/app/models/timestamp.rb index a27bab38f5c4..0df6c42e422d 100644 --- a/app/models/timestamp.rb +++ b/app/models/timestamp.rb @@ -40,14 +40,27 @@ def initialize(arg = Timestamp.now.to_s) end def self.parse(iso8601_string) + iso8601_string.strip! + iso8601_string = substitute_special_shortcut_values(iso8601_string) if iso8601_string.start_with? "P" # ISO8601 "Period" - ActiveSupport::Duration.parse(iso8601_string) - elsif Time.zone.parse(iso8601_string).blank? - raise ArgumentError, "The string \"#{iso8601_string}\" cannot be parsed to a Time." + iso8601_string = ActiveSupport::Duration.parse(iso8601_string).iso8601 + elsif (time = Time.zone.parse(iso8601_string)).present? + iso8601_string = time.iso8601 + else + raise ArgumentError, "The string \"#{iso8601_string}\" cannot be parsed to Time or ActiveSupport::Duration." end Timestamp.new(iso8601_string) end + # Take a comma-separated string of ISO-8601 timestamps and convert it + # into an array of Timestamp objects. + # + def self.parse_multiple(comma_separated_iso8601_string) + comma_separated_iso8601_string.to_s.split(",").compact_blank.collect do |iso8601_string| + Timestamp.parse(iso8601_string) + end + end + def self.now new(ActiveSupport::Duration.build(0).iso8601) end @@ -68,10 +81,18 @@ def iso8601 @timestamp_iso8601_string.to_s end + def to_iso8601 + iso8601 + end + def inspect "#" end + def absolute + Timestamp.new(to_time) + end + def to_time if relative? Time.zone.now - (to_duration * (to_duration.to_i.positive? ? 1 : -1)) @@ -88,7 +109,7 @@ def to_duration end end - def as_json + def as_json(*_args) to_s end @@ -97,8 +118,56 @@ def to_json(*_args) end def ==(other) - iso8601 == other.iso8601 + case other + when String + iso8601 == other or to_s == other + when Timestamp + iso8601 == other.iso8601 + when NilClass + to_s.blank? + else + raise Timestamp::Exception, "Comparison to #{other.class.name} not implemented, yet." + end + end + + def eql?(other) + self == other + end + + def historic? + self != Timestamp.now end class Exception < StandardError; end + + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/PerceivedComplexity + def self.substitute_special_shortcut_values(string) + # map now to PT0S + string = "PT0S" if string == "now" + + # map 1y to P1Y, 1m to P1M, 1w to P1W, 1d to P1D + # map -1y to P-1Y, -1m to P-1M, -1w to P-1W, -1d to P-1D + # map -1y1d to P-1Y-1D + sign = "-" if string.start_with? "-" + years = scan_for_shortcut_value(string:, unit: "y") + months = scan_for_shortcut_value(string:, unit: "m") + weeks = scan_for_shortcut_value(string:, unit: "w") + days = scan_for_shortcut_value(string:, unit: "d") + if years || months || weeks || days + string = "P" \ + "#{sign if years}#{years}#{'Y' if years}" \ + "#{sign if months}#{months}#{'M' if months}" \ + "#{sign if weeks}#{weeks}#{'W' if weeks}" \ + "#{sign if days}#{days}#{'D' if days}" + end + + string + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/PerceivedComplexity + + def self.scan_for_shortcut_value(string:, unit:) + string.scan(/(\d+)#{unit}/).flatten.first + end end diff --git a/app/services/api/v3/parse_query_params_service.rb b/app/services/api/v3/parse_query_params_service.rb index bdc75553f215..c49c4ae6beb2 100644 --- a/app/services/api/v3/parse_query_params_service.rb +++ b/app/services/api/v3/parse_query_params_service.rb @@ -34,11 +34,12 @@ class ParseQueryParamsService def call(params) json_parsed = json_parsed_params(params) - return json_parsed unless json_parsed.success? + return json_parsed if json_parsed.failure? parsed = parsed_params(params) + return parsed if parsed.failure? - result = without_empty(parsed.merge(json_parsed.result), determine_allowed_empty(params)) + result = without_empty(parsed.result.merge(json_parsed.result), determine_allowed_empty(params)) ServiceResult.success(result:) end @@ -59,8 +60,9 @@ def json_parsed_params(params) result end + # rubocop:disable Metrics/AbcSize def parsed_params(params) - { + ServiceResult.success result: { group_by: group_by_from_params(params), columns: columns_from_params(params), display_sums: boolearize(params[:showSums]), @@ -70,9 +72,15 @@ def parsed_params(params) highlighted_attributes: highlighted_attributes_from_params(params), display_representation: params[:displayRepresentation], show_hierarchies: boolearize(params[:showHierarchies]), - include_subprojects: boolearize(params[:includeSubprojects]) + include_subprojects: boolearize(params[:includeSubprojects]), + timestamps: Timestamp.parse_multiple(params[:timestamps]) } + rescue ArgumentError => e + result = ServiceResult.failure + result.errors.add(:base, e.message) + result end + # rubocop:enable Metrics/AbcSize def determine_allowed_empty(params) allow_empty = params.keys @@ -148,7 +156,7 @@ def columns_from_params(params) def highlighted_attributes_from_params(params) highlighted_attributes = Array(params[:highlightedAttributes].presence) - return unless highlighted_attributes.present? + return if highlighted_attributes.blank? highlighted_attributes.map do |href| attr = href.split('/').last @@ -157,9 +165,10 @@ def highlighted_attributes_from_params(params) end def boolearize(value) - if value == 'true' + case value + when 'true' true - elsif value == 'false' + when 'false' false end end @@ -188,9 +197,10 @@ def fix_field_array(field_names) def parse_sorting_from_json(json) JSON.parse(json).map do |order| - attribute, direction = if order.is_a?(Array) + attribute, direction = case order + when Array [order.first, order.last] - elsif order.is_a?(String) + when String order.split(':') end @@ -233,7 +243,7 @@ def without_empty(hash, exceptions) def group_by_empty?(params) params_exist?(params, KEYS_GROUP_BY) && - !params_value(params, KEYS_GROUP_BY).present? + params_value(params, KEYS_GROUP_BY).blank? end end end diff --git a/app/services/api/v3/work_package_collection_from_query_service.rb b/app/services/api/v3/work_package_collection_from_query_service.rb index b2025b7ab9f1..f34daf0a8979 100644 --- a/app/services/api/v3/work_package_collection_from_query_service.rb +++ b/app/services/api/v3/work_package_collection_from_query_service.rb @@ -161,13 +161,15 @@ def collection_representer(work_packages, params:, project:, groups:, sums:) work_packages, self_link: self_link(project), project:, - query: resulting_params, + query_params: resulting_params, page: resulting_params[:offset], per_page: resulting_params[:pageSize], groups:, total_sums: sums, embed_schemas: true, - current_user: + current_user:, + timestamps: query.timestamps, + query: ) end end diff --git a/app/services/update_query_from_params_service.rb b/app/services/update_query_from_params_service.rb index e0c24ffa8c2d..8d9e675354a6 100644 --- a/app/services/update_query_from_params_service.rb +++ b/app/services/update_query_from_params_service.rb @@ -32,6 +32,7 @@ def initialize(query, user) self.current_user = user end + # rubocop:disable Metrics/AbcSize def call(params, valid_subset: false) apply_group_by(params) @@ -53,6 +54,8 @@ def call(params, valid_subset: false) apply_include_subprojects(params) + apply_timestamps(params) + disable_hierarchy_when_only_grouped_by(params) if valid_subset @@ -65,6 +68,7 @@ def call(params, valid_subset: false) ServiceResult.failure(errors: query.errors) end end + # rubocop:enable Metrics/AbcSize private @@ -117,6 +121,10 @@ def apply_include_subprojects(params) query.include_subprojects = params[:include_subprojects] if params.key?(:include_subprojects) end + def apply_timestamps(params) + query.timestamps = params[:timestamps] if params.key?(:timestamps) + end + def disable_hierarchy_when_only_grouped_by(params) if params.key?(:group_by) && !params.key?(:show_hierarchies) query.show_hierarchies = false diff --git a/docs/api/apiv3/openapi-spec.yml b/docs/api/apiv3/openapi-spec.yml index c06bee434704..c0c7260152bf 100644 --- a/docs/api/apiv3/openapi-spec.yml +++ b/docs/api/apiv3/openapi-spec.yml @@ -840,6 +840,7 @@ tags: - "$ref": "./tags/basic_objects.yml" - "$ref": "./tags/collections.yml" - "$ref": "./tags/filters.yml" + - "$ref": "./tags/baseline_comparisons.yml" - "$ref": "./tags/forms.yml" - "$ref": "./tags/signaling.yml" - "$ref": "./tags/actions_and_capabilities.yml" diff --git a/docs/api/apiv3/paths/work_package.yml b/docs/api/apiv3/paths/work_package.yml index 29f41efb00eb..2dc31db82373 100644 --- a/docs/api/apiv3/paths/work_package.yml +++ b/docs/api/apiv3/paths/work_package.yml @@ -71,6 +71,17 @@ get: schema: type: integer example: 1 + - description: |- + In order to perform a [baseline comparison](/docs/api/baseline_comparisons) of the work-package attributes, you may + provide one or several timestamps in ISO-8601 format as comma-separated list. The timestamps may be absolute or relative. + Usually, the first timestamp is the baseline date, the last timestamp is the current date. + in: query + name: timestamps + required: false + schema: + default: PT0S + type: string + example: '2022-01-01T00:00:00Z,PT0S' responses: '200': content: diff --git a/docs/api/apiv3/paths/work_packages.yml b/docs/api/apiv3/paths/work_packages.yml index f45f79f18214..c5542bc4cb11 100644 --- a/docs/api/apiv3/paths/work_packages.yml +++ b/docs/api/apiv3/paths/work_packages.yml @@ -124,6 +124,17 @@ get: required: false schema: type: string + - description: |- + In order to perform a [baseline comparison](/docs/api/baseline_comparisons), you may provide one or several timestamps + in ISO-8601 format as comma-separated list. The timestamps may be absolute or relative. Usually, the first timestamp + is the baseline date, the last timestamp is the current date. + example: '2022-01-01T00:00:00Z,PT0S' + in: query + name: timestamps + required: false + schema: + default: PT0S + type: string responses: '200': description: OK diff --git a/docs/api/apiv3/tags/baseline_comparisons.yml b/docs/api/apiv3/tags/baseline_comparisons.yml new file mode 100644 index 000000000000..5a4f3d7b4a2c --- /dev/null +++ b/docs/api/apiv3/tags/baseline_comparisons.yml @@ -0,0 +1,290 @@ +--- +description: |- + Baseline comparisons allow to compare work packages or collections of work packages with respect to different points in time. + + This helps to answer questions like: + + - Which work packages match a certain set of filters today, which work packages match this set of filters at a certain earlier point in time? + - Which properties of these work packages have changed with respect to these points in time? + + This tool can be used to analyze how a project plan has changed with respect to a certain baseline date. + + ## Requesting Work Packages for Different Timestamps + + The work-packages API supports a `timestamps` parameter to gather information about a single work package or a collection of work packages for several points in time. + + ``` + GET /api/v3/work_packages?timestamps=2022-01-01T00:00:00Z,PT0S + ``` + + ``` + GET /api/v3/work_packages/123?timestamps=2022-01-01T00:00:00Z,PT0S + ``` + + Each timestamp should be given as an [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) string, either an absolute date and time with timezone, e.g. `"2022-01-01T00:00:00Z"`, or a relative timestamp utilizing the [ISO-8601-Duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) format, e.g. `"P-1Y"`, which is composed of an initial `"P"` for "Period", and a duration. `"P-1Y"` is interpreted as the relative timestamp "1 year ago". + + Several timestamps should be passed as comma-separated list of these ISO-8601 strings to the `timestamps` parameter, e.g. `"2022-01-01T00:00:00Z,PT0S"`. + + The timestamps should be given in ascending temporal order, such that the first timestamp is the **baseline** timestamp, and the last timestamp is the **current** timestamp. + + ## Response Overview + + When providing a `timestamps` parameter, the response has several additional properties: + + | Property | Description | Type | Further information | + | ----------------------- | -------------------------------------------------------------------------------------- | ---------------- | --------------------------------------------------- | + | `timestamp` | The requested timestamp corresponding to the surrounding embedded object | String | Section *[Timestamps](#timestamps)* below | + | `attributesByTimestamp` | Attributes and meta information of the work packages at the respective timestamps | Array of Objects | Section *[Attributes](#attributes)* below | + | `matchesFilters` | Marks whether the work package matches the filter criteria at the respective timestamp | Bool | Section *[Filter Matching](#filter-matching)* below | + | `exists` | Marks whether the work package exists at the respective timestamp | Bool | Section *[Existence](#existence)* below | + + Each work-package element has the `attributesByTimestamp` as `_embedded` section. + + The properties `timestamp`, `matchesFilters`, and `exists` are wrapped in a `_meta` section, which is added to each work-package element as well as to each element of the `attributesByTimestamp` array. + + ```json + // /api/v3/work_packages?timestamps=2022-01-01T00:00:00Z,PT0S + { + "_type": "WorkPackageCollection", + "total": 1, + "_embedded": { + "elements": [ + { + "_type": "WorkPackage", + "id": 1528, + "subject": "Current subject of the work package", + // other attributes ..., + "_links": { + "self": { + "href": "/api/v3/work_packages/1528?timestamps=2022-01-01T00:00:00Z,2023-03-01T01:37:10Z" + } + }, + "_meta": { + "matchesFilters": true, + "exists": true, + "timestamp": "PT0S" + }, + "_embedded": { + "attributesByTimestamp": [ + { + "subject": "Original subject of the work package", + "_meta": { + "matchesFilters": true, + "exists": true, + "timestamp": "2022-01-01T00:00:00Z" + }, + "_links": { + "self": { + "href": "/api/v3/work_packages/1528?timestamps=2022-01-01T00:00:00Z" + } + }, + }, + { + "_meta": { + "matchesFilters": true, + "exists": true, + "timestamp": "PT0S" + }, + "_links": { + "self": { + "href": "/api/v3/work_packages/1528?timestamps=2023-03-01T01:37:10Z" + } + } + } + ], + } + } + ] + }, + "_links": { + "self": { + "href": "/api/v3/work_packages?timestamps=2022-01-01T00:00:00Z,2023-03-01T01:37:10Z" + } + } + } + ``` + + ## Meta Information + + Each `_meta` section describes the surrounding object of the meta section, which may be the main work-package object, or an attributes object within the `attributesByTimestamp` array. + + Note that the `_meta` information of the most current (rightmost) timestamp is redundant: It is given as `_meta` section of the main work-package object as well as `_meta` section of the last object within the `attributesByTimestamp` array. + + ## Timestamps + + Each `_meta` section contains a `timestamp` property, which is to the requested `timestamp` corresponding to the object the `_meta` section describes. + + The `timestamp` has the same ISO-8601 format as in the `timestamps` request parameter and preserves the absolute or relative character of the requested timestamp. + + Furthermore, each self link corresponding to an earlier point in time has also a `timestamps` request parameter added to it, which is converted to an absolute ISO-8601 string at the execution time of the query. (`"2023-03-01T01:37:10Z` in the above example, which has been executed at that time.) + + ## Attributes + + To read out the attributes of the work packages at the current timestamp (the last of the given `timestamps`), check the attributes of the work-package objects. To read out the attributes of the work packages at the other given timestamps, check the attributes within `"_embedded"` section `"attributesAtTimestamp"`. + + To save bandwidth, only attributes that differ from the ones in the main work-package object are included in the `"attributesByTimestamp"`. Attributes with the same value as in the main work-package object are not included in the `"attributesByTimestamp"` section. + + ```json + // /api/v3/work_packages?timestamps=2022-01-01T00:00:00Z,PT0S + { + "_type": "WorkPackageCollection", + "_embedded": { + "elements": [ + { + "_type": "WorkPackage", + "subject": "Current subject of the work package", + "_meta": { + "timestamp": "PT0S" + }, + "_embedded": { + "attributesByTimestamp": [ + { + "subject": "Original subject of the work package", + "_meta": { + "timestamp": "2022-01-01T00:00:00Z" + } + }, + { + "_meta": { + "timestamp": "PT0S" + } + } + ], + } + } + ] + } + } + ``` + + In the above example, the last of the given `timestamps` is `"PT0S"` (which means 0 seconds ago, i.e. now). The work-package attributes at this time are included in the main work-package object. The `"subject"` of the work package at the timestamp `"PT0S"` (now) is `"Current subject of the work package"`. + + The `"_embedded"` section `"attributesByTimestamp"` has an array entry for the timestamp `"PT0S"`, which is the last array entry. Because the value of the `"subject"` is the same as up in the main work-package object, the `"subject"` attribute is left out in the `"attributesByTimestamp"` for the timestamp `"PT0S"`. The `"subject"` of the work package at the timestamp `"PT0S"` (now) is `"Current subject of the work package"`. + + The `"_embedded"` section `"attributesByTimestamp"` has an array entry for the baseline timestamp `"2022-01-01T00:00:00Z"`, which is the first array entry. The `"subject"` of the work package at the timestamp `"2022-01-01T00:00:00Z"` is `"Original subject of the work package"`. It is included in the `"attributesByTimestamp"` for the timestamp `"2022-01-01T00:00:00Z"` because it differs from the `"subject"` in the main work-package object, which is `"Current subject of the work package"`. + + ## Filter Matching + + The work-packages API supports filtering the query results by one or several search criteria. See: [Filters](../filters) + + To find out whether a work package matches the given set of filter criteria at a certain timestamp, check the `"matchesFilters"` property in the `"_meta"` section for that timestamp: + + ```json + // /api/v3/work_packages?filters=...×tamps=2022-01-01T00:00:00Z,PT0S + { + "_type": "WorkPackageCollection", + "_embedded": { + "elements": [ + { + "_type": "WorkPackage", + "_meta": { + "matchesFilters": true, + "timestamp": "PT0S" + }, + "_embedded": { + "attributesByTimestamp": [ + { + "_meta": { + "matchesFilters": false, + "timestamp": "2022-01-01T00:00:00Z" + } + }, + { + "_meta": { + "matchesFilters": true, + "timestamp": "PT0S" + } + } + ], + } + } + ] + } + } + ``` + + In the above example, the work package matches the filter criteria at the timestamp `"PT0S"`, but does not match the filter criteria at the timestamp `"2022-01-01T00:00:00Z"`. + + In another example, it might be the other way around: The work package could match the filter criteria (`"matchesFilters": true`) at the baseline timestamp, but not match the filter criteria anymore (`"matchesFilters": false`) at the current timestamp. + + The work package is included in the returned collection if it matches the filter criteria at least at one of the requested timestamps. + + ## Existence + + To find out whether a work package has existed at a requested timestamp, check the `"exists"` property in the `"_meta"` section for that timestamp: + + ```json + // /api/v3/work_packages?timestamps=2022-01-01T00:00:00Z,PT0S + { + "_type": "WorkPackageCollection", + "_embedded": { + "elements": [ + { + "_type": "WorkPackage", + "_meta": { + "exists": true, + "timestamp": "PT0S" + }, + "_embedded": { + "attributesByTimestamp": [ + { + "_meta": { + "exists": false, + "timestamp": "2022-01-01T00:00:00Z" + } + }, + { + "_meta": { + "exists": true, + "timestamp": "PT0S" + } + } + ], + } + } + ] + } + } + ``` + + In the above example, the work package exists at the timestamp `"PT0S"`, but has not existed at the timestamp `"2022-01-01T00:00:00Z"`. + + In another example, it might be the other way around: The work package could exist (`"exists": true`) at the baseline time, but could have been deleted after that time such that it does not exist (`"exists": false`) at the current time. Please note, however, that OpenProject does not support [soft deletion](https://community.openproject.org/projects/openproject/work_packages/40015), yet. Currently, when a work package is deleted, its history is deleted as well, so that its history cannot be retrieved through the baseline API anymore. + + The work package is included in the returned collection if it has existed at least at one of the requested timestamps. + + ## Usage Example + + In this example ruby script, the work packages are retrieved at a baseline date and in their current state. Then the subject of the first work package is compared with respect to the baseline date and the current state. + + ```ruby + # Define timestamps + baseline_timestamp = "2022-01-01T00:00:00Z" + current_timestamp = "PT0S" + timestamps = [baseline_timestamp, current_timestamp] + + # Retrieve work packages + url = "https://community.openproject.org/api/v3/work_packages?timestamps=#{timestamps.join(',')}" + response = JSON.parse(RestClient.get(url), object_class: OpenStruct) + work_packages = response.dig("_embedded", "elements") + + # Extract differing baseline attributes + work_package = work_packages.first + baseline_attributes = work_package.dig("_embedded", "attributesByTimestamp").first + + # Compare baseline state to current state of the work package + if baseline_attributes.subject.present? and baseline_attributes.subject != work_package.subject + puts "The subject of the work package has changed." + puts "Subject at the baseline time: #{baseline_attributes.subject}" + puts "Current subject: #{work_package.subject}" + end + + # Check existence + puts "The work package did exist at the baseline time." if baseline_attributes.dig("_meta", "exists") + puts "The work package exists at the current time." if work_package.dig("_meta", "exists") + + # Check filter matching + puts "The work package matches the query filters at the baseline time." if baseline_attributes.dig("_meta", "matchesFilters") + puts "The work package matches the query filters at the current time." if work_package.dig("_meta", "matchesFilters") + ``` +name: Baseline Comparisons diff --git a/lib/api/decorators/offset_paginated_collection.rb b/lib/api/decorators/offset_paginated_collection.rb index 1e9f0a806647..ca71c2fdfc8d 100644 --- a/lib/api/decorators/offset_paginated_collection.rb +++ b/lib/api/decorators/offset_paginated_collection.rb @@ -35,9 +35,9 @@ def self.per_page_default(relation) relation.base_class.per_page end - def initialize(models, self_link:, current_user:, query: {}, page: nil, per_page: nil, groups: nil) + def initialize(models, self_link:, current_user:, query_params: {}, page: nil, per_page: nil, groups: nil) @self_link_base = self_link - @query = query + @query_params = query_params @page = page.to_i > 0 ? page.to_i : 1 resolved_page_size = resolve_page_size(per_page) @per_page = resulting_page_size(resolved_page_size, models) @@ -103,7 +103,7 @@ def href_query(page = @page, page_size = @per_page) end def query_params(page = @page, page_size = @per_page) - @query.merge(offset: page, pageSize: page_size) + @query_params.merge(offset: page, pageSize: page_size) end def paged_models(models) diff --git a/lib/api/v3/notifications/notification_collection_representer.rb b/lib/api/v3/notifications/notification_collection_representer.rb index 0d18931f25c0..b9f0eb4631b4 100644 --- a/lib/api/v3/notifications/notification_collection_representer.rb +++ b/lib/api/v3/notifications/notification_collection_representer.rb @@ -36,7 +36,7 @@ class NotificationCollectionRepresenter < ::API::Decorators::OffsetPaginatedColl embedded: true, if: ->(*) { details_schemas.any? } - def initialize(models, self_link:, current_user:, query: {}, page: nil, per_page: nil, groups: nil) + def initialize(models, self_link:, current_user:, query_params: {}, page: nil, per_page: nil, groups: nil) super @represented = ::API::V3::Notifications::NotificationEagerLoadingWrapper.wrap(represented) diff --git a/lib/api/v3/utilities/eager_loading/eager_loading_wrapper.rb b/lib/api/v3/utilities/eager_loading/eager_loading_wrapper.rb index 40a5b620bd25..1ed2fee2914b 100644 --- a/lib/api/v3/utilities/eager_loading/eager_loading_wrapper.rb +++ b/lib/api/v3/utilities/eager_loading/eager_loading_wrapper.rb @@ -40,6 +40,10 @@ def to_ary __getobj__.send(:to_ary) end + def inspect + __getobj__.inspect.gsub(/#<(.+)>/m, "#<#{self.class.name} \\1>") + end + class << self def wrap(objects) objects diff --git a/lib/api/v3/utilities/endpoints/index.rb b/lib/api/v3/utilities/endpoints/index.rb index 99eef3f39040..9fc81f6c9650 100644 --- a/lib/api/v3/utilities/endpoints/index.rb +++ b/lib/api/v3/utilities/endpoints/index.rb @@ -92,7 +92,7 @@ def render_paginated_success(results, query, params, self_path) render_representer .create(results, self_link: self_path, - query: resulting_params, + query_params: resulting_params, page: resulting_params[:offset], per_page: resulting_params[:pageSize], groups: calculate_groups(query), diff --git a/lib/api/v3/utilities/endpoints/sql_fallbacked_index.rb b/lib/api/v3/utilities/endpoints/sql_fallbacked_index.rb index 9d69617341bd..a87fcef33004 100644 --- a/lib/api/v3/utilities/endpoints/sql_fallbacked_index.rb +++ b/lib/api/v3/utilities/endpoints/sql_fallbacked_index.rb @@ -45,7 +45,7 @@ def render_paginated_success(results, query, params, self_path) deduce_fallback_render_representer .new(results, self_link: self_path, - query: resulting_params, + query_params: resulting_params, page: resulting_params[:offset], per_page: resulting_params[:pageSize], groups: calculate_groups(query), diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 48bdfdc21aa1..0a301e7b9b51 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -488,6 +488,13 @@ def self.wiki_page(id) resources :work_package, except: :schema + def self.work_package(id, timestamps: nil) + "#{root}/work_packages/#{id}" + \ + if (param_value = timestamps_to_param_value(timestamps)).present? + "?#{{ timestamps: param_value }.to_query}" + end.to_s + end + def self.work_package_schema(project_id, type_id) "#{root}/work_packages/schemas/#{project_id}-#{type_id}" end @@ -540,14 +547,26 @@ def self.work_packages_by_project(project_id) "#{project(project_id)}/work_packages" end - def self.path_for(path, filters: nil, sort_by: nil, group_by: nil, page_size: nil, offset: nil, select: nil) + def self.timestamps_to_param_value(timestamps) + timestamps = Timestamp.parse_multiple(timestamps) if timestamps.is_a? String + timestamps = [timestamps] if timestamps.is_a? Timestamp + if timestamps.present? and timestamps.is_a? Array and timestamps != [Timestamp.now] + timestamps.collect { |timestamp| timestamp.absolute.iso8601 }.join(",") + end + end + + def self.path_for(path, filters: nil, sort_by: nil, group_by: nil, page_size: nil, offset: nil, + select: nil, timestamps: nil) + timestamps = timestamps_to_param_value(timestamps) + query_params = { filters: filters&.to_json, sortBy: sort_by&.to_json, groupBy: group_by, pageSize: page_size, offset:, - select: + select:, + timestamps: }.compact_blank if query_params.any? diff --git a/lib/api/v3/work_packages/eager_loading/base.rb b/lib/api/v3/work_packages/eager_loading/base.rb index bd1a107d1ffa..e04625d044cb 100644 --- a/lib/api/v3/work_packages/eager_loading/base.rb +++ b/lib/api/v3/work_packages/eager_loading/base.rb @@ -30,9 +30,12 @@ module API module V3 module WorkPackages module EagerLoading - class Base < SimpleDelegator - def initialize(work_packages) + class Base + def initialize(work_packages, **options) self.work_packages = work_packages + options.each do |key, value| + send("#{key}=", value) if respond_to?("#{key}=") + end end def apply(_work_package) diff --git a/lib/api/v3/work_packages/eager_loading/historic_attributes.rb b/lib/api/v3/work_packages/eager_loading/historic_attributes.rb new file mode 100644 index 000000000000..44b15f3e0aef --- /dev/null +++ b/lib/api/v3/work_packages/eager_loading/historic_attributes.rb @@ -0,0 +1,92 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2022 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. +#++ + +# rubocop:disable Metrics/AbcSize +module API::V3::WorkPackages::EagerLoading + class HistoricAttributes < Base + attr_accessor :timestamps, :query + + def apply(work_package) + work_package_array_index = work_packages.map(&:id).find_index(work_package.id) + work_package_with_historic_attributes = work_packages_with_historic_attributes[work_package_array_index] + work_package.attributes = work_package_with_historic_attributes.attributes.try(:except, 'timestamp') + work_package.baseline_attributes = work_package_with_historic_attributes.baseline_attributes.try(:except, 'timestamp') + work_package.attributes_by_timestamp = work_package_with_historic_attributes.attributes_by_timestamp \ + .transform_values do |attributes| + attributes.delete_field('timestamp') if attributes.respond_to? :timestamp + attributes + end + work_package.timestamps = work_package_with_historic_attributes.timestamps + work_package.baseline_timestamp = work_package_with_historic_attributes.baseline_timestamp + work_package.matches_query_filters_at_baseline_timestamp = \ + work_package_with_historic_attributes.matches_query_filters_at_baseline_timestamp? + work_package.matches_query_filters_at_timestamps = work_package_with_historic_attributes.matches_query_filters_at_timestamps + work_package.exists_at_timestamps = work_package_with_historic_attributes.exists_at_timestamps + end + + def self.module + HistoricAttributesAccessors + end + + private + + def work_packages_with_historic_attributes + @work_packages_with_historic_attributes ||= begin + @timestamps ||= @query.try(:timestamps) || [] + Journable::WithHistoricAttributes \ + .wrap_multiple(work_packages, timestamps: @timestamps, query: @query, include_only_changed_attributes: true) + end + end + end + + module HistoricAttributesAccessors + extend ActiveSupport::Concern + + included do + attr_accessor :baseline_attributes, :attributes_by_timestamp, :timestamps, :baseline_timestamp, + :matches_query_filters_at_baseline_timestamp, + :matches_query_filters_at_timestamps, + :exists_at_timestamps + end + + # Does the work package match the query filter at the baseline timestamp? + # Returns `nil` if no query is given. + # + def matches_query_filters_at_baseline_timestamp? + matches_query_filters_at_timestamps.any? ? matches_query_filters_at_baseline_timestamp : nil + end + + # Does the work package match the query filter at the given timestamp? + # Returns `nil` if no query is given. + # + def matches_query_filters_at_timestamp?(timestamp) + matches_query_filters_at_timestamps.any? ? matches_query_filters_at_timestamps.include?(timestamp) : nil + end + end +end +# rubocop:enable Metrics/AbcSize diff --git a/lib/api/v3/work_packages/show_end_point.rb b/lib/api/v3/work_packages/show_end_point.rb new file mode 100644 index 000000000000..261b9258ce90 --- /dev/null +++ b/lib/api/v3/work_packages/show_end_point.rb @@ -0,0 +1,39 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2022 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::V3::WorkPackages + class ShowEndPoint < API::V3::Utilities::Endpoints::Show + def render(request) + API::V3::WorkPackages::WorkPackageRepresenter + .create(request.instance_exec(request.params, &instance_generator), + current_user: request.current_user, + embed_links: true, + timestamps: Timestamp.parse_multiple(request.params[:timestamps])) + end + end +end diff --git a/lib/api/v3/work_packages/work_package_collection_representer.rb b/lib/api/v3/work_packages/work_package_collection_representer.rb index a586f4af76a5..0a32fe6e8bc9 100644 --- a/lib/api/v3/work_packages/work_package_collection_representer.rb +++ b/lib/api/v3/work_packages/work_package_collection_representer.rb @@ -30,23 +30,33 @@ module API module V3 module WorkPackages class WorkPackageCollectionRepresenter < ::API::Decorators::OffsetPaginatedCollection + attr_accessor :timestamps, :query + def initialize(models, self_link:, groups:, total_sums:, current_user:, - query: {}, + query_params: {}, project: nil, page: nil, per_page: nil, - embed_schemas: false) + embed_schemas: false, + timestamps: [], + query: nil) @project = project @total_sums = total_sums @embed_schemas = embed_schemas + @timestamps = timestamps + @query = query + + if timestamps.present? && (timestamps.count > 1 or timestamps.first.historic?) + query_params[:timestamps] ||= API::V3::Utilities::PathHelper::ApiV3Path.timestamps_to_param_value(timestamps) + end super(models, self_link:, - query:, + query_params:, page:, per_page:, groups:, @@ -65,7 +75,8 @@ def initialize(models, # and set those to be the represented collection. # A potential ordering is reapplied to the work package collection in ruby. - @represented = ::API::V3::WorkPackages::WorkPackageEagerLoadingWrapper.wrap(represented, current_user) + @represented = ::API::V3::WorkPackages::WorkPackageEagerLoadingWrapper \ + .wrap(represented, current_user, timestamps:, query:) end link :sumsSchema do @@ -134,7 +145,7 @@ def initialize(models, rep_class = element_decorator.custom_field_class(all_fields) represented.map do |model| - rep_class.send(:new, model, current_user:) + rep_class.send(:new, model, current_user:, timestamps:, query:) end }, exec_context: :decorator, diff --git a/lib/api/v3/work_packages/work_package_eager_loading_wrapper.rb b/lib/api/v3/work_packages/work_package_eager_loading_wrapper.rb index 65decb4e0558..4f3b4dad4c29 100644 --- a/lib/api/v3/work_packages/work_package_eager_loading_wrapper.rb +++ b/lib/api/v3/work_packages/work_package_eager_loading_wrapper.rb @@ -35,25 +35,27 @@ def wrapped? end class << self - def wrap(ids_in_order, current_user) + attr_accessor :timestamps, :query + + def wrap(ids_in_order, current_user, timestamps: nil, query: nil) work_packages = add_eager_loading(WorkPackage.where(id: ids_in_order), current_user).to_a - wrap_and_apply(work_packages, eager_loader_classes_all) + wrap_and_apply(work_packages, eager_loader_classes_all, timestamps:, query:) .sort_by { |wp| ids_in_order.index(wp.id) } end - def wrap_one(work_package, _current_user) + def wrap_one(work_package, _current_user, timestamps: nil, query: nil) return work_package if work_package.respond_to?(:wrapped?) - wrap_and_apply([work_package], eager_loader_classes_all) + wrap_and_apply([work_package], eager_loader_classes_all, timestamps:, query:) .first end private - def wrap_and_apply(work_packages, container_classes) + def wrap_and_apply(work_packages, container_classes, timestamps:, query:) containers = container_classes - .map { |klass| klass.new(work_packages) } + .map { |klass| klass.new(work_packages, timestamps:, query:) } work_packages = work_packages.map do |work_package| new(work_package) @@ -75,7 +77,8 @@ def eager_loader_classes_all ::API::V3::WorkPackages::EagerLoading::Project, ::API::V3::WorkPackages::EagerLoading::Checksum, ::API::V3::WorkPackages::EagerLoading::CustomValue, - ::API::V3::WorkPackages::EagerLoading::CustomAction + ::API::V3::WorkPackages::EagerLoading::CustomAction, + ::API::V3::WorkPackages::EagerLoading::HistoricAttributes ] end diff --git a/lib/api/v3/work_packages/work_package_representer.rb b/lib/api/v3/work_packages/work_package_representer.rb index e19c7bd3e468..d66318ec46e7 100644 --- a/lib/api/v3/work_packages/work_package_representer.rb +++ b/lib/api/v3/work_packages/work_package_representer.rb @@ -26,6 +26,10 @@ # See COPYRIGHT and LICENSE files for more details. #++ +# rubocop:disable Lint/SymbolConversion +# because some of the json attributes are written in 'singleQuotes' instead of symbols +# for better reading. + module API module V3 module WorkPackages @@ -41,10 +45,19 @@ class WorkPackageRepresenter < ::API::Decorators::Single cached_representer key_parts: %i(project), disabled: false - def initialize(model, current_user:, embed_links: false) + attr_accessor :timestamps, :query + + def initialize(model, current_user:, embed_links: false, timestamps: nil, query: nil) + @query = query + @timestamps = timestamps || query.try(:timestamps) || [] + model = load_complete_model(model) - super + super(model, current_user:, embed_links:) + end + + def self_v3_path(*) + api_v3_paths.work_package(represented.id, timestamps:) end self_link title_getter: ->(*) { represented.subject } @@ -317,6 +330,33 @@ def initialize(model, current_user:, embed_links: false) end end + property :_meta, + if: ->(*) { + respond_to? :matches_query_filters_at_timestamps \ + and respond_to? :timestamps \ + and timestamps != [Timestamp.now] + }, + getter: ->(*) { + { + # This meta property states whether the attributes of the work package at the + # last given timestamp (commonly the current time) match the filters of the + # query. https://github.com/opf/openproject/pull/11783 + # + 'matchesFilters': matches_query_filters_at_timestamp?(timestamps.last), + + # This meta property states whether the work package exists at the last given + # timestamp (commonly the current time). + # https://github.com/opf/openproject/pull/11783#issuecomment-1374897874 + # + 'exists': exists_at_timestamps.include?(timestamps.last), + + # This meta property holds the timestamp of the data of the work package. + # + 'timestamp': timestamps.last.to_s + }.compact + }, + uncacheable: true + property :id, render_nil: true @@ -438,6 +478,37 @@ def initialize(model, current_user:, embed_links: false) status_id && status.is_readonly? end + property :attributes_by_timestamp, + as: :attributesByTimestamp, + if: ->(*) { + respond_to?(:attributes_by_timestamp) and respond_to?(:timestamps) and timestamps != [Timestamp.now] + }, + getter: ->(*) do + timestamps.collect do |timestamp| + attrs = attributes_by_timestamp[timestamp.to_s].to_h + if exists_at_timestamps.include?(timestamp) + attrs = attrs.merge({ + '_links': { + 'self': { + 'href': API::V3::Utilities::PathHelper::ApiV3Path \ + .work_package(id, timestamps: timestamp) + } + } + }) + end + attrs = attrs.merge({ + '_meta': { + 'timestamp': timestamp.to_s, + 'matchesFilters': matches_query_filters_at_timestamp?(timestamp), + 'exists': exists_at_timestamps.include?(timestamp) + }.compact + }) + attrs + end + end, + embedded: true, + uncacheable: true + associated_resource :category associated_resource :type @@ -647,9 +718,11 @@ def json_cache_key end def load_complete_model(model) - ::API::V3::WorkPackages::WorkPackageEagerLoadingWrapper.wrap_one(model, current_user) + ::API::V3::WorkPackages::WorkPackageEagerLoadingWrapper.wrap_one(model, current_user, timestamps:, query:) end end end end end + +# rubocop:enable Lint/SymbolConversion diff --git a/lib/api/v3/work_packages/work_packages_api.rb b/lib/api/v3/work_packages/work_packages_api.rb index 3fdb49980c86..c4f3f6da8dc6 100644 --- a/lib/api/v3/work_packages/work_packages_api.rb +++ b/lib/api/v3/work_packages/work_packages_api.rb @@ -76,7 +76,7 @@ class WorkPackagesAPI < ::API::OpenProjectAPI end end - get &::API::V3::Utilities::Endpoints::Show.new(model: WorkPackage).mount + get &API::V3::WorkPackages::ShowEndPoint.new(model: WorkPackage).mount patch &::API::V3::WorkPackages::UpdateEndPoint.new(model: WorkPackage, parse_service: ::API::V3::WorkPackages::ParseParamsService, diff --git a/spec/lib/api/v3/work_packages/work_package_collection_representer_spec.rb b/spec/lib/api/v3/work_packages/work_package_collection_representer_spec.rb index beb965946e36..d9114950ac80 100644 --- a/spec/lib/api/v3/work_packages/work_package_collection_representer_spec.rb +++ b/spec/lib/api/v3/work_packages/work_package_collection_representer_spec.rb @@ -35,7 +35,7 @@ let(:work_packages) { WorkPackage.all } let(:user) { build_stubbed(:user) } - let(:query) { {} } + let(:query_params) { {} } let(:groups) { nil } let(:total_sums) { nil } let(:project) { nil } @@ -45,19 +45,23 @@ let(:default_page_size) { 30 } let(:total) { 5 } let(:embed_schemas) { false } + let(:timestamps) { nil } + let(:query) { nil } let(:representer) do described_class.new( work_packages, self_link: self_base_link, - query:, + query_params:, project:, groups:, total_sums:, page: page_parameter, per_page: page_size_parameter, current_user: user, - embed_schemas: + embed_schemas:, + timestamps:, + query: ) end let(:collection_inner_type) { 'WorkPackage' } @@ -73,33 +77,61 @@ subject(:collection) { representer.to_json } describe '_links' do + describe 'self' do + describe 'when providing timestamps' do + let(:timestamps) { [Timestamp.parse("2022-01-01T00:00:00Z"), Timestamp.parse("PT0S")] } + let(:absolute_timestamp_strings) { timestamps.collect { |timestamp| timestamp.absolute.iso8601 } } + let(:absolute_timestamps_query_param) { { timestamps: absolute_timestamp_strings.join(",") }.to_query } + + it 'has the absolute timestamps within the self link' do + Timecop.freeze do + expect(subject) + .to include_json(absolute_timestamps_query_param.to_json) + .at_path('_links/self/href') + end + end + end + + describe 'when providing only the current timestamp' do + let(:timestamps) { [Timestamp.parse("PT0S")] } + + it 'has no timestamps within the self link' do + Timecop.freeze do + expect(subject) + .not_to include_json("timestamps".to_json) + .at_path('_links/self/href') + end + end + end + end + describe 'representations' do context 'when outside of a project and the user has the export_work_packages permission' do - let(:query) { { foo: 'bar' } } + let(:query_params) { { foo: 'bar' } } let(:expected) do - expected_query = query.merge(pageSize: 30, offset: 1) + expected_query_params = query_params.merge(pageSize: 30, offset: 1) JSON.parse([ { - href: work_packages_path({ format: 'pdf' }.merge(expected_query)), + href: work_packages_path({ format: 'pdf' }.merge(expected_query_params)), type: 'application/pdf', identifier: 'pdf', title: I18n.t('export.format.pdf') }, { - href: work_packages_path({ format: 'pdf', show_descriptions: true }.merge(expected_query)), + href: work_packages_path({ format: 'pdf', show_descriptions: true }.merge(expected_query_params)), identifier: 'pdf-with-descriptions', type: 'application/pdf', title: I18n.t('export.format.pdf_with_descriptions') }, { - href: work_packages_path({ format: 'csv' }.merge(expected_query)), + href: work_packages_path({ format: 'csv' }.merge(expected_query_params)), type: 'text/csv', identifier: 'csv', title: I18n.t('export.format.csv') }, { - href: work_packages_path({ format: 'atom' }.merge(expected_query)), + href: work_packages_path({ format: 'atom' }.merge(expected_query_params)), identifier: 'atom', type: 'application/atom+xml', title: I18n.t('export.format.atom') @@ -121,28 +153,28 @@ let(:project) { build_stubbed(:project) } let(:expected) do - expected_query = query.merge(pageSize: 30, offset: 1) + expected_query_params = query_params.merge(pageSize: 30, offset: 1) JSON.parse([ { - href: project_work_packages_path(project, { format: 'pdf' }.merge(expected_query)), + href: project_work_packages_path(project, { format: 'pdf' }.merge(expected_query_params)), type: 'application/pdf', identifier: 'pdf', title: I18n.t('export.format.pdf') }, { - href: project_work_packages_path(project, { format: 'pdf', show_descriptions: true }.merge(expected_query)), + href: project_work_packages_path(project, { format: 'pdf', show_descriptions: true }.merge(expected_query_params)), type: 'application/pdf', identifier: 'pdf-with-descriptions', title: I18n.t('export.format.pdf_with_descriptions') }, { - href: project_work_packages_path(project, { format: 'csv' }.merge(expected_query)), + href: project_work_packages_path(project, { format: 'csv' }.merge(expected_query_params)), identifier: 'csv', type: 'text/csv', title: I18n.t('export.format.csv') }, { - href: project_work_packages_path(project, { format: 'atom' }.merge(expected_query)), + href: project_work_packages_path(project, { format: 'atom' }.merge(expected_query_params)), identifier: 'atom', type: 'application/atom+xml', title: I18n.t('export.format.atom') @@ -244,7 +276,7 @@ describe 'ancestors' do it 'are being eager loaded' do representer.represented.each do |wp| - expect(wp.work_package_ancestors).to be_kind_of(Array) + expect(wp.work_package_ancestors).to be_a(Array) expect(wp.ancestors).to eq(wp.work_package_ancestors) end end @@ -397,8 +429,8 @@ end end - context 'when passing a query hash' do - let(:query) { { a: 'b', b: 'c' } } + context 'when passing a query_params hash' do + let(:query_params) { { a: 'b', b: 'c' } } it_behaves_like 'has an untitled link' do let(:link) { 'self' } @@ -469,4 +501,154 @@ .at_path('_embedded/schemas/_embedded/elements/0/_links/self/href') end end + + context 'when passing timestamps' do + let(:work_pacakges) { WorkPackage.where(id: work_package.id) } + let(:work_package) do + new_work_package = create(:work_package, subject: "The current work package", project:) + new_work_package.update_columns created_at: baseline_time - 1.day + new_work_package + end + let(:original_journal) do + create_journal(journable: work_package, timestamp: baseline_time - 1.day, + version: 1, + attributes: { subject: "The original work package" }) + end + let(:current_journal) do + create_journal(journable: work_package, timestamp: 1.day.ago, + version: 2, + attributes: { subject: "The current work package" }) + end + let(:baseline_time) { "2022-01-01".to_time } + let(:project) { create(:project) } + + def create_journal(journable:, version:, timestamp:, attributes: {}) + work_package_attributes = work_package.attributes.except("id") + journal_attributes = work_package_attributes \ + .extract!(*Journal::WorkPackageJournal.attribute_names) \ + .symbolize_keys.merge(attributes) + create(:work_package_journal, version:, + journable:, created_at: timestamp, updated_at: timestamp, + data: build(:journal_work_package_journal, journal_attributes)) + end + + before do + WorkPackage.destroy_all + work_package + Journal.destroy_all + original_journal + current_journal + end + + shared_examples_for 'includes the properties of the current work package' do + it 'includes the properties of the current work package' do + expect(collection) + .to be_json_eql("The current work package".to_json) + .at_path('_embedded/elements/0/subject') + end + end + + shared_examples_for 'embeds the properties of the baseline work package' do + it 'embeds the properties of the baseline work package in attributesByTimestamp' do + expect(collection) + .to be_json_eql("The original work package".to_json) + .at_path("_embedded/elements/0/_embedded/attributesByTimestamp/0/subject") + end + + it 'embeds the link to the baseline work package in attributesByTimestamp' do + expect(collection) + .to be_json_eql(api_v3_paths.work_package(work_package.id, timestamps: timestamps.first).to_json) + .at_path("_embedded/elements/0/_embedded/attributesByTimestamp/0/_links/self/href") + end + end + + shared_examples_for 'has the absolute timestamps within the self link' do + let(:absolute_timestamp_strings) { timestamps.collect { |timestamp| timestamp.absolute.iso8601 } } + let(:absolute_timestamps_query_param) { { timestamps: absolute_timestamp_strings.join(",") }.to_query } + + it 'has the absolute timestamps within the self link' do + expect(subject) + .to include_json(absolute_timestamps_query_param.to_json) + .at_path('_embedded/elements/0/_links/self/href') + end + end + + context 'with baseline and current timestamps' do + let(:timestamps) { [Timestamp.parse("2022-01-01T00:00:00Z"), Timestamp.parse("PT0S")] } + + it_behaves_like 'includes the properties of the current work package' + it_behaves_like 'embeds the properties of the baseline work package' + it_behaves_like 'has the absolute timestamps within the self link' + end + + context 'with current timestamp only' do + let(:timestamps) { [Timestamp.parse("PT0S")] } + + it_behaves_like 'includes the properties of the current work package' + + it 'has no timestamps within the self link' do + expect(subject) + .not_to include_json("timestamps".to_json) + .at_path('_embedded/elements/0/_links/self/href') + end + end + + context 'with baseline timestamp only' do + let(:timestamps) { [Timestamp.parse("2022-01-01T00:00:00Z")] } + + it 'includes the properties of the baseline work package' do + expect(collection) + .to be_json_eql("The original work package".to_json) + .at_path('_embedded/elements/0/subject') + end + + it_behaves_like 'has the absolute timestamps within the self link' + end + + context 'with empty timestamp' do + let(:timestamps) { [] } + + it_behaves_like 'includes the properties of the current work package' + + it 'has no timestamps within the self link' do + expect(subject) + .not_to include_json("timestamps".to_json) + .at_path('_embedded/elements/0/_links/self/href') + end + end + + context 'when passing a query' do + let(:search_term) { 'original' } + let(:query) do + login_as(current_user) + build(:query, user: current_user, project: nil).tap do |query| + query.filters.clear + query.add_filter 'subject', '~', search_term + query.timestamps = timestamps + end + end + let(:current_user) do + create(:user, + firstname: 'user', + lastname: '1', + member_in_project: project, + member_with_permissions: %i[view_work_packages view_file_links]) + end + + context 'with baseline and current timestamps' do + let(:timestamps) { [Timestamp.parse("2022-01-01T00:00:00Z"), Timestamp.parse("PT0S")] } + + describe 'attributesByTimestamp' do + it 'states whether the work package matches the query filters at the timestamp' do + expect(subject) + .to be_json_eql(true.to_json) + .at_path("_embedded/elements/0/_embedded/attributesByTimestamp/0/_meta/matchesFilters") + expect(subject) + .to be_json_eql(false.to_json) + .at_path("_embedded/elements/0/_embedded/attributesByTimestamp/1/_meta/matchesFilters") + end + end + end + end + end end diff --git a/spec/lib/api/v3/work_packages/work_package_representer_spec.rb b/spec/lib/api/v3/work_packages/work_package_representer_spec.rb index 656fefbf527d..99dee1206f3d 100644 --- a/spec/lib/api/v3/work_packages/work_package_representer_spec.rb +++ b/spec/lib/api/v3/work_packages/work_package_representer_spec.rb @@ -34,8 +34,10 @@ let(:member) { build_stubbed(:user) } let(:current_user) { member } let(:embed_links) { true } + let(:timestamps) { nil } + let(:query) { nil } let(:representer) do - described_class.create(work_package, current_user:, embed_links:) + described_class.create(work_package, current_user:, embed_links:, timestamps:, query:) end let(:parent) { nil } let(:priority) { build_stubbed(:priority, updated_at: Time.zone.now) } @@ -368,7 +370,7 @@ end context 'when false', with_ee: %i[readonly_work_packages] do - let(:status) { build_stubbed :status, is_readonly: false } + let(:status) { build_stubbed(:status, is_readonly: false) } it 'renders as false' do expect(subject).to be_json_eql(false.to_json).at_path('readonly') @@ -376,7 +378,7 @@ end context 'when true', with_ee: %i[readonly_work_packages] do - let(:status) { build_stubbed :status, is_readonly: true } + let(:status) { build_stubbed(:status, is_readonly: true) } it 'renders as true' do expect(subject).to be_json_eql(true.to_json).at_path('readonly') @@ -397,12 +399,10 @@ it { is_expected.to be_json_eql('PT3H45M'.to_json).at_path('derivedEstimatedTime') } end - # rubocop:disable RSpec:MultipleMemoizedHelpers xdescribe 'spentTime' do # spentTime is completely overwritten by costs # TODO: move specs from costs to here end - # rubocop:enable RSpec:MultipleMemoizedHelpers describe 'percentageDone' do describe 'work package done ratio setting behavior' do @@ -629,7 +629,7 @@ end context 'when version is set' do - let!(:version) { create :version, project: } + let!(:version) { create(:version, project:) } before do work_package.version = version @@ -675,7 +675,7 @@ end context 'when category is set' do - let!(:category) { build_stubbed :category } + let!(:category) { build_stubbed(:category) } before do work_package.category = category @@ -1158,7 +1158,7 @@ end context 'when admin' do - let(:current_user) { build_stubbed :admin } + let(:current_user) { build_stubbed(:admin) } it_behaves_like 'has a titled link' do let(:link) { 'configureForm' } @@ -1298,6 +1298,141 @@ .at_path('_embedded/customActions/0/name') end end + + context 'when passing timestamps' do + let(:timestamps) { [Timestamp.new(baseline_time), Timestamp.now] } + let(:baseline_time) { Time.zone.parse("2022-01-01") } + let(:work_pacakges) { WorkPackage.where(id: work_package.id) } + let(:work_package) do + new_work_package = create(:work_package, subject: "The current work package", project:) + new_work_package.update_columns created_at: baseline_time - 1.day + new_work_package + end + let(:original_journal) do + create_journal(journable: work_package, timestamp: baseline_time - 1.day, + version: 1, + attributes: { subject: "The original work package" }) + end + let(:current_journal) do + create_journal(journable: work_package, timestamp: 1.day.ago, + version: 2, + attributes: { subject: "The current work package" }) + end + let(:project) { create(:project) } + + def create_journal(journable:, version:, timestamp:, attributes: {}) + work_package_attributes = work_package.attributes.except("id") + journal_attributes = work_package_attributes \ + .extract!(*Journal::WorkPackageJournal.attribute_names) \ + .symbolize_keys.merge(attributes) + create(:work_package_journal, version:, + journable:, created_at: timestamp, updated_at: timestamp, + data: build(:journal_work_package_journal, journal_attributes)) + end + + before do + # Usually the eager loading wrapper is mocked + # in spec/support/api/v3/work_packages/work_package_representer_eager_loading.rb. + # However, I feel more comfortable if we test the real thing here. + # + allow(API::V3::WorkPackages::WorkPackageEagerLoadingWrapper) + .to receive(:wrap_one) + .and_call_original + + WorkPackage.destroy_all + work_package + Journal.destroy_all + original_journal + current_journal + end + + describe 'attributesByTimestamp' do + it 'has an array' do + expect(JSON.parse(subject)['_embedded']['attributesByTimestamp']).to be_an Array + end + + it 'has the historic attributes for each timestamp when they differ from the current attributes' do + expect(subject) + .to be_json_eql('The original work package'.to_json) + .at_path("_embedded/attributesByTimestamp/0/subject") + end + + it 'skips the historic attributes when they are the same as the current attributes' do + expect(subject) + .to have_json_path("_embedded/attributesByTimestamp/1") + expect(subject) + .not_to have_json_path("_embedded/attributesByTimestamp/1/subject") + end + + it 'has a link to the work package at the timestamp' do + expect(subject) + .to be_json_eql(api_v3_paths.work_package(work_package.id, timestamps: [timestamps[0]]).to_json) + .at_path("_embedded/attributesByTimestamp/0/_links/self/href") + expect(subject) + .to be_json_eql(api_v3_paths.work_package(work_package.id, timestamps: [timestamps[1]]).to_json) + .at_path("_embedded/attributesByTimestamp/1/_links/self/href") + end + + it 'has no information about whether the work package matches the query filters at the timestamp' \ + 'because there are no filters without a query' do + expect(subject) + .not_to have_json_path("_embedded/attributesByTimestamp/0/_meta/matchesFilters") + expect(subject) + .not_to have_json_path("_embedded/attributesByTimestamp/1/_meta/matchesFilters") + end + end + + describe '_meta' do + describe 'matchesFilters' do + it 'does not have this meta field without a query given' do + expect(subject) + .not_to have_json_path('_meta/matchesFilters') + end + end + end + + context 'when passing a query' do + let(:search_term) { 'original' } + let(:query) do + login_as(current_user) + build(:query, user: current_user, project: nil).tap do |query| + query.filters.clear + query.add_filter 'subject', '~', search_term + query.timestamps = timestamps + end + end + let(:current_user) do + create(:user, + firstname: 'user', + lastname: '1', + member_in_project: project, + member_with_permissions: %i[view_work_packages view_file_links]) + end + + describe 'attributesByTimestamp' do + it 'states whether the work package matches the query filters at the timestamp' do + expect(subject) + .to be_json_eql(true.to_json) + .at_path("_embedded/attributesByTimestamp/0/_meta/matchesFilters") + expect(subject) + .to be_json_eql(false.to_json) + .at_path("_embedded/attributesByTimestamp/1/_meta/matchesFilters") + end + end + + describe '_meta' do + describe 'matchesFilters' do + it 'states whether the work package matches the query filters at the last timestamp' do + # If this value is false, it means that the work package has been found by the query + # at another of the given timestamps, e.g. the baseline timestamp. + expect(subject) + .to be_json_eql(false.to_json) + .at_path('_meta/matchesFilters') + end + end + end + end + end end describe 'caching' do diff --git a/spec/models/journable/with_historic_attributes_spec.rb b/spec/models/journable/with_historic_attributes_spec.rb new file mode 100644 index 000000000000..a7815493a6d3 --- /dev/null +++ b/spec/models/journable/with_historic_attributes_spec.rb @@ -0,0 +1,568 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2022 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' + +describe Journable::WithHistoricAttributes do + let(:work_package) do + new_work_package = create(:work_package, subject: "The current work package", project: project1) + new_work_package.update_columns(created_at:) + new_work_package + end + let(:original_journal) do + create_journal(journable: work_package, timestamp: created_at, + version: 1, + attributes: { subject: "The original work package" }) + end + let(:current_journal) do + create_journal(journable: work_package, timestamp: 1.day.ago, + version: 2, + attributes: { subject: "The current work package" }) + end + let(:baseline_time) { "2022-01-01".to_time } + let(:created_at) { baseline_time - 1.day } + let(:project1) { create(:project) } + let(:user1) do + create(:user, + firstname: 'user', + lastname: '1', + member_in_project: project1, + member_with_permissions: %i[view_work_packages view_file_links]) + end + + def create_journal(journable:, version:, timestamp:, attributes: {}) + work_package_attributes = work_package.attributes.except("id") + journal_attributes = work_package_attributes \ + .extract!(*Journal::WorkPackageJournal.attribute_names) \ + .symbolize_keys.merge(attributes) + create(:work_package_journal, version:, + journable:, created_at: timestamp, updated_at: timestamp, + data: build(:journal_work_package_journal, journal_attributes)) + end + + before do + WorkPackage.destroy_all + work_package + Journal.destroy_all + original_journal + current_journal + end + + describe ".wrap" do + let(:timestamps) { [Timestamp.parse("2022-01-01T00:00:00Z"), Timestamp.parse("PT0S")] } + let(:query) { nil } + let(:include_only_changed_attributes) { nil } + + subject { described_class.wrap(work_package, timestamps:, query:, include_only_changed_attributes:) } + + it "returns a Journable::WithHistoricAttributes instance" do + expect(subject).to be_a described_class + end + + it "provides access to the work-package attributes" do + expect(subject.subject).to eq "The current work package" + end + + it "provides access to the work-package attributes at timestamps" do + expect(subject.attributes_by_timestamp["2022-01-01T00:00:00Z"].subject).to eq "The original work package" + expect(subject.attributes_by_timestamp["PT0S"].subject).to eq "The current work package" + end + + it "determines for each timestamp whether the journable exists at that timestamp" do + expect(subject.exists_at_timestamps).to include Timestamp.parse("2022-01-01T00:00:00Z") + expect(subject.exists_at_timestamps).to include Timestamp.parse("PT0S") + end + + it "determines whether the journable attributes are historic" do + expect(subject.historic?).to be false + end + + describe "when providing a query" do + let(:query) do + login_as(user1) + build(:query, user: nil, project: nil).tap do |query| + query.filters.clear + query.add_filter 'subject', '~', search_term + end + end + let(:search_term) { "original" } + + it "determines for each timestamp whether the journable matches the query at that timestamp" do + expect(subject.matches_query_filters_at_timestamps).to include Timestamp.parse("2022-01-01T00:00:00Z") + expect(subject.matches_query_filters_at_timestamps).not_to include Timestamp.parse("PT0S") + end + + describe "when the work package did not exist yet at the basline date" do + let(:timestamps) { [Timestamp.parse("2021-01-01T00:00:00Z"), Timestamp.parse("PT0S")] } + let(:search_term) { "current" } + + it "does not include the timestamp in the matches_query_filters_at_timestamps array" do + expect(subject.matches_query_filters_at_timestamps).not_to include Timestamp.parse("2021-01-01T00:00:00Z") + expect(subject.matches_query_filters_at_timestamps).to include Timestamp.parse("PT0S") + end + + it "does not include the timestamp in the exists_at_timestamps array" do + expect(subject.exists_at_timestamps).not_to include Timestamp.parse("2021-01-01T00:00:00Z") + expect(subject.exists_at_timestamps).to include Timestamp.parse("PT0S") + end + end + end + + describe "with include_only_changed_attributes: true" do + let(:include_only_changed_attributes) { true } + + it "provides access to the work-package attributes at timestamps " \ + "where the attribute is different from the work package's attribute" do + expect(subject.attributes_by_timestamp["2022-01-01T00:00:00Z"].subject).to eq "The original work package" + end + + specify "the attributes at timestamps do not include attributes that are the same as the work package's attribute" do + expect(subject.attributes_by_timestamp["PT0S"].subject).to be_nil + end + + it "includes the timestamps in the exists_at_timestamps array" do + expect(subject.exists_at_timestamps).to include Timestamp.parse("2022-01-01T00:00:00Z") + expect(subject.exists_at_timestamps).to include Timestamp.parse("PT0S") + end + end + + describe "when requesting only historic data" do + let(:timestamps) { [Timestamp.parse("2022-01-01T00:00:00Z")] } + + it "provides access to the historic work-package attributes" do + expect(subject.subject).to eq "The original work package" + end + + it "provides access to the historic work-package attributes at timestamps" do + expect(subject.attributes_by_timestamp["2022-01-01T00:00:00Z"].subject).to eq "The original work package" + end + + it "determines whether the journable attributes are historic" do + expect(subject.historic?).to be true + end + + it "includes the timestamp in the exists_at_timestamps array" do + expect(subject.exists_at_timestamps).to include Timestamp.parse("2022-01-01T00:00:00Z") + end + end + + describe "when the work package did not exist yet at the baseline date" do + let(:timestamps) { [Timestamp.parse("2021-01-01T00:00:00Z"), Timestamp.parse("PT0S")] } + + it "provides access to the work-package attributes" do + expect(subject.subject).to eq "The current work package" + end + + it "has no attributes at the baseline date" do + expect(subject.attributes_by_timestamp["2021-01-01T00:00:00Z"]).to be_nil + end + + it "has no baseline attributes" do + expect(subject.baseline_attributes).to be_nil + end + + it "does not include the timestamp in the exists_at_timestamps array" do + expect(subject.exists_at_timestamps).not_to include Timestamp.parse("2021-01-01T00:00:00Z") + expect(subject.exists_at_timestamps).to include Timestamp.parse("PT0S") + end + end + + describe "when the work package did not exist at the only requested date" do + let(:timestamps) { [Timestamp.parse("2021-01-01T00:00:00Z")] } + + it "has no attributes" do + expect(subject.attributes).to be_nil + end + + it "has no attributes at the baseline date, which is the only given date" do + expect(subject.attributes_by_timestamp["2021-01-01T00:00:00Z"]).to be_nil + end + + it "has no baseline attributes" do + expect(subject.baseline_attributes).to be_nil + end + + it "does not include the timestamp in the exists_at_timestamps array" do + expect(subject.exists_at_timestamps).not_to include Timestamp.parse("2021-01-01T00:00:00Z") + end + end + end + + describe ".wrap_multiple" do + let(:timestamps) { [Timestamp.parse("2022-01-01T00:00:00Z"), Timestamp.parse("PT0S")] } + let(:query) { nil } + let(:include_only_changed_attributes) { nil } + + subject { described_class.wrap_multiple(work_packages, timestamps:, query:, include_only_changed_attributes:) } + + context "with a single work package" do + let(:work_packages) { [work_package] } + + it "returns an array of Journable::WithHistoricAttributes instances" do + expect(subject).to all be_a described_class + end + + it "provides access to the work-package attributes" do + expect(subject.first.subject).to eq "The current work package" + end + + it "provides access to the work-package attributes at timestamps" do + expect(subject.first.attributes_by_timestamp["2022-01-01T00:00:00Z"].subject).to eq "The original work package" + expect(subject.first.attributes_by_timestamp["PT0S"].subject).to eq "The current work package" + end + + describe "when providing a query" do + let(:query) do + login_as(user1) + build(:query, user: nil, project: nil).tap do |query| + query.filters.clear + query.add_filter 'subject', '~', search_term + end + end + let(:search_term) { "original" } + + it "determines for each timestamp whether the journables matches the query at that timestamp" do + expect(subject.first.matches_query_filters_at_timestamps).to include Timestamp.parse("2022-01-01T00:00:00Z") + expect(subject.first.matches_query_filters_at_timestamps).not_to include Timestamp.parse("PT0S") + end + end + + describe "with include_only_changed_attributes: true" do + let(:include_only_changed_attributes) { true } + + it "provides access to the work-package attributes at timestamps " \ + "where the attribute is different from the work package's attribute" do + expect(subject.first.attributes_by_timestamp["2022-01-01T00:00:00Z"].subject).to eq "The original work package" + end + + specify "the attributes at timestamps do not include attributes that are the same as the work package's attribute" do + expect(subject.first.attributes_by_timestamp["PT0S"].subject).to be_nil + end + end + + describe "when requesting only historic data" do + let(:timestamps) { [Timestamp.parse("2022-01-01T00:00:00Z")] } + + it "provides access to the historic work-package attributes" do + expect(subject.first.subject).to eq "The original work package" + end + + it "provides access to the historic work-package attributes at timestamps" do + expect(subject.first.attributes_by_timestamp["2022-01-01T00:00:00Z"].subject).to eq "The original work package" + end + + it "determines whether the journable attributes are historic" do + expect(subject.first.historic?).to be true + end + end + + describe "when the work package did not exist at the only requested date" do + let(:timestamps) { [Timestamp.parse("2021-01-01T00:00:00Z")] } + + specify "the given work package does exist (at present time)" do + expect(work_package).to be_present + expect(work_packages.count).to eq 1 + end + + it "has no attributes" do + expect(subject.first.attributes).to be_nil + end + + it "has no attributes at the baseline date, which is the only given date" do + expect(subject.first.attributes_by_timestamp["2021-01-01T00:00:00Z"]).to be_nil + end + + it "has no baseline attributes" do + expect(subject.first.baseline_attributes).to be_nil + end + + it "does not include the timestamp in the exists_at_timestamps array" do + expect(subject.first.exists_at_timestamps).not_to include Timestamp.parse("2021-01-01T00:00:00Z") + end + end + end + + context "with multiple work packages" do + let!(:work_package2) do + new_work_package = create(:work_package, subject: "Other work package", project: project1) + new_work_package.update_columns(created_at:) + new_work_package.journals.update_all(created_at:) + new_work_package + end + let(:work_packages) { [work_package, work_package2] } + + it "returns an array of Journable::WithHistoricAttributes instances" do + expect(subject).to all be_a described_class + end + + it "provides access to the work-package attributes" do + expect(subject.first.subject).to eq "The current work package" + expect(subject.second.subject).to eq "Other work package" + end + + it "provides access to the work-package attributes at timestamps" do + expect(subject.first.attributes_by_timestamp["2022-01-01T00:00:00Z"].subject).to eq "The original work package" + expect(subject.first.attributes_by_timestamp["PT0S"].subject).to eq "The current work package" + expect(subject.second.attributes_by_timestamp["2022-01-01T00:00:00Z"].subject).to eq "Other work package" + expect(subject.second.attributes_by_timestamp["PT0S"].subject).to eq "Other work package" + end + + describe "when providing a query" do + let(:query) do + login_as(user1) + build(:query, user: nil, project: nil).tap do |query| + query.filters.clear + query.add_filter 'subject', '~', search_term + end + end + let(:search_term) { "original" } + + it "determines for each timestamp whether the journables matches the query at that timestamp" do + expect(subject.first.matches_query_filters_at_timestamps).to include Timestamp.parse("2022-01-01T00:00:00Z") + expect(subject.first.matches_query_filters_at_timestamps).not_to include Timestamp.parse("PT0S") + expect(subject.second.matches_query_filters_at_timestamps).to be_empty + end + end + + describe "when requesting only historic data" do + let(:timestamps) { [Timestamp.parse("2022-01-01T00:00:00Z")] } + + it "provides access to the historic work-package attributes" do + expect(subject.first.subject).to eq "The original work package" + expect(subject.second.subject).to eq "Other work package" + end + + it "provides access to the historic work-package attributes at timestamps" do + expect(subject.first.attributes_by_timestamp["2022-01-01T00:00:00Z"].subject).to eq "The original work package" + expect(subject.second.attributes_by_timestamp["2022-01-01T00:00:00Z"].subject).to eq "Other work package" + end + + it "determines whether the journable attributes are historic" do + expect(subject.first.historic?).to be true + expect(subject.second.historic?).to be true + end + end + + describe "when both work packages did not exist at the only requested date" do + let(:timestamps) { [Timestamp.parse("2021-01-01T00:00:00Z")] } + + specify "the given work packages do exist (at present time)" do + expect(work_package).to be_present + expect(work_package2).to be_present + expect(work_packages.count).to eq 2 + end + + it "has no attributes" do + expect(subject.first.attributes).to be_nil + expect(subject.second.attributes).to be_nil + end + + it "has no attributes at the baseline date, which is the only given date" do + expect(subject.first.attributes_by_timestamp["2021-01-01T00:00:00Z"]).to be_nil + expect(subject.second.attributes_by_timestamp["2021-01-01T00:00:00Z"]).to be_nil + end + + it "has no baseline attributes" do + expect(subject.first.baseline_attributes).to be_nil + expect(subject.second.baseline_attributes).to be_nil + end + + it "does not include the timestamp in the exists_at_timestamps array" do + expect(subject.first.exists_at_timestamps).not_to include Timestamp.parse("2021-01-01T00:00:00Z") + expect(subject.second.exists_at_timestamps).not_to include Timestamp.parse("2021-01-01T00:00:00Z") + end + end + + describe "when only one work package did exist at the only requested date" do + let(:timestamps) { [Timestamp.parse("2022-01-01T00:00:00Z")] } + let!(:work_package2) do + new_work_package = create(:work_package, subject: "Other work package", project: project1) + new_work_package.update_columns(created_at: "2022-05-01") + new_work_package.journals.update_all(created_at: "2022-05-01") + new_work_package + end + + specify "the given work packages do exist (at present time)" do + expect(work_package).to be_present + expect(work_package2).to be_present + expect(work_packages.count).to eq 2 + end + + specify "only one work package exists at the requested date" do + expect(work_package.at_timestamp(timestamps.first)).to be_present + expect(work_package2.at_timestamp(timestamps.first)).not_to be_present + end + + it "provides two wrapper objects" do + expect(subject.count).to eq 2 + end + + it "marks only one work package as existing at the requested date" do + expect(subject.first.exists_at_timestamps).to include Timestamp.parse("2022-01-01T00:00:00Z") + expect(subject.second.exists_at_timestamps).not_to include Timestamp.parse("2022-01-01T00:00:00Z") + end + + it "provides access to the work-package attributes at the requested date" do + expect(subject.first.subject).to eq "The original work package" + expect(subject.second).not_to respond_to :subject + end + + it "provides access to the work-package attributes at timestamps" do + expect(subject.first.attributes_by_timestamp["2022-01-01T00:00:00Z"].subject).to eq "The original work package" + expect(subject.second.attributes_by_timestamp["2022-01-01T00:00:00Z"]).to be_nil + end + + it "determines whether the journable attributes are historic" do + expect(subject.first.historic?).to be true + expect(subject.second).not_to respond_to :historic? + end + end + end + + context "with multiple relative and absolute timestamps" do + let(:timestamps) do + [Timestamp.parse("2015-01-01T00:00:00Z"), Timestamp.parse("P-1Y"), + Timestamp.parse("2022-01-01T00:00:00Z"), Timestamp.parse("PT0S")] + end + let(:work_packages) { [work_package] } + + it "preserves the relative character of the timestamps" do + expect(subject.first.timestamps.map(&:relative?)).to eq [false, true, false, true] + end + end + end + + describe "#baseline_timestamp" do + let(:timestamps) { [Timestamp.parse("2022-01-01T00:00:00Z"), Timestamp.parse("PT0S")] } + let(:journable) { described_class.wrap(work_package, timestamps:) } + + subject { journable.baseline_timestamp } + + it "provides simplified access to the baseline timestamp, which is the first given timestamp" do + expect(subject).to eq Timestamp.parse("2022-01-01T00:00:00Z") + end + end + + describe "#baseline_attributes" do + let(:timestamps) { [Timestamp.parse("2022-01-01T00:00:00Z"), Timestamp.parse("PT0S")] } + let(:journable) { described_class.wrap(work_package, timestamps:) } + + subject { journable.baseline_attributes } + + it "provides access to the work-package attributes at the baseline timestamp" do + expect(subject.subject).to eq "The original work package" + end + end + + describe "#matches_query_filters_at_baseline_timestamp?" do + let(:timestamps) { [Timestamp.parse("2022-01-01T00:00:00Z"), Timestamp.parse("PT0S")] } + let(:journable) { described_class.wrap(work_package, timestamps:, query:) } + let(:query) do + login_as(user1) + build(:query, user: nil, project: nil).tap do |query| + query.filters.clear + query.add_filter 'subject', '~', search_term + end + end + + subject { journable.matches_query_filters_at_baseline_timestamp? } + + describe "providing a filter that matches at the baseline timestamp" do + let(:search_term) { "original" } + + it "determines whether the journable matches the query at the baseline timestamp" do + expect(subject).to be true + end + end + + describe "providing a filter that matches at the current timestamp" do + let(:search_term) { "current" } + + it "determines whether the journable matches the query at the baseline timestamp" do + expect(subject).to be false + end + end + + describe "without a query" do + let(:query) { nil } + + it "does not determine whether the journable matches the query at the baseline timestamp" do + expect(subject).to be_nil + end + end + end + + describe "#current_timestamp" do + let(:timestamps) { [Timestamp.parse("2022-01-01T00:00:00Z"), Timestamp.parse("PT0S")] } + let(:journable) { described_class.wrap(work_package, timestamps:) } + + subject { journable.current_timestamp } + + it "provides simplified access to the current timestamp, which is the last given timestamp" do + expect(subject).to eq Timestamp.parse("PT0S") + end + end + + describe "#matches_query_filters_at_current_timestamp?" do + let(:timestamps) { [Timestamp.parse("2022-01-01T00:00:00Z"), Timestamp.parse("PT0S")] } + let(:journable) { described_class.wrap(work_package, timestamps:, query:) } + let(:query) do + login_as(user1) + build(:query, user: nil, project: nil).tap do |query| + query.filters.clear + query.add_filter 'subject', '~', search_term + end + end + + subject { journable.matches_query_filters_at_current_timestamp? } + + describe "providing a filter that matches at the baseline timestamp" do + let(:search_term) { "original" } + + it "determines whether the journable matches the query at the current timestamp" do + expect(subject).to be false + end + end + + describe "providing a filter that matches at the current timestamp" do + let(:search_term) { "current" } + + it "determines whether the journable matches the query at the current timestamp" do + expect(subject).to be true + end + end + + describe "without a query" do + let(:query) { nil } + + it "does not determine whether the journable matches the query at the current timestamp" do + expect(subject).to be_nil + end + end + end +end diff --git a/spec/models/timestamp_spec.rb b/spec/models/timestamp_spec.rb index 2a2fd056f053..cae668fc5c18 100644 --- a/spec/models/timestamp_spec.rb +++ b/spec/models/timestamp_spec.rb @@ -110,6 +110,51 @@ end end + describe "when providing a special shortcut value" do + describe "now" do + subject { described_class.parse("now") } + + it "returns a Timestamp representing the current time" do + expect(subject).to be_a described_class + expect(subject.to_iso8601).to eq "PT0S" + expect(subject.relative?).to be true + end + end + + describe "-1y" do + subject { described_class.parse("-1y") } + + it "returns a Timestamp representing a time ago that duration" do + expect(subject).to be_a described_class + expect(subject.to_iso8601).to eq "P-1Y" + expect(subject.to_duration).to eq ActiveSupport::Duration.build(-1.year) + expect(subject.relative?).to be true + end + end + + describe "-1y2m" do + subject { described_class.parse("-1y2m") } + + it "returns a Timestamp representing a time ago that duration" do + expect(subject).to be_a described_class + expect(subject.to_iso8601).to eq "P-1Y-2M" + expect(subject.to_duration).to eq ActiveSupport::Duration.build(-1.year - 2.months) + expect(subject.relative?).to be true + end + end + + describe "2022-01-01" do + subject { described_class.parse("2022-01-01") } + + it "returns a Timestamp representing that absolute time" do + expect(subject).to be_a described_class + expect(subject.to_iso8601).to eq "2022-01-01T00:00:00Z" + expect(subject.to_time).to eq Time.zone.parse("2022-01-01T00:00:00Z") + expect(subject.relative?).to be false + end + end + end + describe "when providing something invalid" do subject { described_class.parse("foo") } @@ -128,6 +173,56 @@ end end + describe ".parse_multiple" do + describe "when providing an empty string" do + subject { described_class.parse_multiple("") } + + it "returns an empty array" do + expect(subject).to eq [] + end + end + + describe "when providing a single timestamp" do + subject { described_class.parse_multiple("PT10S") } + + it "returns an array containing that timestamp" do + expect(subject).to eq [described_class.new("PT10S")] + end + end + + describe "when providing multiple comma-separated timestamps" do + subject { described_class.parse_multiple("PT10S,PT20S") } + + it "returns an array containing those timestamps" do + expect(subject).to eq [described_class.new("PT10S"), described_class.new("PT20S")] + end + end + + describe "when providing multiple comma-separated timestamps with whitespace" do + subject { described_class.parse_multiple("PT10S, PT20S") } + + it "returns an array containing those timestamps" do + expect(subject).to eq [described_class.new("PT10S"), described_class.new("PT20S")] + end + end + + describe "when providing multiple comma-separated timestamps with whitespace and empty strings" do + subject { described_class.parse_multiple("PT10S, , PT20S") } + + it "returns an array containing those timestamps" do + expect(subject).to eq [described_class.new("PT10S"), described_class.new("PT20S")] + end + end + + describe "when providing something invalid" do + subject { described_class.parse_multiple("foo") } + + it "raises an error" do + expect { subject }.to raise_error ArgumentError + end + end + end + describe "#relative?" do subject { timestamp.relative? } @@ -282,7 +377,7 @@ subject { Query.where("updated_at < ?", timestamp.to_time) } it "raises no error" do - expect { subject }.not_to raise_error TypeError + expect { subject }.not_to raise_error end end end 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 4dff6560dc38..5f8c11aca1f9 100644 --- a/spec/requests/api/v3/work_packages/index_resource_spec.rb +++ b/spec/requests/api/v3/work_packages/index_resource_spec.rb @@ -49,7 +49,10 @@ end describe 'GET /api/v3/work_packages' do - subject { last_response } + subject do + get path + last_response + end let(:path) { api_v3_paths.work_packages } let(:other_work_package) { create(:work_package) } @@ -57,7 +60,6 @@ before do work_packages - get path end it 'succeeds' do @@ -75,6 +77,10 @@ end context 'with filtering by typeahead' do + before { get path } + + subject { last_response } + let(:path) { api_v3_paths.path_for :work_packages, filters: } let(:filters) do [ @@ -113,12 +119,17 @@ context 'with the user not allowed to see work packages in general' do let(:non_member_permissions) { [] } + before { get path } it_behaves_like 'unauthorized access' end end describe 'encoded query props' do + before { get path } + + subject { last_response } + let(:props) do eprops = { filters: [{ id: { operator: '=', values: [work_package.id.to_s, other_visible_work_package.id.to_s] } }].to_json, @@ -223,5 +234,563 @@ it_behaves_like 'param validation error' end end + + context 'when provoding timestamps' do + subject do + get path + last_response + end + + let(:path) { "#{api_v3_paths.work_packages}?timestamps=#{timestamps.join(',')}" } + let(:timestamps) { [Timestamp.parse('2015-01-01T00:00:00Z'), Timestamp.now] } + let(:baseline_time) { timestamps.first.to_time } + let(:created_at) { baseline_time - 1.day } + + let(:work_package) do + new_work_package = create(:work_package, subject: "The current work package", project:) + new_work_package.update_columns(created_at:) + new_work_package + end + let(:original_journal) do + create_journal(journable: work_package, timestamp: created_at, + version: 1, + attributes: { subject: "The original work package" }) + end + let(:current_journal) do + create_journal(journable: work_package, timestamp: 1.day.ago, + version: 2, + attributes: { subject: "The current work package" }) + end + + def create_journal(journable:, version:, timestamp:, attributes: {}) + work_package_attributes = work_package.attributes.except("id") + journal_attributes = work_package_attributes \ + .extract!(*Journal::WorkPackageJournal.attribute_names) \ + .symbolize_keys.merge(attributes) + create(:work_package_journal, version:, + journable:, created_at: timestamp, updated_at: timestamp, + data: build(:journal_work_package_journal, journal_attributes)) + end + + before do + work_package.journals.destroy_all + original_journal + current_journal + end + + it 'succeeds' do + expect(subject.status).to be 200 + end + + it 'embeds the attributesByTimestamp' do + expect(subject.body) + .to be_json_eql("The original work package".to_json) + .at_path("_embedded/elements/0/_embedded/attributesByTimestamp/0/subject") + expect(subject.body) + .to have_json_path("_embedded/elements/0/_embedded/attributesByTimestamp/1") + end + + it 'does not embed the attributes in attributesByTimestamp if they are the same as the current attributes' do + expect(subject.body) + .not_to have_json_path("_embedded/elements/0/_embedded/attributesByTimestamp/1/description") + expect(subject.body) + .not_to have_json_path("_embedded/elements/0/_embedded/attributesByTimestamp/2/description") + end + + it 'has the current attributes as attributes' do + expect(subject.body) + .to be_json_eql("The current work package".to_json) + .at_path('_embedded/elements/0/subject') + end + + it 'has an embedded link to the baseline work package' do + expect(subject.body) + .to be_json_eql(api_v3_paths.work_package(work_package.id, timestamps: timestamps.first).to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/0/_links/self/href') + end + + it 'has the absolute timestamps within the self links of the elements' do + Timecop.freeze do + expect(subject.body) + .to be_json_eql(api_v3_paths.work_package(work_package.id, timestamps: timestamps.map(&:absolute)).to_json) + .at_path('_embedded/elements/0/_links/self/href') + end + end + + it 'has the absolute timestamps within the collection self link' do + Timecop.freeze do + expect(subject.body) + .to include_json({ timestamps: api_v3_paths.timestamps_to_param_value(timestamps.map(&:absolute)) }.to_query.to_json) + .at_path('_links/self/href') + end + end + + it 'has no redundant timestamp attribute in the main section' do + # The historic work packages have a timestamp attribute. But we do not expose that here + # because the timestamp is already given in the _meta section. + expect(subject.body) + .not_to have_json_path("_embedded/elements/0/timestamp") + end + + it 'has no redundant timestamp attribute in the attributesByTimestamp' do + # The historic work packages have a timestamp attribute. But we do not expose that here + # because the timestamp is already given in the _meta section. + expect(subject.body) + .not_to have_json_path("_embedded/elements/0/_embedded/attributesByTimestamp/0/timestamp") + end + + it 'has the relative timestamps within the _meta timestamps' do + expect(subject.body) + .to be_json_eql('2015-01-01T00:00:00Z'.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/0/_meta/timestamp') + expect(subject.body) + .to be_json_eql('PT0S'.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/1/_meta/timestamp') + expect(subject.body) + .to be_json_eql('PT0S'.to_json) + .at_path('_embedded/elements/0/_meta/timestamp') + end + + describe "when filtering such that the filters do not match at all timestamps" do + let(:path) { "#{api_v3_paths.path_for(:work_packages, filters:)}×tamps=#{timestamps.join(',')}" } + let(:filters) do + [ + { + subject: { + operator: '~', + values: [search_term] + } + } + ] + end + + describe "when the filters match the work package today" do + let(:search_term) { 'current' } + + it 'finds the work package' do + expect(subject.body) + .to be_json_eql(work_package.id.to_json) + .at_path('_embedded/elements/0/id') + end + + describe "_meta" do + describe "matchesFilters" do + it 'marks the work package as matching the filters' do + expect(subject.body) + .to be_json_eql(true.to_json) + .at_path('_embedded/elements/0/_meta/matchesFilters') + end + + it 'marks the work package as existing today' do + expect(subject.body) + .to be_json_eql(true.to_json) + .at_path('_embedded/elements/0/_meta/exists') + end + end + end + + describe "attributesByTimestamp/0 (baseline attributes)" do + describe "_meta" do + describe "matchesFilters" do + it 'marks the work package as not matching the filters at the baseline time' do + expect(subject.body) + .to be_json_eql(false.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/0/_meta/matchesFilters') + end + end + + describe "exists" do + it 'marks the work package as existing at the baseline time' do + expect(subject.body) + .to be_json_eql(true.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/0/_meta/exists') + end + end + end + end + + describe "attributesByTimestamp/1 (current attributes)" do + describe "_meta" do + describe "matchesFilters" do + it 'marks the work package as matching the filters today' do + expect(subject.body) + .to be_json_eql(true.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/1/_meta/matchesFilters') + end + end + + describe "exists" do + it 'marks the work package as existing today' do + expect(subject.body) + .to be_json_eql(true.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/1/_meta/exists') + end + end + end + end + end + + describe "when the filters match the work package at the baseline time" do + let(:search_term) { 'original' } + + it 'finds the work package' do + expect(subject.body) + .to be_json_eql(work_package.id.to_json) + .at_path('_embedded/elements/0/id') + end + + describe "_meta" do + it 'marks the work package as not matching the filters in its current state' do + expect(subject.body) + .to be_json_eql(false.to_json) + .at_path('_embedded/elements/0/_meta/matchesFilters') + end + + it 'marks the work package as existing today' do + expect(subject.body) + .to be_json_eql(true.to_json) + .at_path('_embedded/elements/0/_meta/exists') + end + end + + describe "attributesByTimestamp/0 (baseline attributes)" do + describe "_meta" do + describe "matchesFilters" do + it 'marks the work package as matching the filters at the baseline time' do + expect(subject.body) + .to be_json_eql(true.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/0/_meta/matchesFilters') + end + end + + describe "exists" do + it 'marks the work package as existing at the baseline time' do + expect(subject.body) + .to be_json_eql(true.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/0/_meta/exists') + end + end + end + end + + describe "attributesByTimestamp/1 (current attributes)" do + describe "_meta" do + describe "matchesFilters" do + it 'marks the work package as not matching the filters today' do + expect(subject.body) + .to be_json_eql(false.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/1/_meta/matchesFilters') + end + end + + describe "exists" do + it 'marks the work package as existing today' do + expect(subject.body) + .to be_json_eql(true.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/1/_meta/exists') + end + end + end + end + end + end + + describe "when the work package has not been present at the baseline time" do + let(:timestamps) { [Timestamp.parse('2015-01-01T00:00:00Z'), Timestamp.now] } + let(:created_at) { 10.days.ago } + + describe "attributesByTimestamp" do + describe "0 (baseline attributes)" do + it 'has no attributes because the work package did not exist at the baseline time' do + expect(subject.body) + .not_to have_json_path('_embedded/elements/0/_embedded/attributesByTimestamp/0/subject') + end + + describe "_meta" do + describe "timestamp" do + it 'has the baseline timestamp, which is the first timestmap' do + expect(subject.body) + .to be_json_eql(timestamps.first.to_s.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/0/_meta/timestamp') + end + end + + describe "exists" do + it 'marks the work package as not existing at the baseline time' do + expect(subject.body) + .to be_json_eql(false.to_json) + .at_path("_embedded/elements/0/_embedded/attributesByTimestamp/0/_meta/exists") + end + end + + describe "matchesFilters" do + it 'marks the work package as not matching the filters at the baseline time' do + expect(subject.body) + .to be_json_eql(false.to_json) + .at_path("_embedded/elements/0/_embedded/attributesByTimestamp/0/_meta/matchesFilters") + end + end + end + + describe "_links" do + it 'is not present' do + expect(subject.body) + .not_to have_json_path("_embedded/elements/0/_embedded/attributesByTimestamp/0/_links") + end + end + end + + describe "1 (current attributes)" do + it 'has no embedded attributes because they are the same as in the main object' do + expect(subject.body) + .not_to have_json_path('_embedded/elements/0/_embedded/attributesByTimestamp/1/subject') + end + + describe "_meta" do + describe "timestamp" do + it 'has the current timestamp, which is the second timestamp' do + expect(subject.body) + .to be_json_eql(timestamps.last.to_s.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/1/_meta/timestamp') + end + end + + describe "exists" do + it 'marks the work package as existing today' do + expect(subject.body) + .to be_json_eql(true.to_json) + .at_path("_embedded/elements/0/_embedded/attributesByTimestamp/1/_meta/exists") + end + end + + describe "matchesFilters" do + it 'marks the work package as matching the filters today' do + expect(subject.body) + .to be_json_eql(true.to_json) + .at_path("_embedded/elements/0/_embedded/attributesByTimestamp/1/_meta/matchesFilters") + end + end + end + + describe "_links" do + it 'has a self link' do + expect(subject.body) + .to be_json_eql(api_v3_paths.work_package(work_package.id).to_json) + .at_path("_embedded/elements/0/_embedded/attributesByTimestamp/1/_links/self/href") + end + end + end + end + end + + describe "when the work package has not changed at all between the baseline and today" do + let(:timestamps) { [Timestamp.new(1.minute.ago), Timestamp.now] } + + it "has the attributes in the main object" do + expect(subject.body) + .to be_json_eql(work_package.subject.to_json) + .at_path('_embedded/elements/0/subject') + end + + describe "_meta" do + describe "matchesFilters" do + it 'marks the work package as matching the filters today' do + expect(subject.body) + .to be_json_eql(true.to_json) + .at_path('_embedded/elements/0/_meta/matchesFilters') + end + end + + describe "exists" do + it 'marks the work package as existing today' do + expect(subject.body) + .to be_json_eql(true.to_json) + .at_path('_embedded/elements/0/_meta/exists') + end + end + + describe "timestamp" do + it 'has the current timestamp, which is the second timestamp, in the same format as given in the request parameter' do + expect(subject.body) + .to be_json_eql("PT0S".to_json) + .at_path('_embedded/elements/0/_meta/timestamp') + end + end + end + + describe "attributesByTimestamp" do + it "has no attributes in the embedded objects because they are the same as in the main object" do + expect(subject.body) + .to have_json_path('_embedded/elements/0/_embedded/attributesByTimestamp/0') + expect(subject.body) + .not_to have_json_path('_embedded/elements/0/_embedded/attributesByTimestamp/0/subject') + expect(subject.body) + .to have_json_path('_embedded/elements/0/_embedded/attributesByTimestamp/1') + expect(subject.body) + .not_to have_json_path('_embedded/elements/0/_embedded/attributesByTimestamp/1/subject') + end + + describe "_meta" do + describe "matchesFilters" do + it 'marks the work package as matching the filters today' do + expect(subject.body) + .to be_json_eql(true.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/1/_meta/matchesFilters') + end + + it 'marks the work package as matching the filters at the baseline time' do + expect(subject.body) + .to be_json_eql(true.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/0/_meta/matchesFilters') + end + end + + describe "exists" do + it 'marks the work package as existing today' do + expect(subject.body) + .to be_json_eql(true.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/1/_meta/exists') + end + + it 'marks the work package as existing at the baseline time' do + expect(subject.body) + .to be_json_eql(true.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/0/_meta/exists') + end + end + + describe "timestamp" do + it 'has the current timestamp, which is the second timestamp, ' \ + 'in the same format as given in the request parameter' do + expect(subject.body) + .to be_json_eql("PT0S".to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/1/_meta/timestamp') + end + + it 'has the baseline timestamp, which is the first timestamp, ' \ + 'in the same format as given in the request parameter' do + expect(subject.body) + .to be_json_eql(timestamps.first.to_s.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/0/_meta/timestamp') + end + end + end + end + end + + describe "when providing only the current timestamp PT0S, which is equivalent to providing no timestamps" do + let(:timestamps) { [Timestamp.now] } + + it "has the attributes in the main object" do + expect(subject.body) + .to be_json_eql(work_package.subject.to_json) + .at_path('_embedded/elements/0/subject') + end + + it "has no _meta" do + expect(subject.body) + .not_to have_json_path('_embedded/elements/0/_meta/matchesFilters') + expect(subject.body) + .not_to have_json_path('_embedded/elements/0/_meta/exists') + expect(subject.body) + .not_to have_json_path('_embedded/elements/0/_meta/timestamp') + end + + it "has no attributesByTimestamp" do + expect(subject.body) + .not_to have_json_path('_embedded/elements/0/_embedded/attributesByTimestamp') + end + end + + describe "for multiple work packages" do + let!(:work_package2) do + new_work_package = create(:work_package, subject: "Other work package", project:) + new_work_package.update_columns(created_at:) + new_work_package.journals.update_all(created_at:) + new_work_package + end + + it "succeeds" do + expect(subject.status).to eq(200) + end + + it "has the current attributes of both work packages" do + expect(subject.body) + .to be_json_eql(work_package.subject.to_json) + .at_path('_embedded/elements/0/subject') + expect(subject.body) + .to be_json_eql(work_package2.subject.to_json) + .at_path('_embedded/elements/1/subject') + end + + it "embeds the attributesByTimestamp for both work packages" do + expect(subject.body) + .to have_json_path('_embedded/elements/0/_embedded/attributesByTimestamp') + expect(subject.body) + .to have_json_path('_embedded/elements/1/_embedded/attributesByTimestamp') + end + + it "has the attributes that are different from the current attributes in the embedded objects" do + expect(subject.body) + .to be_json_eql("The original work package".to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/0/subject') + expect(subject.body) + .not_to have_json_path('_embedded/elements/1/_embedded/attributesByTimestamp/0/subject') + end + end + + context "with caching" do + context "with relative timestamps" do + let(:timestamps) { [Timestamp.parse("P-2D"), Timestamp.now] } + let(:created_at) { '2015-01-01' } + + describe "when the filter becomes outdated" do + # The work package has been updated 1 day ago, which is after the baseline + # date (2 days ago). When time progresses, the date of the update will be + # before the baseline date, because the baseline date is relative to the + # current date. This means that the filter will become outdated and we cannot + # use a cached result in this case. + + let(:path) { "#{api_v3_paths.path_for(:work_packages, filters:)}×tamps=#{timestamps.join(',')}" } + let(:filters) do + [ + { + subject: { + operator: '~', + values: [search_term] + } + } + ] + end + let(:search_term) { 'original' } + + it 'has the relative timestamps within the _meta timestamps' do + expect(timestamps.first.to_s).to eq('P-2D') + expect(timestamps.first).to be_relative + expect(subject.body) + .to be_json_eql('P-2D'.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/0/_meta/timestamp') + expect(subject.body) + .to be_json_eql('PT0S'.to_json) + .at_path('_embedded/elements/0/_embedded/attributesByTimestamp/1/_meta/timestamp') + expect(subject.body) + .to be_json_eql('PT0S'.to_json) + .at_path('_embedded/elements/0/_meta/timestamp') + end + + it "does not use an outdated cache" do + get path + expect do + Timecop.travel 5.days do + get path + end + end.to change { + JSON.parse(last_response.body).dig('_embedded', 'elements').count + }.from(1).to(0) + end + end + end + end + end end end diff --git a/spec/requests/api/v3/work_packages/show_resource_spec.rb b/spec/requests/api/v3/work_packages/show_resource_spec.rb index 715bbe69b227..f35c24b82d69 100644 --- a/spec/requests/api/v3/work_packages/show_resource_spec.rb +++ b/spec/requests/api/v3/work_packages/show_resource_spec.rb @@ -206,4 +206,219 @@ I18n.t('api_v3.errors.not_found.work_package') end end + + describe 'GET /api/v3/work_packages/:id?timestamps=' do + let(:get_path) { "#{api_v3_paths.work_package(work_package.id)}?timestamps=#{timestamps.map(&:to_s).join(',')}" } + + describe 'response body' do + subject do + login_as current_user + get get_path + last_response.body + end + + context 'when providing timestamps' do + let(:timestamps) { [Timestamp.parse('2015-01-01T00:00:00Z'), Timestamp.now] } + let(:baseline_time) { timestamps.first.to_time } + let(:created_at) { baseline_time - 1.day } + + let(:work_package) do + new_work_package = create(:work_package, subject: "The current work package", project:) + new_work_package.update_columns(created_at:) + new_work_package + end + let(:original_journal) do + create_journal(journable: work_package, timestamp: created_at, + version: 1, + attributes: { subject: "The original work package" }) + end + let(:current_journal) do + create_journal(journable: work_package, timestamp: 1.day.ago, + version: 2, + attributes: { subject: "The current work package" }) + end + + def create_journal(journable:, version:, timestamp:, attributes: {}) + work_package_attributes = work_package.attributes.except("id") + journal_attributes = work_package_attributes \ + .extract!(*Journal::WorkPackageJournal.attribute_names) \ + .symbolize_keys.merge(attributes) + create(:work_package_journal, version:, + journable:, created_at: timestamp, updated_at: timestamp, + data: build(:journal_work_package_journal, journal_attributes)) + end + + before do + work_package + Journal.destroy_all + original_journal + current_journal + end + + it 'responds with 200' do + expect(subject && last_response.status).to eq(200) + end + + it 'has the current attributes as attributes' do + expect(subject) + .to be_json_eql("The current work package".to_json) + .at_path('subject') + end + + it 'has an embedded link to the baseline work package' do + expect(subject) + .to be_json_eql(api_v3_paths.work_package(work_package.id, timestamps: timestamps.first).to_json) + .at_path('_embedded/attributesByTimestamp/0/_links/self/href') + end + + it 'has the absolute timestamps within the self link' do + Timecop.freeze do + expect(subject) + .to be_json_eql(api_v3_paths.work_package(work_package.id, timestamps: timestamps.map(&:absolute)).to_json) + .at_path('_links/self/href') + end + end + + describe "attributesByTimestamp" do + it 'embeds the attributesByTimestamp' do + expect(subject) + .to be_json_eql("The original work package".to_json) + .at_path("_embedded/attributesByTimestamp/0/subject") + expect(subject) + .to have_json_path("_embedded/attributesByTimestamp/1") + end + + it 'does not embed the attributes in attributesByTimestamp if they are the same as the current attributes' do + expect(subject) + .not_to have_json_path("_embedded/attributesByTimestamp/0/description") + expect(subject) + .not_to have_json_path("_embedded/attributesByTimestamp/1/description") + end + + describe '_meta' do + describe 'timestamp' do + it 'has the relative timestamps' do + expect(subject) + .to be_json_eql('2015-01-01T00:00:00Z'.to_json) + .at_path('_embedded/attributesByTimestamp/0/_meta/timestamp') + expect(subject) + .to be_json_eql('PT0S'.to_json) + .at_path('_embedded/attributesByTimestamp/1/_meta/timestamp') + end + end + end + end + + describe "when the work package has not been present at the baseline time" do + let(:timestamps) { [Timestamp.parse('2015-01-01T00:00:00Z'), Timestamp.now] } + let(:created_at) { 10.days.ago } + + describe "attributesByTimestamp" do + describe "exists" do + it "marks the work package as not existing at the baseline time" do + expect(subject) + .to be_json_eql(false.to_json) + .at_path("_embedded/attributesByTimestamp/0/_meta/exists") + end + + it "marks the work package as existing at the current time" do + expect(subject) + .to be_json_eql(true.to_json) + .at_path("_embedded/attributesByTimestamp/1/_meta/exists") + end + end + end + + describe "_meta" do + describe "exits" do + it "is true because the work package does exist at the last given timestamp" do + expect(subject) + .to be_json_eql(true.to_json) + .at_path("_meta/exists") + end + end + end + end + + describe "when the work package does not exist at the only requested timestamp" do + let(:timestamps) { [Timestamp.parse('2015-01-01T00:00:00Z')] } + let(:created_at) { 10.days.ago } + + describe "attributesByTimestamp" do + describe "exists" do + it "marks the work package as not existing at the requested time" do + expect(subject) + .to be_json_eql(false.to_json) + .at_path("_embedded/attributesByTimestamp/0/_meta/exists") + end + end + end + + describe "_meta" do + describe "exits" do + it "is false because the work package does not exist at the requested timestamp" do + expect(subject) + .to be_json_eql(false.to_json) + .at_path("_meta/exists") + end + end + end + end + + context "with caching" do + before { login_as current_user } + + context "with relative timestamps" do + let(:timestamps) { [Timestamp.parse("P-2D"), Timestamp.now] } + let(:created_at) { '2015-01-01' } + + describe "attributesByTimestamp" do + it "does not cache the self link" do + get get_path + expect do + Timecop.travel 20.minutes do + get get_path + end + end.to change { + JSON.parse(last_response.body) + .dig("_embedded", "attributesByTimestamp", 0, "_links", "self", "href") + } + end + + it "does not cache the attributes" do + get get_path + expect do + Timecop.travel 2.days do + get get_path + end + end.to change { + JSON.parse(last_response.body) + .dig("_embedded", "attributesByTimestamp", 0, "subject") + } + end + end + + describe "_meta" do + describe "exists" do + let(:timestamps) { [Timestamp.parse("P-2D")] } + let(:created_at) { 25.hours.ago } + + it "is not cached" do + get get_path + expect do + Timecop.travel 2.days do + get get_path + end + end.to change { + JSON.parse(last_response.body) + .dig("_meta", "exists") + } + end + end + end + end + end + end + end + end end diff --git a/spec/services/api/v3/parse_query_params_service_spec.rb b/spec/services/api/v3/parse_query_params_service_spec.rb index dce1814bbec1..0a9583c29635 100644 --- a/spec/services/api/v3/parse_query_params_service_spec.rb +++ b/spec/services/api/v3/parse_query_params_service_spec.rb @@ -319,5 +319,40 @@ let(:expected) { { include_subprojects: false } } end end + + context 'with timestamps' do + it_behaves_like 'transforms' do + let(:params) { { timestamps: "" } } + let(:expected) { { timestamps: [] } } + end + + it_behaves_like 'transforms' do + let(:params) { { timestamps: "P-0Y" } } + let(:expected) { { timestamps: [Timestamp.parse("P-0Y")] } } + end + + it_behaves_like 'transforms' do + let(:params) { { timestamps: "2022-10-29T23:01:23Z, P-0Y" } } + let(:expected) { { timestamps: [Timestamp.parse("2022-10-29T23:01:23Z"), Timestamp.parse("P-0Y")] } } + end + + it_behaves_like 'transforms' do + let(:params) { { timestamps: "-1y, now" } } + let(:expected) { { timestamps: [Timestamp.new("P-1Y"), Timestamp.parse("P-0Y")] } } + end + + describe "for invalid parameters" do + let(:params) { { timestamps: "foo,bar" } } + + it 'is not success' do + expect(subject).not_to be_success + end + + it 'returns the error' do + expect(subject.errors.messages[:base].length).to be(1) + expect(subject.errors.messages[:base][0]).to include "\"foo\"" + end + end + end end end diff --git a/spec/services/api/v3/work_package_collection_from_query_service_spec.rb b/spec/services/api/v3/work_package_collection_from_query_service_spec.rb index 02100714b9e9..1f09faf04f82 100644 --- a/spec/services/api/v3/work_package_collection_from_query_service_spec.rb +++ b/spec/services/api/v3/work_package_collection_from_query_service_spec.rb @@ -77,34 +77,40 @@ let(:mock_wp_representer) do Struct.new(:work_packages, :self_link, - :query, + :query_params, :project, :groups, :total_sums, :page, :per_page, :embed_schemas, - :current_user) do + :current_user, + :timestamps, + :query) do def initialize(work_packages, self_link:, - query:, + query_params:, project:, groups:, total_sums:, page:, per_page:, embed_schemas:, - current_user:) + current_user:, + timestamps:, + query:) super(work_packages, self_link, - query, + query_params, project, groups, total_sums, page, per_page, embed_schemas, - current_user) + current_user, + timestamps, + query) end end end @@ -229,6 +235,23 @@ def initialize(group, end end + context 'when timestamps are given' do + let(:timestamps) { [Timestamp.parse("P-1Y"), Timestamp.now] } + let(:query) { build_stubbed(:query, timestamps:) } + + it 'has the query timestamps' do + expect(subject.timestamps) + .to match_array(timestamps) + end + end + + context 'when a _query object is given' do + it 'has the query' do + expect(subject.query) + .to eq(query) + end + end + context 'total_sums' do context 'with query.display_sums? being false' do it 'is nil' do @@ -298,7 +321,7 @@ def initialize(group, it 'is represented' do query.display_sums = true - expect(subject.query[:showSums]) + expect(subject.query_params[:showSums]) .to be(true) end end @@ -307,7 +330,7 @@ def initialize(group, it 'is represented' do query.show_hierarchies = true - expect(subject.query[:showHierarchies]) + expect(subject.query_params[:showHierarchies]) .to be(true) end end @@ -316,7 +339,7 @@ def initialize(group, it 'is represented' do query.group_by = 'status_id' - expect(subject.query[:groupBy]) + expect(subject.query_params[:groupBy]) .to eq('status_id') end end @@ -327,7 +350,7 @@ def initialize(group, expected_sort = JSON::dump [['status', 'desc']] - expect(subject.query[:sortBy]) + expect(subject.query_params[:sortBy]) .to eq(expected_sort) end end @@ -342,14 +365,14 @@ def initialize(group, { subprojectId: { operator: '=', values: ['3', '4'] } } ]) - expect(subject.query[:filters]) + expect(subject.query_params[:filters]) .to eq(expected_filters) end it 'represents no filters' do expected_filters = JSON::dump([]) - expect(subject.query[:filters]) + expect(subject.query_params[:filters]) .to eq(expected_filters) end end @@ -357,7 +380,7 @@ def initialize(group, context 'offset' do it 'is 1 as default' do - expect(subject.query[:offset]) + expect(subject.query_params[:offset]) .to be(1) end @@ -367,7 +390,7 @@ def initialize(group, let(:params) { { 'offset' => 3 } } it 'is that value' do - expect(subject.query[:offset]) + expect(subject.query_params[:offset]) .to be(3) end end @@ -381,7 +404,7 @@ def initialize(group, end it 'is nil' do - expect(subject.query[:pageSize]) + expect(subject.query_params[:pageSize]) .to be(25) end @@ -391,7 +414,7 @@ def initialize(group, let(:params) { { 'pageSize' => 100 } } it 'is that value' do - expect(subject.query[:pageSize]) + expect(subject.query_params[:pageSize]) .to be(100) end end @@ -400,7 +423,7 @@ def initialize(group, let(:query_manually_sorted) { true } it 'is the setting value' do - expect(subject.query[:pageSize]) + expect(subject.query_params[:pageSize]) .to be(42) end @@ -408,7 +431,7 @@ def initialize(group, let(:params) { { 'pageSize' => 100 } } it 'is the setting value' do - expect(subject.query[:pageSize]) + expect(subject.query_params[:pageSize]) .to be(42) end end @@ -417,7 +440,7 @@ def initialize(group, let(:params) { { 'pageSize' => 0 } } it 'is the provided value' do - expect(subject.query[:pageSize]) + expect(subject.query_params[:pageSize]) .to be(0) end end diff --git a/spec/services/update_query_from_params_service_spec.rb b/spec/services/update_query_from_params_service_spec.rb index 01f363c65d56..45b842472b21 100644 --- a/spec/services/update_query_from_params_service_spec.rb +++ b/spec/services/update_query_from_params_service_spec.rb @@ -190,5 +190,16 @@ end end end + + context "when providing timestamps" do + let(:timestamps) { [Timestamp.parse("2022-10-29T23:01:23Z"), Timestamp.parse("PT0S")] } + let(:params) { { timestamps: } } + + it 'sets the timestamps' do + subject + + expect(query.timestamps).to eq timestamps + end + end end end