| # Copyright 2016 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style |
| # license that can be found in the LICENSE file or at |
| # https://developers.google.com/open-source/licenses/bsd |
| |
| """A set of functions that provide persistence for projects. |
| |
| This module provides functions to get, update, create, and (in some |
| cases) delete each type of project business object. It provides |
| a logical persistence layer on top of the database. |
| |
| Business objects are described in project_pb2.py. |
| """ |
| from __future__ import print_function |
| from __future__ import division |
| from __future__ import absolute_import |
| |
| import collections |
| import logging |
| import time |
| |
| import settings |
| from framework import exceptions |
| from framework import framework_constants |
| from framework import framework_helpers |
| from framework import permissions |
| from framework import sql |
| from services import caches |
| from project import project_helpers |
| from proto import project_pb2 |
| |
| |
| PROJECT_TABLE_NAME = 'Project' |
| USER2PROJECT_TABLE_NAME = 'User2Project' |
| EXTRAPERM_TABLE_NAME = 'ExtraPerm' |
| MEMBERNOTES_TABLE_NAME = 'MemberNotes' |
| USERGROUPPROJECTS_TABLE_NAME = 'Group2Project' |
| AUTOCOMPLETEEXCLUSION_TABLE_NAME = 'AutocompleteExclusion' |
| |
| PROJECT_COLS = [ |
| 'project_id', 'project_name', 'summary', 'description', 'state', 'access', |
| 'read_only_reason', 'state_reason', 'delete_time', 'issue_notify_address', |
| 'attachment_bytes_used', 'attachment_quota', 'cached_content_timestamp', |
| 'recent_activity_timestamp', 'moved_to', 'process_inbound_email', |
| 'only_owners_remove_restrictions', 'only_owners_see_contributors', |
| 'revision_url_format', 'home_page', 'docs_url', 'source_url', 'logo_gcs_id', |
| 'logo_file_name', 'issue_notify_always_detailed' |
| ] |
| USER2PROJECT_COLS = ['project_id', 'user_id', 'role_name'] |
| EXTRAPERM_COLS = ['project_id', 'user_id', 'perm'] |
| MEMBERNOTES_COLS = ['project_id', 'user_id', 'notes'] |
| AUTOCOMPLETEEXCLUSION_COLS = [ |
| 'project_id', 'user_id', 'ac_exclude', 'no_expand'] |
| |
| RECENT_ACTIVITY_THRESHOLD = framework_constants.SECS_PER_HOUR |
| |
| |
| class ProjectTwoLevelCache(caches.AbstractTwoLevelCache): |
| """Class to manage both RAM and memcache for Project PBs.""" |
| |
| def __init__(self, cachemanager, project_service): |
| super(ProjectTwoLevelCache, self).__init__( |
| cachemanager, 'project', 'project:', project_pb2.Project) |
| self.project_service = project_service |
| |
| def _DeserializeProjects( |
| self, project_rows, role_rows, extraperm_rows): |
| """Convert database rows into a dictionary of Project PB keyed by ID.""" |
| project_dict = {} |
| |
| for project_row in project_rows: |
| ( |
| project_id, project_name, summary, description, state_name, |
| access_name, read_only_reason, state_reason, delete_time, |
| issue_notify_address, attachment_bytes_used, attachment_quota, cct, |
| recent_activity_timestamp, moved_to, process_inbound_email, oorr, |
| oosc, revision_url_format, home_page, docs_url, source_url, |
| logo_gcs_id, logo_file_name, |
| issue_notify_always_detailed) = project_row |
| project = project_pb2.Project() |
| project.project_id = project_id |
| project.project_name = project_name |
| project.summary = summary |
| project.description = description |
| project.state = project_pb2.ProjectState(state_name.upper()) |
| project.state_reason = state_reason or '' |
| project.access = project_pb2.ProjectAccess(access_name.upper()) |
| project.read_only_reason = read_only_reason or '' |
| project.issue_notify_address = issue_notify_address or '' |
| project.attachment_bytes_used = attachment_bytes_used or 0 |
| project.attachment_quota = attachment_quota |
| project.recent_activity = recent_activity_timestamp or 0 |
| project.cached_content_timestamp = cct or 0 |
| project.delete_time = delete_time or 0 |
| project.moved_to = moved_to or '' |
| project.process_inbound_email = bool(process_inbound_email) |
| project.only_owners_remove_restrictions = bool(oorr) |
| project.only_owners_see_contributors = bool(oosc) |
| project.revision_url_format = revision_url_format or '' |
| project.home_page = home_page or '' |
| project.docs_url = docs_url or '' |
| project.source_url = source_url or '' |
| project.logo_gcs_id = logo_gcs_id or '' |
| project.logo_file_name = logo_file_name or '' |
| project.issue_notify_always_detailed = bool(issue_notify_always_detailed) |
| project_dict[project_id] = project |
| |
| for project_id, user_id, role_name in role_rows: |
| project = project_dict[project_id] |
| if role_name == 'owner': |
| project.owner_ids.append(user_id) |
| elif role_name == 'committer': |
| project.committer_ids.append(user_id) |
| elif role_name == 'contributor': |
| project.contributor_ids.append(user_id) |
| |
| perms = {} |
| for project_id, user_id, perm in extraperm_rows: |
| perms.setdefault(project_id, {}).setdefault(user_id, []).append(perm) |
| |
| for project_id, perms_by_user in perms.items(): |
| project = project_dict[project_id] |
| for user_id, extra_perms in sorted(perms_by_user.items()): |
| project.extra_perms.append(project_pb2.Project.ExtraPerms( |
| member_id=user_id, perms=extra_perms)) |
| |
| return project_dict |
| |
| def FetchItems(self, cnxn, keys): |
| """On RAM and memcache miss, hit the database to get missing projects.""" |
| project_rows = self.project_service.project_tbl.Select( |
| cnxn, cols=PROJECT_COLS, project_id=keys) |
| role_rows = self.project_service.user2project_tbl.Select( |
| cnxn, cols=['project_id', 'user_id', 'role_name'], |
| project_id=keys) |
| extraperm_rows = self.project_service.extraperm_tbl.Select( |
| cnxn, cols=EXTRAPERM_COLS, project_id=keys) |
| retrieved_dict = self._DeserializeProjects( |
| project_rows, role_rows, extraperm_rows) |
| return retrieved_dict |
| |
| |
| class UserToProjectIdTwoLevelCache(caches.AbstractTwoLevelCache): |
| """Class to manage both RAM and memcache for project_ids. |
| |
| Keys for this cache are int, user_ids, which might correspond to a group. |
| This cache should be used to fetch a set of project_ids that the user_id |
| is a member of. |
| """ |
| |
| def __init__(self, cachemanager, project_service): |
| # type: cachemanager_svc.CacheManager, ProjectService -> None |
| super(UserToProjectIdTwoLevelCache, self).__init__( |
| cachemanager, 'project_id', 'project_id:', pb_class=None) |
| self.project_service = project_service |
| |
| # Store the last time the table was fetched for rate limit purposes. |
| self.last_fetched = 0 |
| |
| def FetchItems(self, cnxn, keys): |
| # type MonorailConnection, Collection[int] -> Mapping[int, Collection[int]] |
| """On RAM and memcache miss, hit the database to get missing user_ids.""" |
| |
| # Unlike with other caches, we fetch and store the entire table. |
| # Thus, for cache misses we limit the rate we re-fetch the table to 60s. |
| now = self._GetCurrentTime() |
| result_dict = collections.defaultdict(set) |
| |
| if (now - self.last_fetched) > 60: |
| project_to_user_rows = self.project_service.user2project_tbl.Select( |
| cnxn, cols=['project_id', 'user_id']) |
| self.last_fetched = now |
| # Cache the whole User2Project table. |
| for project_id, user_id in project_to_user_rows: |
| result_dict[user_id].add(project_id) |
| |
| # Assume any requested user missing from result is not in any project. |
| result_dict.update( |
| (user_id, set()) for user_id in keys if user_id not in result_dict) |
| |
| return result_dict |
| |
| def _GetCurrentTime(self): |
| """ Returns the current time. We made a separate method for this to make it |
| easier to unit test. This was a better solution than @mock.patch because |
| the test had several unrelated time.time() calls. Modifying those calls |
| would be more onerous, having to fix calls for this test. |
| """ |
| return time.time() |
| |
| |
| class ProjectService(object): |
| """The persistence layer for project data.""" |
| |
| def __init__(self, cache_manager): |
| """Initialize this module so that it is ready to use. |
| |
| Args: |
| cache_manager: local cache with distributed invalidation. |
| """ |
| self.project_tbl = sql.SQLTableManager(PROJECT_TABLE_NAME) |
| self.user2project_tbl = sql.SQLTableManager(USER2PROJECT_TABLE_NAME) |
| self.extraperm_tbl = sql.SQLTableManager(EXTRAPERM_TABLE_NAME) |
| self.membernotes_tbl = sql.SQLTableManager(MEMBERNOTES_TABLE_NAME) |
| self.usergroupprojects_tbl = sql.SQLTableManager( |
| USERGROUPPROJECTS_TABLE_NAME) |
| self.acexclusion_tbl = sql.SQLTableManager( |
| AUTOCOMPLETEEXCLUSION_TABLE_NAME) |
| |
| # Like a dictionary {project_id: project} |
| self.project_2lc = ProjectTwoLevelCache(cache_manager, self) |
| # A dictionary of user_id to a set of project ids. |
| # Mapping[int, Collection[int]] |
| self.user_to_project_2lc = UserToProjectIdTwoLevelCache(cache_manager, self) |
| |
| # The project name to ID cache can never be invalidated by individual |
| # project changes because it is keyed by strings instead of ints. In |
| # the case of rare operations like deleting a project (or a future |
| # project renaming feature), we just InvalidateAll(). |
| self.project_names_to_ids = caches.RamCache(cache_manager, 'project') |
| |
| ### Creating projects |
| |
| def CreateProject( |
| self, cnxn, project_name, owner_ids, committer_ids, contributor_ids, |
| summary, description, state=project_pb2.ProjectState.LIVE, |
| access=None, read_only_reason=None, home_page=None, docs_url=None, |
| source_url=None, logo_gcs_id=None, logo_file_name=None): |
| """Create and store a Project with the given attributes. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_name: a valid project name, all lower case. |
| owner_ids: a list of user IDs for the project owners. |
| committer_ids: a list of user IDs for the project members. |
| contributor_ids: a list of user IDs for the project contributors. |
| summary: one-line explanation of the project. |
| description: one-page explanation of the project. |
| state: a project state enum defined in project_pb2. |
| access: optional project access enum defined in project.proto. |
| read_only_reason: if given, provides a status message and marks |
| the project as read-only. |
| home_page: home page of the project |
| docs_url: url to redirect to for wiki/documentation links |
| source_url: url to redirect to for source browser links |
| logo_gcs_id: google storage object id of the project's logo |
| logo_file_name: uploaded file name of the project's logo |
| |
| Returns: |
| The int project_id of the new project. |
| |
| Raises: |
| ProjectAlreadyExists: if a project with that name already exists. |
| """ |
| assert project_helpers.IsValidProjectName(project_name) |
| if self.LookupProjectIDs(cnxn, [project_name]): |
| raise exceptions.ProjectAlreadyExists() |
| |
| project = project_pb2.MakeProject( |
| project_name, state=state, access=access, |
| description=description, summary=summary, |
| owner_ids=owner_ids, committer_ids=committer_ids, |
| contributor_ids=contributor_ids, read_only_reason=read_only_reason, |
| home_page=home_page, docs_url=docs_url, source_url=source_url, |
| logo_gcs_id=logo_gcs_id, logo_file_name=logo_file_name) |
| |
| project.project_id = self._InsertProject(cnxn, project) |
| return project.project_id |
| |
| def _InsertProject(self, cnxn, project): |
| """Insert the given project into the database.""" |
| # Note: project_id is not specified because it is auto_increment. |
| project_id = self.project_tbl.InsertRow( |
| cnxn, project_name=project.project_name, |
| summary=project.summary, description=project.description, |
| state=str(project.state), access=str(project.access), |
| home_page=project.home_page, docs_url=project.docs_url, |
| source_url=project.source_url, |
| logo_gcs_id=project.logo_gcs_id, logo_file_name=project.logo_file_name) |
| logging.info('stored project was given project_id %d', project_id) |
| |
| self.user2project_tbl.InsertRows( |
| cnxn, ['project_id', 'user_id', 'role_name'], |
| [(project_id, user_id, 'owner') |
| for user_id in project.owner_ids] + |
| [(project_id, user_id, 'committer') |
| for user_id in project.committer_ids] + |
| [(project_id, user_id, 'contributor') |
| for user_id in project.contributor_ids]) |
| |
| return project_id |
| |
| ### Lookup project names and IDs |
| |
| def LookupProjectIDs(self, cnxn, project_names): |
| """Return a list of project IDs for the specified projects.""" |
| id_dict, missed_names = self.project_names_to_ids.GetAll(project_names) |
| if missed_names: |
| rows = self.project_tbl.Select( |
| cnxn, cols=['project_name', 'project_id'], project_name=missed_names) |
| retrieved_dict = dict(rows) |
| self.project_names_to_ids.CacheAll(retrieved_dict) |
| id_dict.update(retrieved_dict) |
| |
| return id_dict |
| |
| def LookupProjectNames(self, cnxn, project_ids): |
| """Lookup the names of the projects with the given IDs.""" |
| projects_dict = self.GetProjects(cnxn, project_ids) |
| return {p.project_id: p.project_name |
| for p in projects_dict.values()} |
| |
| ### Retrieving projects |
| |
| def GetAllProjects(self, cnxn, use_cache=True): |
| """Return A dict mapping IDs to all live project PBs.""" |
| project_rows = self.project_tbl.Select( |
| cnxn, cols=['project_id'], state=project_pb2.ProjectState.LIVE) |
| project_ids = [row[0] for row in project_rows] |
| projects_dict = self.GetProjects(cnxn, project_ids, use_cache=use_cache) |
| |
| return projects_dict |
| |
| def GetVisibleLiveProjects( |
| self, cnxn, logged_in_user, effective_ids, domain=None, use_cache=True): |
| """Return all user visible live project ids. |
| |
| Args: |
| cnxn: connection to SQL database. |
| logged_in_user: protocol buffer of the logged in user. Can be None. |
| effective_ids: set of user IDs for this user. Can be None. |
| domain: optional string with HTTP request hostname. |
| use_cache: pass False to force database query to find Project protocol |
| buffers. |
| |
| Returns: |
| A list of project ids of user visible live projects sorted by the names |
| of the projects. If host was provided, only projects with that host |
| as their branded domain will be returned. |
| """ |
| project_rows = self.project_tbl.Select( |
| cnxn, cols=['project_id'], state=project_pb2.ProjectState.LIVE) |
| project_ids = [row[0] for row in project_rows] |
| projects_dict = self.GetProjects(cnxn, project_ids, use_cache=use_cache) |
| projects_on_host = { |
| project_id: project for project_id, project in projects_dict.items() |
| if not framework_helpers.GetNeededDomain(project.project_name, domain)} |
| visible_projects = [] |
| for project in projects_on_host.values(): |
| if permissions.UserCanViewProject(logged_in_user, effective_ids, project): |
| visible_projects.append(project) |
| visible_projects.sort(key=lambda p: p.project_name) |
| |
| return [project.project_id for project in visible_projects] |
| |
| def GetProjects(self, cnxn, project_ids, use_cache=True): |
| """Load all the Project PBs for the given projects. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_ids: list of int project IDs |
| use_cache: pass False to force database query. |
| |
| Returns: |
| A dict mapping IDs to the corresponding Project protocol buffers. |
| |
| Raises: |
| NoSuchProjectException: if any of the projects was not found. |
| """ |
| project_dict, missed_ids = self.project_2lc.GetAll( |
| cnxn, project_ids, use_cache=use_cache) |
| |
| # Also, update the project name cache. |
| self.project_names_to_ids.CacheAll( |
| {p.project_name: p.project_id for p in project_dict.values()}) |
| |
| if missed_ids: |
| raise exceptions.NoSuchProjectException() |
| |
| return project_dict |
| |
| def GetProject(self, cnxn, project_id, use_cache=True): |
| """Load the specified project from the database.""" |
| project_id_dict = self.GetProjects(cnxn, [project_id], use_cache=use_cache) |
| return project_id_dict[project_id] |
| |
| def GetProjectsByName(self, cnxn, project_names, use_cache=True): |
| """Load all the Project PBs for the given projects. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_names: list of project names. |
| use_cache: specifify False to force database query. |
| |
| Returns: |
| A dict mapping names to the corresponding Project protocol buffers. |
| """ |
| project_ids = list(self.LookupProjectIDs(cnxn, project_names).values()) |
| projects = self.GetProjects(cnxn, project_ids, use_cache=use_cache) |
| return {p.project_name: p for p in projects.values()} |
| |
| def GetProjectByName(self, cnxn, project_name, use_cache=True): |
| """Load the specified project from the database, None if does not exist.""" |
| project_dict = self.GetProjectsByName( |
| cnxn, [project_name], use_cache=use_cache) |
| return project_dict.get(project_name) |
| |
| ### Deleting projects |
| |
| def ExpungeProject(self, cnxn, project_id): |
| """Wipes a project from the system.""" |
| logging.info('expunging project %r', project_id) |
| self.user2project_tbl.Delete(cnxn, project_id=project_id) |
| self.usergroupprojects_tbl.Delete(cnxn, project_id=project_id) |
| self.extraperm_tbl.Delete(cnxn, project_id=project_id) |
| self.membernotes_tbl.Delete(cnxn, project_id=project_id) |
| self.acexclusion_tbl.Delete(cnxn, project_id=project_id) |
| self.project_tbl.Delete(cnxn, project_id=project_id) |
| |
| ### Updating projects |
| |
| def UpdateProject( |
| self, |
| cnxn, |
| project_id, |
| summary=None, |
| description=None, |
| state=None, |
| state_reason=None, |
| access=None, |
| issue_notify_address=None, |
| attachment_bytes_used=None, |
| attachment_quota=None, |
| moved_to=None, |
| process_inbound_email=None, |
| only_owners_remove_restrictions=None, |
| read_only_reason=None, |
| cached_content_timestamp=None, |
| only_owners_see_contributors=None, |
| delete_time=None, |
| recent_activity=None, |
| revision_url_format=None, |
| home_page=None, |
| docs_url=None, |
| source_url=None, |
| logo_gcs_id=None, |
| logo_file_name=None, |
| issue_notify_always_detailed=None, |
| commit=True): |
| """Update the DB with the given project information.""" |
| exists = self.project_tbl.SelectValue( |
| cnxn, 'project_name', project_id=project_id) |
| if not exists: |
| raise exceptions.NoSuchProjectException() |
| |
| delta = {} |
| if summary is not None: |
| delta['summary'] = summary |
| if description is not None: |
| delta['description'] = description |
| if state is not None: |
| delta['state'] = str(state).lower() |
| if state is not None: |
| delta['state_reason'] = state_reason |
| if access is not None: |
| delta['access'] = str(access).lower() |
| if read_only_reason is not None: |
| delta['read_only_reason'] = read_only_reason |
| if issue_notify_address is not None: |
| delta['issue_notify_address'] = issue_notify_address |
| if attachment_bytes_used is not None: |
| delta['attachment_bytes_used'] = attachment_bytes_used |
| if attachment_quota is not None: |
| delta['attachment_quota'] = attachment_quota |
| if moved_to is not None: |
| delta['moved_to'] = moved_to |
| if process_inbound_email is not None: |
| delta['process_inbound_email'] = process_inbound_email |
| if only_owners_remove_restrictions is not None: |
| delta['only_owners_remove_restrictions'] = ( |
| only_owners_remove_restrictions) |
| if only_owners_see_contributors is not None: |
| delta['only_owners_see_contributors'] = only_owners_see_contributors |
| if delete_time is not None: |
| delta['delete_time'] = delete_time |
| if recent_activity is not None: |
| delta['recent_activity_timestamp'] = recent_activity |
| if revision_url_format is not None: |
| delta['revision_url_format'] = revision_url_format |
| if home_page is not None: |
| delta['home_page'] = home_page |
| if docs_url is not None: |
| delta['docs_url'] = docs_url |
| if source_url is not None: |
| delta['source_url'] = source_url |
| if logo_gcs_id is not None: |
| delta['logo_gcs_id'] = logo_gcs_id |
| if logo_file_name is not None: |
| delta['logo_file_name'] = logo_file_name |
| if issue_notify_always_detailed is not None: |
| delta['issue_notify_always_detailed'] = issue_notify_always_detailed |
| if cached_content_timestamp is not None: |
| delta['cached_content_timestamp'] = cached_content_timestamp |
| self.project_tbl.Update(cnxn, delta, project_id=project_id, commit=False) |
| self.project_2lc.InvalidateKeys(cnxn, [project_id]) |
| if commit: |
| cnxn.Commit() |
| |
| def UpdateCachedContentTimestamp(self, cnxn, project_id, now=None): |
| now = now or int(time.time()) |
| self.project_tbl.Update( |
| cnxn, {'cached_content_timestamp': now}, |
| project_id=project_id, commit=False) |
| return now |
| |
| def UpdateProjectRoles( |
| self, cnxn, project_id, owner_ids, committer_ids, contributor_ids, |
| now=None): |
| """Store the project's roles in the DB and set cached_content_timestamp.""" |
| exists = self.project_tbl.SelectValue( |
| cnxn, 'project_name', project_id=project_id) |
| if not exists: |
| raise exceptions.NoSuchProjectException() |
| |
| self.UpdateCachedContentTimestamp(cnxn, project_id, now=now) |
| |
| self.user2project_tbl.Delete( |
| cnxn, project_id=project_id, role_name='owner', commit=False) |
| self.user2project_tbl.Delete( |
| cnxn, project_id=project_id, role_name='committer', commit=False) |
| self.user2project_tbl.Delete( |
| cnxn, project_id=project_id, role_name='contributor', commit=False) |
| |
| self.user2project_tbl.InsertRows( |
| cnxn, ['project_id', 'user_id', 'role_name'], |
| [(project_id, user_id, 'owner') for user_id in owner_ids], |
| commit=False) |
| self.user2project_tbl.InsertRows( |
| cnxn, ['project_id', 'user_id', 'role_name'], |
| [(project_id, user_id, 'committer') |
| for user_id in committer_ids], commit=False) |
| |
| self.user2project_tbl.InsertRows( |
| cnxn, ['project_id', 'user_id', 'role_name'], |
| [(project_id, user_id, 'contributor') |
| for user_id in contributor_ids], commit=False) |
| |
| cnxn.Commit() |
| self.project_2lc.InvalidateKeys(cnxn, [project_id]) |
| updated_user_ids = owner_ids + committer_ids + contributor_ids |
| self.user_to_project_2lc.InvalidateKeys(cnxn, updated_user_ids) |
| |
| def MarkProjectDeletable(self, cnxn, project_id, config_service): |
| """Update the project's state to make it DELETABLE and free up the name. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int ID of the project that will be deleted soon. |
| config_service: issue tracker configuration persistence service, needed |
| to invalidate cached issue tracker results. |
| """ |
| generated_name = 'DELETABLE_%d' % project_id |
| delta = {'project_name': generated_name, 'state': 'deletable'} |
| self.project_tbl.Update(cnxn, delta, project_id=project_id) |
| |
| self.project_2lc.InvalidateKeys(cnxn, [project_id]) |
| # We cannot invalidate a specific part of the name->proj cache by name, |
| # So, tell every job to just drop the whole cache. It should refill |
| # efficiently and incrementally from memcache. |
| self.project_2lc.InvalidateAllRamEntries(cnxn) |
| self.user_to_project_2lc.InvalidateAllRamEntries(cnxn) |
| config_service.InvalidateMemcacheForEntireProject(project_id) |
| |
| def UpdateRecentActivity(self, cnxn, project_id, now=None): |
| """Set the project's recent_activity to the current time.""" |
| now = now or int(time.time()) |
| project = self.GetProject(cnxn, project_id) |
| if now > project.recent_activity + RECENT_ACTIVITY_THRESHOLD: |
| self.UpdateProject(cnxn, project_id, recent_activity=now) |
| |
| ### Roles, memberships, and extra perms |
| |
| def GetUserRolesInAllProjects(self, cnxn, effective_ids): |
| """Return three sets of project IDs where the user has a role.""" |
| owned_project_ids = set() |
| membered_project_ids = set() |
| contrib_project_ids = set() |
| |
| rows = [] |
| if effective_ids: |
| rows = self.user2project_tbl.Select( |
| cnxn, cols=['project_id', 'role_name'], user_id=effective_ids) |
| |
| for project_id, role_name in rows: |
| if role_name == 'owner': |
| owned_project_ids.add(project_id) |
| elif role_name == 'committer': |
| membered_project_ids.add(project_id) |
| elif role_name == 'contributor': |
| contrib_project_ids.add(project_id) |
| else: |
| logging.warn('Unexpected role name %r', role_name) |
| |
| return owned_project_ids, membered_project_ids, contrib_project_ids |
| |
| def GetProjectMemberships(self, cnxn, effective_ids, use_cache=True): |
| # type: MonorailConnection, Collection[int], Optional[bool] -> |
| # Mapping[int, Collection[int]] |
| """Return a list of project IDs where the user has a membership.""" |
| project_id_dict, missed_ids = self.user_to_project_2lc.GetAll( |
| cnxn, effective_ids, use_cache=use_cache) |
| |
| # Users that were missed are assumed to not have any projects. |
| assert not missed_ids |
| |
| return project_id_dict |
| |
| def UpdateExtraPerms( |
| self, cnxn, project_id, member_id, extra_perms, now=None): |
| """Load the project, update the member's extra perms, and store. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int ID of the current project. |
| member_id: int user id of the user that was edited. |
| extra_perms: list of strings for perms that the member |
| should have over-and-above what their role gives them. |
| now: fake int(time.time()) value passed in during unit testing. |
| """ |
| # This will be a newly constructed object, not from the cache and not |
| # shared with any other thread. |
| project = self.GetProject(cnxn, project_id, use_cache=False) |
| |
| idx, member_extra_perms = permissions.FindExtraPerms(project, member_id) |
| if not member_extra_perms and not extra_perms: |
| return |
| if member_extra_perms and list(member_extra_perms.perms) == extra_perms: |
| return |
| # Either project is None or member_id is not a member of the project. |
| if idx is None: |
| return |
| |
| if member_extra_perms: |
| member_extra_perms.perms = extra_perms |
| else: |
| member_extra_perms = project_pb2.Project.ExtraPerms( |
| member_id=member_id, perms=extra_perms) |
| # Keep the list of extra_perms sorted by member id. |
| project.extra_perms.insert(idx, member_extra_perms) |
| |
| self.extraperm_tbl.Delete( |
| cnxn, project_id=project_id, user_id=member_id, commit=False) |
| self.extraperm_tbl.InsertRows( |
| cnxn, EXTRAPERM_COLS, |
| [(project_id, member_id, perm) for perm in extra_perms], |
| commit=False) |
| project.cached_content_timestamp = self.UpdateCachedContentTimestamp( |
| cnxn, project_id, now=now) |
| cnxn.Commit() |
| |
| self.project_2lc.InvalidateKeys(cnxn, [project_id]) |
| |
| ### Project Commitments |
| |
| def GetProjectCommitments(self, cnxn, project_id): |
| """Get the project commitments (notes) from the DB. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int project ID. |
| |
| Returns: |
| A the specified project's ProjectCommitments instance, or an empty one, |
| if the project doesn't exist, or has not documented member |
| commitments. |
| """ |
| # Get the notes. Don't get the project_id column |
| # since we already know that value. |
| notes_rows = self.membernotes_tbl.Select( |
| cnxn, cols=['user_id', 'notes'], project_id=project_id) |
| notes_dict = dict(notes_rows) |
| |
| project_commitments = project_pb2.ProjectCommitments() |
| project_commitments.project_id = project_id |
| for user_id in notes_dict.keys(): |
| commitment = project_pb2.ProjectCommitments.MemberCommitment( |
| member_id=user_id, |
| notes=notes_dict.get(user_id, '')) |
| project_commitments.commitments.append(commitment) |
| |
| return project_commitments |
| |
| def _StoreProjectCommitments(self, cnxn, project_commitments): |
| """Store an updated set of project commitments in the DB. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_commitments: ProjectCommitments PB |
| """ |
| project_id = project_commitments.project_id |
| notes_rows = [] |
| for commitment in project_commitments.commitments: |
| notes_rows.append( |
| (project_id, commitment.member_id, commitment.notes)) |
| |
| # TODO(jrobbins): this should be in a transaction. |
| self.membernotes_tbl.Delete(cnxn, project_id=project_id) |
| self.membernotes_tbl.InsertRows( |
| cnxn, MEMBERNOTES_COLS, notes_rows, ignore=True) |
| |
| def UpdateCommitments(self, cnxn, project_id, member_id, notes): |
| """Update the member's commitments in the specified project. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int ID of the current project. |
| member_id: int user ID of the user that was edited. |
| notes: further notes on the member's expected involvment |
| in the project. |
| """ |
| project_commitments = self.GetProjectCommitments(cnxn, project_id) |
| |
| commitment = None |
| for c in project_commitments.commitments: |
| if c.member_id == member_id: |
| commitment = c |
| break |
| else: |
| commitment = project_pb2.ProjectCommitments.MemberCommitment( |
| member_id=member_id) |
| project_commitments.commitments.append(commitment) |
| |
| dirty = False |
| |
| if commitment.notes != notes: |
| commitment.notes = notes |
| dirty = True |
| |
| if dirty: |
| self._StoreProjectCommitments(cnxn, project_commitments) |
| |
| def GetProjectAutocompleteExclusion(self, cnxn, project_id): |
| """Get user ids who are excluded from autocomplete list. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int ID of the current project. |
| |
| Returns: |
| A pair containing: a list of user IDs who are excluded from the |
| autocomplete list for given project, and a list of group IDs to |
| not expand. |
| """ |
| ac_exclusion_rows = self.acexclusion_tbl.Select( |
| cnxn, cols=['user_id'], project_id=project_id, ac_exclude=True) |
| ac_exclusion_ids = [row[0] for row in ac_exclusion_rows] |
| no_expand_rows = self.acexclusion_tbl.Select( |
| cnxn, cols=['user_id'], project_id=project_id, no_expand=True) |
| no_expand_ids = [row[0] for row in no_expand_rows] |
| return ac_exclusion_ids, no_expand_ids |
| |
| def UpdateProjectAutocompleteExclusion( |
| self, cnxn, project_id, member_id, ac_exclude, no_expand): |
| """Update autocomplete exclusion for given user. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int ID of the current project. |
| member_id: int user ID of the user that was edited. |
| ac_exclude: Whether this user should be excluded. |
| no_expand: Whether this group should not be expanded. |
| """ |
| if ac_exclude or no_expand: |
| self.acexclusion_tbl.InsertRows( |
| cnxn, AUTOCOMPLETEEXCLUSION_COLS, |
| [(project_id, member_id, ac_exclude, no_expand)], |
| replace=True) |
| else: |
| self.acexclusion_tbl.Delete( |
| cnxn, project_id=project_id, user_id=member_id) |
| |
| self.UpdateCachedContentTimestamp(cnxn, project_id) |
| cnxn.Commit() |
| |
| self.project_2lc.InvalidateKeys(cnxn, [project_id]) |
| |
| def ExpungeUsersInProjects(self, cnxn, user_ids, limit=None): |
| """Wipes the given users from the projects system. |
| |
| This method will not commit the operation. This method will |
| not make changes to in-memory data. |
| """ |
| self.extraperm_tbl.Delete(cnxn, user_id=user_ids, limit=limit, commit=False) |
| self.acexclusion_tbl.Delete( |
| cnxn, user_id=user_ids, limit=limit, commit=False) |
| self.membernotes_tbl.Delete( |
| cnxn, user_id=user_ids, limit=limit, commit=False) |
| self.user2project_tbl.Delete( |
| cnxn, user_id=user_ids, limit=limit, commit=False) |