Feature #7655 » 0001-Add-group-by-assignees-group-for-issue-queries-v2.patch
| 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')
|
- « Previous
- 1
- 2
- 3
- Next »