Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1 | # Copyright 2016 The Chromium Authors |
| 2 | # Use of this source code is governed by a BSD-style license that can be |
| 3 | # found in the LICENSE file. |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 4 | |
| 5 | """API service. |
| 6 | |
| 7 | To manually test this API locally, use the following steps: |
| 8 | 1. Start the development server via 'make serve'. |
| 9 | 2. Start a new Chrome session via the command-line: |
| 10 | PATH_TO_CHROME --user-data-dir=/tmp/test \ |
| 11 | --unsafely-treat-insecure-origin-as-secure=http://localhost:8080 |
| 12 | 3. Visit http://localhost:8080/_ah/api/explorer |
| 13 | 4. Click shield icon in the omnibar and allow unsafe scripts. |
| 14 | 5. Click on the "Services" menu item in the API Explorer. |
| 15 | """ |
| 16 | from __future__ import print_function |
| 17 | from __future__ import division |
| 18 | from __future__ import absolute_import |
| 19 | |
| 20 | import calendar |
| 21 | import datetime |
| 22 | import endpoints |
| 23 | import functools |
| 24 | import logging |
| 25 | import re |
| 26 | import time |
| 27 | from google.appengine.api import oauth |
| 28 | from protorpc import message_types |
| 29 | from protorpc import protojson |
| 30 | from protorpc import remote |
| 31 | |
| 32 | import settings |
| 33 | from businesslogic import work_env |
| 34 | from features import filterrules_helpers |
| 35 | from features import send_notifications |
| 36 | from framework import authdata |
| 37 | from framework import exceptions |
| 38 | from framework import framework_constants |
| 39 | from framework import framework_helpers |
| 40 | from framework import framework_views |
| 41 | from framework import monitoring |
| 42 | from framework import monorailrequest |
| 43 | from framework import permissions |
| 44 | from framework import ratelimiter |
| 45 | from framework import sql |
| 46 | from project import project_helpers |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 47 | from mrproto import api_pb2_v1 |
| 48 | from mrproto import project_pb2 |
| 49 | from mrproto import tracker_pb2 |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 50 | from search import frontendsearchpipeline |
| 51 | from services import api_pb2_v1_helpers |
| 52 | from services import client_config_svc |
| 53 | from services import service_manager |
| 54 | from services import tracker_fulltext |
| 55 | from sitewide import sitewide_helpers |
| 56 | from tracker import field_helpers |
| 57 | from tracker import issuedetailezt |
| 58 | from tracker import tracker_bizobj |
| 59 | from tracker import tracker_constants |
| 60 | from tracker import tracker_helpers |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 61 | from redirect import redirect_utils |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 62 | |
| 63 | from infra_libs import ts_mon |
| 64 | |
| 65 | |
| 66 | ENDPOINTS_API_NAME = 'monorail' |
| 67 | DOC_URL = ( |
| 68 | 'https://chromium.googlesource.com/infra/infra/+/main/' |
| 69 | 'appengine/monorail/doc/api.md') |
| 70 | |
| 71 | |
| 72 | def monorail_api_method( |
| 73 | request_message, response_message, **kwargs): |
| 74 | """Extends endpoints.method by performing base checks.""" |
| 75 | time_fn = kwargs.pop('time_fn', time.time) |
| 76 | method_name = kwargs.get('name', '') |
| 77 | method_path = kwargs.get('path', '') |
| 78 | http_method = kwargs.get('http_method', '') |
| 79 | def new_decorator(func): |
| 80 | @endpoints.method(request_message, response_message, **kwargs) |
| 81 | @functools.wraps(func) |
| 82 | def wrapper(self, *args, **kwargs): |
| 83 | start_time = time_fn() |
| 84 | approximate_http_status = 200 |
| 85 | request = args[0] |
| 86 | ret = None |
| 87 | c_id = None |
| 88 | c_email = None |
| 89 | mar = None |
| 90 | try: |
| 91 | if settings.read_only and http_method.lower() != 'get': |
| 92 | raise permissions.PermissionException( |
| 93 | 'This request is not allowed in read-only mode') |
| 94 | requester = endpoints.get_current_user() |
| 95 | logging.info('requester is %r', requester) |
| 96 | logging.info('args is %r', args) |
| 97 | logging.info('kwargs is %r', kwargs) |
| 98 | auth_client_ids, auth_emails = ( |
| 99 | client_config_svc.GetClientConfigSvc().GetClientIDEmails()) |
| 100 | if settings.local_mode: |
| 101 | auth_client_ids.append(endpoints.API_EXPLORER_CLIENT_ID) |
| 102 | if self._services is None: |
| 103 | self._set_services(service_manager.set_up_services()) |
| 104 | cnxn = sql.MonorailConnection() |
| 105 | c_id, c_email = api_base_checks( |
| 106 | request, requester, self._services, cnxn, |
| 107 | auth_client_ids, auth_emails) |
| 108 | mar = self.mar_factory(request, cnxn) |
| 109 | self.ratelimiter.CheckStart(c_id, c_email, start_time) |
| 110 | monitoring.IncrementAPIRequestsCount( |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 111 | 'endpoints', c_id, client_email=c_email, handler=method_name) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 112 | ret = func(self, mar, *args, **kwargs) |
| 113 | except exceptions.NoSuchUserException as e: |
| 114 | approximate_http_status = 404 |
| 115 | raise endpoints.NotFoundException( |
| 116 | 'The user does not exist: %s' % str(e)) |
| 117 | except (exceptions.NoSuchProjectException, |
| 118 | exceptions.NoSuchIssueException, |
| 119 | exceptions.NoSuchComponentException) as e: |
| 120 | approximate_http_status = 404 |
| 121 | raise endpoints.NotFoundException(str(e)) |
| 122 | except (permissions.BannedUserException, |
| 123 | permissions.PermissionException) as e: |
| 124 | approximate_http_status = 403 |
| 125 | logging.info('Allowlist ID %r email %r', auth_client_ids, auth_emails) |
| 126 | raise endpoints.ForbiddenException(str(e)) |
| 127 | except endpoints.BadRequestException: |
| 128 | approximate_http_status = 400 |
| 129 | raise |
| 130 | except endpoints.UnauthorizedException: |
| 131 | approximate_http_status = 401 |
| 132 | # Client will refresh token and retry. |
| 133 | raise |
| 134 | except oauth.InvalidOAuthTokenError: |
| 135 | approximate_http_status = 401 |
| 136 | # Client will refresh token and retry. |
| 137 | raise endpoints.UnauthorizedException( |
| 138 | 'Auth error: InvalidOAuthTokenError') |
| 139 | except (exceptions.GroupExistsException, |
| 140 | exceptions.InvalidComponentNameException, |
| 141 | ratelimiter.ApiRateLimitExceeded) as e: |
| 142 | approximate_http_status = 400 |
| 143 | raise endpoints.BadRequestException(str(e)) |
| 144 | except Exception as e: |
| 145 | approximate_http_status = 500 |
| 146 | logging.exception('Unexpected error in monorail API') |
| 147 | raise |
| 148 | finally: |
| 149 | if mar: |
| 150 | mar.CleanUp() |
| 151 | now = time_fn() |
| 152 | if c_id and c_email: |
| 153 | self.ratelimiter.CheckEnd(c_id, c_email, now, start_time) |
| 154 | _RecordMonitoringStats( |
| 155 | start_time, request, ret, (method_name or func.__name__), |
| 156 | (method_path or func.__name__), approximate_http_status, now) |
| 157 | |
| 158 | return ret |
| 159 | |
| 160 | return wrapper |
| 161 | return new_decorator |
| 162 | |
| 163 | |
| 164 | def _RecordMonitoringStats( |
| 165 | start_time, |
| 166 | request, |
| 167 | response, |
| 168 | method_name, |
| 169 | method_path, |
| 170 | approximate_http_status, |
| 171 | now=None): |
| 172 | now = now or time.time() |
| 173 | elapsed_ms = int((now - start_time) * 1000) |
| 174 | # Use the api name, not the request path, to prevent an explosion in |
| 175 | # possible field values. |
| 176 | method_identifier = ( |
| 177 | ENDPOINTS_API_NAME + '.endpoints.' + method_name + '/' + method_path) |
| 178 | |
| 179 | # Endpoints APIs don't return the full set of http status values. |
| 180 | fields = monitoring.GetCommonFields( |
| 181 | approximate_http_status, method_identifier) |
| 182 | |
| 183 | monitoring.AddServerDurations(elapsed_ms, fields) |
| 184 | monitoring.IncrementServerResponseStatusCount(fields) |
| 185 | request_length = len(protojson.encode_message(request)) |
| 186 | monitoring.AddServerRequesteBytes(request_length, fields) |
| 187 | response_length = 0 |
| 188 | if response: |
| 189 | response_length = len(protojson.encode_message(response)) |
| 190 | monitoring.AddServerResponseBytes(response_length, fields) |
| 191 | |
| 192 | |
| 193 | def _is_requester_in_allowed_domains(requester): |
| 194 | if requester.email().endswith(settings.api_allowed_email_domains): |
| 195 | if framework_constants.MONORAIL_SCOPE in oauth.get_authorized_scopes( |
| 196 | framework_constants.MONORAIL_SCOPE): |
| 197 | return True |
| 198 | else: |
| 199 | logging.info("User is not authenticated with monorail scope") |
| 200 | return False |
| 201 | |
| 202 | def api_base_checks(request, requester, services, cnxn, |
| 203 | auth_client_ids, auth_emails): |
| 204 | """Base checks for API users. |
| 205 | |
| 206 | Args: |
| 207 | request: The HTTP request from Cloud Endpoints. |
| 208 | requester: The user who sends the request. |
| 209 | services: Services object. |
| 210 | cnxn: connection to the SQL database. |
| 211 | auth_client_ids: authorized client ids. |
| 212 | auth_emails: authorized emails when client is anonymous. |
| 213 | |
| 214 | Returns: |
| 215 | Client ID and client email. |
| 216 | |
| 217 | Raises: |
| 218 | endpoints.UnauthorizedException: If the requester is anonymous. |
| 219 | exceptions.NoSuchUserException: If the requester does not exist in Monorail. |
| 220 | NoSuchProjectException: If the project does not exist in Monorail. |
| 221 | permissions.BannedUserException: If the requester is banned. |
| 222 | permissions.PermissionException: If the requester does not have |
| 223 | permisssion to view. |
| 224 | """ |
| 225 | valid_user = False |
| 226 | auth_err = '' |
| 227 | client_id = None |
| 228 | |
| 229 | try: |
| 230 | client_id = oauth.get_client_id(framework_constants.OAUTH_SCOPE) |
| 231 | logging.info('Oauth client ID %s', client_id) |
| 232 | except oauth.Error as ex: |
| 233 | auth_err = 'oauth.Error: %s' % ex |
| 234 | |
| 235 | if not requester: |
| 236 | try: |
| 237 | requester = oauth.get_current_user(framework_constants.OAUTH_SCOPE) |
| 238 | logging.info('Oauth requester %s', requester.email()) |
| 239 | except oauth.Error as ex: |
| 240 | logging.info('Got oauth error: %r', ex) |
| 241 | auth_err = 'oauth.Error: %s' % ex |
| 242 | |
| 243 | if client_id and requester: |
| 244 | if client_id in auth_client_ids: |
| 245 | # A allowlisted client app can make requests for any user or anon. |
| 246 | logging.info('Client ID %r is allowlisted', client_id) |
| 247 | valid_user = True |
| 248 | elif requester.email() in auth_emails: |
| 249 | # A allowlisted user account can make requests via any client app. |
| 250 | logging.info('Client email %r is allowlisted', requester.email()) |
| 251 | valid_user = True |
| 252 | elif _is_requester_in_allowed_domains(requester): |
| 253 | # A user with an allowed-domain email and authenticated with the |
| 254 | # monorail scope is allowed to make requests via any client app. |
| 255 | logging.info( |
| 256 | 'User email %r is within the allowed domains', requester.email()) |
| 257 | valid_user = True |
| 258 | else: |
| 259 | auth_err = ( |
| 260 | 'Neither client ID %r nor email %r is allowlisted' % |
| 261 | (client_id, requester.email())) |
| 262 | |
| 263 | if not valid_user: |
| 264 | raise endpoints.UnauthorizedException('Auth error: %s' % auth_err) |
| 265 | else: |
| 266 | logging.info('API request from user %s:%s', client_id, requester.email()) |
| 267 | |
| 268 | project_name = None |
| 269 | if hasattr(request, 'projectId'): |
| 270 | project_name = request.projectId |
| 271 | issue_local_id = None |
| 272 | if hasattr(request, 'issueId'): |
| 273 | issue_local_id = request.issueId |
| 274 | # This could raise exceptions.NoSuchUserException |
| 275 | requester_id = services.user.LookupUserID(cnxn, requester.email()) |
| 276 | auth = authdata.AuthData.FromUserID(cnxn, requester_id, services) |
| 277 | if permissions.IsBanned(auth.user_pb, auth.user_view): |
| 278 | raise permissions.BannedUserException( |
| 279 | 'The user %s has been banned from using Monorail' % |
| 280 | requester.email()) |
| 281 | if project_name: |
| 282 | project = services.project.GetProjectByName( |
| 283 | cnxn, project_name) |
| 284 | if not project: |
| 285 | raise exceptions.NoSuchProjectException( |
| 286 | 'Project %s does not exist' % project_name) |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 287 | # Allow to view non-live projects that were migrated. |
| 288 | if (project.state != project_pb2.ProjectState.LIVE and |
| 289 | project_name not in redirect_utils.PROJECT_REDIRECT_MAP): |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 290 | raise permissions.PermissionException( |
| 291 | 'API may not access project %s because it is not live' |
| 292 | % project_name) |
| 293 | if not permissions.UserCanViewProject( |
| 294 | auth.user_pb, auth.effective_ids, project): |
| 295 | raise permissions.PermissionException( |
| 296 | 'The user %s has no permission for project %s' % |
| 297 | (requester.email(), project_name)) |
| 298 | if issue_local_id: |
| 299 | # This may raise a NoSuchIssueException. |
| 300 | issue = services.issue.GetIssueByLocalID( |
| 301 | cnxn, project.project_id, issue_local_id) |
| 302 | perms = permissions.GetPermissions( |
| 303 | auth.user_pb, auth.effective_ids, project) |
| 304 | config = services.config.GetProjectConfig(cnxn, project.project_id) |
| 305 | granted_perms = tracker_bizobj.GetGrantedPerms( |
| 306 | issue, auth.effective_ids, config) |
| 307 | if not permissions.CanViewIssue( |
| 308 | auth.effective_ids, perms, project, issue, |
| 309 | granted_perms=granted_perms): |
| 310 | raise permissions.PermissionException( |
| 311 | 'User is not allowed to view this issue %s:%d' % |
| 312 | (project_name, issue_local_id)) |
| 313 | |
| 314 | return client_id, requester.email() |
| 315 | |
| 316 | |
| 317 | @endpoints.api(name=ENDPOINTS_API_NAME, version='v1', |
| 318 | description='Monorail API to manage issues.', |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 319 | allowed_client_ids=endpoints.SKIP_CLIENT_ID_CHECK, |
| 320 | documentation=DOC_URL) |
| 321 | class MonorailApi(remote.Service): |
| 322 | |
| 323 | # Class variables. Handy to mock. |
| 324 | _services = None |
| 325 | _mar = None |
| 326 | |
| 327 | ratelimiter = ratelimiter.ApiRateLimiter() |
| 328 | |
| 329 | @classmethod |
| 330 | def _set_services(cls, services): |
| 331 | cls._services = services |
| 332 | |
| 333 | def mar_factory(self, request, cnxn): |
| 334 | if not self._mar: |
| 335 | self._mar = monorailrequest.MonorailApiRequest( |
| 336 | request, self._services, cnxn=cnxn) |
| 337 | return self._mar |
| 338 | |
| 339 | def aux_delete_comment(self, mar, request, delete=True): |
| 340 | action_name = 'delete' if delete else 'undelete' |
| 341 | |
| 342 | with work_env.WorkEnv(mar, self._services) as we: |
| 343 | issue = we.GetIssueByLocalID( |
| 344 | mar.project_id, request.issueId, use_cache=False) |
| 345 | all_comments = we.ListIssueComments(issue) |
| 346 | try: |
| 347 | issue_comment = all_comments[request.commentId] |
| 348 | except IndexError: |
| 349 | raise exceptions.NoSuchIssueException( |
| 350 | 'The issue %s:%d does not have comment %d.' % |
| 351 | (mar.project_name, request.issueId, request.commentId)) |
| 352 | |
| 353 | issue_perms = permissions.UpdateIssuePermissions( |
| 354 | mar.perms, mar.project, issue, mar.auth.effective_ids, |
| 355 | granted_perms=mar.granted_perms) |
| 356 | commenter = we.GetUser(issue_comment.user_id) |
| 357 | |
| 358 | if not permissions.CanDeleteComment( |
| 359 | issue_comment, commenter, mar.auth.user_id, issue_perms): |
| 360 | raise permissions.PermissionException( |
| 361 | 'User is not allowed to %s the comment %d of issue %s:%d' % |
| 362 | (action_name, request.commentId, mar.project_name, |
| 363 | request.issueId)) |
| 364 | |
| 365 | we.DeleteComment(issue, issue_comment, delete=delete) |
| 366 | return api_pb2_v1.IssuesCommentsDeleteResponse() |
| 367 | |
| 368 | @monorail_api_method( |
| 369 | api_pb2_v1.ISSUES_COMMENTS_DELETE_REQUEST_RESOURCE_CONTAINER, |
| 370 | api_pb2_v1.IssuesCommentsDeleteResponse, |
| 371 | path='projects/{projectId}/issues/{issueId}/comments/{commentId}', |
| 372 | http_method='DELETE', |
| 373 | name='issues.comments.delete') |
| 374 | def issues_comments_delete(self, mar, request): |
| 375 | """Delete a comment.""" |
| 376 | return self.aux_delete_comment(mar, request, True) |
| 377 | |
| 378 | def parse_imported_reporter(self, mar, request): |
| 379 | """Handle the case where an API client is importing issues for users. |
| 380 | |
| 381 | Args: |
| 382 | mar: monorail API request object including auth and perms. |
| 383 | request: A request PB that defines author and published fields. |
| 384 | |
| 385 | Returns: |
| 386 | A pair (reporter_id, timestamp) with the user ID of the user to |
| 387 | attribute the comment to and timestamp of the original comment. |
| 388 | If the author field is not set, this is not an import request |
| 389 | and the comment is attributed to the API client as per normal. |
| 390 | An API client that is attempting to post on behalf of other |
| 391 | users must have the ImportComment permission in the current |
| 392 | project. |
| 393 | """ |
| 394 | reporter_id = mar.auth.user_id |
| 395 | timestamp = None |
| 396 | if (request.author and request.author.name and |
| 397 | request.author.name != mar.auth.email): |
| 398 | if not mar.perms.HasPerm( |
| 399 | permissions.IMPORT_COMMENT, mar.auth.user_id, mar.project): |
| 400 | logging.info('name is %r', request.author.name) |
| 401 | raise permissions.PermissionException( |
| 402 | 'User is not allowed to attribue comments to others') |
| 403 | reporter_id = self._services.user.LookupUserID( |
| 404 | mar.cnxn, request.author.name, autocreate=True) |
| 405 | logging.info('Importing issue or comment.') |
| 406 | if request.published: |
| 407 | timestamp = calendar.timegm(request.published.utctimetuple()) |
| 408 | |
| 409 | return reporter_id, timestamp |
| 410 | |
| 411 | @monorail_api_method( |
| 412 | api_pb2_v1.ISSUES_COMMENTS_INSERT_REQUEST_RESOURCE_CONTAINER, |
| 413 | api_pb2_v1.IssuesCommentsInsertResponse, |
| 414 | path='projects/{projectId}/issues/{issueId}/comments', |
| 415 | http_method='POST', |
| 416 | name='issues.comments.insert') |
| 417 | def issues_comments_insert(self, mar, request): |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 418 | # type (...) -> mrproto.api_pb2_v1.IssuesCommentsInsertResponse |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 419 | """Add a comment.""" |
| 420 | # Because we will modify issues, load from DB rather than cache. |
| 421 | issue = self._services.issue.GetIssueByLocalID( |
| 422 | mar.cnxn, mar.project_id, request.issueId, use_cache=False) |
| 423 | old_owner_id = tracker_bizobj.GetOwnerId(issue) |
| 424 | if not permissions.CanCommentIssue( |
| 425 | mar.auth.effective_ids, mar.perms, mar.project, issue, |
| 426 | mar.granted_perms): |
| 427 | raise permissions.PermissionException( |
| 428 | 'User is not allowed to comment this issue (%s, %d)' % |
| 429 | (request.projectId, request.issueId)) |
| 430 | |
| 431 | # Temporary block on updating approval subfields. |
| 432 | if request.updates and request.updates.fieldValues: |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 433 | fds_by_name = {fd.field_name.lower(): fd for fd in mar.config.field_defs} |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 434 | for fv in request.updates.fieldValues: |
| 435 | # Checking for fv.approvalName is unreliable since it can be removed. |
| 436 | fd = fds_by_name.get(fv.fieldName.lower()) |
| 437 | if fd and fd.approval_id: |
| 438 | raise exceptions.ActionNotSupported( |
| 439 | 'No API support for approval field changes: (approval %s owns %s)' |
| 440 | % (fd.approval_id, fd.field_name)) |
| 441 | # if fd was None, that gets dealt with later. |
| 442 | |
| 443 | if request.content and len( |
| 444 | request.content) > tracker_constants.MAX_COMMENT_CHARS: |
| 445 | raise endpoints.BadRequestException( |
| 446 | 'Comment is too long on this issue (%s, %d' % |
| 447 | (request.projectId, request.issueId)) |
| 448 | |
| 449 | updates_dict = {} |
| 450 | move_to_project = None |
| 451 | if request.updates: |
| 452 | if not permissions.CanEditIssue( |
| 453 | mar.auth.effective_ids, mar.perms, mar.project, issue, |
| 454 | mar.granted_perms): |
| 455 | raise permissions.PermissionException( |
| 456 | 'User is not allowed to edit this issue (%s, %d)' % |
| 457 | (request.projectId, request.issueId)) |
| 458 | if request.updates.moveToProject: |
| 459 | move_to = request.updates.moveToProject.lower() |
| 460 | move_to_project = issuedetailezt.CheckMoveIssueRequest( |
| 461 | self._services, mar, issue, True, move_to, mar.errors) |
| 462 | if mar.errors.AnyErrors(): |
| 463 | raise endpoints.BadRequestException(mar.errors.move_to) |
| 464 | |
| 465 | updates_dict['summary'] = request.updates.summary |
| 466 | updates_dict['status'] = request.updates.status |
| 467 | updates_dict['is_description'] = request.updates.is_description |
| 468 | if request.updates.owner: |
| 469 | # A current issue owner can be removed via the API with a |
| 470 | # NO_USER_NAME('----') input. |
| 471 | if request.updates.owner == framework_constants.NO_USER_NAME: |
| 472 | updates_dict['owner'] = framework_constants.NO_USER_SPECIFIED |
| 473 | else: |
| 474 | new_owner_id = self._services.user.LookupUserID( |
| 475 | mar.cnxn, request.updates.owner) |
| 476 | valid, msg = tracker_helpers.IsValidIssueOwner( |
| 477 | mar.cnxn, mar.project, new_owner_id, self._services) |
| 478 | if not valid: |
| 479 | raise endpoints.BadRequestException(msg) |
| 480 | updates_dict['owner'] = new_owner_id |
| 481 | updates_dict['cc_add'], updates_dict['cc_remove'] = ( |
| 482 | api_pb2_v1_helpers.split_remove_add(request.updates.cc)) |
| 483 | updates_dict['cc_add'] = list(self._services.user.LookupUserIDs( |
| 484 | mar.cnxn, updates_dict['cc_add'], autocreate=True).values()) |
| 485 | updates_dict['cc_remove'] = list(self._services.user.LookupUserIDs( |
| 486 | mar.cnxn, updates_dict['cc_remove']).values()) |
| 487 | updates_dict['labels_add'], updates_dict['labels_remove'] = ( |
| 488 | api_pb2_v1_helpers.split_remove_add(request.updates.labels)) |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 489 | |
| 490 | field_helpers.ValidateLabels( |
| 491 | mar.cnxn, |
| 492 | self._services, |
| 493 | mar.project_id, |
| 494 | updates_dict.get('labels_add', []), |
| 495 | ezt_errors=mar.errors) |
| 496 | if mar.errors.AnyErrors(): |
| 497 | raise endpoints.BadRequestException( |
| 498 | 'Invalid field values: %s' % mar.errors.labels) |
| 499 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 500 | blocked_on_add_strs, blocked_on_remove_strs = ( |
| 501 | api_pb2_v1_helpers.split_remove_add(request.updates.blockedOn)) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 502 | blocking_add_strs, blocking_remove_strs = ( |
| 503 | api_pb2_v1_helpers.split_remove_add(request.updates.blocking)) |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 504 | blocked_on_add_iids = api_pb2_v1_helpers.issue_global_ids( |
| 505 | blocked_on_add_strs, issue.project_id, mar, self._services) |
| 506 | blocked_on_remove_iids = api_pb2_v1_helpers.issue_global_ids( |
| 507 | blocked_on_remove_strs, issue.project_id, mar, self._services) |
| 508 | blocking_add_iids = api_pb2_v1_helpers.issue_global_ids( |
| 509 | blocking_add_strs, issue.project_id, mar, self._services) |
| 510 | blocking_remove_iids = api_pb2_v1_helpers.issue_global_ids( |
| 511 | blocking_remove_strs, issue.project_id, mar, self._services) |
| 512 | all_block = ( |
| 513 | blocked_on_add_iids + blocked_on_remove_iids + blocking_add_iids + |
| 514 | blocking_remove_iids) |
| 515 | for iid in all_block: |
| 516 | # Because we will modify issues, load from DB rather than cache. |
| 517 | issue = self._services.issue.GetIssue(mar.cnxn, iid, use_cache=False) |
| 518 | project = self._services.project.GetProjectByName( |
| 519 | mar.cnxn, issue.project_name) |
| 520 | if not tracker_helpers.CanEditProjectIssue(mar, project, issue, |
| 521 | mar.granted_perms): |
| 522 | raise permissions.PermissionException( |
| 523 | 'User is not allowed to block with issue (%s, %d)' % |
| 524 | (issue.project_name, issue.local_id)) |
| 525 | updates_dict['blocked_on_add'] = blocked_on_add_iids |
| 526 | updates_dict['blocked_on_remove'] = blocked_on_remove_iids |
| 527 | updates_dict['blocking_add'] = blocking_add_iids |
| 528 | updates_dict['blocking_remove'] = blocking_remove_iids |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 529 | components_add_strs, components_remove_strs = ( |
| 530 | api_pb2_v1_helpers.split_remove_add(request.updates.components)) |
| 531 | updates_dict['components_add'] = ( |
| 532 | api_pb2_v1_helpers.convert_component_ids( |
| 533 | mar.config, components_add_strs)) |
| 534 | updates_dict['components_remove'] = ( |
| 535 | api_pb2_v1_helpers.convert_component_ids( |
| 536 | mar.config, components_remove_strs)) |
| 537 | if request.updates.mergedInto: |
| 538 | merge_project_name, merge_local_id = tracker_bizobj.ParseIssueRef( |
| 539 | request.updates.mergedInto) |
| 540 | merge_into_project = self._services.project.GetProjectByName( |
| 541 | mar.cnxn, merge_project_name or issue.project_name) |
| 542 | # Because we will modify issues, load from DB rather than cache. |
| 543 | merge_into_issue = self._services.issue.GetIssueByLocalID( |
| 544 | mar.cnxn, merge_into_project.project_id, merge_local_id, |
| 545 | use_cache=False) |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 546 | if not tracker_helpers.CanEditProjectIssue( |
| 547 | mar, merge_into_project, merge_into_issue, mar.granted_perms): |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 548 | raise permissions.PermissionException( |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 549 | 'User is not allowed to merge into issue %s:%s' % |
| 550 | (merge_into_issue.project_name, merge_into_issue.local_id)) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 551 | updates_dict['merged_into'] = merge_into_issue.issue_id |
| 552 | (updates_dict['field_vals_add'], updates_dict['field_vals_remove'], |
| 553 | updates_dict['fields_clear'], updates_dict['fields_labels_add'], |
| 554 | updates_dict['fields_labels_remove']) = ( |
| 555 | api_pb2_v1_helpers.convert_field_values( |
| 556 | request.updates.fieldValues, mar, self._services)) |
| 557 | |
| 558 | field_helpers.ValidateCustomFields( |
| 559 | mar.cnxn, self._services, |
| 560 | (updates_dict.get('field_vals_add', []) + |
| 561 | updates_dict.get('field_vals_remove', [])), |
| 562 | mar.config, mar.project, ezt_errors=mar.errors) |
| 563 | if mar.errors.AnyErrors(): |
| 564 | raise endpoints.BadRequestException( |
| 565 | 'Invalid field values: %s' % mar.errors.custom_fields) |
| 566 | |
| 567 | updates_dict['labels_add'] = ( |
| 568 | updates_dict.get('labels_add', []) + |
| 569 | updates_dict.get('fields_labels_add', [])) |
| 570 | updates_dict['labels_remove'] = ( |
| 571 | updates_dict.get('labels_remove', []) + |
| 572 | updates_dict.get('fields_labels_remove', [])) |
| 573 | |
| 574 | # TODO(jrobbins): Stop using updates_dict in the first place. |
| 575 | delta = tracker_bizobj.MakeIssueDelta( |
| 576 | updates_dict.get('status'), |
| 577 | updates_dict.get('owner'), |
| 578 | updates_dict.get('cc_add', []), |
| 579 | updates_dict.get('cc_remove', []), |
| 580 | updates_dict.get('components_add', []), |
| 581 | updates_dict.get('components_remove', []), |
| 582 | (updates_dict.get('labels_add', []) + |
| 583 | updates_dict.get('fields_labels_add', [])), |
| 584 | (updates_dict.get('labels_remove', []) + |
| 585 | updates_dict.get('fields_labels_remove', [])), |
| 586 | updates_dict.get('field_vals_add', []), |
| 587 | updates_dict.get('field_vals_remove', []), |
| 588 | updates_dict.get('fields_clear', []), |
| 589 | updates_dict.get('blocked_on_add', []), |
| 590 | updates_dict.get('blocked_on_remove', []), |
| 591 | updates_dict.get('blocking_add', []), |
| 592 | updates_dict.get('blocking_remove', []), |
| 593 | updates_dict.get('merged_into'), |
| 594 | updates_dict.get('summary')) |
| 595 | |
| 596 | importer_id = None |
| 597 | reporter_id, timestamp = self.parse_imported_reporter(mar, request) |
| 598 | if reporter_id != mar.auth.user_id: |
| 599 | importer_id = mar.auth.user_id |
| 600 | |
| 601 | # TODO(jrobbins): Finish refactoring to make everything go through work_env. |
| 602 | _, comment = self._services.issue.DeltaUpdateIssue( |
| 603 | cnxn=mar.cnxn, services=self._services, |
| 604 | reporter_id=reporter_id, project_id=mar.project_id, config=mar.config, |
| 605 | issue=issue, delta=delta, index_now=False, comment=request.content, |
| 606 | is_description=updates_dict.get('is_description'), |
| 607 | timestamp=timestamp, importer_id=importer_id) |
| 608 | |
| 609 | move_comment = None |
| 610 | if move_to_project: |
| 611 | old_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id) |
| 612 | tracker_fulltext.UnindexIssues([issue.issue_id]) |
| 613 | moved_back_iids = self._services.issue.MoveIssues( |
| 614 | mar.cnxn, move_to_project, [issue], self._services.user) |
| 615 | new_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id) |
| 616 | if issue.issue_id in moved_back_iids: |
| 617 | content = 'Moved %s back to %s again.' % (old_text_ref, new_text_ref) |
| 618 | else: |
| 619 | content = 'Moved %s to now be %s.' % (old_text_ref, new_text_ref) |
| 620 | move_comment = self._services.issue.CreateIssueComment( |
| 621 | mar.cnxn, issue, mar.auth.user_id, content, amendments=[ |
| 622 | tracker_bizobj.MakeProjectAmendment(move_to_project.project_name)]) |
| 623 | |
| 624 | if 'merged_into' in updates_dict: |
| 625 | new_starrers = tracker_helpers.GetNewIssueStarrers( |
| 626 | mar.cnxn, self._services, [issue.issue_id], merge_into_issue.issue_id) |
| 627 | tracker_helpers.AddIssueStarrers( |
| 628 | mar.cnxn, self._services, mar, |
| 629 | merge_into_issue.issue_id, merge_into_project, new_starrers) |
| 630 | # Load target issue again to get the updated star count. |
| 631 | merge_into_issue = self._services.issue.GetIssue( |
| 632 | mar.cnxn, merge_into_issue.issue_id, use_cache=False) |
| 633 | merge_comment_pb = tracker_helpers.MergeCCsAndAddComment( |
| 634 | self._services, mar, issue, merge_into_issue) |
| 635 | hostport = framework_helpers.GetHostPort( |
| 636 | project_name=merge_into_issue.project_name) |
| 637 | send_notifications.PrepareAndSendIssueChangeNotification( |
| 638 | merge_into_issue.issue_id, hostport, |
| 639 | mar.auth.user_id, send_email=True, comment_id=merge_comment_pb.id) |
| 640 | |
| 641 | tracker_fulltext.IndexIssues( |
| 642 | mar.cnxn, [issue], self._services.user, self._services.issue, |
| 643 | self._services.config) |
| 644 | |
| 645 | comment = comment or move_comment |
| 646 | if comment is None: |
| 647 | return api_pb2_v1.IssuesCommentsInsertResponse() |
| 648 | |
| 649 | cmnts = self._services.issue.GetCommentsForIssue(mar.cnxn, issue.issue_id) |
| 650 | seq = len(cmnts) - 1 |
| 651 | |
| 652 | if request.sendEmail: |
| 653 | hostport = framework_helpers.GetHostPort(project_name=issue.project_name) |
| 654 | send_notifications.PrepareAndSendIssueChangeNotification( |
| 655 | issue.issue_id, hostport, comment.user_id, send_email=True, |
| 656 | old_owner_id=old_owner_id, comment_id=comment.id) |
| 657 | |
| 658 | issue_perms = permissions.UpdateIssuePermissions( |
| 659 | mar.perms, mar.project, issue, mar.auth.effective_ids, |
| 660 | granted_perms=mar.granted_perms) |
| 661 | commenter = self._services.user.GetUser(mar.cnxn, comment.user_id) |
| 662 | can_delete = permissions.CanDeleteComment( |
| 663 | comment, commenter, mar.auth.user_id, issue_perms) |
| 664 | return api_pb2_v1.IssuesCommentsInsertResponse( |
| 665 | id=seq, |
| 666 | kind='monorail#issueComment', |
| 667 | author=api_pb2_v1_helpers.convert_person( |
| 668 | comment.user_id, mar.cnxn, self._services), |
| 669 | content=comment.content, |
| 670 | published=datetime.datetime.fromtimestamp(comment.timestamp), |
| 671 | updates=api_pb2_v1_helpers.convert_amendments( |
| 672 | issue, comment.amendments, mar, self._services), |
| 673 | canDelete=can_delete) |
| 674 | |
| 675 | @monorail_api_method( |
| 676 | api_pb2_v1.ISSUES_COMMENTS_LIST_REQUEST_RESOURCE_CONTAINER, |
| 677 | api_pb2_v1.IssuesCommentsListResponse, |
| 678 | path='projects/{projectId}/issues/{issueId}/comments', |
| 679 | http_method='GET', |
| 680 | name='issues.comments.list') |
| 681 | def issues_comments_list(self, mar, request): |
| 682 | """List all comments for an issue.""" |
| 683 | issue = self._services.issue.GetIssueByLocalID( |
| 684 | mar.cnxn, mar.project_id, request.issueId) |
| 685 | comments = self._services.issue.GetCommentsForIssue( |
| 686 | mar.cnxn, issue.issue_id) |
| 687 | comments = [comment for comment in comments if not comment.approval_id] |
| 688 | visible_comments = [] |
| 689 | for comment in comments[ |
| 690 | request.startIndex:(request.startIndex + request.maxResults)]: |
| 691 | visible_comments.append( |
| 692 | api_pb2_v1_helpers.convert_comment( |
| 693 | issue, comment, mar, self._services, mar.granted_perms)) |
| 694 | |
| 695 | return api_pb2_v1.IssuesCommentsListResponse( |
| 696 | kind='monorail#issueCommentList', |
| 697 | totalResults=len(comments), |
| 698 | items=visible_comments) |
| 699 | |
| 700 | @monorail_api_method( |
| 701 | api_pb2_v1.ISSUES_COMMENTS_DELETE_REQUEST_RESOURCE_CONTAINER, |
| 702 | api_pb2_v1.IssuesCommentsDeleteResponse, |
| 703 | path='projects/{projectId}/issues/{issueId}/comments/{commentId}', |
| 704 | http_method='POST', |
| 705 | name='issues.comments.undelete') |
| 706 | def issues_comments_undelete(self, mar, request): |
| 707 | """Restore a deleted comment.""" |
| 708 | return self.aux_delete_comment(mar, request, False) |
| 709 | |
| 710 | @monorail_api_method( |
| 711 | api_pb2_v1.APPROVALS_COMMENTS_LIST_REQUEST_RESOURCE_CONTAINER, |
| 712 | api_pb2_v1.ApprovalsCommentsListResponse, |
| 713 | path='projects/{projectId}/issues/{issueId}/' |
| 714 | 'approvals/{approvalName}/comments', |
| 715 | http_method='GET', |
| 716 | name='approvals.comments.list') |
| 717 | def approvals_comments_list(self, mar, request): |
| 718 | """List all comments for an issue approval.""" |
| 719 | issue = self._services.issue.GetIssueByLocalID( |
| 720 | mar.cnxn, mar.project_id, request.issueId) |
| 721 | if not permissions.CanViewIssue( |
| 722 | mar.auth.effective_ids, mar.perms, mar.project, issue, |
| 723 | mar.granted_perms): |
| 724 | raise permissions.PermissionException( |
| 725 | 'User is not allowed to view this issue (%s, %d)' % |
| 726 | (request.projectId, request.issueId)) |
| 727 | config = self._services.config.GetProjectConfig(mar.cnxn, issue.project_id) |
| 728 | approval_fd = tracker_bizobj.FindFieldDef(request.approvalName, config) |
| 729 | if not approval_fd: |
| 730 | raise endpoints.BadRequestException( |
| 731 | 'Field definition for %s not found in project config' % |
| 732 | request.approvalName) |
| 733 | comments = self._services.issue.GetCommentsForIssue( |
| 734 | mar.cnxn, issue.issue_id) |
| 735 | comments = [comment for comment in comments |
| 736 | if comment.approval_id == approval_fd.field_id] |
| 737 | visible_comments = [] |
| 738 | for comment in comments[ |
| 739 | request.startIndex:(request.startIndex + request.maxResults)]: |
| 740 | visible_comments.append( |
| 741 | api_pb2_v1_helpers.convert_approval_comment( |
| 742 | issue, comment, mar, self._services, mar.granted_perms)) |
| 743 | |
| 744 | return api_pb2_v1.ApprovalsCommentsListResponse( |
| 745 | kind='monorail#approvalCommentList', |
| 746 | totalResults=len(comments), |
| 747 | items=visible_comments) |
| 748 | |
| 749 | @monorail_api_method( |
| 750 | api_pb2_v1.APPROVALS_COMMENTS_INSERT_REQUEST_RESOURCE_CONTAINER, |
| 751 | api_pb2_v1.ApprovalsCommentsInsertResponse, |
| 752 | path=("projects/{projectId}/issues/{issueId}/" |
| 753 | "approvals/{approvalName}/comments"), |
| 754 | http_method='POST', |
| 755 | name='approvals.comments.insert') |
| 756 | def approvals_comments_insert(self, mar, request): |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 757 | # type (...) -> mrproto.api_pb2_v1.ApprovalsCommentsInsertResponse |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 758 | """Add an approval comment.""" |
| 759 | approval_fd = tracker_bizobj.FindFieldDef( |
| 760 | request.approvalName, mar.config) |
| 761 | if not approval_fd or ( |
| 762 | approval_fd.field_type != tracker_pb2.FieldTypes.APPROVAL_TYPE): |
| 763 | raise endpoints.BadRequestException( |
| 764 | 'Field definition for %s not found in project config' % |
| 765 | request.approvalName) |
| 766 | try: |
| 767 | issue = self._services.issue.GetIssueByLocalID( |
| 768 | mar.cnxn, mar.project_id, request.issueId) |
| 769 | except exceptions.NoSuchIssueException: |
| 770 | raise endpoints.BadRequestException( |
| 771 | 'Issue %s:%s not found' % (request.projectId, request.issueId)) |
| 772 | approval = tracker_bizobj.FindApprovalValueByID( |
| 773 | approval_fd.field_id, issue.approval_values) |
| 774 | if not approval: |
| 775 | raise endpoints.BadRequestException( |
| 776 | 'Approval %s not found in issue.' % request.approvalName) |
| 777 | |
| 778 | if not permissions.CanCommentIssue( |
| 779 | mar.auth.effective_ids, mar.perms, mar.project, issue, |
| 780 | mar.granted_perms): |
| 781 | raise permissions.PermissionException( |
| 782 | 'User is not allowed to comment on this issue (%s, %d)' % |
| 783 | (request.projectId, request.issueId)) |
| 784 | |
| 785 | if request.content and len( |
| 786 | request.content) > tracker_constants.MAX_COMMENT_CHARS: |
| 787 | raise endpoints.BadRequestException( |
| 788 | 'Comment is too long on this issue (%s, %d' % |
| 789 | (request.projectId, request.issueId)) |
| 790 | |
| 791 | updates_dict = {} |
| 792 | if request.approvalUpdates: |
| 793 | if request.approvalUpdates.fieldValues: |
| 794 | # Block updating field values that don't belong to the approval. |
| 795 | approvals_fds_by_name = { |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 796 | fd.field_name.lower(): fd |
| 797 | for fd in mar.config.field_defs |
| 798 | if fd.approval_id == approval_fd.field_id |
| 799 | } |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 800 | for fv in request.approvalUpdates.fieldValues: |
| 801 | if approvals_fds_by_name.get(fv.fieldName.lower()) is None: |
| 802 | raise endpoints.BadRequestException( |
| 803 | 'Field defition for %s not found in %s subfields.' % |
| 804 | (fv.fieldName, request.approvalName)) |
| 805 | (updates_dict['field_vals_add'], updates_dict['field_vals_remove'], |
| 806 | updates_dict['fields_clear'], updates_dict['fields_labels_add'], |
| 807 | updates_dict['fields_labels_remove']) = ( |
| 808 | api_pb2_v1_helpers.convert_field_values( |
| 809 | request.approvalUpdates.fieldValues, mar, self._services)) |
| 810 | if request.approvalUpdates.approvers: |
| 811 | if not permissions.CanUpdateApprovers( |
| 812 | mar.auth.effective_ids, mar.perms, mar.project, |
| 813 | approval.approver_ids): |
| 814 | raise permissions.PermissionException( |
| 815 | 'User is not allowed to update approvers') |
| 816 | approvers_add, approvers_remove = api_pb2_v1_helpers.split_remove_add( |
| 817 | request.approvalUpdates.approvers) |
| 818 | updates_dict['approver_ids_add'] = list( |
| 819 | self._services.user.LookupUserIDs(mar.cnxn, approvers_add, |
| 820 | autocreate=True).values()) |
| 821 | updates_dict['approver_ids_remove'] = list( |
| 822 | self._services.user.LookupUserIDs(mar.cnxn, approvers_remove, |
| 823 | autocreate=True).values()) |
| 824 | if request.approvalUpdates.status: |
| 825 | status = tracker_pb2.ApprovalStatus( |
| 826 | api_pb2_v1.ApprovalStatus(request.approvalUpdates.status).number) |
| 827 | if not permissions.CanUpdateApprovalStatus( |
| 828 | mar.auth.effective_ids, mar.perms, mar.project, |
| 829 | approval.approver_ids, status): |
| 830 | raise permissions.PermissionException( |
| 831 | 'User is not allowed to make this status change') |
| 832 | updates_dict['status'] = status |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 833 | approval_delta = tracker_bizobj.MakeApprovalDelta( |
| 834 | updates_dict.get('status'), mar.auth.user_id, |
| 835 | updates_dict.get('approver_ids_add', []), |
| 836 | updates_dict.get('approver_ids_remove', []), |
| 837 | updates_dict.get('field_vals_add', []), |
| 838 | updates_dict.get('field_vals_remove', []), |
| 839 | updates_dict.get('fields_clear', []), |
| 840 | updates_dict.get('fields_labels_add', []), |
| 841 | updates_dict.get('fields_labels_remove', [])) |
| 842 | comment = self._services.issue.DeltaUpdateIssueApproval( |
| 843 | mar.cnxn, mar.auth.user_id, mar.config, issue, approval, approval_delta, |
| 844 | comment_content=request.content, |
| 845 | is_description=request.is_description) |
| 846 | |
| 847 | cmnts = self._services.issue.GetCommentsForIssue(mar.cnxn, issue.issue_id) |
| 848 | seq = len(cmnts) - 1 |
| 849 | |
| 850 | if request.sendEmail: |
| 851 | hostport = framework_helpers.GetHostPort(project_name=issue.project_name) |
| 852 | send_notifications.PrepareAndSendApprovalChangeNotification( |
| 853 | issue.issue_id, approval.approval_id, |
| 854 | hostport, comment.id, send_email=True) |
| 855 | |
| 856 | issue_perms = permissions.UpdateIssuePermissions( |
| 857 | mar.perms, mar.project, issue, mar.auth.effective_ids, |
| 858 | granted_perms=mar.granted_perms) |
| 859 | commenter = self._services.user.GetUser(mar.cnxn, comment.user_id) |
| 860 | can_delete = permissions.CanDeleteComment( |
| 861 | comment, commenter, mar.auth.user_id, issue_perms) |
| 862 | return api_pb2_v1.ApprovalsCommentsInsertResponse( |
| 863 | id=seq, |
| 864 | kind='monorail#approvalComment', |
| 865 | author=api_pb2_v1_helpers.convert_person( |
| 866 | comment.user_id, mar.cnxn, self._services), |
| 867 | content=comment.content, |
| 868 | published=datetime.datetime.fromtimestamp(comment.timestamp), |
| 869 | approvalUpdates=api_pb2_v1_helpers.convert_approval_amendments( |
| 870 | comment.amendments, mar, self._services), |
| 871 | canDelete=can_delete) |
| 872 | |
| 873 | @monorail_api_method( |
| 874 | api_pb2_v1.USERS_GET_REQUEST_RESOURCE_CONTAINER, |
| 875 | api_pb2_v1.UsersGetResponse, |
| 876 | path='users/{userId}', |
| 877 | http_method='GET', |
| 878 | name='users.get') |
| 879 | def users_get(self, mar, request): |
| 880 | """Get a user.""" |
| 881 | owner_project_only = request.ownerProjectsOnly |
| 882 | with work_env.WorkEnv(mar, self._services) as we: |
| 883 | (visible_ownership, visible_deleted, visible_membership, |
| 884 | visible_contrib) = we.GetUserProjects( |
| 885 | mar.viewed_user_auth.effective_ids) |
| 886 | |
| 887 | project_list = [] |
| 888 | for proj in (visible_ownership + visible_deleted): |
| 889 | config = self._services.config.GetProjectConfig( |
| 890 | mar.cnxn, proj.project_id) |
| 891 | templates = self._services.template.GetProjectTemplates( |
| 892 | mar.cnxn, config.project_id) |
| 893 | proj_result = api_pb2_v1_helpers.convert_project( |
| 894 | proj, config, api_pb2_v1.Role.owner, templates) |
| 895 | project_list.append(proj_result) |
| 896 | if not owner_project_only: |
| 897 | for proj in visible_membership: |
| 898 | config = self._services.config.GetProjectConfig( |
| 899 | mar.cnxn, proj.project_id) |
| 900 | templates = self._services.template.GetProjectTemplates( |
| 901 | mar.cnxn, config.project_id) |
| 902 | proj_result = api_pb2_v1_helpers.convert_project( |
| 903 | proj, config, api_pb2_v1.Role.member, templates) |
| 904 | project_list.append(proj_result) |
| 905 | for proj in visible_contrib: |
| 906 | config = self._services.config.GetProjectConfig( |
| 907 | mar.cnxn, proj.project_id) |
| 908 | templates = self._services.template.GetProjectTemplates( |
| 909 | mar.cnxn, config.project_id) |
| 910 | proj_result = api_pb2_v1_helpers.convert_project( |
| 911 | proj, config, api_pb2_v1.Role.contributor, templates) |
| 912 | project_list.append(proj_result) |
| 913 | |
| 914 | return api_pb2_v1.UsersGetResponse( |
| 915 | id=str(mar.viewed_user_auth.user_id), |
| 916 | kind='monorail#user', |
| 917 | projects=project_list, |
| 918 | ) |
| 919 | |
| 920 | @monorail_api_method( |
| 921 | api_pb2_v1.ISSUES_GET_REQUEST_RESOURCE_CONTAINER, |
| 922 | api_pb2_v1.IssuesGetInsertResponse, |
| 923 | path='projects/{projectId}/issues/{issueId}', |
| 924 | http_method='GET', |
| 925 | name='issues.get') |
| 926 | def issues_get(self, mar, request): |
| 927 | """Get an issue.""" |
| 928 | issue = self._services.issue.GetIssueByLocalID( |
| 929 | mar.cnxn, mar.project_id, request.issueId) |
| 930 | |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 931 | with work_env.WorkEnv(mar, self._services) as we: |
| 932 | migrated_id = we.GetIssueMigratedID( |
| 933 | request.projectId, request.issueId, issue.labels) |
| 934 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 935 | return api_pb2_v1_helpers.convert_issue( |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 936 | api_pb2_v1.IssuesGetInsertResponse, issue, mar, self._services, |
| 937 | migrated_id) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 938 | |
| 939 | @monorail_api_method( |
| 940 | api_pb2_v1.ISSUES_INSERT_REQUEST_RESOURCE_CONTAINER, |
| 941 | api_pb2_v1.IssuesGetInsertResponse, |
| 942 | path='projects/{projectId}/issues', |
| 943 | http_method='POST', |
| 944 | name='issues.insert') |
| 945 | def issues_insert(self, mar, request): |
| 946 | """Add a new issue.""" |
| 947 | if not mar.perms.CanUsePerm( |
| 948 | permissions.CREATE_ISSUE, mar.auth.effective_ids, mar.project, []): |
| 949 | raise permissions.PermissionException( |
| 950 | 'The requester %s is not allowed to create issues for project %s.' % |
| 951 | (mar.auth.email, mar.project_name)) |
| 952 | |
| 953 | with work_env.WorkEnv(mar, self._services) as we: |
| 954 | owner_id = framework_constants.NO_USER_SPECIFIED |
| 955 | if request.owner and request.owner.name: |
| 956 | try: |
| 957 | owner_id = self._services.user.LookupUserID( |
| 958 | mar.cnxn, request.owner.name) |
| 959 | except exceptions.NoSuchUserException: |
| 960 | raise endpoints.BadRequestException( |
| 961 | 'The specified owner %s does not exist.' % request.owner.name) |
| 962 | |
| 963 | cc_ids = [] |
| 964 | request.cc = [cc for cc in request.cc if cc] |
| 965 | if request.cc: |
| 966 | cc_ids = list(self._services.user.LookupUserIDs( |
| 967 | mar.cnxn, [ap.name for ap in request.cc], |
| 968 | autocreate=True).values()) |
| 969 | comp_ids = api_pb2_v1_helpers.convert_component_ids( |
| 970 | mar.config, request.components) |
| 971 | fields_add, _, _, fields_labels, _ = ( |
| 972 | api_pb2_v1_helpers.convert_field_values( |
| 973 | request.fieldValues, mar, self._services)) |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 974 | |
| 975 | field_helpers.ValidateLabels( |
| 976 | mar.cnxn, |
| 977 | self._services, |
| 978 | mar.project_id, |
| 979 | fields_labels, |
| 980 | ezt_errors=mar.errors) |
| 981 | if mar.errors.AnyErrors(): |
| 982 | raise endpoints.BadRequestException( |
| 983 | 'Invalid field values: %s' % mar.errors.labels) |
| 984 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 985 | field_helpers.ValidateCustomFields( |
| 986 | mar.cnxn, self._services, fields_add, mar.config, mar.project, |
| 987 | ezt_errors=mar.errors) |
| 988 | if mar.errors.AnyErrors(): |
| 989 | raise endpoints.BadRequestException( |
| 990 | 'Invalid field values: %s' % mar.errors.custom_fields) |
| 991 | |
| 992 | logging.info('request.author is %r', request.author) |
| 993 | reporter_id, timestamp = self.parse_imported_reporter(mar, request) |
| 994 | # To preserve previous behavior, do not raise filter rule errors. |
| 995 | try: |
| 996 | new_issue, _ = we.CreateIssue( |
| 997 | mar.project_id, |
| 998 | request.summary, |
| 999 | request.status, |
| 1000 | owner_id, |
| 1001 | cc_ids, |
| 1002 | request.labels + fields_labels, |
| 1003 | fields_add, |
| 1004 | comp_ids, |
| 1005 | request.description, |
| 1006 | blocked_on=api_pb2_v1_helpers.convert_issueref_pbs( |
| 1007 | request.blockedOn, mar, self._services), |
| 1008 | blocking=api_pb2_v1_helpers.convert_issueref_pbs( |
| 1009 | request.blocking, mar, self._services), |
| 1010 | reporter_id=reporter_id, |
| 1011 | timestamp=timestamp, |
| 1012 | send_email=request.sendEmail, |
| 1013 | raise_filter_errors=False) |
| 1014 | we.StarIssue(new_issue, True) |
| 1015 | except exceptions.InputException as e: |
| 1016 | raise endpoints.BadRequestException(str(e)) |
| 1017 | |
| 1018 | return api_pb2_v1_helpers.convert_issue( |
| 1019 | api_pb2_v1.IssuesGetInsertResponse, new_issue, mar, self._services) |
| 1020 | |
| 1021 | @monorail_api_method( |
| 1022 | api_pb2_v1.ISSUES_LIST_REQUEST_RESOURCE_CONTAINER, |
| 1023 | api_pb2_v1.IssuesListResponse, |
| 1024 | path='projects/{projectId}/issues', |
| 1025 | http_method='GET', |
| 1026 | name='issues.list') |
| 1027 | def issues_list(self, mar, request): |
| 1028 | """List issues for projects.""" |
| 1029 | if request.additionalProject: |
| 1030 | for project_name in request.additionalProject: |
| 1031 | project = self._services.project.GetProjectByName( |
| 1032 | mar.cnxn, project_name) |
| 1033 | if project and not permissions.UserCanViewProject( |
| 1034 | mar.auth.user_pb, mar.auth.effective_ids, project): |
| 1035 | raise permissions.PermissionException( |
| 1036 | 'The user %s has no permission for project %s' % |
| 1037 | (mar.auth.email, project_name)) |
| 1038 | # TODO(jrobbins): This should go through work_env. |
| 1039 | pipeline = frontendsearchpipeline.FrontendSearchPipeline( |
| 1040 | mar.cnxn, |
| 1041 | self._services, |
| 1042 | mar.auth, [mar.me_user_id], |
| 1043 | mar.query, |
| 1044 | mar.query_project_names, |
| 1045 | mar.num, |
| 1046 | mar.start, |
| 1047 | mar.can, |
| 1048 | mar.group_by_spec, |
| 1049 | mar.sort_spec, |
| 1050 | mar.warnings, |
| 1051 | mar.errors, |
| 1052 | mar.use_cached_searches, |
| 1053 | mar.profiler, |
| 1054 | project=mar.project) |
| 1055 | if not mar.errors.AnyErrors(): |
| 1056 | pipeline.SearchForIIDs() |
| 1057 | pipeline.MergeAndSortIssues() |
| 1058 | pipeline.Paginate() |
| 1059 | else: |
| 1060 | raise endpoints.BadRequestException(mar.errors.query) |
| 1061 | |
| 1062 | issue_list = [ |
| 1063 | api_pb2_v1_helpers.convert_issue( |
| 1064 | api_pb2_v1.IssueWrapper, r, mar, self._services) |
| 1065 | for r in pipeline.visible_results] |
| 1066 | return api_pb2_v1.IssuesListResponse( |
| 1067 | kind='monorail#issueList', |
| 1068 | totalResults=pipeline.total_count, |
| 1069 | items=issue_list) |
| 1070 | |
| 1071 | @monorail_api_method( |
| 1072 | api_pb2_v1.GROUPS_SETTINGS_LIST_REQUEST_RESOURCE_CONTAINER, |
| 1073 | api_pb2_v1.GroupsSettingsListResponse, |
| 1074 | path='groupsettings', |
| 1075 | http_method='GET', |
| 1076 | name='groups.settings.list') |
| 1077 | def groups_settings_list(self, mar, request): |
| 1078 | """List all group settings.""" |
| 1079 | all_groups = self._services.usergroup.GetAllUserGroupsInfo(mar.cnxn) |
| 1080 | group_settings = [] |
| 1081 | for g in all_groups: |
| 1082 | setting = g[2] |
| 1083 | wrapper = api_pb2_v1_helpers.convert_group_settings(g[0], setting) |
| 1084 | if not request.importedGroupsOnly or wrapper.ext_group_type: |
| 1085 | group_settings.append(wrapper) |
| 1086 | return api_pb2_v1.GroupsSettingsListResponse( |
| 1087 | groupSettings=group_settings) |
| 1088 | |
| 1089 | @monorail_api_method( |
| 1090 | api_pb2_v1.GROUPS_CREATE_REQUEST_RESOURCE_CONTAINER, |
| 1091 | api_pb2_v1.GroupsCreateResponse, |
| 1092 | path='groups', |
| 1093 | http_method='POST', |
| 1094 | name='groups.create') |
| 1095 | def groups_create(self, mar, request): |
| 1096 | """Create a new user group.""" |
| 1097 | if not permissions.CanCreateGroup(mar.perms): |
| 1098 | raise permissions.PermissionException( |
| 1099 | 'The user is not allowed to create groups.') |
| 1100 | |
| 1101 | user_dict = self._services.user.LookupExistingUserIDs( |
| 1102 | mar.cnxn, [request.groupName]) |
| 1103 | if request.groupName.lower() in user_dict: |
| 1104 | raise exceptions.GroupExistsException( |
| 1105 | 'group %s already exists' % request.groupName) |
| 1106 | |
| 1107 | if request.ext_group_type: |
| 1108 | ext_group_type = str(request.ext_group_type).lower() |
| 1109 | else: |
| 1110 | ext_group_type = None |
| 1111 | group_id = self._services.usergroup.CreateGroup( |
| 1112 | mar.cnxn, self._services, request.groupName, |
| 1113 | str(request.who_can_view_members).lower(), |
| 1114 | ext_group_type) |
| 1115 | |
| 1116 | return api_pb2_v1.GroupsCreateResponse( |
| 1117 | groupID=group_id) |
| 1118 | |
| 1119 | @monorail_api_method( |
| 1120 | api_pb2_v1.GROUPS_GET_REQUEST_RESOURCE_CONTAINER, |
| 1121 | api_pb2_v1.GroupsGetResponse, |
| 1122 | path='groups/{groupName}', |
| 1123 | http_method='GET', |
| 1124 | name='groups.get') |
| 1125 | def groups_get(self, mar, request): |
| 1126 | """Get a group's settings and users.""" |
| 1127 | if not mar.viewed_user_auth: |
| 1128 | raise exceptions.NoSuchUserException(request.groupName) |
| 1129 | group_id = mar.viewed_user_auth.user_id |
| 1130 | group_settings = self._services.usergroup.GetGroupSettings( |
| 1131 | mar.cnxn, group_id) |
| 1132 | member_ids, owner_ids = self._services.usergroup.LookupAllMembers( |
| 1133 | mar.cnxn, [group_id]) |
| 1134 | (owned_project_ids, membered_project_ids, |
| 1135 | contrib_project_ids) = self._services.project.GetUserRolesInAllProjects( |
| 1136 | mar.cnxn, mar.auth.effective_ids) |
| 1137 | project_ids = owned_project_ids.union( |
| 1138 | membered_project_ids).union(contrib_project_ids) |
| 1139 | if not permissions.CanViewGroupMembers( |
| 1140 | mar.perms, mar.auth.effective_ids, group_settings, member_ids[group_id], |
| 1141 | owner_ids[group_id], project_ids): |
| 1142 | raise permissions.PermissionException( |
| 1143 | 'The user is not allowed to view this group.') |
| 1144 | |
| 1145 | member_ids, owner_ids = self._services.usergroup.LookupMembers( |
| 1146 | mar.cnxn, [group_id]) |
| 1147 | |
| 1148 | member_emails = list(self._services.user.LookupUserEmails( |
| 1149 | mar.cnxn, member_ids[group_id]).values()) |
| 1150 | owner_emails = list(self._services.user.LookupUserEmails( |
| 1151 | mar.cnxn, owner_ids[group_id]).values()) |
| 1152 | |
| 1153 | return api_pb2_v1.GroupsGetResponse( |
| 1154 | groupID=group_id, |
| 1155 | groupSettings=api_pb2_v1_helpers.convert_group_settings( |
| 1156 | request.groupName, group_settings), |
| 1157 | groupOwners=owner_emails, |
| 1158 | groupMembers=member_emails) |
| 1159 | |
| 1160 | @monorail_api_method( |
| 1161 | api_pb2_v1.GROUPS_UPDATE_REQUEST_RESOURCE_CONTAINER, |
| 1162 | api_pb2_v1.GroupsUpdateResponse, |
| 1163 | path='groups/{groupName}', |
| 1164 | http_method='POST', |
| 1165 | name='groups.update') |
| 1166 | def groups_update(self, mar, request): |
| 1167 | """Update a group's settings and users.""" |
| 1168 | group_id = mar.viewed_user_auth.user_id |
| 1169 | member_ids_dict, owner_ids_dict = self._services.usergroup.LookupMembers( |
| 1170 | mar.cnxn, [group_id]) |
| 1171 | owner_ids = owner_ids_dict.get(group_id, []) |
| 1172 | member_ids = member_ids_dict.get(group_id, []) |
| 1173 | if not permissions.CanEditGroup( |
| 1174 | mar.perms, mar.auth.effective_ids, owner_ids): |
| 1175 | raise permissions.PermissionException( |
| 1176 | 'The user is not allowed to edit this group.') |
| 1177 | |
| 1178 | group_settings = self._services.usergroup.GetGroupSettings( |
| 1179 | mar.cnxn, group_id) |
| 1180 | if (request.who_can_view_members or request.ext_group_type |
| 1181 | or request.last_sync_time or request.friend_projects): |
| 1182 | group_settings.who_can_view_members = ( |
| 1183 | request.who_can_view_members or group_settings.who_can_view_members) |
| 1184 | group_settings.ext_group_type = ( |
| 1185 | request.ext_group_type or group_settings.ext_group_type) |
| 1186 | group_settings.last_sync_time = ( |
| 1187 | request.last_sync_time or group_settings.last_sync_time) |
| 1188 | if framework_constants.NO_VALUES in request.friend_projects: |
| 1189 | group_settings.friend_projects = [] |
| 1190 | else: |
| 1191 | id_dict = self._services.project.LookupProjectIDs( |
| 1192 | mar.cnxn, request.friend_projects) |
| 1193 | group_settings.friend_projects = ( |
| 1194 | list(id_dict.values()) or group_settings.friend_projects) |
| 1195 | self._services.usergroup.UpdateSettings( |
| 1196 | mar.cnxn, group_id, group_settings) |
| 1197 | |
| 1198 | if request.groupOwners or request.groupMembers: |
| 1199 | self._services.usergroup.RemoveMembers( |
| 1200 | mar.cnxn, group_id, owner_ids + member_ids) |
| 1201 | owners_dict = self._services.user.LookupUserIDs( |
| 1202 | mar.cnxn, request.groupOwners, autocreate=True) |
| 1203 | self._services.usergroup.UpdateMembers( |
| 1204 | mar.cnxn, group_id, list(owners_dict.values()), 'owner') |
| 1205 | members_dict = self._services.user.LookupUserIDs( |
| 1206 | mar.cnxn, request.groupMembers, autocreate=True) |
| 1207 | self._services.usergroup.UpdateMembers( |
| 1208 | mar.cnxn, group_id, list(members_dict.values()), 'member') |
| 1209 | |
| 1210 | return api_pb2_v1.GroupsUpdateResponse() |
| 1211 | |
| 1212 | @monorail_api_method( |
| 1213 | api_pb2_v1.COMPONENTS_LIST_REQUEST_RESOURCE_CONTAINER, |
| 1214 | api_pb2_v1.ComponentsListResponse, |
| 1215 | path='projects/{projectId}/components', |
| 1216 | http_method='GET', |
| 1217 | name='components.list') |
| 1218 | def components_list(self, mar, _request): |
| 1219 | """List all components of a given project.""" |
| 1220 | config = self._services.config.GetProjectConfig(mar.cnxn, mar.project_id) |
| 1221 | components = [api_pb2_v1_helpers.convert_component_def( |
| 1222 | cd, mar, self._services) for cd in config.component_defs] |
| 1223 | return api_pb2_v1.ComponentsListResponse( |
| 1224 | components=components) |
| 1225 | |
| 1226 | @monorail_api_method( |
| 1227 | api_pb2_v1.COMPONENTS_CREATE_REQUEST_RESOURCE_CONTAINER, |
| 1228 | api_pb2_v1.Component, |
| 1229 | path='projects/{projectId}/components', |
| 1230 | http_method='POST', |
| 1231 | name='components.create') |
| 1232 | def components_create(self, mar, request): |
| 1233 | """Create a component.""" |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1234 | if not permissions.CanEditProjectConfig(mar, self._services): |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1235 | raise permissions.PermissionException( |
| 1236 | 'User is not allowed to create components for this project') |
| 1237 | |
| 1238 | config = self._services.config.GetProjectConfig(mar.cnxn, mar.project_id) |
| 1239 | leaf_name = request.componentName |
| 1240 | if not tracker_constants.COMPONENT_NAME_RE.match(leaf_name): |
| 1241 | raise exceptions.InvalidComponentNameException( |
| 1242 | 'The component name %s is invalid.' % leaf_name) |
| 1243 | |
| 1244 | parent_path = request.parentPath |
| 1245 | if parent_path: |
| 1246 | parent_def = tracker_bizobj.FindComponentDef(parent_path, config) |
| 1247 | if not parent_def: |
| 1248 | raise exceptions.NoSuchComponentException( |
| 1249 | 'Parent component %s does not exist.' % parent_path) |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1250 | if not permissions.CanEditComponentDef(mar, self._services, parent_def, |
| 1251 | config): |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1252 | raise permissions.PermissionException( |
| 1253 | 'User is not allowed to add a subcomponent to component %s' % |
| 1254 | parent_path) |
| 1255 | |
| 1256 | path = '%s>%s' % (parent_path, leaf_name) |
| 1257 | else: |
| 1258 | path = leaf_name |
| 1259 | |
| 1260 | if tracker_bizobj.FindComponentDef(path, config): |
| 1261 | raise exceptions.InvalidComponentNameException( |
| 1262 | 'The name %s is already in use.' % path) |
| 1263 | |
| 1264 | created = int(time.time()) |
| 1265 | user_emails = set() |
| 1266 | user_emails.update([mar.auth.email] + request.admin + request.cc) |
| 1267 | user_ids_dict = self._services.user.LookupUserIDs( |
| 1268 | mar.cnxn, list(user_emails), autocreate=False) |
| 1269 | request.admin = [admin for admin in request.admin if admin] |
| 1270 | admin_ids = [user_ids_dict[uname] for uname in request.admin] |
| 1271 | request.cc = [cc for cc in request.cc if cc] |
| 1272 | cc_ids = [user_ids_dict[uname] for uname in request.cc] |
| 1273 | label_ids = [] # TODO(jrobbins): allow API clients to specify this too. |
| 1274 | |
| 1275 | component_id = self._services.config.CreateComponentDef( |
| 1276 | mar.cnxn, mar.project_id, path, request.description, request.deprecated, |
| 1277 | admin_ids, cc_ids, created, user_ids_dict[mar.auth.email], label_ids) |
| 1278 | |
| 1279 | return api_pb2_v1.Component( |
| 1280 | componentId=component_id, |
| 1281 | projectName=request.projectId, |
| 1282 | componentPath=path, |
| 1283 | description=request.description, |
| 1284 | admin=request.admin, |
| 1285 | cc=request.cc, |
| 1286 | deprecated=request.deprecated, |
| 1287 | created=datetime.datetime.fromtimestamp(created), |
| 1288 | creator=mar.auth.email) |
| 1289 | |
| 1290 | @monorail_api_method( |
| 1291 | api_pb2_v1.COMPONENTS_DELETE_REQUEST_RESOURCE_CONTAINER, |
| 1292 | message_types.VoidMessage, |
| 1293 | path='projects/{projectId}/components/{componentPath}', |
| 1294 | http_method='DELETE', |
| 1295 | name='components.delete') |
| 1296 | def components_delete(self, mar, request): |
| 1297 | """Delete a component.""" |
| 1298 | config = self._services.config.GetProjectConfig(mar.cnxn, mar.project_id) |
| 1299 | component_path = request.componentPath |
| 1300 | component_def = tracker_bizobj.FindComponentDef( |
| 1301 | component_path, config) |
| 1302 | if not component_def: |
| 1303 | raise exceptions.NoSuchComponentException( |
| 1304 | 'The component %s does not exist.' % component_path) |
| 1305 | if not permissions.CanViewComponentDef( |
| 1306 | mar.auth.effective_ids, mar.perms, mar.project, component_def): |
| 1307 | raise permissions.PermissionException( |
| 1308 | 'User is not allowed to view this component %s' % component_path) |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1309 | if not permissions.CanEditComponentDef(mar, self._services, component_def, |
| 1310 | config): |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1311 | raise permissions.PermissionException( |
| 1312 | 'User is not allowed to delete this component %s' % component_path) |
| 1313 | |
| 1314 | allow_delete = not tracker_bizobj.FindDescendantComponents( |
| 1315 | config, component_def) |
| 1316 | if not allow_delete: |
| 1317 | raise permissions.PermissionException( |
| 1318 | 'User tried to delete component that had subcomponents') |
| 1319 | |
| 1320 | self._services.issue.DeleteComponentReferences( |
| 1321 | mar.cnxn, component_def.component_id) |
| 1322 | self._services.config.DeleteComponentDef( |
| 1323 | mar.cnxn, mar.project_id, component_def.component_id) |
| 1324 | return message_types.VoidMessage() |
| 1325 | |
| 1326 | @monorail_api_method( |
| 1327 | api_pb2_v1.COMPONENTS_UPDATE_REQUEST_RESOURCE_CONTAINER, |
| 1328 | message_types.VoidMessage, |
| 1329 | path='projects/{projectId}/components/{componentPath}', |
| 1330 | http_method='POST', |
| 1331 | name='components.update') |
| 1332 | def components_update(self, mar, request): |
| 1333 | """Update a component.""" |
| 1334 | config = self._services.config.GetProjectConfig(mar.cnxn, mar.project_id) |
| 1335 | component_path = request.componentPath |
| 1336 | component_def = tracker_bizobj.FindComponentDef( |
| 1337 | component_path, config) |
| 1338 | if not component_def: |
| 1339 | raise exceptions.NoSuchComponentException( |
| 1340 | 'The component %s does not exist.' % component_path) |
| 1341 | if not permissions.CanViewComponentDef( |
| 1342 | mar.auth.effective_ids, mar.perms, mar.project, component_def): |
| 1343 | raise permissions.PermissionException( |
| 1344 | 'User is not allowed to view this component %s' % component_path) |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1345 | if not permissions.CanEditComponentDef(mar, self._services, component_def, |
| 1346 | config): |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1347 | raise permissions.PermissionException( |
| 1348 | 'User is not allowed to edit this component %s' % component_path) |
| 1349 | |
| 1350 | original_path = component_def.path |
| 1351 | new_path = component_def.path |
| 1352 | new_docstring = component_def.docstring |
| 1353 | new_deprecated = component_def.deprecated |
| 1354 | new_admin_ids = component_def.admin_ids |
| 1355 | new_cc_ids = component_def.cc_ids |
| 1356 | update_filterrule = False |
| 1357 | for update in request.updates: |
| 1358 | if update.field == api_pb2_v1.ComponentUpdateFieldID.LEAF_NAME: |
| 1359 | leaf_name = update.leafName |
| 1360 | if not tracker_constants.COMPONENT_NAME_RE.match(leaf_name): |
| 1361 | raise exceptions.InvalidComponentNameException( |
| 1362 | 'The component name %s is invalid.' % leaf_name) |
| 1363 | |
| 1364 | if '>' in original_path: |
| 1365 | parent_path = original_path[:original_path.rindex('>')] |
| 1366 | new_path = '%s>%s' % (parent_path, leaf_name) |
| 1367 | else: |
| 1368 | new_path = leaf_name |
| 1369 | |
| 1370 | conflict = tracker_bizobj.FindComponentDef(new_path, config) |
| 1371 | if conflict and conflict.component_id != component_def.component_id: |
| 1372 | raise exceptions.InvalidComponentNameException( |
| 1373 | 'The name %s is already in use.' % new_path) |
| 1374 | update_filterrule = True |
| 1375 | elif update.field == api_pb2_v1.ComponentUpdateFieldID.DESCRIPTION: |
| 1376 | new_docstring = update.description |
| 1377 | elif update.field == api_pb2_v1.ComponentUpdateFieldID.ADMIN: |
| 1378 | user_ids_dict = self._services.user.LookupUserIDs( |
| 1379 | mar.cnxn, list(update.admin), autocreate=True) |
| 1380 | new_admin_ids = list(set(user_ids_dict.values())) |
| 1381 | elif update.field == api_pb2_v1.ComponentUpdateFieldID.CC: |
| 1382 | user_ids_dict = self._services.user.LookupUserIDs( |
| 1383 | mar.cnxn, list(update.cc), autocreate=True) |
| 1384 | new_cc_ids = list(set(user_ids_dict.values())) |
| 1385 | update_filterrule = True |
| 1386 | elif update.field == api_pb2_v1.ComponentUpdateFieldID.DEPRECATED: |
| 1387 | new_deprecated = update.deprecated |
| 1388 | else: |
| 1389 | logging.error('Unknown component field %r', update.field) |
| 1390 | |
| 1391 | new_modified = int(time.time()) |
| 1392 | new_modifier_id = self._services.user.LookupUserID( |
| 1393 | mar.cnxn, mar.auth.email, autocreate=False) |
| 1394 | logging.info( |
| 1395 | 'Updating component id %d: path-%s, docstring-%s, deprecated-%s,' |
| 1396 | ' admin_ids-%s, cc_ids-%s modified by %s', component_def.component_id, |
| 1397 | new_path, new_docstring, new_deprecated, new_admin_ids, new_cc_ids, |
| 1398 | new_modifier_id) |
| 1399 | self._services.config.UpdateComponentDef( |
| 1400 | mar.cnxn, mar.project_id, component_def.component_id, |
| 1401 | path=new_path, docstring=new_docstring, deprecated=new_deprecated, |
| 1402 | admin_ids=new_admin_ids, cc_ids=new_cc_ids, modified=new_modified, |
| 1403 | modifier_id=new_modifier_id) |
| 1404 | |
| 1405 | # TODO(sheyang): reuse the code in componentdetails |
| 1406 | if original_path != new_path: |
| 1407 | # If the name changed then update all of its subcomponents as well. |
| 1408 | subcomponent_ids = tracker_bizobj.FindMatchingComponentIDs( |
| 1409 | original_path, config, exact=False) |
| 1410 | for subcomponent_id in subcomponent_ids: |
| 1411 | if subcomponent_id == component_def.component_id: |
| 1412 | continue |
| 1413 | subcomponent_def = tracker_bizobj.FindComponentDefByID( |
| 1414 | subcomponent_id, config) |
| 1415 | subcomponent_new_path = subcomponent_def.path.replace( |
| 1416 | original_path, new_path, 1) |
| 1417 | self._services.config.UpdateComponentDef( |
| 1418 | mar.cnxn, mar.project_id, subcomponent_def.component_id, |
| 1419 | path=subcomponent_new_path) |
| 1420 | |
| 1421 | if update_filterrule: |
| 1422 | filterrules_helpers.RecomputeAllDerivedFields( |
| 1423 | mar.cnxn, self._services, mar.project, config) |
| 1424 | |
| 1425 | return message_types.VoidMessage() |
| 1426 | |
| 1427 | |
| 1428 | @endpoints.api(name='monorail_client_configs', version='v1', |
| 1429 | description='Monorail API client configs.') |
| 1430 | class ClientConfigApi(remote.Service): |
| 1431 | |
| 1432 | # Class variables. Handy to mock. |
| 1433 | _services = None |
| 1434 | _mar = None |
| 1435 | |
| 1436 | @classmethod |
| 1437 | def _set_services(cls, services): |
| 1438 | cls._services = services |
| 1439 | |
| 1440 | def mar_factory(self, request, cnxn): |
| 1441 | if not self._mar: |
| 1442 | self._mar = monorailrequest.MonorailApiRequest( |
| 1443 | request, self._services, cnxn=cnxn) |
| 1444 | return self._mar |
| 1445 | |
| 1446 | @endpoints.method( |
| 1447 | message_types.VoidMessage, |
| 1448 | message_types.VoidMessage, |
| 1449 | path='client_configs', |
| 1450 | http_method='POST', |
| 1451 | name='client_configs.update') |
| 1452 | def client_configs_update(self, request): |
| 1453 | if self._services is None: |
| 1454 | self._set_services(service_manager.set_up_services()) |
| 1455 | mar = self.mar_factory(request, sql.MonorailConnection()) |
| 1456 | if not mar.perms.HasPerm(permissions.ADMINISTER_SITE, None, None): |
| 1457 | raise permissions.PermissionException( |
| 1458 | 'The requester %s is not allowed to update client configs.' % |
| 1459 | mar.auth.email) |
| 1460 | |
| 1461 | ROLE_DICT = { |
| 1462 | 1: permissions.COMMITTER_ROLE, |
| 1463 | 2: permissions.CONTRIBUTOR_ROLE, |
| 1464 | } |
| 1465 | |
| 1466 | client_config = client_config_svc.GetClientConfigSvc() |
| 1467 | |
| 1468 | cfg = client_config.GetConfigs() |
| 1469 | if not cfg: |
| 1470 | msg = 'Failed to fetch client configs.' |
| 1471 | logging.error(msg) |
| 1472 | raise endpoints.InternalServerErrorException(msg) |
| 1473 | |
| 1474 | for client in cfg.clients: |
| 1475 | if not client.client_email: |
| 1476 | continue |
| 1477 | # 1: create the user if non-existent |
| 1478 | user_id = self._services.user.LookupUserID( |
| 1479 | mar.cnxn, client.client_email, autocreate=True) |
| 1480 | user_pb = self._services.user.GetUser(mar.cnxn, user_id) |
| 1481 | |
| 1482 | logging.info('User ID %d for email %s', user_id, client.client_email) |
| 1483 | |
| 1484 | # 2: set period and lifetime limit |
| 1485 | # new_soft_limit, new_hard_limit, new_lifetime_limit |
| 1486 | new_limit_tuple = ( |
| 1487 | client.period_limit, client.period_limit, client.lifetime_limit) |
| 1488 | action_limit_updates = {'api_request': new_limit_tuple} |
| 1489 | self._services.user.UpdateUserSettings( |
| 1490 | mar.cnxn, user_id, user_pb, action_limit_updates=action_limit_updates) |
| 1491 | |
| 1492 | logging.info('Updated api request limit %r', new_limit_tuple) |
| 1493 | |
| 1494 | # 3: Update project role and extra perms |
| 1495 | projects_dict = self._services.project.GetAllProjects(mar.cnxn) |
| 1496 | project_name_to_ids = { |
| 1497 | p.project_name: p.project_id for p in projects_dict.values()} |
| 1498 | |
| 1499 | # Set project role and extra perms |
| 1500 | for perm in client.project_permissions: |
| 1501 | project_ids = self._GetProjectIDs(perm.project, project_name_to_ids) |
| 1502 | logging.info('Matching projects %r for name %s', |
| 1503 | project_ids, perm.project) |
| 1504 | |
| 1505 | role = ROLE_DICT[perm.role] |
| 1506 | for p_id in project_ids: |
| 1507 | project = projects_dict[p_id] |
| 1508 | people_list = [] |
| 1509 | if role == 'owner': |
| 1510 | people_list = project.owner_ids |
| 1511 | elif role == 'committer': |
| 1512 | people_list = project.committer_ids |
| 1513 | elif role == 'contributor': |
| 1514 | people_list = project.contributor_ids |
| 1515 | # Onlu update role/extra perms iff changed |
| 1516 | if not user_id in people_list: |
| 1517 | logging.info('Update project %s role %s for user %s', |
| 1518 | project.project_name, role, client.client_email) |
| 1519 | owner_ids, committer_ids, contributor_ids = ( |
| 1520 | project_helpers.MembersWithGivenIDs(project, {user_id}, role)) |
| 1521 | self._services.project.UpdateProjectRoles( |
| 1522 | mar.cnxn, p_id, owner_ids, committer_ids, |
| 1523 | contributor_ids) |
| 1524 | if perm.extra_permissions: |
| 1525 | logging.info('Update project %s extra perm %s for user %s', |
| 1526 | project.project_name, perm.extra_permissions, |
| 1527 | client.client_email) |
| 1528 | self._services.project.UpdateExtraPerms( |
| 1529 | mar.cnxn, p_id, user_id, list(perm.extra_permissions)) |
| 1530 | |
| 1531 | mar.CleanUp() |
| 1532 | return message_types.VoidMessage() |
| 1533 | |
| 1534 | def _GetProjectIDs(self, project_str, project_name_to_ids): |
| 1535 | result = [] |
| 1536 | if any(ch in project_str for ch in ['*', '+', '?', '.']): |
| 1537 | pattern = re.compile(project_str) |
| 1538 | for p_name in project_name_to_ids.keys(): |
| 1539 | if pattern.match(p_name): |
| 1540 | project_id = project_name_to_ids.get(p_name) |
| 1541 | if project_id: |
| 1542 | result.append(project_id) |
| 1543 | else: |
| 1544 | project_id = project_name_to_ids.get(project_str) |
| 1545 | if project_id: |
| 1546 | result.append(project_id) |
| 1547 | |
| 1548 | if not result: |
| 1549 | logging.warning('Cannot find projects for specified name %s', |
| 1550 | project_str) |
| 1551 | return result |