| Index: appengine/monorail/tracker/issuedetail.py
|
| diff --git a/appengine/monorail/tracker/issuedetail.py b/appengine/monorail/tracker/issuedetail.py
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..4dc860187c881ae10d12c2b248f57eea5e5ef7c3
|
| --- /dev/null
|
| +++ b/appengine/monorail/tracker/issuedetail.py
|
| @@ -0,0 +1,1253 @@
|
| +# 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
|
|
|