From cb149f351735fa92cfd9d4d7fdc71e3eb2a6142c Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 18 Mar 2026 14:19:00 +0100 Subject: [PATCH] Allow filtering by group hierarchy --- .../members/user_filter_component.rb | 6 + app/models/queries/members.rb | 1 + .../members/filters/group_hierarchy_filter.rb | 80 +++++++++++++ .../filters/group_hierarchy_filter_spec.rb | 107 ++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 app/models/queries/members/filters/group_hierarchy_filter.rb create mode 100644 spec/models/queries/members/filters/group_hierarchy_filter_spec.rb diff --git a/app/components/members/user_filter_component.rb b/app/components/members/user_filter_component.rb index 87eee13dd37..a37f6c65759 100644 --- a/app/components/members/user_filter_component.rb +++ b/app/components/members/user_filter_component.rb @@ -113,6 +113,12 @@ module Members end end + def filter_group(query, group_id) + if group_id.present? + query.where(:group_hierarchy, "=", group_id) + end + end + protected def filter_shares(query, role_id) diff --git a/app/models/queries/members.rb b/app/models/queries/members.rb index 27e7b3ab669..dde7a967fa9 100644 --- a/app/models/queries/members.rb +++ b/app/models/queries/members.rb @@ -36,6 +36,7 @@ module Queries::Members filter Filters::StatusFilter filter Filters::BlockedFilter filter Filters::GroupFilter + filter Filters::GroupHierarchyFilter filter Filters::RoleFilter filter Filters::PrincipalFilter filter Filters::PrincipalTypeFilter diff --git a/app/models/queries/members/filters/group_hierarchy_filter.rb b/app/models/queries/members/filters/group_hierarchy_filter.rb new file mode 100644 index 00000000000..1b0c60b883e --- /dev/null +++ b/app/models/queries/members/filters/group_hierarchy_filter.rb @@ -0,0 +1,80 @@ +# 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. +#++ + +# Like GroupFilter but hierarchy-aware: matches members who are users of the +# given group or any of its descendant groups, as well as the descendant +# groups themselves (which carry inherited Member records). +class Queries::Members::Filters::GroupHierarchyFilter < Queries::Members::Filters::MemberFilter + def self.key + :group_hierarchy + end + + def allowed_values + @allowed_values ||= ::Group.pluck(:id).map { |g| [g, g.to_s] } + end + + def available? + ::Group.exists? + end + + def type + :list_optional + end + + def human_name + I18n.t("query_fields.member_of_group") + end + + def joins + :principal + end + + def where + case operator + when "=" + "users.id IN (#{hierarchy_subselect})" + when "!" + "users.id NOT IN (#{hierarchy_subselect})" + when "*" + "users.id IN (#{User.within_group([]).select(:id).to_sql})" + when "!*" + "users.id NOT IN (#{User.within_group([]).select(:id).to_sql})" + end + end + + private + + def hierarchy_subselect + groups = Group.where(id: values.map(&:to_i)) + all_group_ids = groups.flat_map { |g| g.self_and_descendants.pluck(:id) }.uniq + user_ids = User.in_group(all_group_ids).pluck(:id) + (user_ids + all_group_ids).uniq.join(",").presence || "NULL" + end +end diff --git a/spec/models/queries/members/filters/group_hierarchy_filter_spec.rb b/spec/models/queries/members/filters/group_hierarchy_filter_spec.rb new file mode 100644 index 00000000000..862ad8ba73d --- /dev/null +++ b/spec/models/queries/members/filters/group_hierarchy_filter_spec.rb @@ -0,0 +1,107 @@ +# 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 Queries::Members::Filters::GroupHierarchyFilter do + include_context "filter tests" + + let(:project) { create(:project) } + let(:admin) { create(:admin) } + let(:role) { create(:project_role) } + + let(:root_user) { create(:user) } + let(:mid_user) { create(:user) } + let(:leaf_user) { create(:user) } + let(:unrelated_user) { create(:user) } + + let!(:root_group) { create(:group, members: [root_user]) } + let!(:mid_group) { create(:group, members: [mid_user], parent: root_group) } + let!(:leaf_group) { create(:group, members: [leaf_user], parent: mid_group) } + let!(:other_group) { create(:group, members: [unrelated_user]) } + + before do + allow(Notifications::GroupMemberAlteredJob).to receive(:perform_later) + + Members::CreateService + .new(user: admin) + .call(principal: root_group, project_id: project.id, role_ids: [role.id]) + end + + it "has key :group_hierarchy" do + expect(described_class.key).to eq(:group_hierarchy) + end + + describe "#allowed_values" do + it "lists all group IDs" do + expect(instance.allowed_values.map(&:first)) + .to include(root_group.id, mid_group.id, leaf_group.id, other_group.id) + end + end + + describe '#where with operator "="' do + let(:operator) { "=" } + let(:values) { [root_group.id.to_s] } + + it "returns members for users in the group and its descendants, plus the descendant groups" do + members = Member.joins(:principal).where(project:).where(instance.where) + + principal_ids = members.pluck(:user_id) + + # Users from root, mid, and leaf groups + expect(principal_ids).to include(root_user.id, mid_user.id, leaf_user.id) + # Descendant groups themselves + expect(principal_ids).to include(mid_group.id, leaf_group.id) + # The root group itself (it's in the tree) + expect(principal_ids).to include(root_group.id) + # Unrelated user is excluded + expect(principal_ids).not_to include(unrelated_user.id) + end + end + + describe '#where with operator "!"' do + let(:operator) { "!" } + let(:values) { [root_group.id.to_s] } + + it "excludes members for users in the group hierarchy and the descendant groups" do + # Add unrelated_user as a member so there's something to match + Members::CreateService + .new(user: admin) + .call(principal: other_group, project_id: project.id, role_ids: [role.id]) + + members = Member.joins(:principal).where(project:).where(instance.where) + principal_ids = members.pluck(:user_id) + + expect(principal_ids).to include(unrelated_user.id, other_group.id) + expect(principal_ids).not_to include(root_user.id, mid_user.id, leaf_user.id) + expect(principal_ids).not_to include(mid_group.id, leaf_group.id) + end + end +end