Merged BugsSite to Bugzilla-3.2.3
[WebKit-https.git] / BugsSite / editgroups.cgi
1 #!/usr/bin/perl -wT
2 # -*- Mode: perl; indent-tabs-mode: nil -*-
3 #
4 # The contents of this file are subject to the Mozilla Public
5 # License Version 1.1 (the "License"); you may not use this file
6 # except in compliance with the License. You may obtain a copy of
7 # the License at http://www.mozilla.org/MPL/
8 #
9 # Software distributed under the License is distributed on an "AS
10 # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
11 # implied. See the License for the specific language governing
12 # rights and limitations under the License.
13 #
14 # The Original Code is the Bugzilla Bug Tracking System.
15 #
16 # The Initial Developer of the Original Code is Netscape Communications
17 # Corporation. Portions created by Netscape are
18 # Copyright (C) 1998 Netscape Communications Corporation. All
19 # Rights Reserved.
20 #
21 # Contributor(s): Dave Miller <justdave@syndicomm.com>
22 #                 Joel Peshkin <bugreport@peshkin.net>
23 #                 Jacob Steenhagen <jake@bugzilla.org>
24 #                 Vlad Dascalu <jocuri@softhome.net>
25 #                 Frédéric Buclin <LpSolit@gmail.com>
26
27 use strict;
28 use lib qw(. lib);
29
30 use Bugzilla;
31 use Bugzilla::Constants;
32 use Bugzilla::Config qw(:admin);
33 use Bugzilla::Util;
34 use Bugzilla::Error;
35 use Bugzilla::Group;
36 use Bugzilla::Product;
37 use Bugzilla::User;
38 use Bugzilla::Token;
39
40 use constant SPECIAL_GROUPS => ('chartgroup', 'insidergroup',
41                                 'timetrackinggroup', 'querysharegroup');
42
43 my $cgi = Bugzilla->cgi;
44 my $dbh = Bugzilla->dbh;
45 my $template = Bugzilla->template;
46 my $vars = {};
47
48 my $user = Bugzilla->login(LOGIN_REQUIRED);
49
50 print $cgi->header();
51
52 $user->in_group('creategroups')
53   || ThrowUserError("auth_failure", {group  => "creategroups",
54                                      action => "edit",
55                                      object => "groups"});
56
57 my $action = trim($cgi->param('action') || '');
58 my $token  = $cgi->param('token');
59
60 # CheckGroupID checks that a positive integer is given and is
61 # actually a valid group ID. If all tests are successful, the
62 # trimmed group ID is returned.
63
64 sub CheckGroupID {
65     my ($group_id) = @_;
66     $group_id = trim($group_id || 0);
67     ThrowUserError("group_not_specified") unless $group_id;
68     (detaint_natural($group_id)
69       && Bugzilla->dbh->selectrow_array("SELECT id FROM groups WHERE id = ?",
70                                         undef, $group_id))
71       || ThrowUserError("invalid_group_ID");
72     return $group_id;
73 }
74
75 # This subroutine is called when:
76 # - a new group is created. CheckGroupName checks that its name
77 #   is not empty and is not already used by any existing group.
78 # - an existing group is edited. CheckGroupName checks that its
79 #   name has not been deleted or renamed to another existing
80 #   group name (whose group ID is different from $group_id).
81 # In both cases, an error message is returned to the user if any
82 # test fails! Else, the trimmed group name is returned.
83
84 sub CheckGroupName {
85     my ($name, $group_id) = @_;
86     $name = trim($name || '');
87     trick_taint($name);
88     ThrowUserError("empty_group_name") unless $name;
89     my $excludeself = (defined $group_id) ? " AND id != $group_id" : "";
90     my $name_exists = Bugzilla->dbh->selectrow_array("SELECT name FROM groups " .
91                                                      "WHERE name = ? $excludeself",
92                                                      undef, $name);
93     if ($name_exists) {
94         ThrowUserError("group_exists", { name => $name });
95     }
96     return $name;
97 }
98
99 # CheckGroupDesc checks that a non empty description is given. The
100 # trimmed description is returned.
101
102 sub CheckGroupDesc {
103     my ($desc) = @_;
104     $desc = trim($desc || '');
105     trick_taint($desc);
106     ThrowUserError("empty_group_description") unless $desc;
107     return $desc;
108 }
109
110 # CheckGroupRegexp checks that the regular expression is valid
111 # (the regular expression being optional, the test is successful
112 # if none is given, as expected). The trimmed regular expression
113 # is returned.
114
115 sub CheckGroupRegexp {
116     my ($regexp) = @_;
117     $regexp = trim($regexp || '');
118     trick_taint($regexp);
119     ThrowUserError("invalid_regexp") unless (eval {qr/$regexp/});
120     return $regexp;
121 }
122
123 # A helper for displaying the edit.html.tmpl template.
124 sub get_current_and_available {
125     my ($group, $vars) = @_;
126
127     my @all_groups         = Bugzilla::Group->get_all;
128     my @members_current    = @{$group->grant_direct(GROUP_MEMBERSHIP)};
129     my @member_of_current  = @{$group->granted_by_direct(GROUP_MEMBERSHIP)};
130     my @bless_from_current = @{$group->grant_direct(GROUP_BLESS)};
131     my @bless_to_current   = @{$group->granted_by_direct(GROUP_BLESS)};
132     my (@visible_from_current, @visible_to_me_current);
133     if (Bugzilla->params->{'usevisibilitygroups'}) {
134         @visible_from_current  = @{$group->grant_direct(GROUP_VISIBLE)};
135         @visible_to_me_current = @{$group->granted_by_direct(GROUP_VISIBLE)};
136     }
137
138     # Figure out what groups are not currently a member of this group,
139     # and what groups this group is not currently a member of.
140     my (@members_available, @member_of_available,
141         @bless_from_available, @bless_to_available,
142         @visible_from_available, @visible_to_me_available);
143     foreach my $group_option (@all_groups) {
144         if (Bugzilla->params->{'usevisibilitygroups'}) {
145             push(@visible_from_available, $group_option)
146                 if !grep($_->id == $group_option->id, @visible_from_current);
147             push(@visible_to_me_available, $group_option)
148                 if !grep($_->id == $group_option->id, @visible_to_me_current);
149         }
150
151         # The group itself should never show up in the bless or 
152         # membership lists.
153         next if $group_option->id == $group->id;
154
155         push(@members_available, $group_option)
156             if !grep($_->id == $group_option->id, @members_current);
157         push(@member_of_available, $group_option)
158             if !grep($_->id == $group_option->id, @member_of_current);
159         push(@bless_from_available, $group_option)
160             if !grep($_->id == $group_option->id, @bless_from_current);
161         push(@bless_to_available, $group_option)
162            if !grep($_->id == $group_option->id, @bless_to_current);
163     }
164
165     $vars->{'members_current'}     = \@members_current;
166     $vars->{'members_available'}   = \@members_available;
167     $vars->{'member_of_current'}   = \@member_of_current;
168     $vars->{'member_of_available'} = \@member_of_available;
169
170     $vars->{'bless_from_current'}   = \@bless_from_current;
171     $vars->{'bless_from_available'} = \@bless_from_available;
172     $vars->{'bless_to_current'}     = \@bless_to_current;
173     $vars->{'bless_to_available'}   = \@bless_to_available;
174
175     if (Bugzilla->params->{'usevisibilitygroups'}) {
176         $vars->{'visible_from_current'}    = \@visible_from_current;
177         $vars->{'visible_from_available'}  = \@visible_from_available;
178         $vars->{'visible_to_me_current'}   = \@visible_to_me_current;
179         $vars->{'visible_to_me_available'} = \@visible_to_me_available;
180     }
181 }
182
183 # If no action is specified, get a list of all groups available.
184
185 unless ($action) {
186     my @groups = Bugzilla::Group->get_all;
187     $vars->{'groups'} = \@groups;
188     
189     print $cgi->header();
190     $template->process("admin/groups/list.html.tmpl", $vars)
191       || ThrowTemplateError($template->error());
192     exit;
193 }
194
195 #
196 # action='changeform' -> present form for altering an existing group
197 #
198 # (next action will be 'postchanges')
199 #
200
201 if ($action eq 'changeform') {
202     # Check that an existing group ID is given
203     my $group_id = CheckGroupID($cgi->param('group'));
204     my $group = new Bugzilla::Group($group_id);
205
206     get_current_and_available($group, $vars);
207     $vars->{'group'} = $group;
208     $vars->{'token'}       = issue_session_token('edit_group');
209
210     print $cgi->header();
211     $template->process("admin/groups/edit.html.tmpl", $vars)
212       || ThrowTemplateError($template->error());
213
214     exit;
215 }
216
217 #
218 # action='add' -> present form for parameters for new group
219 #
220 # (next action will be 'new')
221 #
222
223 if ($action eq 'add') {
224     $vars->{'token'} = issue_session_token('add_group');
225     print $cgi->header();
226     $template->process("admin/groups/create.html.tmpl", $vars)
227       || ThrowTemplateError($template->error());
228     
229     exit;
230 }
231
232
233
234 #
235 # action='new' -> add group entered in the 'action=add' screen
236 #
237
238 if ($action eq 'new') {
239     check_token_data($token, 'add_group');
240     # Check that a not already used group name is given, that
241     # a description is also given and check if the regular
242     # expression is valid (if any).
243     my $name = CheckGroupName($cgi->param('name'));
244     my $desc = CheckGroupDesc($cgi->param('desc'));
245     my $regexp = CheckGroupRegexp($cgi->param('regexp'));
246     my $isactive = $cgi->param('isactive') ? 1 : 0;
247     # This is an admin page. The URL is considered safe.
248     my $icon_url;
249     if ($cgi->param('icon_url')) {
250         $icon_url = clean_text($cgi->param('icon_url'));
251         trick_taint($icon_url);
252     }
253
254     # Add the new group
255     $dbh->do('INSERT INTO groups
256               (name, description, isbuggroup, userregexp, isactive, icon_url)
257               VALUES (?, ?, 1, ?, ?, ?)',
258               undef, ($name, $desc, $regexp, $isactive, $icon_url));
259
260     my $group = new Bugzilla::Group({name => $name});
261     my $admin = Bugzilla::Group->new({name => 'admin'})->id();
262     # Since we created a new group, give the "admin" group all privileges
263     # initially.
264     my $sth = $dbh->prepare('INSERT INTO group_group_map
265                              (member_id, grantor_id, grant_type)
266                              VALUES (?, ?, ?)');
267
268     $sth->execute($admin, $group->id, GROUP_MEMBERSHIP);
269     $sth->execute($admin, $group->id, GROUP_BLESS);
270     $sth->execute($admin, $group->id, GROUP_VISIBLE);
271
272     # Permit all existing products to use the new group if makeproductgroups.
273     if ($cgi->param('insertnew')) {
274         $dbh->do('INSERT INTO group_control_map
275                   (group_id, product_id, entry, membercontrol,
276                    othercontrol, canedit)
277                   SELECT ?, products.id, 0, ?, ?, 0 FROM products',
278                   undef, ($group->id, CONTROLMAPSHOWN, CONTROLMAPNA));
279     }
280     Bugzilla::Group::RederiveRegexp($regexp, $group->id);
281     delete_token($token);
282
283     $vars->{'message'} = 'group_created';
284     $vars->{'group'} = $group;
285     get_current_and_available($group, $vars);
286     $vars->{'token'} = issue_session_token('edit_group');
287
288     print $cgi->header();
289     $template->process("admin/groups/edit.html.tmpl", $vars)
290       || ThrowTemplateError($template->error());
291     exit;
292 }
293
294 #
295 # action='del' -> ask if user really wants to delete
296 #
297 # (next action would be 'delete')
298 #
299
300 if ($action eq 'del') {
301     # Check that an existing group ID is given
302     my $gid = CheckGroupID($cgi->param('group'));
303     my ($name, $desc, $isbuggroup) =
304         $dbh->selectrow_array("SELECT name, description, isbuggroup " .
305                               "FROM groups WHERE id = ?", undef, $gid);
306
307     # System groups cannot be deleted!
308     if (!$isbuggroup) {
309         ThrowUserError("system_group_not_deletable", { name => $name });
310     }
311     # Groups having a special role cannot be deleted.
312     my @special_groups;
313     foreach my $special_group (SPECIAL_GROUPS) {
314         if ($name eq Bugzilla->params->{$special_group}) {
315             push(@special_groups, $special_group);
316         }
317     }
318     if (scalar(@special_groups)) {
319         ThrowUserError('group_has_special_role', {'name'  => $name,
320                                                   'groups' => \@special_groups});
321     }
322
323     # Group inheritance no longer appears in user_group_map.
324     my $grouplist = join(',', @{Bugzilla::User->flatten_group_membership($gid)});
325     my $hasusers =
326         $dbh->selectrow_array("SELECT 1 FROM user_group_map
327                                WHERE group_id IN ($grouplist) AND isbless = 0 " .
328                                $dbh->sql_limit(1)) || 0;
329
330     my ($shared_queries) =
331         $dbh->selectrow_array('SELECT COUNT(*)
332                                  FROM namedquery_group_map
333                                 WHERE group_id = ?',
334                               undef, $gid);
335
336     my $bug_ids = $dbh->selectcol_arrayref('SELECT bug_id FROM bug_group_map
337                                             WHERE group_id = ?', undef, $gid);
338
339     my $hasbugs = scalar(@$bug_ids) ? 1 : 0;
340     my $buglist = join(',', @$bug_ids);
341
342     my $hasproduct = Bugzilla::Product->new({'name' => $name}) ? 1 : 0;
343
344     my $hasflags = $dbh->selectrow_array('SELECT 1 FROM flagtypes 
345                                            WHERE grant_group_id = ?
346                                               OR request_group_id = ? ' .
347                                           $dbh->sql_limit(1),
348                                           undef, ($gid, $gid)) || 0;
349
350     $vars->{'gid'}            = $gid;
351     $vars->{'name'}           = $name;
352     $vars->{'description'}    = $desc;
353     $vars->{'hasusers'}       = $hasusers;
354     $vars->{'hasbugs'}        = $hasbugs;
355     $vars->{'hasproduct'}     = $hasproduct;
356     $vars->{'hasflags'}       = $hasflags;
357     $vars->{'shared_queries'} = $shared_queries;
358     $vars->{'buglist'}        = $buglist;
359     $vars->{'token'}          = issue_session_token('delete_group');
360
361     print $cgi->header();
362     $template->process("admin/groups/delete.html.tmpl", $vars)
363       || ThrowTemplateError($template->error());
364     
365     exit;
366 }
367
368 #
369 # action='delete' -> really delete the group
370 #
371
372 if ($action eq 'delete') {
373     check_token_data($token, 'delete_group');
374     # Check that an existing group ID is given
375     my $gid = CheckGroupID($cgi->param('group'));
376     my ($name, $isbuggroup) =
377         $dbh->selectrow_array("SELECT name, isbuggroup FROM groups " .
378                               "WHERE id = ?", undef, $gid);
379
380     # System groups cannot be deleted!
381     if (!$isbuggroup) {
382         ThrowUserError("system_group_not_deletable", { name => $name });
383     }
384     # Groups having a special role cannot be deleted.
385     my @special_groups;
386     foreach my $special_group (SPECIAL_GROUPS) {
387         if ($name eq Bugzilla->params->{$special_group}) {
388             push(@special_groups, $special_group);
389         }
390     }
391     if (scalar(@special_groups)) {
392         ThrowUserError('group_has_special_role', {'name'  => $name,
393                                                   'groups' => \@special_groups});
394     }
395
396     my $cantdelete = 0;
397
398     # Group inheritance no longer appears in user_group_map.
399     my $grouplist = join(',', @{Bugzilla::User->flatten_group_membership($gid)});
400     my $hasusers =
401         $dbh->selectrow_array("SELECT 1 FROM user_group_map
402                                WHERE group_id IN ($grouplist) AND isbless = 0 " .
403                                $dbh->sql_limit(1)) || 0;
404
405     if ($hasusers && !defined $cgi->param('removeusers')) {
406         $cantdelete = 1;
407     }
408
409     my $hasbugs = $dbh->selectrow_array('SELECT 1 FROM bug_group_map
410                                          WHERE group_id = ? ' .
411                                          $dbh->sql_limit(1),
412                                          undef, $gid) || 0;
413     if ($hasbugs && !defined $cgi->param('removebugs')) {
414         $cantdelete = 1;
415     }
416
417     if (Bugzilla::Product->new({'name' => $name})
418         && !defined $cgi->param('unbind'))
419     {
420         $cantdelete = 1;
421     }
422
423     my $hasflags = $dbh->selectrow_array('SELECT 1 FROM flagtypes 
424                                            WHERE grant_group_id = ?
425                                               OR request_group_id = ? ' .
426                                           $dbh->sql_limit(1),
427                                           undef, ($gid, $gid)) || 0;
428     if ($hasflags && !defined $cgi->param('removeflags')) {
429         $cantdelete = 1;
430     }
431
432     $vars->{'gid'}        = $gid;
433     $vars->{'name'}       = $name;
434
435     ThrowUserError('group_cannot_delete', $vars) if $cantdelete;
436
437     $dbh->do('UPDATE flagtypes SET grant_group_id = ?
438                WHERE grant_group_id = ?',
439               undef, (undef, $gid));
440     $dbh->do('UPDATE flagtypes SET request_group_id = ?
441                WHERE request_group_id = ?',
442               undef, (undef, $gid));
443     $dbh->do('DELETE FROM namedquery_group_map WHERE group_id = ?',
444               undef, $gid);
445     $dbh->do('DELETE FROM user_group_map WHERE group_id = ?',
446               undef, $gid);
447     $dbh->do('DELETE FROM group_group_map 
448                WHERE grantor_id = ? OR member_id = ?',
449               undef, ($gid, $gid));
450     $dbh->do('DELETE FROM bug_group_map WHERE group_id = ?',
451               undef, $gid);
452     $dbh->do('DELETE FROM group_control_map WHERE group_id = ?',
453               undef, $gid);
454     $dbh->do('DELETE FROM whine_schedules
455                WHERE mailto_type = ? AND mailto = ?',
456               undef, (MAILTO_GROUP, $gid));
457     $dbh->do('DELETE FROM groups WHERE id = ?',
458               undef, $gid);
459
460     delete_token($token);
461
462     $vars->{'message'} = 'group_deleted';
463     $vars->{'groups'} = [Bugzilla::Group->get_all];
464
465     print $cgi->header();
466     $template->process("admin/groups/list.html.tmpl", $vars)
467       || ThrowTemplateError($template->error());
468     exit;
469 }
470
471 #
472 # action='postchanges' -> update the groups
473 #
474
475 if ($action eq 'postchanges') {
476     check_token_data($token, 'edit_group');
477     my $changes = doGroupChanges();
478     delete_token($token);
479
480     my $group = new Bugzilla::Group($cgi->param('group_id'));
481     get_current_and_available($group, $vars);
482     $vars->{'message'} = 'group_updated';
483     $vars->{'group'}   = $group;
484     $vars->{'changes'} = $changes;
485     $vars->{'token'} = issue_session_token('edit_group');
486
487     print $cgi->header();
488     $template->process("admin/groups/edit.html.tmpl", $vars)
489       || ThrowTemplateError($template->error());
490     exit;
491 }
492
493 if ($action eq 'confirm_remove') {
494     my $group = new Bugzilla::Group(CheckGroupID($cgi->param('group_id')));
495     $vars->{'group'} = $group;
496     $vars->{'regexp'} = CheckGroupRegexp($cgi->param('regexp'));
497     $vars->{'token'} = issue_session_token('remove_group_members');
498     $template->process('admin/groups/confirm-remove.html.tmpl', $vars)
499         || ThrowTemplateError($template->error());
500     exit;
501 }
502
503 if ($action eq 'remove_regexp') {
504     check_token_data($token, 'remove_group_members');
505     # remove all explicit users from the group with
506     # gid = $cgi->param('group') that match the regular expression
507     # stored in the DB for that group or all of them period
508
509     my $group  = new Bugzilla::Group(CheckGroupID($cgi->param('group_id')));
510     my $regexp = CheckGroupRegexp($cgi->param('regexp'));
511
512     $dbh->bz_start_transaction();
513
514     my $users = $group->members_direct();
515     my $sth_delete = $dbh->prepare(
516         "DELETE FROM user_group_map
517            WHERE user_id = ? AND isbless = 0 AND group_id = ?");
518
519     my @deleted;
520     foreach my $member (@$users) {
521         if ($regexp eq '' || $member->login =~ m/$regexp/i) {
522             $sth_delete->execute($member->id, $group->id);
523             push(@deleted, $member);
524         }
525     }
526     $dbh->bz_commit_transaction();
527
528     $vars->{'users'}  = \@deleted;
529     $vars->{'regexp'} = $regexp;
530     delete_token($token);
531
532     $vars->{'message'} = 'group_membership_removed';
533     $vars->{'group'} = $group->name;
534     $vars->{'groups'} = [Bugzilla::Group->get_all];
535
536     print $cgi->header();
537     $template->process("admin/groups/list.html.tmpl", $vars)
538       || ThrowTemplateError($template->error());
539
540     exit;
541 }
542
543
544 #
545 # No valid action found
546 #
547
548 ThrowCodeError("action_unrecognized", $vars);
549
550
551 # Helper sub to handle the making of changes to a group
552 sub doGroupChanges {
553     my $cgi = Bugzilla->cgi;
554     my $dbh = Bugzilla->dbh;
555
556     $dbh->bz_start_transaction();
557
558     # Check that the given group ID is valid and make a Group.
559     my $group = new Bugzilla::Group(CheckGroupID($cgi->param('group_id')));
560
561     if (defined $cgi->param('regexp')) {
562         $group->set_user_regexp($cgi->param('regexp'));
563     }
564
565     if ($group->is_bug_group) {
566         if (defined $cgi->param('name')) {
567             $group->set_name($cgi->param('name'));
568         }
569         if (defined $cgi->param('desc')) {
570             $group->set_description($cgi->param('desc'));
571         }
572         # Only set isactive if we came from the right form.
573         if (defined $cgi->param('regexp')) {
574             $group->set_is_active($cgi->param('isactive'));
575         }
576     }
577
578     if (defined $cgi->param('icon_url')) {
579         $group->set_icon_url($cgi->param('icon_url'));
580     }
581
582     my $changes = $group->update();
583
584     my $sth_insert = $dbh->prepare('INSERT INTO group_group_map
585                                     (member_id, grantor_id, grant_type)
586                                     VALUES (?, ?, ?)');
587
588     my $sth_delete = $dbh->prepare('DELETE FROM group_group_map
589                                      WHERE member_id = ?
590                                            AND grantor_id = ?
591                                            AND grant_type = ?');
592
593     # First item is the type, second is whether or not it's "reverse" 
594     # (granted_by) (see _do_add for more explanation).
595     my %fields = (
596         members       => [GROUP_MEMBERSHIP, 0],
597         bless_from    => [GROUP_BLESS, 0],
598         visible_from  => [GROUP_VISIBLE, 0],
599         member_of     => [GROUP_MEMBERSHIP, 1],
600         bless_to      => [GROUP_BLESS, 1],
601         visible_to_me => [GROUP_VISIBLE, 1]
602     );
603     while (my ($field, $data) = each %fields) {
604         _do_add($group, $changes, $sth_insert, "${field}_add", 
605                 $data->[0], $data->[1]);
606         _do_remove($group, $changes, $sth_delete, "${field}_remove",
607                    $data->[0], $data->[1]);
608     }
609
610     $dbh->bz_commit_transaction();
611     return $changes;
612 }
613
614 sub _do_add {
615     my ($group, $changes, $sth_insert, $field, $type, $reverse) = @_;
616     my $cgi = Bugzilla->cgi;
617
618     my $current;
619     # $reverse means we're doing a granted_by--that is, somebody else
620     # is granting us something.
621     if ($reverse) {
622         $current = $group->granted_by_direct($type);
623     }
624     else {
625         $current = $group->grant_direct($type);
626     }
627
628     my $add_items = Bugzilla::Group->new_from_list([$cgi->param($field)]);
629
630     foreach my $add (@$add_items) {
631         next if grep($_->id == $add->id, @$current);
632
633         $changes->{$field} ||= [];
634         push(@{$changes->{$field}}, $add->name);
635         # They go this direction for a normal "This group is granting
636         # $add something."
637         my @ids = ($add->id, $group->id);
638         # But they get reversed for "This group is being granted something
639         # by $add."
640         @ids = reverse @ids if $reverse;
641         $sth_insert->execute(@ids, $type);
642     }
643 }
644
645 sub _do_remove {
646     my ($group, $changes, $sth_delete, $field, $type, $reverse) = @_;
647     my $cgi = Bugzilla->cgi;
648     my $remove_items = Bugzilla::Group->new_from_list([$cgi->param($field)]);
649
650     foreach my $remove (@$remove_items) {
651         my @ids = ($remove->id, $group->id);
652         # See _do_add for an explanation of $reverse
653         @ids = reverse @ids if $reverse;
654         # Deletions always succeed and are harmless if they fail, so we
655         # don't need to do any checks.
656         $sth_delete->execute(@ids, $type);
657         $changes->{$field} ||= [];
658         push(@{$changes->{$field}}, $remove->name);
659     }
660 }