Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(488)

Side by Side Diff: appengine/monorail/tracker/issueentry.py

Issue 1868553004: Open Source Monorail (Closed) Base URL: https://chromium.googlesource.com/infra/infra.git@master
Patch Set: Rebase Created 4 years, 8 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « appengine/monorail/tracker/issuedetail.py ('k') | appengine/monorail/tracker/issueexport.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(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]
OLDNEW
« no previous file with comments | « appengine/monorail/tracker/issuedetail.py ('k') | appengine/monorail/tracker/issueexport.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698