| # 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] |