blob: 4dc860187c881ae10d12c2b248f57eea5e5ef7c3 [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
# https://developers.google.com/open-source/licenses/bsd
"""Classes that implement the issue detail page and related forms.
Summary of classes:
IssueDetail: Show one issue in detail w/ all metadata and comments, and
process additional comments or metadata changes on it.
SetStarForm: Record the user's desire to star or unstar an issue.
FlagSpamForm: Record the user's desire to report the issue as spam.
"""
import httplib
import logging
import time
from third_party import ezt
import settings
from features import notify
from framework import actionlimit
from framework import framework_bizobj
from framework import framework_constants
from framework import framework_helpers
from framework import framework_views
from framework import jsonfeed
from framework import monorailrequest
from framework import paginate
from framework import permissions
from framework import servlet
from framework import servlet_helpers
from framework import sql
from framework import template_helpers
from framework import urls
from framework import xsrf
from proto import user_pb2
from search import frontendsearchpipeline
from services import issue_svc
from services import tracker_fulltext
from tracker import field_helpers
from tracker import issuepeek
from tracker import tracker_bizobj
from tracker import tracker_constants
from tracker import tracker_helpers
from tracker import tracker_views
class IssueDetail(issuepeek.IssuePeek):
"""IssueDetail is a page that shows the details of one issue."""
_PAGE_TEMPLATE = 'tracker/issue-detail-page.ezt'
_MISSING_ISSUE_PAGE_TEMPLATE = 'tracker/issue-missing-page.ezt'
_MAIN_TAB_MODE = issuepeek.IssuePeek.MAIN_TAB_ISSUES
_CAPTCHA_ACTION_TYPES = [actionlimit.ISSUE_COMMENT]
_ALLOW_VIEWING_DELETED = True
def __init__(self, request, response, **kwargs):
super(IssueDetail, self).__init__(request, response, **kwargs)
self.missing_issue_template = template_helpers.MonorailTemplate(
self._TEMPLATE_PATH + self._MISSING_ISSUE_PAGE_TEMPLATE)
def GetTemplate(self, page_data):
"""Return a custom 404 page for skipped issue local IDs."""
if page_data.get('http_response_code', httplib.OK) == httplib.NOT_FOUND:
return self.missing_issue_template
else:
return servlet.Servlet.GetTemplate(self, page_data)
def _GetMissingIssuePageData(
self, mr, issue_deleted=False, issue_missing=False,
issue_not_specified=False, issue_not_created=False,
moved_to_project_name=None, moved_to_id=None,
local_id=None, page_perms=None, delete_form_token=None):
if not page_perms:
# Make a default page perms.
page_perms = self.MakePagePerms(mr, None, granted_perms=None)
page_perms.CreateIssue = False
return {
'issue_tab_mode': 'issueDetail',
'http_response_code': httplib.NOT_FOUND,
'issue_deleted': ezt.boolean(issue_deleted),
'issue_missing': ezt.boolean(issue_missing),
'issue_not_specified': ezt.boolean(issue_not_specified),
'issue_not_created': ezt.boolean(issue_not_created),
'moved_to_project_name': moved_to_project_name,
'moved_to_id': moved_to_id,
'local_id': local_id,
'page_perms': page_perms,
'delete_form_token': delete_form_token,
}
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.
"""
with self.profiler.Phase('getting project issue config'):
config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
# The flipper is not itself a Promise, but it contains Promises.
flipper = _Flipper(mr, self.services, self.profiler)
if mr.local_id is None:
return self._GetMissingIssuePageData(mr, issue_not_specified=True)
with self.profiler.Phase('finishing getting issue'):
try:
issue = self._GetIssue(mr)
except issue_svc.NoSuchIssueException:
issue = None
# Show explanation of skipped issue local IDs or deleted issues.
if issue is None or issue.deleted:
missing = mr.local_id <= self.services.issue.GetHighestLocalID(
mr.cnxn, mr.project_id)
if missing or (issue and issue.deleted):
moved_to_ref = self.services.issue.GetCurrentLocationOfMovedIssue(
mr.cnxn, mr.project_id, mr.local_id)
moved_to_project_id, moved_to_id = moved_to_ref
if moved_to_project_id is not None:
moved_to_project = self.services.project.GetProject(
mr.cnxn, moved_to_project_id)
moved_to_project_name = moved_to_project.project_name
else:
moved_to_project_name = None
if issue:
granted_perms = tracker_bizobj.GetGrantedPerms(
issue, mr.auth.effective_ids, config)
else:
granted_perms = None
page_perms = self.MakePagePerms(
mr, issue,
permissions.DELETE_ISSUE, permissions.CREATE_ISSUE,
granted_perms=granted_perms)
return self._GetMissingIssuePageData(
mr,
issue_deleted=ezt.boolean(issue is not None),
issue_missing=ezt.boolean(issue is None and missing),
moved_to_project_name=moved_to_project_name,
moved_to_id=moved_to_id,
local_id=mr.local_id,
page_perms=page_perms,
delete_form_token=xsrf.GenerateToken(
mr.auth.user_id, '/p/%s%s.do' % (
mr.project_name, urls.ISSUE_DELETE_JSON)))
else:
# Issue is not "missing," moved, or deleted, it is just non-existent.
return self._GetMissingIssuePageData(mr, issue_not_created=True)
star_cnxn = sql.MonorailConnection()
star_promise = framework_helpers.Promise(
self.services.issue_star.IsItemStarredBy, star_cnxn,
issue.issue_id, mr.auth.user_id)
granted_perms = tracker_bizobj.GetGrantedPerms(
issue, mr.auth.effective_ids, config)
page_perms = self.MakePagePerms(
mr, issue,
permissions.CREATE_ISSUE,
permissions.FLAG_SPAM,
permissions.VERDICT_SPAM,
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,
granted_perms=granted_perms)
spam_promise = None
spam_hist_promise = None
if page_perms.FlagSpam:
spam_cnxn = sql.MonorailConnection()
spam_promise = framework_helpers.Promise(
self.services.spam.LookupFlaggers, spam_cnxn,
issue.issue_id)
if page_perms.VerdictSpam:
spam_hist_cnxn = sql.MonorailConnection()
spam_hist_promise = framework_helpers.Promise(
self.services.spam.LookUpIssueVerdictHistory, spam_hist_cnxn,
[issue.issue_id])
with self.profiler.Phase('finishing getting comments and pagination'):
(description, visible_comments,
cmnt_pagination) = self._PaginatePartialComments(mr, issue)
with self.profiler.Phase('making user views'):
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_flaggers, comment_flaggers = [], {}
if spam_promise:
issue_flaggers, comment_flaggers = spam_promise.WaitAndGetValue()
(issue_view, description_view,
comment_views) = self._MakeIssueAndCommentViews(
mr, issue, users_by_id, description, visible_comments, config,
issue_flaggers, comment_flaggers)
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,
granted_perms=granted_perms)
page_perms.EditIssue = ezt.boolean(permit_edit)
permit_edit_cc = self.CheckPerm(
mr, permissions.EDIT_ISSUE_CC, art=issue, granted_perms=granted_perms)
discourage_plus_one = not (starred or permit_edit or permit_edit_cc)
# Check whether to allow attachments from the details page
allow_attachments = tracker_helpers.IsUnderSoftAttachmentQuota(mr.project)
mr.ComputeColSpec(config)
back_to_list_url = _ComputeBackToListURL(mr, issue, config)
flipper.SearchForIIDs(mr, issue)
restrict_to_known = config.restrict_to_known
field_name_set = {fd.field_name.lower() for fd in config.field_defs
if not fd.is_deleted} # TODO(jrobbins): restrictions
non_masked_labels = tracker_bizobj.NonMaskedLabels(
issue.labels, field_name_set)
component_paths = []
for comp_id in issue.component_ids:
cd = tracker_bizobj.FindComponentDefByID(comp_id, config)
if cd:
component_paths.append(cd.path)
else:
logging.warn(
'Issue %r has unknown component %r', issue.issue_id, comp_id)
initial_components = ', '.join(component_paths)
after_issue_update = tracker_constants.DEFAULT_AFTER_ISSUE_UPDATE
if mr.auth.user_pb:
after_issue_update = mr.auth.user_pb.after_issue_update
prevent_restriction_removal = (
mr.project.only_owners_remove_restrictions and
not framework_bizobj.UserOwnsProject(
mr.project, mr.auth.effective_ids))
offer_issue_copy_move = True
for lab in tracker_bizobj.GetLabels(issue):
if lab.lower().startswith('restrict-'):
offer_issue_copy_move = False
previous_locations = self.GetPreviousLocations(mr, issue)
spam_verdict_history = []
if spam_hist_promise:
spam_hist = spam_hist_promise.WaitAndGetValue()
spam_verdict_history = [template_helpers.EZTItem(
created=verdict['created'].isoformat(),
is_spam=verdict['is_spam'],
reason=verdict['reason'],
user_id=verdict['user_id'],
classifier_confidence=verdict['classifier_confidence'],
overruled=verdict['overruled'],
) for verdict in spam_hist]
return {
'issue_tab_mode': 'issueDetail',
'issue': issue_view,
'title_summary': issue_view.summary, # used in <head><title>
'description': description_view,
'comments': comment_views,
'num_detail_rows': len(comment_views) + 4,
'noisy': ezt.boolean(tracker_helpers.IsNoisy(
len(comment_views), issue.star_count)),
'flipper': flipper,
'cmnt_pagination': cmnt_pagination,
'searchtip': 'You can jump to any issue by number',
'starred': ezt.boolean(starred),
'discourage_plus_one': ezt.boolean(discourage_plus_one),
'pagegen': str(long(time.time() * 1000000)),
'attachment_form_token': xsrf.GenerateToken(
mr.auth.user_id, '/p/%s%s.do' % (
mr.project_name, urls.ISSUE_ATTACHMENT_DELETION_JSON)),
'delComment_form_token': xsrf.GenerateToken(
mr.auth.user_id, '/p/%s%s.do' % (
mr.project_name, urls.ISSUE_COMMENT_DELETION_JSON)),
'delete_form_token': xsrf.GenerateToken(
mr.auth.user_id, '/p/%s%s.do' % (
mr.project_name, urls.ISSUE_DELETE_JSON)),
'flag_spam_token': xsrf.GenerateToken(
mr.auth.user_id, '/p/%s%s.do' % (
mr.project_name, urls.ISSUE_FLAGSPAM_JSON)),
'set_star_token': xsrf.GenerateToken(
mr.auth.user_id, '/p/%s%s.do' % (
mr.project_name, urls.ISSUE_SETSTAR_JSON)),
# For deep linking and input correction after a failed submit.
'initial_summary': issue_view.summary,
'initial_comment': '',
'initial_status': issue_view.status.name,
'initial_owner': issue_view.owner.email,
'initial_cc': ', '.join([pb.email for pb in issue_view.cc]),
'initial_blocked_on': issue_view.blocked_on_str,
'initial_blocking': issue_view.blocking_str,
'initial_merge_into': issue_view.merged_into_str,
'labels': non_masked_labels,
'initial_components': initial_components,
'fields': issue_view.fields,
'any_errors': ezt.boolean(mr.errors.AnyErrors()),
'allow_attachments': ezt.boolean(allow_attachments),
'max_attach_size': template_helpers.BytesKbOrMb(
framework_constants.MAX_POST_BODY_SIZE),
'colspec': mr.col_spec,
'back_to_list_url': back_to_list_url,
'restrict_to_known': ezt.boolean(restrict_to_known),
'after_issue_update': int(after_issue_update), # TODO(jrobbins): str
'prevent_restriction_removal': ezt.boolean(
prevent_restriction_removal),
'offer_issue_copy_move': ezt.boolean(offer_issue_copy_move),
'statuses_offer_merge': config.statuses_offer_merge,
'page_perms': page_perms,
'previous_locations': previous_locations,
'spam_verdict_history': spam_verdict_history,
}
def GatherHelpData(self, mr, _page_data):
"""Return a dict of values to drive on-page user help.
Args:
mr: commonly used info parsed from the request.
_page_data: Dictionary of base and page template data.
Returns:
A dict of values to drive on-page user help, to be added to page_data.
"""
is_privileged_domain_user = framework_bizobj.IsPriviledgedDomainUser(
mr.auth.user_pb.email)
# Check if the user's query is just the ID of an existing issue.
# If so, display a "did you mean to search?" cue card.
jump_local_id = None
cue = None
if (tracker_constants.JUMP_RE.match(mr.query) and
mr.auth.user_pb and
'search_for_numbers' not in mr.auth.user_pb.dismissed_cues):
jump_local_id = int(mr.query)
cue = 'search_for_numbers'
if (mr.auth.user_id and
'privacy_click_through' not in mr.auth.user_pb.dismissed_cues):
cue = 'privacy_click_through'
return {
'is_privileged_domain_user': ezt.boolean(is_privileged_domain_user),
'jump_local_id': jump_local_id,
'cue': cue,
}
# TODO(sheyang): Support comments incremental loading in API
def _PaginatePartialComments(self, mr, issue):
"""Load and paginate the visible comments for the given issue."""
abbr_comment_rows = self.services.issue.GetAbbrCommentsForIssue(
mr.cnxn, issue.issue_id)
if not abbr_comment_rows:
return None, [], None
description = abbr_comment_rows[0]
comments = abbr_comment_rows[1:]
all_comment_ids = [row[0] for row in comments]
pagination_url = '%s?id=%d' % (urls.ISSUE_DETAIL, issue.local_id)
pagination = paginate.VirtualPagination(
mr, len(all_comment_ids),
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(all_comment_ids):
pagination.visible = ezt.boolean(False)
visible_comment_ids = [description[0]] + all_comment_ids[
pagination.last - 1:pagination.start]
visible_comment_seqs = [0] + range(pagination.last, pagination.start + 1)
visible_comments = self.services.issue.GetCommentsByID(
mr.cnxn, visible_comment_ids, visible_comment_seqs)
return visible_comments[0], visible_comments[1:], pagination
def _ValidateOwner(self, mr, post_data_owner, parsed_owner_id,
original_issue_owner_id):
"""Validates that the issue's owner was changed and is a valid owner.
Args:
mr: Commonly used info parsed from the request.
post_data_owner: The owner as specified in the request's data.
parsed_owner_id: The owner_id from the request.
original_issue_owner_id: The original owner id of the issue.
Returns:
String error message if the owner fails validation else returns None.
"""
parsed_owner_valid, msg = tracker_helpers.IsValidIssueOwner(
mr.cnxn, mr.project, parsed_owner_id, self.services)
if not parsed_owner_valid:
# Only fail validation if the user actually changed the email address.
original_issue_owner = self.services.user.LookupUserEmail(
mr.cnxn, original_issue_owner_id)
if post_data_owner != original_issue_owner:
return msg
else:
# The user did not change the owner, thus do not fail validation.
# See https://bugs.chromium.org/p/monorail/issues/detail?id=28 for
# more details.
pass
def ProcessFormData(self, mr, post_data):
"""Process the posted issue update form.
Args:
mr: commonly used info parsed from the request.
post_data: The post_data dict for the current request.
Returns:
String URL to redirect the user to after processing.
"""
issue = self._GetIssue(mr)
if not issue:
logging.warn('issue not found! project_name: %r local id: %r',
mr.project_name, mr.local_id)
raise monorailrequest.InputException('Issue not found in project')
# Check that the user is logged in; anon users cannot update issues.
if not mr.auth.user_id:
logging.info('user was not logged in, cannot update issue')
raise permissions.PermissionException(
'User must be logged in to update an issue')
# Check that the user has permission to add a comment, and to enter
# metadata if they are trying to do that.
if not self.CheckPerm(mr, permissions.ADD_ISSUE_COMMENT,
art=issue):
logging.info('user has no permission to add issue comment')
raise permissions.PermissionException(
'User has no permission to comment on issue')
parsed = tracker_helpers.ParseIssueRequest(
mr.cnxn, post_data, self.services, mr.errors, issue.project_name)
config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
bounce_labels = parsed.labels[:]
bounce_fields = tracker_views.MakeBounceFieldValueViews(
parsed.fields.vals, config)
field_helpers.ShiftEnumFieldsIntoLabels(
parsed.labels, parsed.labels_remove,
parsed.fields.vals, parsed.fields.vals_remove, config)
field_values = field_helpers.ParseFieldValues(
mr.cnxn, self.services.user, parsed.fields.vals, config)
component_ids = tracker_helpers.LookupComponentIDs(
parsed.components.paths, config, mr.errors)
granted_perms = tracker_bizobj.GetGrantedPerms(
issue, mr.auth.effective_ids, config)
permit_edit = permissions.CanEditIssue(
mr.auth.effective_ids, mr.perms, mr.project, issue,
granted_perms=granted_perms)
page_perms = self.MakePagePerms(
mr, issue,
permissions.CREATE_ISSUE,
permissions.EDIT_ISSUE_SUMMARY,
permissions.EDIT_ISSUE_STATUS,
permissions.EDIT_ISSUE_OWNER,
permissions.EDIT_ISSUE_CC,
granted_perms=granted_perms)
page_perms.EditIssue = ezt.boolean(permit_edit)
if not permit_edit:
if not _FieldEditPermitted(
parsed.labels, parsed.blocked_on.entered_str,
parsed.blocking.entered_str, parsed.summary,
parsed.status, parsed.users.owner_id,
parsed.users.cc_ids, page_perms):
raise permissions.PermissionException(
'User lacks permission to edit fields')
page_generation_time = long(post_data['pagegen'])
reporter_id = mr.auth.user_id
self.CheckCaptcha(mr, post_data)
error_msg = self._ValidateOwner(
mr, post_data.get('owner', '').strip(), parsed.users.owner_id,
issue.owner_id)
if error_msg:
mr.errors.owner = error_msg
if None in parsed.users.cc_ids:
mr.errors.cc = 'Invalid Cc username'
if len(parsed.comment) > tracker_constants.MAX_COMMENT_CHARS:
mr.errors.comment = 'Comment is too long'
if len(parsed.summary) > tracker_constants.MAX_SUMMARY_CHARS:
mr.errors.summary = 'Summary is too long'
old_owner_id = tracker_bizobj.GetOwnerId(issue)
orig_merged_into_iid = issue.merged_into
merge_into_iid = issue.merged_into
merge_into_text, merge_into_issue = tracker_helpers.ParseMergeFields(
mr.cnxn, self.services, mr.project_name, post_data,
parsed.status, config, issue, mr.errors)
if merge_into_issue:
merge_into_iid = merge_into_issue.issue_id
merge_into_project = self.services.project.GetProjectByName(
mr.cnxn, merge_into_issue.project_name)
merge_allowed = tracker_helpers.IsMergeAllowed(
merge_into_issue, mr, self.services)
new_starrers = tracker_helpers.GetNewIssueStarrers(
mr.cnxn, self.services, issue.issue_id, merge_into_iid)
# For any fields that the user does not have permission to edit, use
# the current values in the issue rather than whatever strings were parsed.
labels = parsed.labels
summary = parsed.summary
status = parsed.status
owner_id = parsed.users.owner_id
cc_ids = parsed.users.cc_ids
blocked_on_iids = [iid for iid in parsed.blocked_on.iids
if iid != issue.issue_id]
blocking_iids = [iid for iid in parsed.blocking.iids
if iid != issue.issue_id]
dangling_blocked_on_refs = [tracker_bizobj.MakeDanglingIssueRef(*ref)
for ref in parsed.blocked_on.dangling_refs]
dangling_blocking_refs = [tracker_bizobj.MakeDanglingIssueRef(*ref)
for ref in parsed.blocking.dangling_refs]
if not permit_edit:
labels = issue.labels
field_values = issue.field_values
component_ids = issue.component_ids
blocked_on_iids = issue.blocked_on_iids
blocking_iids = issue.blocking_iids
dangling_blocked_on_refs = issue.dangling_blocked_on_refs
dangling_blocking_refs = issue.dangling_blocking_refs
merge_into_iid = issue.merged_into
if not page_perms.EditIssueSummary:
summary = issue.summary
if not page_perms.EditIssueStatus:
status = issue.status
if not page_perms.EditIssueOwner:
owner_id = issue.owner_id
if not page_perms.EditIssueCc:
cc_ids = issue.cc_ids
field_helpers.ValidateCustomFields(
mr, self.services, field_values, config, mr.errors)
orig_blocked_on = issue.blocked_on_iids
if not mr.errors.AnyErrors():
try:
if parsed.attachments:
new_bytes_used = tracker_helpers.ComputeNewQuotaBytesUsed(
mr.project, parsed.attachments)
self.services.project.UpdateProject(
mr.cnxn, mr.project.project_id,
attachment_bytes_used=new_bytes_used)
# Store everything we got from the form. If the user lacked perms
# any attempted edit would be a no-op because of the logic above.
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,
blocked_on_iids, blocking_iids, dangling_blocked_on_refs,
dangling_blocking_refs, merge_into_iid,
page_gen_ts=page_generation_time, comment=parsed.comment,
attachments=parsed.attachments)
self.services.project.UpdateRecentActivity(
mr.cnxn, mr.project.project_id)
# Also update the Issue PB we have in RAM so that the correct
# CC list will be used for an issue merge.
# TODO(jrobbins): refactor the call above to: 1. compute the updates
# and update the issue PB in RAM, then 2. store the updated issue.
issue.cc_ids = cc_ids
issue.labels = labels
except tracker_helpers.OverAttachmentQuota:
mr.errors.attachments = 'Project attachment quota exceeded.'
if (merge_into_issue and merge_into_iid != orig_merged_into_iid and
merge_allowed):
tracker_helpers.AddIssueStarrers(
mr.cnxn, self.services, mr,
merge_into_iid, merge_into_project, new_starrers)
merge_comment = tracker_helpers.MergeCCsAndAddComment(
self.services, mr, issue, merge_into_project, merge_into_issue)
elif merge_into_issue:
merge_comment = None
logging.info('merge denied: target issue %s not modified',
merge_into_iid)
# TODO(jrobbins): distinguish between EditIssue and
# AddIssueComment and do just the part that is allowed.
# And, give feedback in the source issue if any part of the
# merge was not allowed. Maybe use AJAX to check as the
# user types in the issue local ID.
counts = {actionlimit.ISSUE_COMMENT: 1,
actionlimit.ISSUE_ATTACHMENT: len(parsed.attachments)}
self.CountRateLimitedActions(mr, counts)
copy_to_project = CheckCopyIssueRequest(
self.services, mr, issue, post_data.get('more_actions') == 'copy',
post_data.get('copy_to'), mr.errors)
move_to_project = CheckMoveIssueRequest(
self.services, mr, issue, post_data.get('more_actions') == 'move',
post_data.get('move_to'), mr.errors)
if mr.errors.AnyErrors():
self.PleaseCorrect(
mr, initial_summary=parsed.summary,
initial_status=parsed.status,
initial_owner=parsed.users.owner_username,
initial_cc=', '.join(parsed.users.cc_usernames),
initial_components=', '.join(parsed.components.paths),
initial_comment=parsed.comment,
labels=bounce_labels, fields=bounce_fields,
initial_blocked_on=parsed.blocked_on.entered_str,
initial_blocking=parsed.blocking.entered_str,
initial_merge_into=merge_into_text)
return
send_email = 'send_email' in post_data or not permit_edit
moved_to_project_name_and_local_id = None
copied_to_project_name_and_local_id = None
if move_to_project:
moved_to_project_name_and_local_id = self.HandleCopyOrMove(
mr.cnxn, mr, move_to_project, issue, send_email, move=True)
elif copy_to_project:
copied_to_project_name_and_local_id = self.HandleCopyOrMove(
mr.cnxn, mr, copy_to_project, issue, send_email, move=False)
# TODO(sheyang): use global issue id in case the issue gets moved again
# before the task gets processed
if amendments or parsed.comment.strip() or parsed.attachments:
cmnts = self.services.issue.GetCommentsForIssue(mr.cnxn, issue.issue_id)
notify.PrepareAndSendIssueChangeNotification(
issue.project_id, issue.local_id, mr.request.host, reporter_id,
len(cmnts) - 1, send_email=send_email, old_owner_id=old_owner_id)
if merge_into_issue and merge_allowed and merge_comment:
cmnts = self.services.issue.GetCommentsForIssue(
mr.cnxn, merge_into_issue.issue_id)
notify.PrepareAndSendIssueChangeNotification(
merge_into_issue.project_id, merge_into_issue.local_id,
mr.request.host, reporter_id, len(cmnts) - 1, send_email=send_email)
if permit_edit:
# Only users who can edit metadata could have edited blocking.
blockers_added, blockers_removed = framework_helpers.ComputeListDeltas(
orig_blocked_on, blocked_on_iids)
delta_blockers = blockers_added + blockers_removed
notify.PrepareAndSendIssueBlockingNotification(
issue.project_id, mr.request.host, issue.local_id, delta_blockers,
reporter_id, send_email=send_email)
# We don't send notification emails to newly blocked issues: either they
# know they are blocked, or they don't care and can be fixed anyway.
# This is the same behavior as the issue entry page.
after_issue_update = _DetermineAndSetAfterIssueUpdate(
self.services, mr, post_data)
return _Redirect(
mr, post_data, issue.local_id, config,
moved_to_project_name_and_local_id,
copied_to_project_name_and_local_id, after_issue_update)
def HandleCopyOrMove(self, cnxn, mr, dest_project, issue, send_email, move):
"""Handle Requests dealing with copying or moving an issue between projects.
Args:
cnxn: connection to the database.
mr: commonly used info parsed from the request.
dest_project: The project protobuf we are moving the issue to.
issue: The issue protobuf being moved.
send_email: True to send email for these actions.
move: Whether this is a move request. The original issue will not exist if
this is True.
Returns:
A tuple of (project_id, local_id) of the newly copied / moved issue.
"""
old_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
if move:
tracker_fulltext.UnindexIssues([issue.issue_id])
moved_back_iids = self.services.issue.MoveIssues(
cnxn, dest_project, [issue], self.services.user)
ret_project_name_and_local_id = (issue.project_name, issue.local_id)
new_text_ref = 'issue %s:%s' % ret_project_name_and_local_id
if issue.issue_id in moved_back_iids:
content = 'Moved %s back to %s again.' % (old_text_ref, new_text_ref)
else:
content = 'Moved %s to now be %s.' % (old_text_ref, new_text_ref)
comment = self.services.issue.CreateIssueComment(
mr.cnxn, dest_project.project_id, issue.local_id, mr.auth.user_id,
content, amendments=[
tracker_bizobj.MakeProjectAmendment(dest_project.project_name)])
else:
copied_issues = self.services.issue.CopyIssues(
cnxn, dest_project, [issue], self.services.user, mr.auth.user_id)
copied_issue = copied_issues[0]
ret_project_name_and_local_id = (copied_issue.project_name,
copied_issue.local_id)
new_text_ref = 'issue %s:%s' % ret_project_name_and_local_id
# Add comment to the copied issue.
old_issue_content = 'Copied %s to %s' % (old_text_ref, new_text_ref)
self.services.issue.CreateIssueComment(
mr.cnxn, issue.project_id, issue.local_id, mr.auth.user_id,
old_issue_content)
# Add comment to the newly created issue.
# Add project amendment only if the project changed.
amendments = []
if issue.project_id != copied_issue.project_id:
amendments.append(
tracker_bizobj.MakeProjectAmendment(dest_project.project_name))
new_issue_content = 'Copied %s from %s' % (new_text_ref, old_text_ref)
comment = self.services.issue.CreateIssueComment(
mr.cnxn, dest_project.project_id, copied_issue.local_id,
mr.auth.user_id, new_issue_content, amendments=amendments)
tracker_fulltext.IndexIssues(
mr.cnxn, [issue], self.services.user, self.services.issue,
self.services.config)
if send_email:
logging.info('TODO(jrobbins): send email for a move? or combine? %r',
comment)
return ret_project_name_and_local_id
def _DetermineAndSetAfterIssueUpdate(services, mr, post_data):
after_issue_update = tracker_constants.DEFAULT_AFTER_ISSUE_UPDATE
if 'after_issue_update' in post_data:
after_issue_update = user_pb2.IssueUpdateNav(
int(post_data['after_issue_update'][0]))
if after_issue_update != mr.auth.user_pb.after_issue_update:
logging.info('setting after_issue_update to %r', after_issue_update)
services.user.UpdateUserSettings(
mr.cnxn, mr.auth.user_id, mr.auth.user_pb,
after_issue_update=after_issue_update)
return after_issue_update
def _Redirect(
mr, post_data, local_id, config, moved_to_project_name_and_local_id,
copied_to_project_name_and_local_id, after_issue_update):
"""Prepare a redirect URL for the issuedetail servlets.
Args:
mr: common information parsed from the HTTP request.
post_data: The post_data dict for the current request.
local_id: int Issue ID for the current request.
config: The ProjectIssueConfig pb for the current request.
moved_to_project_name_and_local_id: tuple containing the project name the
issue was moved to and the local id in that project.
copied_to_project_name_and_local_id: tuple containing the project name the
issue was copied to and the local id in that project.
after_issue_update: User preference on where to go next.
Returns:
String URL to redirect the user to after processing.
"""
mr.can = int(post_data['can'])
mr.query = post_data['q']
mr.col_spec = post_data['colspec']
mr.sort_spec = post_data['sort']
mr.group_by_spec = post_data['groupby']
mr.start = int(post_data['start'])
mr.num = int(post_data['num'])
mr.local_id = local_id
# format a redirect url
next_id = post_data.get('next_id', '')
url = _ChooseNextPage(
mr, local_id, config, moved_to_project_name_and_local_id,
copied_to_project_name_and_local_id, after_issue_update, next_id)
logging.debug('Redirecting user to: %s', url)
return url
def _ComputeBackToListURL(mr, issue, config):
"""Construct a URL to return the user to the place that they came from."""
back_to_list_url = None
if not tracker_constants.JUMP_RE.match(mr.query):
back_to_list_url = tracker_helpers.FormatIssueListURL(
mr, config, cursor='%s:%d' % (issue.project_name, issue.local_id))
return back_to_list_url
def _FieldEditPermitted(
labels, blocked_on_str, blocking_str, summary, status, owner_id, cc_ids,
page_perms):
"""Check permissions on editing individual form fields.
This check is only done if the user does not have the overall
EditIssue perm. If the user edited any field that they do not have
permission to edit, then they could have forged a post, or maybe
they had a valid form open in a browser tab while at the same time
their perms in the project were reduced. Either way, the servlet
gives them a BadRequest HTTP error and makes them go back and try
again.
TODO(jrobbins): It would be better to show a custom error page that
takes the user back to the issue with a new page load rather than
having the user use the back button.
Args:
labels: list of label values parsed from the form.
blocked_on_str: list of blocked-on values parsed from the form.
blocking_str: list of blocking values parsed from the form.
summary: issue summary string parsed from the form.
status: issue status string parsed from the form.
owner_id: issue owner user ID parsed from the form and looked up.
cc_ids: list of user IDs for Cc'd users parsed from the form.
page_perms: object with fields for permissions the current user
has on the current issue.
Returns:
True if there was no permission violation. False if the user tried
to edit something that they do not have permission to edit.
"""
if labels or blocked_on_str or blocking_str:
logging.info('user has no permission to edit issue metadata')
return False
if summary and not page_perms.EditIssueSummary:
logging.info('user has no permission to edit issue summary field')
return False
if status and not page_perms.EditIssueStatus:
logging.info('user has no permission to edit issue status field')
return False
if owner_id and not page_perms.EditIssueOwner:
logging.info('user has no permission to edit issue owner field')
return False
if cc_ids and not page_perms.EditIssueCc:
logging.info('user has no permission to edit issue cc field')
return False
return True
def _ChooseNextPage(
mr, local_id, config, moved_to_project_name_and_local_id,
copied_to_project_name_and_local_id, after_issue_update, next_id):
"""Choose the next page to show the user after an issue update.
Args:
mr: information parsed from the request.
local_id: int Issue ID of the issue that was updated.
config: project issue config object.
moved_to_project_name_and_local_id: tuple containing the project name the
issue was moved to and the local id in that project.
copied_to_project_name_and_local_id: tuple containing the project name the
issue was copied to and the local id in that project.
after_issue_update: user pref on where to go next.
next_id: string local ID of next issue at the time the form was generated.
Returns:
String absolute URL of next page to view.
"""
issue_ref_str = '%s:%d' % (mr.project_name, local_id)
kwargs = {
'ts': int(time.time()),
'cursor': issue_ref_str,
}
if moved_to_project_name_and_local_id:
kwargs['moved_to_project'] = moved_to_project_name_and_local_id[0]
kwargs['moved_to_id'] = moved_to_project_name_and_local_id[1]
elif copied_to_project_name_and_local_id:
kwargs['copied_from_id'] = local_id
kwargs['copied_to_project'] = copied_to_project_name_and_local_id[0]
kwargs['copied_to_id'] = copied_to_project_name_and_local_id[1]
else:
kwargs['updated'] = local_id
url = tracker_helpers.FormatIssueListURL(
mr, config, **kwargs)
if after_issue_update == user_pb2.IssueUpdateNav.STAY_SAME_ISSUE:
# If it was a move request then will have to switch to the new project to
# stay on the same issue.
if moved_to_project_name_and_local_id:
mr.project_name = moved_to_project_name_and_local_id[0]
url = framework_helpers.FormatAbsoluteURL(
mr, urls.ISSUE_DETAIL, id=local_id)
elif after_issue_update == user_pb2.IssueUpdateNav.NEXT_IN_LIST:
if next_id:
url = framework_helpers.FormatAbsoluteURL(
mr, urls.ISSUE_DETAIL, id=next_id)
return url
class SetStarForm(jsonfeed.JsonFeed):
"""Star or unstar the specified issue for the logged in user."""
def AssertBasePermission(self, mr):
super(SetStarForm, self).AssertBasePermission(mr)
issue = self.services.issue.GetIssueByLocalID(
mr.cnxn, mr.project_id, mr.local_id)
if not self.CheckPerm(mr, permissions.SET_STAR, art=issue):
raise permissions.PermissionException(
'You are not allowed to star issues')
def HandleRequest(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.
"""
issue = self.services.issue.GetIssueByLocalID(
mr.cnxn, mr.project_id, mr.local_id)
config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
self.services.issue_star.SetStar(
mr.cnxn, self.services, config, issue.issue_id, mr.auth.user_id,
mr.starred)
return {
'starred': bool(mr.starred),
}
def _ShouldShowFlipper(mr, services):
"""Return True if we should show the flipper."""
# Check if the user entered a specific issue ID of an existing issue.
if tracker_constants.JUMP_RE.match(mr.query):
return False
# Check if the user came directly to an issue without specifying any
# query or sort. E.g., through crbug.com. Generating the issue ref
# list can be too expensive in projects that have a large number of
# issues. The all and open issues cans are broad queries, other
# canned queries should be narrow enough to not need this special
# treatment.
if (not mr.query and not mr.sort_spec and
mr.can in [tracker_constants.ALL_ISSUES_CAN,
tracker_constants.OPEN_ISSUES_CAN]):
num_issues_in_project = services.issue.GetHighestLocalID(
mr.cnxn, mr.project_id)
if num_issues_in_project > settings.threshold_to_suppress_prev_next:
return False
return True
class _Flipper(object):
"""Helper class for user to flip among issues within a search result."""
def __init__(self, mr, services, prof):
"""Store info for issue flipper widget (prev & next navigation).
Args:
mr: commonly used info parsed from the request.
services: connections to backend services.
prof: a Profiler for the sevlet's handling of the current request.
"""
if not _ShouldShowFlipper(mr, services):
self.show = ezt.boolean(False)
self.pipeline = None
return
self.pipeline = frontendsearchpipeline.FrontendSearchPipeline(
mr, services, prof, None)
self.services = services
def SearchForIIDs(self, mr, issue):
"""Do the next step of searching for issue IDs for the flipper.
Args:
mr: commonly used info parsed from the request.
issue: the currently viewed issue.
"""
if not self.pipeline:
return
if not mr.errors.AnyErrors():
# Only do the search if the user's query parsed OK.
self.pipeline.SearchForIIDs()
# Note: we never call MergeAndSortIssues() because we don't need a unified
# sorted list, we only need to know the position on such a list of the
# current issue.
prev_iid, cur_index, next_iid = self.pipeline.DetermineIssuePosition(issue)
logging.info('prev_iid, cur_index, next_iid is %r %r %r',
prev_iid, cur_index, next_iid)
# pylint: disable=attribute-defined-outside-init
if cur_index is None or self.pipeline.total_count == 1:
# The user probably edited the URL, or bookmarked an issue
# in a search context that no longer matches the issue.
self.show = ezt.boolean(False)
else:
self.show = True
self.current = cur_index + 1
self.total_count = self.pipeline.total_count
self.next_id = None
self.next_project_name = None
self.prev_url = ''
self.next_url = ''
if prev_iid:
prev_issue = self.services.issue.GetIssue(mr.cnxn, prev_iid)
prev_path = '/p/%s%s' % (prev_issue.project_name, urls.ISSUE_DETAIL)
self.prev_url = framework_helpers.FormatURL(
mr, prev_path, id=prev_issue.local_id)
if next_iid:
next_issue = self.services.issue.GetIssue(mr.cnxn, next_iid)
self.next_id = next_issue.local_id
self.next_project_name = next_issue.project_name
next_path = '/p/%s%s' % (next_issue.project_name, urls.ISSUE_DETAIL)
self.next_url = framework_helpers.FormatURL(
mr, next_path, id=next_issue.local_id)
def DebugString(self):
"""Return a string representation useful in debugging."""
if self.show:
return 'on %s of %s; prev_url:%s; next_url:%s' % (
self.current, self.total_count, self.prev_url, self.next_url)
else:
return 'invisible flipper(show=%s)' % self.show
class IssueCommentDeletion(servlet.Servlet):
"""Form handler that allows user to delete/undelete comments."""
def ProcessFormData(self, mr, post_data):
"""Process the form that un/deletes an issue comment.
Args:
mr: commonly used info parsed from the request.
post_data: The post_data dict for the current request.
Returns:
String URL to redirect the user to after processing.
"""
logging.info('post_data = %s', post_data)
local_id = int(post_data['id'])
sequence_num = int(post_data['sequence_num'])
delete = (post_data['mode'] == '1')
issue = self.services.issue.GetIssueByLocalID(
mr.cnxn, mr.project_id, local_id)
config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
all_comments = self.services.issue.GetCommentsForIssue(
mr.cnxn, issue.issue_id)
logging.info('comments on %s are: %s', local_id, all_comments)
comment = all_comments[sequence_num]
granted_perms = tracker_bizobj.GetGrantedPerms(
issue, mr.auth.effective_ids, config)
if ((comment.is_spam and mr.auth.user_id == comment.user_id) or
not permissions.CanDelete(
mr.auth.user_id, mr.auth.effective_ids, mr.perms,
comment.deleted_by, comment.user_id, mr.project,
permissions.GetRestrictions(issue), granted_perms=granted_perms)):
raise permissions.PermissionException('Cannot delete comment')
self.services.issue.SoftDeleteComment(
mr.cnxn, mr.project_id, local_id, sequence_num,
mr.auth.user_id, self.services.user, delete=delete)
return framework_helpers.FormatAbsoluteURL(
mr, urls.ISSUE_DETAIL, id=local_id)
class IssueDeleteForm(servlet.Servlet):
"""A form handler to delete or undelete an issue.
Project owners will see a button on every issue to delete it, and
if they specifically visit a deleted issue they will see a button to
undelete it.
"""
def ProcessFormData(self, mr, post_data):
"""Process the form that un/deletes an issue comment.
Args:
mr: commonly used info parsed from the request.
post_data: The post_data dict for the current request.
Returns:
String URL to redirect the user to after processing.
"""
local_id = int(post_data['id'])
delete = 'delete' in post_data
logging.info('Marking issue %d as deleted: %r', local_id, delete)
issue = self.services.issue.GetIssueByLocalID(
mr.cnxn, mr.project_id, local_id)
config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
granted_perms = tracker_bizobj.GetGrantedPerms(
issue, mr.auth.effective_ids, config)
permit_delete = self.CheckPerm(
mr, permissions.DELETE_ISSUE, art=issue, granted_perms=granted_perms)
if not permit_delete:
raise permissions.PermissionException('Cannot un/delete issue')
self.services.issue.SoftDeleteIssue(
mr.cnxn, mr.project_id, local_id, delete, self.services.user)
return framework_helpers.FormatAbsoluteURL(
mr, urls.ISSUE_DETAIL, id=local_id)
# TODO(jrobbins): do we want this?
# class IssueDerivedLabelsJSON(jsonfeed.JsonFeed)
def CheckCopyIssueRequest(
services, mr, issue, copy_selected, copy_to, errors):
"""Process the copy issue portions of the issue update form.
Args:
services: A Services object
mr: commonly used info parsed from the request.
issue: Issue protobuf for the issue being copied.
copy_selected: True if the user selected the Copy action.
copy_to: A project_name or url to copy this issue to or None
if the project name wasn't sent in the form.
errors: The errors object for this request.
Returns:
The project pb for the project the issue will be copy to
or None if the copy cannot be performed. Perhaps because
the project does not exist, in which case copy_to and
copy_to_project will be set on the errors object. Perhaps
the user does not have permission to copy the issue to the
destination project, in which case the copy_to field will be
set on the errors object.
"""
if not copy_selected:
return None
if not copy_to:
errors.copy_to = 'No destination project specified'
errors.copy_to_project = copy_to
return None
copy_to_project = services.project.GetProjectByName(mr.cnxn, copy_to)
if not copy_to_project:
errors.copy_to = 'No such project: ' + copy_to
errors.copy_to_project = copy_to
return None
# permissions enforcement
if not servlet_helpers.CheckPermForProject(
mr, permissions.EDIT_ISSUE, copy_to_project):
errors.copy_to = 'You do not have permission to copy issues to project'
errors.copy_to_project = copy_to
return None
elif permissions.GetRestrictions(issue):
errors.copy_to = (
'Issues with Restrict labels are not allowed to be copied.')
errors.copy_to_project = ''
return None
return copy_to_project
def CheckMoveIssueRequest(
services, mr, issue, move_selected, move_to, errors):
"""Process the move issue portions of the issue update form.
Args:
services: A Services object
mr: commonly used info parsed from the request.
issue: Issue protobuf for the issue being moved.
move_selected: True if the user selected the Move action.
move_to: A project_name or url to move this issue to or None
if the project name wasn't sent in the form.
errors: The errors object for this request.
Returns:
The project pb for the project the issue will be moved to
or None if the move cannot be performed. Perhaps because
the project does not exist, in which case move_to and
move_to_project will be set on the errors object. Perhaps
the user does not have permission to move the issue to the
destination project, in which case the move_to field will be
set on the errors object.
"""
if not move_selected:
return None
if not move_to:
errors.move_to = 'No destination project specified'
errors.move_to_project = move_to
return None
if issue.project_name == move_to:
errors.move_to = 'This issue is already in project ' + move_to
errors.move_to_project = move_to
return None
move_to_project = services.project.GetProjectByName(mr.cnxn, move_to)
if not move_to_project:
errors.move_to = 'No such project: ' + move_to
errors.move_to_project = move_to
return None
# permissions enforcement
if not servlet_helpers.CheckPermForProject(
mr, permissions.EDIT_ISSUE, move_to_project):
errors.move_to = 'You do not have permission to move issues to project'
errors.move_to_project = move_to
return None
elif permissions.GetRestrictions(issue):
errors.move_to = (
'Issues with Restrict labels are not allowed to be moved.')
errors.move_to_project = ''
return None
return move_to_project