blob: d15d67f9772950755141dbf96027d2f33539cdbf [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
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +02001215 def MakeIssue(
1216 self,
1217 issue,
1218 description,
1219 send_email,
1220 attachment_uploads=None):
Copybara854996b2021-09-07 19:36:02 +00001221 # type: (tracker_pb2.Issue, str, bool) -> tracker_pb2.Issue
1222 """Check restricted field permissions and create issue.
1223
1224 Args:
1225 issue: Data for the created issue in a Protocol Bugger.
1226 description: Description for the initial description comment created.
1227 send_email: Whether this issue creation should email people.
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +02001228 attachment_uploads: List of AttachmentUpload tuples to be attached to the
1229 new issue.
Copybara854996b2021-09-07 19:36:02 +00001230 Returns:
1231 The created Issue PB.
1232
1233 Raises:
1234 FilterRuleException if creation violates any filter rule that shows error.
1235 InputException: The issue has invalid input, see validation below.
1236 PermissionException if user lacks sufficient permissions.
1237 """
1238 config = self.GetProjectConfig(issue.project_id)
1239 project = self.GetProject(issue.project_id)
1240 self._AssertUserCanEditFieldsAndEnumMaskedLabels(
1241 project, config, [fv.field_id for fv in issue.field_values],
1242 issue.labels)
1243 issue, _comment = self.CreateIssue(
1244 issue.project_id,
1245 issue.summary,
1246 issue.status,
1247 issue.owner_id,
1248 issue.cc_ids,
1249 issue.labels,
1250 issue.field_values,
1251 issue.component_ids,
1252 description,
1253 blocked_on=issue.blocked_on_iids,
1254 blocking=issue.blocking_iids,
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +02001255 attachments=attachment_uploads,
Copybara854996b2021-09-07 19:36:02 +00001256 dangling_blocked_on=issue.dangling_blocked_on_refs,
1257 dangling_blocking=issue.dangling_blocking_refs,
1258 send_email=send_email)
1259 return issue
1260
1261 def MoveIssue(self, issue, target_project):
1262 """Move issue to the target_project.
1263
1264 The current user needs to have permission to delete the current issue, and
1265 to edit issues on the target project.
1266
1267 Args:
1268 issue: the issue PB.
1269 target_project: the project PB where the issue should be moved to.
1270 Returns:
1271 The issue PB of the new issue on the target project.
1272 """
1273 self._AssertPermInIssue(issue, permissions.DELETE_ISSUE)
1274 self._AssertPermInProject(permissions.EDIT_ISSUE, target_project)
1275
1276 if permissions.GetRestrictions(issue):
1277 raise exceptions.InputException(
1278 'Issues with Restrict labels are not allowed to be moved')
1279
1280 with self.mc.profiler.Phase('Moving Issue'):
1281 tracker_fulltext.UnindexIssues([issue.issue_id])
1282
1283 # issue is modified by MoveIssues
1284 old_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
1285 moved_back_iids = self.services.issue.MoveIssues(
1286 self.mc.cnxn, target_project, [issue], self.services.user)
1287 new_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
1288
1289 if issue.issue_id in moved_back_iids:
1290 content = 'Moved %s back to %s again.' % (old_text_ref, new_text_ref)
1291 else:
1292 content = 'Moved %s to now be %s.' % (old_text_ref, new_text_ref)
1293 self.services.issue.CreateIssueComment(
1294 self.mc.cnxn, issue, self.mc.auth.user_id, content,
1295 amendments=[
1296 tracker_bizobj.MakeProjectAmendment(target_project.project_name)])
1297
1298 tracker_fulltext.IndexIssues(
1299 self.mc.cnxn, [issue], self.services.user, self.services.issue,
1300 self.services.config)
1301
1302 return issue
1303
1304 def CopyIssue(self, issue, target_project):
1305 """Copy issue to the target_project.
1306
1307 The current user needs to have permission to delete the current issue, and
1308 to edit issues on the target project.
1309
1310 Args:
1311 issue: the issue PB.
1312 target_project: the project PB where the issue should be copied to.
1313 Returns:
1314 The issue PB of the new issue on the target project.
1315 """
1316 self._AssertPermInIssue(issue, permissions.DELETE_ISSUE)
1317 self._AssertPermInProject(permissions.EDIT_ISSUE, target_project)
1318
1319 if permissions.GetRestrictions(issue):
1320 raise exceptions.InputException(
1321 'Issues with Restrict labels are not allowed to be copied')
1322
1323 with self.mc.profiler.Phase('Copying Issue'):
1324 copied_issue = self.services.issue.CopyIssues(
1325 self.mc.cnxn, target_project, [issue], self.services.user,
1326 self.mc.auth.user_id)[0]
1327
1328 issue_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
1329 copied_issue_ref = 'issue %s:%s' % (
1330 copied_issue.project_name, copied_issue.local_id)
1331
1332 # Add comment to the original issue.
1333 content = 'Copied %s to %s' % (issue_ref, copied_issue_ref)
1334 self.services.issue.CreateIssueComment(
1335 self.mc.cnxn, issue, self.mc.auth.user_id, content)
1336
1337 # Add comment to the newly created issue.
1338 # Add project amendment only if the project changed.
1339 amendments = []
1340 if issue.project_id != copied_issue.project_id:
1341 amendments.append(
1342 tracker_bizobj.MakeProjectAmendment(target_project.project_name))
1343 new_issue_content = 'Copied %s from %s' % (copied_issue_ref, issue_ref)
1344 self.services.issue.CreateIssueComment(
1345 self.mc.cnxn, copied_issue, self.mc.auth.user_id, new_issue_content,
1346 amendments=amendments)
1347
1348 tracker_fulltext.IndexIssues(
1349 self.mc.cnxn, [copied_issue], self.services.user, self.services.issue,
1350 self.services.config)
1351
1352 return copied_issue
1353
1354 def _MergeLinkedAccounts(self, me_user_id):
1355 """Return a list of the given user ID and any linked accounts."""
1356 if not me_user_id:
1357 return []
1358
1359 result = [me_user_id]
1360 me_user = self.services.user.GetUser(self.mc.cnxn, me_user_id)
1361 if me_user:
1362 if me_user.linked_parent_id:
1363 result.append(me_user.linked_parent_id)
1364 result.extend(me_user.linked_child_ids)
1365 return result
1366
1367 def SearchIssues(
1368 self, query_string, query_project_names, me_user_id, items_per_page,
1369 paginate_start, sort_spec):
1370 # type: (str, Sequence[str], int, int, int, str) -> ListResult
1371 """Search for issues in the given projects."""
1372 # View permissions and project existence check.
1373 _projects = self.GetProjectsByName(query_project_names)
1374 # TODO(crbug.com/monorail/6988): Delete ListIssues when endpoints and v1
1375 # are deprecated. Move pipeline call to SearchIssues.
1376 # TODO(crbug.com/monorail/7678): Remove can. Pass project_ids
1377 # into pipeline call instead of project_names into SearchIssues call.
1378 # project_names with project_ids.
1379 use_cached_searches = not settings.local_mode
1380 pipeline = self.ListIssues(
1381 query_string, query_project_names, me_user_id, items_per_page,
1382 paginate_start, 1, '', sort_spec, use_cached_searches)
1383
1384 end = paginate_start + items_per_page
1385 next_start = None
1386 if end < pipeline.total_count:
1387 next_start = end
1388 return ListResult(pipeline.visible_results, next_start)
1389
1390 def ListIssues(
1391 self,
1392 query_string, # type: str
1393 query_project_names, # type: Sequence[str]
1394 me_user_id, # type: int
1395 items_per_page, # type: int
1396 paginate_start, # type: int
1397 can, # type: int
1398 group_by_spec, # type: str
1399 sort_spec, # type: str
1400 use_cached_searches, # type: bool
1401 project=None # type: proto.Project
1402 ):
1403 # type: (...) -> search.frontendsearchpipeline.FrontendSearchPipeline
1404 """Do an issue search w/ mc + passed in args to return a pipeline object.
1405
1406 Args:
1407 query_string: str with the query the user is searching for.
1408 query_project_names: List of project names to query for.
1409 me_user_id: Relevant user id. Usually the logged in user.
1410 items_per_page: Max number of issues to include in the results.
1411 paginate_start: Offset of issues to skip for pagination.
1412 can: id of canned query to use.
1413 group_by_spec: str used to specify how issues should be grouped.
1414 sort_spec: str used to specify how issues should be sorted.
1415 use_cached_searches: Whether to use the cache or not.
1416 project: Project object for the current project the user is viewing.
1417
1418 Returns:
1419 A FrontendSearchPipeline instance with data on issues found.
1420 """
1421 # Permission to view a project is checked in FrontendSearchPipeline().
1422 # Individual results are filtered by permissions in SearchForIIDs().
1423
1424 with self.mc.profiler.Phase('searching issues'):
1425 me_user_ids = self._MergeLinkedAccounts(me_user_id)
1426 pipeline = frontendsearchpipeline.FrontendSearchPipeline(
1427 self.mc.cnxn,
1428 self.services,
1429 self.mc.auth,
1430 me_user_ids,
1431 query_string,
1432 query_project_names,
1433 items_per_page,
1434 paginate_start,
1435 can,
1436 group_by_spec,
1437 sort_spec,
1438 self.mc.warnings,
1439 self.mc.errors,
1440 use_cached_searches,
1441 self.mc.profiler,
1442 project=project)
1443 if not self.mc.errors.AnyErrors():
1444 pipeline.SearchForIIDs()
1445 pipeline.MergeAndSortIssues()
1446 pipeline.Paginate()
1447 # TODO(jojwang): raise InvalidQueryException.
1448 return pipeline
1449
1450 # TODO(jrobbins): This method also requires self.mc to be a MonorailRequest.
1451 def FindIssuePositionInSearch(self, issue):
1452 """Do an issue search and return flipper info for the given issue.
1453
1454 Args:
1455 issue: issue that the user is currently viewing.
1456
1457 Returns:
1458 A 4-tuple of flipper info: (prev_iid, cur_index, next_iid, total_count).
1459 """
1460 # Permission to view a project is checked in FrontendSearchPipeline().
1461 # Individual results are filtered by permissions in SearchForIIDs().
1462
1463 with self.mc.profiler.Phase('finding issue position in search'):
1464 me_user_ids = self._MergeLinkedAccounts(self.mc.me_user_id)
1465 pipeline = frontendsearchpipeline.FrontendSearchPipeline(
1466 self.mc.cnxn,
1467 self.services,
1468 self.mc.auth,
1469 me_user_ids,
1470 self.mc.query,
1471 self.mc.query_project_names,
1472 self.mc.num,
1473 self.mc.start,
1474 self.mc.can,
1475 self.mc.group_by_spec,
1476 self.mc.sort_spec,
1477 self.mc.warnings,
1478 self.mc.errors,
1479 self.mc.use_cached_searches,
1480 self.mc.profiler,
1481 project=self.mc.project)
1482 if not self.mc.errors.AnyErrors():
1483 # Only do the search if the user's query parsed OK.
1484 pipeline.SearchForIIDs()
1485
1486 # Note: we never call MergeAndSortIssues() because we don't need a unified
1487 # sorted list, we only need to know the position on such a list of the
1488 # current issue.
1489 prev_iid, cur_index, next_iid = pipeline.DetermineIssuePosition(issue)
1490
1491 return prev_iid, cur_index, next_iid, pipeline.total_count
1492
1493 # TODO(crbug/monorail/6988): add boolean to ignore_private_issues
1494 def GetIssuesDict(self, issue_ids, use_cache=True,
1495 allow_viewing_deleted=False):
1496 # type: (Collection[int], Optional[Boolean], Optional[Boolean]) ->
1497 # Mapping[int, Issue]
1498 """Return a dict {iid: issue} with the specified issues, if allowed.
1499
1500 Args:
1501 issue_ids: int global issue IDs.
1502 use_cache: set to false to ensure fresh issues.
1503 allow_viewing_deleted: set to true to allow user to view deleted issues.
1504
1505 Returns:
1506 A dict {issue_id: issue} for only those issues that the user is allowed
1507 to view.
1508
1509 Raises:
1510 NoSuchIssueException if an issue is not found.
1511 PermissionException if the user cannot view all issues.
1512 """
1513 with self.mc.profiler.Phase('getting issues %r' % issue_ids):
1514 issues_by_id, missing_ids = self.services.issue.GetIssuesDict(
1515 self.mc.cnxn, issue_ids, use_cache=use_cache)
1516
1517 if missing_ids:
1518 with exceptions.ErrorAggregator(
1519 exceptions.NoSuchIssueException) as missing_err_agg:
1520 for missing_id in missing_ids:
1521 missing_err_agg.AddErrorMessage('No such issue: %s' % missing_id)
1522
1523 with exceptions.ErrorAggregator(
1524 permissions.PermissionException) as permission_err_agg:
1525 for issue in issues_by_id.values():
1526 try:
1527 self._AssertUserCanViewIssue(
1528 issue, allow_viewing_deleted=allow_viewing_deleted)
1529 except permissions.PermissionException as e:
1530 permission_err_agg.AddErrorMessage(e.message)
1531
1532 return issues_by_id
1533
1534 def GetIssue(self, issue_id, use_cache=True, allow_viewing_deleted=False):
1535 """Return the specified issue.
1536
1537 Args:
1538 issue_id: int global issue ID.
1539 use_cache: set to false to ensure fresh issue.
1540 allow_viewing_deleted: set to true to allow user to view a deleted issue.
1541
1542 Returns:
1543 The requested Issue PB.
1544 """
1545 if issue_id is None:
1546 raise exceptions.InputException('No issue issue_id specified')
1547
1548 with self.mc.profiler.Phase('getting issue %r' % issue_id):
1549 issue = self.services.issue.GetIssue(
1550 self.mc.cnxn, issue_id, use_cache=use_cache)
1551
1552 self._AssertUserCanViewIssue(
1553 issue, allow_viewing_deleted=allow_viewing_deleted)
1554 return issue
1555
1556 def ListReferencedIssues(self, ref_tuples, default_project_name):
1557 """Return the specified issues."""
1558 # Make sure ref_tuples are unique, preserving order.
1559 ref_tuples = list(collections.OrderedDict(
1560 list(zip(ref_tuples, ref_tuples))))
1561 ref_projects = self.services.project.GetProjectsByName(
1562 self.mc.cnxn,
1563 [(ref_pn or default_project_name) for ref_pn, _ in ref_tuples])
1564 issue_ids, _misses = self.services.issue.ResolveIssueRefs(
1565 self.mc.cnxn, ref_projects, default_project_name, ref_tuples)
1566 open_issues, closed_issues = (
1567 tracker_helpers.GetAllowedOpenedAndClosedIssues(
1568 self.mc, issue_ids, self.services))
1569 return open_issues, closed_issues
1570
1571 def GetIssueByLocalID(
1572 self, project_id, local_id, use_cache=True,
1573 allow_viewing_deleted=False):
1574 """Return the specified issue, TODO: iff the signed in user may view it.
1575
1576 Args:
1577 project_id: int project ID of the project that contains the issue.
1578 local_id: int issue local id number.
1579 use_cache: set to False when doing read-modify-write operations.
1580 allow_viewing_deleted: set to True to return a deleted issue so that
1581 an authorized user may undelete it.
1582
1583 Returns:
1584 The specified Issue PB.
1585
1586 Raises:
1587 exceptions.InputException: Something was not specified properly.
1588 exceptions.NoSuchIssueException: The issue does not exist.
1589 """
1590 if project_id is None:
1591 raise exceptions.InputException('No project specified')
1592 if local_id is None:
1593 raise exceptions.InputException('No issue local_id specified')
1594
1595 with self.mc.profiler.Phase('getting issue %r:%r' % (project_id, local_id)):
1596 issue = self.services.issue.GetIssueByLocalID(
1597 self.mc.cnxn, project_id, local_id, use_cache=use_cache)
1598
1599 self._AssertUserCanViewIssue(
1600 issue, allow_viewing_deleted=allow_viewing_deleted)
1601 return issue
1602
1603 def GetRelatedIssueRefs(self, issues):
1604 """Return a dict {iid: (project_name, local_id)} for all related issues."""
1605 related_iids = set()
1606 with self.mc.profiler.Phase('getting related issue refs'):
1607 for issue in issues:
1608 related_iids.update(issue.blocked_on_iids)
1609 related_iids.update(issue.blocking_iids)
1610 if issue.merged_into:
1611 related_iids.add(issue.merged_into)
1612 logging.info('related_iids is %r', related_iids)
1613 return self.services.issue.LookupIssueRefs(self.mc.cnxn, related_iids)
1614
1615 def GetIssueRefs(self, issue_ids):
1616 """Return a dict {iid: (project_name, local_id)} for all issue_ids."""
1617 return self.services.issue.LookupIssueRefs(self.mc.cnxn, issue_ids)
1618
1619 def BulkUpdateIssueApprovals(self, issue_ids, approval_id, project,
1620 approval_delta, comment_content,
1621 send_email):
1622 """Update all given issues' specified approval."""
1623 # Anon users and users with no permission to view the project
1624 # will get permission denied. Missing permissions to update
1625 # individual issues will not throw exceptions. Issues will just not be
1626 # updated.
1627 if not self.mc.auth.user_id:
1628 raise permissions.PermissionException('Anon cannot make changes')
1629 if not self._UserCanViewProject(project):
1630 raise permissions.PermissionException('User cannot view project')
1631 updated_issue_ids = []
1632 for issue_id in issue_ids:
1633 try:
1634 self.UpdateIssueApproval(
1635 issue_id, approval_id, approval_delta, comment_content, False,
1636 send_email=False)
1637 updated_issue_ids.append(issue_id)
1638 except exceptions.NoSuchIssueApprovalException as e:
1639 logging.info('Skipping issue %s, no approval: %s', issue_id, e)
1640 except permissions.PermissionException as e:
1641 logging.info('Skipping issue %s, update not allowed: %s', issue_id, e)
1642 # TODO(crbug/monorail/8122): send bulk approval update email if send_email.
1643 if send_email:
1644 pass
1645 return updated_issue_ids
1646
1647 def BulkUpdateIssueApprovalsV3(
1648 self, delta_specifications, comment_content, send_email):
1649 # type: (Sequence[Tuple[int, int, tracker_pb2.ApprovalDelta]]], str,
1650 # Boolean -> Sequence[proto.tracker_pb2.ApprovalValue]
1651 """Executes the ApprovalDeltas.
1652
1653 Args:
1654 delta_specifications: List of (issue_id, approval_id, ApprovalDelta).
1655 comment_content: The content of the comment to be posted with each delta.
1656 send_email: Whether to send an email on each change.
1657 TODO(crbug/monorail/8122): send bulk approval update email instead.
1658
1659 Returns:
1660 A list of (Issue, ApprovalValue) pairs corresponding to each
1661 specification provided in `delta_specifications`.
1662
1663 Raises:
1664 InputException: If a comment is too long.
1665 NoSuchIssueApprovalException: If any of the approvals specified
1666 does not exist.
1667 PermissionException: If the current user lacks permissions to execute
1668 any of the deltas provided.
1669 """
1670 updated_approval_values = []
1671 for (issue_id, approval_id, approval_delta) in delta_specifications:
1672 updated_av, _comment, issue = self.UpdateIssueApproval(
1673 issue_id,
1674 approval_id,
1675 approval_delta,
1676 comment_content,
1677 False,
1678 send_email=send_email,
1679 update_perms=True)
1680 updated_approval_values.append((issue, updated_av))
1681 return updated_approval_values
1682
1683 def UpdateIssueApproval(
1684 self,
1685 issue_id,
1686 approval_id,
1687 approval_delta,
1688 comment_content,
1689 is_description,
1690 attachments=None,
1691 send_email=True,
1692 kept_attachments=None,
1693 update_perms=False):
1694 # type: (int, int, proto.tracker_pb2.ApprovalDelta, str, Boolean,
1695 # Optional[Sequence[proto.tracker_pb2.Attachment]], Optional[Boolean],
1696 # Optional[Sequence[int]], Optional[Boolean]) ->
1697 # (proto.tracker_pb2.ApprovalValue, proto.tracker_pb2.IssueComment)
1698 """Update an issue's approval.
1699
1700 Raises:
1701 InputException: The comment content is too long or additional approvers do
1702 not exist.
1703 PermissionException: The user is lacking one of the permissions needed
1704 for the given delta.
1705 NoSuchIssueApprovalException: The issue/approval combo does not exist.
1706 """
1707
1708 issue, approval_value = self.services.issue.GetIssueApproval(
1709 self.mc.cnxn, issue_id, approval_id, use_cache=False)
1710
1711 self._AssertPermInIssue(issue, permissions.EDIT_ISSUE)
1712
1713 if len(comment_content) > tracker_constants.MAX_COMMENT_CHARS:
1714 raise exceptions.InputException('Comment is too long')
1715
1716 project = self.GetProject(issue.project_id)
1717 config = self.GetProjectConfig(issue.project_id)
1718 # TODO(crbug/monorail/7614): Remove the need for this hack to update perms.
1719 if update_perms:
1720 self.mc.LookupLoggedInUserPerms(project)
1721
1722 if attachments:
1723 with self.mc.profiler.Phase('Accounting for quota'):
1724 new_bytes_used = tracker_helpers.ComputeNewQuotaBytesUsed(
1725 project, attachments)
1726 self.services.project.UpdateProject(
1727 self.mc.cnxn, issue.project_id, attachment_bytes_used=new_bytes_used)
1728
1729 if kept_attachments:
1730 with self.mc.profiler.Phase('Filtering kept attachments'):
1731 kept_attachments = tracker_helpers.FilterKeptAttachments(
1732 is_description, kept_attachments, self.ListIssueComments(issue),
1733 approval_id)
1734
1735 if approval_delta.status:
1736 if not permissions.CanUpdateApprovalStatus(
1737 self.mc.auth.effective_ids, self.mc.perms, project,
1738 approval_value.approver_ids, approval_delta.status):
1739 raise permissions.PermissionException(
1740 'User not allowed to make this status update.')
1741
1742 if approval_delta.approver_ids_remove or approval_delta.approver_ids_add:
1743 if not permissions.CanUpdateApprovers(
1744 self.mc.auth.effective_ids, self.mc.perms, project,
1745 approval_value.approver_ids):
1746 raise permissions.PermissionException(
1747 'User not allowed to modify approvers of this approval.')
1748
1749 # Check additional approvers exist.
1750 with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
1751 tracker_helpers.AssertUsersExist(
1752 self.mc.cnxn, self.services, approval_delta.approver_ids_add, err_agg)
1753
1754 with self.mc.profiler.Phase(
1755 'updating approval for issue %r, aprpoval %r' % (
1756 issue_id, approval_id)):
1757 comment_pb = self.services.issue.DeltaUpdateIssueApproval(
1758 self.mc.cnxn, self.mc.auth.user_id, config, issue, approval_value,
1759 approval_delta, comment_content=comment_content,
1760 is_description=is_description, attachments=attachments,
1761 kept_attachments=kept_attachments)
1762 hostport = framework_helpers.GetHostPort(
1763 project_name=project.project_name)
1764 send_notifications.PrepareAndSendApprovalChangeNotification(
1765 issue_id, approval_id, hostport, comment_pb.id,
1766 send_email=send_email)
1767
1768 return approval_value, comment_pb, issue
1769
1770 def ConvertIssueApprovalsTemplate(
1771 self, config, issue, template_name, comment_content, send_email=True):
1772 # type: (proto.tracker_pb2.ProjectIssueConfig, proto.tracker_pb2.Issue,
1773 # str, str, Optional[Boolean] )
1774 """Convert an issue's existing approvals structure to match the one of
1775 the given template.
1776
1777 Raises:
1778 InputException: The comment content is too long.
1779 """
1780 self._AssertPermInIssue(issue, permissions.EDIT_ISSUE)
1781
1782 template = self.services.template.GetTemplateByName(
1783 self.mc.cnxn, template_name, issue.project_id)
1784 if not template:
1785 raise exceptions.NoSuchTemplateException(
1786 'Template %s is not found' % template_name)
1787
1788 if len(comment_content) > tracker_constants.MAX_COMMENT_CHARS:
1789 raise exceptions.InputException('Comment is too long')
1790
1791 with self.mc.profiler.Phase('updating issue %r' % issue):
1792 comment_pb = self.services.issue.UpdateIssueStructure(
1793 self.mc.cnxn, config, issue, template, self.mc.auth.user_id,
1794 comment_content)
1795 hostport = framework_helpers.GetHostPort(project_name=issue.project_name)
1796 send_notifications.PrepareAndSendIssueChangeNotification(
1797 issue.issue_id, hostport, self.mc.auth.user_id,
1798 send_email=send_email, comment_id=comment_pb.id)
1799
1800 def UpdateIssue(
1801 self, issue, delta, comment_content, attachments=None, send_email=True,
1802 is_description=False, kept_attachments=None, inbound_message=None):
1803 # type: (...) => None
1804 """Update an issue with a set of changes and add a comment.
1805
1806 Args:
1807 issue: Existing Issue PB for the issue to be modified.
1808 delta: IssueDelta object containing all the changes to be made.
1809 comment_content: string content of the user's comment.
1810 attachments: List [(filename, contents, mimetype),...] of attachments.
1811 send_email: set to False to suppress email notifications.
1812 is_description: True if this adds a new issue description.
1813 kept_attachments: This should be a list of int attachment ids for
1814 attachments kept from previous descriptions, if the comment is
1815 a change to the issue description.
1816 inbound_message: optional string full text of an email that caused
1817 this comment to be added.
1818
1819 Returns:
1820 Nothing.
1821
1822 Raises:
1823 InputException: The comment content is too long.
1824 """
1825 if not self._UserCanUsePermInIssue(issue, permissions.EDIT_ISSUE):
1826 # We're editing the issue description. Only users with EditIssue
1827 # permission can edit the description.
1828 if is_description:
1829 raise permissions.PermissionException(
1830 'Users lack permission EditIssue in issue')
1831 # If we're adding a comment, we must have AddIssueComment permission and
1832 # verify it's size.
1833 if comment_content:
1834 self._AssertPermInIssue(issue, permissions.ADD_ISSUE_COMMENT)
1835 # If we're modifying the issue, check that we only modify the fields we're
1836 # allowed to edit.
1837 if delta != tracker_pb2.IssueDelta():
1838 allowed_delta = tracker_pb2.IssueDelta()
1839 if self._UserCanUsePermInIssue(issue, permissions.EDIT_ISSUE_STATUS):
1840 allowed_delta.status = delta.status
1841 if self._UserCanUsePermInIssue(issue, permissions.EDIT_ISSUE_SUMMARY):
1842 allowed_delta.summary = delta.summary
1843 if self._UserCanUsePermInIssue(issue, permissions.EDIT_ISSUE_OWNER):
1844 allowed_delta.owner_id = delta.owner_id
1845 if self._UserCanUsePermInIssue(issue, permissions.EDIT_ISSUE_CC):
1846 allowed_delta.cc_ids_add = delta.cc_ids_add
1847 allowed_delta.cc_ids_remove = delta.cc_ids_remove
1848 if delta != allowed_delta:
1849 raise permissions.PermissionException(
1850 'Users lack permission EditIssue in issue')
1851
1852 if delta.merged_into:
1853 # Reject attempts to merge an issue into an issue we cannot view and edit.
1854 merged_into_issue = self.GetIssue(
1855 delta.merged_into, use_cache=False, allow_viewing_deleted=True)
1856 self._AssertPermInIssue(issue, permissions.EDIT_ISSUE)
1857 # Reject attempts to merge an issue into itself.
1858 if issue.issue_id == delta.merged_into:
1859 raise exceptions.InputException(
1860 'Cannot merge an issue into itself.')
1861
1862 # Reject comments that are too long.
1863 if comment_content and len(
1864 comment_content) > tracker_constants.MAX_COMMENT_CHARS:
1865 raise exceptions.InputException('Comment is too long')
1866
1867 # Reject attempts to block on issue on itself.
1868 if (issue.issue_id in delta.blocked_on_add
1869 or issue.issue_id in delta.blocking_add):
1870 raise exceptions.InputException(
1871 'Cannot block an issue on itself.')
1872
1873 project = self.GetProject(issue.project_id)
1874 config = self.GetProjectConfig(issue.project_id)
1875
1876 # Reject attempts to edit restricted fields that the user cannot change.
1877 field_ids = [fv.field_id for fv in delta.field_vals_add]
1878 field_ids.extend([fvr.field_id for fvr in delta.field_vals_remove])
1879 field_ids.extend(delta.fields_clear)
1880 labels = itertools.chain(delta.labels_add, delta.labels_remove)
1881 self._AssertUserCanEditFieldsAndEnumMaskedLabels(
1882 project, config, field_ids, labels)
1883
1884 old_owner_id = tracker_bizobj.GetOwnerId(issue)
1885
1886 if attachments:
1887 with self.mc.profiler.Phase('Accounting for quota'):
1888 new_bytes_used = tracker_helpers.ComputeNewQuotaBytesUsed(
1889 project, attachments)
1890 self.services.project.UpdateProject(
1891 self.mc.cnxn, issue.project_id,
1892 attachment_bytes_used=new_bytes_used)
1893
1894 with self.mc.profiler.Phase('Validating the issue change'):
1895 # If the owner changed, it must be a project member.
1896 if (delta.owner_id is not None and delta.owner_id != issue.owner_id):
1897 parsed_owner_valid, msg = tracker_helpers.IsValidIssueOwner(
1898 self.mc.cnxn, project, delta.owner_id, self.services)
1899 if not parsed_owner_valid:
1900 raise exceptions.InputException(msg)
1901
1902 if kept_attachments:
1903 with self.mc.profiler.Phase('Filtering kept attachments'):
1904 kept_attachments = tracker_helpers.FilterKeptAttachments(
1905 is_description, kept_attachments, self.ListIssueComments(issue),
1906 None)
1907
1908 with self.mc.profiler.Phase('Updating issue %r' % (issue.issue_id)):
1909 _amendments, comment_pb = self.services.issue.DeltaUpdateIssue(
1910 self.mc.cnxn, self.services, self.mc.auth.user_id, issue.project_id,
1911 config, issue, delta, comment=comment_content,
1912 attachments=attachments, is_description=is_description,
1913 kept_attachments=kept_attachments, inbound_message=inbound_message)
1914
1915 with self.mc.profiler.Phase('Following up after issue update'):
1916 if delta.merged_into:
1917 new_starrers = tracker_helpers.GetNewIssueStarrers(
1918 self.mc.cnxn, self.services, [issue.issue_id],
1919 delta.merged_into)
1920 merged_into_project = self.GetProject(merged_into_issue.project_id)
1921 tracker_helpers.AddIssueStarrers(
1922 self.mc.cnxn, self.services, self.mc,
1923 delta.merged_into, merged_into_project, new_starrers)
1924 # Load target issue again to get the updated star count.
1925 merged_into_issue = self.GetIssue(
1926 merged_into_issue.issue_id, use_cache=False)
1927 merge_comment_pb = tracker_helpers.MergeCCsAndAddComment(
1928 self.services, self.mc, issue, merged_into_issue)
1929 # Send notification emails.
1930 hostport = framework_helpers.GetHostPort(
1931 project_name=merged_into_project.project_name)
1932 reporter_id = self.mc.auth.user_id
1933 send_notifications.PrepareAndSendIssueChangeNotification(
1934 merged_into_issue.issue_id,
1935 hostport,
1936 reporter_id,
1937 send_email=send_email,
1938 comment_id=merge_comment_pb.id)
1939 self.services.project.UpdateRecentActivity(
1940 self.mc.cnxn, issue.project_id)
1941
1942 with self.mc.profiler.Phase('Generating notifications'):
1943 if comment_pb:
1944 hostport = framework_helpers.GetHostPort(
1945 project_name=project.project_name)
1946 reporter_id = self.mc.auth.user_id
1947 send_notifications.PrepareAndSendIssueChangeNotification(
1948 issue.issue_id, hostport, reporter_id,
1949 send_email=send_email, old_owner_id=old_owner_id,
1950 comment_id=comment_pb.id)
1951 delta_blocked_on_iids = delta.blocked_on_add + delta.blocked_on_remove
1952 send_notifications.PrepareAndSendIssueBlockingNotification(
1953 issue.issue_id, hostport, delta_blocked_on_iids,
1954 reporter_id, send_email=send_email)
1955
1956 def ModifyIssues(
1957 self,
1958 issue_id_delta_pairs,
1959 attachment_uploads=None,
1960 comment_content=None,
1961 send_email=True):
1962 # type: (Sequence[Tuple[int, IssueDelta]], Boolean, Optional[str],
1963 # Optional[bool]) -> Sequence[Issue]
1964 """Modify issues by the given deltas and returns all issues post-update.
1965
1966 Note: Issues with NOOP deltas and no comment_content to add will not be
1967 updated and will not be returned.
1968
1969 Args:
1970 issue_id_delta_pairs: List of Tuples containing IDs and IssueDeltas, one
1971 for each issue to modify.
1972 attachment_uploads: List of AttachmentUpload tuples to be attached to the
1973 new comments created for all modified issues in issue_id_delta_pairs.
1974 comment_content: The text for the comment this issue change will use.
1975 send_email: Whether this change sends an email or not.
1976
1977 Returns:
1978 List of modified issues.
1979 """
1980
1981 main_issue_ids = {issue_id for issue_id, _delta in issue_id_delta_pairs}
1982 issues_by_id = self.GetIssuesDict(main_issue_ids, use_cache=False)
1983 issue_delta_pairs = [
1984 (issues_by_id[issue_id], delta)
1985 for (issue_id, delta) in issue_id_delta_pairs
1986 ]
1987
1988 # PHASE 1: Prepare these changes and assert they can be made.
1989 self._AssertUserCanModifyIssues(
1990 issue_delta_pairs, False, comment_content=comment_content)
1991 new_bytes_by_pid = tracker_helpers.PrepareIssueChanges(
1992 self.mc.cnxn,
1993 issue_delta_pairs,
1994 self.services,
1995 attachment_uploads=attachment_uploads,
1996 comment_content=comment_content)
1997 # TODO(crbug.com/monorail/8074): Assert we do not update more than 100
1998 # issues at once.
1999
2000 # PHASE 2: Organize data. tracker_helpers.GroupUniqueDeltaIssues()
2001 (_unique_deltas, issues_for_unique_deltas
2002 ) = tracker_helpers.GroupUniqueDeltaIssues(issue_delta_pairs)
2003
2004 # PHASE 3-4: Modify issues in RAM.
2005 changes = tracker_helpers.ApplyAllIssueChanges(
2006 self.mc.cnxn, issue_delta_pairs, self.services)
2007
2008 # PHASE 5: Apply filter rules.
2009 inflight_issues = changes.issues_to_update_dict.values()
2010 project_ids = list(
2011 {issue.project_id for issue in inflight_issues})
2012 configs_by_id = self.services.config.GetProjectConfigs(
2013 self.mc.cnxn, project_ids)
2014 with exceptions.ErrorAggregator(exceptions.FilterRuleException) as err_agg:
2015 for issue in inflight_issues:
2016 config = configs_by_id[issue.project_id]
2017
2018 # Update closed timestamp before filter rules because filter rules
2019 # may affect them.
2020 old_effective_status = changes.old_statuses_by_iid.get(issue.issue_id)
2021 # The old status might be None because the IssueDeltas did not contain
2022 # a status change and MeansOpenInProject treats None as "Open".
2023 if old_effective_status:
2024 tracker_helpers.UpdateClosedTimestamp(
2025 config, issue, old_effective_status)
2026
2027 filterrules_helpers.ApplyFilterRules(
2028 self.mc.cnxn, self.services, issue, config)
2029 if issue.derived_errors:
2030 err_agg.AddErrorMessage('/n'.join(issue.derived_errors))
2031
2032 # Update closed timestamp after filter rules because filter rules
2033 # could change effective status.
2034 # The old status might be None because the IssueDeltas did not contain
2035 # a status change and MeansOpenInProject treats None as "Open".
2036 if old_effective_status:
2037 tracker_helpers.UpdateClosedTimestamp(
2038 config, issue, old_effective_status)
2039
2040 # PHASE 6: Update modified timestamps for issues in RAM.
2041 all_involved_iids = main_issue_ids.union(
2042 changes.issues_to_update_dict.keys())
2043
2044 now_timestamp = int(time.time())
2045 # Add modified timestamps for issues with amendments.
2046 for iid in all_involved_iids:
2047 issue = changes.issues_to_update_dict.get(iid, issues_by_id.get(iid))
2048 issue_modified = iid in changes.issues_to_update_dict
2049
2050 if not (issue_modified or comment_content or attachment_uploads):
2051 # Skip issues that have neither amendments or comment changes.
2052 continue
2053
2054 old_owner = changes.old_owners_by_iid.get(issue.issue_id)
2055 old_status = changes.old_statuses_by_iid.get(issue.issue_id)
2056 old_components = changes.old_components_by_iid.get(issue.issue_id)
2057
2058 # Adding this issue to issues_to_update, so its modified_timestamp gets
2059 # updated in PHASE 7's UpdateIssues() call. Issues with NOOP changes
2060 # but still need a new comment added for `comment_content` or
2061 # `attachments` are added back here.
2062 changes.issues_to_update_dict[issue.issue_id] = issue
2063
2064 issue.modified_timestamp = now_timestamp
2065
2066 if (iid in changes.old_owners_by_iid and
2067 old_owner != tracker_bizobj.GetOwnerId(issue)):
2068 issue.owner_modified_timestamp = now_timestamp
2069
2070 if (iid in changes.old_statuses_by_iid and
2071 old_status != tracker_bizobj.GetStatus(issue)):
2072 issue.status_modified_timestamp = now_timestamp
2073
2074 if (iid in changes.old_components_by_iid and
2075 set(old_components) != set(issue.component_ids)):
2076 issue.component_modified_timestamp = now_timestamp
2077
2078 # PHASE 7: Apply changes to DB: update issues, combine starrers
2079 # for merged issues, create issue comments, enqueue issues for
2080 # re-indexing.
2081 if changes.issues_to_update_dict:
2082 self.services.issue.UpdateIssues(
2083 self.mc.cnxn, changes.issues_to_update_dict.values(), commit=False)
2084 comments_by_iid = {}
2085 impacted_comments_by_iid = {}
2086
2087 # changes.issues_to_update includes all main issues or impacted
2088 # issues with updated fields and main issues that had noop changes
2089 # but still need a comment created for `comment_content` or `attachments`.
2090 for iid, issue in changes.issues_to_update_dict.items():
2091 # Update starrers for merged issues.
2092 new_starrers = changes.new_starrers_by_iid.get(iid)
2093 if new_starrers:
2094 self.services.issue_star.SetStarsBatch_SkipIssueUpdate(
2095 self.mc.cnxn, iid, new_starrers, True, commit=False)
2096
2097 # Create new issue comment for main issue changes.
2098 amendments = changes.amendments_by_iid.get(iid)
2099 if (amendments or comment_content or
2100 attachment_uploads) and iid in main_issue_ids:
2101 comments_by_iid[iid] = self.services.issue.CreateIssueComment(
2102 self.mc.cnxn,
2103 issue,
2104 self.mc.auth.user_id,
2105 comment_content,
2106 amendments=amendments,
2107 attachments=attachment_uploads,
2108 commit=False)
2109
2110 # Create new issue comment for impacted issue changes.
2111 # ie: when an issue is marked as blockedOn another or similar.
2112 imp_amendments = changes.imp_amendments_by_iid.get(iid)
2113 if imp_amendments:
2114 filtered_imp_amendments = []
2115 content = ''
2116 # Represent MERGEDINTO Amendments for impacted issues with
2117 # comment content instead to be consistent with previous behavior
2118 # and so users can tell whether a merged change comment on an issue
2119 # is a change in the issue's merged_into or a change in another
2120 # issue's merged_into.
2121 for am in imp_amendments:
2122 if am.field is tracker_pb2.FieldID.MERGEDINTO and am.newvalue:
2123 for value in am.newvalue.split():
2124 if value.startswith('-'):
2125 content += UNMERGE_COMMENT % value.strip('-')
2126 else:
2127 content += MERGE_COMMENT % value
2128 else:
2129 filtered_imp_amendments.append(am)
2130
2131 impacted_comments_by_iid[iid] = self.services.issue.CreateIssueComment(
2132 self.mc.cnxn,
2133 issue,
2134 self.mc.auth.user_id,
2135 content,
2136 amendments=filtered_imp_amendments,
2137 commit=False)
2138
2139 # Update used bytes for each impacted project.
2140 for pid, new_bytes_used in new_bytes_by_pid.items():
2141 self.services.project.UpdateProject(
2142 self.mc.cnxn, pid, attachment_bytes_used=new_bytes_used, commit=False)
2143
2144 # Reindex issues and commit all DB changes.
2145 issues_to_reindex = set(
2146 comments_by_iid.keys() + impacted_comments_by_iid.keys())
2147 if issues_to_reindex:
2148 self.services.issue.EnqueueIssuesForIndexing(
2149 self.mc.cnxn, issues_to_reindex, commit=False)
2150 # We only commit if there are issues to reindex. No issues to reindex
2151 # means there were no updates that need a commit.
2152 self.mc.cnxn.Commit()
2153
2154 # PHASE 8: Send notifications for each group of issues from Phase 2.
2155 # Fetch hostports.
2156 hostports_by_pid = {}
2157 for iid, issue in changes.issues_to_update_dict.items():
2158 # Note: issues_to_update only include issues with changes in metadata.
2159 # If iid is not in issues_to_update, the issue may still have a new
2160 # comment that we want to send notifications for.
2161 issue = changes.issues_to_update_dict.get(iid, issues_by_id.get(iid))
2162
2163 if issue.project_id not in hostports_by_pid:
2164 hostports_by_pid[issue.project_id] = framework_helpers.GetHostPort(
2165 project_name=issue.project_name)
2166 # Send emails for main changes in issues by unique delta.
2167 for issues in issues_for_unique_deltas:
2168 # Group issues for each unique delta by project because
2169 # SendIssueBulkChangeNotification cannot handle cross-project
2170 # notifications and hostports are specific to each project.
2171 issues_by_pid = collections.defaultdict(set)
2172 for issue in issues:
2173 issues_by_pid[issue.project_id].add(issue)
2174 for project_issues in issues_by_pid.values():
2175 # Send one email to involved users for the issue.
2176 if len(project_issues) == 1:
2177 (project_issue,) = project_issues
2178 self._ModifyIssuesNotifyForDelta(
2179 project_issue, changes, comments_by_iid, hostports_by_pid,
2180 send_email)
2181 # Send one bulk email for users involved in all updated issues.
2182 else:
2183 self._ModifyIssuesBulkNotifyForDelta(
2184 project_issues,
2185 changes,
2186 hostports_by_pid,
2187 send_email,
2188 comment_content=comment_content)
2189
2190 # Send emails for changes to impacted issues.
2191 for issue_id, comment_pb in impacted_comments_by_iid.items():
2192 issue = changes.issues_to_update_dict[issue_id]
2193 hostport = hostports_by_pid[issue.project_id]
2194 # We do not need to track old owners because the only owner change
2195 # that could have happened for impacted issues' changes is a change from
2196 # no owner to a derived owner.
2197 send_notifications.PrepareAndSendIssueChangeNotification(
2198 issue_id, hostport, self.mc.auth.user_id, comment_id=comment_pb.id,
2199 send_email=send_email)
2200
2201 return [
2202 issues_by_id[iid] for iid in main_issue_ids if iid in comments_by_iid
2203 ]
2204
2205 def _ModifyIssuesNotifyForDelta(
2206 self, issue, changes, comments_by_iid, hostports_by_pid, send_email):
2207 # type: (Issue, tracker_helpers._IssueChangesTuple,
2208 # Mapping[int, IssueComment], Mapping[int, str], bool) -> None
2209 comment_pb = comments_by_iid.get(issue.issue_id)
2210 # Existence of a comment_pb means there were updates to the issue or
2211 # comment_content added to the issue that should trigger
2212 # notifications.
2213 if comment_pb:
2214 hostport = hostports_by_pid[issue.project_id]
2215 old_owner_id = changes.old_owners_by_iid.get(issue.issue_id)
2216 send_notifications.PrepareAndSendIssueChangeNotification(
2217 issue.issue_id,
2218 hostport,
2219 self.mc.auth.user_id,
2220 old_owner_id=old_owner_id,
2221 comment_id=comment_pb.id,
2222 send_email=send_email)
2223
2224 def _ModifyIssuesBulkNotifyForDelta(
2225 self, issues, changes, hostports_by_pid, send_email,
2226 comment_content=None):
2227 # type: (Collection[Issue], _IssueChangesTuple, Mapping[int, str], bool,
2228 # Optional[str]) -> None
2229 iids = {issue.issue_id for issue in issues}
2230 old_owner_ids = [
2231 changes.old_owners_by_iid.get(iid)
2232 for iid in iids
2233 if changes.old_owners_by_iid.get(iid)
2234 ]
2235 amendments = []
2236 for iid in iids:
2237 ams = changes.amendments_by_iid.get(iid, [])
2238 amendments.extend(ams)
2239 # Calling SendBulkChangeNotification does not require the comment_pb
2240 # objects only the amendments. Checking for existence of amendments
2241 # and comment_content is equivalent to checking for existence of new
2242 # comments created for these issues.
2243 if amendments or comment_content:
2244 # TODO(crbug.com/monorail/8125): Stop using UserViews for bulk
2245 # notifications.
2246 users_by_id = framework_views.MakeAllUserViews(
2247 self.mc.cnxn, self.services.user, old_owner_ids,
2248 tracker_bizobj.UsersInvolvedInAmendments(amendments))
2249 hostport = hostports_by_pid[issues.pop().project_id]
2250 send_notifications.SendIssueBulkChangeNotification(
2251 iids, hostport, old_owner_ids, comment_content,
2252 self.mc.auth.user_id, amendments, send_email, users_by_id)
2253
2254 def DeleteIssue(self, issue, delete):
2255 """Mark or unmark the given issue as deleted."""
2256 self._AssertPermInIssue(issue, permissions.DELETE_ISSUE)
2257
2258 with self.mc.profiler.Phase('Marking issue %r deleted' % (issue.issue_id)):
2259 self.services.issue.SoftDeleteIssue(
2260 self.mc.cnxn, issue.project_id, issue.local_id, delete,
2261 self.services.user)
2262
2263 def FlagIssues(self, issues, flag):
2264 """Flag or unflag the given issues as spam."""
2265 for issue in issues:
2266 self._AssertPermInIssue(issue, permissions.FLAG_SPAM)
2267
2268 issue_ids = [issue.issue_id for issue in issues]
2269 with self.mc.profiler.Phase('Marking issues %r as spam' % issue_ids):
2270 self.services.spam.FlagIssues(
2271 self.mc.cnxn, self.services.issue, issues, self.mc.auth.user_id,
2272 flag)
2273 if self._UserCanUsePermInIssue(issue, permissions.VERDICT_SPAM):
2274 self.services.spam.RecordManualIssueVerdicts(
2275 self.mc.cnxn, self.services.issue, issues, self.mc.auth.user_id,
2276 flag)
2277
2278 def LookupIssuesFlaggers(self, issues):
2279 """Returns users who've reported the issue or its comments as spam.
2280
2281 Args:
2282 issues: the list of issues to query.
2283 Returns:
2284 A dictionary
2285 {issue_id: ([issue_reporters], {comment_id: [comment_reporters]})}
2286 For each issue id, a tuple with the users who have flagged the issue;
2287 and a dictionary of users who have flagged a comment for each comment id.
2288 """
2289 for issue in issues:
2290 self._AssertUserCanViewIssue(issue)
2291
2292 issue_ids = [issue.issue_id for issue in issues]
2293 with self.mc.profiler.Phase('Looking up flaggers for %s' % issue_ids):
2294 reporters = self.services.spam.LookupIssuesFlaggers(
2295 self.mc.cnxn, issue_ids)
2296
2297 return reporters
2298
2299 def LookupIssueFlaggers(self, issue):
2300 """Returns users who've reported the issue or its comments as spam.
2301
2302 Args:
2303 issue: the issue to query.
2304 Returns:
2305 A tuple
2306 ([issue_reporters], {comment_id: [comment_reporters]})
2307 With the users who have flagged the issue; and a dictionary of users who
2308 have flagged a comment for each comment id.
2309 """
2310 return self.LookupIssuesFlaggers([issue])[issue.issue_id]
2311
2312 def GetIssuePositionInHotlist(
2313 self, current_issue, hotlist, can, sort_spec, group_by_spec):
2314 # type: (Issue, Hotlist, int, str, str) -> (int, int, int, int)
2315 """Get index info of an issue within a hotlist.
2316
2317 Args:
2318 current_issue: the currently viewed issue.
2319 hotlist: the hotlist this flipper is flipping through.
2320 can: int "canned query" number to scope the visible issues.
2321 sort_spec: string that lists the sort order.
2322 group_by_spec: string that lists the grouping order.
2323 """
2324 issues_list = self.services.issue.GetIssues(self.mc.cnxn,
2325 [item.issue_id for item in hotlist.items])
2326 project_ids = hotlist_helpers.GetAllProjectsOfIssues(issues_list)
2327 config_list = hotlist_helpers.GetAllConfigsOfProjects(
2328 self.mc.cnxn, project_ids, self.services)
2329 harmonized_config = tracker_bizobj.HarmonizeConfigs(config_list)
2330 (sorted_issues, _hotlist_issues_context,
2331 _users) = hotlist_helpers.GetSortedHotlistIssues(
2332 self.mc.cnxn, hotlist.items, issues_list, self.mc.auth,
2333 can, sort_spec, group_by_spec, harmonized_config, self.services,
2334 self.mc.profiler)
2335 (prev_iid, cur_index,
2336 next_iid) = features_bizobj.DetermineHotlistIssuePosition(
2337 current_issue, [issue.issue_id for issue in sorted_issues])
2338 total_count = len(sorted_issues)
2339 return prev_iid, cur_index, next_iid, total_count
2340
2341 def RerankBlockedOnIssues(self, issue, moved_id, target_id, split_above):
2342 """Rerank the blocked on issues for issue_id.
2343
2344 Args:
2345 issue: The issue to modify.
2346 moved_id: The id of the issue to move.
2347 target_id: The id of the issue to move |moved_issue| to.
2348 split_above: Whether to move |moved_issue| before or after |target_issue|.
2349 """
2350 # Make sure the user has permission to edit the issue.
2351 self._AssertPermInIssue(issue, permissions.EDIT_ISSUE)
2352 # Make sure the moved and target issues are in the blocked-on list.
2353 if moved_id not in issue.blocked_on_iids:
2354 raise exceptions.InputException(
2355 'The issue to move is not in the blocked-on list.')
2356 if target_id not in issue.blocked_on_iids:
2357 raise exceptions.InputException(
2358 'The target issue is not in the blocked-on list.')
2359
2360 phase_name = 'Moving issue %r %s issue %d.' % (
2361 moved_id, 'above' if split_above else 'below', target_id)
2362 with self.mc.profiler.Phase(phase_name):
2363 lower, higher = tracker_bizobj.SplitBlockedOnRanks(
2364 issue, target_id, split_above,
2365 [iid for iid in issue.blocked_on_iids if iid != moved_id])
2366 rank_changes = rerank_helpers.GetInsertRankings(
2367 lower, higher, [moved_id])
2368 if rank_changes:
2369 self.services.issue.ApplyIssueRerank(
2370 self.mc.cnxn, issue.issue_id, rank_changes)
2371
2372 # FUTURE: GetIssuePermissionsForUser()
2373
2374 # FUTURE: CreateComment()
2375
2376
2377 # TODO(crbug.com/monorail/7520): Delete when usages removed.
2378 def ListIssueComments(self, issue):
2379 """Return comments on the specified viewable issue."""
2380 self._AssertUserCanViewIssue(issue)
2381
2382 with self.mc.profiler.Phase('getting comments for %r' % issue.issue_id):
2383 comments = self.services.issue.GetCommentsForIssue(
2384 self.mc.cnxn, issue.issue_id)
2385
2386 return comments
2387
2388
2389 def SafeListIssueComments(
2390 self, issue_id, max_items, start, approval_id=None):
2391 # type: (tracker_pb2.Issue, int, int, Optional[int]) -> ListResult
2392 """Return comments on the issue, filtering non-viewable content.
2393
2394 TODO(crbug.com/monorail/7520): Rename to ListIssueComments.
2395
2396 Note: This returns `deleted_by`, but it should only be used for the purposes
2397 of determining whether the comment is deleted. The viewer may not have
2398 access to view who deleted the comment.
2399
2400 Args:
2401 issue_id: The issue for which we're listing comments.
2402 max_items: The maximum number of comments to return.
2403 start: The index of the start position in the list of comments.
2404 approval_id: Whether to only return comments on this approval.
2405
2406 Returns:
2407 A work_env.ListResult namedtuple with the comments for the issue.
2408
2409 Raises:
2410 PermissionException: The logged-in user is not allowed to view the issue.
2411 """
2412 if start < 0:
2413 raise exceptions.InputException('Invalid `start`: %d' % start)
2414 if max_items < 0:
2415 raise exceptions.InputException('Invalid `max_items`: %d' % max_items)
2416
2417 with self.mc.profiler.Phase('getting comments for %r' % issue_id):
2418 issue = self.GetIssue(issue_id)
2419 comments = self.services.issue.GetCommentsForIssue(self.mc.cnxn, issue_id)
2420 _, comment_reporters = self.LookupIssueFlaggers(issue)
2421 users_involved_in_comments = tracker_bizobj.UsersInvolvedInCommentList(
2422 comments)
2423 users_by_id = framework_views.MakeAllUserViews(
2424 self.mc.cnxn, self.services.user, users_involved_in_comments)
2425
2426 with self.mc.profiler.Phase('getting perms for comments'):
2427 project = self.GetProjectByName(issue.project_name)
2428 self.mc.LookupLoggedInUserPerms(project)
2429 config = self.GetProjectConfig(project.project_id)
2430 perms = permissions.UpdateIssuePermissions(
2431 self.mc.perms,
2432 project,
2433 issue,
2434 self.mc.auth.effective_ids,
2435 config=config)
2436
2437 # TODO(crbug.com/monorail/7525): Check values, and return next_start.
2438 end = start + max_items
2439 filtered_comments = []
2440 with self.mc.profiler.Phase('converting comments'):
2441 for comment in comments:
2442 if approval_id and comment.approval_id != approval_id:
2443 continue
2444 commenter = users_by_id[comment.user_id]
2445
2446 _can_flag, is_flagged = permissions.CanFlagComment(
2447 comment, commenter, comment_reporters.get(comment.id, []),
2448 self.mc.auth.user_id, perms)
2449 can_view = permissions.CanViewComment(
2450 comment, commenter, self.mc.auth.user_id, perms)
2451 can_view_inbound_message = permissions.CanViewInboundMessage(
2452 comment, self.mc.auth.user_id, perms)
2453
2454 # By default, all fields should get filtered out.
2455 # i.e. this is an allowlist rather than a denylist to reduce leaking
2456 # info.
2457 filtered_comment = tracker_pb2.IssueComment(
2458 id=comment.id,
2459 issue_id=comment.issue_id,
2460 project_id=comment.project_id,
2461 approval_id=comment.approval_id,
2462 timestamp=comment.timestamp,
2463 deleted_by=comment.deleted_by,
2464 sequence=comment.sequence,
2465 is_spam=is_flagged,
2466 is_description=comment.is_description,
2467 description_num=comment.description_num)
2468 if can_view:
2469 filtered_comment.content = comment.content
2470 filtered_comment.user_id = comment.user_id
2471 filtered_comment.amendments.extend(comment.amendments)
2472 filtered_comment.attachments.extend(comment.attachments)
2473 filtered_comment.importer_id = comment.importer_id
2474 if can_view_inbound_message:
2475 filtered_comment.inbound_message = comment.inbound_message
2476 filtered_comments.append(filtered_comment)
2477 next_start = None
2478 if end < len(filtered_comments):
2479 next_start = end
2480 return ListResult(filtered_comments[start:end], next_start)
2481
2482 # FUTURE: UpdateComment()
2483
2484 def DeleteComment(self, issue, comment, delete):
2485 """Mark or unmark a comment as deleted by the current user."""
2486 self._AssertUserCanDeleteComment(issue, comment)
2487 if comment.is_spam and self.mc.auth.user_id == comment.user_id:
2488 raise permissions.PermissionException('Cannot delete comment.')
2489
2490 with self.mc.profiler.Phase(
2491 'deleting issue %r comment %r' % (issue.issue_id, comment.id)):
2492 self.services.issue.SoftDeleteComment(
2493 self.mc.cnxn, issue, comment, self.mc.auth.user_id,
2494 self.services.user, delete=delete)
2495
2496 def DeleteAttachment(self, issue, comment, attachment_id, delete):
2497 """Mark or unmark a comment attachment as deleted by the current user."""
2498 # A user can delete an attachment iff they can delete a comment.
2499 self._AssertUserCanDeleteComment(issue, comment)
2500
2501 phase_message = 'deleting issue %r comment %r attachment %r' % (
2502 issue.issue_id, comment.id, attachment_id)
2503 with self.mc.profiler.Phase(phase_message):
2504 self.services.issue.SoftDeleteAttachment(
2505 self.mc.cnxn, issue, comment, attachment_id, self.services.user,
2506 delete=delete)
2507
2508 def FlagComment(self, issue, comment, flag):
2509 """Mark or unmark a comment as spam."""
2510 self._AssertPermInIssue(issue, permissions.FLAG_SPAM)
2511 with self.mc.profiler.Phase(
2512 'flagging issue %r comment %r' % (issue.issue_id, comment.id)):
2513 self.services.spam.FlagComment(
2514 self.mc.cnxn, issue, comment.id, comment.user_id,
2515 self.mc.auth.user_id, flag)
2516 if self._UserCanUsePermInIssue(issue, permissions.VERDICT_SPAM):
2517 self.services.spam.RecordManualCommentVerdict(
2518 self.mc.cnxn, self.services.issue, self.services.user, comment.id,
2519 self.mc.auth.user_id, flag)
2520
2521 def StarIssue(self, issue, starred):
2522 # type: (Issue, bool) -> Issue
2523 """Set or clear a star on the given issue for the signed in user."""
2524 if not self.mc.auth.user_id:
2525 raise permissions.PermissionException('Anon cannot star issues')
2526 self._AssertPermInIssue(issue, permissions.SET_STAR)
2527
2528 with self.mc.profiler.Phase('starring issue %r' % issue.issue_id):
2529 config = self.services.config.GetProjectConfig(
2530 self.mc.cnxn, issue.project_id)
2531 self.services.issue_star.SetStar(
2532 self.mc.cnxn, self.services, config, issue.issue_id,
2533 self.mc.auth.user_id, starred)
2534 return self.services.issue.GetIssue(self.mc.cnxn, issue.issue_id)
2535
2536 def IsIssueStarred(self, issue, cnxn=None):
2537 """Return True if the given issue is starred by the signed in user."""
2538 self._AssertUserCanViewIssue(issue)
2539
2540 with self.mc.profiler.Phase('checking star %r' % issue.issue_id):
2541 return self.services.issue_star.IsItemStarredBy(
2542 cnxn or self.mc.cnxn, issue.issue_id, self.mc.auth.user_id)
2543
2544 def ListStarredIssueIDs(self):
2545 """Return a list of the issue IDs that the current issue has starred."""
2546 # This returns an unfiltered list of issue_ids. Permissions will be
2547 # applied if and when the caller attempts to load each issue.
2548
2549 with self.mc.profiler.Phase('getting stars %r' % self.mc.auth.user_id):
2550 return self.services.issue_star.LookupStarredItemIDs(
2551 self.mc.cnxn, self.mc.auth.user_id)
2552
2553 def SnapshotCountsQuery(self, project, timestamp, group_by, label_prefix=None,
2554 query=None, canned_query=None, hotlist=None):
2555 """Query IssueSnapshots for daily counts.
2556
2557 See chart_svc.QueryIssueSnapshots for more detail on arguments.
2558
2559 Args:
2560 project (Project): Project to search.
2561 timestamp (int): Will query for snapshots at this timestamp.
2562 group_by (str): 2nd dimension, see QueryIssueSnapshots for options.
2563 label_prefix (str): Required for label queries. Only returns results
2564 with the supplied prefix.
2565 query (str, optional): If supplied, will parse & apply query conditions.
2566 canned_query (str, optional): Parsed canned query.
2567 hotlist (Hotlist, optional): Hotlist to search under (in lieu of project).
2568
2569 Returns:
2570 1. A dict of {name: count} for each item in group_by.
2571 2. A list of any unsupported query conditions in query.
2572 """
2573 # This returns counts of viewable issues.
2574 with self.mc.profiler.Phase('querying snapshot counts'):
2575 return self.services.chart.QueryIssueSnapshots(
2576 self.mc.cnxn, self.services, timestamp, self.mc.auth.effective_ids,
2577 project, self.mc.perms, group_by=group_by, label_prefix=label_prefix,
2578 query=query, canned_query=canned_query, hotlist=hotlist)
2579
2580 ### User methods
2581
2582 # TODO(crbug/monorail/7238): rewrite this method to call BatchGetUsers.
2583 def GetUser(self, user_id):
2584 # type: (int) -> User
2585 """Return the user with the given ID."""
2586
2587 return self.BatchGetUsers([user_id])[0]
2588
2589 def BatchGetUsers(self, user_ids):
2590 # type: (Sequence[int]) -> Sequence[User]
2591 """Return all Users for given User IDs.
2592
2593 Args:
2594 user_ids: list of User IDs.
2595
2596 Returns:
2597 A list of User objects in the same order as the given User IDs.
2598
2599 Raises:
2600 NoSuchUserException if a User for a given User ID is not found.
2601 """
2602 users_by_id = self.services.user.GetUsersByIDs(
2603 self.mc.cnxn, user_ids, skip_missed=True)
2604 users = []
2605 for user_id in user_ids:
2606 user = users_by_id.get(user_id)
2607 if not user:
2608 raise exceptions.NoSuchUserException(
2609 'No User with ID %s found' % user_id)
2610 users.append(user)
2611 return users
2612
2613 def GetMemberships(self, user_id):
2614 """Return the user group ids for the given user visible to the requester."""
2615 group_ids = self.services.usergroup.LookupMemberships(self.mc.cnxn, user_id)
2616 if user_id == self.mc.auth.user_id:
2617 return group_ids
2618 (member_ids_by_ids, owner_ids_by_ids
2619 ) = self.services.usergroup.LookupAllMembers(
2620 self.mc.cnxn, group_ids)
2621 settings_by_id = self.services.usergroup.GetAllGroupSettings(
2622 self.mc.cnxn, group_ids)
2623
2624 (owned_project_ids, membered_project_ids,
2625 contrib_project_ids) = self.services.project.GetUserRolesInAllProjects(
2626 self.mc.cnxn, self.mc.auth.effective_ids)
2627 project_ids = owned_project_ids.union(
2628 membered_project_ids).union(contrib_project_ids)
2629
2630 visible_group_ids = []
2631 for group_id, group_settings in settings_by_id.items():
2632 member_ids = member_ids_by_ids.get(group_id)
2633 owner_ids = owner_ids_by_ids.get(group_id)
2634 if permissions.CanViewGroupMembers(
2635 self.mc.perms, self.mc.auth.effective_ids, group_settings,
2636 member_ids, owner_ids, project_ids):
2637 visible_group_ids.append(group_id)
2638
2639 return visible_group_ids
2640
2641 def ListReferencedUsers(self, emails):
2642 """Return a list of the given emails' User PBs, plus linked account ids.
2643
2644 Args:
2645 emails: list of emails of users to look up.
2646
2647 Returns:
2648 A pair (users, linked_users_ids) where users is an unsorted list of
2649 User PBs and linked_user_ids is a list of user IDs of any linked accounts.
2650 """
2651 with self.mc.profiler.Phase('getting existing users'):
2652 user_id_dict = self.services.user.LookupExistingUserIDs(
2653 self.mc.cnxn, emails)
2654 users_by_id = self.services.user.GetUsersByIDs(
2655 self.mc.cnxn, list(user_id_dict.values()))
2656 user_list = list(users_by_id.values())
2657
2658 linked_user_ids = []
2659 for user in user_list:
2660 if user.linked_parent_id:
2661 linked_user_ids.append(user.linked_parent_id)
2662 linked_user_ids.extend(user.linked_child_ids)
2663
2664 return user_list, linked_user_ids
2665
2666 def StarUser(self, user_id, starred):
2667 """Star or unstar the specified user.
2668
2669 Args:
2670 user_id: int ID of the user to star/unstar.
2671 starred: true to add a star, false to remove it.
2672
2673 Returns:
2674 Nothing.
2675
2676 Raises:
2677 NoSuchUserException: There is no user with that ID.
2678 """
2679 if not self.mc.auth.user_id:
2680 raise exceptions.InputException('No current user specified')
2681
2682 with self.mc.profiler.Phase('(un)starring user %r' % user_id):
2683 # Make sure the user exists and user has permission to see it.
2684 self.services.user.LookupUserEmail(self.mc.cnxn, user_id)
2685 self.services.user_star.SetStar(
2686 self.mc.cnxn, user_id, self.mc.auth.user_id, starred)
2687
2688 def IsUserStarred(self, user_id):
2689 """Return True if the current user has starred the given user.
2690
2691 Args:
2692 user_id: int ID of the user to check.
2693
2694 Returns:
2695 True if starred.
2696
2697 Raises:
2698 NoSuchUserException: There is no user with that ID.
2699 """
2700 if user_id is None:
2701 raise exceptions.InputException('No user specified')
2702
2703 if not self.mc.auth.user_id:
2704 return False
2705
2706 with self.mc.profiler.Phase('checking user star %r' % user_id):
2707 # Make sure the user exists.
2708 self.services.user.LookupUserEmail(self.mc.cnxn, user_id)
2709 return self.services.user_star.IsItemStarredBy(
2710 self.mc.cnxn, user_id, self.mc.auth.user_id)
2711
2712 def GetUserStarCount(self, user_id):
2713 """Return the number of times the user has been starred.
2714
2715 Args:
2716 user_id: int ID of the user to check.
2717
2718 Returns:
2719 The number of times the user has been starred.
2720
2721 Raises:
2722 NoSuchUserException: There is no user with that ID.
2723 """
2724 if user_id is None:
2725 raise exceptions.InputException('No user specified')
2726
2727 with self.mc.profiler.Phase('counting stars for user %r' % user_id):
2728 # Make sure the user exists.
2729 self.services.user.LookupUserEmail(self.mc.cnxn, user_id)
2730 return self.services.user_star.CountItemStars(self.mc.cnxn, user_id)
2731
2732 def GetPendingLinkedInvites(self, user_id=None):
2733 """Return info about a user's linked account invites."""
2734 with self.mc.profiler.Phase('checking linked account invites'):
2735 result = self.services.user.GetPendingLinkedInvites(
2736 self.mc.cnxn, user_id or self.mc.auth.user_id)
2737 return result
2738
2739 def InviteLinkedParent(self, parent_email):
2740 """Invite a matching account to be my parent."""
2741 if not parent_email:
2742 raise exceptions.InputException('No parent account specified')
2743 if not self.mc.auth.user_id:
2744 raise permissions.PermissionException('Anon cannot link accounts')
2745 with self.mc.profiler.Phase('Validating proposed parent'):
2746 # We only offer self-serve account linking to matching usernames.
2747 (p_username, p_domain,
2748 _obs_username, _obs_email) = framework_bizobj.ParseAndObscureAddress(
2749 parent_email)
2750 c_view = self.mc.auth.user_view
2751 if p_username != c_view.username:
2752 logging.info('Username %r != %r', p_username, c_view.username)
2753 raise exceptions.InputException('Linked account names must match')
2754 allowed_domains = settings.linkable_domains.get(c_view.domain, [])
2755 if p_domain not in allowed_domains:
2756 logging.info('parent domain %r is not in list for %r: %r',
2757 p_domain, c_view.domain, allowed_domains)
2758 raise exceptions.InputException('Linked account unsupported domain')
2759 parent_id = self.services.user.LookupUserID(self.mc.cnxn, parent_email)
2760 with self.mc.profiler.Phase('Creating linked account invite'):
2761 self.services.user.InviteLinkedParent(
2762 self.mc.cnxn, parent_id, self.mc.auth.user_id)
2763
2764 def AcceptLinkedChild(self, child_id):
2765 """Accept an invitation from a child account."""
2766 with self.mc.profiler.Phase('Accept linked account invite'):
2767 self.services.user.AcceptLinkedChild(
2768 self.mc.cnxn, self.mc.auth.user_id, child_id)
2769
2770 def UnlinkAccounts(self, parent_id, child_id):
2771 """Delete a linked-account relationship."""
2772 if (self.mc.auth.user_id != parent_id and
2773 self.mc.auth.user_id != child_id):
2774 permitted = self.mc.perms.CanUsePerm(
2775 permissions.EDIT_OTHER_USERS, self.mc.auth.effective_ids, None, [])
2776 if not permitted:
2777 raise permissions.PermissionException(
2778 'User lacks permission to unlink accounts')
2779
2780 with self.mc.profiler.Phase('Unlink accounts'):
2781 self.services.user.UnlinkAccounts(self.mc.cnxn, parent_id, child_id)
2782
2783 def UpdateUserSettings(self, user, **kwargs):
2784 """Update the preferences of the specified user.
2785
2786 Args:
2787 user: User PB for the user to update.
2788 keyword_args: dictionary of setting names mapped to new values.
2789 """
2790 if not user or not user.user_id:
2791 raise exceptions.InputException('Cannot update user settings for anon.')
2792
2793 with self.mc.profiler.Phase(
2794 'updating settings for %s with %s' % (self.mc.auth.user_id, kwargs)):
2795 self.services.user.UpdateUserSettings(
2796 self.mc.cnxn, user.user_id, user, **kwargs)
2797
2798 def GetUserPrefs(self, user_id):
2799 """Get the UserPrefs for the specified user."""
2800 # Anon user always has default prefs.
2801 if not user_id:
2802 return user_pb2.UserPrefs(user_id=0)
2803 if user_id != self.mc.auth.user_id:
2804 if not self.mc.perms.HasPerm(permissions.EDIT_OTHER_USERS, None, None):
2805 raise permissions.PermissionException(
2806 'Only site admins may see other users\' preferences')
2807 with self.mc.profiler.Phase('Getting prefs for %s' % user_id):
2808 userprefs = self.services.user.GetUserPrefs(self.mc.cnxn, user_id)
2809
2810 # Hard-coded user prefs for at-risk users that should use "corp mode".
2811 # For some users we mark all of their new issues as Restrict-View-Google.
2812 # Others see a "public issue" warning when commenting on public issues.
2813 # TODO(crbug.com/monorail/5462):
2814 # Remove when user group preferences are implemented.
2815 if framework_bizobj.IsRestrictNewIssuesUser(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 'restrict_new_issues' not in {pref.name for pref in userprefs.prefs}:
2820 userprefs.prefs.append(user_pb2.UserPrefValue(
2821 name='restrict_new_issues', value='true'))
2822 if framework_bizobj.IsPublicIssueNoticeUser(self.mc.cnxn, self.services,
2823 user_id):
2824 # Copy so that cached version is not modified.
2825 userprefs = user_pb2.UserPrefs(user_id=user_id, prefs=userprefs.prefs)
2826 if 'public_issue_notice' not in {pref.name for pref in userprefs.prefs}:
2827 userprefs.prefs.append(user_pb2.UserPrefValue(
2828 name='public_issue_notice', value='true'))
2829
2830 return userprefs
2831
2832 def SetUserPrefs(self, user_id, prefs):
2833 """Set zero or more UserPrefValue for the specified user."""
2834 # Anon user always has default prefs.
2835 if not user_id:
2836 raise exceptions.InputException('Anon cannot have prefs')
2837 if user_id != self.mc.auth.user_id:
2838 if not self.mc.perms.HasPerm(permissions.EDIT_OTHER_USERS, None, None):
2839 raise permissions.PermissionException(
2840 'Only site admins may set other users\' preferences')
2841 for pref in prefs:
2842 error_msg = framework_bizobj.ValidatePref(pref.name, pref.value)
2843 if error_msg:
2844 raise exceptions.InputException(error_msg)
2845 with self.mc.profiler.Phase(
2846 'setting prefs for %s' % (self.mc.auth.user_id)):
2847 self.services.user.SetUserPrefs(self.mc.cnxn, user_id, prefs)
2848
2849 # FUTURE: GetUser()
2850 # FUTURE: UpdateUser()
2851 # FUTURE: DeleteUser()
2852 # FUTURE: ListStarredUsers()
2853
2854 def ExpungeUsers(self, emails, check_perms=True, commit=True):
2855 """Permanently deletes user data and removes remaining user references
2856 for all listed users.
2857
2858 To avoid any executions that might take too long and make the site hang,
2859 a limit clause will be added to some operations. If any user references
2860 are left behind due to the cut-off, the final services.user.ExpungeUsers
2861 will fail because we cannot delete User rows that are still referenced
2862 in other tables. work_env.ExpungeUsers can be called again until all user
2863 references are removed and the final services.user.ExpungeUsers succeeds.
2864 The limit clause will not be applied in operations for tables that contain
2865 user_id or email columns but do not officially Reference the User table.
2866 E.g. SpamVerdict and SpamReport. These user references must all be removed
2867 before the attempt to delete rows from User is made. The limit will also
2868 not be applied for sets of operations where values removed in earlier
2869 operations would have to be known in order for later operations to
2870 succeed. E.g. ExpungeUsersIngroups().
2871 """
2872 if check_perms:
2873 if not permissions.CanExpungeUsers(self.mc):
2874 raise permissions.PermissionException(
2875 'User is not allowed to delete users.')
2876
2877 limit = 10000
2878 user_ids_by_email = self.services.user.LookupExistingUserIDs(
2879 self.mc.cnxn, emails)
2880 user_ids = list(set(user_ids_by_email.values()))
2881 if framework_constants.DELETED_USER_ID in user_ids:
2882 raise exceptions.InputException(
2883 'Reserved deleted_user_id found in deletion request and'
2884 'should not be deleted')
2885 if not user_ids:
2886 logging.info('Emails %r not found in DB. No users deleted', emails)
2887 return
2888
2889 # The operations made in the methods below can be limited.
2890 # We can adjust 'limit' as necessary to avoid timing out.
2891 self.services.issue_star.ExpungeStarsByUsers(
2892 self.mc.cnxn, user_ids, limit=limit)
2893 self.services.project_star.ExpungeStarsByUsers(
2894 self.mc.cnxn, user_ids, limit=limit)
2895 self.services.hotlist_star.ExpungeStarsByUsers(
2896 self.mc.cnxn, user_ids, limit=limit)
2897 self.services.user_star.ExpungeStarsByUsers(
2898 self.mc.cnxn, user_ids, limit=limit)
2899 for user_id in user_ids:
2900 self.services.user_star.ExpungeStars(
2901 self.mc.cnxn, user_id, commit=False, limit=limit)
2902
2903 self.services.features.ExpungeQuickEditsByUsers(
2904 self.mc.cnxn, user_ids, limit=limit)
2905 self.services.features.ExpungeSavedQueriesByUsers(
2906 self.mc.cnxn, user_ids, limit=limit)
2907
2908 self.services.template.ExpungeUsersInTemplates(
2909 self.mc.cnxn, user_ids, limit=limit)
2910 self.services.config.ExpungeUsersInConfigs(
2911 self.mc.cnxn, user_ids, limit=limit)
2912
2913 self.services.project.ExpungeUsersInProjects(
2914 self.mc.cnxn, user_ids, limit=limit)
2915
2916 # The upcoming operations cannot be limited with 'limit'.
2917 # So it's possible that these operations below may lead to timing out
2918 # and ExpungeUsers will have to run again to fully delete all users.
2919 # We commit the above operations here, so if a failure does happen
2920 # below, the second run of ExpungeUsers will have less work to do.
2921 if commit:
2922 self.mc.cnxn.Commit()
2923
2924 affected_issue_ids = self.services.issue.ExpungeUsersInIssues(
2925 self.mc.cnxn, user_ids_by_email, limit=limit)
2926 # Commit ExpungeUsersInIssues here, as it has many operations
2927 # and at least one operation that cannot be limited.
2928 if commit:
2929 self.mc.cnxn.Commit()
2930 self.services.issue.EnqueueIssuesForIndexing(
2931 self.mc.cnxn, affected_issue_ids)
2932
2933 # Spam verdict and report tables have user_id columns that do not
2934 # reference User. No limit will be applied.
2935 self.services.spam.ExpungeUsersInSpam(self.mc.cnxn, user_ids)
2936 if commit:
2937 self.mc.cnxn.Commit()
2938
2939 # No limit will be applied for expunging in hotlists.
2940 self.services.features.ExpungeUsersInHotlists(
2941 self.mc.cnxn, user_ids, self.services.hotlist_star, self.services.user,
2942 self.services.chart)
2943 if commit:
2944 self.mc.cnxn.Commit()
2945
2946 # No limit will be applied for expunging in UserGroups.
2947 self.services.usergroup.ExpungeUsersInGroups(
2948 self.mc.cnxn, user_ids)
2949 if commit:
2950 self.mc.cnxn.Commit()
2951
2952 # No limit will be applied for expunging in FilterRules.
2953 deleted_rules_by_project = self.services.features.ExpungeFilterRulesByUser(
2954 self.mc.cnxn, user_ids_by_email)
2955 rule_strs_by_project = filterrules_helpers.BuildRedactedFilterRuleStrings(
2956 self.mc.cnxn, deleted_rules_by_project, self.services.user, emails)
2957 if commit:
2958 self.mc.cnxn.Commit()
2959
2960 # We will attempt to expunge all given users here. Limiting the users we
2961 # delete should be done before work_env.ExpungeUsers is called.
2962 self.services.user.ExpungeUsers(self.mc.cnxn, user_ids)
2963 if commit:
2964 self.mc.cnxn.Commit()
2965 self.services.usergroup.group_dag.MarkObsolete()
2966
2967 for project_id, filter_rule_strs in rule_strs_by_project.items():
2968 project = self.services.project.GetProject(self.mc.cnxn, project_id)
2969 hostport = framework_helpers.GetHostPort(
2970 project_name=project.project_name)
2971 send_notifications.PrepareAndSendDeletedFilterRulesNotification(
2972 project_id, hostport, filter_rule_strs)
2973
2974 def TotalUsersCount(self):
2975 """Returns the total number of Users in Monorail."""
2976 return self.services.user.TotalUsersCount(self.mc.cnxn)
2977
2978 def GetAllUserEmailsBatch(self, limit=1000, offset=0):
2979 """Returns a list emails that belong to Users in Monorail.
2980
2981 Returns:
2982 A list of emails for Users within Monorail ordered by the user.user_ids.
2983 The list will hold at most [limit] emails and will start at the given
2984 [offset].
2985 """
2986 return self.services.user.GetAllUserEmailsBatch(
2987 self.mc.cnxn, limit=limit, offset=offset)
2988
2989 ### Group methods
2990
2991 # FUTURE: CreateGroup()
2992 # FUTURE: ListGroups()
2993 # FUTURE: UpdateGroup()
2994 # FUTURE: DeleteGroup()
2995
2996 ### Hotlist methods
2997
2998 def CreateHotlist(
2999 self, name, summary, description, editor_ids, issue_ids, is_private,
3000 default_col_spec):
3001 # type: (string, string, string, Collection[int], Collection[int], Boolean,
3002 # string)
3003 """Create a hotlist.
3004
3005 Args:
3006 name: a valid hotlist name.
3007 summary: one-line explanation of the hotlist.
3008 description: one-page explanation of the hotlist.
3009 editor_ids: a list of user IDs for the hotlist editors.
3010 issue_ids: a list of issue IDs for the hotlist issues.
3011 is_private: True if the hotlist can only be viewed by owners and editors.
3012 default_col_spec: default columns for the hotlist's list view.
3013
3014
3015 Returns:
3016 The newly created hotlist.
3017
3018 Raises:
3019 HotlistAlreadyExists: A hotlist with the given name already exists.
3020 InputException: No user is signed in or the proposed name is invalid.
3021 PermissionException: If the user cannot view all of the issues.
3022 """
3023 if not self.mc.auth.user_id:
3024 raise exceptions.InputException('Anon cannot create hotlists.')
3025
3026 # GetIssuesDict checks that the user can view all issues.
3027 self.GetIssuesDict(issue_ids)
3028
3029 if not framework_bizobj.IsValidHotlistName(name):
3030 raise exceptions.InputException(
3031 '%s is not a valid name for a Hotlist' % name)
3032 if self.services.features.LookupHotlistIDs(
3033 self.mc.cnxn, [name], [self.mc.auth.user_id]):
3034 raise features_svc.HotlistAlreadyExists()
3035
3036 with self.mc.profiler.Phase('creating hotlist %s' % name):
3037 hotlist = self.services.features.CreateHotlist(
3038 self.mc.cnxn, name, summary, description, [self.mc.auth.user_id],
3039 editor_ids, issue_ids=issue_ids, is_private=is_private,
3040 default_col_spec=default_col_spec, ts=int(time.time()))
3041
3042 return hotlist
3043
3044 def UpdateHotlist(
3045 self, hotlist_id, hotlist_name=None, summary=None, description=None,
3046 is_private=None, default_col_spec=None, owner_id=None,
3047 add_editor_ids=None):
3048 # type: (int, str, str, str, bool, str, int, Collection[int]) -> None
3049 """Update the given hotlist.
3050
3051 If a new value is None, the value does not get updated.
3052
3053 Args:
3054 hotlist_id: hotlist_id of the hotlist to update.
3055 hotlist_name: proposed new name for the hotlist.
3056 summary: new summary for the hotlist.
3057 description: new description for the hotlist.
3058 is_private: true if hotlist should be updated to private.
3059 default_col_spec: new default columns for hotlist list view.
3060 owner_id: User id of the new owner.
3061 add_editor_ids: User ids to add as editors.
3062
3063 Raises:
3064 InputException: The given hotlist_id is None or proposed new name is not
3065 a valid hotlist name.
3066 NoSuchHotlistException: There is no hotlist with the given ID.
3067 PermissionException: The logged-in user is not allowed to update
3068 this hotlist's settings.
3069 NoSuchUserException: Some proposed editors or owner were not found.
3070 HotlistAlreadyExists: The (proposed new) hotlist owner already owns a
3071 hotlist with the same (proposed) name.
3072 """
3073 hotlist = self.services.features.GetHotlist(
3074 self.mc.cnxn, hotlist_id, use_cache=False)
3075 if not permissions.CanAdministerHotlist(
3076 self.mc.auth.effective_ids, self.mc.perms, hotlist):
3077 raise permissions.PermissionException(
3078 'User is not allowed to update hotlist settings.')
3079
3080 if hotlist.name == hotlist_name:
3081 hotlist_name = None
3082 if hotlist.owner_ids[0] == owner_id:
3083 owner_id = None
3084
3085 if hotlist_name and not framework_bizobj.IsValidHotlistName(hotlist_name):
3086 raise exceptions.InputException(
3087 '"%s" is not a valid hotlist name' % hotlist_name)
3088
3089 # Check (new) owner does not already own a hotlist with the (new) name.
3090 if hotlist_name or owner_id:
3091 owner_ids = [owner_id] if owner_id else None
3092 if self.services.features.LookupHotlistIDs(
3093 self.mc.cnxn, [hotlist_name or hotlist.name],
3094 owner_ids or hotlist.owner_ids):
3095 raise features_svc.HotlistAlreadyExists(
3096 'User already owns a hotlist with name %s' %
3097 hotlist_name or hotlist.name)
3098
3099 # Filter out existing editors and users that will be added as owner
3100 # or is the current owner.
3101 next_owner_id = owner_id or hotlist.owner_ids[0]
3102 if add_editor_ids:
3103 new_editor_ids_set = {user_id for user_id in add_editor_ids if
3104 user_id not in hotlist.editor_ids and
3105 user_id != next_owner_id}
3106 add_editor_ids = list(new_editor_ids_set)
3107
3108 # Validate user change requests.
3109 user_ids = []
3110 if add_editor_ids:
3111 user_ids.extend(add_editor_ids)
3112 else:
3113 add_editor_ids = None
3114 if owner_id:
3115 user_ids.append(owner_id)
3116 if user_ids:
3117 self.services.user.LookupUserEmails(self.mc.cnxn, user_ids)
3118
3119 # Check for other no-op changes.
3120 if summary == hotlist.summary:
3121 summary = None
3122 if description == hotlist.description:
3123 description = None
3124 if is_private == hotlist.is_private:
3125 is_private = None
3126 if default_col_spec == hotlist.default_col_spec:
3127 default_col_spec = None
3128
3129 if ([hotlist_name, summary, description, is_private, default_col_spec,
3130 owner_id, add_editor_ids] ==
3131 [None, None, None, None, None, None, None]):
3132 logging.info('No updates given')
3133 return
3134
3135 if (summary is not None) and (not summary):
3136 raise exceptions.InputException('Hotlist cannot have an empty summary.')
3137 if (description is not None) and (not description):
3138 raise exceptions.InputException(
3139 'Hotlist cannot have an empty description.')
3140 if default_col_spec is not None and not framework_bizobj.IsValidColumnSpec(
3141 default_col_spec):
3142 raise exceptions.InputException(
3143 '"%s" is not a valid column spec' % default_col_spec)
3144
3145 self.services.features.UpdateHotlist(
3146 self.mc.cnxn, hotlist_id, name=hotlist_name, summary=summary,
3147 description=description, is_private=is_private,
3148 default_col_spec=default_col_spec, owner_id=owner_id,
3149 add_editor_ids=add_editor_ids)
3150
3151 # TODO(crbug/monorail/7104): delete UpdateHotlistRoles.
3152
3153 def GetHotlist(self, hotlist_id, use_cache=True):
3154 # int, Optional[Boolean] -> Hotlist
3155 """Return the specified hotlist.
3156
3157 Args:
3158 hotlist_id: int hotlist_id of the hotlist to retrieve.
3159 use_cache: set to false when doing read-modify-write.
3160
3161 Returns:
3162 The specified hotlist.
3163
3164 Raises:
3165 NoSuchHotlistException: There is no hotlist with that ID.
3166 PermissionException: The user is not allowed to view the hotlist.
3167 """
3168 if hotlist_id is None:
3169 raise exceptions.InputException('No hotlist specified')
3170
3171 with self.mc.profiler.Phase('getting hotlist %r' % hotlist_id):
3172 hotlist = self.services.features.GetHotlist(
3173 self.mc.cnxn, hotlist_id, use_cache=use_cache)
3174 self._AssertUserCanViewHotlist(hotlist)
3175 return hotlist
3176
3177 # TODO(crbug/monorail/7104): Remove group_by_spec argument and pre-pend
3178 # values to sort_spec.
3179 def ListHotlistItems(self, hotlist_id, max_items, start, can, sort_spec,
3180 group_by_spec, use_cache=True):
3181 # type: (int, int, int, int, str, str, bool) -> ListResult
3182 """Return a list of HotlistItems for the given hotlist that
3183 are visible by the user.
3184
3185 Args:
3186 hotlist_id: int hotlist_id of the hotlist.
3187 max_items: int the maximum number of HotlistItems we want to return.
3188 start: int start position in the total sorted items.
3189 can: int "canned_query" number to scope the visible issues.
3190 sort_spec: string that lists the sort order.
3191 group_by_spec: string that lists the grouping order.
3192 use_cache: set to false when doing read-modify-write.
3193
3194 Returns:
3195 A work_env.ListResult namedtuple.
3196
3197 Raises:
3198 NoSuchHotlistException: There is no hotlist with that ID.
3199 InputException: `max_items` or `start` are negative values.
3200 PermissionException: The user is not allowed to view the hotlist.
3201 """
3202 hotlist = self.GetHotlist(hotlist_id, use_cache=use_cache)
3203 if start < 0:
3204 raise exceptions.InputException('Invalid `start`: %d' % start)
3205 if max_items < 0:
3206 raise exceptions.InputException('Invalid `max_items`: %d' % max_items)
3207
3208 hotlist_issues = self.services.issue.GetIssues(
3209 self.mc.cnxn, [item.issue_id for item in hotlist.items])
3210 project_ids = hotlist_helpers.GetAllProjectsOfIssues(hotlist_issues)
3211 config_list = hotlist_helpers.GetAllConfigsOfProjects(
3212 self.mc.cnxn, project_ids, self.services)
3213 harmonized_config = tracker_bizobj.HarmonizeConfigs(config_list)
3214
3215 (sorted_issues, _hotlist_items_context,
3216 _users_by_id) = hotlist_helpers.GetSortedHotlistIssues(
3217 self.mc.cnxn, hotlist.items, hotlist_issues, self.mc.auth, can,
3218 sort_spec, group_by_spec, harmonized_config, self.services,
3219 self.mc.profiler)
3220
3221
3222 end = start + max_items
3223 visible_issues = sorted_issues[start:end]
3224 hotlist_items_dict = {item.issue_id: item for item in hotlist.items}
3225 visible_hotlist_items = [hotlist_items_dict.get(issue.issue_id) for
3226 issue in visible_issues]
3227
3228 next_start = None
3229 if end < len(sorted_issues):
3230 next_start = end
3231 return ListResult(visible_hotlist_items, next_start)
3232
3233 def TransferHotlistOwnership(self, hotlist_id, new_owner_id, remain_editor,
3234 use_cache=True, commit=True):
3235 """Transfer ownership of hotlist from current owner to new_owner.
3236
3237 Args:
3238 hotlist_id: int hotlist_id of the hotlist we want to transfer
3239 new_owner_id: user_id of the new owner
3240 remain_editor: True if the old owner should remain on the hotlist as
3241 editor.
3242 use_cache: set to false when doing read-modify-write.
3243 commit: True, if changes should be committed.
3244
3245 Raises:
3246 NoSuchHotlistException: There is not hotlist with the given ID.
3247 PermissionException: The logged-in user is not allowed to change ownership
3248 of the hotlist.
3249 InputException: The proposed new owner already owns a hotlist with the
3250 same name.
3251 """
3252 hotlist = self.services.features.GetHotlist(
3253 self.mc.cnxn, hotlist_id, use_cache=use_cache)
3254 edit_permitted = permissions.CanAdministerHotlist(
3255 self.mc.auth.effective_ids, self.mc.perms, hotlist)
3256 if not edit_permitted:
3257 raise permissions.PermissionException(
3258 'User is not allowed to update hotlist members.')
3259
3260 if self.services.features.LookupHotlistIDs(
3261 self.mc.cnxn, [hotlist.name], [new_owner_id]):
3262 raise exceptions.InputException(
3263 'Proposed new owner already owns a hotlist with this name.')
3264
3265 self.services.features.TransferHotlistOwnership(
3266 self.mc.cnxn, hotlist, new_owner_id, remain_editor, commit=commit)
3267
3268 def RemoveHotlistEditors(self, hotlist_id, remove_editor_ids, use_cache=True):
3269 """Removes editors in a hotlist.
3270
3271 Args:
3272 hotlist_id: the id of the hotlist we want to update
3273 remove_editor_ids: list of user_ids to remove from hotlist editors
3274
3275 Raises:
3276 NoSuchHotlistException: There is not hotlist with the given ID.
3277 PermissionException: The logged-in user is not allowed to administer the
3278 hotlist.
3279 InputException: The users being removed are not editors in the hotlist.
3280 """
3281 hotlist = self.services.features.GetHotlist(
3282 self.mc.cnxn, hotlist_id, use_cache=use_cache)
3283 edit_permitted = permissions.CanAdministerHotlist(
3284 self.mc.auth.effective_ids, self.mc.perms, hotlist)
3285
3286 # check if user is only removing themselves from the hotlist.
3287 # removing linked accounts is allowed but users cannot remove groups
3288 # they are part of from hotlists.
3289 user_or_linked_ids = (
3290 self.mc.auth.user_pb.linked_child_ids + [self.mc.auth.user_id])
3291 if self.mc.auth.user_pb.linked_parent_id:
3292 user_or_linked_ids.append(self.mc.auth.user_pb.linked_parent_id)
3293 removing_self_only = set(remove_editor_ids).issubset(
3294 set(user_or_linked_ids))
3295
3296 if not removing_self_only and not edit_permitted:
3297 raise permissions.PermissionException(
3298 'User is not allowed to remove editors')
3299
3300 if not set(remove_editor_ids).issubset(set(hotlist.editor_ids)):
3301 raise exceptions.InputException(
3302 'Cannot remove users who are not hotlist editors.')
3303
3304 self.services.features.RemoveHotlistEditors(
3305 self.mc.cnxn, hotlist_id, remove_editor_ids)
3306
3307 def DeleteHotlist(self, hotlist_id):
3308 """Delete the given hotlist from the DB.
3309
3310 Args:
3311 hotlist_id (int): The id of the hotlist to delete.
3312
3313 Raises:
3314 NoSuchHotlistException: There is not hotlist with the given ID.
3315 PermissionException: The logged-in user is not allowed to
3316 delete the hotlist.
3317 """
3318 hotlist = self.services.features.GetHotlist(
3319 self.mc.cnxn, hotlist_id, use_cache=False)
3320 edit_permitted = permissions.CanAdministerHotlist(
3321 self.mc.auth.effective_ids, self.mc.perms, hotlist)
3322 if not edit_permitted:
3323 raise permissions.PermissionException(
3324 'User is not allowed to delete hotlist')
3325
3326 self.services.features.ExpungeHotlists(
3327 self.mc.cnxn, [hotlist.hotlist_id], self.services.hotlist_star,
3328 self.services.user, self.services.chart)
3329
3330 def ListHotlistsByUser(self, user_id):
3331 """Return the hotlists for the given user.
3332
3333 Args:
3334 user_id (int): The id of the user to query.
3335
3336 Returns:
3337 The hotlists for the given user.
3338 """
3339 if user_id is None:
3340 raise exceptions.InputException('No user specified')
3341
3342 with self.mc.profiler.Phase('querying hotlists for user %r' % user_id):
3343 hotlists = self.services.features.GetHotlistsByUserID(
3344 self.mc.cnxn, user_id)
3345
3346 # Filter the hotlists that the currently authenticated user cannot see.
3347 result = [
3348 hotlist
3349 for hotlist in hotlists
3350 if permissions.CanViewHotlist(
3351 self.mc.auth.effective_ids, self.mc.perms, hotlist)]
3352 return result
3353
3354 def ListHotlistsByIssue(self, issue_id):
3355 """Return the hotlists the given issue is part of.
3356
3357 Args:
3358 issue_id (int): The id of the issue to query.
3359
3360 Returns:
3361 The hotlists the given issue is part of.
3362 """
3363 # Check that the issue exists and the user has permission to see it.
3364 self.GetIssue(issue_id)
3365
3366 with self.mc.profiler.Phase('querying hotlists for issue %r' % issue_id):
3367 hotlists = self.services.features.GetHotlistsByIssueID(
3368 self.mc.cnxn, issue_id)
3369
3370 # Filter the hotlists that the currently authenticated user cannot see.
3371 result = [
3372 hotlist
3373 for hotlist in hotlists
3374 if permissions.CanViewHotlist(
3375 self.mc.auth.effective_ids, self.mc.perms, hotlist)]
3376 return result
3377
3378 def ListRecentlyVisitedHotlists(self):
3379 """Return the recently visited hotlists for the logged in user.
3380
3381 Returns:
3382 The recently visited hotlists for the given user, or an empty list if no
3383 user is logged in.
3384 """
3385 if not self.mc.auth.user_id:
3386 return []
3387
3388 with self.mc.profiler.Phase(
3389 'get recently visited hotlists for user %r' % self.mc.auth.user_id):
3390 hotlist_ids = self.services.user.GetRecentlyVisitedHotlists(
3391 self.mc.cnxn, self.mc.auth.user_id)
3392 hotlists_by_id = self.services.features.GetHotlists(
3393 self.mc.cnxn, hotlist_ids)
3394 hotlists = [hotlists_by_id[hotlist_id] for hotlist_id in hotlist_ids]
3395
3396 # Filter the hotlists that the currently authenticated user cannot see.
3397 # It might be that some of the hotlists have become private since the user
3398 # last visited them, or the user has lost access for other reasons.
3399 result = [
3400 hotlist
3401 for hotlist in hotlists
3402 if permissions.CanViewHotlist(
3403 self.mc.auth.effective_ids, self.mc.perms, hotlist)]
3404 return result
3405
3406 def ListStarredHotlists(self):
3407 """Return the starred hotlists for the logged in user.
3408
3409 Returns:
3410 The starred hotlists for the logged in user.
3411 """
3412 if not self.mc.auth.user_id:
3413 return []
3414
3415 with self.mc.profiler.Phase(
3416 'get starred hotlists for user %r' % self.mc.auth.user_id):
3417 hotlist_ids = self.services.hotlist_star.LookupStarredItemIDs(
3418 self.mc.cnxn, self.mc.auth.user_id)
3419 hotlists_by_id, _ = self.services.features.GetHotlistsByID(
3420 self.mc.cnxn, hotlist_ids)
3421 hotlists = [hotlists_by_id[hotlist_id] for hotlist_id in hotlist_ids]
3422
3423 # Filter the hotlists that the currently authenticated user cannot see.
3424 # It might be that some of the hotlists have become private since the user
3425 # starred them, or the user has lost access for other reasons.
3426 result = [
3427 hotlist
3428 for hotlist in hotlists
3429 if permissions.CanViewHotlist(
3430 self.mc.auth.effective_ids, self.mc.perms, hotlist)]
3431 return result
3432
3433 def StarHotlist(self, hotlist_id, starred):
3434 """Star or unstar the specified hotlist.
3435
3436 Args:
3437 hotlist_id: int ID of the hotlist to star/unstar.
3438 starred: true to add a star, false to remove it.
3439
3440 Returns:
3441 Nothing.
3442
3443 Raises:
3444 NoSuchHotlistException: There is no hotlist with that ID.
3445 """
3446 if hotlist_id is None:
3447 raise exceptions.InputException('No hotlist specified')
3448
3449 if not self.mc.auth.user_id:
3450 raise exceptions.InputException('No current user specified')
3451
3452 with self.mc.profiler.Phase('(un)starring hotlist %r' % hotlist_id):
3453 # Make sure the hotlist exists and user has permission to see it.
3454 self.GetHotlist(hotlist_id)
3455 self.services.hotlist_star.SetStar(
3456 self.mc.cnxn, hotlist_id, self.mc.auth.user_id, starred)
3457
3458 def IsHotlistStarred(self, hotlist_id):
3459 """Return True if the current hotlist has starred the given hotlist.
3460
3461 Args:
3462 hotlist_id: int ID of the hotlist to check.
3463
3464 Returns:
3465 True if starred.
3466
3467 Raises:
3468 NoSuchHotlistException: There is no hotlist with that ID.
3469 """
3470 if hotlist_id is None:
3471 raise exceptions.InputException('No hotlist specified')
3472
3473 if not self.mc.auth.user_id:
3474 return False
3475
3476 with self.mc.profiler.Phase('checking hotlist star %r' % hotlist_id):
3477 # Make sure the hotlist exists and user has permission to see it.
3478 self.GetHotlist(hotlist_id)
3479 return self.services.hotlist_star.IsItemStarredBy(
3480 self.mc.cnxn, hotlist_id, self.mc.auth.user_id)
3481
3482 def GetHotlistStarCount(self, hotlist_id):
3483 """Return the number of times the hotlist has been starred.
3484
3485 Args:
3486 hotlist_id: int ID of the hotlist to check.
3487
3488 Returns:
3489 The number of times the hotlist has been starred.
3490
3491 Raises:
3492 NoSuchHotlistException: There is no hotlist with that ID.
3493 """
3494 if hotlist_id is None:
3495 raise exceptions.InputException('No hotlist specified')
3496
3497 with self.mc.profiler.Phase('counting stars for hotlist %r' % hotlist_id):
3498 # Make sure the hotlist exists and user has permission to see it.
3499 self.GetHotlist(hotlist_id)
3500 return self.services.hotlist_star.CountItemStars(self.mc.cnxn, hotlist_id)
3501
3502 def CheckHotlistName(self, name):
3503 """Check that a hotlist name is valid and not already in use.
3504
3505 Args:
3506 name: str the hotlist name to check.
3507
3508 Returns:
3509 None if the user can create a hotlist with that name, or a string with the
3510 reason the name can't be used.
3511
3512 Raises:
3513 InputException: The user is not signed in.
3514 """
3515 if not self.mc.auth.user_id:
3516 raise exceptions.InputException('No current user specified')
3517
3518 with self.mc.profiler.Phase('checking hotlist name: %r' % name):
3519 if not framework_bizobj.IsValidHotlistName(name):
3520 return '"%s" is not a valid hotlist name.' % name
3521 if self.services.features.LookupHotlistIDs(
3522 self.mc.cnxn, [name], [self.mc.auth.user_id]):
3523 return 'There is already a hotlist with that name.'
3524
3525 return None
3526
3527 def RemoveIssuesFromHotlists(self, hotlist_ids, issue_ids):
3528 """Remove the issues given in issue_ids from the given hotlists.
3529
3530 Args:
3531 hotlist_ids: a list of hotlist ids to remove the issues from.
3532 issue_ids: a list of issue_ids to be removed.
3533
3534 Raises:
3535 PermissionException: The user has no permission to edit the hotlist.
3536 NoSuchHotlistException: One of the hotlist ids was not found.
3537 """
3538 for hotlist_id in hotlist_ids:
3539 self._AssertUserCanEditHotlist(self.GetHotlist(hotlist_id))
3540
3541 with self.mc.profiler.Phase(
3542 'Removing issues %r from hotlists %r' % (issue_ids, hotlist_ids)):
3543 self.services.features.RemoveIssuesFromHotlists(
3544 self.mc.cnxn, hotlist_ids, issue_ids, self.services.issue,
3545 self.services.chart)
3546
3547 def AddIssuesToHotlists(self, hotlist_ids, issue_ids, note):
3548 """Add the issues given in issue_ids to the given hotlists.
3549
3550 Args:
3551 hotlist_ids: a list of hotlist ids to add the issues to.
3552 issue_ids: a list of issue_ids to be added.
3553 note: a string with a message to record along with the issues.
3554
3555 Raises:
3556 PermissionException: The user has no permission to edit the hotlist.
3557 NoSuchHotlistException: One of the hotlist ids was not found.
3558 """
3559 for hotlist_id in hotlist_ids:
3560 self._AssertUserCanEditHotlist(self.GetHotlist(hotlist_id))
3561
3562 # GetIssuesDict checks that the user can view all issues
3563 self.GetIssuesDict(issue_ids)
3564
3565 added_tuples = [
3566 (issue_id, self.mc.auth.user_id, int(time.time()), note)
3567 for issue_id in issue_ids]
3568
3569 with self.mc.profiler.Phase(
3570 'Removing issues %r from hotlists %r' % (issue_ids, hotlist_ids)):
3571 self.services.features.AddIssuesToHotlists(
3572 self.mc.cnxn, hotlist_ids, added_tuples, self.services.issue,
3573 self.services.chart)
3574
3575 # TODO(crbug/monorai/7104): RemoveHotlistItems and RerankHotlistItems should
3576 # replace RemoveIssuesFromHotlist, AddIssuesToHotlists,
3577 # RemoveIssuesFromHotlists.
3578 # The latter 3 methods are still used in v0 API paths and should be removed
3579 # once those v0 API methods are removed.
3580 def RemoveHotlistItems(self, hotlist_id, remove_issue_ids):
3581 # type: (int, Collection[int]) -> None
3582 """Remove given issues from a hotlist.
3583
3584 Args:
3585 hotlist_id: A hotlist ID of the hotlist to remove issues from.
3586 remove_issue_ids: A list of issue IDs that belong to HotlistItems
3587 we want to remove from the hotlist.
3588
3589 Raises:
3590 NoSuchHotlistException: If the hotlist is not found.
3591 NoSuchIssueException: if an Issue is not found for a given
3592 remove_issue_id.
3593 PermissionException: If the user lacks permissions to edit the hotlist or
3594 view all the given issues.
3595 InputException: If there are ids in `remove_issue_ids` that do not exist
3596 in the hotlist.
3597 """
3598 hotlist = self.GetHotlist(hotlist_id)
3599 self._AssertUserCanEditHotlist(hotlist)
3600 if not remove_issue_ids:
3601 raise exceptions.InputException('`remove_issue_ids` empty.')
3602
3603 item_issue_ids = {item.issue_id for item in hotlist.items}
3604 if not (set(remove_issue_ids).issubset(item_issue_ids)):
3605 raise exceptions.InputException('item(s) not found in hotlist.')
3606
3607 # Raise exception for un-viewable or not found item_issue_ids.
3608 self.GetIssuesDict(item_issue_ids)
3609
3610 self.services.features.UpdateHotlistIssues(
3611 self.mc.cnxn, hotlist_id, [], remove_issue_ids, self.services.issue,
3612 self.services.chart)
3613
3614 def AddHotlistItems(self, hotlist_id, new_issue_ids, target_position):
3615 # type: (int, Sequence[int], int) -> None
3616 """Add given issues to a hotlist.
3617
3618 Args:
3619 hotlist_id: A hotlist ID of the hotlist to add issues to.
3620 new_issue_ids: A list of issue IDs that should belong to new
3621 HotlistItems added to the hotlist. HotlistItems will be added
3622 in the same order the IDs are given in. If some HotlistItems already
3623 exist in the Hotlist, they will not be moved.
3624 target_position: The index, starting at 0, of the new position the
3625 first issue in new_issue_ids should have. This value cannot be greater
3626 than (# of current hotlist.items).
3627
3628 Raises:
3629 PermissionException: If the user lacks permissions to edit the hotlist or
3630 view all the given issues.
3631 NoSuchHotlistException: If the hotlist is not found.
3632 NoSuchIssueException: If an Issue is not found for a given new_issue_id.
3633 InputException: If the target_position or new_issue_ids are not valid.
3634 """
3635 hotlist = self.GetHotlist(hotlist_id)
3636 self._AssertUserCanEditHotlist(hotlist)
3637 if not new_issue_ids:
3638 raise exceptions.InputException('no new issues given to add.')
3639
3640 item_issue_ids = {item.issue_id for item in hotlist.items}
3641 confirmed_new_issue_ids = set(new_issue_ids).difference(item_issue_ids)
3642
3643 # Raise exception for un-viewable or not found item_issue_ids.
3644 self.GetIssuesDict(item_issue_ids)
3645
3646 if confirmed_new_issue_ids:
3647 changed_items = self._GetChangedHotlistItems(
3648 hotlist, list(confirmed_new_issue_ids), target_position)
3649 self.services.features.UpdateHotlistIssues(
3650 self.mc.cnxn, hotlist_id, changed_items, [], self.services.issue,
3651 self.services.chart)
3652
3653 def RerankHotlistItems(self, hotlist_id, moved_issue_ids, target_position):
3654 # type: (int, list(int), int) -> Hotlist
3655 """Rerank HotlistItems of a Hotlist.
3656
3657 This method reranks existing hotlist items to the given target_position.
3658 e.g. For a hotlist with items (a, b, c, d, e), if moved_issue_ids were
3659 [e.issue_id, c.issue_id] and target_position were 0,
3660 the hotlist items would be reranked as (e, c, a, b, d).
3661
3662 Args:
3663 hotlist_id: A hotlist ID of the hotlist to rerank.
3664 moved_issue_ids: A list of issue IDs in the hotlist, to be moved
3665 together, in the order they should have after the reranking.
3666 target_position: The index, starting at 0, of the new position the
3667 first issue in moved_issue_ids should have. This value cannot be greater
3668 than (# of current hotlist.items not being reranked).
3669
3670 Returns:
3671 The updated hotlist.
3672
3673 Raises:
3674 PermissionException: If the user lacks permissions to rerank the hotlist
3675 or view all the given issues.
3676 NoSuchHotlistException: If the hotlist is not found.
3677 NoSuchIssueException: If an Issue is not found for a given moved_issue_id.
3678 InputException: If the target_position or moved_issue_ids are not valid.
3679 """
3680 hotlist = self.GetHotlist(hotlist_id)
3681 self._AssertUserCanEditHotlist(hotlist)
3682 if not moved_issue_ids:
3683 raise exceptions.InputException('`moved_issue_ids` empty.')
3684
3685 item_issue_ids = {item.issue_id for item in hotlist.items}
3686 if not (set(moved_issue_ids).issubset(item_issue_ids)):
3687 raise exceptions.InputException('item(s) not found in hotlist.')
3688
3689 # Raise exception for un-viewable or not found item_issue_ids.
3690 self.GetIssuesDict(item_issue_ids)
3691 changed_items = self._GetChangedHotlistItems(
3692 hotlist, moved_issue_ids, target_position)
3693
3694 if changed_items:
3695 self.services.features.UpdateHotlistIssues(
3696 self.mc.cnxn, hotlist_id, changed_items, [], self.services.issue,
3697 self.services.chart)
3698
3699 return self.GetHotlist(hotlist.hotlist_id)
3700
3701 def _GetChangedHotlistItems(self, hotlist, moved_issue_ids, target_position):
3702 # type: (Hotlist, Sequence(int), int) -> Hotlist
3703 """Returns HotlistItems that are changed after moving existing/new issues.
3704
3705 This returns the list of new HotlistItems and existing HotlistItems
3706 with updated ranks as a result of moving the given issues to the given
3707 target_position. This list may include HotlistItems whose ranks' must be
3708 changed as a result of the `moved_issue_ids`.
3709
3710 Args:
3711 hotlist: The hotlist that owns the HotlistItems.
3712 moved_issue_ids: A sequence of issue IDs for new or existing items of the
3713 Hotlist, to be moved together, in the order they should have after
3714 the change.
3715 target_position: The index, starting at 0, of the new position the
3716 first issue in moved_issue_ids should have. This value cannot be greater
3717 than (# of current hotlist.items not being reranked).
3718
3719 Returns:
3720 The updated hotlist.
3721
3722 Raises:
3723 PermissionException: If the user lacks permissions to rerank the hotlist.
3724 NoSuchHotlistException: If the hotlist is not found.
3725 InputException: If the target_position or moved_issue_ids are not valid.
3726 """
3727 # List[Tuple[issue_id, new_rank]]
3728 changed_item_ranks = rerank_helpers.GetHotlistRerankChanges(
3729 hotlist.items, moved_issue_ids, target_position)
3730
3731 items_by_id = {item.issue_id: item for item in hotlist.items}
3732 changed_items = []
3733 current_time = int(time.time())
3734 for issue_id, rank in changed_item_ranks:
3735 # Get existing item to update or create new item.
3736 item = items_by_id.get(
3737 issue_id,
3738 features_pb2.Hotlist.HotlistItem(
3739 issue_id=issue_id,
3740 adder_id=self.mc.auth.user_id,
3741 date_added=current_time))
3742 item.rank = rank
3743 changed_items.append(item)
3744
3745 return changed_items
3746
3747 # TODO(crbug/monorail/7031): Remove this method
3748 # and corresponding v0 prpc method.
3749 def RerankHotlistIssues(self, hotlist_id, moved_ids, target_id, split_above):
3750 """Rerank the moved issues for the hotlist.
3751
3752 Args:
3753 hotlist_id: an int with the id of the hotlist.
3754 moved_ids: The id of the issues to move.
3755 target_id: the id of the issue to move the issues to.
3756 split_above: True if moved issues should be moved before the target issue.
3757 """
3758 hotlist = self.GetHotlist(hotlist_id)
3759 self._AssertUserCanEditHotlist(hotlist)
3760 hotlist_issue_ids = [item.issue_id for item in hotlist.items]
3761 if not set(moved_ids).issubset(set(hotlist_issue_ids)):
3762 raise exceptions.InputException('The issue to move is not in the hotlist')
3763 if target_id not in hotlist_issue_ids:
3764 raise exceptions.InputException('The target issue is not in the hotlist.')
3765
3766 phase_name = 'Moving issues %r %s issue %d.' % (
3767 moved_ids, 'above' if split_above else 'below', target_id)
3768 with self.mc.profiler.Phase(phase_name):
3769 lower, higher = features_bizobj.SplitHotlistIssueRanks(
3770 target_id, split_above,
3771 [(item.issue_id, item.rank) for item in hotlist.items if
3772 item.issue_id not in moved_ids])
3773 rank_changes = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
3774 if rank_changes:
3775 relations_to_change = {
3776 issue_id: rank for issue_id, rank in rank_changes}
3777 self.services.features.UpdateHotlistItemsFields(
3778 self.mc.cnxn, hotlist_id, new_ranks=relations_to_change)
3779
3780 def UpdateHotlistIssueNote(self, hotlist_id, issue_id, note):
3781 """Update the given issue of the given hotlist with the given note.
3782
3783 Args:
3784 hotlist_id: an int with the id of the hotlist.
3785 issue_id: an int with the id of the issue.
3786 note: a string with a message to record for the given issue.
3787 Raises:
3788 PermissionException: The user has no permission to edit the hotlist.
3789 NoSuchHotlistException: The hotlist id was not found.
3790 InputException: The issue is not part of the hotlist.
3791 """
3792 # Make sure the hotlist exists and we have permission to see and edit it.
3793 hotlist = self.GetHotlist(hotlist_id)
3794 self._AssertUserCanEditHotlist(hotlist)
3795
3796 # Make sure the issue exists and we have permission to see it.
3797 self.GetIssue(issue_id)
3798
3799 # Make sure the issue belongs to the hotlist.
3800 if not any(item.issue_id == issue_id for item in hotlist.items):
3801 raise exceptions.InputException('The issue is not part of the hotlist.')
3802
3803 with self.mc.profiler.Phase(
3804 'Editing note for issue %s in hotlist %s' % (issue_id, hotlist_id)):
3805 new_notes = {issue_id: note}
3806 self.services.features.UpdateHotlistItemsFields(
3807 self.mc.cnxn, hotlist_id, new_notes=new_notes)
3808
3809 def expungeUsersFromStars(self, user_ids):
3810 """Wipes any starred user or user's stars from all star services.
3811
3812 This method will not commit the operation. This method will not
3813 make changes to in-memory data.
3814 """
3815
3816 self.services.project_star.ExpungeStarsByUsers(self.mc.cnxn, user_ids)
3817 self.services.issue_star.ExpungeStarsByUsers(self.mc.cnxn, user_ids)
3818 self.services.hotlist_star.ExpungeStarsByUsers(self.mc.cnxn, user_ids)
3819 self.services.user_star.ExpungeStarsByUsers(self.mc.cnxn, user_ids)
3820 for user_id in user_ids:
3821 self.services.user_star.ExpungeStars(self.mc.cnxn, user_id, commit=False)
3822
3823 # Permissions
3824
3825 # ListFooPermission methods will return the list of permissions in addition to
3826 # the permission to "VIEW",
3827 # that the logged in user has for a given resource_id's resource Foo.
3828 # If the user cannot view Foo, PermissionException will be raised.
3829 # Not all resources will have predefined lists of permissions
3830 # (e.g permissions.HOTLIST_OWNER_PERMISSIONS)
3831 # For most cases, the list of permissions will be created within the
3832 # ListFooPermissions method.
3833
3834 def ListHotlistPermissions(self, hotlist_id):
3835 # type: (int) -> List(str)
3836 """Return the list of permissions the current user has for the hotlist."""
3837 # Permission to view checked in GetHotlist()
3838 hotlist = self.GetHotlist(hotlist_id)
3839 if permissions.CanAdministerHotlist(self.mc.auth.effective_ids,
3840 self.mc.perms, hotlist):
3841 return permissions.HOTLIST_OWNER_PERMISSIONS
3842 if permissions.CanEditHotlist(self.mc.auth.effective_ids, self.mc.perms,
3843 hotlist):
3844 return permissions.HOTLIST_EDITOR_PERMISSIONS
3845 return []
3846
3847 def ListFieldDefPermissions(self, field_id, project_id):
3848 # type:(int, int) -> List[str]
3849 """Return the list of permissions the current user has for the fieldDef."""
3850 project = self.GetProject(project_id)
3851 # TODO(crbug/monorail/7614): The line below was added temporarily while this
3852 # bug is fixed.
3853 self.mc.LookupLoggedInUserPerms(project)
3854 field = self.GetFieldDef(field_id, project)
3855 if permissions.CanEditFieldDef(self.mc.auth.effective_ids, self.mc.perms,
3856 project, field):
3857 return [permissions.EDIT_FIELD_DEF, permissions.EDIT_FIELD_DEF_VALUE]
3858 if permissions.CanEditValueForFieldDef(self.mc.auth.effective_ids,
3859 self.mc.perms, project, field):
3860 return [permissions.EDIT_FIELD_DEF_VALUE]
3861 return []