Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
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