Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/tracker/__init__.py b/tracker/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tracker/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tracker/attachment_helpers.py b/tracker/attachment_helpers.py
new file mode 100644
index 0000000..9ed9a7c
--- /dev/null
+++ b/tracker/attachment_helpers.py
@@ -0,0 +1,112 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Functions to help display attachments and compute quotas."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import base64
+import hmac
+import logging
+
+from framework import urls
+from services import secrets_svc
+from tracker import tracker_helpers
+
+
+VIEWABLE_IMAGE_TYPES = [
+ 'image/jpeg', 'image/gif', 'image/png', 'image/x-png', 'image/webp',
+ ]
+VIEWABLE_VIDEO_TYPES = [
+ 'video/ogg', 'video/mp4', 'video/mpg', 'video/mpeg', 'video/webm',
+ 'video/quicktime',
+ ]
+MAX_PREVIEW_FILESIZE = 15 * 1024 * 1024 # 15 MB
+
+
+def IsViewableImage(mimetype_charset, filesize):
+ """Return true if we can safely display such an image in the browser.
+
+ Args:
+ mimetype_charset: string with the mimetype string that we got back
+ from the 'file' command. It may have just the mimetype, or it
+ may have 'foo/bar; charset=baz'.
+ filesize: int length of the file in bytes.
+
+ Returns:
+ True iff we should allow the user to view a thumbnail or safe version
+ of the image in the browser. False if this might not be safe to view,
+ in which case we only offer a download link.
+ """
+ mimetype = mimetype_charset.split(';', 1)[0]
+ return (mimetype in VIEWABLE_IMAGE_TYPES and
+ filesize < MAX_PREVIEW_FILESIZE)
+
+
+def IsViewableVideo(mimetype_charset, filesize):
+ """Return true if we can safely display such a video in the browser.
+
+ Args:
+ mimetype_charset: string with the mimetype string that we got back
+ from the 'file' command. It may have just the mimetype, or it
+ may have 'foo/bar; charset=baz'.
+ filesize: int length of the file in bytes.
+
+ Returns:
+ True iff we should allow the user to watch the video in the page.
+ """
+ mimetype = mimetype_charset.split(';', 1)[0]
+ return (mimetype in VIEWABLE_VIDEO_TYPES and
+ filesize < MAX_PREVIEW_FILESIZE)
+
+
+def IsViewableText(mimetype, filesize):
+ """Return true if we can safely display such a file as escaped text."""
+ return (mimetype.startswith('text/') and
+ filesize < MAX_PREVIEW_FILESIZE)
+
+
+def SignAttachmentID(aid):
+ """One-way hash of attachment ID to make it harder for people to scan."""
+ digester = hmac.new(secrets_svc.GetXSRFKey())
+ digester.update(str(aid))
+ return base64.urlsafe_b64encode(digester.digest())
+
+
+def GetDownloadURL(attachment_id):
+ """Return a relative URL to download an attachment to local disk."""
+ return 'attachment?aid=%s&signed_aid=%s' % (
+ attachment_id, SignAttachmentID(attachment_id))
+
+
+def GetViewURL(attach, download_url, project_name):
+ """Return a relative URL to view an attachment in the browser."""
+ if IsViewableImage(attach.mimetype, attach.filesize):
+ return download_url + '&inline=1'
+ elif IsViewableVideo(attach.mimetype, attach.filesize):
+ return download_url + '&inline=1'
+ elif IsViewableText(attach.mimetype, attach.filesize):
+ return tracker_helpers.FormatRelativeIssueURL(
+ project_name, urls.ISSUE_ATTACHMENT_TEXT,
+ aid=attach.attachment_id)
+ else:
+ return None
+
+
+def GetThumbnailURL(attach, download_url):
+ """Return a relative URL to view an attachment thumbnail."""
+ if IsViewableImage(attach.mimetype, attach.filesize):
+ return download_url + '&inline=1&thumb=1'
+ else:
+ return None
+
+
+def GetVideoURL(attach, download_url):
+ """Return a relative URL to view an attachment thumbnail."""
+ if IsViewableVideo(attach.mimetype, attach.filesize):
+ return download_url + '&inline=1'
+ else:
+ return None
diff --git a/tracker/component_helpers.py b/tracker/component_helpers.py
new file mode 100644
index 0000000..786ab96
--- /dev/null
+++ b/tracker/component_helpers.py
@@ -0,0 +1,93 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions for component-related servlets."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+import re
+
+from proto import tracker_pb2
+from tracker import tracker_bizobj
+
+
+ParsedComponentDef = collections.namedtuple(
+ 'ParsedComponentDef',
+ 'leaf_name, docstring, deprecated, '
+ 'admin_usernames, cc_usernames, admin_ids, cc_ids, '
+ 'label_strs, label_ids')
+
+
+def ParseComponentRequest(mr, post_data, services):
+ """Parse the user's request to create or update a component definition.
+
+ If an error is encountered then this function populates mr.errors
+ """
+ leaf_name = post_data.get('leaf_name', '')
+ docstring = post_data.get('docstring', '')
+ deprecated = 'deprecated' in post_data
+
+ admin_usernames = [
+ uname.strip() for uname in re.split('[,;\s]+', post_data['admins'])
+ if uname.strip()]
+ cc_usernames = [
+ uname.strip() for uname in re.split('[,;\s]+', post_data['cc'])
+ if uname.strip()]
+ all_user_ids = services.user.LookupUserIDs(
+ mr.cnxn, admin_usernames + cc_usernames, autocreate=True)
+
+ admin_ids = []
+ for admin_name in admin_usernames:
+ if admin_name not in all_user_ids:
+ mr.errors.member_admins = '%s unrecognized' % admin_name
+ continue
+ admin_id = all_user_ids[admin_name]
+ if admin_id not in admin_ids:
+ admin_ids.append(admin_id)
+
+ cc_ids = []
+ for cc_name in cc_usernames:
+ if cc_name not in all_user_ids:
+ mr.errors.member_cc = '%s unrecognized' % cc_name
+ continue
+ cc_id = all_user_ids[cc_name]
+ if cc_id not in cc_ids:
+ cc_ids.append(cc_id)
+
+ label_strs = [
+ lab.strip() for lab in re.split('[,;\s]+', post_data['labels'])
+ if lab.strip()]
+
+ label_ids = services.config.LookupLabelIDs(
+ mr.cnxn, mr.project_id, label_strs, autocreate=True)
+
+ return ParsedComponentDef(
+ leaf_name, docstring, deprecated,
+ admin_usernames, cc_usernames, admin_ids, cc_ids,
+ label_strs, label_ids)
+
+
+def GetComponentCcIDs(issue, config):
+ """Return auto-cc'd users for any component or ancestor the issue is in."""
+ result = set()
+ for component_id in issue.component_ids:
+ cd = tracker_bizobj.FindComponentDefByID(component_id, config)
+ if cd:
+ result.update(GetCcIDsForComponentAndAncestors(config, cd))
+
+ return result
+
+
+def GetCcIDsForComponentAndAncestors(config, cd):
+ """Return auto-cc'd user IDs for the given component and ancestors."""
+ result = set(cd.cc_ids)
+ ancestors = tracker_bizobj.FindAncestorComponents(config, cd)
+ for anc_cd in ancestors:
+ result.update(anc_cd.cc_ids)
+
+ return result
diff --git a/tracker/componentcreate.py b/tracker/componentcreate.py
new file mode 100644
index 0000000..9cb713c
--- /dev/null
+++ b/tracker/componentcreate.py
@@ -0,0 +1,153 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A servlet for project owners to create a new component def."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+from framework import framework_helpers
+from framework import framework_views
+from framework import jsonfeed
+from framework import permissions
+from framework import servlet
+from framework import urls
+from tracker import component_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_views
+
+import ezt
+
+
+class ComponentCreate(servlet.Servlet):
+ """Servlet allowing project owners to create a component."""
+
+ _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PROCESS
+ _PAGE_TEMPLATE = 'tracker/component-create-page.ezt'
+
+ def AssertBasePermission(self, mr):
+ """Check whether the user has any permission to visit this page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ """
+ super(ComponentCreate, self).AssertBasePermission(mr)
+ if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+ raise permissions.PermissionException(
+ 'User is not allowed to administer this project')
+
+ 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.
+ """
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user,
+ *[list(cd.admin_ids) + list(cd.cc_ids)
+ for cd in config.component_defs])
+ component_def_views = [
+ tracker_views.ComponentDefView(mr.cnxn, self.services, cd, users_by_id)
+ # TODO(jrobbins): future component-level view restrictions.
+ for cd in config.component_defs]
+ for cdv in component_def_views:
+ setattr(cdv, 'selected', None)
+ path = (cdv.parent_path + '>' + cdv.leaf_name).lstrip('>')
+ if path == mr.component_path:
+ setattr(cdv, 'selected', True)
+
+ return {
+ 'parent_path': mr.component_path,
+ 'admin_tab_mode': servlet.Servlet.PROCESS_TAB_COMPONENTS,
+ 'component_defs': component_def_views,
+ 'initial_leaf_name': '',
+ 'initial_docstring': '',
+ 'initial_deprecated': ezt.boolean(False),
+ 'initial_admins': [],
+ 'initial_cc': [],
+ 'initial_labels': [],
+ }
+
+ def ProcessFormData(self, mr, post_data):
+ """Validate and store the contents of the issues tracker admin page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ post_data: HTML form data from the request.
+
+ Returns:
+ String URL to redirect the user to, or None if response was already sent.
+ """
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ parent_path = post_data.get('parent_path', '')
+ parsed = component_helpers.ParseComponentRequest(
+ mr, post_data, self.services)
+
+ if parent_path:
+ parent_def = tracker_bizobj.FindComponentDef(parent_path, config)
+ if not parent_def:
+ self.abort(500, 'parent component not found')
+ allow_parent_edit = permissions.CanEditComponentDef(
+ mr.auth.effective_ids, mr.perms, mr.project, parent_def, config)
+ if not allow_parent_edit:
+ raise permissions.PermissionException(
+ 'User is not allowed to add a subcomponent here')
+
+ path = '%s>%s' % (parent_path, parsed.leaf_name)
+ else:
+ path = parsed.leaf_name
+
+ leaf_name_error_msg = LeafNameErrorMessage(
+ parent_path, parsed.leaf_name, config)
+ if leaf_name_error_msg:
+ mr.errors.leaf_name = leaf_name_error_msg
+
+ if mr.errors.AnyErrors():
+ self.PleaseCorrect(
+ mr, parent_path=parent_path,
+ initial_leaf_name=parsed.leaf_name,
+ initial_docstring=parsed.docstring,
+ initial_deprecated=ezt.boolean(parsed.deprecated),
+ initial_admins=parsed.admin_usernames,
+ initial_cc=parsed.cc_usernames,
+ initial_labels=parsed.label_strs,
+ )
+ return
+
+ created = int(time.time())
+ creator_id = self.services.user.LookupUserID(
+ mr.cnxn, mr.auth.email, autocreate=False)
+
+ self.services.config.CreateComponentDef(
+ mr.cnxn, mr.project_id, path, parsed.docstring, parsed.deprecated,
+ parsed.admin_ids, parsed.cc_ids, created, creator_id,
+ label_ids=parsed.label_ids)
+
+ return framework_helpers.FormatAbsoluteURL(
+ mr, urls.ADMIN_COMPONENTS, saved=1, ts=int(time.time()))
+
+
+def LeafNameErrorMessage(parent_path, leaf_name, config):
+ """Return an error message for the given component name, or None."""
+ if not tracker_constants.COMPONENT_NAME_RE.match(leaf_name):
+ return 'Invalid component name'
+
+ if parent_path:
+ path = '%s>%s' % (parent_path, leaf_name)
+ else:
+ path = leaf_name
+
+ if tracker_bizobj.FindComponentDef(path, config):
+ return 'That name is already in use.'
+
+ return None
diff --git a/tracker/componentdetail.py b/tracker/componentdetail.py
new file mode 100644
index 0000000..01f2469
--- /dev/null
+++ b/tracker/componentdetail.py
@@ -0,0 +1,246 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A servlet for project and component owners to view and edit components."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from features import filterrules_helpers
+from framework import framework_helpers
+from framework import framework_views
+from framework import permissions
+from framework import servlet
+from framework import timestr
+from framework import urls
+from tracker import component_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_views
+
+
+class ComponentDetail(servlet.Servlet):
+ """Servlets allowing project owners to view and edit a component."""
+
+ _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PROCESS
+ _PAGE_TEMPLATE = 'tracker/component-detail-page.ezt'
+
+ def _GetComponentDef(self, mr):
+ """Get the config and component definition to be viewed or edited."""
+ if not mr.component_path:
+ self.abort(404, 'component not specified')
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ component_def = tracker_bizobj.FindComponentDef(mr.component_path, config)
+ if not component_def:
+ self.abort(404, 'component not found')
+ return config, component_def
+
+ def AssertBasePermission(self, mr):
+ """Check whether the user has any permission to visit this page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ """
+ super(ComponentDetail, self).AssertBasePermission(mr)
+ _config, component_def = self._GetComponentDef(mr)
+
+ # TODO(jrobbins): optional restrictions on viewing fields by component.
+
+ allow_view = permissions.CanViewComponentDef(
+ mr.auth.effective_ids, mr.perms, mr.project, component_def)
+ if not allow_view:
+ raise permissions.PermissionException(
+ 'User is not allowed to view this component')
+
+ 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.
+ """
+ config, component_def = self._GetComponentDef(mr)
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user,
+ component_def.admin_ids, component_def.cc_ids)
+ component_def_view = tracker_views.ComponentDefView(
+ mr.cnxn, self.services, component_def, users_by_id)
+ initial_admins = [users_by_id[uid].email for uid in component_def.admin_ids]
+ initial_cc = [users_by_id[uid].email for uid in component_def.cc_ids]
+ initial_labels = [
+ self.services.config.LookupLabel(mr.cnxn, mr.project_id, label_id)
+ for label_id in component_def.label_ids]
+
+ creator, created = self._GetUserViewAndFormattedTime(
+ mr, component_def.creator_id, component_def.created)
+ modifier, modified = self._GetUserViewAndFormattedTime(
+ mr, component_def.modifier_id, component_def.modified)
+
+ allow_edit = permissions.CanEditComponentDef(
+ mr.auth.effective_ids, mr.perms, mr.project, component_def, config)
+
+ subcomponents = tracker_bizobj.FindDescendantComponents(
+ config, component_def)
+ templates = self.services.template.TemplatesWithComponent(
+ mr.cnxn, component_def.component_id)
+ allow_delete = allow_edit and not subcomponents and not templates
+
+ return {
+ 'admin_tab_mode': servlet.Servlet.PROCESS_TAB_COMPONENTS,
+ 'component_def': component_def_view,
+ 'initial_leaf_name': component_def_view.leaf_name,
+ 'initial_docstring': component_def.docstring,
+ 'initial_deprecated': ezt.boolean(component_def.deprecated),
+ 'initial_admins': initial_admins,
+ 'initial_cc': initial_cc,
+ 'initial_labels': initial_labels,
+ 'allow_edit': ezt.boolean(allow_edit),
+ 'allow_delete': ezt.boolean(allow_delete),
+ 'subcomponents': subcomponents,
+ 'templates': templates,
+ 'creator': creator,
+ 'created': created,
+ 'modifier': modifier,
+ 'modified': modified,
+ }
+
+ def ProcessFormData(self, mr, post_data):
+ """Validate and store the contents of the issues tracker admin page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ post_data: HTML form data from the request.
+
+ Returns:
+ String URL to redirect the user to, or None if response was already sent.
+ """
+ config, component_def = self._GetComponentDef(mr)
+ allow_edit = permissions.CanEditComponentDef(
+ mr.auth.effective_ids, mr.perms, mr.project, component_def, config)
+ if not allow_edit:
+ raise permissions.PermissionException(
+ 'User is not allowed to edit or delete this component')
+
+ if 'deletecomponent' in post_data:
+ allow_delete = not tracker_bizobj.FindDescendantComponents(
+ config, component_def)
+ if not allow_delete:
+ raise permissions.PermissionException(
+ 'User tried to delete component that had subcomponents')
+ return self._ProcessDeleteComponent(mr, component_def)
+
+ else:
+ return self._ProcessEditComponent(mr, post_data, config, component_def)
+
+
+ def _ProcessDeleteComponent(self, mr, component_def):
+ """The user wants to delete the specified custom field definition."""
+ self.services.issue.DeleteComponentReferences(
+ mr.cnxn, component_def.component_id)
+ self.services.config.DeleteComponentDef(
+ mr.cnxn, mr.project_id, component_def.component_id)
+ return framework_helpers.FormatAbsoluteURL(
+ mr, urls.ADMIN_COMPONENTS, deleted=1, ts=int(time.time()))
+
+ def _GetUserViewAndFormattedTime(self, mr, user_id, timestamp):
+ formatted_time = (timestr.FormatAbsoluteDate(timestamp)
+ if timestamp else None)
+ user = self.services.user.GetUser(mr.cnxn, user_id) if user_id else None
+ user_view = None
+ if user:
+ user_view = framework_views.UserView(user)
+ viewing_self = mr.auth.user_id == user_id
+ # Do not obscure email if current user is a site admin. Do not obscure
+ # email if current user is the same as the creator. For all other
+ # cases do whatever obscure_email setting for the user is.
+ email_obscured = (not(mr.auth.user_pb.is_site_admin or viewing_self)
+ and user_view.obscure_email)
+ if not email_obscured:
+ user_view.RevealEmail()
+
+ return user_view, formatted_time
+
+ def _ProcessEditComponent(self, mr, post_data, config, component_def):
+ """The user wants to edit this component definition."""
+ parsed = component_helpers.ParseComponentRequest(
+ mr, post_data, self.services)
+
+ if not tracker_constants.COMPONENT_NAME_RE.match(parsed.leaf_name):
+ mr.errors.leaf_name = 'Invalid component name'
+
+ original_path = component_def.path
+ if mr.component_path and '>' in mr.component_path:
+ parent_path = mr.component_path[:mr.component_path.rindex('>')]
+ new_path = '%s>%s' % (parent_path, parsed.leaf_name)
+ else:
+ new_path = parsed.leaf_name
+
+ conflict = tracker_bizobj.FindComponentDef(new_path, config)
+ if conflict and conflict.component_id != component_def.component_id:
+ mr.errors.leaf_name = 'That name is already in use.'
+
+ creator, created = self._GetUserViewAndFormattedTime(
+ mr, component_def.creator_id, component_def.created)
+ modifier, modified = self._GetUserViewAndFormattedTime(
+ mr, component_def.modifier_id, component_def.modified)
+
+ if mr.errors.AnyErrors():
+ self.PleaseCorrect(
+ mr, initial_leaf_name=parsed.leaf_name,
+ initial_docstring=parsed.docstring,
+ initial_deprecated=ezt.boolean(parsed.deprecated),
+ initial_admins=parsed.admin_usernames,
+ initial_cc=parsed.cc_usernames,
+ initial_labels=parsed.label_strs,
+ created=created,
+ creator=creator,
+ modified=modified,
+ modifier=modifier,
+ )
+ return None
+
+ new_modified = int(time.time())
+ new_modifier_id = self.services.user.LookupUserID(
+ mr.cnxn, mr.auth.email, autocreate=False)
+ self.services.config.UpdateComponentDef(
+ mr.cnxn, mr.project_id, component_def.component_id,
+ path=new_path, docstring=parsed.docstring, deprecated=parsed.deprecated,
+ admin_ids=parsed.admin_ids, cc_ids=parsed.cc_ids, modified=new_modified,
+ modifier_id=new_modifier_id, label_ids=parsed.label_ids)
+
+ update_rule = False
+ if new_path != original_path:
+ update_rule = True
+ # If the name changed then update all of its subcomponents as well.
+ subcomponent_ids = tracker_bizobj.FindMatchingComponentIDs(
+ original_path, config, exact=False)
+ for subcomponent_id in subcomponent_ids:
+ if subcomponent_id == component_def.component_id:
+ continue
+ subcomponent_def = tracker_bizobj.FindComponentDefByID(
+ subcomponent_id, config)
+ subcomponent_new_path = subcomponent_def.path.replace(
+ original_path, new_path, 1)
+ self.services.config.UpdateComponentDef(
+ mr.cnxn, mr.project_id, subcomponent_def.component_id,
+ path=subcomponent_new_path)
+
+ if (set(parsed.cc_ids) != set(component_def.cc_ids) or
+ set(parsed.label_ids) != set(component_def.label_ids)):
+ update_rule = True
+ if update_rule:
+ filterrules_helpers.RecomputeAllDerivedFields(
+ mr.cnxn, self.services, mr.project, config)
+
+ return framework_helpers.FormatAbsoluteURL(
+ mr, urls.COMPONENT_DETAIL,
+ component=new_path, saved=1, ts=int(time.time()))
diff --git a/tracker/field_helpers.py b/tracker/field_helpers.py
new file mode 100644
index 0000000..d15f5e0
--- /dev/null
+++ b/tracker/field_helpers.py
@@ -0,0 +1,542 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions for custom field sevlets."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import itertools
+import logging
+import re
+
+from features import autolink_constants
+from framework import authdata
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import permissions
+from framework import timestr
+from framework import validate
+from proto import tracker_pb2
+from services import config_svc
+from tracker import tracker_bizobj
+
+
+INVALID_USER_ID = -1
+
+ParsedFieldDef = collections.namedtuple(
+ 'ParsedFieldDef',
+ 'field_name, field_type_str, min_value, max_value, regex, '
+ 'needs_member, needs_perm, grants_perm, notify_on, is_required, '
+ 'is_niche, importance, is_multivalued, field_docstring, choices_text, '
+ 'applicable_type, applicable_predicate, revised_labels, date_action_str, '
+ 'approvers_str, survey, parent_approval_name, is_phase_field, '
+ 'is_restricted_field')
+
+
+def ListApplicableFieldDefs(issues, config):
+ # type: (Sequence[proto.tracker_pb2.Issue],
+ # proto.tracker_pb2.ProjectIssueConfig) ->
+ # Sequence[proto.tracker_pb2.FieldDef]
+ """Return the applicable FieldDefs for the given issues. """
+ issue_labels = []
+ issue_approval_ids = []
+ for issue in issues:
+ issue_labels.extend(issue.labels)
+ issue_approval_ids.extend(
+ [approval.approval_id for approval in issue.approval_values])
+ labels_by_prefix = tracker_bizobj.LabelsByPrefix(list(set(issue_labels)), [])
+ types = set(labels_by_prefix.get('type', []))
+ types_lower = [t.lower() for t in types]
+ applicable_fds = []
+ for fd in config.field_defs:
+ if fd.is_deleted:
+ continue
+ if fd.field_id in issue_approval_ids:
+ applicable_fds.append(fd)
+ elif fd.field_type != tracker_pb2.FieldTypes.APPROVAL_TYPE and (
+ not fd.applicable_type or fd.applicable_type.lower() in types_lower):
+ applicable_fds.append(fd)
+ return applicable_fds
+
+
+def ParseFieldDefRequest(post_data, config):
+ """Parse the user's HTML form data to update a field definition."""
+ field_name = post_data.get('name', '')
+ field_type_str = post_data.get('field_type')
+ # TODO(jrobbins): once a min or max is set, it cannot be completely removed.
+ min_value_str = post_data.get('min_value')
+ try:
+ min_value = int(min_value_str)
+ except (ValueError, TypeError):
+ min_value = None
+ max_value_str = post_data.get('max_value')
+ try:
+ max_value = int(max_value_str)
+ except (ValueError, TypeError):
+ max_value = None
+ regex = post_data.get('regex')
+ needs_member = 'needs_member' in post_data
+ needs_perm = post_data.get('needs_perm', '').strip()
+ grants_perm = post_data.get('grants_perm', '').strip()
+ notify_on_str = post_data.get('notify_on')
+ if notify_on_str in config_svc.NOTIFY_ON_ENUM:
+ notify_on = config_svc.NOTIFY_ON_ENUM.index(notify_on_str)
+ else:
+ notify_on = 0
+ importance = post_data.get('importance')
+ is_required = (importance == 'required')
+ is_niche = (importance == 'niche')
+ is_multivalued = 'is_multivalued' in post_data
+ field_docstring = post_data.get('docstring', '')
+ choices_text = post_data.get('choices', '')
+ applicable_type = post_data.get('applicable_type', '')
+ applicable_predicate = '' # TODO(jrobbins): placeholder for future feature
+ revised_labels = _ParseChoicesIntoWellKnownLabels(
+ choices_text, field_name, config, field_type_str)
+ date_action_str = post_data.get('date_action')
+ approvers_str = post_data.get('approver_names', '').strip().rstrip(',')
+ survey = post_data.get('survey', '')
+ parent_approval_name = post_data.get('parent_approval_name', '')
+ # TODO(jojwang): monorail:3774, remove enum_type condition when
+ # phases can have labels.
+ is_phase_field = ('is_phase_field' in post_data) and (
+ field_type_str not in ['approval_type', 'enum_type'])
+ is_restricted_field = 'is_restricted_field' in post_data
+
+ return ParsedFieldDef(
+ field_name, field_type_str, min_value, max_value, regex, needs_member,
+ needs_perm, grants_perm, notify_on, is_required, is_niche, importance,
+ is_multivalued, field_docstring, choices_text, applicable_type,
+ applicable_predicate, revised_labels, date_action_str, approvers_str,
+ survey, parent_approval_name, is_phase_field, is_restricted_field)
+
+
+def _ParseChoicesIntoWellKnownLabels(
+ choices_text, field_name, config, field_type_str):
+ """Parse a field's possible choices and integrate them into the config.
+
+ Args:
+ choices_text: string with one label and optional docstring per line.
+ field_name: string name of the field definition being edited.
+ config: ProjectIssueConfig PB of the current project.
+ field_type_str: string name of the new field's type. None if an existing
+ field is being updated
+
+ Returns:
+ A revised list of labels that can be used to update the config.
+ """
+ fd = tracker_bizobj.FindFieldDef(field_name, config)
+ matches = framework_constants.IDENTIFIER_DOCSTRING_RE.findall(choices_text)
+ maskingFieldNames = []
+ # wkls should only be masked by the field if it is an enum_type.
+ if (field_type_str == 'enum_type') or (
+ fd and fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE):
+ maskingFieldNames.append(field_name.lower())
+
+ new_labels = [
+ ('%s-%s' % (field_name, label), choice_docstring.strip(), False)
+ for label, choice_docstring in matches]
+ kept_labels = [
+ (wkl.label, wkl.label_docstring, wkl.deprecated)
+ for wkl in config.well_known_labels
+ if not tracker_bizobj.LabelIsMaskedByField(
+ wkl.label, maskingFieldNames)]
+ revised_labels = kept_labels + new_labels
+ return revised_labels
+
+
+def ShiftEnumFieldsIntoLabels(
+ labels, labels_remove, field_val_strs, field_val_strs_remove, config):
+ """Look at the custom field values and treat enum fields as labels.
+
+ Args:
+ labels: list of labels to add/set on the issue.
+ labels_remove: list of labels to remove from the issue.
+ field_val_strs: {field_id: [val_str, ...]} of custom fields to add/set.
+ field_val_strs_remove: {field_id: [val_str, ...]} of custom fields to
+ remove.
+ config: ProjectIssueConfig PB including custom field definitions.
+
+ SIDE-EFFECT: the labels and labels_remove lists will be extended with
+ key-value labels corresponding to the enum field values. Those field
+ entries will be removed from field_val_strs and field_val_strs_remove.
+ """
+ for fd in config.field_defs:
+ if fd.field_type != tracker_pb2.FieldTypes.ENUM_TYPE:
+ continue
+
+ if fd.field_id in field_val_strs:
+ labels.extend(
+ '%s-%s' % (fd.field_name, val)
+ for val in field_val_strs[fd.field_id]
+ if val and val != '--')
+ del field_val_strs[fd.field_id]
+
+ if fd.field_id in field_val_strs_remove:
+ labels_remove.extend(
+ '%s-%s' % (fd.field_name, val)
+ for val in field_val_strs_remove[fd.field_id]
+ if val and val != '--')
+ del field_val_strs_remove[fd.field_id]
+
+
+def ReviseApprovals(approval_id, approver_ids, survey, config):
+ revised_approvals = [(
+ approval.approval_id, approval.approver_ids, approval.survey) for
+ approval in config.approval_defs if
+ approval.approval_id != approval_id]
+ revised_approvals.append((approval_id, approver_ids, survey))
+ return revised_approvals
+
+
+def ParseOneFieldValue(cnxn, user_service, fd, val_str):
+ """Make one FieldValue PB from the given user-supplied string."""
+ if fd.field_type == tracker_pb2.FieldTypes.INT_TYPE:
+ try:
+ return tracker_bizobj.MakeFieldValue(
+ fd.field_id, int(val_str), None, None, None, None, False)
+ except ValueError:
+ return None # TODO(jrobbins): should bounce
+
+ elif fd.field_type == tracker_pb2.FieldTypes.STR_TYPE:
+ return tracker_bizobj.MakeFieldValue(
+ fd.field_id, None, val_str, None, None, None, False)
+
+ elif fd.field_type == tracker_pb2.FieldTypes.USER_TYPE:
+ if val_str:
+ try:
+ user_id = user_service.LookupUserID(cnxn, val_str, autocreate=False)
+ except exceptions.NoSuchUserException:
+ # Set to invalid user ID to display error during the validation step.
+ user_id = INVALID_USER_ID
+ return tracker_bizobj.MakeFieldValue(
+ fd.field_id, None, None, user_id, None, None, False)
+ else:
+ return None
+
+ elif fd.field_type == tracker_pb2.FieldTypes.DATE_TYPE:
+ try:
+ timestamp = timestr.DateWidgetStrToTimestamp(val_str)
+ return tracker_bizobj.MakeFieldValue(
+ fd.field_id, None, None, None, timestamp, None, False)
+ except ValueError:
+ return None # TODO(jrobbins): should bounce
+
+ elif fd.field_type == tracker_pb2.FieldTypes.URL_TYPE:
+ val_str = FormatUrlFieldValue(val_str)
+ try:
+ return tracker_bizobj.MakeFieldValue(
+ fd.field_id, None, None, None, None, val_str, False)
+ except ValueError:
+ return None # TODO(jojwang): should bounce
+
+ else:
+ logging.error('Cant parse field with unexpected type %r', fd.field_type)
+ return None
+
+
+def ParseOnePhaseFieldValue(cnxn, user_service, fd, val_str, phase_ids):
+ """Return a list containing a FieldValue PB for each phase."""
+ phase_fvs = []
+ for phase_id in phase_ids:
+ # TODO(jojwang): monorail:3970, create the FieldValue once and find some
+ # proto2 CopyFrom() method to create a new one for each phase.
+ fv = ParseOneFieldValue(cnxn, user_service, fd, val_str)
+ if fv:
+ fv.phase_id = phase_id
+ phase_fvs.append(fv)
+
+ return phase_fvs
+
+
+def ParseFieldValues(cnxn, user_service, field_val_strs, phase_field_val_strs,
+ config, phase_ids_by_name=None):
+ """Return a list of FieldValue PBs based on the given dict of strings."""
+ field_values = []
+ for fd in config.field_defs:
+ if fd.is_phase_field and (
+ fd.field_id in phase_field_val_strs) and phase_ids_by_name:
+ fvs_by_phase_name = phase_field_val_strs.get(fd.field_id, {})
+ for phase_name, val_strs in fvs_by_phase_name.items():
+ phase_ids = phase_ids_by_name.get(phase_name)
+ if not phase_ids:
+ continue
+ for val_str in val_strs:
+ field_values.extend(
+ ParseOnePhaseFieldValue(
+ cnxn, user_service, fd, val_str, phase_ids=phase_ids))
+ # We do not save phase fields when there are no phases.
+ elif not fd.is_phase_field and (fd.field_id in field_val_strs):
+ for val_str in field_val_strs[fd.field_id]:
+ fv = ParseOneFieldValue(cnxn, user_service, fd, val_str)
+ if fv:
+ field_values.append(fv)
+
+ return field_values
+
+
+def ValidateCustomFieldValue(cnxn, project, services, field_def, field_val):
+ # type: (MonorailConnection, proto.tracker_pb2.Project, Services,
+ # proto.tracker_pb2.FieldDef, proto.tracker_pb2.FieldValue) -> str
+ """Validate one custom field value and return an error string or None.
+
+ Args:
+ cnxn: MonorailConnection object.
+ project: Project PB with info on the project the custom field belongs to.
+ services: Services object referencing services that can be queried.
+ field_def: FieldDef for the custom field we're validating against.
+ field_val: The value of the custom field.
+
+ Returns:
+ A string containing an error message if there was one.
+ """
+ if field_def.field_type == tracker_pb2.FieldTypes.INT_TYPE:
+ if (field_def.min_value is not None and
+ field_val.int_value < field_def.min_value):
+ return 'Value must be >= %d.' % field_def.min_value
+ if (field_def.max_value is not None and
+ field_val.int_value > field_def.max_value):
+ return 'Value must be <= %d.' % field_def.max_value
+
+ elif field_def.field_type == tracker_pb2.FieldTypes.STR_TYPE:
+ if field_def.regex and field_val.str_value:
+ try:
+ regex = re.compile(field_def.regex)
+ if not regex.match(field_val.str_value):
+ return 'Value must match regular expression: %s.' % field_def.regex
+ except re.error:
+ logging.info('Failed to process regex %r with value %r. Allowing.',
+ field_def.regex, field_val.str_value)
+ return None
+
+ elif field_def.field_type == tracker_pb2.FieldTypes.USER_TYPE:
+ field_val_user = services.user.GetUser(cnxn, field_val.user_id)
+ auth = authdata.AuthData.FromUser(cnxn, field_val_user, services)
+ if auth.user_pb.user_id == INVALID_USER_ID:
+ return 'User not found.'
+ if field_def.needs_member:
+ user_value_in_project = framework_bizobj.UserIsInProject(
+ project, auth.effective_ids)
+ if not user_value_in_project:
+ return 'User must be a member of the project.'
+ if field_def.needs_perm:
+ user_perms = permissions.GetPermissions(
+ auth.user_pb, auth.effective_ids, project)
+ has_perm = user_perms.CanUsePerm(
+ field_def.needs_perm, auth.effective_ids, project, [])
+ if not has_perm:
+ return 'User must have permission "%s".' % field_def.needs_perm
+ return None
+
+ elif field_def.field_type == tracker_pb2.FieldTypes.DATE_TYPE:
+ # TODO(jrobbins): date validation
+ pass
+
+ elif field_def.field_type == tracker_pb2.FieldTypes.URL_TYPE:
+ if field_val.url_value:
+ if not (validate.IsValidURL(field_val.url_value)
+ or autolink_constants.IS_A_SHORT_LINK_RE.match(
+ field_val.url_value)
+ or autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE.match(
+ field_val.url_value)
+ or autolink_constants.IS_IMPLIED_LINK_RE.match(
+ field_val.url_value)):
+ return 'Value must be a valid url.'
+
+ return None
+
+def ValidateCustomFields(
+ cnxn, services, field_values, config, project, ezt_errors=None, issue=None):
+ # type: (MonorailConnection, Services,
+ # Collection[proto.tracker_pb2.FieldValue],
+ # proto.tracker_pb2.ProjectConfig, proto.tracker_pb2.Project,
+ # Optional[EZTError], Optional[proto.tracker_pb2.Issue]) ->
+ # Sequence[str]
+ """Validate given fields and report problems in error messages."""
+ fds_by_id = {fd.field_id: fd for fd in config.field_defs}
+ err_msgs = []
+
+ # Create a set of field_ids that have required values. If this set still
+ # contains items by the end of the function, there is an error.
+ required_fds = set()
+ if issue:
+ applicable_fds = ListApplicableFieldDefs([issue], config)
+
+ lower_field_names = [fd.field_name.lower() for fd in applicable_fds]
+ label_prefixes = tracker_bizobj.LabelsByPrefix(
+ list(set(issue.labels)), lower_field_names)
+
+ # Add applicable required fields to required_fds.
+ for fd in applicable_fds:
+ if not fd.is_required:
+ continue
+
+ if (fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and
+ fd.field_name.lower() in label_prefixes):
+ # Handle custom enum fields - they're a special case because their
+ # values are stored in labels instead of FieldValues.
+ continue
+
+ required_fds.add(fd.field_id)
+ # Ensure that every field value entered is valid. ie: That users exist.
+ for fv in field_values:
+ # Remove field_ids from the required set when found.
+ if fv.field_id in required_fds:
+ required_fds.remove(fv.field_id)
+
+ fd = fds_by_id.get(fv.field_id)
+ if fd:
+ err_msg = ValidateCustomFieldValue(cnxn, project, services, fd, fv)
+
+ if err_msg:
+ err_msgs.append('Error for %r: %s' % (fv, err_msg))
+ if ezt_errors:
+ ezt_errors.SetCustomFieldError(fv.field_id, err_msg)
+
+ # Add errors for any fields still left in the required set.
+ for field_id in required_fds:
+ fd = fds_by_id.get(field_id)
+ err_msg = '%s field is required.' % (fd.field_name)
+ err_msgs.append(err_msg)
+ if ezt_errors:
+ ezt_errors.SetCustomFieldError(field_id, err_msg)
+
+ return err_msgs
+
+
+def AssertCustomFieldsEditPerms(
+ mr, config, field_vals, field_vals_remove, fields_clear, labels,
+ labels_remove):
+ """Check permissions for any kind of custom field edition attempt."""
+ # TODO: When clearing phase_fields is possible, include it in this method.
+ field_ids = set()
+
+ for fv in field_vals:
+ field_ids.add(fv.field_id)
+ for fvr in field_vals_remove:
+ field_ids.add(fvr.field_id)
+ for fd_id in fields_clear:
+ field_ids.add(fd_id)
+
+ enum_fds_by_name = {
+ fd.field_name.lower(): fd.field_id
+ for fd in config.field_defs
+ if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and not fd.is_deleted
+ }
+ for label in itertools.chain(labels, labels_remove):
+ enum_field_name = tracker_bizobj.LabelIsMaskedByField(
+ label, enum_fds_by_name.keys())
+ if enum_field_name:
+ field_ids.add(enum_fds_by_name.get(enum_field_name))
+
+ fds_by_id = {fd.field_id: fd for fd in config.field_defs}
+ for field_id in field_ids:
+ fd = fds_by_id.get(field_id)
+ if fd:
+ assert permissions.CanEditValueForFieldDef(
+ mr.auth.effective_ids, mr.perms, mr.project,
+ fd), 'No permission to edit certain fields.'
+
+
+def ApplyRestrictedDefaultValues(
+ mr, config, field_vals, labels, template_field_vals, template_labels):
+ """Add default values of template fields that the user cannot edit.
+
+ This method can be called by servlets where restricted field values that
+ a user cannot edit are displayed but do not get returned when the user
+ submits the form (and also assumes that previous assertions ensure these
+ conditions). These missing default values still need to be passed to the
+ services layer when a 'write' is done so that these default values do
+ not get removed.
+
+ Args:
+ mr: MonorailRequest Object to hold info about the request and the user.
+ config: ProjectIssueConfig Object for the project.
+ field_vals: list of FieldValues that the user wants to save.
+ labels: list of labels that the user wants to save.
+ template_field_vals: list of FieldValues belonging to the template.
+ template_labels: list of labels belonging to the template.
+
+ Side Effect:
+ The default values of a template that the user cannot edit are added
+ to 'field_vals' and 'labels'.
+ """
+
+ fds_by_id = {fd.field_id: fd for fd in config.field_defs}
+ for fv in template_field_vals:
+ fd = fds_by_id.get(fv.field_id)
+ if fd and not permissions.CanEditValueForFieldDef(mr.auth.effective_ids,
+ mr.perms, mr.project, fd):
+ field_vals.append(fv)
+
+ fds_by_name = {
+ fd.field_name.lower(): fd
+ for fd in config.field_defs
+ if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and not fd.is_deleted
+ }
+ for label in template_labels:
+ enum_field_name = tracker_bizobj.LabelIsMaskedByField(
+ label, fds_by_name.keys())
+ if enum_field_name:
+ fd = fds_by_name.get(enum_field_name)
+ if fd and not permissions.CanEditValueForFieldDef(
+ mr.auth.effective_ids, mr.perms, mr.project, fd):
+ labels.append(label)
+
+
+def FormatUrlFieldValue(url_str):
+ """Check for and add 'https://' to a url string"""
+ if not url_str.startswith('http'):
+ return 'http://' + url_str
+ return url_str
+
+
+def ReviseFieldDefFromParsed(parsed, old_fd):
+ """Creates new FieldDef based on an original FieldDef and parsed FieldDef"""
+ if parsed.date_action_str in config_svc.DATE_ACTION_ENUM:
+ date_action = config_svc.DATE_ACTION_ENUM.index(parsed.date_action_str)
+ else:
+ date_action = 0
+ return tracker_bizobj.MakeFieldDef(
+ old_fd.field_id, old_fd.project_id, old_fd.field_name, old_fd.field_type,
+ parsed.applicable_type, parsed.applicable_predicate, parsed.is_required,
+ parsed.is_niche, parsed.is_multivalued, parsed.min_value,
+ parsed.max_value, parsed.regex, parsed.needs_member, parsed.needs_perm,
+ parsed.grants_perm, parsed.notify_on, date_action, parsed.field_docstring,
+ False, approval_id=old_fd.approval_id or None,
+ is_phase_field=old_fd.is_phase_field)
+
+
+def ParsedFieldDefAssertions(mr, parsed):
+ """Checks if new/updated FieldDef is not violating basic assertions.
+ If the assertions are violated, the errors
+ will be included in the mr.errors.
+
+ Args:
+ mr: MonorailRequest object used to hold
+ commonly info parsed from the request.
+ parsed: ParsedFieldDef object used to contain parsed info,
+ in this case regarding a custom field definition.
+ """
+ # TODO(crbug/monorail/7275): This method is meant to eventually
+ # do all assertion checkings (shared by create/update fieldDef)
+ # and assign all mr.errors values.
+ if (parsed.is_required and parsed.is_niche):
+ mr.errors.is_niche = 'A field cannot be both required and niche.'
+ if parsed.date_action_str is not None and (
+ parsed.date_action_str not in config_svc.DATE_ACTION_ENUM):
+ mr.errors.date_action = 'The date action should be either: ' + ', '.join(
+ config_svc.DATE_ACTION_ENUM) + '.'
+ if (parsed.min_value is not None and parsed.max_value is not None and
+ parsed.min_value > parsed.max_value):
+ mr.errors.min_value = 'Minimum value must be less than maximum.'
+ if parsed.regex:
+ try:
+ re.compile(parsed.regex)
+ except re.error:
+ mr.errors.regex = 'Invalid regular expression.'
diff --git a/tracker/fieldcreate.py b/tracker/fieldcreate.py
new file mode 100644
index 0000000..ead72ad
--- /dev/null
+++ b/tracker/fieldcreate.py
@@ -0,0 +1,220 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A servlet for project owners to create a new field def."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import re
+import time
+
+import ezt
+
+from framework import exceptions
+from framework import framework_helpers
+from framework import jsonfeed
+from framework import permissions
+from framework import servlet
+from framework import urls
+from proto import tracker_pb2
+from tracker import field_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+
+
+class FieldCreate(servlet.Servlet):
+ """Servlet allowing project owners to create a custom field."""
+
+ _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PROCESS
+ _PAGE_TEMPLATE = 'tracker/field-create-page.ezt'
+
+ def AssertBasePermission(self, mr):
+ """Check whether the user has any permission to visit this page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ """
+ super(FieldCreate, self).AssertBasePermission(mr)
+ if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+ raise permissions.PermissionException(
+ 'You are not allowed to administer this project')
+
+ 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.
+ """
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ well_known_issue_types = tracker_helpers.FilterIssueTypes(config)
+ approval_names = [fd.field_name for fd in config.field_defs if
+ fd.field_type is tracker_pb2.FieldTypes.APPROVAL_TYPE and
+ not fd.is_deleted]
+
+ return {
+ 'admin_tab_mode': servlet.Servlet.PROCESS_TAB_LABELS,
+ 'initial_field_name': '',
+ 'initial_field_docstring': '',
+ 'initial_importance': 'normal',
+ 'initial_is_multivalued': ezt.boolean(False),
+ 'initial_parent_approval_name': '',
+ 'initial_choices': '',
+ 'initial_admins': '',
+ 'initial_editors': '',
+ 'initial_type': 'enum_type',
+ 'initial_applicable_type': '', # That means any issue type
+ 'initial_applicable_predicate': '',
+ 'initial_needs_member': ezt.boolean(False),
+ 'initial_needs_perm': '',
+ 'initial_grants_perm': '',
+ 'initial_notify_on': 0,
+ 'initial_date_action': 'no_action',
+ 'well_known_issue_types': well_known_issue_types,
+ 'initial_approvers': '',
+ 'initial_survey': '',
+ 'approval_names': approval_names,
+ 'initial_is_phase_field': ezt.boolean(False),
+ 'initial_is_restricted_field': ezt.boolean(False),
+ }
+
+ def ProcessFormData(self, mr, post_data):
+ """Validate and store the contents of the issues tracker admin page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ post_data: HTML form data from the request.
+
+ Returns:
+ String URL to redirect the user to, or None if response was already sent.
+ """
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ parsed = field_helpers.ParseFieldDefRequest(post_data, config)
+
+ if not tracker_constants.FIELD_NAME_RE.match(parsed.field_name):
+ mr.errors.field_name = 'Invalid field name'
+
+ field_name_error_msg = FieldNameErrorMessage(parsed.field_name, config)
+ if field_name_error_msg:
+ mr.errors.field_name = field_name_error_msg
+
+ admin_ids, admin_str = tracker_helpers.ParsePostDataUsers(
+ mr.cnxn, post_data['admin_names'], self.services.user)
+ editor_ids, editor_str = tracker_helpers.ParsePostDataUsers(
+ mr.cnxn, post_data.get('editor_names', ''), self.services.user)
+
+ field_helpers.ParsedFieldDefAssertions(mr, parsed)
+
+ if not (parsed.is_restricted_field):
+ assert not editor_ids, 'Editors are only for restricted fields.'
+
+ # TODO(crbug/monorail/7275): This condition could potentially be
+ # included in the field_helpers.ParsedFieldDefAssertions method,
+ # just remember that it should be compatible with its usage in
+ # fielddetail.py where there is a very similar condition.
+ if parsed.field_type_str == 'approval_type':
+ assert not (
+ parsed.is_restricted_field), 'Approval fields cannot be restricted.'
+ if parsed.approvers_str:
+ approver_ids_dict = self.services.user.LookupUserIDs(
+ mr.cnxn, re.split('[,;\s]+', parsed.approvers_str),
+ autocreate=True)
+ approver_ids = list(set(approver_ids_dict.values()))
+ else:
+ mr.errors.approvers = 'Please provide at least one default approver.'
+
+ if mr.errors.AnyErrors():
+ self.PleaseCorrect(
+ mr,
+ initial_field_name=parsed.field_name,
+ initial_type=parsed.field_type_str,
+ initial_parent_approval_name=parsed.parent_approval_name,
+ initial_field_docstring=parsed.field_docstring,
+ initial_applicable_type=parsed.applicable_type,
+ initial_applicable_predicate=parsed.applicable_predicate,
+ initial_needs_member=ezt.boolean(parsed.needs_member),
+ initial_needs_perm=parsed.needs_perm,
+ initial_importance=parsed.importance,
+ initial_is_multivalued=ezt.boolean(parsed.is_multivalued),
+ initial_grants_perm=parsed.grants_perm,
+ initial_notify_on=parsed.notify_on,
+ initial_date_action=parsed.date_action_str,
+ initial_choices=parsed.choices_text,
+ initial_approvers=parsed.approvers_str,
+ initial_survey=parsed.survey,
+ initial_is_phase_field=parsed.is_phase_field,
+ initial_admins=admin_str,
+ initial_editors=editor_str,
+ initial_is_restricted_field=parsed.is_restricted_field)
+ return
+
+ approval_id = None
+ if parsed.parent_approval_name and (
+ parsed.field_type_str != 'approval_type'):
+ approval_fd = tracker_bizobj.FindFieldDef(
+ parsed.parent_approval_name, config)
+ if approval_fd:
+ approval_id = approval_fd.field_id
+ field_id = self.services.config.CreateFieldDef(
+ mr.cnxn,
+ mr.project_id,
+ parsed.field_name,
+ parsed.field_type_str,
+ parsed.applicable_type,
+ parsed.applicable_predicate,
+ parsed.is_required,
+ parsed.is_niche,
+ parsed.is_multivalued,
+ parsed.min_value,
+ parsed.max_value,
+ parsed.regex,
+ parsed.needs_member,
+ parsed.needs_perm,
+ parsed.grants_perm,
+ parsed.notify_on,
+ parsed.date_action_str,
+ parsed.field_docstring,
+ admin_ids,
+ editor_ids,
+ approval_id,
+ parsed.is_phase_field,
+ is_restricted_field=parsed.is_restricted_field)
+ if parsed.field_type_str == 'approval_type':
+ revised_approvals = field_helpers.ReviseApprovals(
+ field_id, approver_ids, parsed.survey, config)
+ self.services.config.UpdateConfig(
+ mr.cnxn, mr.project, approval_defs=revised_approvals)
+ if parsed.field_type_str == 'enum_type':
+ self.services.config.UpdateConfig(
+ mr.cnxn, mr.project, well_known_labels=parsed.revised_labels)
+
+ return framework_helpers.FormatAbsoluteURL(
+ mr, urls.ADMIN_LABELS, saved=1, ts=int(time.time()))
+
+
+def FieldNameErrorMessage(field_name, config):
+ """Return an error message for the given field name, or None."""
+ field_name_lower = field_name.lower()
+ if field_name_lower in tracker_constants.RESERVED_PREFIXES:
+ return 'That name is reserved.'
+ if field_name_lower.endswith(
+ tuple(tracker_constants.RESERVED_COL_NAME_SUFFIXES)):
+ return 'That suffix is reserved.'
+
+ for fd in config.field_defs:
+ fn_lower = fd.field_name.lower()
+ if field_name_lower == fn_lower:
+ return 'That name is already in use.'
+ if field_name_lower.startswith(fn_lower + '-'):
+ return 'An existing field name is a prefix of that name.'
+ if fn_lower.startswith(field_name_lower + '-'):
+ return 'That name is a prefix of an existing field name.'
+
+ return None
diff --git a/tracker/fielddetail.py b/tracker/fielddetail.py
new file mode 100644
index 0000000..3e7ebb3
--- /dev/null
+++ b/tracker/fielddetail.py
@@ -0,0 +1,249 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A servlet for project and component owners to view and edit field defs."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+import re
+
+import ezt
+
+from framework import exceptions
+from framework import framework_helpers
+from framework import framework_views
+from framework import permissions
+from framework import servlet
+from framework import urls
+from proto import tracker_pb2
+from tracker import field_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+
+class FieldDetail(servlet.Servlet):
+ """Servlet allowing project owners to view and edit a custom field."""
+
+ _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PROCESS
+ _PAGE_TEMPLATE = 'tracker/field-detail-page.ezt'
+
+ def _GetFieldDef(self, mr):
+ """Get the config and field definition to be viewed or edited."""
+ # TODO(jrobbins): since so many requests get the config object, and
+ # it is usually cached in RAM, just always get it and include it
+ # in the MonorailRequest, mr.
+ config = self.services.config.GetProjectConfig(
+ mr.cnxn, mr.project_id, use_cache=False)
+ field_def = tracker_bizobj.FindFieldDef(mr.field_name, config)
+ if not field_def:
+ self.abort(404, 'custom field not found')
+ return config, field_def
+
+ def AssertBasePermission(self, mr):
+ """Check whether the user has any permission to visit this page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ """
+ super(FieldDetail, self).AssertBasePermission(mr)
+ _config, field_def = self._GetFieldDef(mr)
+
+ allow_view = permissions.CanViewFieldDef(
+ mr.auth.effective_ids, mr.perms, mr.project, field_def)
+ if not allow_view:
+ raise permissions.PermissionException(
+ 'User is not allowed to view this field definition')
+
+ 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.
+ """
+ config, field_def = self._GetFieldDef(mr)
+ approval_def, subfields = None, []
+ if field_def.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE:
+ approval_def = tracker_bizobj.FindApprovalDefByID(
+ field_def.field_id, config)
+ user_views = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user, field_def.admin_ids,
+ approval_def.approver_ids)
+ subfields = tracker_bizobj.FindApprovalsSubfields(
+ [field_def.field_id], config)[field_def.field_id]
+ else:
+ user_views = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user, field_def.admin_ids,
+ field_def.editor_ids)
+ field_def_view = tracker_views.FieldDefView(
+ field_def, config, user_views=user_views, approval_def=approval_def)
+
+ well_known_issue_types = tracker_helpers.FilterIssueTypes(config)
+
+ allow_edit = permissions.CanEditFieldDef(
+ mr.auth.effective_ids, mr.perms, mr.project, field_def)
+
+ # Right now we do not allow renaming of enum fields.
+ _uneditable_name = field_def.field_type == tracker_pb2.FieldTypes.ENUM_TYPE
+
+ initial_choices = '\n'.join(
+ [choice.name if not choice.docstring else (
+ choice.name + ' = ' + choice.docstring) for
+ choice in field_def_view.choices])
+
+ initial_approvers = ', '.join(sorted([
+ approver_view.email for approver_view in field_def_view.approvers]))
+
+ initial_admins = ', '.join(sorted([
+ uv.email for uv in field_def_view.admins]))
+ initial_editors = ', '.join(
+ sorted([uv.email for uv in field_def_view.editors]))
+
+ return {
+ 'admin_tab_mode': servlet.Servlet.PROCESS_TAB_LABELS,
+ 'field_def': field_def_view,
+ 'allow_edit': ezt.boolean(allow_edit),
+ # TODO(jojwang): update when name changes are actually saved
+ 'uneditable_name': ezt.boolean(True),
+ 'initial_admins': initial_admins,
+ 'initial_editors': initial_editors,
+ 'initial_applicable_type': field_def.applicable_type,
+ 'initial_applicable_predicate': field_def.applicable_predicate,
+ 'initial_approvers': initial_approvers,
+ 'initial_choices': initial_choices,
+ 'approval_subfields': [fd for fd in subfields if not fd.is_deleted],
+ 'well_known_issue_types': well_known_issue_types,
+ }
+
+ def ProcessFormData(self, mr, post_data):
+ """Validate and store the contents of the issues tracker admin page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ post_data: HTML form data from the request.
+
+ Returns:
+ String URL to redirect the user to, or None if response was already sent.
+ """
+ config, field_def = self._GetFieldDef(mr)
+ allow_edit = permissions.CanEditFieldDef(
+ mr.auth.effective_ids, mr.perms, mr.project, field_def)
+ if not allow_edit:
+ raise permissions.PermissionException(
+ 'User is not allowed to delete this field')
+
+ if 'deletefield' in post_data:
+ return self._ProcessDeleteField(mr, config, field_def)
+ elif 'cancel' in post_data:
+ return framework_helpers.FormatAbsoluteURL(
+ mr, urls.ADMIN_LABELS, ts=int(time.time()))
+ else:
+ return self._ProcessEditField(mr, post_data, config, field_def)
+
+
+ def _ProcessDeleteField(self, mr, config, field_def):
+ """The user wants to delete the specified custom field definition."""
+ field_ids = [field_def.field_id]
+ if field_def.field_type is tracker_pb2.FieldTypes.APPROVAL_TYPE:
+ for fd in config.field_defs:
+ if fd.approval_id == field_def.field_id:
+ field_ids.append(fd.field_id)
+ self.services.config.SoftDeleteFieldDefs(
+ mr.cnxn, mr.project_id, field_ids)
+
+ return framework_helpers.FormatAbsoluteURL(
+ mr, urls.ADMIN_LABELS, deleted=1, ts=int(time.time()))
+
+ # TODO(jrobbins): add logic to reaper cron task to look for
+ # soft deleted field definitions that have no issues with
+ # any value and hard deleted them.
+
+ def _ProcessEditField(self, mr, post_data, config, field_def):
+ """The user wants to edit this field definition."""
+ # TODO(jrobbins): future feature: editable field names
+
+ parsed = field_helpers.ParseFieldDefRequest(post_data, config)
+
+ admin_ids, admin_str = tracker_helpers.ParsePostDataUsers(
+ mr.cnxn, post_data['admin_names'], self.services.user)
+ editor_ids, editor_str = tracker_helpers.ParsePostDataUsers(
+ mr.cnxn, post_data.get('editor_names', ''), self.services.user)
+
+ field_helpers.ParsedFieldDefAssertions(mr, parsed)
+
+ if not parsed.is_restricted_field:
+ assert not editor_ids, 'Editors are only for restricted fields.'
+
+ if field_def.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE:
+ assert not (
+ parsed.is_restricted_field), 'Approval fields cannot be restricted.'
+ assert not editor_ids, 'Approval fields cannot have editors.'
+
+ if parsed.approvers_str:
+ approver_ids_dict = self.services.user.LookupUserIDs(
+ mr.cnxn, re.split('[,;\s]+', parsed.approvers_str),
+ autocreate=True)
+ approver_ids = list(set(approver_ids_dict.values()))
+ else:
+ mr.errors.approvers = 'Please provide at least one default approver.'
+
+ if mr.errors.AnyErrors():
+ new_field_def = field_helpers.ReviseFieldDefFromParsed(parsed, field_def)
+
+ new_field_def_view = tracker_views.FieldDefView(
+ new_field_def, config)
+
+ self.PleaseCorrect(
+ mr,
+ field_def=new_field_def_view,
+ initial_applicable_type=parsed.applicable_type,
+ initial_choices=parsed.choices_text,
+ initial_admins=admin_str,
+ initial_editors=editor_str,
+ initial_approvers=parsed.approvers_str,
+ initial_is_restricted_field=parsed.is_restricted_field)
+ return
+
+ self.services.config.UpdateFieldDef(
+ mr.cnxn,
+ mr.project_id,
+ field_def.field_id,
+ applicable_type=parsed.applicable_type,
+ applicable_predicate=parsed.applicable_predicate,
+ is_required=parsed.is_required,
+ is_niche=parsed.is_niche,
+ min_value=parsed.min_value,
+ max_value=parsed.max_value,
+ regex=parsed.regex,
+ needs_member=parsed.needs_member,
+ needs_perm=parsed.needs_perm,
+ grants_perm=parsed.grants_perm,
+ notify_on=parsed.notify_on,
+ is_multivalued=parsed.is_multivalued,
+ date_action=parsed.date_action_str,
+ docstring=parsed.field_docstring,
+ admin_ids=admin_ids,
+ editor_ids=editor_ids,
+ is_restricted_field=parsed.is_restricted_field)
+
+ if field_def.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE:
+ approval_defs = field_helpers.ReviseApprovals(
+ field_def.field_id, approver_ids, parsed.survey, config)
+ self.services.config.UpdateConfig(
+ mr.cnxn, mr.project, approval_defs=approval_defs)
+
+ if field_def.field_type == tracker_pb2.FieldTypes.ENUM_TYPE:
+ self.services.config.UpdateConfig(
+ mr.cnxn, mr.project, well_known_labels=parsed.revised_labels)
+
+ return framework_helpers.FormatAbsoluteURL(
+ mr, urls.FIELD_DETAIL, field=field_def.field_name,
+ saved=1, ts=int(time.time()))
diff --git a/tracker/fltconversion.py b/tracker/fltconversion.py
new file mode 100644
index 0000000..c26ab62
--- /dev/null
+++ b/tracker/fltconversion.py
@@ -0,0 +1,599 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""FLT task to be manually triggered to convert launch issues."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+import re
+import settings
+import time
+
+from businesslogic import work_env
+from framework import permissions
+from framework import exceptions
+from framework import jsonfeed
+from proto import tracker_pb2
+from tracker import template_helpers
+from tracker import tracker_bizobj
+
+PM_PREFIX = 'pm-'
+TL_PREFIX = 'tl-'
+TEST_PREFIX = 'test-'
+UX_PREFIX = 'ux-'
+
+PM_FIELD = 'pm'
+TL_FIELD = 'tl'
+TE_FIELD = 'te'
+UX_FIELD = 'ux'
+MTARGET_FIELD = 'm-target'
+MAPPROVED_FIELD = 'm-approved'
+
+CONVERSION_COMMENT = 'Automatic generating of FLT Launch data.'
+
+BROWSER_APPROVALS_TO_LABELS = {
+ 'Chrome-Accessibility': 'Launch-Accessibility-',
+ 'Chrome-Leadership-Exp': 'Launch-Exp-Leadership-',
+ 'Chrome-Leadership-Full': 'Launch-Leadership-',
+ 'Chrome-Legal': 'Launch-Legal-',
+ 'Chrome-Privacy': 'Launch-Privacy-',
+ 'Chrome-Security': 'Launch-Security-',
+ 'Chrome-Test': 'Launch-Test-',
+ 'Chrome-UX': 'Launch-UI-',
+ }
+
+OS_APPROVALS_TO_LABELS = {
+ 'ChromeOS-Accessibility': 'Launch-Accessibility-',
+ 'ChromeOS-Leadership-Exp': 'Launch-Exp-Leadership-',
+ 'ChromeOS-Leadership-Full': 'Launch-Leadership-',
+ 'ChromeOS-Legal': 'Launch-Legal-',
+ 'ChromeOS-Privacy': 'Launch-Privacy-',
+ 'ChromeOS-Security': 'Launch-Security-',
+ 'ChromeOS-Test': 'Launch-Test-',
+ 'ChromeOS-UX': 'Launch-UI-',
+ }
+
+# 'NotReviewed' not included because this should be converted to
+# the template approval's default value, eg NOT_SET OR NEEDS_REVIEW
+VALUE_TO_STATUS = {
+ 'ReviewRequested': tracker_pb2.ApprovalStatus.REVIEW_REQUESTED,
+ 'NeedInfo': tracker_pb2.ApprovalStatus.NEED_INFO,
+ 'Yes': tracker_pb2.ApprovalStatus.APPROVED,
+ 'No': tracker_pb2.ApprovalStatus.NOT_APPROVED,
+ 'NA': tracker_pb2.ApprovalStatus.NA,
+ # 'Started' is not a valid label value in the chromium project,
+ # but for some reason, some labels have this value.
+ 'Started': tracker_pb2.ApprovalStatus.REVIEW_STARTED,
+}
+
+# This works in the Browser and OS process because
+# BROWSER_APPROVALS_TO_LABELS and OS_APPROVALS_TO_LABELS have the same values.
+# Adding '^' before each label prefix to ensure Blah-Launch-UI-Yes is ignored
+REVIEW_LABELS_RE = re.compile('^' + '|^'.join(
+ list(OS_APPROVALS_TO_LABELS.values())))
+
+# Maps template phases to channel names in 'Launch-M-Target-80-[Channel]' labels
+BROWSER_PHASE_MAP = {
+ 'beta': 'beta',
+ 'stable': 'stable',
+ 'stable-full': 'stable',
+ 'stable-exp': 'stable-exp',
+ }
+
+PHASE_PAT = '$|'.join(list(BROWSER_PHASE_MAP.values()))
+# Matches launch milestone labels, eg. Launch-M-Target-70-Stable-Exp
+BROWSER_M_LABELS_RE = re.compile(
+ r'^Launch-M-(?P<type>Approved|Target)-(?P<m>\d\d)-'
+ r'(?P<channel>%s$)' % PHASE_PAT,
+ re.IGNORECASE)
+
+OS_PHASE_MAP = {'feature freeze': '',
+ 'branch': '',
+ 'stable': 'stable',
+ 'stable-full': 'stable',
+ 'stable-exp': 'stable-exp',}
+# We only care about Launch-M-<type>-<m>-Stable|Stable-Exp labels for OS.
+OS_M_LABELS_RE = re.compile(
+ r'^Launch-M-(?P<type>Approved|Target)-(?P<m>\d\d)-'
+ r'(?P<channel>Stable$|Stable-Exp$)', re.IGNORECASE)
+
+CAN = 2 # Query for open issues only
+# Ensure empty group_by_spec and sort_spec so issues are sorted by 'ID'.
+GROUP_BY_SPEC = ''
+SORT_SPEC = ''
+
+CONVERT_NUM = 20
+CONVERT_START = 0
+VERIFY_NUM = 400
+
+# Queries
+QUERY_MAP = {
+ 'default':
+ 'Type=Launch Rollout-Type=Default OS=Windows,Mac,Linux,Android,iOS',
+ 'finch': 'Type=Launch Rollout-Type=Finch OS=Windows,Mac,Linux,Android,iOS',
+ 'os': 'Type=Launch OS=Chrome -OS=Windows,Mac,Linux,Android,iOS'
+ ' Rollout-Type=Default',
+ 'os-finch': 'Type=Launch OS=Chrome -OS=Windows,Mac,Linux,Android,iOS'
+ ' Rollout-Type=Finch'}
+
+TEMPLATE_MAP = {
+ 'default': 'Chrome Launch - Default',
+ 'finch': 'Chrome Launch - Experimental',
+ 'os': 'Chrome OS Launch - Default',
+ 'os-finch': 'Chrome OS Launch - Experimental',
+}
+
+ProjectInfo = collections.namedtuple(
+ 'ProjectInfo', 'config, q, approval_values, phases, '
+ 'pm_fid, tl_fid, te_fid, ux_fid, m_target_id, m_approved_id, '
+ 'phase_map, approvals_to_labels, labels_re')
+
+
+class FLTConvertTask(jsonfeed.InternalTask):
+ """FLTConvert converts current Type=Launch issues into Type=FLT-Launch."""
+
+ def AssertBasePermission(self, mr):
+ super(FLTConvertTask, self).AssertBasePermission(mr)
+ if not mr.auth.user_pb.is_site_admin:
+ raise permissions.PermissionException(
+ 'Only site admins may trigger conversion job')
+
+ def UndoConversion(self, mr):
+ with work_env.WorkEnv(mr, self.services) as we:
+ pipeline = we.ListIssues(
+ 'Type=FLT-Launch FLT=Conversion', ['chromium'], mr.auth.user_id,
+ CONVERT_NUM, CONVERT_START, 2, GROUP_BY_SPEC, SORT_SPEC, False)
+
+ project = self.services.project.GetProjectByName(mr.cnxn, 'chromium')
+ config = self.services.config.GetProjectConfig(mr.cnxn, project.project_id)
+ pm_id = tracker_bizobj.FindFieldDef('PM', config).field_id
+ tl_id = tracker_bizobj.FindFieldDef('TL', config).field_id
+ te_id = tracker_bizobj.FindFieldDef('TE', config).field_id
+ ux_id = tracker_bizobj.FindFieldDef('UX', config).field_id
+ for possible_stale_issue in pipeline.visible_results:
+ issue = self.services.issue.GetIssue(
+ mr.cnxn, possible_stale_issue.issue_id, use_cache=False)
+
+ issue.approval_values = []
+ issue.phases = []
+ issue.field_values = [fv for fv in issue.field_values
+ if fv.phase_id is None]
+ issue.field_values = [fv for fv in issue.field_values
+ if fv.field_id not in
+ [pm_id, tl_id, te_id, ux_id]]
+ issue.labels.remove('Type-FLT-Launch')
+ issue.labels.remove('FLT-Conversion')
+ issue.labels.append('Type-Launch')
+
+ self.services.issue._UpdateIssuesApprovals(mr.cnxn, issue)
+ self.services.issue.UpdateIssue(mr.cnxn, issue)
+ return {'deleting': [issue.local_id for issue in pipeline.visible_results],
+ 'num': len(pipeline.visible_results),
+ }
+
+ def VerifyConversion(self, mr):
+ """Verify that all FLT-Conversion issues were converted correctly."""
+ with work_env.WorkEnv(mr, self.services) as we:
+ pipeline = we.ListIssues(
+ 'FLT=Conversion', ['chromium'], mr.auth.user_id, VERIFY_NUM,
+ CONVERT_START, 2, GROUP_BY_SPEC, SORT_SPEC, False)
+
+ project = self.services.project.GetProjectByName(mr.cnxn, 'chromium')
+ config = self.services.config.GetProjectConfig(mr.cnxn, project.project_id)
+ browser_approval_names = {fd.field_id: fd.field_name for fd
+ in config.field_defs if fd.field_name in
+ BROWSER_APPROVALS_TO_LABELS.keys()}
+ os_approval_names = {fd.field_id: fd.field_name for fd in config.field_defs
+ if (fd.field_name in OS_APPROVALS_TO_LABELS.keys())
+ or fd.field_name == 'ChromeOS-Enterprise'}
+ pm_id = tracker_bizobj.FindFieldDef('PM', config).field_id
+ tl_id = tracker_bizobj.FindFieldDef('TL', config).field_id
+ te_id = tracker_bizobj.FindFieldDef('TE', config).field_id
+ ux_id = tracker_bizobj.FindFieldDef('UX', config).field_id
+ mapproved_id = tracker_bizobj.FindFieldDef('M-Approved', config).field_id
+ mtarget_id = tracker_bizobj.FindFieldDef('M-Target', config).field_id
+
+ problems = []
+ for possible_stale_issue in pipeline.allowed_results:
+ issue = self.services.issue.GetIssue(
+ mr.cnxn, possible_stale_issue.issue_id, use_cache=False)
+ # Check correct template used
+ approval_names = browser_approval_names
+ approvals_to_labels = BROWSER_APPROVALS_TO_LABELS
+ m_labels_re = BROWSER_M_LABELS_RE
+ label_channel_to_phase_id = {
+ phase.name.lower(): phase.phase_id for phase in issue.phases}
+ if [l for l in issue.labels if l.startswith('OS-')] == ['OS-Chrome']:
+ approval_names = os_approval_names
+ m_labels_re = OS_M_LABELS_RE
+ approvals_to_labels = OS_APPROVALS_TO_LABELS
+ # OS default launch
+ if 'Rollout-Type-Default' in issue.labels:
+ if not all(phase.name in ['Feature Freeze', 'Branch', 'Stable']
+ for phase in issue.phases):
+ problems.append((
+ issue.local_id, 'incorrect phases for OS default launch.'))
+ # OS finch launch
+ elif 'Rollout-Type-Finch' in issue.labels:
+ if not all(phase.name in (
+ 'Feature Freeze', 'Branch', 'Stable-Exp', 'Stable-Full')
+ for phase in issue.phases):
+ problems.append((
+ issue.local_id, 'incorrect phases for OS finch launch.'))
+ else:
+ problems.append((
+ issue.local_id,
+ 'no rollout-type; should not have been converted'))
+ # Browser default launch
+ elif 'Rollout-Type-Default' in issue.labels:
+ if not all(phase.name.lower() in ['beta', 'stable']
+ for phase in issue.phases):
+ problems.append((
+ issue.local_id, 'incorrect phases for Default rollout'))
+ # Browser finch launch
+ elif 'Rollout-Type-Finch' in issue.labels:
+ if not all(phase.name.lower() in ['beta', 'stable-exp', 'stable-full']
+ for phase in issue.phases):
+ problems.append((
+ issue.local_id, 'incorrect phases for Finch rollout'))
+ else:
+ problems.append((
+ issue.local_id,
+ 'no rollout-type; should not have been converted'))
+
+ # Check approval_values
+ for av in issue.approval_values:
+ name = approval_names.get(av.approval_id)
+ if name == 'ChromeOS-Enterprise':
+ if av.status != tracker_pb2.ApprovalStatus.NEEDS_REVIEW:
+ problems.append((issue.local_id, 'bad ChromeOS-Enterprise status'))
+ continue
+ label_pre = approvals_to_labels.get(name)
+ if not label_pre:
+ # either name was None or not found in APPROVALS_TO_LABELS
+ problems.append((issue.local_id, 'approval %s not recognized' % name))
+ continue
+ label_value = next((l[len(label_pre):] for l in issue.labels
+ if l.startswith(label_pre)), None)
+ if (not label_value or label_value == 'NotReviewed') and av.status in [
+ tracker_pb2.ApprovalStatus.NOT_SET,
+ tracker_pb2.ApprovalStatus.NEEDS_REVIEW]:
+ continue
+ if av.status is VALUE_TO_STATUS.get(label_value):
+ continue
+ # neither of the above ifs passed
+ problems.append((issue.local_id,
+ 'approval %s has status %r for label value %s' % (
+ name, av.status.name, label_value)))
+
+ # Check people field_values
+ expected_people_fvs = self.ConvertPeopleLabels(
+ mr, issue.labels, pm_id, tl_id, te_id, ux_id)
+ for people_fv in expected_people_fvs:
+ if people_fv not in issue.field_values:
+ if people_fv.field_id == tl_id:
+ role = 'TL'
+ elif people_fv.field_id == pm_id:
+ role = 'PM'
+ elif people_fv.field_id == ux_id:
+ role = 'UX'
+ else:
+ role = 'TE'
+ problems.append((issue.local_id, 'missing a field for %s' % role))
+
+ # Check M phase field_values
+ for label in issue.labels:
+ match = re.match(m_labels_re, label)
+ if match:
+ channel = match.group('channel')
+ if (channel.lower() == 'stable-exp'
+ and 'Rollout-Type-Default' in issue.labels):
+ # ignore stable-exp for default rollouts.
+ continue
+ milestone = match.group('m')
+ m_type = match.group('type')
+ m_id = mapproved_id if m_type == 'Approved' else mtarget_id
+ phase_id = label_channel_to_phase_id.get(
+ channel.lower(), label_channel_to_phase_id.get('stable-full'))
+ if not next((
+ fv for fv in issue.field_values
+ if fv.phase_id == phase_id and fv.field_id == m_id and
+ fv.int_value == int(milestone)), None):
+ problems.append((
+ issue.local_id, 'no phase field for label %s' % label))
+
+ return {
+ 'problems found': ['issue %d: %s' % problem for problem in problems],
+ 'issues verified': ['issue %d' % issue.local_id for
+ issue in pipeline.allowed_results],
+ 'num': len(pipeline.allowed_results),
+ }
+
+ def HandleRequest(self, mr):
+ """Convert Type=Launch issues to new Type=FLT-Launch issues."""
+ launch = mr.GetParam('launch')
+ if launch == 'delete':
+ return self.UndoConversion(mr)
+ if launch == 'verify':
+ return self.VerifyConversion(mr)
+ project_info = self.FetchAndAssertProjectInfo(mr)
+
+ # Search for issues:
+ with work_env.WorkEnv(mr, self.services) as we:
+ pipeline = we.ListIssues(
+ project_info.q, ['chromium'], mr.auth.user_id, CONVERT_NUM,
+ CONVERT_START, 2, GROUP_BY_SPEC, SORT_SPEC, False)
+
+ # Convert issues:
+ for possible_stale_issue in pipeline.visible_results:
+ # Note: These approval values and phases from templates will be used
+ # and modified to create approval values and phases for each issue.
+ # We need to create copies for each issue so changes are not carried
+ # over to the conversion of the next issue in the loop.
+ template_avs = self.CreateApprovalCopies(project_info.approval_values)
+ template_phases = self.CreatePhasesCopies(project_info.phases)
+ issue = self.services.issue.GetIssue(
+ mr.cnxn, possible_stale_issue.issue_id, use_cache=False)
+ new_approvals = ConvertLaunchLabels(
+ issue.labels, template_avs,
+ project_info.config.field_defs, project_info.approvals_to_labels)
+ m_fvs = ConvertMLabels(
+ issue.labels, template_phases,
+ project_info.m_target_id, project_info.m_approved_id,
+ project_info.labels_re, project_info.phase_map)
+ people_fvs = self.ConvertPeopleLabels(
+ mr, issue.labels,
+ project_info.pm_fid, project_info.tl_fid, project_info.te_fid,
+ project_info.ux_fid)
+ amendments = self.ExecuteIssueChanges(
+ project_info.config, issue, new_approvals,
+ template_phases, m_fvs + people_fvs)
+ logging.info(amendments)
+
+ return {
+ 'converted_issues': [
+ issue.local_id for issue in pipeline.visible_results],
+ 'num': len(pipeline.visible_results),
+ }
+
+ def CreateApprovalCopies(self, avs):
+ return [
+ tracker_pb2.ApprovalValue(
+ approval_id=av.approval_id,
+ status=av.status,
+ setter_id=av.setter_id,
+ set_on=av.set_on,
+ phase_id=av.phase_id) for av in avs
+ ]
+
+ def CreatePhasesCopies(self, phases):
+ return [
+ tracker_pb2.Phase(
+ phase_id=phase.phase_id,
+ name=phase.name,
+ rank=phase.rank) for phase in phases
+ ]
+
+ def FetchAndAssertProjectInfo(self, mr):
+ # Get request details
+ launch = mr.GetParam('launch')
+ logging.info(launch)
+ q = QUERY_MAP.get(launch)
+ template_name = TEMPLATE_MAP.get(launch)
+ assert q and template_name, 'bad launch type: %s' % launch
+
+ phase_map = (
+ OS_PHASE_MAP if launch in ['os', 'os-finch'] else BROWSER_PHASE_MAP)
+ approvals_to_labels = (
+ OS_APPROVALS_TO_LABELS if launch in ['os', 'os-finch']
+ else BROWSER_APPROVALS_TO_LABELS)
+ m_labels_re = (
+ OS_M_LABELS_RE if launch in ['os', 'os-finch'] else BROWSER_M_LABELS_RE)
+
+ # Get project, config, template, assert template in project
+ project = self.services.project.GetProjectByName(mr.cnxn, 'chromium')
+ config = self.services.config.GetProjectConfig(mr.cnxn, project.project_id)
+ template = self.services.template.GetTemplateByName(
+ mr.cnxn, template_name, project.project_id)
+ assert template, 'template %s not found in chromium project' % template_name
+
+ # Get template approval_values/phases and assert they are expected
+ approval_values, phases = template_helpers.FilterApprovalsAndPhases(
+ template.approval_values, template.phases, config)
+ assert approval_values and phases, (
+ 'no approvals or phases in %s' % template_name)
+ assert all(phase.name.lower() in list(
+ phase_map.keys()) for phase in phases), (
+ 'one or more phases not recognized')
+ if launch in ['finch', 'os', 'os-finch']:
+ assert all(
+ av.status is tracker_pb2.ApprovalStatus.NEEDS_REVIEW
+ for av in approval_values
+ ), '%s template not set up correctly' % launch
+
+ approval_fds = {fd.field_id: fd.field_name for fd in config.field_defs
+ if fd.field_type is tracker_pb2.FieldTypes.APPROVAL_TYPE}
+ assert all(
+ approval_fds.get(av.approval_id) in list(approvals_to_labels.keys())
+ for av in approval_values
+ if approval_fds.get(av.approval_id) != 'ChromeOS-Enterprise'), (
+ 'one or more approvals not recognized')
+ approval_def_ids = [ad.approval_id for ad in config.approval_defs]
+ assert all(av.approval_id in approval_def_ids for av in approval_values), (
+ 'one or more approvals not in config.approval_defs')
+
+ # Get relevant USER_TYPE FieldDef ids and assert they exist
+ user_fds = {fd.field_name.lower(): fd.field_id for fd in config.field_defs
+ if fd.field_type is tracker_pb2.FieldTypes.USER_TYPE}
+ logging.info('project USER_TYPE FieldDefs: %s' % user_fds)
+ pm_fid = user_fds.get(PM_FIELD)
+ assert pm_fid, 'project has no FieldDef %s' % PM_FIELD
+ tl_fid = user_fds.get(TL_FIELD)
+ assert tl_fid, 'project has no FieldDef %s' % TL_FIELD
+ te_fid = user_fds.get(TE_FIELD)
+ assert te_fid, 'project has no FieldDef %s' % TE_FIELD
+ ux_fid = user_fds.get(UX_FIELD)
+ assert ux_fid, 'project has no FieldDef %s' % UX_FIELD
+
+ # Get relevant M Phase INT_TYPE FieldDef ids and assert they exist
+ phase_int_fds = {fd.field_name.lower(): fd.field_id
+ for fd in config.field_defs
+ if fd.field_type is tracker_pb2.FieldTypes.INT_TYPE
+ and fd.is_phase_field and fd.is_multivalued}
+ logging.info(
+ 'project Phase INT_TYPE multivalued FieldDefs: %s' % phase_int_fds)
+ m_target_id = phase_int_fds.get(MTARGET_FIELD)
+ assert m_target_id, 'project has no FieldDef %s' % MTARGET_FIELD
+ m_approved_id = phase_int_fds.get(MAPPROVED_FIELD)
+ assert m_approved_id, 'project has no FieldDef %s' % MAPPROVED_FIELD
+
+ return ProjectInfo(config, q, approval_values, phases, pm_fid, tl_fid,
+ te_fid, ux_fid, m_target_id, m_approved_id, phase_map,
+ approvals_to_labels, m_labels_re)
+
+ # TODO(jojwang): mr needs to be passed in as arg and
+ # all self.mr should be changed to mr
+ def ExecuteIssueChanges(self, config, issue, new_approvals, phases, new_fvs):
+ # Apply Approval and phase changes
+ approval_defs_by_id = {ad.approval_id: ad for ad in config.approval_defs}
+ for av in new_approvals:
+ ad = approval_defs_by_id.get(av.approval_id)
+ if ad:
+ av.approver_ids = ad.approver_ids
+ survey = ''
+ if ad.survey:
+ questions = ad.survey.split('\n')
+ survey = '\n'.join(['<b>' + q + '</b>' for q in questions])
+ self.services.issue.InsertComment(
+ self.mr.cnxn, tracker_pb2.IssueComment(
+ issue_id=issue.issue_id, project_id=issue.project_id,
+ user_id=self.mr.auth.user_id, content=survey,
+ is_description=True, approval_id=av.approval_id,
+ timestamp=int(time.time())))
+ else:
+ logging.info(
+ 'ERROR: ApprovalDef %r for ApprovalValue %r not valid', ad, av)
+ issue.approval_values = new_approvals
+ self.services.issue._UpdateIssuesApprovals(self.mr.cnxn, issue)
+
+ # Apply field value changes
+ issue.phases = phases
+ delta = tracker_bizobj.MakeIssueDelta(
+ None, None, [], [], [], [], ['Type-FLT-Launch', 'FLT-Conversion'],
+ ['Type-Launch'], new_fvs, [], [], [], [], [], [], None, None)
+ amendments, _ = self.services.issue.DeltaUpdateIssue(
+ self.mr.cnxn, self.services, self.mr.auth.user_id, issue.project_id,
+ config, issue, delta, comment=CONVERSION_COMMENT)
+
+ return amendments
+
+ def ConvertPeopleLabels(
+ self, mr, labels, pm_field_id, tl_field_id, te_field_id, ux_field_id):
+ field_values = []
+ pm_ldap, tl_ldap, test_ldaps, ux_ldaps = ExtractLabelLDAPs(labels)
+
+ pm_fv = self.CreateUserFieldValue(mr, pm_ldap, pm_field_id)
+ if pm_fv:
+ field_values.append(pm_fv)
+
+ tl_fv = self.CreateUserFieldValue(mr, tl_ldap, tl_field_id)
+ if tl_fv:
+ field_values.append(tl_fv)
+
+ for test_ldap in test_ldaps:
+ te_fv = self.CreateUserFieldValue(mr, test_ldap, te_field_id)
+ if te_fv:
+ field_values.append(te_fv)
+
+ for ux_ldap in ux_ldaps:
+ ux_fv = self.CreateUserFieldValue(mr, ux_ldap, ux_field_id)
+ if ux_fv:
+ field_values.append(ux_fv)
+ return field_values
+
+ def CreateUserFieldValue(self, mr, ldap, field_id):
+ if ldap is None:
+ return None
+ try:
+ user_id = self.services.user.LookupUserID(mr.cnxn, ldap+'@chromium.org')
+ except exceptions.NoSuchUserException:
+ try:
+ user_id = self.services.user.LookupUserID(mr.cnxn, ldap+'@google.com')
+ except exceptions.NoSuchUserException:
+ logging.info('No chromium.org or google.com accound found for %s', ldap)
+ return None
+ return tracker_bizobj.MakeFieldValue(
+ field_id, None, None, user_id, None, None, False)
+
+
+def ConvertMLabels(
+ labels, phases, m_target_id, m_approved_id, labels_re, phase_map):
+ field_values = []
+ for label in labels:
+ match = re.match(labels_re, label)
+ if match:
+ milestone = match.group('m')
+ m_type = match.group('type')
+ channel = match.group('channel')
+ for phase in phases:
+ # We know get(phase) will return something because
+ # we're checking before ConvertMLabels, that all phases
+ # exist in BROWSER_PHASE_MAP or OS_PHASE_MAP
+ if phase_map.get(phase.name.lower()) == channel.lower():
+ field_id = m_target_id if (
+ m_type.lower() == 'target') else m_approved_id
+ field_values.append(tracker_bizobj.MakeFieldValue(
+ field_id, int(milestone), None, None, None, None, False,
+ phase_id=phase.phase_id))
+ break # exit phase loop if match is found.
+ return field_values
+
+
+def ConvertLaunchLabels(labels, approvals, project_fds, approvals_to_labels):
+ """Converts 'Launch-[Review]' values into statuses for given approvals."""
+ label_values = {}
+ for label in labels:
+ launch_match = REVIEW_LABELS_RE.match(label)
+ if launch_match:
+ prefix = launch_match.group()
+ value = label[len(prefix):] # returns 'Yes' from 'Launch-UI-Yes'
+ label_values[prefix] = value
+
+ field_names_dict = {fd.field_id: fd.field_name for fd in project_fds}
+ for approval in approvals:
+ approval_name = field_names_dict.get(approval.approval_id, '')
+ old_prefix = approvals_to_labels.get(approval_name)
+ label_value = label_values.get(old_prefix, '')
+ # if label_value not found in VALUE_TO_STATUS, use current status.
+ approval.status = VALUE_TO_STATUS.get(label_value, approval.status)
+
+ return approvals
+
+
+def ExtractLabelLDAPs(labels):
+ """Extracts LDAPs from labels 'PM-', 'TL-', 'UX-', and 'test-'"""
+
+ pm_ldap = None
+ tl_ldap = None
+ test_ldaps = []
+ ux_ldaps = []
+ for label in labels:
+ label = label.lower()
+ if label.startswith(PM_PREFIX):
+ pm_ldap = label[len(PM_PREFIX):]
+ elif label.startswith(TL_PREFIX):
+ tl_ldap = label[len(TL_PREFIX):]
+ elif label.startswith(TEST_PREFIX):
+ ldap = label[len(TEST_PREFIX):]
+ if ldap:
+ test_ldaps.append(ldap)
+ elif label.startswith(UX_PREFIX):
+ ldap = label[len(UX_PREFIX):]
+ if ldap:
+ ux_ldaps.append(ldap)
+ return pm_ldap, tl_ldap, test_ldaps, ux_ldaps
diff --git a/tracker/issue-blocking-change-notification-email.ezt b/tracker/issue-blocking-change-notification-email.ezt
new file mode 100644
index 0000000..9e45c69
--- /dev/null
+++ b/tracker/issue-blocking-change-notification-email.ezt
@@ -0,0 +1,7 @@
+Issue [issue.local_id]: [format "raw"][summary][end]
+[detail_url]
+
+[if-any is_blocking]This issue is now blocking issue [downstream_issue_ref].
+See [downstream_issue_url]
+[else]This issue is no longer blocking issue [downstream_issue_ref].
+See [downstream_issue_url][end]
diff --git a/tracker/issue-bulk-change-notification-email.ezt b/tracker/issue-bulk-change-notification-email.ezt
new file mode 100644
index 0000000..2051220
--- /dev/null
+++ b/tracker/issue-bulk-change-notification-email.ezt
@@ -0,0 +1,18 @@
+[if-any amendments]Updates:
+[amendments]
+[end]
+Comment[if-any commenter] by [commenter.display_name][end]:
+[if-any comment_text][format "raw"][comment_text][end][else](No comment was entered for this change.)[end]
+
+Affected issues:
+[for issues] issue [issues.local_id]: [format "raw"][issues.summary][end]
+ [format "raw"]http://[hostport][issues.detail_relative_url][end]
+
+[end]
+[is body_type "email"]
+--
+You received this message because you are listed in the owner
+or CC fields of these issues, or because you starred them.
+You may adjust your issue notification preferences at:
+http://[hostport]/hosting/settings
+[end]
diff --git a/tracker/issueadmin.py b/tracker/issueadmin.py
new file mode 100644
index 0000000..5c34f72
--- /dev/null
+++ b/tracker/issueadmin.py
@@ -0,0 +1,587 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Servlets for issue tracker configuration.
+
+These classes implement the Statuses, Labels and fields, Components, Rules, and
+Views subtabs under the Process tab. Unlike most servlet modules, this single
+file holds a base class and several related servlet classes.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import itertools
+import logging
+import time
+
+import ezt
+
+from features import filterrules_helpers
+from features import filterrules_views
+from features import savedqueries_helpers
+from framework import authdata
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import monorailrequest
+from framework import permissions
+from framework import servlet
+from framework import urls
+from proto import tracker_pb2
+from tracker import field_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+
+class IssueAdminBase(servlet.Servlet):
+ """Base class for servlets allowing project owners to configure tracker."""
+
+ _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PROCESS
+ _PROCESS_SUBTAB = None # specified in subclasses
+
+ 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.
+ """
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ config_view = tracker_views.ConfigView(mr, self.services, config,
+ template=None, load_all_templates=True)
+ open_text, closed_text = tracker_views.StatusDefsAsText(config)
+ labels_text = tracker_views.LabelDefsAsText(config)
+
+ return {
+ 'admin_tab_mode': self._PROCESS_SUBTAB,
+ 'config': config_view,
+ 'open_text': open_text,
+ 'closed_text': closed_text,
+ 'labels_text': labels_text,
+ }
+
+ def ProcessFormData(self, mr, post_data):
+ """Validate and store the contents of the issues tracker admin page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ post_data: HTML form data from the request.
+
+ Returns:
+ String URL to redirect the user to, or None if response was already sent.
+ """
+ page_url = self.ProcessSubtabForm(post_data, mr)
+
+ if page_url:
+ return framework_helpers.FormatAbsoluteURL(
+ mr, page_url, saved=1, ts=int(time.time()))
+
+
+class AdminStatuses(IssueAdminBase):
+ """Servlet allowing project owners to configure well-known statuses."""
+
+ _PAGE_TEMPLATE = 'tracker/admin-statuses-page.ezt'
+ _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_STATUSES
+
+ def ProcessSubtabForm(self, post_data, mr):
+ """Process the status definition section of the admin page.
+
+ Args:
+ post_data: HTML form data for the HTTP request being processed.
+ mr: commonly used info parsed from the request.
+
+ Returns:
+ The URL of the page to show after processing.
+ """
+ if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+ raise permissions.PermissionException(
+ 'Only project owners may edit the status definitions')
+
+ wks_open_text = post_data.get('predefinedopen', '')
+ wks_open_matches = framework_constants.IDENTIFIER_DOCSTRING_RE.findall(
+ wks_open_text)
+ wks_open_tuples = [
+ (status.lstrip('#'), docstring.strip(), True, status.startswith('#'))
+ for status, docstring in wks_open_matches]
+ if not wks_open_tuples:
+ mr.errors.open_statuses = 'A project cannot have zero open statuses'
+
+ wks_closed_text = post_data.get('predefinedclosed', '')
+ wks_closed_matches = framework_constants.IDENTIFIER_DOCSTRING_RE.findall(
+ wks_closed_text)
+ wks_closed_tuples = [
+ (status.lstrip('#'), docstring.strip(), False, status.startswith('#'))
+ for status, docstring in wks_closed_matches]
+ if not wks_closed_tuples:
+ mr.errors.closed_statuses = 'A project cannot have zero closed statuses'
+
+ statuses_offer_merge_text = post_data.get('statuses_offer_merge', '')
+ statuses_offer_merge = framework_constants.IDENTIFIER_RE.findall(
+ statuses_offer_merge_text)
+
+ if mr.errors.AnyErrors():
+ self.PleaseCorrect(
+ mr, open_text=wks_open_text, closed_text=wks_closed_text)
+ return
+
+ self.services.config.UpdateConfig(
+ mr.cnxn, mr.project, statuses_offer_merge=statuses_offer_merge,
+ well_known_statuses=wks_open_tuples + wks_closed_tuples)
+
+ # TODO(jrobbins): define a "strict" mode that affects only statuses.
+
+ return urls.ADMIN_STATUSES
+
+
+class AdminLabels(IssueAdminBase):
+ """Servlet allowing project owners to labels and fields."""
+
+ _PAGE_TEMPLATE = 'tracker/admin-labels-page.ezt'
+ _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_LABELS
+
+ 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.
+ """
+ page_data = super(AdminLabels, self).GatherPageData(mr)
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ field_def_views = [
+ tracker_views.FieldDefView(fd, config)
+ # TODO(jrobbins): future field-level view restrictions.
+ for fd in config.field_defs
+ if not fd.is_deleted]
+ page_data.update({
+ 'field_defs': field_def_views,
+ })
+ return page_data
+
+ def ProcessSubtabForm(self, post_data, mr):
+ """Process changes to labels and custom field definitions.
+
+ Args:
+ post_data: HTML form data for the HTTP request being processed.
+ mr: commonly used info parsed from the request.
+
+ Returns:
+ The URL of the page to show after processing.
+ """
+ if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+ raise permissions.PermissionException(
+ 'Only project owners may edit the label definitions')
+
+ wkl_text = post_data.get('predefinedlabels', '')
+ wkl_matches = framework_constants.IDENTIFIER_DOCSTRING_RE.findall(wkl_text)
+ wkl_tuples = [
+ (label.lstrip('#'), docstring.strip(), label.startswith('#'))
+ for label, docstring in wkl_matches]
+ if not wkl_tuples:
+ mr.errors.label_defs = 'A project cannot have zero labels'
+ label_counter = collections.Counter(wkl[0].lower() for wkl in wkl_tuples)
+ for lab, count in label_counter.items():
+ if count > 1:
+ mr.errors.label_defs = 'Duplicate label: %s' % lab
+
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ field_names = [fd.field_name for fd in config.field_defs
+ if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE
+ and not fd.is_deleted]
+ masked_labels = tracker_helpers.LabelsMaskedByFields(config, field_names)
+ field_names_lower = [field_name.lower() for field_name in field_names]
+ for wkl in wkl_tuples:
+ conflict = tracker_bizobj.LabelIsMaskedByField(wkl[0], field_names_lower)
+ if conflict:
+ mr.errors.label_defs = (
+ 'Label "%s" should be defined in enum "%s"' % (wkl[0], conflict))
+ wkl_tuples.extend([
+ (masked.name, masked.docstring, False) for masked in masked_labels])
+
+ excl_prefix_text = post_data.get('excl_prefixes', '')
+ excl_prefixes = framework_constants.IDENTIFIER_RE.findall(excl_prefix_text)
+
+ if mr.errors.AnyErrors():
+ self.PleaseCorrect(mr, labels_text=wkl_text)
+ return
+
+ self.services.config.UpdateConfig(
+ mr.cnxn, mr.project,
+ well_known_labels=wkl_tuples, excl_label_prefixes=excl_prefixes)
+
+ # TODO(jrobbins): define a "strict" mode that affects only labels.
+
+ return urls.ADMIN_LABELS
+
+
+class AdminTemplates(IssueAdminBase):
+ """Servlet allowing project owners to configure templates."""
+
+ _PAGE_TEMPLATE = 'tracker/admin-templates-page.ezt'
+ _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_TEMPLATES
+
+ 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.
+ """
+ return super(AdminTemplates, self).GatherPageData(mr)
+
+ def ProcessSubtabForm(self, post_data, mr):
+ """Process changes to new issue templates.
+
+ Args:
+ post_data: HTML form data for the HTTP request being processed.
+ mr: commonly used info parsed from the request.
+
+ Returns:
+ The URL of the page to show after processing.
+ """
+ if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+ raise permissions.PermissionException(
+ 'Only project owners may edit the default templates')
+
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+
+ templates = self.services.template.GetProjectTemplates(mr.cnxn,
+ config.project_id)
+ default_template_id_for_developers, default_template_id_for_users = (
+ self._ParseDefaultTemplateSelections(post_data, templates))
+ if default_template_id_for_developers or default_template_id_for_users:
+ self.services.config.UpdateConfig(
+ mr.cnxn, mr.project,
+ default_template_for_developers=default_template_id_for_developers,
+ default_template_for_users=default_template_id_for_users)
+
+ return urls.ADMIN_TEMPLATES
+
+ def _ParseDefaultTemplateSelections(self, post_data, templates):
+ """Parse the input for the default templates to offer users."""
+ def GetSelectedTemplateID(name):
+ """Find the ID of the template specified in post_data[name]."""
+ if name not in post_data:
+ return None
+ selected_template_name = post_data[name]
+ for template in templates:
+ if selected_template_name == template.name:
+ return template.template_id
+
+ logging.error('User somehow selected an invalid template: %r',
+ selected_template_name)
+ return None
+
+ return (GetSelectedTemplateID('default_template_for_developers'),
+ GetSelectedTemplateID('default_template_for_users'))
+
+
+class AdminComponents(IssueAdminBase):
+ """Servlet allowing project owners to view the list of components."""
+
+ _PAGE_TEMPLATE = 'tracker/admin-components-page.ezt'
+ _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_COMPONENTS
+
+ 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.
+ """
+ page_data = super(AdminComponents, self).GatherPageData(mr)
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user,
+ *[list(cd.admin_ids) + list(cd.cc_ids)
+ for cd in config.component_defs])
+ framework_views.RevealAllEmailsToMembers(
+ mr.cnxn, self.services, mr.auth, users_by_id, mr.project)
+ component_def_views = [
+ tracker_views.ComponentDefView(mr.cnxn, self.services, cd, users_by_id)
+ # TODO(jrobbins): future component-level view restrictions.
+ for cd in config.component_defs]
+ for cd in component_def_views:
+ if mr.auth.email in [user.email for user in cd.admins]:
+ cd.classes += 'myadmin '
+ if mr.auth.email in [user.email for user in cd.cc]:
+ cd.classes += 'mycc '
+
+ page_data.update({
+ 'component_defs': component_def_views,
+ 'failed_perm': mr.GetParam('failed_perm'),
+ 'failed_subcomp': mr.GetParam('failed_subcomp'),
+ 'failed_templ': mr.GetParam('failed_templ'),
+ })
+ return page_data
+
+ def _GetComponentDefs(self, _mr, post_data, config):
+ """Get the config and component definitions from the request."""
+ component_defs = []
+ component_paths = post_data.get('delete_components').split(',')
+ for component_path in component_paths:
+ component_def = tracker_bizobj.FindComponentDef(component_path, config)
+ component_defs.append(component_def)
+ return component_defs
+
+ def _ProcessDeleteComponent(self, mr, component_def):
+ """Delete the specified component and its references."""
+ self.services.issue.DeleteComponentReferences(
+ mr.cnxn, component_def.component_id)
+ self.services.config.DeleteComponentDef(
+ mr.cnxn, mr.project_id, component_def.component_id)
+
+ def ProcessFormData(self, mr, post_data):
+ """Processes a POST command to delete components.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ post_data: HTML form data from the request.
+
+ Returns:
+ String URL to redirect the user to, or None if response was already sent.
+ """
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ component_defs = self._GetComponentDefs(mr, post_data, config)
+ # Reverse the component_defs so that we start deleting from subcomponents.
+ component_defs.reverse()
+
+ # Collect errors.
+ perm_errors = []
+ subcomponents_errors = []
+ templates_errors = []
+ # Collect successes.
+ deleted_components = []
+
+ for component_def in component_defs:
+ allow_edit = permissions.CanEditComponentDef(
+ mr.auth.effective_ids, mr.perms, mr.project, component_def, config)
+ if not allow_edit:
+ perm_errors.append(component_def.path)
+
+ subcomponents = tracker_bizobj.FindDescendantComponents(
+ config, component_def)
+ if subcomponents:
+ subcomponents_errors.append(component_def.path)
+
+ templates = self.services.template.TemplatesWithComponent(
+ mr.cnxn, component_def.component_id)
+ if templates:
+ templates_errors.append(component_def.path)
+
+ allow_delete = allow_edit and not subcomponents and not templates
+ if allow_delete:
+ self._ProcessDeleteComponent(mr, component_def)
+ deleted_components.append(component_def.path)
+ # Refresh project config after the component deletion.
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+
+ return framework_helpers.FormatAbsoluteURL(
+ mr, urls.ADMIN_COMPONENTS, ts=int(time.time()),
+ failed_perm=','.join(perm_errors),
+ failed_subcomp=','.join(subcomponents_errors),
+ failed_templ=','.join(templates_errors),
+ deleted=','.join(deleted_components))
+
+
+class AdminViews(IssueAdminBase):
+ """Servlet for project owners to set default columns, axes, and sorting."""
+
+ _PAGE_TEMPLATE = 'tracker/admin-views-page.ezt'
+ _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_VIEWS
+
+ 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.
+ """
+ page_data = super(AdminViews, self).GatherPageData(mr)
+ with mr.profiler.Phase('getting canned queries'):
+ canned_queries = self.services.features.GetCannedQueriesByProjectID(
+ mr.cnxn, mr.project_id)
+ canned_query_views = [
+ savedqueries_helpers.SavedQueryView(sq, idx + 1, None, None)
+ for idx, sq in enumerate(canned_queries)]
+
+ page_data.update({
+ 'canned_queries': canned_query_views,
+ 'new_query_indexes': list(range(
+ len(canned_queries) + 1, savedqueries_helpers.MAX_QUERIES + 1)),
+ 'issue_notify': mr.project.issue_notify_address,
+ 'max_queries': savedqueries_helpers.MAX_QUERIES,
+ })
+ return page_data
+
+ def ProcessSubtabForm(self, post_data, mr):
+ """Process the Views subtab.
+
+ Args:
+ post_data: HTML form data for the HTTP request being processed.
+ mr: commonly used info parsed from the request.
+
+ Returns:
+ The URL of the page to show after processing.
+ """
+ if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+ raise permissions.PermissionException(
+ 'Only project owners may edit the default views')
+ existing_queries = savedqueries_helpers.ParseSavedQueries(
+ mr.cnxn, post_data, self.services.project)
+ added_queries = savedqueries_helpers.ParseSavedQueries(
+ mr.cnxn, post_data, self.services.project, prefix='new_')
+ canned_queries = existing_queries + added_queries
+
+ list_prefs = _ParseListPreferences(post_data)
+
+ if mr.errors.AnyErrors():
+ self.PleaseCorrect(mr)
+ return
+
+ self.services.config.UpdateConfig(
+ mr.cnxn, mr.project, list_prefs=list_prefs)
+ self.services.features.UpdateCannedQueries(
+ mr.cnxn, mr.project_id, canned_queries)
+
+ return urls.ADMIN_VIEWS
+
+
+def _ParseListPreferences(post_data):
+ """Parse the part of a project admin form about artifact list preferences."""
+ default_col_spec = ''
+ if 'default_col_spec' in post_data:
+ default_col_spec = post_data['default_col_spec']
+ # Don't allow empty colum spec
+ if not default_col_spec:
+ default_col_spec = tracker_constants.DEFAULT_COL_SPEC
+ col_spec_words = monorailrequest.ParseColSpec(
+ default_col_spec, max_parts=framework_constants.MAX_COL_PARTS)
+ col_spec = ' '.join(word for word in col_spec_words)
+
+ default_sort_spec = ''
+ if 'default_sort_spec' in post_data:
+ default_sort_spec = post_data['default_sort_spec']
+ sort_spec_words = monorailrequest.ParseColSpec(default_sort_spec)
+ sort_spec = ' '.join(sort_spec_words)
+
+ x_attr_str = ''
+ if 'default_x_attr' in post_data:
+ x_attr_str = post_data['default_x_attr']
+ x_attr_words = monorailrequest.ParseColSpec(x_attr_str)
+ x_attr = ''
+ if x_attr_words:
+ x_attr = x_attr_words[0]
+
+ y_attr_str = ''
+ if 'default_y_attr' in post_data:
+ y_attr_str = post_data['default_y_attr']
+ y_attr_words = monorailrequest.ParseColSpec(y_attr_str)
+ y_attr = ''
+ if y_attr_words:
+ y_attr = y_attr_words[0]
+
+ member_default_query = ''
+ if 'member_default_query' in post_data:
+ member_default_query = post_data['member_default_query']
+
+ return col_spec, sort_spec, x_attr, y_attr, member_default_query
+
+
+class AdminRules(IssueAdminBase):
+ """Servlet allowing project owners to configure filter rules."""
+
+ _PAGE_TEMPLATE = 'tracker/admin-rules-page.ezt'
+ _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_RULES
+
+ def AssertBasePermission(self, mr):
+ """Check whether the user has any permission to visit this page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ """
+ super(AdminRules, self).AssertBasePermission(mr)
+ if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+ raise permissions.PermissionException(
+ 'User is not allowed to administer this project')
+
+ 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.
+ """
+ page_data = super(AdminRules, self).GatherPageData(mr)
+ rules = self.services.features.GetFilterRules(
+ mr.cnxn, mr.project_id)
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user,
+ [rule.default_owner_id for rule in rules],
+ *[rule.add_cc_ids for rule in rules])
+ framework_views.RevealAllEmailsToMembers(
+ mr.cnxn, self.services, mr.auth, users_by_id, mr.project)
+ rule_views = [filterrules_views.RuleView(rule, users_by_id)
+ for rule in rules]
+
+ for idx, rule_view in enumerate(rule_views):
+ rule_view.idx = idx + 1 # EZT has no loop index, so we set idx.
+
+ page_data.update({
+ 'rules': rule_views,
+ 'new_rule_indexes': (
+ list(range(len(rules) + 1, filterrules_helpers.MAX_RULES + 1))),
+ 'max_rules': filterrules_helpers.MAX_RULES,
+ })
+ return page_data
+
+ def ProcessSubtabForm(self, post_data, mr):
+ """Process the Rules subtab.
+
+ Args:
+ post_data: HTML form data for the HTTP request being processed.
+ mr: commonly used info parsed from the request.
+
+ Returns:
+ The URL of the page to show after processing.
+ """
+ old_rules = self.services.features.GetFilterRules(mr.cnxn, mr.project_id)
+ rules = filterrules_helpers.ParseRules(
+ mr.cnxn, post_data, self.services.user, mr.errors)
+ new_rules = filterrules_helpers.ParseRules(
+ mr.cnxn, post_data, self.services.user, mr.errors, prefix='new_')
+ rules.extend(new_rules)
+
+ if mr.errors.AnyErrors():
+ self.PleaseCorrect(mr)
+ return
+
+ config = self.services.features.UpdateFilterRules(
+ mr.cnxn, mr.project_id, rules)
+
+ if old_rules != rules:
+ logging.info('recomputing derived fields')
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ filterrules_helpers.RecomputeAllDerivedFields(
+ mr.cnxn, self.services, mr.project, config)
+
+ return urls.ADMIN_RULES
diff --git a/tracker/issueadvsearch.py b/tracker/issueadvsearch.py
new file mode 100644
index 0000000..d824098
--- /dev/null
+++ b/tracker/issueadvsearch.py
@@ -0,0 +1,123 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed 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 advanced search feature page.
+
+The advanced search page simply displays an HTML page with a form.
+The form handler converts the widget-based query into a googley query
+string and redirects the user to the issue list servlet.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import re
+
+from features import savedqueries_helpers
+from framework import framework_helpers
+from framework import permissions
+from framework import servlet
+from framework import urls
+
+# Patterns for search values that can be words, labels,
+# component paths, or email addresses.
+VALUE_RE = re.compile(r'[-a-zA-Z0-9._>@]+')
+
+
+class IssueAdvancedSearch(servlet.Servlet):
+ """IssueAdvancedSearch shows a form to enter an advanced search."""
+
+ _PAGE_TEMPLATE = 'tracker/issue-advsearch-page.ezt'
+ _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
+
+ # This form *only* redirects to a GET request, and permissions are checked
+ # in that handler.
+ CHECK_SECURITY_TOKEN = False
+
+ 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.
+ """
+ # TODO(jrobbins): Allow deep-linking into this page.
+ canned_query_views = []
+ if mr.project_id:
+ with mr.profiler.Phase('getting canned queries'):
+ canned_queries = self.services.features.GetCannedQueriesByProjectID(
+ mr.cnxn, mr.project_id)
+ canned_query_views = [
+ savedqueries_helpers.SavedQueryView(sq, idx + 1, None, None)
+ for idx, sq in enumerate(canned_queries)]
+
+ saved_query_views = []
+ if mr.auth.user_id and self.services.features:
+ with mr.profiler.Phase('getting saved queries'):
+ saved_queries = self.services.features.GetSavedQueriesByUserID(
+ mr.cnxn, mr.me_user_id)
+ saved_query_views = [
+ savedqueries_helpers.SavedQueryView(sq, idx + 1, None, None)
+ for idx, sq in enumerate(saved_queries)
+ if (mr.project_id in sq.executes_in_project_ids or
+ not mr.project_id)]
+
+ return {
+ 'issue_tab_mode': 'issueAdvSearch',
+ 'page_perms': self.MakePagePerms(mr, None, permissions.CREATE_ISSUE),
+ 'canned_queries': canned_query_views,
+ 'saved_queries': saved_query_views,
+ }
+
+ def ProcessFormData(self, mr, post_data):
+ """Process a posted advanced query form.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ post_data: HTML form data from the request.
+
+ Returns:
+ String URL to redirect the user to after processing.
+ """
+ # Default to searching open issues in this project.
+ can = post_data.get('can', 2)
+
+ terms = []
+ self._AccumulateANDTerm('', 'words', post_data, terms)
+ self._AccumulateANDTerm('-', 'without', post_data, terms)
+ self._AccumulateANDTerm('label:', 'labels', post_data, terms)
+ self._AccumulateORTerm('component:', 'components', post_data, terms)
+ self._AccumulateORTerm('status:', 'statuses', post_data, terms)
+ self._AccumulateORTerm('reporter:', 'reporters', post_data, terms)
+ self._AccumulateORTerm('owner:', 'owners', post_data, terms)
+ self._AccumulateORTerm('cc:', 'cc', post_data, terms)
+ self._AccumulateORTerm('commentby:', 'commentby', post_data, terms)
+
+ if 'starcount' in post_data:
+ starcount = int(post_data['starcount'])
+ if starcount >= 0:
+ terms.append('starcount:%s' % starcount)
+
+ return framework_helpers.FormatAbsoluteURL(
+ mr, urls.ISSUE_LIST, q=' '.join(terms), can=can)
+
+ def _AccumulateANDTerm(self, operator, form_field, post_data, search_query):
+ """Build a query that matches issues with ALL of the given field values."""
+ user_input = post_data.get(form_field)
+ if user_input:
+ values = VALUE_RE.findall(user_input)
+ search_terms = ['%s%s' % (operator, v) for v in values]
+ search_query.extend(search_terms)
+
+ def _AccumulateORTerm(self, operator, form_field, post_data, search_query):
+ """Build a query that matches issues with ANY of the given field values."""
+ user_input = post_data.get(form_field)
+ if user_input:
+ values = VALUE_RE.findall(user_input)
+ search_term = '%s%s' % (operator, ','.join(values))
+ search_query.append(search_term)
diff --git a/tracker/issueattachment.py b/tracker/issueattachment.py
new file mode 100644
index 0000000..d6fa978
--- /dev/null
+++ b/tracker/issueattachment.py
@@ -0,0 +1,93 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Issue Tracker code to serve out issue attachments.
+
+Summary of page classes:
+ AttachmentPage: Serve the content of an attachment w/ the appropriate
+ MIME type.
+ IssueAttachmentDeletion: Form handler for deleting attachments.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import base64
+import logging
+import os
+import re
+import urllib
+
+import webapp2
+
+from google.appengine.api import app_identity
+from google.appengine.api import images
+
+from framework import exceptions
+from framework import framework_constants
+from framework import framework_helpers
+from framework import gcs_helpers
+from framework import permissions
+from framework import servlet
+from framework import urls
+from tracker import attachment_helpers
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+
+# This will likely appear blank or as a broken image icon in the browser.
+NO_PREVIEW_ICON = ''
+NO_PREVIEW_MIME_TYPE = 'image/png'
+
+
+class AttachmentPage(servlet.Servlet):
+ """AttachmentPage serves issue attachments."""
+
+ def GatherPageData(self, mr):
+ """Parse the attachment ID from the request and serve its content.
+
+ Args:
+ mr: commonly used info parsed from the request.
+
+ Returns: dict of values used by EZT for rendering the page.
+ """
+ if mr.signed_aid != attachment_helpers.SignAttachmentID(mr.aid):
+ webapp2.abort(400, 'Please reload the issue page')
+
+ try:
+ attachment, _issue = tracker_helpers.GetAttachmentIfAllowed(
+ mr, self.services)
+ except exceptions.NoSuchIssueException:
+ webapp2.abort(404, 'issue not found')
+ except exceptions.NoSuchAttachmentException:
+ webapp2.abort(404, 'attachment not found')
+ except exceptions.NoSuchCommentException:
+ webapp2.abort(404, 'comment not found')
+
+ if not attachment.gcs_object_id:
+ webapp2.abort(404, 'attachment data not found')
+
+ bucket_name = app_identity.get_default_gcs_bucket_name()
+
+ gcs_object_id = attachment.gcs_object_id
+
+ logging.info('attachment id %d is %s', mr.aid, gcs_object_id)
+
+ # By default GCS will return images and attachments displayable inline.
+ if mr.thumb:
+ # Thumbnails are stored in a separate obj always displayed inline.
+ gcs_object_id = gcs_object_id + '-thumbnail'
+ elif not mr.inline:
+ # Downloads are stored in a separate obj with disposiiton set.
+ filename = attachment.filename
+ if not framework_constants.FILENAME_RE.match(filename):
+ logging.info('bad file name: %s' % attachment.attachment_id)
+ filename = 'attachment-%d.dat' % attachment.attachment_id
+ if gcs_helpers.MaybeCreateDownload(
+ bucket_name, gcs_object_id, filename):
+ gcs_object_id = gcs_object_id + '-download'
+
+ url = gcs_helpers.SignUrl(bucket_name, gcs_object_id)
+ self.redirect(url, abort=True)
diff --git a/tracker/issueattachmenttext.py b/tracker/issueattachmenttext.py
new file mode 100644
index 0000000..d3daaf9
--- /dev/null
+++ b/tracker/issueattachmenttext.py
@@ -0,0 +1,103 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Servlet to safely display textual issue attachments.
+
+Unlike most attachments, this is not a download, it is a full HTML page
+with safely escaped user content.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+import webapp2
+
+from google.appengine.api import app_identity
+
+from third_party import cloudstorage
+import ezt
+
+from features import prettify
+from framework import exceptions
+from framework import filecontent
+from framework import permissions
+from framework import servlet
+from framework import template_helpers
+from tracker import attachment_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_helpers
+
+
+class AttachmentText(servlet.Servlet):
+ """AttachmentText displays textual attachments much like source browsing."""
+
+ _PAGE_TEMPLATE = 'tracker/issue-attachment-text.ezt'
+ _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
+
+ def GatherPageData(self, mr):
+ """Parse the attachment ID from the request and serve its content.
+
+ Args:
+ mr: commonly used info parsed from the request.
+
+ Returns:
+ Dict of values used by EZT for rendering almost the page.
+ """
+ with mr.profiler.Phase('get issue, comment, and attachment'):
+ try:
+ attachment, issue = tracker_helpers.GetAttachmentIfAllowed(
+ mr, self.services)
+ except exceptions.NoSuchIssueException:
+ webapp2.abort(404, 'issue not found')
+ except exceptions.NoSuchAttachmentException:
+ webapp2.abort(404, 'attachment not found')
+ except exceptions.NoSuchCommentException:
+ webapp2.abort(404, 'comment not found')
+
+ content = []
+ if attachment.gcs_object_id:
+ bucket_name = app_identity.get_default_gcs_bucket_name()
+ full_path = '/' + bucket_name + attachment.gcs_object_id
+ logging.info("reading gcs: %s" % full_path)
+ with cloudstorage.open(full_path, 'r') as f:
+ content = f.read()
+
+ filesize = len(content)
+
+ # This servlet only displays safe textual attachments. The user should
+ # not have been given a link to this servlet for any other kind.
+ if not attachment_helpers.IsViewableText(attachment.mimetype, filesize):
+ self.abort(400, 'not a text file')
+
+ u_text, is_binary, too_large = filecontent.DecodeFileContents(content)
+ lines = prettify.PrepareSourceLinesForHighlighting(u_text.encode('utf8'))
+
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ granted_perms = tracker_bizobj.GetGrantedPerms(
+ issue, mr.auth.effective_ids, config)
+ page_perms = self.MakePagePerms(
+ mr, issue, permissions.DELETE_ISSUE, permissions.CREATE_ISSUE,
+ granted_perms=granted_perms)
+
+ page_data = {
+ 'issue_tab_mode': 'issueDetail',
+ 'local_id': issue.local_id,
+ 'filename': attachment.filename,
+ 'filesize': template_helpers.BytesKbOrMb(filesize),
+ 'file_lines': lines,
+ 'is_binary': ezt.boolean(is_binary),
+ 'too_large': ezt.boolean(too_large),
+ 'code_reviews': None,
+ 'page_perms': page_perms,
+ }
+ if is_binary or too_large:
+ page_data['should_prettify'] = ezt.boolean(False)
+ else:
+ page_data.update(prettify.BuildPrettifyData(
+ len(lines), attachment.filename))
+
+ return page_data
diff --git a/tracker/issuebulkedit.py b/tracker/issuebulkedit.py
new file mode 100644
index 0000000..c1f5229
--- /dev/null
+++ b/tracker/issuebulkedit.py
@@ -0,0 +1,473 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed 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 bulk edit page and related forms.
+
+Summary of classes:
+ IssueBulkEdit: Show a form for editing multiple issues and allow the
+ user to update them all at once.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import httplib
+import itertools
+import logging
+import time
+
+import ezt
+
+from features import filterrules_helpers
+from features import send_notifications
+from framework import exceptions
+from framework import framework_constants
+from framework import framework_views
+from framework import permissions
+from framework import servlet
+from framework import template_helpers
+from services import tracker_fulltext
+from tracker import field_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+
+class IssueBulkEdit(servlet.Servlet):
+ """IssueBulkEdit lists multiple issues and allows an edit to all of them."""
+
+ _PAGE_TEMPLATE = 'tracker/issue-bulk-edit-page.ezt'
+ _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
+ _SECONDS_OVERHEAD = 4
+ _SECONDS_PER_UPDATE = 0.12
+ _SLOWNESS_THRESHOLD = 10
+
+ def AssertBasePermission(self, mr):
+ """Check whether the user has any permission to visit this page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+
+ Raises:
+ PermissionException: if the user is not allowed to enter an issue.
+ """
+ super(IssueBulkEdit, self).AssertBasePermission(mr)
+ can_edit = self.CheckPerm(mr, permissions.EDIT_ISSUE)
+ can_comment = self.CheckPerm(mr, permissions.ADD_ISSUE_COMMENT)
+ if not (can_edit and can_comment):
+ raise permissions.PermissionException('bulk edit forbidden')
+
+ 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 mr.profiler.Phase('getting issues'):
+ if not mr.local_id_list:
+ raise exceptions.InputException()
+ requested_issues = self.services.issue.GetIssuesByLocalIDs(
+ mr.cnxn, mr.project_id, sorted(mr.local_id_list))
+
+ with mr.profiler.Phase('filtering issues'):
+ # TODO(jrobbins): filter out issues that the user cannot edit and
+ # provide that as feedback rather than just siliently ignoring them.
+ open_issues, closed_issues = (
+ tracker_helpers.GetAllowedOpenedAndClosedIssues(
+ mr, [issue.issue_id for issue in requested_issues],
+ self.services))
+ issues = open_issues + closed_issues
+
+ if not issues:
+ self.abort(404, 'no issues found')
+
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ type_label_set = {
+ lab.lower() for lab in issues[0].labels
+ if lab.lower().startswith('type-')}
+ for issue in issues[1:]:
+ new_type_set = {
+ lab.lower() for lab in issue.labels
+ if lab.lower().startswith('type-')}
+ type_label_set &= new_type_set
+
+ issue_phases = list(
+ itertools.chain.from_iterable(issue.phases for issue in issues))
+
+ field_views = tracker_views.MakeAllFieldValueViews(
+ config, type_label_set, [], [], {}, phases=issue_phases)
+ for fv in field_views:
+ # Explicitly set all field views to not required. We do not want to force
+ # users to have to set it for issues missing required fields.
+ # See https://bugs.chromium.org/p/monorail/issues/detail?id=500 for more
+ # details.
+ fv.field_def.is_required_bool = None
+
+ if permissions.CanEditValueForFieldDef(
+ mr.auth.effective_ids, mr.perms, mr.project, fv.field_def.field_def):
+ fv.is_editable = ezt.boolean(True)
+ else:
+ fv.is_editable = ezt.boolean(False)
+
+ with mr.profiler.Phase('making issue proxies'):
+ issue_views = [
+ template_helpers.EZTItem(
+ local_id=issue.local_id, summary=issue.summary,
+ closed=ezt.boolean(issue in closed_issues))
+ for issue in issues]
+
+ num_seconds = (int(len(issue_views) * self._SECONDS_PER_UPDATE) +
+ self._SECONDS_OVERHEAD)
+
+ page_perms = self.MakePagePerms(
+ mr, None,
+ permissions.CREATE_ISSUE,
+ permissions.DELETE_ISSUE)
+
+ return {
+ 'issue_tab_mode': 'issueBulkEdit',
+ 'issues': issue_views,
+ 'local_ids_str': ','.join([str(issue.local_id) for issue in issues]),
+ 'num_issues': len(issue_views),
+ 'show_progress': ezt.boolean(num_seconds > self._SLOWNESS_THRESHOLD),
+ 'num_seconds': num_seconds,
+
+ 'initial_blocked_on': '',
+ 'initial_blocking': '',
+ 'initial_comment': '',
+ 'initial_status': '',
+ 'initial_owner': '',
+ 'initial_merge_into': '',
+ 'initial_cc': '',
+ 'initial_components': '',
+ 'labels': [],
+ 'fields': field_views,
+
+ 'restrict_to_known': ezt.boolean(config.restrict_to_known),
+ 'page_perms': page_perms,
+ 'statuses_offer_merge': config.statuses_offer_merge,
+ 'issue_phase_names': list(
+ {phase.name.lower() for phase in issue_phases}),
+ }
+
+ def ProcessFormData(self, mr, post_data):
+ # (...) -> str
+ """Process the posted issue update form.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ post_data: HTML form data from the request.
+
+ Returns:
+ String URL to redirect the user to after processing.
+ """
+ if not mr.local_id_list:
+ logging.info('missing issue local IDs, probably tampered')
+ self.response.status = httplib.BAD_REQUEST
+ return
+
+ # 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')
+ self.response.status = httplib.BAD_REQUEST # xxx should raise except
+ return
+
+ # 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):
+ logging.info('user has no permission to add issue comment')
+ self.response.status = httplib.BAD_REQUEST
+ return
+
+ if not self.CheckPerm(mr, permissions.EDIT_ISSUE):
+ logging.info('user has no permission to edit issue metadata')
+ self.response.status = httplib.BAD_REQUEST
+ return
+
+ move_to = post_data.get('move_to', '').lower()
+ if move_to and not self.CheckPerm(mr, permissions.DELETE_ISSUE):
+ logging.info('user has no permission to move issue')
+ self.response.status = httplib.BAD_REQUEST
+ return
+
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+
+ parsed = tracker_helpers.ParseIssueRequest(
+ mr.cnxn, post_data, self.services, mr.errors, mr.project_name)
+ bounce_labels = (
+ parsed.labels[:] +
+ ['-%s' % lr for lr in parsed.labels_remove])
+ bounce_fields = tracker_views.MakeBounceFieldValueViews(
+ parsed.fields.vals, parsed.fields.phase_vals, config)
+ field_helpers.ShiftEnumFieldsIntoLabels(
+ parsed.labels, parsed.labels_remove,
+ parsed.fields.vals, parsed.fields.vals_remove,
+ config)
+ issue_list = self.services.issue.GetIssuesByLocalIDs(
+ mr.cnxn, mr.project_id, mr.local_id_list)
+ issue_phases = list(
+ itertools.chain.from_iterable(issue.phases for issue in issue_list))
+ phase_ids_by_name = collections.defaultdict(set)
+ for phase in issue_phases:
+ phase_ids_by_name[phase.name.lower()].add(phase.phase_id)
+ # Note: Not all parsed phase field values will be applicable to every issue.
+ # tracker_bizobj.ApplyFieldValueChanges will take care of not adding
+ # phase field values to issues that don't contain the correct phase.
+ field_vals = field_helpers.ParseFieldValues(
+ mr.cnxn, self.services.user, parsed.fields.vals,
+ parsed.fields.phase_vals, config,
+ phase_ids_by_name=phase_ids_by_name)
+ field_vals_remove = field_helpers.ParseFieldValues(
+ mr.cnxn, self.services.user, parsed.fields.vals_remove,
+ parsed.fields.phase_vals_remove, config,
+ phase_ids_by_name=phase_ids_by_name)
+
+ field_helpers.AssertCustomFieldsEditPerms(
+ mr, config, field_vals, field_vals_remove, parsed.fields.fields_clear,
+ parsed.labels, parsed.labels_remove)
+ field_helpers.ValidateCustomFields(
+ mr.cnxn, self.services, field_vals, config, mr.project,
+ ezt_errors=mr.errors)
+
+ # Treat status '' as no change and explicit 'clear' as clearing the status.
+ status = parsed.status
+ if status == '':
+ status = None
+ if post_data.get('op_statusenter') == 'clear':
+ status = ''
+
+ reporter_id = mr.auth.user_id
+ logging.info('bulk edit request by %s', reporter_id)
+
+ if parsed.users.owner_id is None:
+ mr.errors.owner = 'Invalid owner username'
+ else:
+ valid, msg = tracker_helpers.IsValidIssueOwner(
+ mr.cnxn, mr.project, parsed.users.owner_id, self.services)
+ if not valid:
+ mr.errors.owner = msg
+
+ if (status in config.statuses_offer_merge and
+ not post_data.get('merge_into')):
+ mr.errors.merge_into_id = 'Please enter a valid issue ID'
+
+ move_to_project = None
+ if move_to:
+ if mr.project_name == move_to:
+ mr.errors.move_to = 'The issues are already in project ' + move_to
+ else:
+ move_to_project = self.services.project.GetProjectByName(
+ mr.cnxn, move_to)
+ if not move_to_project:
+ mr.errors.move_to = 'No such project: ' + move_to
+
+ # Treat owner '' as no change, and explicit 'clear' as NO_USER_SPECIFIED
+ owner_id = parsed.users.owner_id
+ if parsed.users.owner_username == '':
+ owner_id = None
+ if post_data.get('op_ownerenter') == 'clear':
+ owner_id = framework_constants.NO_USER_SPECIFIED
+
+ comp_ids = tracker_helpers.LookupComponentIDs(
+ parsed.components.paths, config, mr.errors)
+ comp_ids_remove = tracker_helpers.LookupComponentIDs(
+ parsed.components.paths_remove, config, mr.errors)
+ if post_data.get('op_componententer') == 'remove':
+ comp_ids, comp_ids_remove = comp_ids_remove, comp_ids
+
+ cc_ids, cc_ids_remove = parsed.users.cc_ids, parsed.users.cc_ids_remove
+ if post_data.get('op_memberenter') == 'remove':
+ cc_ids, cc_ids_remove = parsed.users.cc_ids_remove, parsed.users.cc_ids
+
+ issue_list_iids = {issue.issue_id for issue in issue_list}
+ if post_data.get('op_blockedonenter') == 'append':
+ if issue_list_iids.intersection(parsed.blocked_on.iids):
+ mr.errors.blocked_on = 'Cannot block an issue on itself.'
+ blocked_on_add = parsed.blocked_on.iids
+ blocked_on_remove = []
+ else:
+ blocked_on_add = []
+ blocked_on_remove = parsed.blocked_on.iids
+ if post_data.get('op_blockingenter') == 'append':
+ if issue_list_iids.intersection(parsed.blocking.iids):
+ mr.errors.blocking = 'Cannot block an issue on itself.'
+ blocking_add = parsed.blocking.iids
+ blocking_remove = []
+ else:
+ blocking_add = []
+ blocking_remove = parsed.blocking.iids
+
+ if len(parsed.comment) > tracker_constants.MAX_COMMENT_CHARS:
+ mr.errors.comment = 'Comment is too long.'
+
+ iids_actually_changed = []
+ old_owner_ids = []
+ combined_amendments = []
+ merge_into_issue = None
+ new_starrers = set()
+
+ if not mr.errors.AnyErrors():
+ # Because we will modify issues, load from DB rather than cache.
+ issue_list = self.services.issue.GetIssuesByLocalIDs(
+ mr.cnxn, mr.project_id, mr.local_id_list, use_cache=False)
+
+ # Skip any individual issues that the user is not allowed to edit.
+ editable_issues = [
+ issue for issue in issue_list
+ if permissions.CanEditIssue(
+ mr.auth.effective_ids, mr.perms, mr.project, issue)]
+
+ # Skip any restrict issues that cannot be moved
+ if move_to:
+ editable_issues = [
+ issue for issue in editable_issues
+ if not permissions.GetRestrictions(issue)]
+
+ # If 'Duplicate' status is specified ensure there are no permission issues
+ # with the issue we want to merge with.
+ if post_data.get('merge_into'):
+ for issue in editable_issues:
+ _, 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_allowed = tracker_helpers.IsMergeAllowed(
+ merge_into_issue, mr, self.services)
+ if not merge_allowed:
+ mr.errors.merge_into_id = 'Target issue %s cannot be modified' % (
+ merge_into_issue.local_id)
+ break
+
+ # Update the new_starrers set.
+ new_starrers.update(tracker_helpers.GetNewIssueStarrers(
+ mr.cnxn, self.services, [issue.issue_id],
+ merge_into_issue.issue_id))
+
+ # Proceed with amendments only if there are no reported errors.
+ if not mr.errors.AnyErrors():
+ # Sort the issues: we want them in this order so that the
+ # corresponding old_owner_id are found in the same order.
+ editable_issues.sort(key=lambda issue: issue.local_id)
+
+ iids_to_invalidate = set()
+ rules = self.services.features.GetFilterRules(
+ mr.cnxn, config.project_id)
+ predicate_asts = filterrules_helpers.ParsePredicateASTs(
+ rules, config, [])
+ for issue in editable_issues:
+ old_owner_id = tracker_bizobj.GetOwnerId(issue)
+ merge_into_iid = (
+ merge_into_issue.issue_id if merge_into_issue else None)
+
+ delta = tracker_bizobj.MakeIssueDelta(
+ status, owner_id, cc_ids, cc_ids_remove, comp_ids, comp_ids_remove,
+ parsed.labels, parsed.labels_remove, field_vals, field_vals_remove,
+ parsed.fields.fields_clear, blocked_on_add, blocked_on_remove,
+ blocking_add, blocking_remove, merge_into_iid, None)
+ amendments, _ = self.services.issue.DeltaUpdateIssue(
+ mr.cnxn, self.services, mr.auth.user_id, mr.project_id, config,
+ issue, delta, comment=parsed.comment,
+ iids_to_invalidate=iids_to_invalidate, rules=rules,
+ predicate_asts=predicate_asts)
+
+ if amendments or parsed.comment: # Avoid empty comments.
+ iids_actually_changed.append(issue.issue_id)
+ old_owner_ids.append(old_owner_id)
+ combined_amendments.extend(amendments)
+
+ self.services.issue.InvalidateIIDs(mr.cnxn, iids_to_invalidate)
+ self.services.project.UpdateRecentActivity(
+ mr.cnxn, mr.project.project_id)
+
+ # Add new_starrers and new CCs to merge_into_issue.
+ if merge_into_issue:
+ merge_into_project = self.services.project.GetProjectByName(
+ mr.cnxn, merge_into_issue.project_name)
+ tracker_helpers.AddIssueStarrers(
+ mr.cnxn, self.services, mr, merge_into_issue.issue_id,
+ merge_into_project, new_starrers)
+ # Load target issue again to get the updated star count.
+ merge_into_issue = self.services.issue.GetIssue(
+ mr.cnxn, merge_into_issue.issue_id, use_cache=False)
+ tracker_helpers.MergeCCsAndAddCommentMultipleIssues(
+ self.services, mr, editable_issues, merge_into_issue)
+
+ if move_to and editable_issues:
+ tracker_fulltext.UnindexIssues(
+ [issue.issue_id for issue in editable_issues])
+ for issue in editable_issues:
+ old_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
+ moved_back_iids = self.services.issue.MoveIssues(
+ mr.cnxn, move_to_project, [issue], self.services.user)
+ new_text_ref = 'issue %s:%s' % (issue.project_name, issue.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)
+ self.services.issue.CreateIssueComment(
+ mr.cnxn, issue, mr.auth.user_id, content, amendments=[
+ tracker_bizobj.MakeProjectAmendment(
+ move_to_project.project_name)])
+
+ send_email = 'send_email' in post_data
+
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user,
+ [owner_id], cc_ids, cc_ids_remove, old_owner_ids,
+ tracker_bizobj.UsersInvolvedInAmendments(combined_amendments))
+ if move_to and editable_issues:
+ iids_actually_changed = [
+ issue.issue_id for issue in editable_issues]
+
+ send_notifications.SendIssueBulkChangeNotification(
+ iids_actually_changed, mr.request.host,
+ old_owner_ids, parsed.comment,
+ reporter_id, combined_amendments, send_email, users_by_id)
+
+ if mr.errors.AnyErrors():
+ bounce_cc_parts = (
+ parsed.users.cc_usernames +
+ ['-%s' % ccur for ccur in parsed.users.cc_usernames_remove])
+ self.PleaseCorrect(
+ mr, initial_status=parsed.status,
+ initial_owner=parsed.users.owner_username,
+ initial_merge_into=post_data.get('merge_into', 0),
+ initial_cc=', '.join(bounce_cc_parts),
+ initial_comment=parsed.comment,
+ initial_components=parsed.components.entered_str,
+ labels=bounce_labels,
+ fields=bounce_fields)
+ return
+
+ with mr.profiler.Phase('reindexing issues'):
+ logging.info('starting reindexing')
+ start = time.time()
+ # Get the updated issues and index them
+ issue_list = self.services.issue.GetIssuesByLocalIDs(
+ mr.cnxn, mr.project_id, mr.local_id_list)
+ tracker_fulltext.IndexIssues(
+ mr.cnxn, issue_list, self.services.user, self.services.issue,
+ self.services.config)
+ logging.info('reindexing %d issues took %s sec',
+ len(issue_list), time.time() - start)
+
+ # TODO(jrobbins): These could be put into the form action attribute.
+ 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'])
+
+ # TODO(jrobbins): implement bulk=N param for a better confirmation alert.
+ return tracker_helpers.FormatIssueListURL(
+ mr, config, saved=len(mr.local_id_list), ts=int(time.time()))
diff --git a/tracker/issuedetailezt.py b/tracker/issuedetailezt.py
new file mode 100644
index 0000000..9460669
--- /dev/null
+++ b/tracker/issuedetailezt.py
@@ -0,0 +1,316 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed 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:
+ IssueDetailEzt: Show one issue in detail w/ all metadata and comments, and
+ process additional comments or metadata changes on it.
+ FlagSpamForm: Record the user's desire to report the issue as spam.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import httplib
+import json
+import logging
+import time
+import ezt
+
+import settings
+from api import converters
+from businesslogic import work_env
+from features import features_bizobj
+from features import send_notifications
+from features import hotlist_helpers
+from features import hotlist_views
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import jsonfeed
+from framework import paginate
+from framework import permissions
+from framework import servlet
+from framework import servlet_helpers
+from framework import sorting
+from framework import sql
+from framework import template_helpers
+from framework import urls
+from framework import xsrf
+from proto import user_pb2
+from proto import tracker_pb2
+from services import features_svc
+from services import tracker_fulltext
+from tracker import field_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+from google.protobuf import json_format
+
+
+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
+
+
+def _ComputeBackToListURL(mr, issue, config, hotlist, services):
+ """Construct a URL to return the user to the place that they came from."""
+ if hotlist:
+ back_to_list_url = hotlist_helpers.GetURLOfHotlist(
+ mr.cnxn, hotlist, services.user)
+ else:
+ back_to_list_url = tracker_helpers.FormatIssueListURL(
+ mr, config, cursor='%s:%d' % (issue.project_name, issue.local_id))
+
+ return back_to_list_url
+
+
+class FlipperRedirectBase(servlet.Servlet):
+
+ # pylint: disable=arguments-differ
+ # pylint: disable=unused-argument
+ def get(self, project_name=None, viewed_username=None, hotlist_id=None):
+ with work_env.WorkEnv(self.mr, self.services) as we:
+ hotlist_id = self.mr.GetIntParam('hotlist_id')
+ current_issue = we.GetIssueByLocalID(self.mr.project_id, self.mr.local_id,
+ use_cache=False)
+ hotlist = None
+ if hotlist_id:
+ try:
+ hotlist = self.services.features.GetHotlist(self.mr.cnxn, hotlist_id)
+ except features_svc.NoSuchHotlistException:
+ pass
+
+ try:
+ adj_issue = GetAdjacentIssue(
+ self.mr, we, current_issue, hotlist=hotlist,
+ next_issue=self.next_handler)
+ path = '/p/%s%s' % (adj_issue.project_name, urls.ISSUE_DETAIL)
+ url = framework_helpers.FormatURL(
+ [(name, self.mr.GetParam(name)) for
+ name in framework_helpers.RECOGNIZED_PARAMS],
+ path, id=adj_issue.local_id)
+ except exceptions.NoSuchIssueException:
+ config = we.GetProjectConfig(self.mr.project_id)
+ url = _ComputeBackToListURL(self.mr, current_issue, config,
+ hotlist, self.services)
+ self.redirect(url)
+
+
+class FlipperNext(FlipperRedirectBase):
+ next_handler = True
+
+
+class FlipperPrev(FlipperRedirectBase):
+ next_handler = False
+
+
+class FlipperList(servlet.Servlet):
+ # pylint: disable=arguments-differ
+ # pylint: disable=unused-argument
+ def get(self, project_name=None, viewed_username=None, hotlist_id=None):
+ with work_env.WorkEnv(self.mr, self.services) as we:
+ hotlist_id = self.mr.GetIntParam('hotlist_id')
+ current_issue = we.GetIssueByLocalID(self.mr.project_id, self.mr.local_id,
+ use_cache=False)
+ hotlist = None
+ if hotlist_id:
+ try:
+ hotlist = self.services.features.GetHotlist(self.mr.cnxn, hotlist_id)
+ except features_svc.NoSuchHotlistException:
+ pass
+
+ config = we.GetProjectConfig(self.mr.project_id)
+
+ if hotlist:
+ self.mr.ComputeColSpec(hotlist)
+ else:
+ self.mr.ComputeColSpec(config)
+
+ url = _ComputeBackToListURL(self.mr, current_issue, config,
+ hotlist, self.services)
+ self.redirect(url)
+
+
+class FlipperIndex(jsonfeed.JsonFeed):
+ """Return a JSON object of an issue's index in search.
+
+ This is a distinct JSON endpoint because it can be expensive to compute.
+ """
+ CHECK_SECURITY_TOKEN = False
+
+ def HandleRequest(self, mr):
+ hotlist_id = mr.GetIntParam('hotlist_id')
+ list_url = None
+ with work_env.WorkEnv(mr, self.services) as we:
+ if not _ShouldShowFlipper(mr, self.services):
+ return {}
+ issue = we.GetIssueByLocalID(mr.project_id, mr.local_id, use_cache=False)
+ hotlist = None
+
+ if hotlist_id:
+ hotlist = self.services.features.GetHotlist(mr.cnxn, hotlist_id)
+
+ if not features_bizobj.IssueIsInHotlist(hotlist, issue.issue_id):
+ raise exceptions.InvalidHotlistException()
+
+ if not permissions.CanViewHotlist(
+ mr.auth.effective_ids, mr.perms, hotlist):
+ raise permissions.PermissionException()
+
+ (prev_iid, cur_index, next_iid, total_count
+ ) = we.GetIssuePositionInHotlist(
+ issue, hotlist, mr.can, mr.sort_spec, mr.group_by_spec)
+ else:
+ (prev_iid, cur_index, next_iid, total_count
+ ) = we.FindIssuePositionInSearch(issue)
+
+ config = we.GetProjectConfig(self.mr.project_id)
+
+ if hotlist:
+ mr.ComputeColSpec(hotlist)
+ else:
+ mr.ComputeColSpec(config)
+
+ list_url = _ComputeBackToListURL(mr, issue, config, hotlist,
+ self.services)
+
+ prev_url = None
+ next_url = None
+
+ recognized_params = [(name, mr.GetParam(name)) for name in
+ framework_helpers.RECOGNIZED_PARAMS]
+ if prev_iid:
+ prev_issue = we.services.issue.GetIssue(mr.cnxn, prev_iid)
+ path = '/p/%s%s' % (prev_issue.project_name, urls.ISSUE_DETAIL)
+ prev_url = framework_helpers.FormatURL(
+ recognized_params, path, id=prev_issue.local_id)
+
+ if next_iid:
+ next_issue = we.services.issue.GetIssue(mr.cnxn, next_iid)
+ path = '/p/%s%s' % (next_issue.project_name, urls.ISSUE_DETAIL)
+ next_url = framework_helpers.FormatURL(
+ recognized_params, path, id=next_issue.local_id)
+
+ return {
+ 'prev_iid': prev_iid,
+ 'prev_url': prev_url,
+ 'cur_index': cur_index,
+ 'next_iid': next_iid,
+ 'next_url': next_url,
+ 'list_url': list_url,
+ 'total_count': total_count,
+ }
+
+
+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
+
+
+def GetAdjacentIssue(
+ mr, we, issue, hotlist=None, next_issue=False):
+ """Compute next or previous issue given params of current issue.
+
+ Args:
+ mr: MonorailRequest, including can and sorting/grouping order.
+ we: A WorkEnv instance.
+ issue: The current issue (from which to compute prev/next).
+ hotlist (optional): The current hotlist.
+ next_issue (bool): If True, return next, issue, else return previous issue.
+
+ Returns:
+ The adjacent issue.
+
+ Raises:
+ NoSuchIssueException when there is no adjacent issue in the list.
+ """
+ if hotlist:
+ (prev_iid, _cur_index, next_iid, _total_count
+ ) = we.GetIssuePositionInHotlist(
+ issue, hotlist, mr.can, mr.sort_spec, mr.group_by_spec)
+ else:
+ (prev_iid, _cur_index, next_iid, _total_count
+ ) = we.FindIssuePositionInSearch(issue)
+ iid = next_iid if next_issue else prev_iid
+ if iid is None:
+ raise exceptions.NoSuchIssueException()
+ return we.GetIssue(iid)
diff --git a/tracker/issueentry.py b/tracker/issueentry.py
new file mode 100644
index 0000000..77de114
--- /dev/null
+++ b/tracker/issueentry.py
@@ -0,0 +1,630 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Servlet that implements the entry of new issues."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import difflib
+import logging
+import string
+import time
+
+from businesslogic import work_env
+from features import hotlist_helpers
+from features import send_notifications
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import permissions
+from framework import servlet
+from framework import template_helpers
+from framework import urls
+import ezt
+from tracker import field_helpers
+from tracker import template_helpers as issue_tmpl_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from tracker import tracker_views
+from proto import tracker_pb2
+
+PLACEHOLDER_SUMMARY = 'Enter one-line summary'
+PHASES_WITH_MILESTONES = ['Beta', 'Stable', 'Stable-Exp', 'Stable-Full']
+CORP_RESTRICTION_LABEL = 'Restrict-View-Google'
+RESTRICTED_FLT_FIELDS = ['notice', 'whitepaper', 'm-approved']
+
+
+class IssueEntry(servlet.Servlet):
+ """IssueEntry shows a page with a simple form to enter a new issue."""
+
+ _PAGE_TEMPLATE = 'tracker/issue-entry-page.ezt'
+ _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
+
+ # The issue filing wizard is a separate app that posted back to Monorail's
+ # issue entry page. To make this possible for the wizard, we need to allow
+ # XHR-scoped XSRF tokens.
+ ALLOW_XHR = True
+
+ def AssertBasePermission(self, mr):
+ """Check whether the user has any permission to visit this page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ """
+ super(IssueEntry, self).AssertBasePermission(mr)
+ if not self.CheckPerm(mr, permissions.CREATE_ISSUE):
+ raise permissions.PermissionException(
+ 'User is not allowed to enter an issue')
+
+ def GatherPageData(self, mr):
+ """Build up a dictionary of data values to use when rendering the page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+
+ Returns:
+ Dict of values used by EZT for rendering the page.
+ """
+ with mr.profiler.Phase('getting config'):
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+
+ # In addition to checking perms, we adjust some default field values for
+ # project members.
+ is_member = framework_bizobj.UserIsInProject(
+ mr.project, mr.auth.effective_ids)
+ page_perms = self.MakePagePerms(
+ mr, None,
+ permissions.CREATE_ISSUE,
+ permissions.SET_STAR,
+ permissions.EDIT_ISSUE,
+ permissions.EDIT_ISSUE_SUMMARY,
+ permissions.EDIT_ISSUE_STATUS,
+ permissions.EDIT_ISSUE_OWNER,
+ permissions.EDIT_ISSUE_CC)
+
+
+ with work_env.WorkEnv(mr, self.services) as we:
+ userprefs = we.GetUserPrefs(mr.auth.user_id)
+ code_font = any(pref for pref in userprefs.prefs
+ if pref.name == 'code_font' and pref.value == 'true')
+
+ template = self._GetTemplate(mr.cnxn, config, mr.template_name, is_member)
+
+ if template.summary:
+ initial_summary = template.summary
+ initial_summary_must_be_edited = template.summary_must_be_edited
+ else:
+ initial_summary = PLACEHOLDER_SUMMARY
+ initial_summary_must_be_edited = True
+
+ if template.status:
+ initial_status = template.status
+ elif is_member:
+ initial_status = 'Accepted'
+ else:
+ initial_status = 'New' # not offering meta, only used in hidden field.
+
+ component_paths = []
+ for component_id in template.component_ids:
+ component_paths.append(
+ tracker_bizobj.FindComponentDefByID(component_id, config).path)
+ initial_components = ', '.join(component_paths)
+
+ if template.owner_id:
+ initial_owner = framework_views.MakeUserView(
+ mr.cnxn, self.services.user, template.owner_id)
+ elif template.owner_defaults_to_member and page_perms.EditIssue:
+ initial_owner = mr.auth.user_view
+ else:
+ initial_owner = None
+
+ if initial_owner:
+ initial_owner_name = initial_owner.email
+ owner_avail_state = initial_owner.avail_state
+ owner_avail_message_short = initial_owner.avail_message_short
+ else:
+ initial_owner_name = ''
+ owner_avail_state = None
+ owner_avail_message_short = None
+
+ # Check whether to allow attachments from the entry page
+ allow_attachments = tracker_helpers.IsUnderSoftAttachmentQuota(mr.project)
+
+ config_view = tracker_views.ConfigView(mr, self.services, config, template)
+ # If the user followed a link that specified the template name, make sure
+ # that it is also in the menu as the current choice.
+ # TODO(jeffcarp): Unit test this.
+ config_view.template_view.can_view = ezt.boolean(True)
+
+ # TODO(jeffcarp): Unit test this.
+ offer_templates = len(config_view.template_names) > 1
+ restrict_to_known = config.restrict_to_known
+ link_or_template_labels = mr.GetListParam('labels', template.labels)
+ labels, _derived_labels = tracker_bizobj.ExplicitAndDerivedNonMaskedLabels(
+ link_or_template_labels, [], config)
+
+ # Users with restrict_new_issues user pref automatically add R-V-G.
+ with work_env.WorkEnv(mr, self.services) as we:
+ userprefs = we.GetUserPrefs(mr.auth.user_id)
+ restrict_new_issues = any(
+ up.name == 'restrict_new_issues' and up.value == 'true'
+ for up in userprefs.prefs)
+ if restrict_new_issues:
+ if not any(lab.lower().startswith('restrict-view-') for lab in labels):
+ labels.append(CORP_RESTRICTION_LABEL)
+
+ field_user_views = tracker_views.MakeFieldUserViews(
+ mr.cnxn, template, self.services.user)
+ approval_ids = [av.approval_id for av in template.approval_values]
+ field_views = tracker_views.MakeAllFieldValueViews(
+ config, link_or_template_labels, [], template.field_values,
+ field_user_views, parent_approval_ids=approval_ids,
+ phases=template.phases)
+ # TODO(jojwang): monorail:6305, remove this hack when Edit perms for field
+ # values are implemented.
+ field_views = [view for view in field_views
+ if view.field_name.lower() not in RESTRICTED_FLT_FIELDS]
+ uneditable_fields = ezt.boolean(False)
+ for fv in field_views:
+ if permissions.CanEditValueForFieldDef(
+ mr.auth.effective_ids, mr.perms, mr.project, fv.field_def.field_def):
+ fv.is_editable = ezt.boolean(True)
+ else:
+ fv.is_editable = ezt.boolean(False)
+ uneditable_fields = ezt.boolean(True)
+
+ # TODO(jrobbins): remove "or []" after next release.
+ (prechecked_approvals, required_approval_ids,
+ phases) = issue_tmpl_helpers.GatherApprovalsPageData(
+ template.approval_values or [], template.phases, config)
+ approvals = [view for view in field_views if view.field_id in
+ approval_ids]
+
+ page_data = {
+ 'issue_tab_mode':
+ 'issueEntry',
+ 'initial_summary':
+ initial_summary,
+ 'template_summary':
+ initial_summary,
+ 'clear_summary_on_click':
+ ezt.boolean(
+ initial_summary_must_be_edited and
+ 'initial_summary' not in mr.form_overrides),
+ 'must_edit_summary':
+ ezt.boolean(initial_summary_must_be_edited),
+ 'initial_description':
+ template.content,
+ 'template_name':
+ template.name,
+ 'component_required':
+ ezt.boolean(template.component_required),
+ 'initial_status':
+ initial_status,
+ 'initial_owner':
+ initial_owner_name,
+ 'owner_avail_state':
+ owner_avail_state,
+ 'owner_avail_message_short':
+ owner_avail_message_short,
+ 'initial_components':
+ initial_components,
+ 'initial_cc':
+ '',
+ 'initial_blocked_on':
+ '',
+ 'initial_blocking':
+ '',
+ 'initial_hotlists':
+ '',
+ 'labels':
+ labels,
+ 'fields':
+ field_views,
+ 'any_errors':
+ ezt.boolean(mr.errors.AnyErrors()),
+ 'page_perms':
+ page_perms,
+ 'allow_attachments':
+ ezt.boolean(allow_attachments),
+ 'max_attach_size':
+ template_helpers.BytesKbOrMb(
+ framework_constants.MAX_POST_BODY_SIZE),
+ 'offer_templates':
+ ezt.boolean(offer_templates),
+ 'config':
+ config_view,
+ 'restrict_to_known':
+ ezt.boolean(restrict_to_known),
+ 'is_member':
+ ezt.boolean(is_member),
+ 'code_font':
+ ezt.boolean(code_font),
+ # The following are necessary for displaying phases that come with
+ # this template. These are read-only.
+ 'allow_edit':
+ ezt.boolean(False),
+ 'uneditable_fields':
+ uneditable_fields,
+ 'initial_phases':
+ phases,
+ 'approvals':
+ approvals,
+ 'prechecked_approvals':
+ prechecked_approvals,
+ 'required_approval_ids':
+ required_approval_ids,
+ # See monorail:4692 and the use of PHASES_WITH_MILESTONES
+ # in elements/flt/mr-launch-overview/mr-phase.js
+ 'issue_phase_names':
+ list(
+ {
+ phase.name.lower()
+ for phase in phases
+ if phase.name in PHASES_WITH_MILESTONES
+ }),
+ }
+
+ return page_data
+
+ 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.
+ """
+ help_data = super(IssueEntry, self).GatherHelpData(mr, page_data)
+ dismissed = []
+ if mr.auth.user_pb:
+ with work_env.WorkEnv(mr, self.services) as we:
+ userprefs = we.GetUserPrefs(mr.auth.user_id)
+ dismissed = [
+ pv.name for pv in userprefs.prefs if pv.value == 'true']
+ is_privileged_domain_user = framework_bizobj.IsPriviledgedDomainUser(
+ mr.auth.user_pb.email)
+ if (mr.auth.user_id and
+ 'privacy_click_through' not in dismissed):
+ help_data['cue'] = 'privacy_click_through'
+ elif (mr.auth.user_id and
+ 'code_of_conduct' not in dismissed):
+ help_data['cue'] = 'code_of_conduct'
+
+ help_data.update({
+ 'is_privileged_domain_user': ezt.boolean(is_privileged_domain_user),
+ })
+ return help_data
+
+ def ProcessFormData(self, mr, post_data):
+ """Process the issue entry 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.
+ """
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+
+ parsed = tracker_helpers.ParseIssueRequest(
+ mr.cnxn, post_data, self.services, mr.errors, mr.project_name)
+
+ # Updates parsed.labels and parsed.fields in place.
+ field_helpers.ShiftEnumFieldsIntoLabels(
+ parsed.labels, parsed.labels_remove, parsed.fields.vals,
+ parsed.fields.vals_remove, config)
+
+ labels = _DiscardUnusedTemplateLabelPrefixes(parsed.labels)
+
+ is_member = framework_bizobj.UserIsInProject(
+ mr.project, mr.auth.effective_ids)
+ template = self._GetTemplate(
+ mr.cnxn, config, parsed.template_name, is_member)
+
+ (approval_values,
+ phases) = issue_tmpl_helpers.FilterApprovalsAndPhases(
+ template.approval_values or [], template.phases, config)
+
+ # Issue PB with only approval_values and labels filled out, for the purpose
+ # of computing applicable fields.
+ partial_issue = tracker_pb2.Issue(
+ approval_values=approval_values, labels=labels)
+ applicable_fields = field_helpers.ListApplicableFieldDefs(
+ [partial_issue], config)
+
+ bounce_labels = parsed.labels[:]
+ bounce_fields = tracker_views.MakeBounceFieldValueViews(
+ parsed.fields.vals,
+ parsed.fields.phase_vals,
+ config,
+ applicable_fields=applicable_fields)
+
+ phase_ids_by_name = {
+ phase.name.lower(): [phase.phase_id] for phase in template.phases}
+ field_values = field_helpers.ParseFieldValues(
+ mr.cnxn, self.services.user, parsed.fields.vals,
+ parsed.fields.phase_vals, config,
+ phase_ids_by_name=phase_ids_by_name)
+
+ component_ids = tracker_helpers.LookupComponentIDs(
+ parsed.components.paths, config, mr.errors)
+
+ if not parsed.summary.strip() or parsed.summary == PLACEHOLDER_SUMMARY:
+ mr.errors.summary = 'Summary is required'
+
+ if not parsed.comment.strip():
+ mr.errors.comment = 'A description is required'
+
+ 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'
+
+ if _MatchesTemplate(parsed.comment, template):
+ mr.errors.comment = 'Template must be filled out.'
+
+ if parsed.users.owner_id is None:
+ mr.errors.owner = 'Invalid owner username'
+ else:
+ valid, msg = tracker_helpers.IsValidIssueOwner(
+ mr.cnxn, mr.project, parsed.users.owner_id, self.services)
+ if not valid:
+ mr.errors.owner = msg
+
+ if None in parsed.users.cc_ids:
+ mr.errors.cc = 'Invalid Cc username'
+
+ field_helpers.AssertCustomFieldsEditPerms(
+ mr, config, field_values, [], [], labels, [])
+ field_helpers.ApplyRestrictedDefaultValues(
+ mr, config, field_values, labels, template.field_values,
+ template.labels)
+
+ # This ValidateCustomFields call is redundant with work already done
+ # in CreateIssue. However, this instance passes in an ezt_errors object
+ # to allow showing related errors next to the fields they happen on.
+ field_helpers.ValidateCustomFields(
+ mr.cnxn,
+ self.services,
+ field_values,
+ config,
+ mr.project,
+ ezt_errors=mr.errors,
+ issue=partial_issue)
+
+ hotlist_pbs = ProcessParsedHotlistRefs(
+ mr, self.services, parsed.hotlists.hotlist_refs)
+
+ if not mr.errors.AnyErrors():
+ with work_env.WorkEnv(mr, self.services) as we:
+ try:
+ if parsed.attachments:
+ new_bytes_used = tracker_helpers.ComputeNewQuotaBytesUsed(
+ mr.project, parsed.attachments)
+ # TODO(jrobbins): Make quota be calculated and stored as
+ # part of applying the comment.
+ self.services.project.UpdateProject(
+ mr.cnxn, mr.project.project_id,
+ attachment_bytes_used=new_bytes_used)
+
+ marked_description = tracker_helpers.MarkupDescriptionOnInput(
+ parsed.comment, template.content)
+ has_star = 'star' in post_data and post_data['star'] == '1'
+
+ if approval_values:
+ _AttachDefaultApprovers(config, approval_values)
+
+ # To preserve previous behavior, do not raise filter rule errors.
+ issue, _ = we.CreateIssue(
+ mr.project_id,
+ parsed.summary,
+ parsed.status,
+ parsed.users.owner_id,
+ parsed.users.cc_ids,
+ labels,
+ field_values,
+ component_ids,
+ marked_description,
+ blocked_on=parsed.blocked_on.iids,
+ blocking=parsed.blocking.iids,
+ dangling_blocked_on=[
+ tracker_pb2.DanglingIssueRef(ext_issue_identifier=ref_string)
+ for ref_string in parsed.blocked_on.federated_ref_strings
+ ],
+ dangling_blocking=[
+ tracker_pb2.DanglingIssueRef(ext_issue_identifier=ref_string)
+ for ref_string in parsed.blocking.federated_ref_strings
+ ],
+ attachments=parsed.attachments,
+ approval_values=approval_values,
+ phases=phases,
+ raise_filter_errors=False)
+
+ if has_star:
+ we.StarIssue(issue, True)
+
+ if hotlist_pbs:
+ hotlist_ids = {hotlist.hotlist_id for hotlist in hotlist_pbs}
+ issue_tuple = (issue.issue_id, mr.auth.user_id, int(time.time()),
+ '')
+ self.services.features.AddIssueToHotlists(
+ mr.cnxn, hotlist_ids, issue_tuple, self.services.issue,
+ self.services.chart)
+
+ except exceptions.OverAttachmentQuota:
+ mr.errors.attachments = 'Project attachment quota exceeded.'
+ except exceptions.InputException as e:
+ if 'Undefined or deprecated component with id' in e.message:
+ mr.errors.components = 'Undefined or deprecated component'
+
+ mr.template_name = parsed.template_name
+ 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, template_name=parsed.template_name,
+ initial_blocked_on=parsed.blocked_on.entered_str,
+ initial_blocking=parsed.blocking.entered_str,
+ initial_hotlists=parsed.hotlists.entered_str,
+ component_required=ezt.boolean(template.component_required))
+ return
+
+ # format a redirect url
+ return framework_helpers.FormatAbsoluteURL(
+ mr, urls.ISSUE_DETAIL, id=issue.local_id)
+
+ def _GetTemplate(self, cnxn, config, template_name, is_member):
+ """Tries to fetch template by name and implements default template logic
+ if not found."""
+ template = None
+ if template_name:
+ template_name = template_name.replace('+', ' ')
+ template = self.services.template.GetTemplateByName(cnxn,
+ template_name, config.project_id)
+
+ if not template:
+ if is_member:
+ template_id = config.default_template_for_developers
+ else:
+ template_id = config.default_template_for_users
+ template = self.services.template.GetTemplateById(cnxn, template_id)
+ # If the default templates were deleted, load all and pick the first one.
+ if not template:
+ templates = self.services.template.GetProjectTemplates(cnxn,
+ config.project_id)
+ assert len(templates) > 0, 'Project has no templates!'
+ template = templates[0]
+
+ return template
+
+
+def _AttachDefaultApprovers(config, approval_values):
+ approval_defs_by_id = {ad.approval_id: ad for ad in config.approval_defs}
+ for av in approval_values:
+ ad = approval_defs_by_id.get(av.approval_id)
+ if ad:
+ av.approver_ids = ad.approver_ids[:]
+ else:
+ logging.info('ApprovalDef with approval_id %r could not be found',
+ av.approval_id)
+
+
+def _MatchesTemplate(content, template):
+ content = content.strip(string.whitespace)
+ template_content = template.content.strip(string.whitespace)
+ diff = difflib.unified_diff(content.splitlines(),
+ template_content.splitlines())
+ return len('\n'.join(diff)) == 0
+
+
+def _DiscardUnusedTemplateLabelPrefixes(labels):
+ """Drop any labels that end in '-?'.
+
+ Args:
+ labels: a list of label strings.
+
+ Returns:
+ A list of the same labels, but without any that end with '-?'.
+ Those label prefixes in the new issue templates are intended to
+ prompt the user to enter some label with that prefix, but if
+ nothing is entered there, we do not store anything.
+ """
+ return [lab for lab in labels
+ if not lab.endswith('-?')]
+
+
+def ProcessParsedHotlistRefs(mr, services, parsed_hotlist_refs):
+ """Process a list of ParsedHotlistRefs, returning referenced hotlists.
+
+ This function validates the given ParsedHotlistRefs using four checks; if all
+ of them succeed, then it returns the corresponding hotlist protobuf objects.
+ If any of them fail, it sets the appropriate error string in mr.errors, and
+ returns an empty list.
+
+ Args:
+ mr: the MonorailRequest object
+ services: the service manager
+ parsed_hotlist_refs: a list of ParsedHotlistRef objects
+
+ Returns:
+ on valid input, a list of hotlist protobuf objects
+ if a check fails (and the input is thus considered invalid), an empty list
+
+ Side-effects:
+ if any of the checks fails, set mr.errors.hotlists to a descriptive error
+ """
+ # Pre-processing; common pieces used by functions later.
+ user_hotlist_pbs = services.features.GetHotlistsByUserID(
+ mr.cnxn, mr.auth.user_id)
+ user_hotlist_owners_ids = {hotlist.owner_ids[0]
+ for hotlist in user_hotlist_pbs}
+ user_hotlist_owners_to_emails = services.user.LookupUserEmails(
+ mr.cnxn, user_hotlist_owners_ids)
+ user_hotlist_emails_to_owners = {v: k
+ for k, v in user_hotlist_owners_to_emails.items()}
+ user_hotlist_refs_to_pbs = {
+ hotlist_helpers.HotlistRef(hotlist.owner_ids[0], hotlist.name): hotlist
+ for hotlist in user_hotlist_pbs }
+ short_refs = list()
+ full_refs = list()
+ for parsed_ref in parsed_hotlist_refs:
+ if parsed_ref.user_email is None:
+ short_refs.append(parsed_ref)
+ else:
+ full_refs.append(parsed_ref)
+
+ invalid_names = hotlist_helpers.InvalidParsedHotlistRefsNames(
+ parsed_hotlist_refs, user_hotlist_pbs)
+ if invalid_names:
+ mr.errors.hotlists = (
+ 'You have no hotlist(s) named: %s' % ', '.join(invalid_names))
+ return []
+
+ ambiguous_names = hotlist_helpers.AmbiguousShortrefHotlistNames(
+ short_refs, user_hotlist_pbs)
+ if ambiguous_names:
+ mr.errors.hotlists = (
+ 'Ambiguous hotlist(s) specified: %s' % ', '.join(ambiguous_names))
+ return []
+
+ # At this point, all refs' named hotlists are guaranteed to exist, and
+ # short refs are guaranteed to be unambiguous;
+ # therefore, short refs are also valid.
+ short_refs_hotlist_names = {sref.hotlist_name for sref in short_refs}
+ shortref_valid_pbs = [hotlist for hotlist in user_hotlist_pbs
+ if hotlist.name in short_refs_hotlist_names]
+
+ invalid_emails = hotlist_helpers.InvalidParsedHotlistRefsEmails(
+ full_refs, user_hotlist_emails_to_owners)
+ if invalid_emails:
+ mr.errors.hotlists = (
+ 'You have no hotlist(s) owned by: %s' % ', '.join(invalid_emails))
+ return []
+
+ fullref_valid_pbs, invalid_refs = (
+ hotlist_helpers.GetHotlistsOfParsedHotlistFullRefs(
+ full_refs, user_hotlist_emails_to_owners, user_hotlist_refs_to_pbs))
+ if invalid_refs:
+ invalid_refs_readable = [':'.join(parsed_ref)
+ for parsed_ref in invalid_refs]
+ mr.errors.hotlists = (
+ 'Not in your hotlist(s): %s' % ', '.join(invalid_refs_readable))
+ return []
+
+ hotlist_pbs = shortref_valid_pbs + fullref_valid_pbs
+
+ return hotlist_pbs
diff --git a/tracker/issueentryafterlogin.py b/tracker/issueentryafterlogin.py
new file mode 100644
index 0000000..d25a7c1
--- /dev/null
+++ b/tracker/issueentryafterlogin.py
@@ -0,0 +1,32 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Redirect to /issues/entry or an external URL (like the wizard).
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+from framework import servlet
+from framework import servlet_helpers
+
+
+class IssueEntryAfterLogin(servlet.Servlet):
+ """Redirect after clicking "New issue" and logging in."""
+
+ # Note: This servlet does not use an HTML template.
+
+ def GatherPageData(self, mr):
+ """Build up a dictionary of data values to use when rendering the page."""
+ if not mr.auth.user_id:
+ self.abort(400, 'Only signed-in users should reach this URL.')
+
+ with mr.profiler.Phase('getting config'):
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ entry_page_url = servlet_helpers.ComputeIssueEntryURL(mr, config)
+ logging.info('Redirecting to %r', entry_page_url)
+ self.redirect(entry_page_url, abort=True)
diff --git a/tracker/issueexport.py b/tracker/issueexport.py
new file mode 100644
index 0000000..a457a17
--- /dev/null
+++ b/tracker/issueexport.py
@@ -0,0 +1,279 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Servlet to export a range of issues in JSON format.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from businesslogic import work_env
+from features import savedqueries_helpers
+from framework import permissions
+from framework import jsonfeed
+from framework import servlet
+from tracker import tracker_bizobj
+
+
+class IssueExport(servlet.Servlet):
+ """IssueExportControls let's an admin choose how to export issues."""
+
+ _PAGE_TEMPLATE = 'tracker/issue-export-page.ezt'
+ _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
+
+ def AssertBasePermission(self, mr):
+ """Make sure that the logged in user has permission to view this page."""
+ super(IssueExport, self).AssertBasePermission(mr)
+ if not mr.auth.user_pb.is_site_admin:
+ raise permissions.PermissionException(
+ 'Only site admins may export issues')
+
+ def GatherPageData(self, mr):
+ """Build up a dictionary of data values to use when rendering the page."""
+
+ canned_query_views = []
+ if mr.project_id:
+ with mr.profiler.Phase('getting canned queries'):
+ canned_queries = self.services.features.GetCannedQueriesByProjectID(
+ mr.cnxn, mr.project_id)
+ canned_query_views = [
+ savedqueries_helpers.SavedQueryView(sq, idx + 1, None, None)
+ for idx, sq in enumerate(canned_queries)
+ ]
+
+ saved_query_views = []
+ if mr.auth.user_id and self.services.features:
+ with mr.profiler.Phase('getting saved queries'):
+ saved_queries = self.services.features.GetSavedQueriesByUserID(
+ mr.cnxn, mr.me_user_id)
+ saved_query_views = [
+ savedqueries_helpers.SavedQueryView(sq, idx + 1, None, None)
+ for idx, sq in enumerate(saved_queries)
+ if
+ (mr.project_id in sq.executes_in_project_ids or not mr.project_id)
+ ]
+
+ return {
+ 'issue_tab_mode': None,
+ 'initial_start': mr.start,
+ 'initial_num': mr.num,
+ 'page_perms': self.MakePagePerms(mr, None, permissions.CREATE_ISSUE),
+ 'canned_queries': canned_query_views,
+ 'saved_queries': saved_query_views,
+ }
+
+
+class IssueExportJSON(jsonfeed.JsonFeed):
+ """IssueExport shows a range of issues in JSON format."""
+
+ # Pretty-print the JSON output.
+ JSON_INDENT = 4
+
+ def AssertBasePermission(self, mr):
+ """Make sure that the logged in user has permission to view this page."""
+ super(IssueExportJSON, self).AssertBasePermission(mr)
+ if not mr.auth.user_pb.is_site_admin:
+ raise permissions.PermissionException(
+ 'Only site admins may export 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.
+ """
+ if mr.query or mr.can != 1:
+ with work_env.WorkEnv(mr, self.services) as we:
+ pipeline = we.ListIssues(
+ mr.query, [mr.project.project_name], mr.auth.user_id, mr.num,
+ mr.start, mr.can, mr.group_by_spec, mr.sort_spec, False)
+ issues = pipeline.allowed_results
+ # no user query and mr.can == 1 (we want all issues)
+ elif not mr.start and not mr.num:
+ issues = self.services.issue.GetAllIssuesInProject(
+ mr.cnxn, mr.project.project_id)
+ else:
+ local_id_range = list(range(mr.start, mr.start + mr.num))
+ issues = self.services.issue.GetIssuesByLocalIDs(
+ mr.cnxn, mr.project.project_id, local_id_range)
+
+ user_id_set = tracker_bizobj.UsersInvolvedInIssues(issues)
+
+ comments_dict = self.services.issue.GetCommentsForIssues(
+ mr.cnxn, [issue.issue_id for issue in issues])
+ for comment_list in comments_dict.values():
+ user_id_set.update(
+ tracker_bizobj.UsersInvolvedInCommentList(comment_list))
+
+ starrers_dict = self.services.issue_star.LookupItemsStarrers(
+ mr.cnxn, [issue.issue_id for issue in issues])
+ for starrer_id_list in starrers_dict.values():
+ user_id_set.update(starrer_id_list)
+
+ # The value 0 indicates "no user", e.g., that an issue has no owner.
+ # We don't need to create a User row to represent that.
+ user_id_set.discard(0)
+ email_dict = self.services.user.LookupUserEmails(
+ mr.cnxn, user_id_set, ignore_missed=True)
+
+ issues_json = [
+ self._MakeIssueJSON(
+ mr, issue, email_dict,
+ comments_dict.get(issue.issue_id, []),
+ starrers_dict.get(issue.issue_id, []))
+ for issue in issues if not issue.deleted]
+
+ json_data = {
+ 'metadata': {
+ 'version': 1,
+ 'when': int(time.time()),
+ 'who': mr.auth.email,
+ 'project': mr.project_name,
+ 'start': mr.start,
+ 'num': mr.num,
+ },
+ 'issues': issues_json,
+ # This list could be derived from the 'issues', but we provide it for
+ # ease of processing.
+ 'emails': list(email_dict.values()),
+ }
+ return json_data
+
+ def _MakeAmendmentJSON(self, amendment, email_dict):
+ amendment_json = {
+ 'field': amendment.field.name,
+ }
+ if amendment.custom_field_name:
+ amendment_json.update({'custom_field_name': amendment.custom_field_name})
+ if amendment.newvalue:
+ amendment_json.update({'new_value': amendment.newvalue})
+ if amendment.added_user_ids:
+ amendment_json.update(
+ {'added_emails': [email_dict.get(user_id)
+ for user_id in amendment.added_user_ids]})
+ if amendment.removed_user_ids:
+ amendment_json.update(
+ {'removed_emails': [email_dict.get(user_id)
+ for user_id in amendment.removed_user_ids]})
+ return amendment_json
+
+ def _MakeAttachmentJSON(self, attachment):
+ if attachment.deleted:
+ return None
+ attachment_json = {
+ 'name': attachment.filename,
+ 'size': attachment.filesize,
+ 'mimetype': attachment.mimetype,
+ 'gcs_object_id': attachment.gcs_object_id,
+ }
+ return attachment_json
+
+ def _MakeCommentJSON(self, comment, email_dict):
+ if comment.deleted_by:
+ return None
+ amendments = [self._MakeAmendmentJSON(a, email_dict)
+ for a in comment.amendments]
+ attachments = [self._MakeAttachmentJSON(a)
+ for a in comment.attachments]
+ comment_json = {
+ 'timestamp': comment.timestamp,
+ 'commenter': email_dict.get(comment.user_id),
+ 'content': comment.content,
+ 'amendments': [a for a in amendments if a],
+ 'attachments': [a for a in attachments if a],
+ 'description_num': comment.description_num
+ }
+ return comment_json
+
+ def _MakePhaseJSON(self, phase):
+ return {'id': phase.phase_id, 'name': phase.name, 'rank': phase.rank}
+
+ def _MakeFieldValueJSON(self, field, fd_dict, email_dict, phase_dict):
+ fd = fd_dict.get(field.field_id)
+ field_value_json = {
+ 'field': fd.field_name,
+ 'phase': phase_dict.get(field.phase_id),
+ }
+ approval_fd = fd_dict.get(fd.approval_id)
+ if approval_fd:
+ field_value_json['approval'] = approval_fd.field_name
+
+ if field.int_value:
+ field_value_json['int_value'] = field.int_value
+ if field.str_value:
+ field_value_json['str_value'] = field.str_value
+ if field.user_id:
+ field_value_json['user_value'] = email_dict.get(field.user_id)
+ if field.date_value:
+ field_value_json['date_value'] = field.date_value
+ return field_value_json
+
+ def _MakeApprovalValueJSON(
+ self, approval_value, fd_dict, email_dict, phase_dict):
+ av_json = {
+ 'approval': fd_dict.get(approval_value.approval_id).field_name,
+ 'status': approval_value.status.name,
+ 'setter': email_dict.get(approval_value.setter_id),
+ 'set_on': approval_value.set_on,
+ 'approvers': [email_dict.get(approver_id) for
+ approver_id in approval_value.approver_ids],
+ 'phase': phase_dict.get(approval_value.phase_id),
+ }
+ return av_json
+
+ def _MakeIssueJSON(
+ self, mr, issue, email_dict, comment_list, starrer_id_list):
+ """Return a dict of info about the issue and its comments."""
+ descriptions = [c for c in comment_list if c.is_description]
+ for i, d in enumerate(descriptions):
+ d.description_num = str(i+1)
+ comments = [self._MakeCommentJSON(c, email_dict) for c in comment_list]
+ phase_dict = {phase.phase_id: phase.name for phase in issue.phases}
+ config = self.services.config.GetProjectConfig(
+ mr.cnxn, mr.project.project_id)
+ fd_dict = {fd.field_id: fd for fd in config.field_defs}
+ issue_json = {
+ 'local_id': issue.local_id,
+ 'reporter': email_dict.get(issue.reporter_id),
+ 'summary': issue.summary,
+ 'owner': email_dict.get(issue.owner_id),
+ 'status': issue.status,
+ 'cc': [email_dict[cc_id] for cc_id in issue.cc_ids],
+ 'labels': issue.labels,
+ 'phases': [self._MakePhaseJSON(phase) for phase in issue.phases],
+ 'fields': [
+ self._MakeFieldValueJSON(field, fd_dict, email_dict, phase_dict)
+ for field in issue.field_values],
+ 'approvals': [self._MakeApprovalValueJSON(
+ approval, fd_dict, email_dict, phase_dict)
+ for approval in issue.approval_values],
+ 'starrers': [email_dict[starrer] for starrer in starrer_id_list],
+ 'comments': [c for c in comments if c],
+ 'opened': issue.opened_timestamp,
+ 'modified': issue.modified_timestamp,
+ 'closed': issue.closed_timestamp,
+ }
+ # TODO(http://crbug.com/monorail/7217): Export cross-project references.
+ if issue.blocked_on_iids:
+ issue_json['blocked_on'] = [i.local_id for i in
+ self.services.issue.GetIssues(mr.cnxn, issue.blocked_on_iids)
+ if i.project_id == mr.project.project_id]
+ if issue.blocking_iids:
+ issue_json['blocking'] = [i.local_id for i in
+ self.services.issue.GetIssues(mr.cnxn, issue.blocking_iids)
+ if i.project_id == mr.project.project_id]
+ if issue.merged_into:
+ merge = self.services.issue.GetIssue(mr.cnxn, issue.merged_into)
+ if merge.project_id == mr.project.project_id:
+ issue_json['merged_into'] = merge.local_id
+ return issue_json
diff --git a/tracker/issueimport.py b/tracker/issueimport.py
new file mode 100644
index 0000000..1e0289b
--- /dev/null
+++ b/tracker/issueimport.py
@@ -0,0 +1,310 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Servlet to import a file of issues in JSON format.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import json
+import logging
+import time
+
+import ezt
+
+from features import filterrules_helpers
+from framework import framework_helpers
+from framework import jsonfeed
+from framework import permissions
+from framework import servlet
+from framework import urls
+from proto import tracker_pb2
+
+
+ParserState = collections.namedtuple(
+ 'ParserState',
+ 'user_id_dict, nonexist_emails, issue_list, comments_dict, starrers_dict, '
+ 'relations_dict')
+
+
+class IssueImport(servlet.Servlet):
+ """IssueImport loads a file of issues in JSON format."""
+
+ _PAGE_TEMPLATE = 'tracker/issue-import-page.ezt'
+ _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
+
+ def AssertBasePermission(self, mr):
+ """Make sure that the logged in user has permission to view this page."""
+ super(IssueImport, self).AssertBasePermission(mr)
+ if not mr.auth.user_pb.is_site_admin:
+ raise permissions.PermissionException(
+ 'Only site admins may import issues')
+
+ def GatherPageData(self, mr):
+ """Build up a dictionary of data values to use when rendering the page."""
+ return {
+ 'issue_tab_mode': None,
+ 'page_perms': self.MakePagePerms(mr, None, permissions.CREATE_ISSUE),
+ 'import_errors': [],
+ }
+
+ def ProcessFormData(self, mr, post_data):
+ """Process the issue entry 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.
+ """
+ import_errors = []
+ json_data = None
+
+ pre_check_only = 'pre_check_only' in post_data
+
+ uploaded_file = post_data.get('jsonfile')
+ if uploaded_file is None:
+ import_errors.append('No file uploaded')
+ else:
+ try:
+ json_str = uploaded_file.value
+ if json_str.startswith(jsonfeed.XSSI_PREFIX):
+ json_str = json_str[len(jsonfeed.XSSI_PREFIX):]
+ json_data = json.loads(json_str)
+ except ValueError:
+ import_errors.append('error parsing JSON in file')
+
+ if uploaded_file and not json_data:
+ import_errors.append('JSON file was empty')
+
+ # Note that the project must already exist in order to even reach
+ # this servlet because it is hosted in the context of a project.
+ if json_data and mr.project_name != json_data['metadata']['project']:
+ import_errors.append(
+ 'Project name does not match. '
+ 'Edit the file if you want to import into this project anyway.')
+
+ if import_errors:
+ return self.PleaseCorrect(mr, import_errors=import_errors)
+
+ event_log = [] # We accumulate a list of messages to display to the user.
+
+ try:
+ # First we parse the JSON into objects, but we don't have DB IDs yet.
+ state = self._ParseObjects(mr.cnxn, mr.project_id, json_data, event_log)
+ # If that worked, go ahead and start saving the data to the DB.
+ if not pre_check_only:
+ self._SaveObjects(mr.cnxn, mr.project_id, state, event_log)
+ except JSONImportError:
+ # just report it to the user by displaying event_log
+ event_log.append('Aborted import processing')
+
+ # This is a little bit of a hack because it always uses the form validation
+ # error message display logic to show the results of this import run,
+ # which may include errors or not.
+ return self.PleaseCorrect(mr, import_errors=event_log)
+
+ def _ParseObjects(self, cnxn, project_id, json_data, event_log):
+ """Examine JSON data and return a parser state for further processing."""
+ # Decide which users need to be created.
+ needed_emails = json_data['emails']
+ user_id_dict = self.services.user.LookupExistingUserIDs(cnxn, needed_emails)
+ nonexist_emails = [email for email in needed_emails
+ if email not in user_id_dict]
+
+ event_log.append('Need to create %d users: %r' %
+ (len(nonexist_emails), nonexist_emails))
+ user_id_dict.update({
+ email.lower(): framework_helpers.MurmurHash3_x86_32(email.lower())
+ for email in nonexist_emails})
+
+ num_comments = 0
+ num_stars = 0
+ issue_list = []
+ comments_dict = collections.defaultdict(list)
+ starrers_dict = collections.defaultdict(list)
+ relations_dict = collections.defaultdict(list)
+ for issue_json in json_data.get('issues', []):
+ issue, comment_list, starrer_list, relation_list = self._ParseIssue(
+ cnxn, project_id, user_id_dict, issue_json, event_log)
+ issue_list.append(issue)
+ comments_dict[issue.local_id] = comment_list
+ starrers_dict[issue.local_id] = starrer_list
+ relations_dict[issue.local_id] = relation_list
+ num_comments += len(comment_list)
+ num_stars += len(starrer_list)
+
+ event_log.append(
+ 'Found info for %d issues: %r' %
+ (len(issue_list), sorted([issue.local_id for issue in issue_list])))
+
+ event_log.append(
+ 'Found %d total comments for %d issues' %
+ (num_comments, len(comments_dict)))
+
+ event_log.append(
+ 'Found %d total stars for %d issues' %
+ (num_stars, len(starrers_dict)))
+
+ event_log.append(
+ 'Found %d total relationships.' %
+ sum((len(dsts) for dsts in relations_dict.values())))
+
+ event_log.append('Parsing phase finished OK')
+ return ParserState(
+ user_id_dict, nonexist_emails, issue_list,
+ comments_dict, starrers_dict, relations_dict)
+
+ def _ParseIssue(self, cnxn, project_id, user_id_dict, issue_json, event_log):
+ issue = tracker_pb2.Issue(
+ project_id=project_id,
+ local_id=issue_json['local_id'],
+ reporter_id=user_id_dict[issue_json['reporter']],
+ summary=issue_json['summary'],
+ opened_timestamp=issue_json['opened'],
+ modified_timestamp=issue_json['modified'],
+ cc_ids=[user_id_dict[cc_email]
+ for cc_email in issue_json.get('cc', [])
+ if cc_email in user_id_dict],
+ status=issue_json.get('status', ''),
+ labels=issue_json.get('labels', []),
+ field_values=[self._ParseFieldValue(cnxn, project_id, user_id_dict, field)
+ for field in issue_json.get('fields', [])])
+ if issue_json.get('owner'):
+ issue.owner_id = user_id_dict[issue_json['owner']]
+ if issue_json.get('closed'):
+ issue.closed_timestamp = issue_json['closed']
+ comments = [self._ParseComment(
+ project_id, user_id_dict, comment_json, event_log)
+ for comment_json in issue_json.get('comments', [])]
+
+ starrers = [user_id_dict[starrer] for starrer in issue_json['starrers']]
+
+ relations = []
+ relations.extend(
+ [(i, 'blockedon') for i in issue_json.get('blocked_on', [])])
+ relations.extend(
+ [(i, 'blocking') for i in issue_json.get('blocking', [])])
+ if 'merged_into' in issue_json:
+ relations.append((issue_json['merged_into'], 'mergedinto'))
+
+ return issue, comments, starrers, relations
+
+ def _ParseFieldValue(self, cnxn, project_id, user_id_dict, field_json):
+ field = tracker_pb2.FieldValue(
+ field_id=self.services.config.LookupFieldID(cnxn, project_id,
+ field_json['field']))
+ if 'int_value' in field_json:
+ field.int_value = field_json['int_value']
+ if 'str_value' in field_json:
+ field.str_value = field_json['str_value']
+ if 'user_value' in field_json:
+ field.user_value = user_id_dict.get(field_json['user_value'])
+
+ return field
+
+ def _ParseComment(self, project_id, user_id_dict, comment_json, event_log):
+ comment = tracker_pb2.IssueComment(
+ # Note: issue_id is filled in after the issue is saved.
+ project_id=project_id,
+ timestamp=comment_json['timestamp'],
+ user_id=user_id_dict[comment_json['commenter']],
+ content=comment_json.get('content'))
+
+ for amendment in comment_json['amendments']:
+ comment.amendments.append(
+ self._ParseAmendment(amendment, user_id_dict, event_log))
+
+ for attachment in comment_json['attachments']:
+ comment.attachments.append(
+ self._ParseAttachment(attachment, event_log))
+
+ if comment_json['description_num']:
+ comment.is_description = True
+
+ return comment
+
+ def _ParseAmendment(self, amendment_json, user_id_dict, _event_log):
+ amendment = tracker_pb2.Amendment(
+ field=tracker_pb2.FieldID(amendment_json['field']))
+
+ if 'new_value' in amendment_json:
+ amendment.newvalue = amendment_json['new_value']
+ if 'custom_field_name' in amendment_json:
+ amendment.custom_field_name = amendment_json['custom_field_name']
+ if 'added_users' in amendment_json:
+ amendment.added_user_ids.extend(
+ [user_id_dict[email] for email in amendment_json['added_users']])
+ if 'removed_users' in amendment_json:
+ amendment.removed_user_ids.extend(
+ [user_id_dict[email] for email in amendment_json['removed_users']])
+
+ return amendment
+
+ def _ParseAttachment(self, attachment_json, _event_log):
+ attachment = tracker_pb2.Attachment(
+ filename=attachment_json['name'],
+ filesize=attachment_json['size'],
+ mimetype=attachment_json['mimetype'],
+ gcs_object_id=attachment_json['gcs_object_id']
+ )
+ return attachment
+
+ def _SaveObjects(self, cnxn, project_id, state, event_log):
+ """Examine JSON data and create users, issues, and comments."""
+
+ created_user_ids = self.services.user.LookupUserIDs(
+ cnxn, state.nonexist_emails, autocreate=True)
+ for created_email, created_id in created_user_ids.items():
+ if created_id != state.user_id_dict[created_email]:
+ event_log.append('Mismatched user_id for %r' % created_email)
+ raise JSONImportError()
+ event_log.append('Created %d users' % len(state.nonexist_emails))
+
+ total_comments = 0
+ total_stars = 0
+ config = self.services.config.GetProjectConfig(cnxn, project_id)
+ for issue in state.issue_list:
+ # TODO(jrobbins): renumber issues if there is a local_id conflict.
+ if issue.local_id not in state.starrers_dict:
+ # Issues with stars will have filter rules applied in SetStar().
+ filterrules_helpers.ApplyFilterRules(
+ cnxn, self.services, issue, config)
+ issue_id = self.services.issue.InsertIssue(cnxn, issue)
+ for comment in state.comments_dict[issue.local_id]:
+ total_comments += 1
+ comment.issue_id = issue_id
+ self.services.issue.InsertComment(cnxn, comment)
+ self.services.issue_star.SetStarsBatch(
+ cnxn, self.services, config, issue_id,
+ state.starrers_dict[issue.local_id], True)
+ total_stars += len(state.starrers_dict[issue.local_id])
+
+ event_log.append('Created %d issues' % len(state.issue_list))
+ event_log.append('Created %d comments for %d issues' % (
+ total_comments, len(state.comments_dict)))
+ event_log.append('Set %d stars on %d issues' % (
+ total_stars, len(state.starrers_dict)))
+
+ global_relations_dict = collections.defaultdict(list)
+ for issue, rels in state.relations_dict.items():
+ src_iid = self.services.issue.GetIssueByLocalID(
+ cnxn, project_id, issue).issue_id
+ dst_iids = [i.issue_id for i in self.services.issue.GetIssuesByLocalIDs(
+ cnxn, project_id, [rel[0] for rel in rels])]
+ kinds = [rel[1] for rel in rels]
+ global_relations_dict[src_iid] = list(zip(dst_iids, kinds))
+ self.services.issue.RelateIssues(cnxn, global_relations_dict)
+
+ self.services.issue.SetUsedLocalID(cnxn, project_id)
+ event_log.append('Finished import')
+
+
+class JSONImportError(Exception):
+ """Exception to raise if imported JSON is invalid."""
+ pass
diff --git a/tracker/issueoriginal.py b/tracker/issueoriginal.py
new file mode 100644
index 0000000..55a494f
--- /dev/null
+++ b/tracker/issueoriginal.py
@@ -0,0 +1,98 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Servlet to show the original email that caused an issue comment.
+
+The text of the body the email is shown in an HTML page with <pre>.
+All the text is automatically escaped by EZT to make it safe to
+include in an HTML page.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import ezt
+
+from businesslogic import work_env
+from framework import filecontent
+from framework import permissions
+from framework import servlet
+from services import issue_svc
+
+
+class IssueOriginal(servlet.Servlet):
+ """IssueOriginal shows an inbound email that caused an issue comment."""
+
+ _PAGE_TEMPLATE = 'tracker/issue-original-page.ezt'
+
+ def AssertBasePermission(self, mr):
+ """Make sure that the logged in user has permission to view this page."""
+ super(IssueOriginal, self).AssertBasePermission(mr)
+ issue, comment = self._GetIssueAndComment(mr)
+
+ # TODO(jrobbins): take granted perms into account here.
+ if issue and not permissions.CanViewIssue(
+ mr.auth.effective_ids, mr.perms, mr.project, issue,
+ allow_viewing_deleted=True):
+ raise permissions.PermissionException(
+ 'User is not allowed to view this issue')
+
+ can_view_inbound_message = self.CheckPerm(
+ mr, permissions.VIEW_INBOUND_MESSAGES, art=issue)
+ issue_perms = permissions.UpdateIssuePermissions(
+ mr.perms, mr.project, issue, mr.auth.effective_ids)
+ commenter = self.services.user.GetUser(mr.cnxn, comment.user_id)
+ can_view_comment = permissions.CanViewComment(
+ comment, commenter, mr.auth.user_id, issue_perms)
+ if not can_view_inbound_message or not can_view_comment:
+ raise permissions.PermissionException(
+ 'Only project members may view original email text')
+
+ 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.
+ """
+ issue, comment = self._GetIssueAndComment(mr)
+ message_body_unicode, is_binary, _is_long = (
+ filecontent.DecodeFileContents(comment.inbound_message))
+
+ # Take out the iso8859-1 non-breaking-space characters that gmail
+ # inserts between consecutive spaces when quoting text in a reply.
+ # You can see this in gmail by sending a plain text reply to a
+ # message that had multiple spaces on some line, then use the
+ # "Show original" menu item to view your reply, you will see "=A0".
+ #message_body_unicode = message_body_unicode.replace(u'\xa0', u' ')
+
+ page_data = {
+ 'local_id': issue.local_id,
+ 'seq': comment.sequence,
+ 'is_binary': ezt.boolean(is_binary),
+ 'message_body': message_body_unicode,
+ }
+
+ return page_data
+
+ def _GetIssueAndComment(self, mr):
+ """Wait on retriving the specified issue and issue comment."""
+ if mr.seq is None:
+ self.abort(404, 'comment not specified')
+
+ with work_env.WorkEnv(mr, self.services) as we:
+ issue = self.services.issue.GetIssueByLocalID(
+ mr.cnxn, mr.project_id, mr.local_id)
+ comments = we.ListIssueComments(issue)
+
+ try:
+ comment = comments[mr.seq]
+ except IndexError:
+ self.abort(404, 'comment not found')
+
+ return issue, comment
diff --git a/tracker/issuereindex.py b/tracker/issuereindex.py
new file mode 100644
index 0000000..de5d2f0
--- /dev/null
+++ b/tracker/issuereindex.py
@@ -0,0 +1,87 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed 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 an admin utility to re-index issues in bulk."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import urllib
+
+import settings
+from framework import permissions
+from framework import servlet
+from framework import urls
+from services import tracker_fulltext
+
+
+class IssueReindex(servlet.Servlet):
+ """IssueReindex shows a form to request that issues be indexed."""
+
+ _PAGE_TEMPLATE = 'tracker/issue-reindex-page.ezt'
+ _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
+
+ def AssertBasePermission(self, mr):
+ """Check whether the user has any permission to visit this page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ """
+ super(IssueReindex, self).AssertBasePermission(mr)
+ if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+ raise permissions.PermissionException(
+ 'You are not allowed to administer this project')
+
+ 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.
+ """
+ return {
+ # start and num are already passed to the template.
+ 'issue_tab_mode': None,
+ 'auto_submit': mr.auto_submit,
+ 'page_perms': self.MakePagePerms(mr, None, permissions.CREATE_ISSUE),
+ }
+
+ def ProcessFormData(self, mr, post_data):
+ """Process a posted issue reindex form.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ post_data: HTML form data from the request.
+
+ Returns:
+ String URL to redirect the user to after processing. The URL will contain
+ a new start that is auto-incremented using the specified num value.
+ """
+ start = max(0, int(post_data['start']))
+ num = max(0, min(settings.max_artifact_search_results_per_page,
+ int(post_data['num'])))
+
+ issues = self.services.issue.GetIssuesByLocalIDs(
+ mr.cnxn, mr.project_id, list(range(start, start + num)))
+ logging.info('got %d issues to index', len(issues))
+ if issues:
+ tracker_fulltext.IndexIssues(
+ mr.cnxn, issues, self.services.user, self.services.issue,
+ self.services.config)
+
+ # Make the browser keep submitting the form, if the user wants that,
+ # and we have not run out of issues to process.
+ auto_submit = issues and ('auto_submit' in post_data)
+
+ query_map = {
+ 'start': start + num, # auto-increment start.
+ 'num': num,
+ 'auto_submit': bool(auto_submit),
+ }
+ return '/p/%s%s?%s' % (mr.project_name, urls.ISSUE_REINDEX,
+ urllib.urlencode(query_map))
diff --git a/tracker/issuetips.py b/tracker/issuetips.py
new file mode 100644
index 0000000..eb75265
--- /dev/null
+++ b/tracker/issuetips.py
@@ -0,0 +1,29 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A class to render a page of issue tracker search tips."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+from framework import servlet
+from framework import permissions
+
+
+class IssueSearchTips(servlet.Servlet):
+ """IssueSearchTips on-line help on how to use issue search."""
+
+ _PAGE_TEMPLATE = 'tracker/issue-search-tips.ezt'
+ _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
+
+ def GatherPageData(self, mr):
+ """Build up a dictionary of data values to use when rendering the page."""
+
+ return {
+ 'issue_tab_mode': 'issueSearchTips',
+ 'page_perms': self.MakePagePerms(mr, None, permissions.CREATE_ISSUE),
+ }
diff --git a/tracker/rerank_helpers.py b/tracker/rerank_helpers.py
new file mode 100644
index 0000000..f27582c
--- /dev/null
+++ b/tracker/rerank_helpers.py
@@ -0,0 +1,133 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Functions to help rerank issues in a lit.
+
+This file contains methods that implement a reranking algorithm for
+issues in a list.
+"""
+
+from __future__ import division
+from __future__ import print_function
+from __future__ import absolute_import
+
+import sys
+
+from framework import exceptions
+
+MAX_RANKING = sys.maxint
+MIN_RANKING = 0
+
+def GetHotlistRerankChanges(hotlist_items, moved_issue_ids, target_position):
+ # type: (int, Sequence[int], int) -> Collection[Tuple[int, int]]
+ """Computes the new ranks from reranking and or inserting of HotlistItems.
+
+ Args:
+ hotlist_items: The current list of HotlistItems in the Hotlist.
+ moved_issue_ids: A list of issue IDs to be moved/inserted together,
+ in the order
+ they should have the reranking.
+ target_position: The index, starting at 0, of the new position the
+ first issue in moved_issue_ids should occupy in the updated ordering.
+ Therefore this value cannot be greater than
+ (len(hotlist.items) - len(moved_issue_ids)).
+
+ Returns:
+ A list of [(issue_id, rank), ...] of HotlistItems that need to be updated.
+
+ Raises:
+ InputException: If the target_position is not valid.
+ """
+ # Sort hotlist items by rank.
+ sorted_hotlist_items = sorted(hotlist_items, key=lambda item: item.rank)
+ unmoved_hotlist_items = [
+ item for item in sorted_hotlist_items
+ if item.issue_id not in moved_issue_ids]
+ if target_position < 0:
+ raise exceptions.InputException(
+ 'given `target_position`: %d, must be non-negative')
+ if target_position > len(unmoved_hotlist_items):
+ raise exceptions.InputException(
+ '`target_position` %d is higher than maximum allowed (%d) for'
+ 'moving %d items in a hotlist with %d items total.' % (
+ target_position, len(unmoved_hotlist_items),
+ len(moved_issue_ids), len(sorted_hotlist_items)))
+ lower, higher = [], []
+ for i, item in enumerate(unmoved_hotlist_items):
+ item_tuple = (item.issue_id, item.rank)
+ if i < target_position:
+ lower.append(item_tuple)
+ else:
+ higher.append(item_tuple)
+
+ return GetInsertRankings(lower, higher, moved_issue_ids)
+
+def GetInsertRankings(lower, higher, moved_ids):
+ """Compute rankings for moved_ids to insert between the
+ lower and higher rankings
+
+ Args:
+ lower: a list of [(id, rank),...] of blockers that should have
+ a lower rank than the moved issues. Should be sorted from highest
+ to lowest rank.
+ higher: a list of [(id, rank),...] of blockers that should have
+ a higher rank than the moved issues. Should be sorted from highest
+ to lowest rank.
+ moved_ids: a list of global IDs for issues to re-rank.
+
+ Returns:
+ a list of [(id, rank),...] of blockers that need to be updated. rank
+ is the new rank of the issue with the specified id.
+ """
+ if lower:
+ lower_rank = lower[-1][1]
+ else:
+ lower_rank = MIN_RANKING
+
+ if higher:
+ higher_rank = higher[0][1]
+ else:
+ higher_rank = MAX_RANKING
+
+ slot_count = higher_rank - lower_rank - 1
+ if slot_count >= len(moved_ids):
+ new_ranks = _DistributeRanks(lower_rank, higher_rank, len(moved_ids))
+ return list(zip(moved_ids, new_ranks))
+ else:
+ new_lower, new_higher, new_moved_ids = _ResplitRanks(
+ lower, higher, moved_ids)
+ if not new_moved_ids:
+ return None
+ else:
+ return GetInsertRankings(new_lower, new_higher, new_moved_ids)
+
+
+def _DistributeRanks(low, high, rank_count):
+ """Compute evenly distributed ranks in a range"""
+ bucket_size = (high - low) // rank_count
+ first_rank = low + (bucket_size + 1) // 2
+ return list(range(first_rank, high, bucket_size))
+
+
+def _ResplitRanks(lower, higher, moved_ids):
+ if not (lower or higher):
+ return None, None, None
+
+ if not lower:
+ take_from = 'higher'
+ elif not higher:
+ take_from = 'lower'
+ else:
+ next_lower = lower[-2][1] if len(lower) >= 2 else MIN_RANKING
+ next_higher = higher[1][1] if len(higher) >= 2 else MAX_RANKING
+ if (lower[-1][1] - next_lower) > (next_higher - higher[0][1]):
+ take_from = 'lower'
+ else:
+ take_from = 'higher'
+
+ if take_from == 'lower':
+ return (lower[:-1], higher, [lower[-1][0]] + moved_ids)
+ else:
+ return (lower, higher[1:], moved_ids + [higher[0][0]])
diff --git a/tracker/spam.py b/tracker/spam.py
new file mode 100644
index 0000000..a30fc3e
--- /dev/null
+++ b/tracker/spam.py
@@ -0,0 +1,60 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed 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 spam flagging features.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import httplib
+import logging
+
+from framework import framework_helpers
+from framework import paginate
+from framework import permissions
+from framework import urls
+from framework import servlet
+from framework import template_helpers
+from framework import xsrf
+from tracker import spam_helpers
+from tracker import tracker_bizobj
+
+
+class ModerationQueue(servlet.Servlet):
+ _PAGE_TEMPLATE = 'tracker/spam-moderation-queue.ezt'
+
+ def GatherPageData(self, mr):
+ if not self.CheckPerm(mr, permissions.MODERATE_SPAM):
+ raise permissions.PermissionException()
+
+ page_perms = self.MakePagePerms(
+ mr, None, permissions.MODERATE_SPAM,
+ permissions.EDIT_ISSUE, permissions.CREATE_ISSUE,
+ permissions.SET_STAR)
+
+ # TODO(seanmccullough): Figure out how to get the IssueFlagQueue either
+ # integrated into this page data, or on its own subtab of spam moderation.
+ # Also figure out the same for Comments.
+ issue_items, total_count = self.services.spam.GetIssueClassifierQueue(
+ mr.cnxn, self.services.issue, mr.project.project_id, mr.start, mr.num)
+
+ issue_queue = spam_helpers.DecorateIssueClassifierQueue(mr.cnxn,
+ self.services.issue, self.services.spam, self.services.user,
+ issue_items)
+
+ url_params = [(name, mr.GetParam(name)) for name in
+ framework_helpers.RECOGNIZED_PARAMS]
+ p = paginate.ArtifactPagination(
+ [], mr.num, mr.GetPositiveIntParam('start'),
+ mr.project_name, urls.SPAM_MODERATION_QUEUE, total_count=total_count,
+ url_params=url_params)
+
+ return {
+ 'issue_queue': issue_queue,
+ 'projectname': mr.project.project_name,
+ 'pagination': p,
+ 'page_perms': page_perms,
+ }
diff --git a/tracker/spam_helpers.py b/tracker/spam_helpers.py
new file mode 100644
index 0000000..2bf2c90
--- /dev/null
+++ b/tracker/spam_helpers.py
@@ -0,0 +1,47 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Set of helpers for constructing spam-related pages.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from framework import template_helpers
+import ezt
+
+from datetime import datetime
+
+def DecorateIssueClassifierQueue(
+ cnxn, issue_service, spam_service, user_service, moderation_items):
+ issue_ids = [item.issue_id for item in moderation_items]
+ issues = issue_service.GetIssues(cnxn, issue_ids)
+ issue_map = {}
+ for issue in issues:
+ issue_map[issue.issue_id] = issue
+
+ flag_counts = spam_service.LookupIssueFlagCounts(cnxn, issue_ids)
+
+ reporter_ids = [issue.reporter_id for issue in issues]
+ reporters = user_service.GetUsersByIDs(cnxn, reporter_ids)
+ comments = issue_service.GetCommentsForIssues(cnxn, issue_ids)
+
+ items = []
+ for item in moderation_items:
+ issue=issue_map[item.issue_id]
+ first_comment = comments.get(item.issue_id, ["[Empty]"])[0]
+
+ items.append(template_helpers.EZTItem(
+ issue=issue,
+ summary=template_helpers.FitUnsafeText(issue.summary, 80),
+ comment_text=template_helpers.FitUnsafeText(first_comment.content, 80),
+ reporter=reporters[issue.reporter_id],
+ flag_count=flag_counts.get(issue.issue_id, 0),
+ is_spam=ezt.boolean(item.is_spam),
+ verdict_time=item.verdict_time,
+ classifier_confidence=item.classifier_confidence,
+ reason=item.reason,
+ ))
+
+ return items
diff --git a/tracker/tablecell.py b/tracker/tablecell.py
new file mode 100644
index 0000000..afb6468
--- /dev/null
+++ b/tracker/tablecell.py
@@ -0,0 +1,506 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed 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 generate value cells in the issue list table."""
+
+from __future__ import division
+from __future__ import print_function
+from __future__ import absolute_import
+
+import logging
+import time
+import ezt
+
+from framework import framework_constants
+from framework import table_view_helpers
+from framework import template_helpers
+from framework import urls
+from tracker import tracker_bizobj
+from tracker import tracker_helpers
+
+# pylint: disable=unused-argument
+
+
+class TableCellNote(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing a hotlist issue's note."""
+
+ def __init__(self, issue, note=None, **_kw):
+ if note:
+ display_note = [note]
+ else:
+ display_note = []
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_NOTE, display_note)
+
+
+class TableCellDateAdded(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing the date added of an issue."""
+
+ def __init__(self, issue, date_added=None, **_kw):
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_ATTR, [date_added])
+
+
+class TableCellAdderID(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing an issue's adder_id."""
+
+ def __init__(self, issue, adder_id=None, users_by_id=None, **_kw):
+ if adder_id:
+ display_name = [users_by_id[adder_id].display_name]
+ else:
+ display_name = [None]
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_ATTR,
+ display_name)
+
+
+class TableCellRank(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing issue rank."""
+
+ def __init__(self, issue, issue_rank=None, **_kw):
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_ATTR, [issue_rank])
+
+
+class TableCellID(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing issue IDs."""
+
+ def __init__(self, issue, **_kw):
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_ID, [str(issue.local_id)])
+
+
+class TableCellStatus(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing issue status values."""
+
+ def __init__(self, issue, **_kws):
+ values = []
+ derived_values = []
+ if issue.status:
+ values = [issue.status]
+ if issue.derived_status:
+ derived_values = [issue.derived_status]
+
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_ATTR, values,
+ derived_values=derived_values)
+
+
+class TableCellOwner(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing issue owner name."""
+
+ def __init__(self, issue, users_by_id=None, **_kw):
+ values = []
+ derived_values = []
+ if issue.owner_id:
+ values = [users_by_id[issue.owner_id].display_name]
+ if issue.derived_owner_id:
+ derived_values = [users_by_id[issue.derived_owner_id].display_name]
+
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_ATTR, values,
+ derived_values=derived_values)
+
+
+class TableCellReporter(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing issue reporter name."""
+
+ def __init__(self, issue, users_by_id=None, **_kw):
+ try:
+ values = [users_by_id[issue.reporter_id].display_name]
+ except KeyError:
+ logging.info('issue reporter %r not found', issue.reporter_id)
+ values = ['deleted?']
+
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_ATTR, values)
+
+
+class TableCellCc(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing issue Cc user names."""
+
+ def __init__(self, issue, users_by_id=None, **_kw):
+ values = [users_by_id[cc_id].display_name
+ for cc_id in issue.cc_ids]
+
+ derived_values = [users_by_id[cc_id].display_name
+ for cc_id in issue.derived_cc_ids]
+
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_ATTR, values,
+ derived_values=derived_values)
+
+
+class TableCellAttachments(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing issue attachment count."""
+
+ def __init__(self, issue, **_kw):
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_ATTR, [issue.attachment_count],
+ align='right')
+
+
+class TableCellOpened(table_view_helpers.TableCellDate):
+ """TableCell subclass specifically for showing issue opened date."""
+
+ def __init__(self, issue, **_kw):
+ table_view_helpers.TableCellDate.__init__(self, issue.opened_timestamp)
+
+
+class TableCellClosed(table_view_helpers.TableCellDate):
+ """TableCell subclass specifically for showing issue closed date."""
+
+ def __init__(self, issue, **_kw):
+ table_view_helpers.TableCellDate.__init__(self, issue.closed_timestamp)
+
+
+class TableCellModified(table_view_helpers.TableCellDate):
+ """TableCell subclass specifically for showing issue modified date."""
+
+ def __init__(self, issue, **_kw):
+ table_view_helpers.TableCellDate.__init__(self, issue.modified_timestamp)
+
+
+class TableCellOwnerModified(table_view_helpers.TableCellDate):
+ """TableCell subclass specifically for showing owner modified age."""
+
+ def __init__(self, issue, **_kw):
+ table_view_helpers.TableCellDate.__init__(
+ self, issue.owner_modified_timestamp, days_only=True)
+
+
+class TableCellStatusModified(table_view_helpers.TableCellDate):
+ """TableCell subclass specifically for showing status modified age."""
+
+ def __init__(self, issue, **_kw):
+ table_view_helpers.TableCellDate.__init__(
+ self, issue.status_modified_timestamp, days_only=True)
+
+
+class TableCellComponentModified(table_view_helpers.TableCellDate):
+ """TableCell subclass specifically for showing component modified age."""
+
+ def __init__(self, issue, **_kw):
+ table_view_helpers.TableCellDate.__init__(
+ self, issue.component_modified_timestamp, days_only=True)
+
+
+class TableCellOwnerLastVisit(table_view_helpers.TableCellDate):
+ """TableCell subclass specifically for showing owner last visit days ago."""
+
+ def __init__(self, issue, users_by_id=None, **_kw):
+ owner_view = users_by_id.get(issue.owner_id or issue.derived_owner_id)
+ last_visit = None
+ if owner_view:
+ last_visit = owner_view.user.last_visit_timestamp
+ table_view_helpers.TableCellDate.__init__(
+ self, last_visit, days_only=True)
+
+def _make_issue_view(default_pn, config, viewable_iids_set, ref_issue):
+ viewable = ref_issue.issue_id in viewable_iids_set
+ return template_helpers.EZTItem(
+ id=tracker_bizobj.FormatIssueRef(
+ (ref_issue.project_name, ref_issue.local_id),
+ default_project_name=default_pn),
+ href=tracker_helpers.FormatRelativeIssueURL(
+ ref_issue.project_name, urls.ISSUE_DETAIL, id=ref_issue.local_id),
+ closed=ezt.boolean(
+ viewable and
+ not tracker_helpers.MeansOpenInProject(ref_issue.status, config)),
+ title=ref_issue.summary if viewable else "")
+
+
+class TableCellBlockedOn(table_view_helpers.TableCell):
+ """TableCell subclass for listing issues the current issue is blocked on."""
+
+ def __init__(self, issue, related_issues=None, **_kw):
+ ref_issues = [related_issues[iid] for iid in issue.blocked_on_iids
+ if iid in related_issues]
+ values = [_make_issue_view(issue.project_name, _kw["config"],
+ _kw["viewable_iids_set"], ref_issue)
+ for ref_issue in ref_issues]
+ values.sort(key=lambda x: (x.closed, x.id))
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_ISSUES, values, sort_values=False)
+
+
+class TableCellBlocking(table_view_helpers.TableCell):
+ """TableCell subclass for listing issues the current issue is blocking."""
+
+ def __init__(self, issue, related_issues=None, **_kw):
+ ref_issues = [related_issues[iid] for iid in issue.blocking_iids
+ if iid in related_issues]
+ values = [_make_issue_view(issue.project_name, _kw["config"],
+ _kw["viewable_iids_set"], ref_issue)
+ for ref_issue in ref_issues]
+ values.sort(key=lambda x: (x.closed, x.id))
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_ISSUES, values, sort_values=False)
+
+
+class TableCellBlocked(table_view_helpers.TableCell):
+ """TableCell subclass for showing whether an issue is blocked."""
+
+ def __init__(self, issue, **_kw):
+ if issue.blocked_on_iids:
+ value = 'Yes'
+ else:
+ value = 'No'
+
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_ATTR, [value])
+
+
+class TableCellMergedInto(table_view_helpers.TableCell):
+ """TableCell subclass for showing whether an issue is blocked."""
+
+ def __init__(self, issue, related_issues=None, **_kw):
+ if issue.merged_into:
+ ref_issue = related_issues[issue.merged_into]
+ values = [_make_issue_view(issue.project_name, _kw["config"],
+ _kw["viewable_iids_set"], ref_issue)]
+ else: # Note: None means not merged into any issue.
+ values = []
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_ISSUES, values)
+
+
+class TableCellComponent(table_view_helpers.TableCell):
+ """TableCell subclass for showing components."""
+
+ def __init__(self, issue, config=None, **_kw):
+ explicit_paths = []
+ for component_id in issue.component_ids:
+ cd = tracker_bizobj.FindComponentDefByID(component_id, config)
+ if cd:
+ explicit_paths.append(cd.path)
+
+ derived_paths = []
+ for component_id in issue.derived_component_ids:
+ cd = tracker_bizobj.FindComponentDefByID(component_id, config)
+ if cd:
+ derived_paths.append(cd.path)
+
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_ATTR, explicit_paths,
+ derived_values=derived_paths)
+
+
+class TableCellAllLabels(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing all labels on an issue."""
+
+ def __init__(self, issue, **_kw):
+ values = []
+ derived_values = []
+ if issue.labels:
+ values = issue.labels[:]
+ if issue.derived_labels:
+ derived_values = issue.derived_labels[:]
+
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_ATTR, values,
+ derived_values=derived_values)
+
+
+# This maps column names to factories/constructors that make table cells.
+# Subclasses can override this mapping, so any additions to this mapping
+# should also be added to subclasses.
+CELL_FACTORIES = {
+ 'id': TableCellID,
+ 'project': table_view_helpers.TableCellProject,
+ 'component': TableCellComponent,
+ 'summary': table_view_helpers.TableCellSummary,
+ 'status': TableCellStatus,
+ 'owner': TableCellOwner,
+ 'reporter': TableCellReporter,
+ 'cc': TableCellCc,
+ 'stars': table_view_helpers.TableCellStars,
+ 'attachments': TableCellAttachments,
+ 'opened': TableCellOpened,
+ 'closed': TableCellClosed,
+ 'modified': TableCellModified,
+ 'blockedon': TableCellBlockedOn,
+ 'blocking': TableCellBlocking,
+ 'blocked': TableCellBlocked,
+ 'mergedinto': TableCellMergedInto,
+ 'ownermodified': TableCellOwnerModified,
+ 'statusmodified': TableCellStatusModified,
+ 'componentmodified': TableCellComponentModified,
+ 'ownerlastvisit': TableCellOwnerLastVisit,
+ 'rank': TableCellRank,
+ 'added': TableCellDateAdded,
+ 'adder': TableCellAdderID,
+ 'note': TableCellNote,
+ 'alllabels': TableCellAllLabels,
+ }
+
+
+# Time format that spreadsheets seem to understand.
+# E.g.: "May 19 2008 13:30:23". Tested with MS Excel 2003,
+# OpenOffice.org, NeoOffice, and Google Spreadsheets.
+CSV_DATE_TIME_FMT = '%b %d, %Y %H:%M:%S'
+
+
+def TimeStringForCSV(timestamp):
+ """Return a timestamp in a format that spreadsheets understand."""
+ return time.strftime(CSV_DATE_TIME_FMT, time.gmtime(timestamp))
+
+
+class TableCellOpenedCSV(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing issue opened date."""
+
+ def __init__(self, issue, **_kw):
+ date_str = TimeStringForCSV(issue.opened_timestamp)
+
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_UNFILTERABLE, [date_str])
+
+
+class TableCellOpenedTimestamp(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing issue opened timestamp."""
+
+ def __init__(self, issue, **_kw):
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_UNFILTERABLE,
+ [issue.opened_timestamp])
+
+
+class TableCellModifiedCSV(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing issue modified date."""
+
+ def __init__(self, issue, **_kw):
+ values = []
+ if issue.modified_timestamp:
+ values = [TimeStringForCSV(issue.modified_timestamp)]
+
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_UNFILTERABLE, values)
+
+
+class TableCellModifiedTimestamp(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing issue modified timestamp."""
+
+ def __init__(self, issue, **_kw):
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_UNFILTERABLE,
+ [issue.modified_timestamp])
+
+
+class TableCellClosedCSV(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing issue closed date."""
+
+ def __init__(self, issue, **_kw):
+ values = []
+ if issue.closed_timestamp:
+ values = [TimeStringForCSV(issue.closed_timestamp)]
+
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_UNFILTERABLE, values)
+
+
+class TableCellClosedTimestamp(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing issue closed timestamp."""
+
+ def __init__(self, issue, **_kw):
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_UNFILTERABLE,
+ [issue.closed_timestamp])
+
+
+class TableCellOwnerModifiedCSV(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing owner modified date."""
+
+ def __init__(self, issue, **_kw):
+ values = []
+ if issue.modified_timestamp:
+ values = [TimeStringForCSV(issue.owner_modified_timestamp)]
+
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_UNFILTERABLE, values)
+
+
+class TableCellOwnerModifiedTimestamp(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing owner modified timestamp."""
+
+ def __init__(self, issue, **_kw):
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_UNFILTERABLE,
+ [issue.owner_modified_timestamp])
+
+
+class TableCellStatusModifiedCSV(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing status modified date."""
+
+ def __init__(self, issue, **_kw):
+ values = []
+ if issue.modified_timestamp:
+ values = [TimeStringForCSV(issue.status_modified_timestamp)]
+
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_UNFILTERABLE, values)
+
+
+class TableCellStatusModifiedTimestamp(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing status modified timestamp."""
+
+ def __init__(self, issue, **_kw):
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_UNFILTERABLE,
+ [issue.status_modified_timestamp])
+
+
+class TableCellComponentModifiedCSV(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing component modified date."""
+
+ def __init__(self, issue, **_kw):
+ values = []
+ if issue.modified_timestamp:
+ values = [TimeStringForCSV(issue.component_modified_timestamp)]
+
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_UNFILTERABLE, values)
+
+
+class TableCellComponentModifiedTimestamp(table_view_helpers.TableCell):
+ """TableCell subclass for showing component modified timestamp."""
+
+ def __init__(self, issue, **_kw):
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_UNFILTERABLE,
+ [issue.component_modified_timestamp])
+
+
+class TableCellOwnerLastVisitDaysAgo(table_view_helpers.TableCell):
+ """TableCell subclass specifically for showing owner last visit days ago."""
+
+ def __init__(self, issue, users_by_id=None, **_kw):
+ owner_view = users_by_id.get(issue.owner_id or issue.derived_owner_id)
+ last_visit_days_ago = None
+ if owner_view and owner_view.user.last_visit_timestamp:
+ secs_ago = int(time.time()) - owner_view.user.last_visit_timestamp
+ last_visit_days_ago = secs_ago // framework_constants.SECS_PER_DAY
+ table_view_helpers.TableCell.__init__(
+ self, table_view_helpers.CELL_TYPE_UNFILTERABLE, [last_visit_days_ago])
+
+
+# Maps column names to factories/constructors that make table cells.
+# Uses the defaults in issuelist.py but changes the factory for the
+# summary cell to properly escape the data for CSV files.
+CSV_CELL_FACTORIES = CELL_FACTORIES.copy()
+CSV_CELL_FACTORIES.update({
+ 'opened': TableCellOpenedCSV,
+ 'openedtimestamp': TableCellOpenedTimestamp,
+ 'closed': TableCellClosedCSV,
+ 'closedtimestamp': TableCellClosedTimestamp,
+ 'modified': TableCellModifiedCSV,
+ 'modifiedtimestamp': TableCellModifiedTimestamp,
+ 'ownermodified': TableCellOwnerModifiedCSV,
+ 'ownermodifiedtimestamp': TableCellOwnerModifiedTimestamp,
+ 'statusmodified': TableCellStatusModifiedCSV,
+ 'statusmodifiedtimestamp': TableCellStatusModifiedTimestamp,
+ 'componentmodified': TableCellComponentModifiedCSV,
+ 'componentmodifiedtimestamp': TableCellComponentModifiedTimestamp,
+ 'ownerlastvisitdaysago': TableCellOwnerLastVisitDaysAgo,
+ })
diff --git a/tracker/template_helpers.py b/tracker/template_helpers.py
new file mode 100644
index 0000000..c567b4a
--- /dev/null
+++ b/tracker/template_helpers.py
@@ -0,0 +1,261 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions for issue template servlets"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+
+from framework import authdata
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_helpers
+from tracker import field_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from proto import tracker_pb2
+
+MAX_NUM_PHASES = 6
+
+PHASE_INPUTS = [
+ 'phase_0', 'phase_1', 'phase_2', 'phase_3', 'phase_4', 'phase_5']
+
+_NO_PHASE_VALUE = 'no_phase'
+
+ParsedTemplate = collections.namedtuple(
+ 'ParsedTemplate', 'name, members_only, summary, summary_must_be_edited, '
+ 'content, status, owner_str, labels, field_val_strs, component_paths, '
+ 'component_required, owner_defaults_to_member, admin_str, add_approvals, '
+ 'phase_names, approvals_to_phase_idx, required_approval_ids')
+
+
+def ParseTemplateRequest(post_data, config):
+ """Parse an issue template."""
+
+ name = post_data.get('name', '')
+ members_only = (post_data.get('members_only') == 'on')
+ summary = post_data.get('summary', '')
+ summary_must_be_edited = (
+ post_data.get('summary_must_be_edited') == 'on')
+ content = post_data.get('content', '')
+ content = framework_helpers.WordWrapSuperLongLines(content, max_cols=75)
+ status = post_data.get('status', '')
+ owner_str = post_data.get('owner', '')
+ labels = post_data.getall('label')
+ field_val_strs = collections.defaultdict(list)
+ for fd in config.field_defs:
+ field_value_key = 'custom_%d' % fd.field_id
+ if post_data.get(field_value_key):
+ field_val_strs[fd.field_id].append(post_data[field_value_key])
+
+ component_paths = []
+ if post_data.get('components'):
+ for component_path in post_data.get('components').split(','):
+ if component_path.strip() not in component_paths:
+ component_paths.append(component_path.strip())
+ component_required = post_data.get('component_required') == 'on'
+
+ owner_defaults_to_member = post_data.get('owner_defaults_to_member') == 'on'
+
+ admin_str = post_data.get('admin_names', '')
+
+ add_approvals = post_data.get('add_approvals') == 'on'
+ phase_names = [post_data.get(phase_input, '') for phase_input in PHASE_INPUTS]
+
+ required_approval_ids = []
+ approvals_to_phase_idx = {}
+
+ for approval_def in config.approval_defs:
+ phase_num = post_data.get('approval_%d' % approval_def.approval_id, '')
+ if phase_num == _NO_PHASE_VALUE:
+ approvals_to_phase_idx[approval_def.approval_id] = None
+ else:
+ try:
+ idx = PHASE_INPUTS.index(phase_num)
+ approvals_to_phase_idx[approval_def.approval_id] = idx
+ except ValueError:
+ logging.info('approval %d was omitted' % approval_def.approval_id)
+ required_name = 'approval_%d_required' % approval_def.approval_id
+ if (post_data.get(required_name) == 'on'):
+ required_approval_ids.append(approval_def.approval_id)
+
+ return ParsedTemplate(
+ name, members_only, summary, summary_must_be_edited, content, status,
+ owner_str, labels, field_val_strs, component_paths, component_required,
+ owner_defaults_to_member, admin_str, add_approvals, phase_names,
+ approvals_to_phase_idx, required_approval_ids)
+
+
+def GetTemplateInfoFromParsed(mr, services, parsed, config):
+ """Get Template field info and PBs from a ParsedTemplate."""
+
+ admin_ids, _ = tracker_helpers.ParsePostDataUsers(
+ mr.cnxn, parsed.admin_str, services.user)
+
+ owner_id = 0
+ if parsed.owner_str:
+ try:
+ user_id = services.user.LookupUserID(mr.cnxn, parsed.owner_str)
+ auth = authdata.AuthData.FromUserID(mr.cnxn, user_id, services)
+ if framework_bizobj.UserIsInProject(mr.project, auth.effective_ids):
+ owner_id = user_id
+ else:
+ mr.errors.owner = 'User is not a member of this project.'
+ except exceptions.NoSuchUserException:
+ mr.errors.owner = 'Owner not found.'
+
+ component_ids = tracker_helpers.LookupComponentIDs(
+ parsed.component_paths, config, mr.errors)
+
+ # TODO(jojwang): monorail:4678 Process phase field values.
+ phase_field_val_strs = {}
+ field_values = field_helpers.ParseFieldValues(
+ mr.cnxn, services.user, parsed.field_val_strs,
+ phase_field_val_strs, config)
+ for fv in field_values:
+ logging.info('field_value is %r: %r',
+ fv.field_id, tracker_bizobj.GetFieldValue(fv, {}))
+
+ phases = []
+ approvals = []
+ if parsed.add_approvals:
+ phases, approvals = _GetPhasesAndApprovalsFromParsed(
+ mr, parsed.phase_names, parsed.approvals_to_phase_idx,
+ parsed.required_approval_ids)
+
+ return admin_ids, owner_id, component_ids, field_values, phases, approvals
+
+
+def _GetPhasesAndApprovalsFromParsed(
+ mr, phase_names, approvals_to_phase_idx, required_approval_ids):
+ """Get Phase PBs from a parsed phase_names and approvals_by_phase_idx."""
+
+ phases = []
+ approvals = []
+ valid_phase_names = []
+
+ for name in phase_names:
+ if name:
+ if not tracker_constants.PHASE_NAME_RE.match(name):
+ mr.errors.phase_approvals = 'Invalid gate name(s).'
+ return phases, approvals
+ valid_phase_names.append(name)
+ if len(valid_phase_names) != len(
+ set(name.lower() for name in valid_phase_names)):
+ mr.errors.phase_approvals = 'Duplicate gate names.'
+ return phases, approvals
+ valid_phase_idxs = [idx for idx, name in enumerate(phase_names) if name]
+ if set(valid_phase_idxs) != set([
+ idx for idx in approvals_to_phase_idx.values() if idx is not None]):
+ mr.errors.phase_approvals = 'Defined gates must have assigned approvals.'
+ return phases, approvals
+
+ # Distributing the ranks over a wider range is not necessary since
+ # any edits to template phases will cause a complete rewrite.
+ # phase_id is temporarily the idx for keeping track of which approvals
+ # belong to which phases.
+ for idx, phase_name in enumerate(phase_names):
+ if phase_name:
+ phase = tracker_pb2.Phase(name=phase_name, rank=idx, phase_id=idx)
+ phases.append(phase)
+
+ for approval_id, phase_idx in approvals_to_phase_idx.items():
+ av = tracker_pb2.ApprovalValue(
+ approval_id=approval_id, phase_id=phase_idx)
+ if approval_id in required_approval_ids:
+ av.status = tracker_pb2.ApprovalStatus.NEEDS_REVIEW
+ approvals.append(av)
+
+ return phases, approvals
+
+
+def FilterApprovalsAndPhases(approval_values, phases, config):
+ """Return lists without deleted approvals and empty phases."""
+ deleted_approval_ids = [fd.field_id for fd in config.field_defs if
+ fd.is_deleted and
+ fd.field_type is tracker_pb2.FieldTypes.APPROVAL_TYPE]
+ filtered_avs = [av for av in approval_values if
+ av.approval_id not in deleted_approval_ids]
+
+ av_phase_ids = list(set([av.phase_id for av in filtered_avs]))
+ filtered_phases = [phase for phase in phases if
+ phase.phase_id in av_phase_ids]
+ return filtered_avs, filtered_phases
+
+
+def GatherApprovalsPageData(approval_values, tmpl_phases, config):
+ """Create the page data necessary for filling in the launch-gates-table."""
+ filtered_avs, filtered_phases = FilterApprovalsAndPhases(
+ approval_values, tmpl_phases, config)
+ filtered_phases.sort(key=lambda phase: phase.rank)
+
+ required_approval_ids = []
+ prechecked_approvals = []
+
+ phase_idx_by_id = {
+ phase.phase_id:idx for idx, phase in enumerate(filtered_phases)}
+ for av in filtered_avs:
+ # approval is part of a phase and that phase can be found.
+ if phase_idx_by_id.get(av.phase_id) is not None:
+ idx = phase_idx_by_id.get(av.phase_id)
+ prechecked_approvals.append(
+ '%d_phase_%d' % (av.approval_id, idx))
+ else:
+ prechecked_approvals.append('%d' % av.approval_id)
+ if av.status is tracker_pb2.ApprovalStatus.NEEDS_REVIEW:
+ required_approval_ids.append(av.approval_id)
+
+ num_phases = len(filtered_phases)
+ filtered_phases.extend([tracker_pb2.Phase()] * (
+ MAX_NUM_PHASES - num_phases))
+ return prechecked_approvals, required_approval_ids, filtered_phases
+
+
+def GetCheckedApprovalsFromParsed(approvals_to_phase_idx):
+ checked_approvals = []
+ for approval_id, phs_idx in approvals_to_phase_idx.items():
+ if phs_idx is not None:
+ checked_approvals.append('%d_phase_%d' % (approval_id, phs_idx))
+ else:
+ checked_approvals.append('%d' % approval_id)
+ return checked_approvals
+
+
+def GetIssueFromTemplate(template, project_id, reporter_id):
+ # type: (proto.tracker_pb2.TemplateDef, int, int) ->
+ # proto.tracker_pb2.Issue
+ """Build a templated issue from TemplateDef.
+
+ Args:
+ template: Template that issue creation is based on.
+ project_id: ID of the Project the template belongs to.
+ reporter_id: Requesting user's ID.
+
+ Returns:
+ protorpc Issue filled with data from given `template`.
+ """
+ owner_id = None
+ if template.owner_id:
+ owner_id = template.owner_id
+ elif template.owner_defaults_to_member:
+ owner_id = reporter_id
+
+ issue = tracker_pb2.Issue(
+ project_id=project_id,
+ summary=template.summary,
+ status=template.status,
+ owner_id=owner_id,
+ labels=template.labels,
+ component_ids=template.component_ids,
+ reporter_id=reporter_id,
+ field_values=template.field_values,
+ phases=template.phases,
+ approval_values=template.approval_values)
+
+ return issue
diff --git a/tracker/templatecreate.py b/tracker/templatecreate.py
new file mode 100644
index 0000000..e10a25b
--- /dev/null
+++ b/tracker/templatecreate.py
@@ -0,0 +1,193 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A servlet for project owners to create a new template"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+import time
+
+import ezt
+
+from framework import authdata
+from framework import framework_bizobj
+from framework import framework_helpers
+from framework import servlet
+from framework import urls
+from framework import permissions
+from tracker import field_helpers
+from tracker import template_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_helpers
+from tracker import tracker_views
+from services import user_svc
+from proto import tracker_pb2
+
+
+class TemplateCreate(servlet.Servlet):
+ """Servlet allowing project owners to create an issue template."""
+
+ _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PROCESS
+ _PAGE_TEMPLATE = 'tracker/template-detail-page.ezt'
+ _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_TEMPLATES
+
+ def AssertBasePermission(self, mr):
+ """Check whether the user has any permission to visit this page.
+
+ Args:
+ mr: commonly used info parsed from the request
+ """
+ super(TemplateCreate, self).AssertBasePermission(mr)
+ if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+ raise permissions.PermissionException(
+ 'User is not allowed to administer this project')
+
+ 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.
+ """
+
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ field_views = tracker_views.MakeAllFieldValueViews(
+ config, [], [], [], {})
+ approval_subfields_present = any(
+ fv.field_def.is_approval_subfield for fv in field_views)
+
+ initial_phases = [tracker_pb2.Phase()] * template_helpers.MAX_NUM_PHASES
+ return {
+ 'admin_tab_mode':
+ self._PROCESS_SUBTAB,
+ 'allow_edit':
+ ezt.boolean(True),
+ 'uneditable_fields':
+ ezt.boolean(False),
+ 'new_template_form':
+ ezt.boolean(True),
+ 'initial_members_only':
+ ezt.boolean(False),
+ 'template_name':
+ '',
+ 'initial_content':
+ '',
+ 'initial_must_edit_summary':
+ ezt.boolean(False),
+ 'initial_summary':
+ '',
+ 'initial_status':
+ '',
+ 'initial_owner':
+ '',
+ 'initial_owner_defaults_to_member':
+ ezt.boolean(False),
+ 'initial_components':
+ '',
+ 'initial_component_required':
+ ezt.boolean(False),
+ 'initial_admins':
+ '',
+ 'fields':
+ [
+ view for view in field_views
+ if view.field_def.type_name is not "APPROVAL_TYPE"
+ ],
+ 'initial_add_approvals':
+ ezt.boolean(False),
+ 'initial_phases':
+ initial_phases,
+ 'approvals':
+ [
+ view for view in field_views
+ if view.field_def.type_name is "APPROVAL_TYPE"
+ ],
+ 'prechecked_approvals': [],
+ 'required_approval_ids': [],
+ 'approval_subfields_present':
+ ezt.boolean(approval_subfields_present),
+ # We do not support setting phase field values during template creation.
+ 'phase_fields_present':
+ ezt.boolean(False),
+ }
+
+ def ProcessFormData(self, mr, post_data):
+ """Validate and store the contents of the issues tracker admin page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ post_data: HTML form data from the request.
+
+ Returns:
+ String URL to redirect the user to, or None if response was already sent.
+ """
+
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ parsed = template_helpers.ParseTemplateRequest(post_data, config)
+ field_helpers.ShiftEnumFieldsIntoLabels(
+ parsed.labels, [], parsed.field_val_strs, [], config)
+
+ if not parsed.name:
+ mr.errors.name = 'Please provide a template name'
+ if self.services.template.GetTemplateByName(mr.cnxn, parsed.name,
+ mr.project_id):
+ mr.errors.name = 'Template with name %s already exists' % parsed.name
+
+ (admin_ids, owner_id, component_ids,
+ field_values, phases,
+ approvals) = template_helpers.GetTemplateInfoFromParsed(
+ mr, self.services, parsed, config)
+
+ labels = [label for label in parsed.labels if label]
+ field_helpers.AssertCustomFieldsEditPerms(
+ mr, config, field_values, [], [], labels, [])
+
+ if mr.errors.AnyErrors():
+ field_views = tracker_views.MakeAllFieldValueViews(
+ config, [], [], field_values, {})
+ prechecked_approvals = template_helpers.GetCheckedApprovalsFromParsed(
+ parsed.approvals_to_phase_idx)
+
+ self.PleaseCorrect(
+ mr,
+ initial_members_only=ezt.boolean(parsed.members_only),
+ template_name=parsed.name,
+ initial_content=parsed.summary,
+ initial_must_edit_summary=ezt.boolean(parsed.summary_must_be_edited),
+ initial_description=parsed.content,
+ initial_status=parsed.status,
+ initial_owner=parsed.owner_str,
+ initial_owner_defaults_to_member=ezt.boolean(
+ parsed.owner_defaults_to_member),
+ initial_components=', '.join(parsed.component_paths),
+ initial_component_required=ezt.boolean(parsed.component_required),
+ initial_admins=parsed.admin_str,
+ labels=parsed.labels,
+ fields=[view for view in field_views
+ if view.field_def.type_name is not 'APPROVAL_TYPE'],
+ initial_add_approvals=ezt.boolean(parsed.add_approvals),
+ initial_phases=[tracker_pb2.Phase(name=name) for name in
+ parsed.phase_names],
+ approvals=[view for view in field_views
+ if view.field_def.type_name is 'APPROVAL_TYPE'],
+ prechecked_approvals=prechecked_approvals,
+ required_approval_ids=parsed.required_approval_ids
+ )
+ return
+
+ self.services.template.CreateIssueTemplateDef(
+ mr.cnxn, mr.project_id, parsed.name, parsed.content, parsed.summary,
+ parsed.summary_must_be_edited, parsed.status, parsed.members_only,
+ parsed.owner_defaults_to_member, parsed.component_required,
+ owner_id, labels, component_ids, admin_ids, field_values, phases=phases,
+ approval_values=approvals)
+
+ return framework_helpers.FormatAbsoluteURL(
+ mr, urls.ADMIN_TEMPLATES, saved=1, ts=int(time.time()))
diff --git a/tracker/templatedetail.py b/tracker/templatedetail.py
new file mode 100644
index 0000000..cd48a80
--- /dev/null
+++ b/tracker/templatedetail.py
@@ -0,0 +1,245 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A servlet for project owners to edit/delete a template"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+import time
+
+import ezt
+
+from framework import authdata
+from framework import framework_bizobj
+from framework import framework_helpers
+from framework import framework_views
+from framework import servlet
+from framework import urls
+from framework import permissions
+from tracker import field_helpers
+from tracker import template_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_helpers
+from tracker import tracker_views
+from proto import tracker_pb2
+from services import user_svc
+
+
+class TemplateDetail(servlet.Servlet):
+ """Servlet allowing project owners to edit/delete an issue template"""
+
+ _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PROCESS
+ _PAGE_TEMPLATE = 'tracker/template-detail-page.ezt'
+ _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_TEMPLATES
+
+ def AssertBasePermission(self, mr):
+ """Check whether the user has any permission to visit this page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ """
+ super(TemplateDetail, self).AssertBasePermission(mr)
+ template = self.services.template.GetTemplateByName(mr.cnxn,
+ mr.template_name, mr.project_id)
+
+ if template:
+ allow_view = permissions.CanViewTemplate(
+ mr.auth.effective_ids, mr.perms, mr.project, template)
+ if not allow_view:
+ raise permissions.PermissionException(
+ 'User is not allowed to view this issue template')
+ else:
+ self.abort(404, 'issue template not found %s' % mr.template_name)
+
+ 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.
+ """
+
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ template = self.services.template.GetTemplateByName(mr.cnxn,
+ mr.template_name, mr.project_id)
+ template_view = tracker_views.IssueTemplateView(
+ mr, template, self.services.user, config)
+ with mr.profiler.Phase('making user views'):
+ users_involved = tracker_bizobj.UsersInvolvedInTemplate(template)
+ users_by_id = framework_views.MakeAllUserViews(
+ mr.cnxn, self.services.user, users_involved)
+ framework_views.RevealAllEmailsToMembers(
+ mr.cnxn, self.services, mr.auth, users_by_id, mr.project)
+ field_name_set = {fd.field_name.lower() for fd in config.field_defs
+ if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and
+ not fd.is_deleted}
+ non_masked_labels = tracker_bizobj.NonMaskedLabels(
+ template.labels, field_name_set)
+
+ field_views = tracker_views.MakeAllFieldValueViews(
+ config, template.labels, [], template.field_values, users_by_id,
+ phases=template.phases)
+ uneditable_fields = ezt.boolean(False)
+ for fv in field_views:
+ if permissions.CanEditValueForFieldDef(
+ mr.auth.effective_ids, mr.perms, mr.project, fv.field_def.field_def):
+ fv.is_editable = ezt.boolean(True)
+ else:
+ fv.is_editable = ezt.boolean(False)
+ uneditable_fields = ezt.boolean(True)
+
+ (prechecked_approvals, required_approval_ids,
+ initial_phases) = template_helpers.GatherApprovalsPageData(
+ template.approval_values, template.phases, config)
+
+ allow_edit = permissions.CanEditTemplate(
+ mr.auth.effective_ids, mr.perms, mr.project, template)
+
+ return {
+ 'admin_tab_mode':
+ self._PROCESS_SUBTAB,
+ 'allow_edit':
+ ezt.boolean(allow_edit),
+ 'uneditable_fields':
+ uneditable_fields,
+ 'new_template_form':
+ ezt.boolean(False),
+ 'initial_members_only':
+ template_view.members_only,
+ 'template_name':
+ template_view.name,
+ 'initial_summary':
+ template_view.summary,
+ 'initial_must_edit_summary':
+ template_view.summary_must_be_edited,
+ 'initial_content':
+ template_view.content,
+ 'initial_status':
+ template_view.status,
+ 'initial_owner':
+ template_view.ownername,
+ 'initial_owner_defaults_to_member':
+ template_view.owner_defaults_to_member,
+ 'initial_components':
+ template_view.components,
+ 'initial_component_required':
+ template_view.component_required,
+ 'fields':
+ [
+ view for view in field_views
+ if view.field_def.type_name is not 'APPROVAL_TYPE'
+ ],
+ 'initial_add_approvals':
+ ezt.boolean(prechecked_approvals),
+ 'initial_phases':
+ initial_phases,
+ 'approvals':
+ [
+ view for view in field_views
+ if view.field_def.type_name is 'APPROVAL_TYPE'
+ ],
+ 'prechecked_approvals':
+ prechecked_approvals,
+ 'required_approval_ids':
+ required_approval_ids,
+ 'labels':
+ non_masked_labels,
+ 'initial_admins':
+ template_view.admin_names,
+ }
+
+ def ProcessFormData(self, mr, post_data):
+ """Validate and store the contents of the issues tracker admin page.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ post_data: HTML form data from the request.
+
+ Returns:
+ String URL to redirect the user to, or None if response was already sent.
+ """
+
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ parsed = template_helpers.ParseTemplateRequest(post_data, config)
+ field_helpers.ShiftEnumFieldsIntoLabels(
+ parsed.labels, [], parsed.field_val_strs, [], config)
+ template = self.services.template.GetTemplateByName(mr.cnxn,
+ parsed.name, mr.project_id)
+ allow_edit = permissions.CanEditTemplate(
+ mr.auth.effective_ids, mr.perms, mr.project, template)
+ if not allow_edit:
+ raise permissions.PermissionException(
+ 'User is not allowed edit this issue template.')
+
+ if 'deletetemplate' in post_data:
+ self.services.template.DeleteIssueTemplateDef(
+ mr.cnxn, mr.project_id, template.template_id)
+ return framework_helpers.FormatAbsoluteURL(
+ mr, urls.ADMIN_TEMPLATES, deleted=1, ts=int(time.time()))
+
+ (admin_ids, owner_id, component_ids,
+ field_values, phases,
+ approvals) = template_helpers.GetTemplateInfoFromParsed(
+ mr, self.services, parsed, config)
+
+ labels = [label for label in parsed.labels if label]
+ field_helpers.AssertCustomFieldsEditPerms(
+ mr, config, field_values, [], [], labels, [])
+ field_helpers.ApplyRestrictedDefaultValues(
+ mr, config, field_values, labels, template.field_values,
+ template.labels)
+
+ if mr.errors.AnyErrors():
+ field_views = tracker_views.MakeAllFieldValueViews(
+ config, [], [], field_values, {})
+
+ prechecked_approvals = template_helpers.GetCheckedApprovalsFromParsed(
+ parsed.approvals_to_phase_idx)
+
+ self.PleaseCorrect(
+ mr,
+ initial_members_only=ezt.boolean(parsed.members_only),
+ template_name=parsed.name,
+ initial_summary=parsed.summary,
+ initial_must_edit_summary=ezt.boolean(parsed.summary_must_be_edited),
+ initial_content=parsed.content,
+ initial_status=parsed.status,
+ initial_owner=parsed.owner_str,
+ initial_owner_defaults_to_member=ezt.boolean(
+ parsed.owner_defaults_to_member),
+ initial_components=', '.join(parsed.component_paths),
+ initial_component_required=ezt.boolean(parsed.component_required),
+ initial_admins=parsed.admin_str,
+ labels=parsed.labels,
+ fields=[view for view in field_views
+ if view.field_def.type_name is not 'APPROVAL_TYPE'],
+ initial_add_approvals=ezt.boolean(parsed.add_approvals),
+ initial_phases=[tracker_pb2.Phase(name=name) for name in
+ parsed.phase_names],
+ approvals=[view for view in field_views
+ if view.field_def.type_name is 'APPROVAL_TYPE'],
+ prechecked_approvals=prechecked_approvals,
+ required_approval_ids=parsed.required_approval_ids
+ )
+ return
+
+ self.services.template.UpdateIssueTemplateDef(
+ mr.cnxn, mr.project_id, template.template_id, name=parsed.name,
+ content=parsed.content, summary=parsed.summary,
+ summary_must_be_edited=parsed.summary_must_be_edited,
+ status=parsed.status, members_only=parsed.members_only,
+ owner_defaults_to_member=parsed.owner_defaults_to_member,
+ component_required=parsed.component_required, owner_id=owner_id,
+ labels=labels, component_ids=component_ids, admin_ids=admin_ids,
+ field_values=field_values, phases=phases, approval_values=approvals)
+
+ return framework_helpers.FormatAbsoluteURL(
+ mr, urls.TEMPLATE_DETAIL, template=template.name,
+ saved=1, ts=int(time.time()))
diff --git a/tracker/test/__init__.py b/tracker/test/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tracker/test/__init__.py
diff --git a/tracker/test/attachment_helpers_test.py b/tracker/test/attachment_helpers_test.py
new file mode 100644
index 0000000..18e0efc
--- /dev/null
+++ b/tracker/test/attachment_helpers_test.py
@@ -0,0 +1,149 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for the tracker helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+from mock import Mock, patch
+import unittest
+
+from proto import tracker_pb2
+from tracker import attachment_helpers
+
+
+class AttachmentHelpersFunctionsTest(unittest.TestCase):
+
+ def testIsViewableImage(self):
+ self.assertTrue(attachment_helpers.IsViewableImage('image/gif', 123))
+ self.assertTrue(attachment_helpers.IsViewableImage(
+ 'image/gif; charset=binary', 123))
+ self.assertTrue(attachment_helpers.IsViewableImage('image/png', 123))
+ self.assertTrue(attachment_helpers.IsViewableImage(
+ 'image/png; charset=binary', 123))
+ self.assertTrue(attachment_helpers.IsViewableImage('image/x-png', 123))
+ self.assertTrue(attachment_helpers.IsViewableImage('image/jpeg', 123))
+ self.assertTrue(attachment_helpers.IsViewableImage(
+ 'image/jpeg; charset=binary', 123))
+ self.assertTrue(attachment_helpers.IsViewableImage(
+ 'image/jpeg', 14 * 1024 * 1024))
+
+ self.assertFalse(attachment_helpers.IsViewableImage('junk/bits', 123))
+ self.assertFalse(attachment_helpers.IsViewableImage(
+ 'junk/bits; charset=binary', 123))
+ self.assertFalse(attachment_helpers.IsViewableImage(
+ 'image/jpeg', 16 * 1024 * 1024))
+
+ def testIsViewableVideo(self):
+ self.assertTrue(attachment_helpers.IsViewableVideo('video/ogg', 123))
+ self.assertTrue(attachment_helpers.IsViewableVideo(
+ 'video/ogg; charset=binary', 123))
+ self.assertTrue(attachment_helpers.IsViewableVideo('video/mp4', 123))
+ self.assertTrue(attachment_helpers.IsViewableVideo(
+ 'video/mp4; charset=binary', 123))
+ self.assertTrue(attachment_helpers.IsViewableVideo('video/mpg', 123))
+ self.assertTrue(attachment_helpers.IsViewableVideo(
+ 'video/mpg; charset=binary', 123))
+ self.assertTrue(attachment_helpers.IsViewableVideo('video/mpeg', 123))
+ self.assertTrue(attachment_helpers.IsViewableVideo(
+ 'video/mpeg; charset=binary', 123))
+ self.assertTrue(attachment_helpers.IsViewableVideo(
+ 'video/mpeg', 14 * 1024 * 1024))
+
+ self.assertFalse(attachment_helpers.IsViewableVideo('junk/bits', 123))
+ self.assertFalse(attachment_helpers.IsViewableVideo(
+ 'junk/bits; charset=binary', 123))
+ self.assertFalse(attachment_helpers.IsViewableVideo(
+ 'video/mp4', 16 * 1024 * 1024))
+
+ def testIsViewableText(self):
+ self.assertTrue(attachment_helpers.IsViewableText('text/plain', 0))
+ self.assertTrue(attachment_helpers.IsViewableText('text/plain', 1000))
+ self.assertTrue(attachment_helpers.IsViewableText('text/html', 1000))
+ self.assertFalse(
+ attachment_helpers.IsViewableText('text/plain', 200 * 1024 * 1024))
+ self.assertFalse(attachment_helpers.IsViewableText('image/jpeg', 200))
+ self.assertFalse(
+ attachment_helpers.IsViewableText('image/jpeg', 200 * 1024 * 1024))
+
+ def testSignAttachmentID(self):
+ pass # TODO(jrobbins): write tests
+
+ @patch('tracker.attachment_helpers.SignAttachmentID')
+ def testGetDownloadURL(self, mock_SignAttachmentID):
+ """The download URL is always our to attachment servlet."""
+ mock_SignAttachmentID.return_value = 67890
+ self.assertEqual(
+ 'attachment?aid=12345&signed_aid=67890',
+ attachment_helpers.GetDownloadURL(12345))
+
+ def testGetViewURL(self):
+ """The view URL may add &inline=1, or use our text attachment servlet."""
+ attach = tracker_pb2.Attachment(
+ attachment_id=1, mimetype='see below', filesize=1000)
+ download_url = 'attachment?aid=1&signed_aid=2'
+
+ # Viewable image.
+ attach.mimetype = 'image/jpeg'
+ self.assertEqual(
+ download_url + '&inline=1',
+ attachment_helpers.GetViewURL(attach, download_url, 'proj'))
+
+ # Viewable video.
+ attach.mimetype = 'video/mpeg'
+ self.assertEqual(
+ download_url + '&inline=1',
+ attachment_helpers.GetViewURL(attach, download_url, 'proj'))
+
+ # Viewable text file.
+ attach.mimetype = 'text/html'
+ self.assertEqual(
+ '/p/proj/issues/attachmentText?aid=1',
+ attachment_helpers.GetViewURL(attach, download_url, 'proj'))
+
+ # Something we don't support.
+ attach.mimetype = 'audio/mp3'
+ self.assertIsNone(
+ attachment_helpers.GetViewURL(attach, download_url, 'proj'))
+
+ def testGetThumbnailURL(self):
+ """The thumbnail URL may add param thumb=1 or not."""
+ attach = tracker_pb2.Attachment(
+ attachment_id=1, mimetype='see below', filesize=1000)
+ download_url = 'attachment?aid=1&signed_aid=2'
+
+ # Viewable image.
+ attach.mimetype = 'image/jpeg'
+ self.assertEqual(
+ download_url + '&inline=1&thumb=1',
+ attachment_helpers.GetThumbnailURL(attach, download_url))
+
+ # Viewable video.
+ attach.mimetype = 'video/mpeg'
+ self.assertIsNone(
+ # Video thumbs are displayed via GetVideoURL rather than this.
+ attachment_helpers.GetThumbnailURL(attach, download_url))
+
+ # Something that we don't thumbnail.
+ attach.mimetype = 'audio/mp3'
+ self.assertIsNone(attachment_helpers.GetThumbnailURL(attach, download_url))
+
+ def testGetVideoURL(self):
+ """The video URL is the same as the view URL for actual videos."""
+ attach = tracker_pb2.Attachment(
+ attachment_id=1, mimetype='see below', filesize=1000)
+ download_url = 'attachment?aid=1&signed_aid=2'
+
+ # Viewable video.
+ attach.mimetype = 'video/mpeg'
+ self.assertEqual(
+ download_url + '&inline=1',
+ attachment_helpers.GetVideoURL(attach, download_url))
+
+ # Anything that is not a video.
+ attach.mimetype = 'audio/mp3'
+ self.assertIsNone(attachment_helpers.GetVideoURL(attach, download_url))
+
diff --git a/tracker/test/component_helpers_test.py b/tracker/test/component_helpers_test.py
new file mode 100644
index 0000000..ee7f56c
--- /dev/null
+++ b/tracker/test/component_helpers_test.py
@@ -0,0 +1,113 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the component_helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from tracker import component_helpers
+from tracker import tracker_bizobj
+
+
+class ComponentHelpersTest(unittest.TestCase):
+
+ def setUp(self):
+ self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ self.cd1 = tracker_bizobj.MakeComponentDef(
+ 1, 789, 'FrontEnd', 'doc', False, [], [111], 0, 0)
+ self.cd2 = tracker_bizobj.MakeComponentDef(
+ 2, 789, 'FrontEnd>Splash', 'doc', False, [], [222], 0, 0)
+ self.cd3 = tracker_bizobj.MakeComponentDef(
+ 3, 789, 'BackEnd', 'doc', True, [], [111, 333], 0, 0)
+ self.config.component_defs = [self.cd1, self.cd2, self.cd3]
+ self.services = service_manager.Services(
+ user=fake.UserService(),
+ config=fake.ConfigService())
+ self.services.user.TestAddUser('a@example.com', 111)
+ self.services.user.TestAddUser('b@example.com', 222)
+ self.services.user.TestAddUser('c@example.com', 333)
+ self.mr = fake.MonorailRequest(self.services)
+ self.mr.cnxn = fake.MonorailConnection()
+
+ def testParseComponentRequest_Empty(self):
+ post_data = fake.PostData(admins=[''], cc=[''], labels=[''])
+ parsed = component_helpers.ParseComponentRequest(
+ self.mr, post_data, self.services)
+ self.assertEqual('', parsed.leaf_name)
+ self.assertEqual('', parsed.docstring)
+ self.assertEqual([], parsed.admin_usernames)
+ self.assertEqual([], parsed.cc_usernames)
+ self.assertEqual([], parsed.admin_ids)
+ self.assertEqual([], parsed.cc_ids)
+ self.assertFalse(self.mr.errors.AnyErrors())
+
+ def testParseComponentRequest_Normal(self):
+ post_data = fake.PostData(
+ leaf_name=['FrontEnd'],
+ docstring=['The server-side app that serves pages'],
+ deprecated=[False],
+ admins=['a@example.com'],
+ cc=['b@example.com, c@example.com'],
+ labels=['Hot, Cold'])
+ parsed = component_helpers.ParseComponentRequest(
+ self.mr, post_data, self.services)
+ self.assertEqual('FrontEnd', parsed.leaf_name)
+ self.assertEqual('The server-side app that serves pages', parsed.docstring)
+ self.assertEqual(['a@example.com'], parsed.admin_usernames)
+ self.assertEqual(['b@example.com', 'c@example.com'], parsed.cc_usernames)
+ self.assertEqual(['Hot', 'Cold'], parsed.label_strs)
+ self.assertEqual([111], parsed.admin_ids)
+ self.assertEqual([222, 333], parsed.cc_ids)
+ self.assertEqual([0, 1], parsed.label_ids)
+ self.assertFalse(self.mr.errors.AnyErrors())
+
+ def testParseComponentRequest_InvalidUser(self):
+ post_data = fake.PostData(
+ leaf_name=['FrontEnd'],
+ docstring=['The server-side app that serves pages'],
+ deprecated=[False],
+ admins=['a@example.com, invalid_user'],
+ cc=['b@example.com, c@example.com'],
+ labels=[''])
+ parsed = component_helpers.ParseComponentRequest(
+ self.mr, post_data, self.services)
+ self.assertEqual('FrontEnd', parsed.leaf_name)
+ self.assertEqual('The server-side app that serves pages', parsed.docstring)
+ self.assertEqual(['a@example.com', 'invalid_user'], parsed.admin_usernames)
+ self.assertEqual(['b@example.com', 'c@example.com'], parsed.cc_usernames)
+ self.assertEqual([111], parsed.admin_ids)
+ self.assertEqual([222, 333], parsed.cc_ids)
+ self.assertTrue(self.mr.errors.AnyErrors())
+ self.assertEqual('invalid_user unrecognized', self.mr.errors.member_admins)
+
+ def testGetComponentCcIDs(self):
+ issue = tracker_pb2.Issue()
+ issues_components_cc_ids = component_helpers.GetComponentCcIDs(
+ issue, self.config)
+ self.assertEqual(set(), issues_components_cc_ids)
+
+ issue.component_ids = [1, 2]
+ issues_components_cc_ids = component_helpers.GetComponentCcIDs(
+ issue, self.config)
+ self.assertEqual({111, 222}, issues_components_cc_ids)
+
+ def testGetCcIDsForComponentAndAncestors(self):
+ components_cc_ids = component_helpers.GetCcIDsForComponentAndAncestors(
+ self.config, self.cd1)
+ self.assertEqual({111}, components_cc_ids)
+
+ components_cc_ids = component_helpers.GetCcIDsForComponentAndAncestors(
+ self.config, self.cd2)
+ self.assertEqual({111, 222}, components_cc_ids)
+
+ components_cc_ids = component_helpers.GetCcIDsForComponentAndAncestors(
+ self.config, self.cd3)
+ self.assertEqual({111, 333}, components_cc_ids)
diff --git a/tracker/test/componentcreate_test.py b/tracker/test/componentcreate_test.py
new file mode 100644
index 0000000..1325d9b
--- /dev/null
+++ b/tracker/test/componentcreate_test.py
@@ -0,0 +1,143 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the componentcreate servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import permissions
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import componentcreate
+from tracker import tracker_bizobj
+
+import webapp2
+
+
+class ComponentCreateTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ user=fake.UserService(),
+ config=fake.ConfigService(),
+ project=fake.ProjectService())
+ self.servlet = componentcreate.ComponentCreate(
+ 'req', 'res', services=self.services)
+ self.project = self.services.project.TestAddProject('proj')
+ self.mr = testing_helpers.MakeMonorailRequest(
+ project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+ self.mr.auth.email = 'b@example.com'
+ self.config = self.services.config.GetProjectConfig(
+ 'fake cnxn', self.project.project_id)
+ self.services.config.StoreConfig('fake cnxn', self.config)
+ self.cd = tracker_bizobj.MakeComponentDef(
+ 1, self.project.project_id, 'BackEnd', 'doc', False, [], [111], 0,
+ 122)
+ self.config.component_defs = [self.cd]
+ self.services.user.TestAddUser('a@example.com', 111)
+ self.services.user.TestAddUser('b@example.com', 122)
+
+ def testAssertBasePermission(self):
+ # Anon users can never do it
+ self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.AssertBasePermission, self.mr)
+
+ # Project owner can do it.
+ self.mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+ self.servlet.AssertBasePermission(self.mr)
+
+ # Project member cannot do it
+ self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.AssertBasePermission, self.mr)
+ self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.AssertBasePermission, self.mr)
+
+ def testGatherPageData_CreatingAtTopLevel(self):
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.assertEqual(self.servlet.PROCESS_TAB_COMPONENTS,
+ page_data['admin_tab_mode'])
+ self.assertIsNone(page_data['parent_path'])
+
+ def testGatherPageData_CreatingASubComponent(self):
+ self.mr.component_path = 'BackEnd'
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.assertEqual(self.servlet.PROCESS_TAB_COMPONENTS,
+ page_data['admin_tab_mode'])
+ self.assertEqual('BackEnd', page_data['parent_path'])
+
+ def testProcessFormData_NotFound(self):
+ post_data = fake.PostData(
+ parent_path=['Monitoring'],
+ leaf_name=['Rules'],
+ docstring=['Detecting outages'],
+ deprecated=[False],
+ admins=[''],
+ cc=[''],
+ labels=[''])
+ self.assertRaises(
+ webapp2.HTTPException,
+ self.servlet.ProcessFormData, self.mr, post_data)
+
+ def testProcessFormData_Normal(self):
+ post_data = fake.PostData(
+ parent_path=['BackEnd'],
+ leaf_name=['DB'],
+ docstring=['A database'],
+ deprecated=[False],
+ admins=[''],
+ cc=[''],
+ labels=[''])
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.assertTrue('/adminComponents?saved=1&' in url)
+ config = self.services.config.GetProjectConfig(
+ self.mr.cnxn, self.mr.project_id)
+
+ cd = tracker_bizobj.FindComponentDef('BackEnd>DB', config)
+ self.assertEqual('BackEnd>DB', cd.path)
+ self.assertEqual('A database', cd.docstring)
+ self.assertEqual([], cd.admin_ids)
+ self.assertEqual([], cd.cc_ids)
+ self.assertTrue(cd.created > 0)
+ self.assertEqual(122, cd.creator_id)
+
+
+class ComponentCreateMethodsTest(unittest.TestCase):
+
+ def setUp(self):
+ self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ cd1 = tracker_bizobj.MakeComponentDef(
+ 1, 789, 'BackEnd', 'doc', False, [], [111], 0, 122)
+ cd2 = tracker_bizobj.MakeComponentDef(
+ 2, 789, 'BackEnd>DB', 'doc', True, [], [111], 0, 122)
+ self.config.component_defs = [cd1, cd2]
+
+ def testLeafNameErrorMessage_Invalid(self):
+ self.assertEqual(
+ 'Invalid component name',
+ componentcreate.LeafNameErrorMessage('', 'bad name', self.config))
+
+ def testLeafNameErrorMessage_AlreadyInUse(self):
+ self.assertEqual(
+ 'That name is already in use.',
+ componentcreate.LeafNameErrorMessage('', 'BackEnd', self.config))
+ self.assertEqual(
+ 'That name is already in use.',
+ componentcreate.LeafNameErrorMessage('BackEnd', 'DB', self.config))
+
+ def testLeafNameErrorMessage_OK(self):
+ self.assertIsNone(
+ componentcreate.LeafNameErrorMessage('', 'FrontEnd', self.config))
+ self.assertIsNone(
+ componentcreate.LeafNameErrorMessage('BackEnd', 'Search', self.config))
diff --git a/tracker/test/componentdetail_test.py b/tracker/test/componentdetail_test.py
new file mode 100644
index 0000000..18886bc
--- /dev/null
+++ b/tracker/test/componentdetail_test.py
@@ -0,0 +1,320 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the componentdetail servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from mock import Mock, patch
+
+import mox
+
+from features import filterrules_helpers
+from framework import permissions
+from proto import project_pb2
+from services import service_manager
+from services import template_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import componentdetail
+from tracker import tracker_bizobj
+
+import webapp2
+
+
+class ComponentDetailTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ user=fake.UserService(),
+ issue=fake.IssueService(),
+ config=fake.ConfigService(),
+ template=Mock(spec=template_svc.TemplateService),
+ project=fake.ProjectService())
+ self.servlet = componentdetail.ComponentDetail(
+ 'req', 'res', services=self.services)
+ self.project = self.services.project.TestAddProject('proj')
+ self.mr = testing_helpers.MakeMonorailRequest(
+ project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+ self.mr.auth.email = 'b@example.com'
+ self.config = self.services.config.GetProjectConfig(
+ 'fake cnxn', self.project.project_id)
+ self.services.config.StoreConfig('fake cnxn', self.config)
+ self.cd = tracker_bizobj.MakeComponentDef(
+ 1, self.project.project_id, 'BackEnd', 'doc', False, [], [111], 100000,
+ 122, 10000000, 133)
+ self.config.component_defs = [self.cd]
+ self.services.user.TestAddUser('a@example.com', 111)
+ self.services.user.TestAddUser('b@example.com', 122)
+ self.services.user.TestAddUser('c@example.com', 133)
+ self.mr.component_path = 'BackEnd'
+
+ self.mox = mox.Mox()
+
+ def tearDown(self):
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+
+ def testGetComponentDef_NotFound(self):
+ self.mr.component_path = 'NeverHeardOfIt'
+ self.assertRaises(
+ webapp2.HTTPException,
+ self.servlet._GetComponentDef, self.mr)
+
+ def testGetComponentDef_Normal(self):
+ actual_config, actual_cd = self.servlet._GetComponentDef(self.mr)
+ self.assertEqual(self.config, actual_config)
+ self.assertEqual(self.cd, actual_cd)
+
+ def testAssertBasePermission_AnyoneCanView(self):
+ self.servlet.AssertBasePermission(self.mr)
+ self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+ self.servlet.AssertBasePermission(self.mr)
+ self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+ self.servlet.AssertBasePermission(self.mr)
+ self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+ self.servlet.AssertBasePermission(self.mr)
+
+ def testAssertBasePermission_MembersOnly(self):
+ self.project.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+ # The project members can view the component definition.
+ self.servlet.AssertBasePermission(self.mr)
+ self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+ self.servlet.AssertBasePermission(self.mr)
+ self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+ self.servlet.AssertBasePermission(self.mr)
+ # Non-member is not allowed to view anything in the project.
+ self.mr.perms = permissions.EMPTY_PERMISSIONSET
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.AssertBasePermission, self.mr)
+
+ def testGatherPageData_ReadWrite(self):
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.assertEqual(self.servlet.PROCESS_TAB_COMPONENTS,
+ page_data['admin_tab_mode'])
+ self.assertTrue(page_data['allow_edit'])
+ self.assertEqual([], page_data['initial_admins'])
+ component_def_view = page_data['component_def']
+ self.assertEqual('BackEnd', component_def_view.path)
+
+ def testGatherPageData_ReadOnly(self):
+ self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.assertEqual(self.servlet.PROCESS_TAB_COMPONENTS,
+ page_data['admin_tab_mode'])
+ self.assertFalse(page_data['allow_edit'])
+ self.assertFalse(page_data['allow_delete'])
+ self.assertEqual([], page_data['initial_admins'])
+ component_def_view = page_data['component_def']
+ self.assertEqual('BackEnd', component_def_view.path)
+
+ def testGatherPageData_ObscuredCreatorModifier(self):
+ page_data = self.servlet.GatherPageData(self.mr)
+
+ self.assertEqual('b...@example.com', page_data['creator'].display_name)
+ self.assertEqual('/u/122/', page_data['creator'].profile_url)
+ self.assertEqual('Jan 1970', page_data['created'])
+ self.assertEqual('c...@example.com', page_data['modifier'].display_name)
+ self.assertEqual('/u/133/', page_data['modifier'].profile_url)
+ self.assertEqual('Apr 1970', page_data['modified'])
+
+ def testGatherPageData_VisibleCreatorModifierForAdmin(self):
+ self.mr.auth.user_pb.is_site_admin = True
+ page_data = self.servlet.GatherPageData(self.mr)
+
+ self.assertEqual('b@example.com', page_data['creator'].display_name)
+ self.assertEqual('/u/b@example.com/', page_data['creator'].profile_url)
+ self.assertEqual('Jan 1970', page_data['created'])
+ self.assertEqual('c@example.com', page_data['modifier'].display_name)
+ self.assertEqual('/u/c@example.com/', page_data['modifier'].profile_url)
+ self.assertEqual('Apr 1970', page_data['modified'])
+
+ def testGatherPageData_VisibleCreatorForSelf(self):
+ self.mr.auth.user_id = 122
+ page_data = self.servlet.GatherPageData(self.mr)
+
+ self.assertEqual('b@example.com', page_data['creator'].display_name)
+ self.assertEqual('/u/b@example.com/', page_data['creator'].profile_url)
+ self.assertEqual('Jan 1970', page_data['created'])
+ # Modifier should still be obscured.
+ self.assertEqual('c...@example.com', page_data['modifier'].display_name)
+ self.assertEqual('/u/133/', page_data['modifier'].profile_url)
+ self.assertEqual('Apr 1970', page_data['modified'])
+
+ def testGatherPageData_VisibleCreatorModifierForUnobscuredEmail(self):
+ creator = self.services.user.GetUser(self.mr.cnxn, 122)
+ creator.obscure_email = False
+ modifier = self.services.user.GetUser(self.mr.cnxn, 133)
+ modifier.obscure_email = False
+ page_data = self.servlet.GatherPageData(self.mr)
+
+ self.assertEqual('b@example.com', page_data['creator'].display_name)
+ self.assertEqual('/u/b@example.com/', page_data['creator'].profile_url)
+ self.assertEqual('Jan 1970', page_data['created'])
+ self.assertEqual('c@example.com', page_data['modifier'].display_name)
+ self.assertEqual('/u/c@example.com/', page_data['modifier'].profile_url)
+ self.assertEqual('Apr 1970', page_data['modified'])
+
+ def testGatherPageData_WithSubComponents(self):
+ subcd = tracker_bizobj.MakeComponentDef(
+ 2, self.project.project_id, 'BackEnd>Worker', 'doc', False, [], [111],
+ 0, 122)
+ self.config.component_defs.append(subcd)
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.assertFalse(page_data['allow_delete'])
+ self.assertEqual([subcd], page_data['subcomponents'])
+
+ def testGatherPageData_WithTemplates(self):
+ self.services.template.TemplatesWithComponent.return_value = ['template']
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.assertFalse(page_data['allow_delete'])
+ self.assertEqual(['template'], page_data['templates'])
+
+ def testProcessFormData_Permission(self):
+ """Only owners can edit components."""
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+ mr.component_path = 'BackEnd'
+ post_data = fake.PostData(
+ name=['BackEnd'],
+ deletecomponent=['Submit'])
+ self.assertRaises(permissions.PermissionException,
+ self.servlet.ProcessFormData, mr, post_data)
+
+ self.servlet.ProcessFormData(self.mr, post_data)
+
+ def testProcessFormData_Delete(self):
+ post_data = fake.PostData(
+ name=['BackEnd'],
+ deletecomponent=['Submit'])
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.assertTrue('/adminComponents?deleted=1&' in url)
+ self.assertIsNone(
+ tracker_bizobj.FindComponentDef('BackEnd', self.config))
+
+ def testProcessFormData_Delete_WithSubComponent(self):
+ subcd = tracker_bizobj.MakeComponentDef(
+ 2, self.project.project_id, 'BackEnd>Worker', 'doc', False, [], [111],
+ 0, 122)
+ self.config.component_defs.append(subcd)
+
+ post_data = fake.PostData(
+ name=['BackEnd'],
+ deletecomponent=['Submit'])
+ with self.assertRaises(permissions.PermissionException) as cm:
+ self.servlet.ProcessFormData(self.mr, post_data)
+ self.assertEqual(
+ 'User tried to delete component that had subcomponents',
+ cm.exception.message)
+
+ def testProcessFormData_Edit(self):
+ post_data = fake.PostData(
+ leaf_name=['BackEnd'],
+ docstring=['This is where the magic happens'],
+ deprecated=[True],
+ admins=['a@example.com'],
+ cc=['a@example.com'],
+ labels=['Hot, Cold'])
+
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+
+ self.mox.VerifyAll()
+ self.assertTrue('/components/detail?component=BackEnd&saved=1&' in url)
+ config = self.services.config.GetProjectConfig(
+ self.mr.cnxn, self.mr.project_id)
+
+ cd = tracker_bizobj.FindComponentDef('BackEnd', config)
+ self.assertEqual('BackEnd', cd.path)
+ self.assertEqual(
+ 'This is where the magic happens',
+ cd.docstring)
+ self.assertEqual(True, cd.deprecated)
+ self.assertEqual([111], cd.admin_ids)
+ self.assertEqual([111], cd.cc_ids)
+
+ def testProcessDeleteComponent(self):
+ self.servlet._ProcessDeleteComponent(self.mr, self.cd)
+ self.assertIsNone(
+ tracker_bizobj.FindComponentDef('BackEnd', self.config))
+
+ def testProcessEditComponent(self):
+ post_data = fake.PostData(
+ leaf_name=['BackEnd'],
+ docstring=['This is where the magic happens'],
+ deprecated=[True],
+ admins=['a@example.com'],
+ cc=['a@example.com'],
+ labels=['Hot, Cold'])
+
+ self.servlet._ProcessEditComponent(
+ self.mr, post_data, self.config, self.cd)
+
+ self.mox.VerifyAll()
+ config = self.services.config.GetProjectConfig(
+ self.mr.cnxn, self.mr.project_id)
+ cd = tracker_bizobj.FindComponentDef('BackEnd', config)
+ self.assertEqual('BackEnd', cd.path)
+ self.assertEqual(
+ 'This is where the magic happens',
+ cd.docstring)
+ self.assertEqual(True, cd.deprecated)
+ self.assertEqual([111], cd.admin_ids)
+ self.assertEqual([111], cd.cc_ids)
+ # Assert that creator and created were not updated.
+ self.assertEqual(122, cd.creator_id)
+ self.assertEqual(100000, cd.created)
+ # Assert that modifier and modified were updated.
+ self.assertEqual(122, cd.modifier_id)
+ self.assertTrue(cd.modified > 10000000)
+
+ def testProcessEditComponent_RenameWithSubComponents(self):
+ subcd_1 = tracker_bizobj.MakeComponentDef(
+ 2, self.project.project_id, 'BackEnd>Worker1', 'doc', False, [], [111],
+ 0, 125, 3, 126)
+ subcd_2 = tracker_bizobj.MakeComponentDef(
+ 3, self.project.project_id, 'BackEnd>Worker2', 'doc', False, [], [111],
+ 0, 125, 4, 127)
+ self.config.component_defs.extend([subcd_1, subcd_2])
+
+ self.mox.StubOutWithMock(filterrules_helpers, 'RecomputeAllDerivedFields')
+ filterrules_helpers.RecomputeAllDerivedFields(
+ self.mr.cnxn, self.services, self.mr.project, self.config)
+ self.mox.ReplayAll()
+ post_data = fake.PostData(
+ leaf_name=['BackEnds'],
+ docstring=['This is where the magic happens'],
+ deprecated=[True],
+ admins=['a@example.com'],
+ cc=['a@example.com'],
+ labels=[''])
+
+ self.servlet._ProcessEditComponent(
+ self.mr, post_data, self.config, self.cd)
+
+ self.mox.VerifyAll()
+ config = self.services.config.GetProjectConfig(
+ self.mr.cnxn, self.mr.project_id)
+ cd = tracker_bizobj.FindComponentDef('BackEnds', config)
+ self.assertEqual('BackEnds', cd.path)
+ subcd_1 = tracker_bizobj.FindComponentDef('BackEnds>Worker1', config)
+ self.assertEqual('BackEnds>Worker1', subcd_1.path)
+ # Assert that creator and modifier have not changed for subcd_1.
+ self.assertEqual(125, subcd_1.creator_id)
+ self.assertEqual(0, subcd_1.created)
+ self.assertEqual(126, subcd_1.modifier_id)
+ self.assertEqual(3, subcd_1.modified)
+
+ subcd_2 = tracker_bizobj.FindComponentDef('BackEnds>Worker2', config)
+ self.assertEqual('BackEnds>Worker2', subcd_2.path)
+ # Assert that creator and modifier have not changed for subcd_2.
+ self.assertEqual(125, subcd_2.creator_id)
+ self.assertEqual(0, subcd_2.created)
+ self.assertEqual(127, subcd_2.modifier_id)
+ self.assertEqual(4, subcd_2.modified)
diff --git a/tracker/test/field_helpers_test.py b/tracker/test/field_helpers_test.py
new file mode 100644
index 0000000..f49a147
--- /dev/null
+++ b/tracker/test/field_helpers_test.py
@@ -0,0 +1,1276 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the field_helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import time
+import unittest
+import re
+
+from framework import exceptions
+from framework import permissions
+from framework import template_helpers
+from proto import project_pb2
+from proto import tracker_pb2
+from services import service_manager
+from services import config_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import field_helpers
+from tracker import tracker_bizobj
+
+
+class FieldHelpersTest(unittest.TestCase):
+
+ def setUp(self):
+ self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ self.config.well_known_labels.append(tracker_pb2.LabelDef(
+ label='OldLabel', label_docstring='Do not use any longer',
+ deprecated=True))
+
+ self.services = service_manager.Services(
+ issue=fake.IssueService(),
+ usergroup=fake.UserGroupService(),
+ config=fake.ConfigService(),
+ user=fake.UserService())
+ self.project = fake.Project()
+ self.mr = testing_helpers.MakeMonorailRequest(
+ project=self.project, services=self.services)
+ self.mr.cnxn = fake.MonorailConnection()
+ self.errors = template_helpers.EZTError()
+
+ def testListApplicableFieldDefs(self):
+ issue_1 = fake.MakeTestIssue(
+ 789,
+ 1,
+ 'sum',
+ 'New',
+ 111,
+ issue_id=78901,
+ labels=['type-defect', 'other-label'])
+ issue_2 = fake.MakeTestIssue(
+ 789,
+ 2,
+ 'sum',
+ 'New',
+ 111,
+ issue_id=78902,
+ labels=['type-feedback', 'other-label1'])
+ issue_3 = fake.MakeTestIssue(
+ 789,
+ 3,
+ 'sum',
+ 'New',
+ 111,
+ issue_id=78903,
+ labels=['type-defect'],
+ approval_values=[
+ tracker_pb2.ApprovalValue(approval_id=3),
+ tracker_pb2.ApprovalValue(approval_id=5)
+ ])
+ issue_4 = fake.MakeTestIssue(
+ 789, 4, 'sum', 'New', 111, issue_id=78904) # test no labels at all
+ issue_5 = fake.MakeTestIssue(
+ 789,
+ 5,
+ 'sum',
+ 'New',
+ 111,
+ issue_id=78905,
+ labels=['type'], # test labels ignored
+ approval_values=[tracker_pb2.ApprovalValue(approval_id=5)])
+ self.services.issue.TestAddIssue(issue_1)
+ self.services.issue.TestAddIssue(issue_2)
+ self.services.issue.TestAddIssue(issue_3)
+ self.services.issue.TestAddIssue(issue_4)
+ self.services.issue.TestAddIssue(issue_5)
+ fd_1 = tracker_pb2.FieldDef(
+ field_name='FirstField',
+ field_id=1,
+ field_type=tracker_pb2.FieldTypes.STR_TYPE,
+ applicable_type='feedback') # applicable
+ fd_2 = tracker_pb2.FieldDef(
+ field_name='SecField',
+ field_id=2,
+ field_type=tracker_pb2.FieldTypes.INT_TYPE,
+ applicable_type='no') # not applicable
+ fd_3 = tracker_pb2.FieldDef(
+ field_name='LegalApproval',
+ field_id=3,
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE,
+ applicable_type='') # applicable
+ fd_4 = tracker_pb2.FieldDef(
+ field_name='UserField',
+ field_id=4,
+ field_type=tracker_pb2.FieldTypes.USER_TYPE,
+ applicable_type='') # applicable
+ fd_5 = tracker_pb2.FieldDef(
+ field_name='DogApproval',
+ field_id=5,
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE,
+ applicable_type='') # applicable
+ fd_6 = tracker_pb2.FieldDef(
+ field_name='SixthField',
+ field_id=6,
+ field_type=tracker_pb2.FieldTypes.INT_TYPE,
+ applicable_type='Defect') # applicable
+ fd_7 = tracker_pb2.FieldDef(
+ field_name='CatApproval',
+ field_id=7,
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE,
+ applicable_type='') # not applicable
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ config.field_defs = [fd_1, fd_2, fd_3, fd_4, fd_5, fd_6, fd_7]
+ issues = [issue_1, issue_2, issue_3, issue_4, issue_5]
+
+ actual_fds = field_helpers.ListApplicableFieldDefs(issues, config)
+ self.assertEqual(actual_fds, [fd_1, fd_3, fd_4, fd_5, fd_6])
+
+ def testParseFieldDefRequest_Empty(self):
+ post_data = fake.PostData()
+ parsed = field_helpers.ParseFieldDefRequest(post_data, self.config)
+ self.assertEqual('', parsed.field_name)
+ self.assertEqual(None, parsed.field_type_str)
+ self.assertEqual(None, parsed.min_value)
+ self.assertEqual(None, parsed.max_value)
+ self.assertEqual(None, parsed.regex)
+ self.assertFalse(parsed.needs_member)
+ self.assertEqual('', parsed.needs_perm)
+ self.assertEqual('', parsed.grants_perm)
+ self.assertEqual(0, parsed.notify_on)
+ self.assertFalse(parsed.is_required)
+ self.assertFalse(parsed.is_niche)
+ self.assertFalse(parsed.is_multivalued)
+ self.assertEqual('', parsed.field_docstring)
+ self.assertEqual('', parsed.choices_text)
+ self.assertEqual('', parsed.applicable_type)
+ self.assertEqual('', parsed.applicable_predicate)
+ unchanged_labels = [
+ (label_def.label, label_def.label_docstring, label_def.deprecated)
+ for label_def in self.config.well_known_labels]
+ self.assertEqual(unchanged_labels, parsed.revised_labels)
+ self.assertEqual('', parsed.approvers_str)
+ self.assertEqual('', parsed.survey)
+ self.assertEqual('', parsed.parent_approval_name)
+ self.assertFalse(parsed.is_phase_field)
+
+ def testParseFieldDefRequest_Normal(self):
+ post_data = fake.PostData(
+ name=['somefield'],
+ field_type=['INT_TYPE'],
+ min_value=['11'],
+ max_value=['99'],
+ regex=['.*'],
+ needs_member=['Yes'],
+ needs_perm=['Commit'],
+ grants_perm=['View'],
+ notify_on=['any_comment'],
+ importance=['required'],
+ is_multivalued=['Yes'],
+ docstring=['It is just some field'],
+ choices=['Hot = Lots of activity\nCold = Not much activity'],
+ applicable_type=['Defect'],
+ approver_names=['approver@chromium.org'],
+ survey=['Are there UX changes?'],
+ parent_approval_name=['UIReview'],
+ is_phase_field=['on'],
+ )
+ parsed = field_helpers.ParseFieldDefRequest(post_data, self.config)
+ self.assertEqual('somefield', parsed.field_name)
+ self.assertEqual('INT_TYPE', parsed.field_type_str)
+ self.assertEqual(11, parsed.min_value)
+ self.assertEqual(99, parsed.max_value)
+ self.assertEqual('.*', parsed.regex)
+ self.assertTrue(parsed.needs_member)
+ self.assertEqual('Commit', parsed.needs_perm)
+ self.assertEqual('View', parsed.grants_perm)
+ self.assertEqual(1, parsed.notify_on)
+ self.assertTrue(parsed.is_required)
+ self.assertFalse(parsed.is_niche)
+ self.assertTrue(parsed.is_multivalued)
+ self.assertEqual('It is just some field', parsed.field_docstring)
+ self.assertEqual('Hot = Lots of activity\nCold = Not much activity',
+ parsed.choices_text)
+ self.assertEqual('Defect', parsed.applicable_type)
+ self.assertEqual('', parsed.applicable_predicate)
+ unchanged_labels = [
+ (label_def.label, label_def.label_docstring, label_def.deprecated)
+ for label_def in self.config.well_known_labels]
+ new_labels = [
+ ('somefield-Hot', 'Lots of activity', False),
+ ('somefield-Cold', 'Not much activity', False)]
+ self.assertEqual(unchanged_labels + new_labels, parsed.revised_labels)
+ self.assertEqual('approver@chromium.org', parsed.approvers_str)
+ self.assertEqual('Are there UX changes?', parsed.survey)
+ self.assertEqual('UIReview', parsed.parent_approval_name)
+ self.assertTrue(parsed.is_phase_field)
+
+ def testParseFieldDefRequest_PreventPhaseApprovals(self):
+ post_data = fake.PostData(
+ field_type=['approval_type'],
+ is_phase_field=['on'],
+ )
+ parsed = field_helpers.ParseFieldDefRequest(post_data, self.config)
+ self.assertEqual('approval_type', parsed.field_type_str)
+ self.assertFalse(parsed.is_phase_field)
+
+ def testParseChoicesIntoWellKnownLabels_NewFieldDef(self):
+ choices_text = 'Hot = Lots of activity\nCold = Not much activity'
+ field_name = 'somefield'
+ revised_labels = field_helpers._ParseChoicesIntoWellKnownLabels(
+ choices_text, field_name, self.config, 'enum_type')
+ unchanged_labels = [
+ (label_def.label, label_def.label_docstring, label_def.deprecated)
+ for label_def in self.config.well_known_labels]
+ new_labels = [
+ ('somefield-Hot', 'Lots of activity', False),
+ ('somefield-Cold', 'Not much activity', False)]
+ self.assertEqual(unchanged_labels + new_labels, revised_labels)
+
+ def testParseChoicesIntoWellKnownLabels_ConvertExistingLabel(self):
+ choices_text = 'High = Must be fixed\nMedium = Might slip'
+ field_name = 'Priority'
+ revised_labels = field_helpers._ParseChoicesIntoWellKnownLabels(
+ choices_text, field_name, self.config, 'enum_type')
+ kept_labels = [
+ (label_def.label, label_def.label_docstring, label_def.deprecated)
+ for label_def in self.config.well_known_labels
+ if not label_def.label.startswith('Priority-')]
+ new_labels = [
+ ('Priority-High', 'Must be fixed', False),
+ ('Priority-Medium', 'Might slip', False)]
+ self.maxDiff = None
+ self.assertEqual(kept_labels + new_labels, revised_labels)
+
+ # TODO(jojwang): test this separately
+ # test None field_type_str, updating existing fielddef
+ self.config.field_defs.append(tracker_pb2.FieldDef(
+ field_id=13, field_name='Priority',
+ field_type=tracker_pb2.FieldTypes.ENUM_TYPE))
+ revised_labels = field_helpers._ParseChoicesIntoWellKnownLabels(
+ choices_text, field_name, self.config, None)
+ self.assertEqual(kept_labels + new_labels, revised_labels)
+
+ def testParseChoicesIntoWellKnownLabels_NotEnumField(self):
+ choices_text = ''
+ field_name = 'NotEnum'
+ self.config.well_known_labels = [
+ tracker_pb2.LabelDef(label='NotEnum-Should'),
+ tracker_pb2.LabelDef(label='NotEnum-Not-Be-Masked')]
+ revised_labels = field_helpers._ParseChoicesIntoWellKnownLabels(
+ choices_text, field_name, self.config, 'str_type')
+ new_labels = [
+ ('NotEnum-Should', None, False),
+ ('NotEnum-Not-Be-Masked', None, False)]
+ self.assertEqual(new_labels, revised_labels)
+
+ # TODO(jojwang): test this separately
+ # test None field_type_str, updating existing fielddef
+ self.config.field_defs.append(tracker_pb2.FieldDef(
+ field_id=13, field_name='NotEnum',
+ field_type=tracker_pb2.FieldTypes.STR_TYPE))
+ revised_labels = field_helpers._ParseChoicesIntoWellKnownLabels(
+ choices_text, field_name, self.config, None)
+ self.assertEqual(revised_labels, new_labels)
+
+ def testShiftEnumFieldsIntoLabels_Empty(self):
+ labels = []
+ labels_remove = []
+ field_val_strs = {}
+ field_val_strs_remove = {}
+ field_helpers.ShiftEnumFieldsIntoLabels(
+ labels, labels_remove, field_val_strs, field_val_strs_remove,
+ self.config)
+ self.assertEqual([], labels)
+ self.assertEqual([], labels_remove)
+ self.assertEqual({}, field_val_strs)
+ self.assertEqual({}, field_val_strs_remove)
+
+ def testShiftEnumFieldsIntoLabels_NoOp(self):
+ labels = ['Security', 'Performance', 'Pri-1', 'M-2']
+ labels_remove = ['ReleaseBlock']
+ field_val_strs = {123: ['CPU']}
+ field_val_strs_remove = {234: ['Small']}
+ field_helpers.ShiftEnumFieldsIntoLabels(
+ labels, labels_remove, field_val_strs, field_val_strs_remove,
+ self.config)
+ self.assertEqual(['Security', 'Performance', 'Pri-1', 'M-2'], labels)
+ self.assertEqual(['ReleaseBlock'], labels_remove)
+ self.assertEqual({123: ['CPU']}, field_val_strs)
+ self.assertEqual({234: ['Small']}, field_val_strs_remove)
+
+ def testShiftEnumFieldsIntoLabels_FoundSomeEnumFields(self):
+ self.config.field_defs.append(
+ tracker_bizobj.MakeFieldDef(
+ 123, 789, 'Component', tracker_pb2.FieldTypes.ENUM_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action', 'What HW part is affected?',
+ False))
+ self.config.field_defs.append(
+ tracker_bizobj.MakeFieldDef(
+ 234, 789, 'Size', tracker_pb2.FieldTypes.ENUM_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action', 'How big is this work item?',
+ False))
+ labels = ['Security', 'Performance', 'Pri-1', 'M-2']
+ labels_remove = ['ReleaseBlock']
+ field_val_strs = {123: ['CPU']}
+ field_val_strs_remove = {234: ['Small']}
+ field_helpers.ShiftEnumFieldsIntoLabels(
+ labels, labels_remove, field_val_strs, field_val_strs_remove,
+ self.config)
+ self.assertEqual(
+ ['Security', 'Performance', 'Pri-1', 'M-2', 'Component-CPU'],
+ labels)
+ self.assertEqual(['ReleaseBlock', 'Size-Small'], labels_remove)
+ self.assertEqual({}, field_val_strs)
+ self.assertEqual({}, field_val_strs_remove)
+
+ def testReviseApprovals_New(self):
+ self.config.field_defs.append(
+ tracker_bizobj.MakeFieldDef(
+ 123, 789, 'UX Review', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+ 'Approval for UX review', False))
+ existing_approvaldef = tracker_pb2.ApprovalDef(
+ approval_id=123, approver_ids=[101, 102], survey='')
+ self.config.approval_defs = [existing_approvaldef]
+ revised_approvals = field_helpers.ReviseApprovals(
+ 124, [103], '', self.config)
+ self.assertEqual(len(revised_approvals), 2)
+ self.assertEqual(revised_approvals,
+ [(123, [101, 102], ''), (124, [103], '')])
+
+ def testReviseApprovals_Existing(self):
+ existing_approvaldef = tracker_pb2.ApprovalDef(
+ approval_id=123, approver_ids=[101, 102], survey='')
+ self.config.approval_defs = [existing_approvaldef]
+ revised_approvals = field_helpers.ReviseApprovals(
+ 123, [103], '', self.config)
+ self.assertEqual(revised_approvals, [(123, [103], '')])
+
+ def testParseOneFieldValue_IntType(self):
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'Foo', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ fv = field_helpers.ParseOneFieldValue(
+ self.mr.cnxn, self.services.user, fd, '8675309')
+ self.assertEqual(fv.field_id, 123)
+ self.assertEqual(fv.int_value, 8675309)
+
+ def testParseOneFieldValue_StrType(self):
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'Foo', tracker_pb2.FieldTypes.STR_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ fv = field_helpers.ParseOneFieldValue(
+ self.mr.cnxn, self.services.user, fd, '8675309')
+ self.assertEqual(fv.field_id, 123)
+ self.assertEqual(fv.str_value, '8675309')
+
+ def testParseOneFieldValue_UserType(self):
+ self.services.user.TestAddUser('user@example.com', 111)
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'Foo', tracker_pb2.FieldTypes.USER_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ fv = field_helpers.ParseOneFieldValue(
+ self.mr.cnxn, self.services.user, fd, 'user@example.com')
+ self.assertEqual(fv.field_id, 123)
+ self.assertEqual(fv.user_id, 111)
+
+ def testParseOneFieldValue_DateType(self):
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'Deadline', tracker_pb2.FieldTypes.DATE_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ fv = field_helpers.ParseOneFieldValue(
+ self.mr.cnxn, self.services.user, fd, '2009-02-13')
+ self.assertEqual(fv.field_id, 123)
+ self.assertEqual(fv.date_value, 1234483200)
+
+ def testParseOneFieldValue_UrlType(self):
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'Design Doc', tracker_pb2.FieldTypes.URL_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ fv = field_helpers.ParseOneFieldValue(
+ self.mr.cnxn, self.services.user, fd, 'www.google.com')
+ self.assertEqual(fv.field_id, 123)
+ self.assertEqual(fv.url_value, 'http://www.google.com')
+
+ def testParseOneFieldValue(self):
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'Target', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'milestone target',
+ False, is_phase_field=True)
+ phase_fvs = field_helpers.ParseOnePhaseFieldValue(
+ self.mr.cnxn, self.services.user, fd, '70', [30, 40])
+ self.assertEqual(len(phase_fvs), 2)
+ self.assertEqual(phase_fvs[0].phase_id, 30)
+ self.assertEqual(phase_fvs[1].phase_id, 40)
+
+ def testParseFieldValues_Empty(self):
+ field_val_strs = {}
+ phase_field_val_strs = {}
+ field_values = field_helpers.ParseFieldValues(
+ self.mr.cnxn, self.services.user, field_val_strs,
+ phase_field_val_strs, self.config)
+ self.assertEqual([], field_values)
+
+ def testParseFieldValues_EmptyPhases(self):
+ field_val_strs = {126: ['70']}
+ phase_field_val_strs = {}
+ fd_phase = tracker_bizobj.MakeFieldDef(
+ 126, 789, 'Target', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'milestone target',
+ False, is_phase_field=True)
+ self.config.field_defs.extend([fd_phase])
+ field_values = field_helpers.ParseFieldValues(
+ self.mr.cnxn, self.services.user, field_val_strs,
+ phase_field_val_strs, self.config)
+ self.assertEqual([], field_values)
+
+ def testParseFieldValues_Normal(self):
+ fd_int = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ fd_date = tracker_bizobj.MakeFieldDef(
+ 124, 789, 'Deadline', tracker_pb2.FieldTypes.DATE_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ fd_url = tracker_bizobj.MakeFieldDef(
+ 125, 789, 'Design Doc', tracker_pb2.FieldTypes.URL_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ fd_phase = tracker_bizobj.MakeFieldDef(
+ 126, 789, 'Target', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'milestone target',
+ False, is_phase_field=True)
+ self.config.field_defs.extend([fd_int, fd_date, fd_url, fd_phase])
+ field_val_strs = {
+ 123: ['80386', '68040'],
+ 124: ['2009-02-13'],
+ 125: ['www.google.com'],
+ }
+ phase_field_val_strs = {
+ 126: {'beta': ['89'],
+ 'stable': ['70'],
+ 'missing': ['234'],
+ }}
+ field_values = field_helpers.ParseFieldValues(
+ self.mr.cnxn, self.services.user, field_val_strs,
+ phase_field_val_strs, self.config,
+ phase_ids_by_name={'stable': [30, 40], 'beta': [88]})
+ fv1 = tracker_bizobj.MakeFieldValue(
+ 123, 80386, None, None, None, None, False)
+ fv2 = tracker_bizobj.MakeFieldValue(
+ 123, 68040, None, None, None, None, False)
+ fv3 = tracker_bizobj.MakeFieldValue(
+ 124, None, None, None, 1234483200, None, False)
+ fv4 = tracker_bizobj.MakeFieldValue(
+ 125, None, None, None, None, 'http://www.google.com', False)
+ fv5 = tracker_bizobj.MakeFieldValue(
+ 126, 89, None, None, None, None, False, phase_id=88)
+ fv6 = tracker_bizobj.MakeFieldValue(
+ 126, 70, None, None, None, None, False, phase_id=30)
+ fv7 = tracker_bizobj.MakeFieldValue(
+ 126, 70, None, None, None, None, False, phase_id=40)
+ self.assertEqual([fv1, fv2, fv3, fv4, fv5, fv6, fv7], field_values)
+
+ def test_IntType(self):
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ fv = tracker_bizobj.MakeFieldValue(123, 8086, None, None, None, None, False)
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, fv)
+ self.assertIsNone(msg)
+
+ fd.min_value = 1
+ fd.max_value = 999
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, fv)
+ self.assertEqual('Value must be <= 999.', msg)
+
+ fv.int_value = 0
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, fv)
+ self.assertEqual('Value must be >= 1.', msg)
+
+ def test_StrType(self):
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.STR_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ fv = tracker_bizobj.MakeFieldValue(
+ 123, None, 'i386', None, None, None, False)
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, fv)
+ self.assertIsNone(msg)
+
+ fd.regex = r'^\d*$'
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, fv)
+ self.assertEqual(r'Value must match regular expression: ^\d*$.', msg)
+
+ fv.str_value = '386'
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, fv)
+ self.assertIsNone(msg)
+
+ def test_UserType(self):
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'Fake Field', tracker_pb2.FieldTypes.USER_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+
+ self.services.user.TestAddUser('owner@example.com', 111)
+ self.mr.project.owner_ids.extend([111])
+ owner = tracker_bizobj.MakeFieldValue(
+ fd.field_id, None, None, 111, None, None, False)
+
+ self.services.user.TestAddUser('committer@example.com', 222)
+ self.mr.project.committer_ids.extend([222])
+ self.mr.project.extra_perms = [
+ project_pb2.Project.ExtraPerms(
+ member_id=222,
+ perms=['FooPerm'])]
+ committer = tracker_bizobj.MakeFieldValue(
+ fd.field_id, None, None, 222, None, None, False)
+
+ self.services.user.TestAddUser('user@example.com', 333)
+ user = tracker_bizobj.MakeFieldValue(
+ fd.field_id, None, None, 333, None, None, False)
+
+ # Normal
+ for fv in (owner, committer, user):
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, fv)
+ self.assertIsNone(msg)
+
+ # Needs to be member (user isn't a member).
+ fd.needs_member = True
+ for fv in (owner, committer):
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, fv)
+ self.assertIsNone(msg)
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, user)
+ self.assertEqual('User must be a member of the project.', msg)
+
+ # Needs DeleteAny permission (only owner has it).
+ fd.needs_perm = 'DeleteAny'
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, owner)
+ self.assertIsNone(msg)
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, committer)
+ self.assertEqual('User must have permission "DeleteAny".', msg)
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, user)
+ self.assertEqual('User must be a member of the project.', msg)
+
+ # Needs custom permission (only committer has it).
+ fd.needs_perm = 'FooPerm'
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, owner)
+ self.assertEqual('User must have permission "FooPerm".', msg)
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, committer)
+ self.assertIsNone(msg)
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, user)
+ self.assertEqual('User must be a member of the project.', msg)
+
+ def test_DateType(self):
+ pass # TODO(jrobbins): write this test. @@@
+
+ def test_UrlType(self):
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.URL_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+
+ fv = tracker_bizobj.MakeFieldValue(
+ 123, None, None, None, None, 'www.google.com', False)
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, fv)
+ self.assertIsNone(msg)
+
+ fv.url_value = 'go/puppies'
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, fv)
+ self.assertIsNone(msg)
+
+ fv.url_value = 'go/213'
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, fv)
+ self.assertIsNone(msg)
+
+ fv.url_value = 'puppies'
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, fv)
+ self.assertEqual('Value must be a valid url.', msg)
+
+ def test_OtherType(self):
+ # There are currently no validation options for date-type custom fields.
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'Deadline', tracker_pb2.FieldTypes.DATE_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ fv = tracker_bizobj.MakeFieldValue(
+ 123, None, None, None, 1234567890, None, False)
+ msg = field_helpers.ValidateCustomFieldValue(
+ self.mr.cnxn, self.mr.project, self.services, fd, fv)
+ self.assertIsNone(msg)
+
+ def testValidateCustomFields_NoCustomFieldValues(self):
+ err_msgs = field_helpers.ValidateCustomFields(
+ self.mr, self.services, [], self.config, self.mr.project,
+ ezt_errors=self.errors)
+ self.assertFalse(self.errors.AnyErrors())
+ self.assertEqual(err_msgs, [])
+
+ def testValidateCustomFields_NoErrors(self):
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.config.field_defs.append(fd)
+ fv1 = tracker_bizobj.MakeFieldValue(
+ 123, 8086, None, None, None, None, False)
+ fv2 = tracker_bizobj.MakeFieldValue(123, 486, None, None, None, None, False)
+
+ err_msgs = field_helpers.ValidateCustomFields(
+ self.mr, self.services, [fv1, fv2], self.config, self.mr.project,
+ ezt_errors=self.errors)
+ self.assertFalse(self.errors.AnyErrors())
+ self.assertEqual(err_msgs, [])
+
+ def testValidateCustomFields_SomeValueErrors(self):
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.config.field_defs.append(fd)
+ fv1 = tracker_bizobj.MakeFieldValue(
+ 123, 8086, None, None, None, None, False)
+ fv2 = tracker_bizobj.MakeFieldValue(123, 486, None, None, None, None, False)
+
+ fd.min_value = 1
+ fd.max_value = 999
+ err_msgs = field_helpers.ValidateCustomFields(
+ self.mr, self.services, [fv1, fv2], self.config, self.mr.project,
+ ezt_errors=self.errors)
+ self.assertTrue(self.errors.AnyErrors())
+ self.assertEqual(1, len(self.errors.custom_fields))
+ custom_field_error = self.errors.custom_fields[0]
+ self.assertEqual(123, custom_field_error.field_id)
+ self.assertEqual('Value must be <= 999.', custom_field_error.message)
+ self.assertEqual(len(err_msgs), 1)
+ self.assertTrue(re.search(r'Value must be <= 999.', err_msgs[0]))
+
+ def testValidateCustomFields_DeletedRequiredFields_Ignored(self):
+ issue = fake.MakeTestIssue(
+ 789,
+ 1,
+ 'sum',
+ 'New',
+ 111,
+ issue_id=78901,
+ labels=['type-defect', 'other-label'])
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', False,
+ False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', True)
+ self.config.field_defs.append(fd)
+
+ err_msgs = field_helpers.ValidateCustomFields(
+ self.mr,
+ self.services, [],
+ self.config,
+ self.mr.project,
+ ezt_errors=self.errors,
+ issue=issue)
+ self.assertFalse(self.errors.AnyErrors())
+ self.assertEqual(err_msgs, [])
+
+ def testValidateCustomFields_RequiredFields_Normal(self):
+ issue = fake.MakeTestIssue(
+ 789,
+ 1,
+ 'sum',
+ 'New',
+ 111,
+ issue_id=78901,
+ labels=['type-defect', 'other-label'])
+
+ required = True
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', required,
+ False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.config.field_defs.append(fd)
+ fv1 = tracker_bizobj.MakeFieldValue(
+ 123, 8086, None, None, None, None, False)
+ fv2 = tracker_bizobj.MakeFieldValue(123, 486, None, None, None, None, False)
+
+ err_msgs = field_helpers.ValidateCustomFields(
+ self.mr,
+ self.services, [fv1, fv2],
+ self.config,
+ self.mr.project,
+ issue=issue)
+ self.assertEqual(len(err_msgs), 0)
+
+ def testValidateCustomFields_RequiredFields_ErrorsWhenMissing(self):
+ issue = fake.MakeTestIssue(
+ 789,
+ 1,
+ 'sum',
+ 'New',
+ 111,
+ issue_id=78901,
+ labels=['type-defect', 'other-label'])
+
+ required = True
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', required,
+ False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.config.field_defs.append(fd)
+
+ err_msgs = field_helpers.ValidateCustomFields(
+ self.mr, self.services, [], self.config, self.mr.project, issue=issue)
+ self.assertEqual(len(err_msgs), 1)
+ self.assertTrue(re.search(r'CPU field is required.', err_msgs[0]))
+
+ def testValidateCustomFields_RequiredFields_EnumFieldNormal(self):
+ # Enums are a special case because their values are stored in labels.
+ issue = fake.MakeTestIssue(
+ 789,
+ 1,
+ 'sum',
+ 'New',
+ 111,
+ issue_id=78901,
+ labels=['type-defect', 'other-label', 'CPU-enum-value'])
+ required = True
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.ENUM_TYPE, None, '', required,
+ False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.config.field_defs.append(fd)
+
+ err_msgs = field_helpers.ValidateCustomFields(
+ self.mr, self.services, [], self.config, self.mr.project, issue=issue)
+ self.assertEqual(len(err_msgs), 0)
+
+ def testValidateCustomFields_RequiredFields_EnumFieldMultiWord(self):
+ # Enum fields with dashes in them require special label prefix parsing.
+ issue = fake.MakeTestIssue(
+ 789,
+ 1,
+ 'sum',
+ 'New',
+ 111,
+ issue_id=78901,
+ labels=['type-defect', 'other-label', 'an-enum-value'])
+ required = True
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'an-enum', tracker_pb2.FieldTypes.ENUM_TYPE, None, '',
+ required, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.config.field_defs.append(fd)
+
+ err_msgs = field_helpers.ValidateCustomFields(
+ self.mr, self.services, [], self.config, self.mr.project, issue=issue)
+ self.assertEqual(len(err_msgs), 0)
+
+ def testValidateCustomFields_RequiredFields_EnumFieldError(self):
+ # Enums are a special case because their values are stored in labels.
+ issue = fake.MakeTestIssue(
+ 789,
+ 1,
+ 'sum',
+ 'New',
+ 111,
+ issue_id=78901,
+ labels=['type-defect', 'other-label'])
+ required = True
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.ENUM_TYPE, None, '', required,
+ False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.config.field_defs.append(fd)
+
+ err_msgs = field_helpers.ValidateCustomFields(
+ self.mr, self.services, [], self.config, self.mr.project, issue=issue)
+ self.assertEqual(len(err_msgs), 1)
+ self.assertTrue(re.search(r'CPU field is required.', err_msgs[0]))
+
+ def testAssertCustomFieldsEditPerms_Empty(self):
+ self.assertIsNone(
+ field_helpers.AssertCustomFieldsEditPerms(
+ self.mr, self.config, [], [], [], [], []))
+
+ def testAssertCustomFieldsEditPerms_Normal(self):
+ self.services.user.TestAddUser('user@example.com', 222)
+ self.mr.auth.effective_ids = {222}
+ fd_int = tracker_bizobj.MakeFieldDef(
+ 11111,
+ 1,
+ 'fdInt',
+ tracker_pb2.FieldTypes.INT_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=True)
+ fd_str = tracker_bizobj.MakeFieldDef(
+ 22222,
+ 1,
+ 'fdStr',
+ tracker_pb2.FieldTypes.STR_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=True)
+ fd_date = tracker_bizobj.MakeFieldDef(
+ 33333,
+ 1,
+ 'fdDate',
+ tracker_pb2.FieldTypes.DATE_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=True)
+ fd_enum1 = tracker_bizobj.MakeFieldDef(
+ 44444,
+ 1,
+ 'fdEnum1',
+ tracker_pb2.FieldTypes.ENUM_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=True)
+ fd_enum2 = tracker_bizobj.MakeFieldDef(
+ 55555,
+ 1,
+ 'fdEnum2',
+ tracker_pb2.FieldTypes.ENUM_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=True)
+ self.config.field_defs = [fd_int, fd_str, fd_date, fd_enum1, fd_enum2]
+ fv1 = tracker_bizobj.MakeFieldValue(
+ 11111, 37, None, None, None, None, False)
+ fv2 = tracker_bizobj.MakeFieldValue(
+ 22222, None, 'Chicken', None, None, None, False)
+ self.assertIsNone(
+ field_helpers.AssertCustomFieldsEditPerms(
+ self.mr, self.config, [fv1], [fv2], [33333], ['Dog', 'fdEnum1-a'],
+ ['Cat', 'fdEnum2-b']))
+
+ def testAssertCustomFieldsEditPerms_Reject(self):
+ self.mr.perms = permissions.PermissionSet([])
+ fd_int = tracker_bizobj.MakeFieldDef(
+ 11111,
+ 1,
+ 'fdInt',
+ tracker_pb2.FieldTypes.INT_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=True)
+ fd_enum = tracker_bizobj.MakeFieldDef(
+ 44444,
+ 1,
+ 'fdEnum',
+ tracker_pb2.FieldTypes.ENUM_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=True)
+ self.config.field_defs = [fd_int, fd_enum]
+ fv = tracker_bizobj.MakeFieldValue(11111, 37, None, None, None, None, False)
+
+ self.assertRaises(
+ AssertionError, field_helpers.AssertCustomFieldsEditPerms, self.mr,
+ self.config, [fv], [], [], [], [])
+
+ self.assertRaises(
+ AssertionError, field_helpers.AssertCustomFieldsEditPerms, self.mr,
+ self.config, [], [fv], [], [], [])
+
+ self.assertRaises(
+ AssertionError, field_helpers.AssertCustomFieldsEditPerms, self.mr,
+ self.config, [], [], [11111], [], [])
+
+ self.assertRaises(
+ AssertionError, field_helpers.AssertCustomFieldsEditPerms, self.mr,
+ self.config, [], [], [], ['Dog', 'fdEnum-a'], [])
+
+ self.assertRaises(
+ AssertionError, field_helpers.AssertCustomFieldsEditPerms, self.mr,
+ self.config, [], [], [], [], ['Cat', 'fdEnum-b'])
+
+ def testApplyRestrictedDefaultValues(self):
+ self.mr.perms = permissions.PermissionSet([])
+ self.services.user.TestAddUser('user@example.com', 222)
+ self.mr.auth.effective_ids = {222}
+ fd_int = tracker_bizobj.MakeFieldDef(
+ 11111,
+ 1,
+ 'fdInt',
+ tracker_pb2.FieldTypes.INT_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=False)
+ fd_str = tracker_bizobj.MakeFieldDef(
+ 22222,
+ 1,
+ 'fdStr',
+ tracker_pb2.FieldTypes.STR_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=True)
+ fd_str_2 = tracker_bizobj.MakeFieldDef(
+ 33333,
+ 1,
+ 'fdStr_2',
+ tracker_pb2.FieldTypes.STR_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=False)
+ fd_enum = tracker_bizobj.MakeFieldDef(
+ 44444,
+ 1,
+ 'fdEnum',
+ tracker_pb2.FieldTypes.ENUM_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=False)
+ fd_restricted_enum = tracker_bizobj.MakeFieldDef(
+ 55555,
+ 1,
+ 'fdRestrictedEnum',
+ tracker_pb2.FieldTypes.ENUM_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=True)
+ self.config.field_defs = [
+ fd_int, fd_str, fd_str_2, fd_enum, fd_restricted_enum
+ ]
+ fv = tracker_bizobj.MakeFieldValue(
+ 33333, None, 'Happy', None, None, None, False)
+ temp_fv = tracker_bizobj.MakeFieldValue(
+ 11111, 37, None, None, None, None, False)
+ temp_restricted_fv = tracker_bizobj.MakeFieldValue(
+ 22222, None, 'Chicken', None, None, None, False)
+ field_vals = [fv]
+ labels = ['Car', 'Bus']
+ temp_field_vals = [temp_fv, temp_restricted_fv]
+ temp_labels = ['Bike', 'fdEnum-a', 'fdRestrictedEnum-b']
+ field_helpers.ApplyRestrictedDefaultValues(
+ self.mr, self.config, field_vals, labels, temp_field_vals, temp_labels)
+ self.assertEqual(labels, ['Car', 'Bus', 'fdRestrictedEnum-b'])
+ self.assertEqual(field_vals, [fv, temp_restricted_fv])
+
+ def testFormatUrlFieldValue(self):
+ self.assertEqual('http://www.google.com',
+ field_helpers.FormatUrlFieldValue('www.google.com'))
+ self.assertEqual('https://www.bing.com',
+ field_helpers.FormatUrlFieldValue('https://www.bing.com'))
+
+ def testReviseFieldDefFromParsed_INT(self):
+ parsed_field_def = field_helpers.ParsedFieldDef(
+ 'EstDays',
+ 'int_type',
+ min_value=5,
+ max_value=7,
+ regex='',
+ needs_member=True,
+ needs_perm='Commit',
+ grants_perm='View',
+ notify_on=tracker_pb2.NotifyTriggers.ANY_COMMENT,
+ is_required=True,
+ is_niche=True,
+ importance='required',
+ is_multivalued=True,
+ field_docstring='updated doc',
+ choices_text='',
+ applicable_type='Launch',
+ applicable_predicate='',
+ revised_labels=[],
+ date_action_str='ping_participants',
+ approvers_str='',
+ survey='',
+ parent_approval_name='',
+ is_phase_field=False,
+ is_restricted_field=False)
+
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'EstDays', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, 4, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False,
+ approval_id=3)
+
+ new_fd = field_helpers.ReviseFieldDefFromParsed(parsed_field_def, fd)
+ # assert INT fields
+ self.assertEqual(new_fd.min_value, 5)
+ self.assertEqual(new_fd.max_value, 7)
+
+ # assert USER fields
+ self.assertEqual(new_fd.notify_on, tracker_pb2.NotifyTriggers.ANY_COMMENT)
+ self.assertTrue(new_fd.needs_member)
+ self.assertEqual(new_fd.needs_perm, 'Commit')
+ self.assertEqual(new_fd.grants_perm, 'View')
+
+ # assert DATE fields
+ self.assertEqual(new_fd.date_action,
+ tracker_pb2.DateAction.PING_PARTICIPANTS)
+
+ # assert general fields
+ self.assertTrue(new_fd.is_required)
+ self.assertTrue(new_fd.is_niche)
+ self.assertEqual(new_fd.applicable_type, 'Launch')
+ self.assertEqual(new_fd.docstring, 'updated doc')
+ self.assertTrue(new_fd.is_multivalued)
+ self.assertEqual(new_fd.approval_id, 3)
+ self.assertFalse(new_fd.is_phase_field)
+ self.assertFalse(new_fd.is_restricted_field)
+
+ def testParsedFieldDefAssertions_Accepted(self):
+ parsed_fd = field_helpers.ParsedFieldDef(
+ 'EstDays',
+ 'int_type',
+ min_value=5,
+ max_value=7,
+ regex='',
+ needs_member=True,
+ needs_perm='Commit',
+ grants_perm='View',
+ notify_on=tracker_pb2.NotifyTriggers.ANY_COMMENT,
+ is_required=True,
+ is_niche=False,
+ importance='required',
+ is_multivalued=True,
+ field_docstring='updated doc',
+ choices_text='',
+ applicable_type='Launch',
+ applicable_predicate='',
+ revised_labels=[],
+ date_action_str='ping_participants',
+ approvers_str='',
+ survey='',
+ parent_approval_name='',
+ is_phase_field=False,
+ is_restricted_field=False)
+
+ field_helpers.ParsedFieldDefAssertions(self.mr, parsed_fd)
+ self.assertFalse(self.mr.errors.AnyErrors())
+
+ def testParsedFieldDefAssertions_Rejected(self):
+ parsed_fd = field_helpers.ParsedFieldDef(
+ 'restrictApprovalField',
+ 'approval_type',
+ min_value=10,
+ max_value=7,
+ regex='/foo(?)/',
+ needs_member=True,
+ needs_perm='Commit',
+ grants_perm='View',
+ notify_on=tracker_pb2.NotifyTriggers.ANY_COMMENT,
+ is_required=True,
+ is_niche=True,
+ importance='required',
+ is_multivalued=True,
+ field_docstring='updated doc',
+ choices_text='',
+ applicable_type='Launch',
+ applicable_predicate='',
+ revised_labels=[],
+ date_action_str='custom_date_action_str',
+ approvers_str='',
+ survey='',
+ parent_approval_name='',
+ is_phase_field=False,
+ is_restricted_field=True)
+
+ field_helpers.ParsedFieldDefAssertions(self.mr, parsed_fd)
+ self.assertTrue(self.mr.errors.AnyErrors())
+
+ self.assertEqual(
+ self.mr.errors.is_niche, 'A field cannot be both required and niche.')
+ self.assertEqual(
+ self.mr.errors.date_action,
+ 'The date action should be either: ' + ', '.join(
+ config_svc.DATE_ACTION_ENUM) + '.')
+ self.assertEqual(
+ self.mr.errors.min_value, 'Minimum value must be less than maximum.')
+ self.assertEqual(self.mr.errors.regex, 'Invalid regular expression.')
diff --git a/tracker/test/fieldcreate_test.py b/tracker/test/fieldcreate_test.py
new file mode 100644
index 0000000..580d095
--- /dev/null
+++ b/tracker/test/fieldcreate_test.py
@@ -0,0 +1,301 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the fieldcreate servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import mock
+import unittest
+import logging
+
+import ezt
+
+from framework import permissions
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import fieldcreate
+from tracker import tracker_bizobj
+
+
+class FieldCreateTest(unittest.TestCase):
+
+ def setUp(self):
+ self.cnxn = 'fake cnxn'
+ self.services = service_manager.Services(
+ user=fake.UserService(),
+ config=fake.ConfigService(),
+ project=fake.ProjectService())
+ self.servlet = fieldcreate.FieldCreate(
+ 'req', 'res', services=self.services)
+ self.project = self.services.project.TestAddProject('proj')
+ self.mr = testing_helpers.MakeMonorailRequest(
+ project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+ self.services.user.TestAddUser('gatsby@example.com', 111)
+ self.services.user.TestAddUser('sport@example.com', 222)
+
+ self.mox = mox.Mox()
+
+ def tearDown(self):
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+
+ def testAssertBasePermission(self):
+ # Anon users can never do it
+ self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.AssertBasePermission, self.mr)
+
+ # Project owner can do it.
+ self.mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+ self.servlet.AssertBasePermission(self.mr)
+
+ # Project member cannot do it
+ self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.AssertBasePermission, self.mr)
+ self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.AssertBasePermission, self.mr)
+
+ def testGatherPageData(self):
+ approval_fd = tracker_bizobj.MakeFieldDef(
+ 1, self.mr.project_id, 'LaunchApproval',
+ tracker_pb2.FieldTypes.APPROVAL_TYPE, None, '', False,
+ False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+ 'some approval thing', False)
+ config = self.services.config.GetProjectConfig(
+ self.mr.cnxn, self.mr.project_id)
+ config.field_defs.append(approval_fd)
+ self.services.config.StoreConfig(self.cnxn, config)
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.assertEqual(self.servlet.PROCESS_TAB_LABELS,
+ page_data['admin_tab_mode'])
+ self.assertItemsEqual(
+ ['Defect', 'Enhancement', 'Task', 'Other'],
+ page_data['well_known_issue_types'])
+ self.assertEqual(['LaunchApproval'], page_data['approval_names'])
+ self.assertEqual('', page_data['initial_admins'])
+ self.assertEqual('', page_data['initial_editors'])
+ self.assertIsNone(page_data['initial_is_restricted_field'])
+
+ def testProcessFormData(self):
+ post_data = fake.PostData(
+ name=['somefield'],
+ field_type=['INT_TYPE'],
+ min_value=['1'],
+ max_value=['99'],
+ notify_on=['any_comment'],
+ importance=['required'],
+ is_multivalued=['Yes'],
+ docstring=['It is just some field'],
+ applicable_type=['Defect'],
+ date_action=['no_action'],
+ admin_names=['gatsby@example.com'],
+ editor_names=['sport@example.com'],
+ is_restricted_field=['Yes'])
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.assertTrue('/adminLabels?saved=1&' in url)
+ config = self.services.config.GetProjectConfig(
+ self.mr.cnxn, self.mr.project_id)
+
+ fd = tracker_bizobj.FindFieldDef('somefield', config)
+ self.assertEqual('somefield', fd.field_name)
+ self.assertEqual(tracker_pb2.FieldTypes.INT_TYPE, fd.field_type)
+ self.assertEqual(1, fd.min_value)
+ self.assertEqual(99, fd.max_value)
+ self.assertEqual(tracker_pb2.NotifyTriggers.ANY_COMMENT, fd.notify_on)
+ self.assertTrue(fd.is_required)
+ self.assertFalse(fd.is_niche)
+ self.assertTrue(fd.is_multivalued)
+ self.assertEqual('It is just some field', fd.docstring)
+ self.assertEqual('Defect', fd.applicable_type)
+ self.assertEqual('', fd.applicable_predicate)
+ self.assertEqual([111], fd.admin_ids)
+ self.assertEqual([222], fd.editor_ids)
+
+ def testProcessFormData_Reject_EditorsForNonRestrictedField(self):
+ # This method tests that an exception is raised
+ # when trying to add editors to a non restricted field.
+ post_data = fake.PostData(
+ name=['somefield'],
+ field_type=['INT_TYPE'],
+ min_value=['1'],
+ max_value=['99'],
+ notify_on=['any_comment'],
+ importance=['required'],
+ is_multivalued=['Yes'],
+ docstring=['It is just some field'],
+ applicable_type=['Defect'],
+ date_action=['no_action'],
+ admin_names=['gatsby@example.com'],
+ editor_names=['sport@example.com'])
+
+ self.assertRaises(
+ AssertionError, self.servlet.ProcessFormData, self.mr, post_data)
+
+ def testProcessFormData_Reject_EditorsForApprovalField(self):
+ #This method tests that an exception is raised
+ #when trying to add editors to an approval field.
+ post_data = fake.PostData(
+ name=['approval_field'],
+ field_type=['approval_type'],
+ notify_on=['any_comment'],
+ importance=['required'],
+ is_multivalued=['Yes'],
+ docstring=['It is just some field'],
+ applicable_type=['Defect'],
+ date_action=['no_action'],
+ approver_names=['gatsby@example.com'],
+ is_restricted_field=['Yes'],
+ admin_names=['gatsby@example.com'],
+ editor_names=[''])
+
+ self.assertRaises(
+ AssertionError, self.servlet.ProcessFormData, self.mr, post_data)
+
+ @mock.patch('framework.servlet.Servlet.PleaseCorrect')
+ def testProcessFormData_RejectAssertions(self, fake_servlet_pc):
+ #This method tests when errors are found using when the
+ #field_helpers.ParsedFieldDefAssertions is triggered.
+ post_data = fake.PostData(
+ name=['somefield'],
+ field_type=['INT_TYPE'],
+ min_value=['1'],
+ max_value=['99'],
+ notify_on=['any_comment'],
+ importance=['required'],
+ is_multivalued=['Yes'],
+ docstring=['It is just some field'],
+ applicable_type=['Defect'],
+ date_action=['wrong_date_action'],
+ admin_names=['gatsby@example.com'],
+ editor_names=[''])
+
+ self.servlet.ProcessFormData(self.mr, post_data)
+
+ fake_servlet_pc.assert_called_once_with(
+ self.mr,
+ initial_field_name='somefield',
+ initial_type='INT_TYPE',
+ initial_parent_approval_name='',
+ initial_field_docstring='It is just some field',
+ initial_applicable_type='Defect',
+ initial_applicable_predicate='',
+ initial_needs_member=None,
+ initial_needs_perm='',
+ initial_importance='required',
+ initial_is_multivalued='yes',
+ initial_grants_perm='',
+ initial_notify_on=1,
+ initial_date_action='wrong_date_action',
+ initial_choices='',
+ initial_approvers='',
+ initial_survey='',
+ initial_is_phase_field=False,
+ initial_admins='gatsby@example.com',
+ initial_editors='',
+ initial_is_restricted_field=False)
+ self.assertTrue(self.mr.errors.AnyErrors())
+
+
+ def testProcessFormData_RejectNoApprover(self):
+ post_data = fake.PostData(
+ name=['approvalField'],
+ field_type=['approval_type'],
+ approver_names=[''],
+ admin_names=[''],
+ editor_names=[''],
+ parent_approval_name=['UIApproval'],
+ is_phase_field=['on'])
+
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(
+ self.mr,
+ initial_field_name=post_data.get('name'),
+ initial_type=post_data.get('field_type'),
+ initial_field_docstring=post_data.get('docstring', ''),
+ initial_applicable_type=post_data.get('applical_type', ''),
+ initial_applicable_predicate='',
+ initial_needs_member=ezt.boolean('needs_member' in post_data),
+ initial_needs_perm=post_data.get('needs_perm', '').strip(),
+ initial_importance=post_data.get('importance'),
+ initial_is_multivalued=ezt.boolean('is_multivalued' in post_data),
+ initial_grants_perm=post_data.get('grants_perm', '').strip(),
+ initial_notify_on=0,
+ initial_date_action=post_data.get('date_action'),
+ initial_choices=post_data.get('choices', ''),
+ initial_approvers=post_data.get('approver_names'),
+ initial_parent_approval_name=post_data.get('parent_approval_name', ''),
+ initial_survey=post_data.get('survey', ''),
+ initial_is_phase_field=False,
+ initial_admins=post_data.get('admin_names'),
+ initial_editors=post_data.get('editor_names'),
+ initial_is_restricted_field=False)
+ self.mox.ReplayAll()
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.mox.VerifyAll()
+ self.assertEqual(
+ 'Please provide at least one default approver.',
+ self.mr.errors.approvers)
+ self.assertIsNone(url)
+
+
+class FieldCreateMethodsTest(unittest.TestCase):
+
+ def setUp(self):
+ self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+
+ def testFieldNameErrorMessage_NoConflict(self):
+ self.assertIsNone(fieldcreate.FieldNameErrorMessage(
+ 'somefield', self.config))
+
+ def testFieldNameErrorMessage_PrefixReserved(self):
+ self.assertEqual(
+ 'That name is reserved.',
+ fieldcreate.FieldNameErrorMessage('owner', self.config))
+
+ def testFieldNameErrorMessage_SuffixReserved(self):
+ self.assertEqual(
+ 'That suffix is reserved.',
+ fieldcreate.FieldNameErrorMessage('doh-approver', self.config))
+
+ def testFieldNameErrorMessage_AlreadyInUse(self):
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.config.field_defs.append(fd)
+ self.assertEqual(
+ 'That name is already in use.',
+ fieldcreate.FieldNameErrorMessage('CPU', self.config))
+
+ def testFieldNameErrorMessage_PrefixOfExisting(self):
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'sign-off', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.config.field_defs.append(fd)
+ self.assertEqual(
+ 'That name is a prefix of an existing field name.',
+ fieldcreate.FieldNameErrorMessage('sign', self.config))
+
+ def testFieldNameErrorMessage_IncludesExisting(self):
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'opt', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.config.field_defs.append(fd)
+ self.assertEqual(
+ 'An existing field name is a prefix of that name.',
+ fieldcreate.FieldNameErrorMessage('opt-in', self.config))
diff --git a/tracker/test/fielddetail_test.py b/tracker/test/fielddetail_test.py
new file mode 100644
index 0000000..f9f27b4
--- /dev/null
+++ b/tracker/test/fielddetail_test.py
@@ -0,0 +1,355 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for the fielddetail servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import unittest
+import logging
+
+import webapp2
+
+import ezt
+
+from framework import permissions
+from proto import project_pb2
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import fielddetail
+from tracker import tracker_bizobj
+from tracker import tracker_views
+
+
+class FieldDetailTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ user=fake.UserService(),
+ config=fake.ConfigService(),
+ project=fake.ProjectService())
+ self.servlet = fielddetail.FieldDetail(
+ 'req', 'res', services=self.services)
+ self.project = self.services.project.TestAddProject('proj')
+ self.mr = testing_helpers.MakeMonorailRequest(
+ project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+ self.config = self.services.config.GetProjectConfig(
+ 'fake cnxn', self.project.project_id)
+ self.services.config.StoreConfig('fake cnxn', self.config)
+ self.fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.fd.admin_ids = [111]
+ self.fd.editor_ids = [222]
+ self.config.field_defs.append(self.fd)
+ self.services.user.TestAddUser('gatsby@example.com', 111)
+ self.services.user.TestAddUser('sport@example.com', 222)
+ self.mr.field_name = 'CPU'
+
+ # Approvals
+ self.approval_def = tracker_pb2.ApprovalDef(
+ approval_id=234, approver_ids=[111], survey='Question 1?')
+ self.sub_fd = tracker_pb2.FieldDef(
+ field_name='UIMocks', approval_id=234, applicable_type='')
+ self.sub_fd_deleted = tracker_pb2.FieldDef(
+ field_name='UIMocksDeleted', approval_id=234, applicable_type='',
+ is_deleted=True)
+ self.config.field_defs.extend([self.sub_fd, self.sub_fd_deleted])
+ self.config.approval_defs.append(self.approval_def)
+ self.approval_fd = tracker_bizobj.MakeFieldDef(
+ 234, 789, 'UIReview', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.config.field_defs.append(self.approval_fd)
+
+ self.mox = mox.Mox()
+
+ def tearDown(self):
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+
+ def testGetFieldDef_NotFound(self):
+ self.mr.field_name = 'NeverHeardOfIt'
+ self.assertRaises(
+ webapp2.HTTPException,
+ self.servlet._GetFieldDef, self.mr)
+
+ def testGetFieldDef_Normal(self):
+ actual_config, actual_fd = self.servlet._GetFieldDef(self.mr)
+ self.assertEqual(self.config, actual_config)
+ self.assertEqual(self.fd, actual_fd)
+
+ def testAssertBasePermission_AnyoneCanView(self):
+ self.servlet.AssertBasePermission(self.mr)
+ self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+ self.servlet.AssertBasePermission(self.mr)
+ self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+ self.servlet.AssertBasePermission(self.mr)
+ self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+ self.servlet.AssertBasePermission(self.mr)
+
+ def testAssertBasePermission_MembersOnly(self):
+ self.project.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+ # The project members can view the field definition.
+ self.servlet.AssertBasePermission(self.mr)
+ self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+ self.servlet.AssertBasePermission(self.mr)
+ self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+ self.servlet.AssertBasePermission(self.mr)
+ # Non-member is not allowed to view anything in the project.
+ self.mr.perms = permissions.EMPTY_PERMISSIONSET
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.AssertBasePermission, self.mr)
+
+ def testGatherPageData_ReadWrite(self):
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.assertEqual(self.servlet.PROCESS_TAB_LABELS,
+ page_data['admin_tab_mode'])
+ self.assertTrue(page_data['allow_edit'])
+ self.assertEqual('gatsby@example.com', page_data['initial_admins'])
+ self.assertEqual('sport@example.com', page_data['initial_editors'])
+ field_def_view = page_data['field_def']
+ self.assertEqual('CPU', field_def_view.field_name)
+ self.assertEqual(page_data['approval_subfields'], [])
+ self.assertEqual(page_data['initial_approvers'], '')
+
+ def testGatherPageData_ReadOnly(self):
+ self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.assertEqual(self.servlet.PROCESS_TAB_LABELS,
+ page_data['admin_tab_mode'])
+ self.assertFalse(page_data['allow_edit'])
+ self.assertEqual('gatsby@example.com', page_data['initial_admins'])
+ self.assertEqual('sport@example.com', page_data['initial_editors'])
+ field_def_view = page_data['field_def']
+ self.assertEqual('CPU', field_def_view.field_name)
+ self.assertEqual(page_data['approval_subfields'], [])
+ self.assertEqual(page_data['initial_approvers'], '')
+
+ def testGatherPageData_Approval(self):
+ self.mr.field_name = 'UIReview'
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.assertEqual(page_data['approval_subfields'], [self.sub_fd])
+ self.assertEqual(page_data['initial_approvers'], 'gatsby@example.com')
+ field_def_view = page_data['field_def']
+ self.assertEqual(field_def_view.field_name, 'UIReview')
+ self.assertEqual(field_def_view.survey, 'Question 1?')
+
+ def testProcessFormData_Permission(self):
+ """Only owners can edit fields."""
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+ mr.field_name = 'CPU'
+ post_data = fake.PostData(
+ name=['CPU'],
+ deletefield=['Submit'])
+ self.assertRaises(permissions.PermissionException,
+ self.servlet.ProcessFormData, mr, post_data)
+
+ self.servlet.ProcessFormData(self.mr, post_data)
+
+ def testProcessFormData_Delete(self):
+ post_data = fake.PostData(
+ name=['CPU'],
+ deletefield=['Submit'])
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.assertTrue('/adminLabels?deleted=1&' in url)
+ fd = tracker_bizobj.FindFieldDef('CPU', self.config)
+ self.assertEqual('CPU', fd.field_name)
+ self.assertTrue(fd.is_deleted)
+
+ def testProcessFormData_Cancel(self):
+ post_data = fake.PostData(
+ name=['CPU'],
+ cancel=['Submit'],
+ max_value=['200'])
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ logging.info(url)
+ self.assertTrue('/adminLabels?ts=' in url)
+ config = self.services.config.GetProjectConfig(
+ self.mr.cnxn, self.mr.project_id)
+
+ fd = tracker_bizobj.FindFieldDef('CPU', config)
+ self.assertIsNone(fd.max_value)
+ self.assertIsNone(fd.min_value)
+
+ def testProcessFormData_Edit(self):
+ post_data = fake.PostData(
+ name=['CPU'],
+ field_type=['INT_TYPE'],
+ min_value=['2'],
+ max_value=['98'],
+ notify_on=['never'],
+ is_required=[],
+ is_multivalued=[],
+ docstring=['It is just some field'],
+ applicable_type=['Defect'],
+ admin_names=['gatsby@example.com'])
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.assertTrue('/fields/detail?field=CPU&saved=1&' in url)
+ config = self.services.config.GetProjectConfig(
+ self.mr.cnxn, self.mr.project_id)
+
+ fd = tracker_bizobj.FindFieldDef('CPU', config)
+ self.assertEqual('CPU', fd.field_name)
+ self.assertEqual(2, fd.min_value)
+ self.assertEqual(98, fd.max_value)
+ self.assertEqual([111], fd.admin_ids)
+ self.assertEqual([], fd.editor_ids)
+
+ def testProcessDeleteField(self):
+ self.servlet._ProcessDeleteField(self.mr, self.config, self.fd)
+ self.assertTrue(self.fd.is_deleted)
+
+ def testProcessDeleteField_subfields(self):
+ approval_fd = tracker_bizobj.MakeFieldDef(
+ 3, 789, 'Legal', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.fd.approval_id=3
+ self.config.field_defs.append(approval_fd)
+ self.servlet._ProcessDeleteField(self.mr, self.config, approval_fd)
+ self.assertTrue(self.fd.is_deleted)
+ self.assertTrue(approval_fd.is_deleted)
+
+ def testProcessEditField_Normal(self):
+ post_data = fake.PostData(
+ name=['CPU'],
+ field_type=['INT_TYPE'],
+ min_value=['2'],
+ admin_names=['gatsby@example.com'],
+ editor_names=['sport@example.com'],
+ is_restricted_field=['Yes'])
+ self.servlet._ProcessEditField(
+ self.mr, post_data, self.config, self.fd)
+ fd = tracker_bizobj.FindFieldDef('CPU', self.config)
+ self.assertEqual('CPU', fd.field_name)
+ self.assertEqual(2, fd.min_value)
+ self.assertEqual([111], fd.admin_ids)
+ self.assertEqual([222], fd.editor_ids)
+
+ def testProcessEditField_Reject(self):
+ post_data = fake.PostData(
+ name=['CPU'],
+ field_type=['INT_TYPE'],
+ min_value=['4'],
+ max_value=['1'],
+ admin_names=[''],
+ editor_names=[''])
+
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(
+ self.mr,
+ field_def=mox.IgnoreArg(),
+ initial_applicable_type='',
+ initial_choices='',
+ initial_admins='',
+ initial_editors='',
+ initial_approvers='',
+ initial_is_restricted_field=False)
+ self.mox.ReplayAll()
+
+ url = self.servlet._ProcessEditField(
+ self.mr, post_data, self.config, self.fd)
+ self.assertEqual('Minimum value must be less than maximum.',
+ self.mr.errors.min_value)
+ self.assertIsNone(url)
+
+ fd = tracker_bizobj.FindFieldDef('CPU', self.config)
+ self.assertIsNone(fd.min_value)
+ self.assertIsNone(fd.max_value)
+
+ def testProcessEditField_Reject_EditorsForNonRestrictedField(self):
+ # This method tests that an exception is raised
+ # when trying to add editors to a non restricted field.
+ post_data = fake.PostData(
+ name=['CPU'],
+ field_type=['INT_TYPE'],
+ min_value=['2'],
+ admin_names=[''],
+ editor_names=['gatsby@example.com'])
+
+ self.assertRaises(
+ AssertionError, self.servlet._ProcessEditField, self.mr, post_data,
+ self.config, self.fd)
+
+ def testProcessEditField_RejectAssertions_1(self):
+ # This method tests that an exception is raised
+ # when trying to add editors to an approval field.
+ post_data = fake.PostData(
+ name=['CPU'],
+ approver_names=['gatsby@example.com'],
+ admin_names=[''],
+ editor_names=['sports@example.com'])
+
+ self.assertRaises(
+ AssertionError, self.servlet._ProcessEditField, self.mr, post_data,
+ self.config, self.approval_fd)
+
+ def testProcessEditField_RejectAssertions_2(self):
+ #This method tests that an exception is raised
+ #when trying to restrict an approval field.
+ post_data = fake.PostData(
+ name=['CPU'],
+ approver_names=['gatsby@example.com'],
+ is_restricted_field=['Yes'],
+ admin_names=[''],
+ editor_names=[''])
+
+ self.assertRaises(
+ AssertionError, self.servlet._ProcessEditField, self.mr, post_data,
+ self.config, self.approval_fd)
+
+ def testProcessEditField_RejectApproval(self):
+ self.mr.field_name = 'UIReview'
+ post_data = fake.PostData(
+ name=['UIReview'],
+ admin_names=[''],
+ editor_names=[''],
+ survey=['WIll there be UI changes?'],
+ approver_names=[''])
+
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(
+ self.mr,
+ field_def=mox.IgnoreArg(),
+ initial_applicable_type='',
+ initial_choices='',
+ initial_admins='',
+ initial_editors='',
+ initial_approvers='',
+ initial_is_restricted_field=False)
+ self.mox.ReplayAll()
+
+ url = self.servlet._ProcessEditField(
+ self.mr, post_data, self.config, self.approval_fd)
+ self.assertEqual('Please provide at least one default approver.',
+ self.mr.errors.approvers)
+ self.assertIsNone(url)
+
+ def testProcessEditField_Approval(self):
+ self.mr.field_name = 'UIReview'
+ post_data = fake.PostData(
+ name=['UIReview'],
+ admin_names=[''],
+ editor_names=[''],
+ survey=['WIll there be UI changes?'],
+ approver_names=['sport@example.com, gatsby@example.com'])
+
+
+ url = self.servlet._ProcessEditField(
+ self.mr, post_data, self.config, self.approval_fd)
+ self.assertTrue('/fields/detail?field=UIReview&saved=1&' in url)
+
+ approval_def = tracker_bizobj.FindApprovalDef('UIReview', self.config)
+ self.assertEqual(len(approval_def.approver_ids), 2)
+ self.assertEqual(sorted(approval_def.approver_ids), sorted([111, 222]))
diff --git a/tracker/test/fltconversion_test.py b/tracker/test/fltconversion_test.py
new file mode 100644
index 0000000..e0bae41
--- /dev/null
+++ b/tracker/test/fltconversion_test.py
@@ -0,0 +1,930 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for the flt launch issues conversion task."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+import copy
+import unittest
+import settings
+import mock
+
+from businesslogic import work_env
+from framework import exceptions
+from framework import permissions
+from services import service_manager
+from services import template_svc
+from tracker import fltconversion
+from tracker import tracker_bizobj
+from testing import fake
+from testing import testing_helpers
+from proto import tracker_pb2
+
+class FLTConvertTask(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ issue=fake.IssueService(),
+ user=fake.UserService(),
+ project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ template=mock.Mock(spec=template_svc.TemplateService),)
+ self.mr = testing_helpers.MakeMonorailRequest()
+ self.task = fltconversion.FLTConvertTask(
+ 'req', 'res', services=self.services)
+ self.task.mr = self.mr
+ self.issue = fake.MakeTestIssue(
+ 789, 1, 'summary', 'New', 111, issue_id=78901)
+ self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ self.work_env = work_env.WorkEnv(
+ self.mr, self.services, 'Testing')
+ self.issue1 = fake.MakeTestIssue(
+ 789, 1, 'sum', 'New', 111, issue_id=78901,
+ labels=[
+ 'Launch-M-Approved-71-Stable', 'Launch-M-Target-70-Beta',
+ 'Launch-UI-Yes', 'Launch-Privacy-NeedInfo',
+ 'pm-jojwang', 'tl-annajo', 'ux-shiba', 'Type-Launch'])
+ self.issue2 = fake.MakeTestIssue(
+ 789, 2, 'sum', 'New', 111, issue_id=78902,
+ labels=[
+ 'Launch-M-Target-71-Stable', 'Launch-M-Approved-70-Beta',
+ 'pm-jojwang', 'tl-annajo', 'OS-Chrome', 'OS-Android',
+ 'Type-Launch'])
+ self.issue3 = fake.MakeTestIssue(
+ 789, 3, 'sum', 'New', 111, issue_id=78903,
+ labels=['Launch-M-Approved-71-Stable',
+ 'Launch-M-Approved-79-Stable-Exp', 'Launch-M-Target-70-Beta',
+ 'Launch-M-Target-70-Stable', 'Launch-UI-Yes',
+ 'Launch-Exp-Leadership-Yes', 'pm-annajo', 'tl-jojwang',
+ 'OS-Chrome', 'Type-Launch'])
+ self.issue4 = fake.MakeTestIssue(
+ 789, 4, 'sum', 'New', 111, issue_id=78904,
+ labels=['Launch-UI-Yes', 'OS-Chrome', 'Type-Launch'])
+ self.issue5 = fake.MakeTestIssue(
+ 789, 5, 'sum', 'New', 111, issue_id=78905,
+ labels=['Launch-M-Approved-71-Stable',
+ 'Launch-M-Approved-79-Stable-Exp', 'Launch-M-Target-70-Beta',
+ 'Launch-M-Target-70-Stable', 'Launch-UI-Yes',
+ 'Launch-Privacy-NeedInfo', 'Launch-Exp-Leadership-Yes',
+ 'pm-annajo', 'tl-jojwang', 'OS-Chrome', 'Type-Launch'])
+
+ self.approval_values = [
+ tracker_pb2.ApprovalValue(
+ approval_id=7, status=tracker_pb2.ApprovalStatus.NOT_SET),
+ tracker_pb2.ApprovalValue(
+ approval_id=8, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)]
+ self.phases = [tracker_pb2.Phase(name='Stable', phase_id=88),
+ tracker_pb2.Phase(name='Beta', phase_id=89)]
+
+ def testAssertBasePermission(self):
+ self.mr.auth.user_pb.is_site_admin = True
+ settings.app_id = 'monorail-staging'
+ self.task.AssertBasePermission(self.mr)
+
+ settings.app_id = 'monorail-prod'
+ self.task.AssertBasePermission(self.mr)
+
+ self.mr.auth.user_pb.is_site_admin = False
+ self.assertRaises(permissions.PermissionException,
+ self.task.AssertBasePermission, self.mr)
+
+ def testHandleRequest(self):
+ # Set up Objects
+ project_info = fltconversion.ProjectInfo(
+ self.config, 'q=query', self.approval_values, self.phases,
+ 11, 12, 13, 16, 14, 15, fltconversion.BROWSER_PHASE_MAP,
+ fltconversion.BROWSER_APPROVALS_TO_LABELS,
+ fltconversion.BROWSER_M_LABELS_RE)
+
+ self.config.field_defs = [
+ tracker_pb2.FieldDef(field_id=7, field_name='Chrome-UX',
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+ tracker_pb2.FieldDef(field_id=8, field_name='Chrome-Privacy',
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)
+ ]
+
+ # Set up mocks
+ patcher = mock.patch(
+ 'search.frontendsearchpipeline.FrontendSearchPipeline',
+ spec=True, visible_results=[self.issue1, self.issue2])
+ mockPipeline = patcher.start()
+
+ self.task.services.issue.GetIssue = mock.Mock(
+ side_effect=[self.issue1, self.issue2])
+
+ self.task.FetchAndAssertProjectInfo = mock.Mock(return_value=project_info)
+
+ with self.work_env as we:
+ we.ListIssues = mock.Mock(return_value=mockPipeline)
+
+ def side_effect(_cnxn, email):
+ if email == 'jojwang@chromium.org':
+ return 111
+ if email == 'annajo@google.com':
+ return 222
+ if email == 'shiba@google.com':
+ return 333
+ raise exceptions.NoSuchUserException
+ self.task.services.user.LookupUserID = mock.Mock(side_effect=side_effect)
+
+ self.task.ExecuteIssueChanges = mock.Mock(return_value=[])
+
+ # Call
+ json = self.task.HandleRequest(self.mr)
+
+ # assert
+ self.assertEqual(json['converted_issues'], [1, 2])
+
+ new_approvals1 = [
+ tracker_pb2.ApprovalValue(
+ approval_id=7, status=tracker_pb2.ApprovalStatus.APPROVED),
+ tracker_pb2.ApprovalValue(
+ approval_id=8, status=tracker_pb2.ApprovalStatus.NEED_INFO)]
+ new_fvs1 = [
+ # M-Approved Stable
+ tracker_bizobj.MakeFieldValue(
+ 15, 71, None, None, None, None, False, phase_id=88),
+ # M-Target Beta
+ tracker_bizobj.MakeFieldValue(
+ 14, 70, None, None, None, None, False, phase_id=89),
+ # PM field
+ tracker_bizobj.MakeFieldValue(
+ 11, None, None, 111, None, None, False),
+ # TL field
+ tracker_bizobj.MakeFieldValue(
+ 12, None, None, 222, None, None, False),
+ # UX field
+ tracker_bizobj.MakeFieldValue(
+ 16, None, None, 333, None, None, False)
+ ]
+
+
+ new_approvals2 = [
+ tracker_pb2.ApprovalValue(
+ approval_id=7, status=tracker_pb2.ApprovalStatus.NOT_SET),
+ tracker_pb2.ApprovalValue(
+ approval_id=8, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)
+ ]
+ new_fvs2 = [
+ tracker_bizobj.MakeFieldValue(
+ 14, 71, None, None, None, None, False, phase_id=88),
+ tracker_bizobj.MakeFieldValue(
+ 15, 70, None, None, None, None, False, phase_id=89),
+ # PM field
+ tracker_bizobj.MakeFieldValue(
+ 11, None, None, 111, None, None, False),
+ # TL field
+ tracker_bizobj.MakeFieldValue(
+ 12, None, None, 222, None, None, False)]
+
+ execute_calls = [
+ mock.call(
+ self.config, self.issue1, new_approvals1, self.phases, new_fvs1),
+ mock.call(
+ self.config, self.issue2, new_approvals2, self.phases, new_fvs2)]
+ self.task.ExecuteIssueChanges.assert_has_calls(execute_calls)
+
+ patcher.stop()
+
+ def testHandleRequest_UndoConversion(self):
+ # test Delete() is actually called
+ mr = testing_helpers.MakeMonorailRequest(path='url/url?launch=delete')
+ self.task.UndoConversion = mock.Mock(return_value={'deleteing': [1, 2]})
+ actualReturn = self.task.HandleRequest(mr)
+ self.assertEqual({'deleteing': [1, 2]}, actualReturn)
+
+ def testUndoConversion(self):
+ # Set up objects
+ self.issue1.field_values = [
+ # Test non phase and TL/PM/TE field_values are not deleted
+ tracker_bizobj.MakeFieldValue(
+ 17, None, 'strvalue', None, None, None, False)]
+ issue1 = copy.deepcopy(self.issue1)
+ issue2 = copy.deepcopy(self.issue2)
+ fvs = [
+ tracker_bizobj.MakeFieldValue(
+ 11, None, None, 222, None, None, False),
+ tracker_bizobj.MakeFieldValue(
+ 12, None, None, 111, None, None, False),
+ tracker_bizobj.MakeFieldValue(
+ 16, None, None, 111, None, None, False)]
+ self.config.field_defs = [
+ tracker_pb2.FieldDef(field_id=11, field_name='PM'),
+ tracker_pb2.FieldDef(field_id=12, field_name='TL'),
+ tracker_pb2.FieldDef(field_id=13, field_name='TE'),
+ tracker_pb2.FieldDef(field_id=16, field_name='UX')]
+ # Make element edits made during conversion that should be undone.
+ issue1.labels.extend(['Type-FLT-Launch', 'FLT-Conversion'])
+ issue1.labels.remove('Type-Launch')
+ issue2.labels.extend(['Type-FLT-Launch', 'FLT-Conversion'])
+ issue2.labels.remove('Type-Launch')
+ issue1.approval_values = self.approval_values
+ issue2.approval_values = self.approval_values
+ issue1.phases = self.phases
+ issue2.phases = self.phases
+ issue1.field_values.extend(fvs)
+
+ # Set up mocks
+ patcher = mock.patch(
+ 'search.frontendsearchpipeline.FrontendSearchPipeline',
+ spec=True, visible_results=[issue1, issue2]) # converted issues
+ mockPipeline = patcher.start()
+ self.task.services.project.GetProjectByName = mock.Mock()
+ self.task.services.config.GetProjectConfig = mock.Mock(
+ return_value=self.config)
+ self.task.services.issue.GetIssue = mock.Mock(
+ side_effect=[issue1, issue2])
+ self.task.services.issue._UpdateIssuesApprovals = mock.Mock()
+ self.task.services.issue.UpdateIssue = mock.Mock()
+
+ with self.work_env as we:
+ we.ListIssues = mock.Mock(return_value=mockPipeline)
+
+ json = self.task.UndoConversion(self.mr)
+ self.assertEqual(json['deleting'], [1, 2])
+ # assert convert issue1 is back to the pre-conversion state, self.issue1.
+ self.assertEqual(issue1, self.issue1)
+ self.assertEqual(issue2, self.issue2)
+
+ # assert UpdateIssue calls were made with pre-conversion state issues.
+ update_calls = [
+ mock.call(self.mr.cnxn, self.issue1),
+ mock.call(self.mr.cnxn, self.issue2)]
+ self.task.services.issue._UpdateIssuesApprovals.assert_has_calls(
+ update_calls)
+ self.task.services.issue.UpdateIssue.assert_has_calls(update_calls)
+ patcher.stop()
+
+ def testVerifyConversion(self):
+ # Set up objects
+ self.issue1.labels.extend(
+ # Launch-M-Target-70-Stable-Exp should be ignored
+ ['Rollout-Type-Default', 'Launch-M-Target-70-Stable-Exp'])
+ self.issue1.phases = [tracker_pb2.Phase(name='Beta', phase_id=1),
+ tracker_pb2.Phase(name='Stable', phase_id=2)]
+ self.issue1.approval_values = [
+ tracker_pb2.ApprovalValue(
+ approval_id=1, status=tracker_pb2.ApprovalStatus.NOT_SET),
+ tracker_pb2.ApprovalValue(
+ approval_id=2, status=tracker_pb2.ApprovalStatus.APPROVED),
+ tracker_pb2.ApprovalValue(
+ approval_id=3, status=tracker_pb2.ApprovalStatus.NEED_INFO),
+ ]
+ self.issue1.field_values = [
+ # problem = expected field for TL
+ tracker_bizobj.MakeFieldValue(4, None, None, 111, None, None, False),
+ tracker_pb2.FieldValue(field_id=7, int_value=70, phase_id=1),
+ tracker_pb2.FieldValue(field_id=8, int_value=71, phase_id=2),
+ ]
+
+ self.issue2.labels.extend(['Rollout-Type-Finch'])
+ self.issue2.phases = [tracker_pb2.Phase(name='Beta', phase_id=1),
+ tracker_pb2.Phase(name='Stable-Full', phase_id=2),
+ tracker_pb2.Phase(name='Stable-Exp', phase_id=3)]
+ self.issue2.approval_values = [
+ tracker_pb2.ApprovalValue(
+ approval_id=1, status=tracker_pb2.ApprovalStatus.NOT_SET),
+ tracker_pb2.ApprovalValue(
+ approval_id=2, status=tracker_pb2.ApprovalStatus.NOT_SET),
+ tracker_pb2.ApprovalValue(
+ # problem = approval Chrome-Privacy has status approved for None
+ approval_id=3, status=tracker_pb2.ApprovalStatus.APPROVED),
+ ]
+ self.issue2.field_values = [
+ # problem = no phase field for label 'Launch-M-Approved-70-Beta'
+ tracker_pb2.FieldValue(field_id=7, int_value=71, phase_id=2),
+ tracker_bizobj.MakeFieldValue(4, None, None, 111, None, None, False),
+ tracker_bizobj.MakeFieldValue(5, None, None, 111, None, None, False),
+ ]
+
+ self.issue3.labels.extend(['Rollout-Type-Default'])
+ self.issue3.phases = [tracker_pb2.Phase(name='Feature Freeze', phase_id=4),
+ tracker_pb2.Phase(name='Branch', phase_id=5),
+ tracker_pb2.Phase(name='Stable', phase_id=6)]
+ self.issue3.approval_values = [
+ tracker_pb2.ApprovalValue(
+ approval_id=9, status=tracker_pb2.ApprovalStatus.APPROVED),
+ tracker_pb2.ApprovalValue(
+ approval_id=10, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)]
+ # problem = no phase field label Launch-M-Target-70-Stable
+ # problem = missing a field for TL
+ self.issue3.field_values = [
+ tracker_pb2.FieldValue(field_id=8, int_value=71, phase_id=6),
+ tracker_bizobj.MakeFieldValue(4, None, None, 111, None, None, False)
+ ]
+
+ self.issue4.labels.extend(['Rollout-Type-Default'])
+ # problem = incorrect phases for OS default launch
+ self.issue4.phases = [tracker_pb2.Phase(name='Branch', phase_id=5),
+ tracker_pb2.Phase(name='Stable-Exp', phase_id=7)]
+ # problem = approval ChromeOS-UX has status 'NEEDS_REVIEW'
+ # for label value Yes
+ self.issue4.approval_values = [
+ tracker_pb2.ApprovalValue(
+ approval_id=9, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)]
+
+ self.issue5.labels.extend(['Rollout-Type-Finch'])
+ self.issue5.phases = [tracker_pb2.Phase(name='Branch', phase_id=5),
+ tracker_pb2.Phase(name='Feature Freeze', phase_id=4),
+ tracker_pb2.Phase(name='Stable-Exp', phase_id=7),
+ tracker_pb2.Phase(name='Stable-Full', phase_id=8)]
+ self.issue5.approval_values = [
+ tracker_pb2.ApprovalValue(
+ approval_id=9, status=tracker_pb2.ApprovalStatus.APPROVED),
+ # problem = approval ChromeOS-Privacy has status 'REVIEW_REQUESTED'
+ # for label value NeedInfo
+ tracker_pb2.ApprovalValue(
+ approval_id=11, status=tracker_pb2.ApprovalStatus.REVIEW_REQUESTED),
+ # problem = approval ChromeOS-Leadership-Exp has status 'NA' for label
+ # value Yes.
+ tracker_pb2.ApprovalValue(
+ approval_id=13, status=tracker_pb2.ApprovalStatus.NA)
+ ]
+
+ # problem = no phase field for label Launch-M-Approved-79-Stable-Exp
+ # problem = no phase field for label Launch-M-Target-70-Stable
+ self.issue5.field_values = [
+ tracker_pb2.FieldValue(field_id=8, int_value=71, phase_id=8),
+ tracker_bizobj.MakeFieldValue(4, None, None, 111, None, None, False),
+ tracker_bizobj.MakeFieldValue(5, None, None, 111, None, None, False)]
+
+ self.config.field_defs = [
+ tracker_pb2.FieldDef(field_id=1, field_name='Chrome-Test'),
+ tracker_pb2.FieldDef(field_id=2, field_name='Chrome-UX'),
+ tracker_pb2.FieldDef(field_id=3, field_name='Chrome-Privacy'),
+ tracker_pb2.FieldDef(field_id=4, field_name='PM'),
+ tracker_pb2.FieldDef(field_id=5, field_name='TL'),
+ tracker_pb2.FieldDef(field_id=6, field_name='TE'),
+ tracker_pb2.FieldDef(field_id=12, field_name='UX'),
+ tracker_pb2.FieldDef(field_id=7, field_name='M-Target'),
+ tracker_pb2.FieldDef(field_id=8, field_name='M-Approved'),
+ tracker_pb2.FieldDef(field_id=9, field_name='ChromeOS-UX'),
+ tracker_pb2.FieldDef(field_id=10, field_name='ChromeOS-Enterprise'),
+ tracker_pb2.FieldDef(field_id=11, field_name='ChromeOS-Privacy'),
+ tracker_pb2.FieldDef(field_id=13, field_name='ChromeOS-Leadership-Exp')
+ ]
+
+ # Set up mocks
+ patcher = mock.patch(
+ 'search.frontendsearchpipeline.FrontendSearchPipeline',
+ spec=True, allowed_results=[
+ self.issue1, self.issue2, self.issue3, self.issue4, self.issue5])
+ mockPipeline = patcher.start()
+ self.task.services.project.GetProjectByName = mock.Mock()
+ self.task.services.config.GetProjectConfig = mock.Mock(
+ return_value=self.config)
+ self.task.services.issue.GetIssue = mock.Mock(
+ side_effect=[
+ self.issue1, self.issue2, self.issue3, self.issue4, self.issue5])
+ self.task.services.user.LookupUserID = mock.Mock(return_value=111)
+ with self.work_env as we:
+ we.ListIssues = mock.Mock(return_value=mockPipeline)
+
+ # Assert
+ json = self.task.VerifyConversion(self.mr)
+ self.assertEqual(json['issues verified'],
+ ['issue 1', 'issue 2', 'issue 3', 'issue 4', 'issue 5'])
+ problems = json['problems found']
+ expected_problems = [
+ 'issue 1: missing a field for TL',
+ 'issue 1: missing a field for UX',
+ 'issue 2: approval Chrome-Privacy has status \'APPROVED\' for '
+ 'label value None',
+ 'issue 2: no phase field for label Launch-M-Approved-70-Beta',
+ 'issue 3: missing a field for TL',
+ 'issue 3: no phase field for label Launch-M-Target-70-Stable',
+ 'issue 4: incorrect phases for OS default launch.',
+ 'issue 4: approval ChromeOS-UX has status \'NEEDS_REVIEW\' for '
+ 'label value Yes',
+ 'issue 5: approval ChromeOS-Privacy has status \'REVIEW_REQUESTED\' '
+ 'for label value NeedInfo',
+ 'issue 5: approval ChromeOS-Leadership-Exp has status \'NA\' for label '
+ 'value Yes',
+ 'issue 5: no phase field for label Launch-M-Approved-79-Stable-Exp',
+ 'issue 5: no phase field for label Launch-M-Target-70-Stable',
+ ]
+ self.assertEqual(problems, expected_problems)
+ patcher.stop()
+
+ def testFetchAndAssertProjectInfo(self):
+
+ # test no 'launch' in request
+ self.assertRaisesRegexp(
+ AssertionError, r'bad launch type:',
+ self.task.FetchAndAssertProjectInfo, self.mr)
+
+ # test bad 'launch' in request
+ mr = testing_helpers.MakeMonorailRequest(path='url/url?launch=bad')
+ self.assertRaisesRegexp(
+ AssertionError, r'bad launch type: bad',
+ self.task.FetchAndAssertProjectInfo, mr)
+
+ self.task.services.project.GetProjectByName = mock.Mock()
+ self.task.services.config.GetProjectConfig = mock.Mock(
+ return_value=self.config)
+
+ mr = testing_helpers.MakeMonorailRequest(path='url/url?launch=default')
+ # test no template
+ self.task.services.template.GetTemplateByName = mock.Mock(
+ return_value=None)
+ self.assertRaisesRegexp(
+ AssertionError, r'not found in chromium project',
+ self.task.FetchAndAssertProjectInfo, mr)
+
+ # test template has no phases/approvals
+ template = tracker_bizobj.MakeIssueTemplate(
+ 'template', 'sum', 'New', 111, 'content', [], [], [], [])
+ self.task.services.template.GetTemplateByName = mock.Mock(
+ return_value=template)
+ self.assertRaisesRegexp(
+ AssertionError, 'no approvals or phases in',
+ self.task.FetchAndAssertProjectInfo, mr)
+
+ # test phases not recognized
+ template.phases = [tracker_pb2.Phase(name='WeirdPhase')]
+ template.approval_values = [tracker_pb2.ApprovalValue()]
+ self.assertRaisesRegexp(
+ AssertionError, 'one or more phases not recognized',
+ self.task.FetchAndAssertProjectInfo, mr)
+
+ template.phases = [tracker_pb2.Phase(name='Stable'),
+ tracker_pb2.Phase(name='Stable-Exp')]
+ template.approval_values = [
+ tracker_pb2.ApprovalValue(approval_id=1),
+ tracker_pb2.ApprovalValue(approval_id=2),
+ tracker_pb2.ApprovalValue(approval_id=3)]
+
+ # test approvals not recognized
+ self.assertRaisesRegexp(
+ AssertionError, 'one or more approvals not recognized',
+ self.task.FetchAndAssertProjectInfo, mr)
+
+ self.config.field_defs = [
+ tracker_pb2.FieldDef(field_id=1, field_name='ChromeOS-Enterprise',
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+ tracker_pb2.FieldDef(field_id=2, field_name='Chrome-UX',
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+ tracker_pb2.FieldDef(field_id=3, field_name='Chrome-Privacy',
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)
+ ]
+
+ # test approvals not in config's approval_defs
+ self.assertRaisesRegexp(
+ AssertionError, 'one or more approvals not in config.approval_defs',
+ self.task.FetchAndAssertProjectInfo, mr)
+
+ self.config.approval_defs = [
+ tracker_pb2.ApprovalDef(approval_id=1),
+ tracker_pb2.ApprovalDef(approval_id=2),
+ tracker_pb2.ApprovalDef(approval_id=3)]
+
+ # test no pm field exists in project
+ self.assertRaisesRegexp(
+ AssertionError, 'project has no FieldDef %s' % fltconversion.PM_FIELD,
+ self.task.FetchAndAssertProjectInfo, mr)
+
+ self.config.field_defs.extend([
+ tracker_pb2.FieldDef(field_id=4, field_name='PM',
+ field_type=tracker_pb2.FieldTypes.USER_TYPE),
+ tracker_pb2.FieldDef(field_id=5, field_name='TL',
+ field_type=tracker_pb2.FieldTypes.USER_TYPE),
+ tracker_pb2.FieldDef(field_id=9, field_name='UX',
+ field_type=tracker_pb2.FieldTypes.USER_TYPE),
+ tracker_pb2.FieldDef(field_id=6, field_name='TE')
+ ])
+
+ # test no USER_TYPE te field exists in project
+ self.assertRaisesRegexp(
+ AssertionError, 'project has no FieldDef %s' % fltconversion.TE_FIELD,
+ self.task.FetchAndAssertProjectInfo, mr)
+
+ self.config.field_defs[-1].field_type = tracker_pb2.FieldTypes.USER_TYPE
+ self.config.field_defs.extend([
+ tracker_pb2.FieldDef(
+ field_id=7, field_name='M-Target', is_phase_field=True),
+ tracker_pb2.FieldDef(
+ field_id=8, field_name='M-Approved', is_multivalued=True,
+ field_type=tracker_pb2.FieldTypes.INT_TYPE)
+ ])
+
+ # test no M-Target INT_TYPE multivalued Phase FieldDefs
+ self.assertRaisesRegexp(
+ AssertionError,
+ 'project has no FieldDef %s' % fltconversion.MTARGET_FIELD,
+ self.task.FetchAndAssertProjectInfo, mr)
+
+ self.config.field_defs[-2].field_type = tracker_pb2.FieldTypes.INT_TYPE
+ self.config.field_defs[-2].is_multivalued = True
+
+ # test no M-Approved INT_TYPE multivalued Phase FieldDefs
+ self.assertRaisesRegexp(
+ AssertionError,
+ 'project has no FieldDef %s' % fltconversion.MAPPROVED_FIELD,
+ self.task.FetchAndAssertProjectInfo, mr)
+
+ self.config.field_defs[-1].is_phase_field = True
+
+ self.assertEqual(
+ self.task.FetchAndAssertProjectInfo(mr),
+ fltconversion.ProjectInfo(
+ self.config, fltconversion.QUERY_MAP['default'],
+ template.approval_values, template.phases, 4, 5, 6, 9, 7, 8,
+ fltconversion.BROWSER_PHASE_MAP,
+ fltconversion.BROWSER_APPROVALS_TO_LABELS,
+ fltconversion.BROWSER_M_LABELS_RE))
+
+ # FINCH special case
+ # test approvals for Finch not required
+ mr = testing_helpers.MakeMonorailRequest(path='url/url?launch=finch')
+ self.assertRaisesRegexp(
+ AssertionError, 'finch template not set up correctly',
+ self.task.FetchAndAssertProjectInfo, mr)
+
+ for av in template.approval_values:
+ av.status = tracker_pb2.ApprovalStatus.NEEDS_REVIEW
+
+ self.assertEqual(
+ self.task.FetchAndAssertProjectInfo(mr),
+ fltconversion.ProjectInfo(
+ self.config, fltconversion.QUERY_MAP['finch'],
+ template.approval_values, template.phases, 4, 5, 6, 9, 7, 8,
+ fltconversion.BROWSER_PHASE_MAP,
+ fltconversion.BROWSER_APPROVALS_TO_LABELS,
+ fltconversion.BROWSER_M_LABELS_RE))
+
+ def testFetchAndAssertProjectInfo_OS(self):
+ self.task.services.project.GetProjectByName = mock.Mock()
+ self.task.services.config.GetProjectConfig = mock.Mock(
+ return_value=self.config)
+
+ mr = testing_helpers.MakeMonorailRequest(path='url/url?launch=os')
+ template = tracker_bizobj.MakeIssueTemplate(
+ 'template', 'sum', 'New', 111, 'content', [], [], [], [])
+ self.task.services.template.GetTemplateByName = mock.Mock(
+ return_value=template)
+
+ # test phases not recognized
+ template.phases = [tracker_pb2.Phase(name='Chrome-Test')]
+ template.approval_values = [tracker_pb2.ApprovalValue()]
+ self.assertRaisesRegexp(
+ AssertionError, 'one or more phases not recognized',
+ self.task.FetchAndAssertProjectInfo, mr)
+
+ template.phases = [tracker_pb2.Phase(name='feature freeze'),
+ tracker_pb2.Phase(name='branch')]
+
+ # test template not set up correctly
+ template.approval_values = [
+ tracker_pb2.ApprovalValue(approval_id=1),
+ tracker_pb2.ApprovalValue(approval_id=2),
+ tracker_pb2.ApprovalValue(approval_id=3)]
+ self.assertRaisesRegexp(
+ AssertionError, 'os template not set up correctly',
+ self.task.FetchAndAssertProjectInfo, mr)
+
+ for av in template.approval_values:
+ av.status = tracker_pb2.ApprovalStatus.NEEDS_REVIEW
+
+ # test approvals not recognized
+ self.assertRaisesRegexp(
+ AssertionError, 'one or more approvals not recognized',
+ self.task.FetchAndAssertProjectInfo, mr)
+
+ self.config.field_defs = [
+ tracker_pb2.FieldDef(field_id=1, field_name='ChromeOS-Enterprise',
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+ tracker_pb2.FieldDef(field_id=2, field_name='ChromeOS-UX',
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+ tracker_pb2.FieldDef(field_id=3, field_name='ChromeOS-Privacy',
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)
+ ]
+
+ # Skip remaining checks. No different from Browser process.
+ self.config.approval_defs = [
+ tracker_pb2.ApprovalDef(approval_id=1),
+ tracker_pb2.ApprovalDef(approval_id=2),
+ tracker_pb2.ApprovalDef(approval_id=3)]
+
+ self.config.field_defs.extend([
+ tracker_pb2.FieldDef(field_id=4, field_name='PM',
+ field_type=tracker_pb2.FieldTypes.USER_TYPE),
+ tracker_pb2.FieldDef(field_id=5, field_name='TL',
+ field_type=tracker_pb2.FieldTypes.USER_TYPE),
+ tracker_pb2.FieldDef(field_id=6, field_name='TE',
+ field_type=tracker_pb2.FieldTypes.USER_TYPE),
+ tracker_pb2.FieldDef(field_id=9, field_name='UX',
+ field_type=tracker_pb2.FieldTypes.USER_TYPE)
+ ])
+ self.config.field_defs.extend([
+ tracker_pb2.FieldDef(
+ field_id=7, field_name='M-Target', is_phase_field=True,
+ is_multivalued=True, field_type=tracker_pb2.FieldTypes.INT_TYPE),
+ tracker_pb2.FieldDef(
+ field_id=8, field_name='M-Approved', is_phase_field=True,
+ is_multivalued=True, field_type=tracker_pb2.FieldTypes.INT_TYPE)
+ ])
+
+ self.assertEqual(
+ self.task.FetchAndAssertProjectInfo(mr),
+ fltconversion.ProjectInfo(
+ self.config, fltconversion.QUERY_MAP['os'],
+ template.approval_values, template.phases, 4, 5, 6, 9, 7, 8,
+ fltconversion.OS_PHASE_MAP, fltconversion.OS_APPROVALS_TO_LABELS,
+ fltconversion.OS_M_LABELS_RE))
+
+ @mock.patch('time.time')
+ def testExecuteIssueChanges(self, mockTime):
+ mockTime.return_value = 123
+ self.task.services.issue._UpdateIssuesApprovals = mock.Mock()
+ self.task.services.issue.DeltaUpdateIssue = mock.Mock(
+ return_value=([], None))
+ self.task.services.issue.InsertComment = mock.Mock()
+ self.config.approval_defs = [
+ tracker_pb2.ApprovalDef(
+ # test empty survey
+ approval_id=1, survey='', approver_ids=[111, 222]),
+ tracker_pb2.ApprovalDef(approval_id=2), # test missing survey
+ tracker_pb2.ApprovalDef(survey='Missing approval_id should not error.'),
+ tracker_pb2.ApprovalDef(approval_id=3, survey='Q1\nQ2\n\nQ3'),
+ tracker_pb2.ApprovalDef(approval_id=4, survey='Q1\nQ2\n\nQ3 two'),
+ tracker_pb2.ApprovalDef()]
+
+ new_avs = [tracker_pb2.ApprovalValue(
+ approval_id=1, status=tracker_pb2.ApprovalStatus.APPROVED),
+ tracker_pb2.ApprovalValue(approval_id=4),
+ tracker_pb2.ApprovalValue(approval_id=2),
+ tracker_pb2.ApprovalValue(approval_id=3)]
+
+ phases = [tracker_pb2.Phase(phase_id=1, name='Phase1', rank=1)]
+ new_fvs = [tracker_bizobj.MakeFieldValue(
+ 11, 70, None, None, None, None, False, phase_id=1),
+ tracker_bizobj.MakeFieldValue(
+ 12, None, 'strfield', None, None, None, False)]
+ _amendments = self.task.ExecuteIssueChanges(
+ self.config, self.issue, new_avs, phases, new_fvs)
+
+ # approver_ids set in ExecuteIssueChanges()
+ new_avs[0].approver_ids = [111, 222]
+ self.issue.approval_values = new_avs
+ self.issue.phases = phases
+ delta = tracker_pb2.IssueDelta(
+ labels_add=['Type-FLT-Launch', 'FLT-Conversion'],
+ labels_remove=['Type-Launch'], field_vals_add=new_fvs)
+ cmt_1 = tracker_pb2.IssueComment(
+ issue_id=78901, project_id=789, user_id=self.mr.auth.user_id,
+ content='', is_description=True, approval_id=1, timestamp=123)
+ cmt_2 = tracker_pb2.IssueComment(
+ issue_id=78901, project_id=789, user_id=self.mr.auth.user_id,
+ content='', is_description=True, approval_id=2, timestamp=123)
+ cmt_3 = tracker_pb2.IssueComment(
+ issue_id=78901, project_id=789, user_id=self.mr.auth.user_id,
+ content='<b>Q1</b>\n<b>Q2</b>\n<b></b>\n<b>Q3</b>',
+ is_description=True, approval_id=3, timestamp=123)
+ cmt_4 = tracker_pb2.IssueComment(
+ issue_id=78901, project_id=789, user_id=self.mr.auth.user_id,
+ content='<b>Q1</b>\n<b>Q2</b>\n<b></b>\n<b>Q3 two</b>',
+ is_description=True, approval_id=4, timestamp=123)
+
+
+ comment_calls = [mock.call(self.mr.cnxn, cmt_1),
+ mock.call(self.mr.cnxn, cmt_4),
+ mock.call(self.mr.cnxn, cmt_2),
+ mock.call(self.mr.cnxn, cmt_3)]
+ self.task.services.issue.InsertComment.assert_has_calls(comment_calls)
+
+ self.task.services.issue._UpdateIssuesApprovals.assert_called_once_with(
+ self.mr.cnxn, self.issue)
+ self.task.services.issue.DeltaUpdateIssue.assert_called_once_with(
+ self.mr.cnxn, self.task.services, self.mr.auth.user_id, 789,
+ self.config, self.issue, delta,
+ comment=fltconversion.CONVERSION_COMMENT)
+
+ def testConvertPeopleLabels(self):
+ self.task.services.user.LookupUserID = mock.Mock(
+ side_effect=[1, 2, 3, 4, 5, 6])
+ labels = [
+ 'pm-u1', 'pm-u2', 'tl-u2', 'test-3', 'test-4', 'ux-u5', 'ux-6']
+ fvs = self.task.ConvertPeopleLabels(self.mr, labels, 11, 12, 13, 14)
+ expected = [
+ tracker_bizobj.MakeFieldValue(11, None, None, 1, None, None, False),
+ tracker_bizobj.MakeFieldValue(12, None, None, 2, None, None, False),
+ tracker_bizobj.MakeFieldValue(13, None, None, 3, None, None, False),
+ tracker_bizobj.MakeFieldValue(13, None, None, 4, None, None, False),
+ tracker_bizobj.MakeFieldValue(14, None, None, 5, None, None, False),
+ tracker_bizobj.MakeFieldValue(14, None, None, 6, None, None, False),
+ ]
+ self.assertEqual(fvs, expected)
+
+ def testConvertPeopleLabels_NoUsers(self):
+ def side_effect(_cnxn, _email):
+ raise exceptions.NoSuchUserException()
+ labels = []
+ self.task.services.user.LookupUserID = mock.Mock(side_effect=side_effect)
+ self.assertFalse(
+ len(self.task.ConvertPeopleLabels(self.mr, labels, 11, 12, 13, 14)))
+
+ def testCreateUserFieldValue_Chromium(self):
+ self.task.services.user.LookupUserID = mock.Mock(return_value=1)
+ actual = self.task.CreateUserFieldValue(self.mr, 'ldap', 11)
+ expected = tracker_bizobj.MakeFieldValue(
+ 11, None, None, 1, None, None, False)
+ self.assertEqual(actual, expected)
+ self.task.services.user.LookupUserID.assert_called_once_with(
+ self.mr.cnxn, 'ldap@chromium.org')
+
+ def testCreateUserFieldValue_Goog(self):
+ def side_effect(_cnxn, email):
+ if email.endswith('chromium.org'):
+ raise exceptions.NoSuchUserException()
+ else:
+ return 2
+ self.task.services.user.LookupUserID = mock.Mock(side_effect=side_effect)
+ actual = self.task.CreateUserFieldValue(self.mr, 'ldap', 11)
+ expected = tracker_bizobj.MakeFieldValue(
+ 11, None, None, 2, None, None, False)
+ self.assertEqual(actual, expected)
+ self.task.services.user.LookupUserID.assert_any_call(
+ self.mr.cnxn, 'ldap@chromium.org')
+ self.task.services.user.LookupUserID.assert_any_call(
+ self.mr.cnxn, 'ldap@google.com')
+
+ def testCreateUserFieldValue_NoUserFound(self):
+ def side_effect(_cnxn, _email):
+ raise exceptions.NoSuchUserException()
+ self.task.services.user.LookupUserID = mock.Mock(side_effect=side_effect)
+ self.assertIsNone(self.task.CreateUserFieldValue(self.mr, 'ldap', 11))
+
+
+class ConvertMLabels(unittest.TestCase):
+
+ def setUp(self):
+ self.target_id = 24
+ self.approved_id = 27
+ self.beta_phase = tracker_pb2.Phase(phase_id=1, name='bEtA')
+ self.stable_phase = tracker_pb2.Phase(phase_id=2, name='StAbLe')
+ self.stable_full_phase = tracker_pb2.Phase(phase_id=3, name='stable-FULL')
+ self.stable_exp_phase = tracker_pb2.Phase(phase_id=4, name='STABLE-exp')
+ self.feature_freeze_phase = tracker_pb2.Phase(
+ phase_id=5, name='FEATURE Freeze')
+ self.branch_phase = tracker_pb2.Phase(phase_id=6, name='bRANCH')
+
+ def testConvertMLabels_NormalFinch(self):
+
+ phases = [self.stable_exp_phase, self.beta_phase, self.stable_full_phase]
+ labels = [
+ 'launch-m-approved-81-beta', # beta:M-Approved=81
+ 'launch-m-target-80-stable-car', # ignore
+ 'a-Launch-M-Target-80-Stable-car', # ignore
+ 'launch-m-target-70-Stable', # stable-full:M-Target=70
+ 'LAUNCH-M-TARGET-71-STABLE', # stable-full:M-Target=71
+ 'launch-m-target-70-stable-exp', # stable-exp:M-Target=70
+ 'launch-m-target-69-stable-exp', # stable-exp:M-Target=69
+ 'launch-M-APPROVED-70-Stable-Exp', # stable-exp:M-Approved-70
+ 'launch-m-approved-73-stable', # stable-full:M-Approved-73
+ 'launch-m-error-73-stable', # ignore
+ 'launch-m-approved-8-stable', #ignore
+ 'irrelevant label-weird', # ignore
+ ]
+ actual_fvs = fltconversion.ConvertMLabels(
+ labels, phases, self.target_id, self.approved_id,
+ fltconversion.BROWSER_M_LABELS_RE, fltconversion.BROWSER_PHASE_MAP)
+
+ expected_fvs = [
+ tracker_pb2.FieldValue(
+ field_id=self.approved_id, int_value=81,
+ phase_id=self.beta_phase.phase_id, derived=False,),
+ tracker_pb2.FieldValue(
+ field_id=self.target_id, int_value=70,
+ phase_id=self.stable_full_phase.phase_id, derived=False),
+ tracker_pb2.FieldValue(
+ field_id=self.target_id, int_value=71,
+ phase_id=self.stable_full_phase.phase_id, derived=False),
+ tracker_pb2.FieldValue(
+ field_id=self.target_id, int_value=70,
+ phase_id=self.stable_exp_phase.phase_id, derived=False),
+ tracker_pb2.FieldValue(
+ field_id=self.target_id, int_value=69,
+ phase_id=self.stable_exp_phase.phase_id, derived=False),
+ tracker_pb2.FieldValue(
+ field_id=self.approved_id, int_value=70,
+ phase_id=self.stable_exp_phase.phase_id, derived=False),
+ tracker_pb2.FieldValue(
+ field_id=self.approved_id, int_value=73,
+ phase_id=self.stable_full_phase.phase_id, derived=False)
+ ]
+
+ self.assertEqual(actual_fvs, expected_fvs)
+
+ def testConvertMLabels_OS(self):
+ phases = [self.feature_freeze_phase, self.branch_phase, self.stable_phase]
+ labels = [
+ 'launch-m-approved-81-beta', # ignore
+ 'launch-m-target-80-stable-car', # ignore
+ 'a-Launch-M-Target-80-Stable-car', # ignore
+ 'launch-m-target-70-Stable', # stable:M-Target=70
+ 'LAUNCH-M-TARGET-71-STABLE', # stable:M-Target=71
+ 'launch-m-target-70-stable-exp', # ignore
+ 'launch-M-APPROVED-70-Stable-Exp', # ignore
+ 'launch-m-approved-73-stable', # stable:M-Approved-73
+ 'launch-m-error-73-stable', # ignore
+ 'launch-m-approved-8-stable', #ignore
+ 'irrelevant label-weird', # ignore
+ ]
+ actual_fvs = fltconversion.ConvertMLabels(
+ labels, phases, self.target_id, self.approved_id,
+ fltconversion.OS_M_LABELS_RE, fltconversion.OS_PHASE_MAP)
+
+ expected_fvs = [
+ tracker_pb2.FieldValue(
+ field_id=self.target_id, int_value=70,
+ phase_id=self.stable_phase.phase_id, derived=False,),
+ tracker_pb2.FieldValue(
+ field_id=self.target_id, int_value=71,
+ phase_id=self.stable_phase.phase_id, derived=False),
+ tracker_pb2.FieldValue(
+ field_id=self.approved_id, int_value=73,
+ phase_id=self.stable_phase.phase_id, derived=False)
+ ]
+
+ self.assertEqual(actual_fvs, expected_fvs)
+
+
+class ConvertLaunchLabels(unittest.TestCase):
+
+ def setUp(self):
+ self.project_fds = [
+ tracker_pb2.FieldDef(
+ field_id=1, project_id=789, field_name='String',
+ field_type=tracker_pb2.FieldTypes.STR_TYPE),
+ tracker_pb2.FieldDef(
+ field_id=2, project_id=789, field_name='Chrome-UX',
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+ tracker_pb2.FieldDef(
+ field_id=3, project_id=789, field_name='Chrome-Privacy',
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)
+ ]
+ approvalUX = tracker_pb2.ApprovalValue(
+ approval_id=2, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)
+ approvalPrivacy = tracker_pb2.ApprovalValue(approval_id=3)
+ self.approvals = [approvalUX, approvalPrivacy]
+
+ def testConvertLaunchLabels_Normal(self):
+ labels = [
+ 'Launch-UX-NotReviewed', 'Launch-Privacy-Yes', 'Launch-NotRelevant']
+ actual = fltconversion.ConvertLaunchLabels(
+ labels, self.approvals, self.project_fds,
+ fltconversion.BROWSER_APPROVALS_TO_LABELS)
+ expected = [
+ tracker_pb2.ApprovalValue(
+ approval_id=2, status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW),
+ tracker_pb2.ApprovalValue(
+ approval_id=3, status=tracker_pb2.ApprovalStatus.APPROVED)
+ ]
+ self.assertEqual(actual, expected)
+
+ def testConvertLaunchLabels_ExtraAndMissingLabels(self):
+ labels = [
+ 'Blah-Launch-Privacy-Yes', # Missing, this is not a valid Label
+ 'Launch-Security-Yes', # Extra, no matching approval in given approvals
+ 'Launch-UI-Yes'] # Missing Launch-Privacy
+ actual = fltconversion.ConvertLaunchLabels(
+ labels, self.approvals, self.project_fds,
+ fltconversion.BROWSER_APPROVALS_TO_LABELS)
+ expected = [
+ tracker_pb2.ApprovalValue(
+ approval_id=2, status=tracker_pb2.ApprovalStatus.APPROVED),
+ tracker_pb2.ApprovalValue(
+ approval_id=3, status=tracker_pb2.ApprovalStatus.NOT_SET)
+ ]
+ self.assertEqual(actual, expected)
+
+class ExtractLabelLDAPs(unittest.TestCase):
+
+ def testExtractLabelLDAPs_Normal(self):
+ labels = [
+ 'tl-USER1',
+ 'pm-',
+ 'tL-User2',
+ 'test-user4',
+ 'PM-USER3',
+ 'pm',
+ 'test-user5',
+ 'test-',
+ 'ux-user9']
+ (actual_pm, actual_tl, actual_tests,
+ actual_ux) = fltconversion.ExtractLabelLDAPs(labels)
+ self.assertEqual(actual_pm, 'user3')
+ self.assertEqual(actual_tl, 'user2')
+ self.assertEqual(actual_tests, ['user4', 'user5'])
+ self.assertEqual(actual_ux, ['user9'])
+
+ def testExtractLabelLDAPs_NoLabels(self):
+ (actual_pm, actual_tl, actual_tests,
+ actual_ux) = fltconversion.ExtractLabelLDAPs([])
+ self.assertIsNone(actual_pm)
+ self.assertIsNone(actual_tl)
+ self.assertFalse(len(actual_tests))
+ self.assertFalse(len(actual_ux))
diff --git a/tracker/test/issueadmin_test.py b/tracker/test/issueadmin_test.py
new file mode 100644
index 0000000..751e414
--- /dev/null
+++ b/tracker/test/issueadmin_test.py
@@ -0,0 +1,464 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for the issue admin pages."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import unittest
+
+from mock import Mock, patch
+
+from framework import permissions
+from framework import urls
+from proto import tracker_pb2
+from services import service_manager
+from services import template_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import issueadmin
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+class TestBase(unittest.TestCase):
+
+ def setUpServlet(self, servlet_factory):
+ # pylint: disable=attribute-defined-outside-init
+ self.services = service_manager.Services(
+ project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ user=fake.UserService(),
+ issue=fake.IssueService(),
+ template=Mock(spec=template_svc.TemplateService),
+ features=fake.FeaturesService())
+ self.servlet = servlet_factory('req', 'res', services=self.services)
+ self.project = self.services.project.TestAddProject(
+ 'proj', project_id=789, contrib_ids=[333])
+ self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ self.services.config.StoreConfig(None, self.config)
+ self.cnxn = fake.MonorailConnection()
+ self.mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/admin', project=self.project)
+ self.mox = mox.Mox()
+ self.test_template = tracker_bizobj.MakeIssueTemplate(
+ 'Test Template', 'sum', 'New', 111, 'content', [], [], [], [])
+ self.test_template.template_id = 12345
+ self.test_templates = testing_helpers.DefaultTemplates()
+ self.test_templates.append(self.test_template)
+ self.services.template.GetProjectTemplates\
+ .return_value = self.test_templates
+ self.services.template.GetTemplateSetForProject\
+ .return_value = [(12345, 'Test template', 0)]
+
+ def tearDown(self):
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+
+ def _mockGetUser(self):
+ self.mox.StubOutWithMock(self.services.user, 'GetUser')
+ user = self.services.user.TestAddUser('user@invalid', 100)
+ self.services.user.GetUser(
+ mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+
+
+class IssueAdminBaseTest(TestBase):
+
+ def setUp(self):
+ super(IssueAdminBaseTest, self).setUpServlet(issueadmin.IssueAdminBase)
+
+ def testGatherPageData(self):
+ self._mockGetUser()
+ self.mox.ReplayAll()
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.mox.VerifyAll()
+
+ self.assertItemsEqual(
+ ['admin_tab_mode', 'config', 'open_text', 'closed_text', 'labels_text'],
+ list(page_data.keys()))
+ config_view = page_data['config']
+ self.assertEqual(789, config_view.project_id)
+
+
+class AdminStatusesTest(TestBase):
+
+ def setUp(self):
+ super(AdminStatusesTest, self).setUpServlet(issueadmin.AdminStatuses)
+
+ @patch('framework.servlet.Servlet.PleaseCorrect')
+ def testProcessSubtabForm_MissingInput(self, mock_pc):
+ post_data = fake.PostData()
+ next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+ self.assertIsNone(next_url)
+ mock_pc.assert_called_once()
+ self.assertEqual(len(tracker_constants.DEFAULT_WELL_KNOWN_STATUSES),
+ len(self.config.well_known_statuses))
+ self.assertEqual(tracker_constants.DEFAULT_STATUSES_OFFER_MERGE,
+ self.config.statuses_offer_merge)
+
+ @patch('framework.servlet.Servlet.PleaseCorrect')
+ def testProcessSubtabForm_EmptyInput(self, mock_pc):
+ post_data = fake.PostData(
+ predefinedopen=[''], predefinedclosed=[''], statuses_offer_merge=[''])
+ next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+ self.assertIsNone(next_url)
+ mock_pc.assert_called_once()
+ self.assertEqual(len(tracker_constants.DEFAULT_WELL_KNOWN_STATUSES),
+ len(self.config.well_known_statuses))
+ self.assertEqual(tracker_constants.DEFAULT_STATUSES_OFFER_MERGE,
+ self.config.statuses_offer_merge)
+
+ def testProcessSubtabForm_Normal(self):
+ post_data = fake.PostData(
+ predefinedopen=['New = newly reported'],
+ predefinedclosed=['Fixed\nDuplicate'],
+ statuses_offer_merge=['Duplicate'])
+ next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+ self.assertEqual(urls.ADMIN_STATUSES, next_url)
+ self.assertEqual(3, len(self.config.well_known_statuses))
+ self.assertEqual('New', self.config.well_known_statuses[0].status)
+ self.assertTrue(self.config.well_known_statuses[0].means_open)
+ self.assertEqual('Fixed', self.config.well_known_statuses[1].status)
+ self.assertFalse(self.config.well_known_statuses[1].means_open)
+ self.assertEqual('Duplicate', self.config.well_known_statuses[2].status)
+ self.assertFalse(self.config.well_known_statuses[2].means_open)
+ self.assertEqual(['Duplicate'], self.config.statuses_offer_merge)
+
+
+class AdminLabelsTest(TestBase):
+
+ def setUp(self):
+ super(AdminLabelsTest, self).setUpServlet(issueadmin.AdminLabels)
+
+ def testGatherPageData(self):
+ self._mockGetUser()
+ self.mox.ReplayAll()
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.mox.VerifyAll()
+
+ self.assertItemsEqual(
+ ['admin_tab_mode', 'config', 'field_defs',
+ 'open_text', 'closed_text', 'labels_text'],
+ list(page_data.keys()))
+ config_view = page_data['config']
+ self.assertEqual(789, config_view.project_id)
+ self.assertEqual([], page_data['field_defs'])
+
+ @patch('framework.servlet.Servlet.PleaseCorrect')
+ def testProcessSubtabForm_MissingInput(self, mock_pc):
+ post_data = fake.PostData()
+ next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+ self.assertIsNone(next_url)
+ mock_pc.assert_called_once()
+ self.assertEqual(len(tracker_constants.DEFAULT_WELL_KNOWN_LABELS),
+ len(self.config.well_known_labels))
+ self.assertEqual(tracker_constants.DEFAULT_EXCL_LABEL_PREFIXES,
+ self.config.exclusive_label_prefixes)
+
+ @patch('framework.servlet.Servlet.PleaseCorrect')
+ def testProcessSubtabForm_EmptyInput(self, mock_pc):
+ post_data = fake.PostData(
+ predefinedlabels=[''], excl_prefixes=[''])
+ next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+ self.assertIsNone(next_url) # Because PleaseCorrect() was called.
+ mock_pc.assert_called_once()
+ self.assertEqual(len(tracker_constants.DEFAULT_WELL_KNOWN_LABELS),
+ len(self.config.well_known_labels))
+ self.assertEqual(tracker_constants.DEFAULT_EXCL_LABEL_PREFIXES,
+ self.config.exclusive_label_prefixes)
+
+ def testProcessSubtabForm_Normal(self):
+ post_data = fake.PostData(
+ predefinedlabels=['Pri-0 = Burning issue\nPri-4 = It can wait'],
+ excl_prefixes=['pri'])
+ next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+ self.assertEqual(urls.ADMIN_LABELS, next_url)
+ self.assertEqual(2, len(self.config.well_known_labels))
+ self.assertEqual('Pri-0', self.config.well_known_labels[0].label)
+ self.assertEqual('Pri-4', self.config.well_known_labels[1].label)
+ self.assertEqual(['pri'], self.config.exclusive_label_prefixes)
+
+ @patch('framework.servlet.Servlet.PleaseCorrect')
+ def testProcessSubtabForm_Duplicates(self, mock_pc):
+ post_data = fake.PostData(
+ predefinedlabels=['Pri-0\nPri-4\npri-0'],
+ excl_prefixes=['pri'])
+ next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+ self.assertIsNone(next_url)
+ mock_pc.assert_called_once()
+ self.assertEqual(
+ 'Duplicate label: pri-0',
+ self.mr.errors.label_defs)
+
+ @patch('framework.servlet.Servlet.PleaseCorrect')
+ def testProcessSubtabForm_Conflict(self, mock_pc):
+ post_data = fake.PostData(
+ predefinedlabels=['Multi-Part-One\nPri-4\npri-0'],
+ excl_prefixes=['pri'])
+ self.config.field_defs = [
+ tracker_pb2.FieldDef(
+ field_name='Multi-Part',
+ field_type=tracker_pb2.FieldTypes.ENUM_TYPE)]
+ next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+ self.assertIsNone(next_url)
+ mock_pc.assert_called_once()
+ self.assertEqual(
+ 'Label "Multi-Part-One" should be defined in enum "multi-part"',
+ self.mr.errors.label_defs)
+
+
+class AdminTemplatesTest(TestBase):
+
+ def setUp(self):
+ super(AdminTemplatesTest, self).setUpServlet(issueadmin.AdminTemplates)
+ self.mr.auth.user_id = 333
+ self.mr.auth.effective_ids = {333}
+
+ def testGatherPageData(self):
+ self._mockGetUser()
+ self.mox.ReplayAll()
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.mox.VerifyAll()
+
+ config_view = page_data['config']
+ self.assertEqual(789, config_view.project_id)
+
+ def testProcessSubtabForm_NoEditProjectPerm(self):
+ """If user lacks perms, raise an exception."""
+ post_data = fake.PostData(
+ default_template_for_developers=['Test Template'],
+ default_template_for_users=['Test Template'])
+ self.mr.perms = permissions.EMPTY_PERMISSIONSET
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.ProcessSubtabForm, post_data, self.mr)
+ self.assertEqual(0, self.config.default_template_for_developers)
+ self.assertEqual(0, self.config.default_template_for_users)
+
+ def testProcessSubtabForm_Normal(self):
+ """If user has perms, set default templates."""
+ post_data = fake.PostData(
+ default_template_for_developers=['Test Template'],
+ default_template_for_users=['Test Template'])
+ next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+ self.assertEqual(urls.ADMIN_TEMPLATES, next_url)
+ self.assertEqual(12345, self.config.default_template_for_developers)
+ self.assertEqual(12345, self.config.default_template_for_users)
+
+ def testParseDefaultTemplateSelections_NotSpecified(self):
+ post_data = fake.PostData()
+ for_devs, for_users = self.servlet._ParseDefaultTemplateSelections(
+ post_data, self.test_templates)
+ self.assertEqual(None, for_devs)
+ self.assertEqual(None, for_users)
+
+ def testParseDefaultTemplateSelections_TemplateNotFoundIsIgnored(self):
+ post_data = fake.PostData(
+ default_template_for_developers=['Bad value'],
+ default_template_for_users=['Bad value'])
+ for_devs, for_users = self.servlet._ParseDefaultTemplateSelections(
+ post_data, self.test_templates)
+ self.assertEqual(None, for_devs)
+ self.assertEqual(None, for_users)
+
+ def testParseDefaultTemplateSelections_Normal(self):
+ post_data = fake.PostData(
+ default_template_for_developers=['Test Template'],
+ default_template_for_users=['Test Template'])
+ for_devs, for_users = self.servlet._ParseDefaultTemplateSelections(
+ post_data, self.test_templates)
+ self.assertEqual(12345, for_devs)
+ self.assertEqual(12345, for_users)
+
+
+class AdminComponentsTest(TestBase):
+
+ def setUp(self):
+ super(AdminComponentsTest, self).setUpServlet(issueadmin.AdminComponents)
+ self.cd_clean = tracker_bizobj.MakeComponentDef(
+ 1, self.project.project_id, 'BackEnd', 'doc', False, [], [111], 100000,
+ 122, 10000000, 133)
+ self.cd_with_subcomp = tracker_bizobj.MakeComponentDef(
+ 2, self.project.project_id, 'FrontEnd', 'doc', False, [], [111],
+ 100000, 122, 10000000, 133)
+ self.subcd = tracker_bizobj.MakeComponentDef(
+ 3, self.project.project_id, 'FrontEnd>Worker', 'doc', False, [], [111],
+ 100000, 122, 10000000, 133)
+ self.cd_with_template = tracker_bizobj.MakeComponentDef(
+ 4, self.project.project_id, 'Middle', 'doc', False, [], [111],
+ 100000, 122, 10000000, 133)
+
+ def testGatherPageData(self):
+ self._mockGetUser()
+ self.mox.ReplayAll()
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.mox.VerifyAll()
+ self.assertItemsEqual(
+ ['admin_tab_mode', 'failed_templ', 'component_defs', 'failed_perm',
+ 'config', 'failed_subcomp',
+ 'open_text', 'closed_text', 'labels_text'],
+ list(page_data.keys()))
+ config_view = page_data['config']
+ self.assertEqual(789, config_view.project_id)
+ self.assertEqual([], page_data['component_defs'])
+
+ def testProcessFormData_NoErrors(self):
+ self.config.component_defs = [
+ self.cd_clean, self.cd_with_subcomp, self.subcd, self.cd_with_template]
+ self.services.template.TemplatesWithComponent.return_value = []
+ post_data = {
+ 'delete_components' : '%s,%s,%s' % (
+ self.cd_clean.path, self.cd_with_subcomp.path, self.subcd.path)}
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.assertTrue(
+ url.startswith('http://127.0.0.1/p/proj/adminComponents?deleted='
+ 'FrontEnd%3EWorker%2CFrontEnd%2CBackEnd&failed_perm=&'
+ 'failed_subcomp=&failed_templ=&ts='))
+
+ def testProcessFormData_SubCompError(self):
+ self.config.component_defs = [
+ self.cd_clean, self.cd_with_subcomp, self.subcd, self.cd_with_template]
+ self.services.template.TemplatesWithComponent.return_value = []
+ post_data = {
+ 'delete_components' : '%s,%s' % (
+ self.cd_clean.path, self.cd_with_subcomp.path)}
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.assertTrue(
+ url.startswith('http://127.0.0.1/p/proj/adminComponents?deleted='
+ 'BackEnd&failed_perm=&failed_subcomp=FrontEnd&'
+ 'failed_templ=&ts='))
+
+ def testProcessFormData_TemplateError(self):
+ self.config.component_defs = [
+ self.cd_clean, self.cd_with_subcomp, self.subcd, self.cd_with_template]
+
+ def mockTemplatesWithComponent(_cnxn, component_id):
+ if component_id == 4:
+ return 'template'
+ self.services.template.TemplatesWithComponent\
+ .side_effect = mockTemplatesWithComponent
+
+ post_data = {
+ 'delete_components' : '%s,%s,%s,%s' % (
+ self.cd_clean.path, self.cd_with_subcomp.path, self.subcd.path,
+ self.cd_with_template.path)}
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.assertTrue(
+ url.startswith('http://127.0.0.1/p/proj/adminComponents?deleted='
+ 'FrontEnd%3EWorker%2CFrontEnd%2CBackEnd&failed_perm=&'
+ 'failed_subcomp=&failed_templ=Middle&ts='))
+
+
+class AdminViewsTest(TestBase):
+
+ def setUp(self):
+ super(AdminViewsTest, self).setUpServlet(issueadmin.AdminViews)
+
+ def testGatherPageData(self):
+ self._mockGetUser()
+ self.mox.ReplayAll()
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.mox.VerifyAll()
+
+ self.assertItemsEqual(
+ ['canned_queries', 'admin_tab_mode', 'config', 'issue_notify',
+ 'new_query_indexes', 'max_queries',
+ 'open_text', 'closed_text', 'labels_text'],
+ list(page_data.keys()))
+ config_view = page_data['config']
+ self.assertEqual(789, config_view.project_id)
+
+ def testProcessSubtabForm(self):
+ post_data = fake.PostData(
+ default_col_spec=['id pri mstone owner status summary'],
+ default_sort_spec=['mstone pri'],
+ default_x_attr=['owner'], default_y_attr=['mstone'])
+ next_url = self.servlet.ProcessSubtabForm(post_data, self.mr)
+ self.assertEqual(urls.ADMIN_VIEWS, next_url)
+ self.assertEqual(
+ 'id pri mstone owner status summary', self.config.default_col_spec)
+ self.assertEqual('mstone pri', self.config.default_sort_spec)
+ self.assertEqual('owner', self.config.default_x_attr)
+ self.assertEqual('mstone', self.config.default_y_attr)
+
+
+class AdminViewsFunctionsTest(unittest.TestCase):
+
+ def testParseListPreferences(self):
+ # If no input, col_spec will be default column spec.
+ # For other fiels empty strings should be returned.
+ (col_spec, sort_spec, x_attr, y_attr, member_default_query,
+ ) = issueadmin._ParseListPreferences({})
+ self.assertEqual(tracker_constants.DEFAULT_COL_SPEC, col_spec)
+ self.assertEqual('', sort_spec)
+ self.assertEqual('', x_attr)
+ self.assertEqual('', y_attr)
+ self.assertEqual('', member_default_query)
+
+ # Test how hyphens in input are treated.
+ spec = 'label1-sub1 label2 label3-sub3'
+ (col_spec, sort_spec, x_attr, y_attr, member_default_query,
+ ) = issueadmin._ParseListPreferences(
+ fake.PostData(default_col_spec=[spec],
+ default_sort_spec=[spec],
+ default_x_attr=[spec],
+ default_y_attr=[spec]),
+ )
+
+ # Hyphens (and anything following) should be stripped from each term.
+ self.assertEqual('label1-sub1 label2 label3-sub3', col_spec)
+
+ # The sort spec should be as given (except with whitespace condensed).
+ self.assertEqual(' '.join(spec.split()), sort_spec)
+
+ # Only the first term (up to the first hyphen) should be used for x- or
+ # y-attr.
+ self.assertEqual('label1-sub1', x_attr)
+ self.assertEqual('label1-sub1', y_attr)
+
+ # Test that multibyte strings are not mangled.
+ spec = ('\xe7\xaa\xbf\xe8\x8b\xa5-\xe7\xb9\xb9 '
+ '\xe5\x9c\xb0\xe3\x81\xa6-\xe5\xbd\x93-\xe3\x81\xbe\xe3\x81\x99')
+ spec = spec.decode('utf-8')
+ (col_spec, sort_spec, x_attr, y_attr, member_default_query,
+ ) = issueadmin._ParseListPreferences(
+ fake.PostData(default_col_spec=[spec],
+ default_sort_spec=[spec],
+ default_x_attr=[spec],
+ default_y_attr=[spec],
+ member_default_query=[spec]),
+ )
+ self.assertEqual(spec, col_spec)
+ self.assertEqual(' '.join(spec.split()), sort_spec)
+ self.assertEqual('\xe7\xaa\xbf\xe8\x8b\xa5-\xe7\xb9\xb9'.decode('utf-8'),
+ x_attr)
+ self.assertEqual('\xe7\xaa\xbf\xe8\x8b\xa5-\xe7\xb9\xb9'.decode('utf-8'),
+ y_attr)
+ self.assertEqual(spec, member_default_query)
+
+
+class AdminRulesTest(TestBase):
+
+ def setUp(self):
+ super(AdminRulesTest, self).setUpServlet(issueadmin.AdminRules)
+
+ def testGatherPageData(self):
+ self._mockGetUser()
+ self.mox.ReplayAll()
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.mox.VerifyAll()
+
+ self.assertItemsEqual(
+ ['admin_tab_mode', 'config', 'rules', 'new_rule_indexes',
+ 'max_rules', 'open_text', 'closed_text', 'labels_text'],
+ list(page_data.keys()))
+ config_view = page_data['config']
+ self.assertEqual(789, config_view.project_id)
+ self.assertEqual([], page_data['rules'])
+
+ def testProcessSubtabForm(self):
+ pass # TODO(jrobbins): write this test
diff --git a/tracker/test/issueadvsearch_test.py b/tracker/test/issueadvsearch_test.py
new file mode 100644
index 0000000..fd1ee2e
--- /dev/null
+++ b/tracker/test/issueadvsearch_test.py
@@ -0,0 +1,78 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for monorail.tracker.issueadvsearch."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import issueadvsearch
+
+class IssueAdvSearchTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ config=fake.ConfigService(),
+ features=fake.FeaturesService(),
+ issue=fake.IssueService(),
+ user=fake.UserService(),
+ project=fake.ProjectService())
+ self.project = self.services.project.TestAddProject('proj', project_id=987)
+ self.servlet = issueadvsearch.IssueAdvancedSearch(
+ 'req', 'res', services=self.services)
+
+ def testGatherData(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/advsearch')
+ page_data = self.servlet.GatherPageData(mr)
+
+ self.assertTrue('issue_tab_mode' in page_data)
+ self.assertTrue('page_perms' in page_data)
+
+ def testProcessFormData(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/advsearch')
+ post_data = {}
+ url = self.servlet.ProcessFormData(mr, post_data)
+ self.assertTrue('can=2' in url)
+
+ post_data['can'] = 42
+ url = self.servlet.ProcessFormData(mr, post_data)
+ self.assertTrue('can=42' in url)
+
+ post_data['starcount'] = 42
+ url = self.servlet.ProcessFormData(mr, post_data)
+ self.assertTrue('starcount%3A42' in url)
+
+ post_data['starcount'] = -1
+ url = self.servlet.ProcessFormData(mr, post_data)
+ self.assertTrue('starcount' not in url)
+
+ def _testAND(self, operator, field, post_data, query):
+ self.servlet._AccumulateANDTerm(operator, field, post_data, query)
+ return query
+
+ def test_AccumulateANDTerm(self):
+ query = self._testAND('', 'foo', {'foo': 'bar'}, [])
+ self.assertEqual(['bar'], query)
+
+ query = self._testAND('', 'bar', {'bar': 'baz=zippy'}, query)
+ self.assertEqual(['bar', 'baz', 'zippy'], query)
+
+ def _testOR(self, operator, field, post_data, query):
+ self.servlet._AccumulateORTerm(operator, field, post_data, query)
+ return query
+
+ def test_AccumulateORTerm(self):
+ query = self._testOR('', 'foo', {'foo': 'bar'}, [])
+ self.assertEqual(['bar'], query)
+
+ query = self._testOR('', 'bar', {'bar': 'baz=zippy'}, query)
+ self.assertEqual(['bar', 'baz,zippy'], query)
diff --git a/tracker/test/issueattachment_test.py b/tracker/test/issueattachment_test.py
new file mode 100644
index 0000000..8c65014
--- /dev/null
+++ b/tracker/test/issueattachment_test.py
@@ -0,0 +1,198 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for monorail.tracker.issueattachment."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from google.appengine.api import images
+from google.appengine.ext import testbed
+
+import mox
+import webapp2
+
+from framework import gcs_helpers
+from framework import permissions
+from framework import servlet
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import attachment_helpers
+from tracker import issueattachment
+from tracker import tracker_helpers
+
+from third_party import cloudstorage
+
+
+class IssueattachmentTest(unittest.TestCase):
+
+ def setUp(self):
+ self.mox = mox.Mox()
+ self.testbed = testbed.Testbed()
+ self.testbed.activate()
+ self.testbed.init_memcache_stub()
+ self.testbed.init_app_identity_stub()
+ self.testbed.init_urlfetch_stub()
+ self.attachment_data = ""
+
+ self._old_gcs_open = cloudstorage.open
+ cloudstorage.open = fake.gcs_open
+
+ services = service_manager.Services(
+ project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ user=fake.UserService())
+ self.project = services.project.TestAddProject('proj')
+ self.servlet = issueattachment.AttachmentPage(
+ 'req', webapp2.Response(), services=services)
+ services.user.TestAddUser('commenter@example.com', 111)
+ self.issue = fake.MakeTestIssue(
+ self.project.project_id, 1, 'summary', 'New', 111)
+ services.issue.TestAddIssue(self.issue)
+ self.comment = tracker_pb2.IssueComment(
+ id=123, issue_id=self.issue.issue_id,
+ project_id=self.project.project_id, user_id=111,
+ content='this is a comment')
+ services.issue.TestAddComment(self.comment, self.issue.local_id)
+ self.attachment = tracker_pb2.Attachment(
+ attachment_id=54321, filename='hello.txt', filesize=23432,
+ mimetype='text/plain', gcs_object_id='/pid/attachments/object_id')
+ services.issue.TestAddAttachment(
+ self.attachment, self.comment.id, self.issue.issue_id)
+ self.orig_sign_attachment_id = attachment_helpers.SignAttachmentID
+ attachment_helpers.SignAttachmentID = (
+ lambda aid: 'signed_%d' % aid)
+
+ def tearDown(self):
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+ self.testbed.deactivate()
+ cloudstorage.open = self._old_gcs_open
+ attachment_helpers.SignAttachmentID = self.orig_sign_attachment_id
+
+ def testGatherPageData_NotFound(self):
+ aid = 12345
+ path = '/p/proj/issues/attachment?aid=%s&signed_aid=signed_%d' % (
+ aid, aid)
+ # But, no such attachment is in the database.
+ _request, mr = testing_helpers.GetRequestObjects(
+ project=self.project, path=path,
+ perms=permissions.EMPTY_PERMISSIONSET)
+ with self.assertRaises(webapp2.HTTPException) as cm:
+ self.servlet.GatherPageData(mr)
+ self.assertEqual(404, cm.exception.code)
+
+ # TODO(jrobbins): test cases for missing comment and missing issue.
+
+ def testGatherPageData_PermissionDenied(self):
+ aid = self.attachment.attachment_id
+ path = '/p/proj/issues/attachment?aid=%s&signed_aid=signed_%d' % (
+ aid, aid)
+ _request, mr = testing_helpers.GetRequestObjects(
+ project=self.project, path=path,
+ perms=permissions.EMPTY_PERMISSIONSET) # not even VIEW
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.GatherPageData, mr)
+
+ _request, mr = testing_helpers.GetRequestObjects(
+ project=self.project, path=path,
+ perms=permissions.READ_ONLY_PERMISSIONSET) # includes VIEW
+
+ # issue is now deleted
+ self.issue.deleted = True
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.GatherPageData, mr)
+ self.issue.deleted = False
+
+ # issue is now restricted
+ self.issue.labels.extend(['Restrict-View-PermYouLack'])
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.GatherPageData, mr)
+
+ def testGatherPageData_Download_WithDisposition(self):
+ aid = self.attachment.attachment_id
+ self.mox.StubOutWithMock(gcs_helpers, 'MaybeCreateDownload')
+ gcs_helpers.MaybeCreateDownload(
+ 'app_default_bucket',
+ '/pid/attachments/object_id',
+ self.attachment.filename).AndReturn(True)
+ self.mox.StubOutWithMock(gcs_helpers, 'SignUrl')
+ gcs_helpers.SignUrl(
+ 'app_default_bucket',
+ '/pid/attachments/object_id-download'
+ ).AndReturn('googleusercontent.com/...-download...')
+ self.mox.StubOutWithMock(self.servlet, 'redirect')
+ path = '/p/proj/issues/attachment?aid=%s&signed_aid=signed_%d' % (
+ aid, aid)
+ _request, mr = testing_helpers.GetRequestObjects(
+ project=self.project, path=path,
+ perms=permissions.READ_ONLY_PERMISSIONSET) # includes VIEW
+ self.servlet.redirect(
+ mox.And(mox.StrContains('googleusercontent.com'),
+ mox.StrContains('-download')), abort=True)
+ self.mox.ReplayAll()
+ self.servlet.GatherPageData(mr)
+ self.mox.VerifyAll()
+
+ def testGatherPageData_Download_WithoutDisposition(self):
+ aid = self.attachment.attachment_id
+ path = '/p/proj/issues/attachment?aid=%s&signed_aid=signed_%d' % (
+ aid, aid)
+ self.mox.StubOutWithMock(gcs_helpers, 'MaybeCreateDownload')
+ gcs_helpers.MaybeCreateDownload(
+ 'app_default_bucket',
+ '/pid/attachments/object_id',
+ self.attachment.filename).AndReturn(False)
+ self.mox.StubOutWithMock(gcs_helpers, 'SignUrl')
+ gcs_helpers.SignUrl(
+ 'app_default_bucket',
+ '/pid/attachments/object_id'
+ ).AndReturn('googleusercontent.com/...')
+ self.mox.StubOutWithMock(self.servlet, 'redirect')
+ _request, mr = testing_helpers.GetRequestObjects(
+ project=self.project, path=path,
+ perms=permissions.READ_ONLY_PERMISSIONSET) # includes VIEW
+ self.servlet.redirect(
+ mox.And(mox.StrContains('googleusercontent.com'),
+ mox.Not(mox.StrContains('-download'))), abort=True)
+ self.mox.ReplayAll()
+ self.servlet.GatherPageData(mr)
+ self.mox.VerifyAll()
+
+ def testGatherPageData_DownloadBadFilename(self):
+ aid = self.attachment.attachment_id
+ path = '/p/proj/issues/attachment?aid=%s&signed_aid=signed_%d' % (
+ aid, aid)
+ self.attachment.filename = '<script>alert("xsrf")</script>.txt';
+ safe_filename = 'attachment-%d.dat' % aid
+ self.mox.StubOutWithMock(gcs_helpers, 'MaybeCreateDownload')
+ gcs_helpers.MaybeCreateDownload(
+ 'app_default_bucket',
+ '/pid/attachments/object_id',
+ safe_filename).AndReturn(True)
+ self.mox.StubOutWithMock(gcs_helpers, 'SignUrl')
+ gcs_helpers.SignUrl(
+ 'app_default_bucket',
+ '/pid/attachments/object_id-download'
+ ).AndReturn('googleusercontent.com/...-download...')
+ self.mox.StubOutWithMock(self.servlet, 'redirect')
+ _request, mr = testing_helpers.GetRequestObjects(
+ project=self.project,
+ path=path,
+ perms=permissions.READ_ONLY_PERMISSIONSET) # includes VIEW
+ self.servlet.redirect(mox.And(
+ mox.Not(mox.StrContains(self.attachment.filename)),
+ mox.StrContains('googleusercontent.com')), abort=True)
+ self.mox.ReplayAll()
+ self.servlet.GatherPageData(mr)
+ self.mox.VerifyAll()
diff --git a/tracker/test/issueattachmenttext_test.py b/tracker/test/issueattachmenttext_test.py
new file mode 100644
index 0000000..187aa42
--- /dev/null
+++ b/tracker/test/issueattachmenttext_test.py
@@ -0,0 +1,191 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for issueattachmenttext."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import unittest
+from mock import patch
+
+from google.appengine.ext import testbed
+
+from third_party import cloudstorage
+import ezt
+
+import webapp2
+
+from framework import filecontent
+from framework import permissions
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import issueattachmenttext
+
+
+class IssueAttachmentTextTest(unittest.TestCase):
+
+ def setUp(self):
+ self.testbed = testbed.Testbed()
+ self.testbed.activate()
+ self.testbed.init_app_identity_stub()
+
+ services = service_manager.Services(
+ project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ user=fake.UserService())
+ self.project = services.project.TestAddProject('proj')
+ self.servlet = issueattachmenttext.AttachmentText(
+ 'req', 'res', services=services)
+
+ services.user.TestAddUser('commenter@example.com', 111)
+
+ self.issue = tracker_pb2.Issue()
+ self.issue.local_id = 1
+ self.issue.issue_id = 1
+ self.issue.summary = 'sum'
+ self.issue.project_name = 'proj'
+ self.issue.project_id = self.project.project_id
+ services.issue.TestAddIssue(self.issue)
+
+ self.comment0 = tracker_pb2.IssueComment()
+ self.comment0.content = 'this is the description'
+ self.comment0.user_id = 111
+ self.comment1 = tracker_pb2.IssueComment()
+ self.comment1.content = 'this is a comment'
+ self.comment1.user_id = 111
+
+ self.attach0 = tracker_pb2.Attachment(
+ attachment_id=4567, filename='b.txt', mimetype='text/plain',
+ gcs_object_id='/pid/attachments/abcd')
+ self.comment0.attachments.append(self.attach0)
+
+ self.attach1 = tracker_pb2.Attachment(
+ attachment_id=1234, filename='a.txt', mimetype='text/plain',
+ gcs_object_id='/pid/attachments/abcdefg')
+ self.comment0.attachments.append(self.attach1)
+
+ self.bin_attach = tracker_pb2.Attachment(
+ attachment_id=2468, mimetype='application/octets',
+ gcs_object_id='/pid/attachments/\0\0\0\0\0\1\2\3')
+ self.comment1.attachments.append(self.bin_attach)
+
+ self.comment0.project_id = self.project.project_id
+ services.issue.TestAddComment(self.comment0, self.issue.local_id)
+ self.comment1.project_id = self.project.project_id
+ services.issue.TestAddComment(self.comment1, self.issue.local_id)
+ services.issue.TestAddAttachment(
+ self.attach0, self.comment0.id, self.issue.issue_id)
+ services.issue.TestAddAttachment(
+ self.attach1, self.comment1.id, self.issue.issue_id)
+ # TODO(jrobbins): add tests for binary content
+ self._old_gcs_open = cloudstorage.open
+ cloudstorage.open = fake.gcs_open
+
+ def tearDown(self):
+ self.testbed.deactivate()
+ cloudstorage.open = self._old_gcs_open
+
+ def testGatherPageData_CommentDeleted(self):
+ """If the attachment's comment was deleted, give a 403."""
+ _request, mr = testing_helpers.GetRequestObjects(
+ project=self.project,
+ path='/a/d.com/p/proj/issues/attachmentText?aid=1234',
+ perms=permissions.READ_ONLY_PERMISSIONSET)
+ self.servlet.GatherPageData(mr) # OK
+ self.comment1.deleted_by = 111
+ self.assertRaises( # 403
+ permissions.PermissionException,
+ self.servlet.GatherPageData, mr)
+
+ def testGatherPageData_IssueNotViewable(self):
+ """If the attachment's issue is not viewable, give a 403."""
+ _request, mr = testing_helpers.GetRequestObjects(
+ project=self.project,
+ path='/p/proj/issues/attachment?aid=1234',
+ perms=permissions.EMPTY_PERMISSIONSET) # No VIEW
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.GatherPageData, mr)
+
+ def testGatherPageData_IssueDeleted(self):
+ _request, mr = testing_helpers.GetRequestObjects(
+ project=self.project,
+ path='/p/proj/issues/attachment?aid=1234',
+ perms=permissions.READ_ONLY_PERMISSIONSET)
+ self.issue.deleted = True
+ self.assertRaises( # Issue was deleted
+ permissions.PermissionException,
+ self.servlet.GatherPageData, mr)
+
+ def testGatherPageData_IssueRestricted(self):
+ _request, mr = testing_helpers.GetRequestObjects(
+ project=self.project,
+ path='/p/proj/issues/attachment?aid=1234',
+ perms=permissions.READ_ONLY_PERMISSIONSET)
+ self.issue.labels.append('Restrict-View-Nobody')
+ self.assertRaises( # Issue is restricted
+ permissions.PermissionException,
+ self.servlet.GatherPageData, mr)
+
+ def testGatherPageData_NoSuchAttachment(self):
+ _request, mr = testing_helpers.GetRequestObjects(
+ project=self.project,
+ path='/p/proj/issues/attachmentText?aid=9999',
+ perms=permissions.READ_ONLY_PERMISSIONSET)
+ with self.assertRaises(webapp2.HTTPException) as cm:
+ self.servlet.GatherPageData(mr)
+ self.assertEqual(404, cm.exception.code)
+
+ def testGatherPageData_AttachmentDeleted(self):
+ """If the attachment was deleted, give a 404."""
+ _request, mr = testing_helpers.GetRequestObjects(
+ project=self.project,
+ path='/p/proj/issues/attachmentText?aid=1234',
+ perms=permissions.READ_ONLY_PERMISSIONSET)
+ self.attach1.deleted = True
+ with self.assertRaises(webapp2.HTTPException) as cm:
+ self.servlet.GatherPageData(mr)
+ self.assertEqual(404, cm.exception.code)
+
+ def testGatherPageData_Normal(self):
+ _request, mr = testing_helpers.GetRequestObjects(
+ project=self.project,
+ path='/p/proj/issues/attachmentText?id=1&aid=1234',
+ perms=permissions.READ_ONLY_PERMISSIONSET)
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertEqual(1, page_data['local_id'])
+ self.assertEqual('a.txt', page_data['filename'])
+ self.assertEqual('43 bytes', page_data['filesize'])
+ self.assertEqual(ezt.boolean(False), page_data['should_prettify'])
+ self.assertEqual(ezt.boolean(False), page_data['is_binary'])
+ self.assertEqual(ezt.boolean(False), page_data['too_large'])
+
+ file_lines = page_data['file_lines']
+ self.assertEqual(1, len(file_lines))
+ self.assertEqual(1, file_lines[0].num)
+ self.assertEqual('/app_default_bucket/pid/attachments/abcdefg',
+ file_lines[0].line)
+
+ self.assertEqual(None, page_data['code_reviews'])
+
+ @patch('framework.filecontent.DecodeFileContents')
+ def testGatherPageData_HugeFile(self, mock_DecodeFileContents):
+ _request, mr = testing_helpers.GetRequestObjects(
+ project=self.project,
+ path='/p/proj/issues/attachmentText?id=1&aid=1234',
+ perms=permissions.READ_ONLY_PERMISSIONSET)
+ mock_DecodeFileContents.return_value = (
+ 'too large text', False, True)
+
+ page_data = self.servlet.GatherPageData(mr)
+
+ self.assertEqual(ezt.boolean(False), page_data['should_prettify'])
+ self.assertEqual(ezt.boolean(False), page_data['is_binary'])
+ self.assertEqual(ezt.boolean(True), page_data['too_large'])
diff --git a/tracker/test/issuebulkedit_test.py b/tracker/test/issuebulkedit_test.py
new file mode 100644
index 0000000..89d9bc3
--- /dev/null
+++ b/tracker/test/issuebulkedit_test.py
@@ -0,0 +1,892 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.tracker.issuebulkedit."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import os
+import unittest
+import webapp2
+
+from google.appengine.api import memcache
+from google.appengine.ext import testbed
+
+from framework import exceptions
+from framework import permissions
+from proto import tracker_pb2
+from services import service_manager
+from services import tracker_fulltext
+from testing import fake
+from testing import testing_helpers
+from tracker import issuebulkedit
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+class Response(object):
+
+ def __init__(self):
+ self.status = None
+
+
+class IssueBulkEditTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ features=fake.FeaturesService(),
+ project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ issue_star=fake.IssueStarService(),
+ user=fake.UserService(),
+ usergroup=fake.UserGroupService())
+ self.servlet = issuebulkedit.IssueBulkEdit(
+ 'req', 'res', services=self.services)
+ self.mr = testing_helpers.MakeMonorailRequest(
+ perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+ self.project = self.services.project.TestAddProject(
+ name='proj', project_id=789, owner_ids=[111])
+ self.cnxn = 'fake connection'
+ self.config = self.services.config.GetProjectConfig(
+ self.cnxn, self.project.project_id)
+ self.services.config.StoreConfig(self.cnxn, self.config)
+ self.owner = self.services.user.TestAddUser('owner@example.com', 111)
+
+ self.testbed = testbed.Testbed()
+ self.testbed.activate()
+ self.testbed.init_memcache_stub()
+ self.testbed.init_datastore_v3_stub()
+
+ self.mocked_methods = {}
+
+ def tearDown(self):
+ """Restore mocked objects of other modules."""
+ self.testbed.deactivate()
+ for obj, items in self.mocked_methods.items():
+ for member, previous_value in items.items():
+ setattr(obj, member, previous_value)
+
+ def testAssertBasePermission(self):
+ """Permit users with EDIT_ISSUE and ADD_ISSUE_COMMENT permissions."""
+ mr = testing_helpers.MakeMonorailRequest(
+ perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+ self.assertRaises(permissions.PermissionException,
+ self.servlet.AssertBasePermission, mr)
+
+ self.servlet.AssertBasePermission(self.mr)
+
+ def testGatherPageData(self):
+ """Test GPD works in a normal no-corner-cases case."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 0, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project)
+ mr.local_id_list = [local_id_1]
+
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertEqual(1, page_data['num_issues'])
+
+ def testGatherPageData_CustomFieldEdition(self):
+ """Test GPD works in a normal no-corner-cases case."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 0, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project, perms=permissions.PermissionSet([]))
+ mr.local_id_list = [local_id_1]
+ mr.auth.effective_ids = {222}
+
+ fd_not_restricted = tracker_bizobj.MakeFieldDef(
+ 123,
+ 789,
+ 'CPU',
+ tracker_pb2.FieldTypes.INT_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=False)
+ self.config.field_defs.append(fd_not_restricted)
+
+ fd_restricted = tracker_bizobj.MakeFieldDef(
+ 124,
+ 789,
+ 'CPU',
+ tracker_pb2.FieldTypes.INT_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=True)
+ self.config.field_defs.append(fd_restricted)
+
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertTrue(page_data['fields'][0].is_editable)
+ self.assertFalse(page_data['fields'][1].is_editable)
+
+ def testGatherPageData_NoIssues(self):
+ """Test GPD when no issues are specified in the mr."""
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project)
+ self.assertRaises(exceptions.InputException,
+ self.servlet.GatherPageData, mr)
+
+ def testGatherPageData_FilteredIssues(self):
+ """Test GPD when all specified issues get filtered out."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789,
+ 1,
+ 'issue summary',
+ 'New',
+ 0,
+ reporter_id=111,
+ labels=['restrict-view-Googler'])
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project)
+ mr.local_id_list = [local_id_1]
+
+ self.assertRaises(webapp2.HTTPException,
+ self.servlet.GatherPageData, mr)
+
+ def testGatherPageData_TypeLabels(self):
+ """Test that GPD displays a custom field for appropriate issues."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789,
+ 1,
+ 'issue summary',
+ 'New',
+ 0,
+ reporter_id=111,
+ labels=['type-customlabels'])
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project)
+ mr.local_id_list = [local_id_1]
+
+ fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.config.field_defs.append(fd)
+
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertEqual(1, len(page_data['fields']))
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testProcessFormData(self, _create_task_mock):
+ """Test that PFD works in a normal no-corner-cases case."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+ user_info={'user_id': 111})
+ mr.local_id_list = [local_id_1]
+
+ post_data = fake.PostData(
+ owner=['owner@example.com'], can=[1],
+ q=[''], colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+ self._MockMethods()
+ url = self.servlet.ProcessFormData(mr, post_data)
+ self.assertTrue('list?can=1&q=&saved=1' in url)
+
+ def testProcessFormData_NoIssues(self):
+ """Test PFD when no issues are specified."""
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+ user_info={'user_id': 111})
+ post_data = fake.PostData()
+ self.servlet.response = Response()
+ self.servlet.ProcessFormData(mr, post_data)
+ # 400 == bad request
+ self.assertEqual(400, self.servlet.response.status)
+
+ def testProcessFormData_NoUser(self):
+ """Test PFD when the user is not logged in."""
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project)
+ mr.local_id_list = [99999]
+ post_data = fake.PostData()
+ self.servlet.response = Response()
+ self.servlet.ProcessFormData(mr, post_data)
+ # 400 == bad request
+ self.assertEqual(400, self.servlet.response.status)
+
+ def testProcessFormData_CantComment(self):
+ """Test PFD when the user can't comment on any of the issues."""
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.EMPTY_PERMISSIONSET,
+ user_info={'user_id': 111})
+ mr.local_id_list = [99999]
+ post_data = fake.PostData()
+ self.servlet.response = Response()
+ self.servlet.ProcessFormData(mr, post_data)
+ # 400 == bad request
+ self.assertEqual(400, self.servlet.response.status)
+
+ def testProcessFormData_CantEdit(self):
+ """Test PFD when the user can't edit any issue metadata."""
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET,
+ user_info={'user_id': 111})
+ mr.local_id_list = [99999]
+ post_data = fake.PostData()
+ self.servlet.response = Response()
+ self.servlet.ProcessFormData(mr, post_data)
+ # 400 == bad request
+ self.assertEqual(400, self.servlet.response.status)
+
+ def testProcessFormData_CantMove(self):
+ """Test PFD when the user can't move issues."""
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.COMMITTER_ACTIVE_PERMISSIONSET,
+ user_info={'user_id': 111})
+ mr.local_id_list = [99999]
+ post_data = fake.PostData(move_to=['proj'])
+ self.servlet.response = Response()
+ self.servlet.ProcessFormData(mr, post_data)
+ # 400 == bad request
+ self.assertEqual(400, self.servlet.response.status)
+
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+ mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+ mr.local_id_list = [local_id_1]
+ mr.project_name = 'proj'
+ self._MockMethods()
+ self.servlet.ProcessFormData(mr, post_data)
+ self.assertEqual(
+ 'The issues are already in project proj', mr.errors.move_to)
+
+ post_data = fake.PostData(move_to=['notexist'])
+ self.servlet.ProcessFormData(mr, post_data)
+ self.assertEqual('No such project: notexist', mr.errors.move_to)
+
+ def _MockMethods(self):
+ # Mock methods of other modules to avoid unnecessary testing
+ self.mocked_methods[tracker_fulltext] = {
+ 'IndexIssues': tracker_fulltext.IndexIssues,
+ 'UnindexIssues': tracker_fulltext.UnindexIssues}
+ def DoNothing(*_args, **_kwargs):
+ pass
+ self.servlet.PleaseCorrect = DoNothing
+ tracker_fulltext.IndexIssues = DoNothing
+ tracker_fulltext.UnindexIssues = DoNothing
+
+ def GetFirstAmendment(self, project_id, local_id):
+ issue = self.services.issue.GetIssueByLocalID(
+ self.cnxn, project_id, local_id)
+ issue_id = issue.issue_id
+ comments = self.services.issue.GetCommentsForIssue(self.cnxn, issue_id)
+ last_comment = comments[-1]
+ first_amendment = last_comment.amendments[0]
+ return first_amendment.field, first_amendment.newvalue
+
+ def testProcessFormData_BadUserField(self):
+ """Test PFD when a nonexistent user is added as a field value."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+ user_info={'user_id': 111})
+ mr.local_id_list = [local_id_1]
+
+ fd = tracker_bizobj.MakeFieldDef(
+ 12345, 789, 'PM', tracker_pb2.FieldTypes.USER_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.config.field_defs.append(fd)
+
+ post_data = fake.PostData(
+ custom_12345=['ghost@gmail.com'], owner=['owner@example.com'], can=[1],
+ q=[''], colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+ self._MockMethods()
+ self.servlet.ProcessFormData(mr, post_data)
+ self.assertEqual('User not found.', mr.errors.custom_fields[0].message)
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testProcessFormData_CustomFields(self, _create_task_mock):
+ """Test PFD processes edits to custom fields."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+ user_info={'user_id': 111})
+ mr.local_id_list = [local_id_1]
+
+ fd = tracker_bizobj.MakeFieldDef(
+ 12345, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.config.field_defs.append(fd)
+
+ post_data = fake.PostData(
+ custom_12345=['10'],
+ owner=['owner@example.com'],
+ can=[1],
+ q=[''],
+ colspec=[''],
+ sort=[''],
+ groupby=[''],
+ start=[0],
+ num=[100])
+ self._MockMethods()
+ self.servlet.ProcessFormData(mr, post_data)
+ self.assertEqual(
+ (tracker_pb2.FieldID.CUSTOM, '10'),
+ self.GetFirstAmendment(789, local_id_1))
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testProcessFormData_RestrictedCustomFieldsAccept(self, _create_task_mock):
+ """We accept edits to restricted fields by editors (or admins)."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.PermissionSet(
+ [
+ permissions.EDIT_ISSUE, permissions.ADD_ISSUE_COMMENT,
+ permissions.VIEW
+ ]),
+ user_info={'user_id': 111})
+ mr.local_id_list = [local_id_1]
+
+ fd = tracker_bizobj.MakeFieldDef(
+ 12345,
+ 789,
+ 'CPU',
+ tracker_pb2.FieldTypes.INT_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=True)
+ fd.editor_ids = [111]
+ self.config.field_defs.append(fd)
+
+ post_data = fake.PostData(
+ custom_12345=['10'],
+ owner=['owner@example.com'],
+ can=[1],
+ q=[''],
+ colspec=[''],
+ sort=[''],
+ groupby=[''],
+ start=[0],
+ num=[100])
+ self._MockMethods()
+ self.servlet.ProcessFormData(mr, post_data)
+ self.assertEqual(
+ (tracker_pb2.FieldID.CUSTOM, '10'),
+ self.GetFirstAmendment(789, local_id_1))
+
+ def testProcessFormData_RestrictedCustomFieldsReject(self):
+ """We reject edits to restricted fields by non-editors (and non-admins)."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.PermissionSet(
+ [
+ permissions.EDIT_ISSUE, permissions.ADD_ISSUE_COMMENT,
+ permissions.VIEW
+ ]),
+ user_info={'user_id': 111})
+ mr.local_id_list = [local_id_1]
+
+ fd_int = tracker_bizobj.MakeFieldDef(
+ 11111,
+ 789,
+ 'fd_int',
+ tracker_pb2.FieldTypes.INT_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=True)
+ fd_enum = tracker_bizobj.MakeFieldDef(
+ 44444,
+ 789,
+ 'fdEnum',
+ tracker_pb2.FieldTypes.ENUM_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=True)
+ fd_int.admin_ids = [222]
+ fd_enum.editor_ids = [333]
+ self.config.field_defs = [fd_int, fd_enum]
+
+ post_data_add_fv = fake.PostData(
+ custom_11111=['10'],
+ owner=['owner@example.com'],
+ can=[1],
+ q=[''],
+ colspec=[''],
+ sort=[''],
+ groupby=[''],
+ start=[0],
+ num=[100])
+ post_data_rm_fv = fake.PostData(
+ op_custom_11111=['remove'],
+ custom_11111=['10'],
+ owner=['owner@example.com'],
+ can=[1],
+ q=[''],
+ colspec=[''],
+ sort=[''],
+ groupby=[''],
+ start=[0],
+ num=[100])
+ post_data_clear_fd = fake.PostData(
+ op_custom_11111=['clear'],
+ owner=['owner@example.com'],
+ can=[1],
+ q=[''],
+ colspec=[''],
+ sort=[''],
+ groupby=[''],
+ start=[0],
+ num=[100])
+ post_data_label_edits_enum = fake.PostData(
+ label=['fdEnum-a'],
+ owner=['owner@example.com'],
+ can=[1],
+ q=[''],
+ colspec=[''],
+ sort=[''],
+ groupby=[''],
+ start=[0],
+ num=[100])
+ post_data_label_rm_enum = fake.PostData(
+ label=['-fdEnum-b'],
+ owner=['owner@example.com'],
+ can=[1],
+ q=[''],
+ colspec=[''],
+ sort=[''],
+ groupby=[''],
+ start=[0],
+ num=[100])
+
+ self._MockMethods()
+ self.assertRaises(
+ AssertionError, self.servlet.ProcessFormData, mr, post_data_add_fv)
+ self.assertRaises(
+ AssertionError, self.servlet.ProcessFormData, mr, post_data_rm_fv)
+ self.assertRaises(
+ AssertionError, self.servlet.ProcessFormData, mr, post_data_clear_fd)
+ self.assertRaises(
+ AssertionError, self.servlet.ProcessFormData, mr,
+ post_data_label_edits_enum)
+ self.assertRaises(
+ AssertionError, self.servlet.ProcessFormData, mr,
+ post_data_label_rm_enum)
+
+ def testProcessFormData_DuplicateStatus_MergeSameIssue(self):
+ """Test PFD processes null/cleared status values."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+
+ created_issue_2 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 112, reporter_id=112)
+ self.services.issue.TestAddIssue(created_issue_2)
+ merge_into_local_id_2 = created_issue_2.local_id
+
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+ user_info={'user_id': 111})
+ mr.local_id_list = [local_id_1, merge_into_local_id_2]
+ mr.project_name = 'proj'
+
+ # Add required project_name to merge_into_issue.
+ merge_into_issue = self.services.issue.GetIssueByLocalID(
+ mr.cnxn, self.project.project_id, merge_into_local_id_2)
+ merge_into_issue.project_name = 'proj'
+
+ post_data = fake.PostData(status=['Duplicate'],
+ merge_into=[str(merge_into_local_id_2)], owner=['owner@example.com'],
+ can=[1], q=[''], colspec=[''], sort=[''], groupby=[''], start=[0],
+ num=[100])
+ self._MockMethods()
+ self.servlet.ProcessFormData(mr, post_data)
+ self.assertEqual('Cannot merge issue into itself', mr.errors.merge_into_id)
+
+ def testProcessFormData_DuplicateStatus_MergeMissingIssue(self):
+ """Test PFD processes null/cleared status values."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+ created_issue_2 = fake.MakeTestIssue(
+ 789, 1, 'issue summary2', 'New', 112, reporter_id=112)
+ self.services.issue.TestAddIssue(created_issue_2)
+ local_id_2 = created_issue_2.local_id
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+ user_info={'user_id': 111})
+ mr.local_id_list = [local_id_1, local_id_2]
+ mr.project_name = 'proj'
+
+ post_data = fake.PostData(status=['Duplicate'],
+ merge_into=['non existant id'], owner=['owner@example.com'],
+ can=[1], q=[''], colspec=[''], sort=[''], groupby=[''], start=[0],
+ num=[100])
+ self._MockMethods()
+ self.servlet.ProcessFormData(mr, post_data)
+ self.assertEqual('Please enter an issue ID', mr.errors.merge_into_id)
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testProcessFormData_DuplicateStatus_Success(self, _create_task_mock):
+ """Test PFD processes null/cleared status values."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+ created_issue_2 = fake.MakeTestIssue(
+ 789, 2, 'issue summary2', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_2)
+ local_id_2 = created_issue_2.local_id
+ created_issue_3 = fake.MakeTestIssue(
+ 789, 3, 'issue summary3', 'New', 112, reporter_id=112)
+ self.services.issue.TestAddIssue(created_issue_3)
+ merge_into_local_id_3 = created_issue_3.local_id
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+ user_info={'user_id': 111})
+ mr.local_id_list = [local_id_1, local_id_2]
+ mr.project_name = 'proj'
+
+ post_data = fake.PostData(status=['Duplicate'],
+ merge_into=[str(merge_into_local_id_3)], owner=['owner@example.com'],
+ can=[1], q=[''], colspec=[''], sort=[''], groupby=[''], start=[0],
+ num=[100])
+ self._MockMethods()
+
+ # Add project_name, CCs and starrers to the merge_into_issue.
+ merge_into_issue = self.services.issue.GetIssueByLocalID(
+ mr.cnxn, self.project.project_id, merge_into_local_id_3)
+ merge_into_issue.project_name = 'proj'
+ merge_into_issue.cc_ids = [113, 120]
+ self.services.issue_star.SetStar(
+ mr.cnxn, self.services, None, merge_into_issue.issue_id, 120, True)
+
+ # Add project_name, CCs and starrers to the source issues.
+ # Issue 1
+ issue_1 = self.services.issue.GetIssueByLocalID(
+ mr.cnxn, self.project.project_id, local_id_1)
+ issue_1.project_name = 'proj'
+ issue_1.cc_ids = [113, 114]
+ self.services.issue_star.SetStar(
+ mr.cnxn, self.services, None, issue_1.issue_id, 113, True)
+ # Issue 2
+ issue_2 = self.services.issue.GetIssueByLocalID(
+ mr.cnxn, self.project.project_id, local_id_2)
+ issue_2.project_name = 'proj'
+ issue_2.cc_ids = [113, 115, 118]
+ self.services.issue_star.SetStar(
+ mr.cnxn, self.services, None, issue_2.issue_id, 114, True)
+ self.services.issue_star.SetStar(
+ mr.cnxn, self.services, None, issue_2.issue_id, 115, True)
+
+ self.servlet.ProcessFormData(mr, post_data)
+
+ # Verify both source issues were updated.
+ self.assertEqual(
+ (tracker_pb2.FieldID.STATUS, 'Duplicate'),
+ self.GetFirstAmendment(self.project.project_id, local_id_1))
+ self.assertEqual(
+ (tracker_pb2.FieldID.STATUS, 'Duplicate'),
+ self.GetFirstAmendment(self.project.project_id, local_id_2))
+
+ # Verify that the merge into issue was updated with a comment.
+ comments = self.services.issue.GetCommentsForIssue(
+ self.cnxn, merge_into_issue.issue_id)
+ self.assertEqual(
+ 'Issue 1 has been merged into this issue.\n'
+ 'Issue 2 has been merged into this issue.', comments[-1].content)
+
+ # Verify CC lists and owner were merged to the merge_into issue.
+ self.assertEqual(
+ [113, 120, 114, 115, 118, 111], merge_into_issue.cc_ids)
+ # Verify new starrers were added to the merge_into issue.
+ self.assertEqual(4,
+ self.services.issue_star.CountItemStars(
+ self.cnxn, merge_into_issue.issue_id))
+ self.assertEqual([120, 113, 114, 115],
+ self.services.issue_star.LookupItemStarrers(
+ self.cnxn, merge_into_issue.issue_id))
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testProcessFormData_ClearStatus(self, _create_task_mock):
+ """Test PFD processes null/cleared status values."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+ user_info={'user_id': 111})
+ mr.local_id_list = [local_id_1]
+
+ post_data = fake.PostData(
+ op_statusenter=['clear'], owner=['owner@example.com'], can=[1],
+ q=[''], colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+ self._MockMethods()
+ self.servlet.ProcessFormData(mr, post_data)
+ self.assertEqual(
+ (tracker_pb2.FieldID.STATUS, ''), self.GetFirstAmendment(
+ 789, local_id_1))
+
+ def testProcessFormData_InvalidOwner(self):
+ """Test PFD rejects invalid owner emails."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 0, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+ user_info={'user_id': 111})
+ mr.local_id_list = [local_id_1]
+ post_data = fake.PostData(
+ owner=['invalid'])
+ self.servlet.response = Response()
+ self._MockMethods()
+ self.servlet.ProcessFormData(mr, post_data)
+ self.assertTrue(mr.errors.AnyErrors())
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testProcessFormData_MoveTo(self, _create_task_mock):
+ """Test PFD processes move_to values."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+ move_to_project = self.services.project.TestAddProject(
+ name='proj2', project_id=790, owner_ids=[111])
+
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+ user_info={'user_id': 111})
+ mr.project_name = 'proj'
+ mr.local_id_list = [local_id_1]
+
+ self._MockMethods()
+ post_data = fake.PostData(
+ move_to=['proj2'], can=[1], q=[''],
+ colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+ self.servlet.response = Response()
+ self.servlet.ProcessFormData(mr, post_data)
+
+ issue = self.services.issue.GetIssueByLocalID(
+ self.cnxn, move_to_project.project_id, local_id_1)
+ self.assertIsNotNone(issue)
+
+ def testProcessFormData_InvalidBlockIssues(self):
+ """Test PFD processes invalid blocked_on and blocking values."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+ user_info={'user_id': 111})
+ mr.project_name = 'proj'
+ mr.local_id_list = [local_id_1]
+
+ self._MockMethods()
+ post_data = fake.PostData(
+ op_blockedonenter=['append'], blocked_on=['12345'],
+ op_blockingenter=['append'], blocking=['54321'],
+ can=[1], q=[''],
+ colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+ self.servlet.ProcessFormData(mr, post_data)
+
+ self.assertEqual('Invalid issue ID 12345', mr.errors.blocked_on)
+ self.assertEqual('Invalid issue ID 54321', mr.errors.blocking)
+
+ def testProcessFormData_BlockIssuesOnItself(self):
+ """Test PFD processes invalid blocked_on and blocking values."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+ created_issue_2 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_2)
+ local_id_2 = created_issue_2.local_id
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+ user_info={'user_id': 111})
+ mr.project_name = 'proj'
+ mr.local_id_list = [local_id_1, local_id_2]
+
+ self._MockMethods()
+ post_data = fake.PostData(
+ op_blockedonenter=['append'], blocked_on=[str(local_id_1)],
+ op_blockingenter=['append'], blocking=[str(local_id_2)],
+ can=[1], q=[''],
+ colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+ self.servlet.ProcessFormData(mr, post_data)
+
+ self.assertEqual('Cannot block an issue on itself.', mr.errors.blocked_on)
+ self.assertEqual('Cannot block an issue on itself.', mr.errors.blocking)
+
+ @mock.patch('framework.cloud_tasks_helpers.create_task')
+ def testProcessFormData_NormalBlockIssues(self, _create_task_mock):
+ """Test PFD processes blocked_on and blocking values."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+
+ created_issueid = fake.MakeTestIssue(
+ 789, 2, 'blocking', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issueid)
+ blocking_id = created_issueid.local_id
+
+ created_issueid = fake.MakeTestIssue(
+ 789, 3, 'blocked on', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issueid)
+ blocked_on_id = created_issueid.local_id
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+ user_info={'user_id': 111})
+ mr.project_name = 'proj'
+ mr.local_id_list = [local_id_1]
+
+ self._MockMethods()
+ post_data = fake.PostData(
+ op_blockedonenter=['append'], blocked_on=[str(blocked_on_id)],
+ op_blockingenter=['append'], blocking=[str(blocking_id)],
+ can=[1], q=[''],
+ colspec=[''], sort=[''], groupby=[''], start=[0], num=[100])
+ self.servlet.ProcessFormData(mr, post_data)
+
+ self.assertIsNone(mr.errors.blocked_on)
+ self.assertIsNone(mr.errors.blocking)
+
+ def testProcessFormData_TooLongComment(self):
+ """Test PFD rejects comments that are too long."""
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, 'issue summary', 'New', 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ local_id_1 = created_issue_1.local_id
+
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project,
+ perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+ user_info={'user_id': 111})
+ mr.local_id_list = [local_id_1]
+
+ post_data = fake.PostData(
+ owner=['owner@example.com'],
+ can=[1],
+ q=[''],
+ colspec=[''],
+ sort=[''],
+ groupby=[''],
+ start=[0],
+ num=[100],
+ comment=[' ' + 'c' * tracker_constants.MAX_COMMENT_CHARS + ' '])
+ self._MockMethods()
+ self.servlet.ProcessFormData(mr, post_data)
+ self.assertTrue(mr.errors.AnyErrors())
+ self.assertEqual('Comment is too long.', mr.errors.comment)
diff --git a/tracker/test/issuedetailezt_test.py b/tracker/test/issuedetailezt_test.py
new file mode 100644
index 0000000..d3b8327
--- /dev/null
+++ b/tracker/test/issuedetailezt_test.py
@@ -0,0 +1,306 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.tracker.issuedetailezt."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import mock
+import mox
+import time
+import unittest
+
+import settings
+from businesslogic import work_env
+from proto import features_pb2
+from features import hotlist_views
+from features import send_notifications
+from framework import authdata
+from framework import exceptions
+from framework import framework_views
+from framework import framework_helpers
+from framework import urls
+from framework import permissions
+from framework import profiler
+from framework import sorting
+from framework import template_helpers
+from proto import project_pb2
+from proto import tracker_pb2
+from proto import user_pb2
+from services import service_manager
+from services import issue_svc
+from services import tracker_fulltext
+from testing import fake
+from testing import testing_helpers
+from tracker import issuedetailezt
+from tracker import tracker_constants
+from tracker import tracker_helpers
+
+
+class GetAdjacentIssueTest(unittest.TestCase):
+
+ def setUp(self):
+ self.cnxn = 'fake cnxn'
+ self.services = service_manager.Services(
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ user=fake.UserService(),
+ project=fake.ProjectService(),
+ issue_star=fake.IssueStarService(),
+ spam=fake.SpamService())
+ self.services.project.TestAddProject('proj', project_id=789)
+ self.mr = testing_helpers.MakeMonorailRequest()
+ self.mr.auth.user_id = 111
+ self.mr.auth.effective_ids = {111}
+ self.mr.me_user_id = 111
+ self.work_env = work_env.WorkEnv(
+ self.mr, self.services, 'Testing phase')
+
+ def testGetAdjacentIssue_PrevIssue(self):
+ cur_issue = fake.MakeTestIssue(789, 2, 'sum', 'New', 111, issue_id=78902)
+ next_issue = fake.MakeTestIssue(789, 3, 'sum', 'New', 111, issue_id=78903)
+ prev_issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, issue_id=78901)
+ self.services.issue.TestAddIssue(cur_issue)
+ self.services.issue.TestAddIssue(next_issue)
+ self.services.issue.TestAddIssue(prev_issue)
+
+ with self.work_env as we:
+ we.FindIssuePositionInSearch = mock.Mock(
+ return_value=[78901, 1, 78903, 3])
+
+ actual_issue = issuedetailezt.GetAdjacentIssue(
+ self.mr, we, cur_issue)
+ self.assertEqual(prev_issue, actual_issue)
+ we.FindIssuePositionInSearch.assert_called_once_with(cur_issue)
+
+ def testGetAdjacentIssue_NextIssue(self):
+ cur_issue = fake.MakeTestIssue(789, 2, 'sum', 'New', 111, issue_id=78902)
+ next_issue = fake.MakeTestIssue(789, 3, 'sum', 'New', 111, issue_id=78903)
+ prev_issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, issue_id=78901)
+ self.services.issue.TestAddIssue(cur_issue)
+ self.services.issue.TestAddIssue(next_issue)
+ self.services.issue.TestAddIssue(prev_issue)
+
+ with self.work_env as we:
+ we.FindIssuePositionInSearch = mock.Mock(
+ return_value=[78901, 1, 78903, 3])
+
+ actual_issue = issuedetailezt.GetAdjacentIssue(
+ self.mr, we, cur_issue, next_issue=True)
+ self.assertEqual(next_issue, actual_issue)
+ we.FindIssuePositionInSearch.assert_called_once_with(cur_issue)
+
+ def testGetAdjacentIssue_NotFound(self):
+ cur_issue = fake.MakeTestIssue(789, 2, 'sum', 'New', 111, issue_id=78902)
+ prev_issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, issue_id=78901)
+ self.services.issue.TestAddIssue(cur_issue)
+ self.services.issue.TestAddIssue(prev_issue)
+
+ with self.work_env as we:
+ we.FindIssuePositionInSearch = mock.Mock(
+ return_value=[78901, 1, 78903, 3])
+
+ with self.assertRaises(exceptions.NoSuchIssueException):
+ issuedetailezt.GetAdjacentIssue(
+ self.mr, we, cur_issue, next_issue=True)
+ we.FindIssuePositionInSearch.assert_called_once_with(cur_issue)
+
+ def testGetAdjacentIssue_Hotlist(self):
+ cur_issue = fake.MakeTestIssue(789, 2, 'sum', 'New', 111, issue_id=78902)
+ next_issue = fake.MakeTestIssue(789, 3, 'sum', 'New', 111, issue_id=78903)
+ prev_issue = fake.MakeTestIssue(789, 1, 'sum', 'New', 111, issue_id=78901)
+ self.services.issue.TestAddIssue(cur_issue)
+ self.services.issue.TestAddIssue(next_issue)
+ self.services.issue.TestAddIssue(prev_issue)
+ hotlist = fake.Hotlist('name', 678, owner_ids=[111])
+
+ with self.work_env as we:
+ we.GetIssuePositionInHotlist = mock.Mock(
+ return_value=[78901, 1, 78903, 3])
+
+ actual_issue = issuedetailezt.GetAdjacentIssue(
+ self.mr, we, cur_issue, hotlist=hotlist, next_issue=True)
+ self.assertEqual(next_issue, actual_issue)
+ we.GetIssuePositionInHotlist.assert_called_once_with(
+ cur_issue, hotlist, self.mr.can, self.mr.sort_spec,
+ self.mr.group_by_spec)
+
+
+class FlipperRedirectTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ config=fake.ConfigService(),
+ features=fake.FeaturesService(),
+ issue=fake.IssueService(),
+ user=fake.UserService(),
+ project=fake.ProjectService())
+ self.project = self.services.project.TestAddProject(
+ 'proj', project_id=987, committer_ids=[111])
+ self.next_servlet = issuedetailezt.FlipperNext(
+ 'req', 'res', services=self.services)
+ self.prev_servlet = issuedetailezt.FlipperPrev(
+ 'req', 'res', services=self.services)
+ self.list_servlet = issuedetailezt.FlipperList(
+ 'req', 'res', services=self.services)
+ mr = testing_helpers.MakeMonorailRequest(project=self.project)
+ mr.local_id = 123
+ mr.me_user_id = 111
+
+ self.next_servlet.mr = mr
+ self.prev_servlet.mr = mr
+ self.list_servlet.mr = mr
+
+ self.fake_issue_1 = fake.MakeTestIssue(987, 123, 'summary', 'New', 111,
+ project_name='rutabaga')
+ self.services.issue.TestAddIssue(self.fake_issue_1)
+ self.fake_issue_2 = fake.MakeTestIssue(987, 456, 'summary', 'New', 111,
+ project_name='rutabaga')
+ self.services.issue.TestAddIssue(self.fake_issue_2)
+ self.fake_issue_3 = fake.MakeTestIssue(987, 789, 'summary', 'New', 111,
+ project_name='potato')
+ self.services.issue.TestAddIssue(self.fake_issue_3)
+
+ self.next_servlet.redirect = mock.Mock()
+ self.prev_servlet.redirect = mock.Mock()
+ self.list_servlet.redirect = mock.Mock()
+
+ @mock.patch('tracker.issuedetailezt.GetAdjacentIssue')
+ def testFlipperNext(self, patchGetAdjacentIssue):
+ patchGetAdjacentIssue.return_value = self.fake_issue_2
+ self.next_servlet.mr.GetIntParam = mock.Mock(return_value=None)
+
+ self.next_servlet.get(project_name='proj', viewed_username=None)
+ self.next_servlet.mr.GetIntParam.assert_called_once_with('hotlist_id')
+ patchGetAdjacentIssue.assert_called_once()
+ self.next_servlet.redirect.assert_called_once_with(
+ '/p/rutabaga/issues/detail?id=456')
+
+ @mock.patch('tracker.issuedetailezt.GetAdjacentIssue')
+ def testFlipperNext_Hotlist(self, patchGetAdjacentIssue):
+ patchGetAdjacentIssue.return_value = self.fake_issue_3
+ self.next_servlet.mr.GetIntParam = mock.Mock(return_value=123)
+ # TODO(jeffcarp): Mock hotlist_id param on path here.
+
+ self.next_servlet.get(project_name='proj', viewed_username=None)
+ self.next_servlet.mr.GetIntParam.assert_called_with('hotlist_id')
+ self.next_servlet.redirect.assert_called_once_with(
+ '/p/potato/issues/detail?id=789')
+
+ @mock.patch('tracker.issuedetailezt.GetAdjacentIssue')
+ def testFlipperPrev(self, patchGetAdjacentIssue):
+ patchGetAdjacentIssue.return_value = self.fake_issue_2
+ self.next_servlet.mr.GetIntParam = mock.Mock(return_value=None)
+
+ self.prev_servlet.get(project_name='proj', viewed_username=None)
+ self.prev_servlet.mr.GetIntParam.assert_called_with('hotlist_id')
+ patchGetAdjacentIssue.assert_called_once()
+ self.prev_servlet.redirect.assert_called_once_with(
+ '/p/rutabaga/issues/detail?id=456')
+
+ @mock.patch('tracker.issuedetailezt.GetAdjacentIssue')
+ def testFlipperPrev_Hotlist(self, patchGetAdjacentIssue):
+ patchGetAdjacentIssue.return_value = self.fake_issue_3
+ self.prev_servlet.mr.GetIntParam = mock.Mock(return_value=123)
+ # TODO(jeffcarp): Mock hotlist_id param on path here.
+
+ self.prev_servlet.get(project_name='proj', viewed_username=None)
+ self.prev_servlet.mr.GetIntParam.assert_called_with('hotlist_id')
+ self.prev_servlet.redirect.assert_called_once_with(
+ '/p/potato/issues/detail?id=789')
+
+ @mock.patch('tracker.issuedetailezt._ComputeBackToListURL')
+ def testFlipperList(self, patch_ComputeBackToListURL):
+ patch_ComputeBackToListURL.return_value = '/p/test/issues/list'
+ self.list_servlet.mr.GetIntParam = mock.Mock(return_value=None)
+
+ self.list_servlet.get()
+
+ self.list_servlet.mr.GetIntParam.assert_called_with('hotlist_id')
+ patch_ComputeBackToListURL.assert_called_once()
+ self.list_servlet.redirect.assert_called_once_with(
+ '/p/test/issues/list')
+
+ @mock.patch('tracker.issuedetailezt._ComputeBackToListURL')
+ def testFlipperList_Hotlist(self, patch_ComputeBackToListURL):
+ patch_ComputeBackToListURL.return_value = '/p/test/issues/list'
+ self.list_servlet.mr.GetIntParam = mock.Mock(return_value=123)
+
+ self.list_servlet.get()
+
+ self.list_servlet.mr.GetIntParam.assert_called_with('hotlist_id')
+ self.list_servlet.redirect.assert_called_once_with(
+ '/p/test/issues/list')
+
+
+class ShouldShowFlipperTest(unittest.TestCase):
+
+ def setUp(self):
+ self.cnxn = 'fake cnxn'
+
+ def VerifyShouldShowFlipper(
+ self, expected, query, sort_spec, can, create_issues=0):
+ """Instantiate a _Flipper and check if makes a pipeline or not."""
+ services = service_manager.Services(
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ project=fake.ProjectService(),
+ user=fake.UserService())
+ project = services.project.TestAddProject(
+ 'proj', project_id=987, committer_ids=[111])
+ mr = testing_helpers.MakeMonorailRequest(project=project)
+ mr.query = query
+ mr.sort_spec = sort_spec
+ mr.can = can
+ mr.project_name = project.project_name
+ mr.project = project
+
+ for idx in range(create_issues):
+ _created_issue = fake.MakeTestIssue(
+ project.project_id,
+ idx,
+ 'summary_%d' % idx,
+ 'status',
+ 111,
+ reporter_id=111)
+ services.issue.TestAddIssue(_created_issue)
+
+ self.assertEqual(expected, issuedetailezt._ShouldShowFlipper(mr, services))
+
+ def testShouldShowFlipper_RegularSizedProject(self):
+ # If the user is looking for a specific issue, no flipper.
+ self.VerifyShouldShowFlipper(
+ False, '123', '', tracker_constants.OPEN_ISSUES_CAN)
+ self.VerifyShouldShowFlipper(False, '123', '', 5)
+ self.VerifyShouldShowFlipper(
+ False, '123', 'priority', tracker_constants.OPEN_ISSUES_CAN)
+
+ # If the user did a search or sort or all in a small can, show flipper.
+ self.VerifyShouldShowFlipper(
+ True, 'memory leak', '', tracker_constants.OPEN_ISSUES_CAN)
+ self.VerifyShouldShowFlipper(
+ True, 'id=1,2,3', '', tracker_constants.OPEN_ISSUES_CAN)
+ # Any can other than 1 or 2 is doing a query and so it should have a
+ # failry narrow result set size. 5 is issues starred by me.
+ self.VerifyShouldShowFlipper(True, '', '', 5)
+ self.VerifyShouldShowFlipper(
+ True, '', 'status', tracker_constants.OPEN_ISSUES_CAN)
+
+ # In a project without a huge number of issues, still show the flipper even
+ # if there was no specific query.
+ self.VerifyShouldShowFlipper(
+ True, '', '', tracker_constants.OPEN_ISSUES_CAN)
+
+ def testShouldShowFlipper_LargeSizedProject(self):
+ settings.threshold_to_suppress_prev_next = 1
+
+ # In a project that has tons of issues, save time by not showing the
+ # flipper unless there was a specific query, sort, or can.
+ self.VerifyShouldShowFlipper(
+ False, '', '', tracker_constants.ALL_ISSUES_CAN, create_issues=3)
+ self.VerifyShouldShowFlipper(
+ False, '', '', tracker_constants.OPEN_ISSUES_CAN, create_issues=3)
diff --git a/tracker/test/issueentry_test.py b/tracker/test/issueentry_test.py
new file mode 100644
index 0000000..4a64d7c
--- /dev/null
+++ b/tracker/test/issueentry_test.py
@@ -0,0 +1,1045 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for the issueentry servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import time
+import unittest
+
+import ezt
+
+from google.appengine.ext import testbed
+from mock import Mock, patch
+import webapp2
+
+from framework import framework_bizobj
+from framework import framework_views
+from framework import permissions
+from services import service_manager
+from services import template_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import issueentry
+from tracker import tracker_bizobj
+from proto import tracker_pb2
+from proto import user_pb2
+
+
+class IssueEntryTest(unittest.TestCase):
+ def setUp(self):
+ self.testbed = testbed.Testbed()
+ self.testbed.activate()
+ self.testbed.init_memcache_stub()
+ self.testbed.init_datastore_v3_stub()
+ # Load queue.yaml.
+
+ self.services = service_manager.Services(
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ user=fake.UserService(),
+ usergroup=fake.UserGroupService(),
+ project=fake.ProjectService(),
+ template=Mock(spec=template_svc.TemplateService),
+ features=fake.FeaturesService())
+ self.project = self.services.project.TestAddProject('proj', project_id=987)
+ request = webapp2.Request.blank('/p/proj/issues/entry')
+ response = webapp2.Response()
+ self.servlet = issueentry.IssueEntry(
+ request, response, services=self.services)
+ self.user = self.services.user.TestAddUser('to_pass_tests', 0)
+ self.services.features.TestAddHotlist(
+ name='dontcare', summary='', owner_ids=[0])
+ self.template = testing_helpers.DefaultTemplates()[1]
+ self.services.template.GetTemplateByName = Mock(return_value=self.template)
+ self.services.template.GetTemplateSetForProject = Mock(
+ return_value=[(1, 'name', False)])
+
+ # Set-up for testing hotlist parsing.
+ # Scenario:
+ # Users: U1, U2, and U3
+ # Hotlists:
+ # H1: owned by U1 (private)
+ # H2: owned by U2, can be edited by U1 (private)
+ # H2: owned by U3, can be edited by U1 and U2 (public)
+ self.cnxn = fake.MonorailConnection()
+ self.U1 = self.services.user.TestAddUser('U1', 111)
+ self.U2 = self.services.user.TestAddUser('U2', 222)
+ self.U3 = self.services.user.TestAddUser('U3', 333)
+
+ self.H1 = self.services.features.TestAddHotlist(
+ name='H1', summary='', owner_ids=[111], is_private=True)
+ self.H2 = self.services.features.TestAddHotlist(
+ name='H2', summary='', owner_ids=[222], editor_ids=[111],
+ is_private=True)
+ self.H2_U3 = self.services.features.TestAddHotlist(
+ name='H2', summary='', owner_ids=[333], editor_ids=[111, 222],
+ is_private=False)
+
+ self.mox = mox.Mox()
+
+ def tearDown(self):
+ self.testbed.deactivate()
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+
+ def testAssertBasePermission(self):
+ """Permit users with CREATE_ISSUE."""
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', services=self.services,
+ perms=permissions.EMPTY_PERMISSIONSET)
+ self.assertRaises(permissions.PermissionException,
+ self.servlet.AssertBasePermission, mr)
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', services=self.services,
+ perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+ self.servlet.AssertBasePermission(mr)
+
+ def testDiscardUnusedTemplateLabelPrefixes(self):
+ labels = ['pre-val', 'other-value', 'oneword', 'x', '-y', '-w-z', '', '-']
+ self.assertEqual(labels,
+ issueentry._DiscardUnusedTemplateLabelPrefixes(labels))
+
+ labels = ['prefix-value', 'other-?', 'third-', '', '-', '-?']
+ self.assertEqual(['prefix-value', 'third-', '', '-'],
+ issueentry._DiscardUnusedTemplateLabelPrefixes(labels))
+
+ def testGatherPageData(self):
+ user = self.services.user.TestAddUser('user@invalid', 100)
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', services=self.services)
+ mr.perms = permissions.PermissionSet(
+ [permissions.CREATE_ISSUE, permissions.EDIT_ISSUE])
+ mr.auth.user_view = framework_views.MakeUserView(
+ 'cnxn', self.services.user, 100)
+ mr.auth.effective_ids = {100}
+ mr.template_name = 'rutabaga'
+
+ self.mox.StubOutWithMock(self.services.user, 'GetUser')
+ self.services.user.GetUser(
+ mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+ self.mox.ReplayAll()
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ config.field_defs = [
+ tracker_bizobj.MakeFieldDef(
+ 22, mr.project_id, 'NotEnum', tracker_pb2.FieldTypes.STR_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+ tracker_bizobj.MakeFieldDef(
+ 23, mr.project_id, 'Choices', tracker_pb2.FieldTypes.ENUM_TYPE,
+ None, '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+ tracker_bizobj.MakeFieldDef(
+ 24,
+ mr.project_id,
+ 'RestrictedField',
+ tracker_pb2.FieldTypes.STR_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'doc',
+ False,
+ is_restricted_field=True)
+ ]
+ self.services.config.StoreConfig(mr.cnxn, config)
+ template = tracker_pb2.TemplateDef(
+ labels=['NotEnum-Not-Masked', 'Choices-Masked'])
+ self.services.template.GetTemplateByName.return_value = template
+
+ page_data = self.servlet.GatherPageData(mr)
+ self.mox.VerifyAll()
+ self.assertEqual(page_data['initial_owner'], 'user@invalid')
+ self.assertEqual(page_data['initial_status'], 'New')
+ self.assertTrue(page_data['clear_summary_on_click'])
+ self.assertTrue(page_data['must_edit_summary'])
+ self.assertEqual(page_data['labels'], ['NotEnum-Not-Masked'])
+ self.assertEqual(page_data['offer_templates'], ezt.boolean(False))
+ self.assertEqual(page_data['fields'][0].is_editable, ezt.boolean(True))
+ self.assertEqual(page_data['fields'][1].is_editable, ezt.boolean(True))
+ self.assertEqual(page_data['fields'][2].is_editable, ezt.boolean(False))
+ self.assertEqual(page_data['uneditable_fields'], ezt.boolean(True))
+
+ def testGatherPageData_Approvals(self):
+ user = self.services.user.TestAddUser('user@invalid', 100)
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', services=self.services)
+ mr.auth.user_view = framework_views.MakeUserView(
+ 'cnxn', self.services.user, 100)
+ mr.template_name = 'rutabaga'
+
+ self.mox.StubOutWithMock(self.services.user, 'GetUser')
+ self.services.user.GetUser(
+ mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+ self.mox.ReplayAll()
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ config.field_defs = [
+ tracker_bizobj.MakeFieldDef(
+ 24, mr.project_id, 'UXReview',
+ tracker_pb2.FieldTypes.APPROVAL_TYPE, None, '', False, False,
+ False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)]
+ self.services.config.StoreConfig(mr.cnxn, config)
+ template = tracker_pb2.TemplateDef()
+ template.phases = [tracker_pb2.Phase(
+ phase_id=1, rank=4, name='Stable')]
+ template.approval_values = [tracker_pb2.ApprovalValue(
+ approval_id=24, phase_id=1,
+ status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)]
+ self.services.template.GetTemplateByName.return_value = template
+
+ page_data = self.servlet.GatherPageData(mr)
+ self.mox.VerifyAll()
+ self.assertEqual(page_data['approvals'][0].field_name, 'UXReview')
+ self.assertEqual(page_data['initial_phases'][0],
+ tracker_pb2.Phase(phase_id=1, name='Stable', rank=4))
+ self.assertEqual(page_data['prechecked_approvals'], ['24_phase_0'])
+ self.assertEqual(page_data['required_approval_ids'], [24])
+
+ # phase fields row shown when config contains phase fields.
+ config.field_defs.append(tracker_bizobj.MakeFieldDef(
+ 26, mr.project_id, 'GateTarget',
+ tracker_pb2.FieldTypes.INT_TYPE, None, '', False, False, False,
+ None, None, '', False, '', '', tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action', 'doc', False, is_phase_field=True))
+ self.services.config.StoreConfig(mr.cnxn, config)
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertEqual(page_data['issue_phase_names'], ['stable'])
+
+ # approval subfields in config hidden when chosen template does not contain
+ # its parent approval
+ template = tracker_pb2.TemplateDef()
+ self.services.template.GetTemplateByName.return_value = template
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertEqual(page_data['approvals'], [])
+ # phase fields row hidden when template has no phases
+ self.assertEqual(page_data['issue_phase_names'], [])
+
+ # TODO(jojwang): monorail:6305, remove this test when Edit perms
+ # for field values are implemented.
+ def testGatherPageData_FLTSpecialFields(self):
+ user = self.services.user.TestAddUser('user@invalid', 100)
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', services=self.services)
+ mr.auth.user_view = framework_views.MakeUserView(
+ 'cnxn', self.services.user, 100)
+ mr.template_name = 'rutabaga'
+
+ self.mox.StubOutWithMock(self.services.user, 'GetUser')
+ self.services.user.GetUser(
+ mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+ self.mox.ReplayAll()
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ config.field_defs = [
+ tracker_bizobj.MakeFieldDef(
+ 25, mr.project_id, 'nOtice',
+ tracker_pb2.FieldTypes.STR_TYPE, None, '', False, False,
+ False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+ tracker_bizobj.MakeFieldDef(
+ 24, mr.project_id, 'M-Target',
+ tracker_pb2.FieldTypes.STR_TYPE, None, '', False, False,
+ False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+ tracker_bizobj.MakeFieldDef(
+ 25, mr.project_id, 'whitepaper',
+ tracker_pb2.FieldTypes.STR_TYPE, None, '', False, False,
+ False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+ tracker_bizobj.MakeFieldDef(
+ 25, mr.project_id, 'm-approved',
+ tracker_pb2.FieldTypes.STR_TYPE, None, '', False, False,
+ False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False),
+ ]
+
+ self.services.config.StoreConfig(mr.cnxn, config)
+ template = tracker_pb2.TemplateDef()
+ self.services.template.GetTemplateByName.return_value = template
+
+ page_data = self.servlet.GatherPageData(mr)
+ self.mox.VerifyAll()
+ self.assertEqual(page_data['fields'][0].field_name, 'M-Target')
+ self.assertEqual(len(page_data['fields']), 1)
+
+ def testGatherPageData_DefaultOwnerAvailability(self):
+ user = self.services.user.TestAddUser('user@invalid', 100)
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', services=self.services)
+ mr.auth.user_view = framework_views.MakeUserView(
+ 'cnxn', self.services.user, 100)
+ mr.template_name = 'rutabaga'
+
+ self.mox.StubOutWithMock(self.services.user, 'GetUser')
+ self.services.user.GetUser(
+ mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+ self.mox.ReplayAll()
+
+ page_data = self.servlet.GatherPageData(mr)
+ self.mox.VerifyAll()
+ self.assertEqual(page_data['initial_owner'], 'user@invalid')
+ self.assertEqual(page_data['owner_avail_state'], 'never')
+ self.assertEqual(
+ page_data['owner_avail_message_short'],
+ 'User never visited')
+
+ user.last_visit_timestamp = int(time.time())
+ mr.auth.user_view = framework_views.MakeUserView(
+ 'cnxn', self.services.user, 100)
+ page_data = self.servlet.GatherPageData(mr)
+ self.mox.VerifyAll()
+ self.assertEqual(page_data['initial_owner'], 'user@invalid')
+ self.assertEqual(page_data['owner_avail_state'], None)
+ self.assertEqual(page_data['owner_avail_message_short'], '')
+
+ def testGatherPageData_TemplateAllowsKeepingSummary(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', services=self.services)
+ mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+ mr.template_name = 'rutabaga'
+ user = self.services.user.TestAddUser('user@invalid', 100)
+
+ self.mox.StubOutWithMock(self.services.user, 'GetUser')
+ self.services.user.GetUser(
+ mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+ self.mox.ReplayAll()
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ self.services.config.StoreConfig(mr.cnxn, config)
+ self.template.summary_must_be_edited = False
+
+ page_data = self.servlet.GatherPageData(mr)
+ self.mox.VerifyAll()
+ self.assertEqual(page_data['initial_owner'], 'user@invalid')
+ self.assertEqual(page_data['initial_status'], 'New')
+ self.assertFalse(page_data['clear_summary_on_click'])
+ self.assertFalse(page_data['must_edit_summary'])
+
+ def testGatherPageData_DeepLinkSetsSummary(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry?summary=foo', services=self.services)
+ mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+ user = self.services.user.TestAddUser('user@invalid', 100)
+ mr.template_name = 'rutabaga'
+
+ self.mox.StubOutWithMock(self.services.user, 'GetUser')
+ self.services.user.GetUser(
+ mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+ self.mox.ReplayAll()
+
+ page_data = self.servlet.GatherPageData(mr)
+ self.mox.VerifyAll()
+ self.assertEqual(page_data['initial_owner'], 'user@invalid')
+ self.assertEqual(page_data['initial_status'], 'New')
+ self.assertFalse(page_data['clear_summary_on_click'])
+ self.assertTrue(page_data['must_edit_summary'])
+
+ @patch('framework.framework_bizobj.UserIsInProject')
+ def testGatherPageData_MembersOnlyTemplatesExcluded(self,
+ mockUserIsInProject):
+ """Templates with members_only=True are excluded from results
+ when the user is not a member of the project."""
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', services=self.services)
+ mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+ user = self.services.user.TestAddUser('user@invalid', 100)
+ mr.template_name = 'rutabaga'
+ self.services.template.GetTemplateSetForProject = Mock(
+ return_value=[(1, 'one', False), (2, 'two', True)])
+ mockUserIsInProject.return_value = False
+
+ self.mox.StubOutWithMock(self.services.user, 'GetUser')
+ self.services.user.GetUser(
+ mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+ self.mox.ReplayAll()
+
+ page_data = self.servlet.GatherPageData(mr)
+ self.mox.VerifyAll()
+ self.assertEqual(page_data['config'].template_names, ['one'])
+
+ @patch('framework.framework_bizobj.UserIsInProject')
+ def testGatherPageData_DefaultTemplatesMember(self, mockUserIsInProject):
+ """If no template is specified, the default one is used based on
+ whether the user is a project member."""
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', services=self.services)
+ mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+ user = self.services.user.TestAddUser('user@invalid', 100)
+ self.services.template.GetTemplateSetForProject = Mock(
+ return_value=[(1, 'one', False), (2, 'two', True)])
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ config.default_template_for_users = 456
+ config.default_template_for_developers = 789
+ self.services.config.StoreConfig(mr.cnxn, config)
+
+ mockUserIsInProject.return_value = True
+ self.services.template.GetTemplateById = Mock(return_value=self.template)
+ self.mox.StubOutWithMock(self.services.user, 'GetUser')
+ self.services.user.GetUser(
+ mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+
+ self.mox.ReplayAll()
+ self.servlet.GatherPageData(mr)
+ self.mox.VerifyAll()
+
+ call_args = self.services.template.GetTemplateById.call_args[0]
+ self.assertEqual(call_args[1], 789)
+
+ @patch('framework.framework_bizobj.UserIsInProject')
+ def testGatherPageData_DefaultTemplatesNonMember(self, mockUserIsInProject):
+ """If no template is specified, the default one is used based on
+ whether the user is not a project member."""
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', services=self.services)
+ mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+ user = self.services.user.TestAddUser('user@invalid', 100)
+ self.services.template.GetTemplateSetForProject = Mock(
+ return_value=[(1, 'one', False), (2, 'two', True)])
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ config.default_template_for_users = 456
+ config.default_template_for_developers = 789
+ self.services.config.StoreConfig(mr.cnxn, config)
+
+ mockUserIsInProject.return_value = False
+ self.services.template.GetTemplateById = Mock(return_value=self.template)
+ self.mox.StubOutWithMock(self.services.user, 'GetUser')
+ self.services.user.GetUser(
+ mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+
+ self.mox.ReplayAll()
+ self.servlet.GatherPageData(mr)
+ self.mox.VerifyAll()
+
+ call_args = self.services.template.GetTemplateById.call_args[0]
+ self.assertEqual(call_args[1], 456)
+
+ def testGatherPageData_MissingDefaultTemplates(self):
+ """If the default templates were deleted, pick the first template."""
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', services=self.services)
+ mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+ user = self.services.user.TestAddUser('user@invalid', 100)
+ self.services.template.GetTemplateSetForProject = Mock(
+ return_value=[(1, 'one', False), (2, 'two', True)])
+
+ self.services.template.GetTemplateById.return_value = None
+ self.services.template.GetProjectTemplates.return_value = [
+ tracker_pb2.TemplateDef(members_only=True),
+ tracker_pb2.TemplateDef(members_only=False)]
+ self.mox.StubOutWithMock(self.services.user, 'GetUser')
+ self.services.user.GetUser(
+ mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+
+ self.mox.ReplayAll()
+ page_data = self.servlet.GatherPageData(mr)
+ self.mox.VerifyAll()
+
+ self.assertTrue(self.services.template.GetProjectTemplates.called)
+ self.assertTrue(page_data['config'].template_view.members_only)
+
+ def testGatherPageData_IncorrectTemplate(self):
+ """The handler shouldn't error out if passed a non-existent template."""
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', services=self.services)
+ mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+ mr.template_name = 'rutabaga'
+
+ user = self.services.user.TestAddUser('user@invalid', 100)
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ config.default_template_for_users = 456
+ config.default_template_for_developers = 789
+ self.services.config.StoreConfig(mr.cnxn, config)
+
+ self.services.template.GetTemplateSetForProject.return_value = [
+ (1, 'one', False), (2, 'two', True)]
+ self.services.template.GetTemplateByName.return_value = None
+ self.services.template.GetTemplateById.return_value = \
+ tracker_pb2.TemplateDef(template_id=123, labels=['yo'])
+ self.services.template.GetProjectTemplates.return_value = [
+ tracker_pb2.TemplateDef(labels=['no']),
+ tracker_pb2.TemplateDef(labels=['maybe'])]
+ self.mox.StubOutWithMock(self.services.user, 'GetUser')
+ self.services.user.GetUser(
+ mox.IgnoreArg(), mox.IgnoreArg()).MultipleTimes().AndReturn(user)
+
+ self.mox.ReplayAll()
+ page_data = self.servlet.GatherPageData(mr)
+ self.mox.VerifyAll()
+
+ self.assertTrue(self.services.template.GetTemplateByName.called)
+ self.assertTrue(self.services.template.GetTemplateById.called)
+ self.assertFalse(self.services.template.GetProjectTemplates.called)
+ self.assertEqual(page_data['config'].template_view.label0, 'yo')
+
+ def testGatherPageData_RestrictNewIssues(self):
+ """Users with this pref set default to reporting issues with R-V-G."""
+ self.mox.ReplayAll()
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', services=self.services)
+ mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+ user = self.services.user.TestAddUser('user@invalid', 100)
+ self.services.user.GetUser = Mock(return_value=user)
+ self.services.template.GetTemplateById = Mock(return_value=self.template)
+
+ mr.auth.user_id = 100
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertNotIn('Restrict-View-Google', page_data['labels'])
+
+ pref = user_pb2.UserPrefValue(name='restrict_new_issues', value='true')
+ self.services.user.SetUserPrefs(self.cnxn, 100, [pref])
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertIn('Restrict-View-Google', page_data['labels'])
+
+ def testGatherHelpData_Anon(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', project=self.project)
+ mr.auth.user_pb = user_pb2.User()
+ mr.auth.user_id = 0
+
+ help_data = self.servlet.GatherHelpData(mr, {})
+ self.assertEqual(
+ {'account_cue': None,
+ 'cue': None,
+ 'is_privileged_domain_user': None},
+ help_data)
+
+ def testGatherHelpData_NewUser(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', project=self.project)
+ mr.auth.user_pb = user_pb2.User(user_id=111)
+ mr.auth.user_id = 111
+
+ help_data = self.servlet.GatherHelpData(mr, {})
+ self.assertEqual(
+ {'account_cue': None,
+ 'cue': 'privacy_click_through',
+ 'is_privileged_domain_user': None},
+ help_data)
+
+ def testGatherHelpData_AlreadyClickedThroughPrivacy(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', project=self.project)
+ mr.auth.user_pb = user_pb2.User(user_id=111)
+ mr.auth.user_id = 111
+ self.services.user.SetUserPrefs(
+ self.cnxn, 111,
+ [user_pb2.UserPrefValue(name='privacy_click_through', value='true')])
+
+ help_data = self.servlet.GatherHelpData(mr, {})
+ self.assertEqual(
+ {'account_cue': None,
+ 'cue': 'code_of_conduct',
+ 'is_privileged_domain_user': None},
+ help_data)
+
+ def testGatherHelpData_DismissedEverything(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', project=self.project)
+ mr.auth.user_pb = user_pb2.User(user_id=111)
+ mr.auth.user_id = 111
+ self.services.user.SetUserPrefs(
+ self.cnxn, 111,
+ [user_pb2.UserPrefValue(name='privacy_click_through', value='true'),
+ user_pb2.UserPrefValue(name='code_of_conduct', value='true')])
+
+ help_data = self.servlet.GatherHelpData(mr, {})
+ self.assertEqual(
+ {'account_cue': None,
+ 'cue': None,
+ 'is_privileged_domain_user': None},
+ help_data)
+
+ @patch('framework.cloud_tasks_helpers.create_task')
+ def testProcessFormData_RedirectToEnteredIssue(self, _create_task_mock):
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', project=self.project)
+ mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+ mr.template_name = 'rutabaga'
+ mr.auth.effective_ids = set([100])
+ post_data = fake.PostData(
+ template_name=['rutabaga'],
+ summary=['fake summary'],
+ comment=['fake comment'],
+ status=['New'])
+
+ self.mox.ReplayAll()
+ url = self.servlet.ProcessFormData(mr, post_data)
+
+ self.mox.VerifyAll()
+ self.assertTrue('/p/proj/issues/detail?id=' in url)
+
+ @patch('framework.cloud_tasks_helpers.create_task')
+ def testProcessFormData_AcceptWithFields(self, _create_task_mock):
+ """We can create new issues with custom fields (restricted or not)."""
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', project=self.project)
+ mr.auth.user_view = framework_views.StuffUserView(100, 'admin@test', True)
+ mr.template_name = 'rutabaga'
+ mr.auth.effective_ids = set([100])
+ config = self.services.config.GetProjectConfig(
+ mr.cnxn, self.project.project_id)
+ config.field_defs = [
+ tracker_bizobj.MakeFieldDef(
+ 1, 789, 'NonRestrictedField', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'NonRestrictedField',
+ False),
+ tracker_bizobj.MakeFieldDef(
+ 2,
+ 789,
+ 'RestrictedField',
+ tracker_pb2.FieldTypes.INT_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'RestrictedField',
+ False,
+ is_restricted_field=True),
+ tracker_bizobj.MakeFieldDef(
+ 3,
+ 789,
+ 'RestrictedEnumField',
+ tracker_pb2.FieldTypes.ENUM_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'RestrictedEnumField',
+ False,
+ is_restricted_field=True)
+ ]
+ self.services.config.StoreConfig(mr.cnxn, config)
+ post_data = fake.PostData(
+ template_name=['rutabaga'],
+ summary=['fake summary'],
+ comment=['fake comment'],
+ custom_1=['3'],
+ custom_2=['7'],
+ label=['RestrictedEnumField-7'],
+ status=['New'])
+
+ self.mox.ReplayAll()
+ url = self.servlet.ProcessFormData(mr, post_data)
+
+ self.mox.VerifyAll()
+ self.assertTrue('/p/proj/issues/detail?id=' in url)
+ field_values = self.services.issue.issues_by_project[987][1].field_values
+ self.assertEqual(
+ self.services.issue.issues_by_project[987][1].labels,
+ ['RestrictedEnumField-7'])
+ self.assertEqual(field_values[0].int_value, 3)
+ self.assertEqual(field_values[1].int_value, 7)
+
+ @patch('framework.cloud_tasks_helpers.create_task')
+ def testProcessFormData_AcceptEnforceTemplateRestrictedDefaultValues(
+ self, _create_task_mock):
+ """The template applies default vals on fields that the user cannot edit."""
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', project=self.project)
+ mr.auth.user_view = framework_views.StuffUserView(100, 'admin@test', True)
+ mr.template_name = 'rutabaga'
+ mr.auth.effective_ids = set([100])
+ mr.perms = permissions.PermissionSet([])
+ config = self.services.config.GetProjectConfig(
+ mr.cnxn, self.project.project_id)
+ config.field_defs = [
+ tracker_bizobj.MakeFieldDef(
+ 1, 789, 'NonRestrictedField', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'NonRestrictedField',
+ False),
+ tracker_bizobj.MakeFieldDef(
+ 2,
+ 789,
+ 'RestrictedField',
+ tracker_pb2.FieldTypes.INT_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'RestrictedField',
+ False,
+ is_restricted_field=True),
+ tracker_bizobj.MakeFieldDef(
+ 3,
+ 789,
+ 'RestrictedEnumField',
+ tracker_pb2.FieldTypes.ENUM_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'RestrictedEnumField',
+ False,
+ is_restricted_field=True)
+ ]
+ self.services.config.StoreConfig(mr.cnxn, config)
+ post_data = fake.PostData(
+ template_name=['rutabaga'],
+ summary=['fake summary'],
+ comment=['fake comment'],
+ custom_1=['3'],
+ label=['Hey'],
+ status=['New'])
+
+ temp_restricted_fv = tracker_bizobj.MakeFieldValue(
+ 2, 3737, None, None, None, None, False)
+ self.template.field_values.append(temp_restricted_fv)
+ self.template.labels.append('RestrictedEnumField-b')
+
+ self.mox.ReplayAll()
+ url = self.servlet.ProcessFormData(mr, post_data)
+
+ self.mox.VerifyAll()
+ self.assertTrue('/p/proj/issues/detail?id=' in url)
+ field_values = self.services.issue.issues_by_project[987][1].field_values
+ self.assertEqual(
+ self.services.issue.issues_by_project[987][1].labels,
+ ['Hey', 'RestrictedEnumField-b'])
+ self.assertEqual(field_values[0].int_value, 3)
+ self.assertEqual(field_values[1].int_value, 3737)
+
+ def testProcessFormData_RejectRestrictedFields(self):
+ """We raise an AssertionError when restricted fields are set w/o perms."""
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', project=self.project)
+ mr.auth.user_view = framework_views.StuffUserView(
+ 100, 'non-admin@test', True)
+ mr.template_name = 'rutabaga'
+ mr.auth.effective_ids = set([100])
+ mr.perms = permissions.PermissionSet([])
+ config = self.services.config.GetProjectConfig(
+ mr.cnxn, self.project.project_id)
+ config.field_defs = [
+ tracker_bizobj.MakeFieldDef(
+ 1, 789, 'NonRestrictedField', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'NonRestrictedField',
+ False),
+ tracker_bizobj.MakeFieldDef(
+ 2,
+ 789,
+ 'RestrictedField',
+ tracker_pb2.FieldTypes.INT_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'RestrictedField',
+ False,
+ is_restricted_field=True),
+ tracker_bizobj.MakeFieldDef(
+ 3,
+ 789,
+ 'RestrictedEnumField',
+ tracker_pb2.FieldTypes.ENUM_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'RestrictedEnumField',
+ False,
+ is_restricted_field=True)
+ ]
+ self.services.config.StoreConfig(mr.cnxn, config)
+ post_data_add_fv = fake.PostData(
+ template_name=['rutabaga'],
+ summary=['fake summary'],
+ comment=['fake comment'],
+ custom_1=['3'],
+ custom_2=['7'],
+ status=['New'])
+ post_data_label_edits_enum = fake.PostData(
+ template_name=['rutabaga'],
+ summary=['fake summary'],
+ comment=['fake comment'],
+ label=['RestrictedEnumField-7'],
+ status=['New'])
+
+ self.assertRaises(
+ AssertionError, self.servlet.ProcessFormData, mr, post_data_add_fv)
+ self.assertRaises(
+ AssertionError, self.servlet.ProcessFormData, mr,
+ post_data_label_edits_enum)
+
+ def testProcessFormData_RejectPlacedholderSummary(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry')
+ mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+ mr.perms = permissions.USER_PERMISSIONSET
+ mr.template_name = 'rutabaga'
+ post_data = fake.PostData(
+ template_name=['rutabaga'],
+ summary=[issueentry.PLACEHOLDER_SUMMARY],
+ comment=['fake comment'],
+ status=['New'])
+
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(
+ mr, component_required=None, fields=[], initial_blocked_on='',
+ initial_blocking='', initial_cc='', initial_comment='fake comment',
+ initial_components='', initial_owner='', initial_status='New',
+ initial_summary='Enter one-line summary', initial_hotlists='',
+ labels=[], template_name='rutabaga')
+ self.mox.ReplayAll()
+
+ url = self.servlet.ProcessFormData(mr, post_data)
+ self.mox.VerifyAll()
+ self.assertEqual('Summary is required', mr.errors.summary)
+ self.assertIsNone(url)
+
+ def testProcessFormData_RejectUnmodifiedTemplate(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry')
+ mr.perms = permissions.USER_PERMISSIONSET
+ mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+ post_data = fake.PostData(
+ template_name=['rutabaga'],
+ summary=['Nya nya I modified the summary'],
+ comment=[self.template.content],
+ status=['New'])
+
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(
+ mr, component_required=None, fields=[], initial_blocked_on='',
+ initial_blocking='', initial_cc='',
+ initial_comment=self.template.content, initial_components='',
+ initial_owner='', initial_status='New',
+ initial_summary='Nya nya I modified the summary', initial_hotlists='',
+ labels=[], template_name='rutabaga')
+ self.mox.ReplayAll()
+
+ url = self.servlet.ProcessFormData(mr, post_data)
+ self.mox.VerifyAll()
+ self.assertEqual('Template must be filled out.', mr.errors.comment)
+ self.assertIsNone(url)
+
+ def testProcessFormData_RejectNonexistentHotlist(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', user_info={'user_id': 111})
+ entered_hotlists = 'H3'
+ post_data = fake.PostData(hotlists=[entered_hotlists],
+ template_name=['rutabaga'])
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(
+ mr, component_required=None, fields=[], initial_blocked_on='',
+ initial_blocking='', initial_cc='', initial_comment='',
+ initial_components='', initial_owner='', initial_status='',
+ initial_summary='', initial_hotlists=entered_hotlists, labels=[],
+ template_name='rutabaga')
+ self.mox.ReplayAll()
+ url = self.servlet.ProcessFormData(mr, post_data)
+ self.mox.VerifyAll()
+ self.assertEqual('You have no hotlist(s) named: H3', mr.errors.hotlists)
+ self.assertIsNone(url)
+
+ def testProcessFormData_RejectNonexistentHotlistOwner(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', user_info={'user_id': 111})
+ entered_hotlists = 'abc:H1'
+ post_data = fake.PostData(hotlists=[entered_hotlists],
+ template_name=['rutabaga'])
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(
+ mr, component_required=None, fields=[], initial_blocked_on='',
+ initial_blocking='', initial_cc='', initial_comment='',
+ initial_components='', initial_owner='', initial_status='',
+ initial_summary='', initial_hotlists=entered_hotlists, labels=[],
+ template_name='rutabaga')
+ self.mox.ReplayAll()
+ url = self.servlet.ProcessFormData(mr, post_data)
+ self.mox.VerifyAll()
+ self.assertEqual('You have no hotlist(s) owned by: abc', mr.errors.hotlists)
+ self.assertIsNone(url)
+
+ def testProcessFormData_RejectInvalidHotlistName(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', user_info={'user_id': 111})
+ entered_hotlists = 'U1:H2'
+ post_data = fake.PostData(hotlists=[entered_hotlists],
+ template_name=['rutabaga'])
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(
+ mr, component_required=None, fields=[], initial_blocked_on='',
+ initial_blocking='', initial_cc='', initial_comment='',
+ initial_components='', initial_owner='', initial_status='',
+ initial_summary='', initial_hotlists=entered_hotlists, labels=[],
+ template_name='rutabaga')
+ self.mox.ReplayAll()
+ url = self.servlet.ProcessFormData(mr, post_data)
+ self.mox.VerifyAll()
+ self.assertEqual('Not in your hotlist(s): U1:H2', mr.errors.hotlists)
+ self.assertIsNone(url)
+
+ def testProcessFormData_RejectDeprecatedComponent(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry',
+ user_info={'user_id': 111},
+ project=self.project)
+ config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
+ config.component_defs = [
+ tracker_bizobj.MakeComponentDef(
+ 1, mr.project_id, 'active', '', False, [], [], 0, 0),
+ tracker_bizobj.MakeComponentDef(
+ 2, mr.project_id, 'notactive', '', True, [], [], 0, 0),
+ ]
+ self.services.config.StoreConfig(mr.cnxn, config)
+ print(config)
+ post_data = fake.PostData(
+ template_name=['rutabaga'],
+ summary=['fake summary'],
+ comment=['fake comment'],
+ components=['notactive'])
+
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(
+ mr,
+ component_required=None,
+ fields=[],
+ initial_blocked_on='',
+ initial_blocking='',
+ initial_cc='',
+ initial_comment='fake comment',
+ initial_components='notactive',
+ initial_owner='',
+ initial_status='',
+ initial_summary='fake summary',
+ initial_hotlists='',
+ labels=[],
+ template_name='rutabaga')
+ self.mox.ReplayAll()
+ url = self.servlet.ProcessFormData(mr, post_data)
+ self.mox.VerifyAll()
+ self.assertEqual(mr.errors.components, 'Undefined or deprecated component')
+ self.assertIsNone(url)
+
+ @patch('framework.cloud_tasks_helpers.create_task')
+ def testProcessFormData_TemplateNameMissing(self, _create_task_mock):
+ """POST doesn't fail if no template_name is passed."""
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', project=self.project)
+ mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+ mr.auth.effective_ids = set([100])
+
+ self.services.template.GetTemplateById.return_value = None
+ self.services.template.GetProjectTemplates.return_value = [
+ tracker_pb2.TemplateDef(members_only=True, content=''),
+ tracker_pb2.TemplateDef(members_only=False, content='')]
+ post_data = fake.PostData(
+ summary=['fake summary'],
+ comment=['fake comment'],
+ status=['New'])
+
+ self.mox.ReplayAll()
+ url = self.servlet.ProcessFormData(mr, post_data)
+
+ self.mox.VerifyAll()
+ self.assertTrue('/p/proj/issues/detail?id=' in url)
+
+ @patch('framework.cloud_tasks_helpers.create_task')
+ def testProcessFormData_AcceptsFederatedReferences(self, _create_task_mock):
+ """ProcessFormData accepts federated references."""
+ mr = testing_helpers.MakeMonorailRequest(
+ path='/p/proj/issues/entry', project=self.project)
+ mr.auth.user_view = framework_views.StuffUserView(100, 'user@invalid', True)
+ mr.auth.effective_ids = set([100])
+
+ post_data = fake.PostData(
+ summary=['fake summary'],
+ comment=['fake comment'],
+ status=['New'],
+ template_name='rutabaga',
+ blocking=['b/123, b/987'],
+ blockedon=['b/456, b/654'])
+
+ self.mox.ReplayAll()
+ self.servlet.ProcessFormData(mr, post_data)
+
+ self.mox.VerifyAll()
+ self.assertIsNone(mr.errors.blockedon)
+ self.assertIsNone(mr.errors.blocking)
+
+ def testAttachDefaultApprovers(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ config.approval_defs = [
+ tracker_pb2.ApprovalDef(
+ approval_id=23, approver_ids=[222], survey='Question?'),
+ tracker_pb2.ApprovalDef(
+ approval_id=24, approver_ids=[111], survey='Question?')]
+ approval_values = [tracker_pb2.ApprovalValue(
+ approval_id=24, phase_id=1,
+ status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW)]
+ issueentry._AttachDefaultApprovers(config, approval_values)
+ self.assertEqual(approval_values[0].approver_ids, [111])
+
+ # TODO(aneeshm): add a test for the ambiguous hotlist name case; it works
+ # correctly when tested locally, but for some reason doesn't in the test
+ # environment. Probably a result of some quirk in fake.py?
diff --git a/tracker/test/issueexport_test.py b/tracker/test/issueexport_test.py
new file mode 100644
index 0000000..4e70ab7
--- /dev/null
+++ b/tracker/test/issueexport_test.py
@@ -0,0 +1,163 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for the issueexport servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from mock import Mock, patch
+
+from framework import permissions
+from proto import tracker_pb2
+from services import service_manager
+from testing import testing_helpers
+from testing import fake
+from tracker import issueexport
+
+
+class IssueExportTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ config=fake.ConfigService(),
+ project=fake.ProjectService(),
+ issue=fake.IssueService(),
+ user=fake.UserService(),
+ issue_star=fake.IssueStarService(),
+ )
+ self.cnxn = 'fake connection'
+ self.project = self.services.project.TestAddProject('proj', project_id=789)
+ self.servlet = issueexport.IssueExport(
+ 'req', 'res', services=self.services)
+ self.jsonfeed = issueexport.IssueExportJSON(
+ 'req', 'res', services=self.services)
+ self.mr = testing_helpers.MakeMonorailRequest(
+ project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+ self.mr.can = 1
+
+ def testAssertBasePermission(self):
+ self.assertRaises(permissions.PermissionException,
+ self.servlet.AssertBasePermission, self.mr)
+ self.mr.auth.user_pb.is_site_admin = True
+ self.servlet.AssertBasePermission(self.mr)
+
+ @patch('time.time')
+ def testHandleRequest(self, mockTime):
+ mockTime.return_value = 1234
+ self.services.issue.GetAllIssuesInProject = Mock(return_value=[])
+ self.services.issue.GetCommentsForIssues = Mock(return_value={})
+ self.services.issue_star.LookupItemsStarrers = Mock(return_value={})
+ self.services.user.LookupUserEmails = Mock(
+ return_value={111: 'user1@test.com', 222: 'user2@test.com'})
+
+ self.mr.project_name = self.project.project_name
+ json_data = self.jsonfeed.HandleRequest(self.mr)
+
+ self.assertEqual(json_data['metadata'],
+ {'version': 1, 'who': None, 'when': 1234,
+ 'project': 'proj', 'start': 0, 'num': 100})
+ self.assertEqual(json_data['issues'], [])
+ self.assertItemsEqual(
+ json_data['emails'], ['user1@test.com', 'user2@test.com'])
+
+ # TODO(jojwang): test attachments, amendments, comment details
+ def testMakeIssueJSON(self):
+ config = self.services.config.GetProjectConfig(
+ self.cnxn, 789)
+ config.field_defs.extend(
+ [tracker_pb2.FieldDef(
+ field_id=1, field_name='UXReview',
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+ tracker_pb2.FieldDef(
+ field_id=2, field_name='approvalsubfield',
+ field_type=tracker_pb2.FieldTypes.STR_TYPE, approval_id=1),
+ tracker_pb2.FieldDef(
+ field_id=3, field_name='phasefield',
+ field_type=tracker_pb2.FieldTypes.INT_TYPE, is_phase_field=True),
+ tracker_pb2.FieldDef(
+ field_id=4, field_name='normalfield',
+ field_type=tracker_pb2.FieldTypes.STR_TYPE)
+ ])
+ self.services.config.StoreConfig(self.cnxn, config)
+
+ phases = [tracker_pb2.Phase(phase_id=1, name='Phase1', rank=1),
+ tracker_pb2.Phase(phase_id=2, name='Phase2', rank=2)]
+ avs = [tracker_pb2.ApprovalValue(
+ approval_id=1, status=tracker_pb2.ApprovalStatus.APPROVED,
+ setter_id=111, set_on=7, approver_ids=[333, 444], phase_id=1)]
+ fvs = [tracker_pb2.FieldValue(field_id=2, str_value='two'),
+ tracker_pb2.FieldValue(field_id=3, int_value=3, phase_id=2),
+ tracker_pb2.FieldValue(field_id=4, str_value='four')]
+ labels = ['test', 'Type-FLT-Launch']
+
+ issue = fake.MakeTestIssue(
+ self.project.project_id, 1, 'summary', 'Open', 111, labels=labels,
+ issue_id=78901, reporter_id=222, opened_timestamp=1,
+ closed_timestamp=2, modified_timestamp=3, project_name='project',
+ field_values=fvs, phases=phases, approval_values=avs)
+
+ email_dict = {111: 'user1@test.com', 222: 'user2@test.com',
+ 333: 'user3@test.com', 444: 'user4@test.com'}
+ comment_list = [
+ tracker_pb2.IssueComment(content='simple'),
+ tracker_pb2.IssueComment(
+ content='issue desc', is_description=True)]
+ starrer_id_list = [222, 333]
+
+ issue_JSON = self.jsonfeed._MakeIssueJSON(
+ self.mr, issue, email_dict, comment_list, starrer_id_list)
+ expected_JSON = {
+ 'local_id': 1,
+ 'reporter': 'user2@test.com',
+ 'summary': 'summary',
+ 'owner': 'user1@test.com',
+ 'status': 'Open',
+ 'cc': [],
+ 'labels': labels,
+ 'phases': [{'id': 1, 'name': 'Phase1', 'rank': 1},
+ {'id': 2, 'name': 'Phase2', 'rank': 2}],
+ 'fields': [
+ {'field': 'approvalsubfield',
+ 'phase': None,
+ 'approval': 'UXReview',
+ 'str_value': 'two'},
+ {'field': 'phasefield',
+ 'phase': 'Phase2',
+ 'int_value': 3},
+ {'field': 'normalfield',
+ 'phase': None,
+ 'str_value': 'four'}],
+ 'approvals': [
+ {'approval': 'UXReview',
+ 'status': 'APPROVED',
+ 'setter': 'user1@test.com',
+ 'set_on': 7,
+ 'approvers': ['user3@test.com', 'user4@test.com'],
+ 'phase': 'Phase1'}
+ ],
+ 'starrers': ['user2@test.com', 'user3@test.com'],
+ 'comments': [
+ {'content': 'simple',
+ 'timestamp': None,
+ 'amendments': [],
+ 'commenter': None,
+ 'attachments': [],
+ 'description_num': None},
+ {'content': 'issue desc',
+ 'timestamp': None,
+ 'amendments': [],
+ 'commenter': None,
+ 'attachments': [],
+ 'description_num': '1'},
+ ],
+ 'opened': 1,
+ 'modified': 3,
+ 'closed': 2,
+ }
+
+ self.assertEqual(expected_JSON, issue_JSON)
diff --git a/tracker/test/issueimport_test.py b/tracker/test/issueimport_test.py
new file mode 100644
index 0000000..c0e38af
--- /dev/null
+++ b/tracker/test/issueimport_test.py
@@ -0,0 +1,69 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for the issueimport servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import permissions
+from services import service_manager
+from testing import testing_helpers
+from tracker import issueimport
+from proto import tracker_pb2
+
+
+class IssueExportTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services()
+ self.servlet = issueimport.IssueImport(
+ 'req', 'res', services=self.services)
+ self.event_log = None
+
+ def testAssertBasePermission(self):
+ """Only site admins can import issues."""
+ mr = testing_helpers.MakeMonorailRequest(
+ perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+ self.assertRaises(permissions.PermissionException,
+ self.servlet.AssertBasePermission, mr)
+ mr.auth.user_pb.is_site_admin = True
+ self.servlet.AssertBasePermission(mr)
+
+ def testParseComment(self):
+ """Test a Comment JSON is correctly parsed."""
+ users_id_dict = {'adam@test.com': 111}
+ json = {
+ 'timestamp': 123,
+ 'commenter': 'adam@test.com',
+ 'content': 'so basically, what I was thinkig of',
+ 'amendments': [],
+ 'attachments': [],
+ 'description_num': None,
+ }
+ comment = self.servlet._ParseComment(
+ 12, users_id_dict, json, self.event_log)
+ self.assertEqual(
+ comment, tracker_pb2.IssueComment(
+ project_id=12, timestamp=123, user_id=111,
+ content='so basically, what I was thinkig of'))
+
+ json_desc = {
+ 'timestamp': 223,
+ 'commenter': 'adam@test.com',
+ 'content': 'I cant believe youve done this',
+ 'description_num': '2',
+ 'amendments': [],
+ 'attachments': [],
+ }
+ desc_comment = self.servlet._ParseComment(
+ 12, users_id_dict, json_desc, self.event_log)
+ self.assertEqual(
+ desc_comment, tracker_pb2.IssueComment(
+ project_id=12, timestamp=223, user_id=111,
+ content='I cant believe youve done this',
+ is_description=True))
diff --git a/tracker/test/issueoriginal_test.py b/tracker/test/issueoriginal_test.py
new file mode 100644
index 0000000..1b2b7d6
--- /dev/null
+++ b/tracker/test/issueoriginal_test.py
@@ -0,0 +1,226 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for the issueoriginal module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+
+import webapp2
+
+from framework import exceptions
+from framework import framework_helpers
+from framework import monorailrequest
+from framework import permissions
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import issueoriginal
+
+
+STRIPPED_MSG = 'Are you sure that it is plugged in?\n'
+ORIG_MSG = ('Are you sure that it is plugged in?\n'
+ '\n'
+ '> Issue 1 entered by user foo:\n'
+ '> http://blah blah\n'
+ '> The screen is just dark when I press power on\n')
+XXX_GOOD_UNICODE_MSG = u'Thanks,\n\342\230\206*username*'.encode('utf-8')
+GOOD_UNICODE_MSG = u'Thanks,\n XXX *username*'
+XXX_BAD_UNICODE_MSG = ORIG_MSG + ('\xff' * 1000)
+BAD_UNICODE_MSG = ORIG_MSG + 'XXX'
+GMAIL_CRUFT_MSG = ORIG_MSG # XXX .replace(' ', ' \xa0 ')
+
+
+class IssueOriginalTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ user=fake.UserService())
+ self.servlet = issueoriginal.IssueOriginal(
+ 'req', 'res', services=self.services)
+
+ self.proj = self.services.project.TestAddProject('proj', project_id=789)
+ summary = 'System wont boot'
+ status = 'New'
+ cnxn = 'fake connection'
+ self.services.user.TestAddUser('commenter@example.com', 222)
+
+ created_issue_1 = fake.MakeTestIssue(
+ 789, 1, summary, status, 111, reporter_id=111)
+ self.services.issue.TestAddIssue(created_issue_1)
+ self.local_id_1 = created_issue_1.local_id
+ comment_0 = tracker_pb2.IssueComment(
+ issue_id=created_issue_1.issue_id,
+ user_id=222,
+ project_id=789,
+ content=STRIPPED_MSG,
+ inbound_message=ORIG_MSG)
+ self.services.issue.InsertComment(cnxn, comment_0)
+ comment_1 = tracker_pb2.IssueComment(
+ issue_id=created_issue_1.issue_id,
+ user_id=222,
+ project_id=789,
+ content=STRIPPED_MSG,
+ inbound_message=BAD_UNICODE_MSG)
+ self.services.issue.InsertComment(cnxn, comment_1)
+ comment_2 = tracker_pb2.IssueComment(
+ issue_id=created_issue_1.issue_id,
+ user_id=222,
+ project_id=789,
+ content=STRIPPED_MSG,
+ inbound_message=GMAIL_CRUFT_MSG)
+ self.services.issue.InsertComment(cnxn, comment_2)
+ comment_3 = tracker_pb2.IssueComment(
+ issue_id=created_issue_1.issue_id,
+ user_id=222,
+ project_id=789,
+ content=STRIPPED_MSG,
+ inbound_message=GOOD_UNICODE_MSG)
+ self.services.issue.InsertComment(cnxn, comment_3)
+ self.issue_1 = self.services.issue.GetIssueByLocalID(
+ cnxn, 789, self.local_id_1)
+ self.comments = [comment_0, comment_1, comment_2, comment_3]
+
+ @mock.patch('framework.permissions.GetPermissions')
+ def testAssertBasePermission(self, mock_getpermissions):
+ """Permit users who can view issue, view inbound message and delete."""
+ _request, mr = testing_helpers.GetRequestObjects(
+ path='/p/proj/issues/original?id=1&seq=1',
+ project=self.proj)
+
+ # Allow the user to view the issue itself.
+ mock_getpermissions.return_value = (
+ permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+
+ # Someone without VIEW permission cannot view the inbound email.
+ mr.perms = permissions.EMPTY_PERMISSIONSET
+ self.assertRaises(permissions.PermissionException,
+ self.servlet.AssertBasePermission, mr)
+
+ # Contributors don't have VIEW_INBOUND_MESSAGES.
+ mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+ self.assertRaises(permissions.PermissionException,
+ self.servlet.AssertBasePermission, mr)
+
+ # Committers do have VIEW_INBOUND_MESSAGES.
+ mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+ self.servlet.AssertBasePermission(mr)
+
+ # But, a committer cannot use that if they cannot view the issue.
+ self.issue_1.labels.append('Restrict-View-Foo')
+ mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+ self.assertRaises(permissions.PermissionException,
+ self.servlet.AssertBasePermission, mr)
+
+ # Project owners have VIEW_INBOUND_MESSAGES and bypass restrictions.
+ mock_getpermissions.return_value = (
+ permissions.OWNER_ACTIVE_PERMISSIONSET)
+ mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+ self.servlet.AssertBasePermission(mr)
+
+ def testGatherPageData_Normal(self):
+ _request, mr = testing_helpers.GetRequestObjects(
+ path='/p/proj/issues/original?id=1&seq=1',
+ project=self.proj)
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertEqual(1, page_data['local_id'])
+ self.assertEqual(1, page_data['seq'])
+ self.assertFalse(page_data['is_binary'])
+ self.assertEqual(ORIG_MSG, page_data['message_body'])
+
+ def testGatherPageData_GoodUnicode(self):
+ _request, mr = testing_helpers.GetRequestObjects(
+ path='/p/proj/issues/original?id=1&seq=4',
+ project=self.proj)
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertEqual(1, page_data['local_id'])
+ self.assertEqual(4, page_data['seq'])
+ self.assertEqual(GOOD_UNICODE_MSG, page_data['message_body'])
+ self.assertFalse(page_data['is_binary'])
+
+ def testGatherPageData_BadUnicode(self):
+ _request, mr = testing_helpers.GetRequestObjects(
+ path='/p/proj/issues/original?id=1&seq=2',
+ project=self.proj)
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertEqual(1, page_data['local_id'])
+ self.assertEqual(2, page_data['seq'])
+ # xxx: should be true if cruft was there.
+ # self.assertTrue(page_data['is_binary'])
+
+ def testGatherPageData_GmailCruft(self):
+ _request, mr = testing_helpers.GetRequestObjects(
+ path='/p/proj/issues/original?id=1&seq=3',
+ project=self.proj)
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertEqual(1, page_data['local_id'])
+ self.assertEqual(3, page_data['seq'])
+ self.assertFalse(page_data['is_binary'])
+ self.assertEqual(ORIG_MSG, page_data['message_body'])
+
+ def testGatherPageData_404(self):
+ _request, mr = testing_helpers.GetRequestObjects(
+ path='/p/proj/issues/original',
+ project=self.proj)
+ with self.assertRaises(webapp2.HTTPException) as cm:
+ self.servlet.GatherPageData(mr)
+ self.assertEqual(404, cm.exception.code)
+
+ _request, mr = testing_helpers.GetRequestObjects(
+ path='/p/proj/issues/original?id=1&seq=999',
+ project=self.proj)
+ with self.assertRaises(webapp2.HTTPException) as cm:
+ self.servlet.GatherPageData(mr)
+ self.assertEqual(404, cm.exception.code)
+
+ _request, mr = testing_helpers.GetRequestObjects(
+ path='/p/proj/issues/original?id=999&seq=1',
+ project=self.proj)
+ with self.assertRaises(exceptions.NoSuchIssueException) as cm:
+ self.servlet.GatherPageData(mr)
+
+ def testGetIssueAndComment_Normal(self):
+ _request, mr = testing_helpers.GetRequestObjects(
+ path='/p/proj/issues/original?id=1&seq=1',
+ project=self.proj)
+ issue, comment = self.servlet._GetIssueAndComment(mr)
+ self.assertEqual(self.issue_1, issue)
+ self.assertEqual(self.comments[1].content, comment.content)
+
+ def testGetIssueAndComment_NoSuchComment(self):
+ _request, mr = testing_helpers.GetRequestObjects(
+ path='/p/proj/issues/original?id=1&seq=99',
+ project=self.proj)
+ with self.assertRaises(webapp2.HTTPException) as cm:
+ self.servlet._GetIssueAndComment(mr)
+ self.assertEqual(404, cm.exception.code)
+
+ def testGetIssueAndComment_Malformed(self):
+ _request, mr = testing_helpers.GetRequestObjects(
+ path='/p/proj/issues/original',
+ project=self.proj)
+ with self.assertRaises(webapp2.HTTPException) as cm:
+ self.servlet._GetIssueAndComment(mr)
+ self.assertEqual(404, cm.exception.code)
+
+ _request, mr = testing_helpers.GetRequestObjects(
+ path='/p/proj/issues/original?id=1',
+ project=self.proj)
+ with self.assertRaises(webapp2.HTTPException) as cm:
+ self.servlet._GetIssueAndComment(mr)
+ self.assertEqual(404, cm.exception.code)
+
+ _request, mr = testing_helpers.GetRequestObjects(
+ path='/p/proj/issues/original?seq=1',
+ project=self.proj)
+ with self.assertRaises(exceptions.NoSuchIssueException) as cm:
+ self.servlet._GetIssueAndComment(mr)
diff --git a/tracker/test/issuereindex_test.py b/tracker/test/issuereindex_test.py
new file mode 100644
index 0000000..fe033b8
--- /dev/null
+++ b/tracker/test/issuereindex_test.py
@@ -0,0 +1,124 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.tracker.issuereindex."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+
+import settings
+from framework import permissions
+from framework import template_helpers
+from services import service_manager
+from services import tracker_fulltext
+from testing import fake
+from testing import testing_helpers
+from tracker import issuereindex
+
+
+class IssueReindexTest(unittest.TestCase):
+
+ def setUp(self):
+ self.cnxn = 'fake cnxn'
+ self.services = service_manager.Services(
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ user=fake.UserService(),
+ project=fake.ProjectService())
+ self.project = self.services.project.TestAddProject('proj', project_id=987)
+ self.mox = mox.Mox()
+
+ def tearDown(self):
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+
+ def testAssertBasePermission_NoAccess(self):
+ # Non-members and contributors do not have permission to view this page.
+ for permission in (permissions.USER_PERMISSIONSET,
+ permissions.COMMITTER_ACTIVE_PERMISSIONSET):
+ request, mr = testing_helpers.GetRequestObjects(
+ project=self.project, perms=permission)
+ servlet = issuereindex.IssueReindex(
+ request, 'res', services=self.services)
+ with self.assertRaises(permissions.PermissionException) as cm:
+ servlet.AssertBasePermission(mr)
+ self.assertEqual('You are not allowed to administer this project',
+ cm.exception.message)
+
+ def testAssertBasePermission_WithAccess(self):
+ # Owners and admins have permission to view this page.
+ for permission in (permissions.OWNER_ACTIVE_PERMISSIONSET,
+ permissions.ADMIN_PERMISSIONSET):
+ request, mr = testing_helpers.GetRequestObjects(
+ project=self.project, perms=permission)
+ servlet = issuereindex.IssueReindex(
+ request, 'res', services=self.services)
+ servlet.AssertBasePermission(mr)
+
+ def testGatherPageData(self):
+ servlet = issuereindex.IssueReindex('req', 'res', services=self.services)
+
+ mr = testing_helpers.MakeMonorailRequest()
+ mr.auto_submit = True
+ ret = servlet.GatherPageData(mr)
+
+ self.assertTrue(ret['auto_submit'])
+ self.assertIsNone(ret['issue_tab_mode'])
+ self.assertTrue(ret['page_perms'].CreateIssue)
+
+ def _callProcessFormData(self, post_data, index_issue_1=True):
+ servlet = issuereindex.IssueReindex('req', 'res', services=self.services)
+
+ mr = testing_helpers.MakeMonorailRequest(project=self.project)
+ mr.cnxn = self.cnxn
+
+ issue1 = fake.MakeTestIssue(
+ project_id=self.project.project_id, local_id=1, summary='sum',
+ status='New', owner_id=111)
+ issue1.project_name = self.project.project_name
+ self.services.issue.TestAddIssue(issue1)
+
+ self.mox.StubOutWithMock(tracker_fulltext, 'IndexIssues')
+ if index_issue_1:
+ tracker_fulltext.IndexIssues(
+ self.cnxn, [issue1], self.services.user, self.services.issue,
+ self.services.config)
+
+ self.mox.ReplayAll()
+
+ ret = servlet.ProcessFormData(mr, post_data)
+ self.mox.VerifyAll()
+ return ret
+
+ def testProcessFormData_NormalInputs(self):
+ post_data = {'start': 1, 'num': 5}
+ ret = self._callProcessFormData(post_data)
+ self.assertEqual(
+ '/p/None/issues/reindex?start=6&auto_submit=False&num=5', ret)
+
+ def testProcessFormData_LargeInputs(self):
+ post_data = {'start': 0, 'num': 10000000}
+ ret = self._callProcessFormData(post_data)
+ self.assertEqual(
+ '/p/None/issues/reindex?start=%s&auto_submit=False&num=%s' % (
+ settings.max_artifact_search_results_per_page,
+ settings.max_artifact_search_results_per_page), ret)
+
+ def testProcessFormData_WithAutoSubmit(self):
+ post_data = {'start': 1, 'num': 5, 'auto_submit': 1}
+ ret = self._callProcessFormData(post_data)
+ self.assertEqual(
+ '/p/None/issues/reindex?start=6&auto_submit=True&num=5', ret)
+
+ def testProcessFormData_WithAutoSubmitButNoMoreIssues(self):
+ """This project has no issues 6-10, so stop autosubmitting."""
+ post_data = {'start': 6, 'num': 5, 'auto_submit': 1}
+ ret = self._callProcessFormData(post_data, index_issue_1=False)
+ self.assertEqual(
+ '/p/None/issues/reindex?start=11&auto_submit=False&num=5', ret)
diff --git a/tracker/test/issuetips_test.py b/tracker/test/issuetips_test.py
new file mode 100644
index 0000000..44f5f70
--- /dev/null
+++ b/tracker/test/issuetips_test.py
@@ -0,0 +1,33 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for issuetips module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import issuetips
+
+
+class IssueTipsTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ user=fake.UserService(),
+ project=fake.ProjectService())
+ self.servlet = issuetips.IssueSearchTips(
+ 'req', 'res', services=self.services)
+
+ def testGatherPageData(self):
+ mr = testing_helpers.MakeMonorailRequest(path='/p/proj/issues/tips')
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertEqual('issueSearchTips', page_data['issue_tab_mode'])
diff --git a/tracker/test/rerank_helpers_test.py b/tracker/test/rerank_helpers_test.py
new file mode 100644
index 0000000..47ddd47
--- /dev/null
+++ b/tracker/test/rerank_helpers_test.py
@@ -0,0 +1,135 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.tracker.rerank_helpers."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import exceptions
+from testing import fake
+from tracker import rerank_helpers
+
+
+rerank_helpers.MAX_RANKING = 10
+
+
+class Rerank_HelpersTest(unittest.TestCase):
+
+ def setUp(self):
+ self.PAST_TIME = 12345
+ hotlist_item_fields = [
+ (78904, 31, 111, self.PAST_TIME, 'note'),
+ (78903, 21, 222, self.PAST_TIME, 'note'),
+ (78902, 11, 111, self.PAST_TIME, 'note'),
+ (78901, 1, 222, self.PAST_TIME, 'note')]
+ self.hotlist = fake.Hotlist(
+ 'hotlist_name', 1234, hotlist_item_fields=hotlist_item_fields)
+
+ # Tested in tests for RerankHotlistItems.
+ def testGetHotlistRerankChanges_FirstPosition(self):
+ moved_issue_ids = [78903, 78902]
+ target_position = 0
+ changed_ranks = rerank_helpers.GetHotlistRerankChanges(
+ self.hotlist.items, moved_issue_ids, target_position)
+ self.assertEqual(changed_ranks, [(78903, 5), (78902, 15), (78901, 25)])
+
+ def testGetHotlistRerankChanges_LastPosition(self):
+ moved_issue_ids = [78903, 78902]
+ target_position = 2
+ changed_ranks = rerank_helpers.GetHotlistRerankChanges(
+ self.hotlist.items, moved_issue_ids, target_position)
+ self.assertEqual(changed_ranks, [(78904, 3), (78903, 6), (78902, 9)])
+
+ def testGetHotlistRerankChanges_Middle(self):
+ moved_issue_ids = [78903]
+ target_position = 1
+ changed_ranks = rerank_helpers.GetHotlistRerankChanges(
+ self.hotlist.items, moved_issue_ids, target_position)
+ self.assertEqual(changed_ranks, [(78903, 6)])
+
+
+ def testGetHotlistRerankChanges_NewMoveIds(self):
+ "We can handle reranking for inserting new issues."
+ moved_issue_ids = [78909, 78910, 78903]
+ target_position = 0
+ changed_ranks = rerank_helpers.GetHotlistRerankChanges(
+ self.hotlist.items, moved_issue_ids, target_position)
+ self.assertEqual(
+ changed_ranks, [(78909, 1), (78910, 3), (78903, 5), (78901, 7)])
+
+ def testGetHotlistRerankChanges_InvalidMovedIds(self):
+ moved_issue_ids = [78903]
+ target_position = -1
+ with self.assertRaises(exceptions.InputException):
+ rerank_helpers.GetHotlistRerankChanges(
+ self.hotlist.items, moved_issue_ids, target_position)
+
+ def testGetHotlistRerankChanges_InvalidPosition(self):
+ moved_issue_ids = [78909]
+ target_position = 8
+ with self.assertRaises(exceptions.InputException):
+ rerank_helpers.GetHotlistRerankChanges(
+ self.hotlist.items, moved_issue_ids, target_position)
+
+ def testGetInsertRankings(self):
+ lower = [(1, 0)]
+ higher = [(2, 10)]
+ moved_ids = [3]
+ ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+ self.assertEqual(ret, [(3, 5)])
+
+ def testGetInsertRankings_Below(self):
+ lower = []
+ higher = [(1, 2)]
+ moved_ids = [2]
+ ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+ self.assertEqual(ret, [(2, 1)])
+
+ def testGetInsertRankings_Above(self):
+ lower = [(1, 0)]
+ higher = []
+ moved_ids = [2]
+ ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+ self.assertEqual(ret, [(2, 5)])
+
+ def testGetInsertRankings_Multiple(self):
+ lower = [(1, 0)]
+ higher = [(2, 10)]
+ moved_ids = [3,4,5]
+ ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+ self.assertEqual(ret, [(3, 2), (4, 5), (5, 8)])
+
+ def testGetInsertRankings_SplitLow(self):
+ lower = [(1, 0), (2, 5)]
+ higher = [(3, 6), (4, 10)]
+ moved_ids = [5]
+ ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+ self.assertEqual(ret, [(2, 2), (5, 5)])
+
+ def testGetInsertRankings_SplitHigh(self):
+ lower = [(1, 0), (2, 4)]
+ higher = [(3, 5), (4, 10)]
+ moved_ids = [5]
+ ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+ self.assertEqual(ret, [(5, 6), (3, 9)])
+
+ def testGetInsertRankings_NoLower(self):
+ lower = []
+ higher = [(1, 1)]
+ moved_ids = [2]
+ ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+ self.assertEqual(ret, [(2, 3), (1, 8)])
+
+ def testGetInsertRankings_NoRoom(self):
+ max_ranking, rerank_helpers.MAX_RANKING = rerank_helpers.MAX_RANKING, 1
+ lower = [(1, 0)]
+ higher = [(2, 1)]
+ moved_ids = [3]
+ ret = rerank_helpers.GetInsertRankings(lower, higher, moved_ids)
+ self.assertIsNone(ret)
+ rerank_helpers.MAX_RANKING = max_ranking
diff --git a/tracker/test/tablecell_test.py b/tracker/test/tablecell_test.py
new file mode 100644
index 0000000..c8b7292
--- /dev/null
+++ b/tracker/test/tablecell_test.py
@@ -0,0 +1,491 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for issuelist module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import time
+import unittest
+
+from framework import framework_constants
+from framework import table_view_helpers
+from framework import template_helpers
+from proto import tracker_pb2
+from testing import fake
+from testing import testing_helpers
+from tracker import tablecell
+from tracker import tracker_bizobj
+
+
+class DisplayNameMock(object):
+
+ def __init__(self, name):
+ self.display_name = name
+ self.user = None
+
+
+def MakeTestIssue(local_id, issue_id, summary, status=None):
+ issue = tracker_pb2.Issue()
+ issue.local_id = local_id
+ issue.issue_id = issue_id
+ issue.summary = summary
+ if status:
+ issue.status = status
+ return issue
+
+
+class TableCellUnitTest(unittest.TestCase):
+
+ USERS_BY_ID = {
+ 23456: DisplayNameMock('Jason'),
+ 34567: DisplayNameMock('Nathan'),
+ }
+
+ def setUp(self):
+ self.issue1 = MakeTestIssue(
+ local_id=1, issue_id=100001, summary='One', status="New")
+ self.issue2 = MakeTestIssue(
+ local_id=2, issue_id=100002, summary='Two', status="Fixed")
+ self.issue3 = MakeTestIssue(
+ local_id=3, issue_id=100003, summary='Three', status="UndefinedString")
+ self.issue5 = MakeTestIssue(
+ local_id=5, issue_id=100005, summary='FiveUnviewable', status="Fixed")
+ self.table_cell_kws = {
+ 'col': None,
+ 'users_by_id': self.USERS_BY_ID,
+ 'non_col_labels': [],
+ 'label_values': {},
+ 'related_issues': {},
+ 'config': tracker_bizobj.MakeDefaultProjectIssueConfig(678),
+ 'viewable_iids_set': {100001, 100002, 100003}
+ }
+
+ def testTableCellNote(self):
+ table_cell_kws = self.table_cell_kws.copy()
+ table_cell_kws.update({'note': ''})
+ cell = tablecell.TableCellNote(
+ self.issue1, **table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_NOTE)
+ self.assertEqual(cell.values, [])
+
+ def testTableCellNote_NoNote(self):
+ table_cell_kws = self.table_cell_kws.copy()
+ table_cell_kws.update({'note': 'some note'})
+ cell = tablecell.TableCellNote(
+ self.issue1, **table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_NOTE)
+ self.assertEqual(cell.values[0].item, 'some note')
+
+ def testTableCellDateAdded(self):
+ table_cell_kws = self.table_cell_kws.copy()
+ table_cell_kws.update({'date_added': 1234})
+ cell = tablecell.TableCellDateAdded(
+ self.issue1, **table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+ self.assertEqual(cell.values[0].item, 1234)
+
+ def testTableCellAdderID(self):
+ table_cell_kws = self.table_cell_kws.copy()
+ table_cell_kws.update({'adder_id': 23456})
+ cell = tablecell.TableCellAdderID(
+ self.issue1, **table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+ self.assertEqual(cell.values[0].item, 'Jason')
+
+ def testTableCellRank(self):
+ table_cell_kws = self.table_cell_kws.copy()
+ table_cell_kws.update({'issue_rank': 3})
+ cell = tablecell.TableCellRank(
+ self.issue1, **table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+ self.assertEqual(cell.values[0].item, 3)
+
+ def testTableCellID(self):
+ cell = tablecell.TableCellID(
+ MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ID)
+ # Note that the ID itself is accessed from the row, not the cell.
+
+ def testTableCellOwner(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.owner_id=23456
+
+ cell = tablecell.TableCellOwner(
+ test_issue, **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+ self.assertEqual(cell.values[0].item, 'Jason')
+
+ def testTableCellOwnerNoOwner(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.owner_id=framework_constants.NO_USER_SPECIFIED
+
+ cell = tablecell.TableCellOwner(
+ test_issue, **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+ self.assertEqual(cell.values, [])
+
+ def testTableCellReporter(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.reporter_id=34567
+
+ cell = tablecell.TableCellReporter(
+ test_issue, **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+ self.assertEqual(cell.values[0].item, 'Nathan')
+
+ def testTableCellCc(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.cc_ids = [23456, 34567]
+
+ cell = tablecell.TableCellCc(
+ test_issue, **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+ self.assertEqual(cell.values[0].item, 'Jason')
+ self.assertEqual(cell.values[1].item, 'Nathan')
+
+ def testTableCellCcNoCcs(self):
+ cell = tablecell.TableCellCc(
+ MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+ self.assertEqual(cell.values, [])
+
+ def testTableCellAttachmentsNone(self):
+ cell = tablecell.TableCellAttachments(
+ MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+ self.assertEqual(cell.values[0].item, 0)
+
+ def testTableCellAttachments(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.attachment_count = 2
+
+ cell = tablecell.TableCellAttachments(
+ test_issue, **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+ self.assertEqual(cell.values[0].item, 2)
+
+ def testTableCellOpened(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.opened_timestamp = 1200000000
+
+ cell = tablecell.TableCellOpened(
+ test_issue, **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values[0].item, 'Jan 2008')
+
+ def testTableCellClosed(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.closed_timestamp = None
+
+ cell = tablecell.TableCellClosed(
+ test_issue, **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values, [])
+
+ test_issue.closed_timestamp = 1200000000
+ cell = tablecell.TableCellClosed(
+ test_issue, **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values[0].item, 'Jan 2008')
+
+ def testTableCellModified(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.modified_timestamp = None
+
+ cell = tablecell.TableCellModified(
+ test_issue, **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values, [])
+
+ test_issue.modified_timestamp = 1200000000
+ cell = tablecell.TableCellModified(
+ test_issue, **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values[0].item, 'Jan 2008')
+
+ def testTableCellOwnerLastVisit(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.owner_id = None
+
+ cell = tablecell.TableCellOwnerLastVisit(
+ test_issue, **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values, [])
+
+ test_issue.owner_id = 23456
+ self.USERS_BY_ID[23456].user = testing_helpers.Blank(last_visit_timestamp=0)
+ cell = tablecell.TableCellOwnerLastVisit(
+ test_issue, **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values, [])
+
+ self.USERS_BY_ID[23456].user.last_visit_timestamp = int(time.time())
+ cell = tablecell.TableCellOwnerLastVisit(
+ test_issue, **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values[0].item, 'Today')
+
+ self.USERS_BY_ID[23456].user.last_visit_timestamp = (
+ int(time.time()) - 25 * framework_constants.SECS_PER_HOUR)
+ cell = tablecell.TableCellOwnerLastVisit(
+ test_issue, **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values[0].item, 'Yesterday')
+
+ def testTableCellBlockedOn(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.blocked_on_iids = [
+ self.issue1.issue_id, self.issue2.issue_id, self.issue3.issue_id,
+ self.issue5.issue_id]
+ table_cell_kws = self.table_cell_kws.copy()
+ table_cell_kws['related_issues'] = {
+ self.issue1.issue_id: self.issue1, self.issue2.issue_id: self.issue2,
+ self.issue3.issue_id: self.issue3, self.issue5.issue_id: self.issue5}
+
+ cell = tablecell.TableCellBlockedOn(
+ test_issue, **table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+ self.assertEqual(
+ [x.item for x in cell.values], [
+ template_helpers.EZTItem(
+ href='/p/None/issues/detail?id=1',
+ id='1',
+ closed=None,
+ title='One'),
+ template_helpers.EZTItem(
+ href='/p/None/issues/detail?id=3',
+ id='3',
+ closed=None,
+ title='Three'),
+ template_helpers.EZTItem(
+ href='/p/None/issues/detail?id=5',
+ id='5',
+ closed=None,
+ title=''),
+ template_helpers.EZTItem(
+ href='/p/None/issues/detail?id=2',
+ id='2',
+ closed='yes',
+ title='Two')
+ ])
+
+ def testTableCellBlockedOnNone(self):
+ cell = tablecell.TableCellBlockedOn(
+ MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+ self.assertEqual(cell.values, [])
+
+ def testTableCellBlocking(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.blocking_iids = [
+ self.issue1.issue_id, self.issue2.issue_id, self.issue3.issue_id,
+ self.issue5.issue_id]
+ table_cell_kws = self.table_cell_kws.copy()
+ table_cell_kws['related_issues'] = {
+ self.issue1.issue_id: self.issue1, self.issue2.issue_id: self.issue2,
+ self.issue3.issue_id: self.issue3, self.issue5.issue_id: self.issue5}
+
+ cell = tablecell.TableCellBlocking(
+ test_issue, **table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+ self.assertEqual(
+ [x.item for x in cell.values], [
+ template_helpers.EZTItem(
+ href='/p/None/issues/detail?id=1',
+ id='1',
+ closed=None,
+ title='One'),
+ template_helpers.EZTItem(
+ href='/p/None/issues/detail?id=3',
+ id='3',
+ closed=None,
+ title='Three'),
+ template_helpers.EZTItem(
+ href='/p/None/issues/detail?id=5',
+ id='5',
+ closed=None,
+ title=''),
+ template_helpers.EZTItem(
+ href='/p/None/issues/detail?id=2',
+ id='2',
+ closed='yes',
+ title='Two')
+ ])
+
+ def testTableCellBlockingNone(self):
+ cell = tablecell.TableCellBlocking(
+ MakeTestIssue(4, 4, 'Four'),
+ **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+ self.assertEqual(cell.values, [])
+
+ def testTableCellBlocked(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.blocked_on_iids = [1, 2, 3]
+
+ cell = tablecell.TableCellBlocked(
+ test_issue, **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+ self.assertEqual(cell.values[0].item, 'Yes')
+
+ def testTableCellBlockedNotBlocked(self):
+ cell = tablecell.TableCellBlocked(
+ MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+ self.assertEqual(cell.values[0].item, 'No')
+
+ def testTableCellMergedInto(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.merged_into = self.issue2.issue_id
+ table_cell_kws = self.table_cell_kws.copy()
+ table_cell_kws['related_issues'] = {self.issue2.issue_id: self.issue2}
+
+ cell = tablecell.TableCellMergedInto(
+ test_issue, **table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+ self.assertEqual(
+ cell.values[0].item,
+ template_helpers.EZTItem(
+ href='/p/None/issues/detail?id=2',
+ id='2',
+ closed='yes',
+ title='Two'))
+
+ def testTableCellMergedIntoUnviewable(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.merged_into = self.issue5.issue_id
+ table_cell_kws = self.table_cell_kws.copy()
+ table_cell_kws['related_issues'] = {self.issue5.issue_id: self.issue5}
+
+ cell = tablecell.TableCellMergedInto(
+ test_issue, **table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+ self.assertEqual(
+ cell.values[0].item,
+ template_helpers.EZTItem(
+ href='/p/None/issues/detail?id=5', id='5', closed=None, title=''))
+
+ def testTableCellMergedIntoNotMerged(self):
+ cell = tablecell.TableCellMergedInto(
+ MakeTestIssue(4, 4, 'Four'), **self.table_cell_kws)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ISSUES)
+ self.assertEqual(cell.values, [])
+
+ def testTableCellAllLabels(self):
+ labels = ['A', 'B', 'C', 'D-E', 'F-G']
+ derived_labels = ['W', 'X', 'Y-Z']
+
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.labels = labels
+ test_issue.derived_labels = derived_labels
+
+ cell = tablecell.TableCellAllLabels(test_issue)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_ATTR)
+ self.assertEqual([v.item for v in cell.values], labels + derived_labels)
+
+
+class TableCellCSVTest(unittest.TestCase):
+
+ USERS_BY_ID = {
+ 23456: DisplayNameMock('Jason'),
+ }
+
+ def testTableCellOpenedTimestamp(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.opened_timestamp = 1200000000
+
+ cell = tablecell.TableCellOpenedTimestamp(test_issue)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values[0].item, 1200000000)
+
+ def testTableCellClosedTimestamp(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.closed_timestamp = None
+
+ cell = tablecell.TableCellClosedTimestamp(test_issue)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values[0].item, 0)
+
+ test_issue.closed_timestamp = 1200000000
+ cell = tablecell.TableCellClosedTimestamp(test_issue)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values[0].item, 1200000000)
+
+ def testTableCellModifiedTimestamp(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.modified_timestamp = 0
+
+ cell = tablecell.TableCellModifiedTimestamp(test_issue)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values[0].item, 0)
+
+ test_issue.modified_timestamp = 1200000000
+ cell = tablecell.TableCellModifiedTimestamp(test_issue)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values[0].item, 1200000000)
+
+ def testTableCellOwnerModifiedTimestamp(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.owner_modified_timestamp = 0
+
+ cell = tablecell.TableCellOwnerModifiedTimestamp(test_issue)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values[0].item, 0)
+
+ test_issue.owner_modified_timestamp = 1200000000
+ cell = tablecell.TableCellOwnerModifiedTimestamp(test_issue)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values[0].item, 1200000000)
+
+ def testTableCellStatusModifiedTimestamp(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.status_modified_timestamp = 0
+
+ cell = tablecell.TableCellStatusModifiedTimestamp(test_issue)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values[0].item, 0)
+
+ test_issue.status_modified_timestamp = 1200000000
+ cell = tablecell.TableCellStatusModifiedTimestamp(test_issue)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values[0].item, 1200000000)
+
+ def testTableCellComponentModifiedTimestamp(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.component_modified_timestamp = 0
+
+ cell = tablecell.TableCellComponentModifiedTimestamp(test_issue)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values[0].item, 0)
+
+ test_issue.component_modified_timestamp = 1200000000
+ cell = tablecell.TableCellComponentModifiedTimestamp(test_issue)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(cell.values[0].item, 1200000000)
+
+ def testTableCellOwnerLastVisitDaysAgo(self):
+ test_issue = MakeTestIssue(4, 4, 'Four')
+ test_issue.owner_id = None
+
+ cell = tablecell.TableCellOwnerLastVisitDaysAgo(
+ test_issue, users_by_id=self.USERS_BY_ID)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(None, cell.values[0].item)
+
+ test_issue.owner_id = 23456
+ self.USERS_BY_ID[23456].user = testing_helpers.Blank(last_visit_timestamp=0)
+ cell = tablecell.TableCellOwnerLastVisitDaysAgo(
+ test_issue, users_by_id=self.USERS_BY_ID)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(None, cell.values[0].item)
+
+ self.USERS_BY_ID[23456].user.last_visit_timestamp = (
+ int(time.time()) - 25 * 60 * 60)
+ cell = tablecell.TableCellOwnerLastVisitDaysAgo(
+ test_issue, users_by_id=self.USERS_BY_ID)
+ self.assertEqual(cell.type, table_view_helpers.CELL_TYPE_UNFILTERABLE)
+ self.assertEqual(1, cell.values[0].item)
diff --git a/tracker/test/template_helpers_test.py b/tracker/test/template_helpers_test.py
new file mode 100644
index 0000000..6c4a034
--- /dev/null
+++ b/tracker/test/template_helpers_test.py
@@ -0,0 +1,355 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for the template helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import unittest
+
+import settings
+
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import template_helpers
+from tracker import tracker_bizobj
+from proto import tracker_pb2
+
+
+class TemplateHelpers(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ user=fake.UserService(),
+ config=fake.ConfigService(),
+ project=fake.ProjectService(),
+ usergroup=fake.UserGroupService())
+ self.project = self.services.project.TestAddProject('proj')
+ self.mr = testing_helpers.MakeMonorailRequest(
+ project=self.project)
+ self.config = self.services.config.GetProjectConfig(
+ 'fake cnxn', self.project.project_id)
+ self.fd_1 = tracker_bizobj.MakeFieldDef(
+ 1, 789, 'UXReview', tracker_pb2.FieldTypes.STR_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+ 'Approval for UX review', False)
+ self.fd_2 = tracker_bizobj.MakeFieldDef(
+ 2, 789, 'UXReview', tracker_pb2.FieldTypes.STR_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+ 'Approval for UX review', False)
+ self.fd_3 = tracker_bizobj.MakeFieldDef(
+ 3, 789, 'UXApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+ 'Approval for UX review', False)
+ self.fd_4 = tracker_bizobj.MakeFieldDef(
+ 4, 789, 'TestApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+ 'Approval for Test review', False)
+ self.fd_5 = tracker_bizobj.MakeFieldDef(
+ 5, 789, 'SomeApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+ 'Approval for Test review', False)
+ self.ad_3 = tracker_pb2.ApprovalDef(approval_id=3)
+ self.ad_4 = tracker_pb2.ApprovalDef(approval_id=4)
+ self.ad_5 = tracker_pb2.ApprovalDef(approval_id=5)
+ self.cd_1 = tracker_bizobj.MakeComponentDef(
+ 1, 789, 'BackEnd', 'doc', False, [111], [], 100000, 222)
+
+ self.services.user.TestAddUser('1@ex.com', 111)
+ self.services.user.TestAddUser('2@ex.com', 222)
+ self.services.user.TestAddUser('3@ex.com', 333)
+ self.services.project.TestAddProjectMembers(
+ [111], self.project, 'OWNER_ROLE')
+
+ def testParseTemplateRequest_Empty(self):
+ post_data = fake.PostData()
+ parsed = template_helpers.ParseTemplateRequest(post_data, self.config)
+ self.assertEqual(parsed.name, '')
+ self.assertFalse(parsed.members_only)
+ self.assertEqual(parsed.summary, '')
+ self.assertFalse(parsed.summary_must_be_edited)
+ self.assertEqual(parsed.content, '')
+ self.assertEqual(parsed.status, '')
+ self.assertEqual(parsed.owner_str, '')
+ self.assertEqual(parsed.labels, [])
+ self.assertEqual(parsed.field_val_strs, {})
+ self.assertEqual(parsed.component_paths, [])
+ self.assertFalse(parsed.component_required)
+ self.assertFalse(parsed.owner_defaults_to_member)
+ self.assertFalse(parsed.add_approvals)
+ self.assertItemsEqual(parsed.phase_names, ['', '', '', '', '', ''])
+ self.assertEqual(parsed.approvals_to_phase_idx, {})
+ self.assertEqual(parsed.required_approval_ids, [])
+
+ def testParseTemplateRequest_Normal(self):
+ self.config.field_defs.extend([self.fd_1, self.fd_2])
+ self.config.approval_defs.extend([self.ad_3, self.ad_4])
+ post_data = fake.PostData(
+ name=['sometemplate'],
+ members_only=['on'],
+ summary=['TLDR'],
+ summary_must_be_edited=['on'],
+ content=['HEY WHY'],
+ status=['Accepted'],
+ owner=['someone@world.com'],
+ label=['label-One', 'label-Two'],
+ custom_1=['NO'],
+ custom_2=['MOOD'],
+ components=['hey, hey2,he3'],
+ component_required=['on'],
+ owner_defaults_to_memeber=['no'],
+ admin_names=['jojwang@test.com, annajo@test.com'],
+ add_approvals=['on'],
+ phase_0=['Canary'],
+ phase_1=['Stable-Exp'],
+ phase_2=['Stable'],
+ phase_3=[''],
+ phase_4=[''],
+ phase_5=['Oops'],
+ approval_3=['phase_2'],
+ approval_4=['no_phase'],
+ approval_3_required=['on'],
+ approval_4_required=['on'],
+ # ignore required cb for omitted approvals
+ approval_5_required=['on']
+ )
+
+ parsed = template_helpers.ParseTemplateRequest(post_data, self.config)
+ self.assertEqual(parsed.name, 'sometemplate')
+ self.assertTrue(parsed.members_only)
+ self.assertEqual(parsed.summary, 'TLDR')
+ self.assertTrue(parsed.summary_must_be_edited)
+ self.assertEqual(parsed.content, 'HEY WHY')
+ self.assertEqual(parsed.status, 'Accepted')
+ self.assertEqual(parsed.owner_str, 'someone@world.com')
+ self.assertEqual(parsed.labels, ['label-One', 'label-Two'])
+ self.assertEqual(parsed.field_val_strs, {1: ['NO'], 2: ['MOOD']})
+ self.assertEqual(parsed.component_paths, ['hey', 'hey2', 'he3'])
+ self.assertTrue(parsed.component_required)
+ self.assertFalse(parsed.owner_defaults_to_member)
+ self.assertTrue(parsed.add_approvals)
+ self.assertEqual(parsed.admin_str, 'jojwang@test.com, annajo@test.com')
+ self.assertItemsEqual(parsed.phase_names,
+ ['Canary', 'Stable-Exp', 'Stable', '', '', 'Oops'])
+ self.assertEqual(parsed.approvals_to_phase_idx, {3: 2, 4: None})
+ self.assertItemsEqual(parsed.required_approval_ids, [3, 4])
+
+ def testGetTemplateInfoFromParsed_Normal(self):
+ self.config.field_defs.extend([self.fd_1, self.fd_2])
+ self.config.component_defs.append(self.cd_1)
+ parsed = template_helpers.ParsedTemplate(
+ 'template', True, 'summary', True, 'content', 'Available',
+ '1@ex.com', ['label1', 'label1'], {1: ['NO'], 2: ['MOOD']},
+ ['BackEnd'], True, True, '2@ex.com', False, [], {}, [])
+ (admin_ids, owner_id, component_ids,
+ field_values, phases,
+ approval_values) = template_helpers.GetTemplateInfoFromParsed(
+ self.mr, self.services, parsed, self.config)
+ self.assertEqual(admin_ids, [222])
+ self.assertEqual(owner_id, 111)
+ self.assertEqual(component_ids, [1])
+ self.assertEqual(field_values[0].str_value, 'NO')
+ self.assertEqual(field_values[1].str_value, 'MOOD')
+ self.assertEqual(phases, [])
+ self.assertEqual(approval_values, [])
+
+ def testGetTemplateInfoFromParsed_Errors(self):
+ self.config.field_defs.extend([self.fd_1, self.fd_2])
+ parsed = template_helpers.ParsedTemplate(
+ 'template', True, 'summary', True, 'content', 'Available',
+ '4@ex.com', ['label1', 'label1'], {1: ['NO'], 2: ['MOOD']},
+ ['BackEnd'], True, True, '2@ex.com', False, [], {}, [])
+ (admin_ids, _owner_id, _component_ids,
+ field_values, phases,
+ approval_values) = template_helpers.GetTemplateInfoFromParsed(
+ self.mr, self.services, parsed, self.config)
+ self.assertEqual(admin_ids, [222])
+ self.assertEqual(field_values[0].str_value, 'NO')
+ self.assertEqual(field_values[1].str_value, 'MOOD')
+ self.assertEqual(self.mr.errors.owner, 'Owner not found.')
+ self.assertEqual(self.mr.errors.components, 'Unknown component BackEnd')
+ self.assertEqual(phases, [])
+ self.assertEqual(approval_values, [])
+
+ def testGetPhasesAndApprovalsFromParsed_Normal(self):
+ self.config.field_defs.extend([self.fd_1, self.fd_2])
+ self.config.approval_defs.extend([self.ad_3, self.ad_4, self.ad_5])
+
+ phase_names = ['Canary', '', 'Stable-Exp', '', '', '']
+ approvals_to_phase_idx = {3: 0, 4: None, 5: 2}
+ required_approval_ids = [3, 5]
+
+ phases, approval_values = template_helpers._GetPhasesAndApprovalsFromParsed(
+ self.mr, phase_names, approvals_to_phase_idx, required_approval_ids)
+ self.assertEqual(len(phases), 2)
+ self.assertEqual(len(approval_values), 3)
+
+ canary = tracker_bizobj.FindPhase('canary', phases)
+ self.assertEqual(canary.rank, 0)
+ av_3 = tracker_bizobj.FindApprovalValueByID(3, approval_values)
+ self.assertEqual(av_3.status, tracker_pb2.ApprovalStatus.NEEDS_REVIEW)
+ self.assertEqual(av_3.phase_id, canary.phase_id)
+
+ av_4 = tracker_bizobj.FindApprovalValueByID(4, approval_values)
+ self.assertEqual(av_4.status, tracker_pb2.ApprovalStatus.NOT_SET)
+ self.assertIsNone(av_4.phase_id)
+
+ stable_exp = tracker_bizobj.FindPhase('stable-exp', phases)
+ self.assertEqual(stable_exp.rank, 2)
+ av_5 = tracker_bizobj.FindApprovalValueByID(5, approval_values)
+ self.assertEqual(av_5.status, tracker_pb2.ApprovalStatus.NEEDS_REVIEW)
+ self.assertEqual(av_5.phase_id, stable_exp.phase_id)
+
+ self.assertIsNone(self.mr.errors.phase_approvals)
+
+ def testGetPhasesAndApprovalsFromParsed_Errors(self):
+ self.config.field_defs.extend([self.fd_1, self.fd_2])
+ self.config.approval_defs.extend([self.ad_3, self.ad_4, self.ad_5])
+ required_approval_ids = []
+
+ phase_names = ['Canary', 'Extra', 'Stable-Exp', '', '', '']
+ approvals_to_phase_idx = {3: 0, 4: None, 5: 2}
+
+ template_helpers._GetPhasesAndApprovalsFromParsed(
+ self.mr, phase_names, approvals_to_phase_idx, required_approval_ids)
+ self.assertEqual(self.mr.errors.phase_approvals,
+ 'Defined gates must have assigned approvals.')
+
+ def testGetPhasesAndApprovalsFromParsed_DupsErrors(self):
+ self.config.field_defs.extend([self.fd_1, self.fd_2])
+ self.config.approval_defs.extend([self.ad_3, self.ad_4, self.ad_5])
+ required_approval_ids = []
+
+ phase_names = ['Canary', 'canary', 'Stable-Exp', '', '', '']
+ approvals_to_phase_idx = {3: 0, 4: None, 5: 2}
+
+ template_helpers._GetPhasesAndApprovalsFromParsed(
+ self.mr, phase_names, approvals_to_phase_idx, required_approval_ids)
+ self.assertEqual(self.mr.errors.phase_approvals,
+ 'Duplicate gate names.')
+
+ def testGetPhasesAndApprovalsFromParsed_InvalidPhaseName(self):
+ self.config.field_defs.extend([self.fd_1, self.fd_2])
+ self.config.approval_defs.extend([self.ad_3, self.ad_4, self.ad_5])
+ required_approval_ids = []
+
+ phase_names = ['Canary', 'A B', 'Stable-Exp', '', '', '']
+ approvals_to_phase_idx = {3: 0, 4: None, 5: 2}
+
+ template_helpers._GetPhasesAndApprovalsFromParsed(
+ self.mr, phase_names, approvals_to_phase_idx, required_approval_ids)
+ self.assertEqual(self.mr.errors.phase_approvals,
+ 'Invalid gate name(s).')
+
+ def testGatherApprovalsPageData(self):
+ self.fd_3.is_deleted = True
+ self.config.field_defs = [self.fd_3, self.fd_4, self.fd_5]
+ approval_values = [
+ tracker_pb2.ApprovalValue(approval_id=3, phase_id=8),
+ tracker_pb2.ApprovalValue(
+ approval_id=4, phase_id=9,
+ status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW),
+ tracker_pb2.ApprovalValue(approval_id=5)
+ ]
+ tmpl_phases = [
+ tracker_pb2.Phase(phase_id=8, rank=1, name='deletednoshow'),
+ tracker_pb2.Phase(phase_id=9, rank=2, name='notdeleted')
+ ]
+
+ (prechecked_approvals, required_approval_ids,
+ phases) = template_helpers.GatherApprovalsPageData(
+ approval_values, tmpl_phases, self.config)
+ self.assertItemsEqual(prechecked_approvals,
+ ['4_phase_0', '5'])
+ self.assertEqual(required_approval_ids, [4])
+ self.assertEqual(phases[0], tmpl_phases[1])
+ self.assertIsNone(phases[1].name)
+ self.assertEqual(len(phases), 6)
+
+ def testGetCheckedApprovalsFromParsed(self):
+ approvals_to_phase_idx = {23: 0, 25: 1, 26: None}
+ checked = template_helpers.GetCheckedApprovalsFromParsed(
+ approvals_to_phase_idx)
+ self.assertItemsEqual(checked,
+ ['23_phase_0', '25_phase_1', '26'])
+
+ def testGetIssueFromTemplate(self):
+ """Can fill and return the templated issue"""
+ expected_fvs = [
+ tracker_pb2.FieldValue(field_id=123, str_value='fv_1_value'),
+ tracker_pb2.FieldValue(field_id=124, str_value='fv_2_value'),
+ ]
+ expected_phases = [
+ tracker_pb2.Phase(phase_id=123, name='phase_1_name', rank=1)
+ ]
+ expected_avs = [
+ tracker_pb2.ApprovalValue(
+ approval_id=1,
+ setter_id=111,
+ set_on=1232352,
+ approver_ids=[111],
+ phase_id=123),
+ ]
+ input_template = tracker_pb2.TemplateDef(
+ summary='expected_summary',
+ owner_id=111,
+ status='expected_status',
+ labels=['expected-label_1, expected-label_2'],
+ field_values=expected_fvs,
+ component_ids=[987],
+ phases=expected_phases,
+ approval_values=expected_avs)
+ reporter_id = 321
+ project_id = 1
+
+ actual = template_helpers.GetIssueFromTemplate(
+ input_template, project_id, reporter_id)
+ expected = tracker_pb2.Issue(
+ project_id=project_id,
+ summary='expected_summary',
+ status='expected_status',
+ owner_id=111,
+ labels=['expected-label_1, expected-label_2'],
+ component_ids=[987],
+ reporter_id=reporter_id,
+ field_values=expected_fvs,
+ phases=expected_phases,
+ approval_values=expected_avs)
+ self.assertEqual(actual, expected)
+
+ def testGetIssueFromTemplate_NoOwner(self):
+ """Uses reporter as owner when owner_defaults_to_member"""
+ input_template = tracker_pb2.TemplateDef(owner_defaults_to_member=False)
+
+ actual = template_helpers.GetIssueFromTemplate(input_template, 1, 1)
+ self.assertEqual(actual.owner_id, None)
+
+ def testGetIssueFromTemplate_DefaultsOwnerToReporter(self):
+ """Uses reporter as owner when owner_defaults_to_member"""
+ input_template = tracker_pb2.TemplateDef(owner_defaults_to_member=True)
+ reporter_id = 321
+
+ actual = template_helpers.GetIssueFromTemplate(
+ input_template, 1, reporter_id)
+ self.assertEqual(actual.owner_id, reporter_id)
+
+ def testGetIssueFromTemplate_SpecifiedOwnerOverridesReporter(self):
+ """Specified owner overrides owner_defaults_to_member"""
+ expected_owner_id = 111
+ input_template = tracker_pb2.TemplateDef(
+ owner_id=expected_owner_id, owner_defaults_to_member=True)
+ reporter_id = 321
+
+ actual = template_helpers.GetIssueFromTemplate(
+ input_template, 1, reporter_id)
+ self.assertEqual(actual.owner_id, expected_owner_id)
diff --git a/tracker/test/templatecreate_test.py b/tracker/test/templatecreate_test.py
new file mode 100644
index 0000000..60db78b
--- /dev/null
+++ b/tracker/test/templatecreate_test.py
@@ -0,0 +1,374 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit test for Template creation servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import unittest
+import settings
+
+from mock import Mock
+
+import ezt
+
+from framework import permissions
+from services import service_manager
+from services import template_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import templatecreate
+from tracker import tracker_bizobj
+from tracker import tracker_views
+from proto import tracker_pb2
+
+
+class TemplateCreateTest(unittest.TestCase):
+ """Tests for the TemplateCreate servlet."""
+
+ def setUp(self):
+ self.cnxn = 'fake cnxn'
+ self.services = service_manager.Services(
+ project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ template=Mock(spec=template_svc.TemplateService),
+ user=fake.UserService())
+ self.servlet = templatecreate.TemplateCreate('req', 'res',
+ services=self.services)
+ self.project = self.services.project.TestAddProject('proj')
+
+ self.fd_1 = tracker_bizobj.MakeFieldDef(
+ 1, self.project.project_id, 'StringFieldName',
+ tracker_pb2.FieldTypes.STR_TYPE, None, '', False,
+ False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+ 'some approval thing', False, approval_id=2)
+
+ self.fd_2 = tracker_bizobj.MakeFieldDef(
+ 2, self.project.project_id, 'UXApproval',
+ tracker_pb2.FieldTypes.APPROVAL_TYPE, None, '', False, False, False,
+ None, None, '', False, '', '', tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action', 'Approval for UX review', False)
+ self.fd_3 = tracker_bizobj.MakeFieldDef(
+ 3, self.project.project_id, 'TestApproval',
+ tracker_pb2.FieldTypes.APPROVAL_TYPE, None, '', False, False, False,
+ None, None, '', False, '', '', tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action', 'Approval for Test review', False)
+ self.fd_4 = tracker_bizobj.MakeFieldDef(
+ 4, self.project.project_id, 'Target',
+ tracker_pb2.FieldTypes.INT_TYPE, None, '', False, False, False, None,
+ None, '', False, '', '', tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+ 'milestone target', False, is_phase_field=True)
+ self.fd_5 = tracker_bizobj.MakeFieldDef(
+ 5,
+ self.project.project_id,
+ 'RestrictedField',
+ tracker_pb2.FieldTypes.INT_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'RestrictedField',
+ False,
+ is_restricted_field=True)
+ self.fd_6 = tracker_bizobj.MakeFieldDef(
+ 6,
+ self.project.project_id,
+ 'RestrictedEnumField',
+ tracker_pb2.FieldTypes.ENUM_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'RestrictedEnumField',
+ False,
+ is_restricted_field=True)
+ ad_2 = tracker_pb2.ApprovalDef(approval_id=2)
+ ad_3 = tracker_pb2.ApprovalDef(approval_id=3)
+
+ self.config = self.services.config.GetProjectConfig(
+ 'fake cnxn', self.project.project_id)
+ self.config.approval_defs.extend([ad_2, ad_3])
+ self.config.field_defs.extend(
+ [self.fd_1, self.fd_2, self.fd_3, self.fd_4, self.fd_5, self.fd_6])
+
+ first_tmpl = tracker_bizobj.MakeIssueTemplate(
+ 'sometemplate', 'summary', None, None, 'content', [], [], [],
+ [])
+ self.services.config.StoreConfig(None, self.config)
+
+ templates = testing_helpers.DefaultTemplates()
+ templates.append(first_tmpl)
+ self.services.template.GetProjectTemplates = Mock(
+ return_value=templates)
+
+ self.mr = testing_helpers.MakeMonorailRequest(
+ project=self.project, perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+ self.mox = mox.Mox()
+
+ def tearDown(self):
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+
+ def testAssertBasePermission(self):
+ # Anon users can never do it
+ self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.AssertBasePermission, self.mr)
+
+ # Project owner can do it.
+ self.mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+ self.servlet.AssertBasePermission(self.mr)
+
+ # Project member cannot do it
+ self.mr.perms = permissions.COMMITTER_ACTIVE_PERMISSIONSET
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.AssertBasePermission, self.mr)
+ self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.AssertBasePermission, self.mr)
+
+ def testGatherPageData(self):
+ precomp_view_info = tracker_views._PrecomputeInfoForValueViews(
+ [], [], [], self.config, [])
+ fv = tracker_views._MakeFieldValueView(
+ self.fd_1, self.config, precomp_view_info, {})
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.assertEqual(self.servlet.PROCESS_TAB_TEMPLATES,
+ page_data['admin_tab_mode'])
+ self.assertTrue(page_data['allow_edit'])
+ self.assertEqual(page_data['uneditable_fields'], ezt.boolean(False))
+ self.assertTrue(page_data['new_template_form'])
+ self.assertFalse(page_data['initial_members_only'])
+ self.assertEqual(page_data['template_name'], '')
+ self.assertEqual(page_data['initial_summary'], '')
+ self.assertFalse(page_data['initial_must_edit_summary'])
+ self.assertEqual(page_data['initial_content'], '')
+ self.assertEqual(page_data['initial_status'], '')
+ self.assertEqual(page_data['initial_owner'], '')
+ self.assertFalse(page_data['initial_owner_defaults_to_member'])
+ self.assertEqual(page_data['initial_components'], '')
+ self.assertFalse(page_data['initial_component_required'])
+ self.assertEqual(page_data['fields'][2].field_name, fv.field_name)
+ self.assertEqual(page_data['initial_admins'], '')
+ self.assertEqual(page_data['approval_subfields_present'], ezt.boolean(True))
+ self.assertEqual(page_data['phase_fields_present'], ezt.boolean(False))
+
+ def testProcessFormData_Reject(self):
+ self.services.user.TestAddUser('user@example.com', 222)
+ self.mr.auth.effective_ids = {222}
+ post_data = fake.PostData(
+ name=['sometemplate'],
+ members_only=['on'],
+ summary=['TLDR'],
+ summary_must_be_edited=['on'],
+ content=['HEY WHY'],
+ status=['Accepted'],
+ owner=['someone@world.com'],
+ label=['label-One', 'label-Two'],
+ custom_1=['NO'],
+ custom_2=['MOOD'],
+ components=['hey, hey2,he3'],
+ component_required=['on'],
+ owner_defaults_to_member=['no'],
+ add_approvals = ['on'],
+ phase_0=['Canary'],
+ phase_1=['Stable-Exp'],
+ phase_2=['Stable'],
+ phase_3=[''],
+ phase_4=[''],
+ phase_5=[''],
+ approval_2=['phase_1'],
+ approval_3=['phase_2']
+ )
+
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(
+ self.mr,
+ initial_members_only=ezt.boolean(True),
+ template_name='sometemplate',
+ initial_content='TLDR',
+ initial_must_edit_summary=ezt.boolean(True),
+ initial_description='HEY WHY',
+ initial_status='Accepted',
+ initial_owner='someone@world.com',
+ initial_owner_defaults_to_member=ezt.boolean(False),
+ initial_components='hey, hey2, he3',
+ initial_component_required=ezt.boolean(True),
+ initial_admins='',
+ labels=['label-One', 'label-Two'],
+ fields=mox.IgnoreArg(),
+ initial_add_approvals=ezt.boolean(True),
+ initial_phases=[tracker_pb2.Phase(name=name) for
+ name in ['Canary', 'Stable-Exp', 'Stable', '', '', '']],
+ approvals=mox.IgnoreArg(),
+ prechecked_approvals=['2_phase_1', '3_phase_2'],
+ required_approval_ids=[]
+ )
+ self.mox.ReplayAll()
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.mox.VerifyAll()
+ self.assertEqual('Owner not found.', self.mr.errors.owner)
+ self.assertEqual('Unknown component he3', self.mr.errors.components)
+ self.assertEqual(
+ 'Template with name sometemplate already exists', self.mr.errors.name)
+ self.assertEqual('Defined gates must have assigned approvals.',
+ self.mr.errors.phase_approvals)
+ self.assertIsNone(url)
+
+ def testProcessFormData_RejectRestrictedFields(self):
+ self.services.template.GetTemplateByName = Mock(return_value=None)
+ self.mr.perms = permissions.PermissionSet([])
+ post_data_add_fv = fake.PostData(
+ name=['secondtemplate'],
+ members_only=['on'],
+ summary=['TLDR'],
+ summary_must_be_edited=['on'],
+ content=['HEY WHY'],
+ status=['Accepted'],
+ label=['label-One', 'label-Two'],
+ custom_1=['Hey'],
+ custom_5=['7'],
+ component_required=['on'],
+ owner_defaults_to_member=['no'],
+ add_approvals=['no'],
+ phase_0=[''],
+ phase_1=[''],
+ phase_2=[''],
+ phase_3=[''],
+ phase_4=[''],
+ phase_5=['OOPs'],
+ approval_2=['phase_0'],
+ approval_3=['phase_2'])
+ post_data_label_edits_enum = fake.PostData(
+ name=['secondtemplate'],
+ members_only=['on'],
+ summary=['TLDR'],
+ summary_must_be_edited=['on'],
+ content=['HEY WHY'],
+ status=['Accepted'],
+ label=['label-One', 'label-Two', 'RestrictedEnumField-7'],
+ component_required=['on'],
+ owner_defaults_to_member=['no'],
+ add_approvals=['no'],
+ phase_0=[''],
+ phase_1=[''],
+ phase_2=[''],
+ phase_3=[''],
+ phase_4=[''],
+ phase_5=['OOPs'],
+ approval_2=['phase_0'],
+ approval_3=['phase_2'])
+
+ self.assertRaises(
+ AssertionError, self.servlet.ProcessFormData, self.mr, post_data_add_fv)
+ self.assertRaises(
+ AssertionError, self.servlet.ProcessFormData, self.mr,
+ post_data_label_edits_enum)
+
+ def testProcessFormData_Accept(self):
+ self.services.user.TestAddUser('user@example.com', 222)
+ self.mr.auth.effective_ids = {222}
+ self.services.template.GetTemplateByName = Mock(return_value=None)
+ post_data = fake.PostData(
+ name=['secondtemplate'],
+ members_only=['on'],
+ summary=['TLDR'],
+ summary_must_be_edited=['on'],
+ content=['HEY WHY'],
+ status=['Accepted'],
+ label=['label-One', 'label-Two', 'RestrictedEnumField-7'],
+ custom_1=['NO'],
+ custom_5=['37'],
+ component_required=['on'],
+ owner_defaults_to_member=['no'],
+ add_approvals=['no'],
+ phase_0=[''],
+ phase_1=[''],
+ phase_2=[''],
+ phase_3=[''],
+ phase_4=[''],
+ phase_5=['OOPs'],
+ approval_2=['phase_0'],
+ approval_3=['phase_2'])
+
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+
+ self.assertTrue('/adminTemplates?saved=1&ts' in url)
+
+ self.assertEqual(0,
+ self.services.template.UpdateIssueTemplateDef.call_count)
+
+ # errors in phases should not matter if add_approvals is not 'on'
+ self.assertIsNone(self.mr.errors.phase_approvals)
+
+ def testProcessFormData_AcceptPhases(self):
+ self.services.user.TestAddUser('user@example.com', 222)
+ self.mr.auth.effective_ids = {222}
+ self.services.template.GetTemplateByName = Mock(return_value=None)
+ post_data = fake.PostData(
+ name=['secondtemplate'],
+ members_only=['on'],
+ summary=['TLDR'],
+ summary_must_be_edited=['on'],
+ content=['HEY WHY'],
+ status=['Accepted'],
+ label=['label-One', 'label-Two'],
+ custom_1=['NO'],
+ component_required=['on'],
+ owner_defaults_to_member=['no'],
+ add_approvals = ['on'],
+ phase_0=['Canary'],
+ phase_1=['Stable'],
+ phase_2=[''],
+ phase_3=[''],
+ phase_4=[''],
+ phase_5=[''],
+ approval_2=['phase_0'],
+ approval_3=['phase_1'],
+ approval_3_required=['on']
+ )
+
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.assertTrue('/adminTemplates?saved=1&ts' in url)
+
+ fv = tracker_pb2.FieldValue(field_id=1, str_value='NO', derived=False)
+ phases = [
+ tracker_pb2.Phase(name='Canary', rank=0, phase_id=0),
+ tracker_pb2.Phase(name='Stable', rank=1, phase_id=1)
+ ]
+ approval_values = [
+ tracker_pb2.ApprovalValue(approval_id=2, phase_id=0),
+ tracker_pb2.ApprovalValue(
+ approval_id=3, status=tracker_pb2.ApprovalStatus(
+ tracker_pb2.ApprovalStatus.NEEDS_REVIEW), phase_id=1)
+ ]
+ self.services.template.CreateIssueTemplateDef.assert_called_once_with(
+ self.mr.cnxn, 47925, 'secondtemplate', 'HEY WHY', 'TLDR', True,
+ 'Accepted', True, False, True, 0, ['label-One', 'label-Two'], [], [],
+ [fv], phases=phases, approval_values=approval_values)
diff --git a/tracker/test/templatedetail_test.py b/tracker/test/templatedetail_test.py
new file mode 100644
index 0000000..607996a
--- /dev/null
+++ b/tracker/test/templatedetail_test.py
@@ -0,0 +1,521 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for Template editing/viewing servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mox
+import logging
+import unittest
+import settings
+
+from mock import Mock
+
+import ezt
+
+from framework import permissions
+from services import service_manager
+from services import template_svc
+from testing import fake
+from testing import testing_helpers
+from tracker import templatedetail
+from tracker import tracker_bizobj
+from proto import tracker_pb2
+
+
+class TemplateDetailTest(unittest.TestCase):
+ """Tests for the TemplateDetail servlet."""
+
+ def setUp(self):
+ self.cnxn = 'fake cnxn'
+ mock_template_service = Mock(spec=template_svc.TemplateService)
+ self.services = service_manager.Services(project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ template=mock_template_service,
+ usergroup=fake.UserGroupService(),
+ user=fake.UserService())
+ self.servlet = templatedetail.TemplateDetail('req', 'res',
+ services=self.services)
+
+ self.services.user.TestAddUser('gatsby@example.com', 111)
+ self.services.user.TestAddUser('sport@example.com', 222)
+ self.services.user.TestAddUser('gatsby@example.com', 111)
+ self.services.user.TestAddUser('daisy@example.com', 333)
+
+ self.project = self.services.project.TestAddProject('proj')
+ self.services.project.TestAddProjectMembers(
+ [333], self.project, 'CONTRIBUTOR_ROLE')
+
+ self.template = self.test_template = tracker_bizobj.MakeIssueTemplate(
+ 'TestTemplate', 'sum', 'New', 111, 'content', ['label1', 'label2'],
+ [], [222], [], summary_must_be_edited=True,
+ owner_defaults_to_member=True, component_required=False,
+ members_only=False)
+ self.template.template_id = 12345
+ self.services.template.GetTemplateByName = Mock(
+ return_value=self.template)
+
+ self.mr = testing_helpers.MakeMonorailRequest(project=self.project)
+ self.mr.template_name = 'TestTemplate'
+
+ self.mox = mox.Mox()
+
+ self.fd_1 = tracker_bizobj.MakeFieldDef(
+ 1, 789, 'UXReview', tracker_pb2.FieldTypes.STR_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+ 'Approval for UX review', False, approval_id=2)
+ self.fd_2 = tracker_bizobj.MakeFieldDef(
+ 2, 789, 'UXReview', tracker_pb2.FieldTypes.STR_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+ 'Approval for UX review', False)
+ self.fd_3 = tracker_bizobj.MakeFieldDef(
+ 3, 789, 'TestApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'Approval for Test',
+ False)
+ self.fd_4 = tracker_bizobj.MakeFieldDef(
+ 4, 789, 'SecurityApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'Approval for Security',
+ False)
+ self.fd_5 = tracker_bizobj.MakeFieldDef(
+ 5, 789, 'GateTarget', tracker_pb2.FieldTypes.INT_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+ 'milestone target', False, is_phase_field=True)
+ self.fd_6 = tracker_bizobj.MakeFieldDef(
+ 6, 789, 'Choices', tracker_pb2.FieldTypes.ENUM_TYPE, None,
+ '', False, False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action',
+ 'milestone target', False, is_phase_field=True)
+ self.fd_7 = tracker_bizobj.MakeFieldDef(
+ 7,
+ 789,
+ 'RestrictedField',
+ tracker_pb2.FieldTypes.INT_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'RestrictedField',
+ False,
+ is_restricted_field=True)
+ self.fd_8 = tracker_bizobj.MakeFieldDef(
+ 8,
+ 789,
+ 'RestrictedEnumField',
+ tracker_pb2.FieldTypes.ENUM_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'RestrictedEnumField',
+ False,
+ is_restricted_field=True)
+ self.fd_9 = tracker_bizobj.MakeFieldDef(
+ 9,
+ 789,
+ 'RestrictedField_2',
+ tracker_pb2.FieldTypes.INT_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'RestrictedField_2',
+ False,
+ is_restricted_field=True)
+ self.fd_10 = tracker_bizobj.MakeFieldDef(
+ 10,
+ 789,
+ 'RestrictedEnumField_2',
+ tracker_pb2.FieldTypes.ENUM_TYPE,
+ None,
+ '',
+ False,
+ False,
+ False,
+ None,
+ None,
+ '',
+ False,
+ '',
+ '',
+ tracker_pb2.NotifyTriggers.NEVER,
+ 'no_action',
+ 'RestrictedEnumField_2',
+ False,
+ is_restricted_field=True)
+
+ self.ad_3 = tracker_pb2.ApprovalDef(approval_id=3)
+ self.ad_4 = tracker_pb2.ApprovalDef(approval_id=4)
+
+ self.cd_1 = tracker_bizobj.MakeComponentDef(
+ 1, 789, 'BackEnd', 'doc', False, [111], [], 100000, 222)
+ self.template.component_ids.append(1)
+
+ self.canary_phase = tracker_pb2.Phase(
+ name='Canary', phase_id=1, rank=1)
+ self.av_3 = tracker_pb2.ApprovalValue(approval_id=3, phase_id=1)
+ self.stable_phase = tracker_pb2.Phase(
+ name='Stable', phase_id=2, rank=3)
+ self.av_4 = tracker_pb2.ApprovalValue(approval_id=4, phase_id=2)
+ self.template.phases.extend([self.stable_phase, self.canary_phase])
+ self.template.approval_values.extend([self.av_3, self.av_4])
+
+ self.config = self.services.config.GetProjectConfig(
+ 'fake cnxn', self.project.project_id)
+ self.templates = testing_helpers.DefaultTemplates()
+ self.template.labels.extend(
+ ['GateTarget-Should-Not', 'GateTarget-Be-Masked',
+ 'Choices-Wrapped', 'Choices-Burritod'])
+ self.templates.append(self.template)
+ self.services.template.GetProjectTemplates = Mock(
+ return_value=self.templates)
+ self.services.template.FindTemplateByName = Mock(return_value=self.template)
+ self.config.component_defs.append(self.cd_1)
+ self.config.field_defs.extend(
+ [
+ self.fd_1, self.fd_2, self.fd_3, self.fd_4, self.fd_5, self.fd_6,
+ self.fd_7, self.fd_8, self.fd_9, self.fd_10
+ ])
+ self.config.approval_defs.extend([self.ad_3, self.ad_4])
+ self.services.config.StoreConfig(None, self.config)
+
+ def tearDown(self):
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+
+ def testAssertBasePermission_Anyone(self):
+ self.mr.auth.effective_ids = {222}
+ self.servlet.AssertBasePermission(self.mr)
+
+ self.mr.auth.effective_ids = {333}
+ self.servlet.AssertBasePermission(self.mr)
+
+ self.mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+ self.servlet.AssertBasePermission(self.mr)
+
+ self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+ self.servlet.AssertBasePermission(self.mr)
+
+ def testAssertBasePermision_MembersOnly(self):
+ self.template.members_only = True
+ self.mr.auth.effective_ids = {222}
+ self.servlet.AssertBasePermission(self.mr)
+
+ self.mr.auth.effective_ids = {333}
+ self.servlet.AssertBasePermission(self.mr)
+
+ self.mr.auth.effective_ids = {444}
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.AssertBasePermission, self.mr)
+
+ self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+ self.assertRaises(
+ permissions.PermissionException,
+ self.servlet.AssertBasePermission, self.mr)
+
+ def testGatherPageData(self):
+ self.mr.perms = permissions.PermissionSet([])
+ self.mr.auth.effective_ids = {222} # template admin
+ page_data = self.servlet.GatherPageData(self.mr)
+ self.assertEqual(self.servlet.PROCESS_TAB_TEMPLATES,
+ page_data['admin_tab_mode'])
+ self.assertTrue(page_data['allow_edit'])
+ self.assertEqual(page_data['uneditable_fields'], ezt.boolean(True))
+ self.assertFalse(page_data['new_template_form'])
+ self.assertFalse(page_data['initial_members_only'])
+ self.assertEqual(page_data['template_name'], 'TestTemplate')
+ self.assertEqual(page_data['initial_summary'], 'sum')
+ self.assertTrue(page_data['initial_must_edit_summary'])
+ self.assertEqual(page_data['initial_content'], 'content')
+ self.assertEqual(page_data['initial_status'], 'New')
+ self.assertEqual(page_data['initial_owner'], 'gatsby@example.com')
+ self.assertTrue(page_data['initial_owner_defaults_to_member'])
+ self.assertEqual(page_data['initial_components'], 'BackEnd')
+ self.assertFalse(page_data['initial_component_required'])
+ self.assertItemsEqual(
+ page_data['labels'],
+ ['label1', 'label2', 'GateTarget-Should-Not', 'GateTarget-Be-Masked'])
+ self.assertEqual(page_data['initial_admins'], 'sport@example.com')
+ self.assertTrue(page_data['initial_add_approvals'])
+ self.assertEqual(len(page_data['initial_phases']), 6)
+ phases = [phase for phase in page_data['initial_phases'] if phase.name]
+ self.assertEqual(len(phases), 2)
+ self.assertEqual(len(page_data['approvals']), 2)
+ self.assertItemsEqual(page_data['prechecked_approvals'],
+ ['3_phase_0', '4_phase_1'])
+ self.assertTrue(page_data['fields'][3].is_editable) #nonRestrictedField
+ self.assertIsNone(page_data['fields'][4].is_editable) #restrictedField
+
+ def testProcessFormData_Reject(self):
+ self.mr.auth.effective_ids = {222}
+ post_data = fake.PostData(
+ name=['TestTemplate'],
+ members_only=['on'],
+ summary=['TLDR'],
+ summary_must_be_edited=['on'],
+ content=['HEY WHY'],
+ status=['Accepted'],
+ owner=['someone@world.com'],
+ label=['label-One', 'label-Two'],
+ custom_1=['NO'],
+ custom_2=['MOOD'],
+ components=['hey, hey2,he3'],
+ component_required=['on'],
+ owner_defaults_to_member=['no'],
+ add_approvals = ['on'],
+ phase_0=['Canary'],
+ phase_1=['Stable-Exp'],
+ phase_2=['Stable'],
+ phase_3=[''],
+ phase_4=[''],
+ phase_5=[''],
+ approval_3=['phase_0'],
+ approval_4=['phase_2']
+ )
+
+ self.mox.StubOutWithMock(self.servlet, 'PleaseCorrect')
+ self.servlet.PleaseCorrect(
+ self.mr,
+ initial_members_only=ezt.boolean(True),
+ template_name='TestTemplate',
+ initial_summary='TLDR',
+ initial_must_edit_summary=ezt.boolean(True),
+ initial_content='HEY WHY',
+ initial_status='Accepted',
+ initial_owner='someone@world.com',
+ initial_owner_defaults_to_member=ezt.boolean(False),
+ initial_components='hey, hey2, he3',
+ initial_component_required=ezt.boolean(True),
+ initial_admins='',
+ labels=['label-One', 'label-Two'],
+ fields=mox.IgnoreArg(),
+ initial_add_approvals=ezt.boolean(True),
+ initial_phases=[tracker_pb2.Phase(name=name) for
+ name in ['Canary', 'Stable-Exp', 'Stable', '', '', '']],
+ approvals=mox.IgnoreArg(),
+ prechecked_approvals=['3_phase_0', '4_phase_2'],
+ required_approval_ids=[]
+ )
+ self.mox.ReplayAll()
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+ self.mox.VerifyAll()
+ self.assertEqual('Owner not found.', self.mr.errors.owner)
+ self.assertEqual('Unknown component he3', self.mr.errors.components)
+ self.assertIsNone(url)
+ self.assertEqual('Defined gates must have assigned approvals.',
+ self.mr.errors.phase_approvals)
+
+ def testProcessFormData_RejectRestrictedFields(self):
+ """Template admins cannot set restricted fields by default."""
+ self.mr.perms = permissions.PermissionSet([])
+ self.mr.auth.effective_ids = {222} # template admin
+ post_data_add_fv = fake.PostData(
+ name=['TestTemplate'],
+ members_only=['on'],
+ summary=['TLDR'],
+ summary_must_be_edited=[''],
+ content=['HEY WHY'],
+ status=['Accepted'],
+ owner=['daisy@example.com'],
+ label=['label-One', 'label-Two'],
+ custom_1=['NO'],
+ custom_2=['MOOD'],
+ custom_7=['37'],
+ components=['BackEnd'],
+ component_required=['on'],
+ owner_defaults_to_member=['on'],
+ add_approvals=['no'],
+ phase_0=[''],
+ phase_1=[''],
+ phase_2=[''],
+ phase_3=[''],
+ phase_4=[''],
+ phase_5=['OOPs'],
+ approval_3=['phase_0'],
+ approval_4=['phase_2'])
+ post_data_label_edits_enum = fake.PostData(
+ name=['TestTemplate'],
+ members_only=['on'],
+ summary=['TLDR'],
+ summary_must_be_edited=[''],
+ content=['HEY WHY'],
+ status=['Accepted'],
+ owner=['daisy@example.com'],
+ label=['label-One', 'label-Two', 'RestrictedEnumField-7'],
+ components=['BackEnd'],
+ component_required=['on'],
+ owner_defaults_to_member=['on'],
+ add_approvals=['no'],
+ phase_0=[''],
+ phase_1=[''],
+ phase_2=[''],
+ phase_3=[''],
+ phase_4=[''],
+ phase_5=['OOPs'],
+ approval_3=['phase_0'],
+ approval_4=['phase_2'])
+
+ self.assertRaises(
+ AssertionError, self.servlet.ProcessFormData, self.mr, post_data_add_fv)
+ self.assertRaises(
+ AssertionError, self.servlet.ProcessFormData, self.mr,
+ post_data_label_edits_enum)
+
+ def testProcessFormData_Accept(self):
+ self.fd_7.editor_ids = [222]
+ self.fd_8.editor_ids = [222]
+ self.mr.perms = permissions.PermissionSet([])
+ self.mr.auth.effective_ids = {222} # template admin
+ temp_restricted_fv = tracker_bizobj.MakeFieldValue(
+ 9, 3737, None, None, None, None, False)
+ self.template.field_values.append(temp_restricted_fv)
+ self.template.labels.append('RestrictedEnumField_2-b')
+ post_data = fake.PostData(
+ name=['TestTemplate'],
+ members_only=['on'],
+ summary=['TLDR'],
+ summary_must_be_edited=[''],
+ content=['HEY WHY'],
+ status=['Accepted'],
+ owner=['daisy@example.com'],
+ label=['label-One', 'label-Two', 'RestrictedEnumField-7'],
+ custom_1=['NO'],
+ custom_2=['MOOD'],
+ custom_7=['37'],
+ components=['BackEnd'],
+ component_required=['on'],
+ owner_defaults_to_member=['on'],
+ add_approvals=['no'],
+ phase_0=[''],
+ phase_1=[''],
+ phase_2=[''],
+ phase_3=[''],
+ phase_4=[''],
+ phase_5=['OOPs'],
+ approval_3=['phase_0'],
+ approval_4=['phase_2'])
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+
+ self.assertTrue('/templates/detail?saved=1&template=TestTemplate&' in url)
+
+ self.services.template.UpdateIssueTemplateDef.assert_called_once_with(
+ self.mr.cnxn,
+ 47925,
+ 12345,
+ status='Accepted',
+ component_required=True,
+ phases=[],
+ approval_values=[],
+ name='TestTemplate',
+ field_values=[
+ tracker_pb2.FieldValue(field_id=1, str_value='NO', derived=False),
+ tracker_pb2.FieldValue(field_id=2, str_value='MOOD', derived=False),
+ tracker_pb2.FieldValue(field_id=7, int_value=37, derived=False),
+ tracker_pb2.FieldValue(field_id=9, int_value=3737, derived=False)
+ ],
+ labels=[
+ 'label-One', 'label-Two', 'RestrictedEnumField-7',
+ 'RestrictedEnumField_2-b'
+ ],
+ owner_defaults_to_member=True,
+ admin_ids=[],
+ content='HEY WHY',
+ component_ids=[1],
+ summary_must_be_edited=False,
+ summary='TLDR',
+ members_only=True,
+ owner_id=333)
+
+ def testProcessFormData_AcceptPhases(self):
+ self.mr.auth.effective_ids = {222}
+ post_data = fake.PostData(
+ name=['TestTemplate'],
+ members_only=['on'],
+ summary=['TLDR'],
+ summary_must_be_edited=[''],
+ content=['HEY WHY'],
+ status=['Accepted'],
+ owner=['daisy@example.com'],
+ label=['label-One', 'label-Two'],
+ custom_1=['NO'],
+ custom_2=['MOOD'],
+ components=['BackEnd'],
+ component_required=['on'],
+ owner_defaults_to_member=['on'],
+ add_approvals = ['on'],
+ phase_0=['Canary'],
+ phase_1=['Stable'],
+ phase_2=[''],
+ phase_3=[''],
+ phase_4=[''],
+ phase_5=[''],
+ approval_3=['phase_0'],
+ approval_4=['phase_1']
+ )
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+
+ self.assertTrue('/templates/detail?saved=1&template=TestTemplate&' in url)
+
+ self.services.template.UpdateIssueTemplateDef.assert_called_once_with(
+ self.mr.cnxn, 47925, 12345, status='Accepted', component_required=True,
+ phases=[
+ tracker_pb2.Phase(name='Canary', rank=0, phase_id=0),
+ tracker_pb2.Phase(name='Stable', rank=1, phase_id=1)],
+ approval_values=[tracker_pb2.ApprovalValue(approval_id=3, phase_id=0),
+ tracker_pb2.ApprovalValue(approval_id=4, phase_id=1)],
+ name='TestTemplate', field_values=[
+ tracker_pb2.FieldValue(field_id=1, str_value='NO', derived=False),
+ tracker_pb2.FieldValue(
+ field_id=2, str_value='MOOD', derived=False)],
+ labels=['label-One', 'label-Two'], owner_defaults_to_member=True,
+ admin_ids=[], content='HEY WHY', component_ids=[1],
+ summary_must_be_edited=False, summary='TLDR', members_only=True,
+ owner_id=333)
+
+ def testProcessFormData_Delete(self):
+ post_data = fake.PostData(
+ deletetemplate=['Submit'],
+ name=['TestTemplate'],
+ members_only=['on'],
+ )
+ url = self.servlet.ProcessFormData(self.mr, post_data)
+
+ self.assertTrue('/p/None/adminTemplates?deleted=1' in url)
+ self.services.template.DeleteIssueTemplateDef\
+ .assert_called_once_with(self.mr.cnxn, 47925, 12345)
diff --git a/tracker/test/tracker_bizobj_test.py b/tracker/test/tracker_bizobj_test.py
new file mode 100644
index 0000000..29351b0
--- /dev/null
+++ b/tracker/test/tracker_bizobj_test.py
@@ -0,0 +1,2456 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for issue bizobj functions."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+import logging
+
+from framework import framework_constants
+from framework import framework_views
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+class BizobjTest(unittest.TestCase):
+
+ def setUp(self):
+ self.cnxn = 'fake cnxn'
+ self.services = service_manager.Services(
+ issue=fake.IssueService())
+ self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ self.config.field_defs = [
+ tracker_pb2.FieldDef(
+ field_id=1, project_id=789, field_name='EstDays',
+ field_type=tracker_pb2.FieldTypes.INT_TYPE)
+ ]
+ self.config.component_defs = [
+ tracker_pb2.ComponentDef(component_id=1, project_id=789, path='UI'),
+ tracker_pb2.ComponentDef(component_id=2, project_id=789, path='DB'),
+ ]
+
+ def testGetOwnerId(self):
+ issue = tracker_pb2.Issue()
+ self.assertEqual(
+ tracker_bizobj.GetOwnerId(issue), framework_constants.NO_USER_SPECIFIED)
+
+ issue.derived_owner_id = 123
+ self.assertEqual(tracker_bizobj.GetOwnerId(issue), 123)
+
+ issue.owner_id = 456
+ self.assertEqual(tracker_bizobj.GetOwnerId(issue), 456)
+
+ def testGetStatus(self):
+ issue = tracker_pb2.Issue()
+ self.assertEqual(tracker_bizobj.GetStatus(issue), '')
+
+ issue.derived_status = 'InReview'
+ self.assertEqual(tracker_bizobj.GetStatus(issue), 'InReview')
+
+ issue.status = 'Forgotten'
+ self.assertEqual(tracker_bizobj.GetStatus(issue), 'Forgotten')
+
+ def testGetCcIds(self):
+ issue = tracker_pb2.Issue()
+ self.assertEqual(tracker_bizobj.GetCcIds(issue), [])
+
+ issue.derived_cc_ids.extend([1, 2, 3])
+ self.assertEqual(tracker_bizobj.GetCcIds(issue), [1, 2, 3])
+
+ issue.cc_ids.extend([4, 5, 6])
+ self.assertEqual(tracker_bizobj.GetCcIds(issue), [4, 5, 6, 1, 2, 3])
+
+ def testGetApproverIds(self):
+ issue = tracker_pb2.Issue()
+ self.assertEqual(tracker_bizobj.GetApproverIds(issue), [])
+
+ av_1 = tracker_pb2.ApprovalValue(approver_ids=[111, 222])
+ av_2 = tracker_pb2.ApprovalValue()
+ av_3 = tracker_pb2.ApprovalValue(approver_ids=[222, 333])
+ issue.approval_values = [av_1, av_2, av_3]
+ self.assertItemsEqual(
+ tracker_bizobj.GetApproverIds(issue), [111, 222, 333])
+
+ def testGetLabels(self):
+ issue = tracker_pb2.Issue()
+ self.assertEqual(tracker_bizobj.GetLabels(issue), [])
+
+ issue.derived_labels.extend(['a', 'b', 'c'])
+ self.assertEqual(tracker_bizobj.GetLabels(issue), ['a', 'b', 'c'])
+
+ issue.labels.extend(['d', 'e', 'f'])
+ self.assertEqual(
+ tracker_bizobj.GetLabels(issue), ['d', 'e', 'f', 'a', 'b', 'c'])
+
+ def testFindFieldDef_None(self):
+ config = tracker_pb2.ProjectIssueConfig()
+ self.assertIsNone(tracker_bizobj.FindFieldDef(None, config))
+
+ def testFindFieldDef_Empty(self):
+ config = tracker_pb2.ProjectIssueConfig()
+ self.assertIsNone(tracker_bizobj.FindFieldDef('EstDays', config))
+
+ def testFindFieldDef_Default(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ self.assertIsNone(tracker_bizobj.FindFieldDef('EstDays', config))
+
+ def testFindFieldDef_Normal(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ fd = tracker_pb2.FieldDef(field_name='EstDays')
+ config.field_defs = [fd]
+ self.assertEqual(fd, tracker_bizobj.FindFieldDef('EstDays', config))
+ self.assertEqual(fd, tracker_bizobj.FindFieldDef('ESTDAYS', config))
+ self.assertIsNone(tracker_bizobj.FindFieldDef('Unknown', config))
+
+ def testFindFieldDefByID_Empty(self):
+ config = tracker_pb2.ProjectIssueConfig()
+ self.assertIsNone(tracker_bizobj.FindFieldDefByID(1, config))
+
+ def testFindFieldDefByID_Default(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ self.assertIsNone(tracker_bizobj.FindFieldDefByID(1, config))
+
+ def testFindFieldDefByID_Normal(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ fd = tracker_pb2.FieldDef(field_id=1)
+ config.field_defs = [fd]
+ self.assertEqual(fd, tracker_bizobj.FindFieldDefByID(1, config))
+ self.assertIsNone(tracker_bizobj.FindFieldDefByID(99, config))
+
+ def testFindApprovalDef_Empty(self):
+ config = tracker_pb2.ProjectIssueConfig()
+ self.assertEqual(None, tracker_bizobj.FindApprovalDef(
+ 'Nonexistent', config))
+
+ def testFindApprovalDef_Normal(self):
+ config = tracker_pb2.ProjectIssueConfig()
+ approval_fd = tracker_pb2.FieldDef(field_id=1, field_name='UIApproval')
+ approval_def = tracker_pb2.ApprovalDef(
+ approval_id=1, approver_ids=[111], survey='')
+ config.field_defs = [approval_fd]
+ config.approval_defs = [approval_def]
+ self.assertEqual(approval_def, tracker_bizobj.FindApprovalDef(
+ 'UIApproval', config))
+
+ def testFindApprovalDef_NotApproval(self):
+ config = tracker_pb2.ProjectIssueConfig()
+ field_def = tracker_pb2.FieldDef(field_id=1, field_name='DesignDoc')
+ config.field_defs = [field_def]
+ self.assertEqual(None, tracker_bizobj.FindApprovalDef('DesignDoc', config))
+
+ def testFindApprovalDefByID_Empty(self):
+ config = tracker_pb2.ProjectIssueConfig()
+ self.assertEqual(None, tracker_bizobj.FindApprovalDefByID(1, config))
+
+ def testFindApprovalDefByID_Normal(self):
+ config = tracker_pb2.ProjectIssueConfig()
+ approval_def = tracker_pb2.ApprovalDef(
+ approval_id=1, approver_ids=[111, 222], survey='')
+ config.approval_defs = [approval_def]
+ self.assertEqual(approval_def, tracker_bizobj.FindApprovalDefByID(
+ 1, config))
+ self.assertEqual(None, tracker_bizobj.FindApprovalDefByID(99, config))
+
+ def testFindApprovalValueByID_Normal(self):
+ av_24 = tracker_pb2.ApprovalValue(approval_id=24)
+ av_22 = tracker_pb2.ApprovalValue()
+ self.assertEqual(
+ av_24, tracker_bizobj.FindApprovalValueByID(24, [av_22, av_24]))
+
+ def testFindApprovalValueByID_None(self):
+ av_no_id = tracker_pb2.ApprovalValue()
+ self.assertIsNone(tracker_bizobj.FindApprovalValueByID(24, [av_no_id]))
+
+ def testFindApprovalsSubfields(self):
+ config = tracker_pb2.ProjectIssueConfig()
+ subfd_1 = tracker_pb2.FieldDef(approval_id=1)
+ subfd_2 = tracker_pb2.FieldDef(approval_id=2)
+ subfd_3 = tracker_pb2.FieldDef(approval_id=1)
+ subfd_4 = tracker_pb2.FieldDef()
+ config.field_defs = [subfd_1, subfd_2, subfd_3, subfd_4]
+
+ subfields_dict = tracker_bizobj.FindApprovalsSubfields([1, 2], config)
+ self.assertItemsEqual(subfields_dict[1], [subfd_1, subfd_3])
+ self.assertItemsEqual(subfields_dict[2], [subfd_2])
+ self.assertItemsEqual(subfields_dict[3], [])
+
+ def testFindPhaseByID_Normal(self):
+ canary_phase = tracker_pb2.Phase(phase_id=2, name='Canary')
+ stable_phase = tracker_pb2.Phase(name='Stable')
+ self.assertEqual(
+ canary_phase,
+ tracker_bizobj.FindPhaseByID(2, [stable_phase, canary_phase]))
+
+ def testFindPhaseByID_None(self):
+ stable_phase = tracker_pb2.Phase(name='Stable')
+ self.assertIsNone(tracker_bizobj.FindPhaseByID(42, [stable_phase]))
+
+ def testFindPhase_Normal(self):
+ canary_phase = tracker_pb2.Phase(phase_id=2)
+ stable_phase = tracker_pb2.Phase(name='Stable')
+ self.assertEqual(stable_phase, tracker_bizobj.FindPhase(
+ 'Stable', [stable_phase, canary_phase]))
+
+ def testFindPhase_None(self):
+ self.assertIsNone(tracker_bizobj.FindPhase('ghost_phase', []))
+
+ def testGetGrantedPerms_Empty(self):
+ config = tracker_pb2.ProjectIssueConfig()
+ issue = tracker_pb2.Issue()
+ self.assertEqual(
+ set(), tracker_bizobj.GetGrantedPerms(issue, {111}, config))
+
+ def testGetGrantedPerms_Default(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ issue = tracker_pb2.Issue()
+ self.assertEqual(
+ set(), tracker_bizobj.GetGrantedPerms(issue, {111}, config))
+
+ def testGetGrantedPerms_NothingGranted(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ fd = tracker_pb2.FieldDef(field_id=1) # Nothing granted
+ config.field_defs = [fd]
+ fv = tracker_pb2.FieldValue(field_id=1, user_id=222)
+ issue = tracker_pb2.Issue(field_values=[fv])
+ self.assertEqual(
+ set(),
+ tracker_bizobj.GetGrantedPerms(issue, {111, 222}, config))
+
+ def testGetGrantedPerms_Normal(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ fd = tracker_pb2.FieldDef(field_id=1, grants_perm='Highlight')
+ config.field_defs = [fd]
+ fv = tracker_pb2.FieldValue(field_id=1, user_id=222)
+ issue = tracker_pb2.Issue(field_values=[fv])
+ self.assertEqual(
+ set(),
+ tracker_bizobj.GetGrantedPerms(issue, {111}, config))
+ self.assertEqual(
+ set(['highlight']),
+ tracker_bizobj.GetGrantedPerms(issue, {111, 222}, config))
+
+ def testLabelsByPrefix(self):
+ expected = tracker_bizobj.LabelsByPrefix(
+ ['OneWordLabel', 'Key-Value1', 'Key-Value2', 'Launch-X-Y-Z'],
+ ['launch-x'])
+ self.assertEqual(
+ {'key': ['Value1', 'Value2'],
+ 'launch-x': ['Y-Z']},
+ expected)
+
+ def testLabelIsMaskedByField(self):
+ self.assertIsNone(tracker_bizobj.LabelIsMaskedByField('UI', []))
+ self.assertIsNone(tracker_bizobj.LabelIsMaskedByField('P-1', []))
+ field_names = ['priority', 'size']
+ self.assertIsNone(tracker_bizobj.LabelIsMaskedByField(
+ 'UI', field_names))
+ self.assertIsNone(tracker_bizobj.LabelIsMaskedByField(
+ 'OS-All', field_names))
+ self.assertEqual(
+ 'size', tracker_bizobj.LabelIsMaskedByField('size-xl', field_names))
+ self.assertEqual(
+ 'size', tracker_bizobj.LabelIsMaskedByField('Size-XL', field_names))
+
+ def testNonMaskedLabels(self):
+ self.assertEqual([], tracker_bizobj.NonMaskedLabels([], []))
+ field_names = ['priority', 'size']
+ self.assertEqual([], tracker_bizobj.NonMaskedLabels([], field_names))
+ self.assertEqual(
+ [], tracker_bizobj.NonMaskedLabels(['Size-XL'], field_names))
+ self.assertEqual(
+ ['Hot'], tracker_bizobj.NonMaskedLabels(['Hot'], field_names))
+ self.assertEqual(
+ ['Hot'],
+ tracker_bizobj.NonMaskedLabels(['Hot', 'Size-XL'], field_names))
+
+ def testMakeApprovalValue_Basic(self):
+ av = tracker_bizobj.MakeApprovalValue(2)
+ expected = tracker_pb2.ApprovalValue(approval_id=2)
+ self.assertEqual(av, expected)
+
+ def testMakeApprovalValue_Full(self):
+ av = tracker_bizobj.MakeApprovalValue(
+ 2, approver_ids=[], status=tracker_pb2.ApprovalStatus.APPROVED,
+ setter_id=3, set_on=123, phase_id=3)
+ expected = tracker_pb2.ApprovalValue(
+ approval_id=2, approver_ids=[],
+ status=tracker_pb2.ApprovalStatus.APPROVED,
+ setter_id=3, set_on=123, phase_id=3)
+ self.assertEqual(av, expected)
+
+ def testMakeFieldDef_Basic(self):
+ fd = tracker_bizobj.MakeFieldDef(
+ 1, 789, 'Size', tracker_pb2.FieldTypes.USER_TYPE, None, None,
+ False, False, False, None, None, None, False,
+ None, None, None, 'no_action', 'Some field', False)
+ self.assertEqual(1, fd.field_id)
+ self.assertEqual(None, fd.approval_id)
+ self.assertFalse(fd.is_phase_field)
+ self.assertFalse(fd.is_restricted_field)
+
+ def testMakeFieldDef_Full(self):
+ fd = tracker_bizobj.MakeFieldDef(
+ 1,
+ 789,
+ 'Size',
+ tracker_pb2.FieldTypes.INT_TYPE,
+ None,
+ None,
+ False,
+ False,
+ False,
+ 1,
+ 100,
+ None,
+ False,
+ None,
+ None,
+ None,
+ 'no_action',
+ 'Some field',
+ False,
+ approval_id=4,
+ is_phase_field=True,
+ is_restricted_field=True)
+ self.assertEqual(1, fd.min_value)
+ self.assertEqual(100, fd.max_value)
+ self.assertEqual(4, fd.approval_id)
+ self.assertTrue(fd.is_phase_field)
+ self.assertTrue(fd.is_restricted_field)
+
+ fd = tracker_bizobj.MakeFieldDef(
+ 1,
+ 789,
+ 'Size',
+ tracker_pb2.FieldTypes.STR_TYPE,
+ None,
+ None,
+ False,
+ False,
+ False,
+ None,
+ None,
+ 'A.*Z',
+ False,
+ 'EditIssue',
+ None,
+ None,
+ 'no_action',
+ 'Some field',
+ False,
+ 4,
+ is_restricted_field=False)
+ self.assertEqual('A.*Z', fd.regex)
+ self.assertEqual('EditIssue', fd.needs_perm)
+ self.assertEqual(4, fd.approval_id)
+ self.assertFalse(fd.is_restricted_field)
+
+ def testMakeFieldDef_IntBools(self):
+ fd = tracker_bizobj.MakeFieldDef(
+ 1,
+ 789,
+ 'Size',
+ tracker_pb2.FieldTypes.INT_TYPE,
+ None,
+ None,
+ 0,
+ 0,
+ 0,
+ 1,
+ 100,
+ None,
+ 0,
+ None,
+ None,
+ None,
+ 'no_action',
+ 'Some field',
+ 0,
+ approval_id=4,
+ is_phase_field=1,
+ is_restricted_field=1)
+ self.assertFalse(fd.is_required)
+ self.assertFalse(fd.is_niche)
+ self.assertFalse(fd.is_multivalued)
+ self.assertFalse(fd.needs_member)
+ self.assertFalse(fd.is_deleted)
+ self.assertTrue(fd.is_phase_field)
+ self.assertTrue(fd.is_restricted_field)
+
+ def testMakeFieldValue(self):
+ # Only the first value counts.
+ fv = tracker_bizobj.MakeFieldValue(1, 42, 'yay', 111, None, None, True)
+ self.assertEqual(1, fv.field_id)
+ self.assertEqual(42, fv.int_value)
+ self.assertIsNone(fv.str_value)
+ self.assertEqual(None, fv.user_id)
+ self.assertEqual(None, fv.phase_id)
+
+ fv = tracker_bizobj.MakeFieldValue(1, None, 'yay', 111, None, None, True)
+ self.assertEqual('yay', fv.str_value)
+ self.assertEqual(None, fv.user_id)
+
+ fv = tracker_bizobj.MakeFieldValue(1, None, None, 111, None, None, True)
+ self.assertEqual(111, fv.user_id)
+ self.assertEqual(True, fv.derived)
+
+ fv = tracker_bizobj.MakeFieldValue(
+ 1, None, None, None, 1234567890, None, True)
+ self.assertEqual(1234567890, fv.date_value)
+ self.assertEqual(True, fv.derived)
+
+ fv = tracker_bizobj.MakeFieldValue(
+ 1, None, None, None, None, 'www.google.com', True, phase_id=1)
+ self.assertEqual('www.google.com', fv.url_value)
+ self.assertEqual(True, fv.derived)
+ self.assertEqual(1, fv.phase_id)
+
+ with self.assertRaises(ValueError):
+ tracker_bizobj.MakeFieldValue(1, None, None, None, None, None, True)
+
+ def testGetFieldValueWithRawValue(self):
+ class MockUser(object):
+ def __init__(self):
+ self.email = 'test@example.com'
+ users_by_id = {111: MockUser()}
+
+ class MockFieldValue(object):
+ def __init__(
+ self, int_value=None, str_value=None, user_id=None,
+ date_value=None, url_value=None):
+ self.int_value = int_value
+ self.str_value = str_value
+ self.user_id = user_id
+ self.date_value = date_value
+ self.url_value = url_value
+
+ # Test user types.
+ # Use user_id from the field_value and get user from users_by_id.
+ val = tracker_bizobj.GetFieldValueWithRawValue(
+ field_type=tracker_pb2.FieldTypes.USER_TYPE,
+ users_by_id=users_by_id,
+ field_value=MockFieldValue(user_id=111),
+ raw_value=113,
+ )
+ self.assertEqual('test@example.com', val)
+ # Specify user_id that does not exist in users_by_id.
+ val = tracker_bizobj.GetFieldValueWithRawValue(
+ field_type=tracker_pb2.FieldTypes.USER_TYPE,
+ users_by_id=users_by_id,
+ field_value=MockFieldValue(user_id=112),
+ raw_value=113,
+ )
+ self.assertEqual(112, val)
+ # Pass in empty users_by_id.
+ val = tracker_bizobj.GetFieldValueWithRawValue(
+ field_type=tracker_pb2.FieldTypes.USER_TYPE,
+ users_by_id={},
+ field_value=MockFieldValue(user_id=111),
+ raw_value=113,
+ )
+ self.assertEqual(111, val)
+ # Test different raw_values.
+ raw_value_tests = (
+ (111, 'test@example.com'),
+ (112, 112),
+ (framework_constants.NO_USER_NAME, framework_constants.NO_USER_NAME))
+ for (raw_value, expected_output) in raw_value_tests:
+ val = tracker_bizobj.GetFieldValueWithRawValue(
+ field_type=tracker_pb2.FieldTypes.USER_TYPE,
+ users_by_id=users_by_id,
+ field_value=None,
+ raw_value=raw_value,
+ )
+ self.assertEqual(expected_output, val)
+
+ # Test enum types.
+ # The returned value should be the raw_value regardless of field_value being
+ # specified.
+ for field_value in (MockFieldValue(), None):
+ val = tracker_bizobj.GetFieldValueWithRawValue(
+ field_type=tracker_pb2.FieldTypes.ENUM_TYPE,
+ users_by_id=users_by_id,
+ field_value=field_value,
+ raw_value='abc',
+ )
+ self.assertEqual('abc', val)
+
+ # Test int type.
+ # Use int_value from the field_value.
+ val = tracker_bizobj.GetFieldValueWithRawValue(
+ field_type=tracker_pb2.FieldTypes.INT_TYPE,
+ users_by_id=users_by_id,
+ field_value=MockFieldValue(int_value=100),
+ raw_value=101,
+ )
+ self.assertEqual(100, val)
+ # Use the raw_value when field_value is not specified.
+ val = tracker_bizobj.GetFieldValueWithRawValue(
+ field_type=tracker_pb2.FieldTypes.INT_TYPE,
+ users_by_id=users_by_id,
+ field_value=None,
+ raw_value=101,
+ )
+ self.assertEqual(101, val)
+
+ # Test str type.
+ # Use str_value from the field_value.
+ val = tracker_bizobj.GetFieldValueWithRawValue(
+ field_type=tracker_pb2.FieldTypes.STR_TYPE,
+ users_by_id=users_by_id,
+ field_value=MockFieldValue(str_value='testing'),
+ raw_value='test',
+ )
+ self.assertEqual('testing', val)
+ # Use the raw_value when field_value is not specified.
+ val = tracker_bizobj.GetFieldValueWithRawValue(
+ field_type=tracker_pb2.FieldTypes.STR_TYPE,
+ users_by_id=users_by_id,
+ field_value=None,
+ raw_value='test',
+ )
+ self.assertEqual('test', val)
+
+ # Test date type.
+ # Use date_value from the field_value.
+ val = tracker_bizobj.GetFieldValueWithRawValue(
+ field_type=tracker_pb2.FieldTypes.DATE_TYPE,
+ users_by_id=users_by_id,
+ field_value=MockFieldValue(date_value=1234567890),
+ raw_value=2345678901,
+ )
+ self.assertEqual('2009-02-13', val)
+ # Use the raw_value when field_value is not specified.
+ val = tracker_bizobj.GetFieldValueWithRawValue(
+ field_type=tracker_pb2.FieldTypes.DATE_TYPE,
+ users_by_id=users_by_id,
+ field_value=None,
+ raw_value='2016-10-30',
+ )
+ self.assertEqual('2016-10-30', val)
+
+ def testFindComponentDef_Empty(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ actual = tracker_bizobj.FindComponentDef('DB', config)
+ self.assertIsNone(actual)
+
+ def testFindComponentDef_NoMatch(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ cd = tracker_pb2.ComponentDef(path='UI>Splash')
+ config.component_defs.append(cd)
+ actual = tracker_bizobj.FindComponentDef('DB', config)
+ self.assertIsNone(actual)
+
+ def testFindComponentDef_MatchFound(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ cd = tracker_pb2.ComponentDef(path='UI>Splash')
+ config.component_defs.append(cd)
+ actual = tracker_bizobj.FindComponentDef('UI>Splash', config)
+ self.assertEqual(cd, actual)
+
+ def testFindMatchingComponentIDs_Empty(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ actual = tracker_bizobj.FindMatchingComponentIDs('DB', config)
+ self.assertEqual([], actual)
+ actual = tracker_bizobj.FindMatchingComponentIDs('DB', config, exact=False)
+ self.assertEqual([], actual)
+
+ def testFindMatchingComponentIDs_NoMatch(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ config.component_defs.append(tracker_pb2.ComponentDef(
+ component_id=1, path='UI>Splash'))
+ config.component_defs.append(tracker_pb2.ComponentDef(
+ component_id=2, path='UI>AboutBox'))
+ actual = tracker_bizobj.FindMatchingComponentIDs('DB', config)
+ self.assertEqual([], actual)
+ actual = tracker_bizobj.FindMatchingComponentIDs('DB', config, exact=False)
+ self.assertEqual([], actual)
+
+ def testFindMatchingComponentIDs_Match(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ config.component_defs.append(tracker_pb2.ComponentDef(
+ component_id=1, path='UI>Splash'))
+ config.component_defs.append(tracker_pb2.ComponentDef(
+ component_id=2, path='UI>AboutBox'))
+ config.component_defs.append(tracker_pb2.ComponentDef(
+ component_id=3, path='DB>Attachments'))
+ actual = tracker_bizobj.FindMatchingComponentIDs('DB', config)
+ self.assertEqual([], actual)
+ actual = tracker_bizobj.FindMatchingComponentIDs('DB', config, exact=False)
+ self.assertEqual([3], actual)
+
+ def testFindMatchingComponentIDs_MatchMultiple(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ config.component_defs.append(tracker_pb2.ComponentDef(
+ component_id=1, path='UI>Splash'))
+ config.component_defs.append(tracker_pb2.ComponentDef(
+ component_id=2, path='UI>AboutBox'))
+ config.component_defs.append(tracker_pb2.ComponentDef(
+ component_id=22, path='UI>AboutBox'))
+ config.component_defs.append(tracker_pb2.ComponentDef(
+ component_id=3, path='DB>Attachments'))
+ actual = tracker_bizobj.FindMatchingComponentIDs('UI>AboutBox', config)
+ self.assertEqual([2, 22], actual)
+ actual = tracker_bizobj.FindMatchingComponentIDs('UI', config, exact=False)
+ self.assertEqual([1, 2, 22], actual)
+
+ def testFindComponentDefByID_Empty(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ actual = tracker_bizobj.FindComponentDefByID(999, config)
+ self.assertIsNone(actual)
+
+ def testFindComponentDefByID_NoMatch(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ config.component_defs.append(tracker_pb2.ComponentDef(
+ component_id=1, path='UI>Splash'))
+ config.component_defs.append(tracker_pb2.ComponentDef(
+ component_id=2, path='UI>AboutBox'))
+ actual = tracker_bizobj.FindComponentDefByID(999, config)
+ self.assertIsNone(actual)
+
+ def testFindComponentDefByID_MatchFound(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ cd = tracker_pb2.ComponentDef(component_id=1, path='UI>Splash')
+ config.component_defs.append(cd)
+ config.component_defs.append(tracker_pb2.ComponentDef(
+ component_id=2, path='UI>AboutBox'))
+ actual = tracker_bizobj.FindComponentDefByID(1, config)
+ self.assertEqual(cd, actual)
+
+ def testFindAncestorComponents_Empty(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ cd = tracker_pb2.ComponentDef(component_id=1, path='UI>Splash')
+ actual = tracker_bizobj.FindAncestorComponents(config, cd)
+ self.assertEqual([], actual)
+
+ def testFindAncestorComponents_NoMatch(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ cd = tracker_pb2.ComponentDef(component_id=1, path='UI>Splash')
+ config.component_defs.append(tracker_pb2.ComponentDef(
+ component_id=2, path='UI>AboutBox'))
+ actual = tracker_bizobj.FindAncestorComponents(config, cd)
+ self.assertEqual([], actual)
+
+ def testFindAncestorComponents_NoComponents(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+ config.component_defs.append(cd)
+ cd2 = tracker_pb2.ComponentDef(component_id=2, path='UI>Splash')
+ config.component_defs.append(cd2)
+ actual = tracker_bizobj.FindAncestorComponents(config, cd2)
+ self.assertEqual([cd], actual)
+
+ def testGetIssueComponentsAndAncestors_NoSuchComponent(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+ config.component_defs.append(cd)
+ cd2 = tracker_pb2.ComponentDef(component_id=2, path='UI>Splash')
+ config.component_defs.append(cd2)
+ issue = tracker_pb2.Issue(component_ids=[999])
+ actual = tracker_bizobj.GetIssueComponentsAndAncestors(issue, config)
+ self.assertEqual([], actual)
+
+ def testGetIssueComponentsAndAncestors_AffectsNoComponents(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+ config.component_defs.append(cd)
+ cd2 = tracker_pb2.ComponentDef(component_id=2, path='UI>Splash')
+ config.component_defs.append(cd2)
+ issue = tracker_pb2.Issue(component_ids=[])
+ actual = tracker_bizobj.GetIssueComponentsAndAncestors(issue, config)
+ self.assertEqual([], actual)
+
+ def testGetIssueComponentsAndAncestors_AffectsSomeComponents(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+ config.component_defs.append(cd)
+ cd2 = tracker_pb2.ComponentDef(component_id=2, path='UI>Splash')
+ config.component_defs.append(cd2)
+ issue = tracker_pb2.Issue(component_ids=[2])
+ actual = tracker_bizobj.GetIssueComponentsAndAncestors(issue, config)
+ self.assertEqual([cd, cd2], actual)
+
+ def testFindDescendantComponents_Empty(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+ actual = tracker_bizobj.FindDescendantComponents(config, cd)
+ self.assertEqual([], actual)
+
+ def testFindDescendantComponents_NoMatch(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+ config.component_defs.append(cd)
+ actual = tracker_bizobj.FindDescendantComponents(config, cd)
+ self.assertEqual([], actual)
+
+ def testFindDescendantComponents_SomeMatch(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ cd = tracker_pb2.ComponentDef(component_id=1, path='UI')
+ config.component_defs.append(cd)
+ cd2 = tracker_pb2.ComponentDef(component_id=2, path='UI>Splash')
+ config.component_defs.append(cd2)
+ actual = tracker_bizobj.FindDescendantComponents(config, cd)
+ self.assertEqual([cd2], actual)
+
+ def testMakeComponentDef(self):
+ cd = tracker_bizobj.MakeComponentDef(
+ 1, 789, 'UI', 'doc', False, [111], [222], 1234567890,
+ 111)
+ self.assertEqual(1, cd.component_id)
+ self.assertEqual([111], cd.admin_ids)
+ self.assertEqual([], cd.label_ids)
+
+ def testMakeSavedQuery_WithNone(self):
+ sq = tracker_bizobj.MakeSavedQuery(
+ None, 'my query', 2, 'priority:high')
+ self.assertEqual(None, sq.query_id)
+ self.assertEqual(None, sq.subscription_mode)
+ self.assertEqual([], sq.executes_in_project_ids)
+
+ def testMakeSavedQuery(self):
+ sq = tracker_bizobj.MakeSavedQuery(
+ 100, 'my query', 2, 'priority:high',
+ subscription_mode='immediate', executes_in_project_ids=[789])
+ self.assertEqual(100, sq.query_id)
+ self.assertEqual('immediate', sq.subscription_mode)
+ self.assertEqual([789], sq.executes_in_project_ids)
+
+ def testConvertDictToTemplate(self):
+ template = tracker_bizobj.ConvertDictToTemplate(
+ dict(name='name', content='content', summary='summary',
+ status='status', owner_id=111))
+ self.assertEqual('name', template.name)
+ self.assertEqual('content', template.content)
+ self.assertEqual('summary', template.summary)
+ self.assertEqual('status', template.status)
+ self.assertEqual(111, template.owner_id)
+ self.assertFalse(template.summary_must_be_edited)
+ self.assertTrue(template.owner_defaults_to_member)
+ self.assertFalse(template.component_required)
+
+ template = tracker_bizobj.ConvertDictToTemplate(
+ dict(name='name', content='content', labels=['a', 'b', 'c']))
+ self.assertListEqual(
+ ['a', 'b', 'c'], list(template.labels))
+
+ template = tracker_bizobj.ConvertDictToTemplate(
+ dict(name='name', content='content', summary_must_be_edited=True,
+ owner_defaults_to_member=True, component_required=True))
+ self.assertTrue(template.summary_must_be_edited)
+ self.assertTrue(template.owner_defaults_to_member)
+ self.assertTrue(template.component_required)
+
+ template = tracker_bizobj.ConvertDictToTemplate(
+ dict(name='name', content='content', summary_must_be_edited=False,
+ owner_defaults_to_member=False, component_required=False))
+ self.assertFalse(template.summary_must_be_edited)
+ self.assertFalse(template.owner_defaults_to_member)
+ self.assertFalse(template.component_required)
+
+ def CheckDefaultConfig(self, config):
+ self.assertTrue(len(config.well_known_statuses) > 0)
+ self.assertTrue(config.statuses_offer_merge > 0)
+ self.assertTrue(len(config.well_known_labels) > 0)
+ self.assertTrue(len(config.exclusive_label_prefixes) > 0)
+ # TODO(jrobbins): test actual values from default config
+
+ def testMakeDefaultProjectIssueConfig(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ config.default_template_for_developers = 1
+ config.default_template_for_users = 2
+ self.CheckDefaultConfig(config)
+
+ def testHarmonizeConfigs_Empty(self):
+ harmonized = tracker_bizobj.HarmonizeConfigs([])
+ self.CheckDefaultConfig(harmonized)
+
+ def testHarmonizeConfigs(self):
+ c1 = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ harmonized = tracker_bizobj.HarmonizeConfigs([c1])
+ self.assertListEqual(
+ [stat.status for stat in c1.well_known_statuses],
+ [stat.status for stat in harmonized.well_known_statuses])
+ self.assertListEqual(
+ [lab.label for lab in c1.well_known_labels],
+ [lab.label for lab in harmonized.well_known_labels])
+ self.assertEqual('', harmonized.default_sort_spec)
+
+ c2 = tracker_bizobj.MakeDefaultProjectIssueConfig(678)
+ tracker_bizobj.SetConfigStatuses(c2, [
+ ('Unconfirmed', '', True, False),
+ ('New', '', True, True),
+ ('Accepted', '', True, False),
+ ('Begun', '', True, False),
+ ('Fixed', '', False, False),
+ ('Obsolete', '', False, False)])
+ tracker_bizobj.SetConfigLabels(c2, [
+ ('Pri-0', '', False),
+ ('Priority-High', '', True),
+ ('Pri-1', '', False),
+ ('Priority-Medium', '', True),
+ ('Pri-2', '', False),
+ ('Priority-Low', '', True),
+ ('Pri-3', '', False),
+ ('Pri-4', '', False)])
+ c2.default_sort_spec = 'Pri -status'
+
+ c1.approval_defs = [
+ tracker_pb2.ApprovalDef(approval_id=1),
+ tracker_pb2.ApprovalDef(approval_id=3),
+ ]
+ c1.field_defs = [
+ tracker_pb2.FieldDef(
+ field_id=1, project_id=789, field_name='CowApproval',
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+ tracker_pb2.FieldDef(
+ field_id=3, project_id=789, field_name='MooApproval',
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)
+ ]
+ c2.approval_defs = [
+ tracker_pb2.ApprovalDef(approval_id=2),
+ ]
+ c2.field_defs = [
+ tracker_pb2.FieldDef(
+ field_id=2, project_id=788, field_name='CowApproval',
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE),
+ ]
+ harmonized = tracker_bizobj.HarmonizeConfigs([c1, c2])
+ result_statuses = [stat.status
+ for stat in harmonized.well_known_statuses]
+ result_labels = [lab.label
+ for lab in harmonized.well_known_labels]
+ self.assertListEqual(
+ ['Unconfirmed', 'New', 'Accepted', 'Begun', 'Started', 'Fixed',
+ 'Obsolete', 'Verified', 'Invalid', 'Duplicate', 'WontFix', 'Done'],
+ result_statuses)
+ self.assertListEqual(
+ ['Pri-0', 'Type-Defect', 'Type-Enhancement', 'Type-Task',
+ 'Type-Other', 'Priority-Critical', 'Priority-High',
+ 'Pri-1', 'Priority-Medium', 'Pri-2', 'Priority-Low', 'Pri-3',
+ 'Pri-4'],
+ result_labels[:result_labels.index('OpSys-All')])
+ self.assertEqual('Pri -status', harmonized.default_sort_spec.strip())
+ self.assertItemsEqual(c1.field_defs + c2.field_defs,
+ harmonized.field_defs)
+ self.assertItemsEqual(c1.approval_defs + c2.approval_defs,
+ harmonized.approval_defs)
+
+ def testHarmonizeConfigsMeansOpen(self):
+ c1 = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ c2 = tracker_bizobj.MakeDefaultProjectIssueConfig(678)
+ means_open = [("TT", True, True),
+ ("TF", True, False),
+ ("FT", False, True),
+ ("FF", False, False)]
+ tracker_bizobj.SetConfigStatuses(c1, [
+ (x[0], x[0], x[1], False)
+ for x in means_open])
+ tracker_bizobj.SetConfigStatuses(c2, [
+ (x[0], x[0], x[2], False)
+ for x in means_open])
+
+ harmonized = tracker_bizobj.HarmonizeConfigs([c1, c2])
+ for stat in harmonized.well_known_statuses:
+ self.assertEqual(stat.means_open, stat.status != "FF")
+
+ def testHarmonizeConfigs_DeletedCustomField(self):
+ """Only non-deleted custom fields in configs are included."""
+ harmonized = tracker_bizobj.HarmonizeConfigs([self.config])
+ self.assertEqual(1, len(harmonized.field_defs))
+
+ self.config.field_defs[0].is_deleted = True
+ harmonized = tracker_bizobj.HarmonizeConfigs([self.config])
+ self.assertEqual(0, len(harmonized.field_defs))
+
+ def testHarmonizeLabelOrStatusRows_Empty(self):
+ def_rows = []
+ actual = tracker_bizobj.HarmonizeLabelOrStatusRows(def_rows)
+ self.assertEqual([], actual)
+
+ def testHarmonizeLabelOrStatusRows_Normal(self):
+ def_rows = [
+ (100, 789, 1, 'Priority-High'),
+ (101, 789, 2, 'Priority-Normal'),
+ (103, 789, 3, 'Priority-Low'),
+ (199, 789, None, 'Monday'),
+ (200, 678, 1, 'Priority-High'),
+ (201, 678, 2, 'Priority-Medium'),
+ (202, 678, 3, 'Priority-Low'),
+ (299, 678, None, 'Hot'),
+ ]
+ actual = tracker_bizobj.HarmonizeLabelOrStatusRows(def_rows)
+ self.assertEqual(
+ [(199, None, 'Monday'),
+ (299, None, 'Hot'),
+ (200, 1, 'Priority-High'),
+ (100, 1, 'Priority-High'),
+ (101, 2, 'Priority-Normal'),
+ (201, 2, 'Priority-Medium'),
+ (202, 3, 'Priority-Low'),
+ (103, 3, 'Priority-Low')
+ ],
+ actual)
+
+ def testCombineOrderedLists_Empty(self):
+ self.assertEqual([], tracker_bizobj._CombineOrderedLists([]))
+
+ def testCombineOrderedLists_Normal(self):
+ a = ['Mon', 'Wed', 'Fri']
+ b = ['Mon', 'Tue']
+ c = ['Wed', 'Thu']
+ self.assertEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
+ tracker_bizobj._CombineOrderedLists([a, b, c]))
+
+ d = ['Mon', 'StartOfWeek', 'Wed', 'MidWeek', 'Fri', 'EndOfWeek']
+ self.assertEqual(['Mon', 'StartOfWeek', 'Tue', 'Wed', 'MidWeek', 'Thu',
+ 'Fri', 'EndOfWeek'],
+ tracker_bizobj._CombineOrderedLists([a, b, c, d]))
+
+ def testAccumulateCombinedList_Empty(self):
+ combined_items = []
+ combined_keys = []
+ seen_keys_set = set()
+ tracker_bizobj._AccumulateCombinedList(
+ [], combined_items, combined_keys, seen_keys_set)
+ self.assertEqual([], combined_items)
+ self.assertEqual([], combined_keys)
+ self.assertEqual(set(), seen_keys_set)
+
+ def testAccumulateCombinedList_Normal(self):
+ combined_items = ['a', 'b', 'C']
+ combined_keys = ['a', 'b', 'c'] # Keys are always lowercased
+ seen_keys_set = set(['a', 'b', 'c'])
+ tracker_bizobj._AccumulateCombinedList(
+ ['b', 'x', 'C', 'd', 'a'], combined_items, combined_keys, seen_keys_set)
+ self.assertEqual(['a', 'b', 'x', 'C', 'd'], combined_items)
+ self.assertEqual(['a', 'b', 'x', 'c', 'd'], combined_keys)
+ self.assertEqual(set(['a', 'b', 'x', 'c', 'd']), seen_keys_set)
+
+ def testAccumulateCombinedList_NormalWithKeyFunction(self):
+ combined_items = ['A', 'B', 'C']
+ combined_keys = ['@a', '@b', '@c']
+ seen_keys_set = set(['@a', '@b', '@c'])
+ tracker_bizobj._AccumulateCombinedList(
+ ['B', 'X', 'c', 'D', 'A'], combined_items, combined_keys, seen_keys_set,
+ key=lambda s: '@' + s)
+ self.assertEqual(['A', 'B', 'X', 'C', 'D'], combined_items)
+ self.assertEqual(['@a', '@b', '@x', '@c', '@d'], combined_keys)
+ self.assertEqual(set(['@a', '@b', '@x', '@c', '@d']), seen_keys_set)
+
+ def testGetBuiltInQuery(self):
+ self.assertEqual(
+ 'is:open', tracker_bizobj.GetBuiltInQuery(2))
+ self.assertEqual(
+ '', tracker_bizobj.GetBuiltInQuery(101))
+
+ def testUsersInvolvedInComment(self):
+ comment = tracker_pb2.IssueComment()
+ self.assertEqual({0}, tracker_bizobj.UsersInvolvedInComment(comment))
+
+ comment.user_id = 111
+ self.assertEqual(
+ {111}, tracker_bizobj.UsersInvolvedInComment(comment))
+
+ amendment = tracker_pb2.Amendment(newvalue='foo')
+ comment.amendments.append(amendment)
+ self.assertEqual(
+ {111}, tracker_bizobj.UsersInvolvedInComment(comment))
+
+ amendment.added_user_ids.append(222)
+ amendment.removed_user_ids.append(333)
+ self.assertEqual({111, 222, 333},
+ tracker_bizobj.UsersInvolvedInComment(comment))
+
+ def testUsersInvolvedInCommentList(self):
+ self.assertEqual(set(), tracker_bizobj.UsersInvolvedInCommentList([]))
+
+ c1 = tracker_pb2.IssueComment()
+ c1.user_id = 111
+ c1.amendments.append(tracker_pb2.Amendment(newvalue='foo'))
+
+ c2 = tracker_pb2.IssueComment()
+ c2.user_id = 111
+ c2.amendments.append(tracker_pb2.Amendment(
+ added_user_ids=[222], removed_user_ids=[333]))
+
+ self.assertEqual({111},
+ tracker_bizobj.UsersInvolvedInCommentList([c1]))
+
+ self.assertEqual({111, 222, 333},
+ tracker_bizobj.UsersInvolvedInCommentList([c2]))
+
+ self.assertEqual({111, 222, 333},
+ tracker_bizobj.UsersInvolvedInCommentList([c1, c2]))
+
+ def testUsersInvolvedInIssues_Empty(self):
+ self.assertEqual(set(), tracker_bizobj.UsersInvolvedInIssues([]))
+
+ def testUsersInvolvedInIssues_Normal(self):
+ av_1 = tracker_pb2.ApprovalValue(approver_ids=[666, 222, 444])
+ av_2 = tracker_pb2.ApprovalValue(approver_ids=[777], setter_id=888)
+ issue1 = tracker_pb2.Issue(
+ reporter_id=111, owner_id=222, cc_ids=[222, 333],
+ approval_values=[av_1, av_2])
+ issue2 = tracker_pb2.Issue(
+ reporter_id=333, owner_id=444, derived_cc_ids=[222, 444])
+ issue2.field_values = [tracker_pb2.FieldValue(user_id=555)]
+ self.assertEqual(
+ set([0, 111, 222, 333, 444, 555, 666, 777, 888]),
+ tracker_bizobj.UsersInvolvedInIssues([issue1, issue2]))
+
+ def testUsersInvolvedInTemplate_Empty(self):
+ template = tracker_bizobj.MakeIssueTemplate(
+ 'A report', 'Something went wrong', 'New', None, 'Look out!',
+ ['Priority-High'], [], [], [])
+ self.assertEqual(set(), tracker_bizobj.UsersInvolvedInTemplate(template))
+
+ def testUsersInvolvedInTemplate_Normal(self):
+ template = tracker_bizobj.MakeIssueTemplate(
+ 'A report', 'Something went wrong', 'New', 111, 'Look out!',
+ ['Priority-High'], [], [333, 444], [])
+ template.field_values = [
+ tracker_bizobj.MakeFieldValue(22, None, None, 222, None, None, False),
+ tracker_bizobj.MakeFieldValue(23, None, None, 333, None, None, False),
+ tracker_bizobj.MakeFieldValue(24, None, None, 222, None, None, False),
+ tracker_bizobj.MakeFieldValue(25, None, 'pop', None, None, None, False)]
+ template.approval_values = [
+ tracker_pb2.ApprovalValue(
+ approval_id=30, setter_id=666, approver_ids=[444, 555]),
+ tracker_pb2.ApprovalValue(approval_id=31),
+ ]
+ self.assertEqual(
+ {111, 333, 444, 222, 555, 666},
+ tracker_bizobj.UsersInvolvedInTemplate(template))
+
+ def testUsersInvolvedInTemplates_NoTemplates(self):
+ self.assertEqual(set(), tracker_bizobj.UsersInvolvedInTemplates([]))
+
+ def testUsersInvolvedInTemplates_Normal(self):
+ template1 = tracker_bizobj.MakeIssueTemplate(
+ 'A report', 'Something went wrong', 'New', 111, 'Look out!',
+ ['Priority-High'], [], [333, 444], [])
+ template1.field_values = [
+ tracker_bizobj.MakeFieldValue(22, None, None, 222, None, None, False)]
+
+ template2 = tracker_bizobj.MakeIssueTemplate(
+ 'dude', 'wheres my', 'New', 222, 'car', [], [], [999, 888], [])
+ template2.field_values = [
+ tracker_bizobj.MakeFieldValue(23, None, None, 333, None, None, False)]
+ template2.approval_values = [
+ tracker_pb2.ApprovalValue(
+ approval_id=30, setter_id=666, approver_ids=[444, 555]),
+ tracker_pb2.ApprovalValue(approval_id=31)]
+
+ self.assertEqual(
+ {111, 333, 444, 222, 555, 666, 888, 999},
+ tracker_bizobj.UsersInvolvedInTemplates([template1, template2]))
+
+ def testUsersInvolvedInApprovalDefs_Empty(self):
+ """There are no user IDs given empty inputs"""
+ actual = tracker_bizobj.UsersInvolvedInApprovalDefs([], [])
+ self.assertEqual(set(), actual)
+
+ def testsersInvolvedInApprovalDefs_Normal(self):
+ """We find user IDs mentioned in approval approvers and field admins"""
+ self.config.field_defs[0].admin_ids = [111, 222]
+ approval_def = tracker_pb2.ApprovalDef(
+ approval_id=1, approver_ids=[111, 333], survey='')
+ self.config.approval_defs = [approval_def]
+ actual = tracker_bizobj.UsersInvolvedInApprovalDefs(
+ [approval_def], [self.config.field_defs[0]])
+ self.assertEqual({111, 222, 333}, actual)
+
+ def testUsersInvolvedInConfig_Empty(self):
+ """There are no user IDs mentioned in a default config."""
+ actual = tracker_bizobj.UsersInvolvedInConfig(self.config)
+ self.assertEqual(set(), actual)
+
+ def testUsersInvolvedInConfig_Normal(self):
+ """We find user IDs mentioned components, fields, and approvals."""
+ self.config.component_defs[0].admin_ids = [111]
+ self.config.component_defs[0].cc_ids = [444]
+ self.config.field_defs[0].admin_ids = [111, 222]
+ approval_def = tracker_pb2.ApprovalDef(
+ approval_id=1, approver_ids=[111, 333], survey='')
+ self.config.approval_defs = [approval_def]
+ actual = tracker_bizobj.UsersInvolvedInConfig(self.config)
+ self.assertEqual({111, 222, 333, 444}, actual)
+
+ def testLabelIDsInvolvedInConfig_Empty(self):
+ """There are no label IDs mentioned in a default config."""
+ actual = tracker_bizobj.LabelIDsInvolvedInConfig(self.config)
+ self.assertEqual(set(), actual)
+
+ def testLabelIDsInvolvedInConfig_Normal(self):
+ """We find label IDs added by components."""
+ self.config.component_defs[0].label_ids = [1, 2, 3]
+ actual = tracker_bizobj.LabelIDsInvolvedInConfig(self.config)
+ self.assertEqual({1, 2, 3}, actual)
+
+ def testMakeApprovalDelta_AllSpecified(self):
+ added_fv = tracker_bizobj.MakeFieldValue(
+ 1, None, 'added str', None, None, None, False)
+ removed_fv = tracker_bizobj.MakeFieldValue(
+ 1, None, 'removed str', None, None, None, False)
+ clear_fvs = [24]
+ labels_add = ['ittly-bittly', 'piggly-wiggly']
+ labels_remove = ['golly-goops', 'whoopsie']
+ actual = tracker_bizobj.MakeApprovalDelta(
+ tracker_pb2.ApprovalStatus.APPROVED, 111, [222], [],
+ [added_fv], [removed_fv], clear_fvs, labels_add, labels_remove,
+ set_on=1234)
+ self.assertEqual(actual.status, tracker_pb2.ApprovalStatus.APPROVED)
+ self.assertEqual(actual.setter_id, 111)
+ self.assertEqual(actual.set_on, 1234)
+ self.assertEqual(actual.subfield_vals_add, [added_fv])
+ self.assertEqual(actual.subfield_vals_remove, [removed_fv])
+ self.assertEqual(actual.subfields_clear, clear_fvs)
+ self.assertEqual(actual.labels_add, labels_add)
+ self.assertEqual(actual.labels_remove, labels_remove)
+
+ def testMakeApprovalDelta_WithNones(self):
+ added_fv = tracker_bizobj.MakeFieldValue(
+ 1, None, 'added str', None, None, None, False)
+ removed_fv = tracker_bizobj.MakeFieldValue(
+ 1, None, 'removed str', None, None, None, False)
+ clear_fields = [2]
+ labels_add = ['ittly-bittly', 'piggly-wiggly']
+ labels_remove = ['golly-goops', 'whoopsie']
+ actual = tracker_bizobj.MakeApprovalDelta(
+ None, 111, [222], [],
+ [added_fv], [removed_fv], clear_fields,
+ labels_add, labels_remove)
+ self.assertIsNone(actual.status)
+ self.assertIsNone(actual.setter_id)
+ self.assertIsNone(actual.set_on)
+
+ def testMakeIssueDelta_AllSpecified(self):
+ added_fv = tracker_bizobj.MakeFieldValue(
+ 1, None, 'added str', None, None, None, False)
+ removed_fv = tracker_bizobj.MakeFieldValue(
+ 1, None, 'removed str', None, None, None, False)
+ actual = tracker_bizobj.MakeIssueDelta(
+ 'New', 111, [222], [333], [1], [2],
+ ['AddedLabel'], ['RemovedLabel'], [added_fv], [removed_fv],
+ [3], [78901], [78902], [78903], [78904], 78905,
+ 'New summary',
+ ext_blocked_on_add=['b/123', 'b/234'],
+ ext_blocked_on_remove=['b/345', 'b/456'],
+ ext_blocking_add=['b/567', 'b/678'],
+ ext_blocking_remove=['b/789', 'b/890'])
+ self.assertEqual('New', actual.status)
+ self.assertEqual(111, actual.owner_id)
+ self.assertEqual([222], actual.cc_ids_add)
+ self.assertEqual([333], actual.cc_ids_remove)
+ self.assertEqual([1], actual.comp_ids_add)
+ self.assertEqual([2], actual.comp_ids_remove)
+ self.assertEqual(['AddedLabel'], actual.labels_add)
+ self.assertEqual(['RemovedLabel'], actual.labels_remove)
+ self.assertEqual([added_fv], actual.field_vals_add)
+ self.assertEqual([removed_fv], actual.field_vals_remove)
+ self.assertEqual([3], actual.fields_clear)
+ self.assertEqual([78901], actual.blocked_on_add)
+ self.assertEqual([78902], actual.blocked_on_remove)
+ self.assertEqual([78903], actual.blocking_add)
+ self.assertEqual([78904], actual.blocking_remove)
+ self.assertEqual(78905, actual.merged_into)
+ self.assertEqual('New summary', actual.summary)
+ self.assertEqual(['b/123', 'b/234'], actual.ext_blocked_on_add)
+ self.assertEqual(['b/345', 'b/456'], actual.ext_blocked_on_remove)
+ self.assertEqual(['b/567', 'b/678'], actual.ext_blocking_add)
+ self.assertEqual(['b/789', 'b/890'], actual.ext_blocking_remove)
+
+ def testMakeIssueDelta_WithNones(self):
+ """None for status, owner_id, or summary does not set a value."""
+ actual = tracker_bizobj.MakeIssueDelta(
+ None, None, [], [], [], [],
+ [], [], [], [],
+ [], [], [], [], [], None,
+ None)
+ self.assertIsNone(actual.status)
+ self.assertIsNone(actual.owner_id)
+ self.assertIsNone(actual.merged_into)
+ self.assertIsNone(actual.summary)
+
+ def testApplyLabelChanges_RemoveAndAdd(self):
+ issue = tracker_pb2.Issue(
+ labels=['tobe-removed', 'tobe-notremoved', 'tobe-removed-2'])
+ amendment = tracker_bizobj.ApplyLabelChanges(
+ issue, self.config,
+ [u'tobe-added', 'to:be-added-2'],
+ [u'tobe-removed', u'to:be-removed-2'])
+ self.assertEqual(amendment, tracker_bizobj.MakeLabelsAmendment(
+ ['tobe-added', 'tobe-added-2'], ['tobe-removed', 'tobe-removed-2']))
+
+ def testApplyLabelChanges_RemoveInvalidLabel(self):
+ issue = tracker_pb2.Issue(labels=[])
+ amendment = tracker_bizobj.ApplyLabelChanges(
+ issue, self.config, [], [u'lost-car'])
+ self.assertIsNone(amendment)
+
+ def testApplyLabelChanges_NoChangesAfterMerge(self):
+ issue = tracker_pb2.Issue(labels=['lost-car'])
+ amendment = tracker_bizobj.ApplyLabelChanges(
+ issue, self.config, [u'lost-car'], [])
+ self.assertIsNone(amendment)
+
+ def testApplyLabelChanges_Empty(self):
+ issue = tracker_pb2.Issue(labels=[])
+ amendment = tracker_bizobj.ApplyLabelChanges(issue, self.config, [], [])
+ self.assertIsNone(amendment)
+
+ def testApplyFieldValueChanges(self):
+ self.config.field_defs = [
+ tracker_pb2.FieldDef(
+ field_id=1, project_id=789, field_name='EstDays',
+ field_type=tracker_pb2.FieldTypes.INT_TYPE),
+ tracker_pb2.FieldDef(
+ field_id=2, project_id=789, field_name='SleepHrs',
+ field_type=tracker_pb2.FieldTypes.INT_TYPE, is_phase_field=True),
+ tracker_pb2.FieldDef(
+ field_id=3, project_id=789, field_name='Chickens',
+ field_type=tracker_pb2.FieldTypes.STR_TYPE, is_phase_field=True,
+ is_multivalued=True),
+ ]
+ original_keep = [
+ tracker_pb2.FieldValue(field_id=3, str_value='bok', phase_id=45)]
+ original_replace = [
+ tracker_pb2.FieldValue(field_id=1, int_value=72),
+ tracker_pb2.FieldValue(field_id=2, int_value=88, phase_id=44)]
+ original_remove = [
+ tracker_pb2.FieldValue(field_id=3, str_value='removedbok', phase_id=45),
+ ]
+ issue = tracker_pb2.Issue(
+ phases=[
+ tracker_pb2.Phase(phase_id=45, name='high-school'),
+ tracker_pb2.Phase(phase_id=44, name='college')])
+ issue.field_values = original_keep + original_replace + original_remove
+
+ fvs_add_ignore = [
+ tracker_pb2.FieldValue(field_id=3, str_value='egg', phase_id=42)]
+ fvs_add = [
+ tracker_pb2.FieldValue(field_id=1, int_value=73), # replace
+ tracker_pb2.FieldValue(field_id=2, int_value=99, phase_id=44), #replace
+ tracker_pb2.FieldValue(field_id=2, int_value=100, phase_id=45), # added
+ # added
+ tracker_pb2.FieldValue(field_id=3, str_value='rooster', phase_id=45),
+ ]
+ fvs_remove = original_remove
+ fields_clear = []
+ amendments = tracker_bizobj.ApplyFieldValueChanges(
+ issue, self.config, fvs_add+fvs_add_ignore, fvs_remove, fields_clear)
+
+ self.assertEqual(
+ amendments,
+ [tracker_bizobj.MakeFieldAmendment(1, self.config, [73]),
+ tracker_bizobj.MakeFieldAmendment(
+ 2, self.config, [99], phase_name='college'),
+ tracker_bizobj.MakeFieldAmendment(
+ 2, self.config, [100], phase_name='high-school'),
+ tracker_bizobj.MakeFieldAmendment(
+ 3, self.config, ['rooster'], old_values=['removedbok'],
+ phase_name='high-school')])
+ self.assertEqual(issue.field_values, original_keep + fvs_add)
+
+ def testApplyIssueDelta_NoChange(self):
+ """A delta with no change should change nothing."""
+ issue = tracker_pb2.Issue(
+ status='New', owner_id=111, cc_ids=[222], labels=['a', 'b'],
+ component_ids=[1], blocked_on_iids=[78902], blocking_iids=[78903],
+ merged_into=78904, summary='Sum')
+ delta = tracker_pb2.IssueDelta()
+
+ amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ self.cnxn, self.services.issue, issue, delta, self.config)
+
+ self.assertEqual('New', issue.status)
+ self.assertEqual(111, issue.owner_id)
+ self.assertEqual([222], issue.cc_ids)
+ self.assertEqual(['a', 'b'], issue.labels)
+ self.assertEqual([1], issue.component_ids)
+ self.assertEqual([78902], issue.blocked_on_iids)
+ self.assertEqual([78903], issue.blocking_iids)
+ self.assertEqual(78904, issue.merged_into)
+ self.assertEqual('Sum', issue.summary)
+
+ self.assertEqual(0, len(amendments))
+ self.assertEqual(0, len(impacted_iids))
+
+ def testApplyIssueDelta_BuiltInFields(self):
+ """A delta can change built-in fields."""
+ ref_issue_70 = fake.MakeTestIssue(
+ 789, 70, 'Something that must be done before', 'New', 111)
+ self.services.issue.TestAddIssue(ref_issue_70)
+ ref_issue_71 = fake.MakeTestIssue(
+ 789, 71, 'Something that can only be done after', 'New', 111)
+ self.services.issue.TestAddIssue(ref_issue_71)
+ ref_issue_72 = fake.MakeTestIssue(
+ 789, 72, 'Something that seems the same', 'New', 111)
+ self.services.issue.TestAddIssue(ref_issue_72)
+ ref_issue_73 = fake.MakeTestIssue(
+ 789, 73, 'Something that used to seem the same', 'New', 111)
+ self.services.issue.TestAddIssue(ref_issue_73)
+ issue = tracker_pb2.Issue(
+ status='New', owner_id=111, cc_ids=[222], labels=['a', 'b'],
+ component_ids=[1], blocked_on_iids=[78902], blocking_iids=[78903],
+ merged_into=ref_issue_73.issue_id, summary='Sum')
+ delta = tracker_pb2.IssueDelta(
+ status='Duplicate', owner_id=999, cc_ids_add=[333, 444],
+ comp_ids_add=[2], labels_add=['c', 'd'],
+ blocked_on_add=[ref_issue_70.issue_id],
+ blocking_add=[ref_issue_71.issue_id],
+ merged_into=ref_issue_72.issue_id, summary='New summary')
+
+ actual_amendments, actual_impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ self.cnxn, self.services.issue, issue, delta, self.config)
+
+ self.assertEqual('Duplicate', issue.status)
+ self.assertEqual(999, issue.owner_id)
+ self.assertEqual([222, 333, 444], issue.cc_ids)
+ self.assertEqual([1, 2], issue.component_ids)
+ self.assertEqual(['a', 'b', 'c', 'd'], issue.labels)
+ self.assertEqual([78902, ref_issue_70.issue_id], issue.blocked_on_iids)
+ self.assertEqual([78903, ref_issue_71.issue_id], issue.blocking_iids)
+ self.assertEqual(ref_issue_72.issue_id, issue.merged_into)
+ self.assertEqual('New summary', issue.summary)
+
+ self.assertEqual(
+ [tracker_bizobj.MakeStatusAmendment('Duplicate', 'New'),
+ tracker_bizobj.MakeOwnerAmendment(999, 111),
+ tracker_bizobj.MakeCcAmendment([333, 444], []),
+ tracker_bizobj.MakeComponentsAmendment([2], [], self.config),
+ tracker_bizobj.MakeLabelsAmendment(['c', 'd'], []),
+ tracker_bizobj.MakeBlockedOnAmendment([(None, 70)], []),
+ tracker_bizobj.MakeBlockingAmendment([(None, 71)], []),
+ tracker_bizobj.MakeMergedIntoAmendment([(None, 72)], [(None, 73)]),
+ tracker_bizobj.MakeSummaryAmendment('New summary', 'Sum'),
+ ],
+ actual_amendments)
+ self.assertEqual(
+ set([ref_issue_70.issue_id, ref_issue_71.issue_id,
+ ref_issue_72.issue_id, ref_issue_73.issue_id]),
+ actual_impacted_iids)
+
+ def testApplyIssueDelta_ReferrencedIssueNotFound(self):
+ """This part of the code copes with missing issues."""
+ issue = tracker_pb2.Issue(
+ status='New', owner_id=111, cc_ids=[222], labels=['a', 'b'],
+ component_ids=[1], blocked_on_iids=[78902], blocking_iids=[78903],
+ merged_into=78904, summary='Sum')
+ delta = tracker_pb2.IssueDelta(
+ blocked_on_add=[78905], blocked_on_remove=[78902],
+ blocking_add=[78906], blocking_remove=[78903],
+ merged_into=78907)
+
+ actual_amendments, actual_impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ self.cnxn, self.services.issue, issue, delta, self.config)
+
+ self.assertEqual([78905], issue.blocked_on_iids)
+ self.assertEqual([78906], issue.blocking_iids)
+ self.assertEqual(78907, issue.merged_into)
+
+ self.assertEqual(
+ [tracker_bizobj.MakeBlockedOnAmendment([], []),
+ tracker_bizobj.MakeBlockingAmendment([], []),
+ tracker_bizobj.MakeMergedIntoAmendment([], []),
+ ],
+ actual_amendments)
+ self.assertEqual(
+ set([78902, 78903, 78905, 78906]),
+ actual_impacted_iids)
+
+ def testApplyIssueDelta_CustomPhaseFields(self):
+ """A delta can add, remove, or clear custom phase fields."""
+ fd_a = tracker_pb2.FieldDef(
+ field_id=1, project_id=789, field_name='a',
+ field_type=tracker_pb2.FieldTypes.INT_TYPE,
+ is_multivalued=True, is_phase_field=True)
+ fd_b = tracker_pb2.FieldDef(
+ field_id=2, project_id=789, field_name='b',
+ field_type=tracker_pb2.FieldTypes.INT_TYPE,
+ is_phase_field=True)
+ fd_c = tracker_pb2.FieldDef(
+ field_id=3, project_id=789, field_name='c',
+ field_type=tracker_pb2.FieldTypes.INT_TYPE, is_phase_field=True)
+ self.config.field_defs = [fd_a, fd_b, fd_c]
+ fv_a1_p1 = tracker_pb2.FieldValue(
+ field_id=1, int_value=1, phase_id=1) # fv
+ fv_a2_p1 = tracker_pb2.FieldValue(
+ field_id=1, int_value=2, phase_id=1) # add
+ fv_a3_p1 = tracker_pb2.FieldValue(
+ field_id=1, int_value=3, phase_id=1) # add
+ fv_b1_p1 = tracker_pb2.FieldValue(
+ field_id=2, int_value=1, phase_id=1) # add
+ fv_c2_p1 = tracker_pb2.FieldValue(
+ field_id=3, int_value=2, phase_id=1) # clear
+
+ fv_a2_p2 = tracker_pb2.FieldValue(
+ field_id=1, int_value=2, phase_id=2) # add
+ fv_b1_p2 = tracker_pb2.FieldValue(
+ field_id=2, int_value=1, phase_id=2) # fv remove
+ fv_c1_p2 = tracker_pb2.FieldValue(
+ field_id=3, int_value=1, phase_id=2) # clear
+
+ issue = tracker_pb2.Issue(
+ status='New', owner_id=111, summary='Sum',
+ field_values=[fv_a1_p1, fv_c2_p1, fv_b1_p2, fv_c1_p2])
+ issue.phases = [
+ tracker_pb2.Phase(phase_id=1, name='Phase-1'),
+ tracker_pb2.Phase(phase_id=2, name='Phase-2')]
+
+ delta = tracker_pb2.IssueDelta(
+ field_vals_add=[fv_a2_p1, fv_a3_p1, fv_b1_p1, fv_a2_p2],
+ field_vals_remove=[fv_b1_p2], fields_clear=[3])
+
+ actual_amendments, actual_impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ self.cnxn, self.services.issue, issue, delta, self.config)
+ self.assertEqual(
+ [tracker_bizobj.MakeFieldAmendment(
+ 1, self.config, ['2', '3'], [], phase_name='Phase-1'),
+ tracker_bizobj.MakeFieldAmendment(
+ 1, self.config, ['2'], [], phase_name='Phase-2'),
+ tracker_bizobj.MakeFieldAmendment(
+ 2, self.config, ['1'], [], phase_name='Phase-1'),
+ tracker_bizobj.MakeFieldAmendment(
+ 2, self.config, [], ['1'], phase_name='Phase-2'),
+ tracker_bizobj.MakeFieldClearedAmendment(3, self.config)],
+ actual_amendments)
+ self.assertEqual(set(), actual_impacted_iids)
+
+ def testApplyIssueDelta_CustomFields(self):
+ """A delta can add, remove, or clear custom fields."""
+ fd_a = tracker_pb2.FieldDef(
+ field_id=1, project_id=789, field_name='a',
+ field_type=tracker_pb2.FieldTypes.INT_TYPE,
+ is_multivalued=True)
+ fd_b = tracker_pb2.FieldDef(
+ field_id=2, project_id=789, field_name='b',
+ field_type=tracker_pb2.FieldTypes.INT_TYPE)
+ fd_c = tracker_pb2.FieldDef(
+ field_id=3, project_id=789, field_name='c',
+ field_type=tracker_pb2.FieldTypes.INT_TYPE)
+ fd_d = tracker_pb2.FieldDef(
+ field_id=4, project_id=789, field_name='d',
+ field_type=tracker_pb2.FieldTypes.ENUM_TYPE)
+ self.config.field_defs = [fd_a, fd_b, fd_c, fd_d]
+ fv_a1 = tracker_pb2.FieldValue(field_id=1, int_value=1)
+ fv_a2 = tracker_pb2.FieldValue(field_id=1, int_value=2)
+ fv_b1 = tracker_pb2.FieldValue(field_id=2, int_value=1)
+ fv_c1 = tracker_pb2.FieldValue(field_id=3, int_value=1)
+ issue = tracker_pb2.Issue(
+ status='New', owner_id=111, labels=['d-val', 'Hot'], summary='Sum',
+ field_values=[fv_a1, fv_b1, fv_c1])
+ delta = tracker_pb2.IssueDelta(
+ field_vals_add=[fv_a2], field_vals_remove=[fv_b1], fields_clear=[3, 4])
+
+ actual_amendments, actual_impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ self.cnxn, self.services.issue, issue, delta, self.config)
+
+ self.assertEqual([fv_a1, fv_a2], issue.field_values)
+ self.assertEqual(['Hot'], issue.labels)
+
+ self.assertEqual(
+ [tracker_bizobj.MakeFieldAmendment(1, self.config, ['2'], []),
+ tracker_bizobj.MakeFieldAmendment(2, self.config, [], ['1']),
+ tracker_bizobj.MakeFieldClearedAmendment(3, self.config),
+ tracker_bizobj.MakeFieldClearedAmendment(4, self.config),
+ ],
+ actual_amendments)
+ self.assertEqual(set(), actual_impacted_iids)
+
+ def testApplyIssueDelta_ExternalRefs(self):
+ """Only applies valid issue refs from a delta."""
+ issue = tracker_pb2.Issue(
+ status='New', owner_id=111, cc_ids=[222], labels=['a', 'b'],
+ component_ids=[1], blocked_on_iids=[78902], blocking_iids=[78903],
+ merged_into=78904, summary='Sum',
+ dangling_blocked_on_refs=[
+ tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/345'),
+ tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111')],
+ dangling_blocking_refs=[
+ tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/789'),
+ tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222')])
+ delta = tracker_pb2.IssueDelta(
+ # Add one valid, one invalid, and another valid.
+ ext_blocked_on_add=['b/123', 'b123', 'b/234'],
+ # Remove one valid, one invalid, and one that does not exist.
+ ext_blocked_on_remove=['b/345', 'b', 'b/456'],
+ # Add one valid, one invalid, and another valid.
+ ext_blocking_add=['b/567', 'b//123', 'b/678'],
+ # Remove one valid, one invalid, and one that does not exist.
+ ext_blocking_remove=['b/789', 'b/123/123', 'b/890'])
+
+ amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ self.cnxn, self.services.issue, issue, delta, self.config)
+
+ # Test amendments.
+ self.assertEqual(2, len(amendments))
+ self.assertEqual(tracker_pb2.FieldID.BLOCKEDON, amendments[0].field)
+ self.assertEqual('-b/345 b/123 b/234', amendments[0].newvalue)
+ self.assertEqual(tracker_pb2.FieldID.BLOCKING, amendments[1].field)
+ self.assertEqual('-b/789 b/567 b/678', amendments[1].newvalue)
+
+ self.assertEqual(0, len(impacted_iids))
+
+ # Issue refs are applied correctly and alphabetized.
+ self.assertEqual([
+ tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111'),
+ tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/123'),
+ tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/234'),
+ ], issue.dangling_blocked_on_refs)
+ self.assertEqual([
+ tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222'),
+ tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/567'),
+ tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/678'),
+ ], issue.dangling_blocking_refs)
+
+ def testApplyIssueDelta_AddAndRemoveExtRef(self):
+ """Only applies valid issue refs from a delta."""
+ issue = tracker_pb2.Issue(
+ status='New',
+ summary='Sum',
+ dangling_blocked_on_refs=[
+ tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111')
+ ],
+ dangling_blocking_refs=[
+ tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222')
+ ])
+ delta = tracker_pb2.IssueDelta(
+ ext_blocked_on_add=['b/123'],
+ ext_blocked_on_remove=['b/123'],
+ ext_blocking_add=['b/456'],
+ ext_blocking_remove=['b/456'])
+
+ amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ self.cnxn, self.services.issue, issue, delta, self.config)
+
+ # Nothing changed.
+ self.assertEqual(0, len(amendments))
+ self.assertEqual(0, len(impacted_iids))
+
+ self.assertEqual(
+ [tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111')],
+ issue.dangling_blocked_on_refs)
+ self.assertEqual(
+ [tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222')],
+ issue.dangling_blocking_refs)
+
+ def testApplyIssueDelta_OnlyInvalidExternalRefs(self):
+ """Only applies valid issue refs from a delta."""
+ issue = tracker_pb2.Issue(
+ status='New',
+ summary='Sum',
+ dangling_blocked_on_refs=[
+ tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111')
+ ],
+ dangling_blocking_refs=[
+ tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222')
+ ])
+ delta = tracker_pb2.IssueDelta(
+ # Add one invalid and one that already exists.
+ ext_blocked_on_add=['b123', 'b/111'],
+ # Remove one invalid, and one that does not exist.
+ ext_blocked_on_remove=['b', 'b/456'],
+ # Add one invalid and one that already exists.
+ ext_blocking_add=['b//123', 'b/222'],
+ # Remove one invalid, and one that does not exist.
+ ext_blocking_remove=['b/123/123', 'b/890'])
+
+ amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ self.cnxn, self.services.issue, issue, delta, self.config)
+
+ # Nothing changed.
+ self.assertEqual(0, len(amendments))
+ self.assertEqual(0, len(impacted_iids))
+
+ self.assertEqual(
+ [tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/111')],
+ issue.dangling_blocked_on_refs)
+ self.assertEqual(
+ [tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/222')],
+ issue.dangling_blocking_refs)
+
+ def testApplyIssueDelta_MergedIntoExternal(self):
+ """ApplyIssueDelta applies valid mergedinto refs."""
+ issue = tracker_pb2.Issue(status='New', owner_id=111)
+ delta = tracker_pb2.IssueDelta(merged_into_external='b/5678')
+ amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ self.cnxn, self.services.issue, issue, delta, self.config)
+
+ # Test amendments.
+ self.assertEqual(1, len(amendments))
+ self.assertEqual(tracker_pb2.FieldID.MERGEDINTO, amendments[0].field)
+ self.assertEqual('b/5678', amendments[0].newvalue)
+
+ self.assertEqual(0, len(impacted_iids))
+
+ # Issue refs are applied correctly and alphabetized.
+ self.assertEqual('b/5678', issue.merged_into_external)
+
+ def testApplyIssueDelta_MergedIntoExternalInvalid(self):
+ """ApplyIssueDelta does not accept invalid mergedinto refs."""
+ issue = tracker_pb2.Issue(status='New', owner_id=111)
+ delta = tracker_pb2.IssueDelta(merged_into_external='a/5678')
+ amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ self.cnxn, self.services.issue, issue, delta, self.config)
+
+ # No change.
+ self.assertEqual(0, len(amendments))
+ self.assertEqual(0, len(impacted_iids))
+ self.assertEqual(None, issue.merged_into_external)
+
+ def testApplyIssueDelta_MergedIntoFromInternalToExternal(self):
+ """ApplyIssueDelta updates from an internal to an external ref."""
+ self.services.issue.TestAddIssue(fake.MakeTestIssue(1, 2, 'Summary',
+ 'New', 111, issue_id=6789))
+ issue = tracker_pb2.Issue(status='New', owner_id=111, merged_into=6789)
+ delta = tracker_pb2.IssueDelta(merged_into_external='b/5678')
+ amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ self.cnxn, self.services.issue, issue, delta, self.config)
+
+ # Test amendments.
+ self.assertEqual(1, len(amendments))
+ self.assertEqual(tracker_pb2.FieldID.MERGEDINTO, amendments[0].field)
+ self.assertEqual('-2 b/5678', amendments[0].newvalue)
+ self.assertEqual(set([6789]), impacted_iids)
+ self.assertEqual(0, issue.merged_into)
+ self.assertEqual('b/5678', issue.merged_into_external)
+
+ def testApplyIssueDelta_MergedIntoFromExternalToInternal(self):
+ """ApplyIssueDelta updates from an external to an internalref."""
+ self.services.issue.TestAddIssue(fake.MakeTestIssue(1, 2, 'Summary',
+ 'New', 111, issue_id=6789))
+ issue = tracker_pb2.Issue(status='New', owner_id=111,
+ merged_into_external='b/5678')
+ delta = tracker_pb2.IssueDelta(merged_into=6789)
+ amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ self.cnxn, self.services.issue, issue, delta, self.config)
+
+ # Test amendments.
+ self.assertEqual(1, len(amendments))
+ self.assertEqual(tracker_pb2.FieldID.MERGEDINTO, amendments[0].field)
+ self.assertEqual('-b/5678 2', amendments[0].newvalue)
+ self.assertEqual(set([6789]), impacted_iids)
+ self.assertEqual(6789, issue.merged_into)
+ self.assertEqual(None, issue.merged_into_external)
+
+ def testApplyIssueDelta_MergedIntoFromExternalToExternal(self):
+ """ApplyIssueDelta updates from an external to another external ref."""
+ issue = tracker_pb2.Issue(
+ status='New', owner_id=111, merged_into_external='b/1')
+ delta = tracker_pb2.IssueDelta(merged_into_external='b/5678')
+ amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ self.cnxn, self.services.issue, issue, delta, self.config)
+
+ # Test amendments.
+ self.assertEqual(1, len(amendments))
+ self.assertEqual(tracker_pb2.FieldID.MERGEDINTO, amendments[0].field)
+ self.assertEqual('-b/1 b/5678', amendments[0].newvalue)
+ self.assertEqual(set(), impacted_iids)
+ self.assertEqual(0, issue.merged_into)
+ self.assertEqual('b/5678', issue.merged_into_external)
+
+ def testApplyIssueDelta_NoMergedIntoInternalAndExternal(self):
+ """ApplyIssueDelta does not allow updating the internal and external
+ merged_into fields at the same time.
+ """
+ issue = tracker_pb2.Issue(status='New', owner_id=111, merged_into=321)
+ delta = tracker_pb2.IssueDelta(merged_into=543,
+ merged_into_external='b/5678')
+ with self.assertRaises(ValueError):
+ tracker_bizobj.ApplyIssueDelta(self.cnxn, self.services.issue, issue,
+ delta, self.config)
+
+ def testApplyIssueDelta_RemoveExistingMergedInto(self):
+ self.services.issue.TestAddIssue(
+ fake.MakeTestIssue(1, 2, 'Summary', 'New', 111, issue_id=6789))
+ issue = tracker_pb2.Issue(status='New', owner_id=111, merged_into=6789)
+ delta = tracker_pb2.IssueDelta(merged_into=0)
+ amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ self.cnxn, self.services.issue, issue, delta, self.config)
+ self.assertEqual(impacted_iids, {6789})
+ self.assertEqual(1, len(amendments))
+ self.assertEqual(
+ amendments[0],
+ tracker_bizobj.MakeMergedIntoAmendment(
+ [], [(issue.project_name, 2)],
+ default_project_name=issue.project_name))
+ self.assertEqual(issue.merged_into, 0)
+
+ def testApplyIssueDelta_RemoveExternalMergedInto(self):
+ issue = tracker_pb2.Issue(
+ status='New', owner_id=111, merged_into_external='b/123')
+ delta = tracker_pb2.IssueDelta(merged_into_external='')
+ amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ self.cnxn, self.services.issue, issue, delta, self.config)
+ self.assertEqual(impacted_iids, set())
+ self.assertEqual(1, len(amendments))
+ self.assertEqual(
+ amendments[0],
+ tracker_bizobj.MakeMergedIntoAmendment(
+ [], [tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/123')]))
+ self.assertEqual(issue.merged_into_external, '')
+
+ def testApplyIssueDelta_RemoveMergedIntoNoop(self):
+ issue = tracker_pb2.Issue(
+ status='New', owner_id=111, merged_into_external='b/123')
+ delta = tracker_pb2.IssueDelta(merged_into=0)
+ amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ self.cnxn, self.services.issue, issue, delta, self.config)
+ self.assertEqual(impacted_iids, set())
+ self.assertEqual(0, len(amendments))
+ # A noop request to remove merged_into, should not affect the existing
+ # external value.
+ self.assertIsNone(issue.merged_into)
+ self.assertEqual(issue.merged_into_external, 'b/123')
+
+ def testApplyIssueDelta_RemoveExternalMergedIntoNoop(self):
+ self.services.issue.TestAddIssue(
+ fake.MakeTestIssue(1, 2, 'Summary', 'New', 111, issue_id=6789))
+ issue = tracker_pb2.Issue(status='New', owner_id=111, merged_into=6789)
+ delta = tracker_pb2.IssueDelta(merged_into_external='')
+ amendments, impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ self.cnxn, self.services.issue, issue, delta, self.config)
+ self.assertEqual(impacted_iids, set())
+ self.assertEqual(len(amendments), 0)
+ # A noop request to remove merged_into_external, should not affect the
+ # existing internal merged_into value.
+ self.assertIsNone(issue.merged_into_external)
+ self.assertEqual(issue.merged_into, 6789)
+
+ def testApplyIssueBlockRelationChanges(self):
+ """We can apply blocking and blocked_on relation changes to an issue."""
+
+ blocked_on = fake.MakeTestIssue(
+ 789, 2, 'Something that must be done before', 'New', 111,
+ project_name='proj')
+ self.services.issue.TestAddIssue(blocked_on)
+ blocking = fake.MakeTestIssue(
+ 789, 3, 'Something that must be done after', 'New', 111,
+ project_name='proj')
+ self.services.issue.TestAddIssue(blocking)
+
+ issue = tracker_pb2.Issue(
+ project_name='chicken',
+ blocked_on_iids=[blocked_on.issue_id, 78904],
+ blocking_iids=[blocking.issue_id, 78905])
+ blocked_on_add = fake.MakeTestIssue(
+ 789, 6, 'Something that must be done before', 'New', 111,
+ project_name='chicken')
+ self.services.issue.TestAddIssue(blocked_on_add)
+ blocking_add = fake.MakeTestIssue(
+ 789, 7, 'Something that must be done after', 'New', 111,
+ project_name='chicken')
+ self.services.issue.TestAddIssue(blocking_add)
+
+ (actual_amendments, actual_impacted_iids
+ ) = tracker_bizobj.ApplyIssueBlockRelationChanges(
+ self.cnxn,
+ issue,
+ # 78904 ref already exists can't be added, shuold ignore.
+ # 78404 ref does not exist, can't be removed, should ignore.
+ # blocked_on is ignored in the add list, but honored in the remove.
+ [blocked_on_add.issue_id, 78904, blocked_on.issue_id],
+ [78404, blocked_on.issue_id],
+ # 78905 ref already exists, can't be added, should ignore.
+ # 79404 ref does not exist in issue, can't be removed, should ignore.
+ # blocking_add is ignored in the remove list, but honored in the add.
+ [blocking_add.issue_id, 78905],
+ [79404, blocking.issue_id, blocking_add.issue_id],
+ self.services.issue)
+
+ expected_amendments = [
+ tracker_bizobj.MakeBlockedOnAmendment(
+ [('chicken', blocked_on_add.local_id)],
+ [('proj', blocked_on.local_id)],
+ default_project_name=issue.project_name),
+ tracker_bizobj.MakeBlockingAmendment(
+ [('chicken', blocking_add.local_id)], [('proj', blocking.local_id)],
+ default_project_name=issue.project_name)
+ ]
+ self.assertEqual(actual_amendments, expected_amendments)
+ self.assertItemsEqual(
+ actual_impacted_iids, [
+ blocked_on_add.issue_id, blocking_add.issue_id, blocked_on.issue_id,
+ blocking.issue_id
+ ])
+ self.assertEqual(issue.blocked_on_iids, [78904, blocked_on_add.issue_id])
+ self.assertEqual(issue.blocking_iids, [78905, blocking_add.issue_id])
+
+ def testApplyIssueBlockRelationChanges_Empty(self):
+ """We can handle empty blocking and blocked_on relation changes."""
+ issue = tracker_pb2.Issue(blocked_on_iids=[78901], blocking_iids=[78902])
+ (actual_amendments,
+ actual_impacted_iids) = tracker_bizobj.ApplyIssueBlockRelationChanges(
+ self.cnxn, issue, [], [], [], [], self.services.issue)
+
+ self.assertEqual(actual_amendments, [])
+ self.assertEqual(actual_impacted_iids, set())
+ self.assertEqual(issue.blocked_on_iids, [78901])
+ self.assertEqual(issue.blocking_iids, [78902])
+
+ def testMakeAmendment(self):
+ amendment = tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.STATUS, 'new', [111], [222])
+ self.assertEqual(tracker_pb2.FieldID.STATUS, amendment.field)
+ self.assertEqual('new', amendment.newvalue)
+ self.assertEqual([111], amendment.added_user_ids)
+ self.assertEqual([222], amendment.removed_user_ids)
+
+ def testPlusMinusString(self):
+ self.assertEqual('', tracker_bizobj._PlusMinusString([], []))
+ self.assertEqual('-a -b c d',
+ tracker_bizobj._PlusMinusString(['c', 'd'], ['a', 'b']))
+
+ def testPlusMinusAmendment(self):
+ amendment = tracker_bizobj._PlusMinusAmendment(
+ tracker_pb2.FieldID.STATUS, ['add1', 'add2'], ['remove1'])
+ self.assertEqual(tracker_pb2.FieldID.STATUS, amendment.field)
+ self.assertEqual('-remove1 add1 add2', amendment.newvalue)
+
+ def testPlusMinusRefsAmendment(self):
+ ref1 = (None, 1)
+ ref2 = ('other-proj', 2)
+ amendment = tracker_bizobj._PlusMinusRefsAmendment(
+ tracker_pb2.FieldID.STATUS, [ref1], [ref2])
+ self.assertEqual(tracker_pb2.FieldID.STATUS, amendment.field)
+ self.assertEqual('-other-proj:2 1', amendment.newvalue)
+
+ def testMakeSummaryAmendment(self):
+ amendment = tracker_bizobj.MakeSummaryAmendment('', None)
+ self.assertEqual(tracker_pb2.FieldID.SUMMARY, amendment.field)
+ self.assertEqual('', amendment.newvalue)
+ self.assertEqual(None, amendment.oldvalue)
+
+ amendment = tracker_bizobj.MakeSummaryAmendment('new summary', '')
+ self.assertEqual(tracker_pb2.FieldID.SUMMARY, amendment.field)
+ self.assertEqual('new summary', amendment.newvalue)
+ self.assertEqual('', amendment.oldvalue)
+
+ def testMakeStatusAmendment(self):
+ amendment = tracker_bizobj.MakeStatusAmendment('', None)
+ self.assertEqual(tracker_pb2.FieldID.STATUS, amendment.field)
+ self.assertEqual('', amendment.newvalue)
+ self.assertEqual(None, amendment.oldvalue)
+
+ amendment = tracker_bizobj.MakeStatusAmendment('New', '')
+ self.assertEqual(tracker_pb2.FieldID.STATUS, amendment.field)
+ self.assertEqual('New', amendment.newvalue)
+ self.assertEqual('', amendment.oldvalue)
+
+ def testMakeOwnerAmendment(self):
+ amendment = tracker_bizobj.MakeOwnerAmendment(111, 0)
+ self.assertEqual(tracker_pb2.FieldID.OWNER, amendment.field)
+ self.assertEqual('', amendment.newvalue)
+ self.assertEqual([111], amendment.added_user_ids)
+ self.assertEqual([0], amendment.removed_user_ids)
+
+ def testMakeCcAmendment(self):
+ amendment = tracker_bizobj.MakeCcAmendment([111], [222])
+ self.assertEqual(tracker_pb2.FieldID.CC, amendment.field)
+ self.assertEqual('', amendment.newvalue)
+ self.assertEqual([111], amendment.added_user_ids)
+ self.assertEqual([222], amendment.removed_user_ids)
+
+ def testMakeLabelsAmendment(self):
+ amendment = tracker_bizobj.MakeLabelsAmendment(['added1'], ['removed1'])
+ self.assertEqual(tracker_pb2.FieldID.LABELS, amendment.field)
+ self.assertEqual('-removed1 added1', amendment.newvalue)
+
+ def testDiffValueLists(self):
+ added, removed = tracker_bizobj.DiffValueLists([], [])
+ self.assertItemsEqual([], added)
+ self.assertItemsEqual([], removed)
+
+ added, removed = tracker_bizobj.DiffValueLists([], None)
+ self.assertItemsEqual([], added)
+ self.assertItemsEqual([], removed)
+
+ added, removed = tracker_bizobj.DiffValueLists([1, 2], [])
+ self.assertItemsEqual([1, 2], added)
+ self.assertItemsEqual([], removed)
+
+ added, removed = tracker_bizobj.DiffValueLists([], [8, 9])
+ self.assertItemsEqual([], added)
+ self.assertItemsEqual([8, 9], removed)
+
+ added, removed = tracker_bizobj.DiffValueLists([1, 2], [8, 9])
+ self.assertItemsEqual([1, 2], added)
+ self.assertItemsEqual([8, 9], removed)
+
+ added, removed = tracker_bizobj.DiffValueLists([1, 2, 5, 6], [5, 6, 8, 9])
+ self.assertItemsEqual([1, 2], added)
+ self.assertItemsEqual([8, 9], removed)
+
+ added, removed = tracker_bizobj.DiffValueLists([5, 6], [5, 6, 8, 9])
+ self.assertItemsEqual([], added)
+ self.assertItemsEqual([8, 9], removed)
+
+ added, removed = tracker_bizobj.DiffValueLists([1, 2, 5, 6], [5, 6])
+ self.assertItemsEqual([1, 2], added)
+ self.assertItemsEqual([], removed)
+
+ added, removed = tracker_bizobj.DiffValueLists(
+ [1, 2, 2, 5, 6], [5, 6, 8, 9])
+ self.assertItemsEqual([1, 2, 2], added)
+ self.assertItemsEqual([8, 9], removed)
+
+ added, removed = tracker_bizobj.DiffValueLists(
+ [1, 2, 5, 6], [5, 6, 8, 8, 9])
+ self.assertItemsEqual([1, 2], added)
+ self.assertItemsEqual([8, 8, 9], removed)
+
+ def testMakeFieldAmendment_NoSuchFieldDef(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ with self.assertRaises(ValueError):
+ tracker_bizobj.MakeFieldAmendment(1, config, ['Large'], ['Small'])
+
+ def testMakeFieldAmendment_MultiValued(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ fd = tracker_pb2.FieldDef(
+ field_id=1, field_name='Days', is_multivalued=True)
+ config.field_defs.append(fd)
+ self.assertEqual(
+ tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, '-Mon Tue Wed', [], [], 'Days'),
+ tracker_bizobj.MakeFieldAmendment(1, config, ['Tue', 'Wed'], ['Mon']))
+ self.assertEqual(
+ tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, '-Mon', [], [], 'Days'),
+ tracker_bizobj.MakeFieldAmendment(1, config, [], ['Mon']))
+
+ def testMakeFieldAmendment_MultiValuedUser(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ fd = tracker_pb2.FieldDef(
+ field_id=1, field_name='Friends', is_multivalued=True,
+ field_type=tracker_pb2.FieldTypes.USER_TYPE)
+ config.field_defs.append(fd)
+ self.assertEqual(
+ tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, '', [111], [222], 'Friends'),
+ tracker_bizobj.MakeFieldAmendment(1, config, [111], [222]))
+ self.assertEqual(
+ tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, '', [], [222], 'Friends'),
+ tracker_bizobj.MakeFieldAmendment(1, config, [], [222]))
+
+ def testMakeFieldAmendment_SingleValued(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ fd = tracker_pb2.FieldDef(field_id=1, field_name='Size')
+ config.field_defs.append(fd)
+ self.assertEqual(
+ tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, 'Large', [], [], 'Size'),
+ tracker_bizobj.MakeFieldAmendment(1, config, ['Large'], ['Small']))
+ self.assertEqual(
+ tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, '----', [], [], 'Size'),
+ tracker_bizobj.MakeFieldAmendment(1, config, [], ['Small']))
+
+ def testMakeFieldAmendment_SingleValuedUser(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ fd = tracker_pb2.FieldDef(
+ field_id=1, field_name='Friend',
+ field_type=tracker_pb2.FieldTypes.USER_TYPE)
+ config.field_defs.append(fd)
+ self.assertEqual(
+ tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, '', [111], [], 'Friend'),
+ tracker_bizobj.MakeFieldAmendment(1, config, [111], [222]))
+ self.assertEqual(
+ tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, '', [], [], 'Friend'),
+ tracker_bizobj.MakeFieldAmendment(1, config, [], [222]))
+
+ def testMakeFieldAmendment_PhaseField(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ fd = tracker_pb2.FieldDef(
+ field_id=1, field_name='Friend',
+ field_type=tracker_pb2.FieldTypes.USER_TYPE, is_phase_field=True)
+ config.field_defs.append(fd)
+ self.assertEqual(
+ tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, '', [111], [], 'PhaseName-Friend'),
+ tracker_bizobj.MakeFieldAmendment(
+ 1, config, [111], [222], phase_name='PhaseName'))
+ self.assertEqual(
+ tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, '', [], [], 'PhaseName-3-Friend'),
+ tracker_bizobj.MakeFieldAmendment(
+ 1, config, [], [222], phase_name='PhaseName-3'))
+
+ def testMakeFieldClearedAmendment_FieldNotFound(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ with self.assertRaises(ValueError):
+ tracker_bizobj.MakeFieldClearedAmendment(1, config)
+
+ def testMakeFieldClearedAmendment_Normal(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ fd = tracker_pb2.FieldDef(field_id=1, field_name='Rabbit')
+ config.field_defs.append(fd)
+ self.assertEqual(
+ tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, '----', [], [], 'Rabbit'),
+ tracker_bizobj.MakeFieldClearedAmendment(1, config))
+
+ def testMakeApprovalStructureAmendment(self):
+ actual_amendment = tracker_bizobj.MakeApprovalStructureAmendment(
+ ['Chicken1', 'Chicken', 'Llama'], ['Cow', 'Chicken2', 'Llama'])
+ amendment = tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, '-Cow -Chicken2 Chicken1 Chicken',
+ [], [], 'Approvals')
+ self.assertEqual(amendment, actual_amendment)
+
+ def testMakeApprovalStatusAmendment(self):
+ actual_amendment = tracker_bizobj.MakeApprovalStatusAmendment(
+ tracker_pb2.ApprovalStatus.APPROVED)
+ amendment = tracker_pb2.Amendment(
+ field=tracker_pb2.FieldID.CUSTOM, newvalue='approved',
+ custom_field_name='Status')
+ self.assertEqual(amendment, actual_amendment)
+
+ def testMakeApprovalApproversAmendment(self):
+ actual_amendment = tracker_bizobj.MakeApprovalApproversAmendment(
+ [222], [333])
+ amendment = tracker_pb2.Amendment(
+ field=tracker_pb2.FieldID.CUSTOM, newvalue='', added_user_ids=[222],
+ removed_user_ids=[333], custom_field_name='Approvers')
+ self.assertEqual(actual_amendment, amendment)
+
+ def testMakeComponentsAmendment_NoChange(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ config.component_defs = [
+ tracker_pb2.ComponentDef(component_id=1, path='UI'),
+ tracker_pb2.ComponentDef(component_id=2, path='DB')]
+ self.assertEqual(
+ tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.COMPONENTS, '', [], []),
+ tracker_bizobj.MakeComponentsAmendment([], [], config))
+
+ def testMakeComponentsAmendment_NotFound(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ config.component_defs = [
+ tracker_pb2.ComponentDef(component_id=1, path='UI'),
+ tracker_pb2.ComponentDef(component_id=2, path='DB')]
+ self.assertEqual(
+ tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.COMPONENTS, '', [], []),
+ tracker_bizobj.MakeComponentsAmendment([99], [999], config))
+
+ def testMakeComponentsAmendment_Normal(self):
+ config = tracker_bizobj.MakeDefaultProjectIssueConfig(789)
+ config.component_defs = [
+ tracker_pb2.ComponentDef(component_id=1, path='UI'),
+ tracker_pb2.ComponentDef(component_id=2, path='DB')]
+ self.assertEqual(
+ tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.COMPONENTS, '-UI DB', [], []),
+ tracker_bizobj.MakeComponentsAmendment([2], [1], config))
+
+ def testMakeBlockedOnAmendment(self):
+ ref1 = (None, 1)
+ ref2 = ('other-proj', 2)
+ amendment = tracker_bizobj.MakeBlockedOnAmendment([ref1], [ref2])
+ self.assertEqual(tracker_pb2.FieldID.BLOCKEDON, amendment.field)
+ self.assertEqual('-other-proj:2 1', amendment.newvalue)
+
+ amendment = tracker_bizobj.MakeBlockedOnAmendment([ref2], [ref1])
+ self.assertEqual(tracker_pb2.FieldID.BLOCKEDON, amendment.field)
+ self.assertEqual('-1 other-proj:2', amendment.newvalue)
+
+ def testMakeBlockingAmendment(self):
+ ref1 = (None, 1)
+ ref2 = ('other-proj', 2)
+ amendment = tracker_bizobj.MakeBlockingAmendment([ref1], [ref2])
+ self.assertEqual(tracker_pb2.FieldID.BLOCKING, amendment.field)
+ self.assertEqual('-other-proj:2 1', amendment.newvalue)
+
+ def testMakeMergedIntoAmendment(self):
+ ref1 = (None, 1)
+ ref2 = ('other-proj', 2)
+ ref3 = ('chicken-proj', 3)
+ amendment = tracker_bizobj.MakeMergedIntoAmendment(
+ [ref1, None], [ref2, ref3])
+ self.assertEqual(tracker_pb2.FieldID.MERGEDINTO, amendment.field)
+ self.assertEqual('-other-proj:2 -chicken-proj:3 1', amendment.newvalue)
+
+ def testMakeProjectAmendment(self):
+ self.assertEqual(
+ tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.PROJECT, 'moonshot', [], []),
+ tracker_bizobj.MakeProjectAmendment('moonshot'))
+
+ def testAmendmentString(self):
+ users_by_id = {
+ 111: framework_views.StuffUserView(111, 'username@gmail.com', True),
+ framework_constants.DELETED_USER_ID: framework_views.StuffUserView(
+ framework_constants.DELETED_USER_ID, '', True),
+ }
+ summary_amendment = tracker_bizobj.MakeSummaryAmendment('new summary', None)
+ self.assertEqual(
+ 'new summary',
+ tracker_bizobj.AmendmentString(summary_amendment, users_by_id))
+
+ status_amendment = tracker_bizobj.MakeStatusAmendment('', None)
+ self.assertEqual(
+ '', tracker_bizobj.AmendmentString(status_amendment, users_by_id))
+ status_amendment = tracker_bizobj.MakeStatusAmendment('Assigned', 'New')
+ self.assertEqual(
+ 'Assigned',
+ tracker_bizobj.AmendmentString(status_amendment, users_by_id))
+
+ owner_amendment = tracker_bizobj.MakeOwnerAmendment(0, 0)
+ self.assertEqual(
+ '----', tracker_bizobj.AmendmentString(owner_amendment, users_by_id))
+ owner_amendment = tracker_bizobj.MakeOwnerAmendment(111, 0)
+ self.assertEqual(
+ 'usern...@gmail.com',
+ tracker_bizobj.AmendmentString(owner_amendment, users_by_id))
+
+ owner_amendment_deleted = tracker_bizobj.MakeOwnerAmendment(1, 0)
+ self.assertEqual(
+ framework_constants.DELETED_USER_NAME,
+ tracker_bizobj.AmendmentString(owner_amendment_deleted, users_by_id))
+
+ def testAmendmentString_New(self):
+ """AmendmentString_New behaves equivalently to the old version."""
+ # TODO(crbug.com/monorail/7571): Delete this test.
+ users_by_id = {
+ 111:
+ framework_views.StuffUserView(111, 'username@gmail.com', True),
+ framework_constants.DELETED_USER_ID:
+ framework_views.StuffUserView(
+ framework_constants.DELETED_USER_ID, '', True),
+ }
+ user_display_names = {
+ 111:
+ 'usern...@gmail.com',
+ framework_constants.DELETED_USER_ID:
+ framework_constants.DELETED_USER_NAME
+ }
+
+ summary_amendment = tracker_bizobj.MakeSummaryAmendment('new summary', None)
+ new_str_summary = tracker_bizobj.AmendmentString_New(
+ summary_amendment, user_display_names)
+ self.assertEqual(
+ tracker_bizobj.AmendmentString(summary_amendment, users_by_id),
+ new_str_summary)
+
+ status_amendment = tracker_bizobj.MakeStatusAmendment('', None)
+ new_str_status = tracker_bizobj.AmendmentString_New(
+ status_amendment, user_display_names)
+ self.assertEqual(
+ tracker_bizobj.AmendmentString(status_amendment, users_by_id),
+ new_str_status)
+
+ status_amendment_2 = tracker_bizobj.MakeStatusAmendment('Assigned', 'New')
+ new_str_status_2 = tracker_bizobj.AmendmentString_New(
+ status_amendment_2, user_display_names)
+ self.assertEqual(
+ tracker_bizobj.AmendmentString(status_amendment_2, users_by_id),
+ new_str_status_2)
+
+ owner_amendment = tracker_bizobj.MakeOwnerAmendment(0, 0)
+ new_str_owner = tracker_bizobj.AmendmentString_New(
+ owner_amendment, user_display_names)
+ self.assertEqual(
+ tracker_bizobj.AmendmentString(owner_amendment, users_by_id),
+ new_str_owner)
+
+ owner_amendment_2 = tracker_bizobj.MakeOwnerAmendment(111, 0)
+ new_str_owner_2 = tracker_bizobj.AmendmentString_New(
+ owner_amendment_2, user_display_names)
+ self.assertEqual(
+ tracker_bizobj.AmendmentString(owner_amendment_2, users_by_id),
+ new_str_owner_2)
+
+ owner_amendment_deleted = tracker_bizobj.MakeOwnerAmendment(1, 0)
+ new_str_owner_deleted = tracker_bizobj.AmendmentString_New(
+ owner_amendment_deleted, user_display_names)
+ self.assertEqual(
+ tracker_bizobj.AmendmentString(owner_amendment_deleted, users_by_id),
+ new_str_owner_deleted)
+
+
+ def testAmendmentLinks(self):
+ users_by_id = {
+ 111: framework_views.StuffUserView(111, 'foo@gmail.com', False),
+ 222: framework_views.StuffUserView(222, 'bar@gmail.com', False),
+ 333: framework_views.StuffUserView(333, 'baz@gmail.com', False),
+ framework_constants.DELETED_USER_ID: framework_views.StuffUserView(
+ framework_constants.DELETED_USER_ID, '', True),
+ }
+ # SUMMARY
+ summary_amendment = tracker_bizobj.MakeSummaryAmendment('new summary', None)
+ self.assertEqual(
+ [{'value': 'new summary', 'url': None}],
+ tracker_bizobj.AmendmentLinks(summary_amendment, users_by_id, 'proj'))
+
+ summary_amendment = tracker_bizobj.MakeSummaryAmendment(
+ 'new summary', 'NULL')
+ self.assertEqual(
+ [{'value': 'new summary', 'url': None}],
+ tracker_bizobj.AmendmentLinks(summary_amendment, users_by_id, 'proj'))
+
+ summary_amendment = tracker_bizobj.MakeSummaryAmendment(
+ 'new summary', 'old info')
+ self.assertEqual(
+ [{'value': 'new summary (was: old info)', 'url': None}],
+ tracker_bizobj.AmendmentLinks(summary_amendment, users_by_id, 'proj'))
+
+ # STATUS
+ status_amendment = tracker_bizobj.MakeStatusAmendment('New', None)
+ self.assertEqual(
+ [{'value': 'New', 'url': None}],
+ tracker_bizobj.AmendmentLinks(status_amendment, users_by_id, 'proj'))
+
+ status_amendment = tracker_bizobj.MakeStatusAmendment('New', 'NULL')
+ self.assertEqual(
+ [{'value': 'New', 'url': None}],
+ tracker_bizobj.AmendmentLinks(status_amendment, users_by_id, 'proj'))
+
+ status_amendment = tracker_bizobj.MakeStatusAmendment(
+ 'Assigned', 'New')
+ self.assertEqual(
+ [{'value': 'Assigned (was: New)', 'url': None}],
+ tracker_bizobj.AmendmentLinks(status_amendment, users_by_id, 'proj'))
+
+ # OWNER
+ owner_amendment = tracker_bizobj.MakeOwnerAmendment(0, 0)
+ self.assertEqual(
+ [{'value': '----', 'url': None}],
+ tracker_bizobj.AmendmentLinks(owner_amendment, users_by_id, 'proj'))
+ owner_amendment = tracker_bizobj.MakeOwnerAmendment(111, 0)
+ self.assertEqual(
+ [{'value': 'foo@gmail.com', 'url': None}],
+ tracker_bizobj.AmendmentLinks(owner_amendment, users_by_id, 'proj'))
+
+ # BLOCKEDON, BLOCKING, MERGEDINTO
+ blocking_amendment = tracker_bizobj.MakeBlockingAmendment(
+ [(None, 123), ('blah', 234)], [(None, 345), ('blah', 456)])
+ self.assertEqual([
+ {'value': '-345', 'url': '/p/proj/issues/detail?id=345'},
+ {'value': '-blah:456', 'url': '/p/blah/issues/detail?id=456'},
+ {'value': '123', 'url': '/p/proj/issues/detail?id=123'},
+ {'value': 'blah:234', 'url': '/p/blah/issues/detail?id=234'}],
+ tracker_bizobj.AmendmentLinks(blocking_amendment, users_by_id, 'proj'))
+
+ # newvalue catchall
+ label_amendment = tracker_bizobj.MakeLabelsAmendment(
+ ['My-Label', 'Your-Label'], ['Their-Label'])
+ self.assertEqual([
+ {'value': '-Their-Label', 'url': None},
+ {'value': 'My-Label', 'url': None},
+ {'value': 'Your-Label', 'url': None}],
+ tracker_bizobj.AmendmentLinks(label_amendment, users_by_id, 'proj'))
+
+ # CC, or CUSTOM with user type
+ cc_amendment = tracker_bizobj.MakeCcAmendment([222, 333], [111])
+ self.assertEqual([
+ {'value': '-foo@gmail.com', 'url': None},
+ {'value': 'bar@gmail.com', 'url': None},
+ {'value': 'baz@gmail.com', 'url': None}],
+ tracker_bizobj.AmendmentLinks(cc_amendment, users_by_id, 'proj'))
+ user_amendment = tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, None, [222, 333], [111], 'ultracc')
+ self.assertEqual([
+ {'value': '-foo@gmail.com', 'url': None},
+ {'value': 'bar@gmail.com', 'url': None},
+ {'value': 'baz@gmail.com', 'url': None}],
+ tracker_bizobj.AmendmentLinks(user_amendment, users_by_id, 'proj'))
+
+ # deleted users
+ cc_amendment_deleted = tracker_bizobj.MakeCcAmendment(
+ [framework_constants.DELETED_USER_ID], [])
+ self.assertEqual(
+ [{'value': framework_constants.DELETED_USER_NAME, 'url': None}],
+ tracker_bizobj.AmendmentLinks(
+ cc_amendment_deleted, users_by_id, 'proj'))
+
+ def testGetAmendmentFieldName_Custom(self):
+ amendment = tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, None, [222, 333], [111], 'Rabbit')
+ self.assertEqual('Rabbit', tracker_bizobj.GetAmendmentFieldName(amendment))
+
+ def testGetAmendmentFieldName_Builtin(self):
+ amendment = tracker_bizobj.MakeAmendment(
+ tracker_pb2.FieldID.SUMMARY, 'It broke', [], [])
+ self.assertEqual('Summary', tracker_bizobj.GetAmendmentFieldName(amendment))
+
+ def testMakeDanglingIssueRef(self):
+ di_ref = tracker_bizobj.MakeDanglingIssueRef('proj', 123)
+ self.assertEqual('proj', di_ref.project)
+ self.assertEqual(123, di_ref.issue_id)
+
+ def testFormatIssueURL_NoRef(self):
+ self.assertEqual('', tracker_bizobj.FormatIssueURL(None))
+
+ def testFormatIssueRef(self):
+ self.assertEqual('', tracker_bizobj.FormatIssueRef(None))
+
+ self.assertEqual(
+ 'p:1', tracker_bizobj.FormatIssueRef(('p', 1)))
+
+ self.assertEqual(
+ '1', tracker_bizobj.FormatIssueRef((None, 1)))
+
+ def testFormatIssueRef_External(self):
+ """Outputs shortlink as-is."""
+ ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier='b/1234')
+ self.assertEqual('b/1234', tracker_bizobj.FormatIssueRef(ref))
+
+ def testFormatIssueRef_ExternalInvalid(self):
+ """Does not validate external IDs."""
+ ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier='invalid')
+ self.assertEqual('invalid', tracker_bizobj.FormatIssueRef(ref))
+
+ def testFormatIssueRef_Empty(self):
+ """Passes on empty values."""
+ ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier='')
+ self.assertEqual('', tracker_bizobj.FormatIssueRef(ref))
+
+ def testParseIssueRef(self):
+ self.assertEqual(None, tracker_bizobj.ParseIssueRef(''))
+ self.assertEqual(None, tracker_bizobj.ParseIssueRef(' \t '))
+
+ ref_pn, ref_id = tracker_bizobj.ParseIssueRef('1')
+ self.assertEqual(None, ref_pn)
+ self.assertEqual(1, ref_id)
+
+ ref_pn, ref_id = tracker_bizobj.ParseIssueRef('-1')
+ self.assertEqual(None, ref_pn)
+ self.assertEqual(1, ref_id)
+
+ ref_pn, ref_id = tracker_bizobj.ParseIssueRef('p:2')
+ self.assertEqual('p', ref_pn)
+ self.assertEqual(2, ref_id)
+
+ ref_pn, ref_id = tracker_bizobj.ParseIssueRef('-p:2')
+ self.assertEqual('p', ref_pn)
+ self.assertEqual(2, ref_id)
+
+ def testSafeParseIssueRef(self):
+ self.assertEqual(None, tracker_bizobj._SafeParseIssueRef('-'))
+ self.assertEqual(None, tracker_bizobj._SafeParseIssueRef('test:'))
+ ref_pn, ref_id = tracker_bizobj.ParseIssueRef('p:2')
+ self.assertEqual('p', ref_pn)
+ self.assertEqual(2, ref_id)
+
+ def testMergeFields_NoChange(self):
+ fv1 = tracker_bizobj.MakeFieldValue(1, 42, None, None, None, None, False)
+ merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+ [fv1], [], [], [])
+ self.assertEqual([fv1], merged_fvs)
+ self.assertEqual({}, fvs_added_dict)
+ self.assertEqual({}, fvs_removed_dict)
+
+ def testMergeFields_SingleValued(self):
+ fd = tracker_pb2.FieldDef(field_id=1, field_name='foo')
+ fv1 = tracker_bizobj.MakeFieldValue(1, 42, None, None, None, None, False)
+ fv2 = tracker_bizobj.MakeFieldValue(1, 43, None, None, None, None, False)
+ fv3 = tracker_bizobj.MakeFieldValue(1, 44, None, None, None, None, False)
+
+ # Adding one replaces all values since the field is single-valued.
+ merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+ [fv1, fv2], [fv3], [], [fd])
+ self.assertEqual([fv3], merged_fvs)
+ self.assertEqual({fv3.field_id: [fv3]}, fvs_added_dict)
+ self.assertEqual({}, fvs_removed_dict)
+
+ # Removing one just removes it, does not reset.
+ merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+ [fv1, fv2], [], [fv2], [fd])
+ self.assertEqual([fv1], merged_fvs)
+ self.assertEqual({}, fvs_added_dict)
+ self.assertEqual({fv2.field_id: [fv2]}, fvs_removed_dict)
+
+ def testMergeFields_SingleValuedPhase(self):
+ fd = tracker_pb2.FieldDef(
+ field_id=1, field_name='phase-foo', is_phase_field=True)
+ fv1 = tracker_bizobj.MakeFieldValue(
+ 1, 45, None, None, None, None, False, phase_id=1)
+ fv2 = tracker_bizobj.MakeFieldValue(
+ 1, 46, None, None, None, None, False, phase_id=2)
+ fv3 = tracker_bizobj.MakeFieldValue(
+ 1, 47, None, None, None, None, False, phase_id=1) # should replace fv4
+
+ # Adding one replaces all values since the field is single-valued.
+ merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+ [fv1, fv2], [fv3], [], [fd])
+ self.assertEqual([fv2, fv3], merged_fvs)
+ self.assertEqual({fv3.field_id: [fv3]}, fvs_added_dict)
+ self.assertEqual({}, fvs_removed_dict)
+
+ # Removing one just removes it, does not reset.
+ merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+ [fv1, fv2], [], [fv2], [fd])
+ self.assertEqual([fv1], merged_fvs)
+ self.assertEqual({}, fvs_added_dict)
+ self.assertEqual({fv2.field_id: [fv2]}, fvs_removed_dict)
+
+ def testMergeFields_MultiValued(self):
+ fd = tracker_pb2.FieldDef(
+ field_id=1, field_name='foo', is_multivalued=True)
+ fv1 = tracker_bizobj.MakeFieldValue(1, 42, None, None, None, None, False)
+ fv2 = tracker_bizobj.MakeFieldValue(1, 43, None, None, None, None, False)
+ fv3 = tracker_bizobj.MakeFieldValue(1, 44, None, None, None, None, False)
+ fv4 = tracker_bizobj.MakeFieldValue(1, 42, None, None, None, None, False)
+ fv5 = tracker_bizobj.MakeFieldValue(1, 99, None, None, None, None, False)
+ fv6 = tracker_bizobj.MakeFieldValue(1, 100, None, None, None, None, False)
+
+ merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+ [fv1, fv2], [fv2, fv3, fv6], [fv4, fv5], [fd])
+ self.assertEqual([fv2, fv3, fv6], merged_fvs)
+ self.assertEqual({fv3.field_id: [fv3, fv6]}, fvs_added_dict)
+ self.assertEqual({fv4.field_id: [fv4]}, fvs_removed_dict)
+
+ def testMergeFields_MultiValuedPhase(self):
+ fd = tracker_pb2.FieldDef(
+ field_id=1, field_name='foo', is_multivalued=True, is_phase_field=True)
+ fd2 = tracker_pb2.FieldDef(
+ field_id=2, field_name='cow', is_multivalued=True, is_phase_field=True)
+ fv1 = tracker_bizobj.MakeFieldValue(
+ 1, 42, None, None, None, None, False, phase_id=1)
+ fv2 = tracker_bizobj.MakeFieldValue(
+ 1, 43, None, None, None, None, False, phase_id=2)
+ fv3 = tracker_bizobj.MakeFieldValue(
+ 1, 44, None, None, None, None, False, phase_id=1)
+ fv4 = tracker_bizobj.MakeFieldValue(
+ 1, 99, None, None, None, None, False, phase_id=2)
+ fv5 = tracker_bizobj.MakeFieldValue(
+ 2, 22, None, None, None, None, False, phase_id=2)
+
+ merged_fvs, fvs_added_dict, fvs_removed_dict = tracker_bizobj._MergeFields(
+ [fv1, fv2], [fv3, fv1, fv5], [fv2, fv4], [fd, fd2])
+ self.assertEqual([fv1, fv3, fv5], merged_fvs)
+ self.assertEqual({fv3.field_id: [fv3], fv5.field_id: [fv5]}, fvs_added_dict)
+ self.assertEqual({fv2.field_id: [fv2]}, fvs_removed_dict)
+
+ def testSplitBlockedOnRanks_Normal(self):
+ issue = tracker_pb2.Issue()
+ issue.blocked_on_iids = [78902, 78903, 78904]
+ issue.blocked_on_ranks = [10, 20, 30]
+ rank_rows = list(zip(issue.blocked_on_iids, issue.blocked_on_ranks))
+ rank_rows.reverse()
+ ret = tracker_bizobj.SplitBlockedOnRanks(
+ issue, 78903, False, issue.blocked_on_iids)
+ self.assertEqual(ret, (rank_rows[:1], rank_rows[1:]))
+
+ def testSplitBlockedOnRanks_BadTarget(self):
+ issue = tracker_pb2.Issue()
+ issue.blocked_on_iids = [78902, 78903, 78904]
+ issue.blocked_on_ranks = [10, 20, 30]
+ rank_rows = list(zip(issue.blocked_on_iids, issue.blocked_on_ranks))
+ rank_rows.reverse()
+ ret = tracker_bizobj.SplitBlockedOnRanks(
+ issue, 78999, False, issue.blocked_on_iids)
+ self.assertEqual(ret, (rank_rows, []))
diff --git a/tracker/test/tracker_helpers_test.py b/tracker/test/tracker_helpers_test.py
new file mode 100644
index 0000000..4f89cc9
--- /dev/null
+++ b/tracker/test/tracker_helpers_test.py
@@ -0,0 +1,2775 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for the tracker helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import copy
+import mock
+import unittest
+
+import settings
+
+from businesslogic import work_env
+from framework import exceptions
+from framework import framework_constants
+from framework import framework_helpers
+from framework import permissions
+from framework import template_helpers
+from framework import urls
+from proto import project_pb2
+from proto import tracker_pb2
+from proto import user_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+
+TEST_ID_MAP = {
+ 'a@example.com': 1,
+ 'b@example.com': 2,
+ 'c@example.com': 3,
+ 'd@example.com': 4,
+ }
+
+
+def _Issue(project_name, local_id, summary='', status='', project_id=789):
+ issue = tracker_pb2.Issue()
+ issue.project_name = project_name
+ issue.project_id = project_id
+ issue.local_id = local_id
+ issue.issue_id = 100000 + local_id
+ issue.summary = summary
+ issue.status = status
+ return issue
+
+
+def _MakeConfig():
+ config = tracker_pb2.ProjectIssueConfig()
+ config.well_known_statuses.append(tracker_pb2.StatusDef(
+ means_open=True, status='New', deprecated=False))
+ config.well_known_statuses.append(tracker_pb2.StatusDef(
+ status='Old', means_open=False, deprecated=False))
+ config.well_known_statuses.append(tracker_pb2.StatusDef(
+ status='StatusThatWeDontUseAnymore', means_open=False, deprecated=True))
+
+ return config
+
+
+class HelpersTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ user=fake.UserService(),
+ usergroup=fake.UserGroupService())
+
+ for email, user_id in TEST_ID_MAP.items():
+ self.services.user.TestAddUser(email, user_id)
+
+ self.services.project.TestAddProject('testproj', project_id=789)
+ self.issue1 = fake.MakeTestIssue(789, 1, 'one', 'New', 111)
+ self.issue1.project_name = 'testproj'
+ self.services.issue.TestAddIssue(self.issue1)
+ self.issue2 = fake.MakeTestIssue(789, 2, 'two', 'New', 111)
+ self.issue2.project_name = 'testproj'
+ self.services.issue.TestAddIssue(self.issue2)
+ self.issue3 = fake.MakeTestIssue(789, 3, 'three', 'New', 111)
+ self.issue3.project_name = 'testproj'
+ self.services.issue.TestAddIssue(self.issue3)
+ self.cnxn = 'fake connextion'
+ self.errors = template_helpers.EZTError()
+ self.default_colspec_param = 'colspec=%s' % (
+ tracker_constants.DEFAULT_COL_SPEC.replace(' ', '%20'))
+ self.services.usergroup.TestAddGroupSettings(999, 'group@example.com')
+
+ def testParseIssueRequest_Empty(self):
+ post_data = fake.PostData()
+ errors = template_helpers.EZTError()
+ parsed = tracker_helpers.ParseIssueRequest(
+ 'fake cnxn', post_data, self.services, errors, 'proj')
+ self.assertEqual('', parsed.summary)
+ self.assertEqual('', parsed.comment)
+ self.assertEqual('', parsed.status)
+ self.assertEqual('', parsed.users.owner_username)
+ self.assertEqual(0, parsed.users.owner_id)
+ self.assertEqual([], parsed.users.cc_usernames)
+ self.assertEqual([], parsed.users.cc_usernames_remove)
+ self.assertEqual([], parsed.users.cc_ids)
+ self.assertEqual([], parsed.users.cc_ids_remove)
+ self.assertEqual('', parsed.template_name)
+ self.assertEqual([], parsed.labels)
+ self.assertEqual([], parsed.labels_remove)
+ self.assertEqual({}, parsed.fields.vals)
+ self.assertEqual({}, parsed.fields.vals_remove)
+ self.assertEqual([], parsed.fields.fields_clear)
+ self.assertEqual('', parsed.blocked_on.entered_str)
+ self.assertEqual([], parsed.blocked_on.iids)
+
+ def testParseIssueRequest_Normal(self):
+ post_data = fake.PostData({
+ 'summary': ['some summary'],
+ 'comment': ['some comment'],
+ 'status': ['SomeStatus'],
+ 'template_name': ['some template'],
+ 'label': ['lab1', '-lab2'],
+ 'custom_123': ['field1123a', 'field1123b'],
+ })
+ errors = template_helpers.EZTError()
+ parsed = tracker_helpers.ParseIssueRequest(
+ 'fake cnxn', post_data, self.services, errors, 'proj')
+ self.assertEqual('some summary', parsed.summary)
+ self.assertEqual('some comment', parsed.comment)
+ self.assertEqual('SomeStatus', parsed.status)
+ self.assertEqual('', parsed.users.owner_username)
+ self.assertEqual(0, parsed.users.owner_id)
+ self.assertEqual([], parsed.users.cc_usernames)
+ self.assertEqual([], parsed.users.cc_usernames_remove)
+ self.assertEqual([], parsed.users.cc_ids)
+ self.assertEqual([], parsed.users.cc_ids_remove)
+ self.assertEqual('some template', parsed.template_name)
+ self.assertEqual(['lab1'], parsed.labels)
+ self.assertEqual(['lab2'], parsed.labels_remove)
+ self.assertEqual({123: ['field1123a', 'field1123b']}, parsed.fields.vals)
+ self.assertEqual({}, parsed.fields.vals_remove)
+ self.assertEqual([], parsed.fields.fields_clear)
+
+ def testMarkupDescriptionOnInput(self):
+ content = 'What?\nthat\nWhy?\nidk\nWhere?\n'
+ tmpl_txt = 'What?\nWhy?\nWhere?\nWhen?'
+ desc = '<b>What?</b>\nthat\n<b>Why?</b>\nidk\n<b>Where?</b>\n'
+ self.assertEqual(tracker_helpers.MarkupDescriptionOnInput(
+ content, tmpl_txt), desc)
+
+ def testMarkupDescriptionLineOnInput(self):
+ line = 'What happened??'
+ tmpl_lines = ['What happened??','Why?']
+ self.assertEqual(tracker_helpers._MarkupDescriptionLineOnInput(
+ line, tmpl_lines), '<b>What happened??</b>')
+
+ line = 'Something terrible!!!'
+ self.assertEqual(tracker_helpers._MarkupDescriptionLineOnInput(
+ line, tmpl_lines), 'Something terrible!!!')
+
+ def testClassifyPlusMinusItems(self):
+ add, remove = tracker_helpers._ClassifyPlusMinusItems([])
+ self.assertEqual([], add)
+ self.assertEqual([], remove)
+
+ add, remove = tracker_helpers._ClassifyPlusMinusItems(
+ ['', ' ', ' \t', '-'])
+ self.assertItemsEqual([], add)
+ self.assertItemsEqual([], remove)
+
+ add, remove = tracker_helpers._ClassifyPlusMinusItems(
+ ['a', 'b', 'c'])
+ self.assertItemsEqual(['a', 'b', 'c'], add)
+ self.assertItemsEqual([], remove)
+
+ add, remove = tracker_helpers._ClassifyPlusMinusItems(
+ ['a-a-a', 'b-b', 'c-'])
+ self.assertItemsEqual(['a-a-a', 'b-b', 'c-'], add)
+ self.assertItemsEqual([], remove)
+
+ add, remove = tracker_helpers._ClassifyPlusMinusItems(
+ ['-a'])
+ self.assertItemsEqual([], add)
+ self.assertItemsEqual(['a'], remove)
+
+ add, remove = tracker_helpers._ClassifyPlusMinusItems(
+ ['-a', 'b', 'c-c'])
+ self.assertItemsEqual(['b', 'c-c'], add)
+ self.assertItemsEqual(['a'], remove)
+
+ add, remove = tracker_helpers._ClassifyPlusMinusItems(
+ ['-a', '-b-b', '-c-'])
+ self.assertItemsEqual([], add)
+ self.assertItemsEqual(['a', 'b-b', 'c-'], remove)
+
+ # We dedup, but we don't cancel out items that are both added and removed.
+ add, remove = tracker_helpers._ClassifyPlusMinusItems(
+ ['a', 'a', '-a'])
+ self.assertItemsEqual(['a'], add)
+ self.assertItemsEqual(['a'], remove)
+
+ def testParseIssueRequestFields(self):
+ parsed_fields = tracker_helpers._ParseIssueRequestFields(fake.PostData({
+ 'custom_1': ['https://hello.com'],
+ 'custom_12': ['https://blah.com'],
+ 'custom_14': ['https://remove.com'],
+ 'custom_15_goats': ['2', '3'],
+ 'custom_15_sheep': ['3', '5'],
+ 'custom_16_sheep': ['yarn'],
+ 'op_custom_14': ['remove'],
+ 'op_custom_12': ['clear'],
+ 'op_custom_16_sheep': ['remove'],
+ 'ignore': 'no matter',}))
+ self.assertEqual(
+ parsed_fields,
+ tracker_helpers.ParsedFields(
+ {
+ 1: ['https://hello.com'],
+ 12: ['https://blah.com']
+ }, {14: ['https://remove.com']}, [12],
+ {15: {
+ 'goats': ['2', '3'],
+ 'sheep': ['3', '5']
+ }}, {16: {
+ 'sheep': ['yarn']
+ }}))
+
+ def testParseIssueRequestAttachments(self):
+ file1 = testing_helpers.Blank(
+ filename='hello.c',
+ value='hello world')
+
+ file2 = testing_helpers.Blank(
+ filename='README',
+ value='Welcome to our project')
+
+ file3 = testing_helpers.Blank(
+ filename='c:\\dir\\subdir\\FILENAME.EXT',
+ value='Abort, Retry, or Fail?')
+
+ # Browsers send this if FILE field was not filled in.
+ file4 = testing_helpers.Blank(
+ filename='',
+ value='')
+
+ attachments = tracker_helpers._ParseIssueRequestAttachments({})
+ self.assertEqual([], attachments)
+
+ attachments = tracker_helpers._ParseIssueRequestAttachments(fake.PostData({
+ 'file1': [file1],
+ }))
+ self.assertEqual(
+ [('hello.c', 'hello world', 'text/plain')],
+ attachments)
+
+ attachments = tracker_helpers._ParseIssueRequestAttachments(fake.PostData({
+ 'file1': [file1],
+ 'file2': [file2],
+ }))
+ self.assertEqual(
+ [('hello.c', 'hello world', 'text/plain'),
+ ('README', 'Welcome to our project', 'text/plain')],
+ attachments)
+
+ attachments = tracker_helpers._ParseIssueRequestAttachments(fake.PostData({
+ 'file3': [file3],
+ }))
+ self.assertEqual(
+ [('FILENAME.EXT', 'Abort, Retry, or Fail?',
+ 'application/octet-stream')],
+ attachments)
+
+ attachments = tracker_helpers._ParseIssueRequestAttachments(fake.PostData({
+ 'file1': [file4], # Does not appear in result
+ 'file3': [file3],
+ 'file4': [file4], # Does not appear in result
+ }))
+ self.assertEqual(
+ [('FILENAME.EXT', 'Abort, Retry, or Fail?',
+ 'application/octet-stream')],
+ attachments)
+
+ def testParseIssueRequestKeptAttachments(self):
+ pass # TODO(jrobbins): Write this test.
+
+ def testParseIssueRequestUsers(self):
+ post_data = {}
+ parsed_users = tracker_helpers._ParseIssueRequestUsers(
+ 'fake connection', post_data, self.services)
+ self.assertEqual('', parsed_users.owner_username)
+ self.assertEqual(
+ framework_constants.NO_USER_SPECIFIED, parsed_users.owner_id)
+ self.assertEqual([], parsed_users.cc_usernames)
+ self.assertEqual([], parsed_users.cc_usernames_remove)
+ self.assertEqual([], parsed_users.cc_ids)
+ self.assertEqual([], parsed_users.cc_ids_remove)
+
+ post_data = fake.PostData({
+ 'owner': [''],
+ })
+ parsed_users = tracker_helpers._ParseIssueRequestUsers(
+ 'fake connection', post_data, self.services)
+ self.assertEqual('', parsed_users.owner_username)
+ self.assertEqual(
+ framework_constants.NO_USER_SPECIFIED, parsed_users.owner_id)
+ self.assertEqual([], parsed_users.cc_usernames)
+ self.assertEqual([], parsed_users.cc_usernames_remove)
+ self.assertEqual([], parsed_users.cc_ids)
+ self.assertEqual([], parsed_users.cc_ids_remove)
+
+ post_data = fake.PostData({
+ 'owner': [' \t'],
+ })
+ parsed_users = tracker_helpers._ParseIssueRequestUsers(
+ 'fake connection', post_data, self.services)
+ self.assertEqual('', parsed_users.owner_username)
+ self.assertEqual(
+ framework_constants.NO_USER_SPECIFIED, parsed_users.owner_id)
+ self.assertEqual([], parsed_users.cc_usernames)
+ self.assertEqual([], parsed_users.cc_usernames_remove)
+ self.assertEqual([], parsed_users.cc_ids)
+ self.assertEqual([], parsed_users.cc_ids_remove)
+
+ post_data = fake.PostData({
+ 'owner': ['b@example.com'],
+ })
+ parsed_users = tracker_helpers._ParseIssueRequestUsers(
+ 'fake connection', post_data, self.services)
+ self.assertEqual('b@example.com', parsed_users.owner_username)
+ self.assertEqual(TEST_ID_MAP['b@example.com'], parsed_users.owner_id)
+ self.assertEqual([], parsed_users.cc_usernames)
+ self.assertEqual([], parsed_users.cc_usernames_remove)
+ self.assertEqual([], parsed_users.cc_ids)
+ self.assertEqual([], parsed_users.cc_ids_remove)
+
+ post_data = fake.PostData({
+ 'owner': ['b@example.com'],
+ })
+ parsed_users = tracker_helpers._ParseIssueRequestUsers(
+ 'fake connection', post_data, self.services)
+ self.assertEqual('b@example.com', parsed_users.owner_username)
+ self.assertEqual(TEST_ID_MAP['b@example.com'], parsed_users.owner_id)
+ self.assertEqual([], parsed_users.cc_usernames)
+ self.assertEqual([], parsed_users.cc_usernames_remove)
+ self.assertEqual([], parsed_users.cc_ids)
+ self.assertEqual([], parsed_users.cc_ids_remove)
+
+ post_data = fake.PostData({
+ 'cc': ['b@example.com'],
+ })
+ parsed_users = tracker_helpers._ParseIssueRequestUsers(
+ 'fake connection', post_data, self.services)
+ self.assertEqual('', parsed_users.owner_username)
+ self.assertEqual(
+ framework_constants.NO_USER_SPECIFIED, parsed_users.owner_id)
+ self.assertEqual(['b@example.com'], parsed_users.cc_usernames)
+ self.assertEqual([], parsed_users.cc_usernames_remove)
+ self.assertEqual([TEST_ID_MAP['b@example.com']], parsed_users.cc_ids)
+ self.assertEqual([], parsed_users.cc_ids_remove)
+
+ post_data = fake.PostData({
+ 'cc': ['-b@example.com, c@example.com,,'
+ 'a@example.com,'],
+ })
+ parsed_users = tracker_helpers._ParseIssueRequestUsers(
+ 'fake connection', post_data, self.services)
+ self.assertEqual('', parsed_users.owner_username)
+ self.assertEqual(
+ framework_constants.NO_USER_SPECIFIED, parsed_users.owner_id)
+ self.assertItemsEqual(['c@example.com', 'a@example.com'],
+ parsed_users.cc_usernames)
+ self.assertEqual(['b@example.com'], parsed_users.cc_usernames_remove)
+ self.assertItemsEqual([TEST_ID_MAP['c@example.com'],
+ TEST_ID_MAP['a@example.com']],
+ parsed_users.cc_ids)
+ self.assertEqual([TEST_ID_MAP['b@example.com']],
+ parsed_users.cc_ids_remove)
+
+ post_data = fake.PostData({
+ 'owner': ['fuhqwhgads@example.com'],
+ 'cc': ['c@example.com, fuhqwhgads@example.com'],
+ })
+ parsed_users = tracker_helpers._ParseIssueRequestUsers(
+ 'fake connection', post_data, self.services)
+ self.assertEqual('fuhqwhgads@example.com', parsed_users.owner_username)
+ gen_uid = framework_helpers.MurmurHash3_x86_32(parsed_users.owner_username)
+ self.assertEqual(gen_uid, parsed_users.owner_id) # autocreated user
+ self.assertItemsEqual(
+ ['c@example.com', 'fuhqwhgads@example.com'], parsed_users.cc_usernames)
+ self.assertEqual([], parsed_users.cc_usernames_remove)
+ self.assertItemsEqual(
+ [TEST_ID_MAP['c@example.com'], gen_uid], parsed_users.cc_ids)
+ self.assertEqual([], parsed_users.cc_ids_remove)
+
+ post_data = fake.PostData({
+ 'cc': ['C@example.com, b@exAmple.cOm'],
+ })
+ parsed_users = tracker_helpers._ParseIssueRequestUsers(
+ 'fake connection', post_data, self.services)
+ self.assertItemsEqual(
+ ['c@example.com', 'b@example.com'], parsed_users.cc_usernames)
+ self.assertEqual([], parsed_users.cc_usernames_remove)
+ self.assertItemsEqual(
+ [TEST_ID_MAP['c@example.com'], TEST_ID_MAP['b@example.com']],
+ parsed_users.cc_ids)
+ self.assertEqual([], parsed_users.cc_ids_remove)
+
+ def testParseBlockers_BlockedOnNothing(self):
+ """Was blocked on nothing, still nothing."""
+ post_data = {tracker_helpers.BLOCKED_ON: ''}
+ parsed_blockers = tracker_helpers._ParseBlockers(
+ self.cnxn, post_data, self.services, self.errors, 'testproj',
+ tracker_helpers.BLOCKED_ON)
+
+ self.assertEqual('', parsed_blockers.entered_str)
+ self.assertEqual([], parsed_blockers.iids)
+ self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+ self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKING))
+
+ def testParseBlockers_BlockedOnAdded(self):
+ """Was blocked on nothing; now 1, 2, 3."""
+ post_data = {tracker_helpers.BLOCKED_ON: '1, 2, 3'}
+ parsed_blockers = tracker_helpers._ParseBlockers(
+ self.cnxn, post_data, self.services, self.errors, 'testproj',
+ tracker_helpers.BLOCKED_ON)
+
+ self.assertEqual('1, 2, 3', parsed_blockers.entered_str)
+ self.assertEqual([100001, 100002, 100003], parsed_blockers.iids)
+ self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+ self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKING))
+
+ def testParseBlockers_BlockedOnDuplicateRef(self):
+ """Was blocked on nothing; now just 2, but repeated in input."""
+ post_data = {tracker_helpers.BLOCKED_ON: '2, 2, 2'}
+ parsed_blockers = tracker_helpers._ParseBlockers(
+ self.cnxn, post_data, self.services, self.errors, 'testproj',
+ tracker_helpers.BLOCKED_ON)
+
+ self.assertEqual('2, 2, 2', parsed_blockers.entered_str)
+ self.assertEqual([100002], parsed_blockers.iids)
+ self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+ self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKING))
+
+ def testParseBlockers_Missing(self):
+ """Parsing an input field that was not in the POST."""
+ post_data = {}
+ parsed_blockers = tracker_helpers._ParseBlockers(
+ self.cnxn, post_data, self.services, self.errors, 'testproj',
+ tracker_helpers.BLOCKED_ON)
+
+ self.assertEqual('', parsed_blockers.entered_str)
+ self.assertEqual([], parsed_blockers.iids)
+ self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+ self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKING))
+
+ def testParseBlockers_SameIssueNoProject(self):
+ """Adding same issue as blocker should modify the errors object."""
+ post_data = {'id': '2', tracker_helpers.BLOCKING: '2, 3'}
+
+ parsed_blockers = tracker_helpers._ParseBlockers(
+ self.cnxn, post_data, self.services, self.errors, 'testproj',
+ tracker_helpers.BLOCKING)
+ self.assertEqual('2, 3', parsed_blockers.entered_str)
+ self.assertEqual([], parsed_blockers.iids)
+ self.assertEqual(
+ getattr(self.errors, tracker_helpers.BLOCKING),
+ 'Cannot be blocking the same issue')
+ self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+
+ def testParseBlockers_SameIssueSameProject(self):
+ """Adding same issue as blocker should modify the errors object."""
+ post_data = {'id': '2', tracker_helpers.BLOCKING: 'testproj:2, 3'}
+
+ parsed_blockers = tracker_helpers._ParseBlockers(
+ self.cnxn, post_data, self.services, self.errors, 'testproj',
+ tracker_helpers.BLOCKING)
+ self.assertEqual('testproj:2, 3', parsed_blockers.entered_str)
+ self.assertEqual([], parsed_blockers.iids)
+ self.assertEqual(
+ getattr(self.errors, tracker_helpers.BLOCKING),
+ 'Cannot be blocking the same issue')
+ self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+
+ def testParseBlockers_SameIssueDifferentProject(self):
+ """Adding different blocker issue should not modify the errors object."""
+ post_data = {'id': '2', tracker_helpers.BLOCKING: 'testproj:2'}
+
+ parsed_blockers = tracker_helpers._ParseBlockers(
+ self.cnxn, post_data, self.services, self.errors, 'testprojB',
+ tracker_helpers.BLOCKING)
+ self.assertEqual('testproj:2', parsed_blockers.entered_str)
+ self.assertEqual([100002], parsed_blockers.iids)
+ self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKING))
+ self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+
+ def testParseBlockers_Invalid(self):
+ """Input fields with invalid values should modify the errors object."""
+ post_data = {tracker_helpers.BLOCKING: '2, foo',
+ tracker_helpers.BLOCKED_ON: '3, bar'}
+
+ parsed_blockers = tracker_helpers._ParseBlockers(
+ self.cnxn, post_data, self.services, self.errors, 'testproj',
+ tracker_helpers.BLOCKING)
+ self.assertEqual('2, foo', parsed_blockers.entered_str)
+ self.assertEqual([100002], parsed_blockers.iids)
+ self.assertEqual(
+ getattr(self.errors, tracker_helpers.BLOCKING), 'Invalid issue ID foo')
+ self.assertIsNone(getattr(self.errors, tracker_helpers.BLOCKED_ON))
+
+ parsed_blockers = tracker_helpers._ParseBlockers(
+ self.cnxn, post_data, self.services, self.errors, 'testproj',
+ tracker_helpers.BLOCKED_ON)
+ self.assertEqual('3, bar', parsed_blockers.entered_str)
+ self.assertEqual([100003], parsed_blockers.iids)
+ self.assertEqual(
+ getattr(self.errors, tracker_helpers.BLOCKED_ON),
+ 'Invalid issue ID bar')
+
+ def testParseBlockers_Dangling(self):
+ """A ref to a sanctioned projected should be allowed."""
+ post_data = {'id': '2', tracker_helpers.BLOCKING: 'otherproj:2'}
+ real_codesite_projects = settings.recognized_codesite_projects
+ settings.recognized_codesite_projects = ['otherproj']
+ parsed_blockers = tracker_helpers._ParseBlockers(
+ self.cnxn, post_data, self.services, self.errors, 'testproj',
+ tracker_helpers.BLOCKING)
+ self.assertEqual('otherproj:2', parsed_blockers.entered_str)
+ self.assertEqual([('otherproj', 2)], parsed_blockers.dangling_refs)
+ settings.recognized_codesite_projects = real_codesite_projects
+
+ def testParseBlockers_FederatedReferences(self):
+ """Should parse and return FedRefs."""
+ post_data = {'id': '9', tracker_helpers.BLOCKING: '2, b/123, 3, b/789'}
+ parsed_blockers = tracker_helpers._ParseBlockers(
+ self.cnxn, post_data, self.services, self.errors, 'testproj',
+ tracker_helpers.BLOCKING)
+ self.assertEqual('2, b/123, 3, b/789', parsed_blockers.entered_str)
+ self.assertEqual([100002, 100003], parsed_blockers.iids)
+ self.assertEqual(['b/123', 'b/789'], parsed_blockers.federated_ref_strings)
+
+ def testIsValidIssueOwner(self):
+ project = project_pb2.Project()
+ project.owner_ids.extend([1, 2])
+ project.committer_ids.extend([3])
+ project.contributor_ids.extend([4, 999])
+
+ valid, _ = tracker_helpers.IsValidIssueOwner(
+ 'fake cnxn', project, framework_constants.NO_USER_SPECIFIED,
+ self.services)
+ self.assertTrue(valid)
+
+ valid, _ = tracker_helpers.IsValidIssueOwner(
+ 'fake cnxn', project, 1,
+ self.services)
+ self.assertTrue(valid)
+ valid, _ = tracker_helpers.IsValidIssueOwner(
+ 'fake cnxn', project, 2,
+ self.services)
+ self.assertTrue(valid)
+ valid, _ = tracker_helpers.IsValidIssueOwner(
+ 'fake cnxn', project, 3,
+ self.services)
+ self.assertTrue(valid)
+ valid, _ = tracker_helpers.IsValidIssueOwner(
+ 'fake cnxn', project, 4,
+ self.services)
+ self.assertTrue(valid)
+
+ valid, _ = tracker_helpers.IsValidIssueOwner(
+ 'fake cnxn', project, 7,
+ self.services)
+ self.assertFalse(valid)
+
+ valid, _ = tracker_helpers.IsValidIssueOwner(
+ 'fake cnxn', project, 999,
+ self.services)
+ self.assertFalse(valid)
+
+ # MakeViewsForUsersInIssuesTest is tested in MakeViewsForUsersInIssuesTest.
+
+ def testGetAllowedOpenedAndClosedIssues(self):
+ pass # TOOD(jrobbins): Write this test.
+
+ def testFormatIssueListURL_JumpedToIssue(self):
+ """If we jumped to issue 123, the list is can=1&q=id-123."""
+ config = tracker_pb2.ProjectIssueConfig()
+ path = '/p/proj/issues/detail?id=123&q=123'
+ mr = testing_helpers.MakeMonorailRequest(
+ path=path, headers={'Host': 'code.google.com'})
+ mr.ComputeColSpec(config)
+
+ absolute_base_url = 'http://code.google.com'
+
+ url_1 = tracker_helpers.FormatIssueListURL(mr, config)
+ self.assertEqual(
+ '%s/p/proj/issues/list?can=1&%s&q=id%%3D123' % (
+ absolute_base_url, self.default_colspec_param),
+ url_1)
+
+ def testFormatIssueListURL_NoCurrentState(self):
+ config = tracker_pb2.ProjectIssueConfig()
+ path = '/p/proj/issues/detail?id=123'
+ mr = testing_helpers.MakeMonorailRequest(
+ path=path, headers={'Host': 'code.google.com'})
+ mr.ComputeColSpec(config)
+
+ absolute_base_url = 'http://code.google.com'
+
+ url_1 = tracker_helpers.FormatIssueListURL(mr, config)
+ self.assertEqual(
+ '%s/p/proj/issues/list?%s&q=' % (
+ absolute_base_url, self.default_colspec_param),
+ url_1)
+
+ url_2 = tracker_helpers.FormatIssueListURL(
+ mr, config, foo=123)
+ self.assertEqual(
+ '%s/p/proj/issues/list?%s&foo=123&q=' % (
+ absolute_base_url, self.default_colspec_param),
+ url_2)
+
+ url_3 = tracker_helpers.FormatIssueListURL(
+ mr, config, foo=123, bar='abc')
+ self.assertEqual(
+ '%s/p/proj/issues/list?bar=abc&%s&foo=123&q=' % (
+ absolute_base_url, self.default_colspec_param),
+ url_3)
+
+ url_4 = tracker_helpers.FormatIssueListURL(
+ mr, config, baz='escaped+encoded&and100% "safe"')
+ self.assertEqual(
+ '%s/p/proj/issues/list?'
+ 'baz=escaped%%2Bencoded%%26and100%%25%%20%%22safe%%22&%s&q=' % (
+ absolute_base_url, self.default_colspec_param),
+ url_4)
+
+ def testFormatIssueListURL_KeepCurrentState(self):
+ config = tracker_pb2.ProjectIssueConfig()
+ path = '/p/proj/issues/detail?id=123&sort=aa&colspec=a b c&groupby=d'
+ mr = testing_helpers.MakeMonorailRequest(
+ path=path, headers={'Host': 'localhost:8080'})
+ mr.ComputeColSpec(config)
+
+ absolute_base_url = 'http://localhost:8080'
+
+ url_1 = tracker_helpers.FormatIssueListURL(mr, config)
+ self.assertEqual(
+ '%s/p/proj/issues/list?colspec=a%%20b%%20c'
+ '&groupby=d&q=&sort=aa' % absolute_base_url,
+ url_1)
+
+ url_2 = tracker_helpers.FormatIssueListURL(
+ mr, config, foo=123)
+ self.assertEqual(
+ '%s/p/proj/issues/list?'
+ 'colspec=a%%20b%%20c&foo=123&groupby=d&q=&sort=aa' % absolute_base_url,
+ url_2)
+
+ url_3 = tracker_helpers.FormatIssueListURL(
+ mr, config, colspec='X Y Z')
+ self.assertEqual(
+ '%s/p/proj/issues/list?colspec=a%%20b%%20c'
+ '&groupby=d&q=&sort=aa' % absolute_base_url,
+ url_3)
+
+ def testFormatRelativeIssueURL(self):
+ self.assertEqual(
+ '/p/proj/issues/attachment',
+ tracker_helpers.FormatRelativeIssueURL(
+ 'proj', urls.ISSUE_ATTACHMENT))
+
+ self.assertEqual(
+ '/p/proj/issues/detail?id=123',
+ tracker_helpers.FormatRelativeIssueURL(
+ 'proj', urls.ISSUE_DETAIL, id=123))
+
+ @mock.patch('google.appengine.api.app_identity.get_application_id')
+ def testFormatCrBugURL_Prod(self, mock_get_app_id):
+ mock_get_app_id.return_value = 'monorail-prod'
+ self.assertEqual(
+ 'https://crbug.com/proj/123',
+ tracker_helpers.FormatCrBugURL('proj', 123))
+ self.assertEqual(
+ 'https://crbug.com/123456',
+ tracker_helpers.FormatCrBugURL('chromium', 123456))
+
+ @mock.patch('google.appengine.api.app_identity.get_application_id')
+ def testFormatCrBugURL_NonProd(self, mock_get_app_id):
+ mock_get_app_id.return_value = 'monorail-staging'
+ self.assertEqual(
+ '/p/proj/issues/detail?id=123',
+ tracker_helpers.FormatCrBugURL('proj', 123))
+ self.assertEqual(
+ '/p/chromium/issues/detail?id=123456',
+ tracker_helpers.FormatCrBugURL('chromium', 123456))
+
+ @mock.patch('tracker.tracker_constants.ISSUE_ATTACHMENTS_QUOTA_HARD', 1)
+ def testComputeNewQuotaBytesUsed_ProjectQuota(self):
+ upload_1 = framework_helpers.AttachmentUpload(
+ 'matter not', 'three men make a tiger', 'matter not')
+ upload_2 = framework_helpers.AttachmentUpload(
+ 'matter not', 'chicken', 'matter not')
+ attachments = [upload_1, upload_2]
+
+ project = fake.Project()
+ project.attachment_bytes_used = 10
+ project.attachment_quota = project.attachment_bytes_used + len(
+ upload_1.contents + upload_2.contents) + 1
+
+ actual_new = tracker_helpers.ComputeNewQuotaBytesUsed(project, attachments)
+ expected_new = project.attachment_quota - 1
+ self.assertEqual(actual_new, expected_new)
+
+ upload_3 = framework_helpers.AttachmentUpload(
+ 'matter not', 'donut', 'matter not')
+ attachments.append(upload_3)
+ with self.assertRaises(exceptions.OverAttachmentQuota):
+ tracker_helpers.ComputeNewQuotaBytesUsed(project, attachments)
+
+ @mock.patch(
+ 'tracker.tracker_constants.ISSUE_ATTACHMENTS_QUOTA_HARD', len('tiger'))
+ def testComputeNewQuotaBytesUsed_GeneralQuota(self):
+ upload_1 = framework_helpers.AttachmentUpload(
+ 'matter not', 'tiger', 'matter not')
+ attachments = [upload_1]
+
+ project = fake.Project()
+
+ actual_new = tracker_helpers.ComputeNewQuotaBytesUsed(project, attachments)
+ expected_new = len(upload_1.contents)
+ self.assertEqual(actual_new, expected_new)
+
+ upload_2 = framework_helpers.AttachmentUpload(
+ 'matter not', 'donut', 'matter not')
+ attachments.append(upload_2)
+ with self.assertRaises(exceptions.OverAttachmentQuota):
+ tracker_helpers.ComputeNewQuotaBytesUsed(project, attachments)
+
+ upload_3 = framework_helpers.AttachmentUpload(
+ 'matter not', 'donut', 'matter not')
+ attachments.append(upload_3)
+ with self.assertRaises(exceptions.OverAttachmentQuota):
+ tracker_helpers.ComputeNewQuotaBytesUsed(project, attachments)
+
+ def testIsUnderSoftAttachmentQuota(self):
+ pass # TODO(jrobbins): Write this test.
+
+ # GetAllIssueProjects is tested in GetAllIssueProjectsTest.
+
+ def testGetPermissionsInAllProjects(self):
+ pass # TODO(jrobbins): Write this test.
+
+ # FilterOutNonViewableIssues is tested in FilterOutNonViewableIssuesTest.
+
+ def testMeansOpenInProject(self):
+ config = _MakeConfig()
+
+ # ensure open means open
+ self.assertTrue(tracker_helpers.MeansOpenInProject('New', config))
+ self.assertTrue(tracker_helpers.MeansOpenInProject('new', config))
+
+ # ensure an unrecognized status means open
+ self.assertTrue(tracker_helpers.MeansOpenInProject(
+ '_undefined_status_', config))
+
+ # ensure closed means closed
+ self.assertFalse(tracker_helpers.MeansOpenInProject('Old', config))
+ self.assertFalse(tracker_helpers.MeansOpenInProject('old', config))
+ self.assertFalse(tracker_helpers.MeansOpenInProject(
+ 'StatusThatWeDontUseAnymore', config))
+
+ def testIsNoisy(self):
+ self.assertTrue(tracker_helpers.IsNoisy(778, 320))
+ self.assertFalse(tracker_helpers.IsNoisy(20, 500))
+ self.assertFalse(tracker_helpers.IsNoisy(500, 20))
+ self.assertFalse(tracker_helpers.IsNoisy(1, 1))
+
+ def testMergeCCsAndAddComment(self):
+ target_issue = fake.MakeTestIssue(
+ 789, 10, 'Target issue', 'New', 111)
+ source_issue = fake.MakeTestIssue(
+ 789, 100, 'Source issue', 'New', 222)
+ source_issue.cc_ids.append(111)
+ # Issue without owner
+ source_issue_2 = fake.MakeTestIssue(
+ 789, 101, 'Source issue 2', 'New', 0)
+
+ self.services.issue.TestAddIssue(target_issue)
+ self.services.issue.TestAddIssue(source_issue)
+ self.services.issue.TestAddIssue(source_issue_2)
+
+ # We copy this list so that it isn't updated by the test framework
+ initial_issue_comments = (
+ self.services.issue.GetCommentsForIssue(
+ 'fake cnxn', target_issue.issue_id)[:])
+ mr = testing_helpers.MakeMonorailRequest(user_info={'user_id': 111})
+
+ # Merging source into target should create a comment.
+ self.assertIsNotNone(
+ tracker_helpers.MergeCCsAndAddComment(
+ self.services, mr, source_issue, target_issue))
+ updated_issue_comments = self.services.issue.GetCommentsForIssue(
+ 'fake cnxn', target_issue.issue_id)
+ for comment in initial_issue_comments:
+ self.assertIn(comment, updated_issue_comments)
+ self.assertEqual(
+ len(initial_issue_comments) + 1, len(updated_issue_comments))
+
+ # Merging source into target should add source's owner to target's CCs.
+ updated_target_issue = self.services.issue.GetIssueByLocalID(
+ 'fake cnxn', 789, 10)
+ self.assertIn(111, updated_target_issue.cc_ids)
+ self.assertIn(222, updated_target_issue.cc_ids)
+
+ # Merging source 2 into target should make a comment, but not update CCs.
+ self.assertIsNotNone(
+ tracker_helpers.MergeCCsAndAddComment(
+ self.services, mr, source_issue_2, updated_target_issue))
+ updated_target_issue = self.services.issue.GetIssueByLocalID(
+ 'fake cnxn', 789, 10)
+ self.assertNotIn(0, updated_target_issue.cc_ids)
+
+ def testMergeCCsAndAddComment_RestrictedSourceIssue(self):
+ target_issue = fake.MakeTestIssue(
+ 789, 10, 'Target issue', 'New', 222)
+ target_issue_2 = fake.MakeTestIssue(
+ 789, 11, 'Target issue 2', 'New', 222)
+ source_issue = fake.MakeTestIssue(
+ 789, 100, 'Source issue', 'New', 111)
+ source_issue.cc_ids.append(111)
+ source_issue.labels.append('Restrict-View-Commit')
+ target_issue_2.labels.append('Restrict-View-Commit')
+
+ self.services.issue.TestAddIssue(source_issue)
+ self.services.issue.TestAddIssue(target_issue)
+ self.services.issue.TestAddIssue(target_issue_2)
+
+ # We copy this list so that it isn't updated by the test framework
+ initial_issue_comments = self.services.issue.GetCommentsForIssue(
+ 'fake cnxn', target_issue.issue_id)[:]
+ mr = testing_helpers.MakeMonorailRequest(user_info={'user_id': 111})
+ self.assertIsNotNone(
+ tracker_helpers.MergeCCsAndAddComment(
+ self.services, mr, source_issue, target_issue))
+
+ # When the source is restricted, we update the target comments...
+ updated_issue_comments = self.services.issue.GetCommentsForIssue(
+ 'fake cnxn', target_issue.issue_id)
+ for comment in initial_issue_comments:
+ self.assertIn(comment, updated_issue_comments)
+ self.assertEqual(
+ len(initial_issue_comments) + 1, len(updated_issue_comments))
+ # ...but not the target CCs...
+ updated_target_issue = self.services.issue.GetIssueByLocalID(
+ 'fake cnxn', 789, 10)
+ self.assertNotIn(111, updated_target_issue.cc_ids)
+ # ...unless both issues have the same restrictions.
+ self.assertIsNotNone(
+ tracker_helpers.MergeCCsAndAddComment(
+ self.services, mr, source_issue, target_issue_2))
+ updated_target_issue_2 = self.services.issue.GetIssueByLocalID(
+ 'fake cnxn', 789, 11)
+ self.assertIn(111, updated_target_issue_2.cc_ids)
+
+ def testMergeCCsAndAddCommentMultipleIssues(self):
+ pass # TODO(jrobbins): Write this test.
+
+ def testGetAttachmentIfAllowed(self):
+ pass # TODO(jrobbins): Write this test.
+
+ def testLabelsMaskedByFields(self):
+ pass # TODO(jrobbins): Write this test.
+
+ def testLabelsNotMaskedByFields(self):
+ pass # TODO(jrobbins): Write this test.
+
+ def testLookupComponentIDs(self):
+ pass # TODO(jrobbins): Write this test.
+
+ def testParsePostDataUsers(self):
+ pd_users = 'a@example.com, b@example.com'
+
+ pd_users_ids, pd_users_str = tracker_helpers.ParsePostDataUsers(
+ self.cnxn, pd_users, self.services.user)
+
+ self.assertEqual([1, 2], sorted(pd_users_ids))
+ self.assertEqual('a@example.com, b@example.com', pd_users_str)
+
+ def testParsePostDataUsers_Empty(self):
+ pd_users = ''
+
+ pd_users_ids, pd_users_str = tracker_helpers.ParsePostDataUsers(
+ self.cnxn, pd_users, self.services.user)
+
+ self.assertEqual([], sorted(pd_users_ids))
+ self.assertEqual('', pd_users_str)
+
+ def testFilterIssueTypes(self):
+ pass # TODO(jrobbins): Write this test.
+
+ # ParseMergeFields is tested in IssueMergeTest.
+ # AddIssueStarrers is tested in IssueMergeTest.testMergeIssueStars().
+ # IsMergeAllowed is tested in IssueMergeTest.
+
+ def testPairDerivedValuesWithRuleExplanations_Nothing(self):
+ """Test we return nothing for an issue with no derived values."""
+ proposed_issue = tracker_pb2.Issue() # No derived values.
+ traces = {}
+ derived_users_by_id = {}
+ actual = tracker_helpers.PairDerivedValuesWithRuleExplanations(
+ proposed_issue, traces, derived_users_by_id)
+ (derived_labels_and_why, derived_owner_and_why,
+ derived_cc_and_why, warnings_and_why, errors_and_why) = actual
+ self.assertEqual([], derived_labels_and_why)
+ self.assertEqual([], derived_owner_and_why)
+ self.assertEqual([], derived_cc_and_why)
+ self.assertEqual([], warnings_and_why)
+ self.assertEqual([], errors_and_why)
+
+ def testPairDerivedValuesWithRuleExplanations_SomeValues(self):
+ """Test we return derived values and explanations for an issue."""
+ proposed_issue = tracker_pb2.Issue(
+ derived_owner_id=111, derived_cc_ids=[222, 333],
+ derived_labels=['aaa', 'zzz'],
+ derived_warnings=['Watch out'],
+ derived_errors=['Status Assigned requires an owner'])
+ traces = {
+ (tracker_pb2.FieldID.OWNER, 111): 'explain 1',
+ (tracker_pb2.FieldID.CC, 222): 'explain 2',
+ (tracker_pb2.FieldID.CC, 333): 'explain 3',
+ (tracker_pb2.FieldID.LABELS, 'aaa'): 'explain 4',
+ (tracker_pb2.FieldID.WARNING, 'Watch out'): 'explain 6',
+ (tracker_pb2.FieldID.ERROR,
+ 'Status Assigned requires an owner'): 'explain 7',
+ # There can be extra traces that are not used.
+ (tracker_pb2.FieldID.LABELS, 'bbb'): 'explain 5',
+ # If there is no trace for some derived value, why is None.
+ }
+ derived_users_by_id = {
+ 111: testing_helpers.Blank(display_name='one@example.com'),
+ 222: testing_helpers.Blank(display_name='two@example.com'),
+ 333: testing_helpers.Blank(display_name='three@example.com'),
+ }
+ actual = tracker_helpers.PairDerivedValuesWithRuleExplanations(
+ proposed_issue, traces, derived_users_by_id)
+ (derived_labels_and_why, derived_owner_and_why,
+ derived_cc_and_why, warnings_and_why, errors_and_why) = actual
+ self.assertEqual([
+ {'value': 'aaa', 'why': 'explain 4'},
+ {'value': 'zzz', 'why': None},
+ ], derived_labels_and_why)
+ self.assertEqual([
+ {'value': 'one@example.com', 'why': 'explain 1'},
+ ], derived_owner_and_why)
+ self.assertEqual([
+ {'value': 'two@example.com', 'why': 'explain 2'},
+ {'value': 'three@example.com', 'why': 'explain 3'},
+ ], derived_cc_and_why)
+ self.assertEqual([
+ {'value': 'Watch out', 'why': 'explain 6'},
+ ], warnings_and_why)
+ self.assertEqual([
+ {'value': 'Status Assigned requires an owner', 'why': 'explain 7'},
+ ], errors_and_why)
+
+
+class MakeViewsForUsersInIssuesTest(unittest.TestCase):
+
+ def setUp(self):
+ self.issue1 = _Issue('proj', 1)
+ self.issue1.owner_id = 1001
+ self.issue1.reporter_id = 1002
+
+ self.issue2 = _Issue('proj', 2)
+ self.issue2.owner_id = 2001
+ self.issue2.reporter_id = 2002
+ self.issue2.cc_ids.extend([1, 1001, 1002, 1003])
+
+ self.issue3 = _Issue('proj', 3)
+ self.issue3.owner_id = 1001
+ self.issue3.reporter_id = 3002
+
+ self.user = fake.UserService()
+ for user_id in [1, 1001, 1002, 1003, 2001, 2002, 3002]:
+ self.user.TestAddUser(
+ 'test%d' % user_id, user_id, add_user=True)
+
+ def testMakeViewsForUsersInIssues(self):
+ issue_list = [self.issue1, self.issue2, self.issue3]
+ users_by_id = tracker_helpers.MakeViewsForUsersInIssues(
+ 'fake cnxn', issue_list, self.user)
+ self.assertItemsEqual([0, 1, 1001, 1002, 1003, 2001, 2002, 3002],
+ list(users_by_id.keys()))
+ for user_id in [1001, 1002, 1003, 2001]:
+ self.assertEqual(users_by_id[user_id].user_id, user_id)
+
+ def testMakeViewsForUsersInIssuesOmittingSome(self):
+ issue_list = [self.issue1, self.issue2, self.issue3]
+ users_by_id = tracker_helpers.MakeViewsForUsersInIssues(
+ 'fake cnxn', issue_list, self.user, omit_ids=[1001, 1003])
+ self.assertItemsEqual([0, 1, 1002, 2001, 2002, 3002],
+ list(users_by_id.keys()))
+ for user_id in [1002, 2001, 2002, 3002]:
+ self.assertEqual(users_by_id[user_id].user_id, user_id)
+
+ def testMakeViewsForUsersInIssuesEmpty(self):
+ issue_list = []
+ users_by_id = tracker_helpers.MakeViewsForUsersInIssues(
+ 'fake cnxn', issue_list, self.user)
+ self.assertItemsEqual([], list(users_by_id.keys()))
+
+
+class GetAllIssueProjectsTest(unittest.TestCase):
+ issue_x_1 = tracker_pb2.Issue()
+ issue_x_1.project_id = 789
+ issue_x_1.local_id = 1
+ issue_x_1.reporter_id = 1002
+
+ issue_x_2 = tracker_pb2.Issue()
+ issue_x_2.project_id = 789
+ issue_x_2.local_id = 2
+ issue_x_2.reporter_id = 2002
+
+ issue_y_1 = tracker_pb2.Issue()
+ issue_y_1.project_id = 678
+ issue_y_1.local_id = 1
+ issue_y_1.reporter_id = 2002
+
+ def setUp(self):
+ self.project_service = fake.ProjectService()
+ self.project_service.TestAddProject('proj-x', project_id=789)
+ self.project_service.TestAddProject('proj-y', project_id=678)
+ self.cnxn = 'fake connection'
+
+ def testGetAllIssueProjects_Empty(self):
+ self.assertEqual(
+ {}, tracker_helpers.GetAllIssueProjects(
+ self.cnxn, [], self.project_service))
+
+ def testGetAllIssueProjects_Normal(self):
+ self.assertEqual(
+ {789: self.project_service.GetProjectByName(self.cnxn, 'proj-x')},
+ tracker_helpers.GetAllIssueProjects(
+ self.cnxn, [self.issue_x_1, self.issue_x_2], self.project_service))
+ self.assertEqual(
+ {789: self.project_service.GetProjectByName(self.cnxn, 'proj-x'),
+ 678: self.project_service.GetProjectByName(self.cnxn, 'proj-y')},
+ tracker_helpers.GetAllIssueProjects(
+ self.cnxn, [self.issue_x_1, self.issue_x_2, self.issue_y_1],
+ self.project_service))
+
+
+class FilterOutNonViewableIssuesTest(unittest.TestCase):
+ owner_id = 111
+ committer_id = 222
+ nonmember_1_id = 1002
+ nonmember_2_id = 2002
+ nonmember_3_id = 3002
+
+ issue1 = tracker_pb2.Issue()
+ issue1.project_name = 'proj'
+ issue1.project_id = 789
+ issue1.local_id = 1
+ issue1.reporter_id = nonmember_1_id
+
+ issue2 = tracker_pb2.Issue()
+ issue2.project_name = 'proj'
+ issue2.project_id = 789
+ issue2.local_id = 2
+ issue2.reporter_id = nonmember_2_id
+ issue2.labels.extend(['foo', 'bar'])
+
+ issue3 = tracker_pb2.Issue()
+ issue3.project_name = 'proj'
+ issue3.project_id = 789
+ issue3.local_id = 3
+ issue3.reporter_id = nonmember_3_id
+ issue3.labels.extend(['restrict-view-commit'])
+
+ issue4 = tracker_pb2.Issue()
+ issue4.project_name = 'proj'
+ issue4.project_id = 789
+ issue4.local_id = 4
+ issue4.reporter_id = nonmember_3_id
+ issue4.labels.extend(['Foo', 'Restrict-View-Commit'])
+
+ def setUp(self):
+ self.user = user_pb2.User()
+ self.project = self.MakeProject(project_pb2.ProjectState.LIVE)
+ self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(
+ self.project.project_id)
+ self.project_dict = {self.project.project_id: self.project}
+ self.config_dict = {self.config.project_id: self.config}
+
+ def MakeProject(self, state):
+ p = project_pb2.Project(
+ project_id=789, project_name='proj', state=state,
+ owner_ids=[self.owner_id], committer_ids=[self.committer_id])
+ return p
+
+ def testFilterOutNonViewableIssues_Member(self):
+ # perms will be permissions.COMMITTER_ACTIVE_PERMISSIONSET
+ filtered_issues = tracker_helpers.FilterOutNonViewableIssues(
+ {self.committer_id}, self.user, self.project_dict,
+ self.config_dict,
+ [self.issue1, self.issue2, self.issue3, self.issue4])
+ self.assertListEqual([1, 2, 3, 4],
+ [issue.local_id for issue in filtered_issues])
+
+ def testFilterOutNonViewableIssues_Owner(self):
+ # perms will be permissions.OWNER_ACTIVE_PERMISSIONSET
+ filtered_issues = tracker_helpers.FilterOutNonViewableIssues(
+ {self.owner_id}, self.user, self.project_dict, self.config_dict,
+ [self.issue1, self.issue2, self.issue3, self.issue4])
+ self.assertListEqual([1, 2, 3, 4],
+ [issue.local_id for issue in filtered_issues])
+
+ def testFilterOutNonViewableIssues_Empty(self):
+ # perms will be permissions.COMMITTER_ACTIVE_PERMISSIONSET
+ filtered_issues = tracker_helpers.FilterOutNonViewableIssues(
+ {self.committer_id}, self.user, self.project_dict,
+ self.config_dict, [])
+ self.assertListEqual([], filtered_issues)
+
+ def testFilterOutNonViewableIssues_NonMember(self):
+ # perms will be permissions.READ_ONLY_PERMISSIONSET
+ filtered_issues = tracker_helpers.FilterOutNonViewableIssues(
+ {self.nonmember_1_id}, self.user, self.project_dict,
+ self.config_dict, [self.issue1, self.issue2, self.issue3, self.issue4])
+ self.assertListEqual([1, 2],
+ [issue.local_id for issue in filtered_issues])
+
+ def testFilterOutNonViewableIssues_Reporter(self):
+ # perms will be permissions.READ_ONLY_PERMISSIONSET
+ filtered_issues = tracker_helpers.FilterOutNonViewableIssues(
+ {self.nonmember_3_id}, self.user, self.project_dict,
+ self.config_dict, [self.issue1, self.issue2, self.issue3, self.issue4])
+ self.assertListEqual([1, 2, 3, 4],
+ [issue.local_id for issue in filtered_issues])
+
+
+class IssueMergeTest(unittest.TestCase):
+
+ def setUp(self):
+ self.cnxn = 'fake cnxn'
+ self.services = service_manager.Services(
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ user=fake.UserService(),
+ project=fake.ProjectService(),
+ issue_star=fake.IssueStarService(),
+ spam=fake.SpamService()
+ )
+ self.project = self.services.project.TestAddProject('proj', project_id=987)
+ self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(
+ self.project.project_id)
+ self.project_dict = {self.project.project_id: self.project}
+ self.config_dict = {self.config.project_id: self.config}
+
+ def testParseMergeFields_NotSpecified(self):
+ issue = fake.MakeTestIssue(987, 1, 'summary', 'New', 111)
+ errors = template_helpers.EZTError()
+ post_data = {}
+
+ text, merge_into_issue = tracker_helpers.ParseMergeFields(
+ self.cnxn, None, 'proj', post_data, 'New', self.config, issue, errors)
+ self.assertEqual('', text)
+ self.assertEqual(None, merge_into_issue)
+
+ text, merge_into_issue = tracker_helpers.ParseMergeFields(
+ self.cnxn, None, 'proj', post_data, 'Duplicate', self.config, issue,
+ errors)
+ self.assertEqual('', text)
+ self.assertTrue(errors.merge_into_id)
+ self.assertEqual(None, merge_into_issue)
+
+ def testParseMergeFields_WrongStatus(self):
+ issue = fake.MakeTestIssue(987, 1, 'summary', 'New', 111)
+ errors = template_helpers.EZTError()
+ post_data = {'merge_into': '12'}
+
+ text, merge_into_issue = tracker_helpers.ParseMergeFields(
+ self.cnxn, None, 'proj', post_data, 'New', self.config, issue, errors)
+ self.assertEqual('', text)
+ self.assertEqual(None, merge_into_issue)
+
+ def testParseMergeFields_NoSuchIssue(self):
+ issue = fake.MakeTestIssue(987, 1, 'summary', 'New', 111)
+ issue.merged_into = 12
+ errors = template_helpers.EZTError()
+ post_data = {'merge_into': '12'}
+
+ text, merge_into_issue = tracker_helpers.ParseMergeFields(
+ self.cnxn, self.services, 'proj', post_data, 'Duplicate',
+ self.config, issue, errors)
+ self.assertEqual('12', text)
+ self.assertEqual(None, merge_into_issue)
+
+ def testParseMergeFields_DontSelfMerge(self):
+ issue = fake.MakeTestIssue(987, 1, 'summary', 'New', 111)
+ errors = template_helpers.EZTError()
+ post_data = {'merge_into': '1'}
+
+ text, merge_into_issue = tracker_helpers.ParseMergeFields(
+ self.cnxn, self.services, 'proj', post_data, 'Duplicate', self.config,
+ issue, errors)
+ self.assertEqual('1', text)
+ self.assertEqual(None, merge_into_issue)
+ self.assertEqual('Cannot merge issue into itself', errors.merge_into_id)
+
+ def testParseMergeFields_NewIssueToMerge(self):
+ merged_issue = fake.MakeTestIssue(
+ self.project.project_id,
+ 1,
+ 'unused_summary',
+ 'unused_status',
+ 111,
+ reporter_id=111)
+ self.services.issue.TestAddIssue(merged_issue)
+ mergee_issue = fake.MakeTestIssue(
+ self.project.project_id,
+ 2,
+ 'unused_summary',
+ 'unused_status',
+ 111,
+ reporter_id=111)
+ self.services.issue.TestAddIssue(mergee_issue)
+
+ errors = template_helpers.EZTError()
+ post_data = {'merge_into': str(mergee_issue.local_id)}
+
+ text, merge_into_issue = tracker_helpers.ParseMergeFields(
+ self.cnxn, self.services, 'proj', post_data, 'Duplicate', self.config,
+ merged_issue, errors)
+ self.assertEqual(str(mergee_issue.local_id), text)
+ self.assertEqual(mergee_issue, merge_into_issue)
+
+ def testIsMergeAllowed(self):
+ mr = testing_helpers.MakeMonorailRequest()
+ issue = fake.MakeTestIssue(987, 1, 'summary', 'New', 111)
+ issue.project_name = self.project.project_name
+
+ for (perm_set, expected_merge_allowed) in (
+ (permissions.READ_ONLY_PERMISSIONSET, False),
+ (permissions.COMMITTER_INACTIVE_PERMISSIONSET, False),
+ (permissions.COMMITTER_ACTIVE_PERMISSIONSET, True),
+ (permissions.OWNER_ACTIVE_PERMISSIONSET, True)):
+ mr.perms = perm_set
+ merge_allowed = tracker_helpers.IsMergeAllowed(issue, mr, self.services)
+ self.assertEqual(expected_merge_allowed, merge_allowed)
+
+ def testMergeIssueStars(self):
+ mr = testing_helpers.MakeMonorailRequest()
+ mr.project_name = self.project.project_name
+ mr.project = self.project
+
+ config = self.services.config.GetProjectConfig(
+ self.cnxn, self.project.project_id)
+ self.services.issue_star.SetStar(
+ self.cnxn, self.services, config, 1, 1, True)
+ self.services.issue_star.SetStar(
+ self.cnxn, self.services, config, 1, 2, True)
+ self.services.issue_star.SetStar(
+ self.cnxn, self.services, config, 1, 3, True)
+ self.services.issue_star.SetStar(
+ self.cnxn, self.services, config, 3, 3, True)
+ self.services.issue_star.SetStar(
+ self.cnxn, self.services, config, 3, 6, True)
+ self.services.issue_star.SetStar(
+ self.cnxn, self.services, config, 2, 3, True)
+ self.services.issue_star.SetStar(
+ self.cnxn, self.services, config, 2, 4, True)
+ self.services.issue_star.SetStar(
+ self.cnxn, self.services, config, 2, 5, True)
+
+ new_starrers = tracker_helpers.GetNewIssueStarrers(
+ self.cnxn, self.services, [1, 3], 2)
+ self.assertItemsEqual(new_starrers, [1, 2, 6])
+ tracker_helpers.AddIssueStarrers(
+ self.cnxn, self.services, mr, 2, self.project, new_starrers)
+ issue_2_starrers = self.services.issue_star.LookupItemStarrers(
+ self.cnxn, 2)
+ # XXX(jrobbins): these tests incorrectly mix local IDs with IIDs.
+ self.assertItemsEqual([1, 2, 3, 4, 5, 6], issue_2_starrers)
+
+
+class MergeLinkedMembersTest(unittest.TestCase):
+
+ def setUp(self):
+ self.cnxn = 'fake cnxn'
+ self.services = service_manager.Services(
+ user=fake.UserService())
+ self.user1 = self.services.user.TestAddUser('one@example.com', 111)
+ self.user2 = self.services.user.TestAddUser('two@example.com', 222)
+
+ def testNoLinkedAccounts(self):
+ """When no candidate accounts are linked, they are all returned."""
+ actual = tracker_helpers._MergeLinkedMembers(
+ self.cnxn, self.services.user, [111, 222])
+ self.assertEqual([111, 222], actual)
+
+ def testSomeLinkedButNoMasking(self):
+ """If an account has linked accounts, but they are not here, keep it."""
+ self.user1.linked_child_ids = [999]
+ self.user2.linked_parent_id = 999
+ actual = tracker_helpers._MergeLinkedMembers(
+ self.cnxn, self.services.user, [111, 222])
+ self.assertEqual([111, 222], actual)
+
+ def testParentMasksChild(self):
+ """When two accounts linked, only the parent is returned."""
+ self.user2.linked_parent_id = 111
+ actual = tracker_helpers._MergeLinkedMembers(
+ self.cnxn, self.services.user, [111, 222])
+ self.assertEqual([111], actual)
+
+
+class FilterMemberDataTest(unittest.TestCase):
+
+ def setUp(self):
+ services = service_manager.Services(
+ project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ user=fake.UserService())
+ self.owner_email = 'owner@dom.com'
+ self.committer_email = 'commit@dom.com'
+ self.contributor_email = 'contrib@dom.com'
+ self.indirect_member_email = 'ind@dom.com'
+ self.all_emails = [self.owner_email, self.committer_email,
+ self.contributor_email, self.indirect_member_email]
+ self.project = services.project.TestAddProject('proj')
+
+ def DoFiltering(self, perms, unsigned_user=False):
+ mr = testing_helpers.MakeMonorailRequest(
+ project=self.project, perms=perms)
+ if not unsigned_user:
+ mr.auth.user_id = 111
+ mr.auth.user_view = testing_helpers.Blank(domain='jrobbins.org')
+ return tracker_helpers._FilterMemberData(
+ mr, [self.owner_email], [self.committer_email],
+ [self.contributor_email], [self.indirect_member_email], mr.project)
+
+ def testUnsignedUser_NormalProject(self):
+ visible_members = self.DoFiltering(
+ permissions.READ_ONLY_PERMISSIONSET, unsigned_user=True)
+ self.assertItemsEqual(
+ [self.owner_email, self.committer_email, self.contributor_email,
+ self.indirect_member_email],
+ visible_members)
+
+ def testUnsignedUser_RestrictedProject(self):
+ self.project.only_owners_see_contributors = True
+ visible_members = self.DoFiltering(
+ permissions.READ_ONLY_PERMISSIONSET, unsigned_user=True)
+ self.assertItemsEqual(
+ [self.owner_email, self.committer_email, self.indirect_member_email],
+ visible_members)
+
+ def testOwnersAndAdminsCanSeeAll_NormalProject(self):
+ visible_members = self.DoFiltering(
+ permissions.OWNER_ACTIVE_PERMISSIONSET)
+ self.assertItemsEqual(self.all_emails, visible_members)
+
+ visible_members = self.DoFiltering(
+ permissions.ADMIN_PERMISSIONSET)
+ self.assertItemsEqual(self.all_emails, visible_members)
+
+ def testOwnersAndAdminsCanSeeAll_HubAndSpoke(self):
+ self.project.only_owners_see_contributors = True
+
+ visible_members = self.DoFiltering(
+ permissions.OWNER_ACTIVE_PERMISSIONSET)
+ self.assertItemsEqual(self.all_emails, visible_members)
+
+ visible_members = self.DoFiltering(
+ permissions.ADMIN_PERMISSIONSET)
+ self.assertItemsEqual(self.all_emails, visible_members)
+
+ visible_members = self.DoFiltering(
+ permissions.COMMITTER_ACTIVE_PERMISSIONSET)
+ self.assertItemsEqual(self.all_emails, visible_members)
+
+ def testNonOwnersCanSeeAll_NormalProject(self):
+ visible_members = self.DoFiltering(
+ permissions.COMMITTER_ACTIVE_PERMISSIONSET)
+ self.assertItemsEqual(self.all_emails, visible_members)
+
+ visible_members = self.DoFiltering(
+ permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+ self.assertItemsEqual(self.all_emails, visible_members)
+
+ def testCommittersSeeOnlySameDomain_HubAndSpoke(self):
+ self.project.only_owners_see_contributors = True
+
+ visible_members = self.DoFiltering(
+ permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+ self.assertItemsEqual(
+ [self.owner_email, self.committer_email, self.indirect_member_email],
+ visible_members)
+
+
+class GetLabelOptionsTest(unittest.TestCase):
+
+ @mock.patch('tracker.tracker_helpers.LabelsNotMaskedByFields')
+ def testGetLabelOptions(self, mockLabelsNotMaskedByFields):
+ mockLabelsNotMaskedByFields.return_value = []
+ config = tracker_pb2.ProjectIssueConfig()
+ custom_perms = []
+ actual = tracker_helpers.GetLabelOptions(config, custom_perms)
+ expected = [
+ {'doc': 'Only users who can edit the issue may access it',
+ 'name': 'Restrict-View-EditIssue'},
+ {'doc': 'Only users who can edit the issue may add comments',
+ 'name': 'Restrict-AddIssueComment-EditIssue'},
+ {'doc': 'Custom permission CoreTeam is needed to access',
+ 'name': 'Restrict-View-CoreTeam'}
+ ]
+ self.assertEqual(expected, actual)
+
+ def testBuildRestrictionChoices(self):
+ choices = tracker_helpers._BuildRestrictionChoices([], [], [])
+ self.assertEqual([], choices)
+
+ choices = tracker_helpers._BuildRestrictionChoices(
+ [], ['Hop', 'Jump'], [])
+ self.assertEqual([], choices)
+
+ freq = [('View', 'B', 'You need permission B to do anything'),
+ ('A', 'B', 'You need B to use A')]
+ choices = tracker_helpers._BuildRestrictionChoices(freq, [], [])
+ expected = [dict(name='Restrict-View-B',
+ doc='You need permission B to do anything'),
+ dict(name='Restrict-A-B',
+ doc='You need B to use A')]
+ self.assertListEqual(expected, choices)
+
+ extra_perms = ['Over18', 'Over21']
+ choices = tracker_helpers._BuildRestrictionChoices(
+ [], ['Drink', 'Smoke'], extra_perms)
+ expected = [dict(name='Restrict-Drink-Over18',
+ doc='Permission Over18 needed to use Drink'),
+ dict(name='Restrict-Drink-Over21',
+ doc='Permission Over21 needed to use Drink'),
+ dict(name='Restrict-Smoke-Over18',
+ doc='Permission Over18 needed to use Smoke'),
+ dict(name='Restrict-Smoke-Over21',
+ doc='Permission Over21 needed to use Smoke')]
+ self.assertListEqual(expected, choices)
+
+
+class FilterKeptAttachmentsTest(unittest.TestCase):
+ def testFilterKeptAttachments(self):
+ comments = [
+ tracker_pb2.IssueComment(
+ is_description=True,
+ attachments=[tracker_pb2.Attachment(attachment_id=1)]),
+ tracker_pb2.IssueComment(),
+ tracker_pb2.IssueComment(
+ is_description=True,
+ attachments=[
+ tracker_pb2.Attachment(attachment_id=2),
+ tracker_pb2.Attachment(attachment_id=3)]),
+ tracker_pb2.IssueComment(),
+ tracker_pb2.IssueComment(
+ approval_id=24,
+ is_description=True,
+ attachments=[tracker_pb2.Attachment(attachment_id=4)])]
+
+ filtered = tracker_helpers.FilterKeptAttachments(
+ True, [1, 2, 3, 4], comments, None)
+ self.assertEqual([2, 3], filtered)
+
+ def testApprovalDescription(self):
+ comments = [
+ tracker_pb2.IssueComment(
+ is_description=True,
+ attachments=[tracker_pb2.Attachment(attachment_id=1)]),
+ tracker_pb2.IssueComment(),
+ tracker_pb2.IssueComment(
+ is_description=True,
+ attachments=[
+ tracker_pb2.Attachment(attachment_id=2),
+ tracker_pb2.Attachment(attachment_id=3)]),
+ tracker_pb2.IssueComment(),
+ tracker_pb2.IssueComment(
+ approval_id=24,
+ is_description=True,
+ attachments=[tracker_pb2.Attachment(attachment_id=4)])]
+
+ filtered = tracker_helpers.FilterKeptAttachments(
+ True, [1, 2, 3, 4], comments, 24)
+ self.assertEqual([4], filtered)
+
+ def testNotAnIssueDescription(self):
+ comments = [
+ tracker_pb2.IssueComment(
+ is_description=True,
+ attachments=[tracker_pb2.Attachment(attachment_id=1)]),
+ tracker_pb2.IssueComment(),
+ tracker_pb2.IssueComment(
+ is_description=True,
+ attachments=[
+ tracker_pb2.Attachment(attachment_id=2),
+ tracker_pb2.Attachment(attachment_id=3)]),
+ tracker_pb2.IssueComment(),
+ tracker_pb2.IssueComment(
+ approval_id=24,
+ is_description=True,
+ attachments=[tracker_pb2.Attachment(attachment_id=4)])]
+
+ filtered = tracker_helpers.FilterKeptAttachments(
+ False, [1, 2, 3, 4], comments, None)
+ self.assertIsNone(filtered)
+
+ def testNoDescriptionsInComments(self):
+ comments = [
+ tracker_pb2.IssueComment(),
+ tracker_pb2.IssueComment()]
+
+ filtered = tracker_helpers.FilterKeptAttachments(
+ True, [1, 2, 3, 4], comments, None)
+ self.assertEqual([], filtered)
+
+ def testNoComments(self):
+ filtered = tracker_helpers.FilterKeptAttachments(
+ True, [1, 2, 3, 4], [], None)
+ self.assertEqual([], filtered)
+
+
+class EnumFieldHelpersTest(unittest.TestCase):
+
+ def test_GetEnumFieldValuesAndDocstrings(self):
+ """We can get all choices for an enum field"""
+ fd = tracker_pb2.FieldDef(
+ field_id=123,
+ project_id=1,
+ field_name='yellow',
+ field_type=tracker_pb2.FieldTypes.ENUM_TYPE)
+ ld_1 = tracker_pb2.LabelDef(
+ label='yellow-submarine', label_docstring='ld_1_docstring')
+ ld_2 = tracker_pb2.LabelDef(
+ label='yellow-tisket', label_docstring='ld_2_docstring')
+ ld_3 = tracker_pb2.LabelDef(
+ label='yellow-basket', label_docstring='ld_3_docstring')
+ ld_4 = tracker_pb2.LabelDef(
+ label='yellow', label_docstring='ld_4_docstring')
+ ld_5 = tracker_pb2.LabelDef(
+ label='not-yellow', label_docstring='ld_5_docstring')
+ ld_6 = tracker_pb2.LabelDef(
+ label='yellow-tasket',
+ label_docstring='ld_6_docstring',
+ deprecated=True)
+ config = tracker_pb2.ProjectIssueConfig(
+ default_template_for_developers=1,
+ default_template_for_users=2,
+ well_known_labels=[ld_1, ld_2, ld_3, ld_4, ld_5, ld_6])
+ actual = tracker_helpers._GetEnumFieldValuesAndDocstrings(fd, config)
+ # Expect to omit labels `yellow` and `not-yellow` due to prefix mismatch
+ # Also expect to omit label `yellow-tasket` because it's deprecated
+ expected = [
+ ('submarine', 'ld_1_docstring'), ('tisket', 'ld_2_docstring'),
+ ('basket', 'ld_3_docstring')
+ ]
+ self.assertEqual(expected, actual)
+
+
+class CreateIssueHelpersTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ user=fake.UserService(),
+ usergroup=fake.UserGroupService())
+ self.cnxn = 'fake cnxn'
+
+ self.project_member = self.services.user.TestAddUser(
+ 'user_1@example.com', 111)
+ self.project_group_member = self.services.user.TestAddUser(
+ 'group@example.com', 999)
+ self.project = self.services.project.TestAddProject(
+ 'proj',
+ project_id=789,
+ committer_ids=[
+ self.project_member.user_id, self.project_group_member.user_id
+ ])
+ self.no_project_user = self.services.user.TestAddUser(
+ 'user_2@example.com', 222)
+ self.config = fake.MakeTestConfig(self.project.project_id, [], [])
+ self.int_fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', False,
+ False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.int_fd.max_value = 999
+ self.config.field_defs = [self.int_fd]
+ self.status_1 = tracker_pb2.StatusDef(
+ status='New', means_open=True, status_docstring='status_1 docstring')
+ self.config.well_known_statuses = [self.status_1]
+ self.component_def_1 = tracker_pb2.ComponentDef(
+ component_id=1, path='compFOO')
+ self.component_def_2 = tracker_pb2.ComponentDef(
+ component_id=2, path='deprecated', deprecated=True)
+ self.config.component_defs = [self.component_def_1, self.component_def_2]
+ self.services.config.StoreConfig('cnxn', self.config)
+ self.services.usergroup.TestAddGroupSettings(999, 'group@example.com')
+
+ def testAssertValidIssueForCreate_Valid(self):
+ input_issue = tracker_pb2.Issue(
+ summary='sum',
+ status='New',
+ owner_id=111,
+ project_id=789,
+ component_ids=[1],
+ cc_ids=[999])
+ tracker_helpers.AssertValidIssueForCreate(
+ self.cnxn, self.services, input_issue, 'nonempty description')
+
+ def testAssertValidIssueForCreate_ValidatesOwner(self):
+ input_issue = tracker_pb2.Issue(
+ summary='sum', status='New', owner_id=222, project_id=789)
+ with self.assertRaisesRegexp(exceptions.InputException,
+ 'Issue owner must be a project member'):
+ tracker_helpers.AssertValidIssueForCreate(
+ self.cnxn, self.services, input_issue, 'nonempty description')
+ input_issue.owner_id = 333
+ with self.assertRaisesRegexp(exceptions.InputException,
+ 'Issue owner user ID not found'):
+ tracker_helpers.AssertValidIssueForCreate(
+ self.cnxn, self.services, input_issue, 'nonempty description')
+ input_issue.owner_id = 999
+ with self.assertRaisesRegexp(exceptions.InputException,
+ 'Issue owner cannot be a user group'):
+ tracker_helpers.AssertValidIssueForCreate(
+ self.cnxn, self.services, input_issue, 'nonempty description')
+
+ def testAssertValidIssueForCreate_ValidatesSummary(self):
+ input_issue = tracker_pb2.Issue(
+ summary='', status='New', owner_id=111, project_id=789)
+ with self.assertRaisesRegexp(exceptions.InputException,
+ 'Summary is required'):
+ tracker_helpers.AssertValidIssueForCreate(
+ self.cnxn, self.services, input_issue, 'nonempty description')
+ input_issue.summary = ' '
+ tracker_helpers.AssertValidIssueForCreate(
+ self.cnxn, self.services, input_issue, 'nonempty description')
+
+ def testAssertValidIssueForCreate_ValidatesDescription(self):
+ input_issue = tracker_pb2.Issue(
+ summary='sum', status='New', owner_id=111, project_id=789)
+ with self.assertRaisesRegexp(exceptions.InputException,
+ 'Description is required'):
+ tracker_helpers.AssertValidIssueForCreate(
+ self.cnxn, self.services, input_issue, '')
+ tracker_helpers.AssertValidIssueForCreate(
+ self.cnxn, self.services, input_issue, ' ')
+
+ def testAssertValidIssueForCreate_ValidatesFieldDef(self):
+ fv = tracker_bizobj.MakeFieldValue(
+ self.int_fd.field_id, 1000, None, None, None, None, False)
+ input_issue = tracker_pb2.Issue(
+ summary='sum',
+ status='New',
+ owner_id=111,
+ project_id=789,
+ field_values=[fv])
+ with self.assertRaises(exceptions.InputException):
+ tracker_helpers.AssertValidIssueForCreate(
+ self.cnxn, self.services, input_issue, 'nonempty description')
+
+ def testAssertValidIssueForCreate_ValidatesStatus(self):
+ input_issue = tracker_pb2.Issue(
+ summary='sum', status='DNE_status', owner_id=111, project_id=789)
+
+ def mock_status_lookup(*_args, **_kwargs):
+ return None
+
+ self.services.config.LookupStatusID = mock_status_lookup
+ with self.assertRaisesRegexp(exceptions.InputException,
+ 'Undefined status: DNE_status'):
+ tracker_helpers.AssertValidIssueForCreate(
+ self.cnxn, self.services, input_issue, 'nonempty description')
+
+ def testAssertValidIssueForCreate_ValidatesComponents(self):
+ # Tests an undefined component.
+ input_issue = tracker_pb2.Issue(
+ summary='',
+ status='New',
+ owner_id=111,
+ project_id=789,
+ component_ids=[3])
+ with self.assertRaisesRegexp(
+ exceptions.InputException,
+ 'Undefined or deprecated component with id: 3'):
+ tracker_helpers.AssertValidIssueForCreate(
+ self.cnxn, self.services, input_issue, 'nonempty description')
+
+ # Tests a deprecated component.
+ input_issue = tracker_pb2.Issue(
+ summary='',
+ status='New',
+ owner_id=111,
+ project_id=789,
+ component_ids=[self.component_def_2.component_id])
+ with self.assertRaisesRegexp(
+ exceptions.InputException,
+ 'Undefined or deprecated component with id: 2'):
+ tracker_helpers.AssertValidIssueForCreate(
+ self.cnxn, self.services, input_issue, 'nonempty description')
+
+ def testAssertValidIssueForCreate_ValidatesUsers(self):
+ user_fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', False,
+ False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.services.config.TestAddFieldDef(user_fd)
+
+ input_issue = tracker_pb2.Issue(
+ summary='sum',
+ status='New',
+ owner_id=111,
+ project_id=789,
+ cc_ids=[123],
+ field_values=[
+ tracker_bizobj.MakeFieldValue(
+ user_fd.field_id, None, None, 124, None, None, False)
+ ])
+ copied_issue = copy.deepcopy(input_issue)
+ with self.assertRaisesRegexp(exceptions.InputException,
+ r'users/123: .+\nusers/124: .+'):
+ tracker_helpers.AssertValidIssueForCreate(
+ self.cnxn, self.services, input_issue, 'nonempty description')
+ self.assertEqual(input_issue, copied_issue)
+
+ self.services.user.TestAddUser('a@test.com', 123)
+ self.services.user.TestAddUser('a@test.com', 124)
+ tracker_helpers.AssertValidIssueForCreate(
+ self.cnxn, self.services, input_issue, 'nonempty description')
+ self.assertEqual(input_issue, copied_issue)
+
+
+class ModifyIssuesHelpersTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ project=fake.ProjectService(),
+ config=fake.ConfigService(),
+ issue=fake.IssueService(),
+ issue_star=fake.IssueStarService(),
+ user=fake.UserService(),
+ usergroup=fake.UserGroupService())
+ self.cnxn = 'fake cnxn'
+
+ self.project_member = self.services.user.TestAddUser(
+ 'user_1@example.com', 111)
+ self.project = self.services.project.TestAddProject(
+ 'proj', project_id=789, committer_ids=[self.project_member.user_id])
+ self.no_project_user = self.services.user.TestAddUser(
+ 'user_2@example.com', 222)
+
+ self.config = fake.MakeTestConfig(self.project.project_id, [], [])
+ self.int_fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.INT_TYPE, None, '', False,
+ False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.int_fd.max_value = 999
+ self.config.field_defs = [self.int_fd]
+ self.services.config.StoreConfig('cnxn', self.config)
+
+ def testApplyAllIssueChanges(self):
+ issue_delta_pairs = []
+ no_change_iid = 78942
+
+ expected_issues_to_update = {}
+ expected_amendments = {}
+ expected_imp_amendments = {}
+ expected_old_owners = {}
+ expected_old_statuses = {}
+ expected_old_components = {}
+ expected_merged_from_add = {}
+ expected_new_starrers = {}
+
+ issue_main = _Issue('proj', 100)
+ issue_main_ref = ('proj', issue_main.local_id)
+ issue_main.owner_id = 999
+ issue_main.cc_ids = [111, 222]
+ issue_main.labels = ['dont_touch', 'remove_me']
+
+ expected_main = copy.deepcopy(issue_main)
+ expected_main.owner_id = 888
+ expected_main.cc_ids = [111, 333]
+ expected_main.labels = ['dont_touch', 'add_me']
+ expected_amendments[issue_main.issue_id] = [
+ tracker_bizobj.MakeOwnerAmendment(888, 999),
+ tracker_bizobj.MakeCcAmendment([333], [222]),
+ tracker_bizobj.MakeLabelsAmendment(['add_me'], ['remove_me'])
+ ]
+ expected_old_owners[issue_main.issue_id] = 999
+
+ # blocked_on issues changes setup.
+ bo_add = _Issue('proj', 1)
+ self.services.issue.TestAddIssue(bo_add)
+ expected_bo_add = copy.deepcopy(bo_add)
+ # All impacted issues should be fetched within ApplyAllIssueChanges
+ # directly from the DB, skipping cache with `use_cache=False` in GetIssue().
+ # So we expect these issues to have assume_stale=False.
+ expected_bo_add.assume_stale = False
+ expected_bo_add.blocking_iids = [issue_main.issue_id]
+ expected_issues_to_update[expected_bo_add.issue_id] = expected_bo_add
+ expected_imp_amendments[bo_add.issue_id] = [
+ tracker_bizobj.MakeBlockingAmendment(
+ [issue_main_ref], [], default_project_name='proj')
+ ]
+
+ bo_remove = _Issue('proj', 2)
+ bo_remove.blocking_iids = [issue_main.issue_id]
+ self.services.issue.TestAddIssue(bo_remove)
+ expected_bo_remove = copy.deepcopy(bo_remove)
+ expected_bo_remove.assume_stale = False
+ expected_bo_remove.blocking_iids = []
+ expected_issues_to_update[expected_bo_remove.issue_id] = expected_bo_remove
+ expected_imp_amendments[bo_remove.issue_id] = [
+ tracker_bizobj.MakeBlockingAmendment(
+ [], [issue_main_ref], default_project_name='proj')
+ ]
+
+ issue_main.blocked_on_iids = [no_change_iid, bo_remove.issue_id]
+ # By default new blocked_on issues that appear in blocked_on_iids
+ # with no prior rank associated with it are un-ranked and assigned rank 0.
+ # See SortBlockedOn in issue_svc.py.
+ issue_main.blocked_on_ranks = [0, 0]
+ expected_main.blocked_on_iids = [no_change_iid, bo_add.issue_id]
+ expected_main.blocked_on_ranks = [0, 0]
+ expected_amendments[issue_main.issue_id].append(
+ tracker_bizobj.MakeBlockedOnAmendment(
+ [('proj', bo_add.local_id)], [('proj', bo_remove.local_id)],
+ default_project_name='proj'))
+
+ # blocking_issues changes setup.
+ b_add = _Issue('proj', 3)
+ self.services.issue.TestAddIssue(b_add)
+ expected_b_add = copy.deepcopy(b_add)
+ expected_b_add.assume_stale = False
+ expected_b_add.blocked_on_iids = [issue_main.issue_id]
+ expected_b_add.blocked_on_ranks = [0]
+ expected_issues_to_update[expected_b_add.issue_id] = expected_b_add
+ expected_imp_amendments[b_add.issue_id] = [
+ tracker_bizobj.MakeBlockedOnAmendment(
+ [issue_main_ref], [], default_project_name='proj')
+ ]
+
+ b_remove = _Issue('proj', 4)
+ b_remove.blocked_on_iids = [issue_main.issue_id]
+ self.services.issue.TestAddIssue(b_remove)
+ expected_b_remove = copy.deepcopy(b_remove)
+ expected_b_remove.assume_stale = False
+ expected_b_remove.blocked_on_iids = []
+ # Test we can process delta changes and impact changes.
+ delta_b_remove = tracker_pb2.IssueDelta(labels_add=['more_chickens'])
+ expected_b_remove.labels = ['more_chickens']
+ issue_delta_pairs.append((b_remove, delta_b_remove))
+ expected_issues_to_update[expected_b_remove.issue_id] = expected_b_remove
+ expected_imp_amendments[b_remove.issue_id] = [
+ tracker_bizobj.MakeBlockedOnAmendment(
+ [], [issue_main_ref], default_project_name='proj')
+ ]
+ expected_amendments[b_remove.issue_id] = [
+ tracker_bizobj.MakeLabelsAmendment(['more_chickens'], [])
+ ]
+
+ issue_main.blocking_iids = [no_change_iid, b_remove.issue_id]
+ expected_main.blocking_iids = [no_change_iid, b_add.issue_id]
+ expected_amendments[issue_main.issue_id].append(
+ tracker_bizobj.MakeBlockingAmendment(
+ [('proj', b_add.local_id)], [('proj', b_remove.local_id)],
+ default_project_name='proj'))
+
+ # Merged issues changes setup.
+ merge_remove = _Issue('proj', 5)
+ self.services.issue.TestAddIssue(merge_remove)
+ expected_merge_remove = copy.deepcopy(merge_remove)
+ expected_merge_remove.assume_stale = False
+ expected_issues_to_update[
+ expected_merge_remove.issue_id] = expected_merge_remove
+ expected_imp_amendments[merge_remove.issue_id] = [
+ tracker_bizobj.MakeMergedIntoAmendment(
+ [], [issue_main_ref], default_project_name='proj')
+ ]
+
+ merge_add = _Issue('proj', 6)
+ self.services.issue.TestAddIssue(merge_add)
+ expected_merge_add = copy.deepcopy(merge_add)
+ expected_merge_add.assume_stale = False
+ # We are adding 333 and removing 222 in issue_main with delta_main.
+ expected_merge_add.cc_ids = [expected_main.owner_id, 333, 111]
+ expected_merged_from_add[expected_merge_add.issue_id] = [
+ issue_main.issue_id
+ ]
+
+ expected_imp_amendments[merge_add.issue_id] = [
+ tracker_bizobj.MakeCcAmendment(expected_merge_add.cc_ids, []),
+ tracker_bizobj.MakeMergedIntoAmendment(
+ [issue_main_ref], [], default_project_name='proj')
+ ]
+ # We are merging issue_main into merge_add, so issue_main's starrers
+ # should be merged into merge_add's starrers.
+ self.services.issue_star.SetStar(
+ self.cnxn, self.services, None, issue_main.issue_id, 111, True)
+ self.services.issue_star.SetStar(
+ self.cnxn, self.services, None, issue_main.issue_id, 222, True)
+ expected_merge_add.star_count = 2
+ expected_new_starrers[merge_add.issue_id] = [222, 111]
+
+ expected_issues_to_update[expected_merge_add.issue_id] = expected_merge_add
+
+
+ issue_main.merged_into = merge_remove.issue_id
+ expected_main.merged_into = merge_add.issue_id
+ expected_amendments[issue_main.issue_id].append(
+ tracker_bizobj.MakeMergedIntoAmendment(
+ [('proj', merge_add.local_id)], [('proj', merge_remove.local_id)],
+ default_project_name='proj'))
+
+ self.services.issue.TestAddIssue(issue_main)
+ expected_issues_to_update[expected_main.issue_id] = expected_main
+
+
+ # Issues we'll put in delta_main.*_remove fields that aren't in issue_main.
+ # These issues should not show up in issues_to_update.
+ missing_1 = _Issue('proj', 404)
+ expected_missing_1 = copy.deepcopy(missing_1)
+ expected_missing_1.assume_stale = False
+ self.services.issue.TestAddIssue(missing_1)
+ missing_2 = _Issue('proj', 405)
+ self.services.issue.TestAddIssue(missing_2)
+ expected_missing_2 = copy.deepcopy(missing_2)
+ expected_missing_2.assume_stale = False
+
+ delta_main = tracker_pb2.IssueDelta(
+ owner_id=888,
+ cc_ids_remove=[222, 404], cc_ids_add=[333],
+ labels_remove=['remove_me', 'remove_404'], labels_add=['add_me'],
+ merged_into=merge_add.issue_id,
+ blocked_on_add=[bo_add.issue_id],
+ blocked_on_remove=[bo_remove.issue_id, missing_1.issue_id],
+ blocking_add=[b_add.issue_id],
+ blocking_remove=[b_remove.issue_id, missing_2.issue_id])
+ issue_delta_pairs.append((issue_main, delta_main))
+
+ actual_tuple = tracker_helpers.ApplyAllIssueChanges(
+ self.cnxn, issue_delta_pairs, self.services)
+
+ expected_tuple = tracker_helpers._IssueChangesTuple(
+ expected_issues_to_update, expected_merged_from_add,
+ expected_amendments, expected_imp_amendments, expected_old_owners,
+ expected_old_statuses, expected_old_components, expected_new_starrers)
+ self.assertEqual(actual_tuple, expected_tuple)
+
+ self.assertEqual(missing_1, expected_missing_1)
+ self.assertEqual(missing_2, expected_missing_2)
+
+ def testApplyAllIssueChanges_NOOP(self):
+ """Check we can ignore issue-delta pairs that are NOOP."""
+ noop_issue = _Issue('proj', 1)
+ bo_add_noop = _Issue('proj', 2)
+ bo_remove_noop = _Issue('proj', 3)
+
+ noop_issue.owner_id = 111
+ noop_issue.cc_ids = [222]
+ noop_issue.blocked_on_iids = [bo_add_noop.issue_id]
+ bo_add_noop.blocking_iids = [noop_issue.issue_id]
+
+ self.services.issue.TestAddIssue(noop_issue)
+ self.services.issue.TestAddIssue(bo_add_noop)
+ self.services.issue.TestAddIssue(bo_remove_noop)
+ expected_noop_issue = copy.deepcopy(noop_issue)
+ noop_delta = tracker_pb2.IssueDelta(
+ owner_id=noop_issue.owner_id,
+ cc_ids_add=noop_issue.cc_ids, cc_ids_remove=[333],
+ blocked_on_add=noop_issue.blocked_on_iids,
+ blocked_on_remove=[bo_remove_noop.issue_id])
+ issue_delta_pairs = [(noop_issue, noop_delta)]
+
+ actual_tuple = tracker_helpers.ApplyAllIssueChanges(
+ self.cnxn, issue_delta_pairs, self.services)
+ expected_tuple = tracker_helpers._IssueChangesTuple(
+ {}, {}, {}, {}, {}, {}, {}, {})
+ self.assertEqual(actual_tuple, expected_tuple)
+
+ self.assertEqual(noop_issue, expected_noop_issue)
+
+ def testApplyAllIssueChanges_Empty(self):
+ issue_delta_pairs = []
+ actual_tuple = tracker_helpers.ApplyAllIssueChanges(
+ self.cnxn, issue_delta_pairs, self.services)
+ expected_tuple = tracker_helpers._IssueChangesTuple(
+ {}, {}, {}, {}, {}, {}, {}, {})
+ self.assertEqual(actual_tuple, expected_tuple)
+
+ def testUpdateClosedTimestamp(self):
+ config = tracker_pb2.ProjectIssueConfig()
+ config.well_known_statuses.append(
+ tracker_pb2.StatusDef(status='New', means_open=True))
+ config.well_known_statuses.append(
+ tracker_pb2.StatusDef(status='Accepted', means_open=True))
+ config.well_known_statuses.append(
+ tracker_pb2.StatusDef(status='Old', means_open=False))
+ config.well_known_statuses.append(
+ tracker_pb2.StatusDef(status='Closed', means_open=False))
+
+ issue = tracker_pb2.Issue()
+ issue.local_id = 1234
+ issue.status = 'New'
+
+ # ensure the default value is undef
+ self.assertTrue(not issue.closed_timestamp)
+
+ # ensure transitioning to the same and other open states
+ # doesn't set the timestamp
+ issue.status = 'New'
+ tracker_helpers.UpdateClosedTimestamp(config, issue, 'New')
+ self.assertTrue(not issue.closed_timestamp)
+
+ issue.status = 'Accepted'
+ tracker_helpers.UpdateClosedTimestamp(config, issue, 'New')
+ self.assertTrue(not issue.closed_timestamp)
+
+ # ensure transitioning from open to closed sets the timestamp
+ issue.status = 'Closed'
+ tracker_helpers.UpdateClosedTimestamp(config, issue, 'Accepted')
+ self.assertTrue(issue.closed_timestamp)
+
+ # ensure that the timestamp is cleared when transitioning from
+ # closed to open
+ issue.status = 'New'
+ tracker_helpers.UpdateClosedTimestamp(config, issue, 'Closed')
+ self.assertTrue(not issue.closed_timestamp)
+
+ def testGroupUniqueDeltaIssues(self):
+ """We can identify unique IssueDeltas and group Issues by their deltas."""
+ issue_1 = _Issue('proj', 1)
+ delta_1 = tracker_pb2.IssueDelta(cc_ids_add=[111])
+
+ issue_2 = _Issue('proj', 2)
+ delta_2 = tracker_pb2.IssueDelta(cc_ids_add=[111], cc_ids_remove=[222])
+
+ issue_3 = _Issue('proj', 3)
+ delta_3 = tracker_pb2.IssueDelta(cc_ids_add=[111])
+
+ issue_4 = _Issue('proj', 4)
+ delta_4 = tracker_pb2.IssueDelta()
+
+ issue_5 = _Issue('proj', 5)
+ delta_5 = tracker_pb2.IssueDelta()
+
+ issue_delta_pairs = [
+ (issue_1, delta_1), (issue_2, delta_2), (issue_3, delta_3),
+ (issue_4, delta_4), (issue_5, delta_5)
+ ]
+ unique_deltas, issues_for_deltas = tracker_helpers.GroupUniqueDeltaIssues(
+ issue_delta_pairs)
+
+ expected_unique_deltas = [delta_1, delta_2, delta_4]
+ self.assertEqual(unique_deltas, expected_unique_deltas)
+ expected_issues_for_deltas = [
+ [issue_1, issue_3], [issue_2], [issue_4, issue_5]
+ ]
+ self.assertEqual(issues_for_deltas, expected_issues_for_deltas)
+
+ def testEnforceAttachmentQuotaLimits(self):
+ self.services.project.TestAddProject('Circe', project_id=798)
+ issue_a1 = _Issue('Circe', 1, project_id=798)
+ delta_a1 = tracker_pb2.IssueDelta()
+
+ issue_a2 = _Issue('Circe', 2, project_id=798)
+ delta_a2 = tracker_pb2.IssueDelta()
+
+ self.services.project.TestAddProject('Patroclus', project_id=788)
+ issue_b1 = _Issue('Patroclus', 1, project_id=788)
+ delta_b1 = tracker_pb2.IssueDelta()
+
+ issue_delta_pairs = [
+ (issue_a1, delta_a1), (issue_a2, delta_a2), (issue_b1, delta_b1)
+ ]
+
+ upload_1 = framework_helpers.AttachmentUpload(
+ 'dragon', 'OOOOOO\n', 'text/plain')
+ upload_2 = framework_helpers.AttachmentUpload(
+ 'snake', 'ooooo\n', 'text/plain')
+ attachment_uploads = [upload_1, upload_2]
+
+ actual = tracker_helpers._EnforceAttachmentQuotaLimits(
+ self.cnxn, issue_delta_pairs, self.services, attachment_uploads)
+
+ expected = {
+ 798: len(upload_1.contents + upload_2.contents) * 2,
+ 788: len(upload_1.contents + upload_2.contents)
+ }
+ self.assertEqual(actual, expected)
+
+ @mock.patch('tracker.tracker_constants.ISSUE_ATTACHMENTS_QUOTA_HARD', 1)
+ def testEnforceAttachmentQuotaLimits_Exceeded(self):
+ self.services.project.TestAddProject('Circe', project_id=798)
+ issue_a1 = _Issue('Circe', 1, project_id=798)
+ delta_a1 = tracker_pb2.IssueDelta()
+
+ issue_a2 = _Issue('Circe', 2, project_id=798)
+ delta_a2 = tracker_pb2.IssueDelta()
+
+ self.services.project.TestAddProject('Patroclus', project_id=788)
+ issue_b1 = _Issue('Patroclus', 1, project_id=788)
+ delta_b1 = tracker_pb2.IssueDelta()
+
+ issue_delta_pairs = [
+ (issue_a1, delta_a1), (issue_a2, delta_a2), (issue_b1, delta_b1)
+ ]
+
+ upload_1 = framework_helpers.AttachmentUpload(
+ 'dragon', 'OOOOOO\n', 'text/plain')
+ upload_2 = framework_helpers.AttachmentUpload(
+ 'snake', 'ooooo\n', 'text/plain')
+ attachment_uploads = [upload_1, upload_2]
+
+ with self.assertRaisesRegexp(exceptions.OverAttachmentQuota,
+ r'.+ project Patroclus\n.+ project Circe'):
+ tracker_helpers._EnforceAttachmentQuotaLimits(
+ self.cnxn, issue_delta_pairs, self.services, attachment_uploads)
+
+ def testAssertIssueChangesValid_Valid(self):
+ """We can assert when deltas are valid for issues."""
+ impacted_issue = _Issue('chicken', 101)
+ self.services.issue.TestAddIssue(impacted_issue)
+
+ issue_1 = _Issue('chicken', 1)
+ self.services.issue.TestAddIssue(issue_1)
+ delta_1 = tracker_pb2.IssueDelta(
+ merged_into=impacted_issue.issue_id, status='Duplicate')
+ exp_d1 = copy.deepcopy(delta_1)
+
+ issue_2 = _Issue('chicken', 2)
+ self.services.issue.TestAddIssue(issue_2)
+ delta_2 = tracker_pb2.IssueDelta(blocked_on_add=[impacted_issue.issue_id])
+ exp_d2 = copy.deepcopy(delta_2)
+
+ issue_3 = _Issue('chicken', 3)
+ self.services.issue.TestAddIssue(issue_3)
+ delta_3 = tracker_pb2.IssueDelta()
+ exp_d3 = copy.deepcopy(delta_3)
+
+ issue_4 = _Issue('chicken', 4)
+ self.services.issue.TestAddIssue(issue_4)
+ delta_4 = tracker_pb2.IssueDelta(owner_id=self.project_member.user_id)
+ exp_d4 = copy.deepcopy(delta_4)
+
+ issue_5 = _Issue('chicken', 5)
+ self.services.issue.TestAddIssue(issue_5)
+ fv = tracker_bizobj.MakeFieldValue(
+ self.int_fd.field_id, 998, None, None, None, None, False)
+ delta_5 = tracker_pb2.IssueDelta(field_vals_add=[fv])
+ exp_d5 = copy.deepcopy(delta_5)
+
+ issue_6 = _Issue('chicken', 6)
+ self.services.issue.TestAddIssue(issue_6)
+ delta_6 = tracker_pb2.IssueDelta(
+ summary=' ' + 's' * tracker_constants.MAX_SUMMARY_CHARS + ' ')
+ exp_d6 = copy.deepcopy(delta_6)
+
+ issue_7 = _Issue('chicken', 7)
+ self.services.issue.TestAddIssue(issue_7)
+ issue_8 = _Issue('chicken', 8)
+ self.services.issue.TestAddIssue(issue_8)
+
+ # We are fine with duplicate/consistent deltas.
+ delta_7 = tracker_pb2.IssueDelta(blocked_on_add=[issue_8.issue_id])
+ exp_d7 = copy.deepcopy(delta_7)
+ delta_8 = tracker_pb2.IssueDelta(blocking_add=[issue_7.issue_id])
+ exp_d8 = copy.deepcopy(delta_8)
+
+ issue_9 = _Issue('chicken', 9)
+ self.services.issue.TestAddIssue(issue_9)
+ issue_10 = _Issue('chicken', 10)
+ self.services.issue.TestAddIssue(issue_10)
+
+ delta_9 = tracker_pb2.IssueDelta(blocked_on_remove=[issue_10.issue_id])
+ exp_d9 = copy.deepcopy(delta_9)
+ delta_10 = tracker_pb2.IssueDelta(blocking_remove=[issue_9.issue_id])
+ exp_d10 = copy.deepcopy(delta_10)
+
+ issue_11 = _Issue('chicken', 11)
+ user_fd = tracker_bizobj.MakeFieldDef(
+ 123, 789, 'CPU', tracker_pb2.FieldTypes.USER_TYPE, None, '', False,
+ False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.services.config.TestAddFieldDef(user_fd)
+ a_user = self.services.user.TestAddUser('a_user@test.com', 123)
+ delta_11 = tracker_pb2.IssueDelta(
+ cc_ids_add=[222],
+ field_vals_add=[
+ tracker_bizobj.MakeFieldValue(
+ user_fd.field_id, None, None, a_user.user_id, None, None, False)
+ ])
+ exp_d11 = copy.deepcopy(delta_11)
+
+ issue_delta_pairs = [
+ (issue_1, delta_1), (issue_2, delta_2), (issue_3, delta_3),
+ (issue_4, delta_4), (issue_5, delta_5), (issue_6, delta_6),
+ (issue_7, delta_7), (issue_8, delta_8), (issue_9, delta_9),
+ (issue_10, delta_10), (issue_11, delta_11)
+ ]
+ comment = ' ' + 'c' * tracker_constants.MAX_COMMENT_CHARS + ' '
+ tracker_helpers._AssertIssueChangesValid(
+ self.cnxn, issue_delta_pairs, self.services, comment_content=comment)
+
+ # Check we can handle None `comment_content`.
+ tracker_helpers._AssertIssueChangesValid(
+ self.cnxn, issue_delta_pairs, self.services)
+ self.assertEqual(
+ [
+ exp_d1, exp_d2, exp_d3, exp_d4, exp_d5, exp_d6, exp_d7, exp_d8,
+ exp_d9, exp_d10, exp_d11
+ ], [
+ delta_1, delta_2, delta_3, delta_4, delta_5, delta_6, delta_7,
+ delta_8, delta_9, delta_10, delta_11
+ ])
+
+ def testAssertIssueChangesValid_RequiredField(self):
+ """Asserts fields and requried fields.."""
+ issue_1 = _Issue('chicken', 1)
+ self.services.issue.TestAddIssue(issue_1)
+ delta_1 = tracker_pb2.IssueDelta()
+ exp_d1 = copy.deepcopy(delta_1)
+
+ required_fd = tracker_bizobj.MakeFieldDef(
+ 124, 789, 'StrField', tracker_pb2.FieldTypes.STR_TYPE, None, '', True,
+ False, False, None, None, '', False, '', '',
+ tracker_pb2.NotifyTriggers.NEVER, 'no_action', 'doc', False)
+ self.services.config.TestAddFieldDef(required_fd)
+
+ issue_delta_pairs = [(issue_1, delta_1)]
+ comment = 'just a plain comment'
+ tracker_helpers._AssertIssueChangesValid(
+ self.cnxn, issue_delta_pairs, self.services, comment_content=comment)
+
+ # Check we can handle adding a field value when issue is in invalid state.
+ fv = tracker_bizobj.MakeFieldValue(
+ self.int_fd.field_id, 998, None, None, None, None, False)
+ delta_2 = tracker_pb2.IssueDelta(field_vals_add=[fv])
+ exp_d2 = copy.deepcopy(delta_2)
+ tracker_helpers._AssertIssueChangesValid(
+ self.cnxn, issue_delta_pairs, self.services)
+ self.assertEqual([exp_d1, exp_d2], [delta_1, delta_2])
+
+ def testAssertIssueChangesValid_Invalid(self):
+ """We can raise exceptions when deltas are not valid for issues. """
+
+ def getRef(issue):
+ return '%s:%d' % (issue.project_name, issue.local_id)
+
+ issue_delta_pairs = []
+ expected_err_msgs = []
+
+ comment = 'c' * (tracker_constants.MAX_COMMENT_CHARS + 1)
+ expected_err_msgs.append('Comment is too long.')
+
+ issue_1 = _Issue('chicken', 1)
+ self.services.issue.TestAddIssue(issue_1)
+ issue_1_ref = getRef(issue_1)
+
+ delta_1 = tracker_pb2.IssueDelta(
+ merged_into=issue_1.issue_id,
+ blocked_on_add=[issue_1.issue_id],
+ summary='',
+ status='',
+ cc_ids_add=[9876])
+
+ issue_delta_pairs.append((issue_1, delta_1))
+ expected_err_msgs.extend(
+ [
+ ('%s: MERGED type statuses must accompany mergedInto values.') %
+ issue_1_ref,
+ '%s: Cannot merge an issue into itself.' % issue_1_ref,
+ '%s: Cannot block an issue on itself.' % issue_1_ref,
+ 'users/9876: User does not exist.',
+ '%s: Summary required.' % issue_1_ref,
+ '%s: Status is required.' % issue_1_ref
+ ])
+
+ issue_2 = _Issue('chicken', 2)
+ self.services.issue.TestAddIssue(issue_2)
+ issue_2_ref = getRef(issue_2)
+
+ fv = tracker_bizobj.MakeFieldValue(
+ self.int_fd.field_id, 1000, None, None, None, None, False)
+ delta_2 = tracker_pb2.IssueDelta(
+ status='Duplicate',
+ blocking_add=[issue_2.issue_id],
+ summary='s' * (tracker_constants.MAX_SUMMARY_CHARS + 1),
+ owner_id=self.no_project_user.user_id,
+ field_vals_add=[fv])
+ issue_delta_pairs.append((issue_2, delta_2))
+
+ expected_err_msgs.extend(
+ [
+ ('%s: MERGED type statuses must accompany mergedInto values.') %
+ issue_2_ref,
+ '%s: Cannot block an issue on itself.' % issue_2_ref,
+ '%s: Issue owner must be a project member.' % issue_2_ref,
+ '%s: Summary is too long.' % issue_2_ref,
+ '%s: Error for %r: Value must be <= 999.' % (issue_2_ref, fv)
+ ])
+
+ issue_3 = _Issue('chicken', 3)
+ issue_3.status = 'Duplicate'
+ issue_3.merged_into = 78911
+ self.services.issue.TestAddIssue(issue_3)
+ issue_3_ref = getRef(issue_3)
+ delta_3 = tracker_pb2.IssueDelta(
+ status='Available', merged_into_external='b/123')
+ issue_delta_pairs.append((issue_3, delta_3))
+ expected_err_msgs.append(
+ '%s: MERGED type statuses must accompany mergedInto values.' %
+ issue_3_ref)
+
+ with self.assertRaisesRegexp(exceptions.InputException,
+ '\n'.join(expected_err_msgs)):
+ tracker_helpers._AssertIssueChangesValid(
+ self.cnxn, issue_delta_pairs, self.services, comment_content=comment)
+
+ def testAssertIssueChangesValid_ConflictingDeltas(self):
+
+ def getRef(issue):
+ return '%s:%d' % (issue.project_name, issue.local_id)
+
+ expected_err_msgs = []
+ issue_3 = _Issue('chicken', 3)
+ self.services.issue.TestAddIssue(issue_3)
+ issue_3_ref = getRef(issue_3)
+ issue_4 = _Issue('chicken', 4)
+ self.services.issue.TestAddIssue(issue_4)
+ issue_4_ref = getRef(issue_4)
+ issue_5 = _Issue('chicken', 5)
+ self.services.issue.TestAddIssue(issue_5)
+ issue_5_ref = getRef(issue_5)
+ issue_6 = _Issue('chicken', 6)
+ self.services.issue.TestAddIssue(issue_6)
+ issue_6_ref = getRef(issue_6)
+ issue_7 = _Issue('chicken', 7)
+ self.services.issue.TestAddIssue(issue_7)
+ issue_7_ref = getRef(issue_7)
+
+ delta_3 = tracker_pb2.IssueDelta(
+ blocking_add=[issue_4.issue_id],
+ blocked_on_add=[issue_5.issue_id, issue_6.issue_id])
+
+ delta_4 = tracker_pb2.IssueDelta(
+ blocked_on_remove=[issue_3.issue_id], blocking_add=[issue_5.issue_id])
+ expected_err_msgs.append(
+ 'Changes for %s conflict for %s' % (issue_4_ref, issue_3_ref))
+
+ delta_5 = tracker_pb2.IssueDelta(
+ blocking_remove=[issue_3.issue_id],
+ blocked_on_remove=[issue_4.issue_id])
+ expected_err_msgs.append(
+ 'Changes for %s conflict for %s, %s' %
+ (issue_5_ref, issue_3_ref, issue_4_ref))
+
+ delta_6 = tracker_pb2.IssueDelta(blocking_remove=[issue_3.issue_id])
+ expected_err_msgs.append(
+ 'Changes for %s conflict for %s' % (issue_6_ref, issue_3_ref))
+
+ impacted_issue = _Issue('chicken', 11)
+ self.services.issue.TestAddIssue(impacted_issue)
+ impacted_issue_ref = getRef(impacted_issue)
+ delta_7 = tracker_pb2.IssueDelta(
+ blocking_remove=[issue_3.issue_id],
+ blocking_add=[issue_3.issue_id],
+ blocked_on_remove=[impacted_issue.issue_id],
+ blocked_on_add=[impacted_issue.issue_id])
+ expected_err_msgs.append(
+ 'Changes for %s conflict for %s, %s' %
+ (issue_7_ref, issue_3_ref, impacted_issue_ref))
+
+ issue_delta_pairs = [
+ (issue_3, delta_3),
+ (issue_4, delta_4),
+ (issue_5, delta_5),
+ (issue_6, delta_6),
+ (issue_7, delta_7),
+ ]
+
+ with self.assertRaisesRegexp(exceptions.InputException,
+ '\n'.join(expected_err_msgs)):
+ tracker_helpers._AssertIssueChangesValid(
+ self.cnxn, issue_delta_pairs, self.services)
+
+ def testComputeNewCcsFromIssueMerge(self):
+ """We can compute the new ccs to add to a merge-into issue."""
+ target_issue = fake.MakeTestIssue(789, 10, 'Target issue', 'New', 111)
+ source_issue_1 = fake.MakeTestIssue(
+ 789, 11, 'Source issue', 'New', 111) # different restrictions
+ source_issue_2 = fake.MakeTestIssue(
+ 789, 12, 'Source issue', 'New', 222) # same restrictions
+ source_issue_3 = fake.MakeTestIssue(
+ 789, 13, 'Source issue', 'New', 222) # no restrictions
+ source_issue_4 = fake.MakeTestIssue(
+ 789, 14, 'Source issue', 'New', 666) # empty ccs
+ source_issue_5 = fake.MakeTestIssue(
+ 788, 15, 'Source issue', 'New', 666) # different project
+ source_issue_1.cc_ids.append(333)
+ source_issue_2.cc_ids.append(444)
+ source_issue_3.cc_ids.append(555)
+ source_issue_5.cc_ids.append(999)
+
+ target_issue.labels.append('Restrict-View-Chicken')
+ source_issue_1.labels.append('Restrict-View-Cow')
+ source_issue_2.labels.append('Restrict-View-Chicken')
+
+ self.services.issue.TestAddIssue(target_issue)
+ self.services.issue.TestAddIssue(source_issue_1)
+ self.services.issue.TestAddIssue(source_issue_2)
+ self.services.issue.TestAddIssue(source_issue_3)
+ self.services.issue.TestAddIssue(source_issue_4)
+ self.services.issue.TestAddIssue(source_issue_5)
+
+ new_cc_ids = tracker_helpers._ComputeNewCcsFromIssueMerge(
+ target_issue, [source_issue_1, source_issue_2, source_issue_3])
+ self.assertItemsEqual(new_cc_ids, [444, 555, 222])
+
+ def testComputeNewCcsFromIssueMerge_Empty(self):
+ target_issue = fake.MakeTestIssue(789, 10, 'Target issue', 'New', 111)
+ self.services.issue.TestAddIssue(target_issue)
+ new_cc_ids = tracker_helpers._ComputeNewCcsFromIssueMerge(target_issue, [])
+ self.assertItemsEqual(new_cc_ids, [])
+
+ def testEnforceNonMergeStatusDeltas(self):
+ # No updates: user is setting to a non-MERGED status with no
+ # existing merged_into values.
+ issue_1 = _Issue('chicken', 1)
+ self.services.issue.TestAddIssue(issue_1)
+ delta_1 = tracker_pb2.IssueDelta(status='Available')
+ exp_delta_1 = copy.deepcopy(delta_1)
+
+ # No updates: user is setting to a MERGED status. Whether this request
+ # goes through will be handled by _AssertIssueChangesValid().
+ issue_2 = _Issue('chicken', 2)
+ self.services.issue.TestAddIssue(issue_2)
+ delta_2 = tracker_pb2.IssueDelta(status='Duplicate')
+ exp_delta_2 = copy.deepcopy(delta_2)
+
+ # No updates: user is setting to a MERGED status. (This test issue starts
+ # out with a merged_into value but a non-MERGED status. We don't expect
+ # real data to ever be in this state)
+ issue_3 = _Issue('chicken', 3)
+ issue_3.merged_into = 7011
+ self.services.issue.TestAddIssue(issue_3)
+ delta_3 = tracker_pb2.IssueDelta(status='Duplicate')
+ exp_delta_3 = copy.deepcopy(delta_3)
+
+ # No updates: same situation as above.
+ issue_4 = _Issue('chicken', 4)
+ issue_4.merged_into_external = 'b/123'
+ self.services.issue.TestAddIssue(issue_4)
+ delta_4 = tracker_pb2.IssueDelta(status='Duplicate')
+ exp_delta_4 = copy.deepcopy(delta_4)
+
+ # Update delta: user is setting status AWAY from a MERGED status, so we
+ # auto-remove any existing merged_into values.
+ issue_5 = _Issue('chicken', 5)
+ issue_5.merged_into = 7011
+ self.services.issue.TestAddIssue(issue_5)
+ delta_5 = tracker_pb2.IssueDelta(status='Available')
+ exp_delta_5 = copy.deepcopy(delta_5)
+ exp_delta_5.merged_into = 0
+
+ # Update delta: user is setting status AWAY from a MERGED status, so we
+ # auto-remove any existing merged_into values.
+ issue_6 = _Issue('chicken', 6)
+ issue_6.merged_into_external = 'b/123'
+ self.services.issue.TestAddIssue(issue_6)
+ delta_6 = tracker_pb2.IssueDelta(status='Available')
+ exp_delta_6 = copy.deepcopy(delta_6)
+ exp_delta_6.merged_into_external = ''
+
+ # No updates: user is setting to a non-MERGED status while also setting
+ # a merged_into value. This will be rejected down the line by
+ # _AssertIssueChangesValid()
+ issue_7 = _Issue('chicken', 7)
+ issue_7.merged_into = 7011
+ self.services.issue.TestAddIssue(issue_7)
+ delta_7 = tracker_pb2.IssueDelta(
+ merged_into_external='b/123', status='Available')
+ exp_delta_7 = copy.deepcopy(delta_7)
+
+ # No updates: user is setting to a non-MERGED status while also setting
+ # a merged_into value. This will be rejected down the line by
+ # _AssertIssueChangesValid()
+ issue_8 = _Issue('chicken', 8)
+ issue_8.merged_into_external = 'b/123'
+ self.services.issue.TestAddIssue(issue_8)
+ delta_8 = tracker_pb2.IssueDelta(merged_into=8011, status='Available')
+ exp_delta_8 = copy.deepcopy(delta_8)
+
+ pairs = [
+ (issue_1, delta_1), (issue_2, delta_2), (issue_3, delta_3),
+ (issue_4, delta_4), (issue_5, delta_5), (issue_6, delta_6),
+ (issue_7, delta_7), (issue_8, delta_8)
+ ]
+
+ tracker_helpers._EnforceNonMergeStatusDeltas(
+ self.cnxn, pairs, self.services)
+ self.assertEqual(
+ [
+ delta_1, delta_2, delta_3, delta_4, delta_5, delta_6, delta_7,
+ delta_8
+ ], [
+ exp_delta_1, exp_delta_2, exp_delta_3, exp_delta_4, exp_delta_5,
+ exp_delta_6, exp_delta_7, exp_delta_8
+ ])
+
+
+class IssueChangeImpactedIssuesTest(unittest.TestCase):
+ """Tests for the _IssueChangeImpactedIssues class."""
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ issue=fake.IssueService(), issue_star=fake.IssueStarService())
+ self.cnxn = 'fake connection'
+
+ def testComputeAllImpactedIDs(self):
+ tracker = tracker_helpers._IssueChangeImpactedIssues()
+ tracker.blocking_add[78901].append(1)
+ tracker.blocking_remove[78902].append(2)
+ tracker.blocked_on_add[78903].append(1)
+ tracker.blocked_on_remove[78904].append(1)
+ tracker.merged_from_add[78905].append(3)
+ tracker.merged_from_remove[78906].append(3)
+
+ # Repeat a few iids.
+ tracker.blocked_on_remove[78901].append(1)
+ tracker.merged_from_add[78903].append(1)
+
+ actual = tracker.ComputeAllImpactedIIDs()
+ expected = {78901, 78902, 78903, 78904, 78905, 78906}
+ self.assertEqual(actual, expected)
+
+ def testComputeAllImpactedIDs_Empty(self):
+ tracker = tracker_helpers._IssueChangeImpactedIssues()
+ actual = tracker.ComputeAllImpactedIIDs()
+ self.assertEqual(actual, set())
+
+ def testTrackImpactedIssues(self):
+ issue_delta_pairs = []
+
+ issue_1 = _Issue('project', 1)
+ issue_1.merged_into = 78906
+ delta_1 = tracker_pb2.IssueDelta(
+ merged_into=78905,
+ blocked_on_add=[78901, 78902],
+ blocked_on_remove=[78903, 78904],
+ )
+ issue_delta_pairs.append((issue_1, delta_1))
+
+ issue_2 = _Issue('project', 2)
+ issue_2.merged_into = 78905
+ delta_2 = tracker_pb2.IssueDelta(
+ merged_into=78905, # This should be ignored.
+ blocking_add=[78901, 78902],
+ blocking_remove=[78903, 78904],
+ )
+ issue_delta_pairs.append((issue_2, delta_2))
+
+ issue_3 = _Issue('project', 3)
+ issue_3.merged_into = 78902
+ delta_3 = tracker_pb2.IssueDelta(merged_into=78901)
+ issue_delta_pairs.append((issue_3, delta_3))
+
+ issue_4 = _Issue('project', 4)
+ issue_4.merged_into = 78901
+ delta_4 = tracker_pb2.IssueDelta(
+ merged_into=framework_constants.NO_ISSUE_SPECIFIED)
+ issue_delta_pairs.append((issue_4, delta_4))
+
+ impacted_issues = tracker_helpers._IssueChangeImpactedIssues()
+ for issue, delta in issue_delta_pairs:
+ impacted_issues.TrackImpactedIssues(issue, delta)
+
+ self.assertEqual(
+ impacted_issues.blocking_add, {
+ 78901: [issue_1.issue_id],
+ 78902: [issue_1.issue_id]
+ })
+ self.assertEqual(
+ impacted_issues.blocking_remove, {
+ 78903: [issue_1.issue_id],
+ 78904: [issue_1.issue_id]
+ })
+ self.assertEqual(
+ impacted_issues.blocked_on_add, {
+ 78901: [issue_2.issue_id],
+ 78902: [issue_2.issue_id]
+ })
+ self.assertEqual(
+ impacted_issues.blocked_on_remove, {
+ 78903: [issue_2.issue_id],
+ 78904: [issue_2.issue_id]
+ })
+ self.assertEqual(
+ impacted_issues.merged_from_add, {
+ 78901: [issue_3.issue_id],
+ 78905: [issue_1.issue_id],
+ })
+ self.assertEqual(
+ impacted_issues.merged_from_remove, {
+ 78901: [issue_4.issue_id],
+ 78902: [issue_3.issue_id],
+ 78906: [issue_1.issue_id],
+ })
+
+ def testApplyImpactedIssueChanges(self):
+ impacted_tracker = tracker_helpers._IssueChangeImpactedIssues()
+ impacted_issue = _Issue('proj', 1)
+ self.services.issue.TestAddIssue(impacted_issue)
+ impacted_iid = impacted_issue.issue_id
+
+ # Setup.
+ bo_add = _Issue('proj', 2)
+ self.services.issue.TestAddIssue(bo_add)
+ impacted_tracker.blocked_on_add[impacted_iid].append(bo_add.issue_id)
+
+ bo_remove = _Issue('proj', 3)
+ self.services.issue.TestAddIssue(bo_remove)
+ impacted_tracker.blocked_on_remove[impacted_iid].append(
+ bo_remove.issue_id)
+
+ b_add = _Issue('proj', 4)
+ self.services.issue.TestAddIssue(b_add)
+ impacted_tracker.blocking_add[impacted_iid].append(
+ b_add.issue_id)
+
+ b_remove = _Issue('proj', 5)
+ self.services.issue.TestAddIssue(b_remove)
+ impacted_tracker.blocking_remove[impacted_iid].append(
+ b_remove.issue_id)
+
+ m_add = _Issue('proj', 6)
+ m_add.cc_ids = [666, 777]
+ self.services.issue.TestAddIssue(m_add)
+ m_add_no_ccs = _Issue('proj', 7, '', '')
+ self.services.issue.TestAddIssue(m_add_no_ccs)
+ impacted_tracker.merged_from_add[impacted_iid].extend(
+ [m_add.issue_id, m_add_no_ccs.issue_id])
+ # Set up starrers.
+ self.services.issue_star.SetStar(
+ self.cnxn, self.services, None, impacted_iid, 111, True)
+ self.services.issue_star.SetStar(
+ self.cnxn, self.services, None, impacted_iid, 222, True)
+ self.services.issue_star.SetStar(
+ self.cnxn, self.services, None, m_add.issue_id, 222, True)
+ self.services.issue_star.SetStar(
+ self.cnxn, self.services, None, m_add.issue_id, 333, True)
+ self.services.issue_star.SetStar(
+ self.cnxn, self.services, None, m_add.issue_id, 444, True)
+
+ m_remove = _Issue('proj', 8)
+ m_remove.cc_ids = [888]
+ self.services.issue.TestAddIssue(m_remove)
+ impacted_tracker.merged_from_remove[impacted_iid].append(
+ m_remove.issue_id)
+
+
+ impacted_issue.cc_ids = [666]
+ impacted_issue.blocked_on_iids = [78404, bo_remove.issue_id]
+ impacted_issue.blocking_iids = [78405, b_remove.issue_id]
+ expected_issue = copy.deepcopy(impacted_issue)
+
+ # Verify.
+ (actual_amendments,
+ actual_new_starrers) = impacted_tracker.ApplyImpactedIssueChanges(
+ self.cnxn, impacted_issue, self.services)
+ expected_amendments = [
+ tracker_bizobj.MakeBlockedOnAmendment(
+ [('proj', bo_add.local_id)],
+ [('proj', bo_remove.local_id)], default_project_name='proj'),
+ tracker_bizobj.MakeBlockingAmendment(
+ [('proj', b_add.local_id)],
+ [('proj', b_remove.local_id)], default_project_name='proj'),
+ tracker_bizobj.MakeCcAmendment([777], []),
+ tracker_bizobj.MakeMergedIntoAmendment(
+ [('proj', m_add.local_id), ('proj', m_add_no_ccs.local_id)],
+ [('proj', m_remove.local_id)], default_project_name='proj')
+ ]
+ self.assertEqual(actual_amendments, expected_amendments)
+ self.assertItemsEqual(actual_new_starrers, [333, 444])
+
+ expected_issue.cc_ids.append(777)
+ expected_issue.blocked_on_iids = [78404, bo_add.issue_id]
+ # By default new blocked_on issues that appear in blocked_on_iids
+ # with no prior rank associated with it are un-ranked and assigned rank 0.
+ # See SortBlockedOn in issue_svc.py.
+ expected_issue.blocked_on_ranks = [0, 0]
+ expected_issue.blocking_iids = [78405, b_add.issue_id]
+ expected_issue.star_count = 4
+ self.assertEqual(impacted_issue, expected_issue)
+
+ def testApplyImpactedIssueChanges_Empty(self):
+ impacted_tracker = tracker_helpers._IssueChangeImpactedIssues()
+ impacted_issue = _Issue('proj', 1)
+ expected_issue = copy.deepcopy(impacted_issue)
+
+ (actual_amendments,
+ actual_new_starrers) = impacted_tracker.ApplyImpactedIssueChanges(
+ self.cnxn, impacted_issue, self.services)
+
+ expected_amendments = []
+ self.assertEqual(actual_amendments, expected_amendments)
+ expected_new_starrers = []
+ self.assertEqual(actual_new_starrers, expected_new_starrers)
+ self.assertEqual(impacted_issue, expected_issue)
+
+ def testApplyImpactedIssueChanges_PartiallyEmptyMergedFrom(self):
+ """We can process merged_from changes when one of the lists is empty."""
+ impacted_tracker = tracker_helpers._IssueChangeImpactedIssues()
+ impacted_issue = _Issue('proj', 1)
+ impacted_iid = impacted_issue.issue_id
+ expected_issue = copy.deepcopy(impacted_issue)
+
+ m_add = _Issue('proj', 2)
+ self.services.issue.TestAddIssue(m_add)
+ impacted_tracker.merged_from_add[impacted_iid].append(
+ m_add.issue_id)
+ # We're leaving impacted_tracker.merged_from_remove empty.
+
+ (actual_amendments,
+ actual_new_starrers) = impacted_tracker.ApplyImpactedIssueChanges(
+ self.cnxn, impacted_issue, self.services)
+
+ expected_amendments = [tracker_bizobj.MakeMergedIntoAmendment(
+ [('proj', m_add.local_id)], [], default_project_name='proj')]
+ self.assertEqual(actual_amendments, expected_amendments)
+ expected_new_starrers = []
+ self.assertEqual(actual_new_starrers, expected_new_starrers)
+ self.assertEqual(impacted_issue, expected_issue)
+
+
+class AssertUsersExistTest(unittest.TestCase):
+
+ def setUp(self):
+ self.cnxn = 'fake cnxn'
+ self.services = service_manager.Services(user=fake.UserService())
+ for user_id in [1, 1001, 1002, 1003, 2001, 2002, 3002]:
+ self.services.user.TestAddUser('test%d' % user_id, user_id, add_user=True)
+
+ def test_AssertUsersExist_Passes(self):
+ existing = [1, 1001, 1002, 1003, 2001, 2002, 3002]
+ with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+ tracker_helpers.AssertUsersExist(
+ self.cnxn, self.services, existing, err_agg)
+
+ def test_AssertUsersExist_Empty(self):
+ with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+ tracker_helpers.AssertUsersExist(
+ self.cnxn, self.services, [], err_agg)
+
+ def test_AssertUsersExist(self):
+ dne_users = [2, 3]
+ existing = [1, 1001, 1002, 1003, 2001, 2002, 3002]
+ all_users = existing + dne_users
+ with self.assertRaisesRegexp(
+ exceptions.InputException,
+ 'users/2: User does not exist.\nusers/3: User does not exist.'):
+ with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+ tracker_helpers.AssertUsersExist(
+ self.cnxn, self.services, all_users, err_agg)
diff --git a/tracker/test/tracker_views_test.py b/tracker/test/tracker_views_test.py
new file mode 100644
index 0000000..797b079
--- /dev/null
+++ b/tracker/test/tracker_views_test.py
@@ -0,0 +1,787 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for issue tracker views."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import unittest
+
+import mox
+
+from google.appengine.api import app_identity
+import ezt
+
+from framework import framework_views
+from framework import gcs_helpers
+from framework import template_helpers
+from framework import urls
+from proto import project_pb2
+from proto import tracker_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+from tracker import attachment_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+from tracker import tracker_views
+
+
+def _Issue(project_name, local_id, summary, status):
+ issue = tracker_pb2.Issue()
+ issue.project_name = project_name
+ issue.local_id = local_id
+ issue.issue_id = 100000 + local_id
+ issue.summary = summary
+ issue.status = status
+ return issue
+
+
+def _MakeConfig():
+ config = tracker_pb2.ProjectIssueConfig()
+ config.well_known_labels = [
+ tracker_pb2.LabelDef(
+ label='Priority-High', label_docstring='Must be resolved'),
+ tracker_pb2.LabelDef(
+ label='Priority-Low', label_docstring='Can be slipped'),
+ ]
+ config.well_known_statuses.append(tracker_pb2.StatusDef(
+ status='New', means_open=True))
+ config.well_known_statuses.append(tracker_pb2.StatusDef(
+ status='Old', means_open=False))
+ return config
+
+
+class IssueViewTest(unittest.TestCase):
+
+ def setUp(self):
+ self.issue1 = _Issue('proj', 1, 'not too long summary', 'New')
+ self.issue2 = _Issue('proj', 2, 'sum 2', '')
+ self.issue3 = _Issue('proj', 3, 'sum 3', '')
+ self.issue4 = _Issue('proj', 4, 'sum 4', '')
+
+ self.issue1.reporter_id = 1002
+ self.issue1.owner_id = 2002
+ self.issue1.labels.extend(['A', 'B'])
+ self.issue1.derived_labels.extend(['C', 'D'])
+
+ self.issue2.reporter_id = 2002
+ self.issue2.labels.extend(['foo', 'bar'])
+ self.issue2.blocked_on_iids.extend(
+ [self.issue1.issue_id, self.issue3.issue_id])
+ self.issue2.blocking_iids.extend(
+ [self.issue1.issue_id, self.issue4.issue_id])
+ dref = tracker_pb2.DanglingIssueRef()
+ dref.project = 'codesite'
+ dref.issue_id = 5001
+ self.issue2.dangling_blocking_refs.append(dref)
+
+ self.issue3.reporter_id = 3002
+ self.issue3.labels.extend(['Hot'])
+
+ self.issue4.reporter_id = 3002
+ self.issue4.labels.extend(['Foo', 'Bar'])
+
+ self.restricted = _Issue('proj', 7, 'summary 7', '')
+ self.restricted.labels.extend([
+ 'Restrict-View-Commit', 'Restrict-View-MyCustomPerm'])
+ self.restricted.derived_labels.extend([
+ 'Restrict-AddIssueComment-Commit', 'Restrict-EditIssue-Commit',
+ 'Restrict-Action-NeededPerm'])
+
+ self.users_by_id = {
+ 0: 'user 0',
+ 1002: 'user 1002',
+ 2002: 'user 2002',
+ 3002: 'user 3002',
+ 4002: 'user 4002',
+ }
+
+ def CheckSimpleIssueView(self, config):
+ view1 = tracker_views.IssueView(
+ self.issue1, self.users_by_id, config)
+ self.assertEqual('not too long summary', view1.summary)
+ self.assertEqual('New', view1.status.name)
+ self.assertEqual('user 2002', view1.owner)
+ self.assertEqual('A', view1.labels[0].name)
+ self.assertEqual('B', view1.labels[1].name)
+ self.assertEqual('C', view1.derived_labels[0].name)
+ self.assertEqual('D', view1.derived_labels[1].name)
+ self.assertEqual([], view1.blocked_on)
+ self.assertEqual([], view1.blocking)
+ detail_url = '/p/%s%s?id=%d' % (
+ self.issue1.project_name, urls.ISSUE_DETAIL,
+ self.issue1.local_id)
+ self.assertEqual(detail_url, view1.detail_relative_url)
+ return view1
+
+ def testSimpleIssueView(self):
+ config = tracker_pb2.ProjectIssueConfig()
+ view1 = self.CheckSimpleIssueView(config)
+ self.assertEqual('', view1.status.docstring)
+
+ config.well_known_statuses.append(tracker_pb2.StatusDef(
+ status='New', status_docstring='Issue has not had review yet'))
+ view1 = self.CheckSimpleIssueView(config)
+ self.assertEqual('Issue has not had review yet',
+ view1.status.docstring)
+ self.assertIsNone(view1.restrictions.has_restrictions)
+ self.assertEqual('', view1.restrictions.view)
+ self.assertEqual('', view1.restrictions.add_comment)
+ self.assertEqual('', view1.restrictions.edit)
+
+ def testIsOpen(self):
+ config = _MakeConfig()
+ view1 = tracker_views.IssueView(
+ self.issue1, self.users_by_id, config)
+ self.assertEqual(ezt.boolean(True), view1.is_open)
+
+ self.issue1.status = 'Old'
+ view1 = tracker_views.IssueView(
+ self.issue1, self.users_by_id, config)
+ self.assertEqual(ezt.boolean(False), view1.is_open)
+
+ def testIssueViewWithRestrictions(self):
+ view = tracker_views.IssueView(
+ self.restricted, self.users_by_id, _MakeConfig())
+ self.assertTrue(view.restrictions.has_restrictions)
+ self.assertEqual('Commit and MyCustomPerm', view.restrictions.view)
+ self.assertEqual('Commit', view.restrictions.add_comment)
+ self.assertEqual('Commit', view.restrictions.edit)
+ self.assertEqual(['Restrict-Action-NeededPerm'], view.restrictions.other)
+ self.assertEqual('Restrict-View-Commit', view.labels[0].name)
+ self.assertTrue(view.labels[0].is_restrict)
+
+
+class RestrictionsViewTest(unittest.TestCase):
+ pass # TODO(jrobbins): write tests
+
+
+class AttachmentViewTest(unittest.TestCase):
+
+ def setUp(self):
+ self.orig_sign_attachment_id = attachment_helpers.SignAttachmentID
+ attachment_helpers.SignAttachmentID = (
+ lambda aid: 'signed_%d' % aid)
+
+ def tearDown(self):
+ attachment_helpers.SignAttachmentID = self.orig_sign_attachment_id
+
+ def MakeViewAndVerifyFields(
+ self, size, name, mimetype, expected_size_str, expect_viewable):
+ attach_pb = tracker_pb2.Attachment()
+ attach_pb.filesize = size
+ attach_pb.attachment_id = 12345
+ attach_pb.filename = name
+ attach_pb.mimetype = mimetype
+
+ view = tracker_views.AttachmentView(attach_pb, 'proj')
+ self.assertEqual('/images/paperclip.png', view.iconurl)
+ self.assertEqual(expected_size_str, view.filesizestr)
+ dl = 'attachment?aid=12345&signed_aid=signed_12345'
+ self.assertEqual(dl, view.downloadurl)
+ if expect_viewable:
+ self.assertEqual(dl + '&inline=1', view.url)
+ self.assertEqual(dl + '&inline=1&thumb=1', view.thumbnail_url)
+ else:
+ self.assertEqual(None, view.url)
+ self.assertEqual(None, view.thumbnail_url)
+
+ def testNonImage(self):
+ self.MakeViewAndVerifyFields(
+ 123, 'file.ext', 'funky/bits', '123 bytes', False)
+
+ def testViewableImage(self):
+ self.MakeViewAndVerifyFields(
+ 123, 'logo.gif', 'image/gif', '123 bytes', True)
+
+ self.MakeViewAndVerifyFields(
+ 123, 'screenshot.jpg', 'image/jpeg', '123 bytes', True)
+
+ def testHugeImage(self):
+ self.MakeViewAndVerifyFields(
+ 18 * 1024 * 1024, 'panorama.png', 'image/jpeg', '18.0 MB', False)
+
+ def testViewableText(self):
+ name = 'hello.c'
+ attach_pb = tracker_pb2.Attachment()
+ attach_pb.filesize = 1234
+ attach_pb.attachment_id = 12345
+ attach_pb.filename = name
+ attach_pb.mimetype = 'text/plain'
+ view = tracker_views.AttachmentView(attach_pb, 'proj')
+
+ view_url = '/p/proj/issues/attachmentText?aid=12345'
+ self.assertEqual(view_url, view.url)
+
+
+class LogoViewTest(unittest.TestCase):
+
+ def setUp(self):
+ self.mox = mox.Mox()
+
+ def tearDown(self):
+ self.mox.UnsetStubs()
+ self.mox.ResetAll()
+
+ def testProjectWithLogo(self):
+ bucket_name = 'testbucket'
+ logo_gcs_id = '123'
+ logo_file_name = 'logo.png'
+ project_pb = project_pb2.MakeProject(
+ 'testProject', logo_gcs_id=logo_gcs_id, logo_file_name=logo_file_name)
+
+ self.mox.StubOutWithMock(app_identity, 'get_default_gcs_bucket_name')
+ app_identity.get_default_gcs_bucket_name().AndReturn(bucket_name)
+
+ self.mox.StubOutWithMock(gcs_helpers, 'SignUrl')
+ gcs_helpers.SignUrl(bucket_name,
+ logo_gcs_id + '-thumbnail').AndReturn('signed/url')
+ gcs_helpers.SignUrl(bucket_name, logo_gcs_id).AndReturn('signed/url')
+
+ self.mox.ReplayAll()
+
+ view = tracker_views.LogoView(project_pb)
+ self.mox.VerifyAll()
+ self.assertEqual('logo.png', view.filename)
+ self.assertEqual('image/png', view.mimetype)
+ self.assertEqual('signed/url', view.thumbnail_url)
+ self.assertEqual(
+ 'signed/url&response-content-displacement=attachment%3B'
+ '+filename%3Dlogo.png', view.viewurl)
+
+ def testProjectWithNoLogo(self):
+ project_pb = project_pb2.MakeProject('testProject')
+ view = tracker_views.LogoView(project_pb)
+ self.assertEqual('', view.thumbnail_url)
+ self.assertEqual('', view.viewurl)
+
+
+class AmendmentViewTest(unittest.TestCase):
+ pass # TODO(jrobbins): write tests
+
+
+class ComponentDefViewTest(unittest.TestCase):
+ def setUp(self):
+ self.services = service_manager.Services(
+ user=fake.UserService(),
+ config=fake.ConfigService())
+ self.services.user.TestAddUser('admin@example.com', 111)
+ self.services.user.TestAddUser('cc@example.com', 222)
+ self.users_by_id = framework_views.MakeAllUserViews(
+ 'cnxn', self.services.user, [111, 222])
+ self.services.config.TestAddLabelsDict({'Hot': 1, 'Cold': 2})
+ self.cd = tracker_bizobj.MakeComponentDef(
+ 10, 789, 'UI', 'User interface', False,
+ [111], [222], 0, 111, label_ids=[1, 2])
+
+ def testRootComponent(self):
+ view = tracker_views.ComponentDefView(
+ 'cnxn', self.services, self.cd, self.users_by_id)
+ self.assertEqual('', view.parent_path)
+ self.assertEqual('UI', view.leaf_name)
+ self.assertEqual('User interface', view.docstring_short)
+ self.assertEqual('admin@example.com', view.admins[0].email)
+ self.assertEqual(['Hot', 'Cold'], view.labels)
+ self.assertEqual('all toplevel active ', view.classes)
+
+ def testNestedComponent(self):
+ self.cd.path = 'UI>Dialogs>Print'
+ view = tracker_views.ComponentDefView(
+ 'cnxn', self.services, self.cd, self.users_by_id)
+ self.assertEqual('UI>Dialogs', view.parent_path)
+ self.assertEqual('Print', view.leaf_name)
+ self.assertEqual('User interface', view.docstring_short)
+ self.assertEqual('admin@example.com', view.admins[0].email)
+ self.assertEqual(['Hot', 'Cold'], view.labels)
+ self.assertEqual('all active ', view.classes)
+
+
+class ComponentValueTest(unittest.TestCase):
+ pass # TODO(jrobbins): write tests
+
+
+class FieldValueViewTest(unittest.TestCase):
+
+ def setUp(self):
+ self.config = tracker_pb2.ProjectIssueConfig()
+ self.estdays_fd = tracker_bizobj.MakeFieldDef(
+ 1, 789, 'EstDays', tracker_pb2.FieldTypes.INT_TYPE, None,
+ None, False, False, False, 3, 99, None, False, None, None,
+ None, 'no_action', 'descriptive docstring', False, approval_id=None,
+ is_phase_field=False)
+ self.designdoc_fd = tracker_bizobj.MakeFieldDef(
+ 2, 789, 'DesignDoc', tracker_pb2.FieldTypes.STR_TYPE, 'Enhancement',
+ None, False, False, False, None, None, None, False, None, None,
+ None, 'no_action', 'descriptive docstring', False, approval_id=None,
+ is_phase_field=False)
+ self.mtarget_fd = tracker_bizobj.MakeFieldDef(
+ 3, 789, 'M-Target', tracker_pb2.FieldTypes.INT_TYPE, 'Enhancement',
+ None, False, False, False, None, None, None, False, None, None,
+ None, 'no_action', 'doc doc', False, approval_id=None,
+ is_phase_field=True)
+ self.config.field_defs = [self.estdays_fd, self.designdoc_fd]
+
+ def testNoValues(self):
+ """We can create a FieldValueView with no values."""
+ values = []
+ derived_values = []
+ estdays_fvv = tracker_views.FieldValueView(
+ self.estdays_fd, self.config, values, derived_values, ['defect'],
+ phase_name='Gate')
+ self.assertEqual('EstDays', estdays_fvv.field_def.field_name)
+ self.assertEqual(3, estdays_fvv.field_def.min_value)
+ self.assertEqual(99, estdays_fvv.field_def.max_value)
+ self.assertEqual([], estdays_fvv.values)
+ self.assertEqual([], estdays_fvv.derived_values)
+
+ def testSomeValues(self):
+ """We can create a FieldValueView with some values."""
+ values = [template_helpers.EZTItem(val=12, docstring=None, idx=0)]
+ derived_values = [template_helpers.EZTItem(val=88, docstring=None, idx=0)]
+ estdays_fvv = tracker_views.FieldValueView(
+ self.estdays_fd, self.config, values, derived_values, ['defect'])
+ self.assertEqual(self.estdays_fd, estdays_fvv.field_def.field_def)
+ self.assertTrue(estdays_fvv.is_editable)
+ self.assertEqual(values, estdays_fvv.values)
+ self.assertEqual(derived_values, estdays_fvv.derived_values)
+ self.assertEqual('', estdays_fvv.phase_name)
+ self.assertEqual(ezt.boolean(False), estdays_fvv.field_def.is_phase_field)
+
+ def testApplicability(self):
+ """We know whether a field should show an editing widget."""
+ # Not the right type and has no values.
+ designdoc_fvv = tracker_views.FieldValueView(
+ self.designdoc_fd, self.config, [], [], ['defect'])
+ self.assertFalse(designdoc_fvv.applicable)
+ self.assertEqual('', designdoc_fvv.phase_name)
+ self.assertEqual(ezt.boolean(False), designdoc_fvv.field_def.is_phase_field)
+
+ # Has a value.
+ designdoc_fvv = tracker_views.FieldValueView(
+ self.designdoc_fd, self.config, ['fake value item'], [], ['defect'])
+ self.assertTrue(designdoc_fvv.applicable)
+
+ # Derived values don't cause editing fields to display.
+ designdoc_fvv = tracker_views.FieldValueView(
+ self.designdoc_fd, self.config, [], ['fake value item'], ['defect'])
+ self.assertFalse(designdoc_fvv.applicable)
+
+ # Applicable to this type of issue.
+ designdoc_fvv = tracker_views.FieldValueView(
+ self.designdoc_fd, self.config, [], [], ['enhancement'])
+ self.assertTrue(designdoc_fvv.applicable)
+
+ # Applicable to some issues in a bulk edit.
+ designdoc_fvv = tracker_views.FieldValueView(
+ self.designdoc_fd, self.config, [], [],
+ ['defect', 'task', 'enhancement'])
+ self.assertTrue(designdoc_fvv.applicable)
+
+ # Applicable to all issues.
+ estdays_fvv = tracker_views.FieldValueView(
+ self.estdays_fd, self.config, [], [], ['enhancement'])
+ self.assertTrue(estdays_fvv.applicable)
+
+ # Explicitly set to be applicable when showing bounce values.
+ designdoc_fvv = tracker_views.FieldValueView(
+ self.designdoc_fd, self.config, [], [], ['defect'],
+ applicable=True)
+ self.assertTrue(designdoc_fvv.applicable)
+
+ def testDisplay(self):
+ """We know when a value (or --) should be shown in the metadata column."""
+ # Not the right type and has no values.
+ designdoc_fvv = tracker_views.FieldValueView(
+ self.designdoc_fd, self.config, [], [], ['defect'])
+ self.assertFalse(designdoc_fvv.display)
+
+ # Has a value.
+ designdoc_fvv = tracker_views.FieldValueView(
+ self.designdoc_fd, self.config, ['fake value item'], [], ['defect'])
+ self.assertTrue(designdoc_fvv.display)
+
+ # Has a derived value.
+ designdoc_fvv = tracker_views.FieldValueView(
+ self.designdoc_fd, self.config, [], ['fake value item'], ['defect'])
+ self.assertTrue(designdoc_fvv.display)
+
+ # Applicable to this type of issue, it will show "--".
+ designdoc_fvv = tracker_views.FieldValueView(
+ self.designdoc_fd, self.config, [], [], ['enhancement'])
+ self.assertTrue(designdoc_fvv.display)
+
+ # Applicable to all issues, it will show "--".
+ estdays_fvv = tracker_views.FieldValueView(
+ self.estdays_fd, self.config, [], [], ['enhancement'])
+ self.assertTrue(estdays_fvv.display)
+
+ def testPhaseField(self):
+ mtarget_fvv = tracker_views.FieldValueView(
+ self.mtarget_fd, self.config, [], [], [], phase_name='Stage')
+ self.assertEqual('Stage', mtarget_fvv.phase_name)
+ self.assertEqual(ezt.boolean(True), mtarget_fvv.field_def.is_phase_field)
+
+
+class FVVFunctionsTest(unittest.TestCase):
+
+ def setUp(self):
+ self.config = tracker_pb2.ProjectIssueConfig()
+ self.estdays_fd = tracker_bizobj.MakeFieldDef(
+ 1, 789, 'EstDays', tracker_pb2.FieldTypes.INT_TYPE, None,
+ None, False, False, False, 3, 99, None, False, None, None,
+ None, 'no_action', 'descriptive docstring', False, None, False)
+ self.os_fd = tracker_bizobj.MakeFieldDef(
+ 2, 789, 'OS', tracker_pb2.FieldTypes.ENUM_TYPE,
+ 'Enhancement', None, False, False, False, None, None, None,
+ False, None, None, None, 'no_action', 'descriptive docstring',
+ False, None, False)
+ self.milestone_fd = tracker_bizobj.MakeFieldDef(
+ 3, 789, 'Launch-Milestone', tracker_pb2.FieldTypes.ENUM_TYPE,
+ 'Enhancement', None, False, False, False, None, None, None,
+ False, None, None, None, 'no_action', 'descriptive docstring',
+ False, None, False)
+ self.config.field_defs = [self.estdays_fd, self.os_fd, self.milestone_fd]
+ self.config.well_known_labels = [
+ tracker_pb2.LabelDef(
+ label='Priority-High', label_docstring='Must be resolved'),
+ tracker_pb2.LabelDef(
+ label='Priority-Low', label_docstring='Can be slipped'),
+ ]
+
+ def testPrecomputeInfoForValueViews_NoValues(self):
+ """We can precompute info needed for an issue with no fields or labels."""
+ labels = []
+ derived_labels = []
+ field_values = []
+ phases = []
+ precomp_view_info = tracker_views._PrecomputeInfoForValueViews(
+ labels, derived_labels, field_values, self.config, phases)
+ (labels_by_prefix, der_labels_by_prefix, field_values_by_id,
+ label_docs, phases_by_name) = precomp_view_info
+ self.assertEqual({}, labels_by_prefix)
+ self.assertEqual({}, der_labels_by_prefix)
+ self.assertEqual({}, field_values_by_id)
+ self.assertEqual(
+ {'priority-high': 'Must be resolved',
+ 'priority-low': 'Can be slipped'},
+ label_docs)
+ self.assertEqual({}, phases_by_name)
+
+ def testPrecomputeInfoForValueViews_SomeValues(self):
+ """We can precompute info needed for an issue with fields and labels."""
+ labels = ['Priority-Low', 'GoodFirstBug', 'Feature-UI', 'Feature-Installer',
+ 'Launch-Milestone-66']
+ derived_labels = ['OS-Windows', 'OS-Linux']
+ field_values = [
+ tracker_bizobj.MakeFieldValue(1, 5, None, None, None, None, False),
+ ]
+ phase_1 = tracker_pb2.Phase(phase_id=1, name='Stable')
+ phase_2 = tracker_pb2.Phase(phase_id=2, name='Beta')
+ phase_3 = tracker_pb2.Phase(phase_id=3, name='stable')
+ precomp_view_info = tracker_views._PrecomputeInfoForValueViews(
+ labels, derived_labels, field_values, self.config,
+ phases=[phase_1, phase_2, phase_3])
+ (labels_by_prefix, der_labels_by_prefix, field_values_by_id,
+ _label_docs, phases_by_name) = precomp_view_info
+ self.assertEqual(
+ {'priority': ['Low'],
+ 'feature': ['UI', 'Installer'],
+ 'launch-milestone': ['66']},
+ labels_by_prefix)
+ self.assertEqual(
+ {'os': ['Windows', 'Linux']},
+ der_labels_by_prefix)
+ self.assertEqual(
+ {1: field_values},
+ field_values_by_id)
+ self.assertEqual(
+ {'stable': [phase_1, phase_3],
+ 'beta': [phase_2]},
+ phases_by_name)
+
+ def testMakeAllFieldValueViews(self):
+ labels = ['Priority-Low', 'GoodFirstBug', 'Feature-UI', 'Feature-Installer',
+ 'Launch-Milestone-66']
+ derived_labels = ['OS-Windows', 'OS-Linux']
+ self.config.field_defs.append(tracker_bizobj.MakeFieldDef(
+ 4, 789, 'UIMocks', tracker_pb2.FieldTypes.URL_TYPE,
+ 'Enhancement', None, False, False, False, None, None, None,
+ False, None, None, None, 'no_action', 'descriptive docstring',
+ False, approval_id=23, is_phase_field=False))
+ self.config.field_defs.append(tracker_bizobj.MakeFieldDef(
+ 5, 789, 'LegalFAQs', tracker_pb2.FieldTypes.URL_TYPE,
+ 'Enhancement', None, False, False, False, None, None, None,
+ False, None, None, None, 'no_action', 'descriptive docstring',
+ False, approval_id=26, is_phase_field=False))
+ self.config.field_defs.append(tracker_bizobj.MakeFieldDef(
+ 23, 789, 'Legal', tracker_pb2.FieldTypes.APPROVAL_TYPE,
+ 'Enhancement', None, False, False, False, None, None, None,
+ False, None, None, None, 'no_action', 'descriptive docstring',
+ False, approval_id=None, is_phase_field=False))
+ self.config.field_defs.append(tracker_bizobj.MakeFieldDef(
+ 26, 789, 'UI', tracker_pb2.FieldTypes.APPROVAL_TYPE,
+ 'Enhancement', None, False, False, False, None, None, None,
+ False, None, None, None, 'no_action', 'descriptive docstring',
+ False, approval_id=None, is_phase_field=False))
+ self.config.field_defs.append(tracker_bizobj.MakeFieldDef(
+ 27, 789, 'M-Target', tracker_pb2.FieldTypes.INT_TYPE,
+ 'Enhancement', None, False, False, False, None, None, None,
+ False, None, None, None, 'no_action', 'descriptive docstring',
+ False, approval_id=None, is_phase_field=True))
+ field_values = [
+ tracker_bizobj.MakeFieldValue(1, 5, None, None, None, None, False),
+ tracker_bizobj.MakeFieldValue(
+ 27, 74, None, None, None, None, False, phase_id=3),
+ # phase_id=4 does not belong to any of the phases given below.
+ # this field value should not show up in the views.
+ tracker_bizobj.MakeFieldValue(
+ 27, 79, None, None, None, None, False, phase_id=4),
+ ]
+ users_by_id = {}
+ phase_1 = tracker_pb2.Phase(phase_id=1, name='Stable')
+ phase_2 = tracker_pb2.Phase(phase_id=2, name='Beta')
+ phase_3 = tracker_pb2.Phase(phase_id=3, name='stable')
+ fvvs = tracker_views.MakeAllFieldValueViews(
+ self.config, labels, derived_labels, field_values, users_by_id,
+ parent_approval_ids=[23], phases=[phase_1, phase_2, phase_3])
+ self.assertEqual(9, len(fvvs))
+ # Values are sorted by (applicable_type, field_name).
+ logging.info([fv.field_name for fv in fvvs])
+ (estdays_fvv, launch_milestone_fvv, legal_fvv, legal_faq_fvv,
+ beta_mtarget_fvv, stable_mtarget_fvv, os_fvv, ui_fvv, ui_mocks_fvv) = fvvs
+ self.assertEqual('EstDays', estdays_fvv.field_name)
+ self.assertEqual(1, len(estdays_fvv.values))
+ self.assertEqual(0, len(estdays_fvv.derived_values))
+ self.assertEqual('Launch-Milestone', launch_milestone_fvv.field_name)
+ self.assertEqual(1, len(launch_milestone_fvv.values))
+ self.assertEqual(0, len(launch_milestone_fvv.derived_values))
+ self.assertEqual('OS', os_fvv.field_name)
+ self.assertEqual(0, len(os_fvv.values))
+ self.assertEqual(2, len(os_fvv.derived_values))
+ self.assertEqual(ui_mocks_fvv.field_name, 'UIMocks')
+ self.assertEqual(ui_mocks_fvv.phase_name, '')
+ self.assertTrue(ui_mocks_fvv.applicable)
+ self.assertEqual(legal_faq_fvv.field_name, 'LegalFAQs')
+ self.assertFalse(legal_faq_fvv.applicable)
+ self.assertFalse(legal_fvv.applicable)
+ self.assertFalse(ui_fvv.applicable)
+ self.assertEqual('M-Target', stable_mtarget_fvv.field_name)
+ self.assertEqual('stable', stable_mtarget_fvv.phase_name)
+ self.assertEqual(1, len(stable_mtarget_fvv.values))
+ self.assertEqual(74, stable_mtarget_fvv.values[0].val)
+ self.assertEqual(0, len(stable_mtarget_fvv.derived_values))
+ self.assertEqual('M-Target', beta_mtarget_fvv.field_name)
+ self.assertEqual('beta', beta_mtarget_fvv.phase_name)
+ self.assertEqual(0, len(beta_mtarget_fvv.values))
+ self.assertEqual(0, len(beta_mtarget_fvv.values))
+
+ def testMakeFieldValueView(self):
+ pass # Covered by testMakeAllFieldValueViews()
+
+ def testMakeFieldValueItemsTest(self):
+ pass # Covered by testMakeAllFieldValueViews()
+
+ def testMakeBounceFieldValueViews(self):
+ config = tracker_pb2.ProjectIssueConfig()
+ fd = tracker_pb2.FieldDef(
+ field_id=3, field_type=tracker_pb2.FieldTypes.INT_TYPE,
+ applicable_type='', field_name='EstDays')
+ phase_fd = tracker_pb2.FieldDef(
+ field_id=4, field_type=tracker_pb2.FieldTypes.INT_TYPE,
+ applicable_type='', field_name='Gump')
+ config.field_defs = [fd,
+ phase_fd,
+ tracker_pb2.FieldDef(
+ field_id=5, field_type=tracker_pb2.FieldTypes.STR_TYPE)
+ ]
+ parsed_fvs = {3: [455]}
+ parsed_phase_fvs = {
+ 4: {'stable': [73, 74], 'beta': [8], 'beta-exp': [75]},
+ }
+ fvs = tracker_views.MakeBounceFieldValueViews(
+ parsed_fvs, parsed_phase_fvs, config)
+
+ self.assertEqual(len(fvs), 4)
+
+ estdays_ezt_fv = template_helpers.EZTItem(val=455, docstring='', idx=0)
+ expected = tracker_views.FieldValueView(
+ fd, config, [estdays_ezt_fv], [], [])
+ self.assertEqual(fvs[0].field_name, expected.field_name)
+ self.assertEqual(fvs[0].values[0].val, expected.values[0].val)
+ self.assertEqual(fvs[0].values[0].idx, expected.values[0].idx)
+ self.assertTrue(fvs[0].applicable)
+
+ self.assertEqual(fvs[1].field_name, phase_fd.field_name)
+ self.assertEqual(fvs[2].field_name, phase_fd.field_name)
+ self.assertEqual(fvs[3].field_name, phase_fd.field_name)
+
+ fd.approval_id = 23
+ config.field_defs = [fd,
+ tracker_pb2.FieldDef(
+ field_id=23, field_name='Legal',
+ field_type=tracker_pb2.FieldTypes.APPROVAL_TYPE)]
+ fvs = tracker_views.MakeBounceFieldValueViews(parsed_fvs, {}, config)
+ self.assertTrue(fvs[0].applicable)
+
+
+class ConvertLabelsToFieldValuesTest(unittest.TestCase):
+
+ def testConvertLabelsToFieldValues_NoLabels(self):
+ result = tracker_views._ConvertLabelsToFieldValues(
+ [], 'opsys', {})
+ self.assertEqual([], result)
+
+ def testConvertLabelsToFieldValues_NoMatch(self):
+ result = tracker_views._ConvertLabelsToFieldValues(
+ [], 'opsys', {})
+ self.assertEqual([], result)
+
+ def testConvertLabelsToFieldValues_HasMatch(self):
+ result = tracker_views._ConvertLabelsToFieldValues(
+ ['OSX'], 'opsys', {})
+ self.assertEqual(1, len(result))
+ self.assertEqual('OSX', result[0].val)
+ self.assertEqual('', result[0].docstring)
+
+ result = tracker_views._ConvertLabelsToFieldValues(
+ ['OSX', 'All'], 'opsys', {'opsys-all': 'Happens everywhere'})
+ self.assertEqual(2, len(result))
+ self.assertEqual('OSX', result[0].val)
+ self.assertEqual('', result[0].docstring)
+ self.assertEqual('All', result[1].val)
+ self.assertEqual('Happens everywhere', result[1].docstring)
+
+
+class FieldDefViewTest(unittest.TestCase):
+
+ def setUp(self):
+ self.approval_fd = tracker_bizobj.MakeFieldDef(
+ 1, 789, 'LaunchApproval', tracker_pb2.FieldTypes.APPROVAL_TYPE, None,
+ None, True, True, False, 3, 99, None, False, None, None,
+ None, 'no_action', 'descriptive docstring', False, None, False)
+
+ self.approval_def = tracker_pb2.ApprovalDef(
+ approval_id=1, approver_ids=[111], survey='question?')
+
+ self.field_def = tracker_bizobj.MakeFieldDef(
+ 2, 789, 'AffectedUsers', tracker_pb2.FieldTypes.INT_TYPE, None,
+ None, True, True, False, 3, 99, None, False, None, None,
+ None, 'no_action', 'descriptive docstring', False, 1, False)
+
+ self.field_def.admin_ids = [222]
+ self.field_def.editor_ids = [111, 333]
+
+ def testFieldDefView_Normal(self):
+ config = _MakeConfig()
+ config.field_defs.append(self.approval_fd)
+ config.approval_defs.append(self.approval_def)
+
+ user_view_1 = framework_views.StuffUserView(111, 'uv1@example.com', False)
+ user_view_2 = framework_views.StuffUserView(222, 'uv2@example.com', False)
+ user_view_3 = framework_views.StuffUserView(333, 'uv3@example.com', False)
+ user_views = {111: user_view_1, 222: user_view_2, 333: user_view_3}
+ view = tracker_views.FieldDefView(
+ self.field_def, config, user_views=user_views)
+
+ self.assertEqual('AffectedUsers', view.field_name)
+ self.assertEqual(self.field_def, view.field_def)
+ self.assertEqual('descriptive docstring', view.docstring_short)
+ self.assertEqual('INT_TYPE', view.type_name)
+ self.assertEqual([], view.choices)
+ self.assertEqual('required', view.importance)
+ self.assertEqual(3, view.min_value)
+ self.assertEqual(99, view.max_value)
+ self.assertEqual('no_action', view.date_action_str)
+ self.assertEqual(view.approval_id, 1)
+ self.assertEqual(view.is_approval_subfield, ezt.boolean(True))
+ self.assertEqual(view.approvers, [])
+ self.assertEqual(view.survey, '')
+ self.assertEqual(view.survey_questions, [])
+ self.assertEqual(len(view.admins), 1)
+ self.assertEqual(len(view.editors), 2)
+ self.assertIsNone(view.is_phase_field)
+ self.assertIsNone(view.is_restricted_field)
+
+ def testFieldDefView_Approval(self):
+ config = _MakeConfig()
+ approver_view = framework_views.StuffUserView(
+ 111, 'shouldnotmatter@ch.org', False)
+ user_views = {111: approver_view}
+
+ view = tracker_views.FieldDefView(
+ self.approval_fd, config,
+ user_views= user_views, approval_def=self.approval_def)
+ self.assertEqual(view.approvers, [approver_view])
+ self.assertEqual(view.survey, self.approval_def.survey)
+ self.assertEqual(view.survey_questions, [view.survey])
+
+ self.approval_def.survey = None
+ view = tracker_views.FieldDefView(
+ self.approval_fd, config,
+ user_views= user_views, approval_def=self.approval_def)
+ self.assertEqual(view.survey, '')
+ self.assertEqual(view.survey_questions, [])
+
+ self.approval_def.survey = 'Q1\nQ2\nQ3'
+ view = tracker_views.FieldDefView(
+ self.approval_fd, config,
+ user_views= user_views, approval_def=self.approval_def)
+ self.assertEqual(view.survey, self.approval_def.survey)
+ self.assertEqual(view.survey_questions, ['Q1', 'Q2', 'Q3'])
+
+
+class IssueTemplateViewTest(unittest.TestCase):
+ pass # TODO(jrobbins): write tests
+
+
+class MakeFieldUserViewsTest(unittest.TestCase):
+ pass # TODO(jrobbins): write tests
+
+
+class ConfigViewTest(unittest.TestCase):
+ pass # TODO(jrobbins): write tests
+
+
+class ConfigFunctionsTest(unittest.TestCase):
+
+ def setUp(self):
+ self.config = tracker_bizobj.MakeDefaultProjectIssueConfig(768)
+
+ def testStatusDefsAsText(self):
+ open_text, closed_text = tracker_views.StatusDefsAsText(self.config)
+
+ for wks in tracker_constants.DEFAULT_WELL_KNOWN_STATUSES:
+ status, doc, means_open, _deprecated = wks
+ if means_open:
+ self.assertIn(status, open_text)
+ self.assertIn(doc, open_text)
+ else:
+ self.assertIn(status, closed_text)
+ self.assertIn(doc, closed_text)
+
+ self.assertEqual(
+ len(tracker_constants.DEFAULT_WELL_KNOWN_STATUSES),
+ len(open_text.split('\n')) + len(closed_text.split('\n')))
+
+ def testLabelDefsAsText(self):
+ # Note: Day-Monday will not be part of the result because it is masked.
+ self.config.field_defs.append(tracker_pb2.FieldDef(
+ field_id=1, field_name='Day',
+ field_type=tracker_pb2.FieldTypes.ENUM_TYPE))
+ self.config.well_known_labels.append(tracker_pb2.LabelDef(
+ label='Day-Monday'))
+ labels_text = tracker_views.LabelDefsAsText(self.config)
+
+ for wkl in tracker_constants.DEFAULT_WELL_KNOWN_LABELS:
+ label, doc, _deprecated = wkl
+ self.assertIn(label, labels_text)
+ self.assertIn(doc, labels_text)
+ self.assertEqual(
+ len(tracker_constants.DEFAULT_WELL_KNOWN_LABELS),
+ len(labels_text.split('\n')))
diff --git a/tracker/test/webcomponentspage_test.py b/tracker/test/webcomponentspage_test.py
new file mode 100644
index 0000000..65cfc66
--- /dev/null
+++ b/tracker/test/webcomponentspage_test.py
@@ -0,0 +1,120 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+"""Tests for the Monorail SPA pages, as served by EZT."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import mock
+import unittest
+
+import ezt
+
+import settings
+from framework import permissions
+from proto import project_pb2
+from proto import site_pb2
+from services import service_manager
+from tracker import webcomponentspage
+from testing import fake
+from testing import testing_helpers
+
+
+class WebComponentsPageTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(
+ user=fake.UserService(),
+ project=fake.ProjectService(),
+ features=fake.FeaturesService())
+
+ self.user = self.services.user.TestAddUser('user@example.com', 111)
+ self.project = self.services.project.TestAddProject('proj', project_id=789)
+ self.hotlist = self.services.features.TestAddHotlist(
+ 'HotlistName', summary='summary', owner_ids=[111], hotlist_id=1236)
+
+ self.servlet = webcomponentspage.WebComponentsPage(
+ 'req', 'res', services=self.services)
+
+ def testHotlistPage_OldUiUrl(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 111},
+ path='/hotlists/1236',
+ services=self.services)
+
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertEqual('/u/111/hotlists/HotlistName', page_data['old_ui_url'])
+
+ def testHotlistPage_OldUiUrl_People(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 111},
+ path='/hotlists/1236/people',
+ services=self.services)
+
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertEqual(
+ '/u/111/hotlists/HotlistName/people', page_data['old_ui_url'])
+
+ def testHotlistPage_OldUiUrl_Settings(self):
+ mr = testing_helpers.MakeMonorailRequest(
+ user_info={'user_id': 111},
+ path='/hotlists/1236/settings',
+ services=self.services)
+
+ page_data = self.servlet.GatherPageData(mr)
+ self.assertEqual(
+ '/u/111/hotlists/HotlistName/details', page_data['old_ui_url'])
+
+
+class ProjectListPageTest(unittest.TestCase):
+
+ def setUp(self):
+ self.services = service_manager.Services(project=fake.ProjectService())
+
+ self.project_a = self.services.project.TestAddProject('a', project_id=1)
+ self.project_b = self.services.project.TestAddProject('b', project_id=2)
+
+ self.servlet = webcomponentspage.ProjectListPage(
+ 'req', 'res', services=self.services)
+
+ @mock.patch('settings.domain_to_default_project', {})
+ def testMaybeRedirectToDomainDefaultProject_NoMatch(self):
+ """No redirect if the user is not accessing via a configured domain."""
+ mr = testing_helpers.MakeMonorailRequest()
+ mr.request.host = 'example.com'
+ msg = self.servlet._MaybeRedirectToDomainDefaultProject(mr)
+ print('msg: ' + msg)
+ self.assertTrue(msg.startswith('No configured'))
+
+ @mock.patch('settings.domain_to_default_project', {'example.com': 'huh'})
+ def testMaybeRedirectToDomainDefaultProject_NoSuchProject(self):
+ """No redirect if the configured project does not exist."""
+ mr = testing_helpers.MakeMonorailRequest()
+ mr.request.host = 'example.com'
+ print('host is %r' % mr.request.host)
+ msg = self.servlet._MaybeRedirectToDomainDefaultProject(mr)
+ print('msg: ' + msg)
+ self.assertTrue(msg.endswith('not found'))
+
+ @mock.patch('settings.domain_to_default_project', {'example.com': 'a'})
+ def testMaybeRedirectToDomainDefaultProject_CantView(self):
+ """No redirect if the user can't view the configured project."""
+ self.project_a.access = project_pb2.ProjectAccess.MEMBERS_ONLY
+ mr = testing_helpers.MakeMonorailRequest()
+ mr.request.host = 'example.com'
+ msg = self.servlet._MaybeRedirectToDomainDefaultProject(mr)
+ print('msg: ' + msg)
+ self.assertTrue(msg.startswith('User cannot'))
+
+ @mock.patch('settings.domain_to_default_project', {'example.com': 'a'})
+ def testMaybeRedirectToDomainDefaultProject_Redirect(self):
+ """We redirect if there's a configured project that the user can view."""
+ mr = testing_helpers.MakeMonorailRequest()
+ mr.request.host = 'example.com'
+ self.servlet.redirect = mock.Mock()
+ msg = self.servlet._MaybeRedirectToDomainDefaultProject(mr)
+ print('msg: ' + msg)
+ self.assertTrue(msg.startswith('Redirected'))
+ self.servlet.redirect.assert_called_once()
diff --git a/tracker/tracker_bizobj.py b/tracker/tracker_bizobj.py
new file mode 100644
index 0000000..f3f2594
--- /dev/null
+++ b/tracker/tracker_bizobj.py
@@ -0,0 +1,1831 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Business objects for the Monorail issue tracker.
+
+These are classes and functions that operate on the objects that
+users care about in the issue tracker: e.g., issues, and the issue
+tracker configuration.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+import time
+
+from six import string_types
+
+from features import federated
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import timestr
+from framework import urls
+from proto import tracker_pb2
+from tracker import tracker_constants
+
+
+def GetOwnerId(issue):
+ """Get the owner of an issue, whether it is explicit or derived."""
+ return (issue.owner_id or issue.derived_owner_id or
+ framework_constants.NO_USER_SPECIFIED)
+
+
+def GetStatus(issue):
+ """Get the status of an issue, whether it is explicit or derived."""
+ return issue.status or issue.derived_status or ''
+
+
+def GetCcIds(issue):
+ """Get the Cc's of an issue, whether they are explicit or derived."""
+ return issue.cc_ids + issue.derived_cc_ids
+
+
+def GetApproverIds(issue):
+ """Get the Approvers' ids of an isuses approval_values."""
+ approver_ids = []
+ for av in issue.approval_values:
+ approver_ids.extend(av.approver_ids)
+
+ return list(set(approver_ids))
+
+
+def GetLabels(issue):
+ """Get the labels of an issue, whether explicit or derived."""
+ return issue.labels + issue.derived_labels
+
+
+def MakeProjectIssueConfig(
+ project_id, well_known_statuses, statuses_offer_merge, well_known_labels,
+ excl_label_prefixes, col_spec):
+ """Return a ProjectIssueConfig with the given values."""
+ # pylint: disable=multiple-statements
+ if not well_known_statuses: well_known_statuses = []
+ if not statuses_offer_merge: statuses_offer_merge = []
+ if not well_known_labels: well_known_labels = []
+ if not excl_label_prefixes: excl_label_prefixes = []
+ if not col_spec: col_spec = ' '
+
+ project_config = tracker_pb2.ProjectIssueConfig()
+ if project_id: # There is no ID for harmonized configs.
+ project_config.project_id = project_id
+
+ SetConfigStatuses(project_config, well_known_statuses)
+ project_config.statuses_offer_merge = statuses_offer_merge
+ SetConfigLabels(project_config, well_known_labels)
+ project_config.exclusive_label_prefixes = excl_label_prefixes
+
+ # ID 0 means that nothing has been specified, so use hard-coded defaults.
+ project_config.default_template_for_developers = 0
+ project_config.default_template_for_users = 0
+
+ project_config.default_col_spec = col_spec
+
+ # Note: default project issue config has no filter rules.
+
+ return project_config
+
+
+def FindFieldDef(field_name, config):
+ """Find the specified field, or return None."""
+ if not field_name:
+ return None
+ field_name_lower = field_name.lower()
+ for fd in config.field_defs:
+ if fd.field_name.lower() == field_name_lower:
+ return fd
+
+ return None
+
+
+def FindFieldDefByID(field_id, config):
+ """Find the specified field, or return None."""
+ for fd in config.field_defs:
+ if fd.field_id == field_id:
+ return fd
+
+ return None
+
+
+def FindApprovalDef(approval_name, config):
+ """Find the specified approval, or return None."""
+ fd = FindFieldDef(approval_name, config)
+ if fd:
+ return FindApprovalDefByID(fd.field_id, config)
+
+ return None
+
+
+def FindApprovalDefByID(approval_id, config):
+ """Find the specified approval, or return None."""
+ for approval_def in config.approval_defs:
+ if approval_def.approval_id == approval_id:
+ return approval_def
+
+ return None
+
+
+def FindApprovalValueByID(approval_id, approval_values):
+ """Find the specified approval_value in the given list or return None."""
+ for av in approval_values:
+ if av.approval_id == approval_id:
+ return av
+
+ return None
+
+
+def FindApprovalsSubfields(approval_ids, config):
+ """Return a dict of {approval_ids: approval_subfields}."""
+ approval_subfields_dict = collections.defaultdict(list)
+ for fd in config.field_defs:
+ if fd.approval_id in approval_ids:
+ approval_subfields_dict[fd.approval_id].append(fd)
+
+ return approval_subfields_dict
+
+
+def FindPhaseByID(phase_id, phases):
+ """Find the specified phase, or return None"""
+ for phase in phases:
+ if phase.phase_id == phase_id:
+ return phase
+
+ return None
+
+
+def FindPhase(name, phases):
+ """Find the specified phase, or return None"""
+ for phase in phases:
+ if phase.name.lower() == name.lower():
+ return phase
+
+ return None
+
+
+def GetGrantedPerms(issue, effective_ids, config):
+ """Return a set of permissions granted by user-valued fields in an issue."""
+ granted_perms = set()
+ for field_value in issue.field_values:
+ if field_value.user_id in effective_ids:
+ field_def = FindFieldDefByID(field_value.field_id, config)
+ if field_def and field_def.grants_perm:
+ # TODO(jrobbins): allow comma-separated list in grants_perm
+ granted_perms.add(field_def.grants_perm.lower())
+
+ return granted_perms
+
+
+def LabelsByPrefix(labels, lower_field_names):
+ """Convert a list of key-value labels into {lower_prefix: [value, ...]}.
+
+ It also handles custom fields with dashes in the field name.
+ """
+ label_values_by_prefix = collections.defaultdict(list)
+ for lab in labels:
+ if '-' not in lab:
+ continue
+ lower_lab = lab.lower()
+ for lower_field_name in lower_field_names:
+ if lower_lab.startswith(lower_field_name + '-'):
+ prefix = lower_field_name
+ value = lab[len(lower_field_name)+1:]
+ break
+ else: # No field name matched
+ prefix, value = lab.split('-', 1)
+ prefix = prefix.lower()
+ label_values_by_prefix[prefix].append(value)
+ return label_values_by_prefix
+
+
+def LabelIsMaskedByField(label, field_names):
+ """If the label should be displayed as a field, return the field name.
+
+ Args:
+ label: string label to consider.
+ field_names: a list of field names in lowercase.
+
+ Returns:
+ If masked, return the lowercase name of the field, otherwise None. A label
+ is masked by a custom field if the field name "Foo" matches the key part of
+ a key-value label "Foo-Bar".
+ """
+ if '-' not in label:
+ return None
+
+ for field_name_lower in field_names:
+ if label.lower().startswith(field_name_lower + '-'):
+ return field_name_lower
+
+ return None
+
+
+def NonMaskedLabels(labels, field_names):
+ """Return only those labels that are not masked by custom fields."""
+ return [lab for lab in labels
+ if not LabelIsMaskedByField(lab, field_names)]
+
+
+def ExplicitAndDerivedNonMaskedLabels(labels, derived_labels, config):
+ """Return two lists of labels that are not masked by enum custom fields."""
+ field_names = [fd.field_name.lower() for fd in config.field_defs
+ if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and
+ not fd.is_deleted] # TODO(jrobbins): restricts
+ labels = [
+ lab for lab in labels
+ if not LabelIsMaskedByField(lab, field_names)]
+ derived_labels = [
+ lab for lab in derived_labels
+ if not LabelIsMaskedByField(lab, field_names)]
+ return labels, derived_labels
+
+
+def MakeApprovalValue(approval_id, approver_ids=None, status=None,
+ setter_id=None, set_on=None, phase_id=None):
+ """Return an ApprovalValue PB with the given field values."""
+ av = tracker_pb2.ApprovalValue(
+ approval_id=approval_id, status=status,
+ setter_id=setter_id, set_on=set_on, phase_id=phase_id)
+ if approver_ids is not None:
+ av.approver_ids = approver_ids
+ return av
+
+
+def MakeFieldDef(
+ field_id,
+ project_id,
+ field_name,
+ field_type_int,
+ applic_type,
+ applic_pred,
+ is_required,
+ is_niche,
+ is_multivalued,
+ min_value,
+ max_value,
+ regex,
+ needs_member,
+ needs_perm,
+ grants_perm,
+ notify_on,
+ date_action,
+ docstring,
+ is_deleted,
+ approval_id=None,
+ is_phase_field=False,
+ is_restricted_field=False,
+ admin_ids=None,
+ editor_ids=None):
+ """Make a FieldDef PB for the given FieldDef table row tuple."""
+ if isinstance(date_action, string_types):
+ date_action = date_action.upper()
+ fd = tracker_pb2.FieldDef(
+ field_id=field_id,
+ project_id=project_id,
+ field_name=field_name,
+ field_type=field_type_int,
+ is_required=bool(is_required),
+ is_niche=bool(is_niche),
+ is_multivalued=bool(is_multivalued),
+ docstring=docstring,
+ is_deleted=bool(is_deleted),
+ applicable_type=applic_type or '',
+ applicable_predicate=applic_pred or '',
+ needs_member=bool(needs_member),
+ grants_perm=grants_perm or '',
+ notify_on=tracker_pb2.NotifyTriggers(notify_on or 0),
+ date_action=tracker_pb2.DateAction(date_action or 0),
+ is_phase_field=bool(is_phase_field),
+ is_restricted_field=bool(is_restricted_field))
+ if min_value is not None:
+ fd.min_value = min_value
+ if max_value is not None:
+ fd.max_value = max_value
+ if regex is not None:
+ fd.regex = regex
+ if needs_perm is not None:
+ fd.needs_perm = needs_perm
+ if approval_id is not None:
+ fd.approval_id = approval_id
+ if admin_ids:
+ fd.admin_ids = admin_ids
+ if editor_ids:
+ fd.editor_ids = editor_ids
+ return fd
+
+
+def MakeFieldValue(
+ field_id, int_value, str_value, user_id, date_value, url_value, derived,
+ phase_id=None):
+ """Make a FieldValue based on the given information."""
+ fv = tracker_pb2.FieldValue(field_id=field_id, derived=derived)
+ if phase_id is not None:
+ fv.phase_id = phase_id
+ if int_value is not None:
+ fv.int_value = int_value
+ elif str_value is not None:
+ fv.str_value = str_value
+ elif user_id is not None:
+ fv.user_id = user_id
+ elif date_value is not None:
+ fv.date_value = date_value
+ elif url_value is not None:
+ fv.url_value = url_value
+ else:
+ raise ValueError('Unexpected field value')
+ return fv
+
+
+def GetFieldValueWithRawValue(field_type, field_value, users_by_id, raw_value):
+ """Find and return the field value of the specified field type.
+
+ If the specified field_value is None or is empty then the raw_value is
+ returned. When the field type is USER_TYPE the raw_value is used as a key to
+ lookup users_by_id.
+
+ Args:
+ field_type: tracker_pb2.FieldTypes type.
+ field_value: tracker_pb2.FieldValue type.
+ users_by_id: Dict mapping user_ids to UserViews.
+ raw_value: String to use if field_value is not specified.
+
+ Returns:
+ Value of the specified field type.
+ """
+ ret_value = GetFieldValue(field_value, users_by_id)
+ if ret_value:
+ return ret_value
+ # Special case for user types.
+ if field_type == tracker_pb2.FieldTypes.USER_TYPE:
+ if raw_value in users_by_id:
+ return users_by_id[raw_value].email
+ return raw_value
+
+
+def GetFieldValue(fv, users_by_id):
+ """Return the value of this field. Give emails for users in users_by_id."""
+ if fv is None:
+ return None
+ elif fv.int_value is not None:
+ return fv.int_value
+ elif fv.str_value is not None:
+ return fv.str_value
+ elif fv.user_id is not None:
+ if fv.user_id in users_by_id:
+ return users_by_id[fv.user_id].email
+ else:
+ logging.info('Failed to lookup user %d when getting field', fv.user_id)
+ return fv.user_id
+ elif fv.date_value is not None:
+ return timestr.TimestampToDateWidgetStr(fv.date_value)
+ elif fv.url_value is not None:
+ return fv.url_value
+ else:
+ return None
+
+
+def FindComponentDef(path, config):
+ """Find the specified component, or return None."""
+ path_lower = path.lower()
+ for cd in config.component_defs:
+ if cd.path.lower() == path_lower:
+ return cd
+
+ return None
+
+
+def FindMatchingComponentIDs(path, config, exact=True):
+ """Return a list of components that match the given path."""
+ component_ids = []
+ path_lower = path.lower()
+
+ if exact:
+ for cd in config.component_defs:
+ if cd.path.lower() == path_lower:
+ component_ids.append(cd.component_id)
+ else:
+ path_lower_delim = path.lower() + '>'
+ for cd in config.component_defs:
+ target_delim = cd.path.lower() + '>'
+ if target_delim.startswith(path_lower_delim):
+ component_ids.append(cd.component_id)
+
+ return component_ids
+
+
+def FindComponentDefByID(component_id, config):
+ """Find the specified component, or return None."""
+ for cd in config.component_defs:
+ if cd.component_id == component_id:
+ return cd
+
+ return None
+
+
+def FindAncestorComponents(config, component_def):
+ """Return a list of all components the given component is under."""
+ path_lower = component_def.path.lower()
+ return [cd for cd in config.component_defs
+ if path_lower.startswith(cd.path.lower() + '>')]
+
+
+def GetIssueComponentsAndAncestors(issue, config):
+ """Return a list of all the components that an issue is in."""
+ result = set()
+ for component_id in issue.component_ids:
+ cd = FindComponentDefByID(component_id, config)
+ if cd is None:
+ logging.error('Tried to look up non-existent component %r' % component_id)
+ continue
+ ancestors = FindAncestorComponents(config, cd)
+ result.add(cd)
+ result.update(ancestors)
+
+ return sorted(result, key=lambda cd: cd.path)
+
+
+def FindDescendantComponents(config, component_def):
+ """Return a list of all nested components under the given component."""
+ path_plus_delim = component_def.path.lower() + '>'
+ return [cd for cd in config.component_defs
+ if cd.path.lower().startswith(path_plus_delim)]
+
+
+def MakeComponentDef(
+ component_id, project_id, path, docstring, deprecated, admin_ids, cc_ids,
+ created, creator_id, modified=None, modifier_id=None, label_ids=None):
+ """Make a ComponentDef PB for the given FieldDef table row tuple."""
+ cd = tracker_pb2.ComponentDef(
+ component_id=component_id, project_id=project_id, path=path,
+ docstring=docstring, deprecated=bool(deprecated),
+ admin_ids=admin_ids, cc_ids=cc_ids, created=created,
+ creator_id=creator_id, modified=modified, modifier_id=modifier_id,
+ label_ids=label_ids or [])
+ return cd
+
+
+def MakeSavedQuery(
+ query_id, name, base_query_id, query, subscription_mode=None,
+ executes_in_project_ids=None):
+ """Make SavedQuery PB for the given info."""
+ saved_query = tracker_pb2.SavedQuery(
+ name=name, base_query_id=base_query_id, query=query)
+ if query_id is not None:
+ saved_query.query_id = query_id
+ if subscription_mode is not None:
+ saved_query.subscription_mode = subscription_mode
+ if executes_in_project_ids is not None:
+ saved_query.executes_in_project_ids = executes_in_project_ids
+ return saved_query
+
+
+def SetConfigStatuses(project_config, well_known_statuses):
+ """Internal method to set the well-known statuses of ProjectIssueConfig."""
+ project_config.well_known_statuses = []
+ for status, docstring, means_open, deprecated in well_known_statuses:
+ canonical_status = framework_bizobj.CanonicalizeLabel(status)
+ project_config.well_known_statuses.append(tracker_pb2.StatusDef(
+ status_docstring=docstring, status=canonical_status,
+ means_open=means_open, deprecated=deprecated))
+
+
+def SetConfigLabels(project_config, well_known_labels):
+ """Internal method to set the well-known labels of a ProjectIssueConfig."""
+ project_config.well_known_labels = []
+ for label, docstring, deprecated in well_known_labels:
+ canonical_label = framework_bizobj.CanonicalizeLabel(label)
+ project_config.well_known_labels.append(tracker_pb2.LabelDef(
+ label=canonical_label, label_docstring=docstring,
+ deprecated=deprecated))
+
+
+def SetConfigApprovals(project_config, approval_def_tuples):
+ """Internal method to set up approval defs of a ProjectissueConfig."""
+ project_config.approval_defs = []
+ for approval_id, approver_ids, survey in approval_def_tuples:
+ project_config.approval_defs.append(tracker_pb2.ApprovalDef(
+ approval_id=approval_id, approver_ids=approver_ids, survey=survey))
+
+
+def ConvertDictToTemplate(template_dict):
+ """Construct a Template PB with the values from template_dict.
+
+ Args:
+ template_dict: dictionary with fields corresponding to the Template
+ PB fields.
+
+ Returns:
+ A Template protocol buffer that can be stored in the
+ project's ProjectIssueConfig PB.
+ """
+ return MakeIssueTemplate(
+ template_dict.get('name'), template_dict.get('summary'),
+ template_dict.get('status'), template_dict.get('owner_id'),
+ template_dict.get('content'), template_dict.get('labels'), [], [],
+ template_dict.get('components'),
+ summary_must_be_edited=template_dict.get('summary_must_be_edited'),
+ owner_defaults_to_member=template_dict.get('owner_defaults_to_member'),
+ component_required=template_dict.get('component_required'),
+ members_only=template_dict.get('members_only'))
+
+
+def MakeIssueTemplate(
+ name,
+ summary,
+ status,
+ owner_id,
+ content,
+ labels,
+ field_values,
+ admin_ids,
+ component_ids,
+ summary_must_be_edited=None,
+ owner_defaults_to_member=None,
+ component_required=None,
+ members_only=None,
+ phases=None,
+ approval_values=None):
+ """Make an issue template PB."""
+ template = tracker_pb2.TemplateDef()
+ template.name = name
+ if summary:
+ template.summary = summary
+ if status:
+ template.status = status
+ if owner_id:
+ template.owner_id = owner_id
+ template.content = content
+ template.field_values = field_values
+ template.labels = labels or []
+ template.admin_ids = admin_ids
+ template.component_ids = component_ids or []
+ template.approval_values = approval_values or []
+
+ if summary_must_be_edited is not None:
+ template.summary_must_be_edited = summary_must_be_edited
+ if owner_defaults_to_member is not None:
+ template.owner_defaults_to_member = owner_defaults_to_member
+ if component_required is not None:
+ template.component_required = component_required
+ if members_only is not None:
+ template.members_only = members_only
+ if phases is not None:
+ template.phases = phases
+
+ return template
+
+
+def MakeDefaultProjectIssueConfig(project_id):
+ """Return a ProjectIssueConfig with use by projects that don't have one."""
+ return MakeProjectIssueConfig(
+ project_id,
+ tracker_constants.DEFAULT_WELL_KNOWN_STATUSES,
+ tracker_constants.DEFAULT_STATUSES_OFFER_MERGE,
+ tracker_constants.DEFAULT_WELL_KNOWN_LABELS,
+ tracker_constants.DEFAULT_EXCL_LABEL_PREFIXES,
+ tracker_constants.DEFAULT_COL_SPEC)
+
+
+def HarmonizeConfigs(config_list):
+ """Combine several ProjectIssueConfigs into one for cross-project sorting.
+
+ Args:
+ config_list: a list of ProjectIssueConfig PBs with labels and statuses
+ among other fields.
+
+ Returns:
+ A new ProjectIssueConfig with just the labels and status values filled
+ in to be a logical union of the given configs. Specifically, the order
+ of the combined status and label lists should be maintained.
+ """
+ if not config_list:
+ return MakeDefaultProjectIssueConfig(None)
+
+ harmonized_status_names = _CombineOrderedLists(
+ [[stat.status for stat in config.well_known_statuses]
+ for config in config_list])
+ harmonized_label_names = _CombineOrderedLists(
+ [[lab.label for lab in config.well_known_labels]
+ for config in config_list])
+ harmonized_default_sort_spec = ' '.join(
+ config.default_sort_spec for config in config_list)
+ harmonized_means_open = {
+ status: any([stat.means_open
+ for config in config_list
+ for stat in config.well_known_statuses
+ if stat.status == status])
+ for status in harmonized_status_names}
+
+ # This col_spec is probably not what the user wants to view because it is
+ # too much information. We join all the col_specs here so that we are sure
+ # to lookup all users needed for sorting, even if it is more than needed.
+ # xxx we need to look up users based on colspec rather than sortspec?
+ harmonized_default_col_spec = ' '.join(
+ config.default_col_spec for config in config_list)
+
+ result_config = tracker_pb2.ProjectIssueConfig()
+ # The combined config is only used during sorting, never stored.
+ result_config.default_col_spec = harmonized_default_col_spec
+ result_config.default_sort_spec = harmonized_default_sort_spec
+
+ for status_name in harmonized_status_names:
+ result_config.well_known_statuses.append(tracker_pb2.StatusDef(
+ status=status_name, means_open=harmonized_means_open[status_name]))
+
+ for label_name in harmonized_label_names:
+ result_config.well_known_labels.append(tracker_pb2.LabelDef(
+ label=label_name))
+
+ for config in config_list:
+ result_config.field_defs.extend(
+ list(fd for fd in config.field_defs if not fd.is_deleted))
+ result_config.component_defs.extend(config.component_defs)
+ result_config.approval_defs.extend(config.approval_defs)
+
+ return result_config
+
+
+def HarmonizeLabelOrStatusRows(def_rows):
+ """Put the given label defs into a logical global order."""
+ ranked_defs_by_project = {}
+ oddball_defs = []
+ for row in def_rows:
+ def_id, project_id, rank, label = row[0], row[1], row[2], row[3]
+ if rank is not None:
+ ranked_defs_by_project.setdefault(project_id, []).append(
+ (def_id, rank, label))
+ else:
+ oddball_defs.append((def_id, rank, label))
+
+ oddball_defs.sort(reverse=True, key=lambda def_tuple: def_tuple[2].lower())
+ # Compose the list-of-lists in a consistent order by project_id.
+ list_of_lists = [ranked_defs_by_project[pid]
+ for pid in sorted(ranked_defs_by_project.keys())]
+ harmonized_ranked_defs = _CombineOrderedLists(
+ list_of_lists, include_duplicate_keys=True,
+ key=lambda def_tuple: def_tuple[2])
+
+ return oddball_defs + harmonized_ranked_defs
+
+
+def _CombineOrderedLists(
+ list_of_lists, include_duplicate_keys=False, key=lambda x: x):
+ """Combine lists of items while maintaining their desired order.
+
+ Args:
+ list_of_lists: a list of lists of strings.
+ include_duplicate_keys: Pass True to make the combined list have the
+ same total number of elements as the sum of the input lists.
+ key: optional function to choose which part of the list items hold the
+ string used for comparison. The result will have the whole items.
+
+ Returns:
+ A single list of items containing one copy of each of the items
+ in any of the original list, and in an order that maintains the original
+ list ordering as much as possible.
+ """
+ combined_items = []
+ combined_keys = []
+ seen_keys_set = set()
+ for one_list in list_of_lists:
+ _AccumulateCombinedList(
+ one_list, combined_items, combined_keys, seen_keys_set, key=key,
+ include_duplicate_keys=include_duplicate_keys)
+
+ return combined_items
+
+
+def _AccumulateCombinedList(
+ one_list, combined_items, combined_keys, seen_keys_set,
+ include_duplicate_keys=False, key=lambda x: x):
+ """Accumulate strings into a combined list while its maintaining ordering.
+
+ Args:
+ one_list: list of strings in a desired order.
+ combined_items: accumulated list of items in the desired order.
+ combined_keys: accumulated list of key strings in the desired order.
+ seen_keys_set: set of strings that are already in combined_list.
+ include_duplicate_keys: Pass True to make the combined list have the
+ same total number of elements as the sum of the input lists.
+ key: optional function to choose which part of the list items hold the
+ string used for comparison. The result will have the whole items.
+
+ Returns:
+ Nothing. But, combined_items is modified to mix in all the items of
+ one_list at appropriate points such that nothing in combined_items
+ is reordered, and the ordering of items from one_list is maintained
+ as much as possible. Also, seen_keys_set is modified to add any keys
+ for items that were added to combined_items.
+
+ Also, any strings that begin with "#" are compared regardless of the "#".
+ The purpose of such strings is to guide the final ordering.
+ """
+ insert_idx = 0
+ for item in one_list:
+ s = key(item).lower()
+ if s in seen_keys_set:
+ item_idx = combined_keys.index(s) # Need parallel list of keys
+ insert_idx = max(insert_idx, item_idx + 1)
+
+ if s not in seen_keys_set or include_duplicate_keys:
+ combined_items.insert(insert_idx, item)
+ combined_keys.insert(insert_idx, s)
+ insert_idx += 1
+
+ seen_keys_set.add(s)
+
+
+def GetBuiltInQuery(query_id):
+ """If the given query ID is for a built-in query, return that string."""
+ return tracker_constants.DEFAULT_CANNED_QUERY_CONDS.get(query_id, '')
+
+
+def UsersInvolvedInAmendments(amendments):
+ """Return a set of all user IDs mentioned in the given Amendments."""
+ user_id_set = set()
+ for amendment in amendments:
+ user_id_set.update(amendment.added_user_ids)
+ user_id_set.update(amendment.removed_user_ids)
+
+ return user_id_set
+
+
+def _AccumulateUsersInvolvedInComment(comment, user_id_set):
+ """Build up a set of all users involved in an IssueComment.
+
+ Args:
+ comment: an IssueComment PB.
+ user_id_set: a set of user IDs to build up.
+
+ Returns:
+ The same set, but modified to have the user IDs of user who
+ entered the comment, and all the users mentioned in any amendments.
+ """
+ user_id_set.add(comment.user_id)
+ user_id_set.update(UsersInvolvedInAmendments(comment.amendments))
+
+ return user_id_set
+
+
+def UsersInvolvedInComment(comment):
+ """Return a set of all users involved in an IssueComment.
+
+ Args:
+ comment: an IssueComment PB.
+
+ Returns:
+ A set with the user IDs of user who entered the comment, and all the
+ users mentioned in any amendments.
+ """
+ return _AccumulateUsersInvolvedInComment(comment, set())
+
+
+def UsersInvolvedInCommentList(comments):
+ """Return a set of all users involved in a list of IssueComments.
+
+ Args:
+ comments: a list of IssueComment PBs.
+
+ Returns:
+ A set with the user IDs of user who entered the comment, and all the
+ users mentioned in any amendments.
+ """
+ result = set()
+ for c in comments:
+ _AccumulateUsersInvolvedInComment(c, result)
+
+ return result
+
+
+def UsersInvolvedInIssues(issues):
+ """Return a set of all user IDs referenced in the issues' metadata."""
+ result = set()
+ for issue in issues:
+ result.update([issue.reporter_id, issue.owner_id, issue.derived_owner_id])
+ result.update(issue.cc_ids)
+ result.update(issue.derived_cc_ids)
+ result.update(fv.user_id for fv in issue.field_values if fv.user_id)
+ for av in issue.approval_values:
+ result.update(approver_id for approver_id in av.approver_ids)
+ if av.setter_id:
+ result.update([av.setter_id])
+
+ return result
+
+
+def UsersInvolvedInTemplate(template):
+ """Return a set of all user IDs referenced in the template."""
+ result = set(
+ template.admin_ids +
+ [fv.user_id for fv in template.field_values if fv.user_id])
+ if template.owner_id:
+ result.add(template.owner_id)
+ for av in template.approval_values:
+ result.update(set(av.approver_ids))
+ if av.setter_id:
+ result.add(av.setter_id)
+ return result
+
+
+def UsersInvolvedInTemplates(templates):
+ """Return a set of all user IDs referenced in the given templates."""
+ result = set()
+ for template in templates:
+ result.update(UsersInvolvedInTemplate(template))
+ return result
+
+
+def UsersInvolvedInComponents(component_defs):
+ """Return a set of user IDs referenced in the given components."""
+ result = set()
+ for cd in component_defs:
+ result.update(cd.admin_ids)
+ result.update(cd.cc_ids)
+ if cd.creator_id:
+ result.add(cd.creator_id)
+ if cd.modifier_id:
+ result.add(cd.modifier_id)
+
+ return result
+
+
+def UsersInvolvedInApprovalDefs(approval_defs, matching_fds):
+ # type: (Sequence[proto.tracker_pb2.ApprovalDef],
+ # Sequence[proto.tracker_pb2.FieldDef]) -> Collection[int]
+ """Return a set of user IDs referenced in the approval_defs and field defs"""
+ result = set()
+ for ad in approval_defs:
+ result.update(ad.approver_ids)
+ for fd in matching_fds:
+ result.update(fd.admin_ids)
+ return result
+
+
+def UsersInvolvedInConfig(config):
+ """Return a set of all user IDs referenced in the config."""
+ result = set()
+ for ad in config.approval_defs:
+ result.update(ad.approver_ids)
+ for fd in config.field_defs:
+ result.update(fd.admin_ids)
+ result.update(UsersInvolvedInComponents(config.component_defs))
+ return result
+
+
+def LabelIDsInvolvedInConfig(config):
+ """Return a set of all label IDs referenced in the config."""
+ result = set()
+ for cd in config.component_defs:
+ result.update(cd.label_ids)
+ return result
+
+
+def MakeApprovalDelta(
+ status, setter_id, approver_ids_add, approver_ids_remove,
+ subfield_vals_add, subfield_vals_remove, subfields_clear, labels_add,
+ labels_remove, set_on=None):
+ approval_delta = tracker_pb2.ApprovalDelta(
+ approver_ids_add=approver_ids_add,
+ approver_ids_remove=approver_ids_remove,
+ subfield_vals_add=subfield_vals_add,
+ subfield_vals_remove=subfield_vals_remove,
+ subfields_clear=subfields_clear,
+ labels_add=labels_add,
+ labels_remove=labels_remove
+ )
+ if status is not None:
+ approval_delta.status = status
+ approval_delta.set_on = set_on or int(time.time())
+ approval_delta.setter_id = setter_id
+
+ return approval_delta
+
+
+def MakeIssueDelta(
+ status, owner_id, cc_ids_add, cc_ids_remove, comp_ids_add, comp_ids_remove,
+ labels_add, labels_remove, field_vals_add, field_vals_remove, fields_clear,
+ blocked_on_add, blocked_on_remove, blocking_add, blocking_remove,
+ merged_into, summary, ext_blocked_on_add=None, ext_blocked_on_remove=None,
+ ext_blocking_add=None, ext_blocking_remove=None, merged_into_external=None):
+ """Construct an IssueDelta object with the given fields, iff non-None."""
+ delta = tracker_pb2.IssueDelta(
+ cc_ids_add=cc_ids_add, cc_ids_remove=cc_ids_remove,
+ comp_ids_add=comp_ids_add, comp_ids_remove=comp_ids_remove,
+ labels_add=labels_add, labels_remove=labels_remove,
+ field_vals_add=field_vals_add, field_vals_remove=field_vals_remove,
+ fields_clear=fields_clear,
+ blocked_on_add=blocked_on_add, blocked_on_remove=blocked_on_remove,
+ blocking_add=blocking_add, blocking_remove=blocking_remove)
+ if status is not None:
+ delta.status = status
+ if owner_id is not None:
+ delta.owner_id = owner_id
+ if merged_into is not None:
+ delta.merged_into = merged_into
+ if merged_into_external is not None:
+ delta.merged_into_external = merged_into_external
+ if summary is not None:
+ delta.summary = summary
+ if ext_blocked_on_add is not None:
+ delta.ext_blocked_on_add = ext_blocked_on_add
+ if ext_blocked_on_remove is not None:
+ delta.ext_blocked_on_remove = ext_blocked_on_remove
+ if ext_blocking_add is not None:
+ delta.ext_blocking_add = ext_blocking_add
+ if ext_blocking_remove is not None:
+ delta.ext_blocking_remove = ext_blocking_remove
+
+ return delta
+
+
+def ApplyLabelChanges(issue, config, labels_add, labels_remove):
+ """Updates the PB issue's labels and returns the amendment or None."""
+ canon_labels_add = [framework_bizobj.CanonicalizeLabel(l)
+ for l in labels_add]
+ labels_add = [l for l in canon_labels_add if l]
+ canon_labels_remove = [framework_bizobj.CanonicalizeLabel(l)
+ for l in labels_remove]
+ labels_remove = [l for l in canon_labels_remove if l]
+
+ (labels, update_labels_add,
+ update_labels_remove) = framework_bizobj.MergeLabels(
+ issue.labels, labels_add, labels_remove, config)
+
+ if update_labels_add or update_labels_remove:
+ issue.labels = labels
+ return MakeLabelsAmendment(
+ update_labels_add, update_labels_remove)
+ return None
+
+
+def ApplyFieldValueChanges(issue, config, fvs_add, fvs_remove, fields_clear):
+ """Updates the PB issue's field_values and returns an amendments list."""
+ phase_names_dict = {phase.phase_id: phase.name for phase in issue.phases}
+ phase_ids = list(phase_names_dict.keys())
+ (field_vals, added_fvs_by_id,
+ removed_fvs_by_id) = _MergeFields(
+ issue.field_values,
+ [fv for fv in fvs_add if not fv.phase_id or fv.phase_id in phase_ids],
+ [fv for fv in fvs_remove if not fv.phase_id or fv.phase_id in phase_ids],
+ config.field_defs)
+ amendments = []
+ if added_fvs_by_id or removed_fvs_by_id:
+ issue.field_values = field_vals
+ for fd in config.field_defs:
+ fd_added_values_by_phase = collections.defaultdict(list)
+ fd_removed_values_by_phase = collections.defaultdict(list)
+ # Split fd's added/removed fvs by the phase they belong to.
+ # non-phase fds will result in {None: [added_fvs]}
+ for fv in added_fvs_by_id.get(fd.field_id, []):
+ fd_added_values_by_phase[fv.phase_id].append(fv)
+ for fv in removed_fvs_by_id.get(fd.field_id, []):
+ fd_removed_values_by_phase[fv.phase_id].append(fv)
+ # Use all_fv_phase_ids to create Amendments, so no empty amendments
+ # are created for issue phases that had no field value changes.
+ all_fv_phase_ids = set(
+ fd_removed_values_by_phase.keys() + fd_added_values_by_phase.keys())
+ for phase_id in all_fv_phase_ids:
+ new_values = [GetFieldValue(fv, {}) for fv
+ in fd_added_values_by_phase.get(phase_id, [])]
+ old_values = [GetFieldValue(fv, {}) for fv
+ in fd_removed_values_by_phase.get(phase_id, [])]
+ amendments.append(MakeFieldAmendment(
+ fd.field_id, config, new_values, old_values=old_values,
+ phase_name=phase_names_dict.get(phase_id)))
+
+ # Note: Clearing fields is used with bulk-editing and phase fields do
+ # not appear there and cannot be bulk-edited.
+ if fields_clear:
+ field_clear_set = set(fields_clear)
+ revised_fields = []
+ for fd in config.field_defs:
+ if fd.field_id not in field_clear_set:
+ revised_fields.extend(
+ fv for fv in issue.field_values if fv.field_id == fd.field_id)
+ else:
+ amendments.append(
+ MakeFieldClearedAmendment(fd.field_id, config))
+ if fd.field_type == tracker_pb2.FieldTypes.ENUM_TYPE:
+ prefix = fd.field_name.lower() + '-'
+ filtered_labels = [
+ lab for lab in issue.labels
+ if not lab.lower().startswith(prefix)]
+ issue.labels = filtered_labels
+
+ issue.field_values = revised_fields
+ return amendments
+
+
+def ApplyIssueDelta(cnxn, issue_service, issue, delta, config):
+ """Apply an issue delta to an issue in RAM.
+
+ Args:
+ cnxn: connection to SQL database.
+ issue_service: object to access issue-related data in the database.
+ issue: Issue to be updated.
+ delta: IssueDelta object with new values for everything being changed.
+ config: ProjectIssueConfig object for the project containing the issue.
+
+ Returns:
+ A pair (amendments, impacted_iids) where amendments is a list of Amendment
+ protos to describe what changed, and impacted_iids is a set of other IIDs
+ for issues that are modified because they are related to the given issue.
+ """
+ amendments = []
+ impacted_iids = set()
+ if (delta.status is not None and delta.status != issue.status):
+ status = framework_bizobj.CanonicalizeLabel(delta.status)
+ amendments.append(MakeStatusAmendment(status, issue.status))
+ issue.status = status
+ if (delta.owner_id is not None and delta.owner_id != issue.owner_id):
+ amendments.append(MakeOwnerAmendment(delta.owner_id, issue.owner_id))
+ issue.owner_id = delta.owner_id
+
+ # compute the set of cc'd users added and removed
+ cc_add = [cc for cc in delta.cc_ids_add if cc not in issue.cc_ids]
+ cc_remove = [cc for cc in delta.cc_ids_remove if cc in issue.cc_ids]
+ if cc_add or cc_remove:
+ cc_ids = [cc for cc in list(issue.cc_ids) + cc_add
+ if cc not in cc_remove]
+ issue.cc_ids = cc_ids
+ amendments.append(MakeCcAmendment(cc_add, cc_remove))
+
+ # compute the set of components added and removed
+ comp_ids_add = [
+ c for c in delta.comp_ids_add if c not in issue.component_ids]
+ comp_ids_remove = [
+ c for c in delta.comp_ids_remove if c in issue.component_ids]
+ if comp_ids_add or comp_ids_remove:
+ comp_ids = [cid for cid in list(issue.component_ids) + comp_ids_add
+ if cid not in comp_ids_remove]
+ issue.component_ids = comp_ids
+ amendments.append(MakeComponentsAmendment(
+ comp_ids_add, comp_ids_remove, config))
+
+ # compute the set of labels added and removed
+ label_amendment = ApplyLabelChanges(
+ issue, config, delta.labels_add, delta.labels_remove)
+ if label_amendment:
+ amendments.append(label_amendment)
+
+ # compute the set of custom fields added and removed
+ fv_amendments = ApplyFieldValueChanges(
+ issue, config, delta.field_vals_add, delta.field_vals_remove,
+ delta.fields_clear)
+ amendments.extend(fv_amendments)
+
+ # Update blocking and blocked on issues.
+ (block_changes_amendments,
+ block_changes_impacted_iids) = ApplyIssueBlockRelationChanges(
+ cnxn, issue, delta.blocked_on_add, delta.blocked_on_remove,
+ delta.blocking_add, delta.blocking_remove, issue_service)
+ amendments.extend(block_changes_amendments)
+ impacted_iids.update(block_changes_impacted_iids)
+
+ # Update external issue references.
+ if delta.ext_blocked_on_add or delta.ext_blocked_on_remove:
+ add_refs = []
+ for ext_id in delta.ext_blocked_on_add:
+ ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier=ext_id)
+ if (federated.IsShortlinkValid(ext_id) and
+ ref not in issue.dangling_blocked_on_refs and
+ ext_id not in delta.ext_blocked_on_remove):
+ add_refs.append(ref)
+ remove_refs = []
+ for ext_id in delta.ext_blocked_on_remove:
+ ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier=ext_id)
+ if (federated.IsShortlinkValid(ext_id) and
+ ref in issue.dangling_blocked_on_refs):
+ remove_refs.append(ref)
+ if add_refs or remove_refs:
+ amendments.append(MakeBlockedOnAmendment(add_refs, remove_refs))
+ issue.dangling_blocked_on_refs = [
+ ref for ref in issue.dangling_blocked_on_refs + add_refs
+ if ref.ext_issue_identifier not in delta.ext_blocked_on_remove]
+
+ # Update external issue references.
+ if delta.ext_blocking_add or delta.ext_blocking_remove:
+ add_refs = []
+ for ext_id in delta.ext_blocking_add:
+ ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier=ext_id)
+ if (federated.IsShortlinkValid(ext_id) and
+ ref not in issue.dangling_blocking_refs and
+ ext_id not in delta.ext_blocking_remove):
+ add_refs.append(ref)
+ remove_refs = []
+ for ext_id in delta.ext_blocking_remove:
+ ref = tracker_pb2.DanglingIssueRef(ext_issue_identifier=ext_id)
+ if (federated.IsShortlinkValid(ext_id) and
+ ref in issue.dangling_blocking_refs):
+ remove_refs.append(ref)
+ if add_refs or remove_refs:
+ amendments.append(MakeBlockingAmendment(add_refs, remove_refs))
+ issue.dangling_blocking_refs = [
+ ref for ref in issue.dangling_blocking_refs + add_refs
+ if ref.ext_issue_identifier not in delta.ext_blocking_remove]
+
+ if delta.merged_into is not None and delta.merged_into_external is not None:
+ raise ValueError(('Cannot update merged_into and merged_into_external'
+ ' fields at the same time.'))
+
+ if (delta.merged_into is not None and
+ delta.merged_into != issue.merged_into and
+ ((delta.merged_into == 0 and issue.merged_into is not None) or
+ delta.merged_into != 0)):
+
+ # Handle removing the existing internal merged_into.
+ try:
+ merged_remove = issue.merged_into
+ remove_issue = issue_service.GetIssue(cnxn, merged_remove)
+ remove_ref = remove_issue.project_name, remove_issue.local_id
+ impacted_iids.add(merged_remove)
+ except exceptions.NoSuchIssueException:
+ remove_ref = None
+
+ # Handle going from external->internal mergedinto.
+ if issue.merged_into_external:
+ remove_ref = tracker_pb2.DanglingIssueRef(
+ ext_issue_identifier=issue.merged_into_external)
+ issue.merged_into_external = None
+
+ # Handle adding the new merged_into.
+ try:
+ merged_add = delta.merged_into
+ issue.merged_into = delta.merged_into
+ add_issue = issue_service.GetIssue(cnxn, merged_add)
+ add_ref = add_issue.project_name, add_issue.local_id
+ impacted_iids.add(merged_add)
+ except exceptions.NoSuchIssueException:
+ add_ref = None
+
+ amendments.append(MakeMergedIntoAmendment(
+ [add_ref], [remove_ref], default_project_name=issue.project_name))
+
+ if (delta.merged_into_external is not None and
+ delta.merged_into_external != issue.merged_into_external and
+ (federated.IsShortlinkValid(delta.merged_into_external) or
+ (delta.merged_into_external == '' and issue.merged_into_external))):
+
+ remove_ref = None
+ if issue.merged_into_external:
+ remove_ref = tracker_pb2.DanglingIssueRef(
+ ext_issue_identifier=issue.merged_into_external)
+ elif issue.merged_into:
+ # Handle moving from internal->external mergedinto.
+ try:
+ remove_issue = issue_service.GetIssue(cnxn, issue.merged_into)
+ remove_ref = remove_issue.project_name, remove_issue.local_id
+ impacted_iids.add(issue.merged_into)
+ except exceptions.NoSuchIssueException:
+ pass
+
+ add_ref = tracker_pb2.DanglingIssueRef(
+ ext_issue_identifier=delta.merged_into_external)
+ issue.merged_into = 0
+ issue.merged_into_external = delta.merged_into_external
+ amendments.append(MakeMergedIntoAmendment([add_ref], [remove_ref],
+ default_project_name=issue.project_name))
+
+ if delta.summary and delta.summary != issue.summary:
+ amendments.append(MakeSummaryAmendment(delta.summary, issue.summary))
+ issue.summary = delta.summary
+
+ return amendments, impacted_iids
+
+
+def ApplyIssueBlockRelationChanges(
+ cnxn, issue, blocked_on_add, blocked_on_remove, blocking_add,
+ blocking_remove, issue_service):
+ # type: (MonorailConnection, Issue, Collection[int], Collection[int],
+ # Collection[int], Collection[int], IssueService) ->
+ # Sequence[Amendment], Collection[int]
+ """Apply issue blocking/blocked_on relation changes to an issue in RAM.
+
+ Args:
+ cnxn: connection to SQL database.
+ issue: Issue PB that we are applying the changes to.
+ blocked_on_add: list of issue IDs that we want to add as blocked_on.
+ blocked_on_remove: list of issue IDs that we want to remove from blocked_on.
+ blocking_add: list of issue IDs that we want to add as blocking.
+ blocking_remove: list of issue IDs that we want to remove from blocking.
+ issue_service: IssueService used to fetch info from DB or cache.
+
+ Returns:
+ A tuple that holds the list of Amendments that represent the applied changes
+ and a set of issue IDs that are impacted by the changes.
+
+
+ Side-effect:
+ The given issue's blocked_on and blocking fields will be modified.
+ """
+ amendments = []
+ impacted_iids = set()
+
+ def addAmendment(add_iids, remove_iids, amendment_func):
+ add_refs = issue_service.LookupIssueRefs(cnxn, add_iids).values()
+ remove_refs = issue_service.LookupIssueRefs(cnxn, remove_iids).values()
+ new_am = amendment_func(
+ add_refs, remove_refs, default_project_name=issue.project_name)
+ amendments.append(new_am)
+
+ # Apply blocked_on changes.
+ old_blocked_on = issue.blocked_on_iids
+ blocked_on_add = [iid for iid in blocked_on_add if iid not in old_blocked_on]
+ blocked_on_remove = [
+ iid for iid in blocked_on_remove if iid in old_blocked_on
+ ]
+ # blocked_on_add and blocked_on_remove are filtered above such that they
+ # could not contain matching items.
+ if blocked_on_add or blocked_on_remove:
+ addAmendment(blocked_on_add, blocked_on_remove, MakeBlockedOnAmendment)
+
+ new_blocked_on_iids = [
+ iid for iid in old_blocked_on + blocked_on_add
+ if iid not in blocked_on_remove
+ ]
+ (issue.blocked_on_iids,
+ issue.blocked_on_ranks) = issue_service.SortBlockedOn(
+ cnxn, issue, new_blocked_on_iids)
+ impacted_iids.update(blocked_on_add + blocked_on_remove)
+
+ # Apply blocking changes.
+ old_blocking = issue.blocking_iids
+ blocking_add = [iid for iid in blocking_add if iid not in old_blocking]
+ blocking_remove = [iid for iid in blocking_remove if iid in old_blocking]
+ # blocking_add and blocking_remove are filtered above such that they
+ # could not contain matching items.
+ if blocking_add or blocking_remove:
+ addAmendment(blocking_add, blocking_remove, MakeBlockingAmendment)
+ issue.blocking_iids = [
+ iid for iid in old_blocking + blocking_add if iid not in blocking_remove
+ ]
+ impacted_iids.update(blocking_add + blocking_remove)
+
+ return amendments, impacted_iids
+
+
+def MakeAmendment(
+ field, new_value, added_ids, removed_ids, custom_field_name=None,
+ old_value=None):
+ """Utility function to populate an Amendment PB.
+
+ Args:
+ field: enum for the field being updated.
+ new_value: new string value of that field.
+ added_ids: list of user IDs being added.
+ removed_ids: list of user IDs being removed.
+ custom_field_name: optional name of a custom field.
+ old_value: old string value of that field.
+
+ Returns:
+ An instance of Amendment.
+ """
+ amendment = tracker_pb2.Amendment()
+ amendment.field = field
+ amendment.newvalue = new_value
+ amendment.added_user_ids.extend(added_ids)
+ amendment.removed_user_ids.extend(removed_ids)
+
+ if old_value is not None:
+ amendment.oldvalue = old_value
+
+ if custom_field_name is not None:
+ amendment.custom_field_name = custom_field_name
+
+ return amendment
+
+
+def _PlusMinusString(added_items, removed_items):
+ """Return a concatenation of the items, with a minus on removed items.
+
+ Args:
+ added_items: list of string items added.
+ removed_items: list of string items removed.
+
+ Returns:
+ A unicode string with all the removed items first (preceeded by minus
+ signs) and then the added items.
+ """
+ assert all(isinstance(item, string_types)
+ for item in added_items + removed_items)
+ # TODO(jrobbins): this is not good when values can be negative ints.
+ return ' '.join(
+ ['-%s' % item.strip()
+ for item in removed_items if item] +
+ ['%s' % item for item in added_items if item])
+
+
+def _PlusMinusAmendment(
+ field, added_items, removed_items, custom_field_name=None):
+ """Make an Amendment PB with the given added/removed items."""
+ return MakeAmendment(
+ field, _PlusMinusString(added_items, removed_items), [], [],
+ custom_field_name=custom_field_name)
+
+
+def _PlusMinusRefsAmendment(
+ field, added_refs, removed_refs, default_project_name=None):
+ """Make an Amendment PB with the given added/removed refs."""
+ return _PlusMinusAmendment(
+ field,
+ [FormatIssueRef(r, default_project_name=default_project_name)
+ for r in added_refs if r],
+ [FormatIssueRef(r, default_project_name=default_project_name)
+ for r in removed_refs if r])
+
+
+def MakeSummaryAmendment(new_summary, old_summary):
+ """Make an Amendment PB for a change to the summary."""
+ return MakeAmendment(
+ tracker_pb2.FieldID.SUMMARY, new_summary, [], [], old_value=old_summary)
+
+
+def MakeStatusAmendment(new_status, old_status):
+ """Make an Amendment PB for a change to the status."""
+ return MakeAmendment(
+ tracker_pb2.FieldID.STATUS, new_status, [], [], old_value=old_status)
+
+
+def MakeOwnerAmendment(new_owner_id, old_owner_id):
+ """Make an Amendment PB for a change to the owner."""
+ return MakeAmendment(
+ tracker_pb2.FieldID.OWNER, '', [new_owner_id], [old_owner_id])
+
+
+def MakeCcAmendment(added_cc_ids, removed_cc_ids):
+ """Make an Amendment PB for a change to the Cc list."""
+ return MakeAmendment(
+ tracker_pb2.FieldID.CC, '', added_cc_ids, removed_cc_ids)
+
+
+def MakeLabelsAmendment(added_labels, removed_labels):
+ """Make an Amendment PB for a change to the labels."""
+ return _PlusMinusAmendment(
+ tracker_pb2.FieldID.LABELS, added_labels, removed_labels)
+
+
+def DiffValueLists(new_list, old_list):
+ """Give an old list and a new list, return the added and removed items."""
+ if not old_list:
+ return new_list, []
+ if not new_list:
+ return [], old_list
+
+ added = []
+ removed = old_list[:] # Assume everything was removed, then narrow that down
+ for val in new_list:
+ if val in removed:
+ removed.remove(val)
+ else:
+ added.append(val)
+
+ return added, removed
+
+
+def MakeFieldAmendment(
+ field_id, config, new_values, old_values=None, phase_name=None):
+ """Return an amendment showing how an issue's field changed.
+
+ Args:
+ field_id: int field ID of a built-in or custom issue field.
+ config: config info for the current project, including field_defs.
+ new_values: list of strings representing new values of field.
+ old_values: list of strings representing old values of field.
+ phase_name: name of the phase that owned the field that was changed.
+
+ Returns:
+ A new Amemdnent object.
+
+ Raises:
+ ValueError: if the specified field was not found.
+ """
+ fd = FindFieldDefByID(field_id, config)
+
+ if fd is None:
+ raise ValueError('field %r vanished mid-request', field_id)
+
+ field_name = fd.field_name if not phase_name else '%s-%s' % (
+ phase_name, fd.field_name)
+ if fd.is_multivalued:
+ old_values = old_values or []
+ added, removed = DiffValueLists(new_values, old_values)
+ if fd.field_type == tracker_pb2.FieldTypes.USER_TYPE:
+ return MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, '', added, removed,
+ custom_field_name=field_name)
+ else:
+ return _PlusMinusAmendment(
+ tracker_pb2.FieldID.CUSTOM,
+ ['%s' % item for item in added],
+ ['%s' % item for item in removed],
+ custom_field_name=field_name)
+
+ else:
+ if fd.field_type == tracker_pb2.FieldTypes.USER_TYPE:
+ return MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, '', new_values, [],
+ custom_field_name=field_name)
+
+ if new_values:
+ new_str = ', '.join('%s' % item for item in new_values)
+ else:
+ new_str = '----'
+
+ return MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, new_str, [], [],
+ custom_field_name=field_name)
+
+
+def MakeFieldClearedAmendment(field_id, config):
+ fd = FindFieldDefByID(field_id, config)
+
+ if fd is None:
+ raise ValueError('field %r vanished mid-request', field_id)
+
+ return MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, '----', [], [],
+ custom_field_name=fd.field_name)
+
+
+def MakeApprovalStructureAmendment(new_approvals, old_approvals):
+ """Return an Amendment showing an issue's approval structure changed.
+
+ Args:
+ new_approvals: the new list of approvals.
+ old_approvals: the old list of approvals.
+
+ Returns:
+ A new Amendment object.
+ """
+
+ approvals_added, approvals_removed = DiffValueLists(
+ new_approvals, old_approvals)
+ return MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, _PlusMinusString(
+ approvals_added, approvals_removed),
+ [], [], custom_field_name='Approvals')
+
+
+def MakeApprovalStatusAmendment(new_status):
+ """Return an Amendment showing an issue approval's status changed.
+
+ Args:
+ new_status: ApprovalStatus representing the new approval status.
+
+ Returns:
+ A new Amemdnent object.
+ """
+ return MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, new_status.name.lower(), [], [],
+ custom_field_name='Status')
+
+
+def MakeApprovalApproversAmendment(approvers_add, approvers_remove):
+ """Return an Amendment showing an issue approval's approvers changed.
+
+ Args:
+ approvers_add: list of approver user_ids being added.
+ approvers_remove: list of approver user_ids being removed.
+
+ Returns:
+ A new Amendment object.
+ """
+ return MakeAmendment(
+ tracker_pb2.FieldID.CUSTOM, '', approvers_add, approvers_remove,
+ custom_field_name='Approvers')
+
+
+def MakeComponentsAmendment(added_comp_ids, removed_comp_ids, config):
+ """Make an Amendment PB for a change to the components."""
+ # TODO(jrobbins): record component IDs as ints and display them with
+ # lookups (and maybe permission checks in the future). But, what
+ # about history that references deleleted components?
+ added_comp_paths = []
+ for comp_id in added_comp_ids:
+ cd = FindComponentDefByID(comp_id, config)
+ if cd:
+ added_comp_paths.append(cd.path)
+
+ removed_comp_paths = []
+ for comp_id in removed_comp_ids:
+ cd = FindComponentDefByID(comp_id, config)
+ if cd:
+ removed_comp_paths.append(cd.path)
+
+ return _PlusMinusAmendment(
+ tracker_pb2.FieldID.COMPONENTS,
+ added_comp_paths, removed_comp_paths)
+
+
+def MakeBlockedOnAmendment(
+ added_refs, removed_refs, default_project_name=None):
+ """Make an Amendment PB for a change to the blocked on issues."""
+ return _PlusMinusRefsAmendment(
+ tracker_pb2.FieldID.BLOCKEDON, added_refs, removed_refs,
+ default_project_name=default_project_name)
+
+
+def MakeBlockingAmendment(added_refs, removed_refs, default_project_name=None):
+ """Make an Amendment PB for a change to the blocking issues."""
+ return _PlusMinusRefsAmendment(
+ tracker_pb2.FieldID.BLOCKING, added_refs, removed_refs,
+ default_project_name=default_project_name)
+
+
+def MakeMergedIntoAmendment(
+ added_refs, removed_refs, default_project_name=None):
+ """Make an Amendment PB for a change to the merged-into issue."""
+ return _PlusMinusRefsAmendment(
+ tracker_pb2.FieldID.MERGEDINTO, added_refs, removed_refs,
+ default_project_name=default_project_name)
+
+
+def MakeProjectAmendment(new_project_name):
+ """Make an Amendment PB for a change to an issue's project."""
+ return MakeAmendment(
+ tracker_pb2.FieldID.PROJECT, new_project_name, [], [])
+
+
+def AmendmentString_New(amendment, user_display_names):
+ # type: (tracker_pb2.Amendment, Mapping[int, str]) -> str
+ """Produce a displayable string for an Amendment PB.
+
+ Args:
+ amendment: Amendment PB to display.
+ user_display_names: dict {user_id: display_name, ...} including all users
+ mentioned in amendment.
+
+ Returns:
+ A string that could be displayed on a web page or sent in email.
+ """
+ if amendment.newvalue:
+ return amendment.newvalue
+
+ # Display new owner only
+ if amendment.field == tracker_pb2.FieldID.OWNER:
+ if amendment.added_user_ids and amendment.added_user_ids[0]:
+ uid = amendment.added_user_ids[0]
+ result = user_display_names[uid]
+ else:
+ result = framework_constants.NO_USER_NAME
+ else:
+ added = [
+ user_display_names[uid]
+ for uid in amendment.added_user_ids
+ if uid in user_display_names
+ ]
+ removed = [
+ user_display_names[uid]
+ for uid in amendment.removed_user_ids
+ if uid in user_display_names
+ ]
+ result = _PlusMinusString(added, removed)
+
+ return result
+
+
+def AmendmentString(amendment, user_views_by_id):
+ """Produce a displayable string for an Amendment PB.
+
+ TODO(crbug.com/monorail/7571): Delete this function in favor of _New.
+
+ Args:
+ amendment: Amendment PB to display.
+ user_views_by_id: dict {user_id: user_view, ...} including all users
+ mentioned in amendment.
+
+ Returns:
+ A string that could be displayed on a web page or sent in email.
+ """
+ if amendment.newvalue:
+ return amendment.newvalue
+
+ # Display new owner only
+ if amendment.field == tracker_pb2.FieldID.OWNER:
+ if amendment.added_user_ids and amendment.added_user_ids[0]:
+ uid = amendment.added_user_ids[0]
+ result = user_views_by_id[uid].display_name
+ else:
+ result = framework_constants.NO_USER_NAME
+ else:
+ result = _PlusMinusString(
+ [user_views_by_id[uid].display_name for uid in amendment.added_user_ids
+ if uid in user_views_by_id],
+ [user_views_by_id[uid].display_name
+ for uid in amendment.removed_user_ids if uid in user_views_by_id])
+
+ return result
+
+
+def AmendmentLinks(amendment, users_by_id, project_name):
+ """Produce a list of value/url pairs for an Amendment PB.
+
+ Args:
+ amendment: Amendment PB to display.
+ users_by_id: dict {user_id: user_view, ...} including all users
+ mentioned in amendment.
+ project_nme: Name of project the issue/comment/amendment is in.
+
+ Returns:
+ A list of dicts with 'value' and 'url' keys. 'url' may be None.
+ """
+ # Display both old and new summary, status
+ if (amendment.field == tracker_pb2.FieldID.SUMMARY or
+ amendment.field == tracker_pb2.FieldID.STATUS):
+ result = amendment.newvalue
+ oldValue = amendment.oldvalue;
+ # Old issues have a 'NULL' string as the old value of the summary
+ # or status fields. See crbug.com/monorail/3805
+ if oldValue and oldValue != 'NULL':
+ result += ' (was: %s)' % amendment.oldvalue
+ return [{'value': result, 'url': None}]
+ # Display new owner only
+ elif amendment.field == tracker_pb2.FieldID.OWNER:
+ if amendment.added_user_ids and amendment.added_user_ids[0]:
+ uid = amendment.added_user_ids[0]
+ return [{'value': users_by_id[uid].display_name, 'url': None}]
+ return [{'value': framework_constants.NO_USER_NAME, 'url': None}]
+ elif amendment.field in (tracker_pb2.FieldID.BLOCKEDON,
+ tracker_pb2.FieldID.BLOCKING,
+ tracker_pb2.FieldID.MERGEDINTO):
+ values = amendment.newvalue.split()
+ bug_refs = [_SafeParseIssueRef(v.strip()) for v in values]
+ issue_urls = [FormatIssueURL(ref, default_project_name=project_name)
+ for ref in bug_refs]
+ # TODO(jrobbins): Permission checks on referenced issues to allow
+ # showing summary on hover.
+ return [{'value': v, 'url': u} for (v, u) in zip(values, issue_urls)]
+ elif amendment.newvalue:
+ # Catchall for everything except user-valued fields.
+ return [{'value': v, 'url': None} for v in amendment.newvalue.split()]
+ else:
+ # Applies to field==CC or CUSTOM with user type.
+ values = _PlusMinusString(
+ [users_by_id[uid].display_name for uid in amendment.added_user_ids
+ if uid in users_by_id],
+ [users_by_id[uid].display_name for uid in amendment.removed_user_ids
+ if uid in users_by_id])
+ return [{'value': v.strip(), 'url': None} for v in values.split()]
+
+
+def GetAmendmentFieldName(amendment):
+ """Get user-visible name for an amendment to a built-in or custom field."""
+ if amendment.custom_field_name:
+ return amendment.custom_field_name
+ else:
+ field_name = str(amendment.field)
+ return field_name.capitalize()
+
+
+def MakeDanglingIssueRef(project_name, issue_id, ext_id=''):
+ """Create a DanglingIssueRef pb."""
+ ret = tracker_pb2.DanglingIssueRef()
+ ret.project = project_name
+ ret.issue_id = issue_id
+ ret.ext_issue_identifier = ext_id
+ return ret
+
+
+def FormatIssueURL(issue_ref_tuple, default_project_name=None):
+ """Format an issue url from an issue ref."""
+ if issue_ref_tuple is None:
+ return ''
+ project_name, local_id = issue_ref_tuple
+ project_name = project_name or default_project_name
+ url = framework_helpers.FormatURL(
+ None, '/p/%s%s' % (project_name, urls.ISSUE_DETAIL), id=local_id)
+ return url
+
+
+def FormatIssueRef(issue_ref_tuple, default_project_name=None):
+ """Format an issue reference for users: e.g., 123, or projectname:123."""
+ if issue_ref_tuple is None:
+ return ''
+
+ # TODO(jeffcarp): Improve method signature to not require isinstance.
+ if isinstance(issue_ref_tuple, tracker_pb2.DanglingIssueRef):
+ return issue_ref_tuple.ext_issue_identifier or ''
+
+ project_name, local_id = issue_ref_tuple
+ if project_name and project_name != default_project_name:
+ return '%s:%d' % (project_name, local_id)
+ else:
+ return str(local_id)
+
+
+def ParseIssueRef(ref_str):
+ """Parse an issue ref string: e.g., 123, or projectname:123 into a tuple.
+
+ Raises ValueError if the ref string exists but can't be parsed.
+ """
+ if not ref_str.strip():
+ return None
+
+ if ':' in ref_str:
+ project_name, id_str = ref_str.split(':', 1)
+ project_name = project_name.strip().lstrip('-')
+ else:
+ project_name = None
+ id_str = ref_str
+
+ id_str = id_str.lstrip('-')
+
+ return project_name, int(id_str)
+
+
+def _SafeParseIssueRef(ref_str):
+ """Same as ParseIssueRef, but catches ValueError and returns None instead."""
+ try:
+ return ParseIssueRef(ref_str)
+ except ValueError:
+ return None
+
+
+def _MergeFields(field_values, fields_add, fields_remove, field_defs):
+ """Merge the fields to add/remove into the current field values.
+
+ Args:
+ field_values: list of current FieldValue PBs.
+ fields_add: list of FieldValue PBs to add to field_values. If any of these
+ is for a single-valued field, it replaces all previous values for the
+ same field_id in field_values.
+ fields_remove: list of FieldValues to remove from field_values, if found.
+ field_defs: list of FieldDef PBs from the issue's project's config.
+
+ Returns:
+ A 3-tuple with the merged list of field values and {field_id: field_values}
+ dict for the specific values that are added or removed. The actual added
+ or removed might be fewer than the requested ones if the issue already had
+ one of the values-to-add or lacked one of the values-to-remove.
+ """
+ is_multi = {fd.field_id: fd.is_multivalued for fd in field_defs}
+ merged_fvs = list(field_values)
+ added_fvs_by_id = collections.defaultdict(list)
+ for fv_consider in fields_add:
+ consider_value = GetFieldValue(fv_consider, {})
+ for old_fv in field_values:
+ # Don't add fv_consider if field_values already contains consider_value
+ if (fv_consider.field_id == old_fv.field_id and
+ GetFieldValue(old_fv, {}) == consider_value and
+ fv_consider.phase_id == old_fv.phase_id):
+ break
+ else:
+ # Drop any existing values for non-multi fields.
+ if not is_multi.get(fv_consider.field_id):
+ if fv_consider.phase_id:
+ # Drop existing phase fvs that belong to the same phase
+ merged_fvs = [fv for fv in merged_fvs if
+ not (fv.field_id == fv_consider.field_id
+ and fv.phase_id == fv_consider.phase_id)]
+ else:
+ # Drop existing non-phase fvs
+ merged_fvs = [fv for fv in merged_fvs if
+ not fv.field_id == fv_consider.field_id]
+ added_fvs_by_id[fv_consider.field_id].append(fv_consider)
+ merged_fvs.append(fv_consider)
+
+ removed_fvs_by_id = collections.defaultdict(list)
+ for fv_consider in fields_remove:
+ consider_value = GetFieldValue(fv_consider, {})
+ for old_fv in field_values:
+ # Only remove fv_consider if field_values contains consider_value
+ if (fv_consider.field_id == old_fv.field_id and
+ GetFieldValue(old_fv, {}) == consider_value and
+ fv_consider.phase_id == old_fv.phase_id):
+ removed_fvs_by_id[fv_consider.field_id].append(fv_consider)
+ merged_fvs.remove(old_fv)
+ return merged_fvs, added_fvs_by_id, removed_fvs_by_id
+
+
+def SplitBlockedOnRanks(issue, target_iid, split_above, open_iids):
+ """Splits issue relation rankings by some target issue's rank
+
+ Args:
+ issue: Issue PB for the issue considered.
+ target_iid: the global ID of the issue to split rankings about.
+ split_above: False to split below the target issue, True to split above.
+ open_iids: a list of global IDs of open and visible issues blocking
+ the considered issue.
+
+ Returns:
+ A tuple (lower, higher) where both are lists of
+ [(blocker_iid, rank),...] of issues in rank order. If split_above is False
+ the target issue is included in higher, otherwise it is included in lower
+ """
+ issue_rank_pairs = [(dst_iid, rank)
+ for (dst_iid, rank) in zip(issue.blocked_on_iids, issue.blocked_on_ranks)
+ if dst_iid in open_iids]
+ # blocked_on_iids is sorted high-to-low, we need low-to-high
+ issue_rank_pairs.reverse()
+ offset = int(split_above)
+ for i, (dst_iid, _) in enumerate(issue_rank_pairs):
+ if dst_iid == target_iid:
+ return issue_rank_pairs[:i + offset], issue_rank_pairs[i + offset:]
+
+ logging.error('Target issue %r was not found in blocked_on_iids of %r',
+ target_iid, issue)
+ return issue_rank_pairs, []
diff --git a/tracker/tracker_constants.py b/tracker/tracker_constants.py
new file mode 100644
index 0000000..e0fe1b2
--- /dev/null
+++ b/tracker/tracker_constants.py
@@ -0,0 +1,252 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Some constants used in Monorail issue tracker pages."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import re
+
+from proto import user_pb2
+
+
+# Default columns shown on issue list page, and other built-in cols.
+DEFAULT_COL_SPEC = 'ID Type Status Priority Milestone Owner Summary'
+OTHER_BUILT_IN_COLS = [
+ 'AllLabels', 'Attachments', 'Stars', 'Opened', 'Closed', 'Modified',
+ 'BlockedOn', 'Blocking', 'Blocked', 'MergedInto',
+ 'Reporter', 'Cc', 'Project', 'Component',
+ 'OwnerModified', 'StatusModified', 'ComponentModified',
+ 'OwnerLastVisit']
+
+# These are label prefixes that would conflict with built-in column names.
+# E.g., no issue should have a *label* id-1234 or status-foo because any
+# search for "id:1234" or "status:foo" would not look at labels.
+RESERVED_PREFIXES = [
+ 'id', 'project', 'reporter', 'summary', 'status', 'owner', 'cc',
+ 'attachments', 'attachment', 'component', 'opened', 'closed',
+ 'modified', 'is', 'has', 'blockedon', 'blocking', 'blocked', 'mergedinto',
+ 'stars', 'starredby', 'description', 'comment', 'commentby', 'label',
+ 'hotlist', 'rank', 'explicit_status', 'derived_status', 'explicit_owner',
+ 'derived_owner', 'explicit_cc', 'derived_cc', 'explicit_label',
+ 'derived_label', 'last_comment_by', 'exact_component',
+ 'explicit_component', 'derived_component', 'alllabels', 'gate']
+
+# Suffix of a column name for an approval's approvers.
+APPROVER_COL_SUFFIX = '-approver'
+APPROVAL_SETTER_COL_SUFFIX = '-setter'
+APPROVAL_SET_ON_COL_SUFFIX = '-on'
+
+# Reserved column name suffixes that field names cannot end with.
+RESERVED_COL_NAME_SUFFIXES = [
+ APPROVER_COL_SUFFIX,
+ APPROVAL_SETTER_COL_SUFFIX,
+ APPROVAL_SET_ON_COL_SUFFIX
+]
+
+# The columns are useless in the grid view, so don't offer them.
+# These are also not used in groupby in the issue list.
+NOT_USED_IN_GRID_AXES = [
+ 'Summary', 'ID', 'Opened', 'Closed', 'Modified', 'Cc',
+ 'OwnerModified', 'StatusModified', 'ComponentModified',
+ 'OwnerLastVisit', 'AllLabels']
+
+# Issues per page in the issue list
+DEFAULT_RESULTS_PER_PAGE = 100
+
+# Search field input indicating that the user wants to
+# jump to the specified issue.
+JUMP_RE = re.compile(r'^\d+$')
+
+# Regular expression defining a single search term.
+# Used when parsing the contents of the issue search field.
+TERM_RE = re.compile(r'[-a-zA-Z0-9._]+')
+
+# Pattern used to validate component leaf names, the parts of
+# a component path between the ">" symbols.
+COMPONENT_LEAF_PATTERN = '[a-zA-Z]([-_]?[a-zA-Z0-9])+'
+
+# Regular expression used to validate new component leaf names.
+# This should never match any string with a ">" in it.
+COMPONENT_NAME_RE = re.compile(r'^%s$' % (COMPONENT_LEAF_PATTERN))
+
+# Pattern for matching a full component name, not just a single leaf.
+# Allows any number of repeating valid leaf names separated by ">" characters.
+COMPONENT_PATH_PATTERN = '%s(\>%s)*' % (
+ COMPONENT_LEAF_PATTERN, COMPONENT_LEAF_PATTERN)
+
+# Regular expression used to validate new field names.
+FIELD_NAME_RE = re.compile(r'^[a-zA-Z]([-_]?[a-zA-Z0-9])*$')
+
+# Regular expression used to validate new phase_names.
+PHASE_NAME_RE = re.compile(r'^[a-z]([-_]?[a-z0-9])*$', re.IGNORECASE)
+
+# The next few items are specifications of the defaults for project
+# issue configurations. These are used for projects that do not have
+# their own config.
+DEFAULT_CANNED_QUERIES = [
+ # Query ID, Name, Base query ID (not used for built-in queries), conditions
+ (1, 'All issues', 0, ''),
+ (2, 'Open issues', 0, 'is:open'),
+ (3, 'Open and owned by me', 0, 'is:open owner:me'),
+ (4, 'Open and reported by me', 0, 'is:open reporter:me'),
+ (5, 'Open and starred by me', 0, 'is:open is:starred'),
+ (6, 'New issues', 0, 'status:new'),
+ (7, 'Issues to verify', 0, 'status=fixed,done'),
+ (8, 'Open with comment by me', 0, 'is:open commentby:me'),
+ ]
+
+DEFAULT_CANNED_QUERY_CONDS = {
+ query_id: cond
+ for (query_id, _name, _base, cond) in DEFAULT_CANNED_QUERIES}
+
+ALL_ISSUES_CAN = 1
+OPEN_ISSUES_CAN = 2
+
+# Define well-known issue statuses. Each status has 3 parts: a name, a
+# description, and True if the status means that an issue should be
+# considered to be open or False if it should be considered closed.
+DEFAULT_WELL_KNOWN_STATUSES = [
+ # Name, docstring, means_open, deprecated
+ ('New', 'Issue has not had initial review yet', True, False),
+ ('Accepted', 'Problem reproduced / Need acknowledged', True, False),
+ ('Started', 'Work on this issue has begun', True, False),
+ ('Fixed', 'Developer made source code changes, QA should verify', False,
+ False),
+ ('Verified', 'QA has verified that the fix worked', False, False),
+ ('Invalid', 'This was not a valid issue report', False, False),
+ ('Duplicate', 'This report duplicates an existing issue', False, False),
+ ('WontFix', 'We decided to not take action on this issue', False, False),
+ ('Done', 'The requested non-coding task was completed', False, False),
+ ]
+
+DEFAULT_WELL_KNOWN_LABELS = [
+ # Name, docstring, deprecated
+ ('Type-Defect', 'Report of a software defect', False),
+ ('Type-Enhancement', 'Request for enhancement', False),
+ ('Type-Task', 'Work item that doesn\'t change the code or docs', False),
+ ('Type-Other', 'Some other kind of issue', False),
+ ('Priority-Critical', 'Must resolve in the specified milestone', False),
+ ('Priority-High', 'Strongly want to resolve in the specified milestone',
+ False),
+ ('Priority-Medium', 'Normal priority', False),
+ ('Priority-Low', 'Might slip to later milestone', False),
+ ('OpSys-All', 'Affects all operating systems', False),
+ ('OpSys-Windows', 'Affects Windows users', False),
+ ('OpSys-Linux', 'Affects Linux users', False),
+ ('OpSys-OSX', 'Affects Mac OS X users', False),
+ ('Milestone-Release1.0', 'All essential functionality working', False),
+ ('Security', 'Security risk to users', False),
+ ('Performance', 'Performance issue', False),
+ ('Usability', 'Affects program usability', False),
+ ('Maintainability', 'Hinders future changes', False),
+ ]
+
+# Exclusive label prefixes are ones that can only be used once per issue.
+# For example, an issue would normally have only one Priority-* label, whereas
+# an issue might have many OpSys-* labels.
+DEFAULT_EXCL_LABEL_PREFIXES = ['Type', 'Priority', 'Milestone']
+
+DEFAULT_USER_DEFECT_REPORT_TEMPLATE = {
+ 'name': 'Defect report from user',
+ 'summary': 'Enter one-line summary',
+ 'summary_must_be_edited': True,
+ 'content': (
+ 'What steps will reproduce the problem?\n'
+ '1. \n'
+ '2. \n'
+ '3. \n'
+ '\n'
+ 'What is the expected output?\n'
+ '\n'
+ '\n'
+ 'What do you see instead?\n'
+ '\n'
+ '\n'
+ 'What version of the product are you using? '
+ 'On what operating system?\n'
+ '\n'
+ '\n'
+ 'Please provide any additional information below.\n'),
+ 'status': 'New',
+ 'labels': ['Type-Defect', 'Priority-Medium'],
+ }
+
+DEFAULT_DEVELOPER_DEFECT_REPORT_TEMPLATE = {
+ 'name': 'Defect report from developer',
+ 'summary': 'Enter one-line summary',
+ 'summary_must_be_edited': True,
+ 'content': (
+ 'What steps will reproduce the problem?\n'
+ '1. \n'
+ '2. \n'
+ '3. \n'
+ '\n'
+ 'What is the expected output?\n'
+ '\n'
+ '\n'
+ 'What do you see instead?\n'
+ '\n'
+ '\n'
+ 'Please use labels and text to provide additional information.\n'),
+ 'status': 'Accepted',
+ 'labels': ['Type-Defect', 'Priority-Medium'],
+ 'members_only': True,
+ }
+
+
+DEFAULT_TEMPLATES = [
+ DEFAULT_DEVELOPER_DEFECT_REPORT_TEMPLATE,
+ DEFAULT_USER_DEFECT_REPORT_TEMPLATE,
+ ]
+
+DEFAULT_STATUSES_OFFER_MERGE = ['Duplicate']
+
+
+# This is used by JS on the issue admin page to indicate that the user deleted
+# this template, so it should not be considered when updating the project's
+# issue config.
+DELETED_TEMPLATE_NAME = '<DELETED>'
+
+
+# This is the default maximum total bytes of files attached
+# to all the issues in a project.
+ISSUE_ATTACHMENTS_QUOTA_HARD = 50 * 1024 * 1024
+ISSUE_ATTACHMENTS_QUOTA_SOFT = ISSUE_ATTACHMENTS_QUOTA_HARD - 1 * 1024 * 1024
+
+# Default value for nav action after updating an issue.
+DEFAULT_AFTER_ISSUE_UPDATE = user_pb2.IssueUpdateNav.STAY_SAME_ISSUE
+
+# Maximum comment length to mitigate spammy comments
+MAX_COMMENT_CHARS = 50 * 1024
+MAX_SUMMARY_CHARS = 500
+
+SHORT_SUMMARY_LENGTH = 45
+
+# Number of recent commands to offer the user on the quick edit form.
+MAX_RECENT_COMMANDS = 5
+
+# These recent commands are shown if the user has no history of their own.
+DEFAULT_RECENT_COMMANDS = [
+ ('owner=me status=Accepted', "I'll handle this one."),
+ ('owner=me Priority=High status=Accepted', "I'll look into it soon."),
+ ('status=Fixed', 'The change for this is done now.'),
+ ('Type=Enhancement', 'This is an enhancement, not a defect.'),
+ ('status=Invalid', 'Please report this in a more appropriate place.'),
+ ]
+
+# Consider an issue to be a "noisy" issue if it has more than these:
+NOISY_ISSUE_COMMENT_COUNT = 100
+NOISY_ISSUE_STARRER_COUNT = 100
+
+# After a project owner edits the filter rules, we recompute the
+# derived field values in work items that each handle a chunk of
+# of this many items.
+RECOMPUTE_DERIVED_FIELDS_BLOCK_SIZE = 1000
+
+# This is the number of issues listed in the ReindexQueue table that will
+# be processed each minute.
+MAX_ISSUES_TO_REINDEX_PER_MINUTE = 1000
diff --git a/tracker/tracker_helpers.py b/tracker/tracker_helpers.py
new file mode 100644
index 0000000..c9f9e5a
--- /dev/null
+++ b/tracker/tracker_helpers.py
@@ -0,0 +1,1826 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions and classes used by the Monorail Issue Tracker pages.
+
+This module has functions that are reused in multiple servlets or
+other modules.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import itertools
+import logging
+import re
+import time
+import urllib
+
+from google.appengine.api import app_identity
+
+from six import string_types
+
+import settings
+
+from features import federated
+from framework import authdata
+from framework import exceptions
+from framework import filecontent
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import permissions
+from framework import sorting
+from framework import template_helpers
+from framework import urls
+from project import project_helpers
+from proto import tracker_pb2
+from services import client_config_svc
+from tracker import field_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+# HTML input field names for blocked on and blocking issue refs.
+BLOCKED_ON = 'blocked_on'
+BLOCKING = 'blocking'
+
+# This string is used in HTML form element names to identify custom fields.
+# E.g., a value for a custom field with field_id 12 would be specified in
+# an HTML form element with name="custom_12".
+_CUSTOM_FIELD_NAME_PREFIX = 'custom_'
+
+# When the attachment quota gets within 1MB of the limit, stop offering
+# users the option to attach files.
+_SOFT_QUOTA_LEEWAY = 1024 * 1024
+
+# Accessors for sorting built-in fields.
+SORTABLE_FIELDS = {
+ 'project': lambda issue: issue.project_name,
+ 'id': lambda issue: issue.local_id,
+ 'owner': tracker_bizobj.GetOwnerId, # And postprocessor
+ 'reporter': lambda issue: issue.reporter_id, # And postprocessor
+ 'component': lambda issue: issue.component_ids,
+ 'cc': tracker_bizobj.GetCcIds, # And postprocessor
+ 'summary': lambda issue: issue.summary.lower(),
+ 'stars': lambda issue: issue.star_count,
+ 'attachments': lambda issue: issue.attachment_count,
+ 'opened': lambda issue: issue.opened_timestamp,
+ 'closed': lambda issue: issue.closed_timestamp,
+ 'modified': lambda issue: issue.modified_timestamp,
+ 'status': tracker_bizobj.GetStatus,
+ 'blocked': lambda issue: bool(issue.blocked_on_iids),
+ 'blockedon': lambda issue: issue.blocked_on_iids or sorting.MAX_STRING,
+ 'blocking': lambda issue: issue.blocking_iids or sorting.MAX_STRING,
+ 'mergedinto': lambda issue: issue.merged_into or sorting.MAX_STRING,
+ 'ownermodified': lambda issue: issue.owner_modified_timestamp,
+ 'statusmodified': lambda issue: issue.status_modified_timestamp,
+ 'componentmodified': lambda issue: issue.component_modified_timestamp,
+ 'ownerlastvisit': tracker_bizobj.GetOwnerId, # And postprocessor
+ }
+
+# Some fields take a user ID from the issue and then use that to index
+# into a dictionary of user views, and then get a field of the user view
+# as the value to sort key.
+SORTABLE_FIELDS_POSTPROCESSORS = {
+ 'owner': lambda user_view: user_view.email,
+ 'reporter': lambda user_view: user_view.email,
+ 'cc': lambda user_view: user_view.email,
+ 'ownerlastvisit': lambda user_view: -user_view.user.last_visit_timestamp,
+ }
+
+# Here are some restriction labels to help people do the most common things
+# that they might want to do with restrictions.
+_FREQUENT_ISSUE_RESTRICTIONS = [
+ (permissions.VIEW, permissions.EDIT_ISSUE,
+ 'Only users who can edit the issue may access it'),
+ (permissions.ADD_ISSUE_COMMENT, permissions.EDIT_ISSUE,
+ 'Only users who can edit the issue may add comments'),
+ ]
+
+# These issue restrictions should be offered as examples whenever the project
+# does not have any custom permissions in use already.
+_EXAMPLE_ISSUE_RESTRICTIONS = [
+ (permissions.VIEW, 'CoreTeam',
+ 'Custom permission CoreTeam is needed to access'),
+ ]
+
+# Namedtuples that hold data parsed from post_data.
+ParsedComponents = collections.namedtuple(
+ 'ParsedComponents', 'entered_str, paths, paths_remove')
+ParsedFields = collections.namedtuple(
+ 'ParsedFields',
+ 'vals, vals_remove, fields_clear, '
+ 'phase_vals, phase_vals_remove')
+ParsedUsers = collections.namedtuple(
+ 'ParsedUsers', 'owner_username, owner_id, cc_usernames, '
+ 'cc_usernames_remove, cc_ids, cc_ids_remove')
+ParsedBlockers = collections.namedtuple(
+ 'ParsedBlockers', 'entered_str, iids, dangling_refs, '
+ 'federated_ref_strings')
+ParsedHotlistRef = collections.namedtuple(
+ 'ParsedHotlistRef', 'user_email, hotlist_name')
+ParsedHotlists = collections.namedtuple(
+ 'ParsedHotlists', 'entered_str, hotlist_refs')
+ParsedIssue = collections.namedtuple(
+ 'ParsedIssue', 'summary, comment, is_description, status, users, labels, '
+ 'labels_remove, components, fields, template_name, attachments, '
+ 'kept_attachments, blocked_on, blocking, hotlists')
+
+
+def ParseIssueRequest(cnxn, post_data, services, errors, default_project_name):
+ """Parse all the possible arguments out of the request.
+
+ Args:
+ cnxn: connection to SQL database.
+ post_data: HTML form information.
+ services: Connections to persistence layer.
+ errors: object to accumulate validation error info.
+ default_project_name: name of the project that contains the issue.
+
+ Returns:
+ A namedtuple with all parsed information. User IDs are looked up, but
+ also the strings are returned to allow bouncing the user back to correct
+ any errors.
+ """
+ summary = post_data.get('summary', '')
+ comment = post_data.get('comment', '')
+ is_description = bool(post_data.get('description', ''))
+ status = post_data.get('status', '')
+ template_name = urllib.unquote_plus(post_data.get('template_name', ''))
+ component_str = post_data.get('components', '')
+ label_strs = post_data.getall('label')
+
+ if is_description:
+ tmpl_txt = post_data.get('tmpl_txt', '')
+ comment = MarkupDescriptionOnInput(comment, tmpl_txt)
+
+ comp_paths, comp_paths_remove = _ClassifyPlusMinusItems(
+ re.split('[,;\s]+', component_str))
+ parsed_components = ParsedComponents(
+ component_str, comp_paths, comp_paths_remove)
+ labels, labels_remove = _ClassifyPlusMinusItems(label_strs)
+ parsed_fields = _ParseIssueRequestFields(post_data)
+ # TODO(jrobbins): change from numbered fields to a multi-valued field.
+ attachments = _ParseIssueRequestAttachments(post_data)
+ kept_attachments = _ParseIssueRequestKeptAttachments(post_data)
+ parsed_users = _ParseIssueRequestUsers(cnxn, post_data, services)
+ parsed_blocked_on = _ParseBlockers(
+ cnxn, post_data, services, errors, default_project_name, BLOCKED_ON)
+ parsed_blocking = _ParseBlockers(
+ cnxn, post_data, services, errors, default_project_name, BLOCKING)
+ parsed_hotlists = _ParseHotlists(post_data)
+
+ parsed_issue = ParsedIssue(
+ summary, comment, is_description, status, parsed_users, labels,
+ labels_remove, parsed_components, parsed_fields, template_name,
+ attachments, kept_attachments, parsed_blocked_on, parsed_blocking,
+ parsed_hotlists)
+ return parsed_issue
+
+
+def MarkupDescriptionOnInput(content, tmpl_text):
+ """Return HTML for the content of an issue description or comment.
+
+ Args:
+ content: the text sumbitted by the user, any user-entered markup
+ has already been escaped.
+ tmpl_text: the initial text that was put into the textarea.
+
+ Returns:
+ The description content text with template lines highlighted.
+ """
+ tmpl_lines = tmpl_text.split('\n')
+ tmpl_lines = [pl.strip() for pl in tmpl_lines if pl.strip()]
+
+ entered_lines = content.split('\n')
+ marked_lines = [_MarkupDescriptionLineOnInput(line, tmpl_lines)
+ for line in entered_lines]
+ return '\n'.join(marked_lines)
+
+
+def _MarkupDescriptionLineOnInput(line, tmpl_lines):
+ """Markup one line of an issue description that was just entered.
+
+ Args:
+ line: string containing one line of the user-entered comment.
+ tmpl_lines: list of strings for the text of the template lines.
+
+ Returns:
+ The same user-entered line, or that line highlighted to
+ indicate that it came from the issue template.
+ """
+ for tmpl_line in tmpl_lines:
+ if line.startswith(tmpl_line):
+ return '<b>' + tmpl_line + '</b>' + line[len(tmpl_line):]
+
+ return line
+
+
+def _ClassifyPlusMinusItems(add_remove_list):
+ """Classify the given plus-or-minus items into add and remove lists."""
+ add_remove_set = {s.strip() for s in add_remove_list}
+ add_strs = [s for s in add_remove_set if s and not s.startswith('-')]
+ remove_strs = [s[1:] for s in add_remove_set if s[1:] and s.startswith('-')]
+ return add_strs, remove_strs
+
+
+def _ParseHotlists(post_data):
+ entered_str = post_data.get('hotlists', '').strip()
+ hotlist_refs = []
+ for ref_str in re.split('[,;\s]+', entered_str):
+ if not ref_str:
+ continue
+ if ':' in ref_str:
+ if ref_str.split(':')[0]:
+ # E-mail isn't empty; full reference.
+ hotlist_refs.append(ParsedHotlistRef(*ref_str.split(':', 1)))
+ else:
+ # Short reference.
+ hotlist_refs.append(ParsedHotlistRef(None, ref_str.split(':', 1)[1]))
+ else:
+ # Short reference
+ hotlist_refs.append(ParsedHotlistRef(None, ref_str))
+ parsed_hotlists = ParsedHotlists(entered_str, hotlist_refs)
+ return parsed_hotlists
+
+
+def _ParseIssueRequestFields(post_data):
+ """Iterate over post_data and return custom field values found in it."""
+ field_val_strs = {}
+ field_val_strs_remove = {}
+ phase_field_val_strs = collections.defaultdict(dict)
+ phase_field_val_strs_remove = collections.defaultdict(dict)
+ for key in post_data.keys():
+ if key.startswith(_CUSTOM_FIELD_NAME_PREFIX):
+ val_strs = [v for v in post_data.getall(key) if v]
+ if val_strs:
+ try:
+ field_id = int(key[len(_CUSTOM_FIELD_NAME_PREFIX):])
+ phase_name = None
+ except ValueError: # key must be in format <field_id>_<phase_name>
+ field_id, phase_name = key[len(_CUSTOM_FIELD_NAME_PREFIX):].split(
+ '_', 1)
+ field_id = int(field_id)
+ if post_data.get('op_' + key) == 'remove':
+ if phase_name:
+ phase_field_val_strs_remove[field_id][phase_name] = val_strs
+ else:
+ field_val_strs_remove[field_id] = val_strs
+ else:
+ if phase_name:
+ phase_field_val_strs[field_id][phase_name] = val_strs
+ else:
+ field_val_strs[field_id] = val_strs
+
+ # TODO(jojwang): monorail:5154, no support for clearing phase field values.
+ fields_clear = []
+ op_prefix = 'op_' + _CUSTOM_FIELD_NAME_PREFIX
+ for op_key in post_data.keys():
+ if op_key.startswith(op_prefix):
+ if post_data.get(op_key) == 'clear':
+ field_id = int(op_key[len(op_prefix):])
+ fields_clear.append(field_id)
+
+ return ParsedFields(
+ field_val_strs, field_val_strs_remove, fields_clear,
+ phase_field_val_strs, phase_field_val_strs_remove)
+
+
+def _ParseIssueRequestAttachments(post_data):
+ """Extract and clean-up any attached files from the post data.
+
+ Args:
+ post_data: dict w/ values from the user's HTTP POST form data.
+
+ Returns:
+ [(filename, filecontents, mimetype), ...] with items for each attachment.
+ """
+ # TODO(jrobbins): change from numbered fields to a multi-valued field.
+ attachments = []
+ for i in range(1, 16):
+ if 'file%s' % i in post_data:
+ item = post_data['file%s' % i]
+ if isinstance(item, string_types):
+ continue
+ if '\\' in item.filename: # IE insists on giving us the whole path.
+ item.filename = item.filename[item.filename.rindex('\\') + 1:]
+ if not item.filename:
+ continue # Skip any FILE fields that were not filled in.
+ attachments.append((
+ item.filename, item.value,
+ filecontent.GuessContentTypeFromFilename(item.filename)))
+
+ return attachments
+
+
+def _ParseIssueRequestKeptAttachments(post_data):
+ """Extract attachment ids for attachments kept when updating description
+
+ Args:
+ post_data: dict w/ values from the user's HTTP POST form data.
+
+ Returns:
+ a list of attachment ids for kept attachments
+ """
+ kept_attachments = post_data.getall('keep-attachment')
+ return [int(aid) for aid in kept_attachments]
+
+
+def _ParseIssueRequestUsers(cnxn, post_data, services):
+ """Extract usernames from the POST data, categorize them, and look up IDs.
+
+ Args:
+ cnxn: connection to SQL database.
+ post_data: dict w/ data from the HTTP POST.
+ services: Services.
+
+ Returns:
+ A namedtuple (owner_username, owner_id, cc_usernames, cc_usernames_remove,
+ cc_ids, cc_ids_remove), containing:
+ - issue owner's name and user ID, if any
+ - the list of all cc'd usernames
+ - the user IDs to add or remove from the issue CC list.
+ Any of these user IDs may be None if the corresponding username
+ or email address is invalid.
+ """
+ # Get the user-entered values from post_data.
+ cc_username_str = post_data.get('cc', '').lower()
+ owner_email = post_data.get('owner', '').strip().lower()
+
+ cc_usernames, cc_usernames_remove = _ClassifyPlusMinusItems(
+ re.split('[,;\s]+', cc_username_str))
+
+ # Figure out the email addresses to lookup and do the lookup.
+ emails_to_lookup = cc_usernames + cc_usernames_remove
+ if owner_email:
+ emails_to_lookup.append(owner_email)
+ all_user_ids = services.user.LookupUserIDs(
+ cnxn, emails_to_lookup, autocreate=True)
+ if owner_email:
+ owner_id = all_user_ids.get(owner_email)
+ else:
+ owner_id = framework_constants.NO_USER_SPECIFIED
+
+ # Lookup the user IDs of the Cc addresses to add or remove.
+ cc_ids = [all_user_ids.get(cc) for cc in cc_usernames if cc]
+ cc_ids_remove = [all_user_ids.get(cc) for cc in cc_usernames_remove if cc]
+
+ return ParsedUsers(owner_email, owner_id, cc_usernames, cc_usernames_remove,
+ cc_ids, cc_ids_remove)
+
+
+def _ParseBlockers(cnxn, post_data, services, errors, default_project_name,
+ field_name):
+ """Parse input for issues that the current issue is blocking/blocked on.
+
+ Args:
+ cnxn: connection to SQL database.
+ post_data: dict w/ values from the user's HTTP POST.
+ services: connections to backend services.
+ errors: object to accumulate validation error info.
+ default_project_name: name of the project that contains the issue.
+ field_name: string HTML input field name, e.g., BLOCKED_ON or BLOCKING.
+
+ Returns:
+ A namedtuple with the user input string, and a list of issue IDs.
+ """
+ entered_str = post_data.get(field_name, '').strip()
+ blocker_iids = []
+ dangling_ref_tuples = []
+ federated_ref_strings = []
+
+ issue_ref = None
+ for ref_str in re.split('[,;\s]+', entered_str):
+ # Handle federated references.
+ if federated.IsShortlinkValid(ref_str):
+ federated_ref_strings.append(ref_str)
+ continue
+
+ try:
+ issue_ref = tracker_bizobj.ParseIssueRef(ref_str)
+ except ValueError:
+ setattr(errors, field_name, 'Invalid issue ID %s' % ref_str.strip())
+ break
+
+ if not issue_ref:
+ continue
+
+ blocker_project_name, blocker_issue_id = issue_ref
+ if not blocker_project_name:
+ blocker_project_name = default_project_name
+
+ # Detect and report if the same issue was specified.
+ current_issue_id = int(post_data.get('id')) if post_data.get('id') else -1
+ if (blocker_issue_id == current_issue_id and
+ blocker_project_name == default_project_name):
+ setattr(errors, field_name, 'Cannot be %s the same issue' % field_name)
+ break
+
+ ref_projects = services.project.GetProjectsByName(
+ cnxn, set([blocker_project_name]))
+ blocker_iid, _misses = services.issue.ResolveIssueRefs(
+ cnxn, ref_projects, default_project_name, [issue_ref])
+ if not blocker_iid:
+ if blocker_project_name in settings.recognized_codesite_projects:
+ # We didn't find the issue, but it had a explicitly-specified project
+ # which we know is on Codesite. Allow it as a dangling reference.
+ dangling_ref_tuples.append(issue_ref)
+ continue
+ else:
+ # Otherwise, it doesn't exist, so report it.
+ setattr(errors, field_name, 'Invalid issue ID %s' % ref_str.strip())
+ break
+ if blocker_iid[0] not in blocker_iids:
+ blocker_iids.extend(blocker_iid)
+
+ blocker_iids.sort()
+ dangling_ref_tuples.sort()
+ return ParsedBlockers(entered_str, blocker_iids, dangling_ref_tuples,
+ federated_ref_strings)
+
+
+def PairDerivedValuesWithRuleExplanations(
+ proposed_issue, traces, derived_users_by_id):
+ """Pair up values and explanations into JSON objects."""
+ derived_labels_and_why = [
+ {'value': lab,
+ 'why': traces.get((tracker_pb2.FieldID.LABELS, lab))}
+ for lab in proposed_issue.derived_labels]
+
+ derived_users_by_id = {
+ user_id: user_view.display_name
+ for user_id, user_view in derived_users_by_id.items()
+ if user_view.display_name}
+
+ derived_owner_and_why = []
+ if proposed_issue.derived_owner_id:
+ derived_owner_and_why = [{
+ 'value': derived_users_by_id[proposed_issue.derived_owner_id],
+ 'why': traces.get(
+ (tracker_pb2.FieldID.OWNER, proposed_issue.derived_owner_id))}]
+ derived_cc_and_why = [
+ {'value': derived_users_by_id[cc_id],
+ 'why': traces.get((tracker_pb2.FieldID.CC, cc_id))}
+ for cc_id in proposed_issue.derived_cc_ids
+ if cc_id in derived_users_by_id]
+
+ warnings_and_why = [
+ {'value': warning,
+ 'why': traces.get((tracker_pb2.FieldID.WARNING, warning))}
+ for warning in proposed_issue.derived_warnings]
+
+ errors_and_why = [
+ {'value': error,
+ 'why': traces.get((tracker_pb2.FieldID.ERROR, error))}
+ for error in proposed_issue.derived_errors]
+
+ return (derived_labels_and_why, derived_owner_and_why, derived_cc_and_why,
+ warnings_and_why, errors_and_why)
+
+
+def IsValidIssueOwner(cnxn, project, owner_id, services):
+ """Return True if the given user ID can be an issue owner.
+
+ Args:
+ cnxn: connection to SQL database.
+ project: the current Project PB.
+ owner_id: the user ID of the proposed issue owner.
+ services: connections to backends.
+
+ It is OK to have 0 for the owner_id, that simply means that the issue is
+ unassigned.
+
+ Returns:
+ A pair (valid, err_msg). valid is True if the given user ID can be an
+ issue owner. err_msg is an error message string to display to the user
+ if valid == False, and is None if valid == True.
+ """
+ # An issue is always allowed to have no owner specified.
+ if owner_id == framework_constants.NO_USER_SPECIFIED:
+ return True, None
+
+ try:
+ auth = authdata.AuthData.FromUserID(cnxn, owner_id, services)
+ if not framework_bizobj.UserIsInProject(project, auth.effective_ids):
+ return False, 'Issue owner must be a project member.'
+ except exceptions.NoSuchUserException:
+ return False, 'Issue owner user ID not found.'
+
+ group_ids = services.usergroup.DetermineWhichUserIDsAreGroups(
+ cnxn, [owner_id])
+ if owner_id in group_ids:
+ return False, 'Issue owner cannot be a user group.'
+
+ return True, None
+
+
+def GetAllowedOpenedAndClosedIssues(mr, issue_ids, services):
+ """Get filtered lists of open and closed issues identified by issue_ids.
+
+ The function then filters the results to only the issues that the user
+ is allowed to view. E.g., we only auto-link to issues that the user
+ would be able to view if they clicked the link.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ issue_ids: list of int issue IDs for the target issues.
+ services: connection to issue, config, and project persistence layers.
+
+ Returns:
+ Two lists of issues that the user is allowed to view: one for open
+ issues and one for closed issues.
+ """
+ open_issues, closed_issues = services.issue.GetOpenAndClosedIssues(
+ mr.cnxn, issue_ids)
+ return GetAllowedIssues(mr, [open_issues, closed_issues], services)
+
+
+def GetAllowedIssues(mr, issue_groups, services):
+ """Filter lists of issues identified by issue_groups.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ issue_groups: list of list of issues to filter.
+ services: connection to issue, config, and project persistence layers.
+
+ Returns:
+ List of filtered list of issues.
+ """
+
+ project_dict = GetAllIssueProjects(
+ mr.cnxn, itertools.chain.from_iterable(issue_groups), services.project)
+ config_dict = services.config.GetProjectConfigs(mr.cnxn,
+ list(project_dict.keys()))
+ return [FilterOutNonViewableIssues(
+ mr.auth.effective_ids, mr.auth.user_pb, project_dict, config_dict,
+ issues)
+ for issues in issue_groups]
+
+
+def MakeViewsForUsersInIssues(cnxn, issue_list, user_service, omit_ids=None):
+ """Lookup all the users involved in any of the given issues.
+
+ Args:
+ cnxn: connection to SQL database.
+ issue_list: list of Issue PBs from a result query.
+ user_service: Connection to User backend storage.
+ omit_ids: a list of user_ids to omit, e.g., because we already have them.
+
+ Returns:
+ A dictionary {user_id: user_view,...} for all the users involved
+ in the given issues.
+ """
+ issue_participant_id_set = tracker_bizobj.UsersInvolvedInIssues(issue_list)
+ if omit_ids:
+ issue_participant_id_set.difference_update(omit_ids)
+
+ # TODO(jrobbins): consider caching View objects as well.
+ users_by_id = framework_views.MakeAllUserViews(
+ cnxn, user_service, issue_participant_id_set)
+
+ return users_by_id
+
+
+def FormatIssueListURL(
+ mr, config, absolute=True, project_names=None, **kwargs):
+ """Format a link back to list view as configured by user."""
+ if project_names is None:
+ project_names = [mr.project_name]
+ if tracker_constants.JUMP_RE.match(mr.query):
+ kwargs['q'] = 'id=%s' % mr.query
+ kwargs['can'] = 1 # The specified issue might be closed.
+ else:
+ kwargs['q'] = mr.query
+ if mr.can and mr.can != 2:
+ kwargs['can'] = mr.can
+ def_col_spec = config.default_col_spec
+ if mr.col_spec and mr.col_spec != def_col_spec:
+ kwargs['colspec'] = mr.col_spec
+ if mr.sort_spec:
+ kwargs['sort'] = mr.sort_spec
+ if mr.group_by_spec:
+ kwargs['groupby'] = mr.group_by_spec
+ if mr.start:
+ kwargs['start'] = mr.start
+ if mr.num != tracker_constants.DEFAULT_RESULTS_PER_PAGE:
+ kwargs['num'] = mr.num
+
+ if len(project_names) == 1:
+ url = '/p/%s%s' % (project_names[0], urls.ISSUE_LIST)
+ else:
+ url = urls.ISSUE_LIST
+ kwargs['projects'] = ','.join(sorted(project_names))
+
+ param_strings = ['%s=%s' % (k, urllib.quote((u'%s' % v).encode('utf-8')))
+ for k, v in kwargs.items()]
+ if param_strings:
+ url += '?' + '&'.join(sorted(param_strings))
+ if absolute:
+ url = '%s://%s%s' % (mr.request.scheme, mr.request.host, url)
+
+ return url
+
+
+def FormatRelativeIssueURL(project_name, path, **kwargs):
+ """Format a URL to get to an issue in the named project.
+
+ Args:
+ project_name: string name of the project containing the issue.
+ path: string servlet path, e.g., from framework/urls.py.
+ **kwargs: additional query-string parameters to include in the URL.
+
+ Returns:
+ A URL string.
+ """
+ return framework_helpers.FormatURL(
+ None, '/p/%s%s' % (project_name, path), **kwargs)
+
+
+def FormatCrBugURL(project_name, local_id):
+ """Format a short URL to get to an issue in the named project.
+
+ Args:
+ project_name: string name of the project containing the issue.
+ local_id: int local ID of the issue.
+
+ Returns:
+ A URL string.
+ """
+ if app_identity.get_application_id() != 'monorail-prod':
+ return FormatRelativeIssueURL(
+ project_name, urls.ISSUE_DETAIL, id=local_id)
+
+ if project_name == 'chromium':
+ return 'https://crbug.com/%d' % local_id
+
+ return 'https://crbug.com/%s/%d' % (project_name, local_id)
+
+
+def ComputeNewQuotaBytesUsed(project, attachments):
+ """Add the given attachments to the project's attachment quota usage.
+
+ Args:
+ project: Project PB for the project being updated.
+ attachments: a list of attachments being added to an issue.
+
+ Returns:
+ The new number of bytes used.
+
+ Raises:
+ OverAttachmentQuota: If project would go over quota.
+ """
+ total_attach_size = 0
+ for _filename, content, _mimetype in attachments:
+ total_attach_size += len(content)
+
+ new_bytes_used = project.attachment_bytes_used + total_attach_size
+ quota = (project.attachment_quota or
+ tracker_constants.ISSUE_ATTACHMENTS_QUOTA_HARD)
+ if new_bytes_used > quota:
+ raise exceptions.OverAttachmentQuota(new_bytes_used - quota)
+ return new_bytes_used
+
+
+def IsUnderSoftAttachmentQuota(project):
+ """Check the project's attachment quota against the soft quota limit.
+
+ If there is a custom quota on the project, this will check against
+ that instead of the system-wide default quota.
+
+ Args:
+ project: Project PB for the project to examine
+
+ Returns:
+ True if the project is under quota, false otherwise.
+ """
+ quota = tracker_constants.ISSUE_ATTACHMENTS_QUOTA_SOFT
+ if project.attachment_quota:
+ quota = project.attachment_quota - _SOFT_QUOTA_LEEWAY
+
+ return project.attachment_bytes_used < quota
+
+
+def GetAllIssueProjects(cnxn, issues, project_service):
+ """Get all the projects that the given issues belong to.
+
+ Args:
+ cnxn: connection to SQL database.
+ issues: list of issues, which may come from different projects.
+ project_service: connection to project persistence layer.
+
+ Returns:
+ A dictionary {project_id: project} of all the projects that
+ any of the given issues belongs to.
+ """
+ needed_project_ids = {issue.project_id for issue in issues}
+ project_dict = project_service.GetProjects(cnxn, needed_project_ids)
+ return project_dict
+
+
+def GetPermissionsInAllProjects(user, effective_ids, projects):
+ """Look up the permissions for the given user in each project."""
+ return {
+ project.project_id:
+ permissions.GetPermissions(user, effective_ids, project)
+ for project in projects}
+
+
+def FilterOutNonViewableIssues(
+ effective_ids, user, project_dict, config_dict, issues):
+ """Return a filtered list of issues that the user can view."""
+ perms_dict = GetPermissionsInAllProjects(
+ user, effective_ids, list(project_dict.values()))
+
+ denied_project_ids = {
+ pid for pid, p in project_dict.items()
+ if not permissions.CanView(effective_ids, perms_dict[pid], p, [])}
+
+ results = []
+ for issue in issues:
+ if issue.deleted or issue.project_id in denied_project_ids:
+ continue
+
+ if not permissions.HasRestrictions(issue):
+ may_view = True
+ else:
+ perms = perms_dict[issue.project_id]
+ project = project_dict[issue.project_id]
+ config = config_dict.get(issue.project_id, config_dict.get('harmonized'))
+ granted_perms = tracker_bizobj.GetGrantedPerms(
+ issue, effective_ids, config)
+ may_view = permissions.CanViewIssue(
+ effective_ids, perms, project, issue, granted_perms=granted_perms)
+
+ if may_view:
+ results.append(issue)
+
+ return results
+
+
+def MeansOpenInProject(status, config):
+ """Return true if this status means that the issue is still open.
+
+ This defaults to true if we could not find a matching status.
+
+ Args:
+ status: issue status string. E.g., 'New'.
+ config: the config of the current project.
+
+ Returns:
+ Boolean True if the status means that the issue is open.
+ """
+ status_lower = status.lower()
+
+ # iterate over the list of known statuses for this project
+ # return true if we find a match that declares itself to be open
+ for wks in config.well_known_statuses:
+ if wks.status.lower() == status_lower:
+ return wks.means_open
+
+ return True
+
+
+def IsNoisy(num_comments, num_starrers):
+ """Return True if this is a "noisy" issue that would send a ton of emails.
+
+ The rule is that a very active issue with a large number of comments
+ and starrers will only send notification when a comment (or change)
+ is made by a project member.
+
+ Args:
+ num_comments: int number of comments on issue so far.
+ num_starrers: int number of users who starred the issue.
+
+ Returns:
+ True if we will not bother starrers with an email notification for
+ changes made by non-members.
+ """
+ return (num_comments >= tracker_constants.NOISY_ISSUE_COMMENT_COUNT and
+ num_starrers >= tracker_constants.NOISY_ISSUE_STARRER_COUNT)
+
+
+def MergeCCsAndAddComment(services, mr, issue, merge_into_issue):
+ """Modify the CC field of the target issue and add a comment to it."""
+ return MergeCCsAndAddCommentMultipleIssues(
+ services, mr, [issue], merge_into_issue)
+
+
+def MergeCCsAndAddCommentMultipleIssues(
+ services, mr, issues, merge_into_issue):
+ """Modify the CC field of the target issue and add a comment to it."""
+ merge_comment = ''
+ for issue in issues:
+ if issue.project_name == merge_into_issue.project_name:
+ issue_ref_str = '%d' % issue.local_id
+ else:
+ issue_ref_str = '%s:%d' % (issue.project_name, issue.local_id)
+ if merge_comment:
+ merge_comment += '\n'
+ merge_comment += 'Issue %s has been merged into this issue.' % issue_ref_str
+
+ add_cc = _ComputeNewCcsFromIssueMerge(merge_into_issue, issues)
+
+ config = services.config.GetProjectConfig(
+ mr.cnxn, merge_into_issue.project_id)
+ delta = tracker_pb2.IssueDelta(cc_ids_add=add_cc)
+ _, merge_comment_pb = services.issue.DeltaUpdateIssue(
+ mr.cnxn, services, mr.auth.user_id, merge_into_issue.project_id,
+ config, merge_into_issue, delta, index_now=False, comment=merge_comment)
+
+ return merge_comment_pb
+
+
+def GetAttachmentIfAllowed(mr, services):
+ """Retrieve the requested attachment, or raise an appropriate exception.
+
+ Args:
+ mr: commonly used info parsed from the request.
+ services: connections to backend services.
+
+ Returns:
+ The requested Attachment PB, and the Issue that it belongs to.
+
+ Raises:
+ NoSuchAttachmentException: attachment was not found or was marked deleted.
+ NoSuchIssueException: issue that contains attachment was not found.
+ PermissionException: the user is not allowed to view the attachment.
+ """
+ attachment = None
+
+ attachment, cid, issue_id = services.issue.GetAttachmentAndContext(
+ mr.cnxn, mr.aid)
+
+ issue = services.issue.GetIssue(mr.cnxn, issue_id)
+ config = services.config.GetProjectConfig(mr.cnxn, issue.project_id)
+ granted_perms = tracker_bizobj.GetGrantedPerms(
+ issue, mr.auth.effective_ids, config)
+ permit_view = permissions.CanViewIssue(
+ mr.auth.effective_ids, mr.perms, mr.project, issue,
+ granted_perms=granted_perms)
+ if not permit_view:
+ raise permissions.PermissionException('Cannot view attachment\'s issue')
+
+ comment = services.issue.GetComment(mr.cnxn, cid)
+ commenter = services.user.GetUser(mr.cnxn, comment.user_id)
+ issue_perms = permissions.UpdateIssuePermissions(
+ mr.perms, mr.project, issue, mr.auth.effective_ids,
+ granted_perms=granted_perms)
+ can_view_comment = permissions.CanViewComment(
+ comment, commenter, mr.auth.user_id, issue_perms)
+ if not can_view_comment:
+ raise permissions.PermissionException('Cannot view attachment\'s comment')
+
+ return attachment, issue
+
+
+def LabelsMaskedByFields(config, field_names, trim_prefix=False):
+ """Return a list of EZTItems for labels that would be masked by fields."""
+ return _LabelsMaskedOrNot(config, field_names, trim_prefix=trim_prefix)
+
+
+def LabelsNotMaskedByFields(config, field_names, trim_prefix=False):
+ """Return a list of EZTItems for labels that would not be masked."""
+ return _LabelsMaskedOrNot(
+ config, field_names, invert=True, trim_prefix=trim_prefix)
+
+
+def _LabelsMaskedOrNot(config, field_names, invert=False, trim_prefix=False):
+ """Return EZTItems for labels that'd be masked. Or not, when invert=True."""
+ field_names = [fn.lower() for fn in field_names]
+ result = []
+ for wkl in config.well_known_labels:
+ masked_by = tracker_bizobj.LabelIsMaskedByField(wkl.label, field_names)
+ if (masked_by and not invert) or (not masked_by and invert):
+ display_name = wkl.label
+ if trim_prefix:
+ display_name = display_name[len(masked_by) + 1:]
+ result.append(template_helpers.EZTItem(
+ name=display_name,
+ name_padded=display_name.ljust(20),
+ commented='#' if wkl.deprecated else '',
+ docstring=wkl.label_docstring,
+ docstring_short=template_helpers.FitUnsafeText(
+ wkl.label_docstring, 40),
+ idx=len(result)))
+
+ return result
+
+
+def LookupComponentIDs(component_paths, config, errors=None):
+ """Look up the IDs of the specified components in the given config."""
+ component_ids = []
+ for path in component_paths:
+ if not path:
+ continue
+ cd = tracker_bizobj.FindComponentDef(path, config)
+ if cd:
+ component_ids.append(cd.component_id)
+ else:
+ error_text = 'Unknown component %s' % path
+ if errors:
+ errors.components = error_text
+ else:
+ logging.info(error_text)
+
+ return component_ids
+
+
+def ParsePostDataUsers(cnxn, pd_users_str, user_service):
+ """Parse all the usernames from a users string found in a post data."""
+ emails, _remove = _ClassifyPlusMinusItems(re.split('[,;\s]+', pd_users_str))
+ users_ids_by_email = user_service.LookupUserIDs(cnxn, emails, autocreate=True)
+ user_ids = [users_ids_by_email[username] for username in emails if username]
+ return user_ids, pd_users_str
+
+
+def FilterIssueTypes(config):
+ """Return a list of well-known issue types."""
+ well_known_issue_types = []
+ for wk_label in config.well_known_labels:
+ if wk_label.label.lower().startswith('type-'):
+ _, type_name = wk_label.label.split('-', 1)
+ well_known_issue_types.append(type_name)
+
+ return well_known_issue_types
+
+
+def ParseMergeFields(
+ cnxn, services, project_name, post_data, status, config, issue, errors):
+ """Parse info that identifies the issue to merge into, if any."""
+ merge_into_text = ''
+ merge_into_ref = None
+ merge_into_issue = None
+
+ if status not in config.statuses_offer_merge:
+ return '', None
+
+ merge_into_text = post_data.get('merge_into', '')
+ if merge_into_text:
+ try:
+ merge_into_ref = tracker_bizobj.ParseIssueRef(merge_into_text)
+ except ValueError:
+ logging.info('merge_into not an int: %r', merge_into_text)
+ errors.merge_into_id = 'Please enter a valid issue ID'
+
+ if not merge_into_ref:
+ errors.merge_into_id = 'Please enter an issue ID'
+ return merge_into_text, None
+
+ merge_into_project_name, merge_into_id = merge_into_ref
+ if (merge_into_id == issue.local_id and
+ (merge_into_project_name == project_name or
+ not merge_into_project_name)):
+ logging.info('user tried to merge issue into itself: %r', merge_into_ref)
+ errors.merge_into_id = 'Cannot merge issue into itself'
+ return merge_into_text, None
+
+ project = services.project.GetProjectByName(
+ cnxn, merge_into_project_name or project_name)
+ try:
+ # Because we will modify this issue, load from DB rather than cache.
+ merge_into_issue = services.issue.GetIssueByLocalID(
+ cnxn, project.project_id, merge_into_id, use_cache=False)
+ except Exception:
+ logging.info('merge_into issue not found: %r', merge_into_ref)
+ errors.merge_into_id = 'No such issue'
+ return merge_into_text, None
+
+ return merge_into_text, merge_into_issue
+
+
+def GetNewIssueStarrers(cnxn, services, issue_ids, merge_into_iid):
+ # type: (MonorailConnection, Services, Sequence[int], int) ->
+ # Collection[int]
+ """Get starrers of current issue who have not starred the target issue."""
+ source_starrers_dict = services.issue_star.LookupItemsStarrers(
+ cnxn, issue_ids)
+ source_starrers = list(
+ itertools.chain.from_iterable(source_starrers_dict.values()))
+ target_starrers = services.issue_star.LookupItemStarrers(
+ cnxn, merge_into_iid)
+ return set(source_starrers) - set(target_starrers)
+
+
+def AddIssueStarrers(
+ cnxn, services, mr, merge_into_iid, merge_into_project, new_starrers):
+ """Merge all the starrers for the current issue into the target issue."""
+ project = merge_into_project or mr.project
+ config = services.config.GetProjectConfig(mr.cnxn, project.project_id)
+ services.issue_star.SetStarsBatch(
+ cnxn, services, config, merge_into_iid, new_starrers, True)
+
+
+def IsMergeAllowed(merge_into_issue, mr, services):
+ """Check to see if user has permission to merge with specified issue."""
+ merge_into_project = services.project.GetProjectByName(
+ mr.cnxn, merge_into_issue.project_name)
+ merge_into_config = services.config.GetProjectConfig(
+ mr.cnxn, merge_into_project.project_id)
+ merge_granted_perms = tracker_bizobj.GetGrantedPerms(
+ merge_into_issue, mr.auth.effective_ids, merge_into_config)
+
+ merge_view_allowed = mr.perms.CanUsePerm(
+ permissions.VIEW, mr.auth.effective_ids,
+ merge_into_project, permissions.GetRestrictions(merge_into_issue),
+ granted_perms=merge_granted_perms)
+ merge_edit_allowed = mr.perms.CanUsePerm(
+ permissions.EDIT_ISSUE, mr.auth.effective_ids,
+ merge_into_project, permissions.GetRestrictions(merge_into_issue),
+ granted_perms=merge_granted_perms)
+
+ return merge_view_allowed and merge_edit_allowed
+
+
+def GetVisibleMembers(mr, project, services):
+ all_member_ids = project_helpers.AllProjectMembers(project)
+
+ all_group_ids = services.usergroup.DetermineWhichUserIDsAreGroups(
+ mr.cnxn, all_member_ids)
+
+ (ac_exclusion_ids, no_expand_ids
+ ) = services.project.GetProjectAutocompleteExclusion(
+ mr.cnxn, project.project_id)
+
+ group_ids_to_expand = [
+ gid for gid in all_group_ids if gid not in no_expand_ids]
+
+ # TODO(jrobbins): Normally, users will be allowed view the members
+ # of any user group if the project From: email address is listed
+ # as a group member, as well as any group that they are personally
+ # members of.
+ member_ids, owner_ids = services.usergroup.LookupVisibleMembers(
+ mr.cnxn, group_ids_to_expand, mr.perms, mr.auth.effective_ids, services)
+ indirect_user_ids = set()
+ for gids in member_ids.values():
+ indirect_user_ids.update(gids)
+ for gids in owner_ids.values():
+ indirect_user_ids.update(gids)
+
+ visible_member_ids = _FilterMemberData(
+ mr, project.owner_ids, project.committer_ids, project.contributor_ids,
+ indirect_user_ids, project)
+
+ visible_member_ids = _MergeLinkedMembers(
+ mr.cnxn, services.user, visible_member_ids)
+
+ visible_member_views = framework_views.MakeAllUserViews(
+ mr.cnxn, services.user, visible_member_ids, group_ids=all_group_ids)
+ framework_views.RevealAllEmailsToMembers(
+ mr.cnxn, services, mr.auth, visible_member_views, project)
+
+ # Filter out service accounts
+ service_acct_emails = set(
+ client_config_svc.GetClientConfigSvc().GetClientIDEmails()[1])
+ visible_member_views = {
+ m.user_id: m
+ for m in visible_member_views.values()
+ # Hide service accounts from autocomplete.
+ if not framework_helpers.IsServiceAccount(
+ m.email, client_emails=service_acct_emails)
+ # Hide users who opted out of autocomplete.
+ and not m.user_id in ac_exclusion_ids
+ # Hide users who have obscured email addresses.
+ and not m.obscure_email
+ }
+
+ return visible_member_views
+
+
+def _MergeLinkedMembers(cnxn, user_service, user_ids):
+ """Remove any linked child accounts if the parent would also be shown."""
+ all_ids = set(user_ids)
+ users_by_id = user_service.GetUsersByIDs(cnxn, user_ids)
+ result = [uid for uid in user_ids
+ if users_by_id[uid].linked_parent_id not in all_ids]
+ return result
+
+
+def _FilterMemberData(
+ mr, owner_ids, committer_ids, contributor_ids, indirect_member_ids,
+ project):
+ """Return a filtered list of members that the user can view.
+
+ In most projects, everyone can view the entire member list. But,
+ some projects are configured to only allow project owners to see
+ all members. In those projects, committers and contributors do not
+ see any contributors. Regardless of how the project is configured
+ or the role that the user plays in the current project, we include
+ any indirect members through user groups that the user has access
+ to view.
+
+ Args:
+ mr: Commonly used info parsed from the HTTP request.
+ owner_views: list of user IDs for project owners.
+ committer_views: list of user IDs for project committers.
+ contributor_views: list of user IDs for project contributors.
+ indirect_member_views: list of user IDs for users who have
+ an indirect role in the project via a user group, and that the
+ logged in user is allowed to see.
+ project: the Project we're interested in.
+
+ Returns:
+ A list of owners, committer and visible indirect members if the user is not
+ signed in. If the project is set to display contributors to non-owners or
+ the signed in user has necessary permissions then additionally a list of
+ contributors.
+ """
+ visible_members_ids = set()
+
+ # Everyone can view owners and committers
+ visible_members_ids.update(owner_ids)
+ visible_members_ids.update(committer_ids)
+
+ # The list of indirect members is already limited to ones that the user
+ # is allowed to see according to user group settings.
+ visible_members_ids.update(indirect_member_ids)
+
+ # If the user is allowed to view the list of contributors, add those too.
+ if permissions.CanViewContributorList(mr, project):
+ visible_members_ids.update(contributor_ids)
+
+ return sorted(visible_members_ids)
+
+
+def GetLabelOptions(config, custom_permissions):
+ """Prepares label options for autocomplete."""
+ labels = []
+ field_names = [
+ fd.field_name
+ for fd in config.field_defs
+ if not fd.is_deleted
+ and fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE
+ ]
+ non_masked_labels = LabelsNotMaskedByFields(config, field_names)
+ for wkl in non_masked_labels:
+ if not wkl.commented:
+ item = {'name': wkl.name, 'doc': wkl.docstring}
+ labels.append(item)
+
+ frequent_restrictions = _FREQUENT_ISSUE_RESTRICTIONS[:]
+ if not custom_permissions:
+ frequent_restrictions.extend(_EXAMPLE_ISSUE_RESTRICTIONS)
+
+ labels.extend(_BuildRestrictionChoices(
+ frequent_restrictions, permissions.STANDARD_ISSUE_PERMISSIONS,
+ custom_permissions))
+
+ return labels
+
+
+def _BuildRestrictionChoices(freq_restrictions, actions, custom_permissions):
+ """Return a list of autocompletion choices for restriction labels.
+
+ Args:
+ freq_restrictions: list of (action, perm, doc) tuples for restrictions
+ that are frequently used.
+ actions: list of strings for actions that are relevant to the current
+ artifact.
+ custom_permissions: list of strings with custom permissions for the project.
+
+ Returns:
+ A list of dictionaries [{'name': 'perm name', 'doc': 'docstring'}, ...]
+ suitable for use in a JSON feed to our JS autocompletion functions.
+ """
+ choices = []
+
+ for action, perm, doc in freq_restrictions:
+ choices.append({
+ 'name': 'Restrict-%s-%s' % (action, perm),
+ 'doc': doc,
+ })
+
+ for action in actions:
+ for perm in custom_permissions:
+ choices.append({
+ 'name': 'Restrict-%s-%s' % (action, perm),
+ 'doc': 'Permission %s needed to use %s' % (perm, action),
+ })
+
+ return choices
+
+
+def FilterKeptAttachments(
+ is_description, kept_attachments, comments, approval_id):
+ """Filter kept attachments to be a subset of last description's attachments.
+
+ Args:
+ is_description: bool, if the comment is a change to the issue description.
+ kept_attachments: list of ints with the attachment ids for attachments
+ kept from previous descriptions, if the comment is a change to the
+ issue description.
+ comments: list of IssueComment PBs for the issue we want to edit.
+ approval_id: int id of the APPROVAL_TYPE fielddef, if we're editing an
+ approval description, or None otherwise.
+
+ Returns:
+ A list of kept_attachment ids that are a subset of the last description.
+ """
+ if not is_description:
+ return None
+
+ attachment_ids = set()
+ for comment in reversed(comments):
+ if comment.is_description and comment.approval_id == approval_id:
+ attachment_ids = set([a.attachment_id for a in comment.attachments])
+ break
+
+ kept_attachments = [
+ aid for aid in kept_attachments if aid in attachment_ids]
+ return kept_attachments
+
+
+def _GetEnumFieldValuesAndDocstrings(field_def, config):
+ # type: (proto.tracker_pb2.LabelDef, proto.tracker_pb2.ProjectIssueConfig) ->
+ # Sequence[tuple(string, string)]
+ """Get sequence of value, docstring tuples for an enum field"""
+ label_defs = config.well_known_labels
+ lower_field_name = field_def.field_name.lower()
+ tuples = []
+ for ld in label_defs:
+ if (ld.label.lower().startswith(lower_field_name + '-') and
+ not ld.deprecated):
+ label_value = ld.label[len(lower_field_name) + 1:]
+ tuples.append((label_value, ld.label_docstring))
+ else:
+ continue
+ return tuples
+
+
+# _IssueChangesTuple is returned by ApplyAllIssueChanges() and is used to bundle
+# the updated issues. resulting amendments, and other information needed by the
+# called to process the changes in the DB and send notifications.
+_IssueChangesTuple = collections.namedtuple(
+ '_IssueChangesTuple', [
+ 'issues_to_update_dict', 'merged_from_add_by_iid', 'amendments_by_iid',
+ 'imp_amendments_by_iid', 'old_owners_by_iid', 'old_statuses_by_iid',
+ 'old_components_by_iid', 'new_starrers_by_iid'
+ ])
+# type: (Mapping[int, Issue], DefaultDict[int, Sequence[int]],
+# Mapping[int, Amendment], Mapping[int, Amendment], Mapping[int, int],
+# Mapping[int, str], Mapping[int, Sequence[int]],
+# Mapping[int, Sequence[int]])-> None
+
+
+def ApplyAllIssueChanges(cnxn, issue_delta_pairs, services):
+ # type: (MonorailConnection, Sequence[Tuple[Issue, IssueDelta]], Services) ->
+ # IssueChangesTuple
+ """Modify the given issues with the given deltas and impacted issues in RAM.
+
+ Filter rules are not applied in this method.
+ This method implements phases 3 and 4 of the process for modifying issues.
+ See WorkEnv.ModifyIssues() for other phases and overall process.
+
+ Args:
+ cnxn: MonorailConnection object.
+ issue_delta_pairs: List of tuples that couple Issues with the IssueDeltas
+ that represent the updates we want to make to each Issue.
+ services: Services object for connection to backend services.
+
+ Returns:
+ An _IssueChangesTuple named tuple.
+ """
+ impacted_tracker = _IssueChangeImpactedIssues()
+ project_ids = {issue.project_id for issue, _delta in issue_delta_pairs}
+ configs_by_pid = services.config.GetProjectConfigs(cnxn, list(project_ids))
+
+ # Track issues which have been modified in RAM and will need to
+ # be updated in the DB.
+ issues_to_update_dict = {}
+
+ amendments_by_iid = {}
+ old_owners_by_iid = {}
+ old_statuses_by_iid = {}
+ old_components_by_iid = {}
+ # PHASE 3: Update the main issues in RAM (not indirectly, impacted issues).
+ for issue, delta in issue_delta_pairs:
+ # Cache old data that will be used by future computations.
+ old_owner = tracker_bizobj.GetOwnerId(issue)
+ old_status = tracker_bizobj.GetStatus(issue)
+ if delta.owner_id is not None and delta.owner_id != old_owner:
+ old_owners_by_iid[issue.issue_id] = old_owner
+ if delta.status is not None and delta.status != old_status:
+ old_statuses_by_iid[issue.issue_id] = old_status
+ new_components = set(issue.component_ids)
+ new_components.update(delta.comp_ids_add or [])
+ new_components.difference_update(delta.comp_ids_remove or [])
+ if set(issue.component_ids) != new_components:
+ old_components_by_iid[issue.issue_id] = issue.component_ids
+
+ impacted_tracker.TrackImpactedIssues(issue, delta)
+ config = configs_by_pid.get(issue.project_id)
+ amendments, _impacted_iids = tracker_bizobj.ApplyIssueDelta(
+ cnxn, services.issue, issue, delta, config)
+ if amendments:
+ issues_to_update_dict[issue.issue_id] = issue
+ amendments_by_iid[issue.issue_id] = amendments
+
+ # PHASE 4: Update impacted issues in RAM.
+ logging.info('Applying impacted issue changes: %r', impacted_tracker.__dict__)
+ imp_amendments_by_iid = {}
+ impacted_iids = impacted_tracker.ComputeAllImpactedIIDs()
+ new_starrers_by_iid = {}
+ for issue_id in impacted_iids:
+ # Changes made to an impacted issue should be on top of changes
+ # made to it in PHASE 3 where it might have been a 'main' issue.
+ issue = issues_to_update_dict.get(
+ issue_id, services.issue.GetIssue(cnxn, issue_id, use_cache=False))
+
+ # Apply impacted changes.
+ amendments, new_starrers = impacted_tracker.ApplyImpactedIssueChanges(
+ cnxn, issue, services)
+ if amendments:
+ imp_amendments_by_iid[issue.issue_id] = amendments
+ issues_to_update_dict[issue.issue_id] = issue
+ if new_starrers:
+ new_starrers_by_iid[issue.issue_id] = new_starrers
+
+ return _IssueChangesTuple(
+ issues_to_update_dict, impacted_tracker.merged_from_add,
+ amendments_by_iid, imp_amendments_by_iid, old_owners_by_iid,
+ old_statuses_by_iid, old_components_by_iid, new_starrers_by_iid)
+
+
+def UpdateClosedTimestamp(config, issue, old_effective_status):
+ # type: (proto.tracker_pb2.ProjectIssueConfig, proto.tracker_pb2.Issue, str)
+ # -> None
+ """Sets or unsets the closed_timestamp based based on status changes.
+
+ If the status is changing from open to closed, the closed_timestamp is set to
+ the current time.
+
+ If the status is changing form closed to open, the close_timestamp is unset.
+
+ If the status is changing from one closed to another closed, or from one
+ open to another open, no operations are performed.
+
+ Args:
+ config: the project configuration
+ issue: the issue being updated (a protocol buffer)
+ old_effective_status: the old issue status string. E.g., 'New'
+
+ SIDE EFFECTS:
+ Updated issue in place with new closed timestamp.
+ """
+ old_effective_status = old_effective_status or ''
+ # open -> closed
+ if (MeansOpenInProject(old_effective_status, config) and
+ not MeansOpenInProject(tracker_bizobj.GetStatus(issue), config)):
+
+ issue.closed_timestamp = int(time.time())
+ return
+
+ # closed -> open
+ if (not MeansOpenInProject(old_effective_status, config) and
+ MeansOpenInProject(tracker_bizobj.GetStatus(issue), config)):
+
+ issue.reset('closed_timestamp')
+ return
+
+
+def GroupUniqueDeltaIssues(issue_delta_pairs):
+ # type: (Tuple[Issue, IssueDelta]) -> (
+ # Sequence[IssueDelta], Sequence[Sequence[Issue]])
+ """Identifies unique IssueDeltas and groups Issues with identical IssueDeltas.
+
+ Args:
+ issue_delta_pairs: List of tuples that couple Issues with the IssueDeltas
+ that represent the updates we want to make to each Issue.
+
+ Returns:
+ (unique_deltas, issues_for_unique_deltas):
+ unique_deltas: List of unique IssueDeltas found in issue_delta_pairs.
+ issues_for_unique_deltas: List of Issue lists. Each Issue list
+ contains all the Issues that had identical IssueDeltas.
+ Each issues_for_unique_deltas[i] is the list of Issues
+ that had unique_deltas[i] as their IssueDeltas.
+ """
+ unique_deltas = []
+ issues_for_unique_deltas = []
+ for issue, delta in issue_delta_pairs:
+ try:
+ delta_index = unique_deltas.index(delta)
+ issues_for_unique_deltas[delta_index].append(issue)
+ except ValueError:
+ # delta is not in unique_deltas yet.
+ # Add delta to unique_deltas and add a new list of issues
+ # to issues_for_unique_deltas at the same index.
+ unique_deltas.append(delta)
+ issues_for_unique_deltas.append([issue])
+
+ return unique_deltas, issues_for_unique_deltas
+
+
+def _AssertNoConflictingDeltas(issue_delta_pairs, refs_dict, err_agg):
+ # type: (Sequence[Tuple[Issue, IssueDelta]], Mapping[int, str],
+ # exceptions.ErrorAggregator) -> None
+ """Checks if any issue deltas conflict with each other or themselves.
+
+ Note: refs_dict should contain issue ref strings for all issues found
+ in issue_delta_pairs, including all issues found in
+ {blocked_on|blocking}_{add|remove}.
+ """
+ err_message = 'Changes for {} conflict for {}'
+
+ # Track all delta blocked_on_add and blocking_add in terms of
+ # 'blocking_add' so we can track when a {blocked_on|blocking}_remove
+ # is in conflict with some {blocked_on|blocking}_add.
+ blocking_add = collections.defaultdict(list)
+ for issue, delta in issue_delta_pairs:
+ blocking_add[issue.issue_id].extend(delta.blocking_add)
+
+ for imp_iid in delta.blocked_on_add:
+ blocking_add[imp_iid].append(issue.issue_id)
+
+ # Check *_remove for conflicts with tracking blocking_add.
+ for issue, delta in issue_delta_pairs:
+ added_iids = blocking_add[issue.issue_id]
+ # Get intersection of iids that are in `blocking_remove` and
+ # the tracked `blocking_add`.
+ conflict_iids = set(delta.blocking_remove) & set(added_iids)
+
+ # Get iids of `blocked_on_remove` that conflict with the
+ # tracked `blocking_add`.
+ for possible_conflict_iid in delta.blocked_on_remove:
+ if issue.issue_id in blocking_add[possible_conflict_iid]:
+ conflict_iids.add(possible_conflict_iid)
+
+ if conflict_iids:
+ refs_str = ', '.join([refs_dict[iid] for iid in conflict_iids])
+ err_agg.AddErrorMessage(err_message, refs_dict[issue.issue_id], refs_str)
+
+
+def PrepareIssueChanges(
+ cnxn,
+ issue_delta_pairs,
+ services,
+ attachment_uploads=None,
+ comment_content=None):
+ # type: (MonorailConnection, Sequence[Tuple[Issue, IssueDelta]], Services,
+ # Optional[Sequence[framework_helpers.AttachmentUpload]], Optional[str])
+ # -> Mapping[int, int]
+ """Clean the deltas and assert they are valid for each paired issue."""
+ _EnforceNonMergeStatusDeltas(cnxn, issue_delta_pairs, services)
+ _AssertIssueChangesValid(
+ cnxn, issue_delta_pairs, services, comment_content=comment_content)
+
+ if attachment_uploads:
+ return _EnforceAttachmentQuotaLimits(
+ cnxn, issue_delta_pairs, services, attachment_uploads)
+ return {}
+
+
+def _EnforceAttachmentQuotaLimits(
+ cnxn, issue_delta_pairs, services, attachment_uploads):
+ # type: (MonorailConnection, Sequence[Tuple[Issue, IssueDelta]], Services
+ # Optional[Sequence[framework_helpers.AttachmentUpload]]
+ # -> Mapping[int, int]
+ """Assert that the attachments don't exceed project quotas."""
+ issue_count_by_pid = collections.defaultdict(int)
+ for issue, _delta in issue_delta_pairs:
+ issue_count_by_pid[issue.project_id] += 1
+
+ projects_by_id = services.project.GetProjects(cnxn, issue_count_by_pid.keys())
+
+ new_bytes_by_pid = {}
+ with exceptions.ErrorAggregator(exceptions.OverAttachmentQuota) as err_agg:
+ for pid, count in issue_count_by_pid.items():
+ project = projects_by_id[pid]
+ try:
+ new_bytes_used = ComputeNewQuotaBytesUsed(
+ project, attachment_uploads * count)
+ new_bytes_by_pid[pid] = new_bytes_used
+ except exceptions.OverAttachmentQuota:
+ err_agg.AddErrorMessage(
+ 'Attachment quota exceeded for project {}', project.project_name)
+ return new_bytes_by_pid
+
+
+def _AssertIssueChangesValid(
+ cnxn, issue_delta_pairs, services, comment_content=None):
+ # type: (MonorailConnection, Sequence[Tuple[Issue, IssueDelta]], Services,
+ # Optional[str]) -> None
+ """Assert that the delta changes are valid for each paired issue.
+
+ Note: this method does not check if the changes trigger any FilterRule
+ `warnings` or `errors`.
+ """
+ project_ids = list(
+ {issue.project_id for (issue, _delta) in issue_delta_pairs})
+ projects_by_id = services.project.GetProjects(cnxn, project_ids)
+ configs_by_id = services.config.GetProjectConfigs(cnxn, project_ids)
+ refs_dict = {
+ iss.issue_id: '%s:%d' % (iss.project_name, iss.local_id)
+ for iss, _delta in issue_delta_pairs
+ }
+ # Add refs of deltas' blocking/blocked_on issues needed by
+ # _AssertNoConflictingDeltas.
+ relation_iids = set()
+ for _iss, delta in issue_delta_pairs:
+ relation_iids.update(
+ delta.blocked_on_remove + delta.blocking_remove + delta.blocked_on_add +
+ delta.blocking_add)
+ relation_issues_dict, misses = services.issue.GetIssuesDict(
+ cnxn, relation_iids)
+ if misses:
+ raise exceptions.NoSuchIssueException(
+ 'Could not find issues with ids: %r' % misses)
+ for iid, iss in relation_issues_dict.items():
+ if iid not in refs_dict:
+ refs_dict[iid] = '%s:%d' % (iss.project_name, iss.local_id)
+
+ with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+ if (comment_content and
+ len(comment_content.strip()) > tracker_constants.MAX_COMMENT_CHARS):
+ err_agg.AddErrorMessage('Comment is too long.')
+
+ _AssertNoConflictingDeltas(issue_delta_pairs, refs_dict, err_agg)
+
+ for issue, delta in issue_delta_pairs:
+ project = projects_by_id.get(issue.project_id)
+ config = configs_by_id.get(issue.project_id)
+ issue_ref = refs_dict[issue.issue_id]
+
+ if (delta.merged_into is not None or
+ delta.merged_into_external is not None or delta.status is not None):
+ end_status = delta.status or issue.status
+ merged_options = [
+ delta.merged_into, delta.merged_into_external, issue.merged_into,
+ issue.merged_into_external
+ ]
+ end_merged_into = next(
+ (merge for merge in merged_options if merge is not None), None)
+
+ is_merge_status = end_status.lower() in [
+ status.lower() for status in config.statuses_offer_merge
+ ]
+
+ if ((is_merge_status and not end_merged_into) or
+ (not is_merge_status and end_merged_into)):
+ err_agg.AddErrorMessage(
+ '{}: MERGED type statuses must accompany mergedInto values.',
+ issue_ref)
+
+ if delta.merged_into and issue.issue_id == delta.merged_into:
+ err_agg.AddErrorMessage(
+ '{}: Cannot merge an issue into itself.', issue_ref)
+ if (issue.issue_id in set(
+ delta.blocked_on_add)) or (issue.issue_id in set(delta.blocking_add)):
+ err_agg.AddErrorMessage(
+ '{}: Cannot block an issue on itself.', issue_ref)
+ if (delta.owner_id is not None) and (delta.owner_id != issue.owner_id):
+ parsed_owner_valid, msg = IsValidIssueOwner(
+ cnxn, project, delta.owner_id, services)
+ if not parsed_owner_valid:
+ err_agg.AddErrorMessage('{}: {}', issue_ref, msg)
+ # Owner already check by IsValidIssueOwner
+ all_users = [uid for uid in delta.cc_ids_add]
+ field_users = [fv.user_id for fv in delta.field_vals_add if fv.user_id]
+ all_users.extend(field_users)
+ AssertUsersExist(cnxn, services, all_users, err_agg)
+ if (delta.summary and
+ len(delta.summary.strip()) > tracker_constants.MAX_SUMMARY_CHARS):
+ err_agg.AddErrorMessage('{}: Summary is too long.', issue_ref)
+ if delta.summary == '':
+ err_agg.AddErrorMessage('{}: Summary required.', issue_ref)
+ if delta.status == '':
+ err_agg.AddErrorMessage('{}: Status is required.', issue_ref)
+ # Do not pass in issue for validation, as issue is pre-update, and would
+ # result in being unable to edit issues in invalid states.
+ fvs_err_msgs = field_helpers.ValidateCustomFields(
+ cnxn, services, delta.field_vals_add, config, project)
+ if fvs_err_msgs:
+ err_agg.AddErrorMessage('{}: {}', issue_ref, '\n'.join(fvs_err_msgs))
+ # TODO(crbug.com/monorail/9156): Validate that we do not remove fields
+ # such that a required field becomes unset.
+
+
+def AssertUsersExist(cnxn, services, user_ids, err_agg):
+ # type: (MonorailConnection, Services, Sequence[int], ErrorAggregator) -> None
+ """Assert that all users exist.
+
+ Has the side-effect of adding error messages to the input ErrorAggregator.
+ """
+ users_dict = services.user.GetUsersByIDs(cnxn, user_ids, skip_missed=True)
+ found_ids = set(users_dict.keys())
+ missing = [user_id for user_id in user_ids if user_id not in found_ids]
+ for missing_user_id in missing:
+ err_agg.AddErrorMessage(
+ 'users/{}: User does not exist.'.format(missing_user_id))
+
+
+def AssertValidIssueForCreate(cnxn, services, issue, description):
+ # type: (MonorailConnection, Services, Issue, str) -> None
+ """Assert that issue proto is valid for issue creation.
+
+ Args:
+ cnxn: A connection object to use services with.
+ services: An object containing services to use to look up relevant data.
+ issues: A PB containing the issue to validate.
+ description: The description for the issue.
+
+ Raises:
+ InputException if the issue is not valid.
+ """
+ project = services.project.GetProject(cnxn, issue.project_id)
+ config = services.config.GetProjectConfig(cnxn, issue.project_id)
+
+ with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+ owner_is_valid, owner_err_msg = IsValidIssueOwner(
+ cnxn, project, issue.owner_id, services)
+ if not owner_is_valid:
+ err_agg.AddErrorMessage(owner_err_msg)
+ if not issue.summary.strip():
+ err_agg.AddErrorMessage('Summary is required')
+ if not description.strip():
+ err_agg.AddErrorMessage('Description is required')
+ if len(issue.summary) > tracker_constants.MAX_SUMMARY_CHARS:
+ err_agg.AddErrorMessage('Summary is too long')
+ if len(description) > tracker_constants.MAX_COMMENT_CHARS:
+ err_agg.AddErrorMessage('Description is too long')
+
+ # Check all users exist. Owner already check by IsValidIssueOwner.
+ all_users = [uid for uid in issue.cc_ids]
+ for av in issue.approval_values:
+ all_users.extend(av.approver_ids)
+ field_users = [fv.user_id for fv in issue.field_values if fv.user_id]
+ all_users.extend(field_users)
+ AssertUsersExist(cnxn, services, all_users, err_agg)
+
+ field_validity_errors = field_helpers.ValidateCustomFields(
+ cnxn, services, issue.field_values, config, project, issue=issue)
+ if field_validity_errors:
+ err_agg.AddErrorMessage("\n".join(field_validity_errors))
+ if not services.config.LookupStatusID(cnxn, issue.project_id, issue.status,
+ autocreate=False):
+ err_agg.AddErrorMessage('Undefined status: %s' % issue.status)
+ all_comp_ids = {
+ cd.component_id for cd in config.component_defs if not cd.deprecated
+ }
+ for comp_id in issue.component_ids:
+ if comp_id not in all_comp_ids:
+ err_agg.AddErrorMessage(
+ 'Undefined or deprecated component with id: %d' % comp_id)
+
+
+def _ComputeNewCcsFromIssueMerge(merge_into_issue, source_issues):
+ # type: (Issue, Collection[Issue]) -> Collection[int]
+ """Compute ccs that should be added from source_issues to merge_into_issue."""
+
+ merge_into_restrictions = permissions.GetRestrictions(merge_into_issue)
+ new_cc_ids = set()
+ for issue in source_issues:
+ # We don't want to leak metadata like ccs of restricted issues.
+ # So we don't merge ccs from restricted source issues, unless their
+ # restrictions match the restrictions of the target.
+ if permissions.HasRestrictions(issue, perm='View'):
+ source_restrictions = permissions.GetRestrictions(issue)
+ if (issue.project_id != merge_into_issue.project_id or
+ set(source_restrictions) != set(merge_into_restrictions)):
+ continue
+
+ new_cc_ids.update(issue.cc_ids)
+ if issue.owner_id:
+ new_cc_ids.add(issue.owner_id)
+
+ return [cc_id for cc_id in new_cc_ids if cc_id not in merge_into_issue.cc_ids]
+
+
+def _EnforceNonMergeStatusDeltas(cnxn, issue_delta_pairs, services):
+ # type: (MonorailConnection, Sequence[Tuple[Issue, IssueDelta]], Services)
+ """Update deltas in RAM to remove merged if a MERGED status is removed."""
+ project_ids = list(
+ {issue.project_id for (issue, _delta) in issue_delta_pairs})
+ configs_by_id = services.config.GetProjectConfigs(cnxn, project_ids)
+ statuses_offer_merge_by_pid = {
+ pid:
+ [status.lower() for status in configs_by_id[pid].statuses_offer_merge]
+ for pid in project_ids
+ }
+
+ for issue, delta in issue_delta_pairs:
+ statuses_offer_merge = statuses_offer_merge_by_pid[issue.project_id]
+ # Remove merged_into and merged_into_external when a status is moved
+ # to a non-MERGED status ONLY if the delta does not have merged_into values
+ # If delta does change merged_into values, the request will fail from
+ # AssertIssueChangesValue().
+ if (delta.status and delta.status.lower() not in statuses_offer_merge and
+ delta.merged_into is None and delta.merged_into_external is None):
+ if issue.merged_into:
+ delta.merged_into = 0
+ elif issue.merged_into_external:
+ delta.merged_into_external = ''
+
+
+class _IssueChangeImpactedIssues():
+ """Class to track changes of issues impacted by updates to other issues."""
+
+ def __init__(self):
+
+ # Each of the dicts below should be used to track
+ # {impacted_issue_id: [issues being modified that impact the keyed issue]}.
+
+ # e.g. `blocking_remove` with {iid_1: [iid_2, iid_3]} means that
+ # `TrackImpactedIssues` has been called with a delta of
+ # IssueDelta(blocked_on_remove=[iid_1]) for both issue 2 and issue 3.
+ self.blocking_add = collections.defaultdict(list)
+ self.blocking_remove = collections.defaultdict(list)
+ self.blocked_on_add = collections.defaultdict(list)
+ self.blocked_on_remove = collections.defaultdict(list)
+ self.merged_from_add = collections.defaultdict(list)
+ self.merged_from_remove = collections.defaultdict(list)
+
+ def ComputeAllImpactedIIDs(self):
+ # type: () -> Collection[int]
+ """Computes the unique set of all impacted issue ids."""
+ return set(self.blocking_add.keys() + self.blocking_remove.keys() +
+ self.blocked_on_add.keys() + self.blocked_on_remove.keys() +
+ self.merged_from_add.keys() + self.merged_from_remove.keys())
+
+ def TrackImpactedIssues(self, issue, delta):
+ # type: (Issue, IssueDelta) -> None
+ """Track impacted issues from when `delta` is applied to `issue`.
+
+ Args:
+ issue: Issue that the delta will be applied to, but has not yet.
+ delta: IssueDelta representing the changes that will be made to
+ the issue.
+ """
+ for impacted_iid in delta.blocked_on_add:
+ self.blocking_add[impacted_iid].append(issue.issue_id)
+ for impacted_iid in delta.blocked_on_remove:
+ self.blocking_remove[impacted_iid].append(issue.issue_id)
+
+ for impacted_iid in delta.blocking_add:
+ self.blocked_on_add[impacted_iid].append(issue.issue_id)
+ for impacted_iid in delta.blocking_remove:
+ self.blocked_on_remove[impacted_iid].append(issue.issue_id)
+
+ if (delta.merged_into == framework_constants.NO_ISSUE_SPECIFIED and
+ issue.merged_into):
+ self.merged_from_remove[issue.merged_into].append(issue.issue_id)
+ elif delta.merged_into and issue.merged_into != delta.merged_into:
+ self.merged_from_add[delta.merged_into].append(issue.issue_id)
+ if issue.merged_into:
+ self.merged_from_remove[issue.merged_into].append(issue.issue_id)
+
+ def ApplyImpactedIssueChanges(self, cnxn, impacted_issue, services):
+ # type: (MonorailConnection, Issue, Services) ->
+ # Tuple[Collection[Amendment], Sequence[int]]
+ """Apply the tracked changes in RAM for the given impacted issue.
+
+ Args:
+ cnxn: connection to SQL database.
+ impacted_issue: Issue PB that we are applying the changes to.
+ services: Services used to fetch info from DB or cache.
+
+ Returns:
+ All the amendments that represent the changes applied to the issue
+ and a list of the new issue starrers.
+
+ Side-effect:
+ The given impacted_issue will be updated in RAM.
+ """
+ issue_id = impacted_issue.issue_id
+
+ # Process changes for blocking/blocked_on issue changes.
+ amendments, _impacted_iids = tracker_bizobj.ApplyIssueBlockRelationChanges(
+ cnxn, impacted_issue, self.blocked_on_add[issue_id],
+ self.blocked_on_remove[issue_id], self.blocking_add[issue_id],
+ self.blocking_remove[issue_id], services.issue)
+
+ # Process changes in merged issues.
+ merged_from_add = self.merged_from_add.get(issue_id, [])
+ merged_from_remove = self.merged_from_remove.get(issue_id, [])
+
+ # Merge ccs into impacted_issue from all merged issues,
+ # compute new starrers, and set star_count.
+ new_starrers = []
+ if merged_from_add:
+ issues_dict, _misses = services.issue.GetIssuesDict(cnxn, merged_from_add)
+ merged_from_add_issues = issues_dict.values()
+ new_cc_ids = _ComputeNewCcsFromIssueMerge(
+ impacted_issue, merged_from_add_issues)
+ if new_cc_ids:
+ impacted_issue.cc_ids.extend(new_cc_ids)
+ amendments.append(
+ tracker_bizobj.MakeCcAmendment(new_cc_ids, []))
+ new_starrers = list(
+ GetNewIssueStarrers(cnxn, services, merged_from_add, issue_id))
+ if new_starrers:
+ impacted_issue.star_count += len(new_starrers)
+
+ if merged_from_add or merged_from_remove:
+ merged_from_add_refs = services.issue.LookupIssueRefs(
+ cnxn, merged_from_add).values()
+ merged_from_remove_refs = services.issue.LookupIssueRefs(
+ cnxn, merged_from_remove).values()
+ amendments.append(
+ tracker_bizobj.MakeMergedIntoAmendment(
+ merged_from_add_refs, merged_from_remove_refs,
+ default_project_name=impacted_issue.project_name))
+ return amendments, new_starrers
diff --git a/tracker/tracker_views.py b/tracker/tracker_views.py
new file mode 100644
index 0000000..0c54555
--- /dev/null
+++ b/tracker/tracker_views.py
@@ -0,0 +1,871 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""View objects to help display tracker business objects in templates."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+import re
+import time
+import urllib
+
+from google.appengine.api import app_identity
+import ezt
+
+from features import federated
+from framework import exceptions
+from framework import filecontent
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import gcs_helpers
+from framework import permissions
+from framework import template_helpers
+from framework import timestr
+from framework import urls
+from proto import tracker_pb2
+from tracker import attachment_helpers
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+
+
+class IssueView(template_helpers.PBProxy):
+ """Wrapper class that makes it easier to display an Issue via EZT."""
+
+ def __init__(self, issue, users_by_id, config):
+ """Store relevant values for later display by EZT.
+
+ Args:
+ issue: An Issue protocol buffer.
+ users_by_id: dict {user_id: UserViews} for all users mentioned in issue.
+ config: ProjectIssueConfig for this issue.
+ """
+ super(IssueView, self).__init__(issue)
+
+ # The users involved in this issue must be present in users_by_id if
+ # this IssueView is to be used on the issue detail or peek pages. But,
+ # they can be absent from users_by_id if the IssueView is used as a
+ # tile in the grid view.
+ self.owner = users_by_id.get(issue.owner_id)
+ self.derived_owner = users_by_id.get(issue.derived_owner_id)
+ self.cc = [users_by_id.get(cc_id) for cc_id in issue.cc_ids
+ if cc_id]
+ self.derived_cc = [users_by_id.get(cc_id)
+ for cc_id in issue.derived_cc_ids
+ if cc_id]
+ self.status = framework_views.StatusView(issue.status, config)
+ self.derived_status = framework_views.StatusView(
+ issue.derived_status, config)
+ # If we don't have a config available, we don't need to access is_open, so
+ # let it be True.
+ self.is_open = ezt.boolean(
+ not config or
+ tracker_helpers.MeansOpenInProject(
+ tracker_bizobj.GetStatus(issue), config))
+
+ self.components = sorted(
+ [ComponentValueView(component_id, config, False)
+ for component_id in issue.component_ids
+ if tracker_bizobj.FindComponentDefByID(component_id, config)] +
+ [ComponentValueView(component_id, config, True)
+ for component_id in issue.derived_component_ids
+ if tracker_bizobj.FindComponentDefByID(component_id, config)],
+ key=lambda cvv: cvv.path)
+
+ self.fields = MakeAllFieldValueViews(
+ config, issue.labels, issue.derived_labels, issue.field_values,
+ users_by_id)
+
+ labels, derived_labels = tracker_bizobj.ExplicitAndDerivedNonMaskedLabels(
+ issue.labels, issue.derived_labels, config)
+ self.labels = [
+ framework_views.LabelView(label, config)
+ for label in labels]
+ self.derived_labels = [
+ framework_views.LabelView(label, config)
+ for label in derived_labels]
+ self.restrictions = _RestrictionsView(issue)
+
+ # TODO(jrobbins): sort by order of labels in project config
+
+ self.short_summary = issue.summary[:tracker_constants.SHORT_SUMMARY_LENGTH]
+
+ if issue.closed_timestamp:
+ self.closed = timestr.FormatAbsoluteDate(issue.closed_timestamp)
+ else:
+ self.closed = ''
+
+ self.blocked_on = []
+ self.has_dangling = ezt.boolean(self.dangling_blocked_on_refs)
+ self.blocking = []
+
+ self.detail_relative_url = tracker_helpers.FormatRelativeIssueURL(
+ issue.project_name, urls.ISSUE_DETAIL, id=issue.local_id)
+ self.crbug_url = tracker_helpers.FormatCrBugURL(
+ issue.project_name, issue.local_id)
+
+
+class _RestrictionsView(object):
+ """An EZT object for the restrictions associated with an issue."""
+
+ # Restrict label fragments that correspond to known permissions.
+ _VIEW = permissions.VIEW.lower()
+ _EDIT = permissions.EDIT_ISSUE.lower()
+ _ADD_COMMENT = permissions.ADD_ISSUE_COMMENT.lower()
+ _KNOWN_ACTION_KINDS = {_VIEW, _EDIT, _ADD_COMMENT}
+
+ def __init__(self, issue):
+ # List of restrictions that don't map to a known action kind.
+ self.other = []
+
+ restrictions_by_action = collections.defaultdict(list)
+ # We can't use GetRestrictions here, as we prefer to preserve
+ # the case of the label when showing restrictions in the UI.
+ for label in tracker_bizobj.GetLabels(issue):
+ if permissions.IsRestrictLabel(label):
+ _kw, action_kind, needed_perm = label.split('-', 2)
+ action_kind = action_kind.lower()
+ if action_kind in self._KNOWN_ACTION_KINDS:
+ restrictions_by_action[action_kind].append(needed_perm)
+ else:
+ self.other.append(label)
+
+ self.view = ' and '.join(restrictions_by_action[self._VIEW])
+ self.add_comment = ' and '.join(restrictions_by_action[self._ADD_COMMENT])
+ self.edit = ' and '.join(restrictions_by_action[self._EDIT])
+
+ self.has_restrictions = ezt.boolean(
+ self.view or self.add_comment or self.edit or self.other)
+
+
+class IssueCommentView(template_helpers.PBProxy):
+ """Wrapper class that makes it easier to display an IssueComment via EZT."""
+
+ def __init__(
+ self, project_name, comment_pb, users_by_id, autolink,
+ all_referenced_artifacts, mr, issue, effective_ids=None):
+ """Get IssueComment PB and make its fields available as attrs.
+
+ Args:
+ project_name: Name of the project this issue belongs to.
+ comment_pb: Comment protocol buffer.
+ users_by_id: dict mapping user_ids to UserViews, including
+ the user that entered the comment, and any changed participants.
+ autolink: utility object for automatically linking to other
+ issues, git revisions, etc.
+ all_referenced_artifacts: opaque object with details of referenced
+ artifacts that is needed by autolink.
+ mr: common information parsed from the HTTP request.
+ issue: Issue PB for the issue that this comment is part of.
+ effective_ids: optional set of int user IDs for the comment author.
+ """
+ super(IssueCommentView, self).__init__(comment_pb)
+
+ self.id = comment_pb.id
+ self.creator = users_by_id[comment_pb.user_id]
+
+ # TODO(jrobbins): this should be based on the issue project, not the
+ # request project for non-project views and cross-project.
+ if mr.project:
+ self.creator_role = framework_helpers.GetRoleName(
+ effective_ids or {self.creator.user_id}, mr.project)
+ else:
+ self.creator_role = None
+
+ time_tuple = time.localtime(comment_pb.timestamp)
+ self.date_string = timestr.FormatAbsoluteDate(
+ comment_pb.timestamp, old_format=timestr.MONTH_DAY_YEAR_FMT)
+ self.date_relative = timestr.FormatRelativeDate(comment_pb.timestamp)
+ self.date_tooltip = time.asctime(time_tuple)
+ self.date_yyyymmdd = timestr.FormatAbsoluteDate(
+ comment_pb.timestamp, recent_format=timestr.MONTH_DAY_YEAR_FMT,
+ old_format=timestr.MONTH_DAY_YEAR_FMT)
+ self.text_runs = _ParseTextRuns(comment_pb.content)
+ if autolink and not comment_pb.deleted_by:
+ self.text_runs = autolink.MarkupAutolinks(
+ mr, self.text_runs, all_referenced_artifacts)
+
+ self.attachments = [AttachmentView(attachment, project_name)
+ for attachment in comment_pb.attachments]
+ self.amendments = sorted([
+ AmendmentView(amendment, users_by_id, mr.project_name)
+ for amendment in comment_pb.amendments],
+ key=lambda amendment: amendment.field_name.lower())
+ # Treat comments from banned users as being deleted.
+ self.is_deleted = (comment_pb.deleted_by or
+ (self.creator and self.creator.banned))
+ self.can_delete = False
+
+ # TODO(jrobbins): pass through config to get granted permissions.
+ perms = permissions.UpdateIssuePermissions(
+ mr.perms, mr.project, issue, mr.auth.effective_ids)
+ if mr.auth.user_id and mr.project:
+ self.can_delete = permissions.CanDeleteComment(
+ comment_pb, self.creator, mr.auth.user_id, perms)
+
+ self.visible = permissions.CanViewComment(
+ comment_pb, self.creator, mr.auth.user_id, perms)
+
+
+_TEMPLATE_TEXT_RE = re.compile('^(<b>[^<]+</b>)', re.MULTILINE)
+
+
+def _ParseTextRuns(content):
+ """Convert the user's comment to a list of TextRun objects."""
+ chunks = _TEMPLATE_TEXT_RE.split(content.strip())
+ runs = [_ChunkToRun(chunk) for chunk in chunks]
+ return runs
+
+
+def _ChunkToRun(chunk):
+ """Convert a substring of the user's comment to a TextRun object."""
+ if chunk.startswith('<b>') and chunk.endswith('</b>'):
+ return template_helpers.TextRun(chunk[3:-4], tag='b')
+ else:
+ return template_helpers.TextRun(chunk)
+
+
+class LogoView(template_helpers.PBProxy):
+ """Wrapper class to make it easier to display project logos via EZT."""
+
+ def __init__(self, project_pb):
+ super(LogoView, self).__init__(None)
+ if (not project_pb or
+ not project_pb.logo_gcs_id or
+ not project_pb.logo_file_name):
+ self.thumbnail_url = ''
+ self.viewurl = ''
+ return
+
+ bucket_name = app_identity.get_default_gcs_bucket_name()
+ gcs_object = project_pb.logo_gcs_id
+ self.filename = project_pb.logo_file_name
+ self.mimetype = filecontent.GuessContentTypeFromFilename(self.filename)
+
+ self.thumbnail_url = gcs_helpers.SignUrl(bucket_name,
+ gcs_object + '-thumbnail')
+ self.viewurl = (
+ gcs_helpers.SignUrl(bucket_name, gcs_object) + '&' + urllib.urlencode(
+ {'response-content-displacement':
+ ('attachment; filename=%s' % self.filename)}))
+
+
+class AttachmentView(template_helpers.PBProxy):
+ """Wrapper class to make it easier to display issue attachments via EZT."""
+
+ def __init__(self, attach_pb, project_name):
+ """Get IssueAttachmentContent PB and make its fields available as attrs.
+
+ Args:
+ attach_pb: Attachment part of IssueComment protocol buffer.
+ project_name: string Name of the current project.
+ """
+ super(AttachmentView, self).__init__(attach_pb)
+ self.filesizestr = template_helpers.BytesKbOrMb(attach_pb.filesize)
+ self.downloadurl = attachment_helpers.GetDownloadURL(
+ attach_pb.attachment_id)
+ self.url = attachment_helpers.GetViewURL(
+ attach_pb, self.downloadurl, project_name)
+ self.thumbnail_url = attachment_helpers.GetThumbnailURL(
+ attach_pb, self.downloadurl)
+ self.video_url = attachment_helpers.GetVideoURL(
+ attach_pb, self.downloadurl)
+
+ self.iconurl = '/images/paperclip.png'
+
+
+class AmendmentView(object):
+ """Wrapper class that makes it easier to display an Amendment via EZT."""
+
+ def __init__(self, amendment, users_by_id, project_name):
+ """Get the info from the PB and put it into easily accessible attrs.
+
+ Args:
+ amendment: Amendment part of an IssueComment protocol buffer.
+ users_by_id: dict mapping user_ids to UserViews.
+ project_name: Name of the project the issue/comment/amendment is in.
+ """
+ # TODO(jrobbins): take field-level restrictions into account.
+ # Including the case where user is not allowed to see any amendments.
+ self.field_name = tracker_bizobj.GetAmendmentFieldName(amendment)
+ self.newvalue = tracker_bizobj.AmendmentString(amendment, users_by_id)
+ self.values = tracker_bizobj.AmendmentLinks(
+ amendment, users_by_id, project_name)
+
+
+class ComponentDefView(template_helpers.PBProxy):
+ """Wrapper class to make it easier to display component definitions."""
+
+ def __init__(self, cnxn, services, component_def, users_by_id):
+ super(ComponentDefView, self).__init__(component_def)
+
+ c_path = component_def.path
+ if '>' in c_path:
+ self.parent_path = c_path[:c_path.rindex('>')]
+ self.leaf_name = c_path[c_path.rindex('>') + 1:]
+ else:
+ self.parent_path = ''
+ self.leaf_name = c_path
+
+ self.docstring_short = template_helpers.FitUnsafeText(
+ component_def.docstring, 200)
+
+ self.admins = [users_by_id.get(admin_id)
+ for admin_id in component_def.admin_ids]
+ self.cc = [users_by_id.get(cc_id) for cc_id in component_def.cc_ids]
+ self.labels = [
+ services.config.LookupLabel(cnxn, component_def.project_id, label_id)
+ for label_id in component_def.label_ids]
+ self.classes = 'all '
+ if self.parent_path == '':
+ self.classes += 'toplevel '
+ self.classes += 'deprecated ' if component_def.deprecated else 'active '
+
+
+class ComponentValueView(object):
+ """Wrapper class that makes it easier to display a component value."""
+
+ def __init__(self, component_id, config, derived):
+ """Make the component name and docstring available as attrs.
+
+ Args:
+ component_id: int component_id to look up in the config
+ config: ProjectIssueConfig PB for the issue's project.
+ derived: True if this component was derived.
+ """
+ cd = tracker_bizobj.FindComponentDefByID(component_id, config)
+ self.path = cd.path
+ self.docstring = cd.docstring
+ self.docstring_short = template_helpers.FitUnsafeText(cd.docstring, 60)
+ self.derived = ezt.boolean(derived)
+
+
+class FieldValueView(object):
+ """Wrapper class that makes it easier to display a custom field value."""
+
+ def __init__(
+ self, fd, config, values, derived_values, issue_types, applicable=None,
+ phase_name=None):
+ """Make several values related to this field available as attrs.
+
+ Args:
+ fd: field definition to be displayed (or not, if no value).
+ config: ProjectIssueConfig PB for the issue's project.
+ values: list of explicit field values.
+ derived_values: list of derived field values.
+ issue_types: set of lowered string values from issues' "Type-*" labels.
+ applicable: optional boolean that overrides the rule that determines
+ when a field is applicable.
+ phase_name: name of the phase this field value belongs to.
+ """
+ self.field_def = FieldDefView(fd, config)
+ self.field_id = fd.field_id
+ self.field_name = fd.field_name
+ self.field_docstring = fd.docstring
+ self.field_docstring_short = template_helpers.FitUnsafeText(
+ fd.docstring, 60)
+ self.phase_name = phase_name or ""
+
+ self.values = values
+ self.derived_values = derived_values
+
+ self.applicable_type = fd.applicable_type
+ if applicable is not None:
+ self.applicable = ezt.boolean(applicable)
+ else:
+ # Note: We don't show approval types, approval sub fields, or
+ # phase fields in ezt issue pages.
+ if (fd.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE or
+ fd.approval_id or fd.is_phase_field):
+ self.applicable = ezt.boolean(False)
+ else:
+ # A field is applicable to a given issue if it (a) applies to all,
+ # issues or (b) already has a value on this issue, or (c) says that
+ # it applies to issues with this type (or a prefix of it).
+ applicable_type_lower = self.applicable_type.lower()
+ self.applicable = ezt.boolean(
+ not self.applicable_type or values or
+ any(type_label.startswith(applicable_type_lower)
+ for type_label in issue_types))
+ # TODO(jrobbins): also evaluate applicable_predicate
+
+ self.display = ezt.boolean( # or fd.show_empty
+ self.values or self.derived_values or
+ (self.applicable and not fd.is_niche))
+
+ #FieldValueView does not handle determining if it's editable
+ #by the logged-in user. This can be determined by using
+ #permission.CanEditValueForFieldDef.
+ self.is_editable = ezt.boolean(True)
+
+
+def _PrecomputeInfoForValueViews(labels, derived_labels, field_values, config,
+ phases):
+ """Organize issue values into datastructures used to make FieldValueViews."""
+ field_values_by_id = collections.defaultdict(list)
+ for fv in field_values:
+ field_values_by_id[fv.field_id].append(fv)
+ lower_enum_field_names = [
+ fd.field_name.lower() for fd in config.field_defs
+ if fd.field_type == tracker_pb2.FieldTypes.ENUM_TYPE]
+ labels_by_prefix = tracker_bizobj.LabelsByPrefix(
+ labels, lower_enum_field_names)
+ der_labels_by_prefix = tracker_bizobj.LabelsByPrefix(
+ derived_labels, lower_enum_field_names)
+ label_docs = {wkl.label.lower(): wkl.label_docstring
+ for wkl in config.well_known_labels}
+ phases_by_name = collections.defaultdict(list)
+ # group issue phases by name
+ for phase in phases:
+ phases_by_name[phase.name.lower()].append(phase)
+ return (labels_by_prefix, der_labels_by_prefix, field_values_by_id,
+ label_docs, phases_by_name)
+
+
+def MakeAllFieldValueViews(
+ config, labels, derived_labels, field_values, users_by_id,
+ parent_approval_ids=None, phases=None):
+ """Return a list of FieldValues, each containing values from the issue.
+ A phase field value view will be created for each unique phase name found
+ in the given list a phases. Phase field value views will not be created
+ if the phases list is empty.
+ """
+ parent_approval_ids = parent_approval_ids or []
+ precomp_view_info = _PrecomputeInfoForValueViews(
+ labels, derived_labels, field_values, config, phases or [])
+ def GetApplicable(fd):
+ if fd.approval_id and fd.approval_id in parent_approval_ids:
+ return True
+ return None
+ field_value_views = [
+ _MakeFieldValueView(fd, config, precomp_view_info, users_by_id,
+ applicable=GetApplicable(fd))
+ # TODO(jrobbins): field-level view restrictions, display options
+ for fd in config.field_defs
+ if not fd.is_deleted and not fd.is_phase_field]
+
+ # Make a phase field's view for each unique phase_name found in phases.
+ (_, _, _, _, phases_by_name) = precomp_view_info
+ for phase_name in phases_by_name.keys():
+ field_value_views.extend([
+ _MakeFieldValueView(
+ fd, config, precomp_view_info, users_by_id, phase_name=phase_name)
+ for fd in config.field_defs if fd.is_phase_field])
+
+ field_value_views = sorted(
+ field_value_views, key=lambda f: (f.applicable_type, f.field_name))
+ return field_value_views
+
+
+def _MakeFieldValueView(
+ fd, config, precomp_view_info, users_by_id, applicable=None,
+ phase_name=None):
+ """Return a FieldValueView with all values from the issue for that field."""
+ (labels_by_prefix, der_labels_by_prefix, field_values_by_id,
+ label_docs, phases_by_name) = precomp_view_info
+
+ field_name_lower = fd.field_name.lower()
+ values = []
+ derived_values = []
+
+ if fd.field_type == tracker_pb2.FieldTypes.ENUM_TYPE:
+ values = _ConvertLabelsToFieldValues(
+ labels_by_prefix.get(field_name_lower, []),
+ field_name_lower, label_docs)
+ derived_values = _ConvertLabelsToFieldValues(
+ der_labels_by_prefix.get(field_name_lower, []),
+ field_name_lower, label_docs)
+ else:
+ # Phases with the same name may have different phase_ids. Phases
+ # are defined during template creation and updating a template structure
+ # may result in new phase rows to be created while existing issues
+ # are referencing older phase rows.
+ phase_ids_for_phase_name = [
+ phase.phase_id for phase in phases_by_name.get(phase_name, [])]
+ # If a phase_name is given, we must filter field_values_by_id fvs to those
+ # that belong to the given phase. This is not done for labels
+ # because monorail does not support phase enum_type field values.
+ values = _MakeFieldValueItems(
+ [fv for fv in field_values_by_id.get(fd.field_id, [])
+ if not fv.derived and
+ (not phase_name or (fv.phase_id in phase_ids_for_phase_name))],
+ users_by_id)
+ derived_values = _MakeFieldValueItems(
+ [fv for fv in field_values_by_id.get(fd.field_id, [])
+ if fv.derived and
+ (not phase_name or (fv.phase_id in phase_ids_for_phase_name))],
+ users_by_id)
+
+ issue_types = (labels_by_prefix.get('type', []) +
+ der_labels_by_prefix.get('type', []))
+ issue_types_lower = [it.lower() for it in issue_types]
+
+ return FieldValueView(fd, config, values, derived_values, issue_types_lower,
+ applicable=applicable, phase_name=phase_name)
+
+
+def _MakeFieldValueItems(field_values, users_by_id):
+ """Make appropriate int, string, or user values in the given fields."""
+ result = []
+ for fv in field_values:
+ val = tracker_bizobj.GetFieldValue(fv, users_by_id)
+ result.append(template_helpers.EZTItem(
+ val=val, docstring=val, idx=len(result)))
+
+ return result
+
+
+def MakeBounceFieldValueViews(
+ field_vals, phase_field_vals, config, applicable_fields=None):
+ # type: (Sequence[proto.tracker_pb2.FieldValue],
+ # Sequence[proto.tracker_pb2.FieldValue],
+ # proto.tracker_pb2.ProjectIssueConfig
+ # Sequence[proto.tracker_pb2.FieldDef]) -> Sequence[FieldValueView]
+ """Return a list of field values to display on a validation bounce page."""
+ applicable_set = set()
+ # Handle required fields
+ if applicable_fields:
+ for fd in applicable_fields:
+ applicable_set.add(fd.field_id)
+
+ field_value_views = []
+ for fd in config.field_defs:
+ if fd.field_id in field_vals:
+ # TODO(jrobbins): also bounce derived values.
+ val_items = [
+ template_helpers.EZTItem(val=v, docstring='', idx=idx)
+ for idx, v in enumerate(field_vals[fd.field_id])]
+ field_value_views.append(FieldValueView(
+ fd, config, val_items, [], None, applicable=True))
+ elif fd.field_id in phase_field_vals:
+ vals_by_phase_name = phase_field_vals.get(fd.field_id)
+ for phase_name, values in vals_by_phase_name.items():
+ val_items = [
+ template_helpers.EZTItem(val=v, docstring='', idx=idx)
+ for idx, v in enumerate(values)]
+ field_value_views.append(FieldValueView(
+ fd, config, val_items, [], None, applicable=False,
+ phase_name=phase_name))
+ elif fd.is_required and fd.field_id in applicable_set:
+ # Show required fields that have no value set.
+ field_value_views.append(
+ FieldValueView(fd, config, [], [], None, applicable=True))
+
+ return field_value_views
+
+
+def _ConvertLabelsToFieldValues(label_values, field_name_lower, label_docs):
+ """Iterate through the given labels and pull out values for the field.
+
+ Args:
+ label_values: a list of label strings for the given field.
+ field_name_lower: lowercase string name of the custom field.
+ label_docs: {lower_label: docstring} for well-known labels in the project.
+
+ Returns:
+ A list of EZT items with val and docstring fields. One item is included
+ for each label that matches the given field name.
+ """
+ values = []
+ for idx, lab_val in enumerate(label_values):
+ full_label_lower = '%s-%s' % (field_name_lower, lab_val.lower())
+ values.append(template_helpers.EZTItem(
+ val=lab_val, docstring=label_docs.get(full_label_lower, ''), idx=idx))
+
+ return values
+
+
+class FieldDefView(template_helpers.PBProxy):
+ """Wrapper class to make it easier to display field definitions via EZT."""
+
+ def __init__(self, field_def, config, user_views=None, approval_def=None):
+ super(FieldDefView, self).__init__(field_def)
+
+ self.type_name = str(field_def.field_type)
+ self.field_def = field_def
+
+ self.choices = []
+ if field_def.field_type == tracker_pb2.FieldTypes.ENUM_TYPE:
+ self.choices = tracker_helpers.LabelsMaskedByFields(
+ config, [field_def.field_name], trim_prefix=True)
+
+ self.approvers = []
+ self.survey = ''
+ self.survey_questions = []
+ if (approval_def and
+ field_def.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE):
+ self.approvers = [user_views.get(approver_id) for
+ approver_id in approval_def.approver_ids]
+ if approval_def.survey:
+ self.survey = approval_def.survey
+ self.survey_questions = self.survey.split('\n')
+
+
+ self.docstring_short = template_helpers.FitUnsafeText(
+ field_def.docstring, 200)
+ self.validate_help = None
+
+ if field_def.is_required:
+ self.importance = 'required'
+ elif field_def.is_niche:
+ self.importance = 'niche'
+ else:
+ self.importance = 'normal'
+
+ if field_def.min_value is not None:
+ self.min_value = field_def.min_value
+ self.validate_help = 'Value must be >= %d' % field_def.min_value
+ else:
+ self.min_value = None # Otherwise it would default to 0
+
+ if field_def.max_value is not None:
+ self.max_value = field_def.max_value
+ self.validate_help = 'Value must be <= %d' % field_def.max_value
+ else:
+ self.max_value = None # Otherwise it would default to 0
+
+ if field_def.min_value is not None and field_def.max_value is not None:
+ self.validate_help = 'Value must be between %d and %d' % (
+ field_def.min_value, field_def.max_value)
+
+ if field_def.regex:
+ self.validate_help = 'Value must match regex: %s' % field_def.regex
+
+ if field_def.needs_member:
+ self.validate_help = 'Value must be a project member'
+
+ if field_def.needs_perm:
+ self.validate_help = (
+ 'Value must be a project member with permission %s' %
+ field_def.needs_perm)
+
+ self.date_action_str = str(field_def.date_action or 'no_action').lower()
+
+ self.admins = []
+ if user_views:
+ self.admins = [user_views.get(admin_id)
+ for admin_id in field_def.admin_ids]
+
+ self.editors = []
+ if user_views:
+ self.editors = [
+ user_views.get(editor_id) for editor_id in field_def.editor_ids
+ ]
+
+ if field_def.approval_id:
+ self.is_approval_subfield = ezt.boolean(True)
+ self.parent_approval_name = tracker_bizobj.FindFieldDefByID(
+ field_def.approval_id, config).field_name
+ else:
+ self.is_approval_subfield = ezt.boolean(False)
+
+ self.is_phase_field = ezt.boolean(field_def.is_phase_field)
+ self.is_restricted_field = ezt.boolean(field_def.is_restricted_field)
+
+
+class IssueTemplateView(template_helpers.PBProxy):
+ """Wrapper class to make it easier to display an issue template via EZT."""
+
+ def __init__(self, mr, template, user_service, config):
+ super(IssueTemplateView, self).__init__(template)
+
+ self.ownername = ''
+ try:
+ self.owner_view = framework_views.MakeUserView(
+ mr.cnxn, user_service, template.owner_id)
+ except exceptions.NoSuchUserException:
+ self.owner_view = None
+ if self.owner_view:
+ self.ownername = self.owner_view.email
+
+ self.admin_views = list(framework_views.MakeAllUserViews(
+ mr.cnxn, user_service, template.admin_ids).values())
+ self.admin_names = ', '.join(sorted([
+ admin_view.email for admin_view in self.admin_views]))
+
+ self.summary_must_be_edited = ezt.boolean(template.summary_must_be_edited)
+ self.members_only = ezt.boolean(template.members_only)
+ self.owner_defaults_to_member = ezt.boolean(
+ template.owner_defaults_to_member)
+ self.component_required = ezt.boolean(template.component_required)
+
+ component_paths = []
+ for component_id in template.component_ids:
+ component_paths.append(
+ tracker_bizobj.FindComponentDefByID(component_id, config).path)
+ self.components = ', '.join(component_paths)
+
+ self.can_view = ezt.boolean(permissions.CanViewTemplate(
+ mr.auth.effective_ids, mr.perms, mr.project, template))
+ self.can_edit = ezt.boolean(permissions.CanEditTemplate(
+ mr.auth.effective_ids, mr.perms, mr.project, template))
+
+ field_name_set = {fd.field_name.lower() for fd in config.field_defs
+ if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and
+ not fd.is_deleted} # TODO(jrobbins): restrictions
+ non_masked_labels = [
+ lab for lab in template.labels
+ if not tracker_bizobj.LabelIsMaskedByField(lab, field_name_set)]
+
+ for i, label in enumerate(non_masked_labels):
+ setattr(self, 'label%d' % i, label)
+ for i in range(len(non_masked_labels), framework_constants.MAX_LABELS):
+ setattr(self, 'label%d' % i, '')
+
+ field_user_views = MakeFieldUserViews(mr.cnxn, template, user_service)
+
+ self.field_values = []
+ for fv in template.field_values:
+ self.field_values.append(template_helpers.EZTItem(
+ field_id=fv.field_id,
+ val=tracker_bizobj.GetFieldValue(fv, field_user_views),
+ idx=len(self.field_values)))
+
+ self.complete_field_values = MakeAllFieldValueViews(
+ config, template.labels, [], template.field_values, field_user_views)
+
+ # Templates only display and edit the first value of multi-valued fields, so
+ # expose a single value, if any.
+ # TODO(jrobbins): Fully support multi-valued fields in templates.
+ for idx, field_value_view in enumerate(self.complete_field_values):
+ field_value_view.idx = idx
+ if field_value_view.values:
+ field_value_view.val = field_value_view.values[0].val
+ else:
+ field_value_view.val = None
+
+
+def MakeFieldUserViews(cnxn, template, user_service):
+ """Return {user_id: user_view} for users in template field values."""
+ field_user_ids = [
+ fv.user_id for fv in template.field_values
+ if fv.user_id]
+ field_user_views = framework_views.MakeAllUserViews(
+ cnxn, user_service, field_user_ids)
+ return field_user_views
+
+
+class ConfigView(template_helpers.PBProxy):
+ """Make it easy to display most fields of a ProjectIssueConfig in EZT."""
+
+ def __init__(self, mr, services, config, template=None,
+ load_all_templates=False):
+ """Gather data for the issue section of a project admin page.
+
+ Args:
+ mr: MonorailRequest, including a database connection, the current
+ project, and authenticated user IDs.
+ services: Persist services with ProjectService, ConfigService,
+ TemplateService and UserService included.
+ config: ProjectIssueConfig for the current project..
+ template (TemplateDef, optional): the current template.
+ load_all_templates (boolean): default False. If true loads self.templates.
+
+ Returns:
+ Project info in a dict suitable for EZT.
+ """
+ super(ConfigView, self).__init__(config)
+ self.open_statuses = []
+ self.closed_statuses = []
+ for wks in config.well_known_statuses:
+ item = template_helpers.EZTItem(
+ name=wks.status,
+ name_padded=wks.status.ljust(20),
+ commented='#' if wks.deprecated else '',
+ docstring=wks.status_docstring)
+ if tracker_helpers.MeansOpenInProject(wks.status, config):
+ self.open_statuses.append(item)
+ else:
+ self.closed_statuses.append(item)
+
+ is_member = framework_bizobj.UserIsInProject(
+ mr.project, mr.auth.effective_ids)
+ template_set = services.template.GetTemplateSetForProject(mr.cnxn,
+ config.project_id)
+
+ # Filter non-viewable templates
+ self.template_names = []
+ for _, template_name, members_only in template_set:
+ if members_only and not is_member:
+ continue
+ self.template_names.append(template_name)
+
+ if load_all_templates:
+ templates = services.template.GetProjectTemplates(mr.cnxn,
+ config.project_id)
+ self.templates = [
+ IssueTemplateView(mr, tmpl, services.user, config)
+ for tmpl in templates]
+ for index, template_view in enumerate(self.templates):
+ template_view.index = index
+
+ if template:
+ self.template_view = IssueTemplateView(mr, template, services.user,
+ config)
+
+ self.field_names = [ # TODO(jrobbins): field-level controls
+ fd.field_name for fd in config.field_defs if
+ fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and
+ not fd.is_deleted]
+ self.issue_labels = tracker_helpers.LabelsNotMaskedByFields(
+ config, self.field_names)
+ self.excl_prefixes = [
+ prefix.lower() for prefix in config.exclusive_label_prefixes]
+ self.restrict_to_known = ezt.boolean(config.restrict_to_known)
+
+ self.default_col_spec = (
+ config.default_col_spec or tracker_constants.DEFAULT_COL_SPEC)
+
+
+def StatusDefsAsText(config):
+ """Return two strings for editing open and closed status definitions."""
+ open_lines = []
+ closed_lines = []
+ for wks in config.well_known_statuses:
+ line = '%s%s%s%s' % (
+ '#' if wks.deprecated else '',
+ wks.status.ljust(20),
+ '\t= ' if wks.status_docstring else '',
+ wks.status_docstring)
+
+ if tracker_helpers.MeansOpenInProject(wks.status, config):
+ open_lines.append(line)
+ else:
+ closed_lines.append(line)
+
+ open_text = '\n'.join(open_lines)
+ closed_text = '\n'.join(closed_lines)
+ logging.info('open_text is \n%s', open_text)
+ logging.info('closed_text is \n%s', closed_text)
+ return open_text, closed_text
+
+
+def LabelDefsAsText(config):
+ """Return a string for editing label definitions."""
+ field_names = [fd.field_name for fd in config.field_defs
+ if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE
+ and not fd.is_deleted]
+ masked_labels = tracker_helpers.LabelsMaskedByFields(config, field_names)
+ masked_set = set(masked.name for masked in masked_labels)
+
+ label_def_lines = []
+ for wkl in config.well_known_labels:
+ if wkl.label in masked_set:
+ continue
+ line = '%s%s%s%s' % (
+ '#' if wkl.deprecated else '',
+ wkl.label.ljust(20),
+ '\t= ' if wkl.label_docstring else '',
+ wkl.label_docstring)
+ label_def_lines.append(line)
+
+ labels_text = '\n'.join(label_def_lines)
+ logging.info('labels_text is \n%s', labels_text)
+ return labels_text
diff --git a/tracker/webcomponentspage.py b/tracker/webcomponentspage.py
new file mode 100644
index 0000000..4e2ad0d
--- /dev/null
+++ b/tracker/webcomponentspage.py
@@ -0,0 +1,117 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed 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 a web components page.
+
+Summary of classes:
+ WebComponentsPage: Show one web components page.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+
+import logging
+
+import settings
+from framework import servlet
+from framework import framework_helpers
+from framework import permissions
+from framework import urls
+
+
+class WebComponentsPage(servlet.Servlet):
+
+ _PAGE_TEMPLATE = 'tracker/web-components-page.ezt'
+
+ def AssertBasePermission(self, mr):
+ # type: (MonorailRequest) -> None
+ """Check that the user has permission to visit this page."""
+ super(WebComponentsPage, self).AssertBasePermission(mr)
+
+ def GatherPageData(self, mr):
+ # type: (MonorailRequest) -> Mapping[str, Any]
+ """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.
+ """
+ # Create link to view in old UI for the list view pages.
+ old_ui_url = None
+ url = mr.request.url
+ if '/hotlists/' in url:
+ hotlist = self.services.features.GetHotlist(mr.cnxn, mr.hotlist_id)
+ if '/people' in url:
+ old_ui_url = '/u/%s/hotlists/%s/people' % (
+ hotlist.owner_ids[0], hotlist.name)
+ elif '/settings' in url:
+ old_ui_url = '/u/%s/hotlists/%s/details' % (
+ hotlist.owner_ids[0], hotlist.name)
+ else:
+ old_ui_url = '/u/%s/hotlists/%s' % (hotlist.owner_ids[0], hotlist.name)
+
+ return {
+ 'local_id': mr.local_id,
+ 'old_ui_url': old_ui_url,
+ }
+
+
+class ProjectListPage(WebComponentsPage):
+
+ def GatherPageData(self, mr):
+ # type: (MonorailRequest) -> Mapping[str, Any]
+ """Build up a dictionary of data values to use when rendering the page.
+
+ May redirect the user to a default project if one is configured for
+ the current domain.
+
+ Args:
+ mr: commonly used info parsed from the request.
+
+ Returns:
+ Dict of values used by EZT for rendering the page.
+ """
+ redirect_msg = self._MaybeRedirectToDomainDefaultProject(mr)
+ logging.info(redirect_msg)
+ return {
+ 'local_id': None,
+ 'old_ui_url': '/hosting_old/',
+ }
+
+ def _MaybeRedirectToDomainDefaultProject(self, mr):
+ # type: (MonorailRequest) -> str
+ """If there is a relevant default project, redirect to it.
+
+ This function is copied from: sitewide/hostinghome.py
+
+ Args:
+ mr: commonly used info parsed from the request.
+
+ Returns:
+ String with a message about what happened for logging purposes.
+ """
+ project_name = settings.domain_to_default_project.get(mr.request.host)
+ if not project_name:
+ return 'No configured default project redirect for this domain.'
+
+ project = None
+ try:
+ project = self.services.project.GetProjectByName(mr.cnxn, project_name)
+ except exceptions.NoSuchProjectException:
+ pass
+
+ if not project:
+ return 'Domain default project %s not found' % project_name
+
+ if not permissions.UserCanViewProject(mr.auth.user_pb,
+ mr.auth.effective_ids, project):
+ return 'User cannot view default project: %r' % project
+
+ project_url = '/p/%s' % project_name
+ self.redirect(project_url, abort=True)
+ return 'Redirected to %r' % project_url