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