diff --git a/app/models/issue_query.rb b/app/models/issue_query.rb index f44e5e9..466d4f0 100644 --- a/app/models/issue_query.rb +++ b/app/models/issue_query.rb @@ -303,6 +303,8 @@ class IssueQuery < Query return @available_columns if @available_columns @available_columns = self.class.available_columns.dup + index = @available_columns.find_index {|column| column.name == :assigned_to} + @available_columns.insert((index ? index + 1 : -1), AssigneeGroupQueryColumn.new(self)) @available_columns += issue_custom_fields.visible.collect {|cf| QueryCustomFieldColumn.new(cf)} if User.current.allowed_to?(:view_time_entries, project, :global => true) @@ -349,6 +351,9 @@ class IssueQuery < Query if disabled_fields.include?("estimated_hours") disabled_fields += %w[total_estimated_hours estimated_remaining_hours] end + if disabled_fields.include?("assigned_to") + disabled_fields += %w[assigned_to_group] + end @available_columns.reject! do |column| disabled_fields.include?(column.name.to_s) end @@ -431,6 +436,9 @@ class IssueQuery < Query if has_custom_field_column? scope = scope.preload(:custom_values) end + if has_column?(:assigned_to_group) || group_by_column.is_a?(AssigneeGroupQueryColumn) + scope = scope.preload(:assigned_to) + end if has_column?(:watcher_users) scope = scope.preload(:watcher_users) end @@ -581,6 +589,40 @@ class IssueQuery < Query "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (#{sql})" end + # Returns the SQL expression of the id of the group used to group issues + # by "Assignee's group" (see AssigneeGroupQueryColumn) + def assignee_group_statement + "COALESCE(assignee_group_self.id, assignee_group_preferred.group_id, assignee_group_any.group_id)" + end + + # Returns the group used to group an issue assigned to principal + def assignee_group_for(principal) + if principal.is_a?(Group) + principal.type == 'Group' ? principal : nil + elsif principal.is_a?(User) + group_ids = assignee_group_ids_of(principal) + group_id = (group_ids & assignee_group_preferred_ids).min || group_ids.min + group_id ? assignee_groups_by_id([group_id])[group_id] : nil + end + end + + # Returns the visible groups of principal, or principal if it is a group + def assignee_groups_for(principal) + if principal.is_a?(Group) + principal.type == 'Group' ? [principal] : [] + elsif principal.is_a?(User) + group_ids = assignee_group_ids_of(principal) + assignee_groups_by_id(group_ids).values_at(*group_ids).compact.sort_by {|g| g.name.downcase} + else + [] + end + end + + def cast_assignee_group_keys(hash) + groups = assignee_groups_by_id(hash.keys.compact.map(&:to_i)) + hash.transform_keys {|k| k.nil? ? nil : groups[k.to_i]} + end + def sql_for_member_of_group_field(field, operator, value) if operator == '*' # Any group groups = Group.givable @@ -650,6 +692,62 @@ class IssueQuery < Query end private :group_and_member_ids + # Ids of the givable groups visible by the current user + def assignee_group_visible_ids + @assignee_group_visible_ids ||= Group.givable.visible.ids.sort + end + private :assignee_group_visible_ids + + # Ids of the visible groups selected in the "Assignee's group" filter + # with the "is" operator + def assignee_group_preferred_ids + @assignee_group_preferred_ids ||= + if operator_for('member_of_group') == '=' + values_for('member_of_group').map(&:to_i) & assignee_group_visible_ids + else + [] + end + end + private :assignee_group_preferred_ids + + def assignee_group_ids_of(user) + @assignee_group_ids_by_user ||= {} + @assignee_group_ids_by_user[user.id] ||= user.group_ids & assignee_group_visible_ids + end + private :assignee_group_ids_of + + def assignee_groups_by_id(ids) + @assignee_groups_by_id ||= {} + missing = ids - @assignee_groups_by_id.keys + if missing.any? + Group.where(:id => missing).each {|g| @assignee_groups_by_id[g.id] = g} + end + @assignee_groups_by_id.slice(*ids) + end + private :assignee_groups_by_id + + def joins_for_assignee_group + groups_users = "#{User.table_name_prefix}groups_users#{User.table_name_suffix}" + subquery = lambda do |ids| + condition = ids.any? ? "gu.group_id IN (#{ids.map(&:to_i).join(',')})" : "1=0" + "(SELECT gu.user_id, MIN(gu.group_id) AS group_id FROM #{groups_users} gu" \ + " WHERE #{condition} GROUP BY gu.user_id)" + end + + [ + "LEFT OUTER JOIN #{User.table_name} assignee_group_self" \ + " ON assignee_group_self.id = #{queried_table_name}.assigned_to_id" \ + " AND assignee_group_self.type = 'Group'", + "LEFT OUTER JOIN #{subquery.call(assignee_group_preferred_ids)} assignee_group_preferred" \ + " ON assignee_group_preferred.user_id = #{queried_table_name}.assigned_to_id", + "LEFT OUTER JOIN #{subquery.call(assignee_group_visible_ids)} assignee_group_any" \ + " ON assignee_group_any.user_id = #{queried_table_name}.assigned_to_id", + "LEFT OUTER JOIN #{User.table_name} assignee_group" \ + " ON assignee_group.id = #{assignee_group_statement}" + ] + end + private :joins_for_assignee_group + def sql_for_author_role_field(field, operator, value) role_cond = if value.any? @@ -967,6 +1065,9 @@ class IssueQuery < Query "LEFT OUTER JOIN #{IssuePriority.table_name}" \ " ON #{IssuePriority.table_name}.id = #{queried_table_name}.priority_id" end + if order_options.include?('assignee_group') + joins.concat(joins_for_assignee_group) + end end joins.any? ? joins.join(' ') : nil diff --git a/app/models/query.rb b/app/models/query.rb index 1467a91..c215a1c 100644 --- a/app/models/query.rb +++ b/app/models/query.rb @@ -112,6 +112,45 @@ class WatcherQueryColumn < QueryColumn end end +# Column for the group of an issue's assignee ("Assignee's group"). +# +# A user can belong to several groups, but a grouped query puts each issue +# under exactly one group, so the group used for grouping is chosen as follows: +# * if the issue is assigned to a group, that group; +# * otherwise, among the visible groups of the assignee, the one with the +# lowest id among the groups selected in the "Assignee's group" filter +# (operator "is"), if any, so that grouping is consistent with that filter; +# * otherwise, the visible group of the assignee with the lowest id. +# The displayed value lists all the visible groups of the assignee. +class AssigneeGroupQueryColumn < QueryColumn + def initialize(query) + super(:assigned_to_group, :caption => :field_member_of_group, + :sortable => "assignee_group.lastname", :groupable => true) + @query = query + end + + def value_object(issue) + @query.assignee_groups_for(issue.assigned_to) + end + + def value(issue) + value_object(issue) + end + + def group_value(issue) + @query.assignee_group_for(issue.assigned_to) + end + + def group_by_statement + @query.assignee_group_statement + end + + # Converts the group ids returned by SQL into Group objects + def cast_group_keys(hash) + @query.cast_assignee_group_keys(hash) + end +end + class QueryAssociationColumn < QueryColumn def initialize(association, attribute, options={}) @association = association @@ -1139,6 +1178,8 @@ class Query < ApplicationRecord c = group_by_column if c.is_a?(QueryCustomFieldColumn) r = r.keys.to_h { |k| [c.custom_field.cast_value(k), r[k]] } + elsif c.respond_to?(:cast_group_keys) + r = c.cast_group_keys(r) end end r diff --git a/test/unit/query_test.rb b/test/unit/query_test.rb index ef0d1ae..f05d72f 100644 --- a/test/unit/query_test.rb +++ b/test/unit/query_test.rb @@ -3060,6 +3060,155 @@ class QueryTest < ActiveSupport::TestCase assert_find_issues_with_query_is_successful @query end + def setup_assignee_group + User.current = User.find(1) + Group.destroy_all # No fixtures + @group_a = Group.generate! + @group_b = Group.generate! + @user_a = User.generate! + @user_b = User.generate! + @user_ab = User.generate! + @user_none = User.generate! + @group_a.users << @user_a + @group_a.users << @user_ab + @group_b.users << @user_b + @group_b.users << @user_ab + + @issues = {} + {:a => @user_a, :b => @user_b, :ab => @user_ab, :none => @user_none, :group_b => @group_b, :nobody => nil}.each do |key, principal| + issue = Issue.generate!(:project_id => 1) + issue.update_column :assigned_to_id, principal&.id + @issues[key] = issue.reload + end + end + + def assignee_group_query(filters={}) + query = IssueQuery.new(:name => '_', :group_by => 'assigned_to_group') + query.add_filter('status_id', '*', ['']) + query.add_filter('issue_id', '=', [@issues.values.map(&:id).join(',')]) + filters.each {|field, (operator, values)| query.add_filter(field, operator, values)} + query + end + + test "groupable_columns should include assigned_to_group" do + set_language_if_valid 'en' + query = IssueQuery.new + column = query.groupable_columns.detect {|c| c.name == :assigned_to_group} + assert_kind_of AssigneeGroupQueryColumn, column + assert_equal "Assignee's group", column.caption + end + + test "query grouped by assigned_to_group should be grouped" do + query = IssueQuery.new(:group_by => 'assigned_to_group') + assert query.grouped? + assert_equal :assigned_to_group, query.group_by_column.name + assert_not_nil query.group_by_statement + end + + test "cast_group_keys of assigned_to_group should convert ids to groups" do + column = IssueQuery.new(:group_by => 'assigned_to_group').group_by_column + assert_equal({}, column.cast_group_keys({})) + assert_equal({nil => 5}, column.cast_group_keys({nil => 5})) + assert_equal({Group.find(10) => 3, Group.find(11) => 2, nil => 1}, + column.cast_group_keys({10 => 3, 11 => 2, nil => 1})) + end + + test "available_columns should not include assigned_to_group if assigned_to is disabled" do + Tracker.visible.each do |tracker| + tracker.core_fields = tracker.core_fields - ['assigned_to_id'] + tracker.save! + end + available_columns = IssueQuery.new.available_columns.map(&:name) + assert_not_include :assigned_to, available_columns + assert_not_include :assigned_to_group, available_columns + end + + test "result_count_by_group by assigned_to_group should use the lowest group id without filter" do + setup_assignee_group + count_by_group = assignee_group_query.result_count_by_group + + # a, ab => A; b, group_b => B; none, nobody => nil + assert_equal({@group_a => 2, @group_b => 2, nil => 2}, count_by_group) + end + + test "result_count_by_group by assigned_to_group should prefer the groups of the member_of_group filter" do + setup_assignee_group + query = assignee_group_query('member_of_group' => ['=', [@group_b.id.to_s]]) + + # b, ab, group_b match the filter and all are counted under B + assert_equal({@group_b => 3}, query.result_count_by_group) + end + + test "group_value of assigned_to_group should be consistent with result_count_by_group" do + setup_assignee_group + [ + {}, + {'member_of_group' => ['=', [@group_b.id.to_s]]}, + {'member_of_group' => ['=', [@group_a.id.to_s, @group_b.id.to_s]]}, + {'member_of_group' => ['*', ['']]}, + ].each do |filters| + query = assignee_group_query(filters) + column = query.group_by_column + issues = query.issues + expected = issues.group_by {|issue| column.group_value(issue)}.transform_values(&:size) + assert_equal expected, query.result_count_by_group, "filters: #{filters.inspect}" + end + end + + test "group_value of assigned_to_group" do + setup_assignee_group + query = assignee_group_query('member_of_group' => ['=', [@group_b.id.to_s]]) + column = query.group_by_column + assert_equal @group_a, assignee_group_query.group_by_column.group_value(@issues[:ab]) + assert_equal @group_b, column.group_value(@issues[:ab]) + assert_equal @group_b, column.group_value(@issues[:group_b]) + assert_nil column.group_value(@issues[:none]) + assert_nil column.group_value(@issues[:nobody]) + end + + test "issues grouped by assigned_to_group should be sorted by group name" do + setup_assignee_group + query = assignee_group_query + column = query.group_by_column + groups = query.issues.map {|issue| column.group_value(issue)}.compact + assert_equal groups.uniq.sort_by(&:name), groups.uniq + assert_equal groups.chunk_while {|a, b| a == b}.map(&:first), groups.uniq + end + + test "value of assigned_to_group should list all the groups of the assignee" do + setup_assignee_group + column = assignee_group_query.group_by_column + assert_equal [@group_a, @group_b].sort_by(&:name), column.value(@issues[:ab]) + assert_equal [@group_b], column.value(@issues[:group_b]) + assert_equal [], column.value(@issues[:none]) + end + + test "total_by_group_for by assigned_to_group should be grouped by assignee's group" do + setup_assignee_group + @issues[:a].update_column :estimated_hours, 1.5 + @issues[:ab].update_column :estimated_hours, 2.0 + @issues[:b].update_column :estimated_hours, 4.0 + totals = assignee_group_query.total_by_group_for(:estimated_hours) + assert_equal 3.5, totals[@group_a] + assert_equal 4.0, totals[@group_b] + end + + test "assigned_to_group should ignore groups that are not visible" do + setup_assignee_group + user = User.generate! + role = Role.generate!(:users_visibility => 'members_of_visible_projects', :permissions => [:view_issues]) + User.add_to_project(user, Project.find(1), role) + Member.create!(:principal => @group_b, :project_id => 1, :role_ids => [role.id]) + User.current = user + + query = assignee_group_query + assert_equal [@group_b], Group.givable.visible.to_a + column = query.group_by_column + assert_nil column.group_value(@issues[:a]) + assert_equal @group_b, column.group_value(@issues[:ab]) + assert_equal [@group_b], column.value(@issues[:ab]) + end + def setup_assigned_to_role @manager_role = Role.find_by_name('Manager') @developer_role = Role.find_by_name('Developer')