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

Side by Side Diff: appengine/monorail/tracker/issuebulkedit.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
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 """Classes that implement the issue bulk edit page and related forms.
7
8 Summary of classes:
9 IssueBulkEdit: Show a form for editing multiple issues and allow the
10 user to update them all at once.
11 """
12
13 import httplib
14 import logging
15 import time
16
17 from third_party import ezt
18
19 from features import filterrules_helpers
20 from features import notify
21 from framework import actionlimit
22 from framework import framework_constants
23 from framework import framework_views
24 from framework import monorailrequest
25 from framework import permissions
26 from framework import servlet
27 from framework import template_helpers
28 from services import tracker_fulltext
29 from tracker import field_helpers
30 from tracker import tracker_bizobj
31 from tracker import tracker_helpers
32 from tracker import tracker_views
33
34
35 class IssueBulkEdit(servlet.Servlet):
36 """IssueBulkEdit lists multiple issues and allows an edit to all of them."""
37
38 _PAGE_TEMPLATE = 'tracker/issue-bulk-edit-page.ezt'
39 _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
40 _CAPTCHA_ACTION_TYPES = [actionlimit.ISSUE_BULK_EDIT]
41
42 _SECONDS_OVERHEAD = 4
43 _SECONDS_PER_UPDATE = 0.12
44 _SLOWNESS_THRESHOLD = 10
45
46 def AssertBasePermission(self, mr):
47 """Check whether the user has any permission to visit this page.
48
49 Args:
50 mr: commonly used info parsed from the request.
51
52 Raises:
53 PermissionException: if the user is not allowed to enter an issue.
54 """
55 super(IssueBulkEdit, self).AssertBasePermission(mr)
56 can_edit = self.CheckPerm(mr, permissions.EDIT_ISSUE)
57 can_comment = self.CheckPerm(mr, permissions.ADD_ISSUE_COMMENT)
58 if not (can_edit and can_comment):
59 raise permissions.PermissionException('bulk edit forbidden')
60
61 def GatherPageData(self, mr):
62 """Build up a dictionary of data values to use when rendering the page.
63
64 Args:
65 mr: commonly used info parsed from the request.
66
67 Returns:
68 Dict of values used by EZT for rendering the page.
69 """
70 with self.profiler.Phase('getting issues'):
71 if not mr.local_id_list:
72 raise monorailrequest.InputException()
73 requested_issues = self.services.issue.GetIssuesByLocalIDs(
74 mr.cnxn, mr.project_id, sorted(mr.local_id_list))
75
76 with self.profiler.Phase('filtering issues'):
77 # TODO(jrobbins): filter out issues that the user cannot edit and
78 # provide that as feedback rather than just siliently ignoring them.
79 open_issues, closed_issues = (
80 tracker_helpers.GetAllowedOpenedAndClosedIssues(
81 mr, [issue.issue_id for issue in requested_issues],
82 self.services))
83 issues = open_issues + closed_issues
84
85 if not issues:
86 self.abort(404, 'no issues found')
87
88 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
89 type_label_set = {
90 lab.lower() for lab in issues[0].labels
91 if lab.lower().startswith('type-')}
92 for issue in issues[1:]:
93 new_type_set = {
94 lab.lower() for lab in issue.labels
95 if lab.lower().startswith('type-')}
96 type_label_set &= new_type_set
97
98 field_views = [
99 tracker_views.MakeFieldValueView(
100 fd, config, type_label_set, [], [], {})
101 # TODO(jrobbins): field-level view restrictions, display options
102 # TODO(jrobbins): custom fields in templates supply values to view.
103 for fd in config.field_defs
104 if not fd.is_deleted]
105 # Explicitly set all field views to not required. We do not want to force
106 # users to have to set it for issues missing required fields.
107 # See https://bugs.chromium.org/p/monorail/issues/detail?id=500 for more
108 # details.
109 for fv in field_views:
110 fv.field_def.is_required_bool = None
111
112 with self.profiler.Phase('making issue proxies'):
113 issue_views = [
114 template_helpers.EZTItem(
115 local_id=issue.local_id, summary=issue.summary,
116 closed=ezt.boolean(issue in closed_issues))
117 for issue in issues]
118
119 num_seconds = (int(len(issue_views) * self._SECONDS_PER_UPDATE) +
120 self._SECONDS_OVERHEAD)
121
122 page_perms = self.MakePagePerms(
123 mr, None,
124 permissions.CREATE_ISSUE,
125 permissions.DELETE_ISSUE)
126
127 return {
128 'issue_tab_mode': 'issueBulkEdit',
129 'issues': issue_views,
130 'num_issues': len(issue_views),
131 'show_progress': ezt.boolean(num_seconds > self._SLOWNESS_THRESHOLD),
132 'num_seconds': num_seconds,
133
134 'initial_comment': '',
135 'initial_status': '',
136 'initial_owner': '',
137 'initial_merge_into': '',
138 'initial_cc': '',
139 'initial_components': '',
140 'labels': [],
141 'fields': field_views,
142
143 'restrict_to_known': ezt.boolean(config.restrict_to_known),
144 'page_perms': page_perms,
145 'statuses_offer_merge': config.statuses_offer_merge,
146 }
147
148 def ProcessFormData(self, mr, post_data):
149 """Process the posted issue update form.
150
151 Args:
152 mr: commonly used info parsed from the request.
153 post_data: HTML form data from the request.
154
155 Returns:
156 String URL to redirect the user to after processing.
157 """
158 if not mr.local_id_list:
159 logging.info('missing issue local IDs, probably tampered')
160 self.response.status = httplib.BAD_REQUEST
161 return
162
163 # Check that the user is logged in; anon users cannot update issues.
164 if not mr.auth.user_id:
165 logging.info('user was not logged in, cannot update issue')
166 self.response.status = httplib.BAD_REQUEST # xxx should raise except
167 return
168
169 self.CountRateLimitedActions(
170 mr, {actionlimit.ISSUE_BULK_EDIT: len(mr.local_id_list)})
171
172 # Check that the user has permission to add a comment, and to enter
173 # metadata if they are trying to do that.
174 if not self.CheckPerm(mr, permissions.ADD_ISSUE_COMMENT):
175 logging.info('user has no permission to add issue comment')
176 self.response.status = httplib.BAD_REQUEST
177 return
178
179 if not self.CheckPerm(mr, permissions.EDIT_ISSUE):
180 logging.info('user has no permission to edit issue metadata')
181 self.response.status = httplib.BAD_REQUEST
182 return
183
184 move_to = post_data.get('move_to', '').lower()
185 if move_to and not self.CheckPerm(mr, permissions.DELETE_ISSUE):
186 logging.info('user has no permission to move issue')
187 self.response.status = httplib.BAD_REQUEST
188 return
189
190 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
191
192 parsed = tracker_helpers.ParseIssueRequest(
193 mr.cnxn, post_data, self.services, mr.errors, mr.project_name)
194 field_helpers.ShiftEnumFieldsIntoLabels(
195 parsed.labels, parsed.labels_remove,
196 parsed.fields.vals, parsed.fields.vals_remove,
197 config)
198 field_vals = field_helpers.ParseFieldValues(
199 mr.cnxn, self.services.user, parsed.fields.vals, config)
200 field_vals_remove = field_helpers.ParseFieldValues(
201 mr.cnxn, self.services.user, parsed.fields.vals_remove, config)
202
203 # Treat status '' as no change and explicit 'clear' as clearing the status.
204 status = parsed.status
205 if status == '':
206 status = None
207 if post_data.get('op_statusenter') == 'clear':
208 status = ''
209
210 reporter_id = mr.auth.user_id
211 logging.info('bulk edit request by %s', reporter_id)
212 self.CheckCaptcha(mr, post_data)
213
214 if parsed.users.owner_id is None:
215 mr.errors.owner = 'Invalid owner username'
216 else:
217 valid, msg = tracker_helpers.IsValidIssueOwner(
218 mr.cnxn, mr.project, parsed.users.owner_id, self.services)
219 if not valid:
220 mr.errors.owner = msg
221
222 if (status in config.statuses_offer_merge and
223 not post_data.get('merge_into')):
224 mr.errors.merge_into_id = 'Please enter a valid issue ID'
225
226 move_to_project = None
227 if move_to:
228 if mr.project_name == move_to:
229 mr.errors.move_to = 'The issues are already in project ' + move_to
230 else:
231 move_to_project = self.services.project.GetProjectByName(
232 mr.cnxn, move_to)
233 if not move_to_project:
234 mr.errors.move_to = 'No such project: ' + move_to
235
236 # Treat owner '' as no change, and explicit 'clear' as NO_USER_SPECIFIED
237 owner_id = parsed.users.owner_id
238 if parsed.users.owner_username == '':
239 owner_id = None
240 if post_data.get('op_ownerenter') == 'clear':
241 owner_id = framework_constants.NO_USER_SPECIFIED
242
243 comp_ids = tracker_helpers.LookupComponentIDs(
244 parsed.components.paths, config, mr.errors)
245 comp_ids_remove = tracker_helpers.LookupComponentIDs(
246 parsed.components.paths_remove, config, mr.errors)
247 if post_data.get('op_componententer') == 'remove':
248 comp_ids, comp_ids_remove = comp_ids_remove, comp_ids
249
250 cc_ids, cc_ids_remove = parsed.users.cc_ids, parsed.users.cc_ids_remove
251 if post_data.get('op_memberenter') == 'remove':
252 cc_ids, cc_ids_remove = parsed.users.cc_ids_remove, parsed.users.cc_ids
253
254 local_ids_actually_changed = []
255 old_owner_ids = []
256 combined_amendments = []
257 merge_into_issue = None
258 new_starrers = set()
259
260 if not mr.errors.AnyErrors():
261 issue_list = self.services.issue.GetIssuesByLocalIDs(
262 mr.cnxn, mr.project_id, mr.local_id_list)
263
264 # Skip any individual issues that the user is not allowed to edit.
265 editable_issues = [
266 issue for issue in issue_list
267 if permissions.CanEditIssue(
268 mr.auth.effective_ids, mr.perms, mr.project, issue)]
269
270 # Skip any restrict issues that cannot be moved
271 if move_to:
272 editable_issues = [
273 issue for issue in editable_issues
274 if not permissions.GetRestrictions(issue)]
275
276 # If 'Duplicate' status is specified ensure there are no permission issues
277 # with the issue we want to merge with.
278 if post_data.get('merge_into'):
279 for issue in editable_issues:
280 _, merge_into_issue = tracker_helpers.ParseMergeFields(
281 mr.cnxn, self.services, mr.project_name, post_data, parsed.status,
282 config, issue, mr.errors)
283 if merge_into_issue:
284 merge_allowed = tracker_helpers.IsMergeAllowed(
285 merge_into_issue, mr, self.services)
286 if not merge_allowed:
287 mr.errors.merge_into_id = 'Target issue %s cannot be modified' % (
288 merge_into_issue.local_id)
289 break
290
291 # Update the new_starrers set.
292 new_starrers.update(tracker_helpers.GetNewIssueStarrers(
293 mr.cnxn, self.services, issue.issue_id,
294 merge_into_issue.issue_id))
295
296 # Proceed with amendments only if there are no reported errors.
297 if not mr.errors.AnyErrors():
298 # Sort the issues: we want them in this order so that the
299 # corresponding old_owner_id are found in the same order.
300 editable_issues.sort(lambda i1, i2: cmp(i1.local_id, i2.local_id))
301
302 iids_to_invalidate = set()
303 rules = self.services.features.GetFilterRules(
304 mr.cnxn, config.project_id)
305 predicate_asts = filterrules_helpers.ParsePredicateASTs(
306 rules, config, None)
307 for issue in editable_issues:
308 old_owner_id = tracker_bizobj.GetOwnerId(issue)
309 merge_into_iid = (
310 merge_into_issue.issue_id if merge_into_issue else None)
311
312 amendments, _ = self.services.issue.DeltaUpdateIssue(
313 mr.cnxn, self.services, mr.auth.user_id, mr.project_id, config,
314 issue, status, owner_id, cc_ids, cc_ids_remove, comp_ids,
315 comp_ids_remove, parsed.labels, parsed.labels_remove, field_vals,
316 field_vals_remove, parsed.fields.fields_clear,
317 merged_into=merge_into_iid, comment=parsed.comment,
318 iids_to_invalidate=iids_to_invalidate, rules=rules,
319 predicate_asts=predicate_asts)
320
321 if amendments or parsed.comment: # Avoid empty comments.
322 local_ids_actually_changed.append(issue.local_id)
323 old_owner_ids.append(old_owner_id)
324 combined_amendments.extend(amendments)
325
326 self.services.issue.InvalidateIIDs(mr.cnxn, iids_to_invalidate)
327 self.services.project.UpdateRecentActivity(
328 mr.cnxn, mr.project.project_id)
329
330 # Add new_starrers and new CCs to merge_into_issue.
331 if merge_into_issue:
332 merge_into_project = self.services.project.GetProjectByName(
333 mr.cnxn, merge_into_issue.project_name)
334 tracker_helpers.AddIssueStarrers(
335 mr.cnxn, self.services, mr, merge_into_issue.issue_id,
336 merge_into_project, new_starrers)
337 tracker_helpers.MergeCCsAndAddCommentMultipleIssues(
338 self.services, mr, editable_issues, merge_into_project,
339 merge_into_issue)
340
341 if move_to and editable_issues:
342 tracker_fulltext.UnindexIssues(
343 [issue.issue_id for issue in editable_issues])
344 for issue in editable_issues:
345 old_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
346 moved_back_iids = self.services.issue.MoveIssues(
347 mr.cnxn, move_to_project, [issue], self.services.user)
348 new_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
349 if issue.issue_id in moved_back_iids:
350 content = 'Moved %s back to %s again.' % (
351 old_text_ref, new_text_ref)
352 else:
353 content = 'Moved %s to now be %s.' % (old_text_ref, new_text_ref)
354 self.services.issue.CreateIssueComment(
355 mr.cnxn, move_to_project.project_id, issue.local_id,
356 mr.auth.user_id, content, amendments=[
357 tracker_bizobj.MakeProjectAmendment(
358 move_to_project.project_name)])
359
360 send_email = 'send_email' in post_data
361
362 users_by_id = framework_views.MakeAllUserViews(
363 mr.cnxn, self.services.user,
364 [owner_id], cc_ids, cc_ids_remove, old_owner_ids,
365 tracker_bizobj.UsersInvolvedInAmendments(combined_amendments))
366 if move_to and editable_issues:
367 project_id = move_to_project.project_id
368 local_ids_actually_changed = [
369 issue.local_id for issue in editable_issues]
370 else:
371 project_id = mr.project_id
372
373 notify.SendIssueBulkChangeNotification(
374 mr.request.host, project_id,
375 local_ids_actually_changed, old_owner_ids, parsed.comment,
376 reporter_id, combined_amendments, send_email, users_by_id)
377
378 if mr.errors.AnyErrors():
379 bounce_cc_parts = (
380 parsed.users.cc_usernames +
381 ['-%s' % ccur for ccur in parsed.users.cc_usernames_remove])
382 bounce_labels = (
383 parsed.labels +
384 ['-%s' % lr for lr in parsed.labels_remove])
385 self.PleaseCorrect(
386 mr, initial_status=parsed.status,
387 initial_owner=parsed.users.owner_username,
388 initial_merge_into=post_data.get('merge_into', 0),
389 initial_cc=', '.join(bounce_cc_parts),
390 initial_comment=parsed.comment,
391 initial_components=parsed.components.entered_str,
392 labels=bounce_labels)
393 return
394
395 with self.profiler.Phase('reindexing issues'):
396 logging.info('starting reindexing')
397 start = time.time()
398 # Get the updated issues and index them
399 issue_list = self.services.issue.GetIssuesByLocalIDs(
400 mr.cnxn, mr.project_id, mr.local_id_list)
401 tracker_fulltext.IndexIssues(
402 mr.cnxn, issue_list, self.services.user, self.services.issue,
403 self.services.config)
404 logging.info('reindexing %d issues took %s sec',
405 len(issue_list), time.time() - start)
406
407 # TODO(jrobbins): These could be put into the form action attribute.
408 mr.can = int(post_data['can'])
409 mr.query = post_data['q']
410 mr.col_spec = post_data['colspec']
411 mr.sort_spec = post_data['sort']
412 mr.group_by_spec = post_data['groupby']
413 mr.start = int(post_data['start'])
414 mr.num = int(post_data['num'])
415
416 # TODO(jrobbins): implement bulk=N param for a better confirmation alert.
417 return tracker_helpers.FormatIssueListURL(
418 mr, config, saved=len(mr.local_id_list), ts=int(time.time()))
OLDNEW
« no previous file with comments | « appengine/monorail/tracker/issueattachmenttext.py ('k') | appengine/monorail/tracker/issuedetail.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698