diff --git a/lib/api/v3/utilities/resource_link_generator.rb b/lib/api/v3/utilities/resource_link_generator.rb index d1901e6b27fa..fafecd298e37 100644 --- a/lib/api/v3/utilities/resource_link_generator.rb +++ b/lib/api/v3/utilities/resource_link_generator.rb @@ -45,9 +45,13 @@ def make_link(record) private + # Since not all things are equally named between APIv3 and the rails code, + # we need to convert some names manually def determine_path_method(record) - # since not all things are equally named between APIv3 and the rails code, - # we need to convert some names manually + # Some objects offer a name for the API, use if available: + return record.api_resource_link_name if record.respond_to?(:api_resource_link_name) + + # Manual mapping: case record when Project :project diff --git a/modules/backlogs/app/models/agile/sprint.rb b/modules/backlogs/app/models/agile/sprint.rb index ad53778ff60c..0016930baed6 100644 --- a/modules/backlogs/app/models/agile/sprint.rb +++ b/modules/backlogs/app/models/agile/sprint.rb @@ -106,5 +106,13 @@ def visible_to?(project) end def to_s = name + + def api_resource_link_name + # This is necessary to make the API resource links match the sprint property name. + # E.g., when grouping by a sprint in the WP table. + # Since the property is called `sprint`, but the model_name is `agile_sprint`, there would + # otherwise be a mismatch. Avoid that: + :sprint + end end end diff --git a/modules/backlogs/lib/open_project/backlogs/engine.rb b/modules/backlogs/lib/open_project/backlogs/engine.rb index 004587ec6c89..b05fa50f7678 100644 --- a/modules/backlogs/lib/open_project/backlogs/engine.rb +++ b/modules/backlogs/lib/open_project/backlogs/engine.rb @@ -277,6 +277,7 @@ def self.settings filter OpenProject::Backlogs::WorkPackageFilter select OpenProject::Backlogs::QueryBacklogsSelect + select OpenProject::Backlogs::WorkPackageSprintSelect end end end diff --git a/modules/backlogs/lib/open_project/backlogs/work_package_sprint_select.rb b/modules/backlogs/lib/open_project/backlogs/work_package_sprint_select.rb new file mode 100644 index 000000000000..af7b9eb2349a --- /dev/null +++ b/modules/backlogs/lib/open_project/backlogs/work_package_sprint_select.rb @@ -0,0 +1,101 @@ +# 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 OpenProject::Backlogs + class WorkPackageSprintSelect < Queries::WorkPackages::Selects::WorkPackageSelect + SORT_ORDER = %w[visible_sprints.name + visible_sprints.start_date + visible_sprints.finish_date].freeze + + def self.instances(context = nil) + return [] if context && !context.backlogs_enabled? + return [] unless OpenProject::FeatureDecisions.scrum_projects_active? + return [] unless user_allowed_to_select_sprint?(context) + + [new] + end + + def self.user_allowed_to_select_sprint?(context) + if context + User.current.allowed_in_project?(:view_sprints, context) + else + User.current.allowed_in_any_project?(:view_sprints) + end + end + + def initialize + # Cannot use `association` here since that will break our custom GROUP BY + super(:sprint, + sortable: SORT_ORDER, + groupable_join: sprint_join_with_permissions, + groupable: group_by_statement, + groupable_select: groupable_select) + end + + def sortable_join_statement(_query) + sprint_join_with_permissions + end + + def groupable_select + group_by_statement + end + + def group_by_statement + "visible_sprints.id" + end + + private + + # Custom outer join to ensure that sprints the user cannot show are treated like + # they are not there at all. Without this, group counts would not match the listed + # work packages. + def sprint_join_with_permissions + <<~SQL.squish + LEFT OUTER JOIN "projects" ON "projects"."id" = "work_packages"."project_id" + LEFT OUTER JOIN ( + SELECT + s.id, + s.name, + s.start_date, + s.finish_date, + s.project_id + FROM sprints s + WHERE s.project_id IN (#{projects_with_view_sprints_permissions.to_sql}) + ) AS visible_sprints + ON visible_sprints.id = work_packages.sprint_id + AND visible_sprints.project_id = work_packages.project_id + SQL + end + + def projects_with_view_sprints_permissions + Project.allowed_to(User.current, :view_sprints).select(:id) + end + end +end diff --git a/modules/backlogs/spec/features/work_packages/sprints_on_wp_table_spec.rb b/modules/backlogs/spec/features/work_packages/sprints_on_wp_table_spec.rb new file mode 100644 index 000000000000..23641b49b361 --- /dev/null +++ b/modules/backlogs/spec/features/work_packages/sprints_on_wp_table_spec.rb @@ -0,0 +1,303 @@ +# 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" + +RSpec.describe "Sprint displayed and selectable on work package table", :js do + let(:enabled_module_names) { %i[backlogs work_package_tracking] } + let(:start_date) { Date.new(2025, 10, 5) } + let(:finish_date) { Date.new(2025, 10, 25) } + let(:other_start_date) { start_date + 20.days } + let(:other_finish_date) { finish_date + 20.days } + let(:sprint) { create(:agile_sprint, project:, name: "Sprint", start_date:, finish_date:) } + let(:other_sprint_name) { "Other sprint" } + let(:other_sprint) do + create(:agile_sprint, + project:, + name: other_sprint_name, + start_date: other_start_date, + finish_date: other_finish_date) + end + let(:sprint_from_other_project) { create(:agile_sprint, project: another_project, name: "Sprint from other project") } + let(:project) { create(:project, name: "Project", enabled_module_names:) } + let(:another_project) { create(:project, name: "Another project", enabled_module_names:) } + let(:all_permissions) do + %i[ + view_work_packages + edit_work_packages + manage_work_package_relations + add_work_packages + delete_work_packages + + view_sprints + manage_sprint_items + ] + end + let(:work_package) do + create(:work_package, + project:, + sprint:, + subject: "first wp", + author: current_user) + end + let!(:other_wp) do + create(:work_package, + project:, + sprint: other_sprint, + subject: "other wp", + author: current_user) + end + let!(:wp_without_sprint) do + create(:work_package, + project:, + subject: "wp without sprint", + author: current_user) + end + let!(:wp_from_another_project) do + create(:work_package, + project: another_project, + subject: "wp from another project", + author: current_user) + end + let!(:wp_with_sprint_from_another_project) do + create(:work_package, + project: another_project, + sprint: sprint_from_other_project, + subject: "wp with sprint from another project", + author: current_user) + end + let!(:wp_table) { Pages::WorkPackagesTable.new(work_package.project) } + let(:sort_criteria) { nil } + let(:group_by) { nil } + let(:user) do + create(:user, + member_with_permissions: { + project => project_permissions, + another_project => another_project_permissions + }) + end + let(:project_permissions) { all_permissions } + let(:another_project_permissions) { all_permissions } + let!(:query) do + build(:public_query, user: current_user, project: work_package.project) + end + let(:query_columns) { %w(subject sprint) } + let(:query_filters) { nil } + + current_user { user } + + before do + query.column_names = query_columns + query.sort_criteria = sort_criteria if sort_criteria + query.group_by = group_by if group_by + query.filters.clear + + if query_filters.present? + query_filters.each do |filter| + query.add_filter(filter[:name], filter[:operator], filter[:values]) + end + end + + query.show_hierarchies = false + query.save! + + wp_table.visit_query query + + wait_for_network_idle + end + + context "when the feature flag is on", with_flag: { scrum_projects: true } do + context "when viewing sprints" do + it "shows the sprint column with the correct sprint for the work package" do + wp_table.expect_work_package_with_attributes(work_package, { sprint: sprint.name }) + wp_table.expect_work_package_with_attributes(other_wp, { sprint: other_sprint.name }) + wp_table.expect_work_package_with_attributes(wp_without_sprint, { sprint: "-" }) + end + + describe "filtering" do + let(:query_filters) do + [{ name: "sprint_id", operator:, values: }] + end + + context "when filtering to include a sprint" do + let(:operator) { "=" } + let(:values) { [sprint.id.to_s] } + + it "only shows work packages with this sprint" do + wp_table.expect_work_package_listed(work_package) + wp_table.ensure_work_package_not_listed!(other_wp, wp_without_sprint) + end + end + + context "when filtering to include multiple sprints" do + let(:operator) { "=" } + let(:values) { [sprint.id.to_s, other_sprint.id.to_s] } + + it "only shows work packages with these sprints" do + wp_table.expect_work_package_listed(work_package, other_wp) + wp_table.ensure_work_package_not_listed!(wp_without_sprint) + end + end + + context "when filtering to exclude a sprint" do + let(:operator) { "!" } + let(:values) { [other_sprint.id.to_s] } + + it "shows work packages with other sprints or without a sprint" do + wp_table.expect_work_package_listed(wp_without_sprint, work_package) + wp_table.ensure_work_package_not_listed!(other_wp) + end + end + + context "when filtering to have a sprint" do + let(:operator) { "*" } + let(:values) { nil } + + it "shows work packages with a sprint" do + wp_table.expect_work_package_listed(work_package, other_wp) + wp_table.ensure_work_package_not_listed!(wp_without_sprint) + end + end + + context "when filtering to not have a sprint" do + let(:operator) { "!*" } + let(:values) { nil } + + it "shows work packages without a sprint" do + wp_table.expect_work_package_listed(wp_without_sprint) + wp_table.ensure_work_package_not_listed!(work_package, other_wp) + end + end + end + + context "when sorting by sprint ASC" do + let(:sort_criteria) { [%w[sprint asc]] } + + it "sorts ASC by sprint name" do + wp_table.expect_work_package_order(other_wp, work_package, wp_without_sprint) + end + + context "when sorting via name and dates" do + # Name is identical to the first sprint now, so the dates are used as second sorting criterion: + let(:other_sprint_name) { sprint.name } + + it "sorts ASC by name and then start date and finish date" do + wp_table.expect_work_package_order(work_package, other_wp, wp_without_sprint) + end + end + end + + context "when sorting by sprint DESC" do + let(:sort_criteria) { [%w[sprint desc]] } + + it "sorts DESC by sprint name" do + wp_table.expect_work_package_order(wp_without_sprint, work_package, other_wp) + end + end + + context "when editing the value of a sprint cell" do + it "changes the value" do + wp_table.update_work_package_attributes(wp_without_sprint, sprint: sprint) + wp_table.expect_work_package_with_attributes(wp_without_sprint, { sprint: sprint.name }) + end + end + + context "when grouping by sprint" do + let(:group_by) { :sprint } + + it "groups by sprint" do + wp_table.expect_groups({ + sprint.name => 1, + other_sprint.name => 1, + "-" => 1 + }) + end + end + end + + context "without the necessary permissions to view sprints in some other projects" do + let!(:query) { build(:global_query, user: current_user) } + let(:another_project_permissions) { all_permissions - [:view_sprints] } + + it "does not render sprints you don't have permission for" do + # permission given, sprint visible: + wp_table.expect_work_package_with_attributes(work_package, { sprint: sprint.name }) + + # permission missing, sprint invisible: + wp_table.expect_work_package_with_attributes(wp_from_another_project, { sprint: "" }) + wp_table.expect_work_package_with_attributes(wp_with_sprint_from_another_project, { sprint: "" }) + end + + context "when sorting by sprint ASC" do + let(:sort_criteria) { [%w[sprint asc]] } + + it "sorts work packages from projects you don't have permission to like work packages without a sprint" do + wp_table.expect_work_package_order(other_wp, work_package, wp_with_sprint_from_another_project, + wp_from_another_project, wp_without_sprint) + end + end + + context "when grouping" do + let(:group_by) { :sprint } + + it "groups work packages from projects you don't have permission to like work packages without a sprint" do + wp_table.expect_groups({ + other_sprint.name => 1, + sprint.name => 1, + "-" => 3 + }) + end + end + end + + context "without being a member in a project at all" do + let!(:query) { build(:global_query, user: current_user) } + let!(:project_where_user_is_no_member) { create(:project) } + let!(:sprint_that_user_cannot_see) { create(:agile_sprint, project: project_where_user_is_no_member) } + let!(:work_package_that_user_cannot_see) do + create(:work_package, project: project_where_user_is_no_member, sprint: sprint_that_user_cannot_see) + end + + context "when grouping" do + let(:group_by) { :sprint } + + it "ignores work packages from projects you cannot see" do + wp_table.ensure_work_package_not_listed!(work_package_that_user_cannot_see) + wp_table.expect_groups({ + other_sprint.name => 1, + sprint.name => 1, + sprint_from_other_project.name => 1, + "-" => 2 # There are 3 work packages here, but the user only sees 2 + }) + end + end + end + end +end diff --git a/modules/backlogs/spec/models/agile/sprint_spec.rb b/modules/backlogs/spec/models/agile/sprint_spec.rb index 4fa907fc657b..87de6e32a782 100644 --- a/modules/backlogs/spec/models/agile/sprint_spec.rb +++ b/modules/backlogs/spec/models/agile/sprint_spec.rb @@ -392,4 +392,10 @@ expect(sprint.to_s).to eq("Sprint 1") end end + + describe "#api_resource_link_name" do + it "returns the resource link name" do + expect(sprint.api_resource_link_name).to eq(:sprint) + end + end end diff --git a/modules/backlogs/spec/models/queries/work_packages/selects/work_package_sprint_select_spec.rb b/modules/backlogs/spec/models/queries/work_packages/selects/work_package_sprint_select_spec.rb new file mode 100644 index 000000000000..d211bd9edc59 --- /dev/null +++ b/modules/backlogs/spec/models/queries/work_packages/selects/work_package_sprint_select_spec.rb @@ -0,0 +1,133 @@ +# 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 Rails.root.join("spec/models/queries/work_packages/selects/shared_query_select_specs").to_s + +RSpec.describe OpenProject::Backlogs::WorkPackageSprintSelect do # rubocop:disable RSpec/SpecFilePathFormat + let(:instance) { described_class.new(:sprint) } + + describe ".instances" do + context "when scrum projects feature flag is active", with_flag: { scrum_projects: true } do + context "when user has permission to view sprints in a project" do + let(:project) { build_stubbed(:project, enabled_module_names: %w[backlogs]) } + + current_user { build_stubbed(:user) } + + before do + mock_permissions_for current_user do |mock| + mock.allow_in_project(:view_sprints, project:) + end + end + + it "returns sprint select instances" do + instances = described_class.instances(project) + + expect(instances).to be_an(Array) + expect(instances.size).to eq(1) + expect(instances.first.name).to eq(:sprint) + end + end + + context "when user has permission to view sprints in any project" do + current_user { build_stubbed(:user) } + + before do + mock_permissions_for current_user do |mock| + mock.allow_in_project(:view_sprints, project: build_stubbed(:project)) + end + end + + it "returns sprint select instances when no context provided" do + instances = described_class.instances + + expect(instances).to be_an(Array) + expect(instances.size).to eq(1) + expect(instances.first.name).to eq(:sprint) + end + end + + context "when user lacks permission to view sprints in a(ny) project" do + let(:project) { build_stubbed(:project, enabled_module_names: %w[backlogs]) } + + current_user { build_stubbed(:user) } + + before do + mock_permissions_for current_user do |mock| + # No permissions granted + end + end + + it "returns an empty array" do + # Lacking permission in a project + expect(described_class.instances(project)).to eq([]) + + # Lacking permission in any project + expect(described_class.instances).to eq([]) + end + end + + context "when backlogs module is not enabled for the project" do + let(:project) { build_stubbed(:project, enabled_module_names: []) } + + current_user { build_stubbed(:user) } + + before do + mock_permissions_for current_user do |mock| + mock.allow_in_project(:view_sprints, project:) + end + end + + it "returns an empty array" do + instances = described_class.instances(project) + + expect(instances).to eq([]) + end + end + end + + context "when scrum projects feature flag is inactive", with_flag: { scrum_projects: false } do + let(:project) { build_stubbed(:project, enabled_module_names: %w[backlogs]) } + + current_user { build_stubbed(:user) } + + before do + mock_permissions_for current_user do |mock| + mock.allow_in_project(:view_sprints, project:) + end + end + + it "returns an empty array" do + expect(described_class.instances(project)).to eq([]) + expect(described_class.instances).to eq([]) + end + end + end +end