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

Side by Side Diff: appengine/monorail/tracker/issuedetail.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/issuebulkedit.py ('k') | appengine/monorail/tracker/issueentry.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 """Classes that implement the issue detail page and related forms.
7
8 Summary of classes:
9 IssueDetail: Show one issue in detail w/ all metadata and comments, and
10 process additional comments or metadata changes on it.
11 SetStarForm: Record the user's desire to star or unstar an issue.
12 FlagSpamForm: Record the user's desire to report the issue as spam.
13 """
14
15 import httplib
16 import logging
17 import time
18 from third_party import ezt
19
20 import settings
21 from features import notify
22 from framework import actionlimit
23 from framework import framework_bizobj
24 from framework import framework_constants
25 from framework import framework_helpers
26 from framework import framework_views
27 from framework import jsonfeed
28 from framework import monorailrequest
29 from framework import paginate
30 from framework import permissions
31 from framework import servlet
32 from framework import servlet_helpers
33 from framework import sql
34 from framework import template_helpers
35 from framework import urls
36 from framework import xsrf
37 from proto import user_pb2
38 from search import frontendsearchpipeline
39 from services import issue_svc
40 from services import tracker_fulltext
41 from tracker import field_helpers
42 from tracker import issuepeek
43 from tracker import tracker_bizobj
44 from tracker import tracker_constants
45 from tracker import tracker_helpers
46 from tracker import tracker_views
47
48
49 class IssueDetail(issuepeek.IssuePeek):
50 """IssueDetail is a page that shows the details of one issue."""
51
52 _PAGE_TEMPLATE = 'tracker/issue-detail-page.ezt'
53 _MISSING_ISSUE_PAGE_TEMPLATE = 'tracker/issue-missing-page.ezt'
54 _MAIN_TAB_MODE = issuepeek.IssuePeek.MAIN_TAB_ISSUES
55 _CAPTCHA_ACTION_TYPES = [actionlimit.ISSUE_COMMENT]
56 _ALLOW_VIEWING_DELETED = True
57
58 def __init__(self, request, response, **kwargs):
59 super(IssueDetail, self).__init__(request, response, **kwargs)
60 self.missing_issue_template = template_helpers.MonorailTemplate(
61 self._TEMPLATE_PATH + self._MISSING_ISSUE_PAGE_TEMPLATE)
62
63 def GetTemplate(self, page_data):
64 """Return a custom 404 page for skipped issue local IDs."""
65 if page_data.get('http_response_code', httplib.OK) == httplib.NOT_FOUND:
66 return self.missing_issue_template
67 else:
68 return servlet.Servlet.GetTemplate(self, page_data)
69
70 def _GetMissingIssuePageData(
71 self, mr, issue_deleted=False, issue_missing=False,
72 issue_not_specified=False, issue_not_created=False,
73 moved_to_project_name=None, moved_to_id=None,
74 local_id=None, page_perms=None, delete_form_token=None):
75 if not page_perms:
76 # Make a default page perms.
77 page_perms = self.MakePagePerms(mr, None, granted_perms=None)
78 page_perms.CreateIssue = False
79 return {
80 'issue_tab_mode': 'issueDetail',
81 'http_response_code': httplib.NOT_FOUND,
82 'issue_deleted': ezt.boolean(issue_deleted),
83 'issue_missing': ezt.boolean(issue_missing),
84 'issue_not_specified': ezt.boolean(issue_not_specified),
85 'issue_not_created': ezt.boolean(issue_not_created),
86 'moved_to_project_name': moved_to_project_name,
87 'moved_to_id': moved_to_id,
88 'local_id': local_id,
89 'page_perms': page_perms,
90 'delete_form_token': delete_form_token,
91 }
92
93 def GatherPageData(self, mr):
94 """Build up a dictionary of data values to use when rendering the page.
95
96 Args:
97 mr: commonly used info parsed from the request.
98
99 Returns:
100 Dict of values used by EZT for rendering the page.
101 """
102 with self.profiler.Phase('getting project issue config'):
103 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
104
105 # The flipper is not itself a Promise, but it contains Promises.
106 flipper = _Flipper(mr, self.services, self.profiler)
107
108 if mr.local_id is None:
109 return self._GetMissingIssuePageData(mr, issue_not_specified=True)
110 with self.profiler.Phase('finishing getting issue'):
111 try:
112 issue = self._GetIssue(mr)
113 except issue_svc.NoSuchIssueException:
114 issue = None
115
116 # Show explanation of skipped issue local IDs or deleted issues.
117 if issue is None or issue.deleted:
118 missing = mr.local_id <= self.services.issue.GetHighestLocalID(
119 mr.cnxn, mr.project_id)
120 if missing or (issue and issue.deleted):
121 moved_to_ref = self.services.issue.GetCurrentLocationOfMovedIssue(
122 mr.cnxn, mr.project_id, mr.local_id)
123 moved_to_project_id, moved_to_id = moved_to_ref
124 if moved_to_project_id is not None:
125 moved_to_project = self.services.project.GetProject(
126 mr.cnxn, moved_to_project_id)
127 moved_to_project_name = moved_to_project.project_name
128 else:
129 moved_to_project_name = None
130
131 if issue:
132 granted_perms = tracker_bizobj.GetGrantedPerms(
133 issue, mr.auth.effective_ids, config)
134 else:
135 granted_perms = None
136 page_perms = self.MakePagePerms(
137 mr, issue,
138 permissions.DELETE_ISSUE, permissions.CREATE_ISSUE,
139 granted_perms=granted_perms)
140 return self._GetMissingIssuePageData(
141 mr,
142 issue_deleted=ezt.boolean(issue is not None),
143 issue_missing=ezt.boolean(issue is None and missing),
144 moved_to_project_name=moved_to_project_name,
145 moved_to_id=moved_to_id,
146 local_id=mr.local_id,
147 page_perms=page_perms,
148 delete_form_token=xsrf.GenerateToken(
149 mr.auth.user_id, '/p/%s%s.do' % (
150 mr.project_name, urls.ISSUE_DELETE_JSON)))
151 else:
152 # Issue is not "missing," moved, or deleted, it is just non-existent.
153 return self._GetMissingIssuePageData(mr, issue_not_created=True)
154
155 star_cnxn = sql.MonorailConnection()
156 star_promise = framework_helpers.Promise(
157 self.services.issue_star.IsItemStarredBy, star_cnxn,
158 issue.issue_id, mr.auth.user_id)
159
160 granted_perms = tracker_bizobj.GetGrantedPerms(
161 issue, mr.auth.effective_ids, config)
162
163 page_perms = self.MakePagePerms(
164 mr, issue,
165 permissions.CREATE_ISSUE,
166 permissions.FLAG_SPAM,
167 permissions.VERDICT_SPAM,
168 permissions.SET_STAR,
169 permissions.EDIT_ISSUE,
170 permissions.EDIT_ISSUE_SUMMARY,
171 permissions.EDIT_ISSUE_STATUS,
172 permissions.EDIT_ISSUE_OWNER,
173 permissions.EDIT_ISSUE_CC,
174 permissions.DELETE_ISSUE,
175 permissions.ADD_ISSUE_COMMENT,
176 permissions.DELETE_OWN,
177 permissions.DELETE_ANY,
178 permissions.VIEW_INBOUND_MESSAGES,
179 granted_perms=granted_perms)
180
181 spam_promise = None
182 spam_hist_promise = None
183
184 if page_perms.FlagSpam:
185 spam_cnxn = sql.MonorailConnection()
186 spam_promise = framework_helpers.Promise(
187 self.services.spam.LookupFlaggers, spam_cnxn,
188 issue.issue_id)
189
190 if page_perms.VerdictSpam:
191 spam_hist_cnxn = sql.MonorailConnection()
192 spam_hist_promise = framework_helpers.Promise(
193 self.services.spam.LookUpIssueVerdictHistory, spam_hist_cnxn,
194 [issue.issue_id])
195
196 with self.profiler.Phase('finishing getting comments and pagination'):
197 (description, visible_comments,
198 cmnt_pagination) = self._PaginatePartialComments(mr, issue)
199
200 with self.profiler.Phase('making user views'):
201 users_by_id = framework_views.MakeAllUserViews(
202 mr.cnxn, self.services.user,
203 tracker_bizobj.UsersInvolvedInIssues([issue]),
204 tracker_bizobj.UsersInvolvedInCommentList(
205 [description] + visible_comments))
206 framework_views.RevealAllEmailsToMembers(mr, users_by_id)
207
208 issue_flaggers, comment_flaggers = [], {}
209 if spam_promise:
210 issue_flaggers, comment_flaggers = spam_promise.WaitAndGetValue()
211
212 (issue_view, description_view,
213 comment_views) = self._MakeIssueAndCommentViews(
214 mr, issue, users_by_id, description, visible_comments, config,
215 issue_flaggers, comment_flaggers)
216
217 with self.profiler.Phase('getting starring info'):
218 starred = star_promise.WaitAndGetValue()
219 star_cnxn.Close()
220 permit_edit = permissions.CanEditIssue(
221 mr.auth.effective_ids, mr.perms, mr.project, issue,
222 granted_perms=granted_perms)
223 page_perms.EditIssue = ezt.boolean(permit_edit)
224 permit_edit_cc = self.CheckPerm(
225 mr, permissions.EDIT_ISSUE_CC, art=issue, granted_perms=granted_perms)
226 discourage_plus_one = not (starred or permit_edit or permit_edit_cc)
227
228 # Check whether to allow attachments from the details page
229 allow_attachments = tracker_helpers.IsUnderSoftAttachmentQuota(mr.project)
230 mr.ComputeColSpec(config)
231 back_to_list_url = _ComputeBackToListURL(mr, issue, config)
232 flipper.SearchForIIDs(mr, issue)
233 restrict_to_known = config.restrict_to_known
234 field_name_set = {fd.field_name.lower() for fd in config.field_defs
235 if not fd.is_deleted} # TODO(jrobbins): restrictions
236 non_masked_labels = tracker_bizobj.NonMaskedLabels(
237 issue.labels, field_name_set)
238
239 component_paths = []
240 for comp_id in issue.component_ids:
241 cd = tracker_bizobj.FindComponentDefByID(comp_id, config)
242 if cd:
243 component_paths.append(cd.path)
244 else:
245 logging.warn(
246 'Issue %r has unknown component %r', issue.issue_id, comp_id)
247 initial_components = ', '.join(component_paths)
248
249 after_issue_update = tracker_constants.DEFAULT_AFTER_ISSUE_UPDATE
250 if mr.auth.user_pb:
251 after_issue_update = mr.auth.user_pb.after_issue_update
252
253 prevent_restriction_removal = (
254 mr.project.only_owners_remove_restrictions and
255 not framework_bizobj.UserOwnsProject(
256 mr.project, mr.auth.effective_ids))
257
258 offer_issue_copy_move = True
259 for lab in tracker_bizobj.GetLabels(issue):
260 if lab.lower().startswith('restrict-'):
261 offer_issue_copy_move = False
262
263 previous_locations = self.GetPreviousLocations(mr, issue)
264
265 spam_verdict_history = []
266 if spam_hist_promise:
267 spam_hist = spam_hist_promise.WaitAndGetValue()
268
269 spam_verdict_history = [template_helpers.EZTItem(
270 created=verdict['created'].isoformat(),
271 is_spam=verdict['is_spam'],
272 reason=verdict['reason'],
273 user_id=verdict['user_id'],
274 classifier_confidence=verdict['classifier_confidence'],
275 overruled=verdict['overruled'],
276 ) for verdict in spam_hist]
277
278 return {
279 'issue_tab_mode': 'issueDetail',
280 'issue': issue_view,
281 'title_summary': issue_view.summary, # used in <head><title>
282 'description': description_view,
283 'comments': comment_views,
284 'num_detail_rows': len(comment_views) + 4,
285 'noisy': ezt.boolean(tracker_helpers.IsNoisy(
286 len(comment_views), issue.star_count)),
287
288 'flipper': flipper,
289 'cmnt_pagination': cmnt_pagination,
290 'searchtip': 'You can jump to any issue by number',
291 'starred': ezt.boolean(starred),
292 'discourage_plus_one': ezt.boolean(discourage_plus_one),
293 'pagegen': str(long(time.time() * 1000000)),
294 'attachment_form_token': xsrf.GenerateToken(
295 mr.auth.user_id, '/p/%s%s.do' % (
296 mr.project_name, urls.ISSUE_ATTACHMENT_DELETION_JSON)),
297 'delComment_form_token': xsrf.GenerateToken(
298 mr.auth.user_id, '/p/%s%s.do' % (
299 mr.project_name, urls.ISSUE_COMMENT_DELETION_JSON)),
300 'delete_form_token': xsrf.GenerateToken(
301 mr.auth.user_id, '/p/%s%s.do' % (
302 mr.project_name, urls.ISSUE_DELETE_JSON)),
303 'flag_spam_token': xsrf.GenerateToken(
304 mr.auth.user_id, '/p/%s%s.do' % (
305 mr.project_name, urls.ISSUE_FLAGSPAM_JSON)),
306 'set_star_token': xsrf.GenerateToken(
307 mr.auth.user_id, '/p/%s%s.do' % (
308 mr.project_name, urls.ISSUE_SETSTAR_JSON)),
309
310
311 # For deep linking and input correction after a failed submit.
312 'initial_summary': issue_view.summary,
313 'initial_comment': '',
314 'initial_status': issue_view.status.name,
315 'initial_owner': issue_view.owner.email,
316 'initial_cc': ', '.join([pb.email for pb in issue_view.cc]),
317 'initial_blocked_on': issue_view.blocked_on_str,
318 'initial_blocking': issue_view.blocking_str,
319 'initial_merge_into': issue_view.merged_into_str,
320 'labels': non_masked_labels,
321 'initial_components': initial_components,
322 'fields': issue_view.fields,
323
324 'any_errors': ezt.boolean(mr.errors.AnyErrors()),
325 'allow_attachments': ezt.boolean(allow_attachments),
326 'max_attach_size': template_helpers.BytesKbOrMb(
327 framework_constants.MAX_POST_BODY_SIZE),
328 'colspec': mr.col_spec,
329 'back_to_list_url': back_to_list_url,
330 'restrict_to_known': ezt.boolean(restrict_to_known),
331 'after_issue_update': int(after_issue_update), # TODO(jrobbins): str
332 'prevent_restriction_removal': ezt.boolean(
333 prevent_restriction_removal),
334 'offer_issue_copy_move': ezt.boolean(offer_issue_copy_move),
335 'statuses_offer_merge': config.statuses_offer_merge,
336 'page_perms': page_perms,
337 'previous_locations': previous_locations,
338 'spam_verdict_history': spam_verdict_history,
339 }
340
341 def GatherHelpData(self, mr, _page_data):
342 """Return a dict of values to drive on-page user help.
343
344 Args:
345 mr: commonly used info parsed from the request.
346 _page_data: Dictionary of base and page template data.
347
348 Returns:
349 A dict of values to drive on-page user help, to be added to page_data.
350 """
351 is_privileged_domain_user = framework_bizobj.IsPriviledgedDomainUser(
352 mr.auth.user_pb.email)
353 # Check if the user's query is just the ID of an existing issue.
354 # If so, display a "did you mean to search?" cue card.
355 jump_local_id = None
356 cue = None
357 if (tracker_constants.JUMP_RE.match(mr.query) and
358 mr.auth.user_pb and
359 'search_for_numbers' not in mr.auth.user_pb.dismissed_cues):
360 jump_local_id = int(mr.query)
361 cue = 'search_for_numbers'
362
363 if (mr.auth.user_id and
364 'privacy_click_through' not in mr.auth.user_pb.dismissed_cues):
365 cue = 'privacy_click_through'
366
367 return {
368 'is_privileged_domain_user': ezt.boolean(is_privileged_domain_user),
369 'jump_local_id': jump_local_id,
370 'cue': cue,
371 }
372
373 # TODO(sheyang): Support comments incremental loading in API
374 def _PaginatePartialComments(self, mr, issue):
375 """Load and paginate the visible comments for the given issue."""
376 abbr_comment_rows = self.services.issue.GetAbbrCommentsForIssue(
377 mr.cnxn, issue.issue_id)
378 if not abbr_comment_rows:
379 return None, [], None
380
381 description = abbr_comment_rows[0]
382 comments = abbr_comment_rows[1:]
383 all_comment_ids = [row[0] for row in comments]
384
385 pagination_url = '%s?id=%d' % (urls.ISSUE_DETAIL, issue.local_id)
386 pagination = paginate.VirtualPagination(
387 mr, len(all_comment_ids),
388 framework_constants.DEFAULT_COMMENTS_PER_PAGE,
389 list_page_url=pagination_url,
390 count_up=False, start_param='cstart', num_param='cnum',
391 max_num=settings.max_comments_per_page)
392 if pagination.last == 1 and pagination.start == len(all_comment_ids):
393 pagination.visible = ezt.boolean(False)
394
395 visible_comment_ids = [description[0]] + all_comment_ids[
396 pagination.last - 1:pagination.start]
397 visible_comment_seqs = [0] + range(pagination.last, pagination.start + 1)
398 visible_comments = self.services.issue.GetCommentsByID(
399 mr.cnxn, visible_comment_ids, visible_comment_seqs)
400
401 return visible_comments[0], visible_comments[1:], pagination
402
403
404 def _ValidateOwner(self, mr, post_data_owner, parsed_owner_id,
405 original_issue_owner_id):
406 """Validates that the issue's owner was changed and is a valid owner.
407
408 Args:
409 mr: Commonly used info parsed from the request.
410 post_data_owner: The owner as specified in the request's data.
411 parsed_owner_id: The owner_id from the request.
412 original_issue_owner_id: The original owner id of the issue.
413
414 Returns:
415 String error message if the owner fails validation else returns None.
416 """
417 parsed_owner_valid, msg = tracker_helpers.IsValidIssueOwner(
418 mr.cnxn, mr.project, parsed_owner_id, self.services)
419 if not parsed_owner_valid:
420 # Only fail validation if the user actually changed the email address.
421 original_issue_owner = self.services.user.LookupUserEmail(
422 mr.cnxn, original_issue_owner_id)
423 if post_data_owner != original_issue_owner:
424 return msg
425 else:
426 # The user did not change the owner, thus do not fail validation.
427 # See https://bugs.chromium.org/p/monorail/issues/detail?id=28 for
428 # more details.
429 pass
430
431 def ProcessFormData(self, mr, post_data):
432 """Process the posted issue update form.
433
434 Args:
435 mr: commonly used info parsed from the request.
436 post_data: The post_data dict for the current request.
437
438 Returns:
439 String URL to redirect the user to after processing.
440 """
441 issue = self._GetIssue(mr)
442 if not issue:
443 logging.warn('issue not found! project_name: %r local id: %r',
444 mr.project_name, mr.local_id)
445 raise monorailrequest.InputException('Issue not found in project')
446
447 # Check that the user is logged in; anon users cannot update issues.
448 if not mr.auth.user_id:
449 logging.info('user was not logged in, cannot update issue')
450 raise permissions.PermissionException(
451 'User must be logged in to update an issue')
452
453 # Check that the user has permission to add a comment, and to enter
454 # metadata if they are trying to do that.
455 if not self.CheckPerm(mr, permissions.ADD_ISSUE_COMMENT,
456 art=issue):
457 logging.info('user has no permission to add issue comment')
458 raise permissions.PermissionException(
459 'User has no permission to comment on issue')
460
461 parsed = tracker_helpers.ParseIssueRequest(
462 mr.cnxn, post_data, self.services, mr.errors, issue.project_name)
463 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
464 bounce_labels = parsed.labels[:]
465 bounce_fields = tracker_views.MakeBounceFieldValueViews(
466 parsed.fields.vals, config)
467 field_helpers.ShiftEnumFieldsIntoLabels(
468 parsed.labels, parsed.labels_remove,
469 parsed.fields.vals, parsed.fields.vals_remove, config)
470 field_values = field_helpers.ParseFieldValues(
471 mr.cnxn, self.services.user, parsed.fields.vals, config)
472
473 component_ids = tracker_helpers.LookupComponentIDs(
474 parsed.components.paths, config, mr.errors)
475
476 granted_perms = tracker_bizobj.GetGrantedPerms(
477 issue, mr.auth.effective_ids, config)
478 permit_edit = permissions.CanEditIssue(
479 mr.auth.effective_ids, mr.perms, mr.project, issue,
480 granted_perms=granted_perms)
481 page_perms = self.MakePagePerms(
482 mr, issue,
483 permissions.CREATE_ISSUE,
484 permissions.EDIT_ISSUE_SUMMARY,
485 permissions.EDIT_ISSUE_STATUS,
486 permissions.EDIT_ISSUE_OWNER,
487 permissions.EDIT_ISSUE_CC,
488 granted_perms=granted_perms)
489 page_perms.EditIssue = ezt.boolean(permit_edit)
490
491 if not permit_edit:
492 if not _FieldEditPermitted(
493 parsed.labels, parsed.blocked_on.entered_str,
494 parsed.blocking.entered_str, parsed.summary,
495 parsed.status, parsed.users.owner_id,
496 parsed.users.cc_ids, page_perms):
497 raise permissions.PermissionException(
498 'User lacks permission to edit fields')
499
500 page_generation_time = long(post_data['pagegen'])
501 reporter_id = mr.auth.user_id
502 self.CheckCaptcha(mr, post_data)
503
504 error_msg = self._ValidateOwner(
505 mr, post_data.get('owner', '').strip(), parsed.users.owner_id,
506 issue.owner_id)
507 if error_msg:
508 mr.errors.owner = error_msg
509
510 if None in parsed.users.cc_ids:
511 mr.errors.cc = 'Invalid Cc username'
512
513 if len(parsed.comment) > tracker_constants.MAX_COMMENT_CHARS:
514 mr.errors.comment = 'Comment is too long'
515 if len(parsed.summary) > tracker_constants.MAX_SUMMARY_CHARS:
516 mr.errors.summary = 'Summary is too long'
517
518 old_owner_id = tracker_bizobj.GetOwnerId(issue)
519
520 orig_merged_into_iid = issue.merged_into
521 merge_into_iid = issue.merged_into
522 merge_into_text, merge_into_issue = tracker_helpers.ParseMergeFields(
523 mr.cnxn, self.services, mr.project_name, post_data,
524 parsed.status, config, issue, mr.errors)
525 if merge_into_issue:
526 merge_into_iid = merge_into_issue.issue_id
527 merge_into_project = self.services.project.GetProjectByName(
528 mr.cnxn, merge_into_issue.project_name)
529 merge_allowed = tracker_helpers.IsMergeAllowed(
530 merge_into_issue, mr, self.services)
531
532 new_starrers = tracker_helpers.GetNewIssueStarrers(
533 mr.cnxn, self.services, issue.issue_id, merge_into_iid)
534
535 # For any fields that the user does not have permission to edit, use
536 # the current values in the issue rather than whatever strings were parsed.
537 labels = parsed.labels
538 summary = parsed.summary
539 status = parsed.status
540 owner_id = parsed.users.owner_id
541 cc_ids = parsed.users.cc_ids
542 blocked_on_iids = [iid for iid in parsed.blocked_on.iids
543 if iid != issue.issue_id]
544 blocking_iids = [iid for iid in parsed.blocking.iids
545 if iid != issue.issue_id]
546 dangling_blocked_on_refs = [tracker_bizobj.MakeDanglingIssueRef(*ref)
547 for ref in parsed.blocked_on.dangling_refs]
548 dangling_blocking_refs = [tracker_bizobj.MakeDanglingIssueRef(*ref)
549 for ref in parsed.blocking.dangling_refs]
550 if not permit_edit:
551 labels = issue.labels
552 field_values = issue.field_values
553 component_ids = issue.component_ids
554 blocked_on_iids = issue.blocked_on_iids
555 blocking_iids = issue.blocking_iids
556 dangling_blocked_on_refs = issue.dangling_blocked_on_refs
557 dangling_blocking_refs = issue.dangling_blocking_refs
558 merge_into_iid = issue.merged_into
559 if not page_perms.EditIssueSummary:
560 summary = issue.summary
561 if not page_perms.EditIssueStatus:
562 status = issue.status
563 if not page_perms.EditIssueOwner:
564 owner_id = issue.owner_id
565 if not page_perms.EditIssueCc:
566 cc_ids = issue.cc_ids
567
568 field_helpers.ValidateCustomFields(
569 mr, self.services, field_values, config, mr.errors)
570
571 orig_blocked_on = issue.blocked_on_iids
572 if not mr.errors.AnyErrors():
573 try:
574 if parsed.attachments:
575 new_bytes_used = tracker_helpers.ComputeNewQuotaBytesUsed(
576 mr.project, parsed.attachments)
577 self.services.project.UpdateProject(
578 mr.cnxn, mr.project.project_id,
579 attachment_bytes_used=new_bytes_used)
580
581 # Store everything we got from the form. If the user lacked perms
582 # any attempted edit would be a no-op because of the logic above.
583 amendments, _ = self.services.issue.ApplyIssueComment(
584 mr.cnxn, self.services,
585 mr.auth.user_id, mr.project_id, mr.local_id, summary, status,
586 owner_id, cc_ids, labels, field_values, component_ids,
587 blocked_on_iids, blocking_iids, dangling_blocked_on_refs,
588 dangling_blocking_refs, merge_into_iid,
589 page_gen_ts=page_generation_time, comment=parsed.comment,
590 attachments=parsed.attachments)
591 self.services.project.UpdateRecentActivity(
592 mr.cnxn, mr.project.project_id)
593
594 # Also update the Issue PB we have in RAM so that the correct
595 # CC list will be used for an issue merge.
596 # TODO(jrobbins): refactor the call above to: 1. compute the updates
597 # and update the issue PB in RAM, then 2. store the updated issue.
598 issue.cc_ids = cc_ids
599 issue.labels = labels
600
601 except tracker_helpers.OverAttachmentQuota:
602 mr.errors.attachments = 'Project attachment quota exceeded.'
603
604 if (merge_into_issue and merge_into_iid != orig_merged_into_iid and
605 merge_allowed):
606 tracker_helpers.AddIssueStarrers(
607 mr.cnxn, self.services, mr,
608 merge_into_iid, merge_into_project, new_starrers)
609 merge_comment = tracker_helpers.MergeCCsAndAddComment(
610 self.services, mr, issue, merge_into_project, merge_into_issue)
611 elif merge_into_issue:
612 merge_comment = None
613 logging.info('merge denied: target issue %s not modified',
614 merge_into_iid)
615 # TODO(jrobbins): distinguish between EditIssue and
616 # AddIssueComment and do just the part that is allowed.
617 # And, give feedback in the source issue if any part of the
618 # merge was not allowed. Maybe use AJAX to check as the
619 # user types in the issue local ID.
620
621 counts = {actionlimit.ISSUE_COMMENT: 1,
622 actionlimit.ISSUE_ATTACHMENT: len(parsed.attachments)}
623 self.CountRateLimitedActions(mr, counts)
624
625 copy_to_project = CheckCopyIssueRequest(
626 self.services, mr, issue, post_data.get('more_actions') == 'copy',
627 post_data.get('copy_to'), mr.errors)
628 move_to_project = CheckMoveIssueRequest(
629 self.services, mr, issue, post_data.get('more_actions') == 'move',
630 post_data.get('move_to'), mr.errors)
631
632 if mr.errors.AnyErrors():
633 self.PleaseCorrect(
634 mr, initial_summary=parsed.summary,
635 initial_status=parsed.status,
636 initial_owner=parsed.users.owner_username,
637 initial_cc=', '.join(parsed.users.cc_usernames),
638 initial_components=', '.join(parsed.components.paths),
639 initial_comment=parsed.comment,
640 labels=bounce_labels, fields=bounce_fields,
641 initial_blocked_on=parsed.blocked_on.entered_str,
642 initial_blocking=parsed.blocking.entered_str,
643 initial_merge_into=merge_into_text)
644 return
645
646 send_email = 'send_email' in post_data or not permit_edit
647
648 moved_to_project_name_and_local_id = None
649 copied_to_project_name_and_local_id = None
650 if move_to_project:
651 moved_to_project_name_and_local_id = self.HandleCopyOrMove(
652 mr.cnxn, mr, move_to_project, issue, send_email, move=True)
653 elif copy_to_project:
654 copied_to_project_name_and_local_id = self.HandleCopyOrMove(
655 mr.cnxn, mr, copy_to_project, issue, send_email, move=False)
656
657 # TODO(sheyang): use global issue id in case the issue gets moved again
658 # before the task gets processed
659 if amendments or parsed.comment.strip() or parsed.attachments:
660 cmnts = self.services.issue.GetCommentsForIssue(mr.cnxn, issue.issue_id)
661 notify.PrepareAndSendIssueChangeNotification(
662 issue.project_id, issue.local_id, mr.request.host, reporter_id,
663 len(cmnts) - 1, send_email=send_email, old_owner_id=old_owner_id)
664
665 if merge_into_issue and merge_allowed and merge_comment:
666 cmnts = self.services.issue.GetCommentsForIssue(
667 mr.cnxn, merge_into_issue.issue_id)
668 notify.PrepareAndSendIssueChangeNotification(
669 merge_into_issue.project_id, merge_into_issue.local_id,
670 mr.request.host, reporter_id, len(cmnts) - 1, send_email=send_email)
671
672 if permit_edit:
673 # Only users who can edit metadata could have edited blocking.
674 blockers_added, blockers_removed = framework_helpers.ComputeListDeltas(
675 orig_blocked_on, blocked_on_iids)
676 delta_blockers = blockers_added + blockers_removed
677 notify.PrepareAndSendIssueBlockingNotification(
678 issue.project_id, mr.request.host, issue.local_id, delta_blockers,
679 reporter_id, send_email=send_email)
680 # We don't send notification emails to newly blocked issues: either they
681 # know they are blocked, or they don't care and can be fixed anyway.
682 # This is the same behavior as the issue entry page.
683
684 after_issue_update = _DetermineAndSetAfterIssueUpdate(
685 self.services, mr, post_data)
686 return _Redirect(
687 mr, post_data, issue.local_id, config,
688 moved_to_project_name_and_local_id,
689 copied_to_project_name_and_local_id, after_issue_update)
690
691 def HandleCopyOrMove(self, cnxn, mr, dest_project, issue, send_email, move):
692 """Handle Requests dealing with copying or moving an issue between projects.
693
694 Args:
695 cnxn: connection to the database.
696 mr: commonly used info parsed from the request.
697 dest_project: The project protobuf we are moving the issue to.
698 issue: The issue protobuf being moved.
699 send_email: True to send email for these actions.
700 move: Whether this is a move request. The original issue will not exist if
701 this is True.
702
703 Returns:
704 A tuple of (project_id, local_id) of the newly copied / moved issue.
705 """
706 old_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
707 if move:
708 tracker_fulltext.UnindexIssues([issue.issue_id])
709 moved_back_iids = self.services.issue.MoveIssues(
710 cnxn, dest_project, [issue], self.services.user)
711 ret_project_name_and_local_id = (issue.project_name, issue.local_id)
712 new_text_ref = 'issue %s:%s' % ret_project_name_and_local_id
713 if issue.issue_id in moved_back_iids:
714 content = 'Moved %s back to %s again.' % (old_text_ref, new_text_ref)
715 else:
716 content = 'Moved %s to now be %s.' % (old_text_ref, new_text_ref)
717 comment = self.services.issue.CreateIssueComment(
718 mr.cnxn, dest_project.project_id, issue.local_id, mr.auth.user_id,
719 content, amendments=[
720 tracker_bizobj.MakeProjectAmendment(dest_project.project_name)])
721 else:
722 copied_issues = self.services.issue.CopyIssues(
723 cnxn, dest_project, [issue], self.services.user, mr.auth.user_id)
724 copied_issue = copied_issues[0]
725 ret_project_name_and_local_id = (copied_issue.project_name,
726 copied_issue.local_id)
727 new_text_ref = 'issue %s:%s' % ret_project_name_and_local_id
728
729 # Add comment to the copied issue.
730 old_issue_content = 'Copied %s to %s' % (old_text_ref, new_text_ref)
731 self.services.issue.CreateIssueComment(
732 mr.cnxn, issue.project_id, issue.local_id, mr.auth.user_id,
733 old_issue_content)
734
735 # Add comment to the newly created issue.
736 # Add project amendment only if the project changed.
737 amendments = []
738 if issue.project_id != copied_issue.project_id:
739 amendments.append(
740 tracker_bizobj.MakeProjectAmendment(dest_project.project_name))
741 new_issue_content = 'Copied %s from %s' % (new_text_ref, old_text_ref)
742 comment = self.services.issue.CreateIssueComment(
743 mr.cnxn, dest_project.project_id, copied_issue.local_id,
744 mr.auth.user_id, new_issue_content, amendments=amendments)
745
746 tracker_fulltext.IndexIssues(
747 mr.cnxn, [issue], self.services.user, self.services.issue,
748 self.services.config)
749
750 if send_email:
751 logging.info('TODO(jrobbins): send email for a move? or combine? %r',
752 comment)
753
754 return ret_project_name_and_local_id
755
756
757 def _DetermineAndSetAfterIssueUpdate(services, mr, post_data):
758 after_issue_update = tracker_constants.DEFAULT_AFTER_ISSUE_UPDATE
759 if 'after_issue_update' in post_data:
760 after_issue_update = user_pb2.IssueUpdateNav(
761 int(post_data['after_issue_update'][0]))
762 if after_issue_update != mr.auth.user_pb.after_issue_update:
763 logging.info('setting after_issue_update to %r', after_issue_update)
764 services.user.UpdateUserSettings(
765 mr.cnxn, mr.auth.user_id, mr.auth.user_pb,
766 after_issue_update=after_issue_update)
767
768 return after_issue_update
769
770
771 def _Redirect(
772 mr, post_data, local_id, config, moved_to_project_name_and_local_id,
773 copied_to_project_name_and_local_id, after_issue_update):
774 """Prepare a redirect URL for the issuedetail servlets.
775
776 Args:
777 mr: common information parsed from the HTTP request.
778 post_data: The post_data dict for the current request.
779 local_id: int Issue ID for the current request.
780 config: The ProjectIssueConfig pb for the current request.
781 moved_to_project_name_and_local_id: tuple containing the project name the
782 issue was moved to and the local id in that project.
783 copied_to_project_name_and_local_id: tuple containing the project name the
784 issue was copied to and the local id in that project.
785 after_issue_update: User preference on where to go next.
786
787 Returns:
788 String URL to redirect the user to after processing.
789 """
790 mr.can = int(post_data['can'])
791 mr.query = post_data['q']
792 mr.col_spec = post_data['colspec']
793 mr.sort_spec = post_data['sort']
794 mr.group_by_spec = post_data['groupby']
795 mr.start = int(post_data['start'])
796 mr.num = int(post_data['num'])
797 mr.local_id = local_id
798
799 # format a redirect url
800 next_id = post_data.get('next_id', '')
801 url = _ChooseNextPage(
802 mr, local_id, config, moved_to_project_name_and_local_id,
803 copied_to_project_name_and_local_id, after_issue_update, next_id)
804 logging.debug('Redirecting user to: %s', url)
805 return url
806
807
808 def _ComputeBackToListURL(mr, issue, config):
809 """Construct a URL to return the user to the place that they came from."""
810 back_to_list_url = None
811 if not tracker_constants.JUMP_RE.match(mr.query):
812 back_to_list_url = tracker_helpers.FormatIssueListURL(
813 mr, config, cursor='%s:%d' % (issue.project_name, issue.local_id))
814
815 return back_to_list_url
816
817
818 def _FieldEditPermitted(
819 labels, blocked_on_str, blocking_str, summary, status, owner_id, cc_ids,
820 page_perms):
821 """Check permissions on editing individual form fields.
822
823 This check is only done if the user does not have the overall
824 EditIssue perm. If the user edited any field that they do not have
825 permission to edit, then they could have forged a post, or maybe
826 they had a valid form open in a browser tab while at the same time
827 their perms in the project were reduced. Either way, the servlet
828 gives them a BadRequest HTTP error and makes them go back and try
829 again.
830
831 TODO(jrobbins): It would be better to show a custom error page that
832 takes the user back to the issue with a new page load rather than
833 having the user use the back button.
834
835 Args:
836 labels: list of label values parsed from the form.
837 blocked_on_str: list of blocked-on values parsed from the form.
838 blocking_str: list of blocking values parsed from the form.
839 summary: issue summary string parsed from the form.
840 status: issue status string parsed from the form.
841 owner_id: issue owner user ID parsed from the form and looked up.
842 cc_ids: list of user IDs for Cc'd users parsed from the form.
843 page_perms: object with fields for permissions the current user
844 has on the current issue.
845
846 Returns:
847 True if there was no permission violation. False if the user tried
848 to edit something that they do not have permission to edit.
849 """
850 if labels or blocked_on_str or blocking_str:
851 logging.info('user has no permission to edit issue metadata')
852 return False
853
854 if summary and not page_perms.EditIssueSummary:
855 logging.info('user has no permission to edit issue summary field')
856 return False
857
858 if status and not page_perms.EditIssueStatus:
859 logging.info('user has no permission to edit issue status field')
860 return False
861
862 if owner_id and not page_perms.EditIssueOwner:
863 logging.info('user has no permission to edit issue owner field')
864 return False
865
866 if cc_ids and not page_perms.EditIssueCc:
867 logging.info('user has no permission to edit issue cc field')
868 return False
869
870 return True
871
872
873 def _ChooseNextPage(
874 mr, local_id, config, moved_to_project_name_and_local_id,
875 copied_to_project_name_and_local_id, after_issue_update, next_id):
876 """Choose the next page to show the user after an issue update.
877
878 Args:
879 mr: information parsed from the request.
880 local_id: int Issue ID of the issue that was updated.
881 config: project issue config object.
882 moved_to_project_name_and_local_id: tuple containing the project name the
883 issue was moved to and the local id in that project.
884 copied_to_project_name_and_local_id: tuple containing the project name the
885 issue was copied to and the local id in that project.
886 after_issue_update: user pref on where to go next.
887 next_id: string local ID of next issue at the time the form was generated.
888
889 Returns:
890 String absolute URL of next page to view.
891 """
892 issue_ref_str = '%s:%d' % (mr.project_name, local_id)
893 kwargs = {
894 'ts': int(time.time()),
895 'cursor': issue_ref_str,
896 }
897 if moved_to_project_name_and_local_id:
898 kwargs['moved_to_project'] = moved_to_project_name_and_local_id[0]
899 kwargs['moved_to_id'] = moved_to_project_name_and_local_id[1]
900 elif copied_to_project_name_and_local_id:
901 kwargs['copied_from_id'] = local_id
902 kwargs['copied_to_project'] = copied_to_project_name_and_local_id[0]
903 kwargs['copied_to_id'] = copied_to_project_name_and_local_id[1]
904 else:
905 kwargs['updated'] = local_id
906 url = tracker_helpers.FormatIssueListURL(
907 mr, config, **kwargs)
908
909 if after_issue_update == user_pb2.IssueUpdateNav.STAY_SAME_ISSUE:
910 # If it was a move request then will have to switch to the new project to
911 # stay on the same issue.
912 if moved_to_project_name_and_local_id:
913 mr.project_name = moved_to_project_name_and_local_id[0]
914 url = framework_helpers.FormatAbsoluteURL(
915 mr, urls.ISSUE_DETAIL, id=local_id)
916 elif after_issue_update == user_pb2.IssueUpdateNav.NEXT_IN_LIST:
917 if next_id:
918 url = framework_helpers.FormatAbsoluteURL(
919 mr, urls.ISSUE_DETAIL, id=next_id)
920
921 return url
922
923
924 class SetStarForm(jsonfeed.JsonFeed):
925 """Star or unstar the specified issue for the logged in user."""
926
927 def AssertBasePermission(self, mr):
928 super(SetStarForm, self).AssertBasePermission(mr)
929 issue = self.services.issue.GetIssueByLocalID(
930 mr.cnxn, mr.project_id, mr.local_id)
931 if not self.CheckPerm(mr, permissions.SET_STAR, art=issue):
932 raise permissions.PermissionException(
933 'You are not allowed to star issues')
934
935 def HandleRequest(self, mr):
936 """Build up a dictionary of data values to use when rendering the page.
937
938 Args:
939 mr: commonly used info parsed from the request.
940
941 Returns:
942 Dict of values used by EZT for rendering the page.
943 """
944 issue = self.services.issue.GetIssueByLocalID(
945 mr.cnxn, mr.project_id, mr.local_id)
946 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
947 self.services.issue_star.SetStar(
948 mr.cnxn, self.services, config, issue.issue_id, mr.auth.user_id,
949 mr.starred)
950
951 return {
952 'starred': bool(mr.starred),
953 }
954
955
956 def _ShouldShowFlipper(mr, services):
957 """Return True if we should show the flipper."""
958
959 # Check if the user entered a specific issue ID of an existing issue.
960 if tracker_constants.JUMP_RE.match(mr.query):
961 return False
962
963 # Check if the user came directly to an issue without specifying any
964 # query or sort. E.g., through crbug.com. Generating the issue ref
965 # list can be too expensive in projects that have a large number of
966 # issues. The all and open issues cans are broad queries, other
967 # canned queries should be narrow enough to not need this special
968 # treatment.
969 if (not mr.query and not mr.sort_spec and
970 mr.can in [tracker_constants.ALL_ISSUES_CAN,
971 tracker_constants.OPEN_ISSUES_CAN]):
972 num_issues_in_project = services.issue.GetHighestLocalID(
973 mr.cnxn, mr.project_id)
974 if num_issues_in_project > settings.threshold_to_suppress_prev_next:
975 return False
976
977 return True
978
979
980 class _Flipper(object):
981 """Helper class for user to flip among issues within a search result."""
982
983 def __init__(self, mr, services, prof):
984 """Store info for issue flipper widget (prev & next navigation).
985
986 Args:
987 mr: commonly used info parsed from the request.
988 services: connections to backend services.
989 prof: a Profiler for the sevlet's handling of the current request.
990 """
991
992 if not _ShouldShowFlipper(mr, services):
993 self.show = ezt.boolean(False)
994 self.pipeline = None
995 return
996
997 self.pipeline = frontendsearchpipeline.FrontendSearchPipeline(
998 mr, services, prof, None)
999
1000 self.services = services
1001
1002 def SearchForIIDs(self, mr, issue):
1003 """Do the next step of searching for issue IDs for the flipper.
1004
1005 Args:
1006 mr: commonly used info parsed from the request.
1007 issue: the currently viewed issue.
1008 """
1009 if not self.pipeline:
1010 return
1011
1012 if not mr.errors.AnyErrors():
1013 # Only do the search if the user's query parsed OK.
1014 self.pipeline.SearchForIIDs()
1015
1016 # Note: we never call MergeAndSortIssues() because we don't need a unified
1017 # sorted list, we only need to know the position on such a list of the
1018 # current issue.
1019 prev_iid, cur_index, next_iid = self.pipeline.DetermineIssuePosition(issue)
1020
1021 logging.info('prev_iid, cur_index, next_iid is %r %r %r',
1022 prev_iid, cur_index, next_iid)
1023 # pylint: disable=attribute-defined-outside-init
1024 if cur_index is None or self.pipeline.total_count == 1:
1025 # The user probably edited the URL, or bookmarked an issue
1026 # in a search context that no longer matches the issue.
1027 self.show = ezt.boolean(False)
1028 else:
1029 self.show = True
1030 self.current = cur_index + 1
1031 self.total_count = self.pipeline.total_count
1032 self.next_id = None
1033 self.next_project_name = None
1034 self.prev_url = ''
1035 self.next_url = ''
1036
1037 if prev_iid:
1038 prev_issue = self.services.issue.GetIssue(mr.cnxn, prev_iid)
1039 prev_path = '/p/%s%s' % (prev_issue.project_name, urls.ISSUE_DETAIL)
1040 self.prev_url = framework_helpers.FormatURL(
1041 mr, prev_path, id=prev_issue.local_id)
1042
1043 if next_iid:
1044 next_issue = self.services.issue.GetIssue(mr.cnxn, next_iid)
1045 self.next_id = next_issue.local_id
1046 self.next_project_name = next_issue.project_name
1047 next_path = '/p/%s%s' % (next_issue.project_name, urls.ISSUE_DETAIL)
1048 self.next_url = framework_helpers.FormatURL(
1049 mr, next_path, id=next_issue.local_id)
1050
1051 def DebugString(self):
1052 """Return a string representation useful in debugging."""
1053 if self.show:
1054 return 'on %s of %s; prev_url:%s; next_url:%s' % (
1055 self.current, self.total_count, self.prev_url, self.next_url)
1056 else:
1057 return 'invisible flipper(show=%s)' % self.show
1058
1059
1060 class IssueCommentDeletion(servlet.Servlet):
1061 """Form handler that allows user to delete/undelete comments."""
1062
1063 def ProcessFormData(self, mr, post_data):
1064 """Process the form that un/deletes an issue comment.
1065
1066 Args:
1067 mr: commonly used info parsed from the request.
1068 post_data: The post_data dict for the current request.
1069
1070 Returns:
1071 String URL to redirect the user to after processing.
1072 """
1073 logging.info('post_data = %s', post_data)
1074 local_id = int(post_data['id'])
1075 sequence_num = int(post_data['sequence_num'])
1076 delete = (post_data['mode'] == '1')
1077
1078 issue = self.services.issue.GetIssueByLocalID(
1079 mr.cnxn, mr.project_id, local_id)
1080 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
1081
1082 all_comments = self.services.issue.GetCommentsForIssue(
1083 mr.cnxn, issue.issue_id)
1084 logging.info('comments on %s are: %s', local_id, all_comments)
1085 comment = all_comments[sequence_num]
1086
1087 granted_perms = tracker_bizobj.GetGrantedPerms(
1088 issue, mr.auth.effective_ids, config)
1089
1090 if ((comment.is_spam and mr.auth.user_id == comment.user_id) or
1091 not permissions.CanDelete(
1092 mr.auth.user_id, mr.auth.effective_ids, mr.perms,
1093 comment.deleted_by, comment.user_id, mr.project,
1094 permissions.GetRestrictions(issue), granted_perms=granted_perms)):
1095 raise permissions.PermissionException('Cannot delete comment')
1096
1097 self.services.issue.SoftDeleteComment(
1098 mr.cnxn, mr.project_id, local_id, sequence_num,
1099 mr.auth.user_id, self.services.user, delete=delete)
1100
1101 return framework_helpers.FormatAbsoluteURL(
1102 mr, urls.ISSUE_DETAIL, id=local_id)
1103
1104
1105 class IssueDeleteForm(servlet.Servlet):
1106 """A form handler to delete or undelete an issue.
1107
1108 Project owners will see a button on every issue to delete it, and
1109 if they specifically visit a deleted issue they will see a button to
1110 undelete it.
1111 """
1112
1113 def ProcessFormData(self, mr, post_data):
1114 """Process the form that un/deletes an issue comment.
1115
1116 Args:
1117 mr: commonly used info parsed from the request.
1118 post_data: The post_data dict for the current request.
1119
1120 Returns:
1121 String URL to redirect the user to after processing.
1122 """
1123 local_id = int(post_data['id'])
1124 delete = 'delete' in post_data
1125 logging.info('Marking issue %d as deleted: %r', local_id, delete)
1126
1127 issue = self.services.issue.GetIssueByLocalID(
1128 mr.cnxn, mr.project_id, local_id)
1129 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
1130 granted_perms = tracker_bizobj.GetGrantedPerms(
1131 issue, mr.auth.effective_ids, config)
1132 permit_delete = self.CheckPerm(
1133 mr, permissions.DELETE_ISSUE, art=issue, granted_perms=granted_perms)
1134 if not permit_delete:
1135 raise permissions.PermissionException('Cannot un/delete issue')
1136
1137 self.services.issue.SoftDeleteIssue(
1138 mr.cnxn, mr.project_id, local_id, delete, self.services.user)
1139
1140 return framework_helpers.FormatAbsoluteURL(
1141 mr, urls.ISSUE_DETAIL, id=local_id)
1142
1143 # TODO(jrobbins): do we want this?
1144 # class IssueDerivedLabelsJSON(jsonfeed.JsonFeed)
1145
1146
1147 def CheckCopyIssueRequest(
1148 services, mr, issue, copy_selected, copy_to, errors):
1149 """Process the copy issue portions of the issue update form.
1150
1151 Args:
1152 services: A Services object
1153 mr: commonly used info parsed from the request.
1154 issue: Issue protobuf for the issue being copied.
1155 copy_selected: True if the user selected the Copy action.
1156 copy_to: A project_name or url to copy this issue to or None
1157 if the project name wasn't sent in the form.
1158 errors: The errors object for this request.
1159
1160 Returns:
1161 The project pb for the project the issue will be copy to
1162 or None if the copy cannot be performed. Perhaps because
1163 the project does not exist, in which case copy_to and
1164 copy_to_project will be set on the errors object. Perhaps
1165 the user does not have permission to copy the issue to the
1166 destination project, in which case the copy_to field will be
1167 set on the errors object.
1168 """
1169 if not copy_selected:
1170 return None
1171
1172 if not copy_to:
1173 errors.copy_to = 'No destination project specified'
1174 errors.copy_to_project = copy_to
1175 return None
1176
1177 copy_to_project = services.project.GetProjectByName(mr.cnxn, copy_to)
1178 if not copy_to_project:
1179 errors.copy_to = 'No such project: ' + copy_to
1180 errors.copy_to_project = copy_to
1181 return None
1182
1183 # permissions enforcement
1184 if not servlet_helpers.CheckPermForProject(
1185 mr, permissions.EDIT_ISSUE, copy_to_project):
1186 errors.copy_to = 'You do not have permission to copy issues to project'
1187 errors.copy_to_project = copy_to
1188 return None
1189
1190 elif permissions.GetRestrictions(issue):
1191 errors.copy_to = (
1192 'Issues with Restrict labels are not allowed to be copied.')
1193 errors.copy_to_project = ''
1194 return None
1195
1196 return copy_to_project
1197
1198
1199 def CheckMoveIssueRequest(
1200 services, mr, issue, move_selected, move_to, errors):
1201 """Process the move issue portions of the issue update form.
1202
1203 Args:
1204 services: A Services object
1205 mr: commonly used info parsed from the request.
1206 issue: Issue protobuf for the issue being moved.
1207 move_selected: True if the user selected the Move action.
1208 move_to: A project_name or url to move this issue to or None
1209 if the project name wasn't sent in the form.
1210 errors: The errors object for this request.
1211
1212 Returns:
1213 The project pb for the project the issue will be moved to
1214 or None if the move cannot be performed. Perhaps because
1215 the project does not exist, in which case move_to and
1216 move_to_project will be set on the errors object. Perhaps
1217 the user does not have permission to move the issue to the
1218 destination project, in which case the move_to field will be
1219 set on the errors object.
1220 """
1221 if not move_selected:
1222 return None
1223
1224 if not move_to:
1225 errors.move_to = 'No destination project specified'
1226 errors.move_to_project = move_to
1227 return None
1228
1229 if issue.project_name == move_to:
1230 errors.move_to = 'This issue is already in project ' + move_to
1231 errors.move_to_project = move_to
1232 return None
1233
1234 move_to_project = services.project.GetProjectByName(mr.cnxn, move_to)
1235 if not move_to_project:
1236 errors.move_to = 'No such project: ' + move_to
1237 errors.move_to_project = move_to
1238 return None
1239
1240 # permissions enforcement
1241 if not servlet_helpers.CheckPermForProject(
1242 mr, permissions.EDIT_ISSUE, move_to_project):
1243 errors.move_to = 'You do not have permission to move issues to project'
1244 errors.move_to_project = move_to
1245 return None
1246
1247 elif permissions.GetRestrictions(issue):
1248 errors.move_to = (
1249 'Issues with Restrict labels are not allowed to be moved.')
1250 errors.move_to_project = ''
1251 return None
1252
1253 return move_to_project
OLDNEW
« no previous file with comments | « appengine/monorail/tracker/issuebulkedit.py ('k') | appengine/monorail/tracker/issueentry.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698