| 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."""
|
|
|