OLD | NEW |
(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 |
OLD | NEW |