Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/services/api_svc_v1.py b/services/api_svc_v1.py
new file mode 100644
index 0000000..20a9c8b
--- /dev/null
+++ b/services/api_svc_v1.py
@@ -0,0 +1,1511 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""API service.
+
+To manually test this API locally, use the following steps:
+1. Start the development server via 'make serve'.
+2. Start a new Chrome session via the command-line:
+ PATH_TO_CHROME --user-data-dir=/tmp/test \
+ --unsafely-treat-insecure-origin-as-secure=http://localhost:8080
+3. Visit http://localhost:8080/_ah/api/explorer
+4. Click shield icon in the omnibar and allow unsafe scripts.
+5. Click on the "Services" menu item in the API Explorer.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import calendar
+import datetime
+import endpoints
+import functools
+import logging
+import re
+import time
+from google.appengine.api import oauth
+from protorpc import message_types
+from protorpc import protojson
+from protorpc import remote
+
+import settings
+from businesslogic import work_env
+from features import filterrules_helpers
+from features import send_notifications
+from framework import authdata
+from framework import exceptions
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import monitoring
+from framework import monorailrequest
+from framework import permissions
+from framework import ratelimiter
+from framework import sql
+from project import project_helpers
+from proto import api_pb2_v1
+from proto import project_pb2
+from proto import tracker_pb2
+from search import frontendsearchpipeline
+from services import api_pb2_v1_helpers
+from services import client_config_svc
+from services import service_manager
+from services import tracker_fulltext
+from sitewide import sitewide_helpers
+from tracker import field_helpers
+from tracker import issuedetailezt
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+
+from infra_libs import ts_mon
+
+
+ENDPOINTS_API_NAME = 'monorail'
+DOC_URL = (
+ 'https://chromium.googlesource.com/infra/infra/+/main/'
+ 'appengine/monorail/doc/api.md')
+
+
+def monorail_api_method(
+ request_message, response_message, **kwargs):
+ """Extends endpoints.method by performing base checks."""
+ time_fn = kwargs.pop('time_fn', time.time)
+ method_name = kwargs.get('name', '')
+ method_path = kwargs.get('path', '')
+ http_method = kwargs.get('http_method', '')
+ def new_decorator(func):
+ @endpoints.method(request_message, response_message, **kwargs)
+ @functools.wraps(func)
+ def wrapper(self, *args, **kwargs):
+ start_time = time_fn()
+ approximate_http_status = 200
+ request = args[0]
+ ret = None
+ c_id = None
+ c_email = None
+ mar = None
+ try:
+ if settings.read_only and http_method.lower() != 'get':
+ raise permissions.PermissionException(
+ 'This request is not allowed in read-only mode')
+ requester = endpoints.get_current_user()
+ logging.info('requester is %r', requester)
+ logging.info('args is %r', args)
+ logging.info('kwargs is %r', kwargs)
+ auth_client_ids, auth_emails = (
+ client_config_svc.GetClientConfigSvc().GetClientIDEmails())
+ if settings.local_mode:
+ auth_client_ids.append(endpoints.API_EXPLORER_CLIENT_ID)
+ if self._services is None:
+ self._set_services(service_manager.set_up_services())
+ cnxn = sql.MonorailConnection()
+ c_id, c_email = api_base_checks(
+ request, requester, self._services, cnxn,
+ auth_client_ids, auth_emails)
+ mar = self.mar_factory(request, cnxn)
+ self.ratelimiter.CheckStart(c_id, c_email, start_time)
+ monitoring.IncrementAPIRequestsCount(
+ 'endpoints', c_id, client_email=c_email)
+ ret = func(self, mar, *args, **kwargs)
+ except exceptions.NoSuchUserException as e:
+ approximate_http_status = 404
+ raise endpoints.NotFoundException(
+ 'The user does not exist: %s' % str(e))
+ except (exceptions.NoSuchProjectException,
+ exceptions.NoSuchIssueException,
+ exceptions.NoSuchComponentException) as e:
+ approximate_http_status = 404
+ raise endpoints.NotFoundException(str(e))
+ except (permissions.BannedUserException,
+ permissions.PermissionException) as e:
+ approximate_http_status = 403
+ logging.info('Allowlist ID %r email %r', auth_client_ids, auth_emails)
+ raise endpoints.ForbiddenException(str(e))
+ except endpoints.BadRequestException:
+ approximate_http_status = 400
+ raise
+ except endpoints.UnauthorizedException:
+ approximate_http_status = 401
+ # Client will refresh token and retry.
+ raise
+ except oauth.InvalidOAuthTokenError:
+ approximate_http_status = 401
+ # Client will refresh token and retry.
+ raise endpoints.UnauthorizedException(
+ 'Auth error: InvalidOAuthTokenError')
+ except (exceptions.GroupExistsException,
+ exceptions.InvalidComponentNameException,
+ ratelimiter.ApiRateLimitExceeded) as e:
+ approximate_http_status = 400
+ raise endpoints.BadRequestException(str(e))
+ except Exception as e:
+ approximate_http_status = 500
+ logging.exception('Unexpected error in monorail API')
+ raise
+ finally:
+ if mar:
+ mar.CleanUp()
+ now = time_fn()
+ if c_id and c_email:
+ self.ratelimiter.CheckEnd(c_id, c_email, now, start_time)
+ _RecordMonitoringStats(
+ start_time, request, ret, (method_name or func.__name__),
+ (method_path or func.__name__), approximate_http_status, now)
+
+ return ret
+
+ return wrapper
+ return new_decorator
+
+
+def _RecordMonitoringStats(
+ start_time,
+ request,
+ response,
+ method_name,
+ method_path,
+ approximate_http_status,
+ now=None):
+ now = now or time.time()
+ elapsed_ms = int((now - start_time) * 1000)
+ # Use the api name, not the request path, to prevent an explosion in
+ # possible field values.
+ method_identifier = (
+ ENDPOINTS_API_NAME + '.endpoints.' + method_name + '/' + method_path)
+
+ # Endpoints APIs don't return the full set of http status values.
+ fields = monitoring.GetCommonFields(
+ approximate_http_status, method_identifier)
+
+ monitoring.AddServerDurations(elapsed_ms, fields)
+ monitoring.IncrementServerResponseStatusCount(fields)
+ request_length = len(protojson.encode_message(request))
+ monitoring.AddServerRequesteBytes(request_length, fields)
+ response_length = 0
+ if response:
+ response_length = len(protojson.encode_message(response))
+ monitoring.AddServerResponseBytes(response_length, fields)
+
+
+def _is_requester_in_allowed_domains(requester):
+ if requester.email().endswith(settings.api_allowed_email_domains):
+ if framework_constants.MONORAIL_SCOPE in oauth.get_authorized_scopes(
+ framework_constants.MONORAIL_SCOPE):
+ return True
+ else:
+ logging.info("User is not authenticated with monorail scope")
+ return False
+
+def api_base_checks(request, requester, services, cnxn,
+ auth_client_ids, auth_emails):
+ """Base checks for API users.
+
+ Args:
+ request: The HTTP request from Cloud Endpoints.
+ requester: The user who sends the request.
+ services: Services object.
+ cnxn: connection to the SQL database.
+ auth_client_ids: authorized client ids.
+ auth_emails: authorized emails when client is anonymous.
+
+ Returns:
+ Client ID and client email.
+
+ Raises:
+ endpoints.UnauthorizedException: If the requester is anonymous.
+ exceptions.NoSuchUserException: If the requester does not exist in Monorail.
+ NoSuchProjectException: If the project does not exist in Monorail.
+ permissions.BannedUserException: If the requester is banned.
+ permissions.PermissionException: If the requester does not have
+ permisssion to view.
+ """
+ valid_user = False
+ auth_err = ''
+ client_id = None
+
+ try:
+ client_id = oauth.get_client_id(framework_constants.OAUTH_SCOPE)
+ logging.info('Oauth client ID %s', client_id)
+ except oauth.Error as ex:
+ auth_err = 'oauth.Error: %s' % ex
+
+ if not requester:
+ try:
+ requester = oauth.get_current_user(framework_constants.OAUTH_SCOPE)
+ logging.info('Oauth requester %s', requester.email())
+ except oauth.Error as ex:
+ logging.info('Got oauth error: %r', ex)
+ auth_err = 'oauth.Error: %s' % ex
+
+ if client_id and requester:
+ if client_id in auth_client_ids:
+ # A allowlisted client app can make requests for any user or anon.
+ logging.info('Client ID %r is allowlisted', client_id)
+ valid_user = True
+ elif requester.email() in auth_emails:
+ # A allowlisted user account can make requests via any client app.
+ logging.info('Client email %r is allowlisted', requester.email())
+ valid_user = True
+ elif _is_requester_in_allowed_domains(requester):
+ # A user with an allowed-domain email and authenticated with the
+ # monorail scope is allowed to make requests via any client app.
+ logging.info(
+ 'User email %r is within the allowed domains', requester.email())
+ valid_user = True
+ else:
+ auth_err = (
+ 'Neither client ID %r nor email %r is allowlisted' %
+ (client_id, requester.email()))
+
+ if not valid_user:
+ raise endpoints.UnauthorizedException('Auth error: %s' % auth_err)
+ else:
+ logging.info('API request from user %s:%s', client_id, requester.email())
+
+ project_name = None
+ if hasattr(request, 'projectId'):
+ project_name = request.projectId
+ issue_local_id = None
+ if hasattr(request, 'issueId'):
+ issue_local_id = request.issueId
+ # This could raise exceptions.NoSuchUserException
+ requester_id = services.user.LookupUserID(cnxn, requester.email())
+ auth = authdata.AuthData.FromUserID(cnxn, requester_id, services)
+ if permissions.IsBanned(auth.user_pb, auth.user_view):
+ raise permissions.BannedUserException(
+ 'The user %s has been banned from using Monorail' %
+ requester.email())
+ if project_name:
+ project = services.project.GetProjectByName(
+ cnxn, project_name)
+ if not project:
+ raise exceptions.NoSuchProjectException(
+ 'Project %s does not exist' % project_name)
+ if project.state != project_pb2.ProjectState.LIVE:
+ raise permissions.PermissionException(
+ 'API may not access project %s because it is not live'
+ % project_name)
+ if not permissions.UserCanViewProject(
+ auth.user_pb, auth.effective_ids, project):
+ raise permissions.PermissionException(
+ 'The user %s has no permission for project %s' %
+ (requester.email(), project_name))
+ if issue_local_id:
+ # This may raise a NoSuchIssueException.
+ issue = services.issue.GetIssueByLocalID(
+ cnxn, project.project_id, issue_local_id)
+ perms = permissions.GetPermissions(
+ auth.user_pb, auth.effective_ids, project)
+ config = services.config.GetProjectConfig(cnxn, project.project_id)
+ granted_perms = tracker_bizobj.GetGrantedPerms(
+ issue, auth.effective_ids, config)
+ if not permissions.CanViewIssue(
+ auth.effective_ids, perms, project, issue,
+ granted_perms=granted_perms):
+ raise permissions.PermissionException(
+ 'User is not allowed to view this issue %s:%d' %
+ (project_name, issue_local_id))
+
+ return client_id, requester.email()
+
+
+@endpoints.api(name=ENDPOINTS_API_NAME, version='v1',
+ description='Monorail API to manage issues.',
+ auth_level=endpoints.AUTH_LEVEL.NONE,
+ allowed_client_ids=endpoints.SKIP_CLIENT_ID_CHECK,
+ documentation=DOC_URL)
+class MonorailApi(remote.Service):
+
+ # Class variables. Handy to mock.
+ _services = None
+ _mar = None
+
+ ratelimiter = ratelimiter.ApiRateLimiter()
+
+ @classmethod
+ def _set_services(cls, services):
+ cls._services = services
+
+ def mar_factory(self, request, cnxn):
+ if not self._mar:
+ self._mar = monorailrequest.MonorailApiRequest(
+ request, self._services, cnxn=cnxn)
+ return self._mar
+
+ def aux_delete_comment(self, mar, request, delete=True):
+ action_name = 'delete' if delete else 'undelete'
+
+ with work_env.WorkEnv(mar, self._services) as we:
+ issue = we.GetIssueByLocalID(
+ mar.project_id, request.issueId, use_cache=False)
+ all_comments = we.ListIssueComments(issue)
+ try:
+ issue_comment = all_comments[request.commentId]
+ except IndexError:
+ raise exceptions.NoSuchIssueException(
+ 'The issue %s:%d does not have comment %d.' %
+ (mar.project_name, request.issueId, request.commentId))
+
+ issue_perms = permissions.UpdateIssuePermissions(
+ mar.perms, mar.project, issue, mar.auth.effective_ids,
+ granted_perms=mar.granted_perms)
+ commenter = we.GetUser(issue_comment.user_id)
+
+ if not permissions.CanDeleteComment(
+ issue_comment, commenter, mar.auth.user_id, issue_perms):
+ raise permissions.PermissionException(
+ 'User is not allowed to %s the comment %d of issue %s:%d' %
+ (action_name, request.commentId, mar.project_name,
+ request.issueId))
+
+ we.DeleteComment(issue, issue_comment, delete=delete)
+ return api_pb2_v1.IssuesCommentsDeleteResponse()
+
+ @monorail_api_method(
+ api_pb2_v1.ISSUES_COMMENTS_DELETE_REQUEST_RESOURCE_CONTAINER,
+ api_pb2_v1.IssuesCommentsDeleteResponse,
+ path='projects/{projectId}/issues/{issueId}/comments/{commentId}',
+ http_method='DELETE',
+ name='issues.comments.delete')
+ def issues_comments_delete(self, mar, request):
+ """Delete a comment."""
+ return self.aux_delete_comment(mar, request, True)
+
+ def parse_imported_reporter(self, mar, request):
+ """Handle the case where an API client is importing issues for users.
+
+ Args:
+ mar: monorail API request object including auth and perms.
+ request: A request PB that defines author and published fields.
+
+ Returns:
+ A pair (reporter_id, timestamp) with the user ID of the user to
+ attribute the comment to and timestamp of the original comment.
+ If the author field is not set, this is not an import request
+ and the comment is attributed to the API client as per normal.
+ An API client that is attempting to post on behalf of other
+ users must have the ImportComment permission in the current
+ project.
+ """
+ reporter_id = mar.auth.user_id
+ timestamp = None
+ if (request.author and request.author.name and
+ request.author.name != mar.auth.email):
+ if not mar.perms.HasPerm(
+ permissions.IMPORT_COMMENT, mar.auth.user_id, mar.project):
+ logging.info('name is %r', request.author.name)
+ raise permissions.PermissionException(
+ 'User is not allowed to attribue comments to others')
+ reporter_id = self._services.user.LookupUserID(
+ mar.cnxn, request.author.name, autocreate=True)
+ logging.info('Importing issue or comment.')
+ if request.published:
+ timestamp = calendar.timegm(request.published.utctimetuple())
+
+ return reporter_id, timestamp
+
+ @monorail_api_method(
+ api_pb2_v1.ISSUES_COMMENTS_INSERT_REQUEST_RESOURCE_CONTAINER,
+ api_pb2_v1.IssuesCommentsInsertResponse,
+ path='projects/{projectId}/issues/{issueId}/comments',
+ http_method='POST',
+ name='issues.comments.insert')
+ def issues_comments_insert(self, mar, request):
+ # type (...) -> proto.api_pb2_v1.IssuesCommentsInsertResponse
+ """Add a comment."""
+ # Because we will modify issues, load from DB rather than cache.
+ issue = self._services.issue.GetIssueByLocalID(
+ mar.cnxn, mar.project_id, request.issueId, use_cache=False)
+ old_owner_id = tracker_bizobj.GetOwnerId(issue)
+ if not permissions.CanCommentIssue(
+ mar.auth.effective_ids, mar.perms, mar.project, issue,
+ mar.granted_perms):
+ raise permissions.PermissionException(
+ 'User is not allowed to comment this issue (%s, %d)' %
+ (request.projectId, request.issueId))
+
+ # Temporary block on updating approval subfields.
+ if request.updates and request.updates.fieldValues:
+ fds_by_name = {fd.field_name.lower():fd for fd in mar.config.field_defs}
+ for fv in request.updates.fieldValues:
+ # Checking for fv.approvalName is unreliable since it can be removed.
+ fd = fds_by_name.get(fv.fieldName.lower())
+ if fd and fd.approval_id:
+ raise exceptions.ActionNotSupported(
+ 'No API support for approval field changes: (approval %s owns %s)'
+ % (fd.approval_id, fd.field_name))
+ # if fd was None, that gets dealt with later.
+
+ if request.content and len(
+ request.content) > tracker_constants.MAX_COMMENT_CHARS:
+ raise endpoints.BadRequestException(
+ 'Comment is too long on this issue (%s, %d' %
+ (request.projectId, request.issueId))
+
+ updates_dict = {}
+ move_to_project = None
+ if request.updates:
+ if not permissions.CanEditIssue(
+ mar.auth.effective_ids, mar.perms, mar.project, issue,
+ mar.granted_perms):
+ raise permissions.PermissionException(
+ 'User is not allowed to edit this issue (%s, %d)' %
+ (request.projectId, request.issueId))
+ if request.updates.moveToProject:
+ move_to = request.updates.moveToProject.lower()
+ move_to_project = issuedetailezt.CheckMoveIssueRequest(
+ self._services, mar, issue, True, move_to, mar.errors)
+ if mar.errors.AnyErrors():
+ raise endpoints.BadRequestException(mar.errors.move_to)
+
+ updates_dict['summary'] = request.updates.summary
+ updates_dict['status'] = request.updates.status
+ updates_dict['is_description'] = request.updates.is_description
+ if request.updates.owner:
+ # A current issue owner can be removed via the API with a
+ # NO_USER_NAME('----') input.
+ if request.updates.owner == framework_constants.NO_USER_NAME:
+ updates_dict['owner'] = framework_constants.NO_USER_SPECIFIED
+ else:
+ new_owner_id = self._services.user.LookupUserID(
+ mar.cnxn, request.updates.owner)
+ valid, msg = tracker_helpers.IsValidIssueOwner(
+ mar.cnxn, mar.project, new_owner_id, self._services)
+ if not valid:
+ raise endpoints.BadRequestException(msg)
+ updates_dict['owner'] = new_owner_id
+ updates_dict['cc_add'], updates_dict['cc_remove'] = (
+ api_pb2_v1_helpers.split_remove_add(request.updates.cc))
+ updates_dict['cc_add'] = list(self._services.user.LookupUserIDs(
+ mar.cnxn, updates_dict['cc_add'], autocreate=True).values())
+ updates_dict['cc_remove'] = list(self._services.user.LookupUserIDs(
+ mar.cnxn, updates_dict['cc_remove']).values())
+ updates_dict['labels_add'], updates_dict['labels_remove'] = (
+ api_pb2_v1_helpers.split_remove_add(request.updates.labels))
+ blocked_on_add_strs, blocked_on_remove_strs = (
+ api_pb2_v1_helpers.split_remove_add(request.updates.blockedOn))
+ updates_dict['blocked_on_add'] = api_pb2_v1_helpers.issue_global_ids(
+ blocked_on_add_strs, issue.project_id, mar,
+ self._services)
+ updates_dict['blocked_on_remove'] = api_pb2_v1_helpers.issue_global_ids(
+ blocked_on_remove_strs, issue.project_id, mar,
+ self._services)
+ blocking_add_strs, blocking_remove_strs = (
+ api_pb2_v1_helpers.split_remove_add(request.updates.blocking))
+ updates_dict['blocking_add'] = api_pb2_v1_helpers.issue_global_ids(
+ blocking_add_strs, issue.project_id, mar,
+ self._services)
+ updates_dict['blocking_remove'] = api_pb2_v1_helpers.issue_global_ids(
+ blocking_remove_strs, issue.project_id, mar,
+ self._services)
+ components_add_strs, components_remove_strs = (
+ api_pb2_v1_helpers.split_remove_add(request.updates.components))
+ updates_dict['components_add'] = (
+ api_pb2_v1_helpers.convert_component_ids(
+ mar.config, components_add_strs))
+ updates_dict['components_remove'] = (
+ api_pb2_v1_helpers.convert_component_ids(
+ mar.config, components_remove_strs))
+ if request.updates.mergedInto:
+ merge_project_name, merge_local_id = tracker_bizobj.ParseIssueRef(
+ request.updates.mergedInto)
+ merge_into_project = self._services.project.GetProjectByName(
+ mar.cnxn, merge_project_name or issue.project_name)
+ # Because we will modify issues, load from DB rather than cache.
+ merge_into_issue = self._services.issue.GetIssueByLocalID(
+ mar.cnxn, merge_into_project.project_id, merge_local_id,
+ use_cache=False)
+ merge_allowed = tracker_helpers.IsMergeAllowed(
+ merge_into_issue, mar, self._services)
+ if not merge_allowed:
+ raise permissions.PermissionException(
+ 'User is not allowed to merge into issue %s:%s' %
+ (merge_into_issue.project_name, merge_into_issue.local_id))
+ updates_dict['merged_into'] = merge_into_issue.issue_id
+ (updates_dict['field_vals_add'], updates_dict['field_vals_remove'],
+ updates_dict['fields_clear'], updates_dict['fields_labels_add'],
+ updates_dict['fields_labels_remove']) = (
+ api_pb2_v1_helpers.convert_field_values(
+ request.updates.fieldValues, mar, self._services))
+
+ field_helpers.ValidateCustomFields(
+ mar.cnxn, self._services,
+ (updates_dict.get('field_vals_add', []) +
+ updates_dict.get('field_vals_remove', [])),
+ mar.config, mar.project, ezt_errors=mar.errors)
+ if mar.errors.AnyErrors():
+ raise endpoints.BadRequestException(
+ 'Invalid field values: %s' % mar.errors.custom_fields)
+
+ updates_dict['labels_add'] = (
+ updates_dict.get('labels_add', []) +
+ updates_dict.get('fields_labels_add', []))
+ updates_dict['labels_remove'] = (
+ updates_dict.get('labels_remove', []) +
+ updates_dict.get('fields_labels_remove', []))
+
+ # TODO(jrobbins): Stop using updates_dict in the first place.
+ delta = tracker_bizobj.MakeIssueDelta(
+ updates_dict.get('status'),
+ updates_dict.get('owner'),
+ updates_dict.get('cc_add', []),
+ updates_dict.get('cc_remove', []),
+ updates_dict.get('components_add', []),
+ updates_dict.get('components_remove', []),
+ (updates_dict.get('labels_add', []) +
+ updates_dict.get('fields_labels_add', [])),
+ (updates_dict.get('labels_remove', []) +
+ updates_dict.get('fields_labels_remove', [])),
+ updates_dict.get('field_vals_add', []),
+ updates_dict.get('field_vals_remove', []),
+ updates_dict.get('fields_clear', []),
+ updates_dict.get('blocked_on_add', []),
+ updates_dict.get('blocked_on_remove', []),
+ updates_dict.get('blocking_add', []),
+ updates_dict.get('blocking_remove', []),
+ updates_dict.get('merged_into'),
+ updates_dict.get('summary'))
+
+ importer_id = None
+ reporter_id, timestamp = self.parse_imported_reporter(mar, request)
+ if reporter_id != mar.auth.user_id:
+ importer_id = mar.auth.user_id
+
+ # TODO(jrobbins): Finish refactoring to make everything go through work_env.
+ _, comment = self._services.issue.DeltaUpdateIssue(
+ cnxn=mar.cnxn, services=self._services,
+ reporter_id=reporter_id, project_id=mar.project_id, config=mar.config,
+ issue=issue, delta=delta, index_now=False, comment=request.content,
+ is_description=updates_dict.get('is_description'),
+ timestamp=timestamp, importer_id=importer_id)
+
+ move_comment = None
+ if move_to_project:
+ old_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
+ tracker_fulltext.UnindexIssues([issue.issue_id])
+ moved_back_iids = self._services.issue.MoveIssues(
+ mar.cnxn, move_to_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)
+ move_comment = self._services.issue.CreateIssueComment(
+ mar.cnxn, issue, mar.auth.user_id, content, amendments=[
+ tracker_bizobj.MakeProjectAmendment(move_to_project.project_name)])
+
+ if 'merged_into' in updates_dict:
+ new_starrers = tracker_helpers.GetNewIssueStarrers(
+ mar.cnxn, self._services, [issue.issue_id], merge_into_issue.issue_id)
+ tracker_helpers.AddIssueStarrers(
+ mar.cnxn, self._services, mar,
+ merge_into_issue.issue_id, merge_into_project, new_starrers)
+ # Load target issue again to get the updated star count.
+ merge_into_issue = self._services.issue.GetIssue(
+ mar.cnxn, merge_into_issue.issue_id, use_cache=False)
+ merge_comment_pb = tracker_helpers.MergeCCsAndAddComment(
+ self._services, mar, issue, merge_into_issue)
+ hostport = framework_helpers.GetHostPort(
+ project_name=merge_into_issue.project_name)
+ send_notifications.PrepareAndSendIssueChangeNotification(
+ merge_into_issue.issue_id, hostport,
+ mar.auth.user_id, send_email=True, comment_id=merge_comment_pb.id)
+
+ tracker_fulltext.IndexIssues(
+ mar.cnxn, [issue], self._services.user, self._services.issue,
+ self._services.config)
+
+ comment = comment or move_comment
+ if comment is None:
+ return api_pb2_v1.IssuesCommentsInsertResponse()
+
+ cmnts = self._services.issue.GetCommentsForIssue(mar.cnxn, issue.issue_id)
+ seq = len(cmnts) - 1
+
+ if request.sendEmail:
+ hostport = framework_helpers.GetHostPort(project_name=issue.project_name)
+ send_notifications.PrepareAndSendIssueChangeNotification(
+ issue.issue_id, hostport, comment.user_id, send_email=True,
+ old_owner_id=old_owner_id, comment_id=comment.id)
+
+ issue_perms = permissions.UpdateIssuePermissions(
+ mar.perms, mar.project, issue, mar.auth.effective_ids,
+ granted_perms=mar.granted_perms)
+ commenter = self._services.user.GetUser(mar.cnxn, comment.user_id)
+ can_delete = permissions.CanDeleteComment(
+ comment, commenter, mar.auth.user_id, issue_perms)
+ return api_pb2_v1.IssuesCommentsInsertResponse(
+ id=seq,
+ kind='monorail#issueComment',
+ author=api_pb2_v1_helpers.convert_person(
+ comment.user_id, mar.cnxn, self._services),
+ content=comment.content,
+ published=datetime.datetime.fromtimestamp(comment.timestamp),
+ updates=api_pb2_v1_helpers.convert_amendments(
+ issue, comment.amendments, mar, self._services),
+ canDelete=can_delete)
+
+ @monorail_api_method(
+ api_pb2_v1.ISSUES_COMMENTS_LIST_REQUEST_RESOURCE_CONTAINER,
+ api_pb2_v1.IssuesCommentsListResponse,
+ path='projects/{projectId}/issues/{issueId}/comments',
+ http_method='GET',
+ name='issues.comments.list')
+ def issues_comments_list(self, mar, request):
+ """List all comments for an issue."""
+ issue = self._services.issue.GetIssueByLocalID(
+ mar.cnxn, mar.project_id, request.issueId)
+ comments = self._services.issue.GetCommentsForIssue(
+ mar.cnxn, issue.issue_id)
+ comments = [comment for comment in comments if not comment.approval_id]
+ visible_comments = []
+ for comment in comments[
+ request.startIndex:(request.startIndex + request.maxResults)]:
+ visible_comments.append(
+ api_pb2_v1_helpers.convert_comment(
+ issue, comment, mar, self._services, mar.granted_perms))
+
+ return api_pb2_v1.IssuesCommentsListResponse(
+ kind='monorail#issueCommentList',
+ totalResults=len(comments),
+ items=visible_comments)
+
+ @monorail_api_method(
+ api_pb2_v1.ISSUES_COMMENTS_DELETE_REQUEST_RESOURCE_CONTAINER,
+ api_pb2_v1.IssuesCommentsDeleteResponse,
+ path='projects/{projectId}/issues/{issueId}/comments/{commentId}',
+ http_method='POST',
+ name='issues.comments.undelete')
+ def issues_comments_undelete(self, mar, request):
+ """Restore a deleted comment."""
+ return self.aux_delete_comment(mar, request, False)
+
+ @monorail_api_method(
+ api_pb2_v1.APPROVALS_COMMENTS_LIST_REQUEST_RESOURCE_CONTAINER,
+ api_pb2_v1.ApprovalsCommentsListResponse,
+ path='projects/{projectId}/issues/{issueId}/'
+ 'approvals/{approvalName}/comments',
+ http_method='GET',
+ name='approvals.comments.list')
+ def approvals_comments_list(self, mar, request):
+ """List all comments for an issue approval."""
+ issue = self._services.issue.GetIssueByLocalID(
+ mar.cnxn, mar.project_id, request.issueId)
+ if not permissions.CanViewIssue(
+ mar.auth.effective_ids, mar.perms, mar.project, issue,
+ mar.granted_perms):
+ raise permissions.PermissionException(
+ 'User is not allowed to view this issue (%s, %d)' %
+ (request.projectId, request.issueId))
+ config = self._services.config.GetProjectConfig(mar.cnxn, issue.project_id)
+ approval_fd = tracker_bizobj.FindFieldDef(request.approvalName, config)
+ if not approval_fd:
+ raise endpoints.BadRequestException(
+ 'Field definition for %s not found in project config' %
+ request.approvalName)
+ comments = self._services.issue.GetCommentsForIssue(
+ mar.cnxn, issue.issue_id)
+ comments = [comment for comment in comments
+ if comment.approval_id == approval_fd.field_id]
+ visible_comments = []
+ for comment in comments[
+ request.startIndex:(request.startIndex + request.maxResults)]:
+ visible_comments.append(
+ api_pb2_v1_helpers.convert_approval_comment(
+ issue, comment, mar, self._services, mar.granted_perms))
+
+ return api_pb2_v1.ApprovalsCommentsListResponse(
+ kind='monorail#approvalCommentList',
+ totalResults=len(comments),
+ items=visible_comments)
+
+ @monorail_api_method(
+ api_pb2_v1.APPROVALS_COMMENTS_INSERT_REQUEST_RESOURCE_CONTAINER,
+ api_pb2_v1.ApprovalsCommentsInsertResponse,
+ path=("projects/{projectId}/issues/{issueId}/"
+ "approvals/{approvalName}/comments"),
+ http_method='POST',
+ name='approvals.comments.insert')
+ def approvals_comments_insert(self, mar, request):
+ # type (...) -> proto.api_pb2_v1.ApprovalsCommentsInsertResponse
+ """Add an approval comment."""
+ approval_fd = tracker_bizobj.FindFieldDef(
+ request.approvalName, mar.config)
+ if not approval_fd or (
+ approval_fd.field_type != tracker_pb2.FieldTypes.APPROVAL_TYPE):
+ raise endpoints.BadRequestException(
+ 'Field definition for %s not found in project config' %
+ request.approvalName)
+ try:
+ issue = self._services.issue.GetIssueByLocalID(
+ mar.cnxn, mar.project_id, request.issueId)
+ except exceptions.NoSuchIssueException:
+ raise endpoints.BadRequestException(
+ 'Issue %s:%s not found' % (request.projectId, request.issueId))
+ approval = tracker_bizobj.FindApprovalValueByID(
+ approval_fd.field_id, issue.approval_values)
+ if not approval:
+ raise endpoints.BadRequestException(
+ 'Approval %s not found in issue.' % request.approvalName)
+
+ if not permissions.CanCommentIssue(
+ mar.auth.effective_ids, mar.perms, mar.project, issue,
+ mar.granted_perms):
+ raise permissions.PermissionException(
+ 'User is not allowed to comment on this issue (%s, %d)' %
+ (request.projectId, request.issueId))
+
+ if request.content and len(
+ request.content) > tracker_constants.MAX_COMMENT_CHARS:
+ raise endpoints.BadRequestException(
+ 'Comment is too long on this issue (%s, %d' %
+ (request.projectId, request.issueId))
+
+ updates_dict = {}
+ if request.approvalUpdates:
+ if request.approvalUpdates.fieldValues:
+ # Block updating field values that don't belong to the approval.
+ approvals_fds_by_name = {
+ fd.field_name.lower():fd for fd in mar.config.field_defs
+ if fd.approval_id == approval_fd.field_id}
+ for fv in request.approvalUpdates.fieldValues:
+ if approvals_fds_by_name.get(fv.fieldName.lower()) is None:
+ raise endpoints.BadRequestException(
+ 'Field defition for %s not found in %s subfields.' %
+ (fv.fieldName, request.approvalName))
+ (updates_dict['field_vals_add'], updates_dict['field_vals_remove'],
+ updates_dict['fields_clear'], updates_dict['fields_labels_add'],
+ updates_dict['fields_labels_remove']) = (
+ api_pb2_v1_helpers.convert_field_values(
+ request.approvalUpdates.fieldValues, mar, self._services))
+ if request.approvalUpdates.approvers:
+ if not permissions.CanUpdateApprovers(
+ mar.auth.effective_ids, mar.perms, mar.project,
+ approval.approver_ids):
+ raise permissions.PermissionException(
+ 'User is not allowed to update approvers')
+ approvers_add, approvers_remove = api_pb2_v1_helpers.split_remove_add(
+ request.approvalUpdates.approvers)
+ updates_dict['approver_ids_add'] = list(
+ self._services.user.LookupUserIDs(mar.cnxn, approvers_add,
+ autocreate=True).values())
+ updates_dict['approver_ids_remove'] = list(
+ self._services.user.LookupUserIDs(mar.cnxn, approvers_remove,
+ autocreate=True).values())
+ if request.approvalUpdates.status:
+ status = tracker_pb2.ApprovalStatus(
+ api_pb2_v1.ApprovalStatus(request.approvalUpdates.status).number)
+ if not permissions.CanUpdateApprovalStatus(
+ mar.auth.effective_ids, mar.perms, mar.project,
+ approval.approver_ids, status):
+ raise permissions.PermissionException(
+ 'User is not allowed to make this status change')
+ updates_dict['status'] = status
+ logging.info(time.time)
+ approval_delta = tracker_bizobj.MakeApprovalDelta(
+ updates_dict.get('status'), mar.auth.user_id,
+ updates_dict.get('approver_ids_add', []),
+ updates_dict.get('approver_ids_remove', []),
+ updates_dict.get('field_vals_add', []),
+ updates_dict.get('field_vals_remove', []),
+ updates_dict.get('fields_clear', []),
+ updates_dict.get('fields_labels_add', []),
+ updates_dict.get('fields_labels_remove', []))
+ comment = self._services.issue.DeltaUpdateIssueApproval(
+ mar.cnxn, mar.auth.user_id, mar.config, issue, approval, approval_delta,
+ comment_content=request.content,
+ is_description=request.is_description)
+
+ cmnts = self._services.issue.GetCommentsForIssue(mar.cnxn, issue.issue_id)
+ seq = len(cmnts) - 1
+
+ if request.sendEmail:
+ hostport = framework_helpers.GetHostPort(project_name=issue.project_name)
+ send_notifications.PrepareAndSendApprovalChangeNotification(
+ issue.issue_id, approval.approval_id,
+ hostport, comment.id, send_email=True)
+
+ issue_perms = permissions.UpdateIssuePermissions(
+ mar.perms, mar.project, issue, mar.auth.effective_ids,
+ granted_perms=mar.granted_perms)
+ commenter = self._services.user.GetUser(mar.cnxn, comment.user_id)
+ can_delete = permissions.CanDeleteComment(
+ comment, commenter, mar.auth.user_id, issue_perms)
+ return api_pb2_v1.ApprovalsCommentsInsertResponse(
+ id=seq,
+ kind='monorail#approvalComment',
+ author=api_pb2_v1_helpers.convert_person(
+ comment.user_id, mar.cnxn, self._services),
+ content=comment.content,
+ published=datetime.datetime.fromtimestamp(comment.timestamp),
+ approvalUpdates=api_pb2_v1_helpers.convert_approval_amendments(
+ comment.amendments, mar, self._services),
+ canDelete=can_delete)
+
+ @monorail_api_method(
+ api_pb2_v1.USERS_GET_REQUEST_RESOURCE_CONTAINER,
+ api_pb2_v1.UsersGetResponse,
+ path='users/{userId}',
+ http_method='GET',
+ name='users.get')
+ def users_get(self, mar, request):
+ """Get a user."""
+ owner_project_only = request.ownerProjectsOnly
+ with work_env.WorkEnv(mar, self._services) as we:
+ (visible_ownership, visible_deleted, visible_membership,
+ visible_contrib) = we.GetUserProjects(
+ mar.viewed_user_auth.effective_ids)
+
+ project_list = []
+ for proj in (visible_ownership + visible_deleted):
+ config = self._services.config.GetProjectConfig(
+ mar.cnxn, proj.project_id)
+ templates = self._services.template.GetProjectTemplates(
+ mar.cnxn, config.project_id)
+ proj_result = api_pb2_v1_helpers.convert_project(
+ proj, config, api_pb2_v1.Role.owner, templates)
+ project_list.append(proj_result)
+ if not owner_project_only:
+ for proj in visible_membership:
+ config = self._services.config.GetProjectConfig(
+ mar.cnxn, proj.project_id)
+ templates = self._services.template.GetProjectTemplates(
+ mar.cnxn, config.project_id)
+ proj_result = api_pb2_v1_helpers.convert_project(
+ proj, config, api_pb2_v1.Role.member, templates)
+ project_list.append(proj_result)
+ for proj in visible_contrib:
+ config = self._services.config.GetProjectConfig(
+ mar.cnxn, proj.project_id)
+ templates = self._services.template.GetProjectTemplates(
+ mar.cnxn, config.project_id)
+ proj_result = api_pb2_v1_helpers.convert_project(
+ proj, config, api_pb2_v1.Role.contributor, templates)
+ project_list.append(proj_result)
+
+ return api_pb2_v1.UsersGetResponse(
+ id=str(mar.viewed_user_auth.user_id),
+ kind='monorail#user',
+ projects=project_list,
+ )
+
+ @monorail_api_method(
+ api_pb2_v1.ISSUES_GET_REQUEST_RESOURCE_CONTAINER,
+ api_pb2_v1.IssuesGetInsertResponse,
+ path='projects/{projectId}/issues/{issueId}',
+ http_method='GET',
+ name='issues.get')
+ def issues_get(self, mar, request):
+ """Get an issue."""
+ issue = self._services.issue.GetIssueByLocalID(
+ mar.cnxn, mar.project_id, request.issueId)
+
+ return api_pb2_v1_helpers.convert_issue(
+ api_pb2_v1.IssuesGetInsertResponse, issue, mar, self._services)
+
+ @monorail_api_method(
+ api_pb2_v1.ISSUES_INSERT_REQUEST_RESOURCE_CONTAINER,
+ api_pb2_v1.IssuesGetInsertResponse,
+ path='projects/{projectId}/issues',
+ http_method='POST',
+ name='issues.insert')
+ def issues_insert(self, mar, request):
+ """Add a new issue."""
+ if not mar.perms.CanUsePerm(
+ permissions.CREATE_ISSUE, mar.auth.effective_ids, mar.project, []):
+ raise permissions.PermissionException(
+ 'The requester %s is not allowed to create issues for project %s.' %
+ (mar.auth.email, mar.project_name))
+
+ with work_env.WorkEnv(mar, self._services) as we:
+ owner_id = framework_constants.NO_USER_SPECIFIED
+ if request.owner and request.owner.name:
+ try:
+ owner_id = self._services.user.LookupUserID(
+ mar.cnxn, request.owner.name)
+ except exceptions.NoSuchUserException:
+ raise endpoints.BadRequestException(
+ 'The specified owner %s does not exist.' % request.owner.name)
+
+ cc_ids = []
+ request.cc = [cc for cc in request.cc if cc]
+ if request.cc:
+ cc_ids = list(self._services.user.LookupUserIDs(
+ mar.cnxn, [ap.name for ap in request.cc],
+ autocreate=True).values())
+ comp_ids = api_pb2_v1_helpers.convert_component_ids(
+ mar.config, request.components)
+ fields_add, _, _, fields_labels, _ = (
+ api_pb2_v1_helpers.convert_field_values(
+ request.fieldValues, mar, self._services))
+ field_helpers.ValidateCustomFields(
+ mar.cnxn, self._services, fields_add, mar.config, mar.project,
+ ezt_errors=mar.errors)
+ if mar.errors.AnyErrors():
+ raise endpoints.BadRequestException(
+ 'Invalid field values: %s' % mar.errors.custom_fields)
+
+ logging.info('request.author is %r', request.author)
+ reporter_id, timestamp = self.parse_imported_reporter(mar, request)
+ # To preserve previous behavior, do not raise filter rule errors.
+ try:
+ new_issue, _ = we.CreateIssue(
+ mar.project_id,
+ request.summary,
+ request.status,
+ owner_id,
+ cc_ids,
+ request.labels + fields_labels,
+ fields_add,
+ comp_ids,
+ request.description,
+ blocked_on=api_pb2_v1_helpers.convert_issueref_pbs(
+ request.blockedOn, mar, self._services),
+ blocking=api_pb2_v1_helpers.convert_issueref_pbs(
+ request.blocking, mar, self._services),
+ reporter_id=reporter_id,
+ timestamp=timestamp,
+ send_email=request.sendEmail,
+ raise_filter_errors=False)
+ we.StarIssue(new_issue, True)
+ except exceptions.InputException as e:
+ raise endpoints.BadRequestException(str(e))
+
+ return api_pb2_v1_helpers.convert_issue(
+ api_pb2_v1.IssuesGetInsertResponse, new_issue, mar, self._services)
+
+ @monorail_api_method(
+ api_pb2_v1.ISSUES_LIST_REQUEST_RESOURCE_CONTAINER,
+ api_pb2_v1.IssuesListResponse,
+ path='projects/{projectId}/issues',
+ http_method='GET',
+ name='issues.list')
+ def issues_list(self, mar, request):
+ """List issues for projects."""
+ if request.additionalProject:
+ for project_name in request.additionalProject:
+ project = self._services.project.GetProjectByName(
+ mar.cnxn, project_name)
+ if project and not permissions.UserCanViewProject(
+ mar.auth.user_pb, mar.auth.effective_ids, project):
+ raise permissions.PermissionException(
+ 'The user %s has no permission for project %s' %
+ (mar.auth.email, project_name))
+ # TODO(jrobbins): This should go through work_env.
+ pipeline = frontendsearchpipeline.FrontendSearchPipeline(
+ mar.cnxn,
+ self._services,
+ mar.auth, [mar.me_user_id],
+ mar.query,
+ mar.query_project_names,
+ mar.num,
+ mar.start,
+ mar.can,
+ mar.group_by_spec,
+ mar.sort_spec,
+ mar.warnings,
+ mar.errors,
+ mar.use_cached_searches,
+ mar.profiler,
+ project=mar.project)
+ if not mar.errors.AnyErrors():
+ pipeline.SearchForIIDs()
+ pipeline.MergeAndSortIssues()
+ pipeline.Paginate()
+ else:
+ raise endpoints.BadRequestException(mar.errors.query)
+
+ issue_list = [
+ api_pb2_v1_helpers.convert_issue(
+ api_pb2_v1.IssueWrapper, r, mar, self._services)
+ for r in pipeline.visible_results]
+ return api_pb2_v1.IssuesListResponse(
+ kind='monorail#issueList',
+ totalResults=pipeline.total_count,
+ items=issue_list)
+
+ @monorail_api_method(
+ api_pb2_v1.GROUPS_SETTINGS_LIST_REQUEST_RESOURCE_CONTAINER,
+ api_pb2_v1.GroupsSettingsListResponse,
+ path='groupsettings',
+ http_method='GET',
+ name='groups.settings.list')
+ def groups_settings_list(self, mar, request):
+ """List all group settings."""
+ all_groups = self._services.usergroup.GetAllUserGroupsInfo(mar.cnxn)
+ group_settings = []
+ for g in all_groups:
+ setting = g[2]
+ wrapper = api_pb2_v1_helpers.convert_group_settings(g[0], setting)
+ if not request.importedGroupsOnly or wrapper.ext_group_type:
+ group_settings.append(wrapper)
+ return api_pb2_v1.GroupsSettingsListResponse(
+ groupSettings=group_settings)
+
+ @monorail_api_method(
+ api_pb2_v1.GROUPS_CREATE_REQUEST_RESOURCE_CONTAINER,
+ api_pb2_v1.GroupsCreateResponse,
+ path='groups',
+ http_method='POST',
+ name='groups.create')
+ def groups_create(self, mar, request):
+ """Create a new user group."""
+ if not permissions.CanCreateGroup(mar.perms):
+ raise permissions.PermissionException(
+ 'The user is not allowed to create groups.')
+
+ user_dict = self._services.user.LookupExistingUserIDs(
+ mar.cnxn, [request.groupName])
+ if request.groupName.lower() in user_dict:
+ raise exceptions.GroupExistsException(
+ 'group %s already exists' % request.groupName)
+
+ if request.ext_group_type:
+ ext_group_type = str(request.ext_group_type).lower()
+ else:
+ ext_group_type = None
+ group_id = self._services.usergroup.CreateGroup(
+ mar.cnxn, self._services, request.groupName,
+ str(request.who_can_view_members).lower(),
+ ext_group_type)
+
+ return api_pb2_v1.GroupsCreateResponse(
+ groupID=group_id)
+
+ @monorail_api_method(
+ api_pb2_v1.GROUPS_GET_REQUEST_RESOURCE_CONTAINER,
+ api_pb2_v1.GroupsGetResponse,
+ path='groups/{groupName}',
+ http_method='GET',
+ name='groups.get')
+ def groups_get(self, mar, request):
+ """Get a group's settings and users."""
+ if not mar.viewed_user_auth:
+ raise exceptions.NoSuchUserException(request.groupName)
+ group_id = mar.viewed_user_auth.user_id
+ group_settings = self._services.usergroup.GetGroupSettings(
+ mar.cnxn, group_id)
+ member_ids, owner_ids = self._services.usergroup.LookupAllMembers(
+ mar.cnxn, [group_id])
+ (owned_project_ids, membered_project_ids,
+ contrib_project_ids) = self._services.project.GetUserRolesInAllProjects(
+ mar.cnxn, mar.auth.effective_ids)
+ project_ids = owned_project_ids.union(
+ membered_project_ids).union(contrib_project_ids)
+ if not permissions.CanViewGroupMembers(
+ mar.perms, mar.auth.effective_ids, group_settings, member_ids[group_id],
+ owner_ids[group_id], project_ids):
+ raise permissions.PermissionException(
+ 'The user is not allowed to view this group.')
+
+ member_ids, owner_ids = self._services.usergroup.LookupMembers(
+ mar.cnxn, [group_id])
+
+ member_emails = list(self._services.user.LookupUserEmails(
+ mar.cnxn, member_ids[group_id]).values())
+ owner_emails = list(self._services.user.LookupUserEmails(
+ mar.cnxn, owner_ids[group_id]).values())
+
+ return api_pb2_v1.GroupsGetResponse(
+ groupID=group_id,
+ groupSettings=api_pb2_v1_helpers.convert_group_settings(
+ request.groupName, group_settings),
+ groupOwners=owner_emails,
+ groupMembers=member_emails)
+
+ @monorail_api_method(
+ api_pb2_v1.GROUPS_UPDATE_REQUEST_RESOURCE_CONTAINER,
+ api_pb2_v1.GroupsUpdateResponse,
+ path='groups/{groupName}',
+ http_method='POST',
+ name='groups.update')
+ def groups_update(self, mar, request):
+ """Update a group's settings and users."""
+ group_id = mar.viewed_user_auth.user_id
+ member_ids_dict, owner_ids_dict = self._services.usergroup.LookupMembers(
+ mar.cnxn, [group_id])
+ owner_ids = owner_ids_dict.get(group_id, [])
+ member_ids = member_ids_dict.get(group_id, [])
+ if not permissions.CanEditGroup(
+ mar.perms, mar.auth.effective_ids, owner_ids):
+ raise permissions.PermissionException(
+ 'The user is not allowed to edit this group.')
+
+ group_settings = self._services.usergroup.GetGroupSettings(
+ mar.cnxn, group_id)
+ if (request.who_can_view_members or request.ext_group_type
+ or request.last_sync_time or request.friend_projects):
+ group_settings.who_can_view_members = (
+ request.who_can_view_members or group_settings.who_can_view_members)
+ group_settings.ext_group_type = (
+ request.ext_group_type or group_settings.ext_group_type)
+ group_settings.last_sync_time = (
+ request.last_sync_time or group_settings.last_sync_time)
+ if framework_constants.NO_VALUES in request.friend_projects:
+ group_settings.friend_projects = []
+ else:
+ id_dict = self._services.project.LookupProjectIDs(
+ mar.cnxn, request.friend_projects)
+ group_settings.friend_projects = (
+ list(id_dict.values()) or group_settings.friend_projects)
+ self._services.usergroup.UpdateSettings(
+ mar.cnxn, group_id, group_settings)
+
+ if request.groupOwners or request.groupMembers:
+ self._services.usergroup.RemoveMembers(
+ mar.cnxn, group_id, owner_ids + member_ids)
+ owners_dict = self._services.user.LookupUserIDs(
+ mar.cnxn, request.groupOwners, autocreate=True)
+ self._services.usergroup.UpdateMembers(
+ mar.cnxn, group_id, list(owners_dict.values()), 'owner')
+ members_dict = self._services.user.LookupUserIDs(
+ mar.cnxn, request.groupMembers, autocreate=True)
+ self._services.usergroup.UpdateMembers(
+ mar.cnxn, group_id, list(members_dict.values()), 'member')
+
+ return api_pb2_v1.GroupsUpdateResponse()
+
+ @monorail_api_method(
+ api_pb2_v1.COMPONENTS_LIST_REQUEST_RESOURCE_CONTAINER,
+ api_pb2_v1.ComponentsListResponse,
+ path='projects/{projectId}/components',
+ http_method='GET',
+ name='components.list')
+ def components_list(self, mar, _request):
+ """List all components of a given project."""
+ config = self._services.config.GetProjectConfig(mar.cnxn, mar.project_id)
+ components = [api_pb2_v1_helpers.convert_component_def(
+ cd, mar, self._services) for cd in config.component_defs]
+ return api_pb2_v1.ComponentsListResponse(
+ components=components)
+
+ @monorail_api_method(
+ api_pb2_v1.COMPONENTS_CREATE_REQUEST_RESOURCE_CONTAINER,
+ api_pb2_v1.Component,
+ path='projects/{projectId}/components',
+ http_method='POST',
+ name='components.create')
+ def components_create(self, mar, request):
+ """Create a component."""
+ if not mar.perms.CanUsePerm(
+ permissions.EDIT_PROJECT, mar.auth.effective_ids, mar.project, []):
+ raise permissions.PermissionException(
+ 'User is not allowed to create components for this project')
+
+ config = self._services.config.GetProjectConfig(mar.cnxn, mar.project_id)
+ leaf_name = request.componentName
+ if not tracker_constants.COMPONENT_NAME_RE.match(leaf_name):
+ raise exceptions.InvalidComponentNameException(
+ 'The component name %s is invalid.' % leaf_name)
+
+ parent_path = request.parentPath
+ if parent_path:
+ parent_def = tracker_bizobj.FindComponentDef(parent_path, config)
+ if not parent_def:
+ raise exceptions.NoSuchComponentException(
+ 'Parent component %s does not exist.' % parent_path)
+ if not permissions.CanEditComponentDef(
+ mar.auth.effective_ids, mar.perms, mar.project, parent_def, config):
+ raise permissions.PermissionException(
+ 'User is not allowed to add a subcomponent to component %s' %
+ parent_path)
+
+ path = '%s>%s' % (parent_path, leaf_name)
+ else:
+ path = leaf_name
+
+ if tracker_bizobj.FindComponentDef(path, config):
+ raise exceptions.InvalidComponentNameException(
+ 'The name %s is already in use.' % path)
+
+ created = int(time.time())
+ user_emails = set()
+ user_emails.update([mar.auth.email] + request.admin + request.cc)
+ user_ids_dict = self._services.user.LookupUserIDs(
+ mar.cnxn, list(user_emails), autocreate=False)
+ request.admin = [admin for admin in request.admin if admin]
+ admin_ids = [user_ids_dict[uname] for uname in request.admin]
+ request.cc = [cc for cc in request.cc if cc]
+ cc_ids = [user_ids_dict[uname] for uname in request.cc]
+ label_ids = [] # TODO(jrobbins): allow API clients to specify this too.
+
+ component_id = self._services.config.CreateComponentDef(
+ mar.cnxn, mar.project_id, path, request.description, request.deprecated,
+ admin_ids, cc_ids, created, user_ids_dict[mar.auth.email], label_ids)
+
+ return api_pb2_v1.Component(
+ componentId=component_id,
+ projectName=request.projectId,
+ componentPath=path,
+ description=request.description,
+ admin=request.admin,
+ cc=request.cc,
+ deprecated=request.deprecated,
+ created=datetime.datetime.fromtimestamp(created),
+ creator=mar.auth.email)
+
+ @monorail_api_method(
+ api_pb2_v1.COMPONENTS_DELETE_REQUEST_RESOURCE_CONTAINER,
+ message_types.VoidMessage,
+ path='projects/{projectId}/components/{componentPath}',
+ http_method='DELETE',
+ name='components.delete')
+ def components_delete(self, mar, request):
+ """Delete a component."""
+ config = self._services.config.GetProjectConfig(mar.cnxn, mar.project_id)
+ component_path = request.componentPath
+ component_def = tracker_bizobj.FindComponentDef(
+ component_path, config)
+ if not component_def:
+ raise exceptions.NoSuchComponentException(
+ 'The component %s does not exist.' % component_path)
+ if not permissions.CanViewComponentDef(
+ mar.auth.effective_ids, mar.perms, mar.project, component_def):
+ raise permissions.PermissionException(
+ 'User is not allowed to view this component %s' % component_path)
+ if not permissions.CanEditComponentDef(
+ mar.auth.effective_ids, mar.perms, mar.project, component_def, config):
+ raise permissions.PermissionException(
+ 'User is not allowed to delete this component %s' % component_path)
+
+ allow_delete = not tracker_bizobj.FindDescendantComponents(
+ config, component_def)
+ if not allow_delete:
+ raise permissions.PermissionException(
+ 'User tried to delete component that had subcomponents')
+
+ self._services.issue.DeleteComponentReferences(
+ mar.cnxn, component_def.component_id)
+ self._services.config.DeleteComponentDef(
+ mar.cnxn, mar.project_id, component_def.component_id)
+ return message_types.VoidMessage()
+
+ @monorail_api_method(
+ api_pb2_v1.COMPONENTS_UPDATE_REQUEST_RESOURCE_CONTAINER,
+ message_types.VoidMessage,
+ path='projects/{projectId}/components/{componentPath}',
+ http_method='POST',
+ name='components.update')
+ def components_update(self, mar, request):
+ """Update a component."""
+ config = self._services.config.GetProjectConfig(mar.cnxn, mar.project_id)
+ component_path = request.componentPath
+ component_def = tracker_bizobj.FindComponentDef(
+ component_path, config)
+ if not component_def:
+ raise exceptions.NoSuchComponentException(
+ 'The component %s does not exist.' % component_path)
+ if not permissions.CanViewComponentDef(
+ mar.auth.effective_ids, mar.perms, mar.project, component_def):
+ raise permissions.PermissionException(
+ 'User is not allowed to view this component %s' % component_path)
+ if not permissions.CanEditComponentDef(
+ mar.auth.effective_ids, mar.perms, mar.project, component_def, config):
+ raise permissions.PermissionException(
+ 'User is not allowed to edit this component %s' % component_path)
+
+ original_path = component_def.path
+ new_path = component_def.path
+ new_docstring = component_def.docstring
+ new_deprecated = component_def.deprecated
+ new_admin_ids = component_def.admin_ids
+ new_cc_ids = component_def.cc_ids
+ update_filterrule = False
+ for update in request.updates:
+ if update.field == api_pb2_v1.ComponentUpdateFieldID.LEAF_NAME:
+ leaf_name = update.leafName
+ if not tracker_constants.COMPONENT_NAME_RE.match(leaf_name):
+ raise exceptions.InvalidComponentNameException(
+ 'The component name %s is invalid.' % leaf_name)
+
+ if '>' in original_path:
+ parent_path = original_path[:original_path.rindex('>')]
+ new_path = '%s>%s' % (parent_path, leaf_name)
+ else:
+ new_path = leaf_name
+
+ conflict = tracker_bizobj.FindComponentDef(new_path, config)
+ if conflict and conflict.component_id != component_def.component_id:
+ raise exceptions.InvalidComponentNameException(
+ 'The name %s is already in use.' % new_path)
+ update_filterrule = True
+ elif update.field == api_pb2_v1.ComponentUpdateFieldID.DESCRIPTION:
+ new_docstring = update.description
+ elif update.field == api_pb2_v1.ComponentUpdateFieldID.ADMIN:
+ user_ids_dict = self._services.user.LookupUserIDs(
+ mar.cnxn, list(update.admin), autocreate=True)
+ new_admin_ids = list(set(user_ids_dict.values()))
+ elif update.field == api_pb2_v1.ComponentUpdateFieldID.CC:
+ user_ids_dict = self._services.user.LookupUserIDs(
+ mar.cnxn, list(update.cc), autocreate=True)
+ new_cc_ids = list(set(user_ids_dict.values()))
+ update_filterrule = True
+ elif update.field == api_pb2_v1.ComponentUpdateFieldID.DEPRECATED:
+ new_deprecated = update.deprecated
+ else:
+ logging.error('Unknown component field %r', update.field)
+
+ new_modified = int(time.time())
+ new_modifier_id = self._services.user.LookupUserID(
+ mar.cnxn, mar.auth.email, autocreate=False)
+ logging.info(
+ 'Updating component id %d: path-%s, docstring-%s, deprecated-%s,'
+ ' admin_ids-%s, cc_ids-%s modified by %s', component_def.component_id,
+ new_path, new_docstring, new_deprecated, new_admin_ids, new_cc_ids,
+ new_modifier_id)
+ self._services.config.UpdateComponentDef(
+ mar.cnxn, mar.project_id, component_def.component_id,
+ path=new_path, docstring=new_docstring, deprecated=new_deprecated,
+ admin_ids=new_admin_ids, cc_ids=new_cc_ids, modified=new_modified,
+ modifier_id=new_modifier_id)
+
+ # TODO(sheyang): reuse the code in componentdetails
+ if original_path != new_path:
+ # If the name changed then update all of its subcomponents as well.
+ subcomponent_ids = tracker_bizobj.FindMatchingComponentIDs(
+ original_path, config, exact=False)
+ for subcomponent_id in subcomponent_ids:
+ if subcomponent_id == component_def.component_id:
+ continue
+ subcomponent_def = tracker_bizobj.FindComponentDefByID(
+ subcomponent_id, config)
+ subcomponent_new_path = subcomponent_def.path.replace(
+ original_path, new_path, 1)
+ self._services.config.UpdateComponentDef(
+ mar.cnxn, mar.project_id, subcomponent_def.component_id,
+ path=subcomponent_new_path)
+
+ if update_filterrule:
+ filterrules_helpers.RecomputeAllDerivedFields(
+ mar.cnxn, self._services, mar.project, config)
+
+ return message_types.VoidMessage()
+
+
+@endpoints.api(name='monorail_client_configs', version='v1',
+ description='Monorail API client configs.')
+class ClientConfigApi(remote.Service):
+
+ # Class variables. Handy to mock.
+ _services = None
+ _mar = None
+
+ @classmethod
+ def _set_services(cls, services):
+ cls._services = services
+
+ def mar_factory(self, request, cnxn):
+ if not self._mar:
+ self._mar = monorailrequest.MonorailApiRequest(
+ request, self._services, cnxn=cnxn)
+ return self._mar
+
+ @endpoints.method(
+ message_types.VoidMessage,
+ message_types.VoidMessage,
+ path='client_configs',
+ http_method='POST',
+ name='client_configs.update')
+ def client_configs_update(self, request):
+ if self._services is None:
+ self._set_services(service_manager.set_up_services())
+ mar = self.mar_factory(request, sql.MonorailConnection())
+ if not mar.perms.HasPerm(permissions.ADMINISTER_SITE, None, None):
+ raise permissions.PermissionException(
+ 'The requester %s is not allowed to update client configs.' %
+ mar.auth.email)
+
+ ROLE_DICT = {
+ 1: permissions.COMMITTER_ROLE,
+ 2: permissions.CONTRIBUTOR_ROLE,
+ }
+
+ client_config = client_config_svc.GetClientConfigSvc()
+
+ cfg = client_config.GetConfigs()
+ if not cfg:
+ msg = 'Failed to fetch client configs.'
+ logging.error(msg)
+ raise endpoints.InternalServerErrorException(msg)
+
+ for client in cfg.clients:
+ if not client.client_email:
+ continue
+ # 1: create the user if non-existent
+ user_id = self._services.user.LookupUserID(
+ mar.cnxn, client.client_email, autocreate=True)
+ user_pb = self._services.user.GetUser(mar.cnxn, user_id)
+
+ logging.info('User ID %d for email %s', user_id, client.client_email)
+
+ # 2: set period and lifetime limit
+ # new_soft_limit, new_hard_limit, new_lifetime_limit
+ new_limit_tuple = (
+ client.period_limit, client.period_limit, client.lifetime_limit)
+ action_limit_updates = {'api_request': new_limit_tuple}
+ self._services.user.UpdateUserSettings(
+ mar.cnxn, user_id, user_pb, action_limit_updates=action_limit_updates)
+
+ logging.info('Updated api request limit %r', new_limit_tuple)
+
+ # 3: Update project role and extra perms
+ projects_dict = self._services.project.GetAllProjects(mar.cnxn)
+ project_name_to_ids = {
+ p.project_name: p.project_id for p in projects_dict.values()}
+
+ # Set project role and extra perms
+ for perm in client.project_permissions:
+ project_ids = self._GetProjectIDs(perm.project, project_name_to_ids)
+ logging.info('Matching projects %r for name %s',
+ project_ids, perm.project)
+
+ role = ROLE_DICT[perm.role]
+ for p_id in project_ids:
+ project = projects_dict[p_id]
+ people_list = []
+ if role == 'owner':
+ people_list = project.owner_ids
+ elif role == 'committer':
+ people_list = project.committer_ids
+ elif role == 'contributor':
+ people_list = project.contributor_ids
+ # Onlu update role/extra perms iff changed
+ if not user_id in people_list:
+ logging.info('Update project %s role %s for user %s',
+ project.project_name, role, client.client_email)
+ owner_ids, committer_ids, contributor_ids = (
+ project_helpers.MembersWithGivenIDs(project, {user_id}, role))
+ self._services.project.UpdateProjectRoles(
+ mar.cnxn, p_id, owner_ids, committer_ids,
+ contributor_ids)
+ if perm.extra_permissions:
+ logging.info('Update project %s extra perm %s for user %s',
+ project.project_name, perm.extra_permissions,
+ client.client_email)
+ self._services.project.UpdateExtraPerms(
+ mar.cnxn, p_id, user_id, list(perm.extra_permissions))
+
+ mar.CleanUp()
+ return message_types.VoidMessage()
+
+ def _GetProjectIDs(self, project_str, project_name_to_ids):
+ result = []
+ if any(ch in project_str for ch in ['*', '+', '?', '.']):
+ pattern = re.compile(project_str)
+ for p_name in project_name_to_ids.keys():
+ if pattern.match(p_name):
+ project_id = project_name_to_ids.get(p_name)
+ if project_id:
+ result.append(project_id)
+ else:
+ project_id = project_name_to_ids.get(project_str)
+ if project_id:
+ result.append(project_id)
+
+ if not result:
+ logging.warning('Cannot find projects for specified name %s',
+ project_str)
+ return result