blob: bbc35444bca8e1d37af11c016fc40aaaa48c811f [file] [log] [blame]
# 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]