Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(116)

Side by Side Diff: appengine/monorail/tracker/tracker_helpers.py

Issue 1868553004: Open Source Monorail (Closed) Base URL: https://chromium.googlesource.com/infra/infra.git@master
Patch Set: Rebase Created 4 years, 8 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
(Empty)
1 # Copyright 2016 The Chromium Authors. All rights reserved.
2 # Use of this source code is govered 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 """Helper functions and classes used by the Monorail Issue Tracker pages.
7
8 This module has functions that are reused in multiple servlets or
9 other modules.
10 """
11
12 import collections
13 import logging
14 import re
15 import urllib
16
17 import settings
18
19 from framework import filecontent
20 from framework import framework_bizobj
21 from framework import framework_constants
22 from framework import framework_helpers
23 from framework import framework_views
24 from framework import monorailrequest
25 from framework import permissions
26 from framework import sorting
27 from framework import template_helpers
28 from framework import urls
29 from tracker import tracker_bizobj
30 from tracker import tracker_constants
31
32
33 # HTML input field names for blocked on and blocking issue refs.
34 BLOCKED_ON = 'blocked_on'
35 BLOCKING = 'blocking'
36
37 # This string is used in HTML form element names to identify custom fields.
38 # E.g., a value for a custom field with field_id 12 would be specified in
39 # an HTML form element with name="custom_12".
40 _CUSTOM_FIELD_NAME_PREFIX = 'custom_'
41
42 # When the attachment quota gets within 1MB of the limit, stop offering
43 # users the option to attach files.
44 _SOFT_QUOTA_LEEWAY = 1024 * 1024
45
46 # Accessors for sorting built-in fields.
47 SORTABLE_FIELDS = {
48 'project': lambda issue: issue.project_name,
49 'id': lambda issue: issue.local_id,
50 'owner': tracker_bizobj.GetOwnerId,
51 'reporter': lambda issue: issue.reporter_id,
52 'component': lambda issue: issue.component_ids,
53 'cc': tracker_bizobj.GetCcIds,
54 'summary': lambda issue: issue.summary.lower(),
55 'stars': lambda issue: issue.star_count,
56 'attachments': lambda issue: issue.attachment_count,
57 'opened': lambda issue: issue.opened_timestamp,
58 'closed': lambda issue: issue.closed_timestamp,
59 'modified': lambda issue: issue.modified_timestamp,
60 'status': tracker_bizobj.GetStatus,
61 'blocked': lambda issue: bool(issue.blocked_on_iids),
62 'blockedon': lambda issue: issue.blocked_on_iids or sorting.MAX_STRING,
63 'blocking': lambda issue: issue.blocking_iids or sorting.MAX_STRING,
64 }
65
66
67 # Namedtuples that hold data parsed from post_data.
68 ParsedComponents = collections.namedtuple(
69 'ParsedComponents', 'entered_str, paths, paths_remove')
70 ParsedFields = collections.namedtuple(
71 'ParsedFields', 'vals, vals_remove, fields_clear')
72 ParsedUsers = collections.namedtuple(
73 'ParsedUsers', 'owner_username, owner_id, cc_usernames, '
74 'cc_usernames_remove, cc_ids, cc_ids_remove')
75 ParsedBlockers = collections.namedtuple(
76 'ParsedBlockers', 'entered_str, iids, dangling_refs')
77 ParsedIssue = collections.namedtuple(
78 'ParsedIssue', 'summary, comment, status, users, labels, '
79 'labels_remove, components, fields, template_name, attachments, '
80 'blocked_on, blocking')
81
82
83 def ParseIssueRequest(cnxn, post_data, services, errors, default_project_name):
84 """Parse all the possible arguments out of the request.
85
86 Args:
87 cnxn: connection to SQL database.
88 post_data: HTML form information.
89 services: Connections to persistence layer.
90 errors: object to accumulate validation error info.
91 default_project_name: name of the project that contains the issue.
92
93 Returns:
94 A namedtuple with all parsed information. User IDs are looked up, but
95 also the strings are returned to allow bouncing the user back to correct
96 any errors.
97 """
98 summary = post_data.get('summary', '')
99 comment = post_data.get('comment', '')
100 status = post_data.get('status', '')
101 template_name = post_data.get('template_name', '')
102 component_str = post_data.get('components', '')
103 label_strs = post_data.getall('label')
104
105 comp_paths, comp_paths_remove = _ClassifyPlusMinusItems(
106 re.split('[,;\s]+', component_str))
107 parsed_components = ParsedComponents(
108 component_str, comp_paths, comp_paths_remove)
109 labels, labels_remove = _ClassifyPlusMinusItems(label_strs)
110 parsed_fields = _ParseIssueRequestFields(post_data)
111 # TODO(jrobbins): change from numbered fields to a multi-valued field.
112 attachments = _ParseIssueRequestAttachments(post_data)
113 parsed_users = _ParseIssueRequestUsers(cnxn, post_data, services)
114 parsed_blocked_on = _ParseBlockers(
115 cnxn, post_data, services, errors, default_project_name, BLOCKED_ON)
116 parsed_blocking = _ParseBlockers(
117 cnxn, post_data, services, errors, default_project_name, BLOCKING)
118
119 parsed_issue = ParsedIssue(
120 summary, comment, status, parsed_users, labels, labels_remove,
121 parsed_components, parsed_fields, template_name, attachments,
122 parsed_blocked_on, parsed_blocking)
123 return parsed_issue
124
125
126 def _ClassifyPlusMinusItems(add_remove_list):
127 """Classify the given plus-or-minus items into add and remove lists."""
128 add_remove_set = {s.strip() for s in add_remove_list}
129 add_strs = [s for s in add_remove_set if s and not s.startswith('-')]
130 remove_strs = [s[1:] for s in add_remove_set if s[1:] and s.startswith('-')]
131 return add_strs, remove_strs
132
133
134 def _ParseIssueRequestFields(post_data):
135 """Iterate over post_data and return custom field values found in it."""
136 field_val_strs = {}
137 field_val_strs_remove = {}
138 for key in post_data.keys():
139 if key.startswith(_CUSTOM_FIELD_NAME_PREFIX):
140 val_strs = [v for v in post_data.getall(key) if v]
141 if val_strs:
142 field_id = int(key[len(_CUSTOM_FIELD_NAME_PREFIX):])
143 if post_data.get('op_' + key) == 'remove':
144 field_val_strs_remove[field_id] = val_strs
145 else:
146 field_val_strs[field_id] = val_strs
147
148 fields_clear = []
149 op_prefix = 'op_' + _CUSTOM_FIELD_NAME_PREFIX
150 for op_key in post_data.keys():
151 if op_key.startswith(op_prefix):
152 if post_data.get(op_key) == 'clear':
153 field_id = int(op_key[len(op_prefix):])
154 fields_clear.append(field_id)
155
156 return ParsedFields(field_val_strs, field_val_strs_remove, fields_clear)
157
158
159 def _ParseIssueRequestAttachments(post_data):
160 """Extract and clean-up any attached files from the post data.
161
162 Args:
163 post_data: dict w/ values from the user's HTTP POST form data.
164
165 Returns:
166 [(filename, filecontents, mimetype), ...] with items for each attachment.
167 """
168 # TODO(jrobbins): change from numbered fields to a multi-valued field.
169 attachments = []
170 for i in xrange(1, 16):
171 if 'file%s' % i in post_data:
172 item = post_data['file%s' % i]
173 if isinstance(item, basestring):
174 continue
175 if '\\' in item.filename: # IE insists on giving us the whole path.
176 item.filename = item.filename[item.filename.rindex('\\') + 1:]
177 if not item.filename:
178 continue # Skip any FILE fields that were not filled in.
179 attachments.append((
180 item.filename, item.value,
181 filecontent.GuessContentTypeFromFilename(item.filename)))
182
183 return attachments
184
185
186 def _ParseIssueRequestUsers(cnxn, post_data, services):
187 """Extract usernames from the POST data, categorize them, and look up IDs.
188
189 Args:
190 cnxn: connection to SQL database.
191 post_data: dict w/ data from the HTTP POST.
192 services: Services.
193
194 Returns:
195 A namedtuple (owner_username, owner_id, cc_usernames, cc_usernames_remove,
196 cc_ids, cc_ids_remove), containing:
197 - issue owner's name and user ID, if any
198 - the list of all cc'd usernames
199 - the user IDs to add or remove from the issue CC list.
200 Any of these user IDs may be None if the corresponding username
201 or email address is invalid.
202 """
203 # Get the user-entered values from post_data.
204 cc_username_str = post_data.get('cc', '')
205 owner_email = post_data.get('owner', '').strip()
206
207 cc_usernames, cc_usernames_remove = _ClassifyPlusMinusItems(
208 re.split('[,;\s]+', cc_username_str))
209
210 # Figure out the email addresses to lookup and do the lookup.
211 emails_to_lookup = cc_usernames + cc_usernames_remove
212 if owner_email:
213 emails_to_lookup.append(owner_email)
214 all_user_ids = services.user.LookupUserIDs(
215 cnxn, emails_to_lookup, autocreate=True)
216 if owner_email:
217 owner_id = all_user_ids.get(owner_email)
218 else:
219 owner_id = framework_constants.NO_USER_SPECIFIED
220
221 # Lookup the user IDs of the Cc addresses to add or remove.
222 cc_ids = [all_user_ids.get(cc) for cc in cc_usernames]
223 cc_ids_remove = [all_user_ids.get(cc) for cc in cc_usernames_remove]
224
225 return ParsedUsers(owner_email, owner_id, cc_usernames, cc_usernames_remove,
226 cc_ids, cc_ids_remove)
227
228
229 def _ParseBlockers(cnxn, post_data, services, errors, default_project_name,
230 field_name):
231 """Parse input for issues that the current issue is blocking/blocked on.
232
233 Args:
234 cnxn: connection to SQL database.
235 post_data: dict w/ values from the user's HTTP POST.
236 services: connections to backend services.
237 errors: object to accumulate validation error info.
238 default_project_name: name of the project that contains the issue.
239 field_name: string HTML input field name, e.g., BLOCKED_ON or BLOCKING.
240
241 Returns:
242 A namedtuple with the user input string, and a list of issue IDs.
243 """
244 entered_str = post_data.get(field_name, '').strip()
245 blocker_iids = []
246 dangling_ref_tuples = []
247
248 issue_ref = None
249 for ref_str in re.split('[,;\s]+', entered_str):
250 try:
251 issue_ref = tracker_bizobj.ParseIssueRef(ref_str)
252 except ValueError:
253 setattr(errors, field_name, 'Invalid issue ID %s' % ref_str.strip())
254 break
255
256 if not issue_ref:
257 continue
258
259 blocker_project_name, blocker_issue_id = issue_ref
260 if not blocker_project_name:
261 blocker_project_name = default_project_name
262
263 # Detect and report if the same issue was specified.
264 current_issue_id = int(post_data.get('id')) if post_data.get('id') else -1
265 if (blocker_issue_id == current_issue_id and
266 blocker_project_name == default_project_name):
267 setattr(errors, field_name, 'Cannot be %s the same issue' % field_name)
268 break
269
270 ref_projects = services.project.GetProjectsByName(
271 cnxn, set([blocker_project_name]))
272 blocker_iid = services.issue.ResolveIssueRefs(
273 cnxn, ref_projects, default_project_name, [issue_ref])
274 if not blocker_iid:
275 if blocker_project_name in settings.recognized_codesite_projects:
276 # We didn't find the issue, but it had a explicitly-specified project
277 # which we know is on Codesite. Allow it as a dangling reference.
278 dangling_ref_tuples.append(issue_ref)
279 continue
280 else:
281 # Otherwise, it doesn't exist, so report it.
282 setattr(errors, field_name, 'Invalid issue ID %s' % ref_str.strip())
283 break
284 if blocker_iid[0] not in blocker_iids:
285 blocker_iids.extend(blocker_iid)
286
287 blocker_iids.sort()
288 dangling_ref_tuples.sort()
289 return ParsedBlockers(entered_str, blocker_iids, dangling_ref_tuples)
290
291
292 def IsValidIssueOwner(cnxn, project, owner_id, services):
293 """Return True if the given user ID can be an issue owner.
294
295 Args:
296 cnxn: connection to SQL database.
297 project: the current Project PB.
298 owner_id: the user ID of the proposed issue owner.
299 services: connections to backends.
300
301 It is OK to have 0 for the owner_id, that simply means that the issue is
302 unassigned.
303
304 Returns:
305 A pair (valid, err_msg). valid is True if the given user ID can be an
306 issue owner. err_msg is an error message string to display to the user
307 if valid == False, and is None if valid == True.
308 """
309 # An issue is always allowed to have no owner specified.
310 if owner_id == framework_constants.NO_USER_SPECIFIED:
311 return True, None
312
313 auth = monorailrequest.AuthData.FromUserID(cnxn, owner_id, services)
314 if not framework_bizobj.UserIsInProject(project, auth.effective_ids):
315 return False, 'Issue owner must be a project member'
316
317 group_ids = services.usergroup.DetermineWhichUserIDsAreGroups(
318 cnxn, [owner_id])
319 if owner_id in group_ids:
320 return False, 'Issue owner cannot be a user group'
321
322 return True, None
323
324
325 def GetAllowedOpenedAndClosedIssues(mr, issue_ids, services):
326 """Get filtered lists of open and closed issues identified by issue_ids.
327
328 The function then filters the results to only the issues that the user
329 is allowed to view. E.g., we only auto-link to issues that the user
330 would be able to view if he/she clicked the link.
331
332 Args:
333 mr: commonly used info parsed from the request.
334 issue_ids: list of int issue IDs for the target issues.
335 services: connection to issue, config, and project persistence layers.
336
337 Returns:
338 Two lists of issues that the user is allowed to view: one for open
339 issues and one for closed issues.
340 """
341 open_issues, closed_issues = services.issue.GetOpenAndClosedIssues(
342 mr.cnxn, issue_ids)
343 project_dict = GetAllIssueProjects(
344 mr.cnxn, open_issues + closed_issues, services.project)
345 config_dict = services.config.GetProjectConfigs(mr.cnxn, project_dict.keys())
346 allowed_open_issues = FilterOutNonViewableIssues(
347 mr.auth.effective_ids, mr.auth.user_pb, project_dict, config_dict,
348 open_issues)
349 allowed_closed_issues = FilterOutNonViewableIssues(
350 mr.auth.effective_ids, mr.auth.user_pb, project_dict, config_dict,
351 closed_issues)
352
353 return allowed_open_issues, allowed_closed_issues
354
355
356 def GetAllowedOpenAndClosedRelatedIssues(services, mr, issue):
357 """Retrieve the issues that the given issue references.
358
359 Related issues are the blocked on, blocking, and merged-into issues.
360 This function also filters the results to only the issues that the
361 user is allowed to view.
362
363 Args:
364 services: connection to issue, config, and project persistence layers.
365 mr: commonly used info parsed from the request.
366 issue: the Issue PB being viewed.
367
368 Returns:
369 Two dictionaries of issues that the user is allowed to view: one for open
370 issues and one for closed issues.
371 """
372 related_issue_iids = list(issue.blocked_on_iids) + list(issue.blocking_iids)
373 if issue.merged_into:
374 related_issue_iids.append(issue.merged_into)
375 open_issues, closed_issues = GetAllowedOpenedAndClosedIssues(
376 mr, related_issue_iids, services)
377 open_dict = {issue.issue_id: issue for issue in open_issues}
378 closed_dict = {issue.issue_id: issue for issue in closed_issues}
379 return open_dict, closed_dict
380
381
382 def MakeViewsForUsersInIssues(cnxn, issue_list, user_service, omit_ids=None):
383 """Lookup all the users involved in any of the given issues.
384
385 Args:
386 cnxn: connection to SQL database.
387 issue_list: list of Issue PBs from a result query.
388 user_service: Connection to User backend storage.
389 omit_ids: a list of user_ids to omit, e.g., because we already have them.
390
391 Returns:
392 A dictionary {user_id: user_view,...} for all the users involved
393 in the given issues.
394 """
395 issue_participant_id_set = tracker_bizobj.UsersInvolvedInIssues(issue_list)
396 if omit_ids:
397 issue_participant_id_set.difference_update(omit_ids)
398
399 # TODO(jrobbins): consider caching View objects as well.
400 users_by_id = framework_views.MakeAllUserViews(
401 cnxn, user_service, issue_participant_id_set)
402
403 return users_by_id
404
405
406 def FormatIssueListURL(
407 mr, config, absolute=True, project_names=None, **kwargs):
408 """Format a link back to list view as configured by user."""
409 if project_names is None:
410 project_names = [mr.project_name]
411 if not tracker_constants.JUMP_RE.match(mr.query):
412 if mr.query:
413 kwargs['q'] = mr.query
414 if mr.can and mr.can != 2:
415 kwargs['can'] = mr.can
416 def_col_spec = config.default_col_spec
417 if mr.col_spec and mr.col_spec != def_col_spec:
418 kwargs['colspec'] = mr.col_spec
419 if mr.sort_spec:
420 kwargs['sort'] = mr.sort_spec
421 if mr.group_by_spec:
422 kwargs['groupby'] = mr.group_by_spec
423 if mr.start:
424 kwargs['start'] = mr.start
425 if mr.num != tracker_constants.DEFAULT_RESULTS_PER_PAGE:
426 kwargs['num'] = mr.num
427
428 if len(project_names) == 1:
429 url = '/p/%s%s' % (project_names[0], urls.ISSUE_LIST)
430 else:
431 url = urls.ISSUE_LIST
432 kwargs['projects'] = ','.join(sorted(project_names))
433
434 param_strings = ['%s=%s' % (k, urllib.quote((u'%s' % v).encode('utf-8')))
435 for k, v in kwargs.iteritems()]
436 if param_strings:
437 url += '?' + '&'.join(sorted(param_strings))
438 if absolute:
439 url = '%s://%s%s' % (mr.request.scheme, mr.request.host, url)
440
441 return url
442
443
444 def FormatRelativeIssueURL(project_name, path, **kwargs):
445 """Format a URL to get to an issue in the named project.
446
447 Args:
448 project_name: string name of the project containing the issue.
449 path: string servlet path, e.g., from framework/urls.py.
450 **kwargs: additional query-string parameters to include in the URL.
451
452 Returns:
453 A URL string.
454 """
455 return framework_helpers.FormatURL(
456 None, '/p/%s%s' % (project_name, path), **kwargs)
457
458
459 def ComputeNewQuotaBytesUsed(project, attachments):
460 """Add the given attachments to the project's attachment quota usage.
461
462 Args:
463 project: Project PB for the project being updated.
464 attachments: a list of attachments being added to an issue.
465
466 Returns:
467 The new number of bytes used.
468
469 Raises:
470 OverAttachmentQuota: If project would go over quota.
471 """
472 total_attach_size = 0L
473 for _filename, content, _mimetype in attachments:
474 total_attach_size += len(content)
475
476 new_bytes_used = project.attachment_bytes_used + total_attach_size
477 quota = (project.attachment_quota or
478 tracker_constants.ISSUE_ATTACHMENTS_QUOTA_HARD)
479 if new_bytes_used > quota:
480 raise OverAttachmentQuota(new_bytes_used - quota)
481 return new_bytes_used
482
483
484 def IsUnderSoftAttachmentQuota(project):
485 """Check the project's attachment quota against the soft quota limit.
486
487 If there is a custom quota on the project, this will check against
488 that instead of the system-wide default quota.
489
490 Args:
491 project: Project PB for the project to examine
492
493 Returns:
494 True if the project is under quota, false otherwise.
495 """
496 quota = tracker_constants.ISSUE_ATTACHMENTS_QUOTA_SOFT
497 if project.attachment_quota:
498 quota = project.attachment_quota - _SOFT_QUOTA_LEEWAY
499
500 return project.attachment_bytes_used < quota
501
502
503 def GetAllIssueProjects(cnxn, issues, project_service):
504 """Get all the projects that the given issues belong to.
505
506 Args:
507 cnxn: connection to SQL database.
508 issues: list of issues, which may come from different projects.
509 project_service: connection to project persistence layer.
510
511 Returns:
512 A dictionary {project_id: project} of all the projects that
513 any of the given issues belongs to.
514 """
515 needed_project_ids = {issue.project_id for issue in issues}
516 project_dict = project_service.GetProjects(cnxn, needed_project_ids)
517 return project_dict
518
519
520 def GetPermissionsInAllProjects(user, effective_ids, projects):
521 """Look up the permissions for the given user in each project."""
522 return {
523 project.project_id:
524 permissions.GetPermissions(user, effective_ids, project)
525 for project in projects}
526
527
528 def FilterOutNonViewableIssues(
529 effective_ids, user, project_dict, config_dict, issues):
530 """Return a filtered list of issues that the user can view."""
531 perms_dict = GetPermissionsInAllProjects(
532 user, effective_ids, project_dict.values())
533
534 denied_project_ids = {
535 pid for pid, p in project_dict.iteritems()
536 if not permissions.CanView(effective_ids, perms_dict[pid], p, [])}
537
538 results = []
539 for issue in issues:
540 if issue.deleted or issue.project_id in denied_project_ids:
541 continue
542
543 if not permissions.HasRestrictions(issue):
544 may_view = True
545 else:
546 perms = perms_dict[issue.project_id]
547 project = project_dict[issue.project_id]
548 config = config_dict.get(issue.project_id, config_dict.get('harmonized'))
549 granted_perms = tracker_bizobj.GetGrantedPerms(
550 issue, effective_ids, config)
551 may_view = permissions.CanViewRestrictedIssueInVisibleProject(
552 effective_ids, perms, project, issue, granted_perms=granted_perms)
553
554 if may_view:
555 results.append(issue)
556
557 return results
558
559
560 def MeansOpenInProject(status, config):
561 """Return true if this status means that the issue is still open.
562
563 Args:
564 status: issue status string. E.g., 'New'.
565 config: the config of the current project.
566
567 Returns:
568 Boolean True if the status means that the issue is open.
569 """
570 status_lower = status.lower()
571
572 # iterate over the list of known statuses for this project
573 # return true if we find a match that declares itself to be open
574 for wks in config.well_known_statuses:
575 if wks.status.lower() == status_lower:
576 return wks.means_open
577
578 # if we didn't find a matching status we consider the status open
579 return True
580
581
582 def IsNoisy(num_comments, num_starrers):
583 """Return True if this is a "noisy" issue that would send a ton of emails.
584
585 The rule is that a very active issue with a large number of comments
586 and starrers will only send notification when a comment (or change)
587 is made by a project member.
588
589 Args:
590 num_comments: int number of comments on issue so far.
591 num_starrers: int number of users who starred the issue.
592
593 Returns:
594 True if we will not bother starrers with an email notification for
595 changes made by non-members.
596 """
597 return (num_comments >= tracker_constants.NOISY_ISSUE_COMMENT_COUNT and
598 num_starrers >= tracker_constants.NOISY_ISSUE_STARRER_COUNT)
599
600
601 def MergeCCsAndAddComment(
602 services, mr, issue, merge_into_project, merge_into_issue):
603 """Modify the CC field of the target issue and add a comment to it."""
604 return MergeCCsAndAddCommentMultipleIssues(
605 services, mr, [issue], merge_into_project, merge_into_issue)
606
607
608 def MergeCCsAndAddCommentMultipleIssues(
609 services, mr, issues, merge_into_project, merge_into_issue):
610 """Modify the CC field of the target issue and add a comment to it."""
611 merge_into_restricts = permissions.GetRestrictions(merge_into_issue)
612 merge_comment = ''
613 source_cc = set()
614 for issue in issues:
615 if issue.project_name == merge_into_issue.project_name:
616 issue_ref_str = '%d' % issue.local_id
617 else:
618 issue_ref_str = '%s:%d' % (issue.project_name, issue.local_id)
619 if merge_comment:
620 merge_comment += '\n'
621 merge_comment += 'Issue %s has been merged into this issue.' % issue_ref_str
622
623 if permissions.HasRestrictions(issue, perm='View'):
624 restricts = permissions.GetRestrictions(issue)
625 # Don't leak metadata from a restricted issue.
626 if (issue.project_id != merge_into_issue.project_id or
627 set(restricts) != set(merge_into_restricts)):
628 # TODO(jrobbins): user option to choose to merge CC or not.
629 # TODO(jrobbins): add a private comment rather than nothing
630 continue
631
632 source_cc.update(issue.cc_ids)
633 if issue.owner_id: # owner_id == 0 means no owner
634 source_cc.update([issue.owner_id])
635
636 target_cc = merge_into_issue.cc_ids
637 add_cc = [user_id for user_id in source_cc if user_id not in target_cc]
638
639 services.issue.ApplyIssueComment(
640 mr.cnxn, services, mr.auth.user_id,
641 merge_into_project.project_id, merge_into_issue.local_id,
642 merge_into_issue.summary, merge_into_issue.status,
643 merge_into_issue.owner_id, list(target_cc) + list(add_cc),
644 merge_into_issue.labels, merge_into_issue.field_values,
645 merge_into_issue.component_ids, merge_into_issue.blocked_on_iids,
646 merge_into_issue.blocking_iids, merge_into_issue.dangling_blocked_on_refs,
647 merge_into_issue.dangling_blocking_refs, merge_into_issue.merged_into,
648 index_now=False, comment=merge_comment)
649
650 return merge_comment
651
652
653 def GetAttachmentIfAllowed(mr, services):
654 """Retrieve the requested attachment, or raise an appropriate exception.
655
656 Args:
657 mr: commonly used info parsed from the request.
658 services: connections to backend services.
659
660 Returns:
661 The requested Attachment PB, and the Issue that it belongs to.
662
663 Raises:
664 NoSuchAttachmentException: attachment was not found or was marked deleted.
665 NoSuchIssueException: issue that contains attachment was not found.
666 PermissionException: the user is not allowed to view the attachment.
667 """
668 attachment = None
669
670 attachment, cid, issue_id = services.issue.GetAttachmentAndContext(
671 mr.cnxn, mr.aid)
672
673 issue = services.issue.GetIssue(mr.cnxn, issue_id)
674 config = services.config.GetProjectConfig(mr.cnxn, issue.project_id)
675 granted_perms = tracker_bizobj.GetGrantedPerms(
676 issue, mr.auth.effective_ids, config)
677 permit_view = permissions.CanViewIssue(
678 mr.auth.effective_ids, mr.perms, mr.project, issue,
679 granted_perms=granted_perms)
680 if not permit_view:
681 raise permissions.PermissionException('Cannot view attachment\'s issue')
682
683 comment = services.issue.GetComment(mr.cnxn, cid)
684 can_delete = False
685 if mr.auth.user_id and mr.project:
686 can_delete = permissions.CanDelete(
687 mr.auth.user_id, mr.auth.effective_ids, mr.perms,
688 comment.deleted_by, comment.user_id, mr.project,
689 permissions.GetRestrictions(issue), granted_perms=granted_perms)
690 if comment.deleted_by and not can_delete:
691 raise permissions.PermissionException('Cannot view attachment\'s comment')
692
693 return attachment, issue
694
695
696 def LabelsMaskedByFields(config, field_names, trim_prefix=False):
697 """Return a list of EZTItems for labels that would be masked by fields."""
698 return _LabelsMaskedOrNot(config, field_names, trim_prefix=trim_prefix)
699
700
701 def LabelsNotMaskedByFields(config, field_names, trim_prefix=False):
702 """Return a list of EZTItems for labels that would not be masked."""
703 return _LabelsMaskedOrNot(
704 config, field_names, invert=True, trim_prefix=trim_prefix)
705
706
707 def _LabelsMaskedOrNot(config, field_names, invert=False, trim_prefix=False):
708 """Return EZTItems for labels that'd be masked. Or not, when invert=True."""
709 field_names = [fn.lower() for fn in field_names]
710 result = []
711 for wkl in config.well_known_labels:
712 masked_by = tracker_bizobj.LabelIsMaskedByField(wkl.label, field_names)
713 if (masked_by and not invert) or (not masked_by and invert):
714 display_name = wkl.label
715 if trim_prefix:
716 display_name = display_name[len(masked_by) + 1:]
717 result.append(template_helpers.EZTItem(
718 name=display_name,
719 name_padded=display_name.ljust(20),
720 commented='#' if wkl.deprecated else '',
721 docstring=wkl.label_docstring,
722 docstring_short=template_helpers.FitUnsafeText(
723 wkl.label_docstring, 40),
724 idx=len(result)))
725
726 return result
727
728
729 def LookupComponentIDs(component_paths, config, errors):
730 """Look up the IDs of the specified components in the given config."""
731 component_ids = []
732 for path in component_paths:
733 if not path:
734 continue
735 cd = tracker_bizobj.FindComponentDef(path, config)
736 if cd:
737 component_ids.append(cd.component_id)
738 else:
739 errors.components = 'Unknown component %s' % path
740
741 return component_ids
742
743
744 def ParseAdminUsers(cnxn, admins_str, user_service):
745 """Parse all the usernames of component, field, or template admins."""
746 admins, _remove = _ClassifyPlusMinusItems(
747 re.split('[,;\s]+', admins_str))
748 all_user_ids = user_service.LookupUserIDs(cnxn, admins, autocreate=True)
749 admin_ids = [all_user_ids[username] for username in admins]
750 return admin_ids, admins_str
751
752
753 def FilterIssueTypes(config):
754 """Return a list of well-known issue types."""
755 well_known_issue_types = []
756 for wk_label in config.well_known_labels:
757 if wk_label.label.lower().startswith('type-'):
758 _, type_name = wk_label.label.split('-', 1)
759 well_known_issue_types.append(type_name)
760
761 return well_known_issue_types
762
763
764 def ParseMergeFields(
765 cnxn, services, project_name, post_data, status, config, issue, errors):
766 """Parse info that identifies the issue to merge into, if any."""
767 merge_into_text = ''
768 merge_into_ref = None
769 merge_into_issue = None
770
771 if status not in config.statuses_offer_merge:
772 return '', None
773
774 merge_into_text = post_data.get('merge_into', '')
775 if merge_into_text:
776 try:
777 merge_into_ref = tracker_bizobj.ParseIssueRef(merge_into_text)
778 except ValueError:
779 logging.info('merge_into not an int: %r', merge_into_text)
780 errors.merge_into_id = 'Please enter a valid issue ID'
781
782 if not merge_into_ref:
783 errors.merge_into_id = 'Please enter an issue ID'
784 return merge_into_text, None
785
786 merge_into_project_name, merge_into_id = merge_into_ref
787 if (merge_into_id == issue.local_id and
788 (merge_into_project_name == project_name or
789 not merge_into_project_name)):
790 logging.info('user tried to merge issue into itself: %r', merge_into_ref)
791 errors.merge_into_id = 'Cannot merge issue into itself'
792 return merge_into_text, None
793
794 project = services.project.GetProjectByName(
795 cnxn, merge_into_project_name or project_name)
796 try:
797 merge_into_issue = services.issue.GetIssueByLocalID(
798 cnxn, project.project_id, merge_into_id)
799 except Exception:
800 logging.info('merge_into issue not found: %r', merge_into_ref)
801 errors.merge_into_id = 'No such issue'
802 return merge_into_text, None
803
804 return merge_into_text, merge_into_issue
805
806
807 def GetNewIssueStarrers(cnxn, services, issue_id, merge_into_iid):
808 """Get starrers of current issue who have not starred the target issue."""
809 source_starrers = services.issue_star.LookupItemStarrers(cnxn, issue_id)
810 target_starrers = services.issue_star.LookupItemStarrers(
811 cnxn, merge_into_iid)
812 return set(source_starrers) - set(target_starrers)
813
814
815 def AddIssueStarrers(
816 cnxn, services, mr, merge_into_iid, merge_into_project, new_starrers):
817 """Merge all the starrers for the current issue into the target issue."""
818 project = merge_into_project or mr.project
819 config = services.config.GetProjectConfig(mr.cnxn, project.project_id)
820 for starrer_id in new_starrers:
821 services.issue_star.SetStar(
822 cnxn, services, config, merge_into_iid, starrer_id, True)
823
824
825 def IsMergeAllowed(merge_into_issue, mr, services):
826 """Check to see if user has permission to merge with specified issue."""
827 merge_into_project = services.project.GetProjectByName(
828 mr.cnxn, merge_into_issue.project_name)
829 merge_into_config = services.config.GetProjectConfig(
830 mr.cnxn, merge_into_project.project_id)
831 merge_granted_perms = tracker_bizobj.GetGrantedPerms(
832 merge_into_issue, mr.auth.effective_ids, merge_into_config)
833
834 merge_view_allowed = mr.perms.CanUsePerm(
835 permissions.VIEW, mr.auth.effective_ids,
836 merge_into_project, permissions.GetRestrictions(merge_into_issue),
837 granted_perms=merge_granted_perms)
838 merge_edit_allowed = mr.perms.CanUsePerm(
839 permissions.EDIT_ISSUE, mr.auth.effective_ids,
840 merge_into_project, permissions.GetRestrictions(merge_into_issue),
841 granted_perms=merge_granted_perms)
842
843 return merge_view_allowed and merge_edit_allowed
844
845
846 class Error(Exception):
847 """Base class for errors from this module."""
848
849
850 class OverAttachmentQuota(Error):
851 """Project will exceed quota if the current operation is allowed."""
OLDNEW
« no previous file with comments | « appengine/monorail/tracker/tracker_constants.py ('k') | appengine/monorail/tracker/tracker_views.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698