From 5d4091cb4d41b0a56dd561848564ba70c0c0ecc0 Mon Sep 17 00:00:00 2001 From: ulferts Date: Fri, 12 Sep 2025 13:37:51 +0200 Subject: [PATCH 01/21] add a workspaces endpoint --- lib/api/v3/projects/project_representer.rb | 16 +++- lib/api/v3/root.rb | 3 +- lib/api/v3/utilities/path_helper.rb | 2 + lib/api/v3/workspaces/workspaces_api.rb | 46 ++++++++++ spec/lib/api/v3/utilities/path_helper_spec.rb | 4 + .../api/v3/workspaces/index_resource_spec.rb | 85 +++++++++++++++++++ 6 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 lib/api/v3/workspaces/workspaces_api.rb create mode 100644 spec/requests/api/v3/workspaces/index_resource_spec.rb diff --git a/lib/api/v3/projects/project_representer.rb b/lib/api/v3/projects/project_representer.rb index fdcb364f0542..a5b37a276c3e 100644 --- a/lib/api/v3/projects/project_representer.rb +++ b/lib/api/v3/projects/project_representer.rb @@ -232,7 +232,21 @@ def self.current_user_view_allowed_lambda cache_if: current_user_view_allowed_lambda def _type - "Project" + # TODO: check for a different implementation + case represented.workspace_type + when "project" + "Project" + when "program" + "Program" + when "portfolio" + "Portfolio" + else + raise NoMethodError + end + end + + def self_v3_path(*) + api_v3_paths.project(represented.id) end self.to_eager_load = [:enabled_modules] diff --git a/lib/api/v3/root.rb b/lib/api/v3/root.rb index 815ac373c3b2..50d3b8135b1e 100644 --- a/lib/api/v3/root.rb +++ b/lib/api/v3/root.rb @@ -86,8 +86,9 @@ class Root < ::API::OpenProjectAPI mount ::API::V3::Values::ValuesAPI mount ::API::V3::Versions::VersionsAPI mount ::API::V3::Views::ViewsAPI - mount ::API::V3::WorkPackages::WorkPackagesAPI mount ::API::V3::WikiPages::WikiPagesAPI + mount ::API::V3::Workspaces::WorkspacesAPI + mount ::API::V3::WorkPackages::WorkPackagesAPI get "/" do RootRepresenter.new({}, current_user:) diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 7c9d87afb802..70d8aaa3994b 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -651,6 +651,8 @@ def self.work_packages_by_project(project_id) "#{project(project_id)}/work_packages" end + index :workspace + def self.timestamps_to_param_value(timestamps) Array(timestamps).map { |timestamp| Timestamp.parse(timestamp).absolute }.join(",") end diff --git a/lib/api/v3/workspaces/workspaces_api.rb b/lib/api/v3/workspaces/workspaces_api.rb new file mode 100644 index 000000000000..ec2d3a81eb39 --- /dev/null +++ b/lib/api/v3/workspaces/workspaces_api.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module API + module V3 + module Workspaces + class WorkspacesAPI < ::API::OpenProjectAPI + resources :workspaces do + get &::API::V3::Utilities::Endpoints::SqlFallbackedIndex.new(model: Project, + scope: -> { + Project + .includes(API::V3::Projects::ProjectRepresenter.to_eager_load) + }) + .mount + end + end + end + end +end diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index 8ad9b7bed68e..08cc830c437e 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -667,6 +667,10 @@ def self.filter end end + describe "workspace paths" do + it_behaves_like "index", :workspace + end + describe ".timestamps_to_param_value" do subject { helper.timestamps_to_param_value(timestamps) } diff --git a/spec/requests/api/v3/workspaces/index_resource_spec.rb b/spec/requests/api/v3/workspaces/index_resource_spec.rb new file mode 100644 index 000000000000..3bf90b86e1b5 --- /dev/null +++ b/spec/requests/api/v3/workspaces/index_resource_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" +require "rack/test" + +RSpec.describe "API v3 Project resource index", content_type: :json do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + shared_let(:no_membership_project) do + create(:project, public: false) + end + shared_let(:permissions, reload: true) { [] } + shared_let(:role, reload: true) { create(:project_role, permissions:) } + shared_let(:project, reload: true) do + create(:project, public: false) + end + shared_let(:program, reload: true) do + create(:project, public: false, workspace_type: "program") + end + shared_let(:portfolio, reload: true) do + create(:project, public: false, workspace_type: "portfolio") + end + shared_let(:user, reload: true) do + create(:user, + member_with_roles: + { + portfolio => role, + program => role, + project => role + }) + end + + let(:filters) { [] } + let(:get_path) do + api_v3_paths.path_for :workspaces, filters: + end + let(:response) { last_response } + + current_user { user } + + before do + get get_path + end + + it_behaves_like "API V3 collection response", 3, 3 do + let(:elements) { [portfolio, program, project] } + + it "provides distinct types per workspace type" do + aggregate_failures do + expect(subject).to be_json_eql("Portfolio".to_json).at_path("_embedded/elements/0/_type") + expect(subject).to be_json_eql("Program".to_json).at_path("_embedded/elements/1/_type") + expect(subject).to be_json_eql("Project".to_json).at_path("_embedded/elements/2/_type") + end + end + end +end From b8ba01c82231fd17e0e849fb6171718631c4e3f3 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 17 Sep 2025 11:41:35 +0200 Subject: [PATCH 02/21] support signaling on workspaces endpoint --- .../v3/projects/project_sql_representer.rb | 10 ++- spec/factories/portfolio_factory.rb | 38 ++++++++++ spec/factories/program_factory.rb | 38 ++++++++++ spec/factories/project_factory.rb | 45 +---------- spec/factories/workspace_factory.rb | 76 +++++++++++++++++++ .../project_sql_representer_rendering_spec.rb | 72 ++++++++++++++++-- .../api/v3/workspaces/index_resource_spec.rb | 59 +++++++++++++- 7 files changed, 286 insertions(+), 52 deletions(-) create mode 100644 spec/factories/portfolio_factory.rb create mode 100644 spec/factories/program_factory.rb create mode 100644 spec/factories/workspace_factory.rb diff --git a/lib/api/v3/projects/project_sql_representer.rb b/lib/api/v3/projects/project_sql_representer.rb index 8eb8167e10d7..b642f18c8a46 100644 --- a/lib/api/v3/projects/project_sql_representer.rb +++ b/lib/api/v3/projects/project_sql_representer.rb @@ -112,7 +112,15 @@ def ancestor_projection } property :_type, - representation: ->(*) { "'Project'" } + representation: ->(*) { + <<~SQL.squish + CASE + WHEN workspace_type = 'project' THEN 'Project' + WHEN workspace_type = 'program' THEN 'Program' + WHEN workspace_type = 'portfolio' THEN 'Portfolio' + END + SQL + } property :id diff --git a/spec/factories/portfolio_factory.rb b/spec/factories/portfolio_factory.rb new file mode 100644 index 000000000000..df05dbe31689 --- /dev/null +++ b/spec/factories/portfolio_factory.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +FactoryBot.define do + factory :program, parent: :workspace do + workspace_type { "program" } + + sequence(:name) { |n| "My Program No. #{n}" } + sequence(:identifier) { |n| "myprogram_no_#{n}" } + end +end diff --git a/spec/factories/program_factory.rb b/spec/factories/program_factory.rb new file mode 100644 index 000000000000..afb26180c66b --- /dev/null +++ b/spec/factories/program_factory.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +FactoryBot.define do + factory :portfolio, parent: :workspace do + workspace_type { "portfolio" } + + sequence(:name) { |n| "My Portfolio No. #{n}" } + sequence(:identifier) { |n| "myportfolio_no_#{n}" } + end +end diff --git a/spec/factories/project_factory.rb b/spec/factories/project_factory.rb index 0c457d53b7ee..9f7931fe79ed 100644 --- a/spec/factories/project_factory.rb +++ b/spec/factories/project_factory.rb @@ -29,38 +29,11 @@ #++ FactoryBot.define do - factory :project do - transient do - no_types { false } - disable_modules { [] } - members { [] } - end + factory :project, parent: :workspace do + workspace_type { "project" } sequence(:name) { |n| "My Project No. #{n}" } sequence(:identifier) { |n| "myproject_no_#{n}" } - created_at { Time.zone.now } - updated_at { Time.zone.now } - enabled_module_names { OpenProject::AccessControl.available_project_modules } - public { false } - templated { false } - workspace_type { "project" } - - callback(:after_build) do |project, evaluator| - disabled_modules = Array(evaluator.disable_modules).map(&:to_s) - project.enabled_module_names = project.enabled_module_names - disabled_modules - - if !evaluator.no_types && project.types.empty? - project.types << (Type.where(is_standard: true).first || build(:type_standard)) - end - end - - callback(:after_create) do |project, evaluator| - evaluator.members.each do |user, roles| - Members::CreateService - .new(user: User.system, contract_class: EmptyContract) - .call(principal: user, project:, roles: Array(roles)) - end - end factory :public_project do public { true } # Remark: public defaults to true @@ -92,19 +65,5 @@ end end end - - trait :with_status do - status_code { Project.status_codes.keys.sample } - status_explanation { "some explanation" } - end - - trait :archived do - active { false } - end - - trait :updated_a_long_time_ago do - created_at { 2.years.ago } - updated_at { 2.years.ago } - end end end diff --git a/spec/factories/workspace_factory.rb b/spec/factories/workspace_factory.rb new file mode 100644 index 000000000000..28df3ad51521 --- /dev/null +++ b/spec/factories/workspace_factory.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +FactoryBot.define do + factory :workspace, class: "Project" do + transient do + no_types { false } + disable_modules { [] } + members { [] } + end + + created_at { Time.zone.now } + updated_at { Time.zone.now } + enabled_module_names { OpenProject::AccessControl.available_project_modules } + public { false } + templated { false } + + callback(:after_build) do |project, evaluator| + disabled_modules = Array(evaluator.disable_modules).map(&:to_s) + project.enabled_module_names = project.enabled_module_names - disabled_modules + + if !evaluator.no_types && project.types.empty? + project.types << (Type.where(is_standard: true).first || build(:type_standard)) + end + end + + callback(:after_create) do |project, evaluator| + evaluator.members.each do |user, roles| + Members::CreateService + .new(user: User.system, contract_class: EmptyContract) + .call(principal: user, project:, roles: Array(roles)) + end + end + + trait :with_status do + status_code { Project.status_codes.keys.sample } + status_explanation { "some explanation" } + end + + trait :archived do + active { false } + end + + trait :updated_a_long_time_ago do + created_at { 2.years.ago } + updated_at { 2.years.ago } + end + end +end diff --git a/spec/lib/api/v3/projects/project_sql_representer_rendering_spec.rb b/spec/lib/api/v3/projects/project_sql_representer_rendering_spec.rb index 639247445855..3884e9e13362 100644 --- a/spec/lib/api/v3/projects/project_sql_representer_rendering_spec.rb +++ b/spec/lib/api/v3/projects/project_sql_representer_rendering_spec.rb @@ -44,20 +44,24 @@ Project .where(id: project.id) end + let(:role) { create(:project_role) } + let(:select) { { "*" => {} } } - let(:project) do + shared_let(:portfolio, reload: true) do + create(:portfolio) + end + shared_let(:program, reload: true) do + create(:program) + end + shared_let(:project, reload: true) do create(:project) end - let(:role) { create(:project_role) } - - let(:select) { { "*" => {} } } - current_user do create(:user, member_with_roles: { project => role }) end - context "when rendering all supported properties" do + context "when rendering all supported properties of a project" do it "renders as expected" do expect(json) .to be_json_eql( @@ -80,6 +84,62 @@ end end + context "when rendering all supported properties of a program" do + let(:scope) do + Project + .where(id: program.id) + end + + it "renders as expected" do + expect(json) + .to be_json_eql( + { + id: program.id, + _type: "Program", + name: program.name, + identifier: program.identifier, + active: true, + public: false, + _links: { + ancestors: [], + self: { + href: api_v3_paths.project(program.id), + title: program.name + } + } + }.to_json + ) + end + end + + context "when rendering all supported properties of a portfolio" do + let(:scope) do + Project + .where(id: portfolio.id) + end + + it "renders as expected" do + expect(json) + .to be_json_eql( + { + id: portfolio.id, + _type: "Portfolio", + name: portfolio.name, + identifier: portfolio.identifier, + active: true, + public: false, + _links: { + ancestors: [], + self: { + href: api_v3_paths.project(portfolio.id), + title: portfolio.name + } + } + }.to_json + ) + end + end + context "with an ancestor" do let!(:parent) do create(:project, members: { current_user => role }).tap do |parent| diff --git a/spec/requests/api/v3/workspaces/index_resource_spec.rb b/spec/requests/api/v3/workspaces/index_resource_spec.rb index 3bf90b86e1b5..49a9e94a4756 100644 --- a/spec/requests/api/v3/workspaces/index_resource_spec.rb +++ b/spec/requests/api/v3/workspaces/index_resource_spec.rb @@ -43,11 +43,20 @@ shared_let(:project, reload: true) do create(:project, public: false) end + shared_let(:invisible_project, reload: true) do + create(:project, public: false) + end shared_let(:program, reload: true) do - create(:project, public: false, workspace_type: "program") + create(:program, public: false) + end + shared_let(:invisible_program, reload: true) do + create(:program, public: false) end shared_let(:portfolio, reload: true) do - create(:project, public: false, workspace_type: "portfolio") + create(:portfolio, public: false) + end + shared_let(:invisible_portfolio, reload: true) do + create(:portfolio, public: false) end shared_let(:user, reload: true) do create(:user, @@ -82,4 +91,50 @@ end end end + + context "with a pageSize and offset" do + let(:get_path) do + api_v3_paths.path_for :workspaces, sort_by: { id: :asc }, page_size: 2, offset: 2 + end + + it_behaves_like "API V3 collection response", 3, 1, "Portfolio" do + let(:elements) { [portfolio] } + end + end + + context "when signaling the properties to include" do + let(:select) { "elements/id,elements/name,elements/_type,total" } + let(:get_path) do + api_v3_paths.path_for :workspaces, select: + end + let(:expected) do + { + total: 3, + _embedded: { + elements: [ + { + id: portfolio.id, + name: portfolio.name, + _type: "Portfolio" + }, + { + id: program.id, + name: program.name, + _type: "Program" + }, + { + id: project.id, + name: project.name, + _type: "Project" + } + ] + } + } + end + + it "is the reduced set of properties of the embedded elements" do + expect(last_response.body) + .to be_json_eql(expected.to_json) + end + end end From d3274de4f868e5e48ba851878258af2c652b85bb Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 17 Sep 2025 11:55:46 +0200 Subject: [PATCH 03/21] spec filtering on the workspace endpoint --- .../api/v3/workspaces/index_resource_spec.rb | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/spec/requests/api/v3/workspaces/index_resource_spec.rb b/spec/requests/api/v3/workspaces/index_resource_spec.rb index 49a9e94a4756..fef3d6b6b04d 100644 --- a/spec/requests/api/v3/workspaces/index_resource_spec.rb +++ b/spec/requests/api/v3/workspaces/index_resource_spec.rb @@ -31,7 +31,10 @@ require "spec_helper" require "rack/test" -RSpec.describe "API v3 Project resource index", content_type: :json do +# The workspace endpoint currently is a copy of the projects endpoint and reuses most of the functionality of it. +# As such, this spec tests that all aspects of the index endpoint (filtering, signaling, offset, pagination) are supported +# without going into the same breadth as the specs for the projects endpoint does. +RSpec.describe "API v3 Workspace resource index", content_type: :json do include Rack::Test::Methods include API::V3::Utilities::PathHelper @@ -137,4 +140,34 @@ .to be_json_eql(expected.to_json) end end + + context "when filtering by typeahead" do + let(:filters) do + [{ typeahead: { operator: "**", values: [search_string] } }] + end + + context "when searching for the project" do + let(:search_string) { "Proj" } + + it_behaves_like "API V3 collection response", 1, 1, "Project" do + let(:elements) { [project] } + end + end + + context "when searching for the program" do + let(:search_string) { "Prog" } + + it_behaves_like "API V3 collection response", 1, 1, "Program" do + let(:elements) { [program] } + end + end + + context "when searching for the portfolio" do + let(:search_string) { "Port" } + + it_behaves_like "API V3 collection response", 1, 1, "Portfolio" do + let(:elements) { [portfolio] } + end + end + end end From c284f390de2574d58b35623f2c254ee14d9fa523 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 17 Sep 2025 15:57:51 +0200 Subject: [PATCH 04/21] limit projects api to only return project resources --- lib/api/v3/projects/projects_api.rb | 5 +++-- spec/requests/api/v3/projects/delete_resource_spec.rb | 6 ++++++ spec/requests/api/v3/projects/index_resource_spec.rb | 2 ++ spec/requests/api/v3/projects/show_resource_spec.rb | 10 ++++++++++ .../api/v3/projects/update_form_resource_spec.rb | 10 ++++++++-- 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/lib/api/v3/projects/projects_api.rb b/lib/api/v3/projects/projects_api.rb index f30268b0946f..aa1a6c4d490a 100644 --- a/lib/api/v3/projects/projects_api.rb +++ b/lib/api/v3/projects/projects_api.rb @@ -37,6 +37,7 @@ class ProjectsAPI < ::API::OpenProjectAPI scope: -> { Project .includes(ProjectRepresenter.to_eager_load) + .project }) .mount @@ -58,9 +59,9 @@ class ProjectsAPI < ::API::OpenProjectAPI route_param :id do after_validation do @project = if current_user.admin? - Project.all + Project.project else - Project.visible(current_user) + Project.project.visible(current_user) end.find(params[:id]) end diff --git a/spec/requests/api/v3/projects/delete_resource_spec.rb b/spec/requests/api/v3/projects/delete_resource_spec.rb index 90997dbe349a..4d5799c4fb78 100644 --- a/spec/requests/api/v3/projects/delete_resource_spec.rb +++ b/spec/requests/api/v3/projects/delete_resource_spec.rb @@ -119,6 +119,12 @@ it_behaves_like "not found" end + context "for a portfolio" do + let(:project) { create(:portfolio, public: true) } + + it_behaves_like "not found" + end + context "for a project which has a version foreign work packages refer to" do let(:version) { create(:version, project:) } let(:work_package) { create(:work_package, version:) } diff --git a/spec/requests/api/v3/projects/index_resource_spec.rb b/spec/requests/api/v3/projects/index_resource_spec.rb index 43604319d291..ba4b6d96d14d 100644 --- a/spec/requests/api/v3/projects/index_resource_spec.rb +++ b/spec/requests/api/v3/projects/index_resource_spec.rb @@ -36,6 +36,8 @@ include API::V3::Utilities::PathHelper shared_let(:admin) { create(:admin) } + # This portfolio is here as a check to see if only projects are returned via this endpoint. + shared_let(:portfolio) { create(:portfolio, public: true) } let(:project) do create(:project, public: false, active: project_active) diff --git a/spec/requests/api/v3/projects/show_resource_spec.rb b/spec/requests/api/v3/projects/show_resource_spec.rb index 8a9f330a6a33..132c6fadc43d 100644 --- a/spec/requests/api/v3/projects/show_resource_spec.rb +++ b/spec/requests/api/v3/projects/show_resource_spec.rb @@ -182,6 +182,16 @@ it_behaves_like "not found" end + context "when requesting a portfolio" do + let(:project) { create(:portfolio, public: true) } + + before do + response + end + + it_behaves_like "not found" + end + context "when not being allowed to see the parent project" do let!(:parent_memberships) do # no parent memberships diff --git a/spec/requests/api/v3/projects/update_form_resource_spec.rb b/spec/requests/api/v3/projects/update_form_resource_spec.rb index d075814a140c..3b54738481a3 100644 --- a/spec/requests/api/v3/projects/update_form_resource_spec.rb +++ b/spec/requests/api/v3/projects/update_form_resource_spec.rb @@ -372,9 +372,15 @@ context "with a non existing id" do let(:path) { api_v3_paths.project_form(1) } - it "returns 404 Not found" do - expect(response).to have_http_status(:not_found) + it_behaves_like "not found" + end + + context "with a portfolio id" do + let(:project) do + create(:portfolio, public: true) end + + it_behaves_like "not found" end end end From 85c469900cde0e2fab49361a317f8763a26c5b33 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 17 Sep 2025 16:24:43 +0200 Subject: [PATCH 05/21] provide index endpoint for portfolios --- lib/api/v3/portfolios/portfolios_api.rb | 47 ++++++ lib/api/v3/root.rb | 1 + lib/api/v3/utilities/path_helper.rb | 2 + spec/lib/api/v3/utilities/path_helper_spec.rb | 5 + .../api/v3/portfolios/index_resource_spec.rb | 145 ++++++++++++++++++ .../api/v3/projects/index_resource_spec.rb | 8 + .../api/v3/workspaces/index_resource_spec.rb | 8 + 7 files changed, 216 insertions(+) create mode 100644 lib/api/v3/portfolios/portfolios_api.rb create mode 100644 spec/requests/api/v3/portfolios/index_resource_spec.rb diff --git a/lib/api/v3/portfolios/portfolios_api.rb b/lib/api/v3/portfolios/portfolios_api.rb new file mode 100644 index 000000000000..bf62c9a35763 --- /dev/null +++ b/lib/api/v3/portfolios/portfolios_api.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module API + module V3 + module Portfolios + class PortfoliosAPI < ::API::OpenProjectAPI + resources :portfolios do + get &::API::V3::Utilities::Endpoints::SqlFallbackedIndex.new(model: Project, + scope: -> { + Project + .includes(API::V3::Projects::ProjectRepresenter.to_eager_load) + .portfolio + }) + .mount + end + end + end + end +end diff --git a/lib/api/v3/root.rb b/lib/api/v3/root.rb index 50d3b8135b1e..25d990e474dc 100644 --- a/lib/api/v3/root.rb +++ b/lib/api/v3/root.rb @@ -62,6 +62,7 @@ class Root < ::API::OpenProjectAPI mount ::API::V3::News::NewsAPI mount ::API::V3::OAuth::OAuthApplicationsAPI mount ::API::V3::OAuth::OAuthClientCredentialsAPI + mount ::API::V3::Portfolios::PortfoliosAPI mount ::API::V3::Posts::PostsAPI mount ::API::V3::Principals::PrincipalsAPI mount ::API::V3::Priorities::PrioritiesAPI diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 70d8aaa3994b..6296c30b7a15 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -328,6 +328,8 @@ def self.notification_detail(notification_id, detail_id) index :placeholder_user show :placeholder_user + index :portfolio + index :post show :post diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index 08cc830c437e..1660cb5e2624 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -274,6 +274,11 @@ it_behaves_like "show", :placeholder_user end + describe "portfolios paths" do + it_behaves_like "index", :portfolio + # it_behaves_like "show", :post + end + describe "posts paths" do it_behaves_like "index", :post it_behaves_like "show", :post diff --git a/spec/requests/api/v3/portfolios/index_resource_spec.rb b/spec/requests/api/v3/portfolios/index_resource_spec.rb new file mode 100644 index 000000000000..85a622fa7fbc --- /dev/null +++ b/spec/requests/api/v3/portfolios/index_resource_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" +require "rack/test" + +# The portfolios endpoint currently is a copy of the projects endpoint and reuses most of the functionality of it. +# As such, this spec tests that all aspects of the index endpoint (filtering, signaling, offset, pagination) are supported +# without going into the same breadth as the specs for the projects endpoint does. +RSpec.describe "API v3 Portfolios resource index", content_type: :json do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + shared_let(:permissions, reload: true) { [] } + shared_let(:role, reload: true) { create(:project_role, permissions:) } + # Program and project are here to check that only portfolios are returned. + shared_let(:project, reload: true) do + create(:project, public: false) + end + shared_let(:program, reload: true) do + create(:program, public: false) + end + shared_let(:portfolio, reload: true) do + create(:portfolio, public: false) + end + shared_let(:public_portfolio) do + create(:portfolio, public: true) + end + shared_let(:no_membership_portfolio) do + create(:portfolio, public: false) + end + shared_let(:user, reload: true) do + create(:user, + member_with_roles: + { + portfolio => role, + program => role, + project => role + }) + end + + let(:filters) { [] } + let(:get_path) do + api_v3_paths.path_for :portfolios, filters: + end + let(:response) { last_response } + + current_user { user } + + before do + get get_path + end + + it_behaves_like "API V3 collection response", 2, 2, "Portfolio" do + let(:elements) { [public_portfolio, portfolio] } + end + + context "with a pageSize and offset" do + let(:get_path) do + api_v3_paths.path_for :portfolios, sort_by: { id: :asc }, page_size: 1, offset: 1 + end + + it_behaves_like "API V3 collection response", 2, 1, "Portfolio" do + let(:elements) { [portfolio] } + end + end + + context "when signaling the properties to include" do + let(:select) { "elements/id,elements/name,elements/_type,total" } + let(:get_path) do + api_v3_paths.path_for :portfolios, select: + end + let(:expected) do + { + total: 2, + _embedded: { + elements: [ + { + id: public_portfolio.id, + name: public_portfolio.name, + _type: "Portfolio" + }, + { + id: portfolio.id, + name: portfolio.name, + _type: "Portfolio" + } + ] + } + } + end + + it "is the reduced set of properties of the embedded elements" do + expect(last_response.body) + .to be_json_eql(expected.to_json) + end + end + + context "when filtering by typeahead" do + let(:filters) do + [{ typeahead: { operator: "**", values: [search_string] } }] + end + + let(:search_string) { public_portfolio.name } + + it_behaves_like "API V3 collection response", 1, 1, "Portfolio" do + let(:elements) { [public_portfolio] } + end + end + + context "when not being logged in and login is required" do + current_user { create(:anonymous) } + + context "if user is not logged in" do + it_behaves_like "unauthenticated access" + end + end +end diff --git a/spec/requests/api/v3/projects/index_resource_spec.rb b/spec/requests/api/v3/projects/index_resource_spec.rb index ba4b6d96d14d..4a72a085e96a 100644 --- a/spec/requests/api/v3/projects/index_resource_spec.rb +++ b/spec/requests/api/v3/projects/index_resource_spec.rb @@ -524,4 +524,12 @@ end end end + + context "when not being logged in and login is required" do + current_user { create(:anonymous) } + + context "if user is not logged in" do + it_behaves_like "unauthenticated access" + end + end end diff --git a/spec/requests/api/v3/workspaces/index_resource_spec.rb b/spec/requests/api/v3/workspaces/index_resource_spec.rb index fef3d6b6b04d..67468c1e5ba0 100644 --- a/spec/requests/api/v3/workspaces/index_resource_spec.rb +++ b/spec/requests/api/v3/workspaces/index_resource_spec.rb @@ -170,4 +170,12 @@ end end end + + context "when not being logged in and login is required" do + current_user { create(:anonymous) } + + context "if user is not logged in" do + it_behaves_like "unauthenticated access" + end + end end From 0b67fd059d9905c568951e31136df9ca11ff0756 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 17 Sep 2025 16:38:39 +0200 Subject: [PATCH 06/21] provide show endpoint for portfolio --- lib/api/v3/portfolios/portfolios_api.rb | 12 ++ lib/api/v3/utilities/path_helper.rb | 1 + spec/lib/api/v3/utilities/path_helper_spec.rb | 2 +- .../api/v3/portfolios/show_resource_spec.rb | 124 ++++++++++++++++++ 4 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 spec/requests/api/v3/portfolios/show_resource_spec.rb diff --git a/lib/api/v3/portfolios/portfolios_api.rb b/lib/api/v3/portfolios/portfolios_api.rb index bf62c9a35763..1f3f21a6d804 100644 --- a/lib/api/v3/portfolios/portfolios_api.rb +++ b/lib/api/v3/portfolios/portfolios_api.rb @@ -40,6 +40,18 @@ class PortfoliosAPI < ::API::OpenProjectAPI .portfolio }) .mount + + route_param :id do + after_validation do + @project = if current_user.admin? + Project.portfolio + else + Project.portfolio.visible(current_user) + end.find(params[:id]) + end + + get &::API::V3::Utilities::Endpoints::Show.new(model: Project).mount + end end end end diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 6296c30b7a15..7ce9e46631ad 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -329,6 +329,7 @@ def self.notification_detail(notification_id, detail_id) show :placeholder_user index :portfolio + show :portfolio index :post show :post diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index 1660cb5e2624..6ff197c20a77 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -276,7 +276,7 @@ describe "portfolios paths" do it_behaves_like "index", :portfolio - # it_behaves_like "show", :post + it_behaves_like "show", :portfolio end describe "posts paths" do diff --git a/spec/requests/api/v3/portfolios/show_resource_spec.rb b/spec/requests/api/v3/portfolios/show_resource_spec.rb new file mode 100644 index 000000000000..e6750f34395c --- /dev/null +++ b/spec/requests/api/v3/portfolios/show_resource_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" +require "rack/test" + +# The portfolios endpoint currently is a copy of the projects endpoint and reuses most of the functionality of it. +# As such, this spec focuses on all aspects of the show endpoint are supported +# without going into the same breadth as the specs for the projects endpoint does. +RSpec.describe "API v3 Portfolios resource show", content_type: :json do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + shared_let(:admin) { create(:admin) } + shared_let(:portfolio, reload: true) do + create(:portfolio) + end + let(:other_portfolio) do + create(:portfolio) + end + let(:role) { create(:project_role) } + let(:get_path) { api_v3_paths.portfolio portfolio.id } + + current_user { create(:user, member_with_roles: { portfolio => role }) } + + subject(:response) do + get get_path + + last_response + end + + context "for a logged in user" do + it "responds with 200 OK" do + expect(subject.status).to eq(200) + end + + it "responds with the correct project" do + expect(subject.body).to include_json("Portfolio".to_json).at_path("_type") + expect(subject.body).to be_json_eql(portfolio.identifier.to_json).at_path("identifier") + end + + context "when requesting nonexistent portfolio" do + let(:get_path) { api_v3_paths.portfolio 9999 } + + before do + response + end + + it_behaves_like "not found" + end + + context "when requesting a project" do + let(:portfolio) { create(:project, public: true) } + + before do + response + end + + it_behaves_like "not found" + end + + context "with the project being archived/inactive" do + before do + portfolio.update_attribute(:active, false) + end + + context "with the user being admin" do + current_user { admin } + + it "responds with 200 OK" do + expect(subject.status).to eq(200) + end + + it "responds with the correct project" do + expect(subject.body).to include_json("Portfolio".to_json).at_path("_type") + expect(subject.body).to be_json_eql(portfolio.identifier.to_json).at_path("identifier") + end + end + + context "with the user being no admin" do + it "responds with 404" do + expect(subject.status).to eq(404) + end + end + end + end + + context "for a not logged in user" do + current_user { create(:anonymous) } + + before do + get get_path + end + + it_behaves_like "not found response based on login_required" + end +end From a177447b8a9c14d1e8048bdd828c62c3101e78ff Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 17 Sep 2025 16:53:28 +0200 Subject: [PATCH 07/21] provide index & show endpoint for program --- lib/api/v3/programs/programs_api.rb | 58 +++++++ lib/api/v3/root.rb | 1 + lib/api/v3/utilities/path_helper.rb | 3 + spec/lib/api/v3/utilities/path_helper_spec.rb | 5 + .../api/v3/portfolios/show_resource_spec.rb | 10 +- .../api/v3/program/index_resource_spec.rb | 145 ++++++++++++++++++ .../api/v3/program/show_resource_spec.rb | 126 +++++++++++++++ 7 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 lib/api/v3/programs/programs_api.rb create mode 100644 spec/requests/api/v3/program/index_resource_spec.rb create mode 100644 spec/requests/api/v3/program/show_resource_spec.rb diff --git a/lib/api/v3/programs/programs_api.rb b/lib/api/v3/programs/programs_api.rb new file mode 100644 index 000000000000..5a3089fd26bd --- /dev/null +++ b/lib/api/v3/programs/programs_api.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module API + module V3 + module Programs + class ProgramsAPI < ::API::OpenProjectAPI + resources :programs do + get &::API::V3::Utilities::Endpoints::SqlFallbackedIndex.new(model: Project, + scope: -> { + Project + .includes(API::V3::Projects::ProjectRepresenter.to_eager_load) + .program + }) + .mount + route_param :id do + after_validation do + @project = if current_user.admin? + Project.program + else + Project.program.visible(current_user) + end.find(params[:id]) + end + + get &::API::V3::Utilities::Endpoints::Show.new(model: Project).mount + end + end + end + end + end +end diff --git a/lib/api/v3/root.rb b/lib/api/v3/root.rb index 25d990e474dc..4de67ea51f48 100644 --- a/lib/api/v3/root.rb +++ b/lib/api/v3/root.rb @@ -66,6 +66,7 @@ class Root < ::API::OpenProjectAPI mount ::API::V3::Posts::PostsAPI mount ::API::V3::Principals::PrincipalsAPI mount ::API::V3::Priorities::PrioritiesAPI + mount ::API::V3::Programs::ProgramsAPI mount ::API::V3::Projects::ProjectsAPI mount ::API::V3::ProjectPhaseDefinitions::ProjectPhaseDefinitionsAPI mount ::API::V3::ProjectPhases::ProjectPhasesAPI diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 7ce9e46631ad..62632788463e 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -344,6 +344,9 @@ class << self alias :issue_priority :priority end + index :program + show :program + show :oauth_application show :oauth_client_credentials diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index 6ff197c20a77..6d365c9bdd69 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -293,6 +293,11 @@ it_behaves_like "show", :priority end + describe "programs paths" do + it_behaves_like "index", :program + it_behaves_like "show", :program + end + describe "projects paths" do it_behaves_like "resource", :project diff --git a/spec/requests/api/v3/portfolios/show_resource_spec.rb b/spec/requests/api/v3/portfolios/show_resource_spec.rb index e6750f34395c..2e3d562f0b61 100644 --- a/spec/requests/api/v3/portfolios/show_resource_spec.rb +++ b/spec/requests/api/v3/portfolios/show_resource_spec.rb @@ -31,9 +31,9 @@ require "spec_helper" require "rack/test" -# The portfolios endpoint currently is a copy of the projects endpoint and reuses most of the functionality of it. +# The portfolio endpoint currently is a copy of the project endpoint and reuses most of the functionality of it. # As such, this spec focuses on all aspects of the show endpoint are supported -# without going into the same breadth as the specs for the projects endpoint does. +# without going into the same breadth as the specs for the project endpoint does. RSpec.describe "API v3 Portfolios resource show", content_type: :json do include Rack::Test::Methods include API::V3::Utilities::PathHelper @@ -105,9 +105,11 @@ end context "with the user being no admin" do - it "responds with 404" do - expect(subject.status).to eq(404) + before do + response end + + it_behaves_like "not found" end end end diff --git a/spec/requests/api/v3/program/index_resource_spec.rb b/spec/requests/api/v3/program/index_resource_spec.rb new file mode 100644 index 000000000000..055ee18461f2 --- /dev/null +++ b/spec/requests/api/v3/program/index_resource_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" +require "rack/test" + +# The programs endpoint currently is a copy of the projects endpoint and reuses most of the functionality of it. +# As such, this spec tests that all aspects of the index endpoint (filtering, signaling, offset, pagination) are supported +# without going into the same breadth as the specs for the projects endpoint does. +RSpec.describe "API v3 Program resource index", content_type: :json do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + shared_let(:permissions, reload: true) { [] } + shared_let(:role, reload: true) { create(:project_role, permissions:) } + # Program and project are here to check that only portfolios are returned. + shared_let(:project, reload: true) do + create(:project, public: false) + end + shared_let(:program, reload: true) do + create(:program, public: false) + end + shared_let(:portfolio, reload: true) do + create(:portfolio, public: false) + end + shared_let(:public_program) do + create(:program, public: true) + end + shared_let(:no_membership_program) do + create(:program, public: false) + end + shared_let(:user, reload: true) do + create(:user, + member_with_roles: + { + portfolio => role, + program => role, + project => role + }) + end + + let(:filters) { [] } + let(:get_path) do + api_v3_paths.path_for :programs, filters: + end + let(:response) { last_response } + + current_user { user } + + before do + get get_path + end + + it_behaves_like "API V3 collection response", 2, 2, "Program" do + let(:elements) { [public_program, program] } + end + + context "with a pageSize and offset" do + let(:get_path) do + api_v3_paths.path_for :programs, sort_by: { id: :asc }, page_size: 1, offset: 1 + end + + it_behaves_like "API V3 collection response", 2, 1, "Program" do + let(:elements) { [program] } + end + end + + context "when signaling the properties to include" do + let(:select) { "elements/id,elements/name,elements/_type,total" } + let(:get_path) do + api_v3_paths.path_for :programs, select: + end + let(:expected) do + { + total: 2, + _embedded: { + elements: [ + { + id: public_program.id, + name: public_program.name, + _type: "Program" + }, + { + id: program.id, + name: program.name, + _type: "Program" + } + ] + } + } + end + + it "is the reduced set of properties of the embedded elements" do + expect(last_response.body) + .to be_json_eql(expected.to_json) + end + end + + context "when filtering by typeahead" do + let(:filters) do + [{ typeahead: { operator: "**", values: [search_string] } }] + end + + let(:search_string) { public_program.name } + + it_behaves_like "API V3 collection response", 1, 1, "Program" do + let(:elements) { [public_program] } + end + end + + context "when not being logged in and login is required" do + current_user { create(:anonymous) } + + context "if user is not logged in" do + it_behaves_like "unauthenticated access" + end + end +end diff --git a/spec/requests/api/v3/program/show_resource_spec.rb b/spec/requests/api/v3/program/show_resource_spec.rb new file mode 100644 index 000000000000..b16c7180d8c9 --- /dev/null +++ b/spec/requests/api/v3/program/show_resource_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" +require "rack/test" + +# The program endpoint currently is a copy of the project endpoint and reuses most of the functionality of it. +# As such, this spec focuses on all aspects of the show endpoint are supported +# without going into the same breadth as the specs for the project endpoint does. +RSpec.describe "API v3 Programs resource show", content_type: :json do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + shared_let(:admin) { create(:admin) } + shared_let(:program, reload: true) do + create(:program) + end + let(:other_program) do + create(:program) + end + let(:role) { create(:project_role) } + let(:get_path) { api_v3_paths.program program.id } + + current_user { create(:user, member_with_roles: { program => role }) } + + subject(:response) do + get get_path + + last_response + end + + context "for a logged in user" do + it "responds with 200 OK" do + expect(subject.status).to eq(200) + end + + it "responds with the correct project" do + expect(subject.body).to include_json("Program".to_json).at_path("_type") + expect(subject.body).to be_json_eql(program.identifier.to_json).at_path("identifier") + end + + context "when requesting nonexistent program" do + let(:get_path) { api_v3_paths.program 9999 } + + before do + response + end + + it_behaves_like "not found" + end + + context "when requesting a project" do + let(:program) { create(:project, public: true) } + + before do + response + end + + it_behaves_like "not found" + end + + context "with the project being archived/inactive" do + before do + program.update_attribute(:active, false) + end + + context "with the user being admin" do + current_user { admin } + + it "responds with 200 OK" do + expect(subject.status).to eq(200) + end + + it "responds with the correct project" do + expect(subject.body).to include_json("Program".to_json).at_path("_type") + expect(subject.body).to be_json_eql(program.identifier.to_json).at_path("identifier") + end + end + + context "with the user being no admin" do + before do + response + end + + it_behaves_like "not found" + end + end + end + + context "for a not logged in user" do + current_user { create(:anonymous) } + + before do + get get_path + end + + it_behaves_like "not found response based on login_required" + end +end From b29b7246eadf05e18a8570523ceb5efb72e7c7e9 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 17 Sep 2025 17:50:03 +0200 Subject: [PATCH 08/21] support workspace polymorphism in membership representer --- .../v3/memberships/membership_representer.rb | 4 +- .../principal_representer_factory.rb | 2 + .../workspace_representer_factory.rb | 49 +++++++++++ .../membership_representer_rendering_spec.rb | 88 +++++++++++++++---- 4 files changed, 124 insertions(+), 19 deletions(-) create mode 100644 lib/api/v3/workspaces/workspace_representer_factory.rb diff --git a/lib/api/v3/memberships/membership_representer.rb b/lib/api/v3/memberships/membership_representer.rb index 624d74c8a1a4..ba018ac9d54a 100644 --- a/lib/api/v3/memberships/membership_representer.rb +++ b/lib/api/v3/memberships/membership_representer.rb @@ -61,7 +61,9 @@ class MembershipRepresenter < ::API::Decorators::Single property :id - associated_resource :project + associated_resource :project, + link: ::API::V3::Workspaces::WorkspaceRepresenterFactory + .create_link_lambda(:project) associated_resource :principal, getter: ::API::V3::Principals::PrincipalRepresenterFactory diff --git a/lib/api/v3/principals/principal_representer_factory.rb b/lib/api/v3/principals/principal_representer_factory.rb index 78662e8eec74..8c7d7dcf9343 100644 --- a/lib/api/v3/principals/principal_representer_factory.rb +++ b/lib/api/v3/principals/principal_representer_factory.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH diff --git a/lib/api/v3/workspaces/workspace_representer_factory.rb b/lib/api/v3/workspaces/workspace_representer_factory.rb new file mode 100644 index 000000000000..1849d02a881d --- /dev/null +++ b/lib/api/v3/workspaces/workspace_representer_factory.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module API + module V3 + module Workspaces + module WorkspaceRepresenterFactory + module_function + + def create_link_lambda(name, getter: "#{name}_id") + ->(*) { + instance_exec(&self.class.associated_resource_default_link(name, + v3_path: represented.project&.workspace_type, + skip_link: -> { false }, + title_attribute: :name, + getter:)) + } + end + end + end + end +end diff --git a/spec/lib/api/v3/memberships/membership_representer_rendering_spec.rb b/spec/lib/api/v3/memberships/membership_representer_rendering_spec.rb index bd4b6526b95b..658c0c3f0157 100644 --- a/spec/lib/api/v3/memberships/membership_representer_rendering_spec.rb +++ b/spec/lib/api/v3/memberships/membership_representer_rendering_spec.rb @@ -37,9 +37,9 @@ build_stubbed(:member, member_roles: [member_role1, member_role2, member_role2, marked_member_role], principal:, - project:) + project: workspace) end - let(:project) { build_stubbed(:project) } + let(:workspace) { build_stubbed(:project) } let(:roles) { [role1, role2] } let(:role1) { build_stubbed(:project_role) } let(:member_role1) { build_stubbed(:member_role, role: role1) } @@ -68,7 +68,7 @@ before do mock_permissions_for(current_user) do |mock| - mock.allow_in_project *permissions, project: project || build_stubbed(:project) + mock.allow_in_project *permissions, project: workspace || build_stubbed(:project) end end @@ -123,14 +123,36 @@ end describe "project" do - it_behaves_like "has a titled link" do - let(:link) { "project" } - let(:href) { api_v3_paths.project(project.id) } - let(:title) { project.name } + context "for a project membership" do + it_behaves_like "has a titled link" do + let(:link) { "project" } + let(:href) { api_v3_paths.project(workspace.id) } + let(:title) { workspace.name } + end end - context "for a global member" do - let(:project) { nil } + context "for a program membership" do + let(:workspace) { build_stubbed(:program) } + + it_behaves_like "has a titled link" do + let(:link) { "project" } + let(:href) { api_v3_paths.program(workspace.id) } + let(:title) { workspace.name } + end + end + + context "for a portfolio membership" do + let(:workspace) { build_stubbed(:portfolio) } + + it_behaves_like "has a titled link" do + let(:link) { "project" } + let(:href) { api_v3_paths.portfolio(workspace.id) } + let(:title) { workspace.name } + end + end + + context "for a global membership" do + let(:workspace) { nil } it_behaves_like "has an empty link" do let(:link) { "project" } @@ -207,20 +229,50 @@ describe "project" do let(:embedded_path) { "_embedded/project" } - it "has the project embedded" do - expect(subject) - .to be_json_eql("Project".to_json) - .at_path("#{embedded_path}/_type") + context "for a project membership" do + it "has the project embedded" do + expect(subject) + .to be_json_eql("Project".to_json) + .at_path("#{embedded_path}/_type") - expect(subject) - .to be_json_eql(project.name.to_json) - .at_path("#{embedded_path}/name") + expect(subject) + .to be_json_eql(workspace.name.to_json) + .at_path("#{embedded_path}/name") + end + end + + context "for a program membership" do + let(:workspace) { build_stubbed(:program) } + + it "has the program embedded" do + expect(subject) + .to be_json_eql("Program".to_json) + .at_path("#{embedded_path}/_type") + + expect(subject) + .to be_json_eql(workspace.name.to_json) + .at_path("#{embedded_path}/name") + end + end + + context "for a portfolio membership" do + let(:workspace) { build_stubbed(:portfolio) } + + it "has the portfolio embedded" do + expect(subject) + .to be_json_eql("Portfolio".to_json) + .at_path("#{embedded_path}/_type") + + expect(subject) + .to be_json_eql(workspace.name.to_json) + .at_path("#{embedded_path}/name") + end end context "for a global member" do - let(:project) { nil } + let(:workspace) { nil } - it "has no project embedded" do + it "has no workspace embedded" do expect(subject) .not_to have_json_path(embedded_path) end From 25080a10cfd6922c5cb85ce23f71809df0b98cab Mon Sep 17 00:00:00 2001 From: ulferts Date: Thu, 18 Sep 2025 16:53:15 +0200 Subject: [PATCH 09/21] move available_assignees and types_by_project to workspace --- lib/api/v3/projects/project_representer.rb | 2 +- lib/api/v3/projects/projects_api.rb | 4 +- ...oject_api.rb => types_by_workspace_api.rb} | 9 ++-- lib/api/v3/utilities/path_helper.rb | 9 ++++ .../schema/work_package_schema_representer.rb | 3 +- .../available_assignees_api.rb | 8 ++-- lib/api/v3/workspaces/nested_apis.rb | 40 +++++++++++++++++ lib/api/v3/workspaces/workspaces_api.rb | 12 +++++ .../project_representer_rendering_spec.rb | 44 ++++++++++++------- spec/lib/api/v3/utilities/path_helper_spec.rb | 12 +++++ ...rb => types_by_workspace_resource_spec.rb} | 41 ++++++++--------- .../available_assignees_api_spec.rb | 11 +++-- 12 files changed, 138 insertions(+), 57 deletions(-) rename lib/api/v3/types/{types_by_project_api.rb => types_by_workspace_api.rb} (87%) rename lib/api/v3/{projects => workspaces}/available_assignees_api.rb (95%) create mode 100644 lib/api/v3/workspaces/nested_apis.rb rename spec/requests/api/v3/types/{types_by_project_resource_spec.rb => types_by_workspace_resource_spec.rb} (75%) rename spec/requests/api/v3/{projects => workspaces}/available_assignees_api_spec.rb (84%) diff --git a/lib/api/v3/projects/project_representer.rb b/lib/api/v3/projects/project_representer.rb index a5b37a276c3e..f3937ee0e8da 100644 --- a/lib/api/v3/projects/project_representer.rb +++ b/lib/api/v3/projects/project_representer.rb @@ -110,7 +110,7 @@ def self.current_user_view_allowed_lambda current_user.allowed_in_project?(:view_work_packages, represented) || current_user.allowed_in_project?(:manage_types, represented) } do - { href: api_v3_paths.types_by_project(represented.id) } + { href: api_v3_paths.types_by_workspace(represented.id) } end link :update, diff --git a/lib/api/v3/projects/projects_api.rb b/lib/api/v3/projects/projects_api.rb index aa1a6c4d490a..bebcd6bb4944 100644 --- a/lib/api/v3/projects/projects_api.rb +++ b/lib/api/v3/projects/projects_api.rb @@ -73,12 +73,12 @@ class ProjectsAPI < ::API::OpenProjectAPI mount ::API::V3::Projects::UpdateFormAPI - mount API::V3::Projects::AvailableAssigneesAPI + mount ::API::V3::Workspaces::NestedApis + mount API::V3::Projects::Copy::CopyAPI mount API::V3::WorkPackages::WorkPackagesByProjectAPI mount API::V3::Categories::CategoriesByProjectAPI mount API::V3::Versions::VersionsByProjectAPI - mount API::V3::Types::TypesByProjectAPI mount API::V3::Queries::QueriesByProjectAPI mount API::V3::Favorites::FavoriteActionsAPI, with: { favorite_object_getter: ->(*) { @project } } end diff --git a/lib/api/v3/types/types_by_project_api.rb b/lib/api/v3/types/types_by_workspace_api.rb similarity index 87% rename from lib/api/v3/types/types_by_project_api.rb rename to lib/api/v3/types/types_by_workspace_api.rb index 208c1f386867..a776c118ec44 100644 --- a/lib/api/v3/types/types_by_project_api.rb +++ b/lib/api/v3/types/types_by_workspace_api.rb @@ -26,21 +26,18 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require "api/v3/types/type_collection_representer" - module API module V3 module Types - class TypesByProjectAPI < ::API::OpenProjectAPI + class TypesByWorkspaceAPI < ::API::OpenProjectAPI resources :types do after_validation do authorize_in_project %i[view_work_packages manage_types], project: @project end get do - types = @project.types - TypeCollectionRepresenter.new(types, - self_link: api_v3_paths.types_by_project(@project.id), + TypeCollectionRepresenter.new(@project.types, + self_link: api_v3_paths.types_by_workspace(@project.id), current_user:) end end diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 62632788463e..ddd161844f25 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -174,6 +174,10 @@ def self.available_assignees_in_project(project_id) "#{project(project_id)}/available_assignees" end + def self.available_assignees_in_workspace(project_id) + "#{workspace(project_id)}/available_assignees" + end + def self.available_assignees_in_work_package(work_package_id) "#{work_package(work_package_id)}/available_assignees" end @@ -542,6 +546,10 @@ def self.types_by_project(project_id) "#{project(project_id)}/types" end + def self.types_by_workspace(project_id) + "#{workspace(project_id)}/types" + end + resources :user def self.user_lock(id) @@ -658,6 +666,7 @@ def self.work_packages_by_project(project_id) end index :workspace + show :workspace def self.timestamps_to_param_value(timestamps) Array(timestamps).map { |timestamp| Timestamp.parse(timestamp).absolute }.join(",") diff --git a/lib/api/v3/work_packages/schema/work_package_schema_representer.rb b/lib/api/v3/work_packages/schema/work_package_schema_representer.rb index 2c1a10cde872..0bf958bf98ac 100644 --- a/lib/api/v3/work_packages/schema/work_package_schema_representer.rb +++ b/lib/api/v3/work_packages/schema/work_package_schema_representer.rb @@ -34,6 +34,7 @@ class WorkPackageSchemaRepresenter < ::API::Decorators::SchemaRepresenter extend ::API::V3::Utilities::CustomFieldInjector::RepresenterClass include API::Caching::CachedRepresenter + cached_representer key_parts: %i[project type], dependencies: -> { all_permissions_granted_to_user_under_project + [Setting.work_package_done_ratio, @@ -410,7 +411,7 @@ def assignee_user_autocompleter if work_package&.persisted? api_v3_paths.available_assignees_in_work_package(represented.id) elsif work_package&.project - api_v3_paths.available_assignees_in_project(represented.project_id) + api_v3_paths.available_assignees_in_workspace(represented.project_id) end end end diff --git a/lib/api/v3/projects/available_assignees_api.rb b/lib/api/v3/workspaces/available_assignees_api.rb similarity index 95% rename from lib/api/v3/projects/available_assignees_api.rb rename to lib/api/v3/workspaces/available_assignees_api.rb index 9735c372bb81..bf280f46e9b4 100644 --- a/lib/api/v3/projects/available_assignees_api.rb +++ b/lib/api/v3/workspaces/available_assignees_api.rb @@ -1,4 +1,4 @@ -#-- copyright +# -- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH # @@ -24,13 +24,11 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. -#++ - -require "api/v3/users/user_collection_representer" +# ++ module API module V3 - module Projects + module Workspaces class AvailableAssigneesAPI < ::API::OpenProjectAPI resource :available_assignees do after_validation do diff --git a/lib/api/v3/workspaces/nested_apis.rb b/lib/api/v3/workspaces/nested_apis.rb new file mode 100644 index 000000000000..c9374137a0e0 --- /dev/null +++ b/lib/api/v3/workspaces/nested_apis.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module API + module V3 + module Workspaces + class NestedApis < ::API::OpenProjectAPI + mount API::V3::Workspaces::AvailableAssigneesAPI + mount API::V3::Types::TypesByWorkspaceAPI + end + end + end +end diff --git a/lib/api/v3/workspaces/workspaces_api.rb b/lib/api/v3/workspaces/workspaces_api.rb index ec2d3a81eb39..fd446eb6eecc 100644 --- a/lib/api/v3/workspaces/workspaces_api.rb +++ b/lib/api/v3/workspaces/workspaces_api.rb @@ -39,6 +39,18 @@ class WorkspacesAPI < ::API::OpenProjectAPI .includes(API::V3::Projects::ProjectRepresenter.to_eager_load) }) .mount + + route_param :id, type: Integer do + after_validation do + @project = if current_user.admin? + Project + else + Project.visible(current_user) + end.find(params[:id]) + end + + mount API::V3::Workspaces::NestedApis + end end end end diff --git a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb index 1e0872b8b002..eadb74ff2ecd 100644 --- a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb +++ b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb @@ -486,35 +486,26 @@ context "for a user having the view_work_packages permission" do let(:permissions) { [:view_work_packages] } - it "links to the types active in the project" do - expect(subject).to be_json_eql(api_v3_paths.types_by_project(project.id).to_json) - .at_path("_links/types/href") - end - - it "links to the work packages in the project" do - expect(subject).to be_json_eql(api_v3_paths.work_packages_by_project(project.id).to_json) - .at_path("_links/workPackages/href") + it_behaves_like "has an untitled link" do + let(:link) { "types" } + let(:href) { api_v3_paths.types_by_workspace(project.id) } end end context "for a user having the manage_types permission" do let(:permissions) { [:manage_types] } - it "links to the types active in the project" do - expect(subject).to be_json_eql(api_v3_paths.types_by_project(project.id).to_json) - .at_path("_links/types/href") + it_behaves_like "has an untitled link" do + let(:link) { "types" } + let(:href) { api_v3_paths.types_by_workspace(project.id) } end end context "for a user not having the necessary permissions" do let(:permission) { [] } - it "has no types link" do - expect(subject).not_to have_json_path("_links/types/href") - end - - it "has no work packages link" do - expect(subject).not_to have_json_path("_links/workPackages/href") + it_behaves_like "has no link" do + let(:link) { "types" } end end end @@ -665,6 +656,25 @@ end end end + + describe "workPackages" do + context "for a user having the view_work_packages permission" do + let(:permissions) { [:view_work_packages] } + + it_behaves_like "has an untitled link" do + let(:link) { "workPackages" } + let(:href) { api_v3_paths.work_packages_by_project(project.id) } + end + end + + context "for a user not having the necessary permissions" do + let(:permission) { [] } + + it_behaves_like "has no link" do + let(:link) { "workPackages" } + end + end + end end describe "_embedded" do diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index 6d365c9bdd69..b6ecd6ac279c 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -486,6 +486,12 @@ it_behaves_like "api v3 path", "/projects/12/types" end + + describe "#types_by_workspace" do + subject { helper.types_by_workspace 12 } + + it_behaves_like "api v3 path", "/workspaces/12/types" + end end describe "users paths" do @@ -620,6 +626,12 @@ it_behaves_like "api v3 path", "/projects/42/available_assignees" end + describe "#available_assignees_in_workspace" do + subject { helper.available_assignees_in_workspace 42 } + + it_behaves_like "api v3 path", "/workspaces/42/available_assignees" + end + describe "#available_assignees_in_work_package" do subject { helper.available_assignees_in_work_package 42 } diff --git a/spec/requests/api/v3/types/types_by_project_resource_spec.rb b/spec/requests/api/v3/types/types_by_workspace_resource_spec.rb similarity index 75% rename from spec/requests/api/v3/types/types_by_project_resource_spec.rb rename to spec/requests/api/v3/types/types_by_workspace_resource_spec.rb index 6cc2abffac50..fa6ace075fff 100644 --- a/spec/requests/api/v3/types/types_by_project_resource_spec.rb +++ b/spec/requests/api/v3/types/types_by_workspace_resource_spec.rb @@ -36,7 +36,6 @@ include API::V3::Utilities::PathHelper let(:role) { create(:project_role, permissions: [:view_work_packages]) } - let(:project) { create(:project, no_types: true, public: false) } let(:requested_project) { project } let(:current_user) do create(:user, member_with_roles: { project => role }) @@ -45,38 +44,22 @@ let!(:irrelevant_types) { create_list(:type, 4) } let!(:expected_types) { create_list(:type, 4) } - describe "#get" do - let(:get_path) { api_v3_paths.types_by_project requested_project.id } - + shared_context "for types by workspace" do subject(:response) { last_response } before do project.types << expected_types end - context "logged in user" do + context "for a logged in user" do before do allow(User).to receive(:current).and_return current_user get get_path end - it_behaves_like "API V3 collection response", 4, 4, "Type" - - it "only contains expected types" do - actual_types = JSON.parse(subject.body)["_embedded"]["elements"] - actual_type_ids = actual_types.map { |hash| hash["id"] } - expected_type_ids = expected_types.map(&:id) - - expect(actual_type_ids).to match_array expected_type_ids - end - - # N.B. this test depends on order, while this is not strictly necessary - it "only contains expected types" do - 4.times do |i| - expected_id = expected_types[i].id.to_json - expect(subject.body).to be_json_eql(expected_id).at_path("_embedded/elements/#{i}/id") - end + it_behaves_like "API V3 collection response", 4, 4, "Type" do + let(:elements) { expected_types } end context "in a foreign project" do @@ -86,7 +69,7 @@ end end - context "not logged in user" do + context "for not logged in user" do before do get get_path end @@ -94,4 +77,18 @@ it_behaves_like "not found response based on login_required" end end + + context "for a project" do + let(:project) { create(:project, no_types: true) } + let(:get_path) { api_v3_paths.types_by_project requested_project.id } + + include_context "for types by workspace" + end + + context "for a workspace" do + let(:project) { create(:portfolio, no_types: true) } + let(:get_path) { api_v3_paths.types_by_workspace requested_project.id } + + include_context "for types by workspace" + end end diff --git a/spec/requests/api/v3/projects/available_assignees_api_spec.rb b/spec/requests/api/v3/workspaces/available_assignees_api_spec.rb similarity index 84% rename from spec/requests/api/v3/projects/available_assignees_api_spec.rb rename to spec/requests/api/v3/workspaces/available_assignees_api_spec.rb index 09784d267227..9b3d5d36f13c 100644 --- a/spec/requests/api/v3/projects/available_assignees_api_spec.rb +++ b/spec/requests/api/v3/workspaces/available_assignees_api_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -#-- copyright +# -- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH # @@ -26,13 +26,18 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. -#++ +# ++ require "spec_helper" -RSpec.describe "API::V3::Projects::AvailableAssigneesAPI" do +RSpec.describe "API::V3::Workspaces::AvailableAssigneesAPI" do include API::V3::Utilities::PathHelper + it_behaves_like "available principals", :assignees do + let(:base_permissions) { %i[add_work_packages] } + let(:href) { api_v3_paths.available_assignees_in_workspace(project.id) } + end + it_behaves_like "available principals", :assignees do let(:base_permissions) { %i[add_work_packages] } let(:href) { api_v3_paths.available_assignees_in_project(project.id) } From 1cb3c48e8cb92803ade9ae67bf4f54195ed969c1 Mon Sep 17 00:00:00 2001 From: ulferts Date: Fri, 19 Sep 2025 12:09:06 +0200 Subject: [PATCH 10/21] move work_packages_by_project to workspace --- ...k_package_collection_from_query_service.rb | 2 +- lib/api/v3/projects/project_representer.rb | 6 +- lib/api/v3/projects/projects_api.rb | 1 - .../v3/queries/query_params_representer.rb | 2 +- lib/api/v3/queries/query_representer.rb | 3 +- ...k_package_filter_dependency_representer.rb | 2 +- lib/api/v3/utilities/path_helper.rb | 8 + ...rm_api.rb => create_workspace_form_api.rb} | 2 +- ...b => create_workspace_form_representer.rb} | 8 +- .../work_packages/work_package_representer.rb | 2 +- ...i.rb => work_packages_by_workspace_api.rb} | 4 +- .../v3/workspaces/available_assignees_api.rb | 2 + lib/api/v3/workspaces/nested_apis.rb | 1 + spec/factories/project_factory.rb | 13 +- spec/factories/workspace_factory.rb | 15 + .../project_representer_rendering_spec.rb | 6 +- .../query_representer_rendering_spec.rb | 6 +- .../id_filter_dependency_representer_spec.rb | 2 +- .../v3/support/api_v3_filter_dependency.rb | 2 +- spec/lib/api/v3/utilities/path_helper_spec.rb | 12 + ...create_workspace_form_representer_spec.rb} | 8 +- .../work_package_schema_representer_spec.rb | 10 +- .../work_package_representer_spec.rb | 2 +- .../api/v3/projects/index_resource_spec.rb | 4 +- .../by_project_create_resource_spec.rb | 186 ---------- .../by_project_index_resource_spec.rb | 326 ----------------- .../by_workspace_create_resource_spec.rb | 206 +++++++++++ .../by_workspace_index_resource_spec.rb | 330 ++++++++++++++++++ .../create_form_resource_spec.rb | 2 +- ...=> create_workspace_form_resource_spec.rb} | 31 +- ...kage_collection_from_query_service_spec.rb | 6 +- 31 files changed, 633 insertions(+), 577 deletions(-) rename lib/api/v3/work_packages/{create_project_form_api.rb => create_workspace_form_api.rb} (96%) rename lib/api/v3/work_packages/{create_project_form_representer.rb => create_workspace_form_representer.rb} (88%) rename lib/api/v3/work_packages/{work_packages_by_project_api.rb => work_packages_by_workspace_api.rb} (95%) rename spec/lib/api/v3/work_packages/{create_project_form_representer_spec.rb => create_workspace_form_representer_spec.rb} (94%) delete mode 100644 spec/requests/api/v3/work_packages/by_project_create_resource_spec.rb delete mode 100644 spec/requests/api/v3/work_packages/by_project_index_resource_spec.rb create mode 100644 spec/requests/api/v3/work_packages/by_workspace_create_resource_spec.rb create mode 100644 spec/requests/api/v3/work_packages/by_workspace_index_resource_spec.rb rename spec/requests/api/v3/work_packages/{create_project_form_resource_spec.rb => create_workspace_form_resource_spec.rb} (64%) 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 1f2ffb4cc85f..1fbb7f0b6dd2 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 @@ -194,7 +194,7 @@ def pageSizeParam(params) def self_link(project) if project - api_v3_paths.work_packages_by_project(project.id) + api_v3_paths.work_packages_by_workspace(project.id) else api_v3_paths.work_packages end diff --git a/lib/api/v3/projects/project_representer.rb b/lib/api/v3/projects/project_representer.rb index f3937ee0e8da..38c95f93d2dd 100644 --- a/lib/api/v3/projects/project_representer.rb +++ b/lib/api/v3/projects/project_representer.rb @@ -52,7 +52,7 @@ def self.current_user_view_allowed_lambda link :createWorkPackage, cache_if: -> { current_user.allowed_in_project?(:add_work_packages, represented) } do { - href: api_v3_paths.create_project_work_package_form(represented.id), + href: api_v3_paths.create_workspace_work_package_form(represented.id), method: :post } end @@ -60,7 +60,7 @@ def self.current_user_view_allowed_lambda link :createWorkPackageImmediately, cache_if: -> { current_user.allowed_in_project?(:add_work_packages, represented) } do { - href: api_v3_paths.work_packages_by_project(represented.id), + href: api_v3_paths.work_packages_by_workspace(represented.id), method: :post } end @@ -69,7 +69,7 @@ def self.current_user_view_allowed_lambda cache_if: -> { current_user.allowed_in_project?(:view_work_packages, represented) } do - { href: api_v3_paths.work_packages_by_project(represented.id) } + { href: api_v3_paths.work_packages_by_workspace(represented.id) } end links :storages, diff --git a/lib/api/v3/projects/projects_api.rb b/lib/api/v3/projects/projects_api.rb index bebcd6bb4944..3165e26ebe9a 100644 --- a/lib/api/v3/projects/projects_api.rb +++ b/lib/api/v3/projects/projects_api.rb @@ -76,7 +76,6 @@ class ProjectsAPI < ::API::OpenProjectAPI mount ::API::V3::Workspaces::NestedApis mount API::V3::Projects::Copy::CopyAPI - mount API::V3::WorkPackages::WorkPackagesByProjectAPI mount API::V3::Categories::CategoriesByProjectAPI mount API::V3::Versions::VersionsByProjectAPI mount API::V3::Queries::QueriesByProjectAPI diff --git a/lib/api/v3/queries/query_params_representer.rb b/lib/api/v3/queries/query_params_representer.rb index ca170564ce25..52c7c88890f3 100644 --- a/lib/api/v3/queries/query_params_representer.rb +++ b/lib/api/v3/queries/query_params_representer.rb @@ -57,7 +57,7 @@ def to_h(column_key: :columns) def self_link if query.project - api_v3_paths.work_packages_by_project(query.project.id) + api_v3_paths.work_packages_by_workspace(query.project.id) else api_v3_paths.work_packages end diff --git a/lib/api/v3/queries/query_representer.rb b/lib/api/v3/queries/query_representer.rb index 6eeca1ae994e..ca65e9b45e87 100644 --- a/lib/api/v3/queries/query_representer.rb +++ b/lib/api/v3/queries/query_representer.rb @@ -62,7 +62,7 @@ class QueryRepresenter < ::API::Decorators::Single link :results do path = if represented.project - api_v3_paths.work_packages_by_project(represented.project.id) + api_v3_paths.work_packages_by_workspace(represented.project.id) else api_v3_paths.work_packages end @@ -327,7 +327,6 @@ def initialize(model, results: nil, embed_links: false, params: {}) - self.results = results self.params = params diff --git a/lib/api/v3/queries/schemas/by_work_package_filter_dependency_representer.rb b/lib/api/v3/queries/schemas/by_work_package_filter_dependency_representer.rb index dddd2efd6b57..d675043f51f7 100644 --- a/lib/api/v3/queries/schemas/by_work_package_filter_dependency_representer.rb +++ b/lib/api/v3/queries/schemas/by_work_package_filter_dependency_representer.rb @@ -38,7 +38,7 @@ def json_cache_key def href_callback if filter.project - api_v3_paths.work_packages_by_project(filter.project.id) + api_v3_paths.work_packages_by_workspace(filter.project.id) else api_v3_paths.work_packages end diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index ddd161844f25..c891a03adfca 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -222,6 +222,10 @@ def self.create_project_work_package_form(project_id) "#{work_packages_by_project(project_id)}/form" end + def self.create_workspace_work_package_form(project_id) + "#{work_packages_by_workspace(project_id)}/form" + end + def self.custom_action(id) "#{root}/custom_actions/#{id}" end @@ -665,6 +669,10 @@ def self.work_packages_by_project(project_id) "#{project(project_id)}/work_packages" end + def self.work_packages_by_workspace(workspace_id) + "#{workspace(workspace_id)}/work_packages" + end + index :workspace show :workspace diff --git a/lib/api/v3/work_packages/create_project_form_api.rb b/lib/api/v3/work_packages/create_workspace_form_api.rb similarity index 96% rename from lib/api/v3/work_packages/create_project_form_api.rb rename to lib/api/v3/work_packages/create_workspace_form_api.rb index 83276c948d83..7e4be59ecc15 100644 --- a/lib/api/v3/work_packages/create_project_form_api.rb +++ b/lib/api/v3/work_packages/create_workspace_form_api.rb @@ -29,7 +29,7 @@ module API module V3 module WorkPackages - class CreateProjectFormAPI < ::API::OpenProjectAPI + class CreateWorkspaceFormAPI < ::API::OpenProjectAPI resource :form do post &::API::V3::Utilities::Endpoints::CreateForm.new(model: WorkPackage, parse_service: WorkPackages::ParseParamsService, diff --git a/lib/api/v3/work_packages/create_project_form_representer.rb b/lib/api/v3/work_packages/create_workspace_form_representer.rb similarity index 88% rename from lib/api/v3/work_packages/create_project_form_representer.rb rename to lib/api/v3/work_packages/create_workspace_form_representer.rb index 76fea02105fb..e1de9be5b38a 100644 --- a/lib/api/v3/work_packages/create_project_form_representer.rb +++ b/lib/api/v3/work_packages/create_workspace_form_representer.rb @@ -29,17 +29,17 @@ module API module V3 module WorkPackages - class CreateProjectFormRepresenter < FormRepresenter + class CreateWorkspaceFormRepresenter < FormRepresenter link :self do { - href: api_v3_paths.create_project_work_package_form(represented.project_id), + href: api_v3_paths.create_workspace_work_package_form(represented.project_id), method: :post } end link :validate do { - href: api_v3_paths.create_project_work_package_form(represented.project_id), + href: api_v3_paths.create_workspace_work_package_form(represented.project_id), method: :post } end @@ -55,7 +55,7 @@ class CreateProjectFormRepresenter < FormRepresenter if current_user.allowed_in_work_package?(:edit_work_packages, represented) && @errors.empty? { - href: api_v3_paths.work_packages_by_project(represented.project_id), + href: api_v3_paths.work_packages_by_workspace(represented.project_id), method: :post } end diff --git a/lib/api/v3/work_packages/work_package_representer.rb b/lib/api/v3/work_packages/work_package_representer.rb index ea21abee85dc..6facd3c1b4c4 100644 --- a/lib/api/v3/work_packages/work_package_representer.rb +++ b/lib/api/v3/work_packages/work_package_representer.rb @@ -273,7 +273,7 @@ def self_v3_path(*) next if represented.milestone? || represented.new_record? { - href: api_v3_paths.work_packages_by_project(represented.project.identifier), + href: api_v3_paths.work_packages_by_workspace(represented.project.identifier), method: :post, title: "Add child of #{represented.subject}" } diff --git a/lib/api/v3/work_packages/work_packages_by_project_api.rb b/lib/api/v3/work_packages/work_packages_by_workspace_api.rb similarity index 95% rename from lib/api/v3/work_packages/work_packages_by_project_api.rb rename to lib/api/v3/work_packages/work_packages_by_workspace_api.rb index 64a6fb644f6c..56a4f201ea6b 100644 --- a/lib/api/v3/work_packages/work_packages_by_project_api.rb +++ b/lib/api/v3/work_packages/work_packages_by_workspace_api.rb @@ -29,7 +29,7 @@ module API module V3 module WorkPackages - class WorkPackagesByProjectAPI < ::API::OpenProjectAPI + class WorkPackagesByWorkspaceAPI < ::API::OpenProjectAPI resources :work_packages do helpers ::API::V3::WorkPackages::WorkPackagesSharedHelpers @@ -54,7 +54,7 @@ class WorkPackagesByProjectAPI < ::API::OpenProjectAPI }) .mount - mount ::API::V3::WorkPackages::CreateProjectFormAPI + mount ::API::V3::WorkPackages::CreateWorkspaceFormAPI end end end diff --git a/lib/api/v3/workspaces/available_assignees_api.rb b/lib/api/v3/workspaces/available_assignees_api.rb index bf280f46e9b4..b3e035a7ff25 100644 --- a/lib/api/v3/workspaces/available_assignees_api.rb +++ b/lib/api/v3/workspaces/available_assignees_api.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # -- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH diff --git a/lib/api/v3/workspaces/nested_apis.rb b/lib/api/v3/workspaces/nested_apis.rb index c9374137a0e0..6755fd8e1c26 100644 --- a/lib/api/v3/workspaces/nested_apis.rb +++ b/lib/api/v3/workspaces/nested_apis.rb @@ -34,6 +34,7 @@ module Workspaces class NestedApis < ::API::OpenProjectAPI mount API::V3::Workspaces::AvailableAssigneesAPI mount API::V3::Types::TypesByWorkspaceAPI + mount API::V3::WorkPackages::WorkPackagesByWorkspaceAPI end end end diff --git a/spec/factories/project_factory.rb b/spec/factories/project_factory.rb index 9f7931fe79ed..1527eb1b4e14 100644 --- a/spec/factories/project_factory.rb +++ b/spec/factories/project_factory.rb @@ -46,18 +46,7 @@ end factory :project_with_types do - # using initialize_with types to prevent - # the project's initialize function looking for the default type - # when we will be setting the type later on anyway - initialize_with do - types = if instance_variable_get(:@build_strategy).is_a?(FactoryBot::Strategy::Stub) - [build_stubbed(:type)] - else - [build(:type)] - end - - new(types:) - end + with_types factory :valid_project do callback(:after_build) do |project| diff --git a/spec/factories/workspace_factory.rb b/spec/factories/workspace_factory.rb index 28df3ad51521..0944ec514248 100644 --- a/spec/factories/workspace_factory.rb +++ b/spec/factories/workspace_factory.rb @@ -64,6 +64,21 @@ status_explanation { "some explanation" } end + trait :with_types do + # using initialize_with types to prevent + # the project's initialize function looking for the default type + # when we will be setting the type later on anyway + initialize_with do + types = if instance_variable_get(:@build_strategy).is_a?(FactoryBot::Strategy::Stub) + [build_stubbed(:type)] + else + [build(:type)] + end + + new(types:) + end + end + trait :archived do active { false } end diff --git a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb index eadb74ff2ecd..f13b0e171b2d 100644 --- a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb +++ b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb @@ -259,12 +259,12 @@ describe "create work packages" do context "if user is allowed to create work packages" do it "has the correct path for a create form" do - expect(subject).to be_json_eql(api_v3_paths.create_project_work_package_form(project.id).to_json) + expect(subject).to be_json_eql(api_v3_paths.create_workspace_work_package_form(project.id).to_json) .at_path("_links/createWorkPackage/href") end it "has the correct path to create a work package" do - expect(subject).to be_json_eql(api_v3_paths.work_packages_by_project(project.id).to_json) + expect(subject).to be_json_eql(api_v3_paths.work_packages_by_workspace(project.id).to_json) .at_path("_links/createWorkPackageImmediately/href") end end @@ -663,7 +663,7 @@ it_behaves_like "has an untitled link" do let(:link) { "workPackages" } - let(:href) { api_v3_paths.work_packages_by_project(project.id) } + let(:href) { api_v3_paths.work_packages_by_workspace(project.id) } end end diff --git a/spec/lib/api/v3/queries/query_representer_rendering_spec.rb b/spec/lib/api/v3/queries/query_representer_rendering_spec.rb index d7ccf414be50..88c6466635f3 100644 --- a/spec/lib/api/v3/queries/query_representer_rendering_spec.rb +++ b/spec/lib/api/v3/queries/query_representer_rendering_spec.rb @@ -127,7 +127,7 @@ def non_empty_to_query(hash) pageSize: Setting.per_page_options_array.first, filters: [] } - "#{api_v3_paths.work_packages_by_project(project.id)}?#{non_empty_to_query(params)}" + "#{api_v3_paths.work_packages_by_workspace(project.id)}?#{non_empty_to_query(params)}" end end @@ -350,7 +350,7 @@ def non_empty_to_query(hash) sortBy: JSON::dump([%w[assignee asc], %w[type desc]]) } - api_v3_paths.work_packages_by_project(project.id) + "?#{params.to_query}" + api_v3_paths.work_packages_by_workspace(project.id) + "?#{params.to_query}" end it_behaves_like "has an untitled link" do @@ -376,7 +376,7 @@ def non_empty_to_query(hash) filters: [] } - api_v3_paths.work_packages_by_project(project.id) + "?#{non_empty_to_query(params)}" + api_v3_paths.work_packages_by_workspace(project.id) + "?#{non_empty_to_query(params)}" end it_behaves_like "has an untitled link" do diff --git a/spec/lib/api/v3/queries/schemas/id_filter_dependency_representer_spec.rb b/spec/lib/api/v3/queries/schemas/id_filter_dependency_representer_spec.rb index 48c868052bbf..f0b70f5fc569 100644 --- a/spec/lib/api/v3/queries/schemas/id_filter_dependency_representer_spec.rb +++ b/spec/lib/api/v3/queries/schemas/id_filter_dependency_representer_spec.rb @@ -52,7 +52,7 @@ context "within project" do let(:path) { "values" } let(:type) { "[]WorkPackage" } - let(:href) { api_v3_paths.work_packages_by_project(project.id) } + let(:href) { api_v3_paths.work_packages_by_workspace(project.id) } context "for operator 'Queries::Operators::Equals'" do let(:operator) { Queries::Operators::Equals } diff --git a/spec/lib/api/v3/support/api_v3_filter_dependency.rb b/spec/lib/api/v3/support/api_v3_filter_dependency.rb index 3bb29db7193b..4707c012fe9f 100644 --- a/spec/lib/api/v3/support/api_v3_filter_dependency.rb +++ b/spec/lib/api/v3/support/api_v3_filter_dependency.rb @@ -110,7 +110,7 @@ context "within project" do let(:path) { "values" } let(:type) { "[]WorkPackage" } - let(:href) { api_v3_paths.work_packages_by_project(project.id) } + let(:href) { api_v3_paths.work_packages_by_workspace(project.id) } context "for operator 'Queries::Operators::Equals'" do let(:operator) { Queries::Operators::Equals } diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index b6ecd6ac279c..7683441c4da9 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -607,12 +607,24 @@ it_behaves_like "api v3 path", "/projects/42/work_packages" end + describe "#work_packages_by_workspace" do + subject { helper.work_packages_by_workspace 42 } + + it_behaves_like "api v3 path", "/workspaces/42/work_packages" + end + describe "#create_project_work_package_form" do subject { helper.create_project_work_package_form 42 } it_behaves_like "api v3 path", "/projects/42/work_packages/form" end + describe "#create_workspace_work_package_form" do + subject { helper.create_workspace_work_package_form 42 } + + it_behaves_like "api v3 path", "/workspaces/42/work_packages/form" + end + describe "#watcher" do subject { helper.watcher 1, 42 } diff --git a/spec/lib/api/v3/work_packages/create_project_form_representer_spec.rb b/spec/lib/api/v3/work_packages/create_workspace_form_representer_spec.rb similarity index 94% rename from spec/lib/api/v3/work_packages/create_project_form_representer_spec.rb rename to spec/lib/api/v3/work_packages/create_workspace_form_representer_spec.rb index 8120f0c08b6c..ac26cef542b3 100644 --- a/spec/lib/api/v3/work_packages/create_project_form_representer_spec.rb +++ b/spec/lib/api/v3/work_packages/create_workspace_form_representer_spec.rb @@ -30,7 +30,7 @@ require "spec_helper" -RSpec.describe API::V3::WorkPackages::CreateProjectFormRepresenter do +RSpec.describe API::V3::WorkPackages::CreateWorkspaceFormRepresenter do include API::V3::Utilities::PathHelper let(:errors) { [] } @@ -58,7 +58,7 @@ describe "_links" do it do expect(generated).to be_json_eql( - api_v3_paths.create_project_work_package_form(work_package.project_id).to_json + api_v3_paths.create_workspace_work_package_form(work_package.project_id).to_json ) .at_path("_links/self/href") end @@ -70,7 +70,7 @@ describe "validate" do it do expect(generated).to be_json_eql( - api_v3_paths.create_project_work_package_form(work_package.project_id).to_json + api_v3_paths.create_workspace_work_package_form(work_package.project_id).to_json ) .at_path("_links/validate/href") end @@ -106,7 +106,7 @@ context "with a valid work package" do it do expect(generated).to be_json_eql( - api_v3_paths.work_packages_by_project(work_package.project_id).to_json + api_v3_paths.work_packages_by_workspace(work_package.project_id).to_json ) .at_path("_links/commit/href") end diff --git a/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb b/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb index 546efcf10780..200a12882e64 100644 --- a/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb +++ b/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb @@ -1039,9 +1039,6 @@ end describe "responsible and assignee" do - let(:base_href) { "/api/v3/projects/#{work_package.project.id}" } - let(:wp_base_href) { "/api/v3/work_packages/#{work_package.id}" } - describe "assignee" do it_behaves_like "has basic schema properties" do let(:path) { "assignee" } @@ -1054,8 +1051,7 @@ it_behaves_like "links to allowed values via collection link" do let(:path) { "assignee" } - let(:base_href) { "/api/v3/work_packages/#{work_package.id}" } - let(:href) { "#{base_href}/available_assignees" } + let(:href) { api_v3_paths.available_assignees_in_work_package(work_package.id) } end context "when not embedded" do @@ -1092,7 +1088,7 @@ context "when the work package is persisted" do it_behaves_like "links to allowed values via collection link" do let(:path) { "responsible" } - let(:href) { "#{wp_base_href}/available_assignees" } + let(:href) { api_v3_paths.available_assignees_in_work_package(work_package.id) } end end @@ -1101,7 +1097,7 @@ it_behaves_like "links to allowed values via collection link" do let(:path) { "responsible" } - let(:href) { "#{base_href}/available_assignees" } + let(:href) { api_v3_paths.available_assignees_in_workspace(work_package.project_id) } 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 08be4d08565b..d129357f45ab 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 @@ -1001,7 +1001,7 @@ context "when the user has the permission to add work packages" do it "has a link to add child" do - expect(subject).to be_json_eql("/api/v3/projects/#{project.identifier}/work_packages".to_json) + expect(subject).to be_json_eql(api_v3_paths.work_packages_by_workspace(project.identifier).to_json) .at_path("_links/addChild/href") end end diff --git a/spec/requests/api/v3/projects/index_resource_spec.rb b/spec/requests/api/v3/projects/index_resource_spec.rb index 4a72a085e96a..4f241aebb2da 100644 --- a/spec/requests/api/v3/projects/index_resource_spec.rb +++ b/spec/requests/api/v3/projects/index_resource_spec.rb @@ -385,9 +385,7 @@ context "as project collection" do let(:role) { create(:project_role, permissions: %i[view_work_packages]) } let(:projects) { [project] } - let(:expected) do - "#{api_v3_paths.project(project.id)}/work_packages" - end + let(:expected) { api_v3_paths.work_packages_by_workspace(project.id) } it "has projects with links to their work packages" do expect(last_response.body) diff --git a/spec/requests/api/v3/work_packages/by_project_create_resource_spec.rb b/spec/requests/api/v3/work_packages/by_project_create_resource_spec.rb deleted file mode 100644 index c957d71da257..000000000000 --- a/spec/requests/api/v3/work_packages/by_project_create_resource_spec.rb +++ /dev/null @@ -1,186 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. - -require "spec_helper" -require "rack/test" - -RSpec.describe API::V3::WorkPackages::WorkPackagesByProjectAPI, content_type: :json do - include Rack::Test::Methods - include API::V3::Utilities::PathHelper - - shared_let(:project) { create(:project_with_types, public: false) } - let(:role) { create(:project_role, permissions:) } - let(:path) { api_v3_paths.work_packages_by_project project.id } - let(:permissions) { %i[add_work_packages view_project] } - let(:status) { build(:status, is_default: true) } - let(:priority) { build(:priority, is_default: true) } - let(:other_user) { nil } - let(:parameters) do - { - subject: "new work packages", - _links: { - type: { - href: api_v3_paths.type(project.types.first.id) - } - } - } - end - - current_user do - create(:user, member_with_roles: { project => role }) - end - - before do - status.save! - priority.save! - other_user - - perform_enqueued_jobs do - post path, parameters.to_json - end - end - - describe "notifications" do - let(:other_user) do - create(:user, - member_with_permissions: { project => %i(view_work_packages) }, - notification_settings: [ - build(:notification_setting, - work_package_created: true) - ]) - end - - it "creates a notification" do - expect(Notification.where(recipient: other_user, resource: WorkPackage.last)) - .to exist - end - - context "without notifications" do - let(:path) { "#{api_v3_paths.work_packages_by_project(project.id)}?notify=false" } - - it "creates no notification" do - expect(Notification) - .not_to exist - end - end - - context "with notifications" do - let(:path) { "#{api_v3_paths.work_packages_by_project(project.id)}?notify=true" } - - it "creates a notification" do - expect(Notification.where(recipient: other_user, resource: WorkPackage.last)) - .to exist - end - end - end - - it "returns Created(201)" do - expect(last_response).to have_http_status(:created) - end - - it "creates a work package" do - expect(WorkPackage.all.count).to eq(1) - end - - it "uses the given parameters" do - expect(WorkPackage.first.subject).to eq(parameters[:subject]) - end - - context "without permissions" do - let(:current_user) { create(:user) } - - it "hides the endpoint" do - expect(last_response).to have_http_status(:not_found) - end - end - - context "with view_project permission" do - # Note that this just removes the add_work_packages permission - # view_project is actually provided by being a member of the project - let(:permissions) { [:view_project] } - - it "points out the missing permission" do - expect(last_response).to have_http_status(:forbidden) - end - end - - context "with empty parameters" do - let(:parameters) { {} } - - it_behaves_like "constraint violation" do - let(:message) { "Subject can't be blank" } - end - - it "does not create a work package" do - expect(WorkPackage.all.count).to eq(0) - end - end - - context "with bogus parameters" do - let(:parameters) do - { - bogus: "bogus", - _links: { - type: { - href: api_v3_paths.type(project.types.first.id) - } - } - } - end - - it_behaves_like "constraint violation" do - let(:message) { "Subject can't be blank" } - end - - it "does not create a work package" do - expect(WorkPackage.all.count).to eq(0) - end - end - - context "with an invalid value" do - let(:parameters) do - { - subject: nil, - _links: { - type: { - href: api_v3_paths.type(project.types.first.id) - } - } - } - end - - it_behaves_like "constraint violation" do - let(:message) { "Subject can't be blank" } - end - - it "does not create a work package" do - expect(WorkPackage.all.count).to eq(0) - end - end -end diff --git a/spec/requests/api/v3/work_packages/by_project_index_resource_spec.rb b/spec/requests/api/v3/work_packages/by_project_index_resource_spec.rb deleted file mode 100644 index 2d954f99e36f..000000000000 --- a/spec/requests/api/v3/work_packages/by_project_index_resource_spec.rb +++ /dev/null @@ -1,326 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. - -require "spec_helper" -require "rack/test" - -RSpec.describe API::V3::WorkPackages::WorkPackagesByProjectAPI, content_type: :json do - include Rack::Test::Methods - include API::V3::Utilities::PathHelper - - shared_let(:project) { create(:project_with_types, public: false) } - let(:role) { create(:project_role, permissions:) } - let(:permissions) { [:view_work_packages] } - let(:base_path) { api_v3_paths.work_packages_by_project project.id } - let(:query) { {} } - let(:path) { "#{base_path}?#{query.to_query}" } - let(:work_packages) { [] } - - current_user do - create(:user, member_with_roles: { project => role }) - end - - subject { last_response } - - before do - work_packages.each(&:save!) - get path - end - - it "succeeds" do - expect(subject.status).to eq 200 - end - - context "when not allowed to see the project" do - let(:current_user) { create(:user) } - - it "fails with HTTP Not Found" do - expect(subject.status).to eq 404 - end - end - - context "when not allowed to see work packages" do - let(:permissions) { [:view_project] } - - it "fails with HTTP Not Found" do - expect(subject.status).to eq 403 - end - end - - describe "sorting" do - let(:query) { { sortBy: '[["id", "desc"]]' } } - let(:work_packages) { create_list(:work_package, 2, project:) } - - it_behaves_like "API V3 collection response", 2, 2, "WorkPackage", "WorkPackageCollection" do - let(:elements) { work_packages.reverse } - end - end - - describe "filtering by priority" do - let(:query) do - { - filters: [ - { - priority: { - operator: "=", - values: [priority1.id.to_s] - } - } - ].to_json - } - end - let(:priority1) { create(:priority, name: "Prio A") } - let(:priority2) { create(:priority, name: "Prio B") } - let(:work_packages) do - [ - create(:work_package, project:, priority: priority1), - create(:work_package, project:, priority: priority2) - ] - end - - it_behaves_like "API V3 collection response", 1, 1, "WorkPackage", "WorkPackageCollection" do - let(:elements) { [work_packages.first] } - end - end - - describe "filtering by project (one different from the project of the path)" do - let(:query) do - { - filters: [ - { - project: { - operator: "=", - values: [other_project.id.to_s] - } - } - ].to_json - } - end - let(:other_project) { create(:project, members: { current_user => role }) } - let(:work_packages) { [other_project_work_package, project_work_package] } - let(:project_work_package) { create(:work_package, project:) } - let(:other_project_work_package) { create(:work_package, project: other_project) } - - it_behaves_like "API V3 collection response", 1, 1, "WorkPackage", "WorkPackageCollection" do - let(:elements) { [other_project_work_package] } - end - end - - describe "grouping" do - let(:query) { { groupBy: "priority" } } - let(:priority1) { build(:priority, name: "Prio A", position: 2) } - let(:priority2) { build(:priority, name: "Prio B", position: 1) } - let(:work_packages) do - [ - create(:work_package, - project:, - priority: priority1, - estimated_hours: 1), - create(:work_package, - project:, - priority: priority2, - estimated_hours: 2), - create(:work_package, - project:, - priority: priority1, - estimated_hours: 3) - ] - end - let(:expected_group1) do - { - _links: { - valueLink: [{ - href: api_v3_paths.priority(priority1.id) - }], - groupBy: { - href: api_v3_paths.query_group_by("priority"), - title: "Priority" - } - }, - value: priority1.name, - count: 2 - } - end - let(:expected_group2) do - { - _links: { - valueLink: [{ - href: api_v3_paths.priority(priority2.id) - }], - groupBy: { - href: api_v3_paths.query_group_by("priority"), - title: "Priority" - } - }, - value: priority2.name, - count: 1 - } - end - - it_behaves_like "API V3 collection response", 3, 3, "WorkPackage", "WorkPackageCollection" do - let(:elements) { [work_packages.second, work_packages.first, work_packages.third] } - end - - it "contains group elements" do - expect(subject.body).to include_json(expected_group1.to_json).at_path("groups") - expect(subject.body).to include_json(expected_group2.to_json).at_path("groups") - end - end - - describe "displaying sums" do - let(:query) { { showSums: "true" } } - let(:work_packages) do - [ - create(:work_package, project:, estimated_hours: 1), - create(:work_package, project:, estimated_hours: 2) - ] - end - - it_behaves_like "API V3 collection response", 2, 2, "WorkPackage", "WorkPackageCollection" do - let(:elements) { work_packages } - end - - it "contains the sum element" do - expected = { - estimatedTime: "PT3H", - laborCosts: "0.00 EUR", - materialCosts: "0.00 EUR", - overallCosts: "0.00 EUR", - percentageDone: nil, - remainingTime: nil, - storyPoints: nil - } - - expect(subject.body).to be_json_eql(expected.to_json).at_path("totalSums") - end - - describe "percentageDone/done_ratio sum" do - shared_let(:work_package) { create(:work_package, project:) } - shared_let(:work_packages) { [work_package] } - - subject(:percentage_done_sum) { JSON.parse(last_response.body)["totalSums"]["percentageDone"] } - - context "when work sum and remaining work sum are not set" do - it "is not set" do - expect(percentage_done_sum).to be_nil - end - end - - context "when work sum and remaining work sum are set to valid values (W=10h, RW=4h)" do - before_all do - work_package.update_columns(estimated_hours: 10, remaining_hours: 4) - end - - it "is calculated from them (60%)" do - expect(percentage_done_sum).to eq 60 - end - end - - context "when only work sum is set" do - before_all do - work_package.update_columns(estimated_hours: 10) - end - - it "is not set" do - expect(percentage_done_sum).to be_nil - end - end - - context "when only remaining work sum is set" do - before_all do - work_package.update_columns(remaining_hours: 4) - end - - it "is not set" do - expect(percentage_done_sum).to be_nil - end - end - - context "when work sum and remaining work sum are 0h" do - before_all do - work_package.update_columns(estimated_hours: 0, remaining_hours: 0) - end - - it "is not set" do - expect(percentage_done_sum).to be_nil - end - end - - context "when remaining work sum is greater than work sum (bad data, like W=10h RW=15h)" do - before_all do - work_package.update_columns(estimated_hours: 10, remaining_hours: 15) - end - - it "is set to 0% (and not -50%)" do - expect(percentage_done_sum).to eq 0 - end - end - - context "when remaining work sum is 0h and work sum is positive" do - before_all do - work_package.update_columns(estimated_hours: 10, remaining_hours: 0) - end - - it "is set to 100%" do - expect(percentage_done_sum).to eq 100 - end - end - - context "when calculated % complete sum is xx.5% (like 50.5% or 42.5%)" do - before_all do - work_package.update_columns(estimated_hours: 1000, remaining_hours: 495) - end - - it "is rounded up (like 51% or 43%)" do - expect(percentage_done_sum).to eq 51 - end - end - - context "when calculated % complete sum is almost 0% (like 0.4% or 0.01%)" do - before_all do - work_package.update_columns(estimated_hours: 1000, remaining_hours: 999) - end - - it "is rounded to 1% (because 0% would be wrong)" do - expect(percentage_done_sum).to eq 1 - end - end - - context "when % complete sum is almost 100% (like 99.5% or 99.99%)" do - before_all do - work_package.update_columns(estimated_hours: 1000, remaining_hours: 1) - end - - it "is rounded to 99% (because 100% would be wrong)" do - expect(percentage_done_sum).to eq 99 - end - end - end - end -end diff --git a/spec/requests/api/v3/work_packages/by_workspace_create_resource_spec.rb b/spec/requests/api/v3/work_packages/by_workspace_create_resource_spec.rb new file mode 100644 index 000000000000..5d035f231e33 --- /dev/null +++ b/spec/requests/api/v3/work_packages/by_workspace_create_resource_spec.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. + +require "spec_helper" +require "rack/test" + +RSpec.describe "POST api/v3/workspace/:id/work_packages", content_type: :json do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + shared_let(:project) { create(:project, :with_types, public: false) } + shared_let(:portfolio) { create(:portfolio, :with_types) } + + let(:role) { create(:project_role, permissions:) } + let(:permissions) { %i[add_work_packages view_project] } + let(:status) { build(:status, is_default: true) } + let(:priority) { build(:priority, is_default: true) } + let(:other_user) { nil } + let(:parameters) do + { + subject: "new work packages", + _links: { + type: { + href: api_v3_paths.type(workspace.types.first.id) + } + } + } + end + + current_user do + create(:user, member_with_roles: { + project => role, + portfolio => role + }) + end + + before do + status.save! + priority.save! + other_user + + perform_enqueued_jobs do + post path, parameters.to_json + end + end + + shared_context "with work package creation" do + describe "notifications" do + let(:other_user) do + create(:user, + member_with_permissions: { workspace => %i(view_work_packages) }, + notification_settings: [ + build(:notification_setting, + work_package_created: true) + ]) + end + + it "creates a notification" do + expect(Notification.where(recipient: other_user, resource: WorkPackage.last)) + .to exist + end + + context "without notifications" do + let(:path) { "#{super()}?notify=false" } + + it "creates no notification" do + expect(Notification) + .not_to exist + end + end + + context "with notifications" do + let(:path) { "#{super()}?notify=true" } + + it "creates a notification" do + expect(Notification.where(recipient: other_user, resource: WorkPackage.last)) + .to exist + end + end + end + + it "returns Created(201)" do + expect(last_response).to have_http_status(:created) + end + + it "creates a work package" do + expect(WorkPackage.count).to eq(1) + end + + it "uses the given parameters" do + expect(WorkPackage.first.subject).to eq(parameters[:subject]) + end + + context "without permissions" do + let(:current_user) { create(:user) } + + it "hides the endpoint" do + expect(last_response).to have_http_status(:not_found) + end + end + + context "with view_project permission" do + # Note that this just removes the add_work_packages permission + # view_project is actually provided by being a member of the project + let(:permissions) { [:view_project] } + + it "points out the missing permission" do + expect(last_response).to have_http_status(:forbidden) + end + end + + context "with empty parameters" do + let(:parameters) { {} } + + it_behaves_like "constraint violation" do + let(:message) { "Subject can't be blank" } + end + + it "does not create a work package" do + expect(WorkPackage.count).to eq(0) + end + end + + context "with bogus parameters" do + let(:parameters) do + { + bogus: "bogus", + _links: { + type: { + href: api_v3_paths.type(workspace.types.first.id) + } + } + } + end + + it_behaves_like "constraint violation" do + let(:message) { "Subject can't be blank" } + end + + it "does not create a work package" do + expect(WorkPackage.count).to eq(0) + end + end + + context "with an invalid value" do + let(:parameters) do + { + subject: nil, + _links: { + type: { + href: api_v3_paths.type(workspace.types.first.id) + } + } + } + end + + it_behaves_like "constraint violation" do + let(:message) { "Subject can't be blank" } + end + + it "does not create a work package" do + expect(WorkPackage.count).to eq(0) + end + end + end + + context "for a project path" do + let(:path) { api_v3_paths.work_packages_by_project project.id } + let(:workspace) { project } + + include_context "with work package creation" + end + + context "for a workspace path" do + let(:path) { api_v3_paths.work_packages_by_workspace portfolio.id } + let(:workspace) { portfolio } + + include_context "with work package creation" + end +end diff --git a/spec/requests/api/v3/work_packages/by_workspace_index_resource_spec.rb b/spec/requests/api/v3/work_packages/by_workspace_index_resource_spec.rb new file mode 100644 index 000000000000..d0b1ad8938c1 --- /dev/null +++ b/spec/requests/api/v3/work_packages/by_workspace_index_resource_spec.rb @@ -0,0 +1,330 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. + +require "spec_helper" +require "rack/test" + +RSpec.describe "GET api/v3/workspace/:id/work_packages", content_type: :json do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + shared_let(:project) { create(:project, :with_types) } + shared_let(:portfolio) { create(:portfolio, :with_types) } + + let(:role) { create(:project_role, permissions:) } + let(:permissions) { [:view_work_packages] } + let(:query) { {} } + let(:path) { "#{base_path}?#{query.to_query}" } + let(:work_packages) { [] } + + current_user do + create(:user, member_with_roles: { + project => role, + portfolio => role + }) + end + + subject { last_response } + + before do + work_packages.each(&:save!) + get path + end + + shared_context "with work package indexing" do + it "succeeds" do + expect(subject.status).to eq 200 + end + + context "when not allowed to see the project" do + let(:current_user) { create(:user) } + + it "fails with HTTP Not Found" do + expect(subject.status).to eq 404 + end + end + + context "when not allowed to see work packages" do + let(:permissions) { [:view_project] } + + it "fails with HTTP Not Found" do + expect(subject.status).to eq 403 + end + end + + describe "sorting" do + let(:query) { { sortBy: '[["id", "desc"]]' } } + let(:work_packages) { create_list(:work_package, 2, project: workspace) } + + it_behaves_like "API V3 collection response", 2, 2, "WorkPackage", "WorkPackageCollection" do + let(:elements) { work_packages.reverse } + end + end + + describe "filtering by priority" do + let(:query) do + { + filters: [ + { + priority: { + operator: "=", + values: [priority1.id.to_s] + } + } + ].to_json + } + end + let(:priority1) { create(:priority, name: "Prio A") } + let(:priority2) { create(:priority, name: "Prio B") } + let(:work_packages) do + [ + create(:work_package, project: workspace, priority: priority1), + create(:work_package, project: workspace, priority: priority2) + ] + end + + it_behaves_like "API V3 collection response", 1, 1, "WorkPackage", "WorkPackageCollection" do + let(:elements) { [work_packages.first] } + end + end + + describe "filtering by project (one different from the project of the path)" do + let(:query) do + { + filters: [ + { + project: { + operator: "=", + values: [other_project.id.to_s] + } + } + ].to_json + } + end + let(:other_project) { create(:project, members: { current_user => role }) } + let(:work_packages) { [other_project_work_package, project_work_package] } + let(:project_work_package) { create(:work_package, project: workspace) } + let(:other_project_work_package) { create(:work_package, project: other_project) } + + it_behaves_like "API V3 collection response", 1, 1, "WorkPackage", "WorkPackageCollection" do + let(:elements) { [other_project_work_package] } + end + end + + describe "grouping" do + let(:query) { { groupBy: "priority" } } + let(:priority1) { build(:priority, name: "Prio A", position: 2) } + let(:priority2) { build(:priority, name: "Prio B", position: 1) } + let(:work_packages) do + [ + create(:work_package, + project: workspace, + priority: priority1, + estimated_hours: 1), + create(:work_package, + project: workspace, + priority: priority2, + estimated_hours: 2), + create(:work_package, + project: workspace, + priority: priority1, + estimated_hours: 3) + ] + end + let(:expected_group1) do + { + _links: { + valueLink: [{ + href: api_v3_paths.priority(priority1.id) + }], + groupBy: { + href: api_v3_paths.query_group_by("priority"), + title: "Priority" + } + }, + value: priority1.name, + count: 2 + } + end + let(:expected_group2) do + { + _links: { + valueLink: [{ + href: api_v3_paths.priority(priority2.id) + }], + groupBy: { + href: api_v3_paths.query_group_by("priority"), + title: "Priority" + } + }, + value: priority2.name, + count: 1 + } + end + + it_behaves_like "API V3 collection response", 3, 3, "WorkPackage", "WorkPackageCollection" do + let(:elements) { [work_packages.second, work_packages.first, work_packages.third] } + end + + it "contains group elements" do + expect(subject.body).to include_json(expected_group1.to_json).at_path("groups") + expect(subject.body).to include_json(expected_group2.to_json).at_path("groups") + end + end + + describe "displaying sums" do + let(:query) { { showSums: "true" } } + let(:work_packages) do + [ + create(:work_package, project: workspace, estimated_hours: 1), + create(:work_package, project: workspace, estimated_hours: 2) + ] + end + + it_behaves_like "API V3 collection response", 2, 2, "WorkPackage", "WorkPackageCollection" do + let(:elements) { work_packages } + end + + it "contains the sum element" do + expected = { + estimatedTime: "PT3H", + laborCosts: "0.00 EUR", + materialCosts: "0.00 EUR", + overallCosts: "0.00 EUR", + percentageDone: nil, + remainingTime: nil, + storyPoints: nil + } + + expect(subject.body).to be_json_eql(expected.to_json).at_path("totalSums") + end + + describe "percentageDone/done_ratio sum" do + let(:work_package) { create(:work_package, project: workspace, **hours) } + let(:work_packages) { [work_package] } + + subject(:percentage_done_sum) { JSON.parse(last_response.body)["totalSums"]["percentageDone"] } + + context "when work sum and remaining work sum are not set" do + let(:hours) { {} } + + it "is not set" do + expect(percentage_done_sum).to be_nil + end + end + + context "when work sum and remaining work sum are set to valid values (W=10h, RW=4h)" do + let(:hours) { { estimated_hours: 10, remaining_hours: 4 } } + + it "is calculated from them (60%)" do + expect(percentage_done_sum).to eq 60 + end + end + + context "when only work sum is set" do + let(:hours) { { estimated_hours: 10 } } + + it "is not set" do + expect(percentage_done_sum).to be_nil + end + end + + context "when only remaining work sum is set" do + let(:hours) { { remaining_hours: 4 } } + + it "is not set" do + expect(percentage_done_sum).to be_nil + end + end + + context "when work sum and remaining work sum are 0h" do + let(:hours) { { estimated_hours: 0, remaining_hours: 0 } } + + it "is not set" do + expect(percentage_done_sum).to be_nil + end + end + + context "when remaining work sum is greater than work sum (bad data, like W=10h RW=15h)" do + let(:hours) { { estimated_hours: 10, remaining_hours: 15 } } + + it "is set to 0% (and not -50%)" do + expect(percentage_done_sum).to eq 0 + end + end + + context "when remaining work sum is 0h and work sum is positive" do + let(:hours) { { estimated_hours: 10, remaining_hours: 0 } } + + it "is set to 100%" do + expect(percentage_done_sum).to eq 100 + end + end + + context "when calculated % complete sum is xx.5% (like 50.5% or 42.5%)" do + let(:hours) { { estimated_hours: 1000, remaining_hours: 495 } } + + it "is rounded up (like 51% or 43%)" do + expect(percentage_done_sum).to eq 51 + end + end + + context "when calculated % complete sum is almost 0% (like 0.4% or 0.01%)" do + let(:hours) { { estimated_hours: 1000, remaining_hours: 999 } } + + it "is rounded to 1% (because 0% would be wrong)" do + expect(percentage_done_sum).to eq 1 + end + end + + context "when % complete sum is almost 100% (like 99.5% or 99.99%)" do + let(:hours) { { estimated_hours: 1000, remaining_hours: 1 } } + + it "is rounded to 99% (because 100% would be wrong)" do + expect(percentage_done_sum).to eq 99 + end + end + end + end + end + + context "for a project path" do + let(:base_path) { api_v3_paths.work_packages_by_project project.id } + let(:workspace) { project } + + include_context "with work package indexing" + end + + context "for a workspace path" do + let(:base_path) { api_v3_paths.work_packages_by_workspace portfolio.id } + let(:workspace) { portfolio } + + include_context "with work package indexing" + end +end diff --git a/spec/requests/api/v3/work_packages/create_form_resource_spec.rb b/spec/requests/api/v3/work_packages/create_form_resource_spec.rb index 23413aded233..e6b55c4b2834 100644 --- a/spec/requests/api/v3/work_packages/create_form_resource_spec.rb +++ b/spec/requests/api/v3/work_packages/create_form_resource_spec.rb @@ -30,7 +30,7 @@ require "spec_helper" require "rack/test" -RSpec.describe API::V3::WorkPackages::CreateProjectFormAPI do +RSpec.describe "POST api/v3/workspaces/:id/work_packages/form" do include Rack::Test::Methods include API::V3::Utilities::PathHelper diff --git a/spec/requests/api/v3/work_packages/create_project_form_resource_spec.rb b/spec/requests/api/v3/work_packages/create_workspace_form_resource_spec.rb similarity index 64% rename from spec/requests/api/v3/work_packages/create_project_form_resource_spec.rb rename to spec/requests/api/v3/work_packages/create_workspace_form_resource_spec.rb index 416820757bf6..850554713750 100644 --- a/spec/requests/api/v3/work_packages/create_project_form_resource_spec.rb +++ b/spec/requests/api/v3/work_packages/create_workspace_form_resource_spec.rb @@ -30,26 +30,39 @@ require "spec_helper" require "rack/test" -RSpec.describe API::V3::WorkPackages::CreateProjectFormAPI, content_type: :json do +RSpec.describe "POST api/v3/workspace/:id/work_packages/form", content_type: :json do include Rack::Test::Methods include API::V3::Utilities::PathHelper - let(:project) { create(:project, id: 5) } - let(:post_path) { api_v3_paths.create_project_work_package_form(project.id) } - let(:user) { create(:admin) } + current_user { create(:admin) } before do - login_as(user) post post_path end subject(:response) { last_response } - it "returns 200(OK)" do - expect(response).to have_http_status(:ok) + shared_examples "with work packages form" do + it "returns 200(OK)" do + expect(response).to have_http_status(:ok) + end + + it "is of type form" do + expect(response.body).to be_json_eql("Form".to_json).at_path("_type") + end end - it "is of type form" do - expect(response.body).to be_json_eql("Form".to_json).at_path("_type") + context "for a project path" do + let(:post_path) { api_v3_paths.create_workspace_work_package_form(project.id) } + let(:project) { create(:project) } + + include_context "with work packages form" + end + + context "for a workspace path" do + let(:post_path) { api_v3_paths.create_workspace_work_package_form(portfolio.id) } + let(:portfolio) { create(:portfolio) } + + include_context "with work packages form" 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 389927636f11..a8c15e5dac4b 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 @@ -220,12 +220,12 @@ def initialize(group, end end - context "if the project is set" do + context "if the project/workspace is set" do let(:query) { build_stubbed(:query, project:) } - it "is the global work_package link" do + it "is the workspace work_package link" do expect(subject.self_link) - .to eq(api_v3_paths.work_packages_by_project(project.id)) + .to eq(api_v3_paths.work_packages_by_workspace(project.id)) end end end From aad2cc7b7fc52ead22e8158657eddf377a0f596b Mon Sep 17 00:00:00 2001 From: ulferts Date: Fri, 19 Sep 2025 15:13:33 +0200 Subject: [PATCH 11/21] move categories_by_project to workspace --- ..._api.rb => categories_by_workspace_api.rb} | 10 ++- lib/api/v3/projects/project_representer.rb | 2 +- lib/api/v3/projects/projects_api.rb | 3 +- .../category_filter_dependency_representer.rb | 2 +- lib/api/v3/utilities/path_helper.rb | 4 ++ lib/api/v3/workspaces/nested_apis.rb | 1 + .../project_representer_rendering_spec.rb | 2 +- ...gory_filter_dependency_representer_spec.rb | 2 +- spec/lib/api/v3/utilities/path_helper_spec.rb | 6 ++ .../requests/api/v3/category_resource_spec.rb | 62 +++++++++++-------- 10 files changed, 56 insertions(+), 38 deletions(-) rename lib/api/v3/categories/{categories_by_project_api.rb => categories_by_workspace_api.rb} (85%) diff --git a/lib/api/v3/categories/categories_by_project_api.rb b/lib/api/v3/categories/categories_by_workspace_api.rb similarity index 85% rename from lib/api/v3/categories/categories_by_project_api.rb rename to lib/api/v3/categories/categories_by_workspace_api.rb index 03e2d113d67c..c2736095208d 100644 --- a/lib/api/v3/categories/categories_by_project_api.rb +++ b/lib/api/v3/categories/categories_by_workspace_api.rb @@ -26,22 +26,20 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require "api/v3/categories/category_collection_representer" - module API module V3 module Categories - class CategoriesByProjectAPI < ::API::OpenProjectAPI + class CategoriesByWorkspaceAPI < ::API::OpenProjectAPI resources :categories do after_validation do @categories = @project.categories end get do - self_link = api_v3_paths.categories_by_project(@project.identifier) - CategoryCollectionRepresenter - .new(@categories, self_link:, current_user:) + .new(@categories, + self_link: api_v3_paths.categories_by_workspace(@project.identifier), + current_user:) end end end diff --git a/lib/api/v3/projects/project_representer.rb b/lib/api/v3/projects/project_representer.rb index 38c95f93d2dd..76b41f7df6f8 100644 --- a/lib/api/v3/projects/project_representer.rb +++ b/lib/api/v3/projects/project_representer.rb @@ -85,7 +85,7 @@ def self.current_user_view_allowed_lambda end link :categories do - { href: api_v3_paths.categories_by_project(represented.id) } + { href: api_v3_paths.categories_by_workspace(represented.id) } end link :versions, diff --git a/lib/api/v3/projects/projects_api.rb b/lib/api/v3/projects/projects_api.rb index 3165e26ebe9a..ce71a9112d11 100644 --- a/lib/api/v3/projects/projects_api.rb +++ b/lib/api/v3/projects/projects_api.rb @@ -72,11 +72,10 @@ class ProjectsAPI < ::API::OpenProjectAPI .mount mount ::API::V3::Projects::UpdateFormAPI + mount API::V3::Projects::Copy::CopyAPI mount ::API::V3::Workspaces::NestedApis - mount API::V3::Projects::Copy::CopyAPI - mount API::V3::Categories::CategoriesByProjectAPI mount API::V3::Versions::VersionsByProjectAPI mount API::V3::Queries::QueriesByProjectAPI mount API::V3::Favorites::FavoriteActionsAPI, with: { favorite_object_getter: ->(*) { @project } } diff --git a/lib/api/v3/queries/schemas/category_filter_dependency_representer.rb b/lib/api/v3/queries/schemas/category_filter_dependency_representer.rb index 5aba9ea1719a..8003dbca7755 100644 --- a/lib/api/v3/queries/schemas/category_filter_dependency_representer.rb +++ b/lib/api/v3/queries/schemas/category_filter_dependency_representer.rb @@ -38,7 +38,7 @@ def json_cache_key def href_callback # This filter is only available inside projects - api_v3_paths.categories_by_project(filter.project.identifier) + api_v3_paths.categories_by_workspace(filter.project.identifier) end def type diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index c891a03adfca..b819d5902a47 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -214,6 +214,10 @@ def self.categories_by_project(id) "#{project(id)}/categories" end + def self.categories_by_workspace(id) + "#{workspace(id)}/categories" + end + def self.configuration "#{root}/configuration" end diff --git a/lib/api/v3/workspaces/nested_apis.rb b/lib/api/v3/workspaces/nested_apis.rb index 6755fd8e1c26..902770e0c98e 100644 --- a/lib/api/v3/workspaces/nested_apis.rb +++ b/lib/api/v3/workspaces/nested_apis.rb @@ -35,6 +35,7 @@ class NestedApis < ::API::OpenProjectAPI mount API::V3::Workspaces::AvailableAssigneesAPI mount API::V3::Types::TypesByWorkspaceAPI mount API::V3::WorkPackages::WorkPackagesByWorkspaceAPI + mount API::V3::Categories::CategoriesByWorkspaceAPI end end end diff --git a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb index f13b0e171b2d..411871d2499e 100644 --- a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb +++ b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb @@ -449,7 +449,7 @@ describe "categories" do it "has the correct link to its categories" do - expect(subject).to be_json_eql(api_v3_paths.categories_by_project(project.id).to_json) + expect(subject).to be_json_eql(api_v3_paths.categories_by_workspace(project.id).to_json) .at_path("_links/categories/href") end end diff --git a/spec/lib/api/v3/queries/schemas/category_filter_dependency_representer_spec.rb b/spec/lib/api/v3/queries/schemas/category_filter_dependency_representer_spec.rb index a00d7b3a9df0..150f0dab702a 100644 --- a/spec/lib/api/v3/queries/schemas/category_filter_dependency_representer_spec.rb +++ b/spec/lib/api/v3/queries/schemas/category_filter_dependency_representer_spec.rb @@ -51,7 +51,7 @@ describe "value" do let(:path) { "values" } let(:type) { "[]Category" } - let(:href) { api_v3_paths.categories_by_project(project.identifier) } + let(:href) { api_v3_paths.categories_by_workspace(project.identifier) } context "for operator 'Queries::Operators::Equals'" do let(:operator) { Queries::Operators::Equals } diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index 7683441c4da9..f94575faa59f 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -162,6 +162,12 @@ it_behaves_like "api v3 path", "/projects/42/categories" end + + describe "#categories_by_workspace" do + subject { helper.categories_by_workspace 42 } + + it_behaves_like "api v3 path", "/workspaces/42/categories" + end end context "capabilities paths" do diff --git a/spec/requests/api/v3/category_resource_spec.rb b/spec/requests/api/v3/category_resource_spec.rb index ad4da81a795c..522f021bd875 100644 --- a/spec/requests/api/v3/category_resource_spec.rb +++ b/spec/requests/api/v3/category_resource_spec.rb @@ -35,45 +35,41 @@ include Rack::Test::Methods include API::V3::Utilities::PathHelper - let(:role) { create(:project_role, permissions: []) } - let(:private_project) { create(:project, public: false) } - let(:public_project) { create(:project, public: true) } - let(:anonymous_user) { create(:user) } - let(:privileged_user) do + shared_let(:non_member_user) { create(:user) } + shared_let(:role) { create(:project_role, permissions: []) } + shared_let(:private_project) { create(:project, public: false) } + shared_let(:public_project) { create(:project, public: true) } + shared_let(:privileged_user) do create(:user, member_with_roles: { private_project => role }) end - let!(:categories) { create_list(:category, 3, project: private_project) } - let!(:other_categories) { create_list(:category, 2, project: public_project) } - let!(:user_categories) do + shared_let(:categories) { create_list(:category, 3, project: private_project) } + shared_let(:other_categories) { create_list(:category, 2, project: public_project) } + shared_let(:user_categories) do create_list(:category, 2, project: private_project, assigned_to: privileged_user) end - describe "categories by project" do + shared_context "with categories by" do subject(:response) { last_response } - context "logged in user" do - let(:get_path) { api_v3_paths.categories_by_project private_project.id } + context "for a logged in user" do + current_user { privileged_user } before do - allow(User).to receive(:current).and_return privileged_user - get get_path end it_behaves_like "API V3 collection response", 5, 5, "Category" end - context "not logged in user" do - let(:get_path) { api_v3_paths.categories_by_project private_project.id } + context "for a user without permissions" do + current_user { non_member_user } before do - allow(User).to receive(:current).and_return anonymous_user - get get_path end @@ -81,36 +77,50 @@ end end - describe "categories/:id" do + describe "GET projects/:id/categories" do + include_context "with categories by" do + let(:get_path) { api_v3_paths.categories_by_project private_project.id } + end + end + + describe "GET workspace/:id/categories" do + include_context "with categories by" do + let(:get_path) { api_v3_paths.categories_by_workspace private_project.id } + end + end + + describe "GET categories/:id" do subject(:response) { last_response } - context "logged in user" do + context "for a logged in user" do let(:get_path) { api_v3_paths.category categories.first.id } - before do - allow(User).to receive(:current).and_return privileged_user + current_user { privileged_user } + before do get get_path end - context "valid priority id" do + context "for a valid priority id" do it "returns HTTP 200" do - expect(response.status).to be(200) + expect(response).to have_http_status(200) end end - context "invalid priority id" do + context "with an invalid priority id" do let(:get_path) { api_v3_paths.category "bogus" } it_behaves_like "not found" end end - context "not logged in user" do + context "for a user without permissions" do let(:get_path) { api_v3_paths.category "bogus" } + current_user { non_member_user } + before do - allow(User).to receive(:current).and_return anonymous_user + allow(User).to receive(:current).and_return non_member_user get get_path end From 775aabc24afa9f40c374e3e1c69b71752cbbda1c Mon Sep 17 00:00:00 2001 From: ulferts Date: Fri, 19 Sep 2025 15:47:47 +0200 Subject: [PATCH 12/21] move versions_by_project to workspace --- lib/api/v3/projects/project_representer.rb | 2 +- lib/api/v3/projects/projects_api.rb | 1 - .../version_filter_dependency_representer.rb | 2 +- lib/api/v3/utilities/path_helper.rb | 4 ++ .../v3/versions/versions_by_project_api.rb | 2 +- lib/api/v3/workspaces/nested_apis.rb | 1 + .../project_representer_rendering_spec.rb | 4 +- ...sion_filter_dependency_representer_spec.rb | 2 +- spec/lib/api/v3/utilities/path_helper_spec.rb | 6 +++ spec/requests/api/v3/version_resource_spec.rb | 10 ++-- .../version_resource_spec.rb | 53 +++++++++---------- 11 files changed, 47 insertions(+), 40 deletions(-) rename spec/requests/api/v3/{projects => workspaces}/version_resource_spec.rb (63%) diff --git a/lib/api/v3/projects/project_representer.rb b/lib/api/v3/projects/project_representer.rb index 76b41f7df6f8..50dcbd3fd899 100644 --- a/lib/api/v3/projects/project_representer.rb +++ b/lib/api/v3/projects/project_representer.rb @@ -93,7 +93,7 @@ def self.current_user_view_allowed_lambda current_user.allowed_in_project?(:view_work_packages, represented) || current_user.allowed_in_project?(:manage_versions, represented) } do - { href: api_v3_paths.versions_by_project(represented.id) } + { href: api_v3_paths.versions_by_workspace(represented.id) } end link :memberships, diff --git a/lib/api/v3/projects/projects_api.rb b/lib/api/v3/projects/projects_api.rb index ce71a9112d11..03eb07e77159 100644 --- a/lib/api/v3/projects/projects_api.rb +++ b/lib/api/v3/projects/projects_api.rb @@ -76,7 +76,6 @@ class ProjectsAPI < ::API::OpenProjectAPI mount ::API::V3::Workspaces::NestedApis - mount API::V3::Versions::VersionsByProjectAPI mount API::V3::Queries::QueriesByProjectAPI mount API::V3::Favorites::FavoriteActionsAPI, with: { favorite_object_getter: ->(*) { @project } } end diff --git a/lib/api/v3/queries/schemas/version_filter_dependency_representer.rb b/lib/api/v3/queries/schemas/version_filter_dependency_representer.rb index 2f0e25a5279c..cb214103cbb0 100644 --- a/lib/api/v3/queries/schemas/version_filter_dependency_representer.rb +++ b/lib/api/v3/queries/schemas/version_filter_dependency_representer.rb @@ -42,7 +42,7 @@ def href_callback if filter.project.nil? "#{api_v3_paths.versions}?#{query_params}" else - "#{api_v3_paths.versions_by_project(filter.project.id)}?#{query_params}" + "#{api_v3_paths.versions_by_workspace(filter.project.id)}?#{query_params}" end end diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index b819d5902a47..8d1b33e6599b 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -596,6 +596,10 @@ def self.versions_by_project(project_id) "#{project(project_id)}/versions" end + def self.versions_by_workspace(workspace_id) + "#{workspace(workspace_id)}/versions" + end + def self.projects_by_version(version_id) "#{version(version_id)}/projects" end diff --git a/lib/api/v3/versions/versions_by_project_api.rb b/lib/api/v3/versions/versions_by_project_api.rb index 6673216d57e3..575cb2ef4616 100644 --- a/lib/api/v3/versions/versions_by_project_api.rb +++ b/lib/api/v3/versions/versions_by_project_api.rb @@ -43,7 +43,7 @@ class VersionsByProjectAPI < ::API::OpenProjectAPI ::API::V3::Utilities::ParamsToQuery.collection_response(@versions, current_user, params.except("id"), - self_link: api_v3_paths.versions_by_project(@project.id)) + self_link: api_v3_paths.versions_by_workspace(@project.id)) end end end diff --git a/lib/api/v3/workspaces/nested_apis.rb b/lib/api/v3/workspaces/nested_apis.rb index 902770e0c98e..539abba26228 100644 --- a/lib/api/v3/workspaces/nested_apis.rb +++ b/lib/api/v3/workspaces/nested_apis.rb @@ -36,6 +36,7 @@ class NestedApis < ::API::OpenProjectAPI mount API::V3::Types::TypesByWorkspaceAPI mount API::V3::WorkPackages::WorkPackagesByWorkspaceAPI mount API::V3::Categories::CategoriesByWorkspaceAPI + mount API::V3::Versions::VersionsByProjectAPI end end end diff --git a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb index 411871d2499e..b999aaefbcb8 100644 --- a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb +++ b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb @@ -460,7 +460,7 @@ it_behaves_like "has an untitled link" do let(:link) { "versions" } - let(:href) { api_v3_paths.versions_by_project(project.id) } + let(:href) { api_v3_paths.versions_by_workspace(project.id) } end end @@ -469,7 +469,7 @@ it_behaves_like "has an untitled link" do let(:link) { "versions" } - let(:href) { api_v3_paths.versions_by_project(project.id) } + let(:href) { api_v3_paths.versions_by_workspace(project.id) } end end diff --git a/spec/lib/api/v3/queries/schemas/version_filter_dependency_representer_spec.rb b/spec/lib/api/v3/queries/schemas/version_filter_dependency_representer_spec.rb index 73f505a977f2..02be8f933438 100644 --- a/spec/lib/api/v3/queries/schemas/version_filter_dependency_representer_spec.rb +++ b/spec/lib/api/v3/queries/schemas/version_filter_dependency_representer_spec.rb @@ -67,7 +67,7 @@ context "within project" do let(:href) do - "#{api_v3_paths.versions_by_project(project.id)}?#{order}" + "#{api_v3_paths.versions_by_workspace(project.id)}?#{order}" end context "for operator 'Queries::Operators::Equals'" do diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index f94575faa59f..67039e1bfcd8 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -539,6 +539,12 @@ it_behaves_like "api v3 path", "/projects/42/versions" end + describe "#versions_by_workspace" do + subject { helper.versions_by_workspace 42 } + + it_behaves_like "api v3 path", "/workspaces/42/versions" + end + describe "#projects_by_version" do subject { helper.projects_by_version 42 } diff --git a/spec/requests/api/v3/version_resource_spec.rb b/spec/requests/api/v3/version_resource_spec.rb index 2e1fe98a3f52..b50ad853dff9 100644 --- a/spec/requests/api/v3/version_resource_spec.rb +++ b/spec/requests/api/v3/version_resource_spec.rb @@ -75,7 +75,7 @@ end end - context "logged in user with permissions" do + context "for a logged in user with permissions" do before do version_in_project.save! login_as current_user @@ -88,7 +88,7 @@ end end - context "logged in user with permission on project a version is shared with" do + context "for a logged in user with permission on project a version is shared with" do let(:get_path) { api_v3_paths.version version_in_other_project.id } before do @@ -103,7 +103,7 @@ end end - context "logged in user without permission" do + context "for a logged in user without permission" do let(:permissions) { [] } before do @@ -236,13 +236,13 @@ it_behaves_like "read-only violation", "project", Version end - context "if lacking the manage permissions" do + context "if lacking the manage permissions but having view permission" do let(:permissions) { [:view_work_packages] } it_behaves_like "unauthorized access" end - context "if lacking the manage permissions" do + context "if lacking manage and view permissions" do let(:permissions) { [] } it_behaves_like "not found" diff --git a/spec/requests/api/v3/projects/version_resource_spec.rb b/spec/requests/api/v3/workspaces/version_resource_spec.rb similarity index 63% rename from spec/requests/api/v3/projects/version_resource_spec.rb rename to spec/requests/api/v3/workspaces/version_resource_spec.rb index 84a7b141f44b..4ef0868fbf5c 100644 --- a/spec/requests/api/v3/projects/version_resource_spec.rb +++ b/spec/requests/api/v3/workspaces/version_resource_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -#-- copyright +# -- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH # @@ -26,57 +26,54 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. -#++ +# ++ require "spec_helper" require "rack/test" -RSpec.describe "API v3 project's versions resource" do +RSpec.describe "GET workspaces/:id/versions" do include Rack::Test::Methods include API::V3::Utilities::PathHelper - let(:current_user) do - user = create(:user, - member_with_roles: { project => role }) - - allow(User).to receive(:current).and_return user - - user - end - let(:role) { create(:project_role, permissions: [:view_work_packages]) } - let(:project) { create(:project, public: false) } - let(:other_project) { create(:project, public: false) } - let(:versions) { create_list(:version, 4, project:) } - let(:other_versions) { create_list(:version, 2) } + shared_let(:project) { create(:project, public: false) } + shared_let(:permitted_user) { create(:user, member_with_permissions: { project => [:view_work_packages] }) } + shared_let(:unpermitted_user) { create(:user, member_with_permissions: { project => [] }) } + shared_let(:versions) { create_list(:version, 4, project:) } + shared_let(:other_versions) { create_list(:version, 2) } subject(:response) { last_response } - describe "#get (index)" do - let(:get_path) { api_v3_paths.versions_by_project project.id } + shared_context "with versions by workspace" do + context "for a user with permissions to see the versions" do + current_user { permitted_user } - context "logged in user" do before do - current_user - - versions - other_versions - get get_path end it_behaves_like "API V3 collection response", 4, 4, "Version" end - context "logged in user without permission" do - let(:role) { create(:project_role, permissions: []) } + context "for a user without permissions to see the versions" do + current_user { unpermitted_user } before do - current_user - get get_path end it_behaves_like "unauthorized access" end end + + context "for workspaces/:id/versions" do + let(:get_path) { api_v3_paths.versions_by_workspace project.id } + + include_context "with versions by workspace" + end + + context "for projects/:id/versions" do + let(:get_path) { api_v3_paths.versions_by_project project.id } + + include_context "with versions by workspace" + end end From 6ed9af2380feae23b401e48ba56a1c1e605d000f Mon Sep 17 00:00:00 2001 From: ulferts Date: Fri, 19 Sep 2025 16:13:11 +0200 Subject: [PATCH 13/21] move query_project_default to workspace --- lib/api/v3/projects/projects_api.rb | 1 - ...ect_api.rb => queries_by_workspace_api.rb} | 6 +- lib/api/v3/queries/query_representer.rb | 4 +- .../schemas/query_schema_representer.rb | 2 +- ...y_workspace_filter_instance_schema_api.rb} | 17 +-- ...a_api.rb => query_workspace_schema_api.rb} | 4 +- lib/api/v3/utilities/path_helper.rb | 12 ++ lib/api/v3/workspaces/nested_apis.rb | 1 + .../query_representer_rendering_spec.rb | 2 +- .../schemas/query_schema_representer_spec.rb | 6 +- spec/lib/api/v3/utilities/path_helper_spec.rb | 18 +++ ... => queries_by_workspace_resource_spec.rb} | 28 +++-- ...ry_filter_instance_schema_resource_spec.rb | 105 +++++++++++------- .../query_project_schema_resource_spec.rb | 26 +++-- .../shared_get_individual_query_examples.rb | 7 +- 15 files changed, 157 insertions(+), 82 deletions(-) rename lib/api/v3/queries/{queries_by_project_api.rb => queries_by_workspace_api.rb} (90%) rename lib/api/v3/queries/schemas/{query_project_filter_instance_schema_api.rb => query_workspace_filter_instance_schema_api.rb} (73%) rename lib/api/v3/queries/schemas/{query_project_schema_api.rb => query_workspace_schema_api.rb} (91%) rename spec/requests/api/v3/queries/{queries_by_project_resource_spec.rb => queries_by_workspace_resource_spec.rb} (70%) diff --git a/lib/api/v3/projects/projects_api.rb b/lib/api/v3/projects/projects_api.rb index 03eb07e77159..ea2102facdcf 100644 --- a/lib/api/v3/projects/projects_api.rb +++ b/lib/api/v3/projects/projects_api.rb @@ -76,7 +76,6 @@ class ProjectsAPI < ::API::OpenProjectAPI mount ::API::V3::Workspaces::NestedApis - mount API::V3::Queries::QueriesByProjectAPI mount API::V3::Favorites::FavoriteActionsAPI, with: { favorite_object_getter: ->(*) { @project } } end end diff --git a/lib/api/v3/queries/queries_by_project_api.rb b/lib/api/v3/queries/queries_by_workspace_api.rb similarity index 90% rename from lib/api/v3/queries/queries_by_project_api.rb rename to lib/api/v3/queries/queries_by_workspace_api.rb index f5cc2d6614ed..93fadd2e81d7 100644 --- a/lib/api/v3/queries/queries_by_project_api.rb +++ b/lib/api/v3/queries/queries_by_workspace_api.rb @@ -29,7 +29,7 @@ module API module V3 module Queries - class QueriesByProjectAPI < ::API::OpenProjectAPI + class QueriesByWorkspaceAPI < ::API::OpenProjectAPI namespace :queries do helpers ::API::V3::Queries::Helpers::QueryRepresenterResponse @@ -37,8 +37,8 @@ class QueriesByProjectAPI < ::API::OpenProjectAPI authorize_in_any_work_package(:view_work_packages, in_project: @project) end - mount API::V3::Queries::Schemas::QueryProjectFilterInstanceSchemaAPI - mount API::V3::Queries::Schemas::QueryProjectSchemaAPI + mount API::V3::Queries::Schemas::QueryWorkspaceFilterInstanceSchemaAPI + mount API::V3::Queries::Schemas::QueryWorkspaceSchemaAPI namespace :default do params do diff --git a/lib/api/v3/queries/query_representer.rb b/lib/api/v3/queries/query_representer.rb index ca65e9b45e87..69b22df63c10 100644 --- a/lib/api/v3/queries/query_representer.rb +++ b/lib/api/v3/queries/query_representer.rb @@ -95,7 +95,7 @@ class QueryRepresenter < ::API::Decorators::Single link :schema do href = if represented.project - api_v3_paths.query_project_schema(represented.project.identifier) + api_v3_paths.query_workspace_schema(represented.project.identifier) else api_v3_paths.query_schema end @@ -431,7 +431,7 @@ def map_with_sort_by_as_decorated(sort_criteria) def default_query_path if represented.project - api_v3_paths.query_project_default(represented.project.id) + api_v3_paths.query_workspace_default(represented.project.id) else api_v3_paths.query_default end diff --git a/lib/api/v3/queries/schemas/query_schema_representer.rb b/lib/api/v3/queries/schemas/query_schema_representer.rb index 5cbcd24f5133..220ce702b281 100644 --- a/lib/api/v3/queries/schemas/query_schema_representer.rb +++ b/lib/api/v3/queries/schemas/query_schema_representer.rb @@ -277,7 +277,7 @@ def filters_schemas def filter_instance_schemas_href if represented.project - api_v3_paths.query_project_filter_instance_schemas(represented.project.id) + api_v3_paths.query_workspace_filter_instance_schemas(represented.project.id) else api_v3_paths.query_filter_instance_schemas end diff --git a/lib/api/v3/queries/schemas/query_project_filter_instance_schema_api.rb b/lib/api/v3/queries/schemas/query_workspace_filter_instance_schema_api.rb similarity index 73% rename from lib/api/v3/queries/schemas/query_project_filter_instance_schema_api.rb rename to lib/api/v3/queries/schemas/query_workspace_filter_instance_schema_api.rb index e2d25fc480c3..44f52510aa0d 100644 --- a/lib/api/v3/queries/schemas/query_project_filter_instance_schema_api.rb +++ b/lib/api/v3/queries/schemas/query_workspace_filter_instance_schema_api.rb @@ -30,20 +30,13 @@ module API module V3 module Queries module Schemas - class QueryProjectFilterInstanceSchemaAPI < ::API::OpenProjectAPI + class QueryWorkspaceFilterInstanceSchemaAPI < ::API::OpenProjectAPI resource :filter_instance_schemas do - helpers do - def representer - ::API::V3::Queries::Schemas::QueryFilterInstanceSchemaCollectionRepresenter - end - end - get do - filters = Query.new(project: @project).available_filters - - representer.new(filters, - self_link: api_v3_paths.query_project_filter_instance_schemas(@project.id), - current_user:) + ::API::V3::Queries::Schemas::QueryFilterInstanceSchemaCollectionRepresenter + .new(Query.new(project: @project).available_filters, + self_link: api_v3_paths.query_workspace_filter_instance_schemas(@project.id), + current_user:) end end end diff --git a/lib/api/v3/queries/schemas/query_project_schema_api.rb b/lib/api/v3/queries/schemas/query_workspace_schema_api.rb similarity index 91% rename from lib/api/v3/queries/schemas/query_project_schema_api.rb rename to lib/api/v3/queries/schemas/query_workspace_schema_api.rb index d4e94054c8f8..e2304742292b 100644 --- a/lib/api/v3/queries/schemas/query_project_schema_api.rb +++ b/lib/api/v3/queries/schemas/query_workspace_schema_api.rb @@ -30,7 +30,7 @@ module API module V3 module Queries module Schemas - class QueryProjectSchemaAPI < ::API::OpenProjectAPI + class QueryWorkspaceSchemaAPI < ::API::OpenProjectAPI resource :schema do helpers do def representer @@ -40,7 +40,7 @@ def representer get do representer.new(Query.new(project: @project), - self_link: api_v3_paths.query_project_schema(@project.id), + self_link: api_v3_paths.query_workspace_schema(@project.id), current_user:, form_embedded: false) end diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 8d1b33e6599b..ce1e714bd2de 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -403,6 +403,10 @@ def self.query_project_default(id) "#{project(id)}/queries/default" end + def self.query_workspace_default(id) + "#{workspace(id)}/queries/default" + end + def self.query_star(id) "#{query(id)}/star" end @@ -451,6 +455,10 @@ def self.query_project_filter_instance_schemas(id) "#{project(id)}/queries/filter_instance_schemas" end + def self.query_workspace_filter_instance_schemas(id) + "#{workspace(id)}/queries/filter_instance_schemas" + end + def self.query_operator(name) "#{queries}/operators/#{name}" end @@ -459,6 +467,10 @@ def self.query_project_schema(id) "#{project(id)}/queries/schema" end + def self.query_workspace_schema(id) + "#{workspace(id)}/queries/schema" + end + def self.query_available_projects "#{queries}/available_projects" end diff --git a/lib/api/v3/workspaces/nested_apis.rb b/lib/api/v3/workspaces/nested_apis.rb index 539abba26228..066276918332 100644 --- a/lib/api/v3/workspaces/nested_apis.rb +++ b/lib/api/v3/workspaces/nested_apis.rb @@ -37,6 +37,7 @@ class NestedApis < ::API::OpenProjectAPI mount API::V3::WorkPackages::WorkPackagesByWorkspaceAPI mount API::V3::Categories::CategoriesByWorkspaceAPI mount API::V3::Versions::VersionsByProjectAPI + mount API::V3::Queries::QueriesByWorkspaceAPI end end end diff --git a/spec/lib/api/v3/queries/query_representer_rendering_spec.rb b/spec/lib/api/v3/queries/query_representer_rendering_spec.rb index 88c6466635f3..85a43a4c2008 100644 --- a/spec/lib/api/v3/queries/query_representer_rendering_spec.rb +++ b/spec/lib/api/v3/queries/query_representer_rendering_spec.rb @@ -133,7 +133,7 @@ def non_empty_to_query(hash) it_behaves_like "has an untitled link" do let(:link) { "schema" } - let(:href) { api_v3_paths.query_project_schema(project.identifier) } + let(:href) { api_v3_paths.query_workspace_schema(project.identifier) } end context "when the query has no project" do diff --git a/spec/lib/api/v3/queries/schemas/query_schema_representer_spec.rb b/spec/lib/api/v3/queries/schemas/query_schema_representer_spec.rb index c67d2c4ecfbc..74d4c2571cb5 100644 --- a/spec/lib/api/v3/queries/schemas/query_schema_representer_spec.rb +++ b/spec/lib/api/v3/queries/schemas/query_schema_representer_spec.rb @@ -444,9 +444,9 @@ end end - context "when project query" do + context "when workspace query" do let(:project) { build_stubbed(:project) } - let(:href) { api_v3_paths.query_project_filter_instance_schemas(project.id) } + let(:href) { api_v3_paths.query_workspace_filter_instance_schemas(project.id) } it "contains the link to the filter schemas" do expect(subject) @@ -559,7 +559,7 @@ context "when project query" do let(:project) { build_stubbed(:project) } - let(:href) { api_v3_paths.query_project_filter_instance_schemas(project.id) } + let(:href) { api_v3_paths.query_workspace_filter_instance_schemas(project.id) } it "contains a collection of filter schemas" do expect(subject) diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index 67039e1bfcd8..3009a464ffd1 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -342,6 +342,12 @@ it_behaves_like "api v3 path", "/projects/42/queries/default" end + describe "#query_workspace_default" do + subject { helper.query_workspace_default(42) } + + it_behaves_like "api v3 path", "/workspaces/42/queries/default" + end + describe "#query_star" do subject { helper.query_star 1 } @@ -402,6 +408,12 @@ it_behaves_like "api v3 path", "/projects/42/queries/filter_instance_schemas" end + describe "#query_workspace_filter_instance_schemas" do + subject { helper.query_workspace_filter_instance_schemas(42) } + + it_behaves_like "api v3 path", "/workspaces/42/queries/filter_instance_schemas" + end + describe "#query_operator" do subject { helper.query_operator "=" } @@ -414,6 +426,12 @@ it_behaves_like "api v3 path", "/projects/42/queries/schema" end + describe "#query_workspace_schema" do + subject { helper.query_workspace_schema("42") } + + it_behaves_like "api v3 path", "/workspaces/42/queries/schema" + end + describe "#query_available_projects" do subject { helper.query_available_projects } diff --git a/spec/requests/api/v3/queries/queries_by_project_resource_spec.rb b/spec/requests/api/v3/queries/queries_by_workspace_resource_spec.rb similarity index 70% rename from spec/requests/api/v3/queries/queries_by_project_resource_spec.rb rename to spec/requests/api/v3/queries/queries_by_workspace_resource_spec.rb index e8f6c330bddf..c972088cb8b2 100644 --- a/spec/requests/api/v3/queries/queries_by_project_resource_spec.rb +++ b/spec/requests/api/v3/queries/queries_by_workspace_resource_spec.rb @@ -31,26 +31,38 @@ require "spec_helper" require "rack/test" -RSpec.describe "API v3 Query resource" do +RSpec.describe "GET workspaces/:id/queries/default" do include Rack::Test::Methods include API::V3::Utilities::PathHelper - let(:project) { create(:project, identifier: "test_project", public: false) } - let(:current_user) do - create(:user, member_with_roles: { project => role }) - end + shared_let(:project) { create(:project) } + let(:role) { create(:project_role, permissions:) } let(:permissions) { [:view_work_packages] } + current_user { create(:user, member_with_roles: { project => role }) } + before do allow(User).to receive(:current).and_return current_user end - describe "#get projects/:project_id/queries/default" do - let(:base_path) { api_v3_paths.query_project_default(project.id) } + context "for a project scope" do + it_behaves_like "GET individual query" do + let(:base_path) { api_v3_paths.query_project_default(project.id) } + let(:self_path) { api_v3_paths.query_workspace_default(project.id) } + + context "when lacking permissions" do + let(:permissions) { [] } + + it_behaves_like "unauthorized access" + end + end + end + context "for a workspace scope" do it_behaves_like "GET individual query" do - context "lacking permissions" do + let(:base_path) { api_v3_paths.query_workspace_default(project.id) } + context "when lacking permissions" do let(:permissions) { [] } it_behaves_like "unauthorized access" diff --git a/spec/requests/api/v3/queries/schemas/query_filter_instance_schema_resource_spec.rb b/spec/requests/api/v3/queries/schemas/query_filter_instance_schema_resource_spec.rb index 32f5f273cb5b..e4883f64c686 100644 --- a/spec/requests/api/v3/queries/schemas/query_filter_instance_schema_resource_spec.rb +++ b/spec/requests/api/v3/queries/schemas/query_filter_instance_schema_resource_spec.rb @@ -45,8 +45,6 @@ end let(:role) { create(:project_role, permissions:) } let(:permissions) { [:view_work_packages] } - let(:global_path) { api_v3_paths.query_filter_instance_schemas } - let(:project_path) { api_v3_paths.query_project_filter_instance_schemas(project.id) } current_user do create(:user, member_with_roles: { project => role }) @@ -61,47 +59,19 @@ end describe "#GET /api/v3/queries/filter_instance_schemas" do - %i[global - project].each do |current_path| - context current_path do - let(:path) { send :"#{current_path}_path" } - - it "succeeds" do - expect(subject.status) - .to eq(200) - end - - it "returns a collection of schemas" do - expect(subject.body) - .to be_json_eql("Collection".to_json) - .at_path("_type") - expect(subject.body) - .to be_json_eql(path.to_json) - .at_path("_links/self/href") - - expected_type = "QueryFilterInstanceSchema" - - expect(subject.body) - .to be_json_eql(expected_type.to_json) - .at_path("_embedded/elements/0/_type") - end - - context "when the user is not allowed" do - let(:permissions) { [] } - - it_behaves_like "unauthorized access" - end - end - end - context "when in a global context" do - let(:path) { global_path } + let(:path) { api_v3_paths.query_filter_instance_schemas } before do visible_child get path end + it "succeeds" do + expect(subject.status) + .to eq(200) + end + it "includes only global specific filter" do instance_paths = JSON.parse(subject.body).dig("_embedded", "elements").map { |f| f.dig("_links", "self", "href") } @@ -114,16 +84,40 @@ expect(instance_paths) .not_to include(api_v3_paths.query_filter_instance_schema("subprojectId")) end - end - context "when in a project context" do - let(:path) { project_path } + it "returns a collection of schemas" do + expect(subject.body) + .to be_json_eql("Collection".to_json) + .at_path("_type") + expect(subject.body) + .to be_json_eql(path.to_json) + .at_path("_links/self/href") + + expected_type = "QueryFilterInstanceSchema" + expect(subject.body) + .to be_json_eql(expected_type.to_json) + .at_path("_embedded/elements/0/_type") + end + + context "when the user is not allowed" do + let(:permissions) { [] } + + it_behaves_like "unauthorized access" + end + end + + shared_context "when in a workspace context" do before do visible_child get path end + it "succeeds" do + expect(subject.status) + .to eq(200) + end + it "includes project specific filter" do instance_paths = JSON.parse(subject.body).dig("_embedded", "elements").map { |f| f.dig("_links", "self", "href") } @@ -136,6 +130,39 @@ expect(instance_paths) .to include(api_v3_paths.query_filter_instance_schema("subprojectId")) end + + it "returns a collection of schemas" do + expect(subject.body) + .to be_json_eql("Collection".to_json) + .at_path("_type") + expect(subject.body) + .to be_json_eql(api_v3_paths.query_workspace_filter_instance_schemas(project.id).to_json) + .at_path("_links/self/href") + + expected_type = "QueryFilterInstanceSchema" + + expect(subject.body) + .to be_json_eql(expected_type.to_json) + .at_path("_embedded/elements/0/_type") + end + + context "when the user is not allowed" do + let(:permissions) { [] } + + it_behaves_like "unauthorized access" + end + end + + context "for a project" do + let(:path) { api_v3_paths.query_project_filter_instance_schemas(project.id) } + + include_context "when in a workspace context" + end + + context "for a workspace" do + let(:path) { api_v3_paths.query_workspace_filter_instance_schemas(project.id) } + + include_context "when in a workspace context" end end diff --git a/spec/requests/api/v3/queries/schemas/query_project_schema_resource_spec.rb b/spec/requests/api/v3/queries/schemas/query_project_schema_resource_spec.rb index f828e229b9eb..ddbab29bbc40 100644 --- a/spec/requests/api/v3/queries/schemas/query_project_schema_resource_spec.rb +++ b/spec/requests/api/v3/queries/schemas/query_project_schema_resource_spec.rb @@ -31,7 +31,7 @@ require "spec_helper" require "rack/test" -RSpec.describe "API v3 Query Schema resource" do +RSpec.describe "GET workspaces/:id/queries/schema" do include Rack::Test::Methods include API::V3::Utilities::PathHelper @@ -41,15 +41,11 @@ create(:user, member_with_permissions: { project => permissions }) end - before do - login_as(user) - end + current_user { user } - describe "#get queries/schema" do + shared_context "as workspace schema" do subject { last_response } - let(:path) { api_v3_paths.query_project_schema(project.id) } - before do get path end @@ -61,14 +57,26 @@ it "returns the schema" do expect(subject.body) - .to be_json_eql(path.to_json) + .to be_json_eql(api_v3_paths.query_workspace_schema(project.id).to_json) .at_path("_links/self/href") end - context "user not allowed" do + context "when user not allowed" do let(:permissions) { [] } it_behaves_like "unauthorized access" end end + + context "for the project path" do + let(:path) { api_v3_paths.query_project_schema(project.id) } + + include_context "as workspace schema" + end + + context "for the workspace path" do + let(:path) { api_v3_paths.query_workspace_schema(project.id) } + + include_context "as workspace schema" + end end diff --git a/spec/support/queries/shared_get_individual_query_examples.rb b/spec/support/queries/shared_get_individual_query_examples.rb index dc5c478f41c3..579f368013e4 100644 --- a/spec/support/queries/shared_get_individual_query_examples.rb +++ b/spec/support/queries/shared_get_individual_query_examples.rb @@ -39,6 +39,11 @@ base_path end end + let(:self_path) do + super() + rescue NoMethodError + path + end before do work_package @@ -51,7 +56,7 @@ it "has the right endpoint set for the self reference" do expect(last_response.body) - .to be_json_eql(path.to_json) + .to be_json_eql(self_path.to_json) .at_path("_links/self/href") end From bcd4c799266e3572a382d6c22f6ede00226d4863 Mon Sep 17 00:00:00 2001 From: ulferts Date: Tue, 23 Sep 2025 13:14:48 +0200 Subject: [PATCH 14/21] move favorite project into workspaces --- .../components/schemas/project_model.yml | 24 +++ docs/api/apiv3/paths/projects.yml | 3 +- docs/api/apiv3/tags/projects.yml | 17 +- lib/api/v3/projects/project_representer.rb | 20 +++ lib/api/v3/projects/projects_api.rb | 2 - lib/api/v3/utilities/path_helper.rb | 4 + lib/api/v3/workspaces/nested_apis.rb | 1 + .../project_representer_rendering_spec.rb | 75 +++++++++ spec/lib/api/v3/support/link_examples.rb | 32 ++-- spec/lib/api/v3/utilities/path_helper_spec.rb | 6 + .../api/v3/projects/favorite_resource_spec.rb | 138 ---------------- .../v3/workspaces/favorite_resource_spec.rb | 147 ++++++++++++++++++ 12 files changed, 309 insertions(+), 160 deletions(-) delete mode 100644 spec/requests/api/v3/projects/favorite_resource_spec.rb create mode 100644 spec/requests/api/v3/workspaces/favorite_resource_spec.rb diff --git a/docs/api/apiv3/components/schemas/project_model.yml b/docs/api/apiv3/components/schemas/project_model.yml index 2e766c0c62f2..46791e61e535 100644 --- a/docs/api/apiv3/components/schemas/project_model.yml +++ b/docs/api/apiv3/components/schemas/project_model.yml @@ -17,6 +17,9 @@ properties: active: type: boolean description: Indicates whether the project is currently active or already archived + favorited: + type: boolean + description: Indicates whether the project is favorited by the current user statusExplanation: allOf: - $ref: './formattable.yml' @@ -71,6 +74,27 @@ properties: # Conditions **Permission**: admin + favor: + allOf: + - $ref: './link.yml' + - description: |- + Mark this project as favorited by the current user + + # Conditions + + Only present if the project is not yet favorited + + Permission**: none but login is required + disfavor: + allOf: + - $ref: './link.yml' + - description: |- + Mark this project as not favorited by the current user + + # Conditions + Only present if the project is favorited by the current user + + Permission**: none but login is required createWorkPackage: allOf: - $ref: './link.yml' diff --git a/docs/api/apiv3/paths/projects.yml b/docs/api/apiv3/paths/projects.yml index e021211eb065..225914b96715 100644 --- a/docs/api/apiv3/paths/projects.yml +++ b/docs/api/apiv3/paths/projects.yml @@ -24,12 +24,13 @@ get: + ancestor: filters projects by their ancestor. A project is not considered to be its own ancestor. + available_project_attributes: filters projects based on the activated project project attributes. + created_at: based on the time the project was created + + favorited: based on the favorited property of the project + id: based on projects' id. + latest_activity_at: based on the time the last activity was registered on a project. - + project_phase_any: based on the project phases active in a project. + name_and_identifier: based on both the name and the identifier. + parent_id: filters projects by their parent. + principal: based on members of the project. + + project_phase_any: based on the project phases active in a project. + project_status_code: based on status code of the project + storage_id: filters projects by linked storages + storage_url: filters projects by linked storages identified by the host url diff --git a/docs/api/apiv3/tags/projects.yml b/docs/api/apiv3/tags/projects.yml index 83e852117d09..c398df3f0d88 100644 --- a/docs/api/apiv3/tags/projects.yml +++ b/docs/api/apiv3/tags/projects.yml @@ -6,13 +6,15 @@ description: |- ## Actions - | Link | Description | Condition | - |:--------------------------: |----------------------------------------------------------------------| --------------------------------- | - | update | Form endpoint that aids in updating this project | **Permission**: edit project | - | updateImmediately | Directly update this project | **Permission**: edit project | - | delete | Delete this project | **Permission**: admin | - | createWorkPackage | Form endpoint that aids in preparing and creating a work package | **Permission**: add work packages | - | createWorkPackageImmediately | Directly creates a work package in the project | **Permission**: add work packages | + | Link | Description | Condition | + |:--------------------------: |----------------------------------------------------------------------| --------------------------------- | + | update | Form endpoint that aids in updating this project | **Permission**: edit project | + | updateImmediately | Directly update this project | **Permission**: edit project | + | delete | Delete this project | **Permission**: admin | + | favor | Mark this project as favorited by the current user | **Permission**: none but login is required, only present if the project is not yet favorited | + | disfavor | Mark this project as no longer favorited by the current user | **Permission**: none but login is required, only present if the project is favorited | + | createWorkPackage | Form endpoint that aids in preparing and creating a work package | **Permission**: add work packages | + | createWorkPackageImmediately | Directly creates a work package in the project | **Permission**: add work packages | ## Linked Properties @@ -42,6 +44,7 @@ description: |- | identifier | | String | | READ/WRITE | | name | | String | | READ/WRITE | | active | Indicates whether the project is currently active or already archived | Boolean | | READ/WRITE | + | favorited | Indicates whether the project is favorited by the current user | Boolean | | READ | | statusExplanation | A text detailing and explaining why the project has the reported status | Formattable | | READ/WRITE | | public | Indicates whether the project is accessible for everybody | Boolean | | READ/WRITE | | description | | Formattable | | READ/WRITE | diff --git a/lib/api/v3/projects/project_representer.rb b/lib/api/v3/projects/project_representer.rb index 50dcbd3fd899..1546226853d8 100644 --- a/lib/api/v3/projects/project_representer.rb +++ b/lib/api/v3/projects/project_representer.rb @@ -171,6 +171,22 @@ def self.current_user_view_allowed_lambda { href: api_v3_paths.path_for(:project_storages, filters:) } end + link :favor, + method: :post, + cache_if: -> { + current_user.logged? && !represented.favorited_by?(current_user) + } do + { href: api_v3_paths.favor_workspace(represented.id) } + end + + link :disfavor, + method: :delete, + cache_if: -> { + current_user.logged? && represented.favorited_by?(current_user) + } do + { href: api_v3_paths.favor_workspace(represented.id) } + end + associated_resource :parent, v3_path: :project, representer: ::API::V3::Projects::ProjectRepresenter, @@ -188,6 +204,10 @@ def self.current_user_view_allowed_lambda property :active property :public + property :favorited, + exec_context: :decorator, + getter: ->(*) { represented.favorited_by?(current_user) } + formattable_property :description, cache_if: current_user_view_allowed_lambda diff --git a/lib/api/v3/projects/projects_api.rb b/lib/api/v3/projects/projects_api.rb index ea2102facdcf..aa7c5d37f65c 100644 --- a/lib/api/v3/projects/projects_api.rb +++ b/lib/api/v3/projects/projects_api.rb @@ -75,8 +75,6 @@ class ProjectsAPI < ::API::OpenProjectAPI mount API::V3::Projects::Copy::CopyAPI mount ::API::V3::Workspaces::NestedApis - - mount API::V3::Favorites::FavoriteActionsAPI, with: { favorite_object_getter: ->(*) { @project } } end end end diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index ce1e714bd2de..441e05430634 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -293,6 +293,10 @@ def self.days_non_working_day(date) "#{days_non_working}/#{date}" end + def self.favor_workspace(workspace_id) + "#{workspace(workspace_id)}/favorite" + end + index :help_text show :help_text diff --git a/lib/api/v3/workspaces/nested_apis.rb b/lib/api/v3/workspaces/nested_apis.rb index 066276918332..4f9c27d99c9f 100644 --- a/lib/api/v3/workspaces/nested_apis.rb +++ b/lib/api/v3/workspaces/nested_apis.rb @@ -38,6 +38,7 @@ class NestedApis < ::API::OpenProjectAPI mount API::V3::Categories::CategoriesByWorkspaceAPI mount API::V3::Versions::VersionsByProjectAPI mount API::V3::Queries::QueriesByWorkspaceAPI + mount API::V3::Favorites::FavoriteActionsAPI, with: { favorite_object_getter: ->(*) { @project } } end end end diff --git a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb index b999aaefbcb8..aba574d9afd7 100644 --- a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb +++ b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb @@ -37,6 +37,7 @@ let(:available_custom_fields) { [int_custom_field, version_custom_field] } let(:all_available_custom_fields) { [int_custom_field, version_custom_field] } + let(:favorited) { true } let(:project) do build_stubbed(:project, :with_status, @@ -64,6 +65,10 @@ .to receive(calculated_value_custom_field.attribute_getter) .and_return(calculated_custom_value.value) end + + allow(p) + .to receive(:favorited_by?) + .and_return(favorited) end end @@ -137,6 +142,10 @@ let(:value) { project.public } end + it_behaves_like "property", :favorited do + let(:value) { true } + end + it_behaves_like "formattable property", :description do let(:value) { project.description } end @@ -675,6 +684,72 @@ end end end + + describe "favor" do + context "when the project isn't favored yet" do + let(:favorited) { false } + let(:link) { "favor" } + + it_behaves_like "has an untitled link" do + let(:href) { api_v3_paths.favor_workspace(project.id) } + end + + it_behaves_like "the link indicates the verb" do + let(:verb) { :post } + end + end + + context "when the project is favorited" do + let(:favorited) { true } + + it_behaves_like "has no link" do + let(:link) { "favor" } + end + end + + context "when the project isn't favored yet and the user is not logged in" do + let(:user) { build_stubbed(:anonymous) } + let(:favorited) { false } + + it_behaves_like "has no link" do + let(:link) { "favor" } + end + end + end + + describe "disfavor" do + context "when the project is favorited" do + let(:favorited) { true } + let(:link) { "disfavor" } + + it_behaves_like "has an untitled link" do + let(:href) { api_v3_paths.favor_workspace(project.id) } + end + + it_behaves_like "the link indicates the verb" do + let(:verb) { :delete } + end + end + + context "when the project is not favorited" do + let(:favorited) { false } + + it_behaves_like "has no link" do + let(:link) { "disfavor" } + end + end + + # This should not happen at all since the anonymous user cannot favor a project + # in the first place. + context "when the project is favored yet and the user is not logged in" do + let(:user) { build_stubbed(:anonymous) } + let(:favorited) { true } + + it_behaves_like "has no link" do + let(:link) { "unfavor" } + end + end + end end describe "_embedded" do diff --git a/spec/lib/api/v3/support/link_examples.rb b/spec/lib/api/v3/support/link_examples.rb index aede0b01f5c9..8d467cd4c52a 100644 --- a/spec/lib/api/v3/support/link_examples.rb +++ b/spec/lib/api/v3/support/link_examples.rb @@ -49,30 +49,38 @@ end end - it "indicates the desired method" do - verb = begin - # the standard method #method on an object interferes - # with the let named 'method' conditionally defined + include_examples "the link indicates the verb" + + describe "without permission" do + let(:permissions) { all_permissions - Array(permission) } + + it_behaves_like "has no link" + end +end + +RSpec.shared_examples_for "the link indicates the verb" do + let(:verb) do + super() + rescue NoMethodError + begin + # # the standard method #method on an object interferes + # # with the let named 'method' conditionally defined method rescue ArgumentError :get end + end + it "the link indicates the verb/method or omits it on get" do if verb == :get expect(subject) .not_to have_json_path("_links/#{link}/method") else expect(subject) - .to be_json_eql(method.to_json) - .at_path("_links/#{link}/method") + .to be_json_eql(verb.to_json) + .at_path("_links/#{link}/method") end end - - describe "without permission" do - let(:permissions) { all_permissions - Array(permission) } - - it_behaves_like "has no link" - end end RSpec.shared_examples_for "has an untitled action link" do diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index 3009a464ffd1..903a1fe3e1e9 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -733,6 +733,12 @@ def self.filter describe "workspace paths" do it_behaves_like "index", :workspace + + describe "#favor_workspace" do + subject { helper.favor_workspace 42 } + + it_behaves_like "api v3 path", "/workspaces/42/favorite" + end end describe ".timestamps_to_param_value" do diff --git a/spec/requests/api/v3/projects/favorite_resource_spec.rb b/spec/requests/api/v3/projects/favorite_resource_spec.rb deleted file mode 100644 index 707032e595fa..000000000000 --- a/spec/requests/api/v3/projects/favorite_resource_spec.rb +++ /dev/null @@ -1,138 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -require "spec_helper" -require "rack/test" - -RSpec.describe "API v3 Project favorite resource", content_type: :json do - include Rack::Test::Methods - include API::V3::Utilities::PathHelper - - shared_let(:project) { create(:project) } - shared_let(:user) { create(:user, member_with_permissions: { project => %i[view_project] }) } - let(:favorite_path) { "/api/v3/projects/#{project.id}/favorite" } - - before do - login_as(user) - end - - describe "POST /api/v3/projects/:id/favorite" do - before do - post favorite_path - end - - it "responds with 204 No Content and marks as favorite", :aggregate_failures do - expect(last_response).to have_http_status(204) - expect(project.favorited_by?(user)).to be true - end - - context "when project is already favorited" do - before do - project.set_favorited(user, favorited: true) - post favorite_path - end - - it "responds with 204 No Content and keeps project as is", :aggregate_failures do - expect(last_response).to have_http_status(204) - expect(project.favorited_by?(user)).to be true - end - end - - context "when user lacks permissions" do - let(:other_project) { create(:project, public: false) } - let(:favorite_path) { "/api/v3/projects/#{other_project.id}/favorite" } - - it "responds with 404 Not Found" do - expect(last_response).to have_http_status(404) - end - end - - context "when user is anonymous and login not required", - with_settings: { login_required: false } do - let(:user) { User.anonymous } - - it "responds with 404 Not found" do - expect(last_response).to have_http_status(404) - end - - context "when project is public" do - before do - project.update!(public: true) - end - - it "responds with 403 Forbidden" do - expect(last_response).to have_http_status(404) - end - end - end - - context "when user is anonymous and login required", - with_settings: { login_required: true } do - let(:user) { User.anonymous } - - it "responds with 401 Unauthorized" do - expect(last_response).to have_http_status(401) - end - end - end - - describe "DELETE /api/v3/projects/:id/favorite" do - before do - project.set_favorited(user, favorited: true) - delete favorite_path - end - - it "responds with 204 No Content and removes favorite", :aggregate_failures do - expect(last_response).to have_http_status(204) - expect(project.favorited_by?(user)).to be false - end - - context "when project is not favorited" do - before do - project.set_favorited(user, favorited: false) - delete favorite_path - end - - it "responds with 204 No Content, and keeps the project as is", :aggregate_failures do - expect(last_response).to have_http_status(204) - expect(project.favorited_by?(user)).to be false - end - end - - context "when user lacks permissions" do - let(:other_project) { create(:project, public: false) } - let(:favorite_path) { "/api/v3/projects/#{other_project.id}/favorite" } - - it "responds with 404 Not Found" do - expect(last_response).to have_http_status(404) - end - end - end -end diff --git a/spec/requests/api/v3/workspaces/favorite_resource_spec.rb b/spec/requests/api/v3/workspaces/favorite_resource_spec.rb new file mode 100644 index 000000000000..222cbe1190c7 --- /dev/null +++ b/spec/requests/api/v3/workspaces/favorite_resource_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" +require "rack/test" + +RSpec.describe "API v3 Project favorite resource", content_type: :json do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + shared_let(:project, reload: true) { create(:project) } + shared_let(:user) { create(:user, member_with_permissions: { project => %i[view_project] }) } + + current_user { user } + + shared_examples "favoring a workspace" do + describe "POST" do + before do + post favorite_path + end + + it "responds with 204 No Content and marks as favorite", :aggregate_failures do + expect(last_response).to have_http_status(204) + expect(project.favorited_by?(user)).to be true + end + + context "when project is already favorited" do + before do + project.set_favorited(user, favorited: true) + post favorite_path + end + + it "responds with 204 No Content and keeps project as is", :aggregate_failures do + expect(last_response).to have_http_status(204) + expect(project.favorited_by?(user)).to be true + end + end + + context "when user lacks permissions" do + let(:project) { create(:project, public: false) } + + it "responds with 404 Not Found" do + expect(last_response).to have_http_status(404) + end + end + + context "when user is anonymous and login not required", + with_settings: { login_required: false } do + let(:user) { User.anonymous } + + it "responds with 404 Not found" do + expect(last_response).to have_http_status(404) + end + + context "when project is public" do + before do + project.update!(public: true) + end + + it "responds with 403 Forbidden" do + expect(last_response).to have_http_status(404) + end + end + end + + context "when user is anonymous and login required", + with_settings: { login_required: true } do + let(:user) { User.anonymous } + + it "responds with 401 Unauthorized" do + expect(last_response).to have_http_status(401) + end + end + end + + describe "DELETE" do + before do + project.set_favorited(user, favorited: true) + delete favorite_path + end + + it "responds with 204 No Content and removes favorite", :aggregate_failures do + expect(last_response).to have_http_status(204) + expect(project.favorited_by?(user)).to be false + end + + context "when project is not favorited" do + before do + project.set_favorited(user, favorited: false) + delete favorite_path + end + + it "responds with 204 No Content, and keeps the project as is", :aggregate_failures do + expect(last_response).to have_http_status(204) + expect(project.favorited_by?(user)).to be false + end + end + + context "when user lacks permissions" do + let(:project) { create(:project, public: false) } + + it "responds with 404 Not Found" do + expect(last_response).to have_http_status(404) + end + end + end + end + + context "for api/v3/projects/:id/favorite" do + include_examples "favoring a workspace" do + let(:favorite_path) { "/api/v3/projects/#{project.id}/favorite" } + end + end + + context "for api/v3/workspaces/:id/favorite" do + include_examples "favoring a workspace" do + let(:favorite_path) { api_v3_paths.favor_workspace(project.id) } + end + end +end From 36a94489dfa5146251c7a7fc9b5cfee3299b51ea Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 24 Sep 2025 12:27:11 +0200 Subject: [PATCH 15/21] move project create from to workspace --- lib/api/v3/projects/project_representer.rb | 2 +- lib/api/v3/projects/projects_api.rb | 2 -- lib/api/v3/utilities/path_helper.rb | 3 +-- lib/api/v3/workspaces/nested_apis.rb | 2 ++ .../project_representer_rendering_spec.rb | 2 +- spec/lib/api/v3/utilities/path_helper_spec.rb | 2 +- .../v3/projects/update_form_resource_spec.rb | 27 ++++++++++++++----- .../v3/workspaces/favorite_resource_spec.rb | 4 +-- 8 files changed, 28 insertions(+), 16 deletions(-) diff --git a/lib/api/v3/projects/project_representer.rb b/lib/api/v3/projects/project_representer.rb index 1546226853d8..8438dd8e85fe 100644 --- a/lib/api/v3/projects/project_representer.rb +++ b/lib/api/v3/projects/project_representer.rb @@ -118,7 +118,7 @@ def self.current_user_view_allowed_lambda current_user.allowed_in_project?(:edit_project, represented) } do { - href: api_v3_paths.project_form(represented.id), + href: api_v3_paths.workspace_form(represented.id), method: :post } end diff --git a/lib/api/v3/projects/projects_api.rb b/lib/api/v3/projects/projects_api.rb index aa7c5d37f65c..68ec0622859f 100644 --- a/lib/api/v3/projects/projects_api.rb +++ b/lib/api/v3/projects/projects_api.rb @@ -70,8 +70,6 @@ class ProjectsAPI < ::API::OpenProjectAPI delete &::API::V3::Utilities::Endpoints::Delete.new(model: Project, process_service: ::Projects::ScheduleDeletionService) .mount - - mount ::API::V3::Projects::UpdateFormAPI mount API::V3::Projects::Copy::CopyAPI mount ::API::V3::Workspaces::NestedApis diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 441e05430634..a2f48346e9f1 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -697,8 +697,7 @@ def self.work_packages_by_workspace(workspace_id) "#{workspace(workspace_id)}/work_packages" end - index :workspace - show :workspace + resources :workspace, except: %i[schema create_form] def self.timestamps_to_param_value(timestamps) Array(timestamps).map { |timestamp| Timestamp.parse(timestamp).absolute }.join(",") diff --git a/lib/api/v3/workspaces/nested_apis.rb b/lib/api/v3/workspaces/nested_apis.rb index 4f9c27d99c9f..232107d80000 100644 --- a/lib/api/v3/workspaces/nested_apis.rb +++ b/lib/api/v3/workspaces/nested_apis.rb @@ -32,6 +32,8 @@ module API module V3 module Workspaces class NestedApis < ::API::OpenProjectAPI + mount ::API::V3::Projects::UpdateFormAPI + mount API::V3::Workspaces::AvailableAssigneesAPI mount API::V3::Types::TypesByWorkspaceAPI mount API::V3::WorkPackages::WorkPackagesByWorkspaceAPI diff --git a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb index aba574d9afd7..35981d377ad1 100644 --- a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb +++ b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb @@ -611,7 +611,7 @@ it_behaves_like "has an untitled link" do let(:link) { "update" } - let(:href) { api_v3_paths.project_form project.id } + let(:href) { api_v3_paths.workspace_form project.id } end end diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index 903a1fe3e1e9..466f23d9ed72 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -732,7 +732,7 @@ def self.filter end describe "workspace paths" do - it_behaves_like "index", :workspace + it_behaves_like "resource", :workspace, except: %i[create_form show schema] describe "#favor_workspace" do subject { helper.favor_workspace 42 } diff --git a/spec/requests/api/v3/projects/update_form_resource_spec.rb b/spec/requests/api/v3/projects/update_form_resource_spec.rb index 3b54738481a3..860ee2c5c862 100644 --- a/spec/requests/api/v3/projects/update_form_resource_spec.rb +++ b/spec/requests/api/v3/projects/update_form_resource_spec.rb @@ -61,7 +61,6 @@ end let(:permissions) { %i[edit_project view_project_attributes edit_project_attributes] } let(:parent_project_permissions) { [:add_subprojects] } - let(:path) { api_v3_paths.project_form(project.id) } let(:params) do {} end @@ -74,7 +73,7 @@ subject(:response) { last_response } - describe "#POST /api/v3/projects/:id/form" do + shared_examples_for "form of a workspace" do it "returns 200 OK" do expect(response).to have_http_status(:ok) end @@ -370,17 +369,31 @@ end context "with a non existing id" do - let(:path) { api_v3_paths.project_form(1) } + let(:path_id) { 1 } it_behaves_like "not found" end + end + + describe "POST /api/v3/projects/:id/form" do + include_examples "form of a workspace" do + let(:path_id) { project.id } + let(:path) { api_v3_paths.project_form(path_id) } + + context "with a portfolio id" do + let(:project) do + create(:portfolio, public: true) + end - context "with a portfolio id" do - let(:project) do - create(:portfolio, public: true) + it_behaves_like "not found" end + end + end - it_behaves_like "not found" + describe "POST /api/v3/workspaces/:id/form" do + include_examples "form of a workspace" do + let(:path_id) { project.id } + let(:path) { api_v3_paths.workspace_form(path_id) } end end end diff --git a/spec/requests/api/v3/workspaces/favorite_resource_spec.rb b/spec/requests/api/v3/workspaces/favorite_resource_spec.rb index 222cbe1190c7..0cc629053b36 100644 --- a/spec/requests/api/v3/workspaces/favorite_resource_spec.rb +++ b/spec/requests/api/v3/workspaces/favorite_resource_spec.rb @@ -133,13 +133,13 @@ end end - context "for api/v3/projects/:id/favorite" do + describe "api/v3/projects/:id/favorite" do include_examples "favoring a workspace" do let(:favorite_path) { "/api/v3/projects/#{project.id}/favorite" } end end - context "for api/v3/workspaces/:id/favorite" do + describe "api/v3/workspaces/:id/favorite" do include_examples "favoring a workspace" do let(:favorite_path) { api_v3_paths.favor_workspace(project.id) } end From d7d2046f4b75186fa55849b3543f8ab841779c64 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 24 Sep 2025 12:37:26 +0200 Subject: [PATCH 16/21] move project update to workspace --- lib/api/v3/projects/project_representer.rb | 2 +- lib/api/v3/projects/projects_api.rb | 1 - lib/api/v3/workspaces/nested_apis.rb | 1 + .../project_representer_rendering_spec.rb | 2 +- .../api/v3/projects/update_resource_spec.rb | 613 ----------------- .../update_form_resource_spec.rb | 5 +- .../api/v3/workspaces/update_resource_spec.rb | 634 ++++++++++++++++++ 7 files changed, 640 insertions(+), 618 deletions(-) delete mode 100644 spec/requests/api/v3/projects/update_resource_spec.rb rename spec/requests/api/v3/{projects => workspaces}/update_form_resource_spec.rb (99%) create mode 100644 spec/requests/api/v3/workspaces/update_resource_spec.rb diff --git a/lib/api/v3/projects/project_representer.rb b/lib/api/v3/projects/project_representer.rb index 8438dd8e85fe..4728a3917823 100644 --- a/lib/api/v3/projects/project_representer.rb +++ b/lib/api/v3/projects/project_representer.rb @@ -128,7 +128,7 @@ def self.current_user_view_allowed_lambda current_user.allowed_in_project?(:edit_project, represented) } do { - href: api_v3_paths.project(represented.id), + href: api_v3_paths.workspace(represented.id), method: :patch } end diff --git a/lib/api/v3/projects/projects_api.rb b/lib/api/v3/projects/projects_api.rb index 68ec0622859f..1e50cb457eda 100644 --- a/lib/api/v3/projects/projects_api.rb +++ b/lib/api/v3/projects/projects_api.rb @@ -66,7 +66,6 @@ class ProjectsAPI < ::API::OpenProjectAPI end get &::API::V3::Utilities::Endpoints::Show.new(model: Project).mount - patch &::API::V3::Utilities::Endpoints::Update.new(model: Project).mount delete &::API::V3::Utilities::Endpoints::Delete.new(model: Project, process_service: ::Projects::ScheduleDeletionService) .mount diff --git a/lib/api/v3/workspaces/nested_apis.rb b/lib/api/v3/workspaces/nested_apis.rb index 232107d80000..7bf3f5904d72 100644 --- a/lib/api/v3/workspaces/nested_apis.rb +++ b/lib/api/v3/workspaces/nested_apis.rb @@ -33,6 +33,7 @@ module V3 module Workspaces class NestedApis < ::API::OpenProjectAPI mount ::API::V3::Projects::UpdateFormAPI + patch &::API::V3::Utilities::Endpoints::Update.new(model: Project).mount mount API::V3::Workspaces::AvailableAssigneesAPI mount API::V3::Types::TypesByWorkspaceAPI diff --git a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb index 35981d377ad1..4dc42a39fe00 100644 --- a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb +++ b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb @@ -630,7 +630,7 @@ it_behaves_like "has an untitled link" do let(:link) { "updateImmediately" } - let(:href) { api_v3_paths.project project.id } + let(:href) { api_v3_paths.workspace project.id } end end diff --git a/spec/requests/api/v3/projects/update_resource_spec.rb b/spec/requests/api/v3/projects/update_resource_spec.rb deleted file mode 100644 index 25c8948b9c12..000000000000 --- a/spec/requests/api/v3/projects/update_resource_spec.rb +++ /dev/null @@ -1,613 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -require "spec_helper" -require "rack/test" - -RSpec.describe "API v3 Project resource update", content_type: :json do - include Rack::Test::Methods - include API::V3::Utilities::PathHelper - - let(:admin) { create(:admin) } - let(:project) do - create(:project, - :with_status, - public: false, - active: project_active) - end - let(:project_active) { true } - let(:custom_field) do - create(:text_project_custom_field) - end - let(:admin_only_custom_field) do - create(:text_project_custom_field, admin_only: true) - end - let(:permissions) { %i[edit_project view_project_attributes edit_project_attributes] } - let(:path) { api_v3_paths.project(project.id) } - let(:body) do - { - identifier: "new_project_identifier", - name: "Project name" - } - end - - current_user do - create(:user, member_with_permissions: { project => permissions }) - end - - before do - patch path, body.to_json - end - - it "responds with 200 OK" do - expect(last_response).to have_http_status(:ok) - end - - it "alters the project" do - project.reload - - expect(project.name) - .to eql(body[:name]) - - expect(project.identifier) - .to eql(body[:identifier]) - end - - it "returns the updated project" do - expect(last_response.body) - .to be_json_eql("Project".to_json) - .at_path("_type") - expect(last_response.body) - .to be_json_eql(body[:name].to_json) - .at_path("name") - end - - context "with a visible custom field" do - let(:body) do - { - custom_field.attribute_name(:camel_case) => { - raw: "CF text" - } - } - end - - it "responds with 200 OK" do - expect(last_response).to have_http_status(:ok) - end - - it "sets the cf value" do - expect(project.reload.send(custom_field.attribute_getter)) - .to eql("CF text") - end - - it "automatically activates the cf for project if the value was provided" do - expect(project.project_custom_fields) - .to contain_exactly(custom_field) - end - end - - context "with an admin only custom field" do - let(:body) do - { - admin_only_custom_field.attribute_name(:camel_case) => { - raw: "CF text" - } - } - end - - context "with admin permissions" do - let(:current_user) { create(:admin) } - - it "responds with 200 OK" do - expect(last_response).to have_http_status(:ok) - end - - it "sets the cf value" do - expect(project.reload.send(admin_only_custom_field.attribute_getter)) - .to eql("CF text") - end - - it "automatically activates the cf for project if the value was provided" do - expect(project.reload.project_custom_fields) - .to contain_exactly(admin_only_custom_field) - end - end - - context "with non-admin permissions" do - it "responds with 200 OK" do - # TBD: trying to set a not accessible custom field is silently ignored - expect(last_response).to have_http_status(:ok) - end - - it "does not set the cf value" do - expect(project.reload.custom_values.find_by(custom_field: admin_only_custom_field)) - .to have_attributes(value: nil) - end - - context "when the hidden field has a value already" do - before do - project.update!(custom_field_values: { admin_only_custom_field.id => "1234" }) - - patch path, body.to_json - end - - it "does not change the cf value" do - expect(project.reload.custom_values.find_by(custom_field: admin_only_custom_field)) - .to have_attributes(value: "1234") - end - end - - it "does not activate the cf for project" do - expect(project.reload.project_custom_fields) - .to be_empty - end - end - end - - describe "permissions" do - context "without permission to patch projects" do - let(:permissions) { [] } - - it "responds with 403" do - expect(last_response).to have_http_status(:forbidden) - end - - it "does not change the project" do - attributes_before = project.attributes - - expect(project.reload.name) - .to eql(attributes_before["name"]) - end - - context "and with edit_project_attributes permission" do - let(:permissions) { [:edit_project_attributes] } - let(:body) do - { - custom_field.attribute_name(:camel_case) => { - raw: "CF text" - } - } - end - - it "responds with 403" do - expect(last_response).to have_http_status(:forbidden) - end - - it "does not change the project" do - attributes_before = project.attributes - custom_field_value_before = project.send(custom_field.attribute_getter) - - expect(project.reload.name) - .to eql(attributes_before["name"]) - expect(project.send(custom_field.attribute_getter)) - .to eq custom_field_value_before - end - end - end - - context "with edit_project permission" do - let(:permissions) { [:edit_project] } - - it "responds with 200 OK" do - expect(last_response).to have_http_status(:ok) - end - - it "alters the project" do - project.reload - - expect(project.name) - .to eql(body[:name]) - - expect(project.identifier) - .to eql(body[:identifier]) - end - - context "when custom_field values are updated without edit_project_attributes" do - let(:body) do - { - custom_field.attribute_name(:camel_case) => { - raw: "CF text" - } - } - end - - it "responds with 422" do - expect(last_response).to have_http_status(:unprocessable_entity) - end - - it "does not change the project" do - attributes_before = project.attributes - custom_field_value_before = project.send(custom_field.attribute_getter) - - expect(project.reload.name) - .to eql(attributes_before["name"]) - expect(project.send(custom_field.attribute_getter)) - .to eq custom_field_value_before - end - end - end - end - - context "with a nil status" do - let(:body) do - { - statusExplanation: { - raw: "Some explanation." - }, - _links: { - status: { - href: nil - } - } - } - end - - it "alters the status" do - expect(last_response.body) - .to be_json_eql(nil.to_json) - .at_path("_links/status/href") - - project.reload - expect(project.status_code).to be_nil - expect(project.status_explanation).to eq "Some explanation." - - expect(last_response.body) - .to be_json_eql( - { - format: "markdown", - html: "

Some explanation.

", - raw: "Some explanation." - }.to_json - ) - .at_path("statusExplanation") - end - end - - context "with a status" do - let(:body) do - { - statusExplanation: { - raw: "Some explanation." - }, - _links: { - status: { - href: api_v3_paths.project_status("off_track") - } - } - } - end - - it "alters the status" do - expect(last_response.body) - .to be_json_eql(api_v3_paths.project_status("off_track").to_json) - .at_path("_links/status/href") - - expect(last_response.body) - .to be_json_eql( - { - format: "markdown", - html: "

Some explanation.

", - raw: "Some explanation." - }.to_json - ) - .at_path("statusExplanation") - end - - it "persists the altered status" do - project.reload - - expect(project.status_code) - .to eql("off_track") - - expect(project.status_explanation) - .to eql("Some explanation.") - end - end - - context "with faulty name" do - let(:body) do - { - name: nil - } - end - - it "responds with 422" do - expect(last_response).to have_http_status(:unprocessable_entity) - end - - it "does not change the project" do - attributes_before = project.attributes - - expect(project.reload.name) - .to eql(attributes_before["name"]) - end - - it "denotes the error" do - expect(last_response.body) - .to be_json_eql("Error".to_json) - .at_path("_type") - - expect(last_response.body) - .to be_json_eql("Name can't be blank.".to_json) - .at_path("message") - end - end - - context "with a faulty status" do - let(:body) do - { - _links: { - status: { - href: api_v3_paths.project_status("bogus") - } - } - } - end - - it "responds with 422" do - expect(last_response).to have_http_status(:unprocessable_entity) - end - - it "does not change the project status" do - code_before = project.status_code - - expect(project.reload.status_code) - .to eql(code_before) - end - - it "denotes the error" do - expect(last_response.body) - .to be_json_eql("Error".to_json) - .at_path("_type") - - expect(last_response.body) - .to be_json_eql("Status is not set to one of the allowed values.".to_json) - .at_path("message") - end - end - - context "when archiving the project (change active from true to false)" do - let(:body) do - { - active: false - } - end - - context "for an admin" do - let(:current_user) do - create(:admin) - end - let(:project) do - create(:project).tap do |p| - p.children << child_project - end - end - let(:child_project) do - create(:project) - end - - it "responds with 200 OK" do - expect(last_response) - .to have_http_status(200) - end - - it "archives the project" do - expect(project.reload.active) - .to be_falsey - end - - it "archives the child project" do - expect(child_project.reload.active) - .to be_falsey - end - end - - context "for a user with only edit_project permission" do - let(:permissions) { [:edit_project] } - - it "responds with 403" do - expect(last_response) - .to have_http_status(403) - end - - it "does not alter the project" do - expect(project.reload.active) - .to be_truthy - end - end - - context "for a user with only archive_project permission" do - let(:permissions) { [:archive_project] } - - it "responds with 200 OK" do - expect(last_response) - .to have_http_status(200) - end - - it "archives the project" do - expect(project.reload.active) - .to be_falsey - end - end - - context "for a user missing archive_project permission on child project" do - let(:permissions) { [:archive_project] } - let(:project) do - create(:project).tap do |p| - p.children << child_project - end - end - let(:child_project) { create(:project) } - - it "responds with 422 (and not 403?)" do - expect(last_response) - .to have_http_status(422) - end - - it "does not alter the project" do - expect(project.reload.active) - .to be_truthy - end - end - end - - context "when setting a custom field and archiving the project" do - let(:body) do - { - active: false, - custom_field.attribute_name(:camel_case) => { - raw: "CF text" - } - } - end - - context "for an admin" do - let(:current_user) do - create(:admin) - end - let(:project) do - create(:project).tap do |p| - p.children << child_project - end - end - let(:child_project) do - create(:project) - end - - it "responds with 200 OK" do - expect(last_response) - .to have_http_status(200) - end - - it "sets the cf value" do - expect(project.reload.send(custom_field.attribute_getter)) - .to eql("CF text") - end - - it "archives the project" do - expect(project.reload.active) - .to be_falsey - end - - it "archives the child project" do - expect(child_project.reload.active) - .to be_falsey - end - end - - context "for a user with only edit_project permission" do - let(:permissions) { [:edit_project] } - - it "responds with 403" do - expect(last_response) - .to have_http_status(403) - end - end - - context "for a user with only archive_project permission" do - let(:permissions) { [:archive_project] } - - it "responds with 403" do - expect(last_response) - .to have_http_status(403) - end - end - - context "for a user with archive_project and edit_project permissions" do - let(:permissions) { %i[archive_project edit_project] } - - it "responds with 422 unprocessable_entity" do - expect(last_response) - .to have_http_status(422) - end - end - - context "for a user with archive_project and edit_project and edit_project_attributes permissions" do - let(:permissions) { %i[archive_project edit_project edit_project_attributes] } - - it "responds with 200 OK" do - expect(last_response) - .to have_http_status(200) - end - end - end - - context "when unarchiving the project (change active from false to true)" do - let(:project_active) { false } - let(:body) do - { - active: true - } - end - - context "for an admin" do - let(:current_user) do - create(:admin) - end - let(:project) do - create(:project).tap do |p| - p.children << child_project - end - end - let(:child_project) do - create(:project) - end - - it "responds with 200 OK" do - expect(last_response) - .to have_http_status(200) - end - - it "unarchives the project" do - expect(project.reload) - .to be_active - end - - it "unarchives the child project" do - expect(child_project.reload) - .to be_active - end - end - - context "for a non-admin user, even with both archive_project and edit_project permissions" do - let(:permissions) { %i[archive_project edit_project] } - - it "responds with 404" do - expect(last_response) - .to have_http_status(404) - end - - it "does not alter the project" do - expect(project.reload) - .not_to be_active - end - end - end -end diff --git a/spec/requests/api/v3/projects/update_form_resource_spec.rb b/spec/requests/api/v3/workspaces/update_form_resource_spec.rb similarity index 99% rename from spec/requests/api/v3/projects/update_form_resource_spec.rb rename to spec/requests/api/v3/workspaces/update_form_resource_spec.rb index 860ee2c5c862..9f8177bc8ebf 100644 --- a/spec/requests/api/v3/projects/update_form_resource_spec.rb +++ b/spec/requests/api/v3/workspaces/update_form_resource_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -#-- copyright +# -- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH # @@ -26,11 +26,12 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. +# ++ require "spec_helper" require "rack/test" -RSpec.describe API::V3::Projects::UpdateFormAPI, content_type: :json do +RSpec.describe "API v3 Workspace resource update form", content_type: :json do include Rack::Test::Methods include API::V3::Utilities::PathHelper diff --git a/spec/requests/api/v3/workspaces/update_resource_spec.rb b/spec/requests/api/v3/workspaces/update_resource_spec.rb new file mode 100644 index 000000000000..79f7c9c980db --- /dev/null +++ b/spec/requests/api/v3/workspaces/update_resource_spec.rb @@ -0,0 +1,634 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" +require "rack/test" + +RSpec.describe "API v3 Workspace resource update", content_type: :json do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + let(:admin) { create(:admin) } + let(:project) do + create(:project, + :with_status, + public: false, + active: project_active) + end + let(:project_active) { true } + let(:custom_field) do + create(:text_project_custom_field) + end + let(:admin_only_custom_field) do + create(:text_project_custom_field, admin_only: true) + end + let(:permissions) { %i[edit_project view_project_attributes edit_project_attributes] } + let(:body) do + { + identifier: "new_project_identifier", + name: "Project name" + } + end + + current_user do + create(:user, member_with_permissions: { project => permissions }) + end + + before do + patch path, body.to_json + end + + shared_examples_for "update a workspace" do + it "responds with 200 OK" do + expect(last_response).to have_http_status(:ok) + end + + it "alters the project" do + project.reload + + expect(project.name) + .to eql(body[:name]) + + expect(project.identifier) + .to eql(body[:identifier]) + end + + it "returns the updated project" do + expect(last_response.body) + .to be_json_eql("Project".to_json) + .at_path("_type") + expect(last_response.body) + .to be_json_eql(body[:name].to_json) + .at_path("name") + end + + context "with a visible custom field" do + let(:body) do + { + custom_field.attribute_name(:camel_case) => { + raw: "CF text" + } + } + end + + it "responds with 200 OK" do + expect(last_response).to have_http_status(:ok) + end + + it "sets the cf value" do + expect(project.reload.send(custom_field.attribute_getter)) + .to eql("CF text") + end + + it "automatically activates the cf for project if the value was provided" do + expect(project.project_custom_fields) + .to contain_exactly(custom_field) + end + end + + context "with an admin only custom field" do + let(:body) do + { + admin_only_custom_field.attribute_name(:camel_case) => { + raw: "CF text" + } + } + end + + context "with admin permissions" do + let(:current_user) { create(:admin) } + + it "responds with 200 OK" do + expect(last_response).to have_http_status(:ok) + end + + it "sets the cf value" do + expect(project.reload.send(admin_only_custom_field.attribute_getter)) + .to eql("CF text") + end + + it "automatically activates the cf for project if the value was provided" do + expect(project.reload.project_custom_fields) + .to contain_exactly(admin_only_custom_field) + end + end + + context "with non-admin permissions" do + it "responds with 200 OK" do + # TBD: trying to set a not accessible custom field is silently ignored + expect(last_response).to have_http_status(:ok) + end + + it "does not set the cf value" do + expect(project.reload.custom_values.find_by(custom_field: admin_only_custom_field)) + .to have_attributes(value: nil) + end + + context "when the hidden field has a value already" do + before do + project.update!(custom_field_values: { admin_only_custom_field.id => "1234" }) + + patch path, body.to_json + end + + it "does not change the cf value" do + expect(project.reload.custom_values.find_by(custom_field: admin_only_custom_field)) + .to have_attributes(value: "1234") + end + end + + it "does not activate the cf for project" do + expect(project.reload.project_custom_fields) + .to be_empty + end + end + end + + describe "permissions" do + context "without permission to patch projects" do + let(:permissions) { [] } + + it "responds with 403" do + expect(last_response).to have_http_status(:forbidden) + end + + it "does not change the project" do + attributes_before = project.attributes + + expect(project.reload.name) + .to eql(attributes_before["name"]) + end + + context "and with edit_project_attributes permission" do + let(:permissions) { [:edit_project_attributes] } + let(:body) do + { + custom_field.attribute_name(:camel_case) => { + raw: "CF text" + } + } + end + + it "responds with 403" do + expect(last_response).to have_http_status(:forbidden) + end + + it "does not change the project" do + attributes_before = project.attributes + custom_field_value_before = project.send(custom_field.attribute_getter) + + expect(project.reload.name) + .to eql(attributes_before["name"]) + expect(project.send(custom_field.attribute_getter)) + .to eq custom_field_value_before + end + end + end + + context "with edit_project permission" do + let(:permissions) { [:edit_project] } + + it "responds with 200 OK" do + expect(last_response).to have_http_status(:ok) + end + + it "alters the project" do + project.reload + + expect(project.name) + .to eql(body[:name]) + + expect(project.identifier) + .to eql(body[:identifier]) + end + + context "when custom_field values are updated without edit_project_attributes" do + let(:body) do + { + custom_field.attribute_name(:camel_case) => { + raw: "CF text" + } + } + end + + it "responds with 422" do + expect(last_response).to have_http_status(:unprocessable_entity) + end + + it "does not change the project" do + attributes_before = project.attributes + custom_field_value_before = project.send(custom_field.attribute_getter) + + expect(project.reload.name) + .to eql(attributes_before["name"]) + expect(project.send(custom_field.attribute_getter)) + .to eq custom_field_value_before + end + end + end + end + + context "with a nil status" do + let(:body) do + { + statusExplanation: { + raw: "Some explanation." + }, + _links: { + status: { + href: nil + } + } + } + end + + it "alters the status" do + expect(last_response.body) + .to be_json_eql(nil.to_json) + .at_path("_links/status/href") + + project.reload + expect(project.status_code).to be_nil + expect(project.status_explanation).to eq "Some explanation." + + expect(last_response.body) + .to be_json_eql( + { + format: "markdown", + html: "

Some explanation.

", + raw: "Some explanation." + }.to_json + ) + .at_path("statusExplanation") + end + end + + context "with a status" do + let(:body) do + { + statusExplanation: { + raw: "Some explanation." + }, + _links: { + status: { + href: api_v3_paths.project_status("off_track") + } + } + } + end + + it "alters the status" do + expect(last_response.body) + .to be_json_eql(api_v3_paths.project_status("off_track").to_json) + .at_path("_links/status/href") + + expect(last_response.body) + .to be_json_eql( + { + format: "markdown", + html: "

Some explanation.

", + raw: "Some explanation." + }.to_json + ) + .at_path("statusExplanation") + end + + it "persists the altered status" do + project.reload + + expect(project.status_code) + .to eql("off_track") + + expect(project.status_explanation) + .to eql("Some explanation.") + end + end + + context "with faulty name" do + let(:body) do + { + name: nil + } + end + + it "responds with 422" do + expect(last_response).to have_http_status(:unprocessable_entity) + end + + it "does not change the project" do + attributes_before = project.attributes + + expect(project.reload.name) + .to eql(attributes_before["name"]) + end + + it "denotes the error" do + expect(last_response.body) + .to be_json_eql("Error".to_json) + .at_path("_type") + + expect(last_response.body) + .to be_json_eql("Name can't be blank.".to_json) + .at_path("message") + end + end + + context "with a faulty status" do + let(:body) do + { + _links: { + status: { + href: api_v3_paths.project_status("bogus") + } + } + } + end + + it "responds with 422" do + expect(last_response).to have_http_status(:unprocessable_entity) + end + + it "does not change the project status" do + code_before = project.status_code + + expect(project.reload.status_code) + .to eql(code_before) + end + + it "denotes the error" do + expect(last_response.body) + .to be_json_eql("Error".to_json) + .at_path("_type") + + expect(last_response.body) + .to be_json_eql("Status is not set to one of the allowed values.".to_json) + .at_path("message") + end + end + + context "when archiving the project (change active from true to false)" do + let(:body) do + { + active: false + } + end + + context "for an admin" do + let(:current_user) do + create(:admin) + end + let(:project) do + create(:project).tap do |p| + p.children << child_project + end + end + let(:child_project) do + create(:project) + end + + it "responds with 200 OK" do + expect(last_response) + .to have_http_status(200) + end + + it "archives the project" do + expect(project.reload.active) + .to be_falsey + end + + it "archives the child project" do + expect(child_project.reload.active) + .to be_falsey + end + end + + context "for a user with only edit_project permission" do + let(:permissions) { [:edit_project] } + + it "responds with 403" do + expect(last_response) + .to have_http_status(403) + end + + it "does not alter the project" do + expect(project.reload.active) + .to be_truthy + end + end + + context "for a user with only archive_project permission" do + let(:permissions) { [:archive_project] } + + it "responds with 200 OK" do + expect(last_response) + .to have_http_status(200) + end + + it "archives the project" do + expect(project.reload.active) + .to be_falsey + end + end + + context "for a user missing archive_project permission on child project" do + let(:permissions) { [:archive_project] } + let(:project) do + create(:project).tap do |p| + p.children << child_project + end + end + let(:child_project) { create(:project) } + + it "responds with 422 (and not 403?)" do + expect(last_response) + .to have_http_status(422) + end + + it "does not alter the project" do + expect(project.reload.active) + .to be_truthy + end + end + end + + context "when setting a custom field and archiving the project" do + let(:body) do + { + active: false, + custom_field.attribute_name(:camel_case) => { + raw: "CF text" + } + } + end + + context "for an admin" do + let(:current_user) do + create(:admin) + end + let(:project) do + create(:project).tap do |p| + p.children << child_project + end + end + let(:child_project) do + create(:project) + end + + it "responds with 200 OK" do + expect(last_response) + .to have_http_status(200) + end + + it "sets the cf value" do + expect(project.reload.send(custom_field.attribute_getter)) + .to eql("CF text") + end + + it "archives the project" do + expect(project.reload.active) + .to be_falsey + end + + it "archives the child project" do + expect(child_project.reload.active) + .to be_falsey + end + end + + context "for a user with only edit_project permission" do + let(:permissions) { [:edit_project] } + + it "responds with 403" do + expect(last_response) + .to have_http_status(403) + end + end + + context "for a user with only archive_project permission" do + let(:permissions) { [:archive_project] } + + it "responds with 403" do + expect(last_response) + .to have_http_status(403) + end + end + + context "for a user with archive_project and edit_project permissions" do + let(:permissions) { %i[archive_project edit_project] } + + it "responds with 422 unprocessable_entity" do + expect(last_response) + .to have_http_status(422) + end + end + + context "for a user with archive_project and edit_project and edit_project_attributes permissions" do + let(:permissions) { %i[archive_project edit_project edit_project_attributes] } + + it "responds with 200 OK" do + expect(last_response) + .to have_http_status(200) + end + end + end + + context "when unarchiving the project (change active from false to true)" do + let(:project_active) { false } + let(:body) do + { + active: true + } + end + + context "for an admin" do + let(:current_user) do + create(:admin) + end + let(:project) do + create(:project).tap do |p| + p.children << child_project + end + end + let(:child_project) do + create(:project) + end + + it "responds with 200 OK" do + expect(last_response) + .to have_http_status(200) + end + + it "unarchives the project" do + expect(project.reload) + .to be_active + end + + it "unarchives the child project" do + expect(child_project.reload) + .to be_active + end + end + + context "for a non-admin user, even with both archive_project and edit_project permissions" do + let(:permissions) { %i[archive_project edit_project] } + + it "responds with 404" do + expect(last_response) + .to have_http_status(404) + end + + it "does not alter the project" do + expect(project.reload) + .not_to be_active + end + end + end + end + + describe "PATCH /api/v3/projects/:id" do + include_examples "update a workspace" do + let(:path) { api_v3_paths.project(project.id) } + + context "with a portfolio id" do + let(:project) do + create(:portfolio, public: true) + end + + it_behaves_like "not found" + end + end + end + + describe "PATCH /api/v3/workspace/:id" do + include_examples "update a workspace" do + let(:path) { api_v3_paths.workspace(project.id) } + end + end +end From 1e5191d3b6cf2ebd1ac6ece4656df7234714c6f1 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 24 Sep 2025 12:54:22 +0200 Subject: [PATCH 17/21] move project delete to workspace --- lib/api/v3/projects/project_representer.rb | 2 +- lib/api/v3/projects/projects_api.rb | 3 - lib/api/v3/workspaces/nested_apis.rb | 3 + .../project_representer_rendering_spec.rb | 2 +- .../api/v3/projects/delete_resource_spec.rb | 153 ---------------- .../api/v3/workspaces/delete_resource_spec.rb | 171 ++++++++++++++++++ 6 files changed, 176 insertions(+), 158 deletions(-) delete mode 100644 spec/requests/api/v3/projects/delete_resource_spec.rb create mode 100644 spec/requests/api/v3/workspaces/delete_resource_spec.rb diff --git a/lib/api/v3/projects/project_representer.rb b/lib/api/v3/projects/project_representer.rb index 4728a3917823..ef9a3ced41fe 100644 --- a/lib/api/v3/projects/project_representer.rb +++ b/lib/api/v3/projects/project_representer.rb @@ -136,7 +136,7 @@ def self.current_user_view_allowed_lambda link :delete, cache_if: -> { current_user.admin? } do { - href: api_v3_paths.project(represented.id), + href: api_v3_paths.workspace(represented.id), method: :delete } end diff --git a/lib/api/v3/projects/projects_api.rb b/lib/api/v3/projects/projects_api.rb index 1e50cb457eda..5b8477124c9c 100644 --- a/lib/api/v3/projects/projects_api.rb +++ b/lib/api/v3/projects/projects_api.rb @@ -66,9 +66,6 @@ class ProjectsAPI < ::API::OpenProjectAPI end get &::API::V3::Utilities::Endpoints::Show.new(model: Project).mount - delete &::API::V3::Utilities::Endpoints::Delete.new(model: Project, - process_service: ::Projects::ScheduleDeletionService) - .mount mount API::V3::Projects::Copy::CopyAPI mount ::API::V3::Workspaces::NestedApis diff --git a/lib/api/v3/workspaces/nested_apis.rb b/lib/api/v3/workspaces/nested_apis.rb index 7bf3f5904d72..9c8965a51f12 100644 --- a/lib/api/v3/workspaces/nested_apis.rb +++ b/lib/api/v3/workspaces/nested_apis.rb @@ -34,6 +34,9 @@ module Workspaces class NestedApis < ::API::OpenProjectAPI mount ::API::V3::Projects::UpdateFormAPI patch &::API::V3::Utilities::Endpoints::Update.new(model: Project).mount + delete &::API::V3::Utilities::Endpoints::Delete.new(model: Project, + process_service: ::Projects::ScheduleDeletionService) + .mount mount API::V3::Workspaces::AvailableAssigneesAPI mount API::V3::Types::TypesByWorkspaceAPI diff --git a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb index 4dc42a39fe00..994740eb3e67 100644 --- a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb +++ b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb @@ -653,7 +653,7 @@ it_behaves_like "has an untitled link" do let(:link) { "delete" } - let(:href) { api_v3_paths.project project.id } + let(:href) { api_v3_paths.workspace project.id } end end diff --git a/spec/requests/api/v3/projects/delete_resource_spec.rb b/spec/requests/api/v3/projects/delete_resource_spec.rb deleted file mode 100644 index 4d5799c4fb78..000000000000 --- a/spec/requests/api/v3/projects/delete_resource_spec.rb +++ /dev/null @@ -1,153 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -require "spec_helper" -require "rack/test" - -RSpec.describe "API v3 Project resource delete", content_type: :json do - include Rack::Test::Methods - include API::V3::Utilities::PathHelper - - let(:project) do - create(:project, public: false) - end - let(:role) { create(:project_role) } - let(:path) { api_v3_paths.project(project.id) } - let(:setup) do - # overwritten in some examples - end - let(:member_user) { create(:user, member_with_roles: { project => role }) } - - current_user { create(:admin) } - - before do - setup - member_user - - delete path - - # run the deletion job - perform_enqueued_jobs - end - - subject { last_response } - - context "with required permissions (admin)" do - it "responds with HTTP No Content" do - expect(subject.status).to eq 204 - end - - it "deletes the project" do - expect(Project).not_to exist(project.id) - end - - context "for a project with work packages" do - let(:work_package) { create(:work_package, project:) } - let(:setup) { work_package } - - it "deletes the work packages" do - expect(WorkPackage).not_to exist(work_package.id) - end - end - - context "for a project with members" do - let(:member) do - create(:member, - project:, - principal: current_user, - roles: [create(:project_role)]) - end - let(:member_role) { member.member_roles.first } - let(:setup) do - member - member_role - end - - it "deletes the member" do - expect(Member).not_to exist(member.id) - end - - it "deletes the MemberRole" do - expect(MemberRole).not_to exist(member_role.id) - end - end - - context "for a project with a forum" do - let(:forum) do - create(:forum, - project:) - end - let(:setup) do - forum - end - - it "deletes the forum" do - expect(Forum).not_to exist(forum.id) - end - end - - context "for a non-existent project" do - let(:path) { api_v3_paths.project 0 } - - it_behaves_like "not found" - end - - context "for a portfolio" do - let(:project) { create(:portfolio, public: true) } - - it_behaves_like "not found" - end - - context "for a project which has a version foreign work packages refer to" do - let(:version) { create(:version, project:) } - let(:work_package) { create(:work_package, version:) } - - let(:setup) { work_package } - - it "responds with 422" do - expect(subject.status).to eq 422 - end - - it "explains the error" do - expect(subject.body) - .to be_json_eql(I18n.t(:"activerecord.errors.models.project.foreign_wps_reference_version").to_json) - .at_path("message") - end - end - end - - context "without required permissions" do - current_user { member_user } - - it "responds with 403" do - expect(subject.status).to eq 403 - end - end -end diff --git a/spec/requests/api/v3/workspaces/delete_resource_spec.rb b/spec/requests/api/v3/workspaces/delete_resource_spec.rb new file mode 100644 index 000000000000..c78c51e00f3a --- /dev/null +++ b/spec/requests/api/v3/workspaces/delete_resource_spec.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" +require "rack/test" + +RSpec.describe "API v3 Workspace resource delete", content_type: :json do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + let(:project) do + create(:project, public: false) + end + let(:role) { create(:project_role) } + let(:setup) do + # overwritten in some examples + end + let(:member_user) { create(:user, member_with_roles: { project => role }) } + + current_user { create(:admin) } + + before do + setup + member_user + + delete path + + # run the deletion job + perform_enqueued_jobs + end + + subject { last_response } + + shared_examples_for "deleting a workspace" do + context "with required permissions (admin)" do + it "responds with HTTP No Content" do + expect(subject.status).to eq 204 + end + + it "deletes the project" do + expect(Project).not_to exist(project.id) + end + + context "for a project with work packages" do + let(:work_package) { create(:work_package, project:) } + let(:setup) { work_package } + + it "deletes the work packages" do + expect(WorkPackage).not_to exist(work_package.id) + end + end + + context "for a project with members" do + let(:member) do + create(:member, + project:, + principal: current_user, + roles: [create(:project_role)]) + end + let(:member_role) { member.member_roles.first } + let(:setup) do + member + member_role + end + + it "deletes the member" do + expect(Member).not_to exist(member.id) + end + + it "deletes the MemberRole" do + expect(MemberRole).not_to exist(member_role.id) + end + end + + context "for a project with a forum" do + let(:forum) do + create(:forum, + project:) + end + let(:setup) do + forum + end + + it "deletes the forum" do + expect(Forum).not_to exist(forum.id) + end + end + + context "for a non-existent project" do + let(:path_id) { 0 } + + it_behaves_like "not found" + end + + context "for a project which has a version foreign work packages refer to" do + let(:version) { create(:version, project:) } + let(:work_package) { create(:work_package, version:) } + + let(:setup) { work_package } + + it "responds with 422" do + expect(subject.status).to eq 422 + end + + it "explains the error" do + expect(subject.body) + .to be_json_eql(I18n.t(:"activerecord.errors.models.project.foreign_wps_reference_version").to_json) + .at_path("message") + end + end + end + + context "without required permissions" do + current_user { member_user } + + it "responds with 403" do + expect(subject.status).to eq 403 + end + end + end + + describe "DELETE /api/v3/projects/:id" do + include_examples "deleting a workspace" do + let(:path_id) { project.id } + let(:path) { api_v3_paths.project(path_id) } + + context "with a portfolio id" do + let(:project) do + create(:portfolio, public: true) + end + + it_behaves_like "not found" + end + end + end + + describe "DELETE /api/v3/workspaces/:id" do + include_examples "deleting a workspace" do + let(:project) { create(:portfolio) } + let(:path_id) { project.id } + let(:path) { api_v3_paths.workspace(path_id) } + end + end +end From a6f7591fcbd9bbf7a437a5d1de7d778bf84d5a95 Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 24 Sep 2025 15:22:39 +0200 Subject: [PATCH 18/21] resource type specific self link in project representer --- lib/api/v3/projects/project_representer.rb | 24 ++++---- .../project_representer/portfolio_strategy.rb | 47 ++++++++++++++++ .../project_representer/program_strategy.rb | 47 ++++++++++++++++ .../project_representer/project_strategy.rb | 47 ++++++++++++++++ .../v3/projects/project_sql_representer.rb | 15 +++++ .../project_representer_rendering_spec.rb | 56 ++++++++++++++++--- .../project_sql_representer_rendering_spec.rb | 4 +- 7 files changed, 220 insertions(+), 20 deletions(-) create mode 100644 lib/api/v3/projects/project_representer/portfolio_strategy.rb create mode 100644 lib/api/v3/projects/project_representer/program_strategy.rb create mode 100644 lib/api/v3/projects/project_representer/project_strategy.rb diff --git a/lib/api/v3/projects/project_representer.rb b/lib/api/v3/projects/project_representer.rb index ef9a3ced41fe..7dfbb23f0594 100644 --- a/lib/api/v3/projects/project_representer.rb +++ b/lib/api/v3/projects/project_representer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -26,9 +28,6 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require "roar/decorator" -require "roar/json/hal" - module API module V3 module Projects @@ -252,23 +251,26 @@ def self.current_user_view_allowed_lambda cache_if: current_user_view_allowed_lambda def _type - # TODO: check for a different implementation + strategy.type + end + + def self_v3_path(*) + strategy.path(represented) + end + + def strategy case represented.workspace_type when "project" - "Project" + ProjectStrategy when "program" - "Program" + ProgramStrategy when "portfolio" - "Portfolio" + PortfolioStrategy else raise NoMethodError end end - def self_v3_path(*) - api_v3_paths.project(represented.id) - end - self.to_eager_load = [:enabled_modules] self.checked_permissions = %i[add_work_packages view_project] diff --git a/lib/api/v3/projects/project_representer/portfolio_strategy.rb b/lib/api/v3/projects/project_representer/portfolio_strategy.rb new file mode 100644 index 000000000000..a46989c54ea3 --- /dev/null +++ b/lib/api/v3/projects/project_representer/portfolio_strategy.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module API + module V3 + module Projects + class ProjectRepresenter::PortfolioStrategy + extend APIV3Helper + + def self.type + "Portfolio" + end + + def self.path(represented) + api_v3_paths.portfolio(represented.id) + end + end + end + end +end diff --git a/lib/api/v3/projects/project_representer/program_strategy.rb b/lib/api/v3/projects/project_representer/program_strategy.rb new file mode 100644 index 000000000000..1cdc79112736 --- /dev/null +++ b/lib/api/v3/projects/project_representer/program_strategy.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module API + module V3 + module Projects + class ProjectRepresenter::ProgramStrategy + extend APIV3Helper + + def self.type + "Program" + end + + def self.path(represented) + api_v3_paths.program(represented.id) + end + end + end + end +end diff --git a/lib/api/v3/projects/project_representer/project_strategy.rb b/lib/api/v3/projects/project_representer/project_strategy.rb new file mode 100644 index 000000000000..31c5ec83323d --- /dev/null +++ b/lib/api/v3/projects/project_representer/project_strategy.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module API + module V3 + module Projects + class ProjectRepresenter::ProjectStrategy + extend APIV3Helper + + def self.type + "Project" + end + + def self.path(represented) + api_v3_paths.project(represented.id) + end + end + end + end +end diff --git a/lib/api/v3/projects/project_sql_representer.rb b/lib/api/v3/projects/project_sql_representer.rb index b642f18c8a46..20c909457014 100644 --- a/lib/api/v3/projects/project_sql_representer.rb +++ b/lib/api/v3/projects/project_sql_representer.rb @@ -99,6 +99,21 @@ def ancestor_projection end link :self, + sql: -> { + <<~SQL.squish + CASE + WHEN workspace_type = 'project' + THEN json_build_object('href', format('#{api_v3_paths.project('%s')}', id), + 'title', name) + WHEN workspace_type = 'program' + THEN json_build_object('href', format('#{api_v3_paths.program('%s')}', id), + 'title', name) + WHEN workspace_type = 'portfolio' + THEN json_build_object('href', format('#{api_v3_paths.portfolio('%s')}', id), + 'title', name) + END + SQL + }, path: { api: :project, params: %w(id) }, column: -> { :id }, title: -> { :name } diff --git a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb index 994740eb3e67..c5306fb7be6d 100644 --- a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb +++ b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb @@ -118,8 +118,28 @@ it { is_expected.to include_json("Project".to_json).at_path("_type") } describe "properties" do - it_behaves_like "property", :_type do - let(:value) { "Project" } + describe "_type" do + context "for a project" do + it_behaves_like "property", :_type do + let(:value) { "Project" } + end + end + + context "for a portfolio" do + let(:project) { build_stubbed(:portfolio) } + + it_behaves_like "property", :_type do + let(:value) { "Portfolio" } + end + end + + context "for a program" do + let(:project) { build_stubbed(:program) } + + it_behaves_like "property", :_type do + let(:value) { "Program" } + end + end end it_behaves_like "property", :id do @@ -257,12 +277,34 @@ describe "_links" do it { is_expected.to have_json_type(Object).at_path("_links") } - it "links to self" do - expect(subject).to have_json_path("_links/self/href") - end + describe "self" do + context "for a project" do + it_behaves_like "has a titled link" do + let(:link) { "self" } + let(:href) { api_v3_paths.project(project.id) } + let(:title) { project.name } + end + end + + context "for a portfolio" do + let(:project) { build_stubbed(:portfolio) } - it "has a title for link to self" do - expect(subject).to have_json_path("_links/self/title") + it_behaves_like "has a titled link" do + let(:link) { "self" } + let(:href) { api_v3_paths.portfolio(project.id) } + let(:title) { project.name } + end + end + + context "for a program" do + let(:project) { build_stubbed(:program) } + + it_behaves_like "has a titled link" do + let(:link) { "self" } + let(:href) { api_v3_paths.program(project.id) } + let(:title) { project.name } + end + end end describe "create work packages" do diff --git a/spec/lib/api/v3/projects/project_sql_representer_rendering_spec.rb b/spec/lib/api/v3/projects/project_sql_representer_rendering_spec.rb index 3884e9e13362..ee6aca7a1b47 100644 --- a/spec/lib/api/v3/projects/project_sql_representer_rendering_spec.rb +++ b/spec/lib/api/v3/projects/project_sql_representer_rendering_spec.rb @@ -103,7 +103,7 @@ _links: { ancestors: [], self: { - href: api_v3_paths.project(program.id), + href: api_v3_paths.program(program.id), title: program.name } } @@ -131,7 +131,7 @@ _links: { ancestors: [], self: { - href: api_v3_paths.project(portfolio.id), + href: api_v3_paths.portfolio(portfolio.id), title: portfolio.name } } From 4803d80d4bb04af8d325f565bb822ff12e4033dc Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 24 Sep 2025 15:46:49 +0200 Subject: [PATCH 19/21] move project schema to workflow --- lib/api/v3/projects/project_representer.rb | 2 +- lib/api/v3/projects/projects_api.rb | 2 +- lib/api/v3/utilities/path_helper.rb | 2 +- .../schemas/workspace_schema_api.rb} | 8 +++---- lib/api/v3/workspaces/workspaces_api.rb | 2 ++ .../project_representer_rendering_spec.rb | 7 ++++++ spec/lib/api/v3/utilities/path_helper_spec.rb | 2 +- .../schemas/project_schema_resource_spec.rb | 24 ++++++++++++------- 8 files changed, 33 insertions(+), 16 deletions(-) rename lib/api/v3/{projects/schemas/project_schema_api.rb => workspaces/schemas/workspace_schema_api.rb} (93%) rename spec/requests/api/v3/{projects => workspaces}/schemas/project_schema_resource_spec.rb (78%) diff --git a/lib/api/v3/projects/project_representer.rb b/lib/api/v3/projects/project_representer.rb index 7dfbb23f0594..a75238868117 100644 --- a/lib/api/v3/projects/project_representer.rb +++ b/lib/api/v3/projects/project_representer.rb @@ -142,7 +142,7 @@ def self.current_user_view_allowed_lambda link :schema do { - href: api_v3_paths.projects_schema + href: api_v3_paths.workspace_schema } end diff --git a/lib/api/v3/projects/projects_api.rb b/lib/api/v3/projects/projects_api.rb index 5b8477124c9c..bef0c50b88c3 100644 --- a/lib/api/v3/projects/projects_api.rb +++ b/lib/api/v3/projects/projects_api.rb @@ -48,7 +48,7 @@ class ProjectsAPI < ::API::OpenProjectAPI }) .mount - mount ::API::V3::Projects::Schemas::ProjectSchemaAPI + mount ::API::V3::Workspaces::Schemas::WorkspaceSchemaAPI mount ::API::V3::Projects::CreateFormAPI mount API::V3::Projects::AvailableParentsAPI diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index a2f48346e9f1..4dd981c16f5e 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -697,7 +697,7 @@ def self.work_packages_by_workspace(workspace_id) "#{workspace(workspace_id)}/work_packages" end - resources :workspace, except: %i[schema create_form] + resources :workspace, except: %i[create_form] def self.timestamps_to_param_value(timestamps) Array(timestamps).map { |timestamp| Timestamp.parse(timestamp).absolute }.join(",") diff --git a/lib/api/v3/projects/schemas/project_schema_api.rb b/lib/api/v3/workspaces/schemas/workspace_schema_api.rb similarity index 93% rename from lib/api/v3/projects/schemas/project_schema_api.rb rename to lib/api/v3/workspaces/schemas/workspace_schema_api.rb index bc13b9c54328..af94f4ccad69 100644 --- a/lib/api/v3/projects/schemas/project_schema_api.rb +++ b/lib/api/v3/workspaces/schemas/workspace_schema_api.rb @@ -1,4 +1,4 @@ -#-- copyright +# -- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH # @@ -24,13 +24,13 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. -#++ +# ++ module API module V3 - module Projects + module Workspaces module Schemas - class ProjectSchemaAPI < ::API::OpenProjectAPI + class WorkspaceSchemaAPI < ::API::OpenProjectAPI resources :schema do get &::API::V3::Utilities::Endpoints::Schema.new(model: Project).mount end diff --git a/lib/api/v3/workspaces/workspaces_api.rb b/lib/api/v3/workspaces/workspaces_api.rb index fd446eb6eecc..af3f071b1970 100644 --- a/lib/api/v3/workspaces/workspaces_api.rb +++ b/lib/api/v3/workspaces/workspaces_api.rb @@ -40,6 +40,8 @@ class WorkspacesAPI < ::API::OpenProjectAPI }) .mount + mount ::API::V3::Workspaces::Schemas::WorkspaceSchemaAPI + route_param :id, type: Integer do after_validation do @project = if current_user.admin? diff --git a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb index c5306fb7be6d..6a6412d251c4 100644 --- a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb +++ b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb @@ -576,6 +576,13 @@ end end + describe "schema" do + it_behaves_like "has an untitled link" do + let(:link) { "schema" } + let(:href) { api_v3_paths.workspace_schema } + end + end + describe "storages" do let(:storage) { build_stubbed(:nextcloud_storage) } let(:permissions) { %i[view_file_links] } diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index 466f23d9ed72..20ad3fa61b39 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -732,7 +732,7 @@ def self.filter end describe "workspace paths" do - it_behaves_like "resource", :workspace, except: %i[create_form show schema] + it_behaves_like "resource", :workspace, except: %i[create_form show] describe "#favor_workspace" do subject { helper.favor_workspace 42 } diff --git a/spec/requests/api/v3/projects/schemas/project_schema_resource_spec.rb b/spec/requests/api/v3/workspaces/schemas/project_schema_resource_spec.rb similarity index 78% rename from spec/requests/api/v3/projects/schemas/project_schema_resource_spec.rb rename to spec/requests/api/v3/workspaces/schemas/project_schema_resource_spec.rb index 64c59cb4e83d..4e9c8ae57d30 100644 --- a/spec/requests/api/v3/projects/schemas/project_schema_resource_spec.rb +++ b/spec/requests/api/v3/workspaces/schemas/project_schema_resource_spec.rb @@ -31,23 +31,19 @@ require "spec_helper" require "rack/test" -RSpec.describe "API v3 Projects schema resource", content_type: :json do +RSpec.describe "API v3 Workspaces schema resource", content_type: :json do include Rack::Test::Methods include API::V3::Utilities::PathHelper - shared_let(:current_user) do + shared_let(:user) do create(:user) end - let(:path) { api_v3_paths.project_schema } - - before do - login_as(current_user) - end + current_user { user } subject(:response) { last_response } - describe "#GET /projects/schema" do + shared_examples_for "fetching the project schema" do before do get path end @@ -67,4 +63,16 @@ .not_to have_json_path("parent/_links/allowedValues") end end + + describe "GET /projects/schema" do + include_examples "fetching the project schema" do + let(:path) { api_v3_paths.project_schema } + end + end + + describe "GET /workspaces/schema" do + include_examples "fetching the project schema" do + let(:path) { api_v3_paths.workspace_schema } + end + end end From 335ab2f6b7ced7c1736497d0eb9b1a86d134957d Mon Sep 17 00:00:00 2001 From: ulferts Date: Wed, 24 Sep 2025 16:37:54 +0200 Subject: [PATCH 20/21] turn parent and ancestor links workspace type ready --- lib/api/decorators/linked_resource.rb | 10 +- lib/api/v3/projects/project_representer.rb | 8 +- .../project_representer/portfolio_strategy.rb | 4 + .../project_representer/program_strategy.rb | 4 + .../project_representer/project_strategy.rb | 4 + .../v3/projects/project_sql_representer.rb | 40 ++++---- .../project_representer_rendering_spec.rb | 96 ++++++++++++------- .../project_sql_representer_rendering_spec.rb | 23 +++-- 8 files changed, 116 insertions(+), 73 deletions(-) diff --git a/lib/api/decorators/linked_resource.rb b/lib/api/decorators/linked_resource.rb index a5f7a3eacabd..9a0336723bf2 100644 --- a/lib/api/decorators/linked_resource.rb +++ b/lib/api/decorators/linked_resource.rb @@ -90,7 +90,6 @@ def resource(name, show_if: ->(*) { true }, skip_render: nil, embedded: true) - link(link_attr(name, uncacheable_link, link_cache_if), &link) property name, @@ -113,7 +112,6 @@ def resources(name, show_if: ->(*) { true }, skip_render: nil, embedded: true) - links(link_attr(name, uncacheable_link, link_cache_if), &link) property name, @@ -131,7 +129,6 @@ def resource_link(name, setter:, getter:, show_if: ->(*) { true }) - resource(name, getter: ->(*) {}, setter:, @@ -162,8 +159,7 @@ def associated_resource(name, skip_link:, undisclosed:, title_attribute: link_title_attribute)) - - resource((as || name), + resource(as || name, getter:, setter:, link:, @@ -217,7 +213,7 @@ def associated_resource_default_link(name, elsif !instance_exec(&skip_link) ::API::Decorators::LinkObject .new(represented, - path: v3_path, + path: v3_path.is_a?(Proc) ? instance_exec(&v3_path) : v3_path, property_name: name, title_attribute:, getter:) @@ -240,7 +236,6 @@ def associated_resources(name, v3_path:, skip_link:, title_attribute: link_title_attribute)) - resources(as, getter:, setter:, @@ -251,7 +246,6 @@ def associated_resources(name, def associated_resources_default_getter(name, representer) - representer ||= default_representer(name.to_s.singularize) ->(*) do diff --git a/lib/api/v3/projects/project_representer.rb b/lib/api/v3/projects/project_representer.rb index a75238868117..92c2e3d0a68b 100644 --- a/lib/api/v3/projects/project_representer.rb +++ b/lib/api/v3/projects/project_representer.rb @@ -153,7 +153,7 @@ def self.current_user_view_allowed_lambda # will lead to the admin losing permissions in the project. if current_user.admin? || ancestor.visible? { - href: api_v3_paths.project(ancestor.id), + href: strategy(ancestor).path(ancestor), title: ancestor.name } else @@ -187,7 +187,7 @@ def self.current_user_view_allowed_lambda end associated_resource :parent, - v3_path: :project, + v3_path: ->(*) { represented.parent and strategy(represented.parent).path_name }, representer: ::API::V3::Projects::ProjectRepresenter, uncacheable_link: true, undisclosed: true, @@ -258,8 +258,8 @@ def self_v3_path(*) strategy.path(represented) end - def strategy - case represented.workspace_type + def strategy(resource = represented) + case resource.workspace_type when "project" ProjectStrategy when "program" diff --git a/lib/api/v3/projects/project_representer/portfolio_strategy.rb b/lib/api/v3/projects/project_representer/portfolio_strategy.rb index a46989c54ea3..fb5ac41b2540 100644 --- a/lib/api/v3/projects/project_representer/portfolio_strategy.rb +++ b/lib/api/v3/projects/project_representer/portfolio_strategy.rb @@ -38,6 +38,10 @@ def self.type "Portfolio" end + def self.path_name + :portfolio + end + def self.path(represented) api_v3_paths.portfolio(represented.id) end diff --git a/lib/api/v3/projects/project_representer/program_strategy.rb b/lib/api/v3/projects/project_representer/program_strategy.rb index 1cdc79112736..12e88f87a0d2 100644 --- a/lib/api/v3/projects/project_representer/program_strategy.rb +++ b/lib/api/v3/projects/project_representer/program_strategy.rb @@ -38,6 +38,10 @@ def self.type "Program" end + def self.path_name + :program + end + def self.path(represented) api_v3_paths.program(represented.id) end diff --git a/lib/api/v3/projects/project_representer/project_strategy.rb b/lib/api/v3/projects/project_representer/project_strategy.rb index 31c5ec83323d..d088ecc0b5f3 100644 --- a/lib/api/v3/projects/project_representer/project_strategy.rb +++ b/lib/api/v3/projects/project_representer/project_strategy.rb @@ -38,6 +38,10 @@ def self.type "Project" end + def self.path_name + :project + end + def self.path(represented) api_v3_paths.project(represented.id) end diff --git a/lib/api/v3/projects/project_sql_representer.rb b/lib/api/v3/projects/project_sql_representer.rb index 20c909457014..c578cc8976a9 100644 --- a/lib/api/v3/projects/project_sql_representer.rb +++ b/lib/api/v3/projects/project_sql_representer.rb @@ -77,8 +77,7 @@ def ancestor_projection <<-SQL.squish CASE WHEN ancestors.id IS NOT NULL - THEN json_build_object('href', format('#{api_v3_paths.project('%s')}', ancestors.id), - 'title', ancestors.name) + THEN #{workspace_type_link_case('ancestors')} ELSE NULL END SQL @@ -86,8 +85,7 @@ def ancestor_projection <<-SQL.squish CASE WHEN ancestors.id IS NOT NULL AND ancestors.id IN (SELECT id FROM visible_projects) - THEN json_build_object('href', format('#{api_v3_paths.project('%s')}', ancestors.id), - 'title', ancestors.name) + THEN #{workspace_type_link_case('ancestors')} WHEN ancestors.id IS NOT NULL AND ancestors.id NOT IN (SELECT id FROM visible_projects) THEN json_build_object('href', '#{API::V3::URN_UNDISCLOSED}', 'title', #{ActiveRecord::Base.connection.quote(I18n.t(:"api_v3.undisclosed.ancestor"))}) @@ -96,24 +94,28 @@ def ancestor_projection SQL end end + + def workspace_type_link_case(table = nil) + table = "#{table}." if table + + <<~SQL.squish + CASE + WHEN #{table}workspace_type = 'project' + THEN json_build_object('href', format('#{api_v3_paths.project('%s')}', #{table}id), + 'title', #{table}name) + WHEN #{table}workspace_type = 'program' + THEN json_build_object('href', format('#{api_v3_paths.program('%s')}', #{table}id), + 'title', #{table}name) + WHEN #{table}workspace_type = 'portfolio' + THEN json_build_object('href', format('#{api_v3_paths.portfolio('%s')}', #{table}id), + 'title', #{table}name) + END + SQL + end end link :self, - sql: -> { - <<~SQL.squish - CASE - WHEN workspace_type = 'project' - THEN json_build_object('href', format('#{api_v3_paths.project('%s')}', id), - 'title', name) - WHEN workspace_type = 'program' - THEN json_build_object('href', format('#{api_v3_paths.program('%s')}', id), - 'title', name) - WHEN workspace_type = 'portfolio' - THEN json_build_object('href', format('#{api_v3_paths.portfolio('%s')}', id), - 'title', name) - END - SQL - }, + sql: -> { workspace_type_link_case(nil) }, path: { api: :project, params: %w(id) }, column: -> { :id }, title: -> { :name } diff --git a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb index 6a6412d251c4..934876e9e1ad 100644 --- a/spec/lib/api/v3/projects/project_representer_rendering_spec.rb +++ b/spec/lib/api/v3/projects/project_representer_rendering_spec.rb @@ -41,7 +41,7 @@ let(:project) do build_stubbed(:project, :with_status, - parent: parent_project, + parent: parent_workspace, description: "some description").tap do |p| allow(p).to receive_messages(available_custom_fields:, all_available_custom_fields:, @@ -96,7 +96,7 @@ build(:custom_value, custom_field: calculated_value_custom_field, value: "24.0") end let(:permissions) { %i[view_project add_work_packages view_members] } - let(:parent_project) do + let(:parent_workspace) do build_stubbed(:project).tap do |parent| allow(parent) .to receive(:visible?) @@ -105,7 +105,7 @@ end let(:representer) { described_class.create(project, current_user: user, embed_links: true) } let(:parent_visible) { true } - let(:ancestors) { [parent_project] } + let(:ancestors) { [parent_workspace] } let(:user) { build_stubbed(:user) } @@ -336,9 +336,39 @@ describe "parent" do let(:link) { "parent" } - it_behaves_like "has a titled link" do - let(:href) { api_v3_paths.project(parent_project.id) } - let(:title) { parent_project.name } + context "for a project" do + it_behaves_like "has a titled link" do + let(:href) { api_v3_paths.project(parent_workspace.id) } + let(:title) { parent_workspace.name } + end + end + + context "for a portfolio" do + it_behaves_like "has a titled link" do + let(:parent_workspace) do + build_stubbed(:portfolio).tap do |parent| + allow(parent) + .to receive(:visible?) + .and_return(true) + end + end + let(:href) { api_v3_paths.portfolio(parent_workspace.id) } + let(:title) { parent_workspace.name } + end + end + + context "for a program" do + it_behaves_like "has a titled link" do + let(:parent_workspace) do + build_stubbed(:program).tap do |parent| + allow(parent) + .to receive(:visible?) + .and_return(true) + end + end + let(:href) { api_v3_paths.program(parent_workspace.id) } + let(:title) { parent_workspace.name } + end end context "if lacking the permissions to see the parent" do @@ -360,13 +390,13 @@ end it_behaves_like "has a titled link" do - let(:href) { api_v3_paths.project(parent_project.id) } - let(:title) { parent_project.name } + let(:href) { api_v3_paths.project(parent_workspace.id) } + let(:title) { parent_workspace.name } end end context "without a parent" do - let(:parent_project) { nil } + let(:parent_workspace) { nil } let(:ancestors) { [] } it_behaves_like "has an untitled link" do @@ -377,36 +407,36 @@ describe "ancestors" do let(:link) { "ancestors" } - let(:grandparent_project) do - build_stubbed(:project).tap do |p| + let(:grandparent_program) do + build_stubbed(:program).tap do |p| allow(p) .to receive(:visible?) .and_return(true) end end - let(:root_project) do - build_stubbed(:project).tap do |p| + let(:root_portfolio) do + build_stubbed(:portfolio).tap do |p| allow(p) .to receive(:visible?) .and_return(true) end end - let(:ancestors) { [root_project, grandparent_project, parent_project] } + let(:ancestors) { [root_portfolio, grandparent_program, parent_workspace] } it_behaves_like "has a link collection" do let(:hrefs) do [ { - href: api_v3_paths.project(root_project.id), - title: root_project.name + href: api_v3_paths.portfolio(root_portfolio.id), + title: root_portfolio.name }, { - href: api_v3_paths.project(grandparent_project.id), - title: grandparent_project.name + href: api_v3_paths.program(grandparent_program.id), + title: grandparent_program.name }, { - href: api_v3_paths.project(parent_project.id), - title: parent_project.name + href: api_v3_paths.project(parent_workspace.id), + title: parent_workspace.name } ] end @@ -419,12 +449,12 @@ let(:hrefs) do [ { - href: api_v3_paths.project(root_project.id), - title: root_project.name + href: api_v3_paths.portfolio(root_portfolio.id), + title: root_portfolio.name }, { - href: api_v3_paths.project(grandparent_project.id), - title: grandparent_project.name + href: api_v3_paths.program(grandparent_program.id), + title: grandparent_program.name }, { href: API::V3::URN_UNDISCLOSED, @@ -448,16 +478,16 @@ let(:hrefs) do [ { - href: api_v3_paths.project(root_project.id), - title: root_project.name + href: api_v3_paths.portfolio(root_portfolio.id), + title: root_portfolio.name }, { - href: api_v3_paths.project(grandparent_project.id), - title: grandparent_project.name + href: api_v3_paths.program(grandparent_program.id), + title: grandparent_program.name }, { - href: api_v3_paths.project(parent_project.id), - title: parent_project.name + href: api_v3_paths.project(parent_workspace.id), + title: parent_workspace.name } ] end @@ -465,7 +495,7 @@ end context "without an ancestor" do - let(:parent_project) { nil } + let(:parent_workspace) { nil } let(:ancestors) { [] } it_behaves_like "has an empty link collection" @@ -806,7 +836,7 @@ let(:embedded_path) { "_embedded/parent" } before do - allow(parent_project) + allow(parent_workspace) .to receive(:visible?) .and_return(parent_visible) end @@ -820,7 +850,7 @@ .at_path("#{embedded_path}/_type") expect(generated) - .to be_json_eql(parent_project.name.to_json) + .to be_json_eql(parent_workspace.name.to_json) .at_path("#{embedded_path}/name") end end diff --git a/spec/lib/api/v3/projects/project_sql_representer_rendering_spec.rb b/spec/lib/api/v3/projects/project_sql_representer_rendering_spec.rb index ee6aca7a1b47..8a266e7c6b88 100644 --- a/spec/lib/api/v3/projects/project_sql_representer_rendering_spec.rb +++ b/spec/lib/api/v3/projects/project_sql_representer_rendering_spec.rb @@ -141,17 +141,18 @@ end context "with an ancestor" do - let!(:parent) do - create(:project, members: { current_user => role }).tap do |parent| - project.parent = parent - project.save - end + let!(:elder) do + create(:portfolio, members: { current_user => role }) end let!(:grandparent) do - create(:project, members: { current_user => role }).tap do |grandparent| - parent.parent = grandparent - parent.save + create(:program, members: { current_user => role }, parent: elder) + end + + let!(:parent) do + create(:project, members: { current_user => role }, parent: grandparent) do |parent| + project.parent = parent + project.save end end @@ -164,7 +165,11 @@ _links: { ancestors: [ { - href: api_v3_paths.project(grandparent.id), + href: api_v3_paths.portfolio(elder.id), + title: elder.name + }, + { + href: api_v3_paths.program(grandparent.id), title: grandparent.name }, { From 14f5523aefabbbaff75025d6e8ba452e1d0b4765 Mon Sep 17 00:00:00 2001 From: ulferts Date: Tue, 30 Sep 2025 09:54:49 +0200 Subject: [PATCH 21/21] wip - accept non project links on wp representer --- config/locales/en.yml | 3 +- lib/api/decorators/linked_resource.rb | 62 ++++++++++++------- .../v3/memberships/membership_representer.rb | 5 +- .../principal_representer_factory.rb | 12 ++-- lib/api/v3/projects/project_representer.rb | 25 ++------ .../v3/shares/entity_representer_factory.rb | 12 ++-- .../workspace_representer_factory.rb | 62 +++++++++++++++++-- .../entity_representer_factory.rb | 12 ++-- .../entity_representer_factory.rb | 12 ++-- .../projects/settings/relations_form_spec.rb | 2 +- .../membership_representer_rendering_spec.rb | 2 + ...roject_payload_representer_parsing_spec.rb | 40 +++++++++++- 12 files changed, 175 insertions(+), 74 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index 3d2201a0848a..e2859a7e3397 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -5002,7 +5002,8 @@ en: schema: "Schema" undisclosed: - parent: Undisclosed - The selected parent is invisible because of lacking permissions. + parent: Undisclosed - The parent is invisible because of lacking permissions. + project: Undisclosed - The project is invisible because of lacking permissions. ancestor: Undisclosed - The ancestor is invisible because of lacking permissions. doorkeeper: diff --git a/lib/api/decorators/linked_resource.rb b/lib/api/decorators/linked_resource.rb index 9a0336723bf2..b9b0ada74adb 100644 --- a/lib/api/decorators/linked_resource.rb +++ b/lib/api/decorators/linked_resource.rb @@ -80,6 +80,29 @@ def validate_links!(links) raise ::API::Errors::BadRequest.new(I18n.t("api_v3.errors.bad_request.invalid_link", key: invalid)) end + def associated_resource_default_link(represented, + name, + v3_path:, + skip_link:, + title_attribute:, + getter: :"#{name}_id", + undisclosed: false) + if undisclosed && instance_exec(&skip_link) + { + href: API::V3::URN_UNDISCLOSED, + title: I18n.t(:"api_v3.undisclosed.#{name}") + } + elsif !instance_exec(&skip_link) + ::API::Decorators::LinkObject + .new(represented, + path: v3_path.is_a?(Proc) ? instance_exec(&v3_path) : v3_path, + property_name: name, + title_attribute:, + getter:) + .to_hash + end + end + module ClassMethods def resource(name, getter:, @@ -154,11 +177,11 @@ def associated_resource(name, uncacheable_link: false, getter: associated_resource_default_getter(name, representer), setter: associated_resource_default_setter(name, as, v3_path), - link: associated_resource_default_link(name, - v3_path:, - skip_link:, - undisclosed:, - title_attribute: link_title_attribute)) + link: associated_resource_default_link_lambda(name, + v3_path:, + skip_link:, + undisclosed:, + title_attribute: link_title_attribute)) resource(as || name, getter:, setter:, @@ -198,27 +221,20 @@ def associated_resource_default_setter(name, as, v3_path) end end - def associated_resource_default_link(name, + def associated_resource_default_link_lambda(name, + v3_path:, + skip_link:, + title_attribute:, + getter: :"#{name}_id", + undisclosed: false) + ->(*) do + associated_resource_default_link(represented, + name, v3_path:, skip_link:, title_attribute:, - getter: :"#{name}_id", - undisclosed: false) - ->(*) do - if undisclosed && instance_exec(&skip_link) - { - href: API::V3::URN_UNDISCLOSED, - title: I18n.t(:"api_v3.undisclosed.#{name}") - } - elsif !instance_exec(&skip_link) - ::API::Decorators::LinkObject - .new(represented, - path: v3_path.is_a?(Proc) ? instance_exec(&v3_path) : v3_path, - property_name: name, - title_attribute:, - getter:) - .to_hash - end + getter:, + undisclosed:) end end diff --git a/lib/api/v3/memberships/membership_representer.rb b/lib/api/v3/memberships/membership_representer.rb index ba018ac9d54a..6239a7c81c77 100644 --- a/lib/api/v3/memberships/membership_representer.rb +++ b/lib/api/v3/memberships/membership_representer.rb @@ -31,6 +31,7 @@ module V3 module Memberships class MembershipRepresenter < ::API::Decorators::Single include API::Decorators::LinkedResource + include API::V3::Workspaces::WorkspaceRepresenterFactory::Associations include API::Decorators::DateProperty self_link title_getter: ->(*) { represented.principal&.name } @@ -61,9 +62,7 @@ class MembershipRepresenter < ::API::Decorators::Single property :id - associated_resource :project, - link: ::API::V3::Workspaces::WorkspaceRepresenterFactory - .create_link_lambda(:project) + associated_project :project associated_resource :principal, getter: ::API::V3::Principals::PrincipalRepresenterFactory diff --git a/lib/api/v3/principals/principal_representer_factory.rb b/lib/api/v3/principals/principal_representer_factory.rb index 8c7d7dcf9343..e3fc1b476422 100644 --- a/lib/api/v3/principals/principal_representer_factory.rb +++ b/lib/api/v3/principals/principal_representer_factory.rb @@ -59,11 +59,13 @@ def create_link_lambda(name, getter: "#{name}_id") ->(*) { v3_path = API::V3::Principals::PrincipalType.for(represented.send(name)) - instance_exec(&self.class.associated_resource_default_link(name, - v3_path:, - skip_link: -> { false }, - title_attribute: :name, - getter:)) + # TODO: check if this can be turned into a call to + # associated_resource_default_link + instance_exec(&self.class.associated_resource_default_link_lambda(name, + v3_path:, + skip_link: -> { false }, + title_attribute: :name, + getter:)) } end diff --git a/lib/api/v3/projects/project_representer.rb b/lib/api/v3/projects/project_representer.rb index 92c2e3d0a68b..def23c65f43c 100644 --- a/lib/api/v3/projects/project_representer.rb +++ b/lib/api/v3/projects/project_representer.rb @@ -37,6 +37,7 @@ class ProjectRepresenter < ::API::Decorators::Single include ::API::Caching::CachedRepresenter include API::Decorators::FormattableProperty extend ::API::V3::Utilities::CustomFieldInjector::RepresenterClass + include ::API::V3::Workspaces::WorkspaceRepresenterFactory::Associations def self.current_user_view_allowed_lambda ->(*) { current_user.allowed_in_project?(:view_project, represented) || current_user.allowed_globally?(:add_project) } @@ -149,19 +150,7 @@ def self.current_user_view_allowed_lambda links :ancestors, uncacheable: true do represented.ancestors_from_root.map do |ancestor| - # Explicitly check for admin as an archived project - # will lead to the admin losing permissions in the project. - if current_user.admin? || ancestor.visible? - { - href: strategy(ancestor).path(ancestor), - title: ancestor.name - } - else - { - href: API::V3::URN_UNDISCLOSED, - title: I18n.t(:"api_v3.undisclosed.ancestor") - } - end + project_link(ancestor, name: :ancestor, getter: :id) end end @@ -186,12 +175,10 @@ def self.current_user_view_allowed_lambda { href: api_v3_paths.favor_workspace(represented.id) } end - associated_resource :parent, - v3_path: ->(*) { represented.parent and strategy(represented.parent).path_name }, - representer: ::API::V3::Projects::ProjectRepresenter, - uncacheable_link: true, - undisclosed: true, - skip_render: ->(*) { represented.parent && !represented.parent.visible? && !current_user.admin? } + associated_project :parent, + skip_render: ->(*) { + represented.parent && !represented.parent.visible? && !current_user.admin? + } property :id property :identifier, diff --git a/lib/api/v3/shares/entity_representer_factory.rb b/lib/api/v3/shares/entity_representer_factory.rb index 55b0a43ce739..c1fdc1b674d1 100644 --- a/lib/api/v3/shares/entity_representer_factory.rb +++ b/lib/api/v3/shares/entity_representer_factory.rb @@ -69,11 +69,13 @@ def create_link_lambda(name, getter: "#{name}_id") v3_path = API::V3::Shares::EntityRepresenterFactory.representer_type(represented.send(name)) title_attribute = API::V3::Shares::EntityRepresenterFactory.title_attribute(represented.send(name)) - instance_exec(&self.class.associated_resource_default_link(name, - v3_path:, - skip_link: -> { false }, - title_attribute:, - getter:)) + # TODO: check if this can be turned into a call to + # associated_resource_default_link + instance_exec(&self.class.associated_resource_default_link_lambda(name, + v3_path:, + skip_link: -> { false }, + title_attribute:, + getter:)) } end diff --git a/lib/api/v3/workspaces/workspace_representer_factory.rb b/lib/api/v3/workspaces/workspace_representer_factory.rb index 1849d02a881d..9a5c0e056d95 100644 --- a/lib/api/v3/workspaces/workspace_representer_factory.rb +++ b/lib/api/v3/workspaces/workspace_representer_factory.rb @@ -32,15 +32,65 @@ module API module V3 module Workspaces module WorkspaceRepresenterFactory + module Associations + extend ActiveSupport::Concern + include API::Decorators::LinkedResource + + def project_link(project, name:, getter: "#{name}_id") + # Explicitly check for admin as an archived project + # will lead to the admin losing permissions in the project. + if project && !project.visible? && !current_user.admin? + { + href: API::V3::URN_UNDISCLOSED, + title: I18n.t(:"api_v3.undisclosed.#{name}") + } + elsif !project + { + href: nil + } + else + associated_resource_default_link(project, + :itself, + v3_path: project&.workspace_type, + skip_link: -> { false }, + title_attribute: :name, + getter:) + end + end + + class_methods do + def associated_project(name, skip_render: false) + associated_resource name, + representer: ::API::V3::Projects::ProjectRepresenter, + uncacheable_link: true, + skip_render:, + link: ::API::V3::Workspaces::WorkspaceRepresenterFactory + .create_link_lambda(name), + setter: ::API::V3::Workspaces::WorkspaceRepresenterFactory + .create_setter_lambda(name) + end + end + end + module_function - def create_link_lambda(name, getter: "#{name}_id") + def create_link_lambda(name, property_name: name) ->(*) { - instance_exec(&self.class.associated_resource_default_link(name, - v3_path: represented.project&.workspace_type, - skip_link: -> { false }, - title_attribute: :name, - getter:)) + project_link(represented.public_send(name), + name: property_name, + getter: :id) + } + end + + def create_setter_lambda(name, property_name: name, namespaces: %i(projects programs portfolios)) + ->(fragment:, **) { + ::API::Decorators::LinkObject + .new(represented, + property_name:, + namespace: namespaces, + getter: :"#{name}_id", + setter: :"#{name}_id=") + .from_hash(fragment) } end end diff --git a/modules/costs/lib/api/v3/cost_entries/entity_representer_factory.rb b/modules/costs/lib/api/v3/cost_entries/entity_representer_factory.rb index 001cbf7acbca..59a65308e6e8 100644 --- a/modules/costs/lib/api/v3/cost_entries/entity_representer_factory.rb +++ b/modules/costs/lib/api/v3/cost_entries/entity_representer_factory.rb @@ -73,11 +73,13 @@ def create_link_lambda(name, getter: "#{name}_id") v3_path = API::V3::CostEntries::EntityRepresenterFactory.representer_type(represented.send(name)) title_attribute = API::V3::CostEntries::EntityRepresenterFactory.title_attribute(represented.send(name)) - instance_exec(&self.class.associated_resource_default_link(name, - v3_path:, - skip_link: -> { false }, - title_attribute:, - getter:)) + # TODO: check if this can be turned into a call to + # associated_resource_default_link + instance_exec(&self.class.associated_resource_default_link_lambda(name, + v3_path:, + skip_link: -> { false }, + title_attribute:, + getter:)) } end diff --git a/modules/costs/lib/api/v3/time_entries/entity_representer_factory.rb b/modules/costs/lib/api/v3/time_entries/entity_representer_factory.rb index f5356c14ec91..0b66fdf9e7e1 100644 --- a/modules/costs/lib/api/v3/time_entries/entity_representer_factory.rb +++ b/modules/costs/lib/api/v3/time_entries/entity_representer_factory.rb @@ -79,11 +79,13 @@ def create_link_lambda(name, getter: "#{name}_id") v3_path = API::V3::TimeEntries::EntityRepresenterFactory.representer_type(represented.send(name)) title_attribute = API::V3::TimeEntries::EntityRepresenterFactory.title_attribute(represented.send(name)) - instance_exec(&self.class.associated_resource_default_link(name, - v3_path:, - skip_link: -> { false }, - title_attribute:, - getter:)) + # TODO: check if this can be turned into a call to + # associated_resource_default_link + instance_exec(&self.class.associated_resource_default_link_lambda(name, + v3_path:, + skip_link: -> { false }, + title_attribute:, + getter:)) } end diff --git a/spec/forms/projects/settings/relations_form_spec.rb b/spec/forms/projects/settings/relations_form_spec.rb index 8ae388198bd4..8466e8691fb5 100644 --- a/spec/forms/projects/settings/relations_form_spec.rb +++ b/spec/forms/projects/settings/relations_form_spec.rb @@ -56,7 +56,7 @@ it "renders field with model" do expect(page).to have_element "opce-project-autocompleter", "data-qa-field-name": "parent" do |element| expect(element["data-model"]).to be_json_eql( - %{{"name": "Undisclosed - The selected parent is invisible because of lacking permissions."}} + %{{"name": "Undisclosed - The parent is invisible because of lacking permissions."}} ) end end diff --git a/spec/lib/api/v3/memberships/membership_representer_rendering_spec.rb b/spec/lib/api/v3/memberships/membership_representer_rendering_spec.rb index 658c0c3f0157..2abe5f3b32b5 100644 --- a/spec/lib/api/v3/memberships/membership_representer_rendering_spec.rb +++ b/spec/lib/api/v3/memberships/membership_representer_rendering_spec.rb @@ -70,6 +70,8 @@ mock_permissions_for(current_user) do |mock| mock.allow_in_project *permissions, project: workspace || build_stubbed(:project) end + + allow(workspace).to receive(:visible?).and_return(true) if workspace end describe "_links" do diff --git a/spec/lib/api/v3/projects/project_payload_representer_parsing_spec.rb b/spec/lib/api/v3/projects/project_payload_representer_parsing_spec.rb index 51eb1da6f647..78d8a9c2c59b 100644 --- a/spec/lib/api/v3/projects/project_payload_representer_parsing_spec.rb +++ b/spec/lib/api/v3/projects/project_payload_representer_parsing_spec.rb @@ -135,7 +135,7 @@ describe "_links" do context "with a parent link" do - context "with the href being an url" do + context "with the href being a project url" do let(:hash) do { "_links" => { @@ -154,6 +154,44 @@ end end + context "with the href being a program url" do + let(:hash) do + { + "_links" => { + "parent" => { + "href" => api_v3_paths.program(5) + } + } + } + end + + it "sets the parent_id to the value" do + project = representer.from_hash(hash) + + expect(project[:parent_id]) + .to eq "5" + end + end + + context "with the href being a portfolio url" do + let(:hash) do + { + "_links" => { + "parent" => { + "href" => api_v3_paths.portfolio(5) + } + } + } + end + + it "sets the parent_id to the value" do + project = representer.from_hash(hash) + + expect(project[:parent_id]) + .to eq "5" + end + end + context "with the href being nil" do let(:hash) do {