add derived dates to the work package resource

The derived dates are the minimum/maximum of the descendant's dates. As
long as the work package is scheduled automatically, the derived and non
derived dates should be equal althoug the derived dates will be null in
case non descendants exists. But if the user decides to activate manual
scheduling the dates can deviate. The derived dates can then help to
display the deviation to the user.

This commit however does not alter the front end.

The dates are eager loaded on the work package index commit using the
same mechanims already introduced for spent time. The patched cost
attributes, which are also eager loaded, are now eager loaded in the
core as well so the patch is removed. As those attributes are now part
of the core (although in a different module) it no longer makes sense to
rely on patching for this.
pull/8555/head
ulferts 4 years ago committed by Oliver Günther
parent d14ca600c1
commit 655f021b92
  1. 47
      app/models/work_package.rb
  2. 131
      app/models/work_package/spent_time.rb
  3. 95
      app/models/work_packages/derived_dates.rb
  4. 44
      app/models/work_packages/scopes/include_derived_dates.rb
  5. 79
      app/models/work_packages/scopes/include_spent_time.rb
  6. 99
      app/models/work_packages/scopes/left_join_self_and_descendants.rb
  7. 58
      app/models/work_packages/spent_time.rb
  8. 3
      config/locales/en.yml
  9. 2
      docs/api/apiv3/endpoints/projects.apib
  10. 66
      docs/api/apiv3/endpoints/work-packages.apib
  11. 16
      lib/api/v3/work_packages/schema/work_package_schema_representer.rb
  12. 50
      lib/api/v3/work_packages/work_package_eager_loading_wrapper.rb
  13. 12
      lib/api/v3/work_packages/work_package_representer.rb
  14. 10
      modules/costs/app/models/work_package/abstract_costs.rb
  15. 2
      modules/costs/lib/open_project/costs/engine.rb
  16. 129
      modules/costs/lib/open_project/costs/patches/work_package_eager_loading_patch.rb
  17. 10
      modules/costs/lib/open_project/costs/patches/work_package_patch.rb
  18. 29
      spec/lib/api/v3/work_packages/eager_loading/cost_eager_loading_integration_spec.rb
  19. 72
      spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb
  20. 62
      spec/lib/api/v3/work_packages/work_package_representer_spec.rb
  21. 153
      spec/models/work_packages/derived_dates_spec.rb
  22. 4
      spec/models/work_packages/spent_time_spec.rb
  23. 92
      spec/requests/api/v3/work_package_resource_spec.rb

@ -39,6 +39,8 @@ class WorkPackage < ApplicationRecord
include WorkPackage::TypedDagDefaults
include WorkPackage::CustomActioned
include WorkPackage::Hooks
include WorkPackages::DerivedDates
include WorkPackages::SpentTime
include ::Scopes::Scoped
include OpenProject::Journal::AttachmentHelper
@ -114,7 +116,10 @@ class WorkPackage < ApplicationRecord
where(author_id: author.id)
}
scope_classes WorkPackages::Scopes::ForScheduling
scope_classes WorkPackages::Scopes::ForScheduling,
WorkPackages::Scopes::IncludeSpentTime,
WorkPackages::Scopes::IncludeDerivedDates,
WorkPackages::Scopes::LeftJoinSelfAndDescendants
acts_as_watchable
@ -388,35 +393,6 @@ class WorkPackage < ApplicationRecord
user.allowed_to?(:edit_own_work_package_notes, project, global: project.present?) && journal.user_id == user.id
end
# Adds the 'virtual' attribute 'hours' to the result set. Using the
# patch in config/initializers/eager_load_with_hours, the value is
# returned as the #hours attribute on each work package.
def self.include_spent_hours(user)
WorkPackage::SpentTime
.new(user)
.scope
.select('SUM(time_entries.hours) AS hours')
end
# Returns the total number of hours spent on this work package and its descendants.
# The result can be a subset of the actual spent time in cases where the user's permissions
# are limited, i.e. he lacks the view_time_entries and/or view_work_packages permission.
#
# Example:
# spent_hours => 0.0
# spent_hours => 50.2
#
# The value can stem from either eager loading the value via
# WorkPackage.include_spent_hours in which case the work package has an
# #hours attribute or it is loaded on calling the method.
def spent_hours(user = User.current)
if respond_to?(:hours)
hours.to_f
else
compute_spent_hours(user)
end || 0.0
end
# Returns a scope for the projects
# the user is allowed to move a work package to
def self.allowed_target_projects_on_move(user)
@ -575,7 +551,7 @@ class WorkPackage < ApplicationRecord
def self.self_and_descendants_of_condition(work_package)
relation_subquery = Relation
.with_type_columns_not(hierarchy: 0)
.with_type_columns_not(hierarchy: nil)
.select(:to_id)
.where(from_id: work_package.id)
"#{table_name}.id IN (#{relation_subquery.to_sql}) OR #{table_name}.id = #{work_package.id}"
@ -720,15 +696,6 @@ class WorkPackage < ApplicationRecord
end
end
def compute_spent_hours(user)
WorkPackage::SpentTime
.new(user, self)
.scope
.where(id: id)
.pluck(Arel.sql('SUM(hours)'))
.first
end
def attribute_users
related = [author]

@ -1,131 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
class WorkPackage::SpentTime
attr_accessor :user,
:work_package
def initialize(user, work_package = nil)
@user = user
@work_package = work_package
end
def scope
with_spent_hours_joined
end
private
def with_spent_hours_joined
query = join_descendants(wp_table)
query = join_time_entries(query)
WorkPackage.joins(query.join_sources)
.group(:id)
end
def join_descendants(select)
select
.outer_join(relations_table)
.on(relations_join_descendants_condition)
.outer_join(wp_descendants)
.on(hierarchy_and_allowed_condition)
end
def join_time_entries(select)
join_condition = time_entries_table[:work_package_id]
.eq(wp_descendants[:id])
.and(allowed_to_view_time_entries)
select
.outer_join(time_entries_table)
.on(join_condition)
end
def relations_from_and_type_matches_condition
relations_join_condition = relation_of_wp_and_hierarchy_condition
non_hierarchy_type_columns.each do |type|
relations_join_condition = relations_join_condition.and(relations_table[type].eq(0))
end
relations_join_condition
end
def relation_of_wp_and_hierarchy_condition
wp_table[:id].eq(relations_table[:from_id]).and(relations_table[:hierarchy].gteq(0))
end
def relations_join_descendants_condition
if work_package
relations_from_and_type_matches_condition
.and(wp_table[:id].eq(work_package.id))
else
relations_from_and_type_matches_condition
end
end
def allowed_to_view_work_packages
wp_descendants[:project_id].in(Project.allowed_to(user, :view_work_packages).select(:id).arel)
end
def allowed_to_view_time_entries
time_entries_table[:id].in(TimeEntry.visible(user).select(:id).arel)
end
def hierarchy_and_allowed_condition
self_or_descendant_condition
.and(allowed_to_view_work_packages)
end
def self_or_descendant_condition
relations_table[:to_id].eq(wp_descendants[:id])
end
def non_hierarchy_type_columns
TypedDag::Configuration[WorkPackage].type_columns - [:hierarchy]
end
def wp_table
@wp_table ||= WorkPackage.arel_table
end
def relations_table
@relations || Relation.arel_table
end
def wp_descendants
@wp_descendants ||= wp_table.alias('descendants')
end
def time_entries_table
@time_entries_table ||= TimeEntry.arel_table
end
end

@ -0,0 +1,95 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module WorkPackages::DerivedDates
# Returns the maximum of the dates of all descendants (start and due date)
# No visibility check is applied so a user will always see the maximum regardless of his permission.
#
# The value can stem from either eager loading the value via
# WorkPackage.include_derived_dates in which case the work package has a
# derived_start_date attribute or it is loaded on calling the method.
def derived_start_date
derived_date('derived_start_date')
end
# Returns the minimum of the dates of all descendants (start and due date)
# No visibility check is applied so a user will always see the minimum regardless of his permission.
#
# The value can stem from either eager loading the value via
# WorkPackage.include_derived_dates in which case the work package has a
# derived_due_date attribute or it is loaded on calling the method.
def derived_due_date
derived_date('derived_due_date')
end
def derived_start_date=(date)
compute_derived_dates
@derived_dates[0] = date
end
def derived_due_date=(date)
compute_derived_dates
@derived_dates[1] = date
end
def reload(*)
@derived_dates = nil
super
end
private
def derived_date(key)
if attributes.key?(key)
attributes[key]
else
compute_derived_dates[key]
end
end
def compute_derived_dates
@derived_dates ||= begin
attributes = %w[derived_start_date derived_due_date]
values = if persisted?
WorkPackage
.from(WorkPackage.include_derived_dates.where(id: self))
.pluck(*attributes.each { |a| Arel.sql(a) })
.first || []
else
[]
end
attributes
.zip(values)
.to_h
end
end
end

@ -28,37 +28,27 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
# This patch is needed for eager loading spent hours for a bunch of work
# packages. The WorkPackage.include_spent_hours(user) method adds an additional
# attribute to the result set. As such 'virtual' attributes are not added to
# the model on instantiation (see: https://github.com/rails/rails/issues/15185)
# this patch has been added which is based on
# https://github.com/rails/rails/issues/15185#issuecomment-142230234
class WorkPackages::Scopes::IncludeDerivedDates
attr_accessor :user,
:work_package
module OpenProject::Patches
module ActiveRecordJoinPartPatch
def instantiate(row, aliases)
if base_klass == WorkPackage && row.has_key?('hours')
aliases_with_hours = aliases + [
ActiveRecord::Associations::JoinDependency::Aliases::Column.new('hours', 'hours')
]
super(row, aliases_with_hours)
else
super(row, aliases)
end
class << self
def fetch
WorkPackage
.left_joins(:descendants)
.select(*select_statement)
.group(:id)
end
end
end
require 'active_record'
private
def select_statement
["LEAST(MIN(#{descendants_alias}.start_date), MIN(#{descendants_alias}.due_date)) AS derived_start_date",
"GREATEST(MAX(#{descendants_alias}.start_date), MAX(#{descendants_alias}.due_date)) AS derived_due_date"]
end
module ActiveRecord
module Associations
class JoinDependency
JoinBase && class JoinPart
prepend OpenProject::Patches::ActiveRecordJoinPartPatch
end
def descendants_alias
'descendants_work_packages'
end
end
end

@ -0,0 +1,79 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
class WorkPackages::Scopes::IncludeSpentTime
class << self
def fetch(user, work_package = nil)
query = join_time_entries(user)
scope = WorkPackage
.left_join_self_and_descendants(user, work_package)
.joins(query.join_sources)
.group(:id)
.select('SUM(time_entries.hours) AS hours')
if work_package
scope.where(id: work_package.id)
else
scope
end
end
protected
def join_time_entries(user)
join_condition = time_entries_table[:work_package_id]
.eq(wp_descendants[:id])
.and(allowed_to_view_time_entries(user))
wp_table
.outer_join(time_entries_table)
.on(join_condition)
end
def allowed_to_view_time_entries(user)
time_entries_table[:id].in(TimeEntry.visible(user).select(:id).arel)
end
def wp_table
@wp_table ||= WorkPackage.arel_table
end
def wp_descendants
# Relies on a table called descendants to exist in the scope
# which is provided by left_join_self_and_descendants
@wp_descendants ||= wp_table.alias('descendants')
end
def time_entries_table
@time_entries_table ||= TimeEntry.arel_table
end
end
end

@ -0,0 +1,99 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
class WorkPackages::Scopes::LeftJoinSelfAndDescendants
class << self
def fetch(user, work_package = nil)
WorkPackage.joins(join_descendants(user, work_package).join_sources)
end
private
def join_descendants(user, work_package)
wp_table
.outer_join(relations_table)
.on(relations_join_descendants_condition(work_package))
.outer_join(wp_descendants)
.on(hierarchy_and_allowed_condition(user))
end
def relations_from_and_type_matches_condition
relations_join_condition = relation_of_wp_and_hierarchy_condition
non_hierarchy_type_columns.each do |type|
relations_join_condition = relations_join_condition.and(relations_table[type].eq(0))
end
relations_join_condition
end
def relation_of_wp_and_hierarchy_condition
wp_table[:id].eq(relations_table[:from_id]).and(relations_table[:hierarchy].gteq(0))
end
def relations_join_descendants_condition(work_package)
if work_package
relations_from_and_type_matches_condition
.and(wp_table[:id].eq(work_package.id))
else
relations_from_and_type_matches_condition
end
end
def hierarchy_and_allowed_condition(user)
self_or_descendant_condition
.and(allowed_to_view_work_packages(user))
end
def allowed_to_view_work_packages(user)
wp_descendants[:project_id].in(Project.allowed_to(user, :view_work_packages).select(:id).arel)
end
def self_or_descendant_condition
relations_table[:to_id].eq(wp_descendants[:id])
end
def non_hierarchy_type_columns
TypedDag::Configuration[WorkPackage].type_columns - [:hierarchy]
end
def wp_table
@wp_table ||= WorkPackage.arel_table
end
def relations_table
@relations || Relation.arel_table
end
def wp_descendants
@wp_descendants ||= wp_table.alias('descendants')
end
end
end

@ -0,0 +1,58 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module WorkPackages::SpentTime
# Returns the total number of hours spent on this work package and its descendants.
# The result can be a subset of the actual spent time in cases where the user's permissions
# are limited, i.e. he lacks the view_time_entries and/or view_work_packages permission.
#
# Example:
# spent_hours => 0.0
# spent_hours => 50.2
#
# The value can stem from either eager loading the value via
# WorkPackage.include_spent_time in which case the work package has an
# #hours attribute or it is loaded on calling the method.
def spent_hours(user = User.current)
if respond_to?(:hours)
hours.to_f
else
compute_spent_hours(user)
end || 0.0
end
private
def compute_spent_hours(user)
WorkPackage.include_spent_time(user, self)
.pluck(Arel.sql('SUM(hours)'))
.first
end
end

@ -797,6 +797,9 @@ en:
date: "Date"
default_columns: "Default columns"
description: "Description"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Display Sums"
due_date: "Finish date"
estimated_hours: "Estimated time"

@ -435,6 +435,8 @@ Creates a new project, applying the attributes provided in the body.
You can use the form and schema to be retrieve the valid attribute values and by that be guided towards successful creation.
+ Parameters
+ Request Create project
+ Body

@ -43,22 +43,25 @@
## Local Properties
| Property | Description | Type | Constraints | Supported operations | Condition |
| :--------------: | ------------------------------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------ | -------------------- | -------------------------------- |
| id | Work package id | Integer | x > 0 | READ | |
| lockVersion | The version of the item as used for optimistic locking | Integer | | READ | |
| subject | Work package subject | String | not null; 1 <= length <= 255 | READ / WRITE | |
| type | Name of the work package's type | String | not null | READ | |
| description | The work package description | Formattable | | READ / WRITE | |
| scheduleManually | If false (default) schedule automatically. | Boolean | | READ / WRITE | |
| startDate | Scheduled beginning of a work package | Date | Cannot be set for parent work packages; must be equal or greater than the earliest possible start date; Exists only on work packages of a non milestone type | READ / WRITE | |
| dueDate | Scheduled end of a work package | Date | Cannot be set for parent work packages; must be greater than or equal to the start date; Exists only on work packages of a non milestone type | READ / WRITE | |
| date | Date on which a milestone is achieved | Date | Exists only on work packages of a milestone type
| estimatedTime | Time a work package likely needs to be completed | Duration | Cannot be set for parent work packages | READ / WRITE | |
| spentTime | | Duration | | READ | **Permission** view time entries |
| percentageDone | Amount of total completion for a work package | Integer | 0 <= x <= 100; Cannot be set for parent work packages | READ / WRITE | |
| createdAt | Time of creation | DateTime | | READ | |
| updatedAt | Time of the most recent change to the work package | DateTime | | READ | |
| Property | Description | Type | Constraints | Supported operations | Condition |
| :--------------: | ------------------------------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------ | -------------------- | -------------------------------- |
| id | Work package id | Integer | x > 0 | READ | |
| lockVersion | The version of the item as used for optimistic locking | Integer | | READ | |
| subject | Work package subject | String | not null; 1 <= length <= 255 | READ / WRITE | |
| type | Name of the work package's type | String | not null | READ | |
| description | The work package description | Formattable | | READ / WRITE | |
| scheduleManually | If false (default) schedule automatically. | Boolean | | READ / WRITE | |
| startDate | Scheduled beginning of a work package | Date | Cannot be set for parent work packages; must be equal or greater than the earliest possible start date; Exists only on work packages of a non milestone type | READ / WRITE | |
| dueDate | Scheduled end of a work package | Date | Cannot be set for parent work packages; must be greater than or equal to the start date; Exists only on work packages of a non milestone type | READ / WRITE | |
| date | Date on which a milestone is achieved | Date | Exists only on work packages of a milestone type | READ / WRITE | |
| derivedStartDate | Similar to start date but is not set by a client but rather deduced by the work packages's descendants. If manual scheduleManually is active, the two dates can deviate. | Date | | READ | |
| derivedDueDate | Similar to due date but is not set by a client but rather deduced by the work packages's descendants. If manual scheduleManually is active, the two dates can deviate. | Date | | READ | |
| estimatedTime | Time a work package likely needs to be completed excluding its descendants | Duration | | READ / WRITE | |
| derivedEstimatedTime | Time a work package likely needs to be completed including its descendants | Duration | | READ | |
| spentTime | The time booked for this work package by users working on it | Duration | | READ | **Permission** view time entries |
| percentageDone | Amount of total completion for a work package | Integer | 0 <= x <= 100; Cannot be set for parent work packages | READ / WRITE | |
| createdAt | Time of creation | DateTime | | READ | |
| updatedAt | Time of the most recent change to the work package | DateTime | | READ | |
Note that the properties listed here only cover the built-in properties of the OpenProject Core.
Using plug-ins and custom fields a work package might contain various additional properties.
@ -71,12 +74,12 @@ the human readable name of custom fields.
Properties that cannot be set directly on parent work packages are inferred from their children instead:
* `startDate` is the earliest start date from its children
* `dueDate` is the latest finish date from its children
* `startDate` is the earliest start date from its children if manual scheduling is activated.
* `dueDate` is the latest finish date from its children if manual scheduling is activated.
* `estimatedTime` is the sum of estimated times from its children
* `percentageDone` is the weighted average of the sum of its children percentages done. The weight is given by the average of its children estimatedHours. However, if the percentage done is given by a work package's status, then only the status matters and no value is inferred.
Start date can also not be earlier than a finish date of any predecessor.
`startDate` can also not be earlier than a finish date of any predecessor.
While attachments are returned as a link which's content is to be fetched separately, clients can choose to
replace the work package's attachments by providing an array of already uploaded [Attachment resources](#attachments) on [create](#work-packages-work-packages-post)
@ -261,7 +264,10 @@ and [update](#work-packages-work-package-patch). The attachments the work packag
"scheduleManually": false,
"startDate": null,
"dueDate": null,
"derivedStartDate": null,
"derivedDueDate": null,
"estimatedTime": "PT2H",
"derivedEstimatedTime": "PT10H",
"percentageDone": 0,
"customField1": "Foo",
"customField2": 42,
@ -692,6 +698,28 @@ A project link must be set when creating work packages through this route.
Note that this controls notifications for all users interested in changes to the work package (e.g. watchers, author and assignee),
not just the current user.
+ Request (application/json)
{
"subject": "Lorem",
"customField41": 8,
"startDate": "2048-01-03",
"_links": {
"project": {
"href": "/api/v3/project/42"
},
"type": {
"href": "/api/v3/type/123"
},
"assignee": {
"href": "/api/v3/users/8"
},
"customField32": {
"href": "/api/v3/users/3"
}
}
}
+ Response 200 (application/hal+json)
[Work Package][]

@ -134,6 +134,16 @@ module API
required: false,
show_if: ->(*) { !represented.milestone? }
schema :derived_start_date,
type: 'Date',
required: false,
show_if: ->(*) { !represented.milestone? }
schema :derived_due_date,
type: 'Date',
required: false,
show_if: ->(*) { !represented.milestone? }
schema :date,
type: 'Date',
required: false,
@ -143,10 +153,14 @@ module API
type: 'Duration',
required: false
schema :derived_estimated_time,
type: 'Duration',
required: false
schema :spent_time,
type: 'Duration',
required: false,
show_if: ->(*) { represented.project && represented.project.module_enabled?('time_tracking') }
show_if: ->(*) { represented.project&.module_enabled?('time_tracking') }
schema :percentage_done,
type: 'Integer',

@ -82,15 +82,63 @@ module API
end
def add_eager_loading(scope, current_user)
material_scope = work_package_material_scope(scope)
labor_scope = work_package_labor_scope(scope)
# The eager loading on status is required for the readonly? check in the
# work package schema
scope
.joins(spent_time_subquery(scope, current_user).join_sources)
.joins(derived_dates_subquery(scope).join_sources)
.joins(material_scope.arel.join_sources)
.joins(labor_scope.arel.join_sources)
.includes(WorkPackageRepresenter.to_eager_load)
.includes(:status)
.include_spent_hours(current_user)
.select('work_packages.*')
.select('spent_time_hours.hours')
.select('derived_dates.derived_start_date', 'derived_dates.derived_due_date')
.select(material_scope.select_values)
.select(labor_scope.select_values)
.distinct
end
def spent_time_subquery(scope, current_user)
time_scope = scope
.dup
.include_spent_time(current_user)
.select(:id)
wp_table = WorkPackage.arel_table
wp_table
.outer_join(time_scope.arel.as('spent_time_hours'))
.on(wp_table[:id].eq(time_scope.arel_table.alias('spent_time_hours')[:id]))
end
def derived_dates_subquery(scope)
dates_scope = scope
.dup
.include_derived_dates
.select(:id)
wp_table = WorkPackage.arel_table
wp_table
.outer_join(dates_scope.arel.as('derived_dates'))
.on(wp_table[:id].eq(dates_scope.arel_table.alias('derived_dates')[:id]))
end
def work_package_material_scope(scope)
WorkPackage::MaterialCosts
.new
.add_to_work_package_collection(scope.dup)
end
def work_package_labor_scope(scope)
WorkPackage::LaborCosts
.new
.add_to_work_package_collection(scope.dup)
end
end
eager_loader_classes_all.each do |klass|

@ -364,6 +364,18 @@ module API
!represented.milestone?
}
date_property :derived_start_date,
skip_render: ->(represented:, **) {
represented.milestone?
},
uncacheable: true
date_property :derived_due_date,
skip_render: ->(represented:, **) {
represented.milestone?
},
uncacheable: true
property :estimated_time,
exec_context: :decorator,
getter: ->(*) do

@ -91,7 +91,7 @@ class WorkPackage
##
# Narrows down the query to only include costs visible to the user.
#
# @param [ActiveRecord::QueryMethods] Some query.
# @param [ActiveRecord::QueryMethods] scope Some query.
# @return [ActiveRecord::QueryMethods] The filtered query.
def filter_authorized(scope)
scope # allow all
@ -108,11 +108,13 @@ class WorkPackage
def sum_subselect(base_scope)
base_scope
.dup
.left_join_self_and_descendants(user)
.except(:select)
.select("#{costs_sum} AS #{costs_sum_alias}")
.select(wp_table[:id])
.arel
.outer_join(ce_table).on(ce_table_join_condition)
.outer_join(ce_table)
.on(ce_table_join_condition)
.group(wp_table[:id])
end
@ -121,6 +123,8 @@ class WorkPackage
end
def wp_table_descendants
# Relies on a table called descendants to exist in the scope
# which is provided by left_join_self_and_descendants
wp_table.alias 'descendants'
end
@ -132,8 +136,6 @@ class WorkPackage
authorization_scope = filter_authorized costs_model.all
authorization_where = authorization_scope.arel.ast.cores.last.wheres.last
# relies on the scope having the wp descendants joined at least
# when #to_sql is called.
ce_table[:work_package_id].eq(wp_table_descendants[:id]).and(authorization_where)
end

@ -379,8 +379,6 @@ module OpenProject::Costs
API::V3::WorkPackages::WorkPackageRepresenter.to_eager_load += [:cost_object]
require 'open_project/costs/patches/work_package_eager_loading_patch'
API::V3::WorkPackages::WorkPackageEagerLoadingWrapper.prepend OpenProject::Costs::Patches::WorkPackageEagerLoadingPatch
##
# Add a new group
cost_attributes = %i(cost_object costs_by_type labor_costs material_costs overall_costs)

@ -1,129 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module OpenProject::Costs::Patches::WorkPackageEagerLoadingPatch
def self.prepended(base)
class << base
prepend ClassMethods
end
end
def self.join_costs(scope)
# The core adds a "LEFT OUTER JOIN time_entries" where the on clause
# allows all time entries to be joined if he has the :view_time_entries.
# Costs will add another "LEFT OUTER JOIN time_entries". The two joins
# may or may not include each other's rows depending on the user's and the project's permissions.
# This is caused by entries being joined if he has
# the :view_time_entries permission and additionally those which are
# his and for which he has the :view_own_time_entries permission.
# Because of that, entries may be joined twice.
# We therefore modify the core's join by placing it in a subquery similar to those of costs.
#
# This is very hacky.
#
# We also have to remove the sum calcualtion for time_entries.hours as
# the calculation is later on performed within the subquery added by
# LaborCosts. With it, we can use the value as it is calculated by the subquery.
time_join = core_with_joined_time(scope)
reject_core_time_entries(scope)
target_scope = new_scope_with_costs(scope, time_join)
reject_core_descendants(target_scope)
reject_core_grouping(target_scope)
target_scope
end
def self.core_with_joined_time(scope)
time = scope.dup
wp_table = WorkPackage.arel_table
wp_table
.outer_join(time.arel.as('spent_time_hours'))
.on(wp_table[:id].eq(time.arel_table.alias('spent_time_hours')[:id]))
end
def self.new_scope_with_costs(scope, time_join)
material_scope = work_package_material_scope(scope)
labor_scope = work_package_labor_scope(scope)
scope
.joins(material_scope.arel.join_sources)
.joins(labor_scope.arel.join_sources)
.joins(time_join.join_sources)
.select(material_scope.select_values)
.select(labor_scope.select_values)
.select('spent_time_hours.hours')
end
def self.work_package_material_scope(scope)
WorkPackage::MaterialCosts
.new
.add_to_work_package_collection(scope.dup)
end
def self.work_package_labor_scope(scope)
WorkPackage::LaborCosts
.new
.add_to_work_package_collection(scope.dup)
end
def self.reject_core_time_entries(scope)
scope.joins_values.reject! do |join|
join.is_a?(Arel::Nodes::OuterJoin) &&
join.left.is_a?(Arel::Table) &&
join.left.name == 'time_entries'
end
scope.select_values.reject! do |select|
select == "SUM(time_entries.hours) AS hours"
end
end
def self.reject_core_descendants(scope)
scope.joins_values.reject! do |join|
join.is_a?(Arel::Nodes::OuterJoin) &&
join.left.is_a?(Arel::Nodes::TableAlias) &&
join.left.right == 'descendants'
end
end
def self.reject_core_grouping(scope)
scope.group_values.reject! do |group|
group == :id
end
end
module ClassMethods
def add_eager_loading(*args)
::OpenProject::Costs::Patches::WorkPackageEagerLoadingPatch.join_costs(super)
end
end
end

@ -47,7 +47,7 @@ module OpenProject::Costs::Patches
association = journable.class.reflect_on_association(field.to_sym)
if association
record = association.class_name.constantize.find_by_id(value.to_i)
record.subject if record
record&.subject
end
end
@ -111,11 +111,11 @@ module OpenProject::Costs::Patches
module InstanceMethods
def costs_enabled?
project && project.costs_enabled?
project&.costs_enabled?
end
def cost_reporting_enabled?
project && project.cost_reporting_enabled?
project&.cost_reporting_enabled?
end
def validate_cost_object
@ -149,9 +149,7 @@ module OpenProject::Costs::Patches
# Wraps the association to get the Cost Object subject. Needed for the
# Query and filtering
def cost_object_subject
unless cost_object.nil?
return cost_object.subject
end
cost_object&.subject
end
def update_costs!

@ -28,19 +28,19 @@
require 'spec_helper'
describe WorkPackage, 'cost eager loading', type: :model do
describe ::API::V3::WorkPackages::WorkPackageEagerLoadingWrapper, 'cost eager loading', type: :model do
let(:project) do
work_package.project
end
let(:role) do
FactoryBot.create(:role,
permissions: [:view_work_packages,
:view_cost_entries,
:view_cost_rates,
:view_time_entries,
:log_time,
:log_costs,
:view_hourly_rates])
permissions: %i[view_work_packages
view_cost_entries
view_cost_rates
view_time_entries
log_time
log_costs
view_hourly_rates])
end
let(:user) do
FactoryBot.create(:user,
@ -92,18 +92,11 @@ describe WorkPackage, 'cost eager loading', type: :model do
context "combining core's and cost's eager loading" do
let(:scope) do
scope = WorkPackage
.include_spent_hours(user)
.select('work_packages.*')
.where(id: [work_package.id])
OpenProject::Costs::Patches::WorkPackageEagerLoadingPatch.join_costs(scope)
API::V3::WorkPackages::WorkPackageEagerLoadingWrapper.wrap([work_package.id], user)
end
before do
allow(User)
.to receive(:current)
.and_return(user)
login_as(user)
user_rates
project.reload
@ -114,8 +107,6 @@ describe WorkPackage, 'cost eager loading', type: :model do
time_entry2
end
subject { scope.first }
it 'correctly calculates spent time' do
expect(scope.to_a.first.hours).to eql time_entry1.hours + time_entry2.hours
end

@ -447,9 +447,64 @@ describe ::API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter do
end
end
describe 'derivedStartDate' do
let(:is_milestone) { false }
before do
allow(schema)
.to receive(:milestone?)
.and_return(is_milestone)
end
it_behaves_like 'has basic schema properties' do
let(:path) { 'derivedStartDate' }
let(:type) { 'Date' }
let(:name) { I18n.t('attributes.derived_start_date') }
let(:required) { false }
let(:writable) { false }
end
context 'when the work package is a milestone' do
let(:is_milestone) { true }
it 'has no date attribute' do
is_expected.to_not have_json_path('derivedStartDate')
end
end
end
describe 'derivedDueDate' do
let(:is_milestone) { false }
before do
allow(schema)
.to receive(:milestone?)
.and_return(is_milestone)
end
it_behaves_like 'has basic schema properties' do
let(:path) { 'derivedDueDate' }
let(:type) { 'Date' }
let(:name) { I18n.t('attributes.derived_due_date') }
let(:required) { false }
let(:writable) { false }
end
context 'when the work package is a milestone' do
let(:is_milestone) { true }
it 'has no date attribute' do
is_expected.to_not have_json_path('derivedDueDate')
end
end
end
describe 'estimatedTime' do
before do
allow(schema).to receive(:writable?).with(:estimated_time).and_return true
allow(schema)
.to receive(:writable?)
.with(:estimated_time)
.and_return true
end
it_behaves_like 'has basic schema properties' do
@ -462,7 +517,10 @@ describe ::API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter do
context 'not writable' do
before do
allow(schema).to receive(:writable?).with(:estimated_time).and_return false
allow(schema)
.to receive(:writable?)
.with(:estimated_time)
.and_return false
end
it_behaves_like 'has basic schema properties' do
@ -475,6 +533,16 @@ describe ::API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter do
end
end
describe 'derivedDstimatedTime' do
it_behaves_like 'has basic schema properties' do
let(:path) { 'derivedEstimatedTime' }
let(:type) { 'Duration' }
let(:name) { I18n.t('attributes.derived_estimated_time') }
let(:required) { false }
let(:writable) { false }
end
end
describe 'spentTime' do
context 'with \'time_tracking\' enabled' do
before do

@ -48,6 +48,8 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do
let(:estimated_hours) { nil }
let(:derived_estimated_hours) { nil }
let(:spent_hours) { 0 }
let(:derived_start_date) { Date.today - 4.days }
let(:derived_due_date) { Date.today - 5.days }
let(:work_package) do
FactoryBot.build_stubbed(:stubbed_work_package,
schedule_manually: schedule_manually,
@ -70,6 +72,14 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do
allow(wp)
.to receive(:spent_hours)
.and_return(spent_hours)
allow(wp)
.to receive(:derived_start_date)
.and_return(derived_start_date)
allow(wp)
.to receive(:derived_due_date)
.and_return(derived_due_date)
end
end
let(:all_permissions) do
@ -221,6 +231,58 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do
end
end
describe 'derivedStartDate' do
it_behaves_like 'has ISO 8601 date only' do
let(:date) { derived_start_date }
let(:json_path) { 'derivedStartDate' }
end
context 'no derived start date' do
let(:derived_start_date) { nil }
it 'renders as null' do
is_expected
.to be_json_eql(nil.to_json)
.at_path('derivedStartDate')
end
end
context 'when the work package has a milestone type' do
let(:type_milestone) { true }
it 'has no derivedStartDate' do
is_expected
.to_not have_json_path('derivedStartDate')
end
end
end
describe 'derivedDueDate' do
it_behaves_like 'has ISO 8601 date only' do
let(:date) { derived_due_date }
let(:json_path) { 'derivedDueDate' }
end
context 'no derived due date' do
let(:derived_due_date) { nil }
it 'renders as null' do
is_expected
.to be_json_eql(nil.to_json)
.at_path('derivedDueDate')
end
end
context 'when the work package has a milestone type' do
let(:type_milestone) { true }
it 'has no derivedDueDate' do
is_expected
.to_not have_json_path('derivedDueDate')
end
end
end
describe 'createdAt' do
it_behaves_like 'has UTC ISO 8601 date and time' do
let(:date) { work_package.created_at }

@ -0,0 +1,153 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe WorkPackage, 'derived dates', type: :model do
let(:work_package) do
FactoryBot.create(:work_package)
end
let(:child_work_package) do
FactoryBot.create(:work_package,
project: work_package.project,
start_date: child_start_date,
due_date: child_due_date,
parent: work_package)
end
let(:child_work_package_in_other_project) do
FactoryBot.create(:work_package,
start_date: other_child_start_date,
due_date: other_child_due_date,
parent: work_package)
end
let(:child_start_date) { Date.today - 4.days }
let(:child_due_date) { Date.today + 6.days }
let(:other_child_start_date) { Date.today + 4.days }
let(:other_child_due_date) { Date.today + 10.days }
let(:work_packages) { [work_package, child_work_package, child_work_package_in_other_project] }
let(:role) do
FactoryBot.build(:role,
permissions: %i[view_work_packages])
end
let(:user) do
FactoryBot.build(:user,
member_in_project: work_package.project,
member_through_role: role)
end
before do
login_as user
work_packages
end
shared_examples_for 'derived dates' do
context 'with all dates being set' do
it 'the derived_start_date is the minimum of both start and due date' do
expect(subject.derived_start_date).to eql child_start_date
end
it 'the derived_due_date is the maximum of both start and due date' do
expect(subject.derived_due_date).to eql other_child_due_date
end
end
context 'with the due dates being minimal (start date being nil)' do
let(:child_start_date) { nil }
let(:other_child_start_date) { nil }
it 'the derived_start_date is the minumum of the due dates' do
expect(subject.derived_start_date).to eql child_due_date
end
it 'the derived_due_date is the maximum of the due dates' do
expect(subject.derived_due_date).to eql other_child_due_date
end
end
context 'with the start date being maximum (due date being nil)' do
let(:child_due_date) { nil }
let(:other_child_due_date) { nil }
it 'the derived_start_date is the minimum of the start dates' do
expect(subject.derived_start_date).to eql child_start_date
end
it 'has the derived_due_date is the maximum of the start dates' do
expect(subject.derived_due_date).to eql other_child_start_date
end
end
context 'with child dates being nil' do
let(:child_start_date) { nil }
let(:child_due_date) { nil }
let(:other_child_start_date) { nil }
let(:other_child_due_date) { nil }
it 'is nil' do
expect(subject.derived_start_date).to be_nil
end
end
context 'without children' do
let(:work_packages) { [work_package] }
it 'is nil' do
expect(subject.derived_start_date).to be_nil
end
end
end
context 'for a work_package loaded individually' do
subject { work_package }
it_behaves_like 'derived dates'
end
context 'for a work package that had derived dates loaded' do
subject { WorkPackage.include_derived_dates.first }
it_behaves_like 'derived dates'
end
context 'for an unpersisted work_package' do
let(:work_package) { WorkPackage.new }
let(:work_packages) { [] }
subject { work_package }
it 'the derived_start_date is nil' do
expect(subject.derived_start_date).to be_nil
end
it 'the derived_due_date is nil' do
expect(subject.derived_due_date).to be_nil
end
end
end

@ -28,7 +28,7 @@
require 'spec_helper'
describe WorkPackage::SpentTime, type: :model do
describe WorkPackage, 'spent_time', type: :model do
let(:project) do
work_package.project
end
@ -156,7 +156,7 @@ describe WorkPackage::SpentTime, type: :model do
end
context 'for a work package that had spent time eager loaded' do
subject { WorkPackage.include_spent_hours(user).where(id: work_package.id).first.spent_hours }
subject { WorkPackage.include_spent_time(user).where(id: work_package.id).first.spent_hours }
it_behaves_like 'spent hours'
end

@ -51,7 +51,7 @@ describe 'API v3 Work package resource',
let(:current_user) do
user = FactoryBot.create(:user, member_in_project: project, member_through_role: role)
FactoryBot.create(:user_preference, user: user, others: {no_self_notified: false})
FactoryBot.create(:user_preference, user: user, others: { no_self_notified: false })
user
end
@ -226,7 +226,7 @@ describe 'API v3 Work package resource',
context 'when acting as a user with permission to view work package' do
before(:each) do
allow(User).to receive(:current).and_return current_user
login_as(current_user)
get get_path
end
@ -235,15 +235,20 @@ describe 'API v3 Work package resource',
end
describe 'response body' do
subject(:parsed_response) { JSON.parse(last_response.body) }
subject { last_response.body }
let!(:other_wp) do
FactoryBot.create(:work_package, project_id: project.id,
FactoryBot.create(:work_package,
project_id: project.id,
status: closed_status)
end
let(:work_package) do
FactoryBot.create(:work_package, project_id: project.id,
description: description)
FactoryBot.create(:work_package,
project_id: project.id,
description: description).tap do |wp|
wp.children << children
end
end
let(:children) { [] }
let(:description) do
<<~DESCRIPTION
<macro class="toc"><macro>
@ -266,27 +271,44 @@ describe 'API v3 Work package resource',
DESCRIPTION
end
it 'should respond with work package in HAL+JSON format' do
expect(parsed_response['id']).to eq(work_package.id)
it 'responds with work package in HAL+JSON format' do
expect(subject)
.to be_json_eql(work_package.id.to_json)
.at_path('id')
end
describe "['description']" do
subject { super()['description'] }
it { is_expected.to have_selector('h1') }
end
describe "description" do
subject { JSON.parse(last_response.body)['description'] }
describe "['description']" do
subject { super()['description'] }
it { is_expected.to have_selector('h2') }
end
it 'renders to html' do
is_expected.to have_selector('h1')
is_expected.to have_selector('h2')
it 'should resolve links' do
expect(parsed_response['description']['html'])
.to have_selector("a[href='/work_packages/#{other_wp.id}']")
# resolves links
expect(subject['html'])
.to have_selector("a[href='/work_packages/#{other_wp.id}']")
# resolves macros
is_expected.to have_text('Table of contents')
end
end
it 'should resolve simple macros' do
expect(parsed_response['description']).to have_text('Table of contents')
describe 'derived dates' do
let(:children) do
# This will be in another project but the user is still allowed to see the dates
[FactoryBot.create(:work_package,
start_date: Date.today,
due_date: Date.today + 5.days)]
end
it 'has derived dates' do
is_expected
.to be_json_eql(Date.today.to_json)
.at_path('derivedStartDate')
is_expected
.to be_json_eql((Date.today + 5.days).to_json)
.at_path('derivedDueDate')
end
end
end
@ -297,7 +319,7 @@ describe 'API v3 Work package resource',
end
end
context 'when acting as an user without permission to view work package' do
context 'when acting as a user without permission to view work package' do
before(:each) do
allow(User).to receive(:current).and_return unauthorize_user
get get_path
@ -450,7 +472,7 @@ describe 'API v3 Work package resource',
context 'w/o value (empty)' do
let(:raw) { nil }
let(:html) { '' }
let(:params) { valid_params.merge(description: {raw: nil}) }
let(:params) { valid_params.merge(description: { raw: nil }) }
include_context 'patch request'
@ -464,7 +486,7 @@ describe 'API v3 Work package resource',
let(:html) do
'<p><strong>Some text</strong> <em>describing</em> <strong>something</strong>...</p>'
end
let(:params) { valid_params.merge(description: {raw: raw}) }
let(:params) { valid_params.merge(description: { raw: raw }) }
include_context 'patch request'
@ -520,7 +542,7 @@ describe 'API v3 Work package resource',
context 'status' do
let(:target_status) { FactoryBot.create(:status) }
let(:status_link) { api_v3_paths.status target_status.id }
let(:status_parameter) { {_links: {status: {href: status_link}}} }
let(:status_parameter) { { _links: { status: { href: status_link } } } }
let(:params) { valid_params.merge(status_parameter) }
before { allow(User).to receive(:current).and_return current_user }
@ -576,7 +598,7 @@ describe 'API v3 Work package resource',
context 'type' do
let(:target_type) { FactoryBot.create(:type) }
let(:type_link) { api_v3_paths.type target_type.id }
let(:type_parameter) { {_links: {type: {href: type_link}}} }
let(:type_parameter) { { _links: { type: { href: type_link } } } }
let(:params) { valid_params.merge(type_parameter) }
before { allow(User).to receive(:current).and_return current_user }
@ -600,7 +622,7 @@ describe 'API v3 Work package resource',
context 'valid type changing custom fields' do
let(:custom_field) { FactoryBot.create(:work_package_custom_field) }
let(:custom_field_parameter) { {:"customField#{custom_field.id}" => true} }
let(:custom_field_parameter) { { :"customField#{custom_field.id}" => true } }
let(:params) { valid_params.merge(type_parameter).merge(custom_field_parameter) }
before do
@ -647,7 +669,7 @@ describe 'API v3 Work package resource',
FactoryBot.create(:project, public: false)
end
let(:project_link) { api_v3_paths.project target_project.id }
let(:project_parameter) { {_links: {project: {href: project_link}}} }
let(:project_parameter) { { _links: { project: { href: project_link } } } }
let(:params) { valid_params.merge(project_parameter) }
before do
@ -679,7 +701,7 @@ describe 'API v3 Work package resource',
context 'with a custom field defined on the target project' do
let(:custom_field) { FactoryBot.create(:work_package_custom_field) }
let(:custom_field_parameter) { {:"customField#{custom_field.id}" => true} }
let(:custom_field_parameter) { { :"customField#{custom_field.id}" => true } }
let(:params) { valid_params.merge(project_parameter).merge(custom_field_parameter) }
before do
@ -727,7 +749,7 @@ describe 'API v3 Work package resource',
end
shared_examples_for 'handling people' do |property|
let(:user_parameter) { {_links: {property => {href: user_href}}} }
let(:user_parameter) { { _links: { property => { href: user_href } } } }
let(:href_path) { "_links/#{property}/href" }
describe 'nil' do
@ -848,7 +870,7 @@ describe 'API v3 Work package resource',
context 'version' do
let(:target_version) { FactoryBot.create(:version, project: project) }
let(:version_link) { api_v3_paths.version target_version.id }
let(:version_parameter) { {_links: {version: {href: version_link}}} }
let(:version_parameter) { { _links: { version: { href: version_link } } } }
let(:params) { valid_params.merge(version_parameter) }
before { allow(User).to receive(:current).and_return current_user }
@ -885,7 +907,7 @@ describe 'API v3 Work package resource',
context 'category' do
let(:target_category) { FactoryBot.create(:category, project: project) }
let(:category_link) { api_v3_paths.category target_category.id }
let(:category_parameter) { {_links: {category: {href: category_link}}} }
let(:category_parameter) { { _links: { category: { href: category_link } } } }
let(:params) { valid_params.merge(category_parameter) }
before { allow(User).to receive(:current).and_return current_user }
@ -908,7 +930,7 @@ describe 'API v3 Work package resource',
context 'priority' do
let(:target_priority) { FactoryBot.create(:priority) }
let(:priority_link) { api_v3_paths.priority target_priority.id }
let(:priority_parameter) { {_links: {priority: {href: priority_link}}} }
let(:priority_parameter) { { _links: { priority: { href: priority_link } } } }
let(:params) { valid_params.merge(priority_parameter) }
before { allow(User).to receive(:current).and_return current_user }
@ -940,7 +962,7 @@ describe 'API v3 Work package resource',
end
let(:value_parameter) do
{_links: {custom_field.accessor_name.camelize(:lower) => {href: value_link}}}
{ _links: { custom_field.accessor_name.camelize(:lower) => { href: value_link } } }
end
let(:params) { valid_params.merge(value_parameter) }
@ -1168,7 +1190,7 @@ describe 'API v3 Work package resource',
status.save!
priority.save!
FactoryBot.create(:user_preference, user: current_user, others: {no_self_notified: false})
FactoryBot.create(:user_preference, user: current_user, others: { no_self_notified: false })
post path, parameters.to_json, 'CONTENT_TYPE' => 'application/json'
perform_enqueued_jobs
end

Loading…
Cancel
Save