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