Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/businesslogic/work_env.py b/businesslogic/work_env.py
new file mode 100644
index 0000000..c7282c0
--- /dev/null
+++ b/businesslogic/work_env.py
@@ -0,0 +1,3843 @@
+# Copyright 2017 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
+
+"""WorkEnv is a context manager and API for high-level operations.
+
+A work environment is used by request handlers for the legacy UI, v1
+API, and v2 API. The WorkEnvironment operations are a common code
+path that does permission checking, input validation, coordination of
+service-level calls, follow-up tasks (e.g., triggering
+notifications after certain operations) and other systemic
+functionality so that that code is not duplicated in multiple request
+handlers.
+
+Responsibilities of request handers (legacy UI and external API) and associated
+frameworks:
++ API: check oauth client allowlist or XSRF token
++ Rate-limiting
++ Create a MonorailContext (or MonorailRequest) object:
+ - Parse the request, including syntactic validation, e.g, non-negative ints
+ - Authenticate the requesting user
++ Call the WorkEnvironment to perform the requested action
+ - Catch exceptions and generate error messages
++ UI: Decide screen flow, and on-page online-help
++ Render the result business objects as UI HTML or API response protobufs
+
+Responsibilities of WorkEnv:
++ Most monitoring, profiling, and logging
++ Apply business rules:
+ - Check permissions
+ - Every GetFoo/GetFoosDict method will assert that the user can view Foo(s)
+ - Detailed validation of request parameters
+ - Raise exceptions to indicate problems
++ Make coordinated calls to the services layer to make DB changes
+ - E.g., calls may need to be made in a specific order
++ Enqueue tasks for background follow-up work:
+ - E.g., email notifications
+
+Responsibilities of the Services layer:
++ Individual CRUD operations on objects in the database
+ - Each services class should be independent of others
++ App-specific interface around external services:
+ - E.g., GAE search, GCS, monorail-predict
++ Business object caches
++ Breaking large operations into batches as appropriate for the underlying
+ data storage service, e.g., DB shards and search engine indexing.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import itertools
+import logging
+import time
+
+import settings
+from features import features_constants
+from features import filterrules_helpers
+from features import send_notifications
+from features import features_bizobj
+from features import hotlist_helpers
+from framework import authdata
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import permissions
+from search import frontendsearchpipeline
+from services import features_svc
+from services import tracker_fulltext
+from sitewide import sitewide_helpers
+from tracker import field_helpers
+from tracker import rerank_helpers
+from tracker import field_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from project import project_helpers
+from proto import features_pb2
+from proto import project_pb2
+from proto import tracker_pb2
+from proto import user_pb2
+
+
+# TODO(jrobbins): break this file into one facade plus ~5
+# implementation parts that roughly correspond to services files.
+
+# ListResult is returned in List/Search methods to bundle the requested
+# items and the next start index for a subsequent request. If there are
+# no more items to be fetched, `next_start` should be None.
+ListResult = collections.namedtuple('ListResult', ['items', 'next_start'])
+# type: (Sequence[Object], Optional[int]) -> None
+
+# Comments added to issues impacted by another issue's mergedInto change.
+UNMERGE_COMMENT = 'Issue %s has been un-merged from this issue.\n'
+MERGE_COMMENT = 'Issue %s has been merged into this issue.\n'
+
+
+class WorkEnv(object):
+
+ def __init__(self, mc, services, phase=None):
+ self.mc = mc
+ self.services = services
+ self.phase = phase
+
+ def __enter__(self):
+ if self.mc.profiler and self.phase:
+ self.mc.profiler.StartPhase(name=self.phase)
+ return self # The instance of this class is the context object.
+
+ def __exit__(self, exception_type, value, traceback):
+ if self.mc.profiler and self.phase:
+ self.mc.profiler.EndPhase()
+ return False # Re-raise any exception in the with-block.
+
+ def _UserCanViewProject(self, project):
+ """Test if the user may view the given project."""
+ return permissions.UserCanViewProject(
+ self.mc.auth.user_pb, self.mc.auth.effective_ids, project)
+
+ def _FilterVisibleProjectsDict(self, projects):
+ """Filter out projects the user doesn't have permission to view."""
+ return {
+ key: proj
+ for key, proj in projects.items()
+ if self._UserCanViewProject(proj)}
+
+ def _AssertPermInProject(self, perm, project):
+ """Make sure the user may use perm in the given project."""
+ project_perms = permissions.GetPermissions(
+ self.mc.auth.user_pb, self.mc.auth.effective_ids, project)
+ permitted = project_perms.CanUsePerm(
+ perm, self.mc.auth.effective_ids, project, [])
+ if not permitted:
+ raise permissions.PermissionException(
+ 'User lacks permission %r in project %s' % (perm, project.project_name))
+
+ def _UserCanViewIssue(self, issue, allow_viewing_deleted=False):
+ """Test if user may view an issue according to perms in issue's project."""
+ project = self.GetProject(issue.project_id)
+ config = self.GetProjectConfig(issue.project_id)
+ granted_perms = tracker_bizobj.GetGrantedPerms(
+ issue, self.mc.auth.effective_ids, config)
+ project_perms = permissions.GetPermissions(
+ self.mc.auth.user_pb, self.mc.auth.effective_ids, project)
+ issue_perms = permissions.UpdateIssuePermissions(
+ project_perms, project, issue, self.mc.auth.effective_ids,
+ granted_perms=granted_perms)
+ permit_view = permissions.CanViewIssue(
+ self.mc.auth.effective_ids, issue_perms, project, issue,
+ allow_viewing_deleted=allow_viewing_deleted,
+ granted_perms=granted_perms)
+ return issue_perms, permit_view
+
+ def _AssertUserCanViewIssue(self, issue, allow_viewing_deleted=False):
+ """Make sure the user may view the issue."""
+ issue_perms, permit_view = self._UserCanViewIssue(
+ issue, allow_viewing_deleted)
+ if not permit_view:
+ raise permissions.PermissionException(
+ 'User is not allowed to view issue: %s:%d.' %
+ (issue.project_name, issue.local_id))
+ return issue_perms
+
+ def _UserCanUsePermInIssue(self, issue, perm):
+ """Test if the user may use perm on the given issue."""
+ issue_perms = self._AssertUserCanViewIssue(
+ issue, allow_viewing_deleted=True)
+ return issue_perms.HasPerm(perm, None, None, [])
+
+ def _AssertPermInIssue(self, issue, perm):
+ """Make sure the user may use perm on the given issue."""
+ permitted = self._UserCanUsePermInIssue(issue, perm)
+ if not permitted:
+ raise permissions.PermissionException(
+ 'User lacks permission %r in issue' % perm)
+
+ def _AssertUserCanModifyIssues(
+ self, issue_delta_pairs, is_description_change, comment_content=None):
+ # type: (Tuple[Issue, IssueDelta], Boolean, Optional[str]) -> None
+ """Make sure the user may make the delta changes for each paired issue."""
+ # We assume that view permission for each issue, and therefore project,
+ # was checked by the caller.
+ project_ids = list(
+ {issue.project_id for (issue, _delta) in issue_delta_pairs})
+ projects_by_id = self.services.project.GetProjects(
+ self.mc.cnxn, project_ids)
+ configs_by_id = self.services.config.GetProjectConfigs(
+ self.mc.cnxn, project_ids)
+
+ project_perms_by_ids = {}
+ for project_id, project in projects_by_id.items():
+ project_perms_by_ids[project_id] = permissions.GetPermissions(
+ self.mc.auth.user_pb, self.mc.auth.effective_ids, project)
+
+ with exceptions.ErrorAggregator(permissions.PermissionException) as err_agg:
+ for issue, delta in issue_delta_pairs:
+ project_perms = project_perms_by_ids.get(issue.project_id)
+ config = configs_by_id.get(issue.project_id)
+ project = projects_by_id.get(issue.project_id)
+ granted_perms = tracker_bizobj.GetGrantedPerms(
+ issue, self.mc.auth.effective_ids, config)
+ issue_perms = permissions.UpdateIssuePermissions(
+ project_perms,
+ project,
+ issue,
+ self.mc.auth.effective_ids,
+ granted_perms=granted_perms)
+
+ # User cannot merge any issue into an issue they cannot edit.
+ if delta.merged_into:
+ merged_into_issue = self.GetIssue(
+ delta.merged_into, use_cache=False, allow_viewing_deleted=True)
+ self._AssertPermInIssue(merged_into_issue, permissions.EDIT_ISSUE)
+
+ # User cannot change values for restricted fields they cannot edit.
+ field_ids = [fv.field_id for fv in delta.field_vals_add]
+ field_ids.extend([fv.field_id for fv in delta.field_vals_remove])
+ field_ids.extend(delta.fields_clear)
+ labels = itertools.chain(delta.labels_add, delta.labels_remove)
+ try:
+ self._AssertUserCanEditFieldsAndEnumMaskedLabels(
+ project, config, field_ids, labels)
+ except permissions.PermissionException as e:
+ err_agg.AddErrorMessage(e.message)
+
+ if issue_perms.HasPerm(permissions.EDIT_ISSUE, self.mc.auth.user_id,
+ project):
+ continue
+
+ # The user does not have general EDIT_ISSUE permissions, but may
+ # have perms to modify certain issue parts/fields.
+
+ # Description changes can only be made by users with EDIT_ISSUE.
+ if is_description_change:
+ err_agg.AddErrorMessage(
+ 'User not allowed to edit description in issue %s:%d' %
+ (issue.project_name, issue.local_id))
+
+ if comment_content and not issue_perms.HasPerm(
+ permissions.ADD_ISSUE_COMMENT, self.mc.auth.user_id, project):
+ err_agg.AddErrorMessage(
+ 'User not allowed to add comment in issue %s:%d' %
+ (issue.project_name, issue.local_id))
+
+ if delta == tracker_pb2.IssueDelta():
+ continue
+
+ allowed_delta = tracker_pb2.IssueDelta()
+ if issue_perms.HasPerm(permissions.EDIT_ISSUE_STATUS,
+ self.mc.auth.user_id, project):
+ allowed_delta.status = delta.status
+ if issue_perms.HasPerm(permissions.EDIT_ISSUE_SUMMARY,
+ self.mc.auth.user_id, project):
+ allowed_delta.summary = delta.summary
+ if issue_perms.HasPerm(permissions.EDIT_ISSUE_OWNER,
+ self.mc.auth.user_id, project):
+ allowed_delta.owner_id = delta.owner_id
+ if issue_perms.HasPerm(permissions.EDIT_ISSUE_CC, self.mc.auth.user_id,
+ project):
+ allowed_delta.cc_ids_add = delta.cc_ids_add
+ allowed_delta.cc_ids_remove = delta.cc_ids_remove
+ # We do not check for or add other fields (e.g. comps, labels, fields)
+ # of `delta` to `allowed_delta` because they are only allowed
+ # with EDIT_ISSUE perms.
+ if delta != allowed_delta:
+ err_agg.AddErrorMessage(
+ 'User lack permission to make these changes to issue %s:%d' %
+ (issue.project_name, issue.local_id))
+
+ # end of `with` block.
+
+ def _AssertUserCanDeleteComment(self, issue, comment):
+ issue_perms = self._AssertUserCanViewIssue(
+ issue, allow_viewing_deleted=True)
+ commenter = self.services.user.GetUser(self.mc.cnxn, comment.user_id)
+ permitted = permissions.CanDeleteComment(
+ comment, commenter, self.mc.auth.user_id, issue_perms)
+ if not permitted:
+ raise permissions.PermissionException('Cannot delete comment')
+
+ def _AssertUserCanViewHotlist(self, hotlist):
+ """Make sure the user may view the hotlist."""
+ if not permissions.CanViewHotlist(
+ self.mc.auth.effective_ids, self.mc.perms, hotlist):
+ raise permissions.PermissionException(
+ 'User is not allowed to view this hotlist')
+
+ def _AssertUserCanEditHotlist(self, hotlist):
+ if not permissions.CanEditHotlist(
+ self.mc.auth.effective_ids, self.mc.perms, hotlist):
+ raise permissions.PermissionException(
+ 'User is not allowed to edit this hotlist')
+
+ def _AssertUserCanEditValueForFieldDef(self, project, fielddef):
+ if not permissions.CanEditValueForFieldDef(
+ self.mc.auth.effective_ids, self.mc.perms, project, fielddef):
+ raise permissions.PermissionException(
+ 'User is not allowed to edit custom field %s' % fielddef.field_name)
+
+ def _AssertUserCanEditFieldsAndEnumMaskedLabels(
+ self, project, config, field_ids, labels):
+ field_ids = set(field_ids)
+
+ enum_fds_by_name = {
+ f.field_name.lower(): f.field_id
+ for f in config.field_defs
+ if f.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and not f.is_deleted
+ }
+ for label in labels:
+ enum_field_name = tracker_bizobj.LabelIsMaskedByField(
+ label, enum_fds_by_name.keys())
+ if enum_field_name:
+ field_ids.add(enum_fds_by_name.get(enum_field_name))
+
+ fds_by_id = {fd.field_id: fd for fd in config.field_defs}
+ with exceptions.ErrorAggregator(permissions.PermissionException) as err_agg:
+ for field_id in field_ids:
+ fd = fds_by_id.get(field_id)
+ if fd:
+ try:
+ self._AssertUserCanEditValueForFieldDef(project, fd)
+ except permissions.PermissionException as e:
+ err_agg.AddErrorMessage(e.message)
+
+ def _AssertUserCanViewFieldDef(self, project, field):
+ """Make sure the user may view the field."""
+ if not permissions.CanViewFieldDef(self.mc.auth.effective_ids,
+ self.mc.perms, project, field):
+ raise permissions.PermissionException(
+ 'User is not allowed to view this field')
+
+ ### Site methods
+
+ # FUTURE: GetSiteReadOnlyState()
+ # FUTURE: SetSiteReadOnlyState()
+ # FUTURE: GetSiteBannerMessage()
+ # FUTURE: SetSiteBannerMessage()
+
+ ### Project methods
+
+ def CreateProject(
+ self, 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: A project with that name already exists.
+ """
+ if not permissions.CanCreateProject(self.mc.perms):
+ raise permissions.PermissionException(
+ 'User is not allowed to create a project')
+
+ with self.mc.profiler.Phase('creating project %r' % project_name):
+ project_id = self.services.project.CreateProject(
+ self.mc.cnxn, project_name, owner_ids, committer_ids, contributor_ids,
+ summary, description, state=state, access=access,
+ 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)
+ self.services.template.CreateDefaultProjectTemplates(self.mc.cnxn,
+ project_id)
+ return project_id
+
+ def ListProjects(self, domain=None, use_cache=True):
+ """Return a list of project IDs that the current user may view."""
+ # TODO(crbug.com/monorail/7508): Add permission checking in ListProjects.
+ # Note: No permission checks because anyone can list projects, but
+ # the results are filtered by permission to view each project.
+
+ with self.mc.profiler.Phase('list projects for %r' % self.mc.auth.user_id):
+ project_ids = self.services.project.GetVisibleLiveProjects(
+ self.mc.cnxn, self.mc.auth.user_pb, self.mc.auth.effective_ids,
+ domain=domain, use_cache=use_cache)
+
+ return project_ids
+
+ def CheckProjectName(self, project_name):
+ """Check that a project name is valid and not already in use.
+
+ Args:
+ project_name: str the project name to check.
+
+ Returns:
+ None if the user can create a project with that name, or a string with the
+ reason the name can't be used.
+
+ Raises:
+ PermissionException: The user is not allowed to create a project.
+ """
+ # We check that the user can create a project so we don't leak information
+ # about project names.
+ if not permissions.CanCreateProject(self.mc.perms):
+ raise permissions.PermissionException(
+ 'User is not allowed to create a project')
+
+ with self.mc.profiler.Phase('checking project name %s' % project_name):
+ if not project_helpers.IsValidProjectName(project_name):
+ return '"%s" is not a valid project name.' % project_name
+ if self.services.project.LookupProjectIDs(self.mc.cnxn, [project_name]):
+ return 'There is already a project with that name.'
+ return None
+
+ def CheckComponentName(self, project_id, parent_path, component_name):
+ """Check that the component name is valid and not already in use.
+
+ Args:
+ project_id: int with the id of the project where we want to create the
+ component.
+ parent_path: optional str with the path of the parent component.
+ component_name: str with the name of the proposed component.
+
+ Returns:
+ None if the user can create a component with that name, or a string with
+ the reason the name can't be used.
+ """
+ # Check that the project exists and the user can view it.
+ self.GetProject(project_id)
+ # If a parent component is given, make sure it exists.
+ config = self.GetProjectConfig(project_id)
+ if parent_path and not tracker_bizobj.FindComponentDef(parent_path, config):
+ raise exceptions.NoSuchComponentException(
+ 'Component %r not found' % parent_path)
+ with self.mc.profiler.Phase(
+ 'checking component name %r %r' % (parent_path, component_name)):
+ if not tracker_constants.COMPONENT_NAME_RE.match(component_name):
+ return '"%s" is not a valid component name.' % component_name
+ if parent_path:
+ component_name = '%s>%s' % (parent_path, component_name)
+ if tracker_bizobj.FindComponentDef(component_name, config):
+ return 'There is already a component with that name.'
+ return None
+
+ def CheckFieldName(self, project_id, field_name):
+ """Check that the field name is valid and not already in use.
+
+ Args:
+ project_id: int with the id of the project where we want to create the
+ field.
+ field_name: str with the name of the proposed field.
+
+ Returns:
+ None if the user can create a field with that name, or a string with
+ the reason the name can't be used.
+ """
+ # Check that the project exists and the user can view it.
+ self.GetProject(project_id)
+ config = self.GetProjectConfig(project_id)
+
+ field_name = field_name.lower()
+ with self.mc.profiler.Phase('checking field name %r' % field_name):
+ if not tracker_constants.FIELD_NAME_RE.match(field_name):
+ return '"%s" is not a valid field name.' % field_name
+ if field_name in tracker_constants.RESERVED_PREFIXES:
+ return 'That name is reserved'
+ if field_name.endswith(
+ tuple(tracker_constants.RESERVED_COL_NAME_SUFFIXES)):
+ return 'That suffix is reserved'
+ for fd in config.field_defs:
+ fn = fd.field_name.lower()
+ if field_name == fn:
+ return 'There is already a field with that name.'
+ if field_name.startswith(fn + '-'):
+ return 'An existing field is a prefix of that name.'
+ if fn.startswith(field_name + '-'):
+ return 'That name is a prefix of an existing field name.'
+
+ return None
+
+ def GetProjects(self, project_ids, use_cache=True):
+ """Return the specified projects.
+
+ Args:
+ project_ids: int project_ids of the projects to retrieve.
+ use_cache: set to false when doing read-modify-write.
+
+ Returns:
+ The specified projects.
+
+ Raises:
+ NoSuchProjectException: There is no project with that ID.
+ """
+ with self.mc.profiler.Phase('getting projects %r' % project_ids):
+ projects = self.services.project.GetProjects(
+ self.mc.cnxn, project_ids, use_cache=use_cache)
+
+ projects = self._FilterVisibleProjectsDict(projects)
+ return projects
+
+ def GetProject(self, project_id, use_cache=True):
+ """Return the specified project.
+
+ Args:
+ project_id: int project_id of the project to retrieve.
+ use_cache: set to false when doing read-modify-write.
+
+ Returns:
+ The specified project.
+
+ Raises:
+ NoSuchProjectException: There is no project with that ID.
+ """
+ projects = self.GetProjects([project_id], use_cache=use_cache)
+ if project_id not in projects:
+ raise permissions.PermissionException(
+ 'User is not allowed to view this project')
+ return projects[project_id]
+
+ def GetProjectsByName(self, project_names, use_cache=True):
+ """Return the named project.
+
+ Args:
+ project_names: string names of the projects to retrieve.
+ use_cache: set to false when doing read-modify-write.
+
+ Returns:
+ The specified projects.
+ """
+ with self.mc.profiler.Phase('getting projects %r' % project_names):
+ projects = self.services.project.GetProjectsByName(
+ self.mc.cnxn, project_names, use_cache=use_cache)
+
+ for pn in project_names:
+ if pn not in projects:
+ raise exceptions.NoSuchProjectException('Project %r not found.' % pn)
+
+ projects = self._FilterVisibleProjectsDict(projects)
+ return projects
+
+ def GetProjectByName(self, project_name, use_cache=True):
+ """Return the named project.
+
+ Args:
+ project_name: string name of the project to retrieve.
+ use_cache: set to false when doing read-modify-write.
+
+ Returns:
+ The specified project.
+
+ Raises:
+ NoSuchProjectException: There is no project with that name.
+ """
+ projects = self.GetProjectsByName([project_name], use_cache)
+ if not projects:
+ raise permissions.PermissionException(
+ 'User is not allowed to view this project')
+
+ return projects[project_name]
+
+ def GatherProjectMembershipsForUser(self, user_id):
+ """Return the projects where the user has a role.
+
+ Args:
+ user_id: ID of the user we are requesting project memberships for.
+
+ Returns:
+ A triple with project IDs where the user is an owner, a committer, or a
+ contributor.
+ """
+ viewed_user_effective_ids = authdata.AuthData.FromUserID(
+ self.mc.cnxn, user_id, self.services).effective_ids
+
+ owner_projects, _archived, committer_projects, contrib_projects = (
+ self.GetUserProjects(viewed_user_effective_ids))
+
+ owner_proj_ids = [proj.project_id for proj in owner_projects]
+ committer_proj_ids = [proj.project_id for proj in committer_projects]
+ contrib_proj_ids = [proj.project_id for proj in contrib_projects]
+ return owner_proj_ids, committer_proj_ids, contrib_proj_ids
+
+ def GetUserRolesInAllProjects(self, viewed_user_effective_ids):
+ """Return the projects where the user has a role.
+
+ Args:
+ viewed_user_effective_ids: list of IDs of the user whose projects we want
+ to see.
+
+ Returns:
+ A triple with projects where the user is an owner, a member or a
+ contributor.
+ """
+ with self.mc.profiler.Phase(
+ 'Finding roles in all projects for %r' % viewed_user_effective_ids):
+ project_ids = self.services.project.GetUserRolesInAllProjects(
+ self.mc.cnxn, viewed_user_effective_ids)
+
+ owner_projects = self.GetProjects(project_ids[0])
+ member_projects = self.GetProjects(project_ids[1])
+ contrib_projects = self.GetProjects(project_ids[2])
+
+ return owner_projects, member_projects, contrib_projects
+
+ def GetUserProjects(self, viewed_user_effective_ids):
+ # TODO(crbug.com/monorail/7398): Combine this function with
+ # GatherProjectMembershipsForUser after removing the legacy
+ # project list page and the v0 GetUsersProjects RPC.
+ """Get the projects to display in the user's profile.
+
+ Args:
+ viewed_user_effective_ids: set of int user IDs of the user being viewed.
+
+ Returns:
+ A 4-tuple of lists of PBs:
+ - live projects the viewed user owns
+ - archived projects the viewed user owns
+ - live projects the viewed user is a member of
+ - live projects the viewed user is a contributor to
+
+ Any projects the viewing user should not be able to see are filtered out.
+ Admins can see everything, while other users can see all non-locked
+ projects they own or are a member of, as well as all live projects.
+ """
+ # Permissions are checked in we.GetUserRolesInAllProjects()
+ owner_projects, member_projects, contrib_projects = (
+ self.GetUserRolesInAllProjects(viewed_user_effective_ids))
+
+ # We filter out DELETABLE projects, and keep a project where the user has a
+ # highest role, e.g. if the user is both an owner and a member, the project
+ # is listed under owner projects, not under member_projects.
+ archived_projects = [
+ project
+ for project in owner_projects.values()
+ if project.state == project_pb2.ProjectState.ARCHIVED]
+
+ contrib_projects = [
+ project
+ for pid, project in contrib_projects.items()
+ if pid not in owner_projects
+ and pid not in member_projects
+ and project.state != project_pb2.ProjectState.DELETABLE
+ and project.state != project_pb2.ProjectState.ARCHIVED]
+
+ member_projects = [
+ project
+ for pid, project in member_projects.items()
+ if pid not in owner_projects
+ and project.state != project_pb2.ProjectState.DELETABLE
+ and project.state != project_pb2.ProjectState.ARCHIVED]
+
+ owner_projects = [
+ project
+ for pid, project in owner_projects.items()
+ if project.state != project_pb2.ProjectState.DELETABLE
+ and project.state != project_pb2.ProjectState.ARCHIVED]
+
+ by_name = lambda project: project.project_name
+ owner_projects = sorted(owner_projects, key=by_name)
+ archived_projects = sorted(archived_projects, key=by_name)
+ member_projects = sorted(member_projects, key=by_name)
+ contrib_projects = sorted(contrib_projects, key=by_name)
+
+ return owner_projects, archived_projects, member_projects, contrib_projects
+
+ def UpdateProject(
+ self,
+ 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):
+ """Update the DB with the given project information."""
+ project = self.GetProject(project_id)
+ self._AssertPermInProject(permissions.EDIT_PROJECT, project)
+
+ with self.mc.profiler.Phase('updating project %r' % project_id):
+ self.services.project.UpdateProject(
+ self.mc.cnxn,
+ project_id,
+ summary=summary,
+ description=description,
+ state=state,
+ state_reason=state_reason,
+ access=access,
+ issue_notify_address=issue_notify_address,
+ attachment_bytes_used=attachment_bytes_used,
+ attachment_quota=attachment_quota,
+ moved_to=moved_to,
+ process_inbound_email=process_inbound_email,
+ only_owners_remove_restrictions=only_owners_remove_restrictions,
+ read_only_reason=read_only_reason,
+ cached_content_timestamp=cached_content_timestamp,
+ only_owners_see_contributors=only_owners_see_contributors,
+ delete_time=delete_time,
+ recent_activity=recent_activity,
+ revision_url_format=revision_url_format,
+ home_page=home_page,
+ docs_url=docs_url,
+ source_url=source_url,
+ logo_gcs_id=logo_gcs_id,
+ logo_file_name=logo_file_name,
+ issue_notify_always_detailed=issue_notify_always_detailed)
+
+ def DeleteProject(self, project_id):
+ """Mark the project as deletable. It will be reaped by a cron job.
+
+ Args:
+ project_id: int ID of the project to delete.
+
+ Returns:
+ Nothing.
+
+ Raises:
+ NoSuchProjectException: There is no project with that ID.
+ """
+ project = self.GetProject(project_id)
+ self._AssertPermInProject(permissions.EDIT_PROJECT, project)
+
+ with self.mc.profiler.Phase('marking deletable %r' % project_id):
+ _project = self.GetProject(project_id)
+ self.services.project.MarkProjectDeletable(
+ self.mc.cnxn, project_id, self.services.config)
+
+ def StarProject(self, project_id, starred):
+ """Star or unstar the specified project.
+
+ Args:
+ project_id: int ID of the project to star/unstar.
+ starred: true to add a star, false to remove it.
+
+ Returns:
+ Nothing.
+
+ Raises:
+ NoSuchProjectException: There is no project with that ID.
+ """
+ project = self.GetProject(project_id)
+ self._AssertPermInProject(permissions.SET_STAR, project)
+
+ with self.mc.profiler.Phase('(un)starring project %r' % project_id):
+ self.services.project_star.SetStar(
+ self.mc.cnxn, project_id, self.mc.auth.user_id, starred)
+
+ def IsProjectStarred(self, project_id):
+ """Return True if the current user has starred the given project.
+
+ Args:
+ project_id: int ID of the project to check.
+
+ Returns:
+ True if starred.
+
+ Raises:
+ NoSuchProjectException: There is no project with that ID.
+ """
+ if project_id is None:
+ raise exceptions.InputException('No project specified')
+
+ if not self.mc.auth.user_id:
+ return False
+
+ with self.mc.profiler.Phase('checking project star %r' % project_id):
+ # Make sure the project exists and user has permission to see it.
+ _project = self.GetProject(project_id)
+ return self.services.project_star.IsItemStarredBy(
+ self.mc.cnxn, project_id, self.mc.auth.user_id)
+
+ def GetProjectStarCount(self, project_id):
+ """Return the number of times the project has been starred.
+
+ Args:
+ project_id: int ID of the project to check.
+
+ Returns:
+ The number of times the project has been starred.
+
+ Raises:
+ NoSuchProjectException: There is no project with that ID.
+ """
+ if project_id is None:
+ raise exceptions.InputException('No project specified')
+
+ with self.mc.profiler.Phase('counting stars for project %r' % project_id):
+ # Make sure the project exists and user has permission to see it.
+ _project = self.GetProject(project_id)
+ return self.services.project_star.CountItemStars(self.mc.cnxn, project_id)
+
+ def ListStarredProjects(self, viewed_user_id=None):
+ """Return a list of projects starred by the current or viewed user.
+
+ Args:
+ viewed_user_id: optional user ID for another user's profile page, if
+ not supplied, the signed in user is used.
+
+ Returns:
+ A list of projects that were starred by current user and that they
+ are currently allowed to view.
+ """
+ # Note: No permission checks for this call, but the list of starred
+ # projects is filtered based on permission to view.
+
+ if viewed_user_id is None:
+ if self.mc.auth.user_id:
+ viewed_user_id = self.mc.auth.user_id
+ else:
+ return [] # Anon user and no viewed user specified.
+ with self.mc.profiler.Phase('ListStarredProjects for %r' % viewed_user_id):
+ viewable_projects = sitewide_helpers.GetViewableStarredProjects(
+ self.mc.cnxn, self.services, viewed_user_id,
+ self.mc.auth.effective_ids, self.mc.auth.user_pb)
+ return viewable_projects
+
+ def GetProjectConfigs(self, project_ids, use_cache=True):
+ """Return the specifed configs.
+
+ Args:
+ project_ids: int IDs of the projects to retrieve.
+ use_cache: set to false when doing read-modify-write.
+
+ Returns:
+ The specified configs.
+ """
+ with self.mc.profiler.Phase('getting configs for %r' % project_ids):
+ configs = self.services.config.GetProjectConfigs(
+ self.mc.cnxn, project_ids, use_cache=use_cache)
+
+ projects = self._FilterVisibleProjectsDict(
+ self.GetProjects(list(configs.keys())))
+ configs = {project_id: configs[project_id] for project_id in projects}
+
+ return configs
+
+ def GetProjectConfig(self, project_id, use_cache=True):
+ """Return the specifed config.
+
+ Args:
+ project_id: int ID of the project to retrieve.
+ use_cache: set to false when doing read-modify-write.
+
+ Returns:
+ The specified config.
+
+ Raises:
+ NoSuchProjectException: There is no matching config.
+ """
+ configs = self.GetProjectConfigs([project_id], use_cache)
+ if not configs:
+ raise exceptions.NoSuchProjectException()
+ return configs[project_id]
+
+ def ListProjectTemplates(self, project_id):
+ templates = self.services.template.GetProjectTemplates(
+ self.mc.cnxn, project_id)
+ project = self.GetProject(project_id)
+ # Filter non-viewable templates
+ if framework_bizobj.UserIsInProject(project, self.mc.auth.effective_ids):
+ return templates
+ return [template for template in templates if not template.members_only]
+
+ def ListComponentDefs(self, project_id, page_size, start):
+ # type: (int, int, int) -> ListResult
+ """Returns component defs that belong to the project."""
+ if start < 0:
+ raise exceptions.InputException('Invalid `start`: %d' % start)
+ if page_size < 0:
+ raise exceptions.InputException('Invalid `page_size`: %d' % page_size)
+
+ config = self.GetProjectConfig(project_id)
+ end = start + page_size
+ next_start = None
+ if end < len(config.component_defs):
+ next_start = end
+ return ListResult(config.component_defs[start:end], next_start)
+
+ def CreateComponentDef(
+ self, project_id, path, description, admin_ids, cc_ids, labels):
+ # type: (int, str, str, Collection[int], Collection[int], Collection[str])
+ # -> ComponentDef
+ """Creates a ComponentDef with the given information."""
+ project = self.GetProject(project_id)
+ config = self.GetProjectConfig(project_id)
+
+ # Validate new ComponentDef and check permissions.
+ ancestor_path, leaf_name = None, path
+ if '>' in path:
+ ancestor_path, leaf_name = path.rsplit('>', 1)
+ ancestor_def = tracker_bizobj.FindComponentDef(ancestor_path, config)
+ if not ancestor_def:
+ raise exceptions.InputException(
+ 'Ancestor path %s is invalid.' % ancestor_path)
+ project_perms = permissions.GetPermissions(
+ self.mc.auth.user_pb, self.mc.auth.effective_ids, project)
+ if not permissions.CanEditComponentDef(
+ self.mc.auth.effective_ids, project_perms, project, ancestor_def,
+ config):
+ raise permissions.PermissionException(
+ 'User is not allowed to create a subcomponent under %s.' %
+ ancestor_path)
+ else:
+ # A brand new top level component is being created.
+ self._AssertPermInProject(permissions.EDIT_PROJECT, project)
+
+ if not tracker_constants.COMPONENT_NAME_RE.match(leaf_name):
+ raise exceptions.InputException('Invalid component path: %s.' % leaf_name)
+
+ if tracker_bizobj.FindComponentDef(path, config):
+ raise exceptions.ComponentDefAlreadyExists(
+ 'Component path %s already exists.' % path)
+
+ with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+ tracker_helpers.AssertUsersExist(
+ self.mc.cnxn, self.services, cc_ids + admin_ids, err_agg)
+
+ label_ids = self.services.config.LookupLabelIDs(
+ self.mc.cnxn, project_id, labels, autocreate=True)
+ self.services.config.CreateComponentDef(
+ self.mc.cnxn, project_id, path, description, False, admin_ids, cc_ids,
+ int(time.time()), self.mc.auth.user_id, label_ids)
+ updated_config = self.GetProjectConfig(project_id, use_cache=False)
+ return tracker_bizobj.FindComponentDef(path, updated_config)
+
+ def DeleteComponentDef(self, project_id, component_id):
+ # type: (MonorailConnection, int, int) -> None
+ """Deletes the given ComponentDef."""
+ project = self.GetProject(project_id)
+ config = self.GetProjectConfig(project_id)
+
+ component_def = tracker_bizobj.FindComponentDefByID(component_id, config)
+ if not component_def:
+ raise exceptions.NoSuchComponentException('The component does not exist.')
+
+ project_perms = permissions.GetPermissions(
+ self.mc.auth.user_pb, self.mc.auth.effective_ids, project)
+ if not permissions.CanEditComponentDef(
+ self.mc.auth.effective_ids, project_perms, project, component_def,
+ config):
+ raise permissions.PermissionException(
+ 'User is not allowed to delete this component.')
+
+ if tracker_bizobj.FindDescendantComponents(config, component_def):
+ raise exceptions.InputException(
+ 'Components with subcomponents cannot be deleted.')
+
+ self.services.config.DeleteComponentDef(
+ self.mc.cnxn, project_id, component_id)
+
+ # FUTURE: labels, statuses, components, rules, templates, and views.
+ # FUTURE: project saved queries.
+ # FUTURE: GetProjectPermissionsForUser()
+
+ ### Field methods
+
+ # FUTURE: All other field methods.
+
+ def GetFieldDef(self, field_id, project):
+ # type: (int, Project) -> FieldDef
+ """Return the specified hotlist.
+
+ Args:
+ field_id: int field_id of the field to retrieve.
+ project: Project object that the field belongs to.
+
+ Returns:
+ The specified field.
+
+ Raises:
+ InputException: No field was specified.
+ NoSuchFieldDefException: There is no field with that ID.
+ PermissionException: The user is not allowed to view the field.
+ """
+ with self.mc.profiler.Phase('getting fielddef %r' % field_id):
+ config = self.GetProjectConfig(project.project_id)
+ field = tracker_bizobj.FindFieldDefByID(field_id, config)
+ if field is None:
+ raise exceptions.NoSuchFieldDefException('Field not found.')
+ self._AssertUserCanViewFieldDef(project, field)
+ return field
+
+ ### Issue methods
+
+ def CreateIssue(
+ self,
+ project_id, # type: int
+ summary, # type: str
+ status, # type: str
+ owner_id, # type: int
+ cc_ids, # type: Sequence[int]
+ labels, # type: Sequence[str]
+ field_values, # type: Sequence[proto.tracker_pb2.FieldValue]
+ component_ids, # type: Sequence[int]
+ marked_description, # type: str
+ blocked_on=None, # type: Sequence[int]
+ blocking=None, # type: Sequence[int]
+ attachments=None, # type: Sequence[Tuple[str, str, str]]
+ phases=None, # type: Sequence[proto.tracker_pb2.Phase]
+ approval_values=None, # type: Sequence[proto.tracker_pb2.ApprovalValue]
+ send_email=True, # type: bool
+ reporter_id=None, # type: int
+ timestamp=None, # type: int
+ dangling_blocked_on=None, # type: Sequence[DanglingIssueRef]
+ dangling_blocking=None, # type: Sequence[DanglingIssueRef]
+ raise_filter_errors=True, # type: bool
+ ):
+ # type: (...) -> (proto.tracker_pb2.Issue, proto.tracker_pb2.IssueComment)
+ """Create and store a new issue with all the given information.
+
+ Args:
+ project_id: int ID for the current project.
+ summary: one-line summary string summarizing this issue.
+ status: string issue status value. E.g., 'New'.
+ owner_id: user ID of the issue owner.
+ cc_ids: list of user IDs for users to be CC'd on changes.
+ labels: list of label strings. E.g., 'Priority-High'.
+ field_values: list of FieldValue PBs.
+ component_ids: list of int component IDs.
+ marked_description: issue description with initial HTML markup.
+ blocked_on: list of issue_ids that this issue is blocked on.
+ blocking: list of issue_ids that this issue blocks.
+ attachments: [(filename, contents, mimetype),...] attachments uploaded at
+ the time the comment was made.
+ phases: list of Phase PBs.
+ approval_values: list of ApprovalValue PBs.
+ send_email: set to False to avoid email notifications.
+ reporter_id: optional user ID of a different user to attribute this
+ issue report to. The requester must have the ImportComment perm.
+ timestamp: optional int timestamp of an imported issue.
+ dangling_blocked_on: a list of DanglingIssueRefs this issue is blocked on.
+ dangling_blocking: a list of DanglingIssueRefs that this issue blocks.
+ raise_filter_errors: whether to raise when filter rules produce errors.
+
+ Returns:
+ A tuple (newly created Issue, Comment PB for the description).
+
+ Raises:
+ FilterRuleException if creation violates any filter rule that shows error.
+ InputException: The issue has invalid input, see validation below.
+ PermissionException if user lacks sufficient permissions.
+ """
+ project = self.GetProject(project_id)
+ self._AssertPermInProject(permissions.CREATE_ISSUE, project)
+
+ # TODO(crbug/monorail/7197): The following are needed for v3 API
+ # Phase 5.2 Validate sufficient attachment quota and update
+
+ if reporter_id and reporter_id != self.mc.auth.user_id:
+ self._AssertPermInProject(permissions.IMPORT_COMMENT, project)
+ importer_id = self.mc.auth.user_id
+ else:
+ reporter_id = self.mc.auth.user_id
+ importer_id = None
+
+ with self.mc.profiler.Phase('creating issue in project %r' % project_id):
+ # TODO(crbug/monorail/8000): Refactor issue proto construction
+ # to the caller.
+ status = framework_bizobj.CanonicalizeLabel(status)
+ labels = [framework_bizobj.CanonicalizeLabel(l) for l in labels]
+ labels = [l for l in labels if l]
+
+ issue = tracker_pb2.Issue()
+ issue.project_id = project_id
+ issue.project_name = self.services.project.LookupProjectNames(
+ self.mc.cnxn, [project_id]).get(project_id)
+ issue.summary = summary
+ issue.status = status
+ issue.owner_id = owner_id
+ issue.cc_ids.extend(cc_ids)
+ issue.labels.extend(labels)
+ issue.field_values.extend(field_values)
+ issue.component_ids.extend(component_ids)
+ issue.reporter_id = reporter_id
+ if blocked_on is not None:
+ issue.blocked_on_iids = blocked_on
+ issue.blocked_on_ranks = [0] * len(blocked_on)
+ if blocking is not None:
+ issue.blocking_iids = blocking
+ if dangling_blocked_on is not None:
+ issue.dangling_blocked_on_refs = dangling_blocked_on
+ if dangling_blocking is not None:
+ issue.dangling_blocking_refs = dangling_blocking
+ if attachments:
+ issue.attachment_count = len(attachments)
+ if phases:
+ issue.phases = phases
+ if approval_values:
+ issue.approval_values = approval_values
+ timestamp = timestamp or int(time.time())
+ issue.opened_timestamp = timestamp
+ issue.modified_timestamp = timestamp
+ issue.owner_modified_timestamp = timestamp
+ issue.status_modified_timestamp = timestamp
+ issue.component_modified_timestamp = timestamp
+
+ # Validate the issue
+ tracker_helpers.AssertValidIssueForCreate(
+ self.mc.cnxn, self.services, issue, marked_description)
+
+ # Apply filter rules.
+ # Set the closed_timestamp both before and after filter rules.
+ config = self.GetProjectConfig(issue.project_id)
+ if not tracker_helpers.MeansOpenInProject(
+ tracker_bizobj.GetStatus(issue), config):
+ issue.closed_timestamp = issue.opened_timestamp
+ filterrules_helpers.ApplyFilterRules(
+ self.mc.cnxn, self.services, issue, config)
+ if issue.derived_errors and raise_filter_errors:
+ raise exceptions.FilterRuleException(issue.derived_errors)
+ if not tracker_helpers.MeansOpenInProject(
+ tracker_bizobj.GetStatus(issue), config):
+ issue.closed_timestamp = issue.opened_timestamp
+
+ new_issue, comment = self.services.issue.CreateIssue(
+ self.mc.cnxn,
+ self.services,
+ issue,
+ marked_description,
+ attachments=attachments,
+ index_now=False,
+ importer_id=importer_id)
+ logging.info(
+ 'created issue %r in project %r', new_issue.local_id, project_id)
+
+ with self.mc.profiler.Phase('following up after issue creation'):
+ self.services.project.UpdateRecentActivity(self.mc.cnxn, project_id)
+
+ if send_email:
+ with self.mc.profiler.Phase('queueing notification tasks'):
+ hostport = framework_helpers.GetHostPort(
+ project_name=project.project_name)
+ send_notifications.PrepareAndSendIssueChangeNotification(
+ new_issue.issue_id, hostport, reporter_id, comment_id=comment.id)
+ send_notifications.PrepareAndSendIssueBlockingNotification(
+ new_issue.issue_id, hostport, new_issue.blocked_on_iids,
+ reporter_id)
+
+ return new_issue, comment
+
+ def MakeIssueFromTemplate(self, _template, _description, _issue_delta):
+ # type: (tracker_pb2.TemplateDef, str, tracker_pb2.IssueDelta) ->
+ # tracker_pb2.Issue
+ """Creates issue from template, issue description, and delta.
+
+ Args:
+ template: Template that issue creation is based on.
+ description: Issue description string.
+ issue_delta: Difference between desired issue and base issue.
+
+ Returns:
+ Newly created issue, as protorpc Issue.
+
+ Raises:
+ TODO(crbug/monorail/7197): Document errors when implemented
+ """
+ # Phase 2: Build Issue from TemplateDef
+ # Use helper method, likely from template_helpers
+
+ # Phase 3: Validate proposed deltas and check permissions
+ # Check summary has been edited if required, else throw
+ # Check description is different from template default, else throw
+ # Check edit permission on field values of issue deltas, else throw
+
+ # Phase 4: Merge template, delta, and defaults
+ # Merge delta into issue
+ # Apply approval def defaults to approval values
+ # Capitalize every line of description
+
+ # Phase 5: Create issue by calling work_env.CreateIssue
+
+ return tracker_pb2.Issue()
+
+ def MakeIssue(self, issue, description, send_email):
+ # type: (tracker_pb2.Issue, str, bool) -> tracker_pb2.Issue
+ """Check restricted field permissions and create issue.
+
+ Args:
+ issue: Data for the created issue in a Protocol Bugger.
+ description: Description for the initial description comment created.
+ send_email: Whether this issue creation should email people.
+
+ Returns:
+ The created Issue PB.
+
+ Raises:
+ FilterRuleException if creation violates any filter rule that shows error.
+ InputException: The issue has invalid input, see validation below.
+ PermissionException if user lacks sufficient permissions.
+ """
+ config = self.GetProjectConfig(issue.project_id)
+ project = self.GetProject(issue.project_id)
+ self._AssertUserCanEditFieldsAndEnumMaskedLabels(
+ project, config, [fv.field_id for fv in issue.field_values],
+ issue.labels)
+ issue, _comment = self.CreateIssue(
+ issue.project_id,
+ issue.summary,
+ issue.status,
+ issue.owner_id,
+ issue.cc_ids,
+ issue.labels,
+ issue.field_values,
+ issue.component_ids,
+ description,
+ blocked_on=issue.blocked_on_iids,
+ blocking=issue.blocking_iids,
+ dangling_blocked_on=issue.dangling_blocked_on_refs,
+ dangling_blocking=issue.dangling_blocking_refs,
+ send_email=send_email)
+ return issue
+
+ def MoveIssue(self, issue, target_project):
+ """Move issue to the target_project.
+
+ The current user needs to have permission to delete the current issue, and
+ to edit issues on the target project.
+
+ Args:
+ issue: the issue PB.
+ target_project: the project PB where the issue should be moved to.
+ Returns:
+ The issue PB of the new issue on the target project.
+ """
+ self._AssertPermInIssue(issue, permissions.DELETE_ISSUE)
+ self._AssertPermInProject(permissions.EDIT_ISSUE, target_project)
+
+ if permissions.GetRestrictions(issue):
+ raise exceptions.InputException(
+ 'Issues with Restrict labels are not allowed to be moved')
+
+ with self.mc.profiler.Phase('Moving Issue'):
+ tracker_fulltext.UnindexIssues([issue.issue_id])
+
+ # issue is modified by MoveIssues
+ old_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
+ moved_back_iids = self.services.issue.MoveIssues(
+ self.mc.cnxn, target_project, [issue], self.services.user)
+ new_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
+
+ if issue.issue_id in moved_back_iids:
+ content = 'Moved %s back to %s again.' % (old_text_ref, new_text_ref)
+ else:
+ content = 'Moved %s to now be %s.' % (old_text_ref, new_text_ref)
+ self.services.issue.CreateIssueComment(
+ self.mc.cnxn, issue, self.mc.auth.user_id, content,
+ amendments=[
+ tracker_bizobj.MakeProjectAmendment(target_project.project_name)])
+
+ tracker_fulltext.IndexIssues(
+ self.mc.cnxn, [issue], self.services.user, self.services.issue,
+ self.services.config)
+
+ return issue
+
+ def CopyIssue(self, issue, target_project):
+ """Copy issue to the target_project.
+
+ The current user needs to have permission to delete the current issue, and
+ to edit issues on the target project.
+
+ Args:
+ issue: the issue PB.
+ target_project: the project PB where the issue should be copied to.
+ Returns:
+ The issue PB of the new issue on the target project.
+ """
+ self._AssertPermInIssue(issue, permissions.DELETE_ISSUE)
+ self._AssertPermInProject(permissions.EDIT_ISSUE, target_project)
+
+ if permissions.GetRestrictions(issue):
+ raise exceptions.InputException(
+ 'Issues with Restrict labels are not allowed to be copied')
+
+ with self.mc.profiler.Phase('Copying Issue'):
+ copied_issue = self.services.issue.CopyIssues(
+ self.mc.cnxn, target_project, [issue], self.services.user,
+ self.mc.auth.user_id)[0]
+
+ issue_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
+ copied_issue_ref = 'issue %s:%s' % (
+ copied_issue.project_name, copied_issue.local_id)
+
+ # Add comment to the original issue.
+ content = 'Copied %s to %s' % (issue_ref, copied_issue_ref)
+ self.services.issue.CreateIssueComment(
+ self.mc.cnxn, issue, self.mc.auth.user_id, content)
+
+ # Add comment to the newly created issue.
+ # Add project amendment only if the project changed.
+ amendments = []
+ if issue.project_id != copied_issue.project_id:
+ amendments.append(
+ tracker_bizobj.MakeProjectAmendment(target_project.project_name))
+ new_issue_content = 'Copied %s from %s' % (copied_issue_ref, issue_ref)
+ self.services.issue.CreateIssueComment(
+ self.mc.cnxn, copied_issue, self.mc.auth.user_id, new_issue_content,
+ amendments=amendments)
+
+ tracker_fulltext.IndexIssues(
+ self.mc.cnxn, [copied_issue], self.services.user, self.services.issue,
+ self.services.config)
+
+ return copied_issue
+
+ def _MergeLinkedAccounts(self, me_user_id):
+ """Return a list of the given user ID and any linked accounts."""
+ if not me_user_id:
+ return []
+
+ result = [me_user_id]
+ me_user = self.services.user.GetUser(self.mc.cnxn, me_user_id)
+ if me_user:
+ if me_user.linked_parent_id:
+ result.append(me_user.linked_parent_id)
+ result.extend(me_user.linked_child_ids)
+ return result
+
+ def SearchIssues(
+ self, query_string, query_project_names, me_user_id, items_per_page,
+ paginate_start, sort_spec):
+ # type: (str, Sequence[str], int, int, int, str) -> ListResult
+ """Search for issues in the given projects."""
+ # View permissions and project existence check.
+ _projects = self.GetProjectsByName(query_project_names)
+ # TODO(crbug.com/monorail/6988): Delete ListIssues when endpoints and v1
+ # are deprecated. Move pipeline call to SearchIssues.
+ # TODO(crbug.com/monorail/7678): Remove can. Pass project_ids
+ # into pipeline call instead of project_names into SearchIssues call.
+ # project_names with project_ids.
+ use_cached_searches = not settings.local_mode
+ pipeline = self.ListIssues(
+ query_string, query_project_names, me_user_id, items_per_page,
+ paginate_start, 1, '', sort_spec, use_cached_searches)
+
+ end = paginate_start + items_per_page
+ next_start = None
+ if end < pipeline.total_count:
+ next_start = end
+ return ListResult(pipeline.visible_results, next_start)
+
+ def ListIssues(
+ self,
+ query_string, # type: str
+ query_project_names, # type: Sequence[str]
+ me_user_id, # type: int
+ items_per_page, # type: int
+ paginate_start, # type: int
+ can, # type: int
+ group_by_spec, # type: str
+ sort_spec, # type: str
+ use_cached_searches, # type: bool
+ project=None # type: proto.Project
+ ):
+ # type: (...) -> search.frontendsearchpipeline.FrontendSearchPipeline
+ """Do an issue search w/ mc + passed in args to return a pipeline object.
+
+ Args:
+ query_string: str with the query the user is searching for.
+ query_project_names: List of project names to query for.
+ me_user_id: Relevant user id. Usually the logged in user.
+ items_per_page: Max number of issues to include in the results.
+ paginate_start: Offset of issues to skip for pagination.
+ can: id of canned query to use.
+ group_by_spec: str used to specify how issues should be grouped.
+ sort_spec: str used to specify how issues should be sorted.
+ use_cached_searches: Whether to use the cache or not.
+ project: Project object for the current project the user is viewing.
+
+ Returns:
+ A FrontendSearchPipeline instance with data on issues found.
+ """
+ # Permission to view a project is checked in FrontendSearchPipeline().
+ # Individual results are filtered by permissions in SearchForIIDs().
+
+ with self.mc.profiler.Phase('searching issues'):
+ me_user_ids = self._MergeLinkedAccounts(me_user_id)
+ pipeline = frontendsearchpipeline.FrontendSearchPipeline(
+ self.mc.cnxn,
+ self.services,
+ self.mc.auth,
+ me_user_ids,
+ query_string,
+ query_project_names,
+ items_per_page,
+ paginate_start,
+ can,
+ group_by_spec,
+ sort_spec,
+ self.mc.warnings,
+ self.mc.errors,
+ use_cached_searches,
+ self.mc.profiler,
+ project=project)
+ if not self.mc.errors.AnyErrors():
+ pipeline.SearchForIIDs()
+ pipeline.MergeAndSortIssues()
+ pipeline.Paginate()
+ # TODO(jojwang): raise InvalidQueryException.
+ return pipeline
+
+ # TODO(jrobbins): This method also requires self.mc to be a MonorailRequest.
+ def FindIssuePositionInSearch(self, issue):
+ """Do an issue search and return flipper info for the given issue.
+
+ Args:
+ issue: issue that the user is currently viewing.
+
+ Returns:
+ A 4-tuple of flipper info: (prev_iid, cur_index, next_iid, total_count).
+ """
+ # Permission to view a project is checked in FrontendSearchPipeline().
+ # Individual results are filtered by permissions in SearchForIIDs().
+
+ with self.mc.profiler.Phase('finding issue position in search'):
+ me_user_ids = self._MergeLinkedAccounts(self.mc.me_user_id)
+ pipeline = frontendsearchpipeline.FrontendSearchPipeline(
+ self.mc.cnxn,
+ self.services,
+ self.mc.auth,
+ me_user_ids,
+ self.mc.query,
+ self.mc.query_project_names,
+ self.mc.num,
+ self.mc.start,
+ self.mc.can,
+ self.mc.group_by_spec,
+ self.mc.sort_spec,
+ self.mc.warnings,
+ self.mc.errors,
+ self.mc.use_cached_searches,
+ self.mc.profiler,
+ project=self.mc.project)
+ if not self.mc.errors.AnyErrors():
+ # Only do the search if the user's query parsed OK.
+ pipeline.SearchForIIDs()
+
+ # Note: we never call MergeAndSortIssues() because we don't need a unified
+ # sorted list, we only need to know the position on such a list of the
+ # current issue.
+ prev_iid, cur_index, next_iid = pipeline.DetermineIssuePosition(issue)
+
+ return prev_iid, cur_index, next_iid, pipeline.total_count
+
+ # TODO(crbug/monorail/6988): add boolean to ignore_private_issues
+ def GetIssuesDict(self, issue_ids, use_cache=True,
+ allow_viewing_deleted=False):
+ # type: (Collection[int], Optional[Boolean], Optional[Boolean]) ->
+ # Mapping[int, Issue]
+ """Return a dict {iid: issue} with the specified issues, if allowed.
+
+ Args:
+ issue_ids: int global issue IDs.
+ use_cache: set to false to ensure fresh issues.
+ allow_viewing_deleted: set to true to allow user to view deleted issues.
+
+ Returns:
+ A dict {issue_id: issue} for only those issues that the user is allowed
+ to view.
+
+ Raises:
+ NoSuchIssueException if an issue is not found.
+ PermissionException if the user cannot view all issues.
+ """
+ with self.mc.profiler.Phase('getting issues %r' % issue_ids):
+ issues_by_id, missing_ids = self.services.issue.GetIssuesDict(
+ self.mc.cnxn, issue_ids, use_cache=use_cache)
+
+ if missing_ids:
+ with exceptions.ErrorAggregator(
+ exceptions.NoSuchIssueException) as missing_err_agg:
+ for missing_id in missing_ids:
+ missing_err_agg.AddErrorMessage('No such issue: %s' % missing_id)
+
+ with exceptions.ErrorAggregator(
+ permissions.PermissionException) as permission_err_agg:
+ for issue in issues_by_id.values():
+ try:
+ self._AssertUserCanViewIssue(
+ issue, allow_viewing_deleted=allow_viewing_deleted)
+ except permissions.PermissionException as e:
+ permission_err_agg.AddErrorMessage(e.message)
+
+ return issues_by_id
+
+ def GetIssue(self, issue_id, use_cache=True, allow_viewing_deleted=False):
+ """Return the specified issue.
+
+ Args:
+ issue_id: int global issue ID.
+ use_cache: set to false to ensure fresh issue.
+ allow_viewing_deleted: set to true to allow user to view a deleted issue.
+
+ Returns:
+ The requested Issue PB.
+ """
+ if issue_id is None:
+ raise exceptions.InputException('No issue issue_id specified')
+
+ with self.mc.profiler.Phase('getting issue %r' % issue_id):
+ issue = self.services.issue.GetIssue(
+ self.mc.cnxn, issue_id, use_cache=use_cache)
+
+ self._AssertUserCanViewIssue(
+ issue, allow_viewing_deleted=allow_viewing_deleted)
+ return issue
+
+ def ListReferencedIssues(self, ref_tuples, default_project_name):
+ """Return the specified issues."""
+ # Make sure ref_tuples are unique, preserving order.
+ ref_tuples = list(collections.OrderedDict(
+ list(zip(ref_tuples, ref_tuples))))
+ ref_projects = self.services.project.GetProjectsByName(
+ self.mc.cnxn,
+ [(ref_pn or default_project_name) for ref_pn, _ in ref_tuples])
+ issue_ids, _misses = self.services.issue.ResolveIssueRefs(
+ self.mc.cnxn, ref_projects, default_project_name, ref_tuples)
+ open_issues, closed_issues = (
+ tracker_helpers.GetAllowedOpenedAndClosedIssues(
+ self.mc, issue_ids, self.services))
+ return open_issues, closed_issues
+
+ def GetIssueByLocalID(
+ self, project_id, local_id, use_cache=True,
+ allow_viewing_deleted=False):
+ """Return the specified issue, TODO: iff the signed in user may view it.
+
+ Args:
+ project_id: int project ID of the project that contains the issue.
+ local_id: int issue local id number.
+ use_cache: set to False when doing read-modify-write operations.
+ allow_viewing_deleted: set to True to return a deleted issue so that
+ an authorized user may undelete it.
+
+ Returns:
+ The specified Issue PB.
+
+ Raises:
+ exceptions.InputException: Something was not specified properly.
+ exceptions.NoSuchIssueException: The issue does not exist.
+ """
+ if project_id is None:
+ raise exceptions.InputException('No project specified')
+ if local_id is None:
+ raise exceptions.InputException('No issue local_id specified')
+
+ with self.mc.profiler.Phase('getting issue %r:%r' % (project_id, local_id)):
+ issue = self.services.issue.GetIssueByLocalID(
+ self.mc.cnxn, project_id, local_id, use_cache=use_cache)
+
+ self._AssertUserCanViewIssue(
+ issue, allow_viewing_deleted=allow_viewing_deleted)
+ return issue
+
+ def GetRelatedIssueRefs(self, issues):
+ """Return a dict {iid: (project_name, local_id)} for all related issues."""
+ related_iids = set()
+ with self.mc.profiler.Phase('getting related issue refs'):
+ for issue in issues:
+ related_iids.update(issue.blocked_on_iids)
+ related_iids.update(issue.blocking_iids)
+ if issue.merged_into:
+ related_iids.add(issue.merged_into)
+ logging.info('related_iids is %r', related_iids)
+ return self.services.issue.LookupIssueRefs(self.mc.cnxn, related_iids)
+
+ def GetIssueRefs(self, issue_ids):
+ """Return a dict {iid: (project_name, local_id)} for all issue_ids."""
+ return self.services.issue.LookupIssueRefs(self.mc.cnxn, issue_ids)
+
+ def BulkUpdateIssueApprovals(self, issue_ids, approval_id, project,
+ approval_delta, comment_content,
+ send_email):
+ """Update all given issues' specified approval."""
+ # Anon users and users with no permission to view the project
+ # will get permission denied. Missing permissions to update
+ # individual issues will not throw exceptions. Issues will just not be
+ # updated.
+ if not self.mc.auth.user_id:
+ raise permissions.PermissionException('Anon cannot make changes')
+ if not self._UserCanViewProject(project):
+ raise permissions.PermissionException('User cannot view project')
+ updated_issue_ids = []
+ for issue_id in issue_ids:
+ try:
+ self.UpdateIssueApproval(
+ issue_id, approval_id, approval_delta, comment_content, False,
+ send_email=False)
+ updated_issue_ids.append(issue_id)
+ except exceptions.NoSuchIssueApprovalException as e:
+ logging.info('Skipping issue %s, no approval: %s', issue_id, e)
+ except permissions.PermissionException as e:
+ logging.info('Skipping issue %s, update not allowed: %s', issue_id, e)
+ # TODO(crbug/monorail/8122): send bulk approval update email if send_email.
+ if send_email:
+ pass
+ return updated_issue_ids
+
+ def BulkUpdateIssueApprovalsV3(
+ self, delta_specifications, comment_content, send_email):
+ # type: (Sequence[Tuple[int, int, tracker_pb2.ApprovalDelta]]], str,
+ # Boolean -> Sequence[proto.tracker_pb2.ApprovalValue]
+ """Executes the ApprovalDeltas.
+
+ Args:
+ delta_specifications: List of (issue_id, approval_id, ApprovalDelta).
+ comment_content: The content of the comment to be posted with each delta.
+ send_email: Whether to send an email on each change.
+ TODO(crbug/monorail/8122): send bulk approval update email instead.
+
+ Returns:
+ A list of (Issue, ApprovalValue) pairs corresponding to each
+ specification provided in `delta_specifications`.
+
+ Raises:
+ InputException: If a comment is too long.
+ NoSuchIssueApprovalException: If any of the approvals specified
+ does not exist.
+ PermissionException: If the current user lacks permissions to execute
+ any of the deltas provided.
+ """
+ updated_approval_values = []
+ for (issue_id, approval_id, approval_delta) in delta_specifications:
+ updated_av, _comment, issue = self.UpdateIssueApproval(
+ issue_id,
+ approval_id,
+ approval_delta,
+ comment_content,
+ False,
+ send_email=send_email,
+ update_perms=True)
+ updated_approval_values.append((issue, updated_av))
+ return updated_approval_values
+
+ def UpdateIssueApproval(
+ self,
+ issue_id,
+ approval_id,
+ approval_delta,
+ comment_content,
+ is_description,
+ attachments=None,
+ send_email=True,
+ kept_attachments=None,
+ update_perms=False):
+ # type: (int, int, proto.tracker_pb2.ApprovalDelta, str, Boolean,
+ # Optional[Sequence[proto.tracker_pb2.Attachment]], Optional[Boolean],
+ # Optional[Sequence[int]], Optional[Boolean]) ->
+ # (proto.tracker_pb2.ApprovalValue, proto.tracker_pb2.IssueComment)
+ """Update an issue's approval.
+
+ Raises:
+ InputException: The comment content is too long or additional approvers do
+ not exist.
+ PermissionException: The user is lacking one of the permissions needed
+ for the given delta.
+ NoSuchIssueApprovalException: The issue/approval combo does not exist.
+ """
+
+ issue, approval_value = self.services.issue.GetIssueApproval(
+ self.mc.cnxn, issue_id, approval_id, use_cache=False)
+
+ self._AssertPermInIssue(issue, permissions.EDIT_ISSUE)
+
+ if len(comment_content) > tracker_constants.MAX_COMMENT_CHARS:
+ raise exceptions.InputException('Comment is too long')
+
+ project = self.GetProject(issue.project_id)
+ config = self.GetProjectConfig(issue.project_id)
+ # TODO(crbug/monorail/7614): Remove the need for this hack to update perms.
+ if update_perms:
+ self.mc.LookupLoggedInUserPerms(project)
+
+ if attachments:
+ with self.mc.profiler.Phase('Accounting for quota'):
+ new_bytes_used = tracker_helpers.ComputeNewQuotaBytesUsed(
+ project, attachments)
+ self.services.project.UpdateProject(
+ self.mc.cnxn, issue.project_id, attachment_bytes_used=new_bytes_used)
+
+ if kept_attachments:
+ with self.mc.profiler.Phase('Filtering kept attachments'):
+ kept_attachments = tracker_helpers.FilterKeptAttachments(
+ is_description, kept_attachments, self.ListIssueComments(issue),
+ approval_id)
+
+ if approval_delta.status:
+ if not permissions.CanUpdateApprovalStatus(
+ self.mc.auth.effective_ids, self.mc.perms, project,
+ approval_value.approver_ids, approval_delta.status):
+ raise permissions.PermissionException(
+ 'User not allowed to make this status update.')
+
+ if approval_delta.approver_ids_remove or approval_delta.approver_ids_add:
+ if not permissions.CanUpdateApprovers(
+ self.mc.auth.effective_ids, self.mc.perms, project,
+ approval_value.approver_ids):
+ raise permissions.PermissionException(
+ 'User not allowed to modify approvers of this approval.')
+
+ # Check additional approvers exist.
+ with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+ tracker_helpers.AssertUsersExist(
+ self.mc.cnxn, self.services, approval_delta.approver_ids_add, err_agg)
+
+ with self.mc.profiler.Phase(
+ 'updating approval for issue %r, aprpoval %r' % (
+ issue_id, approval_id)):
+ comment_pb = self.services.issue.DeltaUpdateIssueApproval(
+ self.mc.cnxn, self.mc.auth.user_id, config, issue, approval_value,
+ approval_delta, comment_content=comment_content,
+ is_description=is_description, attachments=attachments,
+ kept_attachments=kept_attachments)
+ hostport = framework_helpers.GetHostPort(
+ project_name=project.project_name)
+ send_notifications.PrepareAndSendApprovalChangeNotification(
+ issue_id, approval_id, hostport, comment_pb.id,
+ send_email=send_email)
+
+ return approval_value, comment_pb, issue
+
+ def ConvertIssueApprovalsTemplate(
+ self, config, issue, template_name, comment_content, send_email=True):
+ # type: (proto.tracker_pb2.ProjectIssueConfig, proto.tracker_pb2.Issue,
+ # str, str, Optional[Boolean] )
+ """Convert an issue's existing approvals structure to match the one of
+ the given template.
+
+ Raises:
+ InputException: The comment content is too long.
+ """
+ self._AssertPermInIssue(issue, permissions.EDIT_ISSUE)
+
+ template = self.services.template.GetTemplateByName(
+ self.mc.cnxn, template_name, issue.project_id)
+ if not template:
+ raise exceptions.NoSuchTemplateException(
+ 'Template %s is not found' % template_name)
+
+ if len(comment_content) > tracker_constants.MAX_COMMENT_CHARS:
+ raise exceptions.InputException('Comment is too long')
+
+ with self.mc.profiler.Phase('updating issue %r' % issue):
+ comment_pb = self.services.issue.UpdateIssueStructure(
+ self.mc.cnxn, config, issue, template, self.mc.auth.user_id,
+ comment_content)
+ hostport = framework_helpers.GetHostPort(project_name=issue.project_name)
+ send_notifications.PrepareAndSendIssueChangeNotification(
+ issue.issue_id, hostport, self.mc.auth.user_id,
+ send_email=send_email, comment_id=comment_pb.id)
+
+ def UpdateIssue(
+ self, issue, delta, comment_content, attachments=None, send_email=True,
+ is_description=False, kept_attachments=None, inbound_message=None):
+ # type: (...) => None
+ """Update an issue with a set of changes and add a comment.
+
+ Args:
+ issue: Existing Issue PB for the issue to be modified.
+ delta: IssueDelta object containing all the changes to be made.
+ comment_content: string content of the user's comment.
+ attachments: List [(filename, contents, mimetype),...] of attachments.
+ send_email: set to False to suppress email notifications.
+ is_description: True if this adds a new issue description.
+ kept_attachments: This should be a list of int attachment ids for
+ attachments kept from previous descriptions, if the comment is
+ a change to the issue description.
+ inbound_message: optional string full text of an email that caused
+ this comment to be added.
+
+ Returns:
+ Nothing.
+
+ Raises:
+ InputException: The comment content is too long.
+ """
+ if not self._UserCanUsePermInIssue(issue, permissions.EDIT_ISSUE):
+ # We're editing the issue description. Only users with EditIssue
+ # permission can edit the description.
+ if is_description:
+ raise permissions.PermissionException(
+ 'Users lack permission EditIssue in issue')
+ # If we're adding a comment, we must have AddIssueComment permission and
+ # verify it's size.
+ if comment_content:
+ self._AssertPermInIssue(issue, permissions.ADD_ISSUE_COMMENT)
+ # If we're modifying the issue, check that we only modify the fields we're
+ # allowed to edit.
+ if delta != tracker_pb2.IssueDelta():
+ allowed_delta = tracker_pb2.IssueDelta()
+ if self._UserCanUsePermInIssue(issue, permissions.EDIT_ISSUE_STATUS):
+ allowed_delta.status = delta.status
+ if self._UserCanUsePermInIssue(issue, permissions.EDIT_ISSUE_SUMMARY):
+ allowed_delta.summary = delta.summary
+ if self._UserCanUsePermInIssue(issue, permissions.EDIT_ISSUE_OWNER):
+ allowed_delta.owner_id = delta.owner_id
+ if self._UserCanUsePermInIssue(issue, permissions.EDIT_ISSUE_CC):
+ allowed_delta.cc_ids_add = delta.cc_ids_add
+ allowed_delta.cc_ids_remove = delta.cc_ids_remove
+ if delta != allowed_delta:
+ raise permissions.PermissionException(
+ 'Users lack permission EditIssue in issue')
+
+ if delta.merged_into:
+ # Reject attempts to merge an issue into an issue we cannot view and edit.
+ merged_into_issue = self.GetIssue(
+ delta.merged_into, use_cache=False, allow_viewing_deleted=True)
+ self._AssertPermInIssue(issue, permissions.EDIT_ISSUE)
+ # Reject attempts to merge an issue into itself.
+ if issue.issue_id == delta.merged_into:
+ raise exceptions.InputException(
+ 'Cannot merge an issue into itself.')
+
+ # Reject comments that are too long.
+ if comment_content and len(
+ comment_content) > tracker_constants.MAX_COMMENT_CHARS:
+ raise exceptions.InputException('Comment is too long')
+
+ # Reject attempts to block on issue on itself.
+ if (issue.issue_id in delta.blocked_on_add
+ or issue.issue_id in delta.blocking_add):
+ raise exceptions.InputException(
+ 'Cannot block an issue on itself.')
+
+ project = self.GetProject(issue.project_id)
+ config = self.GetProjectConfig(issue.project_id)
+
+ # Reject attempts to edit restricted fields that the user cannot change.
+ field_ids = [fv.field_id for fv in delta.field_vals_add]
+ field_ids.extend([fvr.field_id for fvr in delta.field_vals_remove])
+ field_ids.extend(delta.fields_clear)
+ labels = itertools.chain(delta.labels_add, delta.labels_remove)
+ self._AssertUserCanEditFieldsAndEnumMaskedLabels(
+ project, config, field_ids, labels)
+
+ old_owner_id = tracker_bizobj.GetOwnerId(issue)
+
+ if attachments:
+ with self.mc.profiler.Phase('Accounting for quota'):
+ new_bytes_used = tracker_helpers.ComputeNewQuotaBytesUsed(
+ project, attachments)
+ self.services.project.UpdateProject(
+ self.mc.cnxn, issue.project_id,
+ attachment_bytes_used=new_bytes_used)
+
+ with self.mc.profiler.Phase('Validating the issue change'):
+ # If the owner changed, it must be a project member.
+ if (delta.owner_id is not None and delta.owner_id != issue.owner_id):
+ parsed_owner_valid, msg = tracker_helpers.IsValidIssueOwner(
+ self.mc.cnxn, project, delta.owner_id, self.services)
+ if not parsed_owner_valid:
+ raise exceptions.InputException(msg)
+
+ if kept_attachments:
+ with self.mc.profiler.Phase('Filtering kept attachments'):
+ kept_attachments = tracker_helpers.FilterKeptAttachments(
+ is_description, kept_attachments, self.ListIssueComments(issue),
+ None)
+
+ with self.mc.profiler.Phase('Updating issue %r' % (issue.issue_id)):
+ _amendments, comment_pb = self.services.issue.DeltaUpdateIssue(
+ self.mc.cnxn, self.services, self.mc.auth.user_id, issue.project_id,
+ config, issue, delta, comment=comment_content,
+ attachments=attachments, is_description=is_description,
+ kept_attachments=kept_attachments, inbound_message=inbound_message)
+
+ with self.mc.profiler.Phase('Following up after issue update'):
+ if delta.merged_into:
+ new_starrers = tracker_helpers.GetNewIssueStarrers(
+ self.mc.cnxn, self.services, [issue.issue_id],
+ delta.merged_into)
+ merged_into_project = self.GetProject(merged_into_issue.project_id)
+ tracker_helpers.AddIssueStarrers(
+ self.mc.cnxn, self.services, self.mc,
+ delta.merged_into, merged_into_project, new_starrers)
+ # Load target issue again to get the updated star count.
+ merged_into_issue = self.GetIssue(
+ merged_into_issue.issue_id, use_cache=False)
+ merge_comment_pb = tracker_helpers.MergeCCsAndAddComment(
+ self.services, self.mc, issue, merged_into_issue)
+ # Send notification emails.
+ hostport = framework_helpers.GetHostPort(
+ project_name=merged_into_project.project_name)
+ reporter_id = self.mc.auth.user_id
+ send_notifications.PrepareAndSendIssueChangeNotification(
+ merged_into_issue.issue_id,
+ hostport,
+ reporter_id,
+ send_email=send_email,
+ comment_id=merge_comment_pb.id)
+ self.services.project.UpdateRecentActivity(
+ self.mc.cnxn, issue.project_id)
+
+ with self.mc.profiler.Phase('Generating notifications'):
+ if comment_pb:
+ hostport = framework_helpers.GetHostPort(
+ project_name=project.project_name)
+ reporter_id = self.mc.auth.user_id
+ send_notifications.PrepareAndSendIssueChangeNotification(
+ issue.issue_id, hostport, reporter_id,
+ send_email=send_email, old_owner_id=old_owner_id,
+ comment_id=comment_pb.id)
+ delta_blocked_on_iids = delta.blocked_on_add + delta.blocked_on_remove
+ send_notifications.PrepareAndSendIssueBlockingNotification(
+ issue.issue_id, hostport, delta_blocked_on_iids,
+ reporter_id, send_email=send_email)
+
+ def ModifyIssues(
+ self,
+ issue_id_delta_pairs,
+ attachment_uploads=None,
+ comment_content=None,
+ send_email=True):
+ # type: (Sequence[Tuple[int, IssueDelta]], Boolean, Optional[str],
+ # Optional[bool]) -> Sequence[Issue]
+ """Modify issues by the given deltas and returns all issues post-update.
+
+ Note: Issues with NOOP deltas and no comment_content to add will not be
+ updated and will not be returned.
+
+ Args:
+ issue_id_delta_pairs: List of Tuples containing IDs and IssueDeltas, one
+ for each issue to modify.
+ attachment_uploads: List of AttachmentUpload tuples to be attached to the
+ new comments created for all modified issues in issue_id_delta_pairs.
+ comment_content: The text for the comment this issue change will use.
+ send_email: Whether this change sends an email or not.
+
+ Returns:
+ List of modified issues.
+ """
+
+ main_issue_ids = {issue_id for issue_id, _delta in issue_id_delta_pairs}
+ issues_by_id = self.GetIssuesDict(main_issue_ids, use_cache=False)
+ issue_delta_pairs = [
+ (issues_by_id[issue_id], delta)
+ for (issue_id, delta) in issue_id_delta_pairs
+ ]
+
+ # PHASE 1: Prepare these changes and assert they can be made.
+ self._AssertUserCanModifyIssues(
+ issue_delta_pairs, False, comment_content=comment_content)
+ new_bytes_by_pid = tracker_helpers.PrepareIssueChanges(
+ self.mc.cnxn,
+ issue_delta_pairs,
+ self.services,
+ attachment_uploads=attachment_uploads,
+ comment_content=comment_content)
+ # TODO(crbug.com/monorail/8074): Assert we do not update more than 100
+ # issues at once.
+
+ # PHASE 2: Organize data. tracker_helpers.GroupUniqueDeltaIssues()
+ (_unique_deltas, issues_for_unique_deltas
+ ) = tracker_helpers.GroupUniqueDeltaIssues(issue_delta_pairs)
+
+ # PHASE 3-4: Modify issues in RAM.
+ changes = tracker_helpers.ApplyAllIssueChanges(
+ self.mc.cnxn, issue_delta_pairs, self.services)
+
+ # PHASE 5: Apply filter rules.
+ inflight_issues = changes.issues_to_update_dict.values()
+ project_ids = list(
+ {issue.project_id for issue in inflight_issues})
+ configs_by_id = self.services.config.GetProjectConfigs(
+ self.mc.cnxn, project_ids)
+ with exceptions.ErrorAggregator(exceptions.FilterRuleException) as err_agg:
+ for issue in inflight_issues:
+ config = configs_by_id[issue.project_id]
+
+ # Update closed timestamp before filter rules because filter rules
+ # may affect them.
+ old_effective_status = changes.old_statuses_by_iid.get(issue.issue_id)
+ # The old status might be None because the IssueDeltas did not contain
+ # a status change and MeansOpenInProject treats None as "Open".
+ if old_effective_status:
+ tracker_helpers.UpdateClosedTimestamp(
+ config, issue, old_effective_status)
+
+ filterrules_helpers.ApplyFilterRules(
+ self.mc.cnxn, self.services, issue, config)
+ if issue.derived_errors:
+ err_agg.AddErrorMessage('/n'.join(issue.derived_errors))
+
+ # Update closed timestamp after filter rules because filter rules
+ # could change effective status.
+ # The old status might be None because the IssueDeltas did not contain
+ # a status change and MeansOpenInProject treats None as "Open".
+ if old_effective_status:
+ tracker_helpers.UpdateClosedTimestamp(
+ config, issue, old_effective_status)
+
+ # PHASE 6: Update modified timestamps for issues in RAM.
+ all_involved_iids = main_issue_ids.union(
+ changes.issues_to_update_dict.keys())
+
+ now_timestamp = int(time.time())
+ # Add modified timestamps for issues with amendments.
+ for iid in all_involved_iids:
+ issue = changes.issues_to_update_dict.get(iid, issues_by_id.get(iid))
+ issue_modified = iid in changes.issues_to_update_dict
+
+ if not (issue_modified or comment_content or attachment_uploads):
+ # Skip issues that have neither amendments or comment changes.
+ continue
+
+ old_owner = changes.old_owners_by_iid.get(issue.issue_id)
+ old_status = changes.old_statuses_by_iid.get(issue.issue_id)
+ old_components = changes.old_components_by_iid.get(issue.issue_id)
+
+ # Adding this issue to issues_to_update, so its modified_timestamp gets
+ # updated in PHASE 7's UpdateIssues() call. Issues with NOOP changes
+ # but still need a new comment added for `comment_content` or
+ # `attachments` are added back here.
+ changes.issues_to_update_dict[issue.issue_id] = issue
+
+ issue.modified_timestamp = now_timestamp
+
+ if (iid in changes.old_owners_by_iid and
+ old_owner != tracker_bizobj.GetOwnerId(issue)):
+ issue.owner_modified_timestamp = now_timestamp
+
+ if (iid in changes.old_statuses_by_iid and
+ old_status != tracker_bizobj.GetStatus(issue)):
+ issue.status_modified_timestamp = now_timestamp
+
+ if (iid in changes.old_components_by_iid and
+ set(old_components) != set(issue.component_ids)):
+ issue.component_modified_timestamp = now_timestamp
+
+ # PHASE 7: Apply changes to DB: update issues, combine starrers
+ # for merged issues, create issue comments, enqueue issues for
+ # re-indexing.
+ if changes.issues_to_update_dict:
+ self.services.issue.UpdateIssues(
+ self.mc.cnxn, changes.issues_to_update_dict.values(), commit=False)
+ comments_by_iid = {}
+ impacted_comments_by_iid = {}
+
+ # changes.issues_to_update includes all main issues or impacted
+ # issues with updated fields and main issues that had noop changes
+ # but still need a comment created for `comment_content` or `attachments`.
+ for iid, issue in changes.issues_to_update_dict.items():
+ # Update starrers for merged issues.
+ new_starrers = changes.new_starrers_by_iid.get(iid)
+ if new_starrers:
+ self.services.issue_star.SetStarsBatch_SkipIssueUpdate(
+ self.mc.cnxn, iid, new_starrers, True, commit=False)
+
+ # Create new issue comment for main issue changes.
+ amendments = changes.amendments_by_iid.get(iid)
+ if (amendments or comment_content or
+ attachment_uploads) and iid in main_issue_ids:
+ comments_by_iid[iid] = self.services.issue.CreateIssueComment(
+ self.mc.cnxn,
+ issue,
+ self.mc.auth.user_id,
+ comment_content,
+ amendments=amendments,
+ attachments=attachment_uploads,
+ commit=False)
+
+ # Create new issue comment for impacted issue changes.
+ # ie: when an issue is marked as blockedOn another or similar.
+ imp_amendments = changes.imp_amendments_by_iid.get(iid)
+ if imp_amendments:
+ filtered_imp_amendments = []
+ content = ''
+ # Represent MERGEDINTO Amendments for impacted issues with
+ # comment content instead to be consistent with previous behavior
+ # and so users can tell whether a merged change comment on an issue
+ # is a change in the issue's merged_into or a change in another
+ # issue's merged_into.
+ for am in imp_amendments:
+ if am.field is tracker_pb2.FieldID.MERGEDINTO and am.newvalue:
+ for value in am.newvalue.split():
+ if value.startswith('-'):
+ content += UNMERGE_COMMENT % value.strip('-')
+ else:
+ content += MERGE_COMMENT % value
+ else:
+ filtered_imp_amendments.append(am)
+
+ impacted_comments_by_iid[iid] = self.services.issue.CreateIssueComment(
+ self.mc.cnxn,
+ issue,
+ self.mc.auth.user_id,
+ content,
+ amendments=filtered_imp_amendments,
+ commit=False)
+
+ # Update used bytes for each impacted project.
+ for pid, new_bytes_used in new_bytes_by_pid.items():
+ self.services.project.UpdateProject(
+ self.mc.cnxn, pid, attachment_bytes_used=new_bytes_used, commit=False)
+
+ # Reindex issues and commit all DB changes.
+ issues_to_reindex = set(
+ comments_by_iid.keys() + impacted_comments_by_iid.keys())
+ if issues_to_reindex:
+ self.services.issue.EnqueueIssuesForIndexing(
+ self.mc.cnxn, issues_to_reindex, commit=False)
+ # We only commit if there are issues to reindex. No issues to reindex
+ # means there were no updates that need a commit.
+ self.mc.cnxn.Commit()
+
+ # PHASE 8: Send notifications for each group of issues from Phase 2.
+ # Fetch hostports.
+ hostports_by_pid = {}
+ for iid, issue in changes.issues_to_update_dict.items():
+ # Note: issues_to_update only include issues with changes in metadata.
+ # If iid is not in issues_to_update, the issue may still have a new
+ # comment that we want to send notifications for.
+ issue = changes.issues_to_update_dict.get(iid, issues_by_id.get(iid))
+
+ if issue.project_id not in hostports_by_pid:
+ hostports_by_pid[issue.project_id] = framework_helpers.GetHostPort(
+ project_name=issue.project_name)
+ # Send emails for main changes in issues by unique delta.
+ for issues in issues_for_unique_deltas:
+ # Group issues for each unique delta by project because
+ # SendIssueBulkChangeNotification cannot handle cross-project
+ # notifications and hostports are specific to each project.
+ issues_by_pid = collections.defaultdict(set)
+ for issue in issues:
+ issues_by_pid[issue.project_id].add(issue)
+ for project_issues in issues_by_pid.values():
+ # Send one email to involved users for the issue.
+ if len(project_issues) == 1:
+ (project_issue,) = project_issues
+ self._ModifyIssuesNotifyForDelta(
+ project_issue, changes, comments_by_iid, hostports_by_pid,
+ send_email)
+ # Send one bulk email for users involved in all updated issues.
+ else:
+ self._ModifyIssuesBulkNotifyForDelta(
+ project_issues,
+ changes,
+ hostports_by_pid,
+ send_email,
+ comment_content=comment_content)
+
+ # Send emails for changes to impacted issues.
+ for issue_id, comment_pb in impacted_comments_by_iid.items():
+ issue = changes.issues_to_update_dict[issue_id]
+ hostport = hostports_by_pid[issue.project_id]
+ # We do not need to track old owners because the only owner change
+ # that could have happened for impacted issues' changes is a change from
+ # no owner to a derived owner.
+ send_notifications.PrepareAndSendIssueChangeNotification(
+ issue_id, hostport, self.mc.auth.user_id, comment_id=comment_pb.id,
+ send_email=send_email)
+
+ return [
+ issues_by_id[iid] for iid in main_issue_ids if iid in comments_by_iid
+ ]
+
+ def _ModifyIssuesNotifyForDelta(
+ self, issue, changes, comments_by_iid, hostports_by_pid, send_email):
+ # type: (Issue, tracker_helpers._IssueChangesTuple,
+ # Mapping[int, IssueComment], Mapping[int, str], bool) -> None
+ comment_pb = comments_by_iid.get(issue.issue_id)
+ # Existence of a comment_pb means there were updates to the issue or
+ # comment_content added to the issue that should trigger
+ # notifications.
+ if comment_pb:
+ hostport = hostports_by_pid[issue.project_id]
+ old_owner_id = changes.old_owners_by_iid.get(issue.issue_id)
+ send_notifications.PrepareAndSendIssueChangeNotification(
+ issue.issue_id,
+ hostport,
+ self.mc.auth.user_id,
+ old_owner_id=old_owner_id,
+ comment_id=comment_pb.id,
+ send_email=send_email)
+
+ def _ModifyIssuesBulkNotifyForDelta(
+ self, issues, changes, hostports_by_pid, send_email,
+ comment_content=None):
+ # type: (Collection[Issue], _IssueChangesTuple, Mapping[int, str], bool,
+ # Optional[str]) -> None
+ iids = {issue.issue_id for issue in issues}
+ old_owner_ids = [
+ changes.old_owners_by_iid.get(iid)
+ for iid in iids
+ if changes.old_owners_by_iid.get(iid)
+ ]
+ amendments = []
+ for iid in iids:
+ ams = changes.amendments_by_iid.get(iid, [])
+ amendments.extend(ams)
+ # Calling SendBulkChangeNotification does not require the comment_pb
+ # objects only the amendments. Checking for existence of amendments
+ # and comment_content is equivalent to checking for existence of new
+ # comments created for these issues.
+ if amendments or comment_content:
+ # TODO(crbug.com/monorail/8125): Stop using UserViews for bulk
+ # notifications.
+ users_by_id = framework_views.MakeAllUserViews(
+ self.mc.cnxn, self.services.user, old_owner_ids,
+ tracker_bizobj.UsersInvolvedInAmendments(amendments))
+ hostport = hostports_by_pid[issues.pop().project_id]
+ send_notifications.SendIssueBulkChangeNotification(
+ iids, hostport, old_owner_ids, comment_content,
+ self.mc.auth.user_id, amendments, send_email, users_by_id)
+
+ def DeleteIssue(self, issue, delete):
+ """Mark or unmark the given issue as deleted."""
+ self._AssertPermInIssue(issue, permissions.DELETE_ISSUE)
+
+ with self.mc.profiler.Phase('Marking issue %r deleted' % (issue.issue_id)):
+ self.services.issue.SoftDeleteIssue(
+ self.mc.cnxn, issue.project_id, issue.local_id, delete,
+ self.services.user)
+
+ def FlagIssues(self, issues, flag):
+ """Flag or unflag the given issues as spam."""
+ for issue in issues:
+ self._AssertPermInIssue(issue, permissions.FLAG_SPAM)
+
+ issue_ids = [issue.issue_id for issue in issues]
+ with self.mc.profiler.Phase('Marking issues %r as spam' % issue_ids):
+ self.services.spam.FlagIssues(
+ self.mc.cnxn, self.services.issue, issues, self.mc.auth.user_id,
+ flag)
+ if self._UserCanUsePermInIssue(issue, permissions.VERDICT_SPAM):
+ self.services.spam.RecordManualIssueVerdicts(
+ self.mc.cnxn, self.services.issue, issues, self.mc.auth.user_id,
+ flag)
+
+ def LookupIssuesFlaggers(self, issues):
+ """Returns users who've reported the issue or its comments as spam.
+
+ Args:
+ issues: the list of issues to query.
+ Returns:
+ A dictionary
+ {issue_id: ([issue_reporters], {comment_id: [comment_reporters]})}
+ For each issue id, a tuple with the users who have flagged the issue;
+ and a dictionary of users who have flagged a comment for each comment id.
+ """
+ for issue in issues:
+ self._AssertUserCanViewIssue(issue)
+
+ issue_ids = [issue.issue_id for issue in issues]
+ with self.mc.profiler.Phase('Looking up flaggers for %s' % issue_ids):
+ reporters = self.services.spam.LookupIssuesFlaggers(
+ self.mc.cnxn, issue_ids)
+
+ return reporters
+
+ def LookupIssueFlaggers(self, issue):
+ """Returns users who've reported the issue or its comments as spam.
+
+ Args:
+ issue: the issue to query.
+ Returns:
+ A tuple
+ ([issue_reporters], {comment_id: [comment_reporters]})
+ With the users who have flagged the issue; and a dictionary of users who
+ have flagged a comment for each comment id.
+ """
+ return self.LookupIssuesFlaggers([issue])[issue.issue_id]
+
+ def GetIssuePositionInHotlist(
+ self, current_issue, hotlist, can, sort_spec, group_by_spec):
+ # type: (Issue, Hotlist, int, str, str) -> (int, int, int, int)
+ """Get index info of an issue within a hotlist.
+
+ Args:
+ current_issue: the currently viewed issue.
+ hotlist: the hotlist this flipper is flipping through.
+ can: int "canned query" number to scope the visible issues.
+ sort_spec: string that lists the sort order.
+ group_by_spec: string that lists the grouping order.
+ """
+ issues_list = self.services.issue.GetIssues(self.mc.cnxn,
+ [item.issue_id for item in hotlist.items])
+ project_ids = hotlist_helpers.GetAllProjectsOfIssues(issues_list)
+ config_list = hotlist_helpers.GetAllConfigsOfProjects(
+ self.mc.cnxn, project_ids, self.services)
+ harmonized_config = tracker_bizobj.HarmonizeConfigs(config_list)
+ (sorted_issues, _hotlist_issues_context,
+ _users) = hotlist_helpers.GetSortedHotlistIssues(
+ self.mc.cnxn, hotlist.items, issues_list, self.mc.auth,
+ can, sort_spec, group_by_spec, harmonized_config, self.services,
+ self.mc.profiler)
+ (prev_iid, cur_index,
+ next_iid) = features_bizobj.DetermineHotlistIssuePosition(
+ current_issue, [issue.issue_id for issue in sorted_issues])
+ total_count = len(sorted_issues)
+ return prev_iid, cur_index, next_iid, total_count
+
+ def RerankBlockedOnIssues(self, issue, moved_id, target_id, split_above):
+ """Rerank the blocked on issues for issue_id.
+
+ Args:
+ issue: The issue to modify.
+ moved_id: The id of the issue to move.
+ target_id: The id of the issue to move |moved_issue| to.
+ split_above: Whether to move |moved_issue| before or after |target_issue|.
+ """
+ # Make sure the user has permission to edit the issue.
+ self._AssertPermInIssue(issue, permissions.EDIT_ISSUE)
+ # Make sure the moved and target issues are in the blocked-on list.
+ if moved_id not in issue.blocked_on_iids:
+ raise exceptions.InputException(
+ 'The issue to move is not in the blocked-on list.')
+ if target_id not in issue.blocked_on_iids:
+ raise exceptions.InputException(
+ 'The target issue is not in the blocked-on list.')
+
+ phase_name = 'Moving issue %r %s issue %d.' % (
+ moved_id, 'above' if split_above else 'below', target_id)
+ with self.mc.profiler.Phase(phase_name):
+ lower, higher = tracker_bizobj.SplitBlockedOnRanks(
+ issue, target_id, split_above,
+ [iid for iid in issue.blocked_on_iids if iid != moved_id])
+ rank_changes = rerank_helpers.GetInsertRankings(
+ lower, higher, [moved_id])
+ if rank_changes:
+ self.services.issue.ApplyIssueRerank(
+ self.mc.cnxn, issue.issue_id, rank_changes)
+
+ # FUTURE: GetIssuePermissionsForUser()
+
+ # FUTURE: CreateComment()
+
+
+ # TODO(crbug.com/monorail/7520): Delete when usages removed.
+ def ListIssueComments(self, issue):
+ """Return comments on the specified viewable issue."""
+ self._AssertUserCanViewIssue(issue)
+
+ with self.mc.profiler.Phase('getting comments for %r' % issue.issue_id):
+ comments = self.services.issue.GetCommentsForIssue(
+ self.mc.cnxn, issue.issue_id)
+
+ return comments
+
+
+ def SafeListIssueComments(
+ self, issue_id, max_items, start, approval_id=None):
+ # type: (tracker_pb2.Issue, int, int, Optional[int]) -> ListResult
+ """Return comments on the issue, filtering non-viewable content.
+
+ TODO(crbug.com/monorail/7520): Rename to ListIssueComments.
+
+ Note: This returns `deleted_by`, but it should only be used for the purposes
+ of determining whether the comment is deleted. The viewer may not have
+ access to view who deleted the comment.
+
+ Args:
+ issue_id: The issue for which we're listing comments.
+ max_items: The maximum number of comments to return.
+ start: The index of the start position in the list of comments.
+ approval_id: Whether to only return comments on this approval.
+
+ Returns:
+ A work_env.ListResult namedtuple with the comments for the issue.
+
+ Raises:
+ PermissionException: The logged-in user is not allowed to view the issue.
+ """
+ if start < 0:
+ raise exceptions.InputException('Invalid `start`: %d' % start)
+ if max_items < 0:
+ raise exceptions.InputException('Invalid `max_items`: %d' % max_items)
+
+ with self.mc.profiler.Phase('getting comments for %r' % issue_id):
+ issue = self.GetIssue(issue_id)
+ comments = self.services.issue.GetCommentsForIssue(self.mc.cnxn, issue_id)
+ _, comment_reporters = self.LookupIssueFlaggers(issue)
+ users_involved_in_comments = tracker_bizobj.UsersInvolvedInCommentList(
+ comments)
+ users_by_id = framework_views.MakeAllUserViews(
+ self.mc.cnxn, self.services.user, users_involved_in_comments)
+
+ with self.mc.profiler.Phase('getting perms for comments'):
+ project = self.GetProjectByName(issue.project_name)
+ self.mc.LookupLoggedInUserPerms(project)
+ config = self.GetProjectConfig(project.project_id)
+ perms = permissions.UpdateIssuePermissions(
+ self.mc.perms,
+ project,
+ issue,
+ self.mc.auth.effective_ids,
+ config=config)
+
+ # TODO(crbug.com/monorail/7525): Check values, and return next_start.
+ end = start + max_items
+ filtered_comments = []
+ with self.mc.profiler.Phase('converting comments'):
+ for comment in comments:
+ if approval_id and comment.approval_id != approval_id:
+ continue
+ commenter = users_by_id[comment.user_id]
+
+ _can_flag, is_flagged = permissions.CanFlagComment(
+ comment, commenter, comment_reporters.get(comment.id, []),
+ self.mc.auth.user_id, perms)
+ can_view = permissions.CanViewComment(
+ comment, commenter, self.mc.auth.user_id, perms)
+ can_view_inbound_message = permissions.CanViewInboundMessage(
+ comment, self.mc.auth.user_id, perms)
+
+ # By default, all fields should get filtered out.
+ # i.e. this is an allowlist rather than a denylist to reduce leaking
+ # info.
+ filtered_comment = tracker_pb2.IssueComment(
+ id=comment.id,
+ issue_id=comment.issue_id,
+ project_id=comment.project_id,
+ approval_id=comment.approval_id,
+ timestamp=comment.timestamp,
+ deleted_by=comment.deleted_by,
+ sequence=comment.sequence,
+ is_spam=is_flagged,
+ is_description=comment.is_description,
+ description_num=comment.description_num)
+ if can_view:
+ filtered_comment.content = comment.content
+ filtered_comment.user_id = comment.user_id
+ filtered_comment.amendments.extend(comment.amendments)
+ filtered_comment.attachments.extend(comment.attachments)
+ filtered_comment.importer_id = comment.importer_id
+ if can_view_inbound_message:
+ filtered_comment.inbound_message = comment.inbound_message
+ filtered_comments.append(filtered_comment)
+ next_start = None
+ if end < len(filtered_comments):
+ next_start = end
+ return ListResult(filtered_comments[start:end], next_start)
+
+ # FUTURE: UpdateComment()
+
+ def DeleteComment(self, issue, comment, delete):
+ """Mark or unmark a comment as deleted by the current user."""
+ self._AssertUserCanDeleteComment(issue, comment)
+ if comment.is_spam and self.mc.auth.user_id == comment.user_id:
+ raise permissions.PermissionException('Cannot delete comment.')
+
+ with self.mc.profiler.Phase(
+ 'deleting issue %r comment %r' % (issue.issue_id, comment.id)):
+ self.services.issue.SoftDeleteComment(
+ self.mc.cnxn, issue, comment, self.mc.auth.user_id,
+ self.services.user, delete=delete)
+
+ def DeleteAttachment(self, issue, comment, attachment_id, delete):
+ """Mark or unmark a comment attachment as deleted by the current user."""
+ # A user can delete an attachment iff they can delete a comment.
+ self._AssertUserCanDeleteComment(issue, comment)
+
+ phase_message = 'deleting issue %r comment %r attachment %r' % (
+ issue.issue_id, comment.id, attachment_id)
+ with self.mc.profiler.Phase(phase_message):
+ self.services.issue.SoftDeleteAttachment(
+ self.mc.cnxn, issue, comment, attachment_id, self.services.user,
+ delete=delete)
+
+ def FlagComment(self, issue, comment, flag):
+ """Mark or unmark a comment as spam."""
+ self._AssertPermInIssue(issue, permissions.FLAG_SPAM)
+ with self.mc.profiler.Phase(
+ 'flagging issue %r comment %r' % (issue.issue_id, comment.id)):
+ self.services.spam.FlagComment(
+ self.mc.cnxn, issue, comment.id, comment.user_id,
+ self.mc.auth.user_id, flag)
+ if self._UserCanUsePermInIssue(issue, permissions.VERDICT_SPAM):
+ self.services.spam.RecordManualCommentVerdict(
+ self.mc.cnxn, self.services.issue, self.services.user, comment.id,
+ self.mc.auth.user_id, flag)
+
+ def StarIssue(self, issue, starred):
+ # type: (Issue, bool) -> Issue
+ """Set or clear a star on the given issue for the signed in user."""
+ if not self.mc.auth.user_id:
+ raise permissions.PermissionException('Anon cannot star issues')
+ self._AssertPermInIssue(issue, permissions.SET_STAR)
+
+ with self.mc.profiler.Phase('starring issue %r' % issue.issue_id):
+ config = self.services.config.GetProjectConfig(
+ self.mc.cnxn, issue.project_id)
+ self.services.issue_star.SetStar(
+ self.mc.cnxn, self.services, config, issue.issue_id,
+ self.mc.auth.user_id, starred)
+ return self.services.issue.GetIssue(self.mc.cnxn, issue.issue_id)
+
+ def IsIssueStarred(self, issue, cnxn=None):
+ """Return True if the given issue is starred by the signed in user."""
+ self._AssertUserCanViewIssue(issue)
+
+ with self.mc.profiler.Phase('checking star %r' % issue.issue_id):
+ return self.services.issue_star.IsItemStarredBy(
+ cnxn or self.mc.cnxn, issue.issue_id, self.mc.auth.user_id)
+
+ def ListStarredIssueIDs(self):
+ """Return a list of the issue IDs that the current issue has starred."""
+ # This returns an unfiltered list of issue_ids. Permissions will be
+ # applied if and when the caller attempts to load each issue.
+
+ with self.mc.profiler.Phase('getting stars %r' % self.mc.auth.user_id):
+ return self.services.issue_star.LookupStarredItemIDs(
+ self.mc.cnxn, self.mc.auth.user_id)
+
+ def SnapshotCountsQuery(self, project, timestamp, group_by, label_prefix=None,
+ query=None, canned_query=None, hotlist=None):
+ """Query IssueSnapshots for daily counts.
+
+ See chart_svc.QueryIssueSnapshots for more detail on arguments.
+
+ Args:
+ project (Project): Project to search.
+ timestamp (int): Will query for snapshots at this timestamp.
+ group_by (str): 2nd dimension, see QueryIssueSnapshots for options.
+ label_prefix (str): Required for label queries. Only returns results
+ with the supplied prefix.
+ query (str, optional): If supplied, will parse & apply query conditions.
+ canned_query (str, optional): Parsed canned query.
+ hotlist (Hotlist, optional): Hotlist to search under (in lieu of project).
+
+ Returns:
+ 1. A dict of {name: count} for each item in group_by.
+ 2. A list of any unsupported query conditions in query.
+ """
+ # This returns counts of viewable issues.
+ with self.mc.profiler.Phase('querying snapshot counts'):
+ return self.services.chart.QueryIssueSnapshots(
+ self.mc.cnxn, self.services, timestamp, self.mc.auth.effective_ids,
+ project, self.mc.perms, group_by=group_by, label_prefix=label_prefix,
+ query=query, canned_query=canned_query, hotlist=hotlist)
+
+ ### User methods
+
+ # TODO(crbug/monorail/7238): rewrite this method to call BatchGetUsers.
+ def GetUser(self, user_id):
+ # type: (int) -> User
+ """Return the user with the given ID."""
+
+ return self.BatchGetUsers([user_id])[0]
+
+ def BatchGetUsers(self, user_ids):
+ # type: (Sequence[int]) -> Sequence[User]
+ """Return all Users for given User IDs.
+
+ Args:
+ user_ids: list of User IDs.
+
+ Returns:
+ A list of User objects in the same order as the given User IDs.
+
+ Raises:
+ NoSuchUserException if a User for a given User ID is not found.
+ """
+ users_by_id = self.services.user.GetUsersByIDs(
+ self.mc.cnxn, user_ids, skip_missed=True)
+ users = []
+ for user_id in user_ids:
+ user = users_by_id.get(user_id)
+ if not user:
+ raise exceptions.NoSuchUserException(
+ 'No User with ID %s found' % user_id)
+ users.append(user)
+ return users
+
+ def GetMemberships(self, user_id):
+ """Return the user group ids for the given user visible to the requester."""
+ group_ids = self.services.usergroup.LookupMemberships(self.mc.cnxn, user_id)
+ if user_id == self.mc.auth.user_id:
+ return group_ids
+ (member_ids_by_ids, owner_ids_by_ids
+ ) = self.services.usergroup.LookupAllMembers(
+ self.mc.cnxn, group_ids)
+ settings_by_id = self.services.usergroup.GetAllGroupSettings(
+ self.mc.cnxn, group_ids)
+
+ (owned_project_ids, membered_project_ids,
+ contrib_project_ids) = self.services.project.GetUserRolesInAllProjects(
+ self.mc.cnxn, self.mc.auth.effective_ids)
+ project_ids = owned_project_ids.union(
+ membered_project_ids).union(contrib_project_ids)
+
+ visible_group_ids = []
+ for group_id, group_settings in settings_by_id.items():
+ member_ids = member_ids_by_ids.get(group_id)
+ owner_ids = owner_ids_by_ids.get(group_id)
+ if permissions.CanViewGroupMembers(
+ self.mc.perms, self.mc.auth.effective_ids, group_settings,
+ member_ids, owner_ids, project_ids):
+ visible_group_ids.append(group_id)
+
+ return visible_group_ids
+
+ def ListReferencedUsers(self, emails):
+ """Return a list of the given emails' User PBs, plus linked account ids.
+
+ Args:
+ emails: list of emails of users to look up.
+
+ Returns:
+ A pair (users, linked_users_ids) where users is an unsorted list of
+ User PBs and linked_user_ids is a list of user IDs of any linked accounts.
+ """
+ with self.mc.profiler.Phase('getting existing users'):
+ user_id_dict = self.services.user.LookupExistingUserIDs(
+ self.mc.cnxn, emails)
+ users_by_id = self.services.user.GetUsersByIDs(
+ self.mc.cnxn, list(user_id_dict.values()))
+ user_list = list(users_by_id.values())
+
+ linked_user_ids = []
+ for user in user_list:
+ if user.linked_parent_id:
+ linked_user_ids.append(user.linked_parent_id)
+ linked_user_ids.extend(user.linked_child_ids)
+
+ return user_list, linked_user_ids
+
+ def StarUser(self, user_id, starred):
+ """Star or unstar the specified user.
+
+ Args:
+ user_id: int ID of the user to star/unstar.
+ starred: true to add a star, false to remove it.
+
+ Returns:
+ Nothing.
+
+ Raises:
+ NoSuchUserException: There is no user with that ID.
+ """
+ if not self.mc.auth.user_id:
+ raise exceptions.InputException('No current user specified')
+
+ with self.mc.profiler.Phase('(un)starring user %r' % user_id):
+ # Make sure the user exists and user has permission to see it.
+ self.services.user.LookupUserEmail(self.mc.cnxn, user_id)
+ self.services.user_star.SetStar(
+ self.mc.cnxn, user_id, self.mc.auth.user_id, starred)
+
+ def IsUserStarred(self, user_id):
+ """Return True if the current user has starred the given user.
+
+ Args:
+ user_id: int ID of the user to check.
+
+ Returns:
+ True if starred.
+
+ Raises:
+ NoSuchUserException: There is no user with that ID.
+ """
+ if user_id is None:
+ raise exceptions.InputException('No user specified')
+
+ if not self.mc.auth.user_id:
+ return False
+
+ with self.mc.profiler.Phase('checking user star %r' % user_id):
+ # Make sure the user exists.
+ self.services.user.LookupUserEmail(self.mc.cnxn, user_id)
+ return self.services.user_star.IsItemStarredBy(
+ self.mc.cnxn, user_id, self.mc.auth.user_id)
+
+ def GetUserStarCount(self, user_id):
+ """Return the number of times the user has been starred.
+
+ Args:
+ user_id: int ID of the user to check.
+
+ Returns:
+ The number of times the user has been starred.
+
+ Raises:
+ NoSuchUserException: There is no user with that ID.
+ """
+ if user_id is None:
+ raise exceptions.InputException('No user specified')
+
+ with self.mc.profiler.Phase('counting stars for user %r' % user_id):
+ # Make sure the user exists.
+ self.services.user.LookupUserEmail(self.mc.cnxn, user_id)
+ return self.services.user_star.CountItemStars(self.mc.cnxn, user_id)
+
+ def GetPendingLinkedInvites(self, user_id=None):
+ """Return info about a user's linked account invites."""
+ with self.mc.profiler.Phase('checking linked account invites'):
+ result = self.services.user.GetPendingLinkedInvites(
+ self.mc.cnxn, user_id or self.mc.auth.user_id)
+ return result
+
+ def InviteLinkedParent(self, parent_email):
+ """Invite a matching account to be my parent."""
+ if not parent_email:
+ raise exceptions.InputException('No parent account specified')
+ if not self.mc.auth.user_id:
+ raise permissions.PermissionException('Anon cannot link accounts')
+ with self.mc.profiler.Phase('Validating proposed parent'):
+ # We only offer self-serve account linking to matching usernames.
+ (p_username, p_domain,
+ _obs_username, _obs_email) = framework_bizobj.ParseAndObscureAddress(
+ parent_email)
+ c_view = self.mc.auth.user_view
+ if p_username != c_view.username:
+ logging.info('Username %r != %r', p_username, c_view.username)
+ raise exceptions.InputException('Linked account names must match')
+ allowed_domains = settings.linkable_domains.get(c_view.domain, [])
+ if p_domain not in allowed_domains:
+ logging.info('parent domain %r is not in list for %r: %r',
+ p_domain, c_view.domain, allowed_domains)
+ raise exceptions.InputException('Linked account unsupported domain')
+ parent_id = self.services.user.LookupUserID(self.mc.cnxn, parent_email)
+ with self.mc.profiler.Phase('Creating linked account invite'):
+ self.services.user.InviteLinkedParent(
+ self.mc.cnxn, parent_id, self.mc.auth.user_id)
+
+ def AcceptLinkedChild(self, child_id):
+ """Accept an invitation from a child account."""
+ with self.mc.profiler.Phase('Accept linked account invite'):
+ self.services.user.AcceptLinkedChild(
+ self.mc.cnxn, self.mc.auth.user_id, child_id)
+
+ def UnlinkAccounts(self, parent_id, child_id):
+ """Delete a linked-account relationship."""
+ if (self.mc.auth.user_id != parent_id and
+ self.mc.auth.user_id != child_id):
+ permitted = self.mc.perms.CanUsePerm(
+ permissions.EDIT_OTHER_USERS, self.mc.auth.effective_ids, None, [])
+ if not permitted:
+ raise permissions.PermissionException(
+ 'User lacks permission to unlink accounts')
+
+ with self.mc.profiler.Phase('Unlink accounts'):
+ self.services.user.UnlinkAccounts(self.mc.cnxn, parent_id, child_id)
+
+ def UpdateUserSettings(self, user, **kwargs):
+ """Update the preferences of the specified user.
+
+ Args:
+ user: User PB for the user to update.
+ keyword_args: dictionary of setting names mapped to new values.
+ """
+ if not user or not user.user_id:
+ raise exceptions.InputException('Cannot update user settings for anon.')
+
+ with self.mc.profiler.Phase(
+ 'updating settings for %s with %s' % (self.mc.auth.user_id, kwargs)):
+ self.services.user.UpdateUserSettings(
+ self.mc.cnxn, user.user_id, user, **kwargs)
+
+ def GetUserPrefs(self, user_id):
+ """Get the UserPrefs for the specified user."""
+ # Anon user always has default prefs.
+ if not user_id:
+ return user_pb2.UserPrefs(user_id=0)
+ if user_id != self.mc.auth.user_id:
+ if not self.mc.perms.HasPerm(permissions.EDIT_OTHER_USERS, None, None):
+ raise permissions.PermissionException(
+ 'Only site admins may see other users\' preferences')
+ with self.mc.profiler.Phase('Getting prefs for %s' % user_id):
+ userprefs = self.services.user.GetUserPrefs(self.mc.cnxn, user_id)
+
+ # Hard-coded user prefs for at-risk users that should use "corp mode".
+ # For some users we mark all of their new issues as Restrict-View-Google.
+ # Others see a "public issue" warning when commenting on public issues.
+ # TODO(crbug.com/monorail/5462):
+ # Remove when user group preferences are implemented.
+ if framework_bizobj.IsRestrictNewIssuesUser(self.mc.cnxn, self.services,
+ user_id):
+ # Copy so that cached version is not modified.
+ userprefs = user_pb2.UserPrefs(user_id=user_id, prefs=userprefs.prefs)
+ if 'restrict_new_issues' not in {pref.name for pref in userprefs.prefs}:
+ userprefs.prefs.append(user_pb2.UserPrefValue(
+ name='restrict_new_issues', value='true'))
+ if framework_bizobj.IsPublicIssueNoticeUser(self.mc.cnxn, self.services,
+ user_id):
+ # Copy so that cached version is not modified.
+ userprefs = user_pb2.UserPrefs(user_id=user_id, prefs=userprefs.prefs)
+ if 'public_issue_notice' not in {pref.name for pref in userprefs.prefs}:
+ userprefs.prefs.append(user_pb2.UserPrefValue(
+ name='public_issue_notice', value='true'))
+
+ return userprefs
+
+ def SetUserPrefs(self, user_id, prefs):
+ """Set zero or more UserPrefValue for the specified user."""
+ # Anon user always has default prefs.
+ if not user_id:
+ raise exceptions.InputException('Anon cannot have prefs')
+ if user_id != self.mc.auth.user_id:
+ if not self.mc.perms.HasPerm(permissions.EDIT_OTHER_USERS, None, None):
+ raise permissions.PermissionException(
+ 'Only site admins may set other users\' preferences')
+ for pref in prefs:
+ error_msg = framework_bizobj.ValidatePref(pref.name, pref.value)
+ if error_msg:
+ raise exceptions.InputException(error_msg)
+ with self.mc.profiler.Phase(
+ 'setting prefs for %s' % (self.mc.auth.user_id)):
+ self.services.user.SetUserPrefs(self.mc.cnxn, user_id, prefs)
+
+ # FUTURE: GetUser()
+ # FUTURE: UpdateUser()
+ # FUTURE: DeleteUser()
+ # FUTURE: ListStarredUsers()
+
+ def ExpungeUsers(self, emails, check_perms=True, commit=True):
+ """Permanently deletes user data and removes remaining user references
+ for all listed users.
+
+ To avoid any executions that might take too long and make the site hang,
+ a limit clause will be added to some operations. If any user references
+ are left behind due to the cut-off, the final services.user.ExpungeUsers
+ will fail because we cannot delete User rows that are still referenced
+ in other tables. work_env.ExpungeUsers can be called again until all user
+ references are removed and the final services.user.ExpungeUsers succeeds.
+ The limit clause will not be applied in operations for tables that contain
+ user_id or email columns but do not officially Reference the User table.
+ E.g. SpamVerdict and SpamReport. These user references must all be removed
+ before the attempt to delete rows from User is made. The limit will also
+ not be applied for sets of operations where values removed in earlier
+ operations would have to be known in order for later operations to
+ succeed. E.g. ExpungeUsersIngroups().
+ """
+ if check_perms:
+ if not permissions.CanExpungeUsers(self.mc):
+ raise permissions.PermissionException(
+ 'User is not allowed to delete users.')
+
+ limit = 10000
+ user_ids_by_email = self.services.user.LookupExistingUserIDs(
+ self.mc.cnxn, emails)
+ user_ids = list(set(user_ids_by_email.values()))
+ if framework_constants.DELETED_USER_ID in user_ids:
+ raise exceptions.InputException(
+ 'Reserved deleted_user_id found in deletion request and'
+ 'should not be deleted')
+ if not user_ids:
+ logging.info('Emails %r not found in DB. No users deleted', emails)
+ return
+
+ # The operations made in the methods below can be limited.
+ # We can adjust 'limit' as necessary to avoid timing out.
+ self.services.issue_star.ExpungeStarsByUsers(
+ self.mc.cnxn, user_ids, limit=limit)
+ self.services.project_star.ExpungeStarsByUsers(
+ self.mc.cnxn, user_ids, limit=limit)
+ self.services.hotlist_star.ExpungeStarsByUsers(
+ self.mc.cnxn, user_ids, limit=limit)
+ self.services.user_star.ExpungeStarsByUsers(
+ self.mc.cnxn, user_ids, limit=limit)
+ for user_id in user_ids:
+ self.services.user_star.ExpungeStars(
+ self.mc.cnxn, user_id, commit=False, limit=limit)
+
+ self.services.features.ExpungeQuickEditsByUsers(
+ self.mc.cnxn, user_ids, limit=limit)
+ self.services.features.ExpungeSavedQueriesByUsers(
+ self.mc.cnxn, user_ids, limit=limit)
+
+ self.services.template.ExpungeUsersInTemplates(
+ self.mc.cnxn, user_ids, limit=limit)
+ self.services.config.ExpungeUsersInConfigs(
+ self.mc.cnxn, user_ids, limit=limit)
+
+ self.services.project.ExpungeUsersInProjects(
+ self.mc.cnxn, user_ids, limit=limit)
+
+ # The upcoming operations cannot be limited with 'limit'.
+ # So it's possible that these operations below may lead to timing out
+ # and ExpungeUsers will have to run again to fully delete all users.
+ # We commit the above operations here, so if a failure does happen
+ # below, the second run of ExpungeUsers will have less work to do.
+ if commit:
+ self.mc.cnxn.Commit()
+
+ affected_issue_ids = self.services.issue.ExpungeUsersInIssues(
+ self.mc.cnxn, user_ids_by_email, limit=limit)
+ # Commit ExpungeUsersInIssues here, as it has many operations
+ # and at least one operation that cannot be limited.
+ if commit:
+ self.mc.cnxn.Commit()
+ self.services.issue.EnqueueIssuesForIndexing(
+ self.mc.cnxn, affected_issue_ids)
+
+ # Spam verdict and report tables have user_id columns that do not
+ # reference User. No limit will be applied.
+ self.services.spam.ExpungeUsersInSpam(self.mc.cnxn, user_ids)
+ if commit:
+ self.mc.cnxn.Commit()
+
+ # No limit will be applied for expunging in hotlists.
+ self.services.features.ExpungeUsersInHotlists(
+ self.mc.cnxn, user_ids, self.services.hotlist_star, self.services.user,
+ self.services.chart)
+ if commit:
+ self.mc.cnxn.Commit()
+
+ # No limit will be applied for expunging in UserGroups.
+ self.services.usergroup.ExpungeUsersInGroups(
+ self.mc.cnxn, user_ids)
+ if commit:
+ self.mc.cnxn.Commit()
+
+ # No limit will be applied for expunging in FilterRules.
+ deleted_rules_by_project = self.services.features.ExpungeFilterRulesByUser(
+ self.mc.cnxn, user_ids_by_email)
+ rule_strs_by_project = filterrules_helpers.BuildRedactedFilterRuleStrings(
+ self.mc.cnxn, deleted_rules_by_project, self.services.user, emails)
+ if commit:
+ self.mc.cnxn.Commit()
+
+ # We will attempt to expunge all given users here. Limiting the users we
+ # delete should be done before work_env.ExpungeUsers is called.
+ self.services.user.ExpungeUsers(self.mc.cnxn, user_ids)
+ if commit:
+ self.mc.cnxn.Commit()
+ self.services.usergroup.group_dag.MarkObsolete()
+
+ for project_id, filter_rule_strs in rule_strs_by_project.items():
+ project = self.services.project.GetProject(self.mc.cnxn, project_id)
+ hostport = framework_helpers.GetHostPort(
+ project_name=project.project_name)
+ send_notifications.PrepareAndSendDeletedFilterRulesNotification(
+ project_id, hostport, filter_rule_strs)
+
+ def TotalUsersCount(self):
+ """Returns the total number of Users in Monorail."""
+ return self.services.user.TotalUsersCount(self.mc.cnxn)
+
+ def GetAllUserEmailsBatch(self, limit=1000, offset=0):
+ """Returns a list emails that belong to Users in Monorail.
+
+ Returns:
+ A list of emails for Users within Monorail ordered by the user.user_ids.
+ The list will hold at most [limit] emails and will start at the given
+ [offset].
+ """
+ return self.services.user.GetAllUserEmailsBatch(
+ self.mc.cnxn, limit=limit, offset=offset)
+
+ ### Group methods
+
+ # FUTURE: CreateGroup()
+ # FUTURE: ListGroups()
+ # FUTURE: UpdateGroup()
+ # FUTURE: DeleteGroup()
+
+ ### Hotlist methods
+
+ def CreateHotlist(
+ self, name, summary, description, editor_ids, issue_ids, is_private,
+ default_col_spec):
+ # type: (string, string, string, Collection[int], Collection[int], Boolean,
+ # string)
+ """Create a hotlist.
+
+ Args:
+ name: a valid hotlist name.
+ summary: one-line explanation of the hotlist.
+ description: one-page explanation of the hotlist.
+ editor_ids: a list of user IDs for the hotlist editors.
+ issue_ids: a list of issue IDs for the hotlist issues.
+ is_private: True if the hotlist can only be viewed by owners and editors.
+ default_col_spec: default columns for the hotlist's list view.
+
+
+ Returns:
+ The newly created hotlist.
+
+ Raises:
+ HotlistAlreadyExists: A hotlist with the given name already exists.
+ InputException: No user is signed in or the proposed name is invalid.
+ PermissionException: If the user cannot view all of the issues.
+ """
+ if not self.mc.auth.user_id:
+ raise exceptions.InputException('Anon cannot create hotlists.')
+
+ # GetIssuesDict checks that the user can view all issues.
+ self.GetIssuesDict(issue_ids)
+
+ if not framework_bizobj.IsValidHotlistName(name):
+ raise exceptions.InputException(
+ '%s is not a valid name for a Hotlist' % name)
+ if self.services.features.LookupHotlistIDs(
+ self.mc.cnxn, [name], [self.mc.auth.user_id]):
+ raise features_svc.HotlistAlreadyExists()
+
+ with self.mc.profiler.Phase('creating hotlist %s' % name):
+ hotlist = self.services.features.CreateHotlist(
+ self.mc.cnxn, name, summary, description, [self.mc.auth.user_id],
+ editor_ids, issue_ids=issue_ids, is_private=is_private,
+ default_col_spec=default_col_spec, ts=int(time.time()))
+
+ return hotlist
+
+ def UpdateHotlist(
+ self, hotlist_id, hotlist_name=None, summary=None, description=None,
+ is_private=None, default_col_spec=None, owner_id=None,
+ add_editor_ids=None):
+ # type: (int, str, str, str, bool, str, int, Collection[int]) -> None
+ """Update the given hotlist.
+
+ If a new value is None, the value does not get updated.
+
+ Args:
+ hotlist_id: hotlist_id of the hotlist to update.
+ hotlist_name: proposed new name for the hotlist.
+ summary: new summary for the hotlist.
+ description: new description for the hotlist.
+ is_private: true if hotlist should be updated to private.
+ default_col_spec: new default columns for hotlist list view.
+ owner_id: User id of the new owner.
+ add_editor_ids: User ids to add as editors.
+
+ Raises:
+ InputException: The given hotlist_id is None or proposed new name is not
+ a valid hotlist name.
+ NoSuchHotlistException: There is no hotlist with the given ID.
+ PermissionException: The logged-in user is not allowed to update
+ this hotlist's settings.
+ NoSuchUserException: Some proposed editors or owner were not found.
+ HotlistAlreadyExists: The (proposed new) hotlist owner already owns a
+ hotlist with the same (proposed) name.
+ """
+ hotlist = self.services.features.GetHotlist(
+ self.mc.cnxn, hotlist_id, use_cache=False)
+ if not permissions.CanAdministerHotlist(
+ self.mc.auth.effective_ids, self.mc.perms, hotlist):
+ raise permissions.PermissionException(
+ 'User is not allowed to update hotlist settings.')
+
+ if hotlist.name == hotlist_name:
+ hotlist_name = None
+ if hotlist.owner_ids[0] == owner_id:
+ owner_id = None
+
+ if hotlist_name and not framework_bizobj.IsValidHotlistName(hotlist_name):
+ raise exceptions.InputException(
+ '"%s" is not a valid hotlist name' % hotlist_name)
+
+ # Check (new) owner does not already own a hotlist with the (new) name.
+ if hotlist_name or owner_id:
+ owner_ids = [owner_id] if owner_id else None
+ if self.services.features.LookupHotlistIDs(
+ self.mc.cnxn, [hotlist_name or hotlist.name],
+ owner_ids or hotlist.owner_ids):
+ raise features_svc.HotlistAlreadyExists(
+ 'User already owns a hotlist with name %s' %
+ hotlist_name or hotlist.name)
+
+ # Filter out existing editors and users that will be added as owner
+ # or is the current owner.
+ next_owner_id = owner_id or hotlist.owner_ids[0]
+ if add_editor_ids:
+ new_editor_ids_set = {user_id for user_id in add_editor_ids if
+ user_id not in hotlist.editor_ids and
+ user_id != next_owner_id}
+ add_editor_ids = list(new_editor_ids_set)
+
+ # Validate user change requests.
+ user_ids = []
+ if add_editor_ids:
+ user_ids.extend(add_editor_ids)
+ else:
+ add_editor_ids = None
+ if owner_id:
+ user_ids.append(owner_id)
+ if user_ids:
+ self.services.user.LookupUserEmails(self.mc.cnxn, user_ids)
+
+ # Check for other no-op changes.
+ if summary == hotlist.summary:
+ summary = None
+ if description == hotlist.description:
+ description = None
+ if is_private == hotlist.is_private:
+ is_private = None
+ if default_col_spec == hotlist.default_col_spec:
+ default_col_spec = None
+
+ if ([hotlist_name, summary, description, is_private, default_col_spec,
+ owner_id, add_editor_ids] ==
+ [None, None, None, None, None, None, None]):
+ logging.info('No updates given')
+ return
+
+ if (summary is not None) and (not summary):
+ raise exceptions.InputException('Hotlist cannot have an empty summary.')
+ if (description is not None) and (not description):
+ raise exceptions.InputException(
+ 'Hotlist cannot have an empty description.')
+ if default_col_spec is not None and not framework_bizobj.IsValidColumnSpec(
+ default_col_spec):
+ raise exceptions.InputException(
+ '"%s" is not a valid column spec' % default_col_spec)
+
+ self.services.features.UpdateHotlist(
+ self.mc.cnxn, hotlist_id, name=hotlist_name, summary=summary,
+ description=description, is_private=is_private,
+ default_col_spec=default_col_spec, owner_id=owner_id,
+ add_editor_ids=add_editor_ids)
+
+ # TODO(crbug/monorail/7104): delete UpdateHotlistRoles.
+
+ def GetHotlist(self, hotlist_id, use_cache=True):
+ # int, Optional[Boolean] -> Hotlist
+ """Return the specified hotlist.
+
+ Args:
+ hotlist_id: int hotlist_id of the hotlist to retrieve.
+ use_cache: set to false when doing read-modify-write.
+
+ Returns:
+ The specified hotlist.
+
+ Raises:
+ NoSuchHotlistException: There is no hotlist with that ID.
+ PermissionException: The user is not allowed to view the hotlist.
+ """
+ if hotlist_id is None:
+ raise exceptions.InputException('No hotlist specified')
+
+ with self.mc.profiler.Phase('getting hotlist %r' % hotlist_id):
+ hotlist = self.services.features.GetHotlist(
+ self.mc.cnxn, hotlist_id, use_cache=use_cache)
+ self._AssertUserCanViewHotlist(hotlist)
+ return hotlist
+
+ # TODO(crbug/monorail/7104): Remove group_by_spec argument and pre-pend
+ # values to sort_spec.
+ def ListHotlistItems(self, hotlist_id, max_items, start, can, sort_spec,
+ group_by_spec, use_cache=True):
+ # type: (int, int, int, int, str, str, bool) -> ListResult
+ """Return a list of HotlistItems for the given hotlist that
+ are visible by the user.
+
+ Args:
+ hotlist_id: int hotlist_id of the hotlist.
+ max_items: int the maximum number of HotlistItems we want to return.
+ start: int start position in the total sorted items.
+ can: int "canned_query" number to scope the visible issues.
+ sort_spec: string that lists the sort order.
+ group_by_spec: string that lists the grouping order.
+ use_cache: set to false when doing read-modify-write.
+
+ Returns:
+ A work_env.ListResult namedtuple.
+
+ Raises:
+ NoSuchHotlistException: There is no hotlist with that ID.
+ InputException: `max_items` or `start` are negative values.
+ PermissionException: The user is not allowed to view the hotlist.
+ """
+ hotlist = self.GetHotlist(hotlist_id, use_cache=use_cache)
+ if start < 0:
+ raise exceptions.InputException('Invalid `start`: %d' % start)
+ if max_items < 0:
+ raise exceptions.InputException('Invalid `max_items`: %d' % max_items)
+
+ hotlist_issues = self.services.issue.GetIssues(
+ self.mc.cnxn, [item.issue_id for item in hotlist.items])
+ project_ids = hotlist_helpers.GetAllProjectsOfIssues(hotlist_issues)
+ config_list = hotlist_helpers.GetAllConfigsOfProjects(
+ self.mc.cnxn, project_ids, self.services)
+ harmonized_config = tracker_bizobj.HarmonizeConfigs(config_list)
+
+ (sorted_issues, _hotlist_items_context,
+ _users_by_id) = hotlist_helpers.GetSortedHotlistIssues(
+ self.mc.cnxn, hotlist.items, hotlist_issues, self.mc.auth, can,
+ sort_spec, group_by_spec, harmonized_config, self.services,
+ self.mc.profiler)
+
+
+ end = start + max_items
+ visible_issues = sorted_issues[start:end]
+ hotlist_items_dict = {item.issue_id: item for item in hotlist.items}
+ visible_hotlist_items = [hotlist_items_dict.get(issue.issue_id) for
+ issue in visible_issues]
+
+ next_start = None
+ if end < len(sorted_issues):
+ next_start = end
+ return ListResult(visible_hotlist_items, next_start)
+
+ def TransferHotlistOwnership(self, hotlist_id, new_owner_id, remain_editor,
+ use_cache=True, commit=True):
+ """Transfer ownership of hotlist from current owner to new_owner.
+
+ Args:
+ hotlist_id: int hotlist_id of the hotlist we want to transfer
+ new_owner_id: user_id of the new owner
+ remain_editor: True if the old owner should remain on the hotlist as
+ editor.
+ use_cache: set to false when doing read-modify-write.
+ commit: True, if changes should be committed.
+
+ Raises:
+ NoSuchHotlistException: There is not hotlist with the given ID.
+ PermissionException: The logged-in user is not allowed to change ownership
+ of the hotlist.
+ InputException: The proposed new owner already owns a hotlist with the
+ same name.
+ """
+ hotlist = self.services.features.GetHotlist(
+ self.mc.cnxn, hotlist_id, use_cache=use_cache)
+ edit_permitted = permissions.CanAdministerHotlist(
+ self.mc.auth.effective_ids, self.mc.perms, hotlist)
+ if not edit_permitted:
+ raise permissions.PermissionException(
+ 'User is not allowed to update hotlist members.')
+
+ if self.services.features.LookupHotlistIDs(
+ self.mc.cnxn, [hotlist.name], [new_owner_id]):
+ raise exceptions.InputException(
+ 'Proposed new owner already owns a hotlist with this name.')
+
+ self.services.features.TransferHotlistOwnership(
+ self.mc.cnxn, hotlist, new_owner_id, remain_editor, commit=commit)
+
+ def RemoveHotlistEditors(self, hotlist_id, remove_editor_ids, use_cache=True):
+ """Removes editors in a hotlist.
+
+ Args:
+ hotlist_id: the id of the hotlist we want to update
+ remove_editor_ids: list of user_ids to remove from hotlist editors
+
+ Raises:
+ NoSuchHotlistException: There is not hotlist with the given ID.
+ PermissionException: The logged-in user is not allowed to administer the
+ hotlist.
+ InputException: The users being removed are not editors in the hotlist.
+ """
+ hotlist = self.services.features.GetHotlist(
+ self.mc.cnxn, hotlist_id, use_cache=use_cache)
+ edit_permitted = permissions.CanAdministerHotlist(
+ self.mc.auth.effective_ids, self.mc.perms, hotlist)
+
+ # check if user is only removing themselves from the hotlist.
+ # removing linked accounts is allowed but users cannot remove groups
+ # they are part of from hotlists.
+ user_or_linked_ids = (
+ self.mc.auth.user_pb.linked_child_ids + [self.mc.auth.user_id])
+ if self.mc.auth.user_pb.linked_parent_id:
+ user_or_linked_ids.append(self.mc.auth.user_pb.linked_parent_id)
+ removing_self_only = set(remove_editor_ids).issubset(
+ set(user_or_linked_ids))
+
+ if not removing_self_only and not edit_permitted:
+ raise permissions.PermissionException(
+ 'User is not allowed to remove editors')
+
+ if not set(remove_editor_ids).issubset(set(hotlist.editor_ids)):
+ raise exceptions.InputException(
+ 'Cannot remove users who are not hotlist editors.')
+
+ self.services.features.RemoveHotlistEditors(
+ self.mc.cnxn, hotlist_id, remove_editor_ids)
+
+ def DeleteHotlist(self, hotlist_id):
+ """Delete the given hotlist from the DB.
+
+ Args:
+ hotlist_id (int): The id of the hotlist to delete.
+
+ Raises:
+ NoSuchHotlistException: There is not hotlist with the given ID.
+ PermissionException: The logged-in user is not allowed to
+ delete the hotlist.
+ """
+ hotlist = self.services.features.GetHotlist(
+ self.mc.cnxn, hotlist_id, use_cache=False)
+ edit_permitted = permissions.CanAdministerHotlist(
+ self.mc.auth.effective_ids, self.mc.perms, hotlist)
+ if not edit_permitted:
+ raise permissions.PermissionException(
+ 'User is not allowed to delete hotlist')
+
+ self.services.features.ExpungeHotlists(
+ self.mc.cnxn, [hotlist.hotlist_id], self.services.hotlist_star,
+ self.services.user, self.services.chart)
+
+ def ListHotlistsByUser(self, user_id):
+ """Return the hotlists for the given user.
+
+ Args:
+ user_id (int): The id of the user to query.
+
+ Returns:
+ The hotlists for the given user.
+ """
+ if user_id is None:
+ raise exceptions.InputException('No user specified')
+
+ with self.mc.profiler.Phase('querying hotlists for user %r' % user_id):
+ hotlists = self.services.features.GetHotlistsByUserID(
+ self.mc.cnxn, user_id)
+
+ # Filter the hotlists that the currently authenticated user cannot see.
+ result = [
+ hotlist
+ for hotlist in hotlists
+ if permissions.CanViewHotlist(
+ self.mc.auth.effective_ids, self.mc.perms, hotlist)]
+ return result
+
+ def ListHotlistsByIssue(self, issue_id):
+ """Return the hotlists the given issue is part of.
+
+ Args:
+ issue_id (int): The id of the issue to query.
+
+ Returns:
+ The hotlists the given issue is part of.
+ """
+ # Check that the issue exists and the user has permission to see it.
+ self.GetIssue(issue_id)
+
+ with self.mc.profiler.Phase('querying hotlists for issue %r' % issue_id):
+ hotlists = self.services.features.GetHotlistsByIssueID(
+ self.mc.cnxn, issue_id)
+
+ # Filter the hotlists that the currently authenticated user cannot see.
+ result = [
+ hotlist
+ for hotlist in hotlists
+ if permissions.CanViewHotlist(
+ self.mc.auth.effective_ids, self.mc.perms, hotlist)]
+ return result
+
+ def ListRecentlyVisitedHotlists(self):
+ """Return the recently visited hotlists for the logged in user.
+
+ Returns:
+ The recently visited hotlists for the given user, or an empty list if no
+ user is logged in.
+ """
+ if not self.mc.auth.user_id:
+ return []
+
+ with self.mc.profiler.Phase(
+ 'get recently visited hotlists for user %r' % self.mc.auth.user_id):
+ hotlist_ids = self.services.user.GetRecentlyVisitedHotlists(
+ self.mc.cnxn, self.mc.auth.user_id)
+ hotlists_by_id = self.services.features.GetHotlists(
+ self.mc.cnxn, hotlist_ids)
+ hotlists = [hotlists_by_id[hotlist_id] for hotlist_id in hotlist_ids]
+
+ # Filter the hotlists that the currently authenticated user cannot see.
+ # It might be that some of the hotlists have become private since the user
+ # last visited them, or the user has lost access for other reasons.
+ result = [
+ hotlist
+ for hotlist in hotlists
+ if permissions.CanViewHotlist(
+ self.mc.auth.effective_ids, self.mc.perms, hotlist)]
+ return result
+
+ def ListStarredHotlists(self):
+ """Return the starred hotlists for the logged in user.
+
+ Returns:
+ The starred hotlists for the logged in user.
+ """
+ if not self.mc.auth.user_id:
+ return []
+
+ with self.mc.profiler.Phase(
+ 'get starred hotlists for user %r' % self.mc.auth.user_id):
+ hotlist_ids = self.services.hotlist_star.LookupStarredItemIDs(
+ self.mc.cnxn, self.mc.auth.user_id)
+ hotlists_by_id, _ = self.services.features.GetHotlistsByID(
+ self.mc.cnxn, hotlist_ids)
+ hotlists = [hotlists_by_id[hotlist_id] for hotlist_id in hotlist_ids]
+
+ # Filter the hotlists that the currently authenticated user cannot see.
+ # It might be that some of the hotlists have become private since the user
+ # starred them, or the user has lost access for other reasons.
+ result = [
+ hotlist
+ for hotlist in hotlists
+ if permissions.CanViewHotlist(
+ self.mc.auth.effective_ids, self.mc.perms, hotlist)]
+ return result
+
+ def StarHotlist(self, hotlist_id, starred):
+ """Star or unstar the specified hotlist.
+
+ Args:
+ hotlist_id: int ID of the hotlist to star/unstar.
+ starred: true to add a star, false to remove it.
+
+ Returns:
+ Nothing.
+
+ Raises:
+ NoSuchHotlistException: There is no hotlist with that ID.
+ """
+ if hotlist_id is None:
+ raise exceptions.InputException('No hotlist specified')
+
+ if not self.mc.auth.user_id:
+ raise exceptions.InputException('No current user specified')
+
+ with self.mc.profiler.Phase('(un)starring hotlist %r' % hotlist_id):
+ # Make sure the hotlist exists and user has permission to see it.
+ self.GetHotlist(hotlist_id)
+ self.services.hotlist_star.SetStar(
+ self.mc.cnxn, hotlist_id, self.mc.auth.user_id, starred)
+
+ def IsHotlistStarred(self, hotlist_id):
+ """Return True if the current hotlist has starred the given hotlist.
+
+ Args:
+ hotlist_id: int ID of the hotlist to check.
+
+ Returns:
+ True if starred.
+
+ Raises:
+ NoSuchHotlistException: There is no hotlist with that ID.
+ """
+ if hotlist_id is None:
+ raise exceptions.InputException('No hotlist specified')
+
+ if not self.mc.auth.user_id:
+ return False
+
+ with self.mc.profiler.Phase('checking hotlist star %r' % hotlist_id):
+ # Make sure the hotlist exists and user has permission to see it.
+ self.GetHotlist(hotlist_id)
+ return self.services.hotlist_star.IsItemStarredBy(
+ self.mc.cnxn, hotlist_id, self.mc.auth.user_id)
+
+ def GetHotlistStarCount(self, hotlist_id):
+ """Return the number of times the hotlist has been starred.
+
+ Args:
+ hotlist_id: int ID of the hotlist to check.
+
+ Returns:
+ The number of times the hotlist has been starred.
+
+ Raises:
+ NoSuchHotlistException: There is no hotlist with that ID.
+ """
+ if hotlist_id is None:
+ raise exceptions.InputException('No hotlist specified')
+
+ with self.mc.profiler.Phase('counting stars for hotlist %r' % hotlist_id):
+ # Make sure the hotlist exists and user has permission to see it.
+ self.GetHotlist(hotlist_id)
+ return self.services.hotlist_star.CountItemStars(self.mc.cnxn, hotlist_id)
+
+ def CheckHotlistName(self, name):
+ """Check that a hotlist name is valid and not already in use.
+
+ Args:
+ name: str the hotlist name to check.
+
+ Returns:
+ None if the user can create a hotlist with that name, or a string with the
+ reason the name can't be used.
+
+ Raises:
+ InputException: The user is not signed in.
+ """
+ if not self.mc.auth.user_id:
+ raise exceptions.InputException('No current user specified')
+
+ with self.mc.profiler.Phase('checking hotlist name: %r' % name):
+ if not framework_bizobj.IsValidHotlistName(name):
+ return '"%s" is not a valid hotlist name.' % name
+ if self.services.features.LookupHotlistIDs(
+ self.mc.cnxn, [name], [self.mc.auth.user_id]):
+ return 'There is already a hotlist with that name.'
+
+ return None
+
+ def RemoveIssuesFromHotlists(self, hotlist_ids, issue_ids):
+ """Remove the issues given in issue_ids from the given hotlists.
+
+ Args:
+ hotlist_ids: a list of hotlist ids to remove the issues from.
+ issue_ids: a list of issue_ids to be removed.
+
+ Raises:
+ PermissionException: The user has no permission to edit the hotlist.
+ NoSuchHotlistException: One of the hotlist ids was not found.
+ """
+ for hotlist_id in hotlist_ids:
+ self._AssertUserCanEditHotlist(self.GetHotlist(hotlist_id))
+
+ with self.mc.profiler.Phase(
+ 'Removing issues %r from hotlists %r' % (issue_ids, hotlist_ids)):
+ self.services.features.RemoveIssuesFromHotlists(
+ self.mc.cnxn, hotlist_ids, issue_ids, self.services.issue,
+ self.services.chart)
+
+ def AddIssuesToHotlists(self, hotlist_ids, issue_ids, note):
+ """Add the issues given in issue_ids to the given hotlists.
+
+ Args:
+ hotlist_ids: a list of hotlist ids to add the issues to.
+ issue_ids: a list of issue_ids to be added.
+ note: a string with a message to record along with the issues.
+
+ Raises:
+ PermissionException: The user has no permission to edit the hotlist.
+ NoSuchHotlistException: One of the hotlist ids was not found.
+ """
+ for hotlist_id in hotlist_ids:
+ self._AssertUserCanEditHotlist(self.GetHotlist(hotlist_id))
+
+ # GetIssuesDict checks that the user can view all issues
+ self.GetIssuesDict(issue_ids)
+
+ added_tuples = [
+ (issue_id, self.mc.auth.user_id, int(time.time()), note)
+ for issue_id in issue_ids]
+
+ with self.mc.profiler.Phase(
+ 'Removing issues %r from hotlists %r' % (issue_ids, hotlist_ids)):
+ self.services.features.AddIssuesToHotlists(
+ self.mc.cnxn, hotlist_ids, added_tuples, self.services.issue,
+ self.services.chart)
+
+ # TODO(crbug/monorai/7104): RemoveHotlistItems and RerankHotlistItems should
+ # replace RemoveIssuesFromHotlist, AddIssuesToHotlists,
+ # RemoveIssuesFromHotlists.
+ # The latter 3 methods are still used in v0 API paths and should be removed
+ # once those v0 API methods are removed.
+ def RemoveHotlistItems(self, hotlist_id, remove_issue_ids):
+ # type: (int, Collection[int]) -> None
+ """Remove given issues from a hotlist.
+
+ Args:
+ hotlist_id: A hotlist ID of the hotlist to remove issues from.
+ remove_issue_ids: A list of issue IDs that belong to HotlistItems
+ we want to remove from the hotlist.
+
+ Raises:
+ NoSuchHotlistException: If the hotlist is not found.
+ NoSuchIssueException: if an Issue is not found for a given
+ remove_issue_id.
+ PermissionException: If the user lacks permissions to edit the hotlist or
+ view all the given issues.
+ InputException: If there are ids in `remove_issue_ids` that do not exist
+ in the hotlist.
+ """
+ hotlist = self.GetHotlist(hotlist_id)
+ self._AssertUserCanEditHotlist(hotlist)
+ if not remove_issue_ids:
+ raise exceptions.InputException('`remove_issue_ids` empty.')
+
+ item_issue_ids = {item.issue_id for item in hotlist.items}
+ if not (set(remove_issue_ids).issubset(item_issue_ids)):
+ raise exceptions.InputException('item(s) not found in hotlist.')
+
+ # Raise exception for un-viewable or not found item_issue_ids.
+ self.GetIssuesDict(item_issue_ids)
+
+ self.services.features.UpdateHotlistIssues(
+ self.mc.cnxn, hotlist_id, [], remove_issue_ids, self.services.issue,
+ self.services.chart)
+
+ def AddHotlistItems(self, hotlist_id, new_issue_ids, target_position):
+ # type: (int, Sequence[int], int) -> None
+ """Add given issues to a hotlist.
+
+ Args:
+ hotlist_id: A hotlist ID of the hotlist to add issues to.
+ new_issue_ids: A list of issue IDs that should belong to new
+ HotlistItems added to the hotlist. HotlistItems will be added
+ in the same order the IDs are given in. If some HotlistItems already
+ exist in the Hotlist, they will not be moved.
+ target_position: The index, starting at 0, of the new position the
+ first issue in new_issue_ids should have. This value cannot be greater
+ than (# of current hotlist.items).
+
+ Raises:
+ PermissionException: If the user lacks permissions to edit the hotlist or
+ view all the given issues.
+ NoSuchHotlistException: If the hotlist is not found.
+ NoSuchIssueException: If an Issue is not found for a given new_issue_id.
+ InputException: If the target_position or new_issue_ids are not valid.
+ """
+ hotlist = self.GetHotlist(hotlist_id)
+ self._AssertUserCanEditHotlist(hotlist)
+ if not new_issue_ids:
+ raise exceptions.InputException('no new issues given to add.')
+
+ item_issue_ids = {item.issue_id for item in hotlist.items}
+ confirmed_new_issue_ids = set(new_issue_ids).difference(item_issue_ids)
+
+ # Raise exception for un-viewable or not found item_issue_ids.
+ self.GetIssuesDict(item_issue_ids)
+
+ if confirmed_new_issue_ids:
+ changed_items = self._GetChangedHotlistItems(
+ hotlist, list(confirmed_new_issue_ids), target_position)
+ self.services.features.UpdateHotlistIssues(
+ self.mc.cnxn, hotlist_id, changed_items, [], self.services.issue,
+ self.services.chart)
+
+ def RerankHotlistItems(self, hotlist_id, moved_issue_ids, target_position):
+ # type: (int, list(int), int) -> Hotlist
+ """Rerank HotlistItems of a Hotlist.
+
+ This method reranks existing hotlist items to the given target_position.
+ e.g. For a hotlist with items (a, b, c, d, e), if moved_issue_ids were
+ [e.issue_id, c.issue_id] and target_position were 0,
+ the hotlist items would be reranked as (e, c, a, b, d).
+
+ Args:
+ hotlist_id: A hotlist ID of the hotlist to rerank.
+ moved_issue_ids: A list of issue IDs in the hotlist, to be moved
+ together, in the order they should have after the reranking.
+ target_position: The index, starting at 0, of the new position the
+ first issue in moved_issue_ids should have. This value cannot be greater
+ than (# of current hotlist.items not being reranked).
+
+ Returns:
+ The updated hotlist.
+
+ Raises:
+ PermissionException: If the user lacks permissions to rerank the hotlist
+ or view all the given issues.
+ NoSuchHotlistException: If the hotlist is not found.
+ NoSuchIssueException: If an Issue is not found for a given moved_issue_id.
+ InputException: If the target_position or moved_issue_ids are not valid.
+ """
+ hotlist = self.GetHotlist(hotlist_id)
+ self._AssertUserCanEditHotlist(hotlist)
+ if not moved_issue_ids:
+ raise exceptions.InputException('`moved_issue_ids` empty.')
+
+ item_issue_ids = {item.issue_id for item in hotlist.items}
+ if not (set(moved_issue_ids).issubset(item_issue_ids)):
+ raise exceptions.InputException('item(s) not found in hotlist.')
+
+ # Raise exception for un-viewable or not found item_issue_ids.
+ self.GetIssuesDict(item_issue_ids)
+ changed_items = self._GetChangedHotlistItems(
+ hotlist, moved_issue_ids, target_position)
+
+ if changed_items:
+ self.services.features.UpdateHotlistIssues(
+ self.mc.cnxn, hotlist_id, changed_items, [], self.services.issue,
+ self.services.chart)
+
+ return self.GetHotlist(hotlist.hotlist_id)
+
+ def _GetChangedHotlistItems(self, hotlist, moved_issue_ids, target_position):
+ # type: (Hotlist, Sequence(int), int) -> Hotlist
+ """Returns HotlistItems that are changed after moving existing/new issues.
+
+ This returns the list of new HotlistItems and existing HotlistItems
+ with updated ranks as a result of moving the given issues to the given
+ target_position. This list may include HotlistItems whose ranks' must be
+ changed as a result of the `moved_issue_ids`.
+
+ Args:
+ hotlist: The hotlist that owns the HotlistItems.
+ moved_issue_ids: A sequence of issue IDs for new or existing items of the
+ Hotlist, to be moved together, in the order they should have after
+ the change.
+ target_position: The index, starting at 0, of the new position the
+ first issue in moved_issue_ids should have. This value cannot be greater
+ than (# of current hotlist.items not being reranked).
+
+ Returns:
+ The updated hotlist.
+
+ Raises:
+ PermissionException: If the user lacks permissions to rerank the hotlist.
+ NoSuchHotlistException: If the hotlist is not found.
+ InputException: If the target_position or moved_issue_ids are not valid.
+ """
+ # List[Tuple[issue_id, new_rank]]
+ changed_item_ranks = rerank_helpers.GetHotlistRerankChanges(
+ hotlist.items, moved_issue_ids, target_position)
+
+ items_by_id = {item.issue_id: item for item in hotlist.items}
+ changed_items = []
+ current_time = int(time.time())
+ for issue_id, rank in changed_item_ranks:
+ # Get existing item to update or create new item.
+ item = items_by_id.get(
+ issue_id,
+ features_pb2.Hotlist.HotlistItem(
+ issue_id=issue_id,
+ adder_id=self.mc.auth.user_id,
+ date_added=current_time))
+ item.rank = rank
+ changed_items.append(item)
+
+ return changed_items
+
+ # TODO(crbug/monorail/7031): Remove this method
+ # and corresponding v0 prpc method.
+ def RerankHotlistIssues(self, hotlist_id, moved_ids, target_id, split_above):
+ """Rerank the moved issues for the hotlist.
+
+ Args:
+ hotlist_id: an int with the id of the hotlist.
+ moved_ids: The id of the issues to move.
+ target_id: the id of the issue to move the issues to.
+ split_above: True if moved issues should be moved before the target issue.
+ """
+ hotlist = self.GetHotlist(hotlist_id)
+ self._AssertUserCanEditHotlist(hotlist)
+ hotlist_issue_ids = [item.issue_id for item in hotlist.items]
+ if not set(moved_ids).issubset(set(hotlist_issue_ids)):
+ raise exceptions.InputException('The issue to move is not in the hotlist')
+ if target_id not in hotlist_issue_ids:
+ raise exceptions.InputException('The target issue is not in the hotlist.')
+
+ phase_name = 'Moving issues %r %s issue %d.' % (
+ moved_ids, 'above' if split_above else 'below', target_id)
+ with self.mc.profiler.Phase(phase_name):
+ lower, higher = features_bizobj.SplitHotlistIssueRanks(
+ target_id, split_above,
+ [(item.issue_id, item.rank) for item in hotlist.items if
+ item.issue_id not in moved_ids])
+ rank_changes = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+ if rank_changes:
+ relations_to_change = {
+ issue_id: rank for issue_id, rank in rank_changes}
+ self.services.features.UpdateHotlistItemsFields(
+ self.mc.cnxn, hotlist_id, new_ranks=relations_to_change)
+
+ def UpdateHotlistIssueNote(self, hotlist_id, issue_id, note):
+ """Update the given issue of the given hotlist with the given note.
+
+ Args:
+ hotlist_id: an int with the id of the hotlist.
+ issue_id: an int with the id of the issue.
+ note: a string with a message to record for the given issue.
+ Raises:
+ PermissionException: The user has no permission to edit the hotlist.
+ NoSuchHotlistException: The hotlist id was not found.
+ InputException: The issue is not part of the hotlist.
+ """
+ # Make sure the hotlist exists and we have permission to see and edit it.
+ hotlist = self.GetHotlist(hotlist_id)
+ self._AssertUserCanEditHotlist(hotlist)
+
+ # Make sure the issue exists and we have permission to see it.
+ self.GetIssue(issue_id)
+
+ # Make sure the issue belongs to the hotlist.
+ if not any(item.issue_id == issue_id for item in hotlist.items):
+ raise exceptions.InputException('The issue is not part of the hotlist.')
+
+ with self.mc.profiler.Phase(
+ 'Editing note for issue %s in hotlist %s' % (issue_id, hotlist_id)):
+ new_notes = {issue_id: note}
+ self.services.features.UpdateHotlistItemsFields(
+ self.mc.cnxn, hotlist_id, new_notes=new_notes)
+
+ def expungeUsersFromStars(self, user_ids):
+ """Wipes any starred user or user's stars from all star services.
+
+ This method will not commit the operation. This method will not
+ make changes to in-memory data.
+ """
+
+ self.services.project_star.ExpungeStarsByUsers(self.mc.cnxn, user_ids)
+ self.services.issue_star.ExpungeStarsByUsers(self.mc.cnxn, user_ids)
+ self.services.hotlist_star.ExpungeStarsByUsers(self.mc.cnxn, user_ids)
+ self.services.user_star.ExpungeStarsByUsers(self.mc.cnxn, user_ids)
+ for user_id in user_ids:
+ self.services.user_star.ExpungeStars(self.mc.cnxn, user_id, commit=False)
+
+ # Permissions
+
+ # ListFooPermission methods will return the list of permissions in addition to
+ # the permission to "VIEW",
+ # that the logged in user has for a given resource_id's resource Foo.
+ # If the user cannot view Foo, PermissionException will be raised.
+ # Not all resources will have predefined lists of permissions
+ # (e.g permissions.HOTLIST_OWNER_PERMISSIONS)
+ # For most cases, the list of permissions will be created within the
+ # ListFooPermissions method.
+
+ def ListHotlistPermissions(self, hotlist_id):
+ # type: (int) -> List(str)
+ """Return the list of permissions the current user has for the hotlist."""
+ # Permission to view checked in GetHotlist()
+ hotlist = self.GetHotlist(hotlist_id)
+ if permissions.CanAdministerHotlist(self.mc.auth.effective_ids,
+ self.mc.perms, hotlist):
+ return permissions.HOTLIST_OWNER_PERMISSIONS
+ if permissions.CanEditHotlist(self.mc.auth.effective_ids, self.mc.perms,
+ hotlist):
+ return permissions.HOTLIST_EDITOR_PERMISSIONS
+ return []
+
+ def ListFieldDefPermissions(self, field_id, project_id):
+ # type:(int, int) -> List[str]
+ """Return the list of permissions the current user has for the fieldDef."""
+ project = self.GetProject(project_id)
+ # TODO(crbug/monorail/7614): The line below was added temporarily while this
+ # bug is fixed.
+ self.mc.LookupLoggedInUserPerms(project)
+ field = self.GetFieldDef(field_id, project)
+ if permissions.CanEditFieldDef(self.mc.auth.effective_ids, self.mc.perms,
+ project, field):
+ return [permissions.EDIT_FIELD_DEF, permissions.EDIT_FIELD_DEF_VALUE]
+ if permissions.CanEditValueForFieldDef(self.mc.auth.effective_ids,
+ self.mc.perms, project, field):
+ return [permissions.EDIT_FIELD_DEF_VALUE]
+ return []