Index: appengine/monorail/services/project_svc.py |
diff --git a/appengine/monorail/services/project_svc.py b/appengine/monorail/services/project_svc.py |
new file mode 100644 |
index 0000000000000000000000000000000000000000..41cf12764988ee9987bdcf8496905983508aaf6c |
--- /dev/null |
+++ b/appengine/monorail/services/project_svc.py |
@@ -0,0 +1,648 @@ |
+# Copyright 2016 The Chromium Authors. All rights reserved. |
+# Use of this source code is govered 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. |
+""" |
+ |
+import collections |
+import logging |
+import time |
+ |
+import settings |
+from framework import framework_bizobj |
+from framework import permissions |
+from framework import sql |
+from services import caches |
+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' |
+ |
+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', 'logo_gcs_id', 'logo_file_name'] |
+USER2PROJECT_COLS = ['project_id', 'user_id', 'role_name'] |
+EXTRAPERM_COLS = ['project_id', 'user_id', 'perm'] |
+MEMBERNOTES_COLS = ['project_id', 'user_id', 'notes'] |
+ |
+ |
+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, |
+ logo_gcs_id, logo_file_name) = 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.logo_gcs_id = logo_gcs_id or '' |
+ project.logo_file_name = logo_file_name or '' |
+ 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) |
+ |
+ for project_id, user_id, perm in extraperm_rows: |
+ project = project_dict[project_id] |
+ extra_perms = permissions.FindExtraPerms(project, user_id) |
+ if not extra_perms: |
+ extra_perms = project_pb2.Project.ExtraPerms( |
+ member_id=user_id) |
+ project.extra_perms.append(extra_perms) |
+ |
+ extra_perms.perms.append(perm) |
+ |
+ 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 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) |
+ |
+ # Like a dictionary {project_id: project} |
+ self.project_2lc = ProjectTwoLevelCache(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 = cache_manager.MakeCache('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=None, home_page=None, docs_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: 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 |
+ 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 framework_bizobj.IsValidProjectName(project_name) |
+ if self.LookupProjectIDs(cnxn, [project_name]): |
+ raise 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=read_only, |
+ home_page=home_page, docs_url=docs_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, |
+ 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.itervalues()} |
+ |
+ ### 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, |
+ 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. |
+ 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. |
+ """ |
+ 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) |
+ |
+ visible_projects = [project for project in projects_dict.values() |
+ if permissions.UserCanViewProject( |
+ logged_in_user, effective_ids, 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.itervalues()}) |
+ |
+ if missed_ids: |
+ raise 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 = 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.itervalues()} |
+ |
+ 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.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, logo_gcs_id=None, logo_file_name=None): |
+ """Update the DB with the given project information.""" |
+ # 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) |
+ if not project: |
+ raise 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 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 cached_content_timestamp is not None: |
+ delta['cached_content_timestamp'] = cached_content_timestamp |
+ self.project_tbl.Update(cnxn, delta, project_id=project_id) |
+ |
+ self.project_2lc.InvalidateKeys(cnxn, [project_id]) |
+ |
+ # Now update the full-text index. |
+ if summary is not None: |
+ project.summary = summary |
+ if description is not None: |
+ project.description = description |
+ if state is not None: |
+ project.state = state |
+ if access is not None: |
+ project.access = access |
+ if only_owners_remove_restrictions is not None: |
+ project.only_owners_remove_restrictions = ( |
+ only_owners_remove_restrictions) |
+ if only_owners_see_contributors is not None: |
+ project.only_owners_see_contributors = only_owners_see_contributors |
+ |
+ 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.""" |
+ # 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) |
+ if not project: |
+ raise NoSuchProjectException() |
+ |
+ now = now or int(time.time()) |
+ self.project_tbl.Update( |
+ cnxn, {'cached_content_timestamp': now}, |
+ project_id=project_id) |
+ |
+ 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]) |
+ |
+ project.owner_ids = owner_ids |
+ project.committer_ids = committer_ids |
+ project.contributor_ids = contributor_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) |
+ 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()) |
+ self.UpdateProject(cnxn, project_id, recent_activity=now) |
+ |
+ ### Roles 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 = 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 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) |
+ |
+ 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 |
+ |
+ 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) |
+ project.extra_perms.append(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) |
+ now = now or int(time.time()) |
+ project.cached_content_timestamp = now |
+ self.project_tbl.Update( |
+ cnxn, {'cached_content_timestamp': project.cached_content_timestamp}, |
+ project_id=project_id, commit=False) |
+ 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) |
+ |
+ |
+class Error(Exception): |
+ """Base exception class for this package.""" |
+ |
+ |
+class ProjectAlreadyExists(Error): |
+ """Tried to create a project that already exists.""" |
+ |
+ |
+class NoSuchProjectException(Error): |
+ """No project with the specified name exists.""" |
+ pass |