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 peek page and related forms.""" |
| 7 |
| 8 import logging |
| 9 import time |
| 10 from third_party import ezt |
| 11 |
| 12 import settings |
| 13 from features import commands |
| 14 from features import notify |
| 15 from framework import framework_bizobj |
| 16 from framework import framework_constants |
| 17 from framework import framework_helpers |
| 18 from framework import framework_views |
| 19 from framework import monorailrequest |
| 20 from framework import paginate |
| 21 from framework import permissions |
| 22 from framework import servlet |
| 23 from framework import sql |
| 24 from framework import template_helpers |
| 25 from framework import urls |
| 26 from framework import xsrf |
| 27 from services import issue_svc |
| 28 from tracker import tracker_bizobj |
| 29 from tracker import tracker_constants |
| 30 from tracker import tracker_helpers |
| 31 from tracker import tracker_views |
| 32 |
| 33 |
| 34 class IssuePeek(servlet.Servlet): |
| 35 """IssuePeek is a page that shows the details of one issue.""" |
| 36 |
| 37 _PAGE_TEMPLATE = 'tracker/issue-peek-ajah.ezt' |
| 38 _ALLOW_VIEWING_DELETED = False |
| 39 |
| 40 def AssertBasePermission(self, mr): |
| 41 """Check that the user has permission to even visit this page.""" |
| 42 super(IssuePeek, self).AssertBasePermission(mr) |
| 43 try: |
| 44 issue = self._GetIssue(mr) |
| 45 except issue_svc.NoSuchIssueException: |
| 46 return |
| 47 if not issue: |
| 48 return |
| 49 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id) |
| 50 granted_perms = tracker_bizobj.GetGrantedPerms( |
| 51 issue, mr.auth.effective_ids, config) |
| 52 permit_view = permissions.CanViewIssue( |
| 53 mr.auth.effective_ids, mr.perms, mr.project, issue, |
| 54 allow_viewing_deleted=self._ALLOW_VIEWING_DELETED, |
| 55 granted_perms=granted_perms) |
| 56 if not permit_view: |
| 57 raise permissions.PermissionException( |
| 58 'User is not allowed to view this issue') |
| 59 |
| 60 def _GetIssue(self, mr): |
| 61 """Retrieve the current issue.""" |
| 62 if mr.local_id is None: |
| 63 return None # GatherPageData will detect the same condition. |
| 64 issue = self.services.issue.GetIssueByLocalID( |
| 65 mr.cnxn, mr.project_id, mr.local_id) |
| 66 return issue |
| 67 |
| 68 def GatherPageData(self, mr): |
| 69 """Build up a dictionary of data values to use when rendering the page. |
| 70 |
| 71 Args: |
| 72 mr: commonly used info parsed from the request. |
| 73 |
| 74 Returns: |
| 75 Dict of values used by EZT for rendering the page. |
| 76 """ |
| 77 if mr.local_id is None: |
| 78 self.abort(404, 'no issue specified') |
| 79 with self.profiler.Phase('finishing getting issue'): |
| 80 issue = self._GetIssue(mr) |
| 81 if issue is None: |
| 82 self.abort(404, 'issue not found') |
| 83 |
| 84 # We give no explanation of missing issues on the peek page. |
| 85 if issue is None or issue.deleted: |
| 86 self.abort(404, 'issue not found') |
| 87 |
| 88 star_cnxn = sql.MonorailConnection() |
| 89 star_promise = framework_helpers.Promise( |
| 90 self.services.issue_star.IsItemStarredBy, star_cnxn, |
| 91 issue.issue_id, mr.auth.user_id) |
| 92 |
| 93 with self.profiler.Phase('getting project issue config'): |
| 94 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id) |
| 95 |
| 96 with self.profiler.Phase('finishing getting comments'): |
| 97 comments = self.services.issue.GetCommentsForIssue( |
| 98 mr.cnxn, issue.issue_id) |
| 99 |
| 100 description, visible_comments, cmnt_pagination = PaginateComments( |
| 101 mr, issue, comments, config) |
| 102 |
| 103 with self.profiler.Phase('making user proxies'): |
| 104 users_by_id = framework_views.MakeAllUserViews( |
| 105 mr.cnxn, self.services.user, |
| 106 tracker_bizobj.UsersInvolvedInIssues([issue]), |
| 107 tracker_bizobj.UsersInvolvedInCommentList( |
| 108 [description] + visible_comments)) |
| 109 framework_views.RevealAllEmailsToMembers(mr, users_by_id) |
| 110 |
| 111 (issue_view, description_view, |
| 112 comment_views) = self._MakeIssueAndCommentViews( |
| 113 mr, issue, users_by_id, description, visible_comments, config) |
| 114 |
| 115 with self.profiler.Phase('getting starring info'): |
| 116 starred = star_promise.WaitAndGetValue() |
| 117 star_cnxn.Close() |
| 118 permit_edit = permissions.CanEditIssue( |
| 119 mr.auth.effective_ids, mr.perms, mr.project, issue) |
| 120 |
| 121 mr.ComputeColSpec(config) |
| 122 restrict_to_known = config.restrict_to_known |
| 123 |
| 124 page_perms = self.MakePagePerms( |
| 125 mr, issue, |
| 126 permissions.CREATE_ISSUE, |
| 127 permissions.SET_STAR, |
| 128 permissions.EDIT_ISSUE, |
| 129 permissions.EDIT_ISSUE_SUMMARY, |
| 130 permissions.EDIT_ISSUE_STATUS, |
| 131 permissions.EDIT_ISSUE_OWNER, |
| 132 permissions.EDIT_ISSUE_CC, |
| 133 permissions.DELETE_ISSUE, |
| 134 permissions.ADD_ISSUE_COMMENT, |
| 135 permissions.DELETE_OWN, |
| 136 permissions.DELETE_ANY, |
| 137 permissions.VIEW_INBOUND_MESSAGES) |
| 138 page_perms.EditIssue = ezt.boolean(permit_edit) |
| 139 |
| 140 prevent_restriction_removal = ( |
| 141 mr.project.only_owners_remove_restrictions and |
| 142 not framework_bizobj.UserOwnsProject( |
| 143 mr.project, mr.auth.effective_ids)) |
| 144 |
| 145 cmd_slots, default_slot_num = self.services.features.GetRecentCommands( |
| 146 mr.cnxn, mr.auth.user_id, mr.project_id) |
| 147 cmd_slot_views = [ |
| 148 template_helpers.EZTItem( |
| 149 slot_num=slot_num, command=command, comment=comment) |
| 150 for slot_num, command, comment in cmd_slots] |
| 151 |
| 152 previous_locations = self.GetPreviousLocations(mr, issue) |
| 153 |
| 154 return { |
| 155 'issue_tab_mode': 'issueDetail', |
| 156 'issue': issue_view, |
| 157 'description': description_view, |
| 158 'comments': comment_views, |
| 159 'labels': issue.labels, |
| 160 'num_detail_rows': len(comment_views) + 4, |
| 161 'noisy': ezt.boolean(tracker_helpers.IsNoisy( |
| 162 len(comment_views), issue.star_count)), |
| 163 |
| 164 'cmnt_pagination': cmnt_pagination, |
| 165 'colspec': mr.col_spec, |
| 166 'searchtip': 'You can jump to any issue by number', |
| 167 'starred': ezt.boolean(starred), |
| 168 |
| 169 'pagegen': str(long(time.time() * 1000000)), |
| 170 'set_star_token': xsrf.GenerateToken( |
| 171 mr.auth.user_id, '/p/%s%s' % ( # Note: no .do suffix. |
| 172 mr.project_name, urls.ISSUE_SETSTAR_JSON)), |
| 173 |
| 174 'restrict_to_known': ezt.boolean(restrict_to_known), |
| 175 'prevent_restriction_removal': ezt.boolean( |
| 176 prevent_restriction_removal), |
| 177 |
| 178 'statuses_offer_merge': config.statuses_offer_merge, |
| 179 'page_perms': page_perms, |
| 180 'cmd_slots': cmd_slot_views, |
| 181 'default_slot_num': default_slot_num, |
| 182 'quick_edit_submit_url': tracker_helpers.FormatRelativeIssueURL( |
| 183 issue.project_name, urls.ISSUE_PEEK + '.do', id=issue.local_id), |
| 184 'previous_locations': previous_locations, |
| 185 } |
| 186 |
| 187 def GetPreviousLocations(self, mr, issue): |
| 188 """Return a list of previous locations of the current issue.""" |
| 189 previous_location_ids = self.services.issue.GetPreviousLocations( |
| 190 mr.cnxn, issue) |
| 191 previous_locations = [] |
| 192 for old_pid, old_id in previous_location_ids: |
| 193 old_project = self.services.project.GetProject(mr.cnxn, old_pid) |
| 194 previous_locations.append( |
| 195 template_helpers.EZTItem( |
| 196 project_name=old_project.project_name, local_id=old_id)) |
| 197 |
| 198 return previous_locations |
| 199 |
| 200 def _MakeIssueAndCommentViews( |
| 201 self, mr, issue, users_by_id, initial_description, comments, config, |
| 202 issue_reporters=None, comment_reporters=None): |
| 203 """Create view objects that help display parts of an issue. |
| 204 |
| 205 Args: |
| 206 mr: commonly used info parsed from the request. |
| 207 issue: issue PB for the currently viewed issue. |
| 208 users_by_id: dictionary of {user_id: UserView,...}. |
| 209 initial_description: IssueComment for the initial issue report. |
| 210 comments: list of IssueComment PBs on the current issue. |
| 211 issue_reporters: list of user IDs who have flagged the issue as spam. |
| 212 comment_reporters: map of comment ID to list of flagging user IDs. |
| 213 config: ProjectIssueConfig for the project that contains this issue. |
| 214 |
| 215 Returns: |
| 216 (issue_view, description_view, comment_views). One IssueView for |
| 217 the whole issue, one IssueCommentView for the initial description, |
| 218 and then a list of IssueCommentView's for each additional comment. |
| 219 """ |
| 220 with self.profiler.Phase('getting related issues'): |
| 221 open_related, closed_related = ( |
| 222 tracker_helpers.GetAllowedOpenAndClosedRelatedIssues( |
| 223 self.services, mr, issue)) |
| 224 all_related_iids = list(issue.blocked_on_iids) + list(issue.blocking_iids) |
| 225 if issue.merged_into: |
| 226 all_related_iids.append(issue.merged_into) |
| 227 all_related = self.services.issue.GetIssues(mr.cnxn, all_related_iids) |
| 228 |
| 229 with self.profiler.Phase('making issue view'): |
| 230 issue_view = tracker_views.IssueView( |
| 231 issue, users_by_id, config, |
| 232 open_related=open_related, closed_related=closed_related, |
| 233 all_related={rel.issue_id: rel for rel in all_related}) |
| 234 |
| 235 with self.profiler.Phase('autolinker object lookup'): |
| 236 all_ref_artifacts = self.services.autolink.GetAllReferencedArtifacts( |
| 237 mr, [c.content for c in [initial_description] + comments]) |
| 238 |
| 239 with self.profiler.Phase('making comment views'): |
| 240 reporter_auth = monorailrequest.AuthData.FromUserID( |
| 241 mr.cnxn, initial_description.user_id, self.services) |
| 242 desc_view = tracker_views.IssueCommentView( |
| 243 mr.project_name, initial_description, users_by_id, |
| 244 self.services.autolink, all_ref_artifacts, mr, |
| 245 issue, effective_ids=reporter_auth.effective_ids) |
| 246 # TODO(jrobbins): get effective_ids of each comment author, but |
| 247 # that is too slow right now. |
| 248 comment_views = [ |
| 249 tracker_views.IssueCommentView( |
| 250 mr.project_name, c, users_by_id, self.services.autolink, |
| 251 all_ref_artifacts, mr, issue) |
| 252 for c in comments] |
| 253 |
| 254 issue_view.flagged_spam = mr.auth.user_id in issue_reporters |
| 255 if comment_reporters is not None: |
| 256 for c in comment_views: |
| 257 c.flagged_spam = mr.auth.user_id in comment_reporters.get(c.id, []) |
| 258 |
| 259 return issue_view, desc_view, comment_views |
| 260 |
| 261 def ProcessFormData(self, mr, post_data): |
| 262 """Process the posted issue update form. |
| 263 |
| 264 Args: |
| 265 mr: commonly used info parsed from the request. |
| 266 post_data: HTML form data from the request. |
| 267 |
| 268 Returns: |
| 269 String URL to redirect the user to, or None if response was already sent. |
| 270 """ |
| 271 cmd = post_data.get('cmd', '') |
| 272 send_email = 'send_email' in post_data |
| 273 comment = post_data.get('comment', '') |
| 274 slot_used = int(post_data.get('slot_used', 1)) |
| 275 page_generation_time = long(post_data['pagegen']) |
| 276 issue = self._GetIssue(mr) |
| 277 old_owner_id = tracker_bizobj.GetOwnerId(issue) |
| 278 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id) |
| 279 |
| 280 summary, status, owner_id, cc_ids, labels = commands.ParseQuickEditCommand( |
| 281 mr.cnxn, cmd, issue, config, mr.auth.user_id, self.services) |
| 282 component_ids = issue.component_ids # TODO(jrobbins): component commands |
| 283 field_values = issue.field_values # TODO(jrobbins): edit custom fields |
| 284 |
| 285 permit_edit = permissions.CanEditIssue( |
| 286 mr.auth.effective_ids, mr.perms, mr.project, issue) |
| 287 if not permit_edit: |
| 288 raise permissions.PermissionException( |
| 289 'User is not allowed to edit this issue') |
| 290 |
| 291 amendments, _ = self.services.issue.ApplyIssueComment( |
| 292 mr.cnxn, self.services, mr.auth.user_id, |
| 293 mr.project_id, mr.local_id, summary, status, owner_id, cc_ids, |
| 294 labels, field_values, component_ids, issue.blocked_on_iids, |
| 295 issue.blocking_iids, issue.dangling_blocked_on_refs, |
| 296 issue.dangling_blocking_refs, issue.merged_into, |
| 297 page_gen_ts=page_generation_time, comment=comment) |
| 298 self.services.project.UpdateRecentActivity( |
| 299 mr.cnxn, mr.project.project_id) |
| 300 |
| 301 if send_email: |
| 302 if amendments or comment.strip(): |
| 303 cmnts = self.services.issue.GetCommentsForIssue( |
| 304 mr.cnxn, issue.issue_id) |
| 305 notify.PrepareAndSendIssueChangeNotification( |
| 306 mr.project_id, mr.local_id, mr.request.host, |
| 307 mr.auth.user_id, len(cmnts) - 1, |
| 308 send_email=send_email, old_owner_id=old_owner_id) |
| 309 |
| 310 # TODO(jrobbins): allow issue merge via quick-edit. |
| 311 |
| 312 self.services.features.StoreRecentCommand( |
| 313 mr.cnxn, mr.auth.user_id, mr.project_id, slot_used, cmd, comment) |
| 314 |
| 315 # TODO(jrobbins): this is very similar to a block of code in issuebulkedit. |
| 316 mr.can = int(post_data['can']) |
| 317 mr.query = post_data.get('q', '') |
| 318 mr.col_spec = post_data.get('colspec', '') |
| 319 mr.sort_spec = post_data.get('sort', '') |
| 320 mr.group_by_spec = post_data.get('groupby', '') |
| 321 mr.start = int(post_data['start']) |
| 322 mr.num = int(post_data['num']) |
| 323 preview_issue_ref_str = '%s:%d' % (issue.project_name, issue.local_id) |
| 324 return tracker_helpers.FormatIssueListURL( |
| 325 mr, config, preview=preview_issue_ref_str, updated=mr.local_id, |
| 326 ts=int(time.time())) |
| 327 |
| 328 |
| 329 def PaginateComments(mr, issue, issuecomment_list, config): |
| 330 """Filter and paginate the IssueComment PBs for the given issue. |
| 331 |
| 332 Unlike most pagination, this one starts at the end of the whole |
| 333 list so it shows only the most recent comments. The user can use |
| 334 the "Older" and "Newer" links to page through older comments. |
| 335 |
| 336 Args: |
| 337 mr: common info parsed from the HTTP request. |
| 338 issue: Issue PB for the issue being viewed. |
| 339 issuecomment_list: list of IssueComment PBs for the viewed issue, |
| 340 the zeroth item in this list is the initial issue description. |
| 341 config: ProjectIssueConfig for the project that contains this issue. |
| 342 |
| 343 Returns: |
| 344 A tuple (description, visible_comments, pagination), where description |
| 345 is the IssueComment for the initial issue description, visible_comments |
| 346 is a list of IssueComment PBs for the comments that should be displayed |
| 347 on the current pagination page, and pagination is a VirtualPagination |
| 348 object that keeps track of the Older and Newer links. |
| 349 """ |
| 350 if not issuecomment_list: |
| 351 return None, [], None |
| 352 |
| 353 description = issuecomment_list[0] |
| 354 comments = issuecomment_list[1:] |
| 355 allowed_comments = [] |
| 356 restrictions = permissions.GetRestrictions(issue) |
| 357 granted_perms = tracker_bizobj.GetGrantedPerms( |
| 358 issue, mr.auth.effective_ids, config) |
| 359 for c in comments: |
| 360 can_delete = permissions.CanDelete( |
| 361 mr.auth.user_id, mr.auth.effective_ids, mr.perms, c.deleted_by, |
| 362 c.user_id, mr.project, restrictions, granted_perms=granted_perms) |
| 363 if can_delete or not c.deleted_by: |
| 364 allowed_comments.append(c) |
| 365 |
| 366 pagination_url = '%s?id=%d' % (urls.ISSUE_DETAIL, issue.local_id) |
| 367 pagination = paginate.VirtualPagination( |
| 368 mr, len(allowed_comments), |
| 369 framework_constants.DEFAULT_COMMENTS_PER_PAGE, |
| 370 list_page_url=pagination_url, |
| 371 count_up=False, start_param='cstart', num_param='cnum', |
| 372 max_num=settings.max_comments_per_page) |
| 373 if pagination.last == 1 and pagination.start == len(allowed_comments): |
| 374 pagination.visible = ezt.boolean(False) |
| 375 visible_comments = allowed_comments[ |
| 376 pagination.last - 1:pagination.start] |
| 377 |
| 378 return description, visible_comments, pagination |
OLD | NEW |