blob: e92f6a95671ce88b32245785abf33cabd4177a06 [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"""A set of functions that provide persistence for projects.
7
8This module provides functions to get, update, create, and (in some
9cases) delete each type of project business object. It provides
10a logical persistence layer on top of the database.
11
12Business objects are described in project_pb2.py.
13"""
14from __future__ import print_function
15from __future__ import division
16from __future__ import absolute_import
17
18import collections
19import logging
20import time
21
22import settings
23from framework import exceptions
24from framework import framework_constants
25from framework import framework_helpers
26from framework import permissions
27from framework import sql
28from services import caches
29from project import project_helpers
30from proto import project_pb2
31
32
33PROJECT_TABLE_NAME = 'Project'
34USER2PROJECT_TABLE_NAME = 'User2Project'
35EXTRAPERM_TABLE_NAME = 'ExtraPerm'
36MEMBERNOTES_TABLE_NAME = 'MemberNotes'
37USERGROUPPROJECTS_TABLE_NAME = 'Group2Project'
38AUTOCOMPLETEEXCLUSION_TABLE_NAME = 'AutocompleteExclusion'
39
40PROJECT_COLS = [
41 'project_id', 'project_name', 'summary', 'description', 'state', 'access',
42 'read_only_reason', 'state_reason', 'delete_time', 'issue_notify_address',
43 'attachment_bytes_used', 'attachment_quota', 'cached_content_timestamp',
44 'recent_activity_timestamp', 'moved_to', 'process_inbound_email',
45 'only_owners_remove_restrictions', 'only_owners_see_contributors',
46 'revision_url_format', 'home_page', 'docs_url', 'source_url', 'logo_gcs_id',
47 'logo_file_name', 'issue_notify_always_detailed'
48]
49USER2PROJECT_COLS = ['project_id', 'user_id', 'role_name']
50EXTRAPERM_COLS = ['project_id', 'user_id', 'perm']
51MEMBERNOTES_COLS = ['project_id', 'user_id', 'notes']
52AUTOCOMPLETEEXCLUSION_COLS = [
53 'project_id', 'user_id', 'ac_exclude', 'no_expand']
54
55RECENT_ACTIVITY_THRESHOLD = framework_constants.SECS_PER_HOUR
56
57
58class ProjectTwoLevelCache(caches.AbstractTwoLevelCache):
59 """Class to manage both RAM and memcache for Project PBs."""
60
61 def __init__(self, cachemanager, project_service):
62 super(ProjectTwoLevelCache, self).__init__(
63 cachemanager, 'project', 'project:', project_pb2.Project)
64 self.project_service = project_service
65
66 def _DeserializeProjects(
67 self, project_rows, role_rows, extraperm_rows):
68 """Convert database rows into a dictionary of Project PB keyed by ID."""
69 project_dict = {}
70
71 for project_row in project_rows:
72 (
73 project_id, project_name, summary, description, state_name,
74 access_name, read_only_reason, state_reason, delete_time,
75 issue_notify_address, attachment_bytes_used, attachment_quota, cct,
76 recent_activity_timestamp, moved_to, process_inbound_email, oorr,
77 oosc, revision_url_format, home_page, docs_url, source_url,
78 logo_gcs_id, logo_file_name,
79 issue_notify_always_detailed) = project_row
80 project = project_pb2.Project()
81 project.project_id = project_id
82 project.project_name = project_name
83 project.summary = summary
84 project.description = description
85 project.state = project_pb2.ProjectState(state_name.upper())
86 project.state_reason = state_reason or ''
87 project.access = project_pb2.ProjectAccess(access_name.upper())
88 project.read_only_reason = read_only_reason or ''
89 project.issue_notify_address = issue_notify_address or ''
90 project.attachment_bytes_used = attachment_bytes_used or 0
91 project.attachment_quota = attachment_quota
92 project.recent_activity = recent_activity_timestamp or 0
93 project.cached_content_timestamp = cct or 0
94 project.delete_time = delete_time or 0
95 project.moved_to = moved_to or ''
96 project.process_inbound_email = bool(process_inbound_email)
97 project.only_owners_remove_restrictions = bool(oorr)
98 project.only_owners_see_contributors = bool(oosc)
99 project.revision_url_format = revision_url_format or ''
100 project.home_page = home_page or ''
101 project.docs_url = docs_url or ''
102 project.source_url = source_url or ''
103 project.logo_gcs_id = logo_gcs_id or ''
104 project.logo_file_name = logo_file_name or ''
105 project.issue_notify_always_detailed = bool(issue_notify_always_detailed)
106 project_dict[project_id] = project
107
108 for project_id, user_id, role_name in role_rows:
109 project = project_dict[project_id]
110 if role_name == 'owner':
111 project.owner_ids.append(user_id)
112 elif role_name == 'committer':
113 project.committer_ids.append(user_id)
114 elif role_name == 'contributor':
115 project.contributor_ids.append(user_id)
116
117 perms = {}
118 for project_id, user_id, perm in extraperm_rows:
119 perms.setdefault(project_id, {}).setdefault(user_id, []).append(perm)
120
121 for project_id, perms_by_user in perms.items():
122 project = project_dict[project_id]
123 for user_id, extra_perms in sorted(perms_by_user.items()):
124 project.extra_perms.append(project_pb2.Project.ExtraPerms(
125 member_id=user_id, perms=extra_perms))
126
127 return project_dict
128
129 def FetchItems(self, cnxn, keys):
130 """On RAM and memcache miss, hit the database to get missing projects."""
131 project_rows = self.project_service.project_tbl.Select(
132 cnxn, cols=PROJECT_COLS, project_id=keys)
133 role_rows = self.project_service.user2project_tbl.Select(
134 cnxn, cols=['project_id', 'user_id', 'role_name'],
135 project_id=keys)
136 extraperm_rows = self.project_service.extraperm_tbl.Select(
137 cnxn, cols=EXTRAPERM_COLS, project_id=keys)
138 retrieved_dict = self._DeserializeProjects(
139 project_rows, role_rows, extraperm_rows)
140 return retrieved_dict
141
142
143class UserToProjectIdTwoLevelCache(caches.AbstractTwoLevelCache):
144 """Class to manage both RAM and memcache for project_ids.
145
146 Keys for this cache are int, user_ids, which might correspond to a group.
147 This cache should be used to fetch a set of project_ids that the user_id
148 is a member of.
149 """
150
151 def __init__(self, cachemanager, project_service):
152 # type: cachemanager_svc.CacheManager, ProjectService -> None
153 super(UserToProjectIdTwoLevelCache, self).__init__(
154 cachemanager, 'project_id', 'project_id:', pb_class=None)
155 self.project_service = project_service
156
157 # Store the last time the table was fetched for rate limit purposes.
158 self.last_fetched = 0
159
160 def FetchItems(self, cnxn, keys):
161 # type MonorailConnection, Collection[int] -> Mapping[int, Collection[int]]
162 """On RAM and memcache miss, hit the database to get missing user_ids."""
163
164 # Unlike with other caches, we fetch and store the entire table.
165 # Thus, for cache misses we limit the rate we re-fetch the table to 60s.
166 now = self._GetCurrentTime()
167 result_dict = collections.defaultdict(set)
168
169 if (now - self.last_fetched) > 60:
170 project_to_user_rows = self.project_service.user2project_tbl.Select(
171 cnxn, cols=['project_id', 'user_id'])
172 self.last_fetched = now
173 # Cache the whole User2Project table.
174 for project_id, user_id in project_to_user_rows:
175 result_dict[user_id].add(project_id)
176
177 # Assume any requested user missing from result is not in any project.
178 result_dict.update(
179 (user_id, set()) for user_id in keys if user_id not in result_dict)
180
181 return result_dict
182
183 def _GetCurrentTime(self):
184 """ Returns the current time. We made a separate method for this to make it
185 easier to unit test. This was a better solution than @mock.patch because
186 the test had several unrelated time.time() calls. Modifying those calls
187 would be more onerous, having to fix calls for this test.
188 """
189 return time.time()
190
191
192class ProjectService(object):
193 """The persistence layer for project data."""
194
195 def __init__(self, cache_manager):
196 """Initialize this module so that it is ready to use.
197
198 Args:
199 cache_manager: local cache with distributed invalidation.
200 """
201 self.project_tbl = sql.SQLTableManager(PROJECT_TABLE_NAME)
202 self.user2project_tbl = sql.SQLTableManager(USER2PROJECT_TABLE_NAME)
203 self.extraperm_tbl = sql.SQLTableManager(EXTRAPERM_TABLE_NAME)
204 self.membernotes_tbl = sql.SQLTableManager(MEMBERNOTES_TABLE_NAME)
205 self.usergroupprojects_tbl = sql.SQLTableManager(
206 USERGROUPPROJECTS_TABLE_NAME)
207 self.acexclusion_tbl = sql.SQLTableManager(
208 AUTOCOMPLETEEXCLUSION_TABLE_NAME)
209
210 # Like a dictionary {project_id: project}
211 self.project_2lc = ProjectTwoLevelCache(cache_manager, self)
212 # A dictionary of user_id to a set of project ids.
213 # Mapping[int, Collection[int]]
214 self.user_to_project_2lc = UserToProjectIdTwoLevelCache(cache_manager, self)
215
216 # The project name to ID cache can never be invalidated by individual
217 # project changes because it is keyed by strings instead of ints. In
218 # the case of rare operations like deleting a project (or a future
219 # project renaming feature), we just InvalidateAll().
220 self.project_names_to_ids = caches.RamCache(cache_manager, 'project')
221
222 ### Creating projects
223
224 def CreateProject(
225 self, cnxn, project_name, owner_ids, committer_ids, contributor_ids,
226 summary, description, state=project_pb2.ProjectState.LIVE,
227 access=None, read_only_reason=None, home_page=None, docs_url=None,
228 source_url=None, logo_gcs_id=None, logo_file_name=None):
229 """Create and store a Project with the given attributes.
230
231 Args:
232 cnxn: connection to SQL database.
233 project_name: a valid project name, all lower case.
234 owner_ids: a list of user IDs for the project owners.
235 committer_ids: a list of user IDs for the project members.
236 contributor_ids: a list of user IDs for the project contributors.
237 summary: one-line explanation of the project.
238 description: one-page explanation of the project.
239 state: a project state enum defined in project_pb2.
240 access: optional project access enum defined in project.proto.
241 read_only_reason: if given, provides a status message and marks
242 the project as read-only.
243 home_page: home page of the project
244 docs_url: url to redirect to for wiki/documentation links
245 source_url: url to redirect to for source browser links
246 logo_gcs_id: google storage object id of the project's logo
247 logo_file_name: uploaded file name of the project's logo
248
249 Returns:
250 The int project_id of the new project.
251
252 Raises:
253 ProjectAlreadyExists: if a project with that name already exists.
254 """
255 assert project_helpers.IsValidProjectName(project_name)
256 if self.LookupProjectIDs(cnxn, [project_name]):
257 raise exceptions.ProjectAlreadyExists()
258
259 project = project_pb2.MakeProject(
260 project_name, state=state, access=access,
261 description=description, summary=summary,
262 owner_ids=owner_ids, committer_ids=committer_ids,
263 contributor_ids=contributor_ids, read_only_reason=read_only_reason,
264 home_page=home_page, docs_url=docs_url, source_url=source_url,
265 logo_gcs_id=logo_gcs_id, logo_file_name=logo_file_name)
266
267 project.project_id = self._InsertProject(cnxn, project)
268 return project.project_id
269
270 def _InsertProject(self, cnxn, project):
271 """Insert the given project into the database."""
272 # Note: project_id is not specified because it is auto_increment.
273 project_id = self.project_tbl.InsertRow(
274 cnxn, project_name=project.project_name,
275 summary=project.summary, description=project.description,
276 state=str(project.state), access=str(project.access),
277 home_page=project.home_page, docs_url=project.docs_url,
278 source_url=project.source_url,
279 logo_gcs_id=project.logo_gcs_id, logo_file_name=project.logo_file_name)
280 logging.info('stored project was given project_id %d', project_id)
281
282 self.user2project_tbl.InsertRows(
283 cnxn, ['project_id', 'user_id', 'role_name'],
284 [(project_id, user_id, 'owner')
285 for user_id in project.owner_ids] +
286 [(project_id, user_id, 'committer')
287 for user_id in project.committer_ids] +
288 [(project_id, user_id, 'contributor')
289 for user_id in project.contributor_ids])
290
291 return project_id
292
293 ### Lookup project names and IDs
294
295 def LookupProjectIDs(self, cnxn, project_names):
296 """Return a list of project IDs for the specified projects."""
297 id_dict, missed_names = self.project_names_to_ids.GetAll(project_names)
298 if missed_names:
299 rows = self.project_tbl.Select(
300 cnxn, cols=['project_name', 'project_id'], project_name=missed_names)
301 retrieved_dict = dict(rows)
302 self.project_names_to_ids.CacheAll(retrieved_dict)
303 id_dict.update(retrieved_dict)
304
305 return id_dict
306
307 def LookupProjectNames(self, cnxn, project_ids):
308 """Lookup the names of the projects with the given IDs."""
309 projects_dict = self.GetProjects(cnxn, project_ids)
310 return {p.project_id: p.project_name
311 for p in projects_dict.values()}
312
313 ### Retrieving projects
314
315 def GetAllProjects(self, cnxn, use_cache=True):
316 """Return A dict mapping IDs to all live project PBs."""
317 project_rows = self.project_tbl.Select(
318 cnxn, cols=['project_id'], state=project_pb2.ProjectState.LIVE)
319 project_ids = [row[0] for row in project_rows]
320 projects_dict = self.GetProjects(cnxn, project_ids, use_cache=use_cache)
321
322 return projects_dict
323
324 def GetVisibleLiveProjects(
325 self, cnxn, logged_in_user, effective_ids, domain=None, use_cache=True):
326 """Return all user visible live project ids.
327
328 Args:
329 cnxn: connection to SQL database.
330 logged_in_user: protocol buffer of the logged in user. Can be None.
331 effective_ids: set of user IDs for this user. Can be None.
332 domain: optional string with HTTP request hostname.
333 use_cache: pass False to force database query to find Project protocol
334 buffers.
335
336 Returns:
337 A list of project ids of user visible live projects sorted by the names
338 of the projects. If host was provided, only projects with that host
339 as their branded domain will be returned.
340 """
341 project_rows = self.project_tbl.Select(
342 cnxn, cols=['project_id'], state=project_pb2.ProjectState.LIVE)
343 project_ids = [row[0] for row in project_rows]
344 projects_dict = self.GetProjects(cnxn, project_ids, use_cache=use_cache)
345 projects_on_host = {
346 project_id: project for project_id, project in projects_dict.items()
347 if not framework_helpers.GetNeededDomain(project.project_name, domain)}
348 visible_projects = []
349 for project in projects_on_host.values():
350 if permissions.UserCanViewProject(logged_in_user, effective_ids, project):
351 visible_projects.append(project)
352 visible_projects.sort(key=lambda p: p.project_name)
353
354 return [project.project_id for project in visible_projects]
355
356 def GetProjects(self, cnxn, project_ids, use_cache=True):
357 """Load all the Project PBs for the given projects.
358
359 Args:
360 cnxn: connection to SQL database.
361 project_ids: list of int project IDs
362 use_cache: pass False to force database query.
363
364 Returns:
365 A dict mapping IDs to the corresponding Project protocol buffers.
366
367 Raises:
368 NoSuchProjectException: if any of the projects was not found.
369 """
370 project_dict, missed_ids = self.project_2lc.GetAll(
371 cnxn, project_ids, use_cache=use_cache)
372
373 # Also, update the project name cache.
374 self.project_names_to_ids.CacheAll(
375 {p.project_name: p.project_id for p in project_dict.values()})
376
377 if missed_ids:
378 raise exceptions.NoSuchProjectException()
379
380 return project_dict
381
382 def GetProject(self, cnxn, project_id, use_cache=True):
383 """Load the specified project from the database."""
384 project_id_dict = self.GetProjects(cnxn, [project_id], use_cache=use_cache)
385 return project_id_dict[project_id]
386
387 def GetProjectsByName(self, cnxn, project_names, use_cache=True):
388 """Load all the Project PBs for the given projects.
389
390 Args:
391 cnxn: connection to SQL database.
392 project_names: list of project names.
393 use_cache: specifify False to force database query.
394
395 Returns:
396 A dict mapping names to the corresponding Project protocol buffers.
397 """
398 project_ids = list(self.LookupProjectIDs(cnxn, project_names).values())
399 projects = self.GetProjects(cnxn, project_ids, use_cache=use_cache)
400 return {p.project_name: p for p in projects.values()}
401
402 def GetProjectByName(self, cnxn, project_name, use_cache=True):
403 """Load the specified project from the database, None if does not exist."""
404 project_dict = self.GetProjectsByName(
405 cnxn, [project_name], use_cache=use_cache)
406 return project_dict.get(project_name)
407
408 ### Deleting projects
409
410 def ExpungeProject(self, cnxn, project_id):
411 """Wipes a project from the system."""
412 logging.info('expunging project %r', project_id)
413 self.user2project_tbl.Delete(cnxn, project_id=project_id)
414 self.usergroupprojects_tbl.Delete(cnxn, project_id=project_id)
415 self.extraperm_tbl.Delete(cnxn, project_id=project_id)
416 self.membernotes_tbl.Delete(cnxn, project_id=project_id)
417 self.acexclusion_tbl.Delete(cnxn, project_id=project_id)
418 self.project_tbl.Delete(cnxn, project_id=project_id)
419
420 ### Updating projects
421
422 def UpdateProject(
423 self,
424 cnxn,
425 project_id,
426 summary=None,
427 description=None,
428 state=None,
429 state_reason=None,
430 access=None,
431 issue_notify_address=None,
432 attachment_bytes_used=None,
433 attachment_quota=None,
434 moved_to=None,
435 process_inbound_email=None,
436 only_owners_remove_restrictions=None,
437 read_only_reason=None,
438 cached_content_timestamp=None,
439 only_owners_see_contributors=None,
440 delete_time=None,
441 recent_activity=None,
442 revision_url_format=None,
443 home_page=None,
444 docs_url=None,
445 source_url=None,
446 logo_gcs_id=None,
447 logo_file_name=None,
448 issue_notify_always_detailed=None,
449 commit=True):
450 """Update the DB with the given project information."""
451 exists = self.project_tbl.SelectValue(
452 cnxn, 'project_name', project_id=project_id)
453 if not exists:
454 raise exceptions.NoSuchProjectException()
455
456 delta = {}
457 if summary is not None:
458 delta['summary'] = summary
459 if description is not None:
460 delta['description'] = description
461 if state is not None:
462 delta['state'] = str(state).lower()
463 if state is not None:
464 delta['state_reason'] = state_reason
465 if access is not None:
466 delta['access'] = str(access).lower()
467 if read_only_reason is not None:
468 delta['read_only_reason'] = read_only_reason
469 if issue_notify_address is not None:
470 delta['issue_notify_address'] = issue_notify_address
471 if attachment_bytes_used is not None:
472 delta['attachment_bytes_used'] = attachment_bytes_used
473 if attachment_quota is not None:
474 delta['attachment_quota'] = attachment_quota
475 if moved_to is not None:
476 delta['moved_to'] = moved_to
477 if process_inbound_email is not None:
478 delta['process_inbound_email'] = process_inbound_email
479 if only_owners_remove_restrictions is not None:
480 delta['only_owners_remove_restrictions'] = (
481 only_owners_remove_restrictions)
482 if only_owners_see_contributors is not None:
483 delta['only_owners_see_contributors'] = only_owners_see_contributors
484 if delete_time is not None:
485 delta['delete_time'] = delete_time
486 if recent_activity is not None:
487 delta['recent_activity_timestamp'] = recent_activity
488 if revision_url_format is not None:
489 delta['revision_url_format'] = revision_url_format
490 if home_page is not None:
491 delta['home_page'] = home_page
492 if docs_url is not None:
493 delta['docs_url'] = docs_url
494 if source_url is not None:
495 delta['source_url'] = source_url
496 if logo_gcs_id is not None:
497 delta['logo_gcs_id'] = logo_gcs_id
498 if logo_file_name is not None:
499 delta['logo_file_name'] = logo_file_name
500 if issue_notify_always_detailed is not None:
501 delta['issue_notify_always_detailed'] = issue_notify_always_detailed
502 if cached_content_timestamp is not None:
503 delta['cached_content_timestamp'] = cached_content_timestamp
504 self.project_tbl.Update(cnxn, delta, project_id=project_id, commit=False)
505 self.project_2lc.InvalidateKeys(cnxn, [project_id])
506 if commit:
507 cnxn.Commit()
508
509 def UpdateCachedContentTimestamp(self, cnxn, project_id, now=None):
510 now = now or int(time.time())
511 self.project_tbl.Update(
512 cnxn, {'cached_content_timestamp': now},
513 project_id=project_id, commit=False)
514 return now
515
516 def UpdateProjectRoles(
517 self, cnxn, project_id, owner_ids, committer_ids, contributor_ids,
518 now=None):
519 """Store the project's roles in the DB and set cached_content_timestamp."""
520 exists = self.project_tbl.SelectValue(
521 cnxn, 'project_name', project_id=project_id)
522 if not exists:
523 raise exceptions.NoSuchProjectException()
524
525 self.UpdateCachedContentTimestamp(cnxn, project_id, now=now)
526
527 self.user2project_tbl.Delete(
528 cnxn, project_id=project_id, role_name='owner', commit=False)
529 self.user2project_tbl.Delete(
530 cnxn, project_id=project_id, role_name='committer', commit=False)
531 self.user2project_tbl.Delete(
532 cnxn, project_id=project_id, role_name='contributor', commit=False)
533
534 self.user2project_tbl.InsertRows(
535 cnxn, ['project_id', 'user_id', 'role_name'],
536 [(project_id, user_id, 'owner') for user_id in owner_ids],
537 commit=False)
538 self.user2project_tbl.InsertRows(
539 cnxn, ['project_id', 'user_id', 'role_name'],
540 [(project_id, user_id, 'committer')
541 for user_id in committer_ids], commit=False)
542
543 self.user2project_tbl.InsertRows(
544 cnxn, ['project_id', 'user_id', 'role_name'],
545 [(project_id, user_id, 'contributor')
546 for user_id in contributor_ids], commit=False)
547
548 cnxn.Commit()
549 self.project_2lc.InvalidateKeys(cnxn, [project_id])
550 updated_user_ids = owner_ids + committer_ids + contributor_ids
551 self.user_to_project_2lc.InvalidateKeys(cnxn, updated_user_ids)
552
553 def MarkProjectDeletable(self, cnxn, project_id, config_service):
554 """Update the project's state to make it DELETABLE and free up the name.
555
556 Args:
557 cnxn: connection to SQL database.
558 project_id: int ID of the project that will be deleted soon.
559 config_service: issue tracker configuration persistence service, needed
560 to invalidate cached issue tracker results.
561 """
562 generated_name = 'DELETABLE_%d' % project_id
563 delta = {'project_name': generated_name, 'state': 'deletable'}
564 self.project_tbl.Update(cnxn, delta, project_id=project_id)
565
566 self.project_2lc.InvalidateKeys(cnxn, [project_id])
567 # We cannot invalidate a specific part of the name->proj cache by name,
568 # So, tell every job to just drop the whole cache. It should refill
569 # efficiently and incrementally from memcache.
570 self.project_2lc.InvalidateAllRamEntries(cnxn)
571 self.user_to_project_2lc.InvalidateAllRamEntries(cnxn)
572 config_service.InvalidateMemcacheForEntireProject(project_id)
573
574 def UpdateRecentActivity(self, cnxn, project_id, now=None):
575 """Set the project's recent_activity to the current time."""
576 now = now or int(time.time())
577 project = self.GetProject(cnxn, project_id)
578 if now > project.recent_activity + RECENT_ACTIVITY_THRESHOLD:
579 self.UpdateProject(cnxn, project_id, recent_activity=now)
580
581 ### Roles, memberships, and extra perms
582
583 def GetUserRolesInAllProjects(self, cnxn, effective_ids):
584 """Return three sets of project IDs where the user has a role."""
585 owned_project_ids = set()
586 membered_project_ids = set()
587 contrib_project_ids = set()
588
589 rows = []
590 if effective_ids:
591 rows = self.user2project_tbl.Select(
592 cnxn, cols=['project_id', 'role_name'], user_id=effective_ids)
593
594 for project_id, role_name in rows:
595 if role_name == 'owner':
596 owned_project_ids.add(project_id)
597 elif role_name == 'committer':
598 membered_project_ids.add(project_id)
599 elif role_name == 'contributor':
600 contrib_project_ids.add(project_id)
601 else:
602 logging.warn('Unexpected role name %r', role_name)
603
604 return owned_project_ids, membered_project_ids, contrib_project_ids
605
606 def GetProjectMemberships(self, cnxn, effective_ids, use_cache=True):
607 # type: MonorailConnection, Collection[int], Optional[bool] ->
608 # Mapping[int, Collection[int]]
609 """Return a list of project IDs where the user has a membership."""
610 project_id_dict, missed_ids = self.user_to_project_2lc.GetAll(
611 cnxn, effective_ids, use_cache=use_cache)
612
613 # Users that were missed are assumed to not have any projects.
614 assert not missed_ids
615
616 return project_id_dict
617
618 def UpdateExtraPerms(
619 self, cnxn, project_id, member_id, extra_perms, now=None):
620 """Load the project, update the member's extra perms, and store.
621
622 Args:
623 cnxn: connection to SQL database.
624 project_id: int ID of the current project.
625 member_id: int user id of the user that was edited.
626 extra_perms: list of strings for perms that the member
627 should have over-and-above what their role gives them.
628 now: fake int(time.time()) value passed in during unit testing.
629 """
630 # This will be a newly constructed object, not from the cache and not
631 # shared with any other thread.
632 project = self.GetProject(cnxn, project_id, use_cache=False)
633
634 idx, member_extra_perms = permissions.FindExtraPerms(project, member_id)
635 if not member_extra_perms and not extra_perms:
636 return
637 if member_extra_perms and list(member_extra_perms.perms) == extra_perms:
638 return
639 # Either project is None or member_id is not a member of the project.
640 if idx is None:
641 return
642
643 if member_extra_perms:
644 member_extra_perms.perms = extra_perms
645 else:
646 member_extra_perms = project_pb2.Project.ExtraPerms(
647 member_id=member_id, perms=extra_perms)
648 # Keep the list of extra_perms sorted by member id.
649 project.extra_perms.insert(idx, member_extra_perms)
650
651 self.extraperm_tbl.Delete(
652 cnxn, project_id=project_id, user_id=member_id, commit=False)
653 self.extraperm_tbl.InsertRows(
654 cnxn, EXTRAPERM_COLS,
655 [(project_id, member_id, perm) for perm in extra_perms],
656 commit=False)
657 project.cached_content_timestamp = self.UpdateCachedContentTimestamp(
658 cnxn, project_id, now=now)
659 cnxn.Commit()
660
661 self.project_2lc.InvalidateKeys(cnxn, [project_id])
662
663 ### Project Commitments
664
665 def GetProjectCommitments(self, cnxn, project_id):
666 """Get the project commitments (notes) from the DB.
667
668 Args:
669 cnxn: connection to SQL database.
670 project_id: int project ID.
671
672 Returns:
673 A the specified project's ProjectCommitments instance, or an empty one,
674 if the project doesn't exist, or has not documented member
675 commitments.
676 """
677 # Get the notes. Don't get the project_id column
678 # since we already know that value.
679 notes_rows = self.membernotes_tbl.Select(
680 cnxn, cols=['user_id', 'notes'], project_id=project_id)
681 notes_dict = dict(notes_rows)
682
683 project_commitments = project_pb2.ProjectCommitments()
684 project_commitments.project_id = project_id
685 for user_id in notes_dict.keys():
686 commitment = project_pb2.ProjectCommitments.MemberCommitment(
687 member_id=user_id,
688 notes=notes_dict.get(user_id, ''))
689 project_commitments.commitments.append(commitment)
690
691 return project_commitments
692
693 def _StoreProjectCommitments(self, cnxn, project_commitments):
694 """Store an updated set of project commitments in the DB.
695
696 Args:
697 cnxn: connection to SQL database.
698 project_commitments: ProjectCommitments PB
699 """
700 project_id = project_commitments.project_id
701 notes_rows = []
702 for commitment in project_commitments.commitments:
703 notes_rows.append(
704 (project_id, commitment.member_id, commitment.notes))
705
706 # TODO(jrobbins): this should be in a transaction.
707 self.membernotes_tbl.Delete(cnxn, project_id=project_id)
708 self.membernotes_tbl.InsertRows(
709 cnxn, MEMBERNOTES_COLS, notes_rows, ignore=True)
710
711 def UpdateCommitments(self, cnxn, project_id, member_id, notes):
712 """Update the member's commitments in the specified project.
713
714 Args:
715 cnxn: connection to SQL database.
716 project_id: int ID of the current project.
717 member_id: int user ID of the user that was edited.
718 notes: further notes on the member's expected involvment
719 in the project.
720 """
721 project_commitments = self.GetProjectCommitments(cnxn, project_id)
722
723 commitment = None
724 for c in project_commitments.commitments:
725 if c.member_id == member_id:
726 commitment = c
727 break
728 else:
729 commitment = project_pb2.ProjectCommitments.MemberCommitment(
730 member_id=member_id)
731 project_commitments.commitments.append(commitment)
732
733 dirty = False
734
735 if commitment.notes != notes:
736 commitment.notes = notes
737 dirty = True
738
739 if dirty:
740 self._StoreProjectCommitments(cnxn, project_commitments)
741
742 def GetProjectAutocompleteExclusion(self, cnxn, project_id):
743 """Get user ids who are excluded from autocomplete list.
744
745 Args:
746 cnxn: connection to SQL database.
747 project_id: int ID of the current project.
748
749 Returns:
750 A pair containing: a list of user IDs who are excluded from the
751 autocomplete list for given project, and a list of group IDs to
752 not expand.
753 """
754 ac_exclusion_rows = self.acexclusion_tbl.Select(
755 cnxn, cols=['user_id'], project_id=project_id, ac_exclude=True)
756 ac_exclusion_ids = [row[0] for row in ac_exclusion_rows]
757 no_expand_rows = self.acexclusion_tbl.Select(
758 cnxn, cols=['user_id'], project_id=project_id, no_expand=True)
759 no_expand_ids = [row[0] for row in no_expand_rows]
760 return ac_exclusion_ids, no_expand_ids
761
762 def UpdateProjectAutocompleteExclusion(
763 self, cnxn, project_id, member_id, ac_exclude, no_expand):
764 """Update autocomplete exclusion for given user.
765
766 Args:
767 cnxn: connection to SQL database.
768 project_id: int ID of the current project.
769 member_id: int user ID of the user that was edited.
770 ac_exclude: Whether this user should be excluded.
771 no_expand: Whether this group should not be expanded.
772 """
773 if ac_exclude or no_expand:
774 self.acexclusion_tbl.InsertRows(
775 cnxn, AUTOCOMPLETEEXCLUSION_COLS,
776 [(project_id, member_id, ac_exclude, no_expand)],
777 replace=True)
778 else:
779 self.acexclusion_tbl.Delete(
780 cnxn, project_id=project_id, user_id=member_id)
781
782 self.UpdateCachedContentTimestamp(cnxn, project_id)
783 cnxn.Commit()
784
785 self.project_2lc.InvalidateKeys(cnxn, [project_id])
786
787 def ExpungeUsersInProjects(self, cnxn, user_ids, limit=None):
788 """Wipes the given users from the projects system.
789
790 This method will not commit the operation. This method will
791 not make changes to in-memory data.
792 """
793 self.extraperm_tbl.Delete(cnxn, user_id=user_ids, limit=limit, commit=False)
794 self.acexclusion_tbl.Delete(
795 cnxn, user_id=user_ids, limit=limit, commit=False)
796 self.membernotes_tbl.Delete(
797 cnxn, user_id=user_ids, limit=limit, commit=False)
798 self.user2project_tbl.Delete(
799 cnxn, user_id=user_ids, limit=limit, commit=False)