OLD | NEW |
(Empty) | |
| 1 # Copyright 2016 The Chromium Authors. All rights reserved. |
| 2 # Use of this source code is govered by a BSD-style |
| 3 # license that can be found in the LICENSE file or at |
| 4 # https://developers.google.com/open-source/licenses/bsd |
| 5 |
| 6 """Servlet that implements the entry of new issues.""" |
| 7 |
| 8 import logging |
| 9 import time |
| 10 from third_party import ezt |
| 11 |
| 12 from features import notify |
| 13 from framework import actionlimit |
| 14 from framework import framework_bizobj |
| 15 from framework import framework_constants |
| 16 from framework import framework_helpers |
| 17 from framework import framework_views |
| 18 from framework import permissions |
| 19 from framework import servlet |
| 20 from framework import template_helpers |
| 21 from framework import urls |
| 22 from tracker import field_helpers |
| 23 from tracker import tracker_bizobj |
| 24 from tracker import tracker_constants |
| 25 from tracker import tracker_helpers |
| 26 from tracker import tracker_views |
| 27 |
| 28 PLACEHOLDER_SUMMARY = 'Enter one-line summary' |
| 29 |
| 30 |
| 31 class IssueEntry(servlet.Servlet): |
| 32 """IssueEntry shows a page with a simple form to enter a new issue.""" |
| 33 |
| 34 _PAGE_TEMPLATE = 'tracker/issue-entry-page.ezt' |
| 35 _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES |
| 36 _CAPTCHA_ACTION_TYPES = [actionlimit.ISSUE_COMMENT] |
| 37 |
| 38 def AssertBasePermission(self, mr): |
| 39 """Check whether the user has any permission to visit this page. |
| 40 |
| 41 Args: |
| 42 mr: commonly used info parsed from the request. |
| 43 """ |
| 44 super(IssueEntry, self).AssertBasePermission(mr) |
| 45 if not self.CheckPerm(mr, permissions.CREATE_ISSUE): |
| 46 raise permissions.PermissionException( |
| 47 'User is not allowed to enter an issue') |
| 48 |
| 49 def GatherPageData(self, mr): |
| 50 """Build up a dictionary of data values to use when rendering the page. |
| 51 |
| 52 Args: |
| 53 mr: commonly used info parsed from the request. |
| 54 |
| 55 Returns: |
| 56 Dict of values used by EZT for rendering the page. |
| 57 """ |
| 58 with self.profiler.Phase('getting config'): |
| 59 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id) |
| 60 |
| 61 # In addition to checking perms, we adjust some default field values for |
| 62 # project members. |
| 63 is_member = framework_bizobj.UserIsInProject( |
| 64 mr.project, mr.auth.effective_ids) |
| 65 page_perms = self.MakePagePerms( |
| 66 mr, None, |
| 67 permissions.CREATE_ISSUE, |
| 68 permissions.SET_STAR, |
| 69 permissions.EDIT_ISSUE, |
| 70 permissions.EDIT_ISSUE_SUMMARY, |
| 71 permissions.EDIT_ISSUE_STATUS, |
| 72 permissions.EDIT_ISSUE_OWNER, |
| 73 permissions.EDIT_ISSUE_CC) |
| 74 |
| 75 wkp = _SelectTemplate(mr.template_name, config, is_member) |
| 76 |
| 77 if wkp.summary: |
| 78 initial_summary = wkp.summary |
| 79 initial_summary_must_be_edited = wkp.summary_must_be_edited |
| 80 else: |
| 81 initial_summary = PLACEHOLDER_SUMMARY |
| 82 initial_summary_must_be_edited = True |
| 83 |
| 84 if wkp.status: |
| 85 initial_status = wkp.status |
| 86 elif is_member: |
| 87 initial_status = 'Accepted' |
| 88 else: |
| 89 initial_status = 'New' # not offering meta, only used in hidden field. |
| 90 |
| 91 component_paths = [] |
| 92 for component_id in wkp.component_ids: |
| 93 component_paths.append( |
| 94 tracker_bizobj.FindComponentDefByID(component_id, config).path) |
| 95 initial_components = ', '.join(component_paths) |
| 96 |
| 97 if wkp.owner_id: |
| 98 initial_owner = framework_views.MakeUserView( |
| 99 mr.cnxn, self.services.user, wkp.owner_id) |
| 100 initial_owner_name = initial_owner.email |
| 101 elif wkp.owner_defaults_to_member and page_perms.EditIssue: |
| 102 initial_owner_name = mr.auth.user_view.email |
| 103 else: |
| 104 initial_owner_name = '' |
| 105 |
| 106 # Check whether to allow attachments from the entry page |
| 107 allow_attachments = tracker_helpers.IsUnderSoftAttachmentQuota(mr.project) |
| 108 |
| 109 config_view = tracker_views.ConfigView(mr, self.services, config) |
| 110 # If the user followed a link that specified the template name, make sure |
| 111 # that it is also in the menu as the current choice. |
| 112 for template_view in config_view.templates: |
| 113 if template_view.name == mr.template_name: |
| 114 template_view.can_view = ezt.boolean(True) |
| 115 |
| 116 offer_templates = len(list( |
| 117 tmpl for tmpl in config_view.templates if tmpl.can_view)) > 1 |
| 118 restrict_to_known = config.restrict_to_known |
| 119 field_name_set = {fd.field_name.lower() for fd in config.field_defs |
| 120 if not fd.is_deleted} # TODO(jrobbins): restrictions |
| 121 link_or_template_labels = mr.GetListParam('labels', wkp.labels) |
| 122 labels = [lab for lab in link_or_template_labels |
| 123 if not tracker_bizobj.LabelIsMaskedByField(lab, field_name_set)] |
| 124 |
| 125 field_user_views = tracker_views.MakeFieldUserViews( |
| 126 mr.cnxn, wkp, self.services.user) |
| 127 field_views = [ |
| 128 tracker_views.MakeFieldValueView( |
| 129 fd, config, link_or_template_labels, [], wkp.field_values, |
| 130 field_user_views) |
| 131 # TODO(jrobbins): field-level view restrictions, display options |
| 132 for fd in config.field_defs |
| 133 if not fd.is_deleted] |
| 134 |
| 135 page_data = { |
| 136 'issue_tab_mode': 'issueEntry', |
| 137 'initial_summary': initial_summary, |
| 138 'template_summary': initial_summary, |
| 139 'clear_summary_on_click': ezt.boolean( |
| 140 initial_summary_must_be_edited and |
| 141 'initial_summary' not in mr.form_overrides), |
| 142 'must_edit_summary': ezt.boolean(initial_summary_must_be_edited), |
| 143 |
| 144 'initial_description': wkp.content, |
| 145 'template_name': wkp.name, |
| 146 'component_required': ezt.boolean(wkp.component_required), |
| 147 'initial_status': initial_status, |
| 148 'initial_owner': initial_owner_name, |
| 149 'initial_components': initial_components, |
| 150 'initial_cc': '', |
| 151 'initial_blocked_on': '', |
| 152 'initial_blocking': '', |
| 153 'labels': labels, |
| 154 'fields': field_views, |
| 155 |
| 156 'any_errors': ezt.boolean(mr.errors.AnyErrors()), |
| 157 'page_perms': page_perms, |
| 158 'allow_attachments': ezt.boolean(allow_attachments), |
| 159 'max_attach_size': template_helpers.BytesKbOrMb( |
| 160 framework_constants.MAX_POST_BODY_SIZE), |
| 161 |
| 162 'offer_templates': ezt.boolean(offer_templates), |
| 163 'config': config_view, |
| 164 |
| 165 'restrict_to_known': ezt.boolean(restrict_to_known), |
| 166 } |
| 167 |
| 168 return page_data |
| 169 |
| 170 def GatherHelpData(self, mr, _page_data): |
| 171 """Return a dict of values to drive on-page user help. |
| 172 |
| 173 Args: |
| 174 mr: commonly used info parsed from the request. |
| 175 _page_data: Dictionary of base and page template data. |
| 176 |
| 177 Returns: |
| 178 A dict of values to drive on-page user help, to be added to page_data. |
| 179 """ |
| 180 is_privileged_domain_user = framework_bizobj.IsPriviledgedDomainUser( |
| 181 mr.auth.user_pb.email) |
| 182 cue = None |
| 183 if (mr.auth.user_id and |
| 184 'privacy_click_through' not in mr.auth.user_pb.dismissed_cues): |
| 185 cue = 'privacy_click_through' |
| 186 |
| 187 return { |
| 188 'is_privileged_domain_user': ezt.boolean(is_privileged_domain_user), |
| 189 'cue': cue, |
| 190 } |
| 191 |
| 192 def ProcessFormData(self, mr, post_data): |
| 193 """Process the issue entry form. |
| 194 |
| 195 Args: |
| 196 mr: commonly used info parsed from the request. |
| 197 post_data: The post_data dict for the current request. |
| 198 |
| 199 Returns: |
| 200 String URL to redirect the user to after processing. |
| 201 """ |
| 202 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id) |
| 203 parsed = tracker_helpers.ParseIssueRequest( |
| 204 mr.cnxn, post_data, self.services, mr.errors, mr.project_name) |
| 205 bounce_labels = parsed.labels[:] |
| 206 bounce_fields = tracker_views.MakeBounceFieldValueViews( |
| 207 parsed.fields.vals, config) |
| 208 field_helpers.ShiftEnumFieldsIntoLabels( |
| 209 parsed.labels, parsed.labels_remove, parsed.fields.vals, |
| 210 parsed.fields.vals_remove, config) |
| 211 field_values = field_helpers.ParseFieldValues( |
| 212 mr.cnxn, self.services.user, parsed.fields.vals, config) |
| 213 |
| 214 labels = _DiscardUnusedTemplateLabelPrefixes(parsed.labels) |
| 215 component_ids = tracker_helpers.LookupComponentIDs( |
| 216 parsed.components.paths, config, mr.errors) |
| 217 |
| 218 reporter_id = mr.auth.user_id |
| 219 self.CheckCaptcha(mr, post_data) |
| 220 |
| 221 if not parsed.summary.strip(): |
| 222 mr.errors.summary = 'Summary is required' |
| 223 |
| 224 if not parsed.comment.strip(): |
| 225 mr.errors.comment = 'A description is required' |
| 226 |
| 227 if len(parsed.comment) > tracker_constants.MAX_COMMENT_CHARS: |
| 228 mr.errors.comment = 'Comment is too long' |
| 229 if len(parsed.summary) > tracker_constants.MAX_SUMMARY_CHARS: |
| 230 mr.errors.summary = 'Summary is too long' |
| 231 |
| 232 if parsed.users.owner_id is None: |
| 233 mr.errors.owner = 'Invalid owner username' |
| 234 else: |
| 235 valid, msg = tracker_helpers.IsValidIssueOwner( |
| 236 mr.cnxn, mr.project, parsed.users.owner_id, self.services) |
| 237 if not valid: |
| 238 mr.errors.owner = msg |
| 239 |
| 240 if None in parsed.users.cc_ids: |
| 241 mr.errors.cc = 'Invalid Cc username' |
| 242 |
| 243 field_helpers.ValidateCustomFields( |
| 244 mr, self.services, field_values, config, mr.errors) |
| 245 |
| 246 new_local_id = None |
| 247 |
| 248 if not mr.errors.AnyErrors(): |
| 249 try: |
| 250 if parsed.attachments: |
| 251 new_bytes_used = tracker_helpers.ComputeNewQuotaBytesUsed( |
| 252 mr.project, parsed.attachments) |
| 253 self.services.project.UpdateProject( |
| 254 mr.cnxn, mr.project.project_id, |
| 255 attachment_bytes_used=new_bytes_used) |
| 256 |
| 257 template_content = '' |
| 258 for wkp in config.templates: |
| 259 if wkp.name == parsed.template_name: |
| 260 template_content = wkp.content |
| 261 marked_comment = _MarkupDescriptionOnInput( |
| 262 parsed.comment, template_content) |
| 263 has_star = 'star' in post_data and post_data['star'] == '1' |
| 264 |
| 265 new_local_id = self.services.issue.CreateIssue( |
| 266 mr.cnxn, self.services, |
| 267 mr.project_id, parsed.summary, parsed.status, parsed.users.owner_id, |
| 268 parsed.users.cc_ids, labels, field_values, |
| 269 component_ids, reporter_id, marked_comment, |
| 270 blocked_on=parsed.blocked_on.iids, blocking=parsed.blocking.iids, |
| 271 attachments=parsed.attachments) |
| 272 self.services.project.UpdateRecentActivity( |
| 273 mr.cnxn, mr.project.project_id) |
| 274 |
| 275 issue = self.services.issue.GetIssueByLocalID( |
| 276 mr.cnxn, mr.project_id, new_local_id) |
| 277 |
| 278 if has_star: |
| 279 self.services.issue_star.SetStar( |
| 280 mr.cnxn, self.services, config, issue.issue_id, reporter_id, True) |
| 281 |
| 282 except tracker_helpers.OverAttachmentQuota: |
| 283 mr.errors.attachments = 'Project attachment quota exceeded.' |
| 284 |
| 285 counts = {actionlimit.ISSUE_COMMENT: 1, |
| 286 actionlimit.ISSUE_ATTACHMENT: len(parsed.attachments)} |
| 287 self.CountRateLimitedActions(mr, counts) |
| 288 |
| 289 if mr.errors.AnyErrors(): |
| 290 component_required = False |
| 291 for wkp in config.templates: |
| 292 if wkp.name == parsed.template_name: |
| 293 component_required = wkp.component_required |
| 294 self.PleaseCorrect( |
| 295 mr, initial_summary=parsed.summary, initial_status=parsed.status, |
| 296 initial_owner=parsed.users.owner_username, |
| 297 initial_cc=', '.join(parsed.users.cc_usernames), |
| 298 initial_components=', '.join(parsed.components.paths), |
| 299 initial_comment=parsed.comment, labels=bounce_labels, |
| 300 fields=bounce_fields, |
| 301 initial_blocked_on=parsed.blocked_on.entered_str, |
| 302 initial_blocking=parsed.blocking.entered_str, |
| 303 component_required=ezt.boolean(component_required)) |
| 304 return |
| 305 |
| 306 notify.PrepareAndSendIssueChangeNotification( |
| 307 mr.project_id, new_local_id, mr.request.host, |
| 308 reporter_id, 0) # Initial description is comment 0. |
| 309 |
| 310 notify.PrepareAndSendIssueBlockingNotification( |
| 311 mr.project_id, mr.request.host, new_local_id, |
| 312 parsed.blocked_on.iids, reporter_id) |
| 313 |
| 314 # format a redirect url |
| 315 return framework_helpers.FormatAbsoluteURL( |
| 316 mr, urls.ISSUE_DETAIL, id=new_local_id) |
| 317 |
| 318 |
| 319 def _MarkupDescriptionOnInput(content, tmpl_text): |
| 320 """Return HTML for the content of an issue description or comment. |
| 321 |
| 322 Args: |
| 323 content: the text sumbitted by the user, any user-entered markup |
| 324 has already been escaped. |
| 325 tmpl_text: the initial text that was put into the textarea. |
| 326 |
| 327 Returns: |
| 328 The description content text with template lines highlighted. |
| 329 """ |
| 330 tmpl_lines = tmpl_text.split('\n') |
| 331 tmpl_lines = [pl.strip() for pl in tmpl_lines if pl.strip()] |
| 332 |
| 333 entered_lines = content.split('\n') |
| 334 marked_lines = [_MarkupDescriptionLineOnInput(line, tmpl_lines) |
| 335 for line in entered_lines] |
| 336 return '\n'.join(marked_lines) |
| 337 |
| 338 |
| 339 def _MarkupDescriptionLineOnInput(line, tmpl_lines): |
| 340 """Markup one line of an issue description that was just entered. |
| 341 |
| 342 Args: |
| 343 line: string containing one line of the user-entered comment. |
| 344 tmpl_lines: list of strings for the text of the template lines. |
| 345 |
| 346 Returns: |
| 347 The same user-entered line, or that line highlighted to |
| 348 indicate that it came from the issue template. |
| 349 """ |
| 350 for tmpl_line in tmpl_lines: |
| 351 if line.startswith(tmpl_line): |
| 352 return '<b>' + tmpl_line + '</b>' + line[len(tmpl_line):] |
| 353 |
| 354 return line |
| 355 |
| 356 |
| 357 def _DiscardUnusedTemplateLabelPrefixes(labels): |
| 358 """Drop any labels that end in '-?'. |
| 359 |
| 360 Args: |
| 361 labels: a list of label strings. |
| 362 |
| 363 Returns: |
| 364 A list of the same labels, but without any that end with '-?'. |
| 365 Those label prefixes in the new issue templates are intended to |
| 366 prompt the user to enter some label with that prefix, but if |
| 367 nothing is entered there, we do not store anything. |
| 368 """ |
| 369 return [lab for lab in labels |
| 370 if not lab.endswith('-?')] |
| 371 |
| 372 |
| 373 def _SelectTemplate(requested_template_name, config, is_member): |
| 374 """Return the template to show to the user in this situation. |
| 375 |
| 376 Args: |
| 377 requested_template_name: name of template requested by user, or None. |
| 378 config: ProjectIssueConfig for this project. |
| 379 is_member: True if user is a project member. |
| 380 |
| 381 Returns: |
| 382 A Template PB with info needed to populate the issue entry form. |
| 383 """ |
| 384 if requested_template_name: |
| 385 for template in config.templates: |
| 386 if requested_template_name == template.name: |
| 387 return template |
| 388 logging.info('Issue template name %s not found', requested_template_name) |
| 389 |
| 390 # No template was specified, or it was not found, so go with a default. |
| 391 if is_member: |
| 392 default_id = config.default_template_for_developers |
| 393 else: |
| 394 default_id = config.default_template_for_users |
| 395 |
| 396 # Newly created projects have no default templates specified, use hard-coded |
| 397 # positions of the templates that are defined in tracker_constants. |
| 398 if default_id == 0: |
| 399 if is_member: |
| 400 return config.templates[0] |
| 401 elif len(config.templates) > 1: |
| 402 return config.templates[1] |
| 403 |
| 404 # This project has a relevant default template ID that we can use. |
| 405 for template in config.templates: |
| 406 if template.template_id == default_id: |
| 407 return template |
| 408 |
| 409 # If it was not found, just go with a template that we know exists. |
| 410 return config.templates[0] |
OLD | NEW |