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

Unified 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 side-by-side diff with in-line comments
Download patch
« no previous file with comments | « appengine/monorail/tracker/tracker_constants.py ('k') | appengine/monorail/tracker/tracker_views.py » ('j') | no next file with comments »
Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
Index: appengine/monorail/tracker/tracker_helpers.py
diff --git a/appengine/monorail/tracker/tracker_helpers.py b/appengine/monorail/tracker/tracker_helpers.py
new file mode 100644
index 0000000000000000000000000000000000000000..ec74994cd4fcc3a9816263085ea37282227a6238
--- /dev/null
+++ b/appengine/monorail/tracker/tracker_helpers.py
@@ -0,0 +1,851 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is govered by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions and classes used by the Monorail Issue Tracker pages.
+
+This module has functions that are reused in multiple servlets or
+other modules.
+"""
+
+import collections
+import logging
+import re
+import urllib
+
+import settings
+
+from framework import filecontent
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import monorailrequest
+from framework import permissions
+from framework import sorting
+from framework import template_helpers
+from framework import urls
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+# HTML input field names for blocked on and blocking issue refs.
+BLOCKED_ON = 'blocked_on'
+BLOCKING = 'blocking'
+
+# This string is used in HTML form element names to identify custom fields.
+# E.g., a value for a custom field with field_id 12 would be specified in
+# an HTML form element with name="custom_12".
+_CUSTOM_FIELD_NAME_PREFIX = 'custom_'
+
+# When the attachment quota gets within 1MB of the limit, stop offering
+# users the option to attach files.
+_SOFT_QUOTA_LEEWAY = 1024 * 1024
+
+# Accessors for sorting built-in fields.
+SORTABLE_FIELDS = {
+ 'project': lambda issue: issue.project_name,
+ 'id': lambda issue: issue.local_id,
+ 'owner': tracker_bizobj.GetOwnerId,
+ 'reporter': lambda issue: issue.reporter_id,
+ 'component': lambda issue: issue.component_ids,
+ 'cc': tracker_bizobj.GetCcIds,
+ 'summary': lambda issue: issue.summary.lower(),
+ 'stars': lambda issue: issue.star_count,
+ 'attachments': lambda issue: issue.attachment_count,
+ 'opened': lambda issue: issue.opened_timestamp,
+ 'closed': lambda issue: issue.closed_timestamp,
+ 'modified': lambda issue: issue.modified_timestamp,
+ 'status': tracker_bizobj.GetStatus,
+ 'blocked': lambda issue: bool(issue.blocked_on_iids),
+ 'blockedon': lambda issue: issue.blocked_on_iids or sorting.MAX_STRING,
+ 'blocking': lambda issue: issue.blocking_iids or sorting.MAX_STRING,
+ }
+
+
+# Namedtuples that hold data parsed from post_data.
+ParsedComponents = collections.namedtuple(
+ 'ParsedComponents', 'entered_str, paths, paths_remove')
+ParsedFields = collections.namedtuple(
+ 'ParsedFields', 'vals, vals_remove, fields_clear')
+ParsedUsers = collections.namedtuple(
+ 'ParsedUsers', 'owner_username, owner_id, cc_usernames, '
+ 'cc_usernames_remove, cc_ids, cc_ids_remove')
+ParsedBlockers = collections.namedtuple(
+ 'ParsedBlockers', 'entered_str, iids, dangling_refs')
+ParsedIssue = collections.namedtuple(
+ 'ParsedIssue', 'summary, comment, status, users, labels, '
+ 'labels_remove, components, fields, template_name, attachments, '
+ 'blocked_on, blocking')
+
+
+def ParseIssueRequest(cnxn, post_data, services, errors, default_project_name):
+ """Parse all the possible arguments out of the request.
+
+ Args:
+ cnxn: connection to SQL database.
+ post_data: HTML form information.
+ services: Connections to persistence layer.
+ errors: object to accumulate validation error info.
+ default_project_name: name of the project that contains the issue.
+
+ Returns:
+ A namedtuple with all parsed information. User IDs are looked up, but
+ also the strings are returned to allow bouncing the user back to correct
+ any errors.
+ """
+ summary = post_data.get('summary', '')
+ comment = post_data.get('comment', '')
+ status = post_data.get('status', '')
+ template_name = post_data.get('template_name', '')
+ component_str = post_data.get('components', '')
+ label_strs = post_data.getall('label')
+
+ comp_paths, comp_paths_remove = _ClassifyPlusMinusItems(
+ re.split('[,;\s]+', component_str))
+ parsed_components = ParsedComponents(
+ component_str, comp_paths, comp_paths_remove)
+ labels, labels_remove = _ClassifyPlusMinusItems(label_strs)
+ parsed_fields = _ParseIssueRequestFields(post_data)
+ # TODO(jrobbins): change from numbered fields to a multi-valued field.
+ attachments = _ParseIssueRequestAttachments(post_data)
+ parsed_users = _ParseIssueRequestUsers(cnxn, post_data, services)
+ parsed_blocked_on = _ParseBlockers(
+ cnxn, post_data, services, errors, default_project_name, BLOCKED_ON)
+ parsed_blocking = _ParseBlockers(
+ cnxn, post_data, services, errors, default_project_name, BLOCKING)
+
+ parsed_issue = ParsedIssue(
+ summary, comment, status, parsed_users, labels, labels_remove,
+ parsed_components, parsed_fields, template_name, attachments,
+ parsed_blocked_on, parsed_blocking)
+ return parsed_issue
+
+
+def _ClassifyPlusMinusItems(add_remove_list):
+ """Classify the given plus-or-minus items into add and remove lists."""
+ add_remove_set = {s.strip() for s in add_remove_list}
+ add_strs = [s for s in add_remove_set if s and not s.startswith('-')]
+ remove_strs = [s[1:] for s in add_remove_set if s[1:] and s.startswith('-')]
+ return add_strs, remove_strs
+
+
+def _ParseIssueRequestFields(post_data):
+ """Iterate over post_data and return custom field values found in it."""
+ field_val_strs = {}
+ field_val_strs_remove = {}
+ for key in post_data.keys():
+ if key.startswith(_CUSTOM_FIELD_NAME_PREFIX):
+ val_strs = [v for v in post_data.getall(key) if v]
+ if val_strs:
+ field_id = int(key[len(_CUSTOM_FIELD_NAME_PREFIX):])
+ if post_data.get('op_' + key) == 'remove':
+ field_val_strs_remove[field_id] = val_strs
+ else:
+ field_val_strs[field_id] = val_strs
+
+ fields_clear = []
+ op_prefix = 'op_' + _CUSTOM_FIELD_NAME_PREFIX
+ for op_key in post_data.keys():
+ if op_key.startswith(op_prefix):
+ if post_data.get(op_key) == 'clear':
+ field_id = int(op_key[len(op_prefix):])
+ fields_clear.append(field_id)
+
+ return ParsedFields(field_val_strs, field_val_strs_remove, fields_clear)
+
+
+def _ParseIssueRequestAttachments(post_data):
+ """Extract and clean-up any attached files from the post data.
+
+ Args:
+ post_data: dict w/ values from the user's HTTP POST form data.
+
+ Returns:
+ [(filename, filecontents, mimetype), ...] with items for each attachment.
+ """
+ # TODO(jrobbins): change from numbered fields to a multi-valued field.
+ attachments = []
+ for i in xrange(1, 16):
+ if 'file%s' % i in post_data:
+ item = post_data['file%s' % i]
+ if isinstance(item, basestring):
+ continue
+ if '\\' in item.filename: # IE insists on giving us the whole path.
+ item.filename = item.filename[item.filename.rindex('\\') + 1:]
+ if not item.filename:
+ continue # Skip any FILE fields that were not filled in.
+ attachments.append((
+ item.filename, item.value,
+ filecontent.GuessContentTypeFromFilename(item.filename)))
+
+ return attachments
+
+
+def _ParseIssueRequestUsers(cnxn, post_data, services):
+ """Extract usernames from the POST data, categorize them, and look up IDs.
+
+ Args:
+ cnxn: connection to SQL database.
+ post_data: dict w/ data from the HTTP POST.
+ services: Services.
+
+ Returns:
+ A namedtuple (owner_username, owner_id, cc_usernames, cc_usernames_remove,
+ cc_ids, cc_ids_remove), containing:
+ - issue owner's name and user ID, if any
+ - the list of all cc'd usernames
+ - the user IDs to add or remove from the issue CC list.
+ Any of these user IDs may be None if the corresponding username
+ or email address is invalid.
+ """
+ # Get the user-entered values from post_data.
+ cc_username_str = post_data.get('cc', '')
+ owner_email = post_data.get('owner', '').strip()
+
+ cc_usernames, cc_usernames_remove = _ClassifyPlusMinusItems(
+ re.split('[,;\s]+', cc_username_str))
+
+ # Figure out the email addresses to lookup and do the lookup.
+ emails_to_lookup = cc_usernames + cc_usernames_remove
+ if owner_email:
+ emails_to_lookup.append(owner_email)
+ all_user_ids = services.user.LookupUserIDs(
+ cnxn, emails_to_lookup, autocreate=True)
+ if owner_email:
+ owner_id = all_user_ids.get(owner_email)
+ else:
+ owner_id = framework_constants.NO_USER_SPECIFIED
+
+ # Lookup the user IDs of the Cc addresses to add or remove.
+ cc_ids = [all_user_ids.get(cc) for cc in cc_usernames]
+ cc_ids_remove = [all_user_ids.get(cc) for cc in cc_usernames_remove]
+
+ return ParsedUsers(owner_email, owner_id, cc_usernames, cc_usernames_remove,
+ cc_ids, cc_ids_remove)
+
+
+def _ParseBlockers(cnxn, post_data, services, errors, default_project_name,
+ field_name):
+ """Parse input for issues that the current issue is blocking/blocked on.
+
+ Args:
+ cnxn: connection to SQL database.
+ post_data: dict w/ values from the user's HTTP POST.
+ services: connections to backend services.
+ errors: object to accumulate validation error info.
+ default_project_name: name of the project that contains the issue.
+ field_name: string HTML input field name, e.g., BLOCKED_ON or BLOCKING.
+
+ Returns:
+ A namedtuple with the user input string, and a list of issue IDs.
+ """
+ entered_str = post_data.get(field_name, '').strip()
+ blocker_iids = []
+ dangling_ref_tuples = []
+
+ issue_ref = None
+ for ref_str in re.split('[,;\s]+', entered_str):
+ try:
+ issue_ref = tracker_bizobj.ParseIssueRef(ref_str)
+ except ValueError:
+ setattr(errors, field_name, 'Invalid issue ID %s' % ref_str.strip())
+ break
+
+ if not issue_ref:
+ continue
+
+ blocker_project_name, blocker_issue_id = issue_ref
+ if not blocker_project_name:
+ blocker_project_name = default_project_name
+
+ # Detect and report if the same issue was specified.
+ current_issue_id = int(post_data.get('id')) if post_data.get('id') else -1
+ if (blocker_issue_id == current_issue_id and
+ blocker_project_name == default_project_name):
+ setattr(errors, field_name, 'Cannot be %s the same issue' % field_name)
+ break
+
+ ref_projects = services.project.GetProjectsByName(
+ cnxn, set([blocker_project_name]))
+ blocker_iid = services.issue.ResolveIssueRefs(
+ cnxn, ref_projects, default_project_name, [issue_ref])
+ if not blocker_iid:
+ if blocker_project_name in settings.recognized_codesite_projects:
+ # We didn't find the issue, but it had a explicitly-specified project
+ # which we know is on Codesite. Allow it as a dangling reference.
+ dangling_ref_tuples.append(issue_ref)
+ continue
+ else:
+ # Otherwise, it doesn't exist, so report it.
+ setattr(errors, field_name, 'Invalid issue ID %s' % ref_str.strip())
+ break
+ if blocker_iid[0] not in blocker_iids:
+ blocker_iids.extend(blocker_iid)
+
+ blocker_iids.sort()
+ dangling_ref_tuples.sort()
+ return ParsedBlockers(entered_str, blocker_iids, dangling_ref_tuples)
+
+
+def IsValidIssueOwner(cnxn, project, owner_id, services):
+ """Return True if the given user ID can be an issue owner.
+
+ Args:
+ cnxn: connection to SQL database.
+ project: the current Project PB.
+ owner_id: the user ID of the proposed issue owner.
+ services: connections to backends.
+
+ It is OK to have 0 for the owner_id, that simply means that the issue is
+ unassigned.
+
+ Returns:
+ A pair (valid, err_msg). valid is True if the given user ID can be an
+ issue owner. err_msg is an error message string to display to the user
+ if valid == False, and is None if valid == True.
+ """
+ # An issue is always allowed to have no owner specified.
+ if owner_id == framework_constants.NO_USER_SPECIFIED:
+ return True, None
+
+ auth = monorailrequest.AuthData.FromUserID(cnxn, owner_id, services)
+ if not framework_bizobj.UserIsInProject(project, auth.effective_ids):
+ return False, 'Issue owner must be a project member'
+
+ group_ids = services.usergroup.DetermineWhichUserIDsAreGroups(
+ cnxn, [owner_id])
+ if owner_id in group_ids:
+ return False, 'Issue owner cannot be a user group'
+
+ return True, None
+
+
+def GetAllowedOpenedAndClosedIssues(mr, issue_ids, services):
+ """Get filtered lists of open and closed issues identified by issue_ids.
+
+ The function then filters the results to only the issues that the user
+ is allowed to view. E.g., we only auto-link to issues that the user
+ would be able to view if he/she clicked the link.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ issue_ids: list of int issue IDs for the target issues.
+ services: connection to issue, config, and project persistence layers.
+
+ Returns:
+ Two lists of issues that the user is allowed to view: one for open
+ issues and one for closed issues.
+ """
+ open_issues, closed_issues = services.issue.GetOpenAndClosedIssues(
+ mr.cnxn, issue_ids)
+ project_dict = GetAllIssueProjects(
+ mr.cnxn, open_issues + closed_issues, services.project)
+ config_dict = services.config.GetProjectConfigs(mr.cnxn, project_dict.keys())
+ allowed_open_issues = FilterOutNonViewableIssues(
+ mr.auth.effective_ids, mr.auth.user_pb, project_dict, config_dict,
+ open_issues)
+ allowed_closed_issues = FilterOutNonViewableIssues(
+ mr.auth.effective_ids, mr.auth.user_pb, project_dict, config_dict,
+ closed_issues)
+
+ return allowed_open_issues, allowed_closed_issues
+
+
+def GetAllowedOpenAndClosedRelatedIssues(services, mr, issue):
+ """Retrieve the issues that the given issue references.
+
+ Related issues are the blocked on, blocking, and merged-into issues.
+ This function also filters the results to only the issues that the
+ user is allowed to view.
+
+ Args:
+ services: connection to issue, config, and project persistence layers.
+ mr: commonly used info parsed from the request.
+ issue: the Issue PB being viewed.
+
+ Returns:
+ Two dictionaries of issues that the user is allowed to view: one for open
+ issues and one for closed issues.
+ """
+ related_issue_iids = list(issue.blocked_on_iids) + list(issue.blocking_iids)
+ if issue.merged_into:
+ related_issue_iids.append(issue.merged_into)
+ open_issues, closed_issues = GetAllowedOpenedAndClosedIssues(
+ mr, related_issue_iids, services)
+ open_dict = {issue.issue_id: issue for issue in open_issues}
+ closed_dict = {issue.issue_id: issue for issue in closed_issues}
+ return open_dict, closed_dict
+
+
+def MakeViewsForUsersInIssues(cnxn, issue_list, user_service, omit_ids=None):
+ """Lookup all the users involved in any of the given issues.
+
+ Args:
+ cnxn: connection to SQL database.
+ issue_list: list of Issue PBs from a result query.
+ user_service: Connection to User backend storage.
+ omit_ids: a list of user_ids to omit, e.g., because we already have them.
+
+ Returns:
+ A dictionary {user_id: user_view,...} for all the users involved
+ in the given issues.
+ """
+ issue_participant_id_set = tracker_bizobj.UsersInvolvedInIssues(issue_list)
+ if omit_ids:
+ issue_participant_id_set.difference_update(omit_ids)
+
+ # TODO(jrobbins): consider caching View objects as well.
+ users_by_id = framework_views.MakeAllUserViews(
+ cnxn, user_service, issue_participant_id_set)
+
+ return users_by_id
+
+
+def FormatIssueListURL(
+ mr, config, absolute=True, project_names=None, **kwargs):
+ """Format a link back to list view as configured by user."""
+ if project_names is None:
+ project_names = [mr.project_name]
+ if not tracker_constants.JUMP_RE.match(mr.query):
+ if mr.query:
+ kwargs['q'] = mr.query
+ if mr.can and mr.can != 2:
+ kwargs['can'] = mr.can
+ def_col_spec = config.default_col_spec
+ if mr.col_spec and mr.col_spec != def_col_spec:
+ kwargs['colspec'] = mr.col_spec
+ if mr.sort_spec:
+ kwargs['sort'] = mr.sort_spec
+ if mr.group_by_spec:
+ kwargs['groupby'] = mr.group_by_spec
+ if mr.start:
+ kwargs['start'] = mr.start
+ if mr.num != tracker_constants.DEFAULT_RESULTS_PER_PAGE:
+ kwargs['num'] = mr.num
+
+ if len(project_names) == 1:
+ url = '/p/%s%s' % (project_names[0], urls.ISSUE_LIST)
+ else:
+ url = urls.ISSUE_LIST
+ kwargs['projects'] = ','.join(sorted(project_names))
+
+ param_strings = ['%s=%s' % (k, urllib.quote((u'%s' % v).encode('utf-8')))
+ for k, v in kwargs.iteritems()]
+ if param_strings:
+ url += '?' + '&'.join(sorted(param_strings))
+ if absolute:
+ url = '%s://%s%s' % (mr.request.scheme, mr.request.host, url)
+
+ return url
+
+
+def FormatRelativeIssueURL(project_name, path, **kwargs):
+ """Format a URL to get to an issue in the named project.
+
+ Args:
+ project_name: string name of the project containing the issue.
+ path: string servlet path, e.g., from framework/urls.py.
+ **kwargs: additional query-string parameters to include in the URL.
+
+ Returns:
+ A URL string.
+ """
+ return framework_helpers.FormatURL(
+ None, '/p/%s%s' % (project_name, path), **kwargs)
+
+
+def ComputeNewQuotaBytesUsed(project, attachments):
+ """Add the given attachments to the project's attachment quota usage.
+
+ Args:
+ project: Project PB for the project being updated.
+ attachments: a list of attachments being added to an issue.
+
+ Returns:
+ The new number of bytes used.
+
+ Raises:
+ OverAttachmentQuota: If project would go over quota.
+ """
+ total_attach_size = 0L
+ for _filename, content, _mimetype in attachments:
+ total_attach_size += len(content)
+
+ new_bytes_used = project.attachment_bytes_used + total_attach_size
+ quota = (project.attachment_quota or
+ tracker_constants.ISSUE_ATTACHMENTS_QUOTA_HARD)
+ if new_bytes_used > quota:
+ raise OverAttachmentQuota(new_bytes_used - quota)
+ return new_bytes_used
+
+
+def IsUnderSoftAttachmentQuota(project):
+ """Check the project's attachment quota against the soft quota limit.
+
+ If there is a custom quota on the project, this will check against
+ that instead of the system-wide default quota.
+
+ Args:
+ project: Project PB for the project to examine
+
+ Returns:
+ True if the project is under quota, false otherwise.
+ """
+ quota = tracker_constants.ISSUE_ATTACHMENTS_QUOTA_SOFT
+ if project.attachment_quota:
+ quota = project.attachment_quota - _SOFT_QUOTA_LEEWAY
+
+ return project.attachment_bytes_used < quota
+
+
+def GetAllIssueProjects(cnxn, issues, project_service):
+ """Get all the projects that the given issues belong to.
+
+ Args:
+ cnxn: connection to SQL database.
+ issues: list of issues, which may come from different projects.
+ project_service: connection to project persistence layer.
+
+ Returns:
+ A dictionary {project_id: project} of all the projects that
+ any of the given issues belongs to.
+ """
+ needed_project_ids = {issue.project_id for issue in issues}
+ project_dict = project_service.GetProjects(cnxn, needed_project_ids)
+ return project_dict
+
+
+def GetPermissionsInAllProjects(user, effective_ids, projects):
+ """Look up the permissions for the given user in each project."""
+ return {
+ project.project_id:
+ permissions.GetPermissions(user, effective_ids, project)
+ for project in projects}
+
+
+def FilterOutNonViewableIssues(
+ effective_ids, user, project_dict, config_dict, issues):
+ """Return a filtered list of issues that the user can view."""
+ perms_dict = GetPermissionsInAllProjects(
+ user, effective_ids, project_dict.values())
+
+ denied_project_ids = {
+ pid for pid, p in project_dict.iteritems()
+ if not permissions.CanView(effective_ids, perms_dict[pid], p, [])}
+
+ results = []
+ for issue in issues:
+ if issue.deleted or issue.project_id in denied_project_ids:
+ continue
+
+ if not permissions.HasRestrictions(issue):
+ may_view = True
+ else:
+ perms = perms_dict[issue.project_id]
+ project = project_dict[issue.project_id]
+ config = config_dict.get(issue.project_id, config_dict.get('harmonized'))
+ granted_perms = tracker_bizobj.GetGrantedPerms(
+ issue, effective_ids, config)
+ may_view = permissions.CanViewRestrictedIssueInVisibleProject(
+ effective_ids, perms, project, issue, granted_perms=granted_perms)
+
+ if may_view:
+ results.append(issue)
+
+ return results
+
+
+def MeansOpenInProject(status, config):
+ """Return true if this status means that the issue is still open.
+
+ Args:
+ status: issue status string. E.g., 'New'.
+ config: the config of the current project.
+
+ Returns:
+ Boolean True if the status means that the issue is open.
+ """
+ status_lower = status.lower()
+
+ # iterate over the list of known statuses for this project
+ # return true if we find a match that declares itself to be open
+ for wks in config.well_known_statuses:
+ if wks.status.lower() == status_lower:
+ return wks.means_open
+
+ # if we didn't find a matching status we consider the status open
+ return True
+
+
+def IsNoisy(num_comments, num_starrers):
+ """Return True if this is a "noisy" issue that would send a ton of emails.
+
+ The rule is that a very active issue with a large number of comments
+ and starrers will only send notification when a comment (or change)
+ is made by a project member.
+
+ Args:
+ num_comments: int number of comments on issue so far.
+ num_starrers: int number of users who starred the issue.
+
+ Returns:
+ True if we will not bother starrers with an email notification for
+ changes made by non-members.
+ """
+ return (num_comments >= tracker_constants.NOISY_ISSUE_COMMENT_COUNT and
+ num_starrers >= tracker_constants.NOISY_ISSUE_STARRER_COUNT)
+
+
+def MergeCCsAndAddComment(
+ services, mr, issue, merge_into_project, merge_into_issue):
+ """Modify the CC field of the target issue and add a comment to it."""
+ return MergeCCsAndAddCommentMultipleIssues(
+ services, mr, [issue], merge_into_project, merge_into_issue)
+
+
+def MergeCCsAndAddCommentMultipleIssues(
+ services, mr, issues, merge_into_project, merge_into_issue):
+ """Modify the CC field of the target issue and add a comment to it."""
+ merge_into_restricts = permissions.GetRestrictions(merge_into_issue)
+ merge_comment = ''
+ source_cc = set()
+ for issue in issues:
+ if issue.project_name == merge_into_issue.project_name:
+ issue_ref_str = '%d' % issue.local_id
+ else:
+ issue_ref_str = '%s:%d' % (issue.project_name, issue.local_id)
+ if merge_comment:
+ merge_comment += '\n'
+ merge_comment += 'Issue %s has been merged into this issue.' % issue_ref_str
+
+ if permissions.HasRestrictions(issue, perm='View'):
+ restricts = permissions.GetRestrictions(issue)
+ # Don't leak metadata from a restricted issue.
+ if (issue.project_id != merge_into_issue.project_id or
+ set(restricts) != set(merge_into_restricts)):
+ # TODO(jrobbins): user option to choose to merge CC or not.
+ # TODO(jrobbins): add a private comment rather than nothing
+ continue
+
+ source_cc.update(issue.cc_ids)
+ if issue.owner_id: # owner_id == 0 means no owner
+ source_cc.update([issue.owner_id])
+
+ target_cc = merge_into_issue.cc_ids
+ add_cc = [user_id for user_id in source_cc if user_id not in target_cc]
+
+ services.issue.ApplyIssueComment(
+ mr.cnxn, services, mr.auth.user_id,
+ merge_into_project.project_id, merge_into_issue.local_id,
+ merge_into_issue.summary, merge_into_issue.status,
+ merge_into_issue.owner_id, list(target_cc) + list(add_cc),
+ merge_into_issue.labels, merge_into_issue.field_values,
+ merge_into_issue.component_ids, merge_into_issue.blocked_on_iids,
+ merge_into_issue.blocking_iids, merge_into_issue.dangling_blocked_on_refs,
+ merge_into_issue.dangling_blocking_refs, merge_into_issue.merged_into,
+ index_now=False, comment=merge_comment)
+
+ return merge_comment
+
+
+def GetAttachmentIfAllowed(mr, services):
+ """Retrieve the requested attachment, or raise an appropriate exception.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ services: connections to backend services.
+
+ Returns:
+ The requested Attachment PB, and the Issue that it belongs to.
+
+ Raises:
+ NoSuchAttachmentException: attachment was not found or was marked deleted.
+ NoSuchIssueException: issue that contains attachment was not found.
+ PermissionException: the user is not allowed to view the attachment.
+ """
+ attachment = None
+
+ attachment, cid, issue_id = services.issue.GetAttachmentAndContext(
+ mr.cnxn, mr.aid)
+
+ issue = services.issue.GetIssue(mr.cnxn, issue_id)
+ config = services.config.GetProjectConfig(mr.cnxn, issue.project_id)
+ granted_perms = tracker_bizobj.GetGrantedPerms(
+ issue, mr.auth.effective_ids, config)
+ permit_view = permissions.CanViewIssue(
+ mr.auth.effective_ids, mr.perms, mr.project, issue,
+ granted_perms=granted_perms)
+ if not permit_view:
+ raise permissions.PermissionException('Cannot view attachment\'s issue')
+
+ comment = services.issue.GetComment(mr.cnxn, cid)
+ can_delete = False
+ if mr.auth.user_id and mr.project:
+ can_delete = permissions.CanDelete(
+ mr.auth.user_id, mr.auth.effective_ids, mr.perms,
+ comment.deleted_by, comment.user_id, mr.project,
+ permissions.GetRestrictions(issue), granted_perms=granted_perms)
+ if comment.deleted_by and not can_delete:
+ raise permissions.PermissionException('Cannot view attachment\'s comment')
+
+ return attachment, issue
+
+
+def LabelsMaskedByFields(config, field_names, trim_prefix=False):
+ """Return a list of EZTItems for labels that would be masked by fields."""
+ return _LabelsMaskedOrNot(config, field_names, trim_prefix=trim_prefix)
+
+
+def LabelsNotMaskedByFields(config, field_names, trim_prefix=False):
+ """Return a list of EZTItems for labels that would not be masked."""
+ return _LabelsMaskedOrNot(
+ config, field_names, invert=True, trim_prefix=trim_prefix)
+
+
+def _LabelsMaskedOrNot(config, field_names, invert=False, trim_prefix=False):
+ """Return EZTItems for labels that'd be masked. Or not, when invert=True."""
+ field_names = [fn.lower() for fn in field_names]
+ result = []
+ for wkl in config.well_known_labels:
+ masked_by = tracker_bizobj.LabelIsMaskedByField(wkl.label, field_names)
+ if (masked_by and not invert) or (not masked_by and invert):
+ display_name = wkl.label
+ if trim_prefix:
+ display_name = display_name[len(masked_by) + 1:]
+ result.append(template_helpers.EZTItem(
+ name=display_name,
+ name_padded=display_name.ljust(20),
+ commented='#' if wkl.deprecated else '',
+ docstring=wkl.label_docstring,
+ docstring_short=template_helpers.FitUnsafeText(
+ wkl.label_docstring, 40),
+ idx=len(result)))
+
+ return result
+
+
+def LookupComponentIDs(component_paths, config, errors):
+ """Look up the IDs of the specified components in the given config."""
+ component_ids = []
+ for path in component_paths:
+ if not path:
+ continue
+ cd = tracker_bizobj.FindComponentDef(path, config)
+ if cd:
+ component_ids.append(cd.component_id)
+ else:
+ errors.components = 'Unknown component %s' % path
+
+ return component_ids
+
+
+def ParseAdminUsers(cnxn, admins_str, user_service):
+ """Parse all the usernames of component, field, or template admins."""
+ admins, _remove = _ClassifyPlusMinusItems(
+ re.split('[,;\s]+', admins_str))
+ all_user_ids = user_service.LookupUserIDs(cnxn, admins, autocreate=True)
+ admin_ids = [all_user_ids[username] for username in admins]
+ return admin_ids, admins_str
+
+
+def FilterIssueTypes(config):
+ """Return a list of well-known issue types."""
+ well_known_issue_types = []
+ for wk_label in config.well_known_labels:
+ if wk_label.label.lower().startswith('type-'):
+ _, type_name = wk_label.label.split('-', 1)
+ well_known_issue_types.append(type_name)
+
+ return well_known_issue_types
+
+
+def ParseMergeFields(
+ cnxn, services, project_name, post_data, status, config, issue, errors):
+ """Parse info that identifies the issue to merge into, if any."""
+ merge_into_text = ''
+ merge_into_ref = None
+ merge_into_issue = None
+
+ if status not in config.statuses_offer_merge:
+ return '', None
+
+ merge_into_text = post_data.get('merge_into', '')
+ if merge_into_text:
+ try:
+ merge_into_ref = tracker_bizobj.ParseIssueRef(merge_into_text)
+ except ValueError:
+ logging.info('merge_into not an int: %r', merge_into_text)
+ errors.merge_into_id = 'Please enter a valid issue ID'
+
+ if not merge_into_ref:
+ errors.merge_into_id = 'Please enter an issue ID'
+ return merge_into_text, None
+
+ merge_into_project_name, merge_into_id = merge_into_ref
+ if (merge_into_id == issue.local_id and
+ (merge_into_project_name == project_name or
+ not merge_into_project_name)):
+ logging.info('user tried to merge issue into itself: %r', merge_into_ref)
+ errors.merge_into_id = 'Cannot merge issue into itself'
+ return merge_into_text, None
+
+ project = services.project.GetProjectByName(
+ cnxn, merge_into_project_name or project_name)
+ try:
+ merge_into_issue = services.issue.GetIssueByLocalID(
+ cnxn, project.project_id, merge_into_id)
+ except Exception:
+ logging.info('merge_into issue not found: %r', merge_into_ref)
+ errors.merge_into_id = 'No such issue'
+ return merge_into_text, None
+
+ return merge_into_text, merge_into_issue
+
+
+def GetNewIssueStarrers(cnxn, services, issue_id, merge_into_iid):
+ """Get starrers of current issue who have not starred the target issue."""
+ source_starrers = services.issue_star.LookupItemStarrers(cnxn, issue_id)
+ target_starrers = services.issue_star.LookupItemStarrers(
+ cnxn, merge_into_iid)
+ return set(source_starrers) - set(target_starrers)
+
+
+def AddIssueStarrers(
+ cnxn, services, mr, merge_into_iid, merge_into_project, new_starrers):
+ """Merge all the starrers for the current issue into the target issue."""
+ project = merge_into_project or mr.project
+ config = services.config.GetProjectConfig(mr.cnxn, project.project_id)
+ for starrer_id in new_starrers:
+ services.issue_star.SetStar(
+ cnxn, services, config, merge_into_iid, starrer_id, True)
+
+
+def IsMergeAllowed(merge_into_issue, mr, services):
+ """Check to see if user has permission to merge with specified issue."""
+ merge_into_project = services.project.GetProjectByName(
+ mr.cnxn, merge_into_issue.project_name)
+ merge_into_config = services.config.GetProjectConfig(
+ mr.cnxn, merge_into_project.project_id)
+ merge_granted_perms = tracker_bizobj.GetGrantedPerms(
+ merge_into_issue, mr.auth.effective_ids, merge_into_config)
+
+ merge_view_allowed = mr.perms.CanUsePerm(
+ permissions.VIEW, mr.auth.effective_ids,
+ merge_into_project, permissions.GetRestrictions(merge_into_issue),
+ granted_perms=merge_granted_perms)
+ merge_edit_allowed = mr.perms.CanUsePerm(
+ permissions.EDIT_ISSUE, mr.auth.effective_ids,
+ merge_into_project, permissions.GetRestrictions(merge_into_issue),
+ granted_perms=merge_granted_perms)
+
+ return merge_view_allowed and merge_edit_allowed
+
+
+class Error(Exception):
+ """Base class for errors from this module."""
+
+
+class OverAttachmentQuota(Error):
+ """Project will exceed quota if the current operation is allowed."""
« 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