blob: 58d52cb978706f6e9533b3c05ddc299357a1c415 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001# Copyright 2017 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"""WorkEnv is a context manager and API for high-level operations.
6
7A work environment is used by request handlers for the legacy UI, v1
8API, and v2 API. The WorkEnvironment operations are a common code
9path that does permission checking, input validation, coordination of
10service-level calls, follow-up tasks (e.g., triggering
11notifications after certain operations) and other systemic
12functionality so that that code is not duplicated in multiple request
13handlers.
14
15Responsibilities of request handers (legacy UI and external API) and associated
16frameworks:
17+ API: check oauth client allowlist or XSRF token
18+ Rate-limiting
19+ Create a MonorailContext (or MonorailRequest) object:
20 - Parse the request, including syntactic validation, e.g, non-negative ints
21 - Authenticate the requesting user
22+ Call the WorkEnvironment to perform the requested action
23 - Catch exceptions and generate error messages
24+ UI: Decide screen flow, and on-page online-help
25+ Render the result business objects as UI HTML or API response protobufs
26
27Responsibilities of WorkEnv:
28+ Most monitoring, profiling, and logging
29+ Apply business rules:
30 - Check permissions
31 - Every GetFoo/GetFoosDict method will assert that the user can view Foo(s)
32 - Detailed validation of request parameters
33 - Raise exceptions to indicate problems
34+ Make coordinated calls to the services layer to make DB changes
35 - E.g., calls may need to be made in a specific order
36+ Enqueue tasks for background follow-up work:
37 - E.g., email notifications
38
39Responsibilities of the Services layer:
40+ Individual CRUD operations on objects in the database
41 - Each services class should be independent of others
42+ App-specific interface around external services:
43 - E.g., GAE search, GCS, monorail-predict
44+ Business object caches
45+ Breaking large operations into batches as appropriate for the underlying
46 data storage service, e.g., DB shards and search engine indexing.
47"""
48from __future__ import print_function
49from __future__ import division
50from __future__ import absolute_import
51
52import collections
53import itertools
54import logging
55import time
56
57import settings
58from features import features_constants
59from features import filterrules_helpers
60from features import send_notifications
61from features import features_bizobj
62from features import hotlist_helpers
63from framework import authdata
64from framework import exceptions
65from framework import framework_bizobj
66from framework import framework_constants
67from framework import framework_helpers
68from framework import framework_views
69from framework import permissions
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010070from redirect import redirectissue
Copybara854996b2021-09-07 19:36:02 +000071from search import frontendsearchpipeline
72from services import features_svc
73from services import tracker_fulltext
74from sitewide import sitewide_helpers
75from tracker import field_helpers
76from tracker import rerank_helpers
77from tracker import field_helpers
78from tracker import tracker_bizobj
79from tracker import tracker_constants
80from tracker import tracker_helpers
81from project import project_helpers
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010082from mrproto import features_pb2
83from mrproto import project_pb2
84from mrproto import tracker_pb2
85from mrproto import user_pb2
Copybara854996b2021-09-07 19:36:02 +000086
87
88# TODO(jrobbins): break this file into one facade plus ~5
89# implementation parts that roughly correspond to services files.
90
91# ListResult is returned in List/Search methods to bundle the requested
92# items and the next start index for a subsequent request. If there are
93# no more items to be fetched, `next_start` should be None.
94ListResult = collections.namedtuple('ListResult', ['items', 'next_start'])
95# type: (Sequence[Object], Optional[int]) -> None
96
97# Comments added to issues impacted by another issue's mergedInto change.
98UNMERGE_COMMENT = 'Issue %s has been un-merged from this issue.\n'
99MERGE_COMMENT = 'Issue %s has been merged into this issue.\n'
100
101
102class WorkEnv(object):
103
104 def __init__(self, mc, services, phase=None):
105 self.mc = mc
106 self.services = services
107 self.phase = phase
108
109 def __enter__(self):
110 if self.mc.profiler and self.phase:
111 self.mc.profiler.StartPhase(name=self.phase)
112 return self # The instance of this class is the context object.
113
114 def __exit__(self, exception_type, value, traceback):
115 if self.mc.profiler and self.phase:
116 self.mc.profiler.EndPhase()
117 return False # Re-raise any exception in the with-block.
118
119 def _UserCanViewProject(self, project):
120 """Test if the user may view the given project."""
121 return permissions.UserCanViewProject(
122 self.mc.auth.user_pb, self.mc.auth.effective_ids, project)
123
124 def _FilterVisibleProjectsDict(self, projects):
125 """Filter out projects the user doesn't have permission to view."""
126 return {
127 key: proj
128 for key, proj in projects.items()
129 if self._UserCanViewProject(proj)}
130
131 def _AssertPermInProject(self, perm, project):
132 """Make sure the user may use perm in the given project."""
133 project_perms = permissions.GetPermissions(
134 self.mc.auth.user_pb, self.mc.auth.effective_ids, project)
135 permitted = project_perms.CanUsePerm(
136 perm, self.mc.auth.effective_ids, project, [])
137 if not permitted:
138 raise permissions.PermissionException(
139 'User lacks permission %r in project %s' % (perm, project.project_name))
140
141 def _UserCanViewIssue(self, issue, allow_viewing_deleted=False):
142 """Test if user may view an issue according to perms in issue's project."""
143 project = self.GetProject(issue.project_id)
144 config = self.GetProjectConfig(issue.project_id)
145 granted_perms = tracker_bizobj.GetGrantedPerms(
146 issue, self.mc.auth.effective_ids, config)
147 project_perms = permissions.GetPermissions(
148 self.mc.auth.user_pb, self.mc.auth.effective_ids, project)
149 issue_perms = permissions.UpdateIssuePermissions(
150 project_perms, project, issue, self.mc.auth.effective_ids,
151 granted_perms=granted_perms)
152 permit_view = permissions.CanViewIssue(
153 self.mc.auth.effective_ids, issue_perms, project, issue,
154 allow_viewing_deleted=allow_viewing_deleted,
155 granted_perms=granted_perms)
156 return issue_perms, permit_view
157
158 def _AssertUserCanViewIssue(self, issue, allow_viewing_deleted=False):
159 """Make sure the user may view the issue."""
160 issue_perms, permit_view = self._UserCanViewIssue(
161 issue, allow_viewing_deleted)
162 if not permit_view:
163 raise permissions.PermissionException(
164 'User is not allowed to view issue: %s:%d.' %
165 (issue.project_name, issue.local_id))
166 return issue_perms
167
168 def _UserCanUsePermInIssue(self, issue, perm):
169 """Test if the user may use perm on the given issue."""
170 issue_perms = self._AssertUserCanViewIssue(
171 issue, allow_viewing_deleted=True)
172 return issue_perms.HasPerm(perm, None, None, [])
173
174 def _AssertPermInIssue(self, issue, perm):
175 """Make sure the user may use perm on the given issue."""
176 permitted = self._UserCanUsePermInIssue(issue, perm)
177 if not permitted:
178 raise permissions.PermissionException(
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100179 'User lacks permission %r in issue %s %d', perm, issue.project_name,
180 issue.local_id)
Copybara854996b2021-09-07 19:36:02 +0000181
182 def _AssertUserCanModifyIssues(
183 self, issue_delta_pairs, is_description_change, comment_content=None):
184 # type: (Tuple[Issue, IssueDelta], Boolean, Optional[str]) -> None
185 """Make sure the user may make the delta changes for each paired issue."""
186 # We assume that view permission for each issue, and therefore project,
187 # was checked by the caller.
188 project_ids = list(
189 {issue.project_id for (issue, _delta) in issue_delta_pairs})
190 projects_by_id = self.services.project.GetProjects(
191 self.mc.cnxn, project_ids)
192 configs_by_id = self.services.config.GetProjectConfigs(
193 self.mc.cnxn, project_ids)
194
195 project_perms_by_ids = {}
196 for project_id, project in projects_by_id.items():
197 project_perms_by_ids[project_id] = permissions.GetPermissions(
198 self.mc.auth.user_pb, self.mc.auth.effective_ids, project)
199
200 with exceptions.ErrorAggregator(permissions.PermissionException) as err_agg:
201 for issue, delta in issue_delta_pairs:
202 project_perms = project_perms_by_ids.get(issue.project_id)
203 config = configs_by_id.get(issue.project_id)
204 project = projects_by_id.get(issue.project_id)
205 granted_perms = tracker_bizobj.GetGrantedPerms(
206 issue, self.mc.auth.effective_ids, config)
207 issue_perms = permissions.UpdateIssuePermissions(
208 project_perms,
209 project,
210 issue,
211 self.mc.auth.effective_ids,
212 granted_perms=granted_perms)
213
214 # User cannot merge any issue into an issue they cannot edit.
215 if delta.merged_into:
216 merged_into_issue = self.GetIssue(
217 delta.merged_into, use_cache=False, allow_viewing_deleted=True)
218 self._AssertPermInIssue(merged_into_issue, permissions.EDIT_ISSUE)
219
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100220 # User cannot modify blocking issues on issues they cannot edit.
221 all_block = (
222 delta.blocked_on_add + delta.blocking_add +
223 delta.blocked_on_remove + delta.blocking_remove)
224 for block_iid in all_block:
225 blocked_issue = self.GetIssue(
226 block_iid, use_cache=False, allow_viewing_deleted=True)
227 self._AssertPermInIssue(blocked_issue, permissions.EDIT_ISSUE)
228
Copybara854996b2021-09-07 19:36:02 +0000229 # User cannot change values for restricted fields they cannot edit.
230 field_ids = [fv.field_id for fv in delta.field_vals_add]
231 field_ids.extend([fv.field_id for fv in delta.field_vals_remove])
232 field_ids.extend(delta.fields_clear)
233 labels = itertools.chain(delta.labels_add, delta.labels_remove)
234 try:
235 self._AssertUserCanEditFieldsAndEnumMaskedLabels(
236 project, config, field_ids, labels)
237 except permissions.PermissionException as e:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100238 err_agg.AddErrorMessage(str(e))
Copybara854996b2021-09-07 19:36:02 +0000239
240 if issue_perms.HasPerm(permissions.EDIT_ISSUE, self.mc.auth.user_id,
241 project):
242 continue
243
244 # The user does not have general EDIT_ISSUE permissions, but may
245 # have perms to modify certain issue parts/fields.
246
247 # Description changes can only be made by users with EDIT_ISSUE.
248 if is_description_change:
249 err_agg.AddErrorMessage(
250 'User not allowed to edit description in issue %s:%d' %
251 (issue.project_name, issue.local_id))
252
253 if comment_content and not issue_perms.HasPerm(
254 permissions.ADD_ISSUE_COMMENT, self.mc.auth.user_id, project):
255 err_agg.AddErrorMessage(
256 'User not allowed to add comment in issue %s:%d' %
257 (issue.project_name, issue.local_id))
258
259 if delta == tracker_pb2.IssueDelta():
260 continue
261
262 allowed_delta = tracker_pb2.IssueDelta()
263 if issue_perms.HasPerm(permissions.EDIT_ISSUE_STATUS,
264 self.mc.auth.user_id, project):
265 allowed_delta.status = delta.status
266 if issue_perms.HasPerm(permissions.EDIT_ISSUE_SUMMARY,
267 self.mc.auth.user_id, project):
268 allowed_delta.summary = delta.summary
269 if issue_perms.HasPerm(permissions.EDIT_ISSUE_OWNER,
270 self.mc.auth.user_id, project):
271 allowed_delta.owner_id = delta.owner_id
272 if issue_perms.HasPerm(permissions.EDIT_ISSUE_CC, self.mc.auth.user_id,
273 project):
274 allowed_delta.cc_ids_add = delta.cc_ids_add
275 allowed_delta.cc_ids_remove = delta.cc_ids_remove
276 # We do not check for or add other fields (e.g. comps, labels, fields)
277 # of `delta` to `allowed_delta` because they are only allowed
278 # with EDIT_ISSUE perms.
279 if delta != allowed_delta:
280 err_agg.AddErrorMessage(
281 'User lack permission to make these changes to issue %s:%d' %
282 (issue.project_name, issue.local_id))
283
284 # end of `with` block.
285
286 def _AssertUserCanDeleteComment(self, issue, comment):
287 issue_perms = self._AssertUserCanViewIssue(
288 issue, allow_viewing_deleted=True)
289 commenter = self.services.user.GetUser(self.mc.cnxn, comment.user_id)
290 permitted = permissions.CanDeleteComment(
291 comment, commenter, self.mc.auth.user_id, issue_perms)
292 if not permitted:
293 raise permissions.PermissionException('Cannot delete comment')
294
295 def _AssertUserCanViewHotlist(self, hotlist):
296 """Make sure the user may view the hotlist."""
297 if not permissions.CanViewHotlist(
298 self.mc.auth.effective_ids, self.mc.perms, hotlist):
299 raise permissions.PermissionException(
300 'User is not allowed to view this hotlist')
301
302 def _AssertUserCanEditHotlist(self, hotlist):
303 if not permissions.CanEditHotlist(
304 self.mc.auth.effective_ids, self.mc.perms, hotlist):
305 raise permissions.PermissionException(
306 'User is not allowed to edit this hotlist')
307
308 def _AssertUserCanEditValueForFieldDef(self, project, fielddef):
309 if not permissions.CanEditValueForFieldDef(
310 self.mc.auth.effective_ids, self.mc.perms, project, fielddef):
311 raise permissions.PermissionException(
312 'User is not allowed to edit custom field %s' % fielddef.field_name)
313
314 def _AssertUserCanEditFieldsAndEnumMaskedLabels(
315 self, project, config, field_ids, labels):
316 field_ids = set(field_ids)
317
318 enum_fds_by_name = {
319 f.field_name.lower(): f.field_id
320 for f in config.field_defs
321 if f.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and not f.is_deleted
322 }
323 for label in labels:
324 enum_field_name = tracker_bizobj.LabelIsMaskedByField(
325 label, enum_fds_by_name.keys())
326 if enum_field_name:
327 field_ids.add(enum_fds_by_name.get(enum_field_name))
328
329 fds_by_id = {fd.field_id: fd for fd in config.field_defs}
330 with exceptions.ErrorAggregator(permissions.PermissionException) as err_agg:
331 for field_id in field_ids:
332 fd = fds_by_id.get(field_id)
333 if fd:
334 try:
335 self._AssertUserCanEditValueForFieldDef(project, fd)
336 except permissions.PermissionException as e:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100337 err_agg.AddErrorMessage(str(e))
Copybara854996b2021-09-07 19:36:02 +0000338
339 def _AssertUserCanViewFieldDef(self, project, field):
340 """Make sure the user may view the field."""
341 if not permissions.CanViewFieldDef(self.mc.auth.effective_ids,
342 self.mc.perms, project, field):
343 raise permissions.PermissionException(
344 'User is not allowed to view this field')
345
346 ### Site methods
347
348 # FUTURE: GetSiteReadOnlyState()
349 # FUTURE: SetSiteReadOnlyState()
350 # FUTURE: GetSiteBannerMessage()
351 # FUTURE: SetSiteBannerMessage()
352
353 ### Project methods
354
355 def CreateProject(
356 self, project_name, owner_ids, committer_ids, contributor_ids,
357 summary, description, state=project_pb2.ProjectState.LIVE,
358 access=None, read_only_reason=None, home_page=None, docs_url=None,
359 source_url=None, logo_gcs_id=None, logo_file_name=None):
360 """Create and store a Project with the given attributes.
361
362 Args:
363 cnxn: connection to SQL database.
364 project_name: a valid project name, all lower case.
365 owner_ids: a list of user IDs for the project owners.
366 committer_ids: a list of user IDs for the project members.
367 contributor_ids: a list of user IDs for the project contributors.
368 summary: one-line explanation of the project.
369 description: one-page explanation of the project.
370 state: a project state enum defined in project_pb2.
371 access: optional project access enum defined in project.proto.
372 read_only_reason: if given, provides a status message and marks
373 the project as read-only.
374 home_page: home page of the project
375 docs_url: url to redirect to for wiki/documentation links
376 source_url: url to redirect to for source browser links
377 logo_gcs_id: google storage object id of the project's logo
378 logo_file_name: uploaded file name of the project's logo
379
380 Returns:
381 The int project_id of the new project.
382
383 Raises:
384 ProjectAlreadyExists: A project with that name already exists.
385 """
386 if not permissions.CanCreateProject(self.mc.perms):
387 raise permissions.PermissionException(
388 'User is not allowed to create a project')
389
390 with self.mc.profiler.Phase('creating project %r' % project_name):
391 project_id = self.services.project.CreateProject(
392 self.mc.cnxn, project_name, owner_ids, committer_ids, contributor_ids,
393 summary, description, state=state, access=access,
394 read_only_reason=read_only_reason, home_page=home_page,
395 docs_url=docs_url, source_url=source_url, logo_gcs_id=logo_gcs_id,
396 logo_file_name=logo_file_name)
397 self.services.template.CreateDefaultProjectTemplates(self.mc.cnxn,
398 project_id)
399 return project_id
400
401 def ListProjects(self, domain=None, use_cache=True):
402 """Return a list of project IDs that the current user may view."""
403 # TODO(crbug.com/monorail/7508): Add permission checking in ListProjects.
404 # Note: No permission checks because anyone can list projects, but
405 # the results are filtered by permission to view each project.
406
407 with self.mc.profiler.Phase('list projects for %r' % self.mc.auth.user_id):
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100408 project_ids = self.services.project.GetVisibleProjects(
409 self.mc.cnxn,
410 self.mc.auth.user_pb,
411 self.mc.auth.effective_ids,
412 domain=domain,
413 use_cache=use_cache)
Copybara854996b2021-09-07 19:36:02 +0000414
415 return project_ids
416
417 def CheckProjectName(self, project_name):
418 """Check that a project name is valid and not already in use.
419
420 Args:
421 project_name: str the project name to check.
422
423 Returns:
424 None if the user can create a project with that name, or a string with the
425 reason the name can't be used.
426
427 Raises:
428 PermissionException: The user is not allowed to create a project.
429 """
430 # We check that the user can create a project so we don't leak information
431 # about project names.
432 if not permissions.CanCreateProject(self.mc.perms):
433 raise permissions.PermissionException(
434 'User is not allowed to create a project')
435
436 with self.mc.profiler.Phase('checking project name %s' % project_name):
437 if not project_helpers.IsValidProjectName(project_name):
438 return '"%s" is not a valid project name.' % project_name
439 if self.services.project.LookupProjectIDs(self.mc.cnxn, [project_name]):
440 return 'There is already a project with that name.'
441 return None
442
443 def CheckComponentName(self, project_id, parent_path, component_name):
444 """Check that the component name is valid and not already in use.
445
446 Args:
447 project_id: int with the id of the project where we want to create the
448 component.
449 parent_path: optional str with the path of the parent component.
450 component_name: str with the name of the proposed component.
451
452 Returns:
453 None if the user can create a component with that name, or a string with
454 the reason the name can't be used.
455 """
456 # Check that the project exists and the user can view it.
457 self.GetProject(project_id)
458 # If a parent component is given, make sure it exists.
459 config = self.GetProjectConfig(project_id)
460 if parent_path and not tracker_bizobj.FindComponentDef(parent_path, config):
461 raise exceptions.NoSuchComponentException(
462 'Component %r not found' % parent_path)
463 with self.mc.profiler.Phase(
464 'checking component name %r %r' % (parent_path, component_name)):
465 if not tracker_constants.COMPONENT_NAME_RE.match(component_name):
466 return '"%s" is not a valid component name.' % component_name
467 if parent_path:
468 component_name = '%s>%s' % (parent_path, component_name)
469 if tracker_bizobj.FindComponentDef(component_name, config):
470 return 'There is already a component with that name.'
471 return None
472
473 def CheckFieldName(self, project_id, field_name):
474 """Check that the field name is valid and not already in use.
475
476 Args:
477 project_id: int with the id of the project where we want to create the
478 field.
479 field_name: str with the name of the proposed field.
480
481 Returns:
482 None if the user can create a field with that name, or a string with
483 the reason the name can't be used.
484 """
485 # Check that the project exists and the user can view it.
486 self.GetProject(project_id)
487 config = self.GetProjectConfig(project_id)
488
489 field_name = field_name.lower()
490 with self.mc.profiler.Phase('checking field name %r' % field_name):
491 if not tracker_constants.FIELD_NAME_RE.match(field_name):
492 return '"%s" is not a valid field name.' % field_name
493 if field_name in tracker_constants.RESERVED_PREFIXES:
494 return 'That name is reserved'
495 if field_name.endswith(
496 tuple(tracker_constants.RESERVED_COL_NAME_SUFFIXES)):
497 return 'That suffix is reserved'
498 for fd in config.field_defs:
499 fn = fd.field_name.lower()
500 if field_name == fn:
501 return 'There is already a field with that name.'
502 if field_name.startswith(fn + '-'):
503 return 'An existing field is a prefix of that name.'
504 if fn.startswith(field_name + '-'):
505 return 'That name is a prefix of an existing field name.'
506
507 return None
508
509 def GetProjects(self, project_ids, use_cache=True):
510 """Return the specified projects.
511
512 Args:
513 project_ids: int project_ids of the projects to retrieve.
514 use_cache: set to false when doing read-modify-write.
515
516 Returns:
517 The specified projects.
518
519 Raises:
520 NoSuchProjectException: There is no project with that ID.
521 """
522 with self.mc.profiler.Phase('getting projects %r' % project_ids):
523 projects = self.services.project.GetProjects(
524 self.mc.cnxn, project_ids, use_cache=use_cache)
525
526 projects = self._FilterVisibleProjectsDict(projects)
527 return projects
528
529 def GetProject(self, project_id, use_cache=True):
530 """Return the specified project.
531
532 Args:
533 project_id: int project_id of the project to retrieve.
534 use_cache: set to false when doing read-modify-write.
535
536 Returns:
537 The specified project.
538
539 Raises:
540 NoSuchProjectException: There is no project with that ID.
541 """
542 projects = self.GetProjects([project_id], use_cache=use_cache)
543 if project_id not in projects:
544 raise permissions.PermissionException(
545 'User is not allowed to view this project')
546 return projects[project_id]
547
548 def GetProjectsByName(self, project_names, use_cache=True):
549 """Return the named project.
550
551 Args:
552 project_names: string names of the projects to retrieve.
553 use_cache: set to false when doing read-modify-write.
554
555 Returns:
556 The specified projects.
557 """
558 with self.mc.profiler.Phase('getting projects %r' % project_names):
559 projects = self.services.project.GetProjectsByName(
560 self.mc.cnxn, project_names, use_cache=use_cache)
561
562 for pn in project_names:
563 if pn not in projects:
564 raise exceptions.NoSuchProjectException('Project %r not found.' % pn)
565
566 projects = self._FilterVisibleProjectsDict(projects)
567 return projects
568
569 def GetProjectByName(self, project_name, use_cache=True):
570 """Return the named project.
571
572 Args:
573 project_name: string name of the project to retrieve.
574 use_cache: set to false when doing read-modify-write.
575
576 Returns:
577 The specified project.
578
579 Raises:
580 NoSuchProjectException: There is no project with that name.
581 """
582 projects = self.GetProjectsByName([project_name], use_cache)
583 if not projects:
584 raise permissions.PermissionException(
585 'User is not allowed to view this project')
586
587 return projects[project_name]
588
589 def GatherProjectMembershipsForUser(self, user_id):
590 """Return the projects where the user has a role.
591
592 Args:
593 user_id: ID of the user we are requesting project memberships for.
594
595 Returns:
596 A triple with project IDs where the user is an owner, a committer, or a
597 contributor.
598 """
599 viewed_user_effective_ids = authdata.AuthData.FromUserID(
600 self.mc.cnxn, user_id, self.services).effective_ids
601
602 owner_projects, _archived, committer_projects, contrib_projects = (
603 self.GetUserProjects(viewed_user_effective_ids))
604
605 owner_proj_ids = [proj.project_id for proj in owner_projects]
606 committer_proj_ids = [proj.project_id for proj in committer_projects]
607 contrib_proj_ids = [proj.project_id for proj in contrib_projects]
608 return owner_proj_ids, committer_proj_ids, contrib_proj_ids
609
610 def GetUserRolesInAllProjects(self, viewed_user_effective_ids):
611 """Return the projects where the user has a role.
612
613 Args:
614 viewed_user_effective_ids: list of IDs of the user whose projects we want
615 to see.
616
617 Returns:
618 A triple with projects where the user is an owner, a member or a
619 contributor.
620 """
621 with self.mc.profiler.Phase(
622 'Finding roles in all projects for %r' % viewed_user_effective_ids):
623 project_ids = self.services.project.GetUserRolesInAllProjects(
624 self.mc.cnxn, viewed_user_effective_ids)
625
626 owner_projects = self.GetProjects(project_ids[0])
627 member_projects = self.GetProjects(project_ids[1])
628 contrib_projects = self.GetProjects(project_ids[2])
629
630 return owner_projects, member_projects, contrib_projects
631
632 def GetUserProjects(self, viewed_user_effective_ids):
633 # TODO(crbug.com/monorail/7398): Combine this function with
634 # GatherProjectMembershipsForUser after removing the legacy
635 # project list page and the v0 GetUsersProjects RPC.
636 """Get the projects to display in the user's profile.
637
638 Args:
639 viewed_user_effective_ids: set of int user IDs of the user being viewed.
640
641 Returns:
642 A 4-tuple of lists of PBs:
643 - live projects the viewed user owns
644 - archived projects the viewed user owns
645 - live projects the viewed user is a member of
646 - live projects the viewed user is a contributor to
647
648 Any projects the viewing user should not be able to see are filtered out.
649 Admins can see everything, while other users can see all non-locked
650 projects they own or are a member of, as well as all live projects.
651 """
652 # Permissions are checked in we.GetUserRolesInAllProjects()
653 owner_projects, member_projects, contrib_projects = (
654 self.GetUserRolesInAllProjects(viewed_user_effective_ids))
655
656 # We filter out DELETABLE projects, and keep a project where the user has a
657 # highest role, e.g. if the user is both an owner and a member, the project
658 # is listed under owner projects, not under member_projects.
659 archived_projects = [
660 project
661 for project in owner_projects.values()
662 if project.state == project_pb2.ProjectState.ARCHIVED]
663
664 contrib_projects = [
665 project
666 for pid, project in contrib_projects.items()
667 if pid not in owner_projects
668 and pid not in member_projects
669 and project.state != project_pb2.ProjectState.DELETABLE
670 and project.state != project_pb2.ProjectState.ARCHIVED]
671
672 member_projects = [
673 project
674 for pid, project in member_projects.items()
675 if pid not in owner_projects
676 and project.state != project_pb2.ProjectState.DELETABLE
677 and project.state != project_pb2.ProjectState.ARCHIVED]
678
679 owner_projects = [
680 project
681 for pid, project in owner_projects.items()
682 if project.state != project_pb2.ProjectState.DELETABLE
683 and project.state != project_pb2.ProjectState.ARCHIVED]
684
685 by_name = lambda project: project.project_name
686 owner_projects = sorted(owner_projects, key=by_name)
687 archived_projects = sorted(archived_projects, key=by_name)
688 member_projects = sorted(member_projects, key=by_name)
689 contrib_projects = sorted(contrib_projects, key=by_name)
690
691 return owner_projects, archived_projects, member_projects, contrib_projects
692
693 def UpdateProject(
694 self,
695 project_id,
696 summary=None,
697 description=None,
698 state=None,
699 state_reason=None,
700 access=None,
701 issue_notify_address=None,
702 attachment_bytes_used=None,
703 attachment_quota=None,
704 moved_to=None,
705 process_inbound_email=None,
706 only_owners_remove_restrictions=None,
707 read_only_reason=None,
708 cached_content_timestamp=None,
709 only_owners_see_contributors=None,
710 delete_time=None,
711 recent_activity=None,
712 revision_url_format=None,
713 home_page=None,
714 docs_url=None,
715 source_url=None,
716 logo_gcs_id=None,
717 logo_file_name=None,
718 issue_notify_always_detailed=None):
719 """Update the DB with the given project information."""
720 project = self.GetProject(project_id)
721 self._AssertPermInProject(permissions.EDIT_PROJECT, project)
722
723 with self.mc.profiler.Phase('updating project %r' % project_id):
724 self.services.project.UpdateProject(
725 self.mc.cnxn,
726 project_id,
727 summary=summary,
728 description=description,
729 state=state,
730 state_reason=state_reason,
731 access=access,
732 issue_notify_address=issue_notify_address,
733 attachment_bytes_used=attachment_bytes_used,
734 attachment_quota=attachment_quota,
735 moved_to=moved_to,
736 process_inbound_email=process_inbound_email,
737 only_owners_remove_restrictions=only_owners_remove_restrictions,
738 read_only_reason=read_only_reason,
739 cached_content_timestamp=cached_content_timestamp,
740 only_owners_see_contributors=only_owners_see_contributors,
741 delete_time=delete_time,
742 recent_activity=recent_activity,
743 revision_url_format=revision_url_format,
744 home_page=home_page,
745 docs_url=docs_url,
746 source_url=source_url,
747 logo_gcs_id=logo_gcs_id,
748 logo_file_name=logo_file_name,
749 issue_notify_always_detailed=issue_notify_always_detailed)
750
751 def DeleteProject(self, project_id):
752 """Mark the project as deletable. It will be reaped by a cron job.
753
754 Args:
755 project_id: int ID of the project to delete.
756
757 Returns:
758 Nothing.
759
760 Raises:
761 NoSuchProjectException: There is no project with that ID.
762 """
763 project = self.GetProject(project_id)
764 self._AssertPermInProject(permissions.EDIT_PROJECT, project)
765
766 with self.mc.profiler.Phase('marking deletable %r' % project_id):
767 _project = self.GetProject(project_id)
768 self.services.project.MarkProjectDeletable(
769 self.mc.cnxn, project_id, self.services.config)
770
771 def StarProject(self, project_id, starred):
772 """Star or unstar the specified project.
773
774 Args:
775 project_id: int ID of the project to star/unstar.
776 starred: true to add a star, false to remove it.
777
778 Returns:
779 Nothing.
780
781 Raises:
782 NoSuchProjectException: There is no project with that ID.
783 """
784 project = self.GetProject(project_id)
785 self._AssertPermInProject(permissions.SET_STAR, project)
786
787 with self.mc.profiler.Phase('(un)starring project %r' % project_id):
788 self.services.project_star.SetStar(
789 self.mc.cnxn, project_id, self.mc.auth.user_id, starred)
790
791 def IsProjectStarred(self, project_id):
792 """Return True if the current user has starred the given project.
793
794 Args:
795 project_id: int ID of the project to check.
796
797 Returns:
798 True if starred.
799
800 Raises:
801 NoSuchProjectException: There is no project with that ID.
802 """
803 if project_id is None:
804 raise exceptions.InputException('No project specified')
805
806 if not self.mc.auth.user_id:
807 return False
808
809 with self.mc.profiler.Phase('checking project star %r' % project_id):
810 # Make sure the project exists and user has permission to see it.
811 _project = self.GetProject(project_id)
812 return self.services.project_star.IsItemStarredBy(
813 self.mc.cnxn, project_id, self.mc.auth.user_id)
814
815 def GetProjectStarCount(self, project_id):
816 """Return the number of times the project has been starred.
817
818 Args:
819 project_id: int ID of the project to check.
820
821 Returns:
822 The number of times the project has been starred.
823
824 Raises:
825 NoSuchProjectException: There is no project with that ID.
826 """
827 if project_id is None:
828 raise exceptions.InputException('No project specified')
829
830 with self.mc.profiler.Phase('counting stars for project %r' % project_id):
831 # Make sure the project exists and user has permission to see it.
832 _project = self.GetProject(project_id)
833 return self.services.project_star.CountItemStars(self.mc.cnxn, project_id)
834
835 def ListStarredProjects(self, viewed_user_id=None):
836 """Return a list of projects starred by the current or viewed user.
837
838 Args:
839 viewed_user_id: optional user ID for another user's profile page, if
840 not supplied, the signed in user is used.
841
842 Returns:
843 A list of projects that were starred by current user and that they
844 are currently allowed to view.
845 """
846 # Note: No permission checks for this call, but the list of starred
847 # projects is filtered based on permission to view.
848
849 if viewed_user_id is None:
850 if self.mc.auth.user_id:
851 viewed_user_id = self.mc.auth.user_id
852 else:
853 return [] # Anon user and no viewed user specified.
854 with self.mc.profiler.Phase('ListStarredProjects for %r' % viewed_user_id):
855 viewable_projects = sitewide_helpers.GetViewableStarredProjects(
856 self.mc.cnxn, self.services, viewed_user_id,
857 self.mc.auth.effective_ids, self.mc.auth.user_pb)
858 return viewable_projects
859
860 def GetProjectConfigs(self, project_ids, use_cache=True):
861 """Return the specifed configs.
862
863 Args:
864 project_ids: int IDs of the projects to retrieve.
865 use_cache: set to false when doing read-modify-write.
866
867 Returns:
868 The specified configs.
869 """
870 with self.mc.profiler.Phase('getting configs for %r' % project_ids):
871 configs = self.services.config.GetProjectConfigs(
872 self.mc.cnxn, project_ids, use_cache=use_cache)
873
874 projects = self._FilterVisibleProjectsDict(
875 self.GetProjects(list(configs.keys())))
876 configs = {project_id: configs[project_id] for project_id in projects}
877
878 return configs
879
880 def GetProjectConfig(self, project_id, use_cache=True):
881 """Return the specifed config.
882
883 Args:
884 project_id: int ID of the project to retrieve.
885 use_cache: set to false when doing read-modify-write.
886
887 Returns:
888 The specified config.
889
890 Raises:
891 NoSuchProjectException: There is no matching config.
892 """
893 configs = self.GetProjectConfigs([project_id], use_cache)
894 if not configs:
895 raise exceptions.NoSuchProjectException()
896 return configs[project_id]
897
898 def ListProjectTemplates(self, project_id):
899 templates = self.services.template.GetProjectTemplates(
900 self.mc.cnxn, project_id)
901 project = self.GetProject(project_id)
902 # Filter non-viewable templates
903 if framework_bizobj.UserIsInProject(project, self.mc.auth.effective_ids):
904 return templates
905 return [template for template in templates if not template.members_only]
906
907 def ListComponentDefs(self, project_id, page_size, start):
908 # type: (int, int, int) -> ListResult
909 """Returns component defs that belong to the project."""
910 if start < 0:
911 raise exceptions.InputException('Invalid `start`: %d' % start)
912 if page_size < 0:
913 raise exceptions.InputException('Invalid `page_size`: %d' % page_size)
914
915 config = self.GetProjectConfig(project_id)
916 end = start + page_size
917 next_start = None
918 if end < len(config.component_defs):
919 next_start = end
920 return ListResult(config.component_defs[start:end], next_start)
921
Adrià Vilanova Martínezf5e10392021-12-07 22:55:40 +0100922 def GetComponentDef(self, project_id, component_id):
923 # type: (int, int) -> ComponentDef
924 """Returns component def for component id that belongs to the project."""
925 if component_id < 0:
926 raise exceptions.InputException(
927 'Invalid `component_id`: %d' % component_id)
928
929 config = self.GetProjectConfig(project_id)
930 return tracker_bizobj.FindComponentDefByID(component_id, config)
931
932
Copybara854996b2021-09-07 19:36:02 +0000933 def CreateComponentDef(
934 self, project_id, path, description, admin_ids, cc_ids, labels):
935 # type: (int, str, str, Collection[int], Collection[int], Collection[str])
936 # -> ComponentDef
937 """Creates a ComponentDef with the given information."""
938 project = self.GetProject(project_id)
939 config = self.GetProjectConfig(project_id)
940
941 # Validate new ComponentDef and check permissions.
942 ancestor_path, leaf_name = None, path
943 if '>' in path:
944 ancestor_path, leaf_name = path.rsplit('>', 1)
945 ancestor_def = tracker_bizobj.FindComponentDef(ancestor_path, config)
946 if not ancestor_def:
947 raise exceptions.InputException(
948 'Ancestor path %s is invalid.' % ancestor_path)
949 project_perms = permissions.GetPermissions(
950 self.mc.auth.user_pb, self.mc.auth.effective_ids, project)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100951 if not permissions.CanEditComponentDefLegacy(
Copybara854996b2021-09-07 19:36:02 +0000952 self.mc.auth.effective_ids, project_perms, project, ancestor_def,
953 config):
954 raise permissions.PermissionException(
955 'User is not allowed to create a subcomponent under %s.' %
956 ancestor_path)
957 else:
958 # A brand new top level component is being created.
959 self._AssertPermInProject(permissions.EDIT_PROJECT, project)
960
961 if not tracker_constants.COMPONENT_NAME_RE.match(leaf_name):
962 raise exceptions.InputException('Invalid component path: %s.' % leaf_name)
963
964 if tracker_bizobj.FindComponentDef(path, config):
965 raise exceptions.ComponentDefAlreadyExists(
966 'Component path %s already exists.' % path)
967
968 with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
969 tracker_helpers.AssertUsersExist(
970 self.mc.cnxn, self.services, cc_ids + admin_ids, err_agg)
971
972 label_ids = self.services.config.LookupLabelIDs(
973 self.mc.cnxn, project_id, labels, autocreate=True)
974 self.services.config.CreateComponentDef(
975 self.mc.cnxn, project_id, path, description, False, admin_ids, cc_ids,
976 int(time.time()), self.mc.auth.user_id, label_ids)
977 updated_config = self.GetProjectConfig(project_id, use_cache=False)
978 return tracker_bizobj.FindComponentDef(path, updated_config)
979
980 def DeleteComponentDef(self, project_id, component_id):
981 # type: (MonorailConnection, int, int) -> None
982 """Deletes the given ComponentDef."""
983 project = self.GetProject(project_id)
984 config = self.GetProjectConfig(project_id)
985
986 component_def = tracker_bizobj.FindComponentDefByID(component_id, config)
987 if not component_def:
988 raise exceptions.NoSuchComponentException('The component does not exist.')
989
990 project_perms = permissions.GetPermissions(
991 self.mc.auth.user_pb, self.mc.auth.effective_ids, project)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100992 if not permissions.CanEditComponentDefLegacy(
Copybara854996b2021-09-07 19:36:02 +0000993 self.mc.auth.effective_ids, project_perms, project, component_def,
994 config):
995 raise permissions.PermissionException(
996 'User is not allowed to delete this component.')
997
998 if tracker_bizobj.FindDescendantComponents(config, component_def):
999 raise exceptions.InputException(
1000 'Components with subcomponents cannot be deleted.')
1001
1002 self.services.config.DeleteComponentDef(
1003 self.mc.cnxn, project_id, component_id)
1004
1005 # FUTURE: labels, statuses, components, rules, templates, and views.
1006 # FUTURE: project saved queries.
1007 # FUTURE: GetProjectPermissionsForUser()
1008
1009 ### Field methods
1010
1011 # FUTURE: All other field methods.
1012
1013 def GetFieldDef(self, field_id, project):
1014 # type: (int, Project) -> FieldDef
1015 """Return the specified hotlist.
1016
1017 Args:
1018 field_id: int field_id of the field to retrieve.
1019 project: Project object that the field belongs to.
1020
1021 Returns:
1022 The specified field.
1023
1024 Raises:
1025 InputException: No field was specified.
1026 NoSuchFieldDefException: There is no field with that ID.
1027 PermissionException: The user is not allowed to view the field.
1028 """
1029 with self.mc.profiler.Phase('getting fielddef %r' % field_id):
1030 config = self.GetProjectConfig(project.project_id)
1031 field = tracker_bizobj.FindFieldDefByID(field_id, config)
1032 if field is None:
1033 raise exceptions.NoSuchFieldDefException('Field not found.')
1034 self._AssertUserCanViewFieldDef(project, field)
1035 return field
1036
1037 ### Issue methods
1038
1039 def CreateIssue(
1040 self,
1041 project_id, # type: int
1042 summary, # type: str
1043 status, # type: str
1044 owner_id, # type: int
1045 cc_ids, # type: Sequence[int]
1046 labels, # type: Sequence[str]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001047 field_values, # type: Sequence[mrproto.tracker_pb2.FieldValue]
Copybara854996b2021-09-07 19:36:02 +00001048 component_ids, # type: Sequence[int]
1049 marked_description, # type: str
1050 blocked_on=None, # type: Sequence[int]
1051 blocking=None, # type: Sequence[int]
1052 attachments=None, # type: Sequence[Tuple[str, str, str]]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001053 phases=None, # type: Sequence[mrproto.tracker_pb2.Phase]
1054 approval_values=None, # type: Sequence[mrproto.tracker_pb2.ApprovalValue]
Copybara854996b2021-09-07 19:36:02 +00001055 send_email=True, # type: bool
1056 reporter_id=None, # type: int
1057 timestamp=None, # type: int
1058 dangling_blocked_on=None, # type: Sequence[DanglingIssueRef]
1059 dangling_blocking=None, # type: Sequence[DanglingIssueRef]
1060 raise_filter_errors=True, # type: bool
1061 ):
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001062 # type: (...) ->
1063 # (mrproto.tracker_pb2.Issue, mrproto.tracker_pb2.IssueComment)
Copybara854996b2021-09-07 19:36:02 +00001064 """Create and store a new issue with all the given information.
1065
1066 Args:
1067 project_id: int ID for the current project.
1068 summary: one-line summary string summarizing this issue.
1069 status: string issue status value. E.g., 'New'.
1070 owner_id: user ID of the issue owner.
1071 cc_ids: list of user IDs for users to be CC'd on changes.
1072 labels: list of label strings. E.g., 'Priority-High'.
1073 field_values: list of FieldValue PBs.
1074 component_ids: list of int component IDs.
1075 marked_description: issue description with initial HTML markup.
1076 blocked_on: list of issue_ids that this issue is blocked on.
1077 blocking: list of issue_ids that this issue blocks.
1078 attachments: [(filename, contents, mimetype),...] attachments uploaded at
1079 the time the comment was made.
1080 phases: list of Phase PBs.
1081 approval_values: list of ApprovalValue PBs.
1082 send_email: set to False to avoid email notifications.
1083 reporter_id: optional user ID of a different user to attribute this
1084 issue report to. The requester must have the ImportComment perm.
1085 timestamp: optional int timestamp of an imported issue.
1086 dangling_blocked_on: a list of DanglingIssueRefs this issue is blocked on.
1087 dangling_blocking: a list of DanglingIssueRefs that this issue blocks.
1088 raise_filter_errors: whether to raise when filter rules produce errors.
1089
1090 Returns:
1091 A tuple (newly created Issue, Comment PB for the description).
1092
1093 Raises:
1094 FilterRuleException if creation violates any filter rule that shows error.
1095 InputException: The issue has invalid input, see validation below.
1096 PermissionException if user lacks sufficient permissions.
1097 """
1098 project = self.GetProject(project_id)
1099 self._AssertPermInProject(permissions.CREATE_ISSUE, project)
1100
1101 # TODO(crbug/monorail/7197): The following are needed for v3 API
1102 # Phase 5.2 Validate sufficient attachment quota and update
1103
1104 if reporter_id and reporter_id != self.mc.auth.user_id:
1105 self._AssertPermInProject(permissions.IMPORT_COMMENT, project)
1106 importer_id = self.mc.auth.user_id
1107 else:
1108 reporter_id = self.mc.auth.user_id
1109 importer_id = None
1110
1111 with self.mc.profiler.Phase('creating issue in project %r' % project_id):
1112 # TODO(crbug/monorail/8000): Refactor issue proto construction
1113 # to the caller.
1114 status = framework_bizobj.CanonicalizeLabel(status)
1115 labels = [framework_bizobj.CanonicalizeLabel(l) for l in labels]
1116 labels = [l for l in labels if l]
1117
1118 issue = tracker_pb2.Issue()
1119 issue.project_id = project_id
1120 issue.project_name = self.services.project.LookupProjectNames(
1121 self.mc.cnxn, [project_id]).get(project_id)
1122 issue.summary = summary
1123 issue.status = status
1124 issue.owner_id = owner_id
1125 issue.cc_ids.extend(cc_ids)
1126 issue.labels.extend(labels)
1127 issue.field_values.extend(field_values)
1128 issue.component_ids.extend(component_ids)
1129 issue.reporter_id = reporter_id
1130 if blocked_on is not None:
1131 issue.blocked_on_iids = blocked_on
1132 issue.blocked_on_ranks = [0] * len(blocked_on)
1133 if blocking is not None:
1134 issue.blocking_iids = blocking
1135 if dangling_blocked_on is not None:
1136 issue.dangling_blocked_on_refs = dangling_blocked_on
1137 if dangling_blocking is not None:
1138 issue.dangling_blocking_refs = dangling_blocking
1139 if attachments:
1140 issue.attachment_count = len(attachments)
1141 if phases:
1142 issue.phases = phases
1143 if approval_values:
1144 issue.approval_values = approval_values
1145 timestamp = timestamp or int(time.time())
1146 issue.opened_timestamp = timestamp
1147 issue.modified_timestamp = timestamp
1148 issue.owner_modified_timestamp = timestamp
1149 issue.status_modified_timestamp = timestamp
1150 issue.component_modified_timestamp = timestamp
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001151 issue.migration_modified_timestamp = timestamp
Copybara854996b2021-09-07 19:36:02 +00001152
1153 # Validate the issue
1154 tracker_helpers.AssertValidIssueForCreate(
1155 self.mc.cnxn, self.services, issue, marked_description)
1156
1157 # Apply filter rules.
1158 # Set the closed_timestamp both before and after filter rules.
1159 config = self.GetProjectConfig(issue.project_id)
1160 if not tracker_helpers.MeansOpenInProject(
1161 tracker_bizobj.GetStatus(issue), config):
1162 issue.closed_timestamp = issue.opened_timestamp
1163 filterrules_helpers.ApplyFilterRules(
1164 self.mc.cnxn, self.services, issue, config)
1165 if issue.derived_errors and raise_filter_errors:
1166 raise exceptions.FilterRuleException(issue.derived_errors)
1167 if not tracker_helpers.MeansOpenInProject(
1168 tracker_bizobj.GetStatus(issue), config):
1169 issue.closed_timestamp = issue.opened_timestamp
1170
1171 new_issue, comment = self.services.issue.CreateIssue(
1172 self.mc.cnxn,
1173 self.services,
1174 issue,
1175 marked_description,
1176 attachments=attachments,
1177 index_now=False,
1178 importer_id=importer_id)
1179 logging.info(
1180 'created issue %r in project %r', new_issue.local_id, project_id)
1181
1182 with self.mc.profiler.Phase('following up after issue creation'):
1183 self.services.project.UpdateRecentActivity(self.mc.cnxn, project_id)
1184
1185 if send_email:
1186 with self.mc.profiler.Phase('queueing notification tasks'):
1187 hostport = framework_helpers.GetHostPort(
1188 project_name=project.project_name)
1189 send_notifications.PrepareAndSendIssueChangeNotification(
1190 new_issue.issue_id, hostport, reporter_id, comment_id=comment.id)
1191 send_notifications.PrepareAndSendIssueBlockingNotification(
1192 new_issue.issue_id, hostport, new_issue.blocked_on_iids,
1193 reporter_id)
1194
1195 return new_issue, comment
1196
1197 def MakeIssueFromTemplate(self, _template, _description, _issue_delta):
1198 # type: (tracker_pb2.TemplateDef, str, tracker_pb2.IssueDelta) ->
1199 # tracker_pb2.Issue
1200 """Creates issue from template, issue description, and delta.
1201
1202 Args:
1203 template: Template that issue creation is based on.
1204 description: Issue description string.
1205 issue_delta: Difference between desired issue and base issue.
1206
1207 Returns:
1208 Newly created issue, as protorpc Issue.
1209
1210 Raises:
1211 TODO(crbug/monorail/7197): Document errors when implemented
1212 """
1213 # Phase 2: Build Issue from TemplateDef
1214 # Use helper method, likely from template_helpers
1215
1216 # Phase 3: Validate proposed deltas and check permissions
1217 # Check summary has been edited if required, else throw
1218 # Check description is different from template default, else throw
1219 # Check edit permission on field values of issue deltas, else throw
1220
1221 # Phase 4: Merge template, delta, and defaults
1222 # Merge delta into issue
1223 # Apply approval def defaults to approval values
1224 # Capitalize every line of description
1225
1226 # Phase 5: Create issue by calling work_env.CreateIssue
1227
1228 return tracker_pb2.Issue()
1229
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +02001230 def MakeIssue(
1231 self,
1232 issue,
1233 description,
1234 send_email,
1235 attachment_uploads=None):
Copybara854996b2021-09-07 19:36:02 +00001236 # type: (tracker_pb2.Issue, str, bool) -> tracker_pb2.Issue
1237 """Check restricted field permissions and create issue.
1238
1239 Args:
1240 issue: Data for the created issue in a Protocol Bugger.
1241 description: Description for the initial description comment created.
1242 send_email: Whether this issue creation should email people.
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +02001243 attachment_uploads: List of AttachmentUpload tuples to be attached to the
1244 new issue.
Copybara854996b2021-09-07 19:36:02 +00001245 Returns:
1246 The created Issue PB.
1247
1248 Raises:
1249 FilterRuleException if creation violates any filter rule that shows error.
1250 InputException: The issue has invalid input, see validation below.
1251 PermissionException if user lacks sufficient permissions.
1252 """
1253 config = self.GetProjectConfig(issue.project_id)
1254 project = self.GetProject(issue.project_id)
1255 self._AssertUserCanEditFieldsAndEnumMaskedLabels(
1256 project, config, [fv.field_id for fv in issue.field_values],
1257 issue.labels)
1258 issue, _comment = self.CreateIssue(
1259 issue.project_id,
1260 issue.summary,
1261 issue.status,
1262 issue.owner_id,
1263 issue.cc_ids,
1264 issue.labels,
1265 issue.field_values,
1266 issue.component_ids,
1267 description,
1268 blocked_on=issue.blocked_on_iids,
1269 blocking=issue.blocking_iids,
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +02001270 attachments=attachment_uploads,
Copybara854996b2021-09-07 19:36:02 +00001271 dangling_blocked_on=issue.dangling_blocked_on_refs,
1272 dangling_blocking=issue.dangling_blocking_refs,
1273 send_email=send_email)
1274 return issue
1275
1276 def MoveIssue(self, issue, target_project):
1277 """Move issue to the target_project.
1278
1279 The current user needs to have permission to delete the current issue, and
1280 to edit issues on the target project.
1281
1282 Args:
1283 issue: the issue PB.
1284 target_project: the project PB where the issue should be moved to.
1285 Returns:
1286 The issue PB of the new issue on the target project.
1287 """
1288 self._AssertPermInIssue(issue, permissions.DELETE_ISSUE)
1289 self._AssertPermInProject(permissions.EDIT_ISSUE, target_project)
1290
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001291 restrictions = permissions.GetRestrictions(issue)
1292 # Issues with allowed labels may move between allowed projects.
1293 # Context: https://crbug.com/monorail/11894
1294 allowed_project_names = ['chromium', 'webrtc']
1295 allowed_labels = frozenset(
1296 ['restrict-view-securityteam', 'restrict-view-securitynotify'])
1297 if (target_project.project_name.lower()
1298 in allowed_project_names) and (issue.project_name.lower()
1299 in allowed_project_names):
1300 restrictions = set(restrictions) - allowed_labels
1301
1302 if restrictions:
Copybara854996b2021-09-07 19:36:02 +00001303 raise exceptions.InputException(
1304 'Issues with Restrict labels are not allowed to be moved')
1305
1306 with self.mc.profiler.Phase('Moving Issue'):
1307 tracker_fulltext.UnindexIssues([issue.issue_id])
1308
1309 # issue is modified by MoveIssues
1310 old_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
1311 moved_back_iids = self.services.issue.MoveIssues(
1312 self.mc.cnxn, target_project, [issue], self.services.user)
1313 new_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
1314
1315 if issue.issue_id in moved_back_iids:
1316 content = 'Moved %s back to %s again.' % (old_text_ref, new_text_ref)
1317 else:
1318 content = 'Moved %s to now be %s.' % (old_text_ref, new_text_ref)
1319 self.services.issue.CreateIssueComment(
1320 self.mc.cnxn, issue, self.mc.auth.user_id, content,
1321 amendments=[
1322 tracker_bizobj.MakeProjectAmendment(target_project.project_name)])
1323
1324 tracker_fulltext.IndexIssues(
1325 self.mc.cnxn, [issue], self.services.user, self.services.issue,
1326 self.services.config)
1327
1328 return issue
1329
1330 def CopyIssue(self, issue, target_project):
1331 """Copy issue to the target_project.
1332
1333 The current user needs to have permission to delete the current issue, and
1334 to edit issues on the target project.
1335
1336 Args:
1337 issue: the issue PB.
1338 target_project: the project PB where the issue should be copied to.
1339 Returns:
1340 The issue PB of the new issue on the target project.
1341 """
1342 self._AssertPermInIssue(issue, permissions.DELETE_ISSUE)
1343 self._AssertPermInProject(permissions.EDIT_ISSUE, target_project)
1344
1345 if permissions.GetRestrictions(issue):
1346 raise exceptions.InputException(
1347 'Issues with Restrict labels are not allowed to be copied')
1348
1349 with self.mc.profiler.Phase('Copying Issue'):
1350 copied_issue = self.services.issue.CopyIssues(
1351 self.mc.cnxn, target_project, [issue], self.services.user,
1352 self.mc.auth.user_id)[0]
1353
1354 issue_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
1355 copied_issue_ref = 'issue %s:%s' % (
1356 copied_issue.project_name, copied_issue.local_id)
1357
1358 # Add comment to the original issue.
1359 content = 'Copied %s to %s' % (issue_ref, copied_issue_ref)
1360 self.services.issue.CreateIssueComment(
1361 self.mc.cnxn, issue, self.mc.auth.user_id, content)
1362
1363 # Add comment to the newly created issue.
1364 # Add project amendment only if the project changed.
1365 amendments = []
1366 if issue.project_id != copied_issue.project_id:
1367 amendments.append(
1368 tracker_bizobj.MakeProjectAmendment(target_project.project_name))
1369 new_issue_content = 'Copied %s from %s' % (copied_issue_ref, issue_ref)
1370 self.services.issue.CreateIssueComment(
1371 self.mc.cnxn, copied_issue, self.mc.auth.user_id, new_issue_content,
1372 amendments=amendments)
1373
1374 tracker_fulltext.IndexIssues(
1375 self.mc.cnxn, [copied_issue], self.services.user, self.services.issue,
1376 self.services.config)
1377
1378 return copied_issue
1379
1380 def _MergeLinkedAccounts(self, me_user_id):
1381 """Return a list of the given user ID and any linked accounts."""
1382 if not me_user_id:
1383 return []
1384
1385 result = [me_user_id]
1386 me_user = self.services.user.GetUser(self.mc.cnxn, me_user_id)
1387 if me_user:
1388 if me_user.linked_parent_id:
1389 result.append(me_user.linked_parent_id)
1390 result.extend(me_user.linked_child_ids)
1391 return result
1392
1393 def SearchIssues(
1394 self, query_string, query_project_names, me_user_id, items_per_page,
1395 paginate_start, sort_spec):
1396 # type: (str, Sequence[str], int, int, int, str) -> ListResult
1397 """Search for issues in the given projects."""
1398 # View permissions and project existence check.
1399 _projects = self.GetProjectsByName(query_project_names)
1400 # TODO(crbug.com/monorail/6988): Delete ListIssues when endpoints and v1
1401 # are deprecated. Move pipeline call to SearchIssues.
1402 # TODO(crbug.com/monorail/7678): Remove can. Pass project_ids
1403 # into pipeline call instead of project_names into SearchIssues call.
1404 # project_names with project_ids.
1405 use_cached_searches = not settings.local_mode
1406 pipeline = self.ListIssues(
1407 query_string, query_project_names, me_user_id, items_per_page,
1408 paginate_start, 1, '', sort_spec, use_cached_searches)
1409
1410 end = paginate_start + items_per_page
1411 next_start = None
1412 if end < pipeline.total_count:
1413 next_start = end
1414 return ListResult(pipeline.visible_results, next_start)
1415
1416 def ListIssues(
1417 self,
1418 query_string, # type: str
1419 query_project_names, # type: Sequence[str]
1420 me_user_id, # type: int
1421 items_per_page, # type: int
1422 paginate_start, # type: int
1423 can, # type: int
1424 group_by_spec, # type: str
1425 sort_spec, # type: str
1426 use_cached_searches, # type: bool
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001427 project=None # type: mrproto.Project
Copybara854996b2021-09-07 19:36:02 +00001428 ):
1429 # type: (...) -> search.frontendsearchpipeline.FrontendSearchPipeline
1430 """Do an issue search w/ mc + passed in args to return a pipeline object.
1431
1432 Args:
1433 query_string: str with the query the user is searching for.
1434 query_project_names: List of project names to query for.
1435 me_user_id: Relevant user id. Usually the logged in user.
1436 items_per_page: Max number of issues to include in the results.
1437 paginate_start: Offset of issues to skip for pagination.
1438 can: id of canned query to use.
1439 group_by_spec: str used to specify how issues should be grouped.
1440 sort_spec: str used to specify how issues should be sorted.
1441 use_cached_searches: Whether to use the cache or not.
1442 project: Project object for the current project the user is viewing.
1443
1444 Returns:
1445 A FrontendSearchPipeline instance with data on issues found.
1446 """
1447 # Permission to view a project is checked in FrontendSearchPipeline().
1448 # Individual results are filtered by permissions in SearchForIIDs().
1449
1450 with self.mc.profiler.Phase('searching issues'):
1451 me_user_ids = self._MergeLinkedAccounts(me_user_id)
1452 pipeline = frontendsearchpipeline.FrontendSearchPipeline(
1453 self.mc.cnxn,
1454 self.services,
1455 self.mc.auth,
1456 me_user_ids,
1457 query_string,
1458 query_project_names,
1459 items_per_page,
1460 paginate_start,
1461 can,
1462 group_by_spec,
1463 sort_spec,
1464 self.mc.warnings,
1465 self.mc.errors,
1466 use_cached_searches,
1467 self.mc.profiler,
1468 project=project)
1469 if not self.mc.errors.AnyErrors():
1470 pipeline.SearchForIIDs()
1471 pipeline.MergeAndSortIssues()
1472 pipeline.Paginate()
1473 # TODO(jojwang): raise InvalidQueryException.
1474 return pipeline
1475
1476 # TODO(jrobbins): This method also requires self.mc to be a MonorailRequest.
1477 def FindIssuePositionInSearch(self, issue):
1478 """Do an issue search and return flipper info for the given issue.
1479
1480 Args:
1481 issue: issue that the user is currently viewing.
1482
1483 Returns:
1484 A 4-tuple of flipper info: (prev_iid, cur_index, next_iid, total_count).
1485 """
1486 # Permission to view a project is checked in FrontendSearchPipeline().
1487 # Individual results are filtered by permissions in SearchForIIDs().
1488
1489 with self.mc.profiler.Phase('finding issue position in search'):
1490 me_user_ids = self._MergeLinkedAccounts(self.mc.me_user_id)
1491 pipeline = frontendsearchpipeline.FrontendSearchPipeline(
1492 self.mc.cnxn,
1493 self.services,
1494 self.mc.auth,
1495 me_user_ids,
1496 self.mc.query,
1497 self.mc.query_project_names,
1498 self.mc.num,
1499 self.mc.start,
1500 self.mc.can,
1501 self.mc.group_by_spec,
1502 self.mc.sort_spec,
1503 self.mc.warnings,
1504 self.mc.errors,
1505 self.mc.use_cached_searches,
1506 self.mc.profiler,
1507 project=self.mc.project)
1508 if not self.mc.errors.AnyErrors():
1509 # Only do the search if the user's query parsed OK.
1510 pipeline.SearchForIIDs()
1511
1512 # Note: we never call MergeAndSortIssues() because we don't need a unified
1513 # sorted list, we only need to know the position on such a list of the
1514 # current issue.
1515 prev_iid, cur_index, next_iid = pipeline.DetermineIssuePosition(issue)
1516
1517 return prev_iid, cur_index, next_iid, pipeline.total_count
1518
1519 # TODO(crbug/monorail/6988): add boolean to ignore_private_issues
1520 def GetIssuesDict(self, issue_ids, use_cache=True,
1521 allow_viewing_deleted=False):
1522 # type: (Collection[int], Optional[Boolean], Optional[Boolean]) ->
1523 # Mapping[int, Issue]
1524 """Return a dict {iid: issue} with the specified issues, if allowed.
1525
1526 Args:
1527 issue_ids: int global issue IDs.
1528 use_cache: set to false to ensure fresh issues.
1529 allow_viewing_deleted: set to true to allow user to view deleted issues.
1530
1531 Returns:
1532 A dict {issue_id: issue} for only those issues that the user is allowed
1533 to view.
1534
1535 Raises:
1536 NoSuchIssueException if an issue is not found.
1537 PermissionException if the user cannot view all issues.
1538 """
1539 with self.mc.profiler.Phase('getting issues %r' % issue_ids):
1540 issues_by_id, missing_ids = self.services.issue.GetIssuesDict(
1541 self.mc.cnxn, issue_ids, use_cache=use_cache)
1542
1543 if missing_ids:
1544 with exceptions.ErrorAggregator(
1545 exceptions.NoSuchIssueException) as missing_err_agg:
1546 for missing_id in missing_ids:
1547 missing_err_agg.AddErrorMessage('No such issue: %s' % missing_id)
1548
1549 with exceptions.ErrorAggregator(
1550 permissions.PermissionException) as permission_err_agg:
1551 for issue in issues_by_id.values():
1552 try:
1553 self._AssertUserCanViewIssue(
1554 issue, allow_viewing_deleted=allow_viewing_deleted)
1555 except permissions.PermissionException as e:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001556 permission_err_agg.AddErrorMessage(str(e))
Copybara854996b2021-09-07 19:36:02 +00001557
1558 return issues_by_id
1559
1560 def GetIssue(self, issue_id, use_cache=True, allow_viewing_deleted=False):
1561 """Return the specified issue.
1562
1563 Args:
1564 issue_id: int global issue ID.
1565 use_cache: set to false to ensure fresh issue.
1566 allow_viewing_deleted: set to true to allow user to view a deleted issue.
1567
1568 Returns:
1569 The requested Issue PB.
1570 """
1571 if issue_id is None:
1572 raise exceptions.InputException('No issue issue_id specified')
1573
1574 with self.mc.profiler.Phase('getting issue %r' % issue_id):
1575 issue = self.services.issue.GetIssue(
1576 self.mc.cnxn, issue_id, use_cache=use_cache)
1577
1578 self._AssertUserCanViewIssue(
1579 issue, allow_viewing_deleted=allow_viewing_deleted)
1580 return issue
1581
1582 def ListReferencedIssues(self, ref_tuples, default_project_name):
1583 """Return the specified issues."""
1584 # Make sure ref_tuples are unique, preserving order.
1585 ref_tuples = list(collections.OrderedDict(
1586 list(zip(ref_tuples, ref_tuples))))
1587 ref_projects = self.services.project.GetProjectsByName(
1588 self.mc.cnxn,
1589 [(ref_pn or default_project_name) for ref_pn, _ in ref_tuples])
1590 issue_ids, _misses = self.services.issue.ResolveIssueRefs(
1591 self.mc.cnxn, ref_projects, default_project_name, ref_tuples)
1592 open_issues, closed_issues = (
1593 tracker_helpers.GetAllowedOpenedAndClosedIssues(
1594 self.mc, issue_ids, self.services))
1595 return open_issues, closed_issues
1596
1597 def GetIssueByLocalID(
1598 self, project_id, local_id, use_cache=True,
1599 allow_viewing_deleted=False):
1600 """Return the specified issue, TODO: iff the signed in user may view it.
1601
1602 Args:
1603 project_id: int project ID of the project that contains the issue.
1604 local_id: int issue local id number.
1605 use_cache: set to False when doing read-modify-write operations.
1606 allow_viewing_deleted: set to True to return a deleted issue so that
1607 an authorized user may undelete it.
1608
1609 Returns:
1610 The specified Issue PB.
1611
1612 Raises:
1613 exceptions.InputException: Something was not specified properly.
1614 exceptions.NoSuchIssueException: The issue does not exist.
1615 """
1616 if project_id is None:
1617 raise exceptions.InputException('No project specified')
1618 if local_id is None:
1619 raise exceptions.InputException('No issue local_id specified')
1620
1621 with self.mc.profiler.Phase('getting issue %r:%r' % (project_id, local_id)):
1622 issue = self.services.issue.GetIssueByLocalID(
1623 self.mc.cnxn, project_id, local_id, use_cache=use_cache)
1624
1625 self._AssertUserCanViewIssue(
1626 issue, allow_viewing_deleted=allow_viewing_deleted)
1627 return issue
1628
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001629 def ExtractMigratedIdFromLabels(self, labels):
1630 """Returns the issue ID from a migration label if present."""
1631 # Assume that there's only one migrated label.
1632 # Or at least drop any labels besides the first one.
1633 if labels is not None:
1634 for label in labels:
1635 lower_label = label.lower()
1636 for prefix in settings.migrated_buganizer_issue_prefixes:
1637 if lower_label.startswith(prefix):
1638 return label.replace(prefix, '')
1639 return None
1640
1641 def GetIssueMigratedID(self, project_name, local_id, labels=None):
1642 """Return the redirect id for a specific issue."""
1643 migrated_id = redirectissue.RedirectIssue.Get(project_name, local_id)
1644 if migrated_id is not None:
1645 return migrated_id
1646 return self.ExtractMigratedIdFromLabels(labels)
1647
Copybara854996b2021-09-07 19:36:02 +00001648 def GetRelatedIssueRefs(self, issues):
1649 """Return a dict {iid: (project_name, local_id)} for all related issues."""
1650 related_iids = set()
1651 with self.mc.profiler.Phase('getting related issue refs'):
1652 for issue in issues:
1653 related_iids.update(issue.blocked_on_iids)
1654 related_iids.update(issue.blocking_iids)
1655 if issue.merged_into:
1656 related_iids.add(issue.merged_into)
Copybara854996b2021-09-07 19:36:02 +00001657 return self.services.issue.LookupIssueRefs(self.mc.cnxn, related_iids)
1658
1659 def GetIssueRefs(self, issue_ids):
1660 """Return a dict {iid: (project_name, local_id)} for all issue_ids."""
1661 return self.services.issue.LookupIssueRefs(self.mc.cnxn, issue_ids)
1662
1663 def BulkUpdateIssueApprovals(self, issue_ids, approval_id, project,
1664 approval_delta, comment_content,
1665 send_email):
1666 """Update all given issues' specified approval."""
1667 # Anon users and users with no permission to view the project
1668 # will get permission denied. Missing permissions to update
1669 # individual issues will not throw exceptions. Issues will just not be
1670 # updated.
1671 if not self.mc.auth.user_id:
1672 raise permissions.PermissionException('Anon cannot make changes')
1673 if not self._UserCanViewProject(project):
1674 raise permissions.PermissionException('User cannot view project')
1675 updated_issue_ids = []
1676 for issue_id in issue_ids:
1677 try:
1678 self.UpdateIssueApproval(
1679 issue_id, approval_id, approval_delta, comment_content, False,
1680 send_email=False)
1681 updated_issue_ids.append(issue_id)
1682 except exceptions.NoSuchIssueApprovalException as e:
1683 logging.info('Skipping issue %s, no approval: %s', issue_id, e)
1684 except permissions.PermissionException as e:
1685 logging.info('Skipping issue %s, update not allowed: %s', issue_id, e)
1686 # TODO(crbug/monorail/8122): send bulk approval update email if send_email.
1687 if send_email:
1688 pass
1689 return updated_issue_ids
1690
1691 def BulkUpdateIssueApprovalsV3(
1692 self, delta_specifications, comment_content, send_email):
1693 # type: (Sequence[Tuple[int, int, tracker_pb2.ApprovalDelta]]], str,
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001694 # Boolean -> Sequence[mrproto.tracker_pb2.ApprovalValue]
Copybara854996b2021-09-07 19:36:02 +00001695 """Executes the ApprovalDeltas.
1696
1697 Args:
1698 delta_specifications: List of (issue_id, approval_id, ApprovalDelta).
1699 comment_content: The content of the comment to be posted with each delta.
1700 send_email: Whether to send an email on each change.
1701 TODO(crbug/monorail/8122): send bulk approval update email instead.
1702
1703 Returns:
1704 A list of (Issue, ApprovalValue) pairs corresponding to each
1705 specification provided in `delta_specifications`.
1706
1707 Raises:
1708 InputException: If a comment is too long.
1709 NoSuchIssueApprovalException: If any of the approvals specified
1710 does not exist.
1711 PermissionException: If the current user lacks permissions to execute
1712 any of the deltas provided.
1713 """
1714 updated_approval_values = []
1715 for (issue_id, approval_id, approval_delta) in delta_specifications:
1716 updated_av, _comment, issue = self.UpdateIssueApproval(
1717 issue_id,
1718 approval_id,
1719 approval_delta,
1720 comment_content,
1721 False,
1722 send_email=send_email,
1723 update_perms=True)
1724 updated_approval_values.append((issue, updated_av))
1725 return updated_approval_values
1726
1727 def UpdateIssueApproval(
1728 self,
1729 issue_id,
1730 approval_id,
1731 approval_delta,
1732 comment_content,
1733 is_description,
1734 attachments=None,
1735 send_email=True,
1736 kept_attachments=None,
1737 update_perms=False):
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001738 # type: (int, int, mrproto.tracker_pb2.ApprovalDelta, str, Boolean,
1739 # Optional[Sequence[mrproto.tracker_pb2.Attachment]], Optional[Boolean],
Copybara854996b2021-09-07 19:36:02 +00001740 # Optional[Sequence[int]], Optional[Boolean]) ->
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001741 # (mrproto.tracker_pb2.ApprovalValue, mrproto.tracker_pb2.IssueComment)
Copybara854996b2021-09-07 19:36:02 +00001742 """Update an issue's approval.
1743
1744 Raises:
1745 InputException: The comment content is too long or additional approvers do
1746 not exist.
1747 PermissionException: The user is lacking one of the permissions needed
1748 for the given delta.
1749 NoSuchIssueApprovalException: The issue/approval combo does not exist.
1750 """
1751
1752 issue, approval_value = self.services.issue.GetIssueApproval(
1753 self.mc.cnxn, issue_id, approval_id, use_cache=False)
1754
1755 self._AssertPermInIssue(issue, permissions.EDIT_ISSUE)
1756
1757 if len(comment_content) > tracker_constants.MAX_COMMENT_CHARS:
1758 raise exceptions.InputException('Comment is too long')
1759
1760 project = self.GetProject(issue.project_id)
1761 config = self.GetProjectConfig(issue.project_id)
1762 # TODO(crbug/monorail/7614): Remove the need for this hack to update perms.
1763 if update_perms:
1764 self.mc.LookupLoggedInUserPerms(project)
1765
1766 if attachments:
1767 with self.mc.profiler.Phase('Accounting for quota'):
1768 new_bytes_used = tracker_helpers.ComputeNewQuotaBytesUsed(
1769 project, attachments)
1770 self.services.project.UpdateProject(
1771 self.mc.cnxn, issue.project_id, attachment_bytes_used=new_bytes_used)
1772
1773 if kept_attachments:
1774 with self.mc.profiler.Phase('Filtering kept attachments'):
1775 kept_attachments = tracker_helpers.FilterKeptAttachments(
1776 is_description, kept_attachments, self.ListIssueComments(issue),
1777 approval_id)
1778
1779 if approval_delta.status:
1780 if not permissions.CanUpdateApprovalStatus(
1781 self.mc.auth.effective_ids, self.mc.perms, project,
1782 approval_value.approver_ids, approval_delta.status):
1783 raise permissions.PermissionException(
1784 'User not allowed to make this status update.')
1785
1786 if approval_delta.approver_ids_remove or approval_delta.approver_ids_add:
1787 if not permissions.CanUpdateApprovers(
1788 self.mc.auth.effective_ids, self.mc.perms, project,
1789 approval_value.approver_ids):
1790 raise permissions.PermissionException(
1791 'User not allowed to modify approvers of this approval.')
1792
1793 # Check additional approvers exist.
1794 with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
1795 tracker_helpers.AssertUsersExist(
1796 self.mc.cnxn, self.services, approval_delta.approver_ids_add, err_agg)
1797
1798 with self.mc.profiler.Phase(
1799 'updating approval for issue %r, aprpoval %r' % (
1800 issue_id, approval_id)):
1801 comment_pb = self.services.issue.DeltaUpdateIssueApproval(
1802 self.mc.cnxn, self.mc.auth.user_id, config, issue, approval_value,
1803 approval_delta, comment_content=comment_content,
1804 is_description=is_description, attachments=attachments,
1805 kept_attachments=kept_attachments)
1806 hostport = framework_helpers.GetHostPort(
1807 project_name=project.project_name)
1808 send_notifications.PrepareAndSendApprovalChangeNotification(
1809 issue_id, approval_id, hostport, comment_pb.id,
1810 send_email=send_email)
1811
1812 return approval_value, comment_pb, issue
1813
1814 def ConvertIssueApprovalsTemplate(
1815 self, config, issue, template_name, comment_content, send_email=True):
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001816 # type: (mrproto.tracker_pb2.ProjectIssueConfig, mrproto.tracker_pb2.Issue,
Copybara854996b2021-09-07 19:36:02 +00001817 # str, str, Optional[Boolean] )
1818 """Convert an issue's existing approvals structure to match the one of
1819 the given template.
1820
1821 Raises:
1822 InputException: The comment content is too long.
1823 """
1824 self._AssertPermInIssue(issue, permissions.EDIT_ISSUE)
1825
1826 template = self.services.template.GetTemplateByName(
1827 self.mc.cnxn, template_name, issue.project_id)
1828 if not template:
1829 raise exceptions.NoSuchTemplateException(
1830 'Template %s is not found' % template_name)
1831
1832 if len(comment_content) > tracker_constants.MAX_COMMENT_CHARS:
1833 raise exceptions.InputException('Comment is too long')
1834
1835 with self.mc.profiler.Phase('updating issue %r' % issue):
1836 comment_pb = self.services.issue.UpdateIssueStructure(
1837 self.mc.cnxn, config, issue, template, self.mc.auth.user_id,
1838 comment_content)
1839 hostport = framework_helpers.GetHostPort(project_name=issue.project_name)
1840 send_notifications.PrepareAndSendIssueChangeNotification(
1841 issue.issue_id, hostport, self.mc.auth.user_id,
1842 send_email=send_email, comment_id=comment_pb.id)
1843
1844 def UpdateIssue(
1845 self, issue, delta, comment_content, attachments=None, send_email=True,
1846 is_description=False, kept_attachments=None, inbound_message=None):
1847 # type: (...) => None
1848 """Update an issue with a set of changes and add a comment.
1849
1850 Args:
1851 issue: Existing Issue PB for the issue to be modified.
1852 delta: IssueDelta object containing all the changes to be made.
1853 comment_content: string content of the user's comment.
1854 attachments: List [(filename, contents, mimetype),...] of attachments.
1855 send_email: set to False to suppress email notifications.
1856 is_description: True if this adds a new issue description.
1857 kept_attachments: This should be a list of int attachment ids for
1858 attachments kept from previous descriptions, if the comment is
1859 a change to the issue description.
1860 inbound_message: optional string full text of an email that caused
1861 this comment to be added.
1862
1863 Returns:
1864 Nothing.
1865
1866 Raises:
1867 InputException: The comment content is too long.
1868 """
1869 if not self._UserCanUsePermInIssue(issue, permissions.EDIT_ISSUE):
1870 # We're editing the issue description. Only users with EditIssue
1871 # permission can edit the description.
1872 if is_description:
1873 raise permissions.PermissionException(
1874 'Users lack permission EditIssue in issue')
1875 # If we're adding a comment, we must have AddIssueComment permission and
1876 # verify it's size.
1877 if comment_content:
1878 self._AssertPermInIssue(issue, permissions.ADD_ISSUE_COMMENT)
1879 # If we're modifying the issue, check that we only modify the fields we're
1880 # allowed to edit.
1881 if delta != tracker_pb2.IssueDelta():
1882 allowed_delta = tracker_pb2.IssueDelta()
1883 if self._UserCanUsePermInIssue(issue, permissions.EDIT_ISSUE_STATUS):
1884 allowed_delta.status = delta.status
1885 if self._UserCanUsePermInIssue(issue, permissions.EDIT_ISSUE_SUMMARY):
1886 allowed_delta.summary = delta.summary
1887 if self._UserCanUsePermInIssue(issue, permissions.EDIT_ISSUE_OWNER):
1888 allowed_delta.owner_id = delta.owner_id
1889 if self._UserCanUsePermInIssue(issue, permissions.EDIT_ISSUE_CC):
1890 allowed_delta.cc_ids_add = delta.cc_ids_add
1891 allowed_delta.cc_ids_remove = delta.cc_ids_remove
1892 if delta != allowed_delta:
1893 raise permissions.PermissionException(
1894 'Users lack permission EditIssue in issue')
1895
1896 if delta.merged_into:
1897 # Reject attempts to merge an issue into an issue we cannot view and edit.
1898 merged_into_issue = self.GetIssue(
1899 delta.merged_into, use_cache=False, allow_viewing_deleted=True)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001900 self._AssertPermInIssue(merged_into_issue, permissions.EDIT_ISSUE)
Copybara854996b2021-09-07 19:36:02 +00001901 self._AssertPermInIssue(issue, permissions.EDIT_ISSUE)
1902 # Reject attempts to merge an issue into itself.
1903 if issue.issue_id == delta.merged_into:
1904 raise exceptions.InputException(
1905 'Cannot merge an issue into itself.')
1906
1907 # Reject comments that are too long.
1908 if comment_content and len(
1909 comment_content) > tracker_constants.MAX_COMMENT_CHARS:
1910 raise exceptions.InputException('Comment is too long')
1911
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001912 # Reject attempts to modifying blocking issues we cannot edit.
1913 all_block = (
1914 delta.blocked_on_add + delta.blocking_add + delta.blocked_on_remove +
1915 delta.blocking_remove)
1916 for block_iid in all_block:
1917 blocked_issue = self.GetIssue(
1918 block_iid, use_cache=False, allow_viewing_deleted=True)
1919 self._AssertPermInIssue(blocked_issue, permissions.EDIT_ISSUE)
1920
Copybara854996b2021-09-07 19:36:02 +00001921 # Reject attempts to block on issue on itself.
1922 if (issue.issue_id in delta.blocked_on_add
1923 or issue.issue_id in delta.blocking_add):
1924 raise exceptions.InputException(
1925 'Cannot block an issue on itself.')
1926
1927 project = self.GetProject(issue.project_id)
1928 config = self.GetProjectConfig(issue.project_id)
1929
1930 # Reject attempts to edit restricted fields that the user cannot change.
1931 field_ids = [fv.field_id for fv in delta.field_vals_add]
1932 field_ids.extend([fvr.field_id for fvr in delta.field_vals_remove])
1933 field_ids.extend(delta.fields_clear)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001934
Copybara854996b2021-09-07 19:36:02 +00001935 labels = itertools.chain(delta.labels_add, delta.labels_remove)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001936 labels_err_msg = field_helpers.ValidateLabels(
1937 self.mc.cnxn, self.services, issue.project_id, delta.labels_add)
1938 if labels_err_msg:
1939 raise exceptions.InputException(labels_err_msg)
1940
Copybara854996b2021-09-07 19:36:02 +00001941 self._AssertUserCanEditFieldsAndEnumMaskedLabels(
1942 project, config, field_ids, labels)
1943
1944 old_owner_id = tracker_bizobj.GetOwnerId(issue)
1945
1946 if attachments:
1947 with self.mc.profiler.Phase('Accounting for quota'):
1948 new_bytes_used = tracker_helpers.ComputeNewQuotaBytesUsed(
1949 project, attachments)
1950 self.services.project.UpdateProject(
1951 self.mc.cnxn, issue.project_id,
1952 attachment_bytes_used=new_bytes_used)
1953
1954 with self.mc.profiler.Phase('Validating the issue change'):
1955 # If the owner changed, it must be a project member.
1956 if (delta.owner_id is not None and delta.owner_id != issue.owner_id):
1957 parsed_owner_valid, msg = tracker_helpers.IsValidIssueOwner(
1958 self.mc.cnxn, project, delta.owner_id, self.services)
1959 if not parsed_owner_valid:
1960 raise exceptions.InputException(msg)
1961
1962 if kept_attachments:
1963 with self.mc.profiler.Phase('Filtering kept attachments'):
1964 kept_attachments = tracker_helpers.FilterKeptAttachments(
1965 is_description, kept_attachments, self.ListIssueComments(issue),
1966 None)
1967
1968 with self.mc.profiler.Phase('Updating issue %r' % (issue.issue_id)):
1969 _amendments, comment_pb = self.services.issue.DeltaUpdateIssue(
1970 self.mc.cnxn, self.services, self.mc.auth.user_id, issue.project_id,
1971 config, issue, delta, comment=comment_content,
1972 attachments=attachments, is_description=is_description,
1973 kept_attachments=kept_attachments, inbound_message=inbound_message)
1974
1975 with self.mc.profiler.Phase('Following up after issue update'):
1976 if delta.merged_into:
1977 new_starrers = tracker_helpers.GetNewIssueStarrers(
1978 self.mc.cnxn, self.services, [issue.issue_id],
1979 delta.merged_into)
1980 merged_into_project = self.GetProject(merged_into_issue.project_id)
1981 tracker_helpers.AddIssueStarrers(
1982 self.mc.cnxn, self.services, self.mc,
1983 delta.merged_into, merged_into_project, new_starrers)
1984 # Load target issue again to get the updated star count.
1985 merged_into_issue = self.GetIssue(
1986 merged_into_issue.issue_id, use_cache=False)
1987 merge_comment_pb = tracker_helpers.MergeCCsAndAddComment(
1988 self.services, self.mc, issue, merged_into_issue)
1989 # Send notification emails.
1990 hostport = framework_helpers.GetHostPort(
1991 project_name=merged_into_project.project_name)
1992 reporter_id = self.mc.auth.user_id
1993 send_notifications.PrepareAndSendIssueChangeNotification(
1994 merged_into_issue.issue_id,
1995 hostport,
1996 reporter_id,
1997 send_email=send_email,
1998 comment_id=merge_comment_pb.id)
1999 self.services.project.UpdateRecentActivity(
2000 self.mc.cnxn, issue.project_id)
2001
2002 with self.mc.profiler.Phase('Generating notifications'):
2003 if comment_pb:
2004 hostport = framework_helpers.GetHostPort(
2005 project_name=project.project_name)
2006 reporter_id = self.mc.auth.user_id
2007 send_notifications.PrepareAndSendIssueChangeNotification(
2008 issue.issue_id, hostport, reporter_id,
2009 send_email=send_email, old_owner_id=old_owner_id,
2010 comment_id=comment_pb.id)
2011 delta_blocked_on_iids = delta.blocked_on_add + delta.blocked_on_remove
2012 send_notifications.PrepareAndSendIssueBlockingNotification(
2013 issue.issue_id, hostport, delta_blocked_on_iids,
2014 reporter_id, send_email=send_email)
2015
2016 def ModifyIssues(
2017 self,
2018 issue_id_delta_pairs,
2019 attachment_uploads=None,
2020 comment_content=None,
2021 send_email=True):
2022 # type: (Sequence[Tuple[int, IssueDelta]], Boolean, Optional[str],
2023 # Optional[bool]) -> Sequence[Issue]
2024 """Modify issues by the given deltas and returns all issues post-update.
2025
2026 Note: Issues with NOOP deltas and no comment_content to add will not be
2027 updated and will not be returned.
2028
2029 Args:
2030 issue_id_delta_pairs: List of Tuples containing IDs and IssueDeltas, one
2031 for each issue to modify.
2032 attachment_uploads: List of AttachmentUpload tuples to be attached to the
2033 new comments created for all modified issues in issue_id_delta_pairs.
2034 comment_content: The text for the comment this issue change will use.
2035 send_email: Whether this change sends an email or not.
2036
2037 Returns:
2038 List of modified issues.
2039 """
2040
2041 main_issue_ids = {issue_id for issue_id, _delta in issue_id_delta_pairs}
2042 issues_by_id = self.GetIssuesDict(main_issue_ids, use_cache=False)
2043 issue_delta_pairs = [
2044 (issues_by_id[issue_id], delta)
2045 for (issue_id, delta) in issue_id_delta_pairs
2046 ]
2047
2048 # PHASE 1: Prepare these changes and assert they can be made.
2049 self._AssertUserCanModifyIssues(
2050 issue_delta_pairs, False, comment_content=comment_content)
2051 new_bytes_by_pid = tracker_helpers.PrepareIssueChanges(
2052 self.mc.cnxn,
2053 issue_delta_pairs,
2054 self.services,
2055 attachment_uploads=attachment_uploads,
2056 comment_content=comment_content)
2057 # TODO(crbug.com/monorail/8074): Assert we do not update more than 100
2058 # issues at once.
2059
2060 # PHASE 2: Organize data. tracker_helpers.GroupUniqueDeltaIssues()
2061 (_unique_deltas, issues_for_unique_deltas
2062 ) = tracker_helpers.GroupUniqueDeltaIssues(issue_delta_pairs)
2063
2064 # PHASE 3-4: Modify issues in RAM.
2065 changes = tracker_helpers.ApplyAllIssueChanges(
2066 self.mc.cnxn, issue_delta_pairs, self.services)
2067
2068 # PHASE 5: Apply filter rules.
2069 inflight_issues = changes.issues_to_update_dict.values()
2070 project_ids = list(
2071 {issue.project_id for issue in inflight_issues})
2072 configs_by_id = self.services.config.GetProjectConfigs(
2073 self.mc.cnxn, project_ids)
2074 with exceptions.ErrorAggregator(exceptions.FilterRuleException) as err_agg:
2075 for issue in inflight_issues:
2076 config = configs_by_id[issue.project_id]
2077
2078 # Update closed timestamp before filter rules because filter rules
2079 # may affect them.
2080 old_effective_status = changes.old_statuses_by_iid.get(issue.issue_id)
2081 # The old status might be None because the IssueDeltas did not contain
2082 # a status change and MeansOpenInProject treats None as "Open".
2083 if old_effective_status:
2084 tracker_helpers.UpdateClosedTimestamp(
2085 config, issue, old_effective_status)
2086
2087 filterrules_helpers.ApplyFilterRules(
2088 self.mc.cnxn, self.services, issue, config)
2089 if issue.derived_errors:
2090 err_agg.AddErrorMessage('/n'.join(issue.derived_errors))
2091
2092 # Update closed timestamp after filter rules because filter rules
2093 # could change effective status.
2094 # The old status might be None because the IssueDeltas did not contain
2095 # a status change and MeansOpenInProject treats None as "Open".
2096 if old_effective_status:
2097 tracker_helpers.UpdateClosedTimestamp(
2098 config, issue, old_effective_status)
2099
2100 # PHASE 6: Update modified timestamps for issues in RAM.
2101 all_involved_iids = main_issue_ids.union(
2102 changes.issues_to_update_dict.keys())
2103
2104 now_timestamp = int(time.time())
2105 # Add modified timestamps for issues with amendments.
2106 for iid in all_involved_iids:
2107 issue = changes.issues_to_update_dict.get(iid, issues_by_id.get(iid))
2108 issue_modified = iid in changes.issues_to_update_dict
2109
2110 if not (issue_modified or comment_content or attachment_uploads):
2111 # Skip issues that have neither amendments or comment changes.
2112 continue
2113
2114 old_owner = changes.old_owners_by_iid.get(issue.issue_id)
2115 old_status = changes.old_statuses_by_iid.get(issue.issue_id)
2116 old_components = changes.old_components_by_iid.get(issue.issue_id)
2117
2118 # Adding this issue to issues_to_update, so its modified_timestamp gets
2119 # updated in PHASE 7's UpdateIssues() call. Issues with NOOP changes
2120 # but still need a new comment added for `comment_content` or
2121 # `attachments` are added back here.
2122 changes.issues_to_update_dict[issue.issue_id] = issue
2123
2124 issue.modified_timestamp = now_timestamp
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01002125 issue.migration_modified_timestamp = now_timestamp
Copybara854996b2021-09-07 19:36:02 +00002126
2127 if (iid in changes.old_owners_by_iid and
2128 old_owner != tracker_bizobj.GetOwnerId(issue)):
2129 issue.owner_modified_timestamp = now_timestamp
2130
2131 if (iid in changes.old_statuses_by_iid and
2132 old_status != tracker_bizobj.GetStatus(issue)):
2133 issue.status_modified_timestamp = now_timestamp
2134
2135 if (iid in changes.old_components_by_iid and
2136 set(old_components) != set(issue.component_ids)):
2137 issue.component_modified_timestamp = now_timestamp
2138
2139 # PHASE 7: Apply changes to DB: update issues, combine starrers
2140 # for merged issues, create issue comments, enqueue issues for
2141 # re-indexing.
2142 if changes.issues_to_update_dict:
2143 self.services.issue.UpdateIssues(
2144 self.mc.cnxn, changes.issues_to_update_dict.values(), commit=False)
2145 comments_by_iid = {}
2146 impacted_comments_by_iid = {}
2147
2148 # changes.issues_to_update includes all main issues or impacted
2149 # issues with updated fields and main issues that had noop changes
2150 # but still need a comment created for `comment_content` or `attachments`.
2151 for iid, issue in changes.issues_to_update_dict.items():
2152 # Update starrers for merged issues.
2153 new_starrers = changes.new_starrers_by_iid.get(iid)
2154 if new_starrers:
2155 self.services.issue_star.SetStarsBatch_SkipIssueUpdate(
2156 self.mc.cnxn, iid, new_starrers, True, commit=False)
2157
2158 # Create new issue comment for main issue changes.
2159 amendments = changes.amendments_by_iid.get(iid)
2160 if (amendments or comment_content or
2161 attachment_uploads) and iid in main_issue_ids:
2162 comments_by_iid[iid] = self.services.issue.CreateIssueComment(
2163 self.mc.cnxn,
2164 issue,
2165 self.mc.auth.user_id,
2166 comment_content,
2167 amendments=amendments,
2168 attachments=attachment_uploads,
2169 commit=False)
2170
2171 # Create new issue comment for impacted issue changes.
2172 # ie: when an issue is marked as blockedOn another or similar.
2173 imp_amendments = changes.imp_amendments_by_iid.get(iid)
2174 if imp_amendments:
2175 filtered_imp_amendments = []
2176 content = ''
2177 # Represent MERGEDINTO Amendments for impacted issues with
2178 # comment content instead to be consistent with previous behavior
2179 # and so users can tell whether a merged change comment on an issue
2180 # is a change in the issue's merged_into or a change in another
2181 # issue's merged_into.
2182 for am in imp_amendments:
2183 if am.field is tracker_pb2.FieldID.MERGEDINTO and am.newvalue:
2184 for value in am.newvalue.split():
2185 if value.startswith('-'):
2186 content += UNMERGE_COMMENT % value.strip('-')
2187 else:
2188 content += MERGE_COMMENT % value
2189 else:
2190 filtered_imp_amendments.append(am)
2191
2192 impacted_comments_by_iid[iid] = self.services.issue.CreateIssueComment(
2193 self.mc.cnxn,
2194 issue,
2195 self.mc.auth.user_id,
2196 content,
2197 amendments=filtered_imp_amendments,
2198 commit=False)
2199
2200 # Update used bytes for each impacted project.
2201 for pid, new_bytes_used in new_bytes_by_pid.items():
2202 self.services.project.UpdateProject(
2203 self.mc.cnxn, pid, attachment_bytes_used=new_bytes_used, commit=False)
2204
2205 # Reindex issues and commit all DB changes.
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01002206 issues_to_reindex = (
2207 set(comments_by_iid.keys()) | set(impacted_comments_by_iid.keys()))
Copybara854996b2021-09-07 19:36:02 +00002208 if issues_to_reindex:
2209 self.services.issue.EnqueueIssuesForIndexing(
2210 self.mc.cnxn, issues_to_reindex, commit=False)
2211 # We only commit if there are issues to reindex. No issues to reindex
2212 # means there were no updates that need a commit.
2213 self.mc.cnxn.Commit()
2214
2215 # PHASE 8: Send notifications for each group of issues from Phase 2.
2216 # Fetch hostports.
2217 hostports_by_pid = {}
2218 for iid, issue in changes.issues_to_update_dict.items():
2219 # Note: issues_to_update only include issues with changes in metadata.
2220 # If iid is not in issues_to_update, the issue may still have a new
2221 # comment that we want to send notifications for.
2222 issue = changes.issues_to_update_dict.get(iid, issues_by_id.get(iid))
2223
2224 if issue.project_id not in hostports_by_pid:
2225 hostports_by_pid[issue.project_id] = framework_helpers.GetHostPort(
2226 project_name=issue.project_name)
2227 # Send emails for main changes in issues by unique delta.
2228 for issues in issues_for_unique_deltas:
2229 # Group issues for each unique delta by project because
2230 # SendIssueBulkChangeNotification cannot handle cross-project
2231 # notifications and hostports are specific to each project.
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01002232 issues_by_pid = collections.defaultdict(list)
Copybara854996b2021-09-07 19:36:02 +00002233 for issue in issues:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01002234 issues_by_pid[issue.project_id].append(issue)
Copybara854996b2021-09-07 19:36:02 +00002235 for project_issues in issues_by_pid.values():
2236 # Send one email to involved users for the issue.
2237 if len(project_issues) == 1:
2238 (project_issue,) = project_issues
2239 self._ModifyIssuesNotifyForDelta(
2240 project_issue, changes, comments_by_iid, hostports_by_pid,
2241 send_email)
2242 # Send one bulk email for users involved in all updated issues.
2243 else:
2244 self._ModifyIssuesBulkNotifyForDelta(
2245 project_issues,
2246 changes,
2247 hostports_by_pid,
2248 send_email,
2249 comment_content=comment_content)
2250
2251 # Send emails for changes to impacted issues.
2252 for issue_id, comment_pb in impacted_comments_by_iid.items():
2253 issue = changes.issues_to_update_dict[issue_id]
2254 hostport = hostports_by_pid[issue.project_id]
2255 # We do not need to track old owners because the only owner change
2256 # that could have happened for impacted issues' changes is a change from
2257 # no owner to a derived owner.
2258 send_notifications.PrepareAndSendIssueChangeNotification(
2259 issue_id, hostport, self.mc.auth.user_id, comment_id=comment_pb.id,
2260 send_email=send_email)
2261
2262 return [
2263 issues_by_id[iid] for iid in main_issue_ids if iid in comments_by_iid
2264 ]
2265
2266 def _ModifyIssuesNotifyForDelta(
2267 self, issue, changes, comments_by_iid, hostports_by_pid, send_email):
2268 # type: (Issue, tracker_helpers._IssueChangesTuple,
2269 # Mapping[int, IssueComment], Mapping[int, str], bool) -> None
2270 comment_pb = comments_by_iid.get(issue.issue_id)
2271 # Existence of a comment_pb means there were updates to the issue or
2272 # comment_content added to the issue that should trigger
2273 # notifications.
2274 if comment_pb:
2275 hostport = hostports_by_pid[issue.project_id]
2276 old_owner_id = changes.old_owners_by_iid.get(issue.issue_id)
2277 send_notifications.PrepareAndSendIssueChangeNotification(
2278 issue.issue_id,
2279 hostport,
2280 self.mc.auth.user_id,
2281 old_owner_id=old_owner_id,
2282 comment_id=comment_pb.id,
2283 send_email=send_email)
2284
2285 def _ModifyIssuesBulkNotifyForDelta(
2286 self, issues, changes, hostports_by_pid, send_email,
2287 comment_content=None):
2288 # type: (Collection[Issue], _IssueChangesTuple, Mapping[int, str], bool,
2289 # Optional[str]) -> None
2290 iids = {issue.issue_id for issue in issues}
2291 old_owner_ids = [
2292 changes.old_owners_by_iid.get(iid)
2293 for iid in iids
2294 if changes.old_owners_by_iid.get(iid)
2295 ]
2296 amendments = []
2297 for iid in iids:
2298 ams = changes.amendments_by_iid.get(iid, [])
2299 amendments.extend(ams)
2300 # Calling SendBulkChangeNotification does not require the comment_pb
2301 # objects only the amendments. Checking for existence of amendments
2302 # and comment_content is equivalent to checking for existence of new
2303 # comments created for these issues.
2304 if amendments or comment_content:
2305 # TODO(crbug.com/monorail/8125): Stop using UserViews for bulk
2306 # notifications.
2307 users_by_id = framework_views.MakeAllUserViews(
2308 self.mc.cnxn, self.services.user, old_owner_ids,
2309 tracker_bizobj.UsersInvolvedInAmendments(amendments))
2310 hostport = hostports_by_pid[issues.pop().project_id]
2311 send_notifications.SendIssueBulkChangeNotification(
2312 iids, hostport, old_owner_ids, comment_content,
2313 self.mc.auth.user_id, amendments, send_email, users_by_id)
2314
2315 def DeleteIssue(self, issue, delete):
2316 """Mark or unmark the given issue as deleted."""
2317 self._AssertPermInIssue(issue, permissions.DELETE_ISSUE)
2318
2319 with self.mc.profiler.Phase('Marking issue %r deleted' % (issue.issue_id)):
2320 self.services.issue.SoftDeleteIssue(
2321 self.mc.cnxn, issue.project_id, issue.local_id, delete,
2322 self.services.user)
2323
2324 def FlagIssues(self, issues, flag):
2325 """Flag or unflag the given issues as spam."""
2326 for issue in issues:
2327 self._AssertPermInIssue(issue, permissions.FLAG_SPAM)
2328
2329 issue_ids = [issue.issue_id for issue in issues]
2330 with self.mc.profiler.Phase('Marking issues %r as spam' % issue_ids):
2331 self.services.spam.FlagIssues(
2332 self.mc.cnxn, self.services.issue, issues, self.mc.auth.user_id,
2333 flag)
2334 if self._UserCanUsePermInIssue(issue, permissions.VERDICT_SPAM):
2335 self.services.spam.RecordManualIssueVerdicts(
2336 self.mc.cnxn, self.services.issue, issues, self.mc.auth.user_id,
2337 flag)
2338
2339 def LookupIssuesFlaggers(self, issues):
2340 """Returns users who've reported the issue or its comments as spam.
2341
2342 Args:
2343 issues: the list of issues to query.
2344 Returns:
2345 A dictionary
2346 {issue_id: ([issue_reporters], {comment_id: [comment_reporters]})}
2347 For each issue id, a tuple with the users who have flagged the issue;
2348 and a dictionary of users who have flagged a comment for each comment id.
2349 """
2350 for issue in issues:
2351 self._AssertUserCanViewIssue(issue)
2352
2353 issue_ids = [issue.issue_id for issue in issues]
2354 with self.mc.profiler.Phase('Looking up flaggers for %s' % issue_ids):
2355 reporters = self.services.spam.LookupIssuesFlaggers(
2356 self.mc.cnxn, issue_ids)
2357
2358 return reporters
2359
2360 def LookupIssueFlaggers(self, issue):
2361 """Returns users who've reported the issue or its comments as spam.
2362
2363 Args:
2364 issue: the issue to query.
2365 Returns:
2366 A tuple
2367 ([issue_reporters], {comment_id: [comment_reporters]})
2368 With the users who have flagged the issue; and a dictionary of users who
2369 have flagged a comment for each comment id.
2370 """
2371 return self.LookupIssuesFlaggers([issue])[issue.issue_id]
2372
2373 def GetIssuePositionInHotlist(
2374 self, current_issue, hotlist, can, sort_spec, group_by_spec):
2375 # type: (Issue, Hotlist, int, str, str) -> (int, int, int, int)
2376 """Get index info of an issue within a hotlist.
2377
2378 Args:
2379 current_issue: the currently viewed issue.
2380 hotlist: the hotlist this flipper is flipping through.
2381 can: int "canned query" number to scope the visible issues.
2382 sort_spec: string that lists the sort order.
2383 group_by_spec: string that lists the grouping order.
2384 """
2385 issues_list = self.services.issue.GetIssues(self.mc.cnxn,
2386 [item.issue_id for item in hotlist.items])
2387 project_ids = hotlist_helpers.GetAllProjectsOfIssues(issues_list)
2388 config_list = hotlist_helpers.GetAllConfigsOfProjects(
2389 self.mc.cnxn, project_ids, self.services)
2390 harmonized_config = tracker_bizobj.HarmonizeConfigs(config_list)
2391 (sorted_issues, _hotlist_issues_context,
2392 _users) = hotlist_helpers.GetSortedHotlistIssues(
2393 self.mc.cnxn, hotlist.items, issues_list, self.mc.auth,
2394 can, sort_spec, group_by_spec, harmonized_config, self.services,
2395 self.mc.profiler)
2396 (prev_iid, cur_index,
2397 next_iid) = features_bizobj.DetermineHotlistIssuePosition(
2398 current_issue, [issue.issue_id for issue in sorted_issues])
2399 total_count = len(sorted_issues)
2400 return prev_iid, cur_index, next_iid, total_count
2401
2402 def RerankBlockedOnIssues(self, issue, moved_id, target_id, split_above):
2403 """Rerank the blocked on issues for issue_id.
2404
2405 Args:
2406 issue: The issue to modify.
2407 moved_id: The id of the issue to move.
2408 target_id: The id of the issue to move |moved_issue| to.
2409 split_above: Whether to move |moved_issue| before or after |target_issue|.
2410 """
2411 # Make sure the user has permission to edit the issue.
2412 self._AssertPermInIssue(issue, permissions.EDIT_ISSUE)
2413 # Make sure the moved and target issues are in the blocked-on list.
2414 if moved_id not in issue.blocked_on_iids:
2415 raise exceptions.InputException(
2416 'The issue to move is not in the blocked-on list.')
2417 if target_id not in issue.blocked_on_iids:
2418 raise exceptions.InputException(
2419 'The target issue is not in the blocked-on list.')
2420
2421 phase_name = 'Moving issue %r %s issue %d.' % (
2422 moved_id, 'above' if split_above else 'below', target_id)
2423 with self.mc.profiler.Phase(phase_name):
2424 lower, higher = tracker_bizobj.SplitBlockedOnRanks(
2425 issue, target_id, split_above,
2426 [iid for iid in issue.blocked_on_iids if iid != moved_id])
2427 rank_changes = rerank_helpers.GetInsertRankings(
2428 lower, higher, [moved_id])
2429 if rank_changes:
2430 self.services.issue.ApplyIssueRerank(
2431 self.mc.cnxn, issue.issue_id, rank_changes)
2432
2433 # FUTURE: GetIssuePermissionsForUser()
2434
2435 # FUTURE: CreateComment()
2436
2437
2438 # TODO(crbug.com/monorail/7520): Delete when usages removed.
2439 def ListIssueComments(self, issue):
2440 """Return comments on the specified viewable issue."""
2441 self._AssertUserCanViewIssue(issue)
2442
2443 with self.mc.profiler.Phase('getting comments for %r' % issue.issue_id):
2444 comments = self.services.issue.GetCommentsForIssue(
2445 self.mc.cnxn, issue.issue_id)
2446
2447 return comments
2448
2449
2450 def SafeListIssueComments(
2451 self, issue_id, max_items, start, approval_id=None):
2452 # type: (tracker_pb2.Issue, int, int, Optional[int]) -> ListResult
2453 """Return comments on the issue, filtering non-viewable content.
2454
2455 TODO(crbug.com/monorail/7520): Rename to ListIssueComments.
2456
2457 Note: This returns `deleted_by`, but it should only be used for the purposes
2458 of determining whether the comment is deleted. The viewer may not have
2459 access to view who deleted the comment.
2460
2461 Args:
2462 issue_id: The issue for which we're listing comments.
2463 max_items: The maximum number of comments to return.
2464 start: The index of the start position in the list of comments.
2465 approval_id: Whether to only return comments on this approval.
2466
2467 Returns:
2468 A work_env.ListResult namedtuple with the comments for the issue.
2469
2470 Raises:
2471 PermissionException: The logged-in user is not allowed to view the issue.
2472 """
2473 if start < 0:
2474 raise exceptions.InputException('Invalid `start`: %d' % start)
2475 if max_items < 0:
2476 raise exceptions.InputException('Invalid `max_items`: %d' % max_items)
2477
2478 with self.mc.profiler.Phase('getting comments for %r' % issue_id):
2479 issue = self.GetIssue(issue_id)
2480 comments = self.services.issue.GetCommentsForIssue(self.mc.cnxn, issue_id)
2481 _, comment_reporters = self.LookupIssueFlaggers(issue)
2482 users_involved_in_comments = tracker_bizobj.UsersInvolvedInCommentList(
2483 comments)
2484 users_by_id = framework_views.MakeAllUserViews(
2485 self.mc.cnxn, self.services.user, users_involved_in_comments)
2486
2487 with self.mc.profiler.Phase('getting perms for comments'):
2488 project = self.GetProjectByName(issue.project_name)
2489 self.mc.LookupLoggedInUserPerms(project)
2490 config = self.GetProjectConfig(project.project_id)
2491 perms = permissions.UpdateIssuePermissions(
2492 self.mc.perms,
2493 project,
2494 issue,
2495 self.mc.auth.effective_ids,
2496 config=config)
2497
2498 # TODO(crbug.com/monorail/7525): Check values, and return next_start.
2499 end = start + max_items
2500 filtered_comments = []
2501 with self.mc.profiler.Phase('converting comments'):
2502 for comment in comments:
2503 if approval_id and comment.approval_id != approval_id:
2504 continue
2505 commenter = users_by_id[comment.user_id]
2506
2507 _can_flag, is_flagged = permissions.CanFlagComment(
2508 comment, commenter, comment_reporters.get(comment.id, []),
2509 self.mc.auth.user_id, perms)
2510 can_view = permissions.CanViewComment(
2511 comment, commenter, self.mc.auth.user_id, perms)
2512 can_view_inbound_message = permissions.CanViewInboundMessage(
2513 comment, self.mc.auth.user_id, perms)
2514
2515 # By default, all fields should get filtered out.
2516 # i.e. this is an allowlist rather than a denylist to reduce leaking
2517 # info.
2518 filtered_comment = tracker_pb2.IssueComment(
2519 id=comment.id,
2520 issue_id=comment.issue_id,
2521 project_id=comment.project_id,
2522 approval_id=comment.approval_id,
2523 timestamp=comment.timestamp,
2524 deleted_by=comment.deleted_by,
2525 sequence=comment.sequence,
2526 is_spam=is_flagged,
2527 is_description=comment.is_description,
2528 description_num=comment.description_num)
2529 if can_view:
2530 filtered_comment.content = comment.content
2531 filtered_comment.user_id = comment.user_id
2532 filtered_comment.amendments.extend(comment.amendments)
2533 filtered_comment.attachments.extend(comment.attachments)
2534 filtered_comment.importer_id = comment.importer_id
2535 if can_view_inbound_message:
2536 filtered_comment.inbound_message = comment.inbound_message
2537 filtered_comments.append(filtered_comment)
2538 next_start = None
2539 if end < len(filtered_comments):
2540 next_start = end
2541 return ListResult(filtered_comments[start:end], next_start)
2542
2543 # FUTURE: UpdateComment()
2544
2545 def DeleteComment(self, issue, comment, delete):
2546 """Mark or unmark a comment as deleted by the current user."""
2547 self._AssertUserCanDeleteComment(issue, comment)
2548 if comment.is_spam and self.mc.auth.user_id == comment.user_id:
2549 raise permissions.PermissionException('Cannot delete comment.')
2550
2551 with self.mc.profiler.Phase(
2552 'deleting issue %r comment %r' % (issue.issue_id, comment.id)):
2553 self.services.issue.SoftDeleteComment(
2554 self.mc.cnxn, issue, comment, self.mc.auth.user_id,
2555 self.services.user, delete=delete)
2556
2557 def DeleteAttachment(self, issue, comment, attachment_id, delete):
2558 """Mark or unmark a comment attachment as deleted by the current user."""
2559 # A user can delete an attachment iff they can delete a comment.
2560 self._AssertUserCanDeleteComment(issue, comment)
2561
2562 phase_message = 'deleting issue %r comment %r attachment %r' % (
2563 issue.issue_id, comment.id, attachment_id)
2564 with self.mc.profiler.Phase(phase_message):
2565 self.services.issue.SoftDeleteAttachment(
2566 self.mc.cnxn, issue, comment, attachment_id, self.services.user,
2567 delete=delete)
2568
2569 def FlagComment(self, issue, comment, flag):
2570 """Mark or unmark a comment as spam."""
2571 self._AssertPermInIssue(issue, permissions.FLAG_SPAM)
2572 with self.mc.profiler.Phase(
2573 'flagging issue %r comment %r' % (issue.issue_id, comment.id)):
2574 self.services.spam.FlagComment(
2575 self.mc.cnxn, issue, comment.id, comment.user_id,
2576 self.mc.auth.user_id, flag)
2577 if self._UserCanUsePermInIssue(issue, permissions.VERDICT_SPAM):
2578 self.services.spam.RecordManualCommentVerdict(
2579 self.mc.cnxn, self.services.issue, self.services.user, comment.id,
2580 self.mc.auth.user_id, flag)
2581
2582 def StarIssue(self, issue, starred):
2583 # type: (Issue, bool) -> Issue
2584 """Set or clear a star on the given issue for the signed in user."""
2585 if not self.mc.auth.user_id:
2586 raise permissions.PermissionException('Anon cannot star issues')
2587 self._AssertPermInIssue(issue, permissions.SET_STAR)
2588
2589 with self.mc.profiler.Phase('starring issue %r' % issue.issue_id):
2590 config = self.services.config.GetProjectConfig(
2591 self.mc.cnxn, issue.project_id)
2592 self.services.issue_star.SetStar(
2593 self.mc.cnxn, self.services, config, issue.issue_id,
2594 self.mc.auth.user_id, starred)
2595 return self.services.issue.GetIssue(self.mc.cnxn, issue.issue_id)
2596
2597 def IsIssueStarred(self, issue, cnxn=None):
2598 """Return True if the given issue is starred by the signed in user."""
2599 self._AssertUserCanViewIssue(issue)
2600
2601 with self.mc.profiler.Phase('checking star %r' % issue.issue_id):
2602 return self.services.issue_star.IsItemStarredBy(
2603 cnxn or self.mc.cnxn, issue.issue_id, self.mc.auth.user_id)
2604
2605 def ListStarredIssueIDs(self):
2606 """Return a list of the issue IDs that the current issue has starred."""
2607 # This returns an unfiltered list of issue_ids. Permissions will be
2608 # applied if and when the caller attempts to load each issue.
2609
2610 with self.mc.profiler.Phase('getting stars %r' % self.mc.auth.user_id):
2611 return self.services.issue_star.LookupStarredItemIDs(
2612 self.mc.cnxn, self.mc.auth.user_id)
2613
2614 def SnapshotCountsQuery(self, project, timestamp, group_by, label_prefix=None,
2615 query=None, canned_query=None, hotlist=None):
2616 """Query IssueSnapshots for daily counts.
2617
2618 See chart_svc.QueryIssueSnapshots for more detail on arguments.
2619
2620 Args:
2621 project (Project): Project to search.
2622 timestamp (int): Will query for snapshots at this timestamp.
2623 group_by (str): 2nd dimension, see QueryIssueSnapshots for options.
2624 label_prefix (str): Required for label queries. Only returns results
2625 with the supplied prefix.
2626 query (str, optional): If supplied, will parse & apply query conditions.
2627 canned_query (str, optional): Parsed canned query.
2628 hotlist (Hotlist, optional): Hotlist to search under (in lieu of project).
2629
2630 Returns:
2631 1. A dict of {name: count} for each item in group_by.
2632 2. A list of any unsupported query conditions in query.
2633 """
2634 # This returns counts of viewable issues.
2635 with self.mc.profiler.Phase('querying snapshot counts'):
2636 return self.services.chart.QueryIssueSnapshots(
2637 self.mc.cnxn, self.services, timestamp, self.mc.auth.effective_ids,
2638 project, self.mc.perms, group_by=group_by, label_prefix=label_prefix,
2639 query=query, canned_query=canned_query, hotlist=hotlist)
2640
2641 ### User methods
2642
2643 # TODO(crbug/monorail/7238): rewrite this method to call BatchGetUsers.
2644 def GetUser(self, user_id):
2645 # type: (int) -> User
2646 """Return the user with the given ID."""
2647
2648 return self.BatchGetUsers([user_id])[0]
2649
2650 def BatchGetUsers(self, user_ids):
2651 # type: (Sequence[int]) -> Sequence[User]
2652 """Return all Users for given User IDs.
2653
2654 Args:
2655 user_ids: list of User IDs.
2656
2657 Returns:
2658 A list of User objects in the same order as the given User IDs.
2659
2660 Raises:
2661 NoSuchUserException if a User for a given User ID is not found.
2662 """
2663 users_by_id = self.services.user.GetUsersByIDs(
2664 self.mc.cnxn, user_ids, skip_missed=True)
2665 users = []
2666 for user_id in user_ids:
2667 user = users_by_id.get(user_id)
2668 if not user:
2669 raise exceptions.NoSuchUserException(
2670 'No User with ID %s found' % user_id)
2671 users.append(user)
2672 return users
2673
2674 def GetMemberships(self, user_id):
2675 """Return the user group ids for the given user visible to the requester."""
2676 group_ids = self.services.usergroup.LookupMemberships(self.mc.cnxn, user_id)
2677 if user_id == self.mc.auth.user_id:
2678 return group_ids
2679 (member_ids_by_ids, owner_ids_by_ids
2680 ) = self.services.usergroup.LookupAllMembers(
2681 self.mc.cnxn, group_ids)
2682 settings_by_id = self.services.usergroup.GetAllGroupSettings(
2683 self.mc.cnxn, group_ids)
2684
2685 (owned_project_ids, membered_project_ids,
2686 contrib_project_ids) = self.services.project.GetUserRolesInAllProjects(
2687 self.mc.cnxn, self.mc.auth.effective_ids)
2688 project_ids = owned_project_ids.union(
2689 membered_project_ids).union(contrib_project_ids)
2690
2691 visible_group_ids = []
2692 for group_id, group_settings in settings_by_id.items():
2693 member_ids = member_ids_by_ids.get(group_id)
2694 owner_ids = owner_ids_by_ids.get(group_id)
2695 if permissions.CanViewGroupMembers(
2696 self.mc.perms, self.mc.auth.effective_ids, group_settings,
2697 member_ids, owner_ids, project_ids):
2698 visible_group_ids.append(group_id)
2699
2700 return visible_group_ids
2701
2702 def ListReferencedUsers(self, emails):
2703 """Return a list of the given emails' User PBs, plus linked account ids.
2704
2705 Args:
2706 emails: list of emails of users to look up.
2707
2708 Returns:
2709 A pair (users, linked_users_ids) where users is an unsorted list of
2710 User PBs and linked_user_ids is a list of user IDs of any linked accounts.
2711 """
2712 with self.mc.profiler.Phase('getting existing users'):
2713 user_id_dict = self.services.user.LookupExistingUserIDs(
2714 self.mc.cnxn, emails)
2715 users_by_id = self.services.user.GetUsersByIDs(
2716 self.mc.cnxn, list(user_id_dict.values()))
2717 user_list = list(users_by_id.values())
2718
2719 linked_user_ids = []
2720 for user in user_list:
2721 if user.linked_parent_id:
2722 linked_user_ids.append(user.linked_parent_id)
2723 linked_user_ids.extend(user.linked_child_ids)
2724
2725 return user_list, linked_user_ids
2726
2727 def StarUser(self, user_id, starred):
2728 """Star or unstar the specified user.
2729
2730 Args:
2731 user_id: int ID of the user to star/unstar.
2732 starred: true to add a star, false to remove it.
2733
2734 Returns:
2735 Nothing.
2736
2737 Raises:
2738 NoSuchUserException: There is no user with that ID.
2739 """
2740 if not self.mc.auth.user_id:
2741 raise exceptions.InputException('No current user specified')
2742
2743 with self.mc.profiler.Phase('(un)starring user %r' % user_id):
2744 # Make sure the user exists and user has permission to see it.
2745 self.services.user.LookupUserEmail(self.mc.cnxn, user_id)
2746 self.services.user_star.SetStar(
2747 self.mc.cnxn, user_id, self.mc.auth.user_id, starred)
2748
2749 def IsUserStarred(self, user_id):
2750 """Return True if the current user has starred the given user.
2751
2752 Args:
2753 user_id: int ID of the user to check.
2754
2755 Returns:
2756 True if starred.
2757
2758 Raises:
2759 NoSuchUserException: There is no user with that ID.
2760 """
2761 if user_id is None:
2762 raise exceptions.InputException('No user specified')
2763
2764 if not self.mc.auth.user_id:
2765 return False
2766
2767 with self.mc.profiler.Phase('checking user star %r' % user_id):
2768 # Make sure the user exists.
2769 self.services.user.LookupUserEmail(self.mc.cnxn, user_id)
2770 return self.services.user_star.IsItemStarredBy(
2771 self.mc.cnxn, user_id, self.mc.auth.user_id)
2772
2773 def GetUserStarCount(self, user_id):
2774 """Return the number of times the user has been starred.
2775
2776 Args:
2777 user_id: int ID of the user to check.
2778
2779 Returns:
2780 The number of times the user has been starred.
2781
2782 Raises:
2783 NoSuchUserException: There is no user with that ID.
2784 """
2785 if user_id is None:
2786 raise exceptions.InputException('No user specified')
2787
2788 with self.mc.profiler.Phase('counting stars for user %r' % user_id):
2789 # Make sure the user exists.
2790 self.services.user.LookupUserEmail(self.mc.cnxn, user_id)
2791 return self.services.user_star.CountItemStars(self.mc.cnxn, user_id)
2792
2793 def GetPendingLinkedInvites(self, user_id=None):
2794 """Return info about a user's linked account invites."""
2795 with self.mc.profiler.Phase('checking linked account invites'):
2796 result = self.services.user.GetPendingLinkedInvites(
2797 self.mc.cnxn, user_id or self.mc.auth.user_id)
2798 return result
2799
2800 def InviteLinkedParent(self, parent_email):
2801 """Invite a matching account to be my parent."""
2802 if not parent_email:
2803 raise exceptions.InputException('No parent account specified')
2804 if not self.mc.auth.user_id:
2805 raise permissions.PermissionException('Anon cannot link accounts')
2806 with self.mc.profiler.Phase('Validating proposed parent'):
2807 # We only offer self-serve account linking to matching usernames.
2808 (p_username, p_domain,
2809 _obs_username, _obs_email) = framework_bizobj.ParseAndObscureAddress(
2810 parent_email)
2811 c_view = self.mc.auth.user_view
2812 if p_username != c_view.username:
2813 logging.info('Username %r != %r', p_username, c_view.username)
2814 raise exceptions.InputException('Linked account names must match')
2815 allowed_domains = settings.linkable_domains.get(c_view.domain, [])
2816 if p_domain not in allowed_domains:
2817 logging.info('parent domain %r is not in list for %r: %r',
2818 p_domain, c_view.domain, allowed_domains)
2819 raise exceptions.InputException('Linked account unsupported domain')
2820 parent_id = self.services.user.LookupUserID(self.mc.cnxn, parent_email)
2821 with self.mc.profiler.Phase('Creating linked account invite'):
2822 self.services.user.InviteLinkedParent(
2823 self.mc.cnxn, parent_id, self.mc.auth.user_id)
2824
2825 def AcceptLinkedChild(self, child_id):
2826 """Accept an invitation from a child account."""
2827 with self.mc.profiler.Phase('Accept linked account invite'):
2828 self.services.user.AcceptLinkedChild(
2829 self.mc.cnxn, self.mc.auth.user_id, child_id)
2830
2831 def UnlinkAccounts(self, parent_id, child_id):
2832 """Delete a linked-account relationship."""
2833 if (self.mc.auth.user_id != parent_id and
2834 self.mc.auth.user_id != child_id):
2835 permitted = self.mc.perms.CanUsePerm(
2836 permissions.EDIT_OTHER_USERS, self.mc.auth.effective_ids, None, [])
2837 if not permitted:
2838 raise permissions.PermissionException(
2839 'User lacks permission to unlink accounts')
2840
2841 with self.mc.profiler.Phase('Unlink accounts'):
2842 self.services.user.UnlinkAccounts(self.mc.cnxn, parent_id, child_id)
2843
2844 def UpdateUserSettings(self, user, **kwargs):
2845 """Update the preferences of the specified user.
2846
2847 Args:
2848 user: User PB for the user to update.
2849 keyword_args: dictionary of setting names mapped to new values.
2850 """
2851 if not user or not user.user_id:
2852 raise exceptions.InputException('Cannot update user settings for anon.')
2853
2854 with self.mc.profiler.Phase(
2855 'updating settings for %s with %s' % (self.mc.auth.user_id, kwargs)):
2856 self.services.user.UpdateUserSettings(
2857 self.mc.cnxn, user.user_id, user, **kwargs)
2858
2859 def GetUserPrefs(self, user_id):
2860 """Get the UserPrefs for the specified user."""
2861 # Anon user always has default prefs.
2862 if not user_id:
2863 return user_pb2.UserPrefs(user_id=0)
2864 if user_id != self.mc.auth.user_id:
2865 if not self.mc.perms.HasPerm(permissions.EDIT_OTHER_USERS, None, None):
2866 raise permissions.PermissionException(
2867 'Only site admins may see other users\' preferences')
2868 with self.mc.profiler.Phase('Getting prefs for %s' % user_id):
2869 userprefs = self.services.user.GetUserPrefs(self.mc.cnxn, user_id)
2870
2871 # Hard-coded user prefs for at-risk users that should use "corp mode".
2872 # For some users we mark all of their new issues as Restrict-View-Google.
2873 # Others see a "public issue" warning when commenting on public issues.
2874 # TODO(crbug.com/monorail/5462):
2875 # Remove when user group preferences are implemented.
2876 if framework_bizobj.IsRestrictNewIssuesUser(self.mc.cnxn, self.services,
2877 user_id):
2878 # Copy so that cached version is not modified.
2879 userprefs = user_pb2.UserPrefs(user_id=user_id, prefs=userprefs.prefs)
2880 if 'restrict_new_issues' not in {pref.name for pref in userprefs.prefs}:
2881 userprefs.prefs.append(user_pb2.UserPrefValue(
2882 name='restrict_new_issues', value='true'))
2883 if framework_bizobj.IsPublicIssueNoticeUser(self.mc.cnxn, self.services,
2884 user_id):
2885 # Copy so that cached version is not modified.
2886 userprefs = user_pb2.UserPrefs(user_id=user_id, prefs=userprefs.prefs)
2887 if 'public_issue_notice' not in {pref.name for pref in userprefs.prefs}:
2888 userprefs.prefs.append(user_pb2.UserPrefValue(
2889 name='public_issue_notice', value='true'))
2890
2891 return userprefs
2892
2893 def SetUserPrefs(self, user_id, prefs):
2894 """Set zero or more UserPrefValue for the specified user."""
2895 # Anon user always has default prefs.
2896 if not user_id:
2897 raise exceptions.InputException('Anon cannot have prefs')
2898 if user_id != self.mc.auth.user_id:
2899 if not self.mc.perms.HasPerm(permissions.EDIT_OTHER_USERS, None, None):
2900 raise permissions.PermissionException(
2901 'Only site admins may set other users\' preferences')
2902 for pref in prefs:
2903 error_msg = framework_bizobj.ValidatePref(pref.name, pref.value)
2904 if error_msg:
2905 raise exceptions.InputException(error_msg)
2906 with self.mc.profiler.Phase(
2907 'setting prefs for %s' % (self.mc.auth.user_id)):
2908 self.services.user.SetUserPrefs(self.mc.cnxn, user_id, prefs)
2909
2910 # FUTURE: GetUser()
2911 # FUTURE: UpdateUser()
2912 # FUTURE: DeleteUser()
2913 # FUTURE: ListStarredUsers()
2914
2915 def ExpungeUsers(self, emails, check_perms=True, commit=True):
2916 """Permanently deletes user data and removes remaining user references
2917 for all listed users.
2918
2919 To avoid any executions that might take too long and make the site hang,
2920 a limit clause will be added to some operations. If any user references
2921 are left behind due to the cut-off, the final services.user.ExpungeUsers
2922 will fail because we cannot delete User rows that are still referenced
2923 in other tables. work_env.ExpungeUsers can be called again until all user
2924 references are removed and the final services.user.ExpungeUsers succeeds.
2925 The limit clause will not be applied in operations for tables that contain
2926 user_id or email columns but do not officially Reference the User table.
2927 E.g. SpamVerdict and SpamReport. These user references must all be removed
2928 before the attempt to delete rows from User is made. The limit will also
2929 not be applied for sets of operations where values removed in earlier
2930 operations would have to be known in order for later operations to
2931 succeed. E.g. ExpungeUsersIngroups().
2932 """
2933 if check_perms:
2934 if not permissions.CanExpungeUsers(self.mc):
2935 raise permissions.PermissionException(
2936 'User is not allowed to delete users.')
2937
2938 limit = 10000
2939 user_ids_by_email = self.services.user.LookupExistingUserIDs(
2940 self.mc.cnxn, emails)
2941 user_ids = list(set(user_ids_by_email.values()))
2942 if framework_constants.DELETED_USER_ID in user_ids:
2943 raise exceptions.InputException(
2944 'Reserved deleted_user_id found in deletion request and'
2945 'should not be deleted')
2946 if not user_ids:
2947 logging.info('Emails %r not found in DB. No users deleted', emails)
2948 return
2949
2950 # The operations made in the methods below can be limited.
2951 # We can adjust 'limit' as necessary to avoid timing out.
2952 self.services.issue_star.ExpungeStarsByUsers(
2953 self.mc.cnxn, user_ids, limit=limit)
2954 self.services.project_star.ExpungeStarsByUsers(
2955 self.mc.cnxn, user_ids, limit=limit)
2956 self.services.hotlist_star.ExpungeStarsByUsers(
2957 self.mc.cnxn, user_ids, limit=limit)
2958 self.services.user_star.ExpungeStarsByUsers(
2959 self.mc.cnxn, user_ids, limit=limit)
2960 for user_id in user_ids:
2961 self.services.user_star.ExpungeStars(
2962 self.mc.cnxn, user_id, commit=False, limit=limit)
2963
2964 self.services.features.ExpungeQuickEditsByUsers(
2965 self.mc.cnxn, user_ids, limit=limit)
2966 self.services.features.ExpungeSavedQueriesByUsers(
2967 self.mc.cnxn, user_ids, limit=limit)
2968
2969 self.services.template.ExpungeUsersInTemplates(
2970 self.mc.cnxn, user_ids, limit=limit)
2971 self.services.config.ExpungeUsersInConfigs(
2972 self.mc.cnxn, user_ids, limit=limit)
2973
2974 self.services.project.ExpungeUsersInProjects(
2975 self.mc.cnxn, user_ids, limit=limit)
2976
2977 # The upcoming operations cannot be limited with 'limit'.
2978 # So it's possible that these operations below may lead to timing out
2979 # and ExpungeUsers will have to run again to fully delete all users.
2980 # We commit the above operations here, so if a failure does happen
2981 # below, the second run of ExpungeUsers will have less work to do.
2982 if commit:
2983 self.mc.cnxn.Commit()
2984
2985 affected_issue_ids = self.services.issue.ExpungeUsersInIssues(
2986 self.mc.cnxn, user_ids_by_email, limit=limit)
2987 # Commit ExpungeUsersInIssues here, as it has many operations
2988 # and at least one operation that cannot be limited.
2989 if commit:
2990 self.mc.cnxn.Commit()
2991 self.services.issue.EnqueueIssuesForIndexing(
2992 self.mc.cnxn, affected_issue_ids)
2993
2994 # Spam verdict and report tables have user_id columns that do not
2995 # reference User. No limit will be applied.
2996 self.services.spam.ExpungeUsersInSpam(self.mc.cnxn, user_ids)
2997 if commit:
2998 self.mc.cnxn.Commit()
2999
3000 # No limit will be applied for expunging in hotlists.
3001 self.services.features.ExpungeUsersInHotlists(
3002 self.mc.cnxn, user_ids, self.services.hotlist_star, self.services.user,
3003 self.services.chart)
3004 if commit:
3005 self.mc.cnxn.Commit()
3006
3007 # No limit will be applied for expunging in UserGroups.
3008 self.services.usergroup.ExpungeUsersInGroups(
3009 self.mc.cnxn, user_ids)
3010 if commit:
3011 self.mc.cnxn.Commit()
3012
3013 # No limit will be applied for expunging in FilterRules.
3014 deleted_rules_by_project = self.services.features.ExpungeFilterRulesByUser(
3015 self.mc.cnxn, user_ids_by_email)
3016 rule_strs_by_project = filterrules_helpers.BuildRedactedFilterRuleStrings(
3017 self.mc.cnxn, deleted_rules_by_project, self.services.user, emails)
3018 if commit:
3019 self.mc.cnxn.Commit()
3020
3021 # We will attempt to expunge all given users here. Limiting the users we
3022 # delete should be done before work_env.ExpungeUsers is called.
3023 self.services.user.ExpungeUsers(self.mc.cnxn, user_ids)
3024 if commit:
3025 self.mc.cnxn.Commit()
3026 self.services.usergroup.group_dag.MarkObsolete()
3027
3028 for project_id, filter_rule_strs in rule_strs_by_project.items():
3029 project = self.services.project.GetProject(self.mc.cnxn, project_id)
3030 hostport = framework_helpers.GetHostPort(
3031 project_name=project.project_name)
3032 send_notifications.PrepareAndSendDeletedFilterRulesNotification(
3033 project_id, hostport, filter_rule_strs)
3034
3035 def TotalUsersCount(self):
3036 """Returns the total number of Users in Monorail."""
3037 return self.services.user.TotalUsersCount(self.mc.cnxn)
3038
3039 def GetAllUserEmailsBatch(self, limit=1000, offset=0):
3040 """Returns a list emails that belong to Users in Monorail.
3041
3042 Returns:
3043 A list of emails for Users within Monorail ordered by the user.user_ids.
3044 The list will hold at most [limit] emails and will start at the given
3045 [offset].
3046 """
3047 return self.services.user.GetAllUserEmailsBatch(
3048 self.mc.cnxn, limit=limit, offset=offset)
3049
3050 ### Group methods
3051
3052 # FUTURE: CreateGroup()
3053 # FUTURE: ListGroups()
3054 # FUTURE: UpdateGroup()
3055 # FUTURE: DeleteGroup()
3056
3057 ### Hotlist methods
3058
3059 def CreateHotlist(
3060 self, name, summary, description, editor_ids, issue_ids, is_private,
3061 default_col_spec):
3062 # type: (string, string, string, Collection[int], Collection[int], Boolean,
3063 # string)
3064 """Create a hotlist.
3065
3066 Args:
3067 name: a valid hotlist name.
3068 summary: one-line explanation of the hotlist.
3069 description: one-page explanation of the hotlist.
3070 editor_ids: a list of user IDs for the hotlist editors.
3071 issue_ids: a list of issue IDs for the hotlist issues.
3072 is_private: True if the hotlist can only be viewed by owners and editors.
3073 default_col_spec: default columns for the hotlist's list view.
3074
3075
3076 Returns:
3077 The newly created hotlist.
3078
3079 Raises:
3080 HotlistAlreadyExists: A hotlist with the given name already exists.
3081 InputException: No user is signed in or the proposed name is invalid.
3082 PermissionException: If the user cannot view all of the issues.
3083 """
3084 if not self.mc.auth.user_id:
3085 raise exceptions.InputException('Anon cannot create hotlists.')
3086
3087 # GetIssuesDict checks that the user can view all issues.
3088 self.GetIssuesDict(issue_ids)
3089
3090 if not framework_bizobj.IsValidHotlistName(name):
3091 raise exceptions.InputException(
3092 '%s is not a valid name for a Hotlist' % name)
3093 if self.services.features.LookupHotlistIDs(
3094 self.mc.cnxn, [name], [self.mc.auth.user_id]):
3095 raise features_svc.HotlistAlreadyExists()
3096
3097 with self.mc.profiler.Phase('creating hotlist %s' % name):
3098 hotlist = self.services.features.CreateHotlist(
3099 self.mc.cnxn, name, summary, description, [self.mc.auth.user_id],
3100 editor_ids, issue_ids=issue_ids, is_private=is_private,
3101 default_col_spec=default_col_spec, ts=int(time.time()))
3102
3103 return hotlist
3104
3105 def UpdateHotlist(
3106 self, hotlist_id, hotlist_name=None, summary=None, description=None,
3107 is_private=None, default_col_spec=None, owner_id=None,
3108 add_editor_ids=None):
3109 # type: (int, str, str, str, bool, str, int, Collection[int]) -> None
3110 """Update the given hotlist.
3111
3112 If a new value is None, the value does not get updated.
3113
3114 Args:
3115 hotlist_id: hotlist_id of the hotlist to update.
3116 hotlist_name: proposed new name for the hotlist.
3117 summary: new summary for the hotlist.
3118 description: new description for the hotlist.
3119 is_private: true if hotlist should be updated to private.
3120 default_col_spec: new default columns for hotlist list view.
3121 owner_id: User id of the new owner.
3122 add_editor_ids: User ids to add as editors.
3123
3124 Raises:
3125 InputException: The given hotlist_id is None or proposed new name is not
3126 a valid hotlist name.
3127 NoSuchHotlistException: There is no hotlist with the given ID.
3128 PermissionException: The logged-in user is not allowed to update
3129 this hotlist's settings.
3130 NoSuchUserException: Some proposed editors or owner were not found.
3131 HotlistAlreadyExists: The (proposed new) hotlist owner already owns a
3132 hotlist with the same (proposed) name.
3133 """
3134 hotlist = self.services.features.GetHotlist(
3135 self.mc.cnxn, hotlist_id, use_cache=False)
3136 if not permissions.CanAdministerHotlist(
3137 self.mc.auth.effective_ids, self.mc.perms, hotlist):
3138 raise permissions.PermissionException(
3139 'User is not allowed to update hotlist settings.')
3140
3141 if hotlist.name == hotlist_name:
3142 hotlist_name = None
3143 if hotlist.owner_ids[0] == owner_id:
3144 owner_id = None
3145
3146 if hotlist_name and not framework_bizobj.IsValidHotlistName(hotlist_name):
3147 raise exceptions.InputException(
3148 '"%s" is not a valid hotlist name' % hotlist_name)
3149
3150 # Check (new) owner does not already own a hotlist with the (new) name.
3151 if hotlist_name or owner_id:
3152 owner_ids = [owner_id] if owner_id else None
3153 if self.services.features.LookupHotlistIDs(
3154 self.mc.cnxn, [hotlist_name or hotlist.name],
3155 owner_ids or hotlist.owner_ids):
3156 raise features_svc.HotlistAlreadyExists(
3157 'User already owns a hotlist with name %s' %
3158 hotlist_name or hotlist.name)
3159
3160 # Filter out existing editors and users that will be added as owner
3161 # or is the current owner.
3162 next_owner_id = owner_id or hotlist.owner_ids[0]
3163 if add_editor_ids:
3164 new_editor_ids_set = {user_id for user_id in add_editor_ids if
3165 user_id not in hotlist.editor_ids and
3166 user_id != next_owner_id}
3167 add_editor_ids = list(new_editor_ids_set)
3168
3169 # Validate user change requests.
3170 user_ids = []
3171 if add_editor_ids:
3172 user_ids.extend(add_editor_ids)
3173 else:
3174 add_editor_ids = None
3175 if owner_id:
3176 user_ids.append(owner_id)
3177 if user_ids:
3178 self.services.user.LookupUserEmails(self.mc.cnxn, user_ids)
3179
3180 # Check for other no-op changes.
3181 if summary == hotlist.summary:
3182 summary = None
3183 if description == hotlist.description:
3184 description = None
3185 if is_private == hotlist.is_private:
3186 is_private = None
3187 if default_col_spec == hotlist.default_col_spec:
3188 default_col_spec = None
3189
3190 if ([hotlist_name, summary, description, is_private, default_col_spec,
3191 owner_id, add_editor_ids] ==
3192 [None, None, None, None, None, None, None]):
3193 logging.info('No updates given')
3194 return
3195
3196 if (summary is not None) and (not summary):
3197 raise exceptions.InputException('Hotlist cannot have an empty summary.')
3198 if (description is not None) and (not description):
3199 raise exceptions.InputException(
3200 'Hotlist cannot have an empty description.')
3201 if default_col_spec is not None and not framework_bizobj.IsValidColumnSpec(
3202 default_col_spec):
3203 raise exceptions.InputException(
3204 '"%s" is not a valid column spec' % default_col_spec)
3205
3206 self.services.features.UpdateHotlist(
3207 self.mc.cnxn, hotlist_id, name=hotlist_name, summary=summary,
3208 description=description, is_private=is_private,
3209 default_col_spec=default_col_spec, owner_id=owner_id,
3210 add_editor_ids=add_editor_ids)
3211
3212 # TODO(crbug/monorail/7104): delete UpdateHotlistRoles.
3213
3214 def GetHotlist(self, hotlist_id, use_cache=True):
3215 # int, Optional[Boolean] -> Hotlist
3216 """Return the specified hotlist.
3217
3218 Args:
3219 hotlist_id: int hotlist_id of the hotlist to retrieve.
3220 use_cache: set to false when doing read-modify-write.
3221
3222 Returns:
3223 The specified hotlist.
3224
3225 Raises:
3226 NoSuchHotlistException: There is no hotlist with that ID.
3227 PermissionException: The user is not allowed to view the hotlist.
3228 """
3229 if hotlist_id is None:
3230 raise exceptions.InputException('No hotlist specified')
3231
3232 with self.mc.profiler.Phase('getting hotlist %r' % hotlist_id):
3233 hotlist = self.services.features.GetHotlist(
3234 self.mc.cnxn, hotlist_id, use_cache=use_cache)
3235 self._AssertUserCanViewHotlist(hotlist)
3236 return hotlist
3237
3238 # TODO(crbug/monorail/7104): Remove group_by_spec argument and pre-pend
3239 # values to sort_spec.
3240 def ListHotlistItems(self, hotlist_id, max_items, start, can, sort_spec,
3241 group_by_spec, use_cache=True):
3242 # type: (int, int, int, int, str, str, bool) -> ListResult
3243 """Return a list of HotlistItems for the given hotlist that
3244 are visible by the user.
3245
3246 Args:
3247 hotlist_id: int hotlist_id of the hotlist.
3248 max_items: int the maximum number of HotlistItems we want to return.
3249 start: int start position in the total sorted items.
3250 can: int "canned_query" number to scope the visible issues.
3251 sort_spec: string that lists the sort order.
3252 group_by_spec: string that lists the grouping order.
3253 use_cache: set to false when doing read-modify-write.
3254
3255 Returns:
3256 A work_env.ListResult namedtuple.
3257
3258 Raises:
3259 NoSuchHotlistException: There is no hotlist with that ID.
3260 InputException: `max_items` or `start` are negative values.
3261 PermissionException: The user is not allowed to view the hotlist.
3262 """
3263 hotlist = self.GetHotlist(hotlist_id, use_cache=use_cache)
3264 if start < 0:
3265 raise exceptions.InputException('Invalid `start`: %d' % start)
3266 if max_items < 0:
3267 raise exceptions.InputException('Invalid `max_items`: %d' % max_items)
3268
3269 hotlist_issues = self.services.issue.GetIssues(
3270 self.mc.cnxn, [item.issue_id for item in hotlist.items])
3271 project_ids = hotlist_helpers.GetAllProjectsOfIssues(hotlist_issues)
3272 config_list = hotlist_helpers.GetAllConfigsOfProjects(
3273 self.mc.cnxn, project_ids, self.services)
3274 harmonized_config = tracker_bizobj.HarmonizeConfigs(config_list)
3275
3276 (sorted_issues, _hotlist_items_context,
3277 _users_by_id) = hotlist_helpers.GetSortedHotlistIssues(
3278 self.mc.cnxn, hotlist.items, hotlist_issues, self.mc.auth, can,
3279 sort_spec, group_by_spec, harmonized_config, self.services,
3280 self.mc.profiler)
3281
3282
3283 end = start + max_items
3284 visible_issues = sorted_issues[start:end]
3285 hotlist_items_dict = {item.issue_id: item for item in hotlist.items}
3286 visible_hotlist_items = [hotlist_items_dict.get(issue.issue_id) for
3287 issue in visible_issues]
3288
3289 next_start = None
3290 if end < len(sorted_issues):
3291 next_start = end
3292 return ListResult(visible_hotlist_items, next_start)
3293
3294 def TransferHotlistOwnership(self, hotlist_id, new_owner_id, remain_editor,
3295 use_cache=True, commit=True):
3296 """Transfer ownership of hotlist from current owner to new_owner.
3297
3298 Args:
3299 hotlist_id: int hotlist_id of the hotlist we want to transfer
3300 new_owner_id: user_id of the new owner
3301 remain_editor: True if the old owner should remain on the hotlist as
3302 editor.
3303 use_cache: set to false when doing read-modify-write.
3304 commit: True, if changes should be committed.
3305
3306 Raises:
3307 NoSuchHotlistException: There is not hotlist with the given ID.
3308 PermissionException: The logged-in user is not allowed to change ownership
3309 of the hotlist.
3310 InputException: The proposed new owner already owns a hotlist with the
3311 same name.
3312 """
3313 hotlist = self.services.features.GetHotlist(
3314 self.mc.cnxn, hotlist_id, use_cache=use_cache)
3315 edit_permitted = permissions.CanAdministerHotlist(
3316 self.mc.auth.effective_ids, self.mc.perms, hotlist)
3317 if not edit_permitted:
3318 raise permissions.PermissionException(
3319 'User is not allowed to update hotlist members.')
3320
3321 if self.services.features.LookupHotlistIDs(
3322 self.mc.cnxn, [hotlist.name], [new_owner_id]):
3323 raise exceptions.InputException(
3324 'Proposed new owner already owns a hotlist with this name.')
3325
3326 self.services.features.TransferHotlistOwnership(
3327 self.mc.cnxn, hotlist, new_owner_id, remain_editor, commit=commit)
3328
3329 def RemoveHotlistEditors(self, hotlist_id, remove_editor_ids, use_cache=True):
3330 """Removes editors in a hotlist.
3331
3332 Args:
3333 hotlist_id: the id of the hotlist we want to update
3334 remove_editor_ids: list of user_ids to remove from hotlist editors
3335
3336 Raises:
3337 NoSuchHotlistException: There is not hotlist with the given ID.
3338 PermissionException: The logged-in user is not allowed to administer the
3339 hotlist.
3340 InputException: The users being removed are not editors in the hotlist.
3341 """
3342 hotlist = self.services.features.GetHotlist(
3343 self.mc.cnxn, hotlist_id, use_cache=use_cache)
3344 edit_permitted = permissions.CanAdministerHotlist(
3345 self.mc.auth.effective_ids, self.mc.perms, hotlist)
3346
3347 # check if user is only removing themselves from the hotlist.
3348 # removing linked accounts is allowed but users cannot remove groups
3349 # they are part of from hotlists.
3350 user_or_linked_ids = (
3351 self.mc.auth.user_pb.linked_child_ids + [self.mc.auth.user_id])
3352 if self.mc.auth.user_pb.linked_parent_id:
3353 user_or_linked_ids.append(self.mc.auth.user_pb.linked_parent_id)
3354 removing_self_only = set(remove_editor_ids).issubset(
3355 set(user_or_linked_ids))
3356
3357 if not removing_self_only and not edit_permitted:
3358 raise permissions.PermissionException(
3359 'User is not allowed to remove editors')
3360
3361 if not set(remove_editor_ids).issubset(set(hotlist.editor_ids)):
3362 raise exceptions.InputException(
3363 'Cannot remove users who are not hotlist editors.')
3364
3365 self.services.features.RemoveHotlistEditors(
3366 self.mc.cnxn, hotlist_id, remove_editor_ids)
3367
3368 def DeleteHotlist(self, hotlist_id):
3369 """Delete the given hotlist from the DB.
3370
3371 Args:
3372 hotlist_id (int): The id of the hotlist to delete.
3373
3374 Raises:
3375 NoSuchHotlistException: There is not hotlist with the given ID.
3376 PermissionException: The logged-in user is not allowed to
3377 delete the hotlist.
3378 """
3379 hotlist = self.services.features.GetHotlist(
3380 self.mc.cnxn, hotlist_id, use_cache=False)
3381 edit_permitted = permissions.CanAdministerHotlist(
3382 self.mc.auth.effective_ids, self.mc.perms, hotlist)
3383 if not edit_permitted:
3384 raise permissions.PermissionException(
3385 'User is not allowed to delete hotlist')
3386
3387 self.services.features.ExpungeHotlists(
3388 self.mc.cnxn, [hotlist.hotlist_id], self.services.hotlist_star,
3389 self.services.user, self.services.chart)
3390
3391 def ListHotlistsByUser(self, user_id):
3392 """Return the hotlists for the given user.
3393
3394 Args:
3395 user_id (int): The id of the user to query.
3396
3397 Returns:
3398 The hotlists for the given user.
3399 """
3400 if user_id is None:
3401 raise exceptions.InputException('No user specified')
3402
3403 with self.mc.profiler.Phase('querying hotlists for user %r' % user_id):
3404 hotlists = self.services.features.GetHotlistsByUserID(
3405 self.mc.cnxn, user_id)
3406
3407 # Filter the hotlists that the currently authenticated user cannot see.
3408 result = [
3409 hotlist
3410 for hotlist in hotlists
3411 if permissions.CanViewHotlist(
3412 self.mc.auth.effective_ids, self.mc.perms, hotlist)]
3413 return result
3414
3415 def ListHotlistsByIssue(self, issue_id):
3416 """Return the hotlists the given issue is part of.
3417
3418 Args:
3419 issue_id (int): The id of the issue to query.
3420
3421 Returns:
3422 The hotlists the given issue is part of.
3423 """
3424 # Check that the issue exists and the user has permission to see it.
3425 self.GetIssue(issue_id)
3426
3427 with self.mc.profiler.Phase('querying hotlists for issue %r' % issue_id):
3428 hotlists = self.services.features.GetHotlistsByIssueID(
3429 self.mc.cnxn, issue_id)
3430
3431 # Filter the hotlists that the currently authenticated user cannot see.
3432 result = [
3433 hotlist
3434 for hotlist in hotlists
3435 if permissions.CanViewHotlist(
3436 self.mc.auth.effective_ids, self.mc.perms, hotlist)]
3437 return result
3438
3439 def ListRecentlyVisitedHotlists(self):
3440 """Return the recently visited hotlists for the logged in user.
3441
3442 Returns:
3443 The recently visited hotlists for the given user, or an empty list if no
3444 user is logged in.
3445 """
3446 if not self.mc.auth.user_id:
3447 return []
3448
3449 with self.mc.profiler.Phase(
3450 'get recently visited hotlists for user %r' % self.mc.auth.user_id):
3451 hotlist_ids = self.services.user.GetRecentlyVisitedHotlists(
3452 self.mc.cnxn, self.mc.auth.user_id)
3453 hotlists_by_id = self.services.features.GetHotlists(
3454 self.mc.cnxn, hotlist_ids)
3455 hotlists = [hotlists_by_id[hotlist_id] for hotlist_id in hotlist_ids]
3456
3457 # Filter the hotlists that the currently authenticated user cannot see.
3458 # It might be that some of the hotlists have become private since the user
3459 # last visited them, or the user has lost access for other reasons.
3460 result = [
3461 hotlist
3462 for hotlist in hotlists
3463 if permissions.CanViewHotlist(
3464 self.mc.auth.effective_ids, self.mc.perms, hotlist)]
3465 return result
3466
3467 def ListStarredHotlists(self):
3468 """Return the starred hotlists for the logged in user.
3469
3470 Returns:
3471 The starred hotlists for the logged in user.
3472 """
3473 if not self.mc.auth.user_id:
3474 return []
3475
3476 with self.mc.profiler.Phase(
3477 'get starred hotlists for user %r' % self.mc.auth.user_id):
3478 hotlist_ids = self.services.hotlist_star.LookupStarredItemIDs(
3479 self.mc.cnxn, self.mc.auth.user_id)
3480 hotlists_by_id, _ = self.services.features.GetHotlistsByID(
3481 self.mc.cnxn, hotlist_ids)
3482 hotlists = [hotlists_by_id[hotlist_id] for hotlist_id in hotlist_ids]
3483
3484 # Filter the hotlists that the currently authenticated user cannot see.
3485 # It might be that some of the hotlists have become private since the user
3486 # starred them, or the user has lost access for other reasons.
3487 result = [
3488 hotlist
3489 for hotlist in hotlists
3490 if permissions.CanViewHotlist(
3491 self.mc.auth.effective_ids, self.mc.perms, hotlist)]
3492 return result
3493
3494 def StarHotlist(self, hotlist_id, starred):
3495 """Star or unstar the specified hotlist.
3496
3497 Args:
3498 hotlist_id: int ID of the hotlist to star/unstar.
3499 starred: true to add a star, false to remove it.
3500
3501 Returns:
3502 Nothing.
3503
3504 Raises:
3505 NoSuchHotlistException: There is no hotlist with that ID.
3506 """
3507 if hotlist_id is None:
3508 raise exceptions.InputException('No hotlist specified')
3509
3510 if not self.mc.auth.user_id:
3511 raise exceptions.InputException('No current user specified')
3512
3513 with self.mc.profiler.Phase('(un)starring hotlist %r' % hotlist_id):
3514 # Make sure the hotlist exists and user has permission to see it.
3515 self.GetHotlist(hotlist_id)
3516 self.services.hotlist_star.SetStar(
3517 self.mc.cnxn, hotlist_id, self.mc.auth.user_id, starred)
3518
3519 def IsHotlistStarred(self, hotlist_id):
3520 """Return True if the current hotlist has starred the given hotlist.
3521
3522 Args:
3523 hotlist_id: int ID of the hotlist to check.
3524
3525 Returns:
3526 True if starred.
3527
3528 Raises:
3529 NoSuchHotlistException: There is no hotlist with that ID.
3530 """
3531 if hotlist_id is None:
3532 raise exceptions.InputException('No hotlist specified')
3533
3534 if not self.mc.auth.user_id:
3535 return False
3536
3537 with self.mc.profiler.Phase('checking hotlist star %r' % hotlist_id):
3538 # Make sure the hotlist exists and user has permission to see it.
3539 self.GetHotlist(hotlist_id)
3540 return self.services.hotlist_star.IsItemStarredBy(
3541 self.mc.cnxn, hotlist_id, self.mc.auth.user_id)
3542
3543 def GetHotlistStarCount(self, hotlist_id):
3544 """Return the number of times the hotlist has been starred.
3545
3546 Args:
3547 hotlist_id: int ID of the hotlist to check.
3548
3549 Returns:
3550 The number of times the hotlist has been starred.
3551
3552 Raises:
3553 NoSuchHotlistException: There is no hotlist with that ID.
3554 """
3555 if hotlist_id is None:
3556 raise exceptions.InputException('No hotlist specified')
3557
3558 with self.mc.profiler.Phase('counting stars for hotlist %r' % hotlist_id):
3559 # Make sure the hotlist exists and user has permission to see it.
3560 self.GetHotlist(hotlist_id)
3561 return self.services.hotlist_star.CountItemStars(self.mc.cnxn, hotlist_id)
3562
3563 def CheckHotlistName(self, name):
3564 """Check that a hotlist name is valid and not already in use.
3565
3566 Args:
3567 name: str the hotlist name to check.
3568
3569 Returns:
3570 None if the user can create a hotlist with that name, or a string with the
3571 reason the name can't be used.
3572
3573 Raises:
3574 InputException: The user is not signed in.
3575 """
3576 if not self.mc.auth.user_id:
3577 raise exceptions.InputException('No current user specified')
3578
3579 with self.mc.profiler.Phase('checking hotlist name: %r' % name):
3580 if not framework_bizobj.IsValidHotlistName(name):
3581 return '"%s" is not a valid hotlist name.' % name
3582 if self.services.features.LookupHotlistIDs(
3583 self.mc.cnxn, [name], [self.mc.auth.user_id]):
3584 return 'There is already a hotlist with that name.'
3585
3586 return None
3587
3588 def RemoveIssuesFromHotlists(self, hotlist_ids, issue_ids):
3589 """Remove the issues given in issue_ids from the given hotlists.
3590
3591 Args:
3592 hotlist_ids: a list of hotlist ids to remove the issues from.
3593 issue_ids: a list of issue_ids to be removed.
3594
3595 Raises:
3596 PermissionException: The user has no permission to edit the hotlist.
3597 NoSuchHotlistException: One of the hotlist ids was not found.
3598 """
3599 for hotlist_id in hotlist_ids:
3600 self._AssertUserCanEditHotlist(self.GetHotlist(hotlist_id))
3601
3602 with self.mc.profiler.Phase(
3603 'Removing issues %r from hotlists %r' % (issue_ids, hotlist_ids)):
3604 self.services.features.RemoveIssuesFromHotlists(
3605 self.mc.cnxn, hotlist_ids, issue_ids, self.services.issue,
3606 self.services.chart)
3607
3608 def AddIssuesToHotlists(self, hotlist_ids, issue_ids, note):
3609 """Add the issues given in issue_ids to the given hotlists.
3610
3611 Args:
3612 hotlist_ids: a list of hotlist ids to add the issues to.
3613 issue_ids: a list of issue_ids to be added.
3614 note: a string with a message to record along with the issues.
3615
3616 Raises:
3617 PermissionException: The user has no permission to edit the hotlist.
3618 NoSuchHotlistException: One of the hotlist ids was not found.
3619 """
3620 for hotlist_id in hotlist_ids:
3621 self._AssertUserCanEditHotlist(self.GetHotlist(hotlist_id))
3622
3623 # GetIssuesDict checks that the user can view all issues
3624 self.GetIssuesDict(issue_ids)
3625
3626 added_tuples = [
3627 (issue_id, self.mc.auth.user_id, int(time.time()), note)
3628 for issue_id in issue_ids]
3629
3630 with self.mc.profiler.Phase(
3631 'Removing issues %r from hotlists %r' % (issue_ids, hotlist_ids)):
3632 self.services.features.AddIssuesToHotlists(
3633 self.mc.cnxn, hotlist_ids, added_tuples, self.services.issue,
3634 self.services.chart)
3635
3636 # TODO(crbug/monorai/7104): RemoveHotlistItems and RerankHotlistItems should
3637 # replace RemoveIssuesFromHotlist, AddIssuesToHotlists,
3638 # RemoveIssuesFromHotlists.
3639 # The latter 3 methods are still used in v0 API paths and should be removed
3640 # once those v0 API methods are removed.
3641 def RemoveHotlistItems(self, hotlist_id, remove_issue_ids):
3642 # type: (int, Collection[int]) -> None
3643 """Remove given issues from a hotlist.
3644
3645 Args:
3646 hotlist_id: A hotlist ID of the hotlist to remove issues from.
3647 remove_issue_ids: A list of issue IDs that belong to HotlistItems
3648 we want to remove from the hotlist.
3649
3650 Raises:
3651 NoSuchHotlistException: If the hotlist is not found.
3652 NoSuchIssueException: if an Issue is not found for a given
3653 remove_issue_id.
3654 PermissionException: If the user lacks permissions to edit the hotlist or
3655 view all the given issues.
3656 InputException: If there are ids in `remove_issue_ids` that do not exist
3657 in the hotlist.
3658 """
3659 hotlist = self.GetHotlist(hotlist_id)
3660 self._AssertUserCanEditHotlist(hotlist)
3661 if not remove_issue_ids:
3662 raise exceptions.InputException('`remove_issue_ids` empty.')
3663
3664 item_issue_ids = {item.issue_id for item in hotlist.items}
3665 if not (set(remove_issue_ids).issubset(item_issue_ids)):
3666 raise exceptions.InputException('item(s) not found in hotlist.')
3667
3668 # Raise exception for un-viewable or not found item_issue_ids.
3669 self.GetIssuesDict(item_issue_ids)
3670
3671 self.services.features.UpdateHotlistIssues(
3672 self.mc.cnxn, hotlist_id, [], remove_issue_ids, self.services.issue,
3673 self.services.chart)
3674
3675 def AddHotlistItems(self, hotlist_id, new_issue_ids, target_position):
3676 # type: (int, Sequence[int], int) -> None
3677 """Add given issues to a hotlist.
3678
3679 Args:
3680 hotlist_id: A hotlist ID of the hotlist to add issues to.
3681 new_issue_ids: A list of issue IDs that should belong to new
3682 HotlistItems added to the hotlist. HotlistItems will be added
3683 in the same order the IDs are given in. If some HotlistItems already
3684 exist in the Hotlist, they will not be moved.
3685 target_position: The index, starting at 0, of the new position the
3686 first issue in new_issue_ids should have. This value cannot be greater
3687 than (# of current hotlist.items).
3688
3689 Raises:
3690 PermissionException: If the user lacks permissions to edit the hotlist or
3691 view all the given issues.
3692 NoSuchHotlistException: If the hotlist is not found.
3693 NoSuchIssueException: If an Issue is not found for a given new_issue_id.
3694 InputException: If the target_position or new_issue_ids are not valid.
3695 """
3696 hotlist = self.GetHotlist(hotlist_id)
3697 self._AssertUserCanEditHotlist(hotlist)
3698 if not new_issue_ids:
3699 raise exceptions.InputException('no new issues given to add.')
3700
3701 item_issue_ids = {item.issue_id for item in hotlist.items}
3702 confirmed_new_issue_ids = set(new_issue_ids).difference(item_issue_ids)
3703
3704 # Raise exception for un-viewable or not found item_issue_ids.
3705 self.GetIssuesDict(item_issue_ids)
3706
3707 if confirmed_new_issue_ids:
3708 changed_items = self._GetChangedHotlistItems(
3709 hotlist, list(confirmed_new_issue_ids), target_position)
3710 self.services.features.UpdateHotlistIssues(
3711 self.mc.cnxn, hotlist_id, changed_items, [], self.services.issue,
3712 self.services.chart)
3713
3714 def RerankHotlistItems(self, hotlist_id, moved_issue_ids, target_position):
3715 # type: (int, list(int), int) -> Hotlist
3716 """Rerank HotlistItems of a Hotlist.
3717
3718 This method reranks existing hotlist items to the given target_position.
3719 e.g. For a hotlist with items (a, b, c, d, e), if moved_issue_ids were
3720 [e.issue_id, c.issue_id] and target_position were 0,
3721 the hotlist items would be reranked as (e, c, a, b, d).
3722
3723 Args:
3724 hotlist_id: A hotlist ID of the hotlist to rerank.
3725 moved_issue_ids: A list of issue IDs in the hotlist, to be moved
3726 together, in the order they should have after the reranking.
3727 target_position: The index, starting at 0, of the new position the
3728 first issue in moved_issue_ids should have. This value cannot be greater
3729 than (# of current hotlist.items not being reranked).
3730
3731 Returns:
3732 The updated hotlist.
3733
3734 Raises:
3735 PermissionException: If the user lacks permissions to rerank the hotlist
3736 or view all the given issues.
3737 NoSuchHotlistException: If the hotlist is not found.
3738 NoSuchIssueException: If an Issue is not found for a given moved_issue_id.
3739 InputException: If the target_position or moved_issue_ids are not valid.
3740 """
3741 hotlist = self.GetHotlist(hotlist_id)
3742 self._AssertUserCanEditHotlist(hotlist)
3743 if not moved_issue_ids:
3744 raise exceptions.InputException('`moved_issue_ids` empty.')
3745
3746 item_issue_ids = {item.issue_id for item in hotlist.items}
3747 if not (set(moved_issue_ids).issubset(item_issue_ids)):
3748 raise exceptions.InputException('item(s) not found in hotlist.')
3749
3750 # Raise exception for un-viewable or not found item_issue_ids.
3751 self.GetIssuesDict(item_issue_ids)
3752 changed_items = self._GetChangedHotlistItems(
3753 hotlist, moved_issue_ids, target_position)
3754
3755 if changed_items:
3756 self.services.features.UpdateHotlistIssues(
3757 self.mc.cnxn, hotlist_id, changed_items, [], self.services.issue,
3758 self.services.chart)
3759
3760 return self.GetHotlist(hotlist.hotlist_id)
3761
3762 def _GetChangedHotlistItems(self, hotlist, moved_issue_ids, target_position):
3763 # type: (Hotlist, Sequence(int), int) -> Hotlist
3764 """Returns HotlistItems that are changed after moving existing/new issues.
3765
3766 This returns the list of new HotlistItems and existing HotlistItems
3767 with updated ranks as a result of moving the given issues to the given
3768 target_position. This list may include HotlistItems whose ranks' must be
3769 changed as a result of the `moved_issue_ids`.
3770
3771 Args:
3772 hotlist: The hotlist that owns the HotlistItems.
3773 moved_issue_ids: A sequence of issue IDs for new or existing items of the
3774 Hotlist, to be moved together, in the order they should have after
3775 the change.
3776 target_position: The index, starting at 0, of the new position the
3777 first issue in moved_issue_ids should have. This value cannot be greater
3778 than (# of current hotlist.items not being reranked).
3779
3780 Returns:
3781 The updated hotlist.
3782
3783 Raises:
3784 PermissionException: If the user lacks permissions to rerank the hotlist.
3785 NoSuchHotlistException: If the hotlist is not found.
3786 InputException: If the target_position or moved_issue_ids are not valid.
3787 """
3788 # List[Tuple[issue_id, new_rank]]
3789 changed_item_ranks = rerank_helpers.GetHotlistRerankChanges(
3790 hotlist.items, moved_issue_ids, target_position)
3791
3792 items_by_id = {item.issue_id: item for item in hotlist.items}
3793 changed_items = []
3794 current_time = int(time.time())
3795 for issue_id, rank in changed_item_ranks:
3796 # Get existing item to update or create new item.
3797 item = items_by_id.get(
3798 issue_id,
3799 features_pb2.Hotlist.HotlistItem(
3800 issue_id=issue_id,
3801 adder_id=self.mc.auth.user_id,
3802 date_added=current_time))
3803 item.rank = rank
3804 changed_items.append(item)
3805
3806 return changed_items
3807
3808 # TODO(crbug/monorail/7031): Remove this method
3809 # and corresponding v0 prpc method.
3810 def RerankHotlistIssues(self, hotlist_id, moved_ids, target_id, split_above):
3811 """Rerank the moved issues for the hotlist.
3812
3813 Args:
3814 hotlist_id: an int with the id of the hotlist.
3815 moved_ids: The id of the issues to move.
3816 target_id: the id of the issue to move the issues to.
3817 split_above: True if moved issues should be moved before the target issue.
3818 """
3819 hotlist = self.GetHotlist(hotlist_id)
3820 self._AssertUserCanEditHotlist(hotlist)
3821 hotlist_issue_ids = [item.issue_id for item in hotlist.items]
3822 if not set(moved_ids).issubset(set(hotlist_issue_ids)):
3823 raise exceptions.InputException('The issue to move is not in the hotlist')
3824 if target_id not in hotlist_issue_ids:
3825 raise exceptions.InputException('The target issue is not in the hotlist.')
3826
3827 phase_name = 'Moving issues %r %s issue %d.' % (
3828 moved_ids, 'above' if split_above else 'below', target_id)
3829 with self.mc.profiler.Phase(phase_name):
3830 lower, higher = features_bizobj.SplitHotlistIssueRanks(
3831 target_id, split_above,
3832 [(item.issue_id, item.rank) for item in hotlist.items if
3833 item.issue_id not in moved_ids])
3834 rank_changes = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
3835 if rank_changes:
3836 relations_to_change = {
3837 issue_id: rank for issue_id, rank in rank_changes}
3838 self.services.features.UpdateHotlistItemsFields(
3839 self.mc.cnxn, hotlist_id, new_ranks=relations_to_change)
3840
3841 def UpdateHotlistIssueNote(self, hotlist_id, issue_id, note):
3842 """Update the given issue of the given hotlist with the given note.
3843
3844 Args:
3845 hotlist_id: an int with the id of the hotlist.
3846 issue_id: an int with the id of the issue.
3847 note: a string with a message to record for the given issue.
3848 Raises:
3849 PermissionException: The user has no permission to edit the hotlist.
3850 NoSuchHotlistException: The hotlist id was not found.
3851 InputException: The issue is not part of the hotlist.
3852 """
3853 # Make sure the hotlist exists and we have permission to see and edit it.
3854 hotlist = self.GetHotlist(hotlist_id)
3855 self._AssertUserCanEditHotlist(hotlist)
3856
3857 # Make sure the issue exists and we have permission to see it.
3858 self.GetIssue(issue_id)
3859
3860 # Make sure the issue belongs to the hotlist.
3861 if not any(item.issue_id == issue_id for item in hotlist.items):
3862 raise exceptions.InputException('The issue is not part of the hotlist.')
3863
3864 with self.mc.profiler.Phase(
3865 'Editing note for issue %s in hotlist %s' % (issue_id, hotlist_id)):
3866 new_notes = {issue_id: note}
3867 self.services.features.UpdateHotlistItemsFields(
3868 self.mc.cnxn, hotlist_id, new_notes=new_notes)
3869
Copybara854996b2021-09-07 19:36:02 +00003870 # Permissions
3871
3872 # ListFooPermission methods will return the list of permissions in addition to
3873 # the permission to "VIEW",
3874 # that the logged in user has for a given resource_id's resource Foo.
3875 # If the user cannot view Foo, PermissionException will be raised.
3876 # Not all resources will have predefined lists of permissions
3877 # (e.g permissions.HOTLIST_OWNER_PERMISSIONS)
3878 # For most cases, the list of permissions will be created within the
3879 # ListFooPermissions method.
3880
3881 def ListHotlistPermissions(self, hotlist_id):
3882 # type: (int) -> List(str)
3883 """Return the list of permissions the current user has for the hotlist."""
3884 # Permission to view checked in GetHotlist()
3885 hotlist = self.GetHotlist(hotlist_id)
3886 if permissions.CanAdministerHotlist(self.mc.auth.effective_ids,
3887 self.mc.perms, hotlist):
3888 return permissions.HOTLIST_OWNER_PERMISSIONS
3889 if permissions.CanEditHotlist(self.mc.auth.effective_ids, self.mc.perms,
3890 hotlist):
3891 return permissions.HOTLIST_EDITOR_PERMISSIONS
3892 return []
3893
3894 def ListFieldDefPermissions(self, field_id, project_id):
3895 # type:(int, int) -> List[str]
3896 """Return the list of permissions the current user has for the fieldDef."""
3897 project = self.GetProject(project_id)
3898 # TODO(crbug/monorail/7614): The line below was added temporarily while this
3899 # bug is fixed.
3900 self.mc.LookupLoggedInUserPerms(project)
3901 field = self.GetFieldDef(field_id, project)
3902 if permissions.CanEditFieldDef(self.mc.auth.effective_ids, self.mc.perms,
3903 project, field):
3904 return [permissions.EDIT_FIELD_DEF, permissions.EDIT_FIELD_DEF_VALUE]
3905 if permissions.CanEditValueForFieldDef(self.mc.auth.effective_ids,
3906 self.mc.perms, project, field):
3907 return [permissions.EDIT_FIELD_DEF_VALUE]
3908 return []