Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(1657)

Unified Diff: appengine/monorail/tracker/issuedetail.py

Issue 1868553004: Open Source Monorail (Closed) Base URL: https://chromium.googlesource.com/infra/infra.git@master
Patch Set: Rebase Created 4 years, 8 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View side-by-side diff with in-line comments
Download patch
« no previous file with comments | « appengine/monorail/tracker/issuebulkedit.py ('k') | appengine/monorail/tracker/issueentry.py » ('j') | no next file with comments »
Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
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
« no previous file with comments | « appengine/monorail/tracker/issuebulkedit.py ('k') | appengine/monorail/tracker/issueentry.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698