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