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