From d22b3917fc6f48feb5cc506aabb6cb64a811e247 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Thu, 9 Apr 2026 15:16:58 +0200 Subject: [PATCH 1/9] [#73104] Add Sprint column to work package table --- .../lib/open_project/backlogs/engine.rb | 1 + .../backlogs/work_package_sprint_select.rb | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 modules/backlogs/lib/open_project/backlogs/work_package_sprint_select.rb 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..9544e70d7d5f --- /dev/null +++ b/modules/backlogs/lib/open_project/backlogs/work_package_sprint_select.rb @@ -0,0 +1,52 @@ +# 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 + class_attribute :sprint_selects + + self.sprint_selects = { + sprint: { + association: "sprint", + sortable: %w(name start_date finish_date), + groupable: "#{WorkPackage.table_name}.sprint_id" + } + } + + def self.instances(context = nil) + return [] if context && !context.backlogs_enabled? + return [] unless OpenProject::FeatureDecisions.scrum_projects_active? + + sprint_selects.map do |name, options| + new(name, options) + end + end + end +end From ec1bae67435b5d576dfe9288dd7d7c61b734db58 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Fri, 10 Apr 2026 14:52:31 +0200 Subject: [PATCH 2/9] [#73104] Check permission for Sprint filter --- .../open_project/backlogs/work_package_sprint_select.rb | 9 +++++++++ 1 file changed, 9 insertions(+) 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 index 9544e70d7d5f..ddb71ad2ecf9 100644 --- a/modules/backlogs/lib/open_project/backlogs/work_package_sprint_select.rb +++ b/modules/backlogs/lib/open_project/backlogs/work_package_sprint_select.rb @@ -43,10 +43,19 @@ class WorkPackageSprintSelect < Queries::WorkPackages::Selects::WorkPackageSelec 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) sprint_selects.map do |name, options| new(name, options) end 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 end end From e7242f09323ff95d6a104aa57f41f65b867402c4 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Fri, 10 Apr 2026 15:50:12 +0200 Subject: [PATCH 3/9] [#73104] Allow resource linking for sprints This is necessary to make the group_by work --- lib/api/v3/utilities/resource_link_generator.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/api/v3/utilities/resource_link_generator.rb b/lib/api/v3/utilities/resource_link_generator.rb index d1901e6b27fa..62621eaa4f4b 100644 --- a/lib/api/v3/utilities/resource_link_generator.rb +++ b/lib/api/v3/utilities/resource_link_generator.rb @@ -61,6 +61,10 @@ def determine_path_method(record) :revision when ::CustomField::Hierarchy::HierarchyItemAdapter :custom_field_item + when Agile::Sprint + # Strictly speaking, this belongs into the backlogs module, but since we will rename that class + # to `Sprint` soon, we will be able to remove this code branch. + :sprint else record.class.model_name.singular end From 50147668f3e145ac6314f37d9e58d58e8835d9c7 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Fri, 10 Apr 2026 20:21:33 +0200 Subject: [PATCH 4/9] [#73104] Remove backlogs module pollution from core We delegate the resource name to the object itself, that way we can override it in our record and no knowledge about the backlogs module is necessary in the core. --- lib/api/v3/utilities/resource_link_generator.rb | 12 ++++++------ modules/backlogs/app/models/agile/sprint.rb | 2 ++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/api/v3/utilities/resource_link_generator.rb b/lib/api/v3/utilities/resource_link_generator.rb index 62621eaa4f4b..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 @@ -61,10 +65,6 @@ def determine_path_method(record) :revision when ::CustomField::Hierarchy::HierarchyItemAdapter :custom_field_item - when Agile::Sprint - # Strictly speaking, this belongs into the backlogs module, but since we will rename that class - # to `Sprint` soon, we will be able to remove this code branch. - :sprint else record.class.model_name.singular end diff --git a/modules/backlogs/app/models/agile/sprint.rb b/modules/backlogs/app/models/agile/sprint.rb index ad53778ff60c..981f12e4fa25 100644 --- a/modules/backlogs/app/models/agile/sprint.rb +++ b/modules/backlogs/app/models/agile/sprint.rb @@ -106,5 +106,7 @@ def visible_to?(project) end def to_s = name + + def api_resource_link_name = :sprint end end From cae39e2f1c82bd86df6761901fc414a26226bae0 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Fri, 10 Apr 2026 20:46:09 +0200 Subject: [PATCH 5/9] [#73104] Add explanatory comment to #api_resource_link_name --- modules/backlogs/app/models/agile/sprint.rb | 8 +++++++- modules/backlogs/spec/models/agile/sprint_spec.rb | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/modules/backlogs/app/models/agile/sprint.rb b/modules/backlogs/app/models/agile/sprint.rb index 981f12e4fa25..0016930baed6 100644 --- a/modules/backlogs/app/models/agile/sprint.rb +++ b/modules/backlogs/app/models/agile/sprint.rb @@ -107,6 +107,12 @@ def visible_to?(project) def to_s = name - def api_resource_link_name = :sprint + 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/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 From 4c79e473e6073a8271bb61a949d2d3382916f488 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Mon, 13 Apr 2026 11:56:23 +0200 Subject: [PATCH 6/9] [#73104] Make GROUP BY work with permission checking --- .../backlogs/work_package_sprint_select.rb | 59 +++- .../work_packages/sprints_on_wp_table_spec.rb | 280 ++++++++++++++++++ 2 files changed, 328 insertions(+), 11 deletions(-) create mode 100644 modules/backlogs/spec/features/work_packages/sprints_on_wp_table_spec.rb 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 index ddb71ad2ecf9..059c906a98dc 100644 --- a/modules/backlogs/lib/open_project/backlogs/work_package_sprint_select.rb +++ b/modules/backlogs/lib/open_project/backlogs/work_package_sprint_select.rb @@ -30,24 +30,25 @@ module OpenProject::Backlogs class WorkPackageSprintSelect < Queries::WorkPackages::Selects::WorkPackageSelect - class_attribute :sprint_selects + SORT_ORDER = %w[visible_sprints.name + visible_sprints.start_date + visible_sprints.finish_date].freeze - self.sprint_selects = { - sprint: { - association: "sprint", - sortable: %w(name start_date finish_date), - groupable: "#{WorkPackage.table_name}.sprint_id" - } - } + 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 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) - sprint_selects.map do |name, options| - new(name, options) - end + [new] end def self.user_allowed_to_select_sprint?(context) @@ -57,5 +58,41 @@ def self.user_allowed_to_select_sprint?(context) User.current.allowed_in_any_project?(:view_sprints) end 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 + + 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..29bb6f4da23f --- /dev/null +++ b/modules/backlogs/spec/features/work_packages/sprints_on_wp_table_spec.rb @@ -0,0 +1,280 @@ +# 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 + end +end From b7eb3917ccdcc8bc05b321ba18db6d872153c6ea Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Tue, 14 Apr 2026 07:43:20 +0200 Subject: [PATCH 7/9] [#73104] Add test case for user not being a project member --- .../work_packages/sprints_on_wp_table_spec.rb | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 index 29bb6f4da23f..23641b49b361 100644 --- 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 @@ -276,5 +276,28 @@ 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 From ce40bf4a2600cf4679ed37df79f5f1dde8caae6f Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Fri, 10 Apr 2026 20:55:01 +0200 Subject: [PATCH 8/9] [#73104] Specs for WorkPackageSprintSelect --- .../work_package_sprint_select_spec.rb | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 modules/backlogs/spec/models/queries/work_packages/selects/work_package_sprint_select_spec.rb 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 From 90275fde9e4dc38483811718e7e1ed931a2b14c5 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Tue, 14 Apr 2026 07:57:05 +0200 Subject: [PATCH 9/9] [#73104] Small refactor --- .../backlogs/work_package_sprint_select.rb | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) 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 index 059c906a98dc..af7b9eb2349a 100644 --- a/modules/backlogs/lib/open_project/backlogs/work_package_sprint_select.rb +++ b/modules/backlogs/lib/open_project/backlogs/work_package_sprint_select.rb @@ -34,15 +34,6 @@ class WorkPackageSprintSelect < Queries::WorkPackages::Selects::WorkPackageSelec visible_sprints.start_date visible_sprints.finish_date].freeze - 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 self.instances(context = nil) return [] if context && !context.backlogs_enabled? return [] unless OpenProject::FeatureDecisions.scrum_projects_active? @@ -59,6 +50,15 @@ def self.user_allowed_to_select_sprint?(context) 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 @@ -73,6 +73,9 @@ def group_by_statement 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"