blob: 883d69fc7aa42a57771186e17e65ccce335c4254 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001# 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.
Copybara854996b2021-09-07 19:36:02 +00004
5"""API service.
6
7To manually test this API locally, use the following steps:
81. Start the development server via 'make serve'.
92. 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
123. Visit http://localhost:8080/_ah/api/explorer
134. Click shield icon in the omnibar and allow unsafe scripts.
145. Click on the "Services" menu item in the API Explorer.
15"""
16from __future__ import print_function
17from __future__ import division
18from __future__ import absolute_import
19
20import calendar
21import datetime
22import endpoints
23import functools
24import logging
25import re
26import time
27from google.appengine.api import oauth
28from protorpc import message_types
29from protorpc import protojson
30from protorpc import remote
31
32import settings
33from businesslogic import work_env
34from features import filterrules_helpers
35from features import send_notifications
36from framework import authdata
37from framework import exceptions
38from framework import framework_constants
39from framework import framework_helpers
40from framework import framework_views
41from framework import monitoring
42from framework import monorailrequest
43from framework import permissions
44from framework import ratelimiter
45from framework import sql
46from project import project_helpers
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010047from mrproto import api_pb2_v1
48from mrproto import project_pb2
49from mrproto import tracker_pb2
Copybara854996b2021-09-07 19:36:02 +000050from search import frontendsearchpipeline
51from services import api_pb2_v1_helpers
52from services import client_config_svc
53from services import service_manager
54from services import tracker_fulltext
55from sitewide import sitewide_helpers
56from tracker import field_helpers
57from tracker import issuedetailezt
58from tracker import tracker_bizobj
59from tracker import tracker_constants
60from tracker import tracker_helpers
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010061from redirect import redirect_utils
Copybara854996b2021-09-07 19:36:02 +000062
63from infra_libs import ts_mon
64
65
66ENDPOINTS_API_NAME = 'monorail'
67DOC_URL = (
68 'https://chromium.googlesource.com/infra/infra/+/main/'
69 'appengine/monorail/doc/api.md')
70
71
72def 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ínezac4a6442022-05-15 19:05:13 +0200111 'endpoints', c_id, client_email=c_email, handler=method_name)
Copybara854996b2021-09-07 19:36:02 +0000112 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
164def _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
193def _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
202def 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ínezf19ea432024-01-23 20:20:52 +0100287 # 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):
Copybara854996b2021-09-07 19:36:02 +0000290 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.',
Copybara854996b2021-09-07 19:36:02 +0000319 allowed_client_ids=endpoints.SKIP_CLIENT_ID_CHECK,
320 documentation=DOC_URL)
321class 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ínezf19ea432024-01-23 20:20:52 +0100418 # type (...) -> mrproto.api_pb2_v1.IssuesCommentsInsertResponse
Copybara854996b2021-09-07 19:36:02 +0000419 """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ínezf19ea432024-01-23 20:20:52 +0100433 fds_by_name = {fd.field_name.lower(): fd for fd in mar.config.field_defs}
Copybara854996b2021-09-07 19:36:02 +0000434 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ínezf19ea432024-01-23 20:20:52 +0100489
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
Copybara854996b2021-09-07 19:36:02 +0000500 blocked_on_add_strs, blocked_on_remove_strs = (
501 api_pb2_v1_helpers.split_remove_add(request.updates.blockedOn))
Copybara854996b2021-09-07 19:36:02 +0000502 blocking_add_strs, blocking_remove_strs = (
503 api_pb2_v1_helpers.split_remove_add(request.updates.blocking))
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100504 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
Copybara854996b2021-09-07 19:36:02 +0000529 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ínezf19ea432024-01-23 20:20:52 +0100546 if not tracker_helpers.CanEditProjectIssue(
547 mar, merge_into_project, merge_into_issue, mar.granted_perms):
Copybara854996b2021-09-07 19:36:02 +0000548 raise permissions.PermissionException(
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100549 'User is not allowed to merge into issue %s:%s' %
550 (merge_into_issue.project_name, merge_into_issue.local_id))
Copybara854996b2021-09-07 19:36:02 +0000551 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ínezf19ea432024-01-23 20:20:52 +0100757 # type (...) -> mrproto.api_pb2_v1.ApprovalsCommentsInsertResponse
Copybara854996b2021-09-07 19:36:02 +0000758 """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ínezf19ea432024-01-23 20:20:52 +0100796 fd.field_name.lower(): fd
797 for fd in mar.config.field_defs
798 if fd.approval_id == approval_fd.field_id
799 }
Copybara854996b2021-09-07 19:36:02 +0000800 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
Copybara854996b2021-09-07 19:36:02 +0000833 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ínezf19ea432024-01-23 20:20:52 +0100931 with work_env.WorkEnv(mar, self._services) as we:
932 migrated_id = we.GetIssueMigratedID(
933 request.projectId, request.issueId, issue.labels)
934
Copybara854996b2021-09-07 19:36:02 +0000935 return api_pb2_v1_helpers.convert_issue(
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100936 api_pb2_v1.IssuesGetInsertResponse, issue, mar, self._services,
937 migrated_id)
Copybara854996b2021-09-07 19:36:02 +0000938
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ínezf19ea432024-01-23 20:20:52 +0100974
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
Copybara854996b2021-09-07 19:36:02 +0000985 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ínezf19ea432024-01-23 20:20:52 +01001234 if not permissions.CanEditProjectConfig(mar, self._services):
Copybara854996b2021-09-07 19:36:02 +00001235 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ínezf19ea432024-01-23 20:20:52 +01001250 if not permissions.CanEditComponentDef(mar, self._services, parent_def,
1251 config):
Copybara854996b2021-09-07 19:36:02 +00001252 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ínezf19ea432024-01-23 20:20:52 +01001309 if not permissions.CanEditComponentDef(mar, self._services, component_def,
1310 config):
Copybara854996b2021-09-07 19:36:02 +00001311 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ínezf19ea432024-01-23 20:20:52 +01001345 if not permissions.CanEditComponentDef(mar, self._services, component_def,
1346 config):
Copybara854996b2021-09-07 19:36:02 +00001347 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.')
1430class 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