blob: d959a552d5ce21f3d4e3b8addf766cfbc343eb0a [file] [log] [blame]
# 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
"""Classes that implement the issue peek page and related forms."""
import logging
import time
from third_party import ezt
import settings
from businesslogic import work_env
from features import commands
from features import notify
from framework import authdata
from framework import exceptions
from framework import framework_bizobj
from framework import framework_constants
from framework import framework_helpers
from framework import framework_views
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 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'
def AssertBasePermission(self, mr):
"""Check that the user has permission to even visit this page."""
super(IssuePeek, self).AssertBasePermission(mr)
with work_env.WorkEnv(mr, as we:
issue = we.GetIssueByLocalID(mr.project_id, mr.local_id)
config = we.GetProjectConfig(mr.project_id)
except exceptions.NoSuchIssueException:
if not issue:
granted_perms = tracker_bizobj.GetGrantedPerms(
issue, mr.auth.effective_ids, config)
permit_view = permissions.CanViewIssue(
mr.auth.effective_ids, mr.perms, mr.project, issue,
if not permit_view:
logging.warning('Issue is %r', issue)
raise permissions.PermissionException(
'User is not allowed to view this issue')
def GatherPageData(self, mr):
"""Build up a dictionary of data values to use when rendering the page.
mr: commonly used info parsed from the request.
Dict of values used by EZT for rendering the page.
if mr.local_id is None:
self.abort(404, 'no issue specified')
with work_env.WorkEnv(mr, as we:
issue = we.GetIssueByLocalID(
mr.project_id, mr.local_id, use_cache=False)
# We give no explanation of missing issues on the peek page.
if issue.deleted:
self.abort(404, 'issue not found')
star_cnxn = sql.MonorailConnection()
star_promise = framework_helpers.Promise(
we.IsIssueStarred, issue, cnxn=star_cnxn)
config = we.GetProjectConfig(mr.project_id)
comments = we.GetCommentsForIssue(mr.cnxn, issue.issue_id)
descriptions, visible_comments, cmnt_pagination = PaginateComments(
mr, issue, comments, config)
with mr.profiler.Phase('making user proxies'):
involved_user_ids = tracker_bizobj.UsersInvolvedInIssues([issue])
group_ids =
mr.cnxn, involved_user_ids)
comment_user_ids = tracker_bizobj.UsersInvolvedInCommentList(
descriptions + visible_comments)
users_by_id = framework_views.MakeAllUserViews(
mr.cnxn,, involved_user_ids,
comment_user_ids, group_ids=group_ids)
framework_views.RevealAllEmailsToMembers(mr, users_by_id)
(issue_view, description_views,
comment_views) = self._MakeIssueAndCommentViews(
mr, issue, users_by_id, descriptions, visible_comments, config)
with mr.profiler.Phase('getting starring info'):
starred = star_promise.WaitAndGetValue()
permit_edit = permissions.CanEditIssue(
mr.auth.effective_ids, mr.perms, mr.project, issue)
restrict_to_known = config.restrict_to_known
page_perms = self.MakePagePerms(
mr, issue,
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 =
mr.cnxn, mr.auth.user_id, mr.project_id)
cmd_slot_views = [
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_views,
'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(
'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,
# for template issue-meta-part shared by issuedetail servlet
'user_remaining_hotlists': [],
'user_issue_hotlists': [],
'involved_users_issue_hotlists': [],
'remaining_issue_hotlists': [],
def GetPreviousLocations(self, mr, issue):
"""Return a list of previous locations of the current issue."""
previous_location_ids =
mr.cnxn, issue)
previous_locations = []
for old_pid, old_id in previous_location_ids:
old_project =, old_pid)
project_name=old_project.project_name, local_id=old_id))
return previous_locations
def _MakeIssueAndCommentViews(
self, mr, issue, users_by_id, descriptions, comments, config,
issue_reporters=None, comment_reporters=None):
"""Create view objects that help display parts of an issue.
mr: commonly used info parsed from the request.
issue: issue PB for the currently viewed issue.
users_by_id: dictionary of {user_id: UserView,...}.
descriptions: list of IssueComment PBs for the issue report history.
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.
(issue_view, description_views, comment_views). One IssueView for
the whole issue, a list of IssueCommentViews for the issue descriptions,
and then a list of IssueCommentViews for each additional comment.
with mr.profiler.Phase('getting related issues'):
open_related, closed_related = (
tracker_helpers.GetAllowedOpenAndClosedRelatedIssues(, mr, issue))
all_related_iids = list(issue.blocked_on_iids) + list(issue.blocking_iids)
if issue.merged_into:
all_related =, all_related_iids)
with mr.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 mr.profiler.Phase('autolinker object lookup'):
all_ref_artifacts =
mr, [c.content for c in descriptions + comments
if not c.deleted_by])
with mr.profiler.Phase('making comment views'):
reporter_auth = authdata.AuthData.FromUserID(
mr.cnxn, descriptions[0].user_id,
desc_views = [
mr.project_name, d, users_by_id,, all_ref_artifacts, mr,
issue, effective_ids=reporter_auth.effective_ids)
for d in descriptions]
# TODO(jrobbins): get effective_ids of each comment author, but
# that is too slow right now.
comment_views = [
mr.project_name, c, users_by_id,,
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(, [])
return issue_view, desc_views, comment_views
def ProcessFormData(self, mr, post_data):
"""Process the posted issue update form.
mr: commonly used info parsed from the request.
post_data: HTML form data from the request.
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'])
with work_env.WorkEnv(mr, as we:
issue = we.GetIssueByLocalID(
mr.project_id, mr.local_id, use_cache=False)
old_owner_id = tracker_bizobj.GetOwnerId(issue)
config = we.GetProjectConfig(mr.project_id)
summary, status, owner_id, cc_ids, labels = commands.ParseQuickEditCommand(
mr.cnxn, cmd, issue, config, mr.auth.user_id,
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, comment_pb =
mr.cnxn,, 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)
mr.cnxn, mr.project.project_id)
if send_email:
if amendments or comment.strip():
# TODO(jrobbins): Remove the seq_num parameter after we have
# deployed the change that switches to comment_id.
cmnts =
mr.cnxn, issue.issue_id)
seq_num = len(cmnts) - 1
issue.issue_id,, mr.auth.user_id, seq_num,
send_email=send_email, old_owner_id=old_owner_id,
# TODO(jrobbins): allow issue merge via quick-edit.
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,
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.
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.
A tuple (descriptions, visible_comments, pagination), where descriptions
is a list of IssueComment PBs for the issue description history,
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
# TODO(lukasperaza): update first comments' rows to is_description=TRUE
# so [issuecomment_list[0]] can be removed
descriptions = (
[issuecomment_list[0]] +
[comment for comment in issuecomment_list[1:] if comment.is_description])
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:
pagination_url = '%s?id=%d' % (urls.ISSUE_DETAIL, issue.local_id)
pagination = paginate.VirtualPagination(
mr, len(allowed_comments),
count_up=False, start_param='cstart', num_param='cnum',
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 descriptions, visible_comments, pagination