blob: 77de114984ff2355217a31fb62e18b0db32f4d20 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# Copyright 2016 The Chromium Authors. All rights reserved.
2# Use of this source code is governed 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."""
7from __future__ import print_function
8from __future__ import division
9from __future__ import absolute_import
10
11import collections
12import difflib
13import logging
14import string
15import time
16
17from businesslogic import work_env
18from features import hotlist_helpers
19from features import send_notifications
20from framework import exceptions
21from framework import framework_bizobj
22from framework import framework_constants
23from framework import framework_helpers
24from framework import framework_views
25from framework import permissions
26from framework import servlet
27from framework import template_helpers
28from framework import urls
29import ezt
30from tracker import field_helpers
31from tracker import template_helpers as issue_tmpl_helpers
32from tracker import tracker_bizobj
33from tracker import tracker_constants
34from tracker import tracker_helpers
35from tracker import tracker_views
36from proto import tracker_pb2
37
38PLACEHOLDER_SUMMARY = 'Enter one-line summary'
39PHASES_WITH_MILESTONES = ['Beta', 'Stable', 'Stable-Exp', 'Stable-Full']
40CORP_RESTRICTION_LABEL = 'Restrict-View-Google'
41RESTRICTED_FLT_FIELDS = ['notice', 'whitepaper', 'm-approved']
42
43
44class IssueEntry(servlet.Servlet):
45 """IssueEntry shows a page with a simple form to enter a new issue."""
46
47 _PAGE_TEMPLATE = 'tracker/issue-entry-page.ezt'
48 _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
49
50 # The issue filing wizard is a separate app that posted back to Monorail's
51 # issue entry page. To make this possible for the wizard, we need to allow
52 # XHR-scoped XSRF tokens.
53 ALLOW_XHR = True
54
55 def AssertBasePermission(self, mr):
56 """Check whether the user has any permission to visit this page.
57
58 Args:
59 mr: commonly used info parsed from the request.
60 """
61 super(IssueEntry, self).AssertBasePermission(mr)
62 if not self.CheckPerm(mr, permissions.CREATE_ISSUE):
63 raise permissions.PermissionException(
64 'User is not allowed to enter an issue')
65
66 def GatherPageData(self, mr):
67 """Build up a dictionary of data values to use when rendering the page.
68
69 Args:
70 mr: commonly used info parsed from the request.
71
72 Returns:
73 Dict of values used by EZT for rendering the page.
74 """
75 with mr.profiler.Phase('getting config'):
76 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
77
78 # In addition to checking perms, we adjust some default field values for
79 # project members.
80 is_member = framework_bizobj.UserIsInProject(
81 mr.project, mr.auth.effective_ids)
82 page_perms = self.MakePagePerms(
83 mr, None,
84 permissions.CREATE_ISSUE,
85 permissions.SET_STAR,
86 permissions.EDIT_ISSUE,
87 permissions.EDIT_ISSUE_SUMMARY,
88 permissions.EDIT_ISSUE_STATUS,
89 permissions.EDIT_ISSUE_OWNER,
90 permissions.EDIT_ISSUE_CC)
91
92
93 with work_env.WorkEnv(mr, self.services) as we:
94 userprefs = we.GetUserPrefs(mr.auth.user_id)
95 code_font = any(pref for pref in userprefs.prefs
96 if pref.name == 'code_font' and pref.value == 'true')
97
98 template = self._GetTemplate(mr.cnxn, config, mr.template_name, is_member)
99
100 if template.summary:
101 initial_summary = template.summary
102 initial_summary_must_be_edited = template.summary_must_be_edited
103 else:
104 initial_summary = PLACEHOLDER_SUMMARY
105 initial_summary_must_be_edited = True
106
107 if template.status:
108 initial_status = template.status
109 elif is_member:
110 initial_status = 'Accepted'
111 else:
112 initial_status = 'New' # not offering meta, only used in hidden field.
113
114 component_paths = []
115 for component_id in template.component_ids:
116 component_paths.append(
117 tracker_bizobj.FindComponentDefByID(component_id, config).path)
118 initial_components = ', '.join(component_paths)
119
120 if template.owner_id:
121 initial_owner = framework_views.MakeUserView(
122 mr.cnxn, self.services.user, template.owner_id)
123 elif template.owner_defaults_to_member and page_perms.EditIssue:
124 initial_owner = mr.auth.user_view
125 else:
126 initial_owner = None
127
128 if initial_owner:
129 initial_owner_name = initial_owner.email
130 owner_avail_state = initial_owner.avail_state
131 owner_avail_message_short = initial_owner.avail_message_short
132 else:
133 initial_owner_name = ''
134 owner_avail_state = None
135 owner_avail_message_short = None
136
137 # Check whether to allow attachments from the entry page
138 allow_attachments = tracker_helpers.IsUnderSoftAttachmentQuota(mr.project)
139
140 config_view = tracker_views.ConfigView(mr, self.services, config, template)
141 # If the user followed a link that specified the template name, make sure
142 # that it is also in the menu as the current choice.
143 # TODO(jeffcarp): Unit test this.
144 config_view.template_view.can_view = ezt.boolean(True)
145
146 # TODO(jeffcarp): Unit test this.
147 offer_templates = len(config_view.template_names) > 1
148 restrict_to_known = config.restrict_to_known
149 link_or_template_labels = mr.GetListParam('labels', template.labels)
150 labels, _derived_labels = tracker_bizobj.ExplicitAndDerivedNonMaskedLabels(
151 link_or_template_labels, [], config)
152
153 # Users with restrict_new_issues user pref automatically add R-V-G.
154 with work_env.WorkEnv(mr, self.services) as we:
155 userprefs = we.GetUserPrefs(mr.auth.user_id)
156 restrict_new_issues = any(
157 up.name == 'restrict_new_issues' and up.value == 'true'
158 for up in userprefs.prefs)
159 if restrict_new_issues:
160 if not any(lab.lower().startswith('restrict-view-') for lab in labels):
161 labels.append(CORP_RESTRICTION_LABEL)
162
163 field_user_views = tracker_views.MakeFieldUserViews(
164 mr.cnxn, template, self.services.user)
165 approval_ids = [av.approval_id for av in template.approval_values]
166 field_views = tracker_views.MakeAllFieldValueViews(
167 config, link_or_template_labels, [], template.field_values,
168 field_user_views, parent_approval_ids=approval_ids,
169 phases=template.phases)
170 # TODO(jojwang): monorail:6305, remove this hack when Edit perms for field
171 # values are implemented.
172 field_views = [view for view in field_views
173 if view.field_name.lower() not in RESTRICTED_FLT_FIELDS]
174 uneditable_fields = ezt.boolean(False)
175 for fv in field_views:
176 if permissions.CanEditValueForFieldDef(
177 mr.auth.effective_ids, mr.perms, mr.project, fv.field_def.field_def):
178 fv.is_editable = ezt.boolean(True)
179 else:
180 fv.is_editable = ezt.boolean(False)
181 uneditable_fields = ezt.boolean(True)
182
183 # TODO(jrobbins): remove "or []" after next release.
184 (prechecked_approvals, required_approval_ids,
185 phases) = issue_tmpl_helpers.GatherApprovalsPageData(
186 template.approval_values or [], template.phases, config)
187 approvals = [view for view in field_views if view.field_id in
188 approval_ids]
189
190 page_data = {
191 'issue_tab_mode':
192 'issueEntry',
193 'initial_summary':
194 initial_summary,
195 'template_summary':
196 initial_summary,
197 'clear_summary_on_click':
198 ezt.boolean(
199 initial_summary_must_be_edited and
200 'initial_summary' not in mr.form_overrides),
201 'must_edit_summary':
202 ezt.boolean(initial_summary_must_be_edited),
203 'initial_description':
204 template.content,
205 'template_name':
206 template.name,
207 'component_required':
208 ezt.boolean(template.component_required),
209 'initial_status':
210 initial_status,
211 'initial_owner':
212 initial_owner_name,
213 'owner_avail_state':
214 owner_avail_state,
215 'owner_avail_message_short':
216 owner_avail_message_short,
217 'initial_components':
218 initial_components,
219 'initial_cc':
220 '',
221 'initial_blocked_on':
222 '',
223 'initial_blocking':
224 '',
225 'initial_hotlists':
226 '',
227 'labels':
228 labels,
229 'fields':
230 field_views,
231 'any_errors':
232 ezt.boolean(mr.errors.AnyErrors()),
233 'page_perms':
234 page_perms,
235 'allow_attachments':
236 ezt.boolean(allow_attachments),
237 'max_attach_size':
238 template_helpers.BytesKbOrMb(
239 framework_constants.MAX_POST_BODY_SIZE),
240 'offer_templates':
241 ezt.boolean(offer_templates),
242 'config':
243 config_view,
244 'restrict_to_known':
245 ezt.boolean(restrict_to_known),
246 'is_member':
247 ezt.boolean(is_member),
248 'code_font':
249 ezt.boolean(code_font),
250 # The following are necessary for displaying phases that come with
251 # this template. These are read-only.
252 'allow_edit':
253 ezt.boolean(False),
254 'uneditable_fields':
255 uneditable_fields,
256 'initial_phases':
257 phases,
258 'approvals':
259 approvals,
260 'prechecked_approvals':
261 prechecked_approvals,
262 'required_approval_ids':
263 required_approval_ids,
264 # See monorail:4692 and the use of PHASES_WITH_MILESTONES
265 # in elements/flt/mr-launch-overview/mr-phase.js
266 'issue_phase_names':
267 list(
268 {
269 phase.name.lower()
270 for phase in phases
271 if phase.name in PHASES_WITH_MILESTONES
272 }),
273 }
274
275 return page_data
276
277 def GatherHelpData(self, mr, page_data):
278 """Return a dict of values to drive on-page user help.
279
280 Args:
281 mr: commonly used info parsed from the request.
282 page_data: Dictionary of base and page template data.
283
284 Returns:
285 A dict of values to drive on-page user help, to be added to page_data.
286 """
287 help_data = super(IssueEntry, self).GatherHelpData(mr, page_data)
288 dismissed = []
289 if mr.auth.user_pb:
290 with work_env.WorkEnv(mr, self.services) as we:
291 userprefs = we.GetUserPrefs(mr.auth.user_id)
292 dismissed = [
293 pv.name for pv in userprefs.prefs if pv.value == 'true']
294 is_privileged_domain_user = framework_bizobj.IsPriviledgedDomainUser(
295 mr.auth.user_pb.email)
296 if (mr.auth.user_id and
297 'privacy_click_through' not in dismissed):
298 help_data['cue'] = 'privacy_click_through'
299 elif (mr.auth.user_id and
300 'code_of_conduct' not in dismissed):
301 help_data['cue'] = 'code_of_conduct'
302
303 help_data.update({
304 'is_privileged_domain_user': ezt.boolean(is_privileged_domain_user),
305 })
306 return help_data
307
308 def ProcessFormData(self, mr, post_data):
309 """Process the issue entry form.
310
311 Args:
312 mr: commonly used info parsed from the request.
313 post_data: The post_data dict for the current request.
314
315 Returns:
316 String URL to redirect the user to after processing.
317 """
318 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
319
320 parsed = tracker_helpers.ParseIssueRequest(
321 mr.cnxn, post_data, self.services, mr.errors, mr.project_name)
322
323 # Updates parsed.labels and parsed.fields in place.
324 field_helpers.ShiftEnumFieldsIntoLabels(
325 parsed.labels, parsed.labels_remove, parsed.fields.vals,
326 parsed.fields.vals_remove, config)
327
328 labels = _DiscardUnusedTemplateLabelPrefixes(parsed.labels)
329
330 is_member = framework_bizobj.UserIsInProject(
331 mr.project, mr.auth.effective_ids)
332 template = self._GetTemplate(
333 mr.cnxn, config, parsed.template_name, is_member)
334
335 (approval_values,
336 phases) = issue_tmpl_helpers.FilterApprovalsAndPhases(
337 template.approval_values or [], template.phases, config)
338
339 # Issue PB with only approval_values and labels filled out, for the purpose
340 # of computing applicable fields.
341 partial_issue = tracker_pb2.Issue(
342 approval_values=approval_values, labels=labels)
343 applicable_fields = field_helpers.ListApplicableFieldDefs(
344 [partial_issue], config)
345
346 bounce_labels = parsed.labels[:]
347 bounce_fields = tracker_views.MakeBounceFieldValueViews(
348 parsed.fields.vals,
349 parsed.fields.phase_vals,
350 config,
351 applicable_fields=applicable_fields)
352
353 phase_ids_by_name = {
354 phase.name.lower(): [phase.phase_id] for phase in template.phases}
355 field_values = field_helpers.ParseFieldValues(
356 mr.cnxn, self.services.user, parsed.fields.vals,
357 parsed.fields.phase_vals, config,
358 phase_ids_by_name=phase_ids_by_name)
359
360 component_ids = tracker_helpers.LookupComponentIDs(
361 parsed.components.paths, config, mr.errors)
362
363 if not parsed.summary.strip() or parsed.summary == PLACEHOLDER_SUMMARY:
364 mr.errors.summary = 'Summary is required'
365
366 if not parsed.comment.strip():
367 mr.errors.comment = 'A description is required'
368
369 if len(parsed.comment) > tracker_constants.MAX_COMMENT_CHARS:
370 mr.errors.comment = 'Comment is too long'
371 if len(parsed.summary) > tracker_constants.MAX_SUMMARY_CHARS:
372 mr.errors.summary = 'Summary is too long'
373
374 if _MatchesTemplate(parsed.comment, template):
375 mr.errors.comment = 'Template must be filled out.'
376
377 if parsed.users.owner_id is None:
378 mr.errors.owner = 'Invalid owner username'
379 else:
380 valid, msg = tracker_helpers.IsValidIssueOwner(
381 mr.cnxn, mr.project, parsed.users.owner_id, self.services)
382 if not valid:
383 mr.errors.owner = msg
384
385 if None in parsed.users.cc_ids:
386 mr.errors.cc = 'Invalid Cc username'
387
388 field_helpers.AssertCustomFieldsEditPerms(
389 mr, config, field_values, [], [], labels, [])
390 field_helpers.ApplyRestrictedDefaultValues(
391 mr, config, field_values, labels, template.field_values,
392 template.labels)
393
394 # This ValidateCustomFields call is redundant with work already done
395 # in CreateIssue. However, this instance passes in an ezt_errors object
396 # to allow showing related errors next to the fields they happen on.
397 field_helpers.ValidateCustomFields(
398 mr.cnxn,
399 self.services,
400 field_values,
401 config,
402 mr.project,
403 ezt_errors=mr.errors,
404 issue=partial_issue)
405
406 hotlist_pbs = ProcessParsedHotlistRefs(
407 mr, self.services, parsed.hotlists.hotlist_refs)
408
409 if not mr.errors.AnyErrors():
410 with work_env.WorkEnv(mr, self.services) as we:
411 try:
412 if parsed.attachments:
413 new_bytes_used = tracker_helpers.ComputeNewQuotaBytesUsed(
414 mr.project, parsed.attachments)
415 # TODO(jrobbins): Make quota be calculated and stored as
416 # part of applying the comment.
417 self.services.project.UpdateProject(
418 mr.cnxn, mr.project.project_id,
419 attachment_bytes_used=new_bytes_used)
420
421 marked_description = tracker_helpers.MarkupDescriptionOnInput(
422 parsed.comment, template.content)
423 has_star = 'star' in post_data and post_data['star'] == '1'
424
425 if approval_values:
426 _AttachDefaultApprovers(config, approval_values)
427
428 # To preserve previous behavior, do not raise filter rule errors.
429 issue, _ = we.CreateIssue(
430 mr.project_id,
431 parsed.summary,
432 parsed.status,
433 parsed.users.owner_id,
434 parsed.users.cc_ids,
435 labels,
436 field_values,
437 component_ids,
438 marked_description,
439 blocked_on=parsed.blocked_on.iids,
440 blocking=parsed.blocking.iids,
441 dangling_blocked_on=[
442 tracker_pb2.DanglingIssueRef(ext_issue_identifier=ref_string)
443 for ref_string in parsed.blocked_on.federated_ref_strings
444 ],
445 dangling_blocking=[
446 tracker_pb2.DanglingIssueRef(ext_issue_identifier=ref_string)
447 for ref_string in parsed.blocking.federated_ref_strings
448 ],
449 attachments=parsed.attachments,
450 approval_values=approval_values,
451 phases=phases,
452 raise_filter_errors=False)
453
454 if has_star:
455 we.StarIssue(issue, True)
456
457 if hotlist_pbs:
458 hotlist_ids = {hotlist.hotlist_id for hotlist in hotlist_pbs}
459 issue_tuple = (issue.issue_id, mr.auth.user_id, int(time.time()),
460 '')
461 self.services.features.AddIssueToHotlists(
462 mr.cnxn, hotlist_ids, issue_tuple, self.services.issue,
463 self.services.chart)
464
465 except exceptions.OverAttachmentQuota:
466 mr.errors.attachments = 'Project attachment quota exceeded.'
467 except exceptions.InputException as e:
468 if 'Undefined or deprecated component with id' in e.message:
469 mr.errors.components = 'Undefined or deprecated component'
470
471 mr.template_name = parsed.template_name
472 if mr.errors.AnyErrors():
473 self.PleaseCorrect(
474 mr, initial_summary=parsed.summary, initial_status=parsed.status,
475 initial_owner=parsed.users.owner_username,
476 initial_cc=', '.join(parsed.users.cc_usernames),
477 initial_components=', '.join(parsed.components.paths),
478 initial_comment=parsed.comment, labels=bounce_labels,
479 fields=bounce_fields, template_name=parsed.template_name,
480 initial_blocked_on=parsed.blocked_on.entered_str,
481 initial_blocking=parsed.blocking.entered_str,
482 initial_hotlists=parsed.hotlists.entered_str,
483 component_required=ezt.boolean(template.component_required))
484 return
485
486 # format a redirect url
487 return framework_helpers.FormatAbsoluteURL(
488 mr, urls.ISSUE_DETAIL, id=issue.local_id)
489
490 def _GetTemplate(self, cnxn, config, template_name, is_member):
491 """Tries to fetch template by name and implements default template logic
492 if not found."""
493 template = None
494 if template_name:
495 template_name = template_name.replace('+', ' ')
496 template = self.services.template.GetTemplateByName(cnxn,
497 template_name, config.project_id)
498
499 if not template:
500 if is_member:
501 template_id = config.default_template_for_developers
502 else:
503 template_id = config.default_template_for_users
504 template = self.services.template.GetTemplateById(cnxn, template_id)
505 # If the default templates were deleted, load all and pick the first one.
506 if not template:
507 templates = self.services.template.GetProjectTemplates(cnxn,
508 config.project_id)
509 assert len(templates) > 0, 'Project has no templates!'
510 template = templates[0]
511
512 return template
513
514
515def _AttachDefaultApprovers(config, approval_values):
516 approval_defs_by_id = {ad.approval_id: ad for ad in config.approval_defs}
517 for av in approval_values:
518 ad = approval_defs_by_id.get(av.approval_id)
519 if ad:
520 av.approver_ids = ad.approver_ids[:]
521 else:
522 logging.info('ApprovalDef with approval_id %r could not be found',
523 av.approval_id)
524
525
526def _MatchesTemplate(content, template):
527 content = content.strip(string.whitespace)
528 template_content = template.content.strip(string.whitespace)
529 diff = difflib.unified_diff(content.splitlines(),
530 template_content.splitlines())
531 return len('\n'.join(diff)) == 0
532
533
534def _DiscardUnusedTemplateLabelPrefixes(labels):
535 """Drop any labels that end in '-?'.
536
537 Args:
538 labels: a list of label strings.
539
540 Returns:
541 A list of the same labels, but without any that end with '-?'.
542 Those label prefixes in the new issue templates are intended to
543 prompt the user to enter some label with that prefix, but if
544 nothing is entered there, we do not store anything.
545 """
546 return [lab for lab in labels
547 if not lab.endswith('-?')]
548
549
550def ProcessParsedHotlistRefs(mr, services, parsed_hotlist_refs):
551 """Process a list of ParsedHotlistRefs, returning referenced hotlists.
552
553 This function validates the given ParsedHotlistRefs using four checks; if all
554 of them succeed, then it returns the corresponding hotlist protobuf objects.
555 If any of them fail, it sets the appropriate error string in mr.errors, and
556 returns an empty list.
557
558 Args:
559 mr: the MonorailRequest object
560 services: the service manager
561 parsed_hotlist_refs: a list of ParsedHotlistRef objects
562
563 Returns:
564 on valid input, a list of hotlist protobuf objects
565 if a check fails (and the input is thus considered invalid), an empty list
566
567 Side-effects:
568 if any of the checks fails, set mr.errors.hotlists to a descriptive error
569 """
570 # Pre-processing; common pieces used by functions later.
571 user_hotlist_pbs = services.features.GetHotlistsByUserID(
572 mr.cnxn, mr.auth.user_id)
573 user_hotlist_owners_ids = {hotlist.owner_ids[0]
574 for hotlist in user_hotlist_pbs}
575 user_hotlist_owners_to_emails = services.user.LookupUserEmails(
576 mr.cnxn, user_hotlist_owners_ids)
577 user_hotlist_emails_to_owners = {v: k
578 for k, v in user_hotlist_owners_to_emails.items()}
579 user_hotlist_refs_to_pbs = {
580 hotlist_helpers.HotlistRef(hotlist.owner_ids[0], hotlist.name): hotlist
581 for hotlist in user_hotlist_pbs }
582 short_refs = list()
583 full_refs = list()
584 for parsed_ref in parsed_hotlist_refs:
585 if parsed_ref.user_email is None:
586 short_refs.append(parsed_ref)
587 else:
588 full_refs.append(parsed_ref)
589
590 invalid_names = hotlist_helpers.InvalidParsedHotlistRefsNames(
591 parsed_hotlist_refs, user_hotlist_pbs)
592 if invalid_names:
593 mr.errors.hotlists = (
594 'You have no hotlist(s) named: %s' % ', '.join(invalid_names))
595 return []
596
597 ambiguous_names = hotlist_helpers.AmbiguousShortrefHotlistNames(
598 short_refs, user_hotlist_pbs)
599 if ambiguous_names:
600 mr.errors.hotlists = (
601 'Ambiguous hotlist(s) specified: %s' % ', '.join(ambiguous_names))
602 return []
603
604 # At this point, all refs' named hotlists are guaranteed to exist, and
605 # short refs are guaranteed to be unambiguous;
606 # therefore, short refs are also valid.
607 short_refs_hotlist_names = {sref.hotlist_name for sref in short_refs}
608 shortref_valid_pbs = [hotlist for hotlist in user_hotlist_pbs
609 if hotlist.name in short_refs_hotlist_names]
610
611 invalid_emails = hotlist_helpers.InvalidParsedHotlistRefsEmails(
612 full_refs, user_hotlist_emails_to_owners)
613 if invalid_emails:
614 mr.errors.hotlists = (
615 'You have no hotlist(s) owned by: %s' % ', '.join(invalid_emails))
616 return []
617
618 fullref_valid_pbs, invalid_refs = (
619 hotlist_helpers.GetHotlistsOfParsedHotlistFullRefs(
620 full_refs, user_hotlist_emails_to_owners, user_hotlist_refs_to_pbs))
621 if invalid_refs:
622 invalid_refs_readable = [':'.join(parsed_ref)
623 for parsed_ref in invalid_refs]
624 mr.errors.hotlists = (
625 'Not in your hotlist(s): %s' % ', '.join(invalid_refs_readable))
626 return []
627
628 hotlist_pbs = shortref_valid_pbs + fullref_valid_pbs
629
630 return hotlist_pbs