Index: appengine/monorail/tracker/issuepeek.py |
diff --git a/appengine/monorail/tracker/issuepeek.py b/appengine/monorail/tracker/issuepeek.py |
new file mode 100644 |
index 0000000000000000000000000000000000000000..24d8a902ca9164e0bc75330dcd3319d1053ac162 |
--- /dev/null |
+++ b/appengine/monorail/tracker/issuepeek.py |
@@ -0,0 +1,378 @@ |
+# Copyright 2016 The Chromium Authors. All rights reserved. |
+# Use of this source code is govered by a BSD-style |
+# license that can be found in the LICENSE file or at |
+# https://developers.google.com/open-source/licenses/bsd |
+ |
+"""Classes that implement the issue peek page and related forms.""" |
+ |
+import logging |
+import time |
+from third_party import ezt |
+ |
+import settings |
+from features import commands |
+from features import notify |
+from framework import framework_bizobj |
+from framework import framework_constants |
+from framework import framework_helpers |
+from framework import framework_views |
+from framework import monorailrequest |
+from framework import paginate |
+from framework import permissions |
+from framework import servlet |
+from framework import sql |
+from framework import template_helpers |
+from framework import urls |
+from framework import xsrf |
+from services import issue_svc |
+from tracker import tracker_bizobj |
+from tracker import tracker_constants |
+from tracker import tracker_helpers |
+from tracker import tracker_views |
+ |
+ |
+class IssuePeek(servlet.Servlet): |
+ """IssuePeek is a page that shows the details of one issue.""" |
+ |
+ _PAGE_TEMPLATE = 'tracker/issue-peek-ajah.ezt' |
+ _ALLOW_VIEWING_DELETED = False |
+ |
+ def AssertBasePermission(self, mr): |
+ """Check that the user has permission to even visit this page.""" |
+ super(IssuePeek, self).AssertBasePermission(mr) |
+ try: |
+ issue = self._GetIssue(mr) |
+ except issue_svc.NoSuchIssueException: |
+ return |
+ if not issue: |
+ return |
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id) |
+ granted_perms = tracker_bizobj.GetGrantedPerms( |
+ issue, mr.auth.effective_ids, config) |
+ permit_view = permissions.CanViewIssue( |
+ mr.auth.effective_ids, mr.perms, mr.project, issue, |
+ allow_viewing_deleted=self._ALLOW_VIEWING_DELETED, |
+ granted_perms=granted_perms) |
+ if not permit_view: |
+ raise permissions.PermissionException( |
+ 'User is not allowed to view this issue') |
+ |
+ def _GetIssue(self, mr): |
+ """Retrieve the current issue.""" |
+ if mr.local_id is None: |
+ return None # GatherPageData will detect the same condition. |
+ issue = self.services.issue.GetIssueByLocalID( |
+ mr.cnxn, mr.project_id, mr.local_id) |
+ return issue |
+ |
+ def GatherPageData(self, mr): |
+ """Build up a dictionary of data values to use when rendering the page. |
+ |
+ Args: |
+ mr: commonly used info parsed from the request. |
+ |
+ Returns: |
+ Dict of values used by EZT for rendering the page. |
+ """ |
+ if mr.local_id is None: |
+ self.abort(404, 'no issue specified') |
+ with self.profiler.Phase('finishing getting issue'): |
+ issue = self._GetIssue(mr) |
+ if issue is None: |
+ self.abort(404, 'issue not found') |
+ |
+ # We give no explanation of missing issues on the peek page. |
+ if issue is None or issue.deleted: |
+ self.abort(404, 'issue not found') |
+ |
+ star_cnxn = sql.MonorailConnection() |
+ star_promise = framework_helpers.Promise( |
+ self.services.issue_star.IsItemStarredBy, star_cnxn, |
+ issue.issue_id, mr.auth.user_id) |
+ |
+ with self.profiler.Phase('getting project issue config'): |
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id) |
+ |
+ with self.profiler.Phase('finishing getting comments'): |
+ comments = self.services.issue.GetCommentsForIssue( |
+ mr.cnxn, issue.issue_id) |
+ |
+ description, visible_comments, cmnt_pagination = PaginateComments( |
+ mr, issue, comments, config) |
+ |
+ with self.profiler.Phase('making user proxies'): |
+ users_by_id = framework_views.MakeAllUserViews( |
+ mr.cnxn, self.services.user, |
+ tracker_bizobj.UsersInvolvedInIssues([issue]), |
+ tracker_bizobj.UsersInvolvedInCommentList( |
+ [description] + visible_comments)) |
+ framework_views.RevealAllEmailsToMembers(mr, users_by_id) |
+ |
+ (issue_view, description_view, |
+ comment_views) = self._MakeIssueAndCommentViews( |
+ mr, issue, users_by_id, description, visible_comments, config) |
+ |
+ with self.profiler.Phase('getting starring info'): |
+ starred = star_promise.WaitAndGetValue() |
+ star_cnxn.Close() |
+ permit_edit = permissions.CanEditIssue( |
+ mr.auth.effective_ids, mr.perms, mr.project, issue) |
+ |
+ mr.ComputeColSpec(config) |
+ restrict_to_known = config.restrict_to_known |
+ |
+ page_perms = self.MakePagePerms( |
+ mr, issue, |
+ permissions.CREATE_ISSUE, |
+ permissions.SET_STAR, |
+ permissions.EDIT_ISSUE, |
+ permissions.EDIT_ISSUE_SUMMARY, |
+ permissions.EDIT_ISSUE_STATUS, |
+ permissions.EDIT_ISSUE_OWNER, |
+ permissions.EDIT_ISSUE_CC, |
+ permissions.DELETE_ISSUE, |
+ permissions.ADD_ISSUE_COMMENT, |
+ permissions.DELETE_OWN, |
+ permissions.DELETE_ANY, |
+ permissions.VIEW_INBOUND_MESSAGES) |
+ page_perms.EditIssue = ezt.boolean(permit_edit) |
+ |
+ prevent_restriction_removal = ( |
+ mr.project.only_owners_remove_restrictions and |
+ not framework_bizobj.UserOwnsProject( |
+ mr.project, mr.auth.effective_ids)) |
+ |
+ cmd_slots, default_slot_num = self.services.features.GetRecentCommands( |
+ mr.cnxn, mr.auth.user_id, mr.project_id) |
+ cmd_slot_views = [ |
+ template_helpers.EZTItem( |
+ slot_num=slot_num, command=command, comment=comment) |
+ for slot_num, command, comment in cmd_slots] |
+ |
+ previous_locations = self.GetPreviousLocations(mr, issue) |
+ |
+ return { |
+ 'issue_tab_mode': 'issueDetail', |
+ 'issue': issue_view, |
+ 'description': description_view, |
+ 'comments': comment_views, |
+ 'labels': issue.labels, |
+ 'num_detail_rows': len(comment_views) + 4, |
+ 'noisy': ezt.boolean(tracker_helpers.IsNoisy( |
+ len(comment_views), issue.star_count)), |
+ |
+ 'cmnt_pagination': cmnt_pagination, |
+ 'colspec': mr.col_spec, |
+ 'searchtip': 'You can jump to any issue by number', |
+ 'starred': ezt.boolean(starred), |
+ |
+ 'pagegen': str(long(time.time() * 1000000)), |
+ 'set_star_token': xsrf.GenerateToken( |
+ mr.auth.user_id, '/p/%s%s' % ( # Note: no .do suffix. |
+ mr.project_name, urls.ISSUE_SETSTAR_JSON)), |
+ |
+ 'restrict_to_known': ezt.boolean(restrict_to_known), |
+ 'prevent_restriction_removal': ezt.boolean( |
+ prevent_restriction_removal), |
+ |
+ 'statuses_offer_merge': config.statuses_offer_merge, |
+ 'page_perms': page_perms, |
+ 'cmd_slots': cmd_slot_views, |
+ 'default_slot_num': default_slot_num, |
+ 'quick_edit_submit_url': tracker_helpers.FormatRelativeIssueURL( |
+ issue.project_name, urls.ISSUE_PEEK + '.do', id=issue.local_id), |
+ 'previous_locations': previous_locations, |
+ } |
+ |
+ def GetPreviousLocations(self, mr, issue): |
+ """Return a list of previous locations of the current issue.""" |
+ previous_location_ids = self.services.issue.GetPreviousLocations( |
+ mr.cnxn, issue) |
+ previous_locations = [] |
+ for old_pid, old_id in previous_location_ids: |
+ old_project = self.services.project.GetProject(mr.cnxn, old_pid) |
+ previous_locations.append( |
+ template_helpers.EZTItem( |
+ project_name=old_project.project_name, local_id=old_id)) |
+ |
+ return previous_locations |
+ |
+ def _MakeIssueAndCommentViews( |
+ self, mr, issue, users_by_id, initial_description, comments, config, |
+ issue_reporters=None, comment_reporters=None): |
+ """Create view objects that help display parts of an issue. |
+ |
+ Args: |
+ mr: commonly used info parsed from the request. |
+ issue: issue PB for the currently viewed issue. |
+ users_by_id: dictionary of {user_id: UserView,...}. |
+ initial_description: IssueComment for the initial issue report. |
+ comments: list of IssueComment PBs on the current issue. |
+ issue_reporters: list of user IDs who have flagged the issue as spam. |
+ comment_reporters: map of comment ID to list of flagging user IDs. |
+ config: ProjectIssueConfig for the project that contains this issue. |
+ |
+ Returns: |
+ (issue_view, description_view, comment_views). One IssueView for |
+ the whole issue, one IssueCommentView for the initial description, |
+ and then a list of IssueCommentView's for each additional comment. |
+ """ |
+ with self.profiler.Phase('getting related issues'): |
+ open_related, closed_related = ( |
+ tracker_helpers.GetAllowedOpenAndClosedRelatedIssues( |
+ self.services, mr, issue)) |
+ all_related_iids = list(issue.blocked_on_iids) + list(issue.blocking_iids) |
+ if issue.merged_into: |
+ all_related_iids.append(issue.merged_into) |
+ all_related = self.services.issue.GetIssues(mr.cnxn, all_related_iids) |
+ |
+ with self.profiler.Phase('making issue view'): |
+ issue_view = tracker_views.IssueView( |
+ issue, users_by_id, config, |
+ open_related=open_related, closed_related=closed_related, |
+ all_related={rel.issue_id: rel for rel in all_related}) |
+ |
+ with self.profiler.Phase('autolinker object lookup'): |
+ all_ref_artifacts = self.services.autolink.GetAllReferencedArtifacts( |
+ mr, [c.content for c in [initial_description] + comments]) |
+ |
+ with self.profiler.Phase('making comment views'): |
+ reporter_auth = monorailrequest.AuthData.FromUserID( |
+ mr.cnxn, initial_description.user_id, self.services) |
+ desc_view = tracker_views.IssueCommentView( |
+ mr.project_name, initial_description, users_by_id, |
+ self.services.autolink, all_ref_artifacts, mr, |
+ issue, effective_ids=reporter_auth.effective_ids) |
+ # TODO(jrobbins): get effective_ids of each comment author, but |
+ # that is too slow right now. |
+ comment_views = [ |
+ tracker_views.IssueCommentView( |
+ mr.project_name, c, users_by_id, self.services.autolink, |
+ all_ref_artifacts, mr, issue) |
+ for c in comments] |
+ |
+ issue_view.flagged_spam = mr.auth.user_id in issue_reporters |
+ if comment_reporters is not None: |
+ for c in comment_views: |
+ c.flagged_spam = mr.auth.user_id in comment_reporters.get(c.id, []) |
+ |
+ return issue_view, desc_view, comment_views |
+ |
+ def ProcessFormData(self, mr, post_data): |
+ """Process the posted issue update form. |
+ |
+ Args: |
+ mr: commonly used info parsed from the request. |
+ post_data: HTML form data from the request. |
+ |
+ Returns: |
+ String URL to redirect the user to, or None if response was already sent. |
+ """ |
+ cmd = post_data.get('cmd', '') |
+ send_email = 'send_email' in post_data |
+ comment = post_data.get('comment', '') |
+ slot_used = int(post_data.get('slot_used', 1)) |
+ page_generation_time = long(post_data['pagegen']) |
+ issue = self._GetIssue(mr) |
+ old_owner_id = tracker_bizobj.GetOwnerId(issue) |
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id) |
+ |
+ summary, status, owner_id, cc_ids, labels = commands.ParseQuickEditCommand( |
+ mr.cnxn, cmd, issue, config, mr.auth.user_id, self.services) |
+ component_ids = issue.component_ids # TODO(jrobbins): component commands |
+ field_values = issue.field_values # TODO(jrobbins): edit custom fields |
+ |
+ permit_edit = permissions.CanEditIssue( |
+ mr.auth.effective_ids, mr.perms, mr.project, issue) |
+ if not permit_edit: |
+ raise permissions.PermissionException( |
+ 'User is not allowed to edit this issue') |
+ |
+ amendments, _ = self.services.issue.ApplyIssueComment( |
+ mr.cnxn, self.services, mr.auth.user_id, |
+ mr.project_id, mr.local_id, summary, status, owner_id, cc_ids, |
+ labels, field_values, component_ids, issue.blocked_on_iids, |
+ issue.blocking_iids, issue.dangling_blocked_on_refs, |
+ issue.dangling_blocking_refs, issue.merged_into, |
+ page_gen_ts=page_generation_time, comment=comment) |
+ self.services.project.UpdateRecentActivity( |
+ mr.cnxn, mr.project.project_id) |
+ |
+ if send_email: |
+ if amendments or comment.strip(): |
+ cmnts = self.services.issue.GetCommentsForIssue( |
+ mr.cnxn, issue.issue_id) |
+ notify.PrepareAndSendIssueChangeNotification( |
+ mr.project_id, mr.local_id, mr.request.host, |
+ mr.auth.user_id, len(cmnts) - 1, |
+ send_email=send_email, old_owner_id=old_owner_id) |
+ |
+ # TODO(jrobbins): allow issue merge via quick-edit. |
+ |
+ self.services.features.StoreRecentCommand( |
+ mr.cnxn, mr.auth.user_id, mr.project_id, slot_used, cmd, comment) |
+ |
+ # TODO(jrobbins): this is very similar to a block of code in issuebulkedit. |
+ mr.can = int(post_data['can']) |
+ mr.query = post_data.get('q', '') |
+ mr.col_spec = post_data.get('colspec', '') |
+ mr.sort_spec = post_data.get('sort', '') |
+ mr.group_by_spec = post_data.get('groupby', '') |
+ mr.start = int(post_data['start']) |
+ mr.num = int(post_data['num']) |
+ preview_issue_ref_str = '%s:%d' % (issue.project_name, issue.local_id) |
+ return tracker_helpers.FormatIssueListURL( |
+ mr, config, preview=preview_issue_ref_str, updated=mr.local_id, |
+ ts=int(time.time())) |
+ |
+ |
+def PaginateComments(mr, issue, issuecomment_list, config): |
+ """Filter and paginate the IssueComment PBs for the given issue. |
+ |
+ Unlike most pagination, this one starts at the end of the whole |
+ list so it shows only the most recent comments. The user can use |
+ the "Older" and "Newer" links to page through older comments. |
+ |
+ Args: |
+ mr: common info parsed from the HTTP request. |
+ issue: Issue PB for the issue being viewed. |
+ issuecomment_list: list of IssueComment PBs for the viewed issue, |
+ the zeroth item in this list is the initial issue description. |
+ config: ProjectIssueConfig for the project that contains this issue. |
+ |
+ Returns: |
+ A tuple (description, visible_comments, pagination), where description |
+ is the IssueComment for the initial issue description, visible_comments |
+ is a list of IssueComment PBs for the comments that should be displayed |
+ on the current pagination page, and pagination is a VirtualPagination |
+ object that keeps track of the Older and Newer links. |
+ """ |
+ if not issuecomment_list: |
+ return None, [], None |
+ |
+ description = issuecomment_list[0] |
+ comments = issuecomment_list[1:] |
+ allowed_comments = [] |
+ restrictions = permissions.GetRestrictions(issue) |
+ granted_perms = tracker_bizobj.GetGrantedPerms( |
+ issue, mr.auth.effective_ids, config) |
+ for c in comments: |
+ can_delete = permissions.CanDelete( |
+ mr.auth.user_id, mr.auth.effective_ids, mr.perms, c.deleted_by, |
+ c.user_id, mr.project, restrictions, granted_perms=granted_perms) |
+ if can_delete or not c.deleted_by: |
+ allowed_comments.append(c) |
+ |
+ pagination_url = '%s?id=%d' % (urls.ISSUE_DETAIL, issue.local_id) |
+ pagination = paginate.VirtualPagination( |
+ mr, len(allowed_comments), |
+ framework_constants.DEFAULT_COMMENTS_PER_PAGE, |
+ list_page_url=pagination_url, |
+ count_up=False, start_param='cstart', num_param='cnum', |
+ max_num=settings.max_comments_per_page) |
+ if pagination.last == 1 and pagination.start == len(allowed_comments): |
+ pagination.visible = ezt.boolean(False) |
+ visible_comments = allowed_comments[ |
+ pagination.last - 1:pagination.start] |
+ |
+ return description, visible_comments, pagination |