Project

General

Profile

Feature #7655 » 0001-Add-group-by-assignees-group-for-issue-queries-v2.patch

Ryoh HAMADA, 2026-11-05 08:11

View differences:

app/models/issue_query.rb
303 303
    return @available_columns if @available_columns
304 304

  
305 305
    @available_columns = self.class.available_columns.dup
306
    index = @available_columns.find_index {|column| column.name == :assigned_to}
307
    @available_columns.insert((index ? index + 1 : -1), AssigneeGroupQueryColumn.new(self))
306 308
    @available_columns += issue_custom_fields.visible.collect {|cf| QueryCustomFieldColumn.new(cf)}
307 309

  
308 310
    if User.current.allowed_to?(:view_time_entries, project, :global => true)
......
349 351
    if disabled_fields.include?("estimated_hours")
350 352
      disabled_fields += %w[total_estimated_hours estimated_remaining_hours]
351 353
    end
354
    if disabled_fields.include?("assigned_to")
355
      disabled_fields += %w[assigned_to_group]
356
    end
352 357
    @available_columns.reject! do |column|
353 358
      disabled_fields.include?(column.name.to_s)
354 359
    end
......
431 436
    if has_custom_field_column?
432 437
      scope = scope.preload(:custom_values)
433 438
    end
439
    if has_column?(:assigned_to_group) || group_by_column.is_a?(AssigneeGroupQueryColumn)
440
      scope = scope.preload(:assigned_to)
441
    end
434 442
    if has_column?(:watcher_users)
435 443
      scope = scope.preload(:watcher_users)
436 444
    end
......
581 589
    "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (#{sql})"
582 590
  end
583 591

  
592
  # Returns the SQL expression of the id of the group used to group issues
593
  # by "Assignee's group" (see AssigneeGroupQueryColumn)
594
  def assignee_group_statement
595
    "COALESCE(assignee_group_self.id, assignee_group_preferred.group_id, assignee_group_any.group_id)"
596
  end
597

  
598
  # Returns the group used to group an issue assigned to principal
599
  def assignee_group_for(principal)
600
    if principal.is_a?(Group)
601
      principal.type == 'Group' ? principal : nil
602
    elsif principal.is_a?(User)
603
      group_ids = assignee_group_ids_of(principal)
604
      group_id = (group_ids & assignee_group_preferred_ids).min || group_ids.min
605
      group_id ? assignee_groups_by_id([group_id])[group_id] : nil
606
    end
607
  end
608

  
609
  # Returns the visible groups of principal, or principal if it is a group
610
  def assignee_groups_for(principal)
611
    if principal.is_a?(Group)
612
      principal.type == 'Group' ? [principal] : []
613
    elsif principal.is_a?(User)
614
      group_ids = assignee_group_ids_of(principal)
615
      assignee_groups_by_id(group_ids).values_at(*group_ids).compact.sort_by {|g| g.name.downcase}
616
    else
617
      []
618
    end
619
  end
620

  
621
  def cast_assignee_group_keys(hash)
622
    groups = assignee_groups_by_id(hash.keys.compact.map(&:to_i))
623
    hash.transform_keys {|k| k.nil? ? nil : groups[k.to_i]}
624
  end
625

  
584 626
  def sql_for_member_of_group_field(field, operator, value)
585 627
    if operator == '*' # Any group
586 628
      groups = Group.givable
......
650 692
  end
651 693
  private :group_and_member_ids
652 694

  
695
  # Ids of the givable groups visible by the current user
696
  def assignee_group_visible_ids
697
    @assignee_group_visible_ids ||= Group.givable.visible.ids.sort
698
  end
699
  private :assignee_group_visible_ids
700

  
701
  # Ids of the visible groups selected in the "Assignee's group" filter
702
  # with the "is" operator
703
  def assignee_group_preferred_ids
704
    @assignee_group_preferred_ids ||=
705
      if operator_for('member_of_group') == '='
706
        values_for('member_of_group').map(&:to_i) & assignee_group_visible_ids
707
      else
708
        []
709
      end
710
  end
711
  private :assignee_group_preferred_ids
712

  
713
  def assignee_group_ids_of(user)
714
    @assignee_group_ids_by_user ||= {}
715
    @assignee_group_ids_by_user[user.id] ||= user.group_ids & assignee_group_visible_ids
716
  end
717
  private :assignee_group_ids_of
718

  
719
  def assignee_groups_by_id(ids)
720
    @assignee_groups_by_id ||= {}
721
    missing = ids - @assignee_groups_by_id.keys
722
    if missing.any?
723
      Group.where(:id => missing).each {|g| @assignee_groups_by_id[g.id] = g}
724
    end
725
    @assignee_groups_by_id.slice(*ids)
726
  end
727
  private :assignee_groups_by_id
728

  
729
  def joins_for_assignee_group
730
    groups_users = "#{User.table_name_prefix}groups_users#{User.table_name_suffix}"
731
    subquery = lambda do |ids|
732
      condition = ids.any? ? "gu.group_id IN (#{ids.map(&:to_i).join(',')})" : "1=0"
733
      "(SELECT gu.user_id, MIN(gu.group_id) AS group_id FROM #{groups_users} gu" \
734
        " WHERE #{condition} GROUP BY gu.user_id)"
735
    end
736

  
737
    [
738
      "LEFT OUTER JOIN #{User.table_name} assignee_group_self" \
739
        " ON assignee_group_self.id = #{queried_table_name}.assigned_to_id" \
740
        " AND assignee_group_self.type = 'Group'",
741
      "LEFT OUTER JOIN #{subquery.call(assignee_group_preferred_ids)} assignee_group_preferred" \
742
        " ON assignee_group_preferred.user_id = #{queried_table_name}.assigned_to_id",
743
      "LEFT OUTER JOIN #{subquery.call(assignee_group_visible_ids)} assignee_group_any" \
744
        " ON assignee_group_any.user_id = #{queried_table_name}.assigned_to_id",
745
      "LEFT OUTER JOIN #{User.table_name} assignee_group" \
746
        " ON assignee_group.id = #{assignee_group_statement}"
747
    ]
748
  end
749
  private :joins_for_assignee_group
750

  
653 751
  def sql_for_author_role_field(field, operator, value)
654 752
    role_cond =
655 753
      if value.any?
......
967 1065
          "LEFT OUTER JOIN #{IssuePriority.table_name}" \
968 1066
            " ON #{IssuePriority.table_name}.id = #{queried_table_name}.priority_id"
969 1067
      end
1068
      if order_options.include?('assignee_group')
1069
        joins.concat(joins_for_assignee_group)
1070
      end
970 1071
    end
971 1072

  
972 1073
    joins.any? ? joins.join(' ') : nil
app/models/query.rb
112 112
  end
113 113
end
114 114

  
115
# Column for the group of an issue's assignee ("Assignee's group").
116
#
117
# A user can belong to several groups, but a grouped query puts each issue
118
# under exactly one group, so the group used for grouping is chosen as follows:
119
# * if the issue is assigned to a group, that group;
120
# * otherwise, among the visible groups of the assignee, the one with the
121
#   lowest id among the groups selected in the "Assignee's group" filter
122
#   (operator "is"), if any, so that grouping is consistent with that filter;
123
# * otherwise, the visible group of the assignee with the lowest id.
124
# The displayed value lists all the visible groups of the assignee.
125
class AssigneeGroupQueryColumn < QueryColumn
126
  def initialize(query)
127
    super(:assigned_to_group, :caption => :field_member_of_group,
128
          :sortable => "assignee_group.lastname", :groupable => true)
129
    @query = query
130
  end
131

  
132
  def value_object(issue)
133
    @query.assignee_groups_for(issue.assigned_to)
134
  end
135

  
136
  def value(issue)
137
    value_object(issue)
138
  end
139

  
140
  def group_value(issue)
141
    @query.assignee_group_for(issue.assigned_to)
142
  end
143

  
144
  def group_by_statement
145
    @query.assignee_group_statement
146
  end
147

  
148
  # Converts the group ids returned by SQL into Group objects
149
  def cast_group_keys(hash)
150
    @query.cast_assignee_group_keys(hash)
151
  end
152
end
153

  
115 154
class QueryAssociationColumn < QueryColumn
116 155
  def initialize(association, attribute, options={})
117 156
    @association = association
......
1139 1178
      c = group_by_column
1140 1179
      if c.is_a?(QueryCustomFieldColumn)
1141 1180
        r = r.keys.to_h { |k| [c.custom_field.cast_value(k), r[k]] }
1181
      elsif c.respond_to?(:cast_group_keys)
1182
        r = c.cast_group_keys(r)
1142 1183
      end
1143 1184
    end
1144 1185
    r
test/unit/query_test.rb
3060 3060
    assert_find_issues_with_query_is_successful @query
3061 3061
  end
3062 3062

  
3063
  def setup_assignee_group
3064
    User.current = User.find(1)
3065
    Group.destroy_all # No fixtures
3066
    @group_a = Group.generate!
3067
    @group_b = Group.generate!
3068
    @user_a = User.generate!
3069
    @user_b = User.generate!
3070
    @user_ab = User.generate!
3071
    @user_none = User.generate!
3072
    @group_a.users << @user_a
3073
    @group_a.users << @user_ab
3074
    @group_b.users << @user_b
3075
    @group_b.users << @user_ab
3076

  
3077
    @issues = {}
3078
    {:a => @user_a, :b => @user_b, :ab => @user_ab, :none => @user_none, :group_b => @group_b, :nobody => nil}.each do |key, principal|
3079
      issue = Issue.generate!(:project_id => 1)
3080
      issue.update_column :assigned_to_id, principal&.id
3081
      @issues[key] = issue.reload
3082
    end
3083
  end
3084

  
3085
  def assignee_group_query(filters={})
3086
    query = IssueQuery.new(:name => '_', :group_by => 'assigned_to_group')
3087
    query.add_filter('status_id', '*', [''])
3088
    query.add_filter('issue_id', '=', [@issues.values.map(&:id).join(',')])
3089
    filters.each {|field, (operator, values)| query.add_filter(field, operator, values)}
3090
    query
3091
  end
3092

  
3093
  test "groupable_columns should include assigned_to_group" do
3094
    set_language_if_valid 'en'
3095
    query = IssueQuery.new
3096
    column = query.groupable_columns.detect {|c| c.name == :assigned_to_group}
3097
    assert_kind_of AssigneeGroupQueryColumn, column
3098
    assert_equal "Assignee's group", column.caption
3099
  end
3100

  
3101
  test "query grouped by assigned_to_group should be grouped" do
3102
    query = IssueQuery.new(:group_by => 'assigned_to_group')
3103
    assert query.grouped?
3104
    assert_equal :assigned_to_group, query.group_by_column.name
3105
    assert_not_nil query.group_by_statement
3106
  end
3107

  
3108
  test "cast_group_keys of assigned_to_group should convert ids to groups" do
3109
    column = IssueQuery.new(:group_by => 'assigned_to_group').group_by_column
3110
    assert_equal({}, column.cast_group_keys({}))
3111
    assert_equal({nil => 5}, column.cast_group_keys({nil => 5}))
3112
    assert_equal({Group.find(10) => 3, Group.find(11) => 2, nil => 1},
3113
                 column.cast_group_keys({10 => 3, 11 => 2, nil => 1}))
3114
  end
3115

  
3116
  test "available_columns should not include assigned_to_group if assigned_to is disabled" do
3117
    Tracker.visible.each do |tracker|
3118
      tracker.core_fields = tracker.core_fields - ['assigned_to_id']
3119
      tracker.save!
3120
    end
3121
    available_columns = IssueQuery.new.available_columns.map(&:name)
3122
    assert_not_include :assigned_to, available_columns
3123
    assert_not_include :assigned_to_group, available_columns
3124
  end
3125

  
3126
  test "result_count_by_group by assigned_to_group should use the lowest group id without filter" do
3127
    setup_assignee_group
3128
    count_by_group = assignee_group_query.result_count_by_group
3129

  
3130
    # a, ab => A; b, group_b => B; none, nobody => nil
3131
    assert_equal({@group_a => 2, @group_b => 2, nil => 2}, count_by_group)
3132
  end
3133

  
3134
  test "result_count_by_group by assigned_to_group should prefer the groups of the member_of_group filter" do
3135
    setup_assignee_group
3136
    query = assignee_group_query('member_of_group' => ['=', [@group_b.id.to_s]])
3137

  
3138
    # b, ab, group_b match the filter and all are counted under B
3139
    assert_equal({@group_b => 3}, query.result_count_by_group)
3140
  end
3141

  
3142
  test "group_value of assigned_to_group should be consistent with result_count_by_group" do
3143
    setup_assignee_group
3144
    [
3145
      {},
3146
      {'member_of_group' => ['=', [@group_b.id.to_s]]},
3147
      {'member_of_group' => ['=', [@group_a.id.to_s, @group_b.id.to_s]]},
3148
      {'member_of_group' => ['*', ['']]},
3149
    ].each do |filters|
3150
      query = assignee_group_query(filters)
3151
      column = query.group_by_column
3152
      issues = query.issues
3153
      expected = issues.group_by {|issue| column.group_value(issue)}.transform_values(&:size)
3154
      assert_equal expected, query.result_count_by_group, "filters: #{filters.inspect}"
3155
    end
3156
  end
3157

  
3158
  test "group_value of assigned_to_group" do
3159
    setup_assignee_group
3160
    query = assignee_group_query('member_of_group' => ['=', [@group_b.id.to_s]])
3161
    column = query.group_by_column
3162
    assert_equal @group_a, assignee_group_query.group_by_column.group_value(@issues[:ab])
3163
    assert_equal @group_b, column.group_value(@issues[:ab])
3164
    assert_equal @group_b, column.group_value(@issues[:group_b])
3165
    assert_nil column.group_value(@issues[:none])
3166
    assert_nil column.group_value(@issues[:nobody])
3167
  end
3168

  
3169
  test "issues grouped by assigned_to_group should be sorted by group name" do
3170
    setup_assignee_group
3171
    query = assignee_group_query
3172
    column = query.group_by_column
3173
    groups = query.issues.map {|issue| column.group_value(issue)}.compact
3174
    assert_equal groups.uniq.sort_by(&:name), groups.uniq
3175
    assert_equal groups.chunk_while {|a, b| a == b}.map(&:first), groups.uniq
3176
  end
3177

  
3178
  test "value of assigned_to_group should list all the groups of the assignee" do
3179
    setup_assignee_group
3180
    column = assignee_group_query.group_by_column
3181
    assert_equal [@group_a, @group_b].sort_by(&:name), column.value(@issues[:ab])
3182
    assert_equal [@group_b], column.value(@issues[:group_b])
3183
    assert_equal [], column.value(@issues[:none])
3184
  end
3185

  
3186
  test "total_by_group_for by assigned_to_group should be grouped by assignee's group" do
3187
    setup_assignee_group
3188
    @issues[:a].update_column :estimated_hours, 1.5
3189
    @issues[:ab].update_column :estimated_hours, 2.0
3190
    @issues[:b].update_column :estimated_hours, 4.0
3191
    totals = assignee_group_query.total_by_group_for(:estimated_hours)
3192
    assert_equal 3.5, totals[@group_a]
3193
    assert_equal 4.0, totals[@group_b]
3194
  end
3195

  
3196
  test "assigned_to_group should ignore groups that are not visible" do
3197
    setup_assignee_group
3198
    user = User.generate!
3199
    role = Role.generate!(:users_visibility => 'members_of_visible_projects', :permissions => [:view_issues])
3200
    User.add_to_project(user, Project.find(1), role)
3201
    Member.create!(:principal => @group_b, :project_id => 1, :role_ids => [role.id])
3202
    User.current = user
3203

  
3204
    query = assignee_group_query
3205
    assert_equal [@group_b], Group.givable.visible.to_a
3206
    column = query.group_by_column
3207
    assert_nil column.group_value(@issues[:a])
3208
    assert_equal @group_b, column.group_value(@issues[:ab])
3209
    assert_equal [@group_b], column.value(@issues[:ab])
3210
  end
3211

  
3063 3212
  def setup_assigned_to_role
3064 3213
    @manager_role = Role.find_by_name('Manager')
3065 3214
    @developer_role = Role.find_by_name('Developer')
(3-3/3)