From 8dbac61c57190a3fc4925e055292a30818dc596c Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 28 Jan 2026 21:10:53 +0300 Subject: [PATCH] Add project-scoped configuration API [OP#70979] Adds `GET /api/v3/projects/:id/configuration` endpoint that returns all global configuration properties plus project-specific settings. This allows client apps to check both enterprise token features (availableFeatures) and project settings (enabledInternalComments) in a single API call. --- .../schemas/project_configuration_model.yml | 10 ++ docs/api/apiv3/openapi-spec.yml | 4 + .../api/apiv3/paths/project_configuration.yml | 53 +++++++ .../project_configuration_api.rb | 48 ++++++ .../project_configuration_representer.rb | 67 ++++++++ lib/api/v3/utilities/path_helper.rb | 8 + lib/api/v3/workspaces/nested_apis.rb | 1 + .../work_packages_controller_spec.rb | 2 +- .../work_package/activities_spec.rb | 2 +- spec/helpers/work_packages_helper_spec.rb | 2 +- .../emoji_reactions/grouped_queries_spec.rb | 4 +- ...ctivities_by_work_package_resource_spec.rb | 2 +- ...tions_by_work_package_comments_api_spec.rb | 2 +- .../project_configuration_resource_spec.rb | 147 ++++++++++++++++++ .../activities_tab/paginator_spec.rb | 2 +- 15 files changed, 346 insertions(+), 8 deletions(-) create mode 100644 docs/api/apiv3/components/schemas/project_configuration_model.yml create mode 100644 docs/api/apiv3/paths/project_configuration.yml create mode 100644 lib/api/v3/projects/configuration/project_configuration_api.rb create mode 100644 lib/api/v3/projects/configuration/project_configuration_representer.rb create mode 100644 spec/requests/api/v3/projects/configuration/project_configuration_resource_spec.rb diff --git a/docs/api/apiv3/components/schemas/project_configuration_model.yml b/docs/api/apiv3/components/schemas/project_configuration_model.yml new file mode 100644 index 00000000000..b9071e4491c --- /dev/null +++ b/docs/api/apiv3/components/schemas/project_configuration_model.yml @@ -0,0 +1,10 @@ +# Schema: ProjectConfigurationModel +--- +allOf: + - $ref: "./configuration_model.yml" + - type: object + properties: + enabledInternalComments: + type: boolean + description: Whether internal comments are enabled for this project + readOnly: true diff --git a/docs/api/apiv3/openapi-spec.yml b/docs/api/apiv3/openapi-spec.yml index 4310e19bf45..4c92879d312 100644 --- a/docs/api/apiv3/openapi-spec.yml +++ b/docs/api/apiv3/openapi-spec.yml @@ -364,6 +364,8 @@ paths: "$ref": "./paths/project_versions.yml" "/api/v3/projects/{id}/favorite": "$ref": "./paths/project_favorite.yml" + "/api/v3/projects/{id}/configuration": + "$ref": "./paths/project_configuration.yml" "/api/v3/queries": "$ref": "./paths/queries.yml" "/api/v3/queries/available_projects": @@ -717,6 +719,8 @@ components: "$ref": "./components/schemas/collection_model.yml" ConfigurationModel: "$ref": "./components/schemas/configuration_model.yml" + ProjectConfigurationModel: + "$ref": "./components/schemas/project_configuration_model.yml" CustomActionModel: "$ref": "./components/schemas/custom_action_model.yml" CustomOptionModel: diff --git a/docs/api/apiv3/paths/project_configuration.yml b/docs/api/apiv3/paths/project_configuration.yml new file mode 100644 index 00000000000..a50141e94ff --- /dev/null +++ b/docs/api/apiv3/paths/project_configuration.yml @@ -0,0 +1,53 @@ +# /api/v3/projects/{id}/configuration +--- +get: + parameters: + - description: Project id + example: '1' + in: path + name: id + required: true + schema: + type: integer + responses: + '200': + content: + application/hal+json: + examples: + response: + value: + _type: Configuration + _links: + self: + href: "/api/v3/projects/1/configuration" + userPreferences: + href: "/api/v3/my_preferences" + maximumAttachmentFileSize: 5242880 + perPageOptions: + - 20 + - 100 + enabledInternalComments: true + schema: + "$ref": "../components/schemas/project_configuration_model.yml" + description: OK + headers: {} + '404': + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The requested resource could not be found. + description: Returned if the project does not exist or the user cannot view it. + headers: {} + tags: + - Configuration + description: |- + Returns the configuration scoped to a specific project, including all global + configuration properties plus project-specific settings. + operationId: View_project_configuration + summary: View project configuration diff --git a/lib/api/v3/projects/configuration/project_configuration_api.rb b/lib/api/v3/projects/configuration/project_configuration_api.rb new file mode 100644 index 00000000000..53f0aef02f6 --- /dev/null +++ b/lib/api/v3/projects/configuration/project_configuration_api.rb @@ -0,0 +1,48 @@ +# 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 + module Configuration + class ProjectConfigurationAPI < ::API::OpenProjectAPI + resource :configuration do + get do + ProjectConfigurationRepresenter.new( + ProjectConfiguration.new(@project), + current_user: + ) + end + end + end + end + end + end +end diff --git a/lib/api/v3/projects/configuration/project_configuration_representer.rb b/lib/api/v3/projects/configuration/project_configuration_representer.rb new file mode 100644 index 00000000000..6a303a74962 --- /dev/null +++ b/lib/api/v3/projects/configuration/project_configuration_representer.rb @@ -0,0 +1,67 @@ +# 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 + module Configuration + # Extends Setting (global config) with project-specific settings. + # All global configuration is delegated to Setting, with project-specific + # settings overridden. + class ProjectConfiguration < SimpleDelegator + attr_reader :project + + delegate :id, to: :project + + def initialize(project) + super(Setting) + @project = project + end + + def enabled_internal_comments + project.enabled_internal_comments || false + end + end + + class ProjectConfigurationRepresenter < ::API::V3::Configuration::ConfigurationRepresenter + link :self do + { + href: api_v3_paths.project_configuration(represented.id) + } + end + + # Project-specific settings + property :enabled_internal_comments, + render_nil: true + end + end + end + end +end diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 7397e328531..d4f7b675782 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -222,6 +222,14 @@ module API "#{root}/configuration" end + def self.project_configuration(project_id) + "#{project(project_id)}/configuration" + end + + def self.workspace_configuration(workspace_id) + "#{workspace(workspace_id)}/configuration" + end + def self.create_project_work_package_form(project_id) "#{work_packages_by_project(project_id)}/form" end diff --git a/lib/api/v3/workspaces/nested_apis.rb b/lib/api/v3/workspaces/nested_apis.rb index 4f9c27d99c9..f3d8faa41a6 100644 --- a/lib/api/v3/workspaces/nested_apis.rb +++ b/lib/api/v3/workspaces/nested_apis.rb @@ -39,6 +39,7 @@ module API mount API::V3::Versions::VersionsByProjectAPI mount API::V3::Queries::QueriesByWorkspaceAPI mount API::V3::Favorites::FavoriteActionsAPI, with: { favorite_object_getter: ->(*) { @project } } + mount API::V3::Projects::Configuration::ProjectConfigurationAPI end end end diff --git a/spec/controllers/work_packages_controller_spec.rb b/spec/controllers/work_packages_controller_spec.rb index ca4fde8d9e1..1552aa1e892 100644 --- a/spec/controllers/work_packages_controller_spec.rb +++ b/spec/controllers/work_packages_controller_spec.rb @@ -343,7 +343,7 @@ RSpec.describe WorkPackagesController do end end - context "and the user has permission to see such comments" do + context "and the user has permission to see such comments", with_ee: [:internal_comments] do before do login_as admin end diff --git a/spec/features/activities/work_package/activities_spec.rb b/spec/features/activities/work_package/activities_spec.rb index 6ce7b89ab18..efcea076348 100644 --- a/spec/features/activities/work_package/activities_spec.rb +++ b/spec/features/activities/work_package/activities_spec.rb @@ -31,7 +31,7 @@ require "spec_helper" require "support/flash/expectations" -RSpec.describe "Work package activity", :js, :with_cuprite do +RSpec.describe "Work package activity", :js, :with_cuprite, with_ee: %i[internal_comments] do include Flash::Expectations let(:project) { create(:project, enabled_internal_comments: true) } diff --git a/spec/helpers/work_packages_helper_spec.rb b/spec/helpers/work_packages_helper_spec.rb index 0cd2228c751..20c06a8cf70 100644 --- a/spec/helpers/work_packages_helper_spec.rb +++ b/spec/helpers/work_packages_helper_spec.rb @@ -154,7 +154,7 @@ RSpec.describe WorkPackagesHelper do end end - context "and the user has permissions to see internal notes" do + context "and the user has permissions to see internal notes", with_ee: [:internal_comments] do let(:permissions) { %i[view_work_packages view_internal_comments] } it "returns the last unrestricted note" do diff --git a/spec/models/concerns/emoji_reactions/grouped_queries_spec.rb b/spec/models/concerns/emoji_reactions/grouped_queries_spec.rb index bcf3dec632b..aa59d6e621f 100644 --- a/spec/models/concerns/emoji_reactions/grouped_queries_spec.rb +++ b/spec/models/concerns/emoji_reactions/grouped_queries_spec.rb @@ -68,7 +68,7 @@ RSpec.describe EmojiReactions::GroupedQueries do expect(result[1].reacting_users).to eq([[user2.id, user2.name]]) end - context "when the current user is allowed to view internal comments" do + context "when the current user is allowed to view internal comments", with_ee: [:internal_comments] do let(:current_user) do create(:user, member_with_permissions: { work_package.project => %i[view_work_packages view_internal_comments] }) end @@ -124,7 +124,7 @@ RSpec.describe EmojiReactions::GroupedQueries do ) end - context "when the current user is allowed to view internal comments" do + context "when the current user is allowed to view internal comments", with_ee: [:internal_comments] do let(:current_user) do create(:user, member_with_permissions: { work_package.project => %i[view_work_packages view_internal_comments] }) end diff --git a/spec/requests/api/v3/activities_by_work_package_resource_spec.rb b/spec/requests/api/v3/activities_by_work_package_resource_spec.rb index 31f2e9c37ea..8dc18ab93dd 100644 --- a/spec/requests/api/v3/activities_by_work_package_resource_spec.rb +++ b/spec/requests/api/v3/activities_by_work_package_resource_spec.rb @@ -211,7 +211,7 @@ RSpec.describe API::V3::Activities::ActivitiesByWorkPackageAPI, with_ee: [:inter end context "and internal comments are disabled on the project" do - let(:permissions) { %i(view_work_packages view_internal_comments add_internal_comments) } + let(:permissions) { %i(view_work_packages add_work_package_comments view_internal_comments add_internal_comments) } include_context "to create internal comment", internal: true, enabled_internal_comments: false diff --git a/spec/requests/api/v3/emoji_reactions/emoji_reactions_by_work_package_comments_api_spec.rb b/spec/requests/api/v3/emoji_reactions/emoji_reactions_by_work_package_comments_api_spec.rb index c11f5bb83bd..464ca5c5417 100644 --- a/spec/requests/api/v3/emoji_reactions/emoji_reactions_by_work_package_comments_api_spec.rb +++ b/spec/requests/api/v3/emoji_reactions/emoji_reactions_by_work_package_comments_api_spec.rb @@ -110,7 +110,7 @@ RSpec.describe API::V3::EmojiReactions::EmojiReactionsByWorkPackageCommentsAPI d project.save! end - context "and user has permission to view internal comments" do + context "and user has permission to view internal comments", with_ee: [:internal_comments] do before do get api_v3_paths.emoji_reactions_by_work_package_comments(work_package.id) end diff --git a/spec/requests/api/v3/projects/configuration/project_configuration_resource_spec.rb b/spec/requests/api/v3/projects/configuration/project_configuration_resource_spec.rb new file mode 100644 index 00000000000..7f7b3297891 --- /dev/null +++ b/spec/requests/api/v3/projects/configuration/project_configuration_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 Configuration resource" do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + let(:project) { create(:project) } + let(:user) { create(:user, member_with_permissions: { project => [:view_project] }) } + + current_user { user } + + describe "GET /api/v3/projects/:id/configuration" do + let(:path) { api_v3_paths.project_configuration(project.id) } + + subject(:response) do + get path + last_response + end + + context "when user can view project" do + it "returns 200 OK" do + expect(response).to have_http_status(:ok) + end + + it "returns Configuration type" do + expect(response.body) + .to be_json_eql("Configuration".to_json) + .at_path("_type") + end + + it "includes self link to project configuration" do + expect(response.body) + .to be_json_eql(api_v3_paths.project_configuration(project.id).to_json) + .at_path("_links/self/href") + end + + it "includes global configuration properties", with_settings: { per_page_options: "20, 100" } do + expect(response.body).to have_json_path("maximumAttachmentFileSize") + expect(response.body).to have_json_path("perPageOptions") + expect(response.body).to have_json_path("availableFeatures") + expect(response.body) + .to be_json_eql([20, 100].to_json) + .at_path("perPageOptions") + end + + context "when enabled_internal_comments is true" do + before do + project.update!(enabled_internal_comments: true) + end + + it "returns enabledInternalComments as true" do + expect(response.body) + .to be_json_eql(true.to_json) + .at_path("enabledInternalComments") + end + end + + context "when enabled_internal_comments is false" do + before do + project.update!(enabled_internal_comments: false) + end + + it "returns enabledInternalComments as false" do + expect(response.body) + .to be_json_eql(false.to_json) + .at_path("enabledInternalComments") + end + end + + context "when enabled_internal_comments is nil (default)" do + before do + # Ensure project.enabled_internal_comments is nil + project.settings["enabled_internal_comments"] = nil + project.save! + end + + it "returns enabledInternalComments as false" do + expect(response.body) + .to be_json_eql(false.to_json) + .at_path("enabledInternalComments") + end + end + end + + context "when user cannot view project" do + let(:other_user) { create(:user) } + + current_user { other_user } + + it "returns 404 Not Found" do + expect(response).to have_http_status(:not_found) + end + end + end + + describe "GET /api/v3/workspaces/:id/configuration" do + let(:path) { api_v3_paths.workspace_configuration(project.id) } + + subject(:response) do + get path + last_response + end + + context "when user can view project" do + it "returns 200 OK" do + expect(response).to have_http_status(:ok) + end + + it "returns Configuration type" do + expect(response.body) + .to be_json_eql("Configuration".to_json) + .at_path("_type") + end + end + end +end diff --git a/spec/services/work_packages/activities_tab/paginator_spec.rb b/spec/services/work_packages/activities_tab/paginator_spec.rb index 62833aabd36..02de1178c8f 100644 --- a/spec/services/work_packages/activities_tab/paginator_spec.rb +++ b/spec/services/work_packages/activities_tab/paginator_spec.rb @@ -229,7 +229,7 @@ RSpec.describe WorkPackages::ActivitiesTab::Paginator, with_settings: { journal_ work_package.project.save! end - context "when user can see internal comments" do + context "when user can see internal comments", with_ee: [:internal_comments] do it "includes internal journals" do _pagy, records = paginator.call