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