| Index: appengine/monorail/tracker/issueentry.py
|
| diff --git a/appengine/monorail/tracker/issueentry.py b/appengine/monorail/tracker/issueentry.py
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..bbc35444bca8e1d37af11c016fc40aaaa48c811f
|
| --- /dev/null
|
| +++ b/appengine/monorail/tracker/issueentry.py
|
| @@ -0,0 +1,410 @@
|
| +# 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
|
| +
|
| +"""Servlet that implements the entry of new issues."""
|
| +
|
| +import logging
|
| +import time
|
| +from third_party import ezt
|
| +
|
| +from features import notify
|
| +from framework import actionlimit
|
| +from framework import framework_bizobj
|
| +from framework import framework_constants
|
| +from framework import framework_helpers
|
| +from framework import framework_views
|
| +from framework import permissions
|
| +from framework import servlet
|
| +from framework import template_helpers
|
| +from framework import urls
|
| +from tracker import field_helpers
|
| +from tracker import tracker_bizobj
|
| +from tracker import tracker_constants
|
| +from tracker import tracker_helpers
|
| +from tracker import tracker_views
|
| +
|
| +PLACEHOLDER_SUMMARY = 'Enter one-line summary'
|
| +
|
| +
|
| +class IssueEntry(servlet.Servlet):
|
| + """IssueEntry shows a page with a simple form to enter a new issue."""
|
| +
|
| + _PAGE_TEMPLATE = 'tracker/issue-entry-page.ezt'
|
| + _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
|
| + _CAPTCHA_ACTION_TYPES = [actionlimit.ISSUE_COMMENT]
|
| +
|
| + def AssertBasePermission(self, mr):
|
| + """Check whether the user has any permission to visit this page.
|
| +
|
| + Args:
|
| + mr: commonly used info parsed from the request.
|
| + """
|
| + super(IssueEntry, self).AssertBasePermission(mr)
|
| + if not self.CheckPerm(mr, permissions.CREATE_ISSUE):
|
| + raise permissions.PermissionException(
|
| + 'User is not allowed to enter an issue')
|
| +
|
| + def GatherPageData(self, mr):
|
| + """Build up a dictionary of data values to use when rendering the page.
|
| +
|
| + Args:
|
| + mr: commonly used info parsed from the request.
|
| +
|
| + Returns:
|
| + Dict of values used by EZT for rendering the page.
|
| + """
|
| + with self.profiler.Phase('getting config'):
|
| + config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
|
| +
|
| + # In addition to checking perms, we adjust some default field values for
|
| + # project members.
|
| + is_member = framework_bizobj.UserIsInProject(
|
| + mr.project, mr.auth.effective_ids)
|
| + page_perms = self.MakePagePerms(
|
| + mr, None,
|
| + permissions.CREATE_ISSUE,
|
| + permissions.SET_STAR,
|
| + permissions.EDIT_ISSUE,
|
| + permissions.EDIT_ISSUE_SUMMARY,
|
| + permissions.EDIT_ISSUE_STATUS,
|
| + permissions.EDIT_ISSUE_OWNER,
|
| + permissions.EDIT_ISSUE_CC)
|
| +
|
| + wkp = _SelectTemplate(mr.template_name, config, is_member)
|
| +
|
| + if wkp.summary:
|
| + initial_summary = wkp.summary
|
| + initial_summary_must_be_edited = wkp.summary_must_be_edited
|
| + else:
|
| + initial_summary = PLACEHOLDER_SUMMARY
|
| + initial_summary_must_be_edited = True
|
| +
|
| + if wkp.status:
|
| + initial_status = wkp.status
|
| + elif is_member:
|
| + initial_status = 'Accepted'
|
| + else:
|
| + initial_status = 'New' # not offering meta, only used in hidden field.
|
| +
|
| + component_paths = []
|
| + for component_id in wkp.component_ids:
|
| + component_paths.append(
|
| + tracker_bizobj.FindComponentDefByID(component_id, config).path)
|
| + initial_components = ', '.join(component_paths)
|
| +
|
| + if wkp.owner_id:
|
| + initial_owner = framework_views.MakeUserView(
|
| + mr.cnxn, self.services.user, wkp.owner_id)
|
| + initial_owner_name = initial_owner.email
|
| + elif wkp.owner_defaults_to_member and page_perms.EditIssue:
|
| + initial_owner_name = mr.auth.user_view.email
|
| + else:
|
| + initial_owner_name = ''
|
| +
|
| + # Check whether to allow attachments from the entry page
|
| + allow_attachments = tracker_helpers.IsUnderSoftAttachmentQuota(mr.project)
|
| +
|
| + config_view = tracker_views.ConfigView(mr, self.services, config)
|
| + # If the user followed a link that specified the template name, make sure
|
| + # that it is also in the menu as the current choice.
|
| + for template_view in config_view.templates:
|
| + if template_view.name == mr.template_name:
|
| + template_view.can_view = ezt.boolean(True)
|
| +
|
| + offer_templates = len(list(
|
| + tmpl for tmpl in config_view.templates if tmpl.can_view)) > 1
|
| + restrict_to_known = config.restrict_to_known
|
| + field_name_set = {fd.field_name.lower() for fd in config.field_defs
|
| + if not fd.is_deleted} # TODO(jrobbins): restrictions
|
| + link_or_template_labels = mr.GetListParam('labels', wkp.labels)
|
| + labels = [lab for lab in link_or_template_labels
|
| + if not tracker_bizobj.LabelIsMaskedByField(lab, field_name_set)]
|
| +
|
| + field_user_views = tracker_views.MakeFieldUserViews(
|
| + mr.cnxn, wkp, self.services.user)
|
| + field_views = [
|
| + tracker_views.MakeFieldValueView(
|
| + fd, config, link_or_template_labels, [], wkp.field_values,
|
| + field_user_views)
|
| + # TODO(jrobbins): field-level view restrictions, display options
|
| + for fd in config.field_defs
|
| + if not fd.is_deleted]
|
| +
|
| + page_data = {
|
| + 'issue_tab_mode': 'issueEntry',
|
| + 'initial_summary': initial_summary,
|
| + 'template_summary': initial_summary,
|
| + 'clear_summary_on_click': ezt.boolean(
|
| + initial_summary_must_be_edited and
|
| + 'initial_summary' not in mr.form_overrides),
|
| + 'must_edit_summary': ezt.boolean(initial_summary_must_be_edited),
|
| +
|
| + 'initial_description': wkp.content,
|
| + 'template_name': wkp.name,
|
| + 'component_required': ezt.boolean(wkp.component_required),
|
| + 'initial_status': initial_status,
|
| + 'initial_owner': initial_owner_name,
|
| + 'initial_components': initial_components,
|
| + 'initial_cc': '',
|
| + 'initial_blocked_on': '',
|
| + 'initial_blocking': '',
|
| + 'labels': labels,
|
| + 'fields': field_views,
|
| +
|
| + 'any_errors': ezt.boolean(mr.errors.AnyErrors()),
|
| + 'page_perms': page_perms,
|
| + 'allow_attachments': ezt.boolean(allow_attachments),
|
| + 'max_attach_size': template_helpers.BytesKbOrMb(
|
| + framework_constants.MAX_POST_BODY_SIZE),
|
| +
|
| + 'offer_templates': ezt.boolean(offer_templates),
|
| + 'config': config_view,
|
| +
|
| + 'restrict_to_known': ezt.boolean(restrict_to_known),
|
| + }
|
| +
|
| + return page_data
|
| +
|
| + def GatherHelpData(self, mr, _page_data):
|
| + """Return a dict of values to drive on-page user help.
|
| +
|
| + Args:
|
| + mr: commonly used info parsed from the request.
|
| + _page_data: Dictionary of base and page template data.
|
| +
|
| + Returns:
|
| + A dict of values to drive on-page user help, to be added to page_data.
|
| + """
|
| + is_privileged_domain_user = framework_bizobj.IsPriviledgedDomainUser(
|
| + mr.auth.user_pb.email)
|
| + cue = None
|
| + if (mr.auth.user_id and
|
| + 'privacy_click_through' not in mr.auth.user_pb.dismissed_cues):
|
| + cue = 'privacy_click_through'
|
| +
|
| + return {
|
| + 'is_privileged_domain_user': ezt.boolean(is_privileged_domain_user),
|
| + 'cue': cue,
|
| + }
|
| +
|
| + def ProcessFormData(self, mr, post_data):
|
| + """Process the issue entry form.
|
| +
|
| + Args:
|
| + mr: commonly used info parsed from the request.
|
| + post_data: The post_data dict for the current request.
|
| +
|
| + Returns:
|
| + String URL to redirect the user to after processing.
|
| + """
|
| + config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
|
| + parsed = tracker_helpers.ParseIssueRequest(
|
| + mr.cnxn, post_data, self.services, mr.errors, mr.project_name)
|
| + bounce_labels = parsed.labels[:]
|
| + bounce_fields = tracker_views.MakeBounceFieldValueViews(
|
| + parsed.fields.vals, config)
|
| + field_helpers.ShiftEnumFieldsIntoLabels(
|
| + parsed.labels, parsed.labels_remove, parsed.fields.vals,
|
| + parsed.fields.vals_remove, config)
|
| + field_values = field_helpers.ParseFieldValues(
|
| + mr.cnxn, self.services.user, parsed.fields.vals, config)
|
| +
|
| + labels = _DiscardUnusedTemplateLabelPrefixes(parsed.labels)
|
| + component_ids = tracker_helpers.LookupComponentIDs(
|
| + parsed.components.paths, config, mr.errors)
|
| +
|
| + reporter_id = mr.auth.user_id
|
| + self.CheckCaptcha(mr, post_data)
|
| +
|
| + if not parsed.summary.strip():
|
| + mr.errors.summary = 'Summary is required'
|
| +
|
| + if not parsed.comment.strip():
|
| + mr.errors.comment = 'A description is required'
|
| +
|
| + if len(parsed.comment) > tracker_constants.MAX_COMMENT_CHARS:
|
| + mr.errors.comment = 'Comment is too long'
|
| + if len(parsed.summary) > tracker_constants.MAX_SUMMARY_CHARS:
|
| + mr.errors.summary = 'Summary is too long'
|
| +
|
| + if parsed.users.owner_id is None:
|
| + mr.errors.owner = 'Invalid owner username'
|
| + else:
|
| + valid, msg = tracker_helpers.IsValidIssueOwner(
|
| + mr.cnxn, mr.project, parsed.users.owner_id, self.services)
|
| + if not valid:
|
| + mr.errors.owner = msg
|
| +
|
| + if None in parsed.users.cc_ids:
|
| + mr.errors.cc = 'Invalid Cc username'
|
| +
|
| + field_helpers.ValidateCustomFields(
|
| + mr, self.services, field_values, config, mr.errors)
|
| +
|
| + new_local_id = None
|
| +
|
| + if not mr.errors.AnyErrors():
|
| + try:
|
| + if parsed.attachments:
|
| + new_bytes_used = tracker_helpers.ComputeNewQuotaBytesUsed(
|
| + mr.project, parsed.attachments)
|
| + self.services.project.UpdateProject(
|
| + mr.cnxn, mr.project.project_id,
|
| + attachment_bytes_used=new_bytes_used)
|
| +
|
| + template_content = ''
|
| + for wkp in config.templates:
|
| + if wkp.name == parsed.template_name:
|
| + template_content = wkp.content
|
| + marked_comment = _MarkupDescriptionOnInput(
|
| + parsed.comment, template_content)
|
| + has_star = 'star' in post_data and post_data['star'] == '1'
|
| +
|
| + new_local_id = self.services.issue.CreateIssue(
|
| + mr.cnxn, self.services,
|
| + mr.project_id, parsed.summary, parsed.status, parsed.users.owner_id,
|
| + parsed.users.cc_ids, labels, field_values,
|
| + component_ids, reporter_id, marked_comment,
|
| + blocked_on=parsed.blocked_on.iids, blocking=parsed.blocking.iids,
|
| + attachments=parsed.attachments)
|
| + self.services.project.UpdateRecentActivity(
|
| + mr.cnxn, mr.project.project_id)
|
| +
|
| + issue = self.services.issue.GetIssueByLocalID(
|
| + mr.cnxn, mr.project_id, new_local_id)
|
| +
|
| + if has_star:
|
| + self.services.issue_star.SetStar(
|
| + mr.cnxn, self.services, config, issue.issue_id, reporter_id, True)
|
| +
|
| + except tracker_helpers.OverAttachmentQuota:
|
| + mr.errors.attachments = 'Project attachment quota exceeded.'
|
| +
|
| + counts = {actionlimit.ISSUE_COMMENT: 1,
|
| + actionlimit.ISSUE_ATTACHMENT: len(parsed.attachments)}
|
| + self.CountRateLimitedActions(mr, counts)
|
| +
|
| + if mr.errors.AnyErrors():
|
| + component_required = False
|
| + for wkp in config.templates:
|
| + if wkp.name == parsed.template_name:
|
| + component_required = wkp.component_required
|
| + self.PleaseCorrect(
|
| + mr, initial_summary=parsed.summary, initial_status=parsed.status,
|
| + initial_owner=parsed.users.owner_username,
|
| + initial_cc=', '.join(parsed.users.cc_usernames),
|
| + initial_components=', '.join(parsed.components.paths),
|
| + initial_comment=parsed.comment, labels=bounce_labels,
|
| + fields=bounce_fields,
|
| + initial_blocked_on=parsed.blocked_on.entered_str,
|
| + initial_blocking=parsed.blocking.entered_str,
|
| + component_required=ezt.boolean(component_required))
|
| + return
|
| +
|
| + notify.PrepareAndSendIssueChangeNotification(
|
| + mr.project_id, new_local_id, mr.request.host,
|
| + reporter_id, 0) # Initial description is comment 0.
|
| +
|
| + notify.PrepareAndSendIssueBlockingNotification(
|
| + mr.project_id, mr.request.host, new_local_id,
|
| + parsed.blocked_on.iids, reporter_id)
|
| +
|
| + # format a redirect url
|
| + return framework_helpers.FormatAbsoluteURL(
|
| + mr, urls.ISSUE_DETAIL, id=new_local_id)
|
| +
|
| +
|
| +def _MarkupDescriptionOnInput(content, tmpl_text):
|
| + """Return HTML for the content of an issue description or comment.
|
| +
|
| + Args:
|
| + content: the text sumbitted by the user, any user-entered markup
|
| + has already been escaped.
|
| + tmpl_text: the initial text that was put into the textarea.
|
| +
|
| + Returns:
|
| + The description content text with template lines highlighted.
|
| + """
|
| + tmpl_lines = tmpl_text.split('\n')
|
| + tmpl_lines = [pl.strip() for pl in tmpl_lines if pl.strip()]
|
| +
|
| + entered_lines = content.split('\n')
|
| + marked_lines = [_MarkupDescriptionLineOnInput(line, tmpl_lines)
|
| + for line in entered_lines]
|
| + return '\n'.join(marked_lines)
|
| +
|
| +
|
| +def _MarkupDescriptionLineOnInput(line, tmpl_lines):
|
| + """Markup one line of an issue description that was just entered.
|
| +
|
| + Args:
|
| + line: string containing one line of the user-entered comment.
|
| + tmpl_lines: list of strings for the text of the template lines.
|
| +
|
| + Returns:
|
| + The same user-entered line, or that line highlighted to
|
| + indicate that it came from the issue template.
|
| + """
|
| + for tmpl_line in tmpl_lines:
|
| + if line.startswith(tmpl_line):
|
| + return '<b>' + tmpl_line + '</b>' + line[len(tmpl_line):]
|
| +
|
| + return line
|
| +
|
| +
|
| +def _DiscardUnusedTemplateLabelPrefixes(labels):
|
| + """Drop any labels that end in '-?'.
|
| +
|
| + Args:
|
| + labels: a list of label strings.
|
| +
|
| + Returns:
|
| + A list of the same labels, but without any that end with '-?'.
|
| + Those label prefixes in the new issue templates are intended to
|
| + prompt the user to enter some label with that prefix, but if
|
| + nothing is entered there, we do not store anything.
|
| + """
|
| + return [lab for lab in labels
|
| + if not lab.endswith('-?')]
|
| +
|
| +
|
| +def _SelectTemplate(requested_template_name, config, is_member):
|
| + """Return the template to show to the user in this situation.
|
| +
|
| + Args:
|
| + requested_template_name: name of template requested by user, or None.
|
| + config: ProjectIssueConfig for this project.
|
| + is_member: True if user is a project member.
|
| +
|
| + Returns:
|
| + A Template PB with info needed to populate the issue entry form.
|
| + """
|
| + if requested_template_name:
|
| + for template in config.templates:
|
| + if requested_template_name == template.name:
|
| + return template
|
| + logging.info('Issue template name %s not found', requested_template_name)
|
| +
|
| + # No template was specified, or it was not found, so go with a default.
|
| + if is_member:
|
| + default_id = config.default_template_for_developers
|
| + else:
|
| + default_id = config.default_template_for_users
|
| +
|
| + # Newly created projects have no default templates specified, use hard-coded
|
| + # positions of the templates that are defined in tracker_constants.
|
| + if default_id == 0:
|
| + if is_member:
|
| + return config.templates[0]
|
| + elif len(config.templates) > 1:
|
| + return config.templates[1]
|
| +
|
| + # This project has a relevant default template ID that we can use.
|
| + for template in config.templates:
|
| + if template.template_id == default_id:
|
| + return template
|
| +
|
| + # If it was not found, just go with a template that we know exists.
|
| + return config.templates[0]
|
|
|