blob: 72797fc9104ac98702cc171fe25fa2b47799b599 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# Copyright 2016 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style
3# license that can be found in the LICENSE file or at
4# https://developers.google.com/open-source/licenses/bsd
5
6"""Persistence class for user groups.
7
8User groups are represented in the database by:
9- A row in the Users table giving an email address and user ID.
10 (A "group ID" is the user_id of the group in the User table.)
11- A row in the UserGroupSettings table giving user group settings.
12
13Membership of a user X in user group Y is represented as:
14- A row in the UserGroup table with user_id=X and group_id=Y.
15"""
16from __future__ import print_function
17from __future__ import division
18from __future__ import absolute_import
19
20import collections
21import logging
22import re
23
24from framework import exceptions
25from framework import permissions
26from framework import sql
27from proto import usergroup_pb2
28from services import caches
29
30
31USERGROUP_TABLE_NAME = 'UserGroup'
32USERGROUPSETTINGS_TABLE_NAME = 'UserGroupSettings'
33USERGROUPPROJECTS_TABLE_NAME = 'Group2Project'
34
35USERGROUP_COLS = ['user_id', 'group_id', 'role']
36USERGROUPSETTINGS_COLS = ['group_id', 'who_can_view_members',
37 'external_group_type', 'last_sync_time',
38 'notify_members', 'notify_group']
39USERGROUPPROJECTS_COLS = ['group_id', 'project_id']
40
41GROUP_TYPE_ENUM = (
42 'chrome_infra_auth', 'mdb', 'baggins', 'computed')
43
44
45class MembershipTwoLevelCache(caches.AbstractTwoLevelCache):
46 """Class to manage RAM and memcache for each user's memberships."""
47
48 def __init__(self, cache_manager, usergroup_service, group_dag):
49 super(MembershipTwoLevelCache, self).__init__(
50 cache_manager, 'user', 'memberships:', None)
51 self.usergroup_service = usergroup_service
52 self.group_dag = group_dag
53
54 def _DeserializeMemberships(self, memberships_rows):
55 """Reserialize the DB results into a {user_id: {group_id}}."""
56 result_dict = collections.defaultdict(set)
57 for user_id, group_id in memberships_rows:
58 result_dict[user_id].add(group_id)
59
60 return result_dict
61
62 def FetchItems(self, cnxn, keys):
63 """On RAM and memcache miss, hit the database to get memberships."""
64 direct_memberships_rows = self.usergroup_service.usergroup_tbl.Select(
65 cnxn, cols=['user_id', 'group_id'], distinct=True,
66 user_id=keys)
67 memberships_set = set()
68 self.group_dag.MarkObsolete()
69 logging.info('Rebuild group dag on RAM and memcache miss')
70 for c_id, p_id in direct_memberships_rows:
71 all_parents = self.group_dag.GetAllAncestors(cnxn, p_id, True)
72 all_parents.append(p_id)
73 memberships_set.update([(c_id, g_id) for g_id in all_parents])
74 retrieved_dict = self._DeserializeMemberships(list(memberships_set))
75
76 # Make sure that every requested user is in the result, and gets cached.
77 retrieved_dict.update(
78 (user_id, set()) for user_id in keys
79 if user_id not in retrieved_dict)
80 return retrieved_dict
81
82
83class UserGroupService(object):
84 """The persistence layer for user group data."""
85
86 def __init__(self, cache_manager):
87 """Initialize this service so that it is ready to use.
88
89 Args:
90 cache_manager: local cache with distributed invalidation.
91 """
92 self.usergroup_tbl = sql.SQLTableManager(USERGROUP_TABLE_NAME)
93 self.usergroupsettings_tbl = sql.SQLTableManager(
94 USERGROUPSETTINGS_TABLE_NAME)
95 self.usergroupprojects_tbl = sql.SQLTableManager(
96 USERGROUPPROJECTS_TABLE_NAME)
97
98 self.group_dag = UserGroupDAG(self)
99
100 # Like a dictionary {user_id: {group_id}}
101 self.memberships_2lc = MembershipTwoLevelCache(
102 cache_manager, self, self.group_dag)
103 # Like a dictionary {group_email: [group_id]}
104 self.group_id_cache = caches.ValueCentricRamCache(
105 cache_manager, 'usergroup')
106
107 ### Group creation
108
109 def CreateGroup(self, cnxn, services, group_name, who_can_view_members,
110 ext_group_type=None, friend_projects=None):
111 """Create a new user group.
112
113 Args:
114 cnxn: connection to SQL database.
115 services: connections to backend services.
116 group_name: string email address of the group to create.
117 who_can_view_members: 'owners', 'members', or 'anyone'.
118 ext_group_type: The type of external group to import.
119 friend_projects: The project ids declared as group friends to view its
120 members.
121
122 Returns:
123 int group_id of the new group.
124 """
125 friend_projects = friend_projects or []
126 assert who_can_view_members in ('owners', 'members', 'anyone')
127 if ext_group_type:
128 ext_group_type = str(ext_group_type).lower()
129 assert ext_group_type in GROUP_TYPE_ENUM, ext_group_type
130 assert who_can_view_members == 'owners'
131 group_id = services.user.LookupUserID(
132 cnxn, group_name.lower(), autocreate=True, allowgroups=True)
133 group_settings = usergroup_pb2.MakeSettings(
134 who_can_view_members, ext_group_type, 0, friend_projects)
135 self.UpdateSettings(cnxn, group_id, group_settings)
136 self.group_id_cache.InvalidateAll(cnxn)
137 return group_id
138
139 def DeleteGroups(self, cnxn, group_ids):
140 """Delete groups' members and settings. It will NOT delete user entries.
141
142 Args:
143 cnxn: connection to SQL database.
144 group_ids: list of group ids to delete.
145 """
146 member_ids_dict, owner_ids_dict = self.LookupMembers(cnxn, group_ids)
147 citizens_id_dict = collections.defaultdict(list)
148 for g_id, user_ids in member_ids_dict.items():
149 citizens_id_dict[g_id].extend(user_ids)
150 for g_id, user_ids in owner_ids_dict.items():
151 citizens_id_dict[g_id].extend(user_ids)
152 for g_id, citizen_ids in citizens_id_dict.items():
153 logging.info('Deleting group %d', g_id)
154 # Remove group members, friend projects and settings
155 self.RemoveMembers(cnxn, g_id, citizen_ids)
156 self.usergroupprojects_tbl.Delete(cnxn, group_id=g_id)
157 self.usergroupsettings_tbl.Delete(cnxn, group_id=g_id)
158 self.group_id_cache.InvalidateAll(cnxn)
159
160 def DetermineWhichUserIDsAreGroups(self, cnxn, user_ids):
161 """From a list of user IDs, identify potential user groups.
162
163 Args:
164 cnxn: connection to SQL database.
165 user_ids: list of user IDs to examine.
166
167 Returns:
168 A list with a subset of the given user IDs that are user groups
169 rather than individual users.
170 """
171 # It is a group if there is any entry in the UserGroupSettings table.
172 group_id_rows = self.usergroupsettings_tbl.Select(
173 cnxn, cols=['group_id'], group_id=user_ids)
174 group_ids = [row[0] for row in group_id_rows]
175 return group_ids
176
177 ### User memberships in groups
178
179 def LookupComputedMemberships(self, cnxn, domain, use_cache=True):
180 """Look up the computed group memberships of a list of users.
181
182 Args:
183 cnxn: connection to SQL database.
184 domain: string with domain part of user's email address.
185 use_cache: set to False to ignore cached values.
186
187 Returns:
188 A list [group_id] of computed user groups that match the user.
189 For now, the length of this list will always be zero or one.
190 """
191 group_email = 'everyone@%s' % domain
192 group_id = self.LookupUserGroupID(cnxn, group_email, use_cache=use_cache)
193 if group_id:
194 return [group_id]
195
196 return []
197
198 def LookupUserGroupID(self, cnxn, group_email, use_cache=True):
199 """Lookup the group ID for the given user group email address.
200
201 Args:
202 cnxn: connection to SQL database.
203 group_email: string that identies the user group.
204 use_cache: set to False to ignore cached values.
205
206 Returns:
207 Int group_id if found, otherwise None.
208 """
209 if use_cache and self.group_id_cache.HasItem(group_email):
210 return self.group_id_cache.GetItem(group_email)
211
212 rows = self.usergroupsettings_tbl.Select(
213 cnxn, cols=['email', 'group_id'],
214 left_joins=[('User ON UserGroupSettings.group_id = User.user_id', [])],
215 email=group_email,
216 where=[('group_id IS NOT NULL', [])])
217 retrieved_dict = dict(rows)
218 # Cache a "not found" value for emails that are not user groups.
219 if group_email not in retrieved_dict:
220 retrieved_dict[group_email] = None
221 self.group_id_cache.CacheAll(retrieved_dict)
222
223 return retrieved_dict.get(group_email)
224
225 def LookupAllMemberships(self, cnxn, user_ids, use_cache=True):
226 """Lookup all the group memberships of a list of users.
227
228 Args:
229 cnxn: connection to SQL database.
230 user_ids: list of int user IDs to get memberships for.
231 use_cache: set to False to ignore cached values.
232
233 Returns:
234 A dict {user_id: {group_id}} for the given user_ids.
235 """
236 result_dict, missed_ids = self.memberships_2lc.GetAll(
237 cnxn, user_ids, use_cache=use_cache)
238 assert not missed_ids
239 return result_dict
240
241 def LookupMemberships(self, cnxn, user_id):
242 """Return a set of group_ids that this user is a member of."""
243 membership_dict = self.LookupAllMemberships(cnxn, [user_id])
244 return membership_dict[user_id]
245
246 ### Group member addition, removal, and retrieval
247
248 def RemoveMembers(self, cnxn, group_id, old_member_ids):
249 """Remove the given members/owners from the user group."""
250 self.usergroup_tbl.Delete(
251 cnxn, group_id=group_id, user_id=old_member_ids)
252
253 all_affected = self._GetAllMembersInList(cnxn, old_member_ids)
254
255 self.group_dag.MarkObsolete()
256 self.memberships_2lc.InvalidateAllKeys(cnxn, all_affected)
257
258 def UpdateMembers(self, cnxn, group_id, member_ids, new_role):
259 """Update role for given members/owners to the user group."""
260 # Circle detection
261 for mid in member_ids:
262 if self.group_dag.IsChild(cnxn, group_id, mid):
263 raise exceptions.CircularGroupException(
264 '%s is already an ancestor of group %s.' % (mid, group_id))
265
266 self.usergroup_tbl.Delete(
267 cnxn, group_id=group_id, user_id=member_ids)
268 rows = [(member_id, group_id, new_role) for member_id in member_ids]
269 self.usergroup_tbl.InsertRows(
270 cnxn, ['user_id', 'group_id', 'role'], rows)
271
272 all_affected = self._GetAllMembersInList(cnxn, member_ids)
273
274 self.group_dag.MarkObsolete()
275 self.memberships_2lc.InvalidateAllKeys(cnxn, all_affected)
276
277 def _GetAllMembersInList(self, cnxn, group_ids):
278 """Get all direct/indirect members/owners in a list."""
279 children_member_ids, children_owner_ids = self.LookupAllMembers(
280 cnxn, group_ids)
281 all_members_owners = set()
282 all_members_owners.update(group_ids)
283 for users in children_member_ids.values():
284 all_members_owners.update(users)
285 for users in children_owner_ids.values():
286 all_members_owners.update(users)
287 return list(all_members_owners)
288
289 def LookupAllMembers(self, cnxn, group_ids):
290 """Retrieve user IDs of members/owners of any of the given groups
291 transitively."""
292 member_ids_dict = {}
293 owner_ids_dict = {}
294 if not group_ids:
295 return member_ids_dict, owner_ids_dict
296 direct_member_rows = self.usergroup_tbl.Select(
297 cnxn, cols=['user_id', 'group_id', 'role'], distinct=True,
298 group_id=group_ids)
299 for gid in group_ids:
300 all_descendants = self.group_dag.GetAllDescendants(cnxn, gid, True)
301 indirect_member_rows = []
302 if all_descendants:
303 indirect_member_rows = self.usergroup_tbl.Select(
304 cnxn, cols=['user_id'], distinct=True,
305 group_id=all_descendants)
306
307 # Owners must have direct membership. All indirect users are members.
308 owner_ids_dict[gid] = [m[0] for m in direct_member_rows
309 if m[1] == gid and m[2] == 'owner']
310 member_ids_list = [r[0] for r in indirect_member_rows]
311 member_ids_list.extend([m[0] for m in direct_member_rows
312 if m[1] == gid and m[2] == 'member'])
313 member_ids_dict[gid] = list(set(member_ids_list))
314 return member_ids_dict, owner_ids_dict
315
316 def LookupMembers(self, cnxn, group_ids):
317 """"Retrieve user IDs of direct members/owners of any of the given groups.
318
319 Args:
320 cnxn: connection to SQL database.
321 group_ids: list of int user IDs for all user groups to be examined.
322
323 Returns:
324 A dict of member IDs, and a dict of owner IDs keyed by group id.
325 """
326 member_ids_dict = {}
327 owner_ids_dict = {}
328 if not group_ids:
329 return member_ids_dict, owner_ids_dict
330 member_rows = self.usergroup_tbl.Select(
331 cnxn, cols=['user_id', 'group_id', 'role'], distinct=True,
332 group_id=group_ids)
333 for gid in group_ids:
334 member_ids_dict[gid] = [row[0] for row in member_rows
335 if row[1] == gid and row[2] == 'member']
336 owner_ids_dict[gid] = [row[0] for row in member_rows
337 if row[1] == gid and row[2] == 'owner']
338 return member_ids_dict, owner_ids_dict
339
340 def ExpandAnyGroupEmailRecipients(self, cnxn, user_ids):
341 """Expand the list with members that are part of a group configured
342 to have notifications sent directly to members. Remove any groups
343 not configured to have notifications sent directly to the group.
344
345 Args:
346 cnxn: connection to SQL database.
347 user_ids: list of user IDs to check.
348
349 Returns:
350 A paire (individual user_ids, transitive_ids). individual_user_ids
351 is a list of user IDs that were in the given user_ids list and
352 that identify individual members or a group that has
353 settings.notify_group set to True. transitive_ids is a list of
354 user IDs of members of any user group in user_ids with
355 settings.notify_members set to True.
356 """
357 group_ids = self.DetermineWhichUserIDsAreGroups(cnxn, user_ids)
358 group_settings_dict = self.GetAllGroupSettings(cnxn, group_ids)
359 member_ids_dict, owner_ids_dict = self.LookupAllMembers(cnxn, group_ids)
360 indirect_ids = set()
361 direct_ids = {uid for uid in user_ids if uid not in group_ids}
362 for gid, settings in group_settings_dict.items():
363 if settings.notify_members:
364 indirect_ids.update(member_ids_dict.get(gid, set()))
365 indirect_ids.update(owner_ids_dict.get(gid, set()))
366 if settings.notify_group:
367 direct_ids.add(gid)
368
369 return list(direct_ids), list(indirect_ids)
370
371 def LookupVisibleMembers(
372 self, cnxn, group_id_list, perms, effective_ids, services):
373 """"Retrieve the list of user group direct member/owner IDs that the user
374 may see.
375
376 Args:
377 cnxn: connection to SQL database.
378 group_id_list: list of int user IDs for all user groups to be examined.
379 perms: optional PermissionSet for the user viewing this page.
380 effective_ids: set of int user IDs for that user and all
381 their group memberships.
382 services: backend services.
383
384 Returns:
385 A list of all the member IDs from any group that the user is allowed
386 to view.
387 """
388 settings_dict = self.GetAllGroupSettings(cnxn, group_id_list)
389 group_ids = list(settings_dict.keys())
390 (owned_project_ids, membered_project_ids,
391 contrib_project_ids) = services.project.GetUserRolesInAllProjects(
392 cnxn, effective_ids)
393 project_ids = owned_project_ids.union(
394 membered_project_ids).union(contrib_project_ids)
395 # We need to fetch all members/owners to determine whether the requester
396 # has permission to view.
397 direct_member_ids_dict, direct_owner_ids_dict = self.LookupMembers(
398 cnxn, group_ids)
399 all_member_ids_dict, all_owner_ids_dict = self.LookupAllMembers(
400 cnxn, group_ids)
401 visible_member_ids = {}
402 visible_owner_ids = {}
403 for gid in group_ids:
404 member_ids = all_member_ids_dict[gid]
405 owner_ids = all_owner_ids_dict[gid]
406
407 if permissions.CanViewGroupMembers(
408 perms, effective_ids, settings_dict[gid], member_ids, owner_ids,
409 project_ids):
410 visible_member_ids[gid] = direct_member_ids_dict[gid]
411 visible_owner_ids[gid] = direct_owner_ids_dict[gid]
412
413 return visible_member_ids, visible_owner_ids
414
415 ### Group settings
416
417 def GetAllUserGroupsInfo(self, cnxn):
418 """Fetch (addr, member_count, usergroup_settings) for all user groups."""
419 group_rows = self.usergroupsettings_tbl.Select(
420 cnxn, cols=['email'] + USERGROUPSETTINGS_COLS,
421 left_joins=[('User ON UserGroupSettings.group_id = User.user_id', [])])
422 count_rows = self.usergroup_tbl.Select(
423 cnxn, cols=['group_id', 'COUNT(*)'],
424 group_by=['group_id'])
425 count_dict = dict(count_rows)
426
427 group_ids = [g[1] for g in group_rows]
428 friends_dict = self.GetAllGroupFriendProjects(cnxn, group_ids)
429
430 user_group_info_tuples = [
431 (email, count_dict.get(group_id, 0),
432 usergroup_pb2.MakeSettings(visiblity, group_type, last_sync_time,
433 friends_dict.get(group_id, []),
434 bool(notify_members), bool(notify_group)),
435 group_id)
436 for (email, group_id, visiblity, group_type, last_sync_time,
437 notify_members, notify_group) in group_rows]
438 return user_group_info_tuples
439
440 def GetAllGroupSettings(self, cnxn, group_ids):
441 """Fetch {group_id: group_settings} for the specified groups."""
442 # TODO(jrobbins): add settings to control who can join, etc.
443 rows = self.usergroupsettings_tbl.Select(
444 cnxn, cols=USERGROUPSETTINGS_COLS, group_id=group_ids)
445 friends_dict = self.GetAllGroupFriendProjects(cnxn, group_ids)
446 settings_dict = {
447 group_id: usergroup_pb2.MakeSettings(
448 vis, group_type, last_sync_time, friends_dict.get(group_id, []),
449 notify_members=bool(notify_members),
450 notify_group=bool(notify_group))
451 for (group_id, vis, group_type, last_sync_time,
452 notify_members, notify_group) in rows}
453 return settings_dict
454
455 def GetGroupSettings(self, cnxn, group_id):
456 """Retrieve group settings for the specified user group.
457
458 Args:
459 cnxn: connection to SQL database.
460 group_id: int user ID of the user group.
461
462 Returns:
463 A UserGroupSettings object, or None if no such group exists.
464 """
465 return self.GetAllGroupSettings(cnxn, [group_id]).get(group_id)
466
467 def UpdateSettings(self, cnxn, group_id, group_settings):
468 """Update the visiblity settings of the specified group."""
469 who_can_view_members = str(group_settings.who_can_view_members).lower()
470 ext_group_type = group_settings.ext_group_type
471 assert who_can_view_members in ('owners', 'members', 'anyone')
472 if ext_group_type:
473 ext_group_type = str(group_settings.ext_group_type).lower()
474 assert ext_group_type in GROUP_TYPE_ENUM, ext_group_type
475 assert who_can_view_members == 'owners'
476 self.usergroupsettings_tbl.InsertRow(
477 cnxn, group_id=group_id, who_can_view_members=who_can_view_members,
478 external_group_type=ext_group_type,
479 last_sync_time=group_settings.last_sync_time,
480 notify_members=group_settings.notify_members,
481 notify_group=group_settings.notify_group,
482 replace=True)
483 self.usergroupprojects_tbl.Delete(
484 cnxn, group_id=group_id)
485 if group_settings.friend_projects:
486 rows = [(group_id, p_id) for p_id in group_settings.friend_projects]
487 self.usergroupprojects_tbl.InsertRows(
488 cnxn, ['group_id', 'project_id'], rows)
489
490 def GetAllGroupFriendProjects(self, cnxn, group_ids):
491 """Get {group_id: [project_ids]} for the specified user groups."""
492 rows = self.usergroupprojects_tbl.Select(
493 cnxn, cols=USERGROUPPROJECTS_COLS, group_id=group_ids)
494 friends_dict = {}
495 for group_id, project_id in rows:
496 friends_dict.setdefault(group_id, []).append(project_id)
497 return friends_dict
498
499 def GetGroupFriendProjects(self, cnxn, group_id):
500 """Get a list of friend projects for the specified user group."""
501 return self.GetAllGroupFriendProjects(cnxn, [group_id]).get(group_id)
502
503 def ValidateFriendProjects(self, cnxn, services, friend_projects):
504 """Validate friend projects.
505
506 Returns:
507 A list of project ids if no errors, or an error message.
508 """
509 project_names = list(filter(None, re.split('; |, | |;|,', friend_projects)))
510 id_dict = services.project.LookupProjectIDs(cnxn, project_names)
511 missed_projects = []
512 result = []
513 for p_name in project_names:
514 if p_name in id_dict:
515 result.append(id_dict[p_name])
516 else:
517 missed_projects.append(p_name)
518 error_msg = ''
519 if missed_projects:
520 error_msg = 'Project(s) %s do not exist' % ', '.join(missed_projects)
521 return None, error_msg
522 else:
523 return result, None
524
525 # TODO(jrobbins): re-implement FindUntrustedGroups()
526
527 def ExpungeUsersInGroups(self, cnxn, ids):
528 """Wipes the given user from the groups system.
529 The given user_ids may to members or groups, or groups themselves.
530 The groups and all their members will be deleted. The users will be
531 wiped from the groups they belong to.
532
533 It will NOT delete user entries. This method will not commit the
534 operations. This method will not make any changes to in-memory data.
535 """
536 # Delete any groups
537 self.usergroupprojects_tbl.Delete(cnxn, group_id=ids, commit=False)
538 self.usergroupsettings_tbl.Delete(cnxn, group_id=ids, commit=False)
539 self.usergroup_tbl.Delete(cnxn, group_id=ids, commit=False)
540
541 # Delete any group members
542 self.usergroup_tbl.Delete(cnxn, user_id=ids, commit=False)
543
544
545class UserGroupDAG(object):
546 """A directed-acyclic graph of potentially nested user groups."""
547
548 def __init__(self, usergroup_service):
549 self.usergroup_service = usergroup_service
550 self.user_group_parents = collections.defaultdict(list)
551 self.user_group_children = collections.defaultdict(list)
552 self.initialized = False
553
554 def Build(self, cnxn, circle_detection=False):
555 if not self.initialized:
556 self.user_group_parents.clear()
557 self.user_group_children.clear()
558 group_ids = self.usergroup_service.usergroupsettings_tbl.Select(
559 cnxn, cols=['group_id'])
560 usergroup_rows = self.usergroup_service.usergroup_tbl.Select(
561 cnxn, cols=['user_id', 'group_id'], distinct=True,
562 user_id=[r[0] for r in group_ids])
563 for user_id, group_id in usergroup_rows:
564 self.user_group_parents[user_id].append(group_id)
565 self.user_group_children[group_id].append(user_id)
566 self.initialized = True
567
568 if circle_detection:
569 for child_id, parent_ids in self.user_group_parents.items():
570 for parent_id in parent_ids:
571 if self.IsChild(cnxn, parent_id, child_id):
572 logging.error(
573 'Circle exists between group %d and %d.', child_id, parent_id)
574
575 def GetAllAncestors(self, cnxn, group_id, circle_detection=False):
576 """Return a list of distinct ancestor group IDs for the given group."""
577 self.Build(cnxn, circle_detection)
578 result = set()
579 child_ids = [group_id]
580 while child_ids:
581 parent_ids = set()
582 for c_id in child_ids:
583 group_ids = self.user_group_parents[c_id]
584 parent_ids.update(g_id for g_id in group_ids if g_id not in result)
585 result.update(parent_ids)
586 child_ids = list(parent_ids)
587 return list(result)
588
589 def GetAllDescendants(self, cnxn, group_id, circle_detection=False):
590 """Return a list of distinct descendant group IDs for the given group."""
591 self.Build(cnxn, circle_detection)
592 result = set()
593 parent_ids = [group_id]
594 while parent_ids:
595 child_ids = set()
596 for p_id in parent_ids:
597 group_ids = self.user_group_children[p_id]
598 child_ids.update(g_id for g_id in group_ids if g_id not in result)
599 result.update(child_ids)
600 parent_ids = list(child_ids)
601 return list(result)
602
603 def IsChild(self, cnxn, child_id, parent_id):
604 """Returns True if child_id is a direct/indirect child of parent_id."""
605 all_descendants = self.GetAllDescendants(cnxn, parent_id)
606 return child_id in all_descendants
607
608 def MarkObsolete(self):
609 """Mark the DAG as uninitialized so it'll be re-built."""
610 self.initialized = False
611
612 def __repr__(self):
613 result = {}
614 result['parents'] = self.user_group_parents
615 result['children'] = self.user_group_children
616 return str(result)