blob: d15d67f9772950755141dbf96027d2f33539cdbf [file] [log] [blame]
# 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 GetComponentDef(self, project_id, component_id):
# type: (int, int) -> ComponentDef
"""Returns component def for component id that belongs to the project."""
if component_id < 0:
raise exceptions.InputException(
'Invalid `component_id`: %d' % component_id)
config = self.GetProjectConfig(project_id)
return tracker_bizobj.FindComponentDefByID(component_id, config)
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,
attachment_uploads=None):
# 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.
attachment_uploads: List of AttachmentUpload tuples to be attached to the
new issue.
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,
attachments=attachment_uploads,
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 []