Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/framework/monorailrequest.py b/framework/monorailrequest.py
new file mode 100644
index 0000000..e51aa15
--- /dev/null
+++ b/framework/monorailrequest.py
@@ -0,0 +1,713 @@
+# 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 to hold information parsed from a request.
+
+To simplify our servlets and avoid duplication of code, we parse some
+info out of the request as soon as we get it and then pass a MonorailRequest
+object to the servlet-specific request handler methods.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import endpoints
+import logging
+import re
+import urllib
+
+import ezt
+import six
+
+from google.appengine.api import app_identity
+from google.appengine.api import oauth
+
+import webapp2
+
+import settings
+from businesslogic import work_env
+from features import features_constants
+from framework import authdata
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_views
+from framework import monorailcontext
+from framework import permissions
+from framework import profiler
+from framework import sql
+from framework import template_helpers
+from proto import api_pb2_v1
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+_HOSTPORT_RE = re.compile('^[-a-z0-9.]+(:\d+)?$', re.I)
+
+
+# TODO(jrobbins): Stop extending MonorailContext and change whole servlet
+# framework to pass around separate objects for mc and mr.
+class MonorailRequestBase(monorailcontext.MonorailContext):
+  """A base class with common attributes for internal and external requests."""
+
+  def __init__(self, services, requester=None, cnxn=None):
+    super(MonorailRequestBase, self).__init__(
+        services, cnxn=cnxn, requester=requester)
+
+    self.project_name = None
+    self.project = None
+    self.config = None
+
+  @property
+  def project_id(self):
+    return self.project.project_id if self.project else None
+
+
+class MonorailApiRequest(MonorailRequestBase):
+  """A class to hold information parsed from the Endpoints API request."""
+
+  # pylint: disable=attribute-defined-outside-init
+  def __init__(self, request, services, cnxn=None):
+    requester_object = (
+        endpoints.get_current_user() or
+        oauth.get_current_user(
+            framework_constants.OAUTH_SCOPE))
+    requester = requester_object.email().lower()
+    super(MonorailApiRequest, self).__init__(
+        services, requester=requester, cnxn=cnxn)
+    self.me_user_id = self.auth.user_id
+    self.viewed_username = None
+    self.viewed_user_auth = None
+    self.issue = None
+    self.granted_perms = set()
+
+    # query parameters
+    self.params = {
+        'can': 1,
+        'start': 0,
+        'num': tracker_constants.DEFAULT_RESULTS_PER_PAGE,
+        'q': '',
+        'sort': '',
+        'groupby': '',
+        'projects': [],
+        'hotlists': []
+    }
+    self.use_cached_searches = True
+    self.mode = None
+
+    if hasattr(request, 'projectId'):
+      self.project_name = request.projectId
+      with work_env.WorkEnv(self, services) as we:
+        self.project = we.GetProjectByName(self.project_name)
+        self.params['projects'].append(self.project_name)
+        self.config = we.GetProjectConfig(self.project_id)
+        if hasattr(request, 'additionalProject'):
+          self.params['projects'].extend(request.additionalProject)
+          self.params['projects'] = list(set(self.params['projects']))
+    self.LookupLoggedInUserPerms(self.project)
+    if hasattr(request, 'projectId'):
+      with work_env.WorkEnv(self, services) as we:
+        if hasattr(request, 'issueId'):
+          self.issue = we.GetIssueByLocalID(
+              self.project_id, request.issueId, use_cache=False)
+          self.granted_perms = tracker_bizobj.GetGrantedPerms(
+              self.issue, self.auth.effective_ids, self.config)
+    if hasattr(request, 'userId'):
+      self.viewed_username = request.userId.lower()
+      if self.viewed_username == 'me':
+        self.viewed_username = requester
+      self.viewed_user_auth = authdata.AuthData.FromEmail(
+          self.cnxn, self.viewed_username, services)
+    elif hasattr(request, 'groupName'):
+      self.viewed_username = request.groupName.lower()
+      try:
+        self.viewed_user_auth = authdata.AuthData.FromEmail(
+            self.cnxn, self.viewed_username, services)
+      except exceptions.NoSuchUserException:
+        self.viewed_user_auth = None
+
+    # Build q.
+    if hasattr(request, 'q') and request.q:
+      self.params['q'] = request.q
+    if hasattr(request, 'publishedMax') and request.publishedMax:
+      self.params['q'] += ' opened<=%d' % request.publishedMax
+    if hasattr(request, 'publishedMin') and request.publishedMin:
+      self.params['q'] += ' opened>=%d' % request.publishedMin
+    if hasattr(request, 'updatedMax') and request.updatedMax:
+      self.params['q'] += ' modified<=%d' % request.updatedMax
+    if hasattr(request, 'updatedMin') and request.updatedMin:
+      self.params['q'] += ' modified>=%d' % request.updatedMin
+    if hasattr(request, 'owner') and request.owner:
+      self.params['q'] += ' owner:%s' % request.owner
+    if hasattr(request, 'status') and request.status:
+      self.params['q'] += ' status:%s' % request.status
+    if hasattr(request, 'label') and request.label:
+      self.params['q'] += ' label:%s' % request.label
+
+    if hasattr(request, 'can') and request.can:
+      if request.can == api_pb2_v1.CannedQuery.all:
+        self.params['can'] = 1
+      elif request.can == api_pb2_v1.CannedQuery.new:
+        self.params['can'] = 6
+      elif request.can == api_pb2_v1.CannedQuery.open:
+        self.params['can'] = 2
+      elif request.can == api_pb2_v1.CannedQuery.owned:
+        self.params['can'] = 3
+      elif request.can == api_pb2_v1.CannedQuery.reported:
+        self.params['can'] = 4
+      elif request.can == api_pb2_v1.CannedQuery.starred:
+        self.params['can'] = 5
+      elif request.can == api_pb2_v1.CannedQuery.to_verify:
+        self.params['can'] = 7
+      else: # Endpoints should have caught this.
+        raise exceptions.InputException(
+            'Canned query %s is not supported.', request.can)
+    if hasattr(request, 'startIndex') and request.startIndex:
+      self.params['start'] = request.startIndex
+    if hasattr(request, 'maxResults') and request.maxResults:
+      self.params['num'] = request.maxResults
+    if hasattr(request, 'sort') and request.sort:
+      self.params['sort'] = request.sort
+
+    self.query_project_names = self.GetParam('projects')
+    self.group_by_spec = self.GetParam('groupby')
+    self.group_by_spec = ' '.join(ParseColSpec(
+        self.group_by_spec, ignore=tracker_constants.NOT_USED_IN_GRID_AXES))
+    self.sort_spec = self.GetParam('sort')
+    self.sort_spec = ' '.join(ParseColSpec(self.sort_spec))
+    self.query = self.GetParam('q')
+    self.can = self.GetParam('can')
+    self.start = self.GetParam('start')
+    self.num = self.GetParam('num')
+
+  def GetParam(self, query_param_name, default_value=None,
+               _antitamper_re=None):
+    return self.params.get(query_param_name, default_value)
+
+  def GetPositiveIntParam(self, query_param_name, default_value=None):
+    """Returns 0 if the user-provided value is less than 0."""
+    return max(self.GetParam(query_param_name, default_value=default_value),
+               0)
+
+
+class MonorailRequest(MonorailRequestBase):
+  """A class to hold information parsed from the HTTP request.
+
+  The goal of MonorailRequest is to do almost all URL path and query string
+  procesing in one place, which makes the servlet code simpler.
+
+  Attributes:
+   cnxn: connection to the SQL databases.
+   logged_in_user_id: int user ID of the signed-in user, or None.
+   effective_ids: set of signed-in user ID and all their user group IDs.
+   user_pb: User object for the signed in user.
+   project_name: string name of the current project.
+   project_id: int ID of the current projet.
+   viewed_username: string username of the user whose profile is being viewed.
+   can: int "canned query" number to scope the user's search.
+   num: int number of results to show per pagination page.
+   start: int position in result set to show on this pagination page.
+   etc: there are many more, all read-only.
+  """
+
+  # pylint: disable=attribute-defined-outside-init
+  def __init__(self, services, params=None):
+    """Initialize the MonorailRequest object."""
+    # Note: mr starts off assuming anon until ParseRequest() is called.
+    super(MonorailRequest, self).__init__(services)
+    self.form_overrides = {}
+    if params:
+      self.form_overrides.update(params)
+    self.debug_enabled = False
+    self.use_cached_searches = True
+
+    self.hotlist_id = None
+    self.hotlist = None
+    self.hotlist_name = None
+
+    self.viewed_username = None
+    self.viewed_user_auth = authdata.AuthData()
+
+  def ParseRequest(self, request, services, do_user_lookups=True):
+    """Parse tons of useful info from the given request object.
+
+    Args:
+      request: webapp2 Request object w/ path and query params.
+      services: connections to backend servers including DB.
+      do_user_lookups: Set to False to disable lookups during testing.
+    """
+    with self.profiler.Phase('basic parsing'):
+      self.request = request
+      self.current_page_url = request.url
+      self.current_page_url_encoded = urllib.quote_plus(self.current_page_url)
+
+      # Only accept a hostport from the request that looks valid.
+      if not _HOSTPORT_RE.match(request.host):
+        raise exceptions.InputException(
+            'request.host looks funny: %r', request.host)
+
+      logging.info('Request: %s', self.current_page_url)
+
+    with self.profiler.Phase('path parsing'):
+      (viewed_user_val, self.project_name,
+       self.hotlist_id, self.hotlist_name) = _ParsePathIdentifiers(
+           self.request.path)
+      self.viewed_username = _GetViewedEmail(
+          viewed_user_val, self.cnxn, services)
+    with self.profiler.Phase('qs parsing'):
+      self._ParseQueryParameters()
+    with self.profiler.Phase('overrides parsing'):
+      self._ParseFormOverrides()
+
+    if not self.project:  # It can be already set in unit tests.
+      self._LookupProject(services)
+    if self.project_id and services.config:
+      self.config = services.config.GetProjectConfig(self.cnxn, self.project_id)
+
+    if do_user_lookups:
+      if self.viewed_username:
+        self._LookupViewedUser(services)
+      self._LookupLoggedInUser(services)
+      # TODO(jrobbins): re-implement HandleLurkerViewingSelf()
+
+    if not self.hotlist:
+      self._LookupHotlist(services)
+
+    if self.query is None:
+      self.query = self._CalcDefaultQuery()
+
+    prod_debug_allowed = self.perms.HasPerm(
+        permissions.VIEW_DEBUG, self.auth.user_id, None)
+    self.debug_enabled = (request.params.get('debug') and
+                          (settings.local_mode or prod_debug_allowed))
+    # temporary option for perf testing on staging instance.
+    if request.params.get('disable_cache'):
+      if settings.local_mode or 'staging' in request.host:
+        self.use_cached_searches = False
+
+  def _CalcDefaultQuery(self):
+    """When URL has no q= param, return the default for members or ''."""
+    if (self.can == 2 and self.project and self.auth.effective_ids and
+        framework_bizobj.UserIsInProject(self.project, self.auth.effective_ids)
+        and self.config):
+      return self.config.member_default_query
+    else:
+      return ''
+
+  def _ParseQueryParameters(self):
+    """Parse and convert all the query string params used in any servlet."""
+    self.start = self.GetPositiveIntParam('start', default_value=0)
+    self.num = self.GetPositiveIntParam(
+        'num', default_value=tracker_constants.DEFAULT_RESULTS_PER_PAGE)
+    # Prevent DoS attacks that try to make us serve really huge result pages.
+    self.num = min(self.num, settings.max_artifact_search_results_per_page)
+
+    self.invalidation_timestep = self.GetIntParam(
+        'invalidation_timestep', default_value=0)
+
+    self.continue_issue_id = self.GetIntParam(
+        'continue_issue_id', default_value=0)
+    self.redir = self.GetParam('redir')
+
+    # Search scope, a.k.a., canned query ID
+    # TODO(jrobbins): make configurable
+    self.can = self.GetIntParam(
+        'can', default_value=tracker_constants.OPEN_ISSUES_CAN)
+
+    # Search query
+    self.query = self.GetParam('q')
+
+    # Sorting of search results (needed for result list and flipper)
+    self.sort_spec = self.GetParam(
+        'sort', default_value='',
+        antitamper_re=framework_constants.SORTSPEC_RE)
+    self.sort_spec = ' '.join(ParseColSpec(self.sort_spec))
+
+    # Note: This is set later in request handling by ComputeColSpec().
+    self.col_spec = None
+
+    # Grouping of search results (needed for result list and flipper)
+    self.group_by_spec = self.GetParam(
+        'groupby', default_value='',
+        antitamper_re=framework_constants.SORTSPEC_RE)
+    self.group_by_spec = ' '.join(ParseColSpec(
+        self.group_by_spec, ignore=tracker_constants.NOT_USED_IN_GRID_AXES))
+
+    # For issue list and grid mode.
+    self.cursor = self.GetParam('cursor')
+    self.preview = self.GetParam('preview')
+    self.mode = self.GetParam('mode') or 'list'
+    self.x = self.GetParam('x', default_value='')
+    self.y = self.GetParam('y', default_value='')
+    self.cells = self.GetParam('cells', default_value='ids')
+
+    # For the dashboard and issue lists included in the dashboard.
+    self.ajah = self.GetParam('ajah')  # AJAH = Asychronous Javascript And HTML
+    self.table_title = self.GetParam('table_title')
+    self.panel_id = self.GetIntParam('panel')
+
+    # For pagination of updates lists
+    self.before = self.GetPositiveIntParam('before')
+    self.after = self.GetPositiveIntParam('after')
+
+    # For cron tasks and backend calls
+    self.lower_bound = self.GetIntParam('lower_bound')
+    self.upper_bound = self.GetIntParam('upper_bound')
+    self.shard_id = self.GetIntParam('shard_id')
+
+    # For specifying which objects to operate on
+    self.local_id = self.GetIntParam('id')
+    self.local_id_list = self.GetIntListParam('ids')
+    self.seq = self.GetIntParam('seq')
+    self.aid = self.GetIntParam('aid')
+    self.signed_aid = self.GetParam('signed_aid')
+    self.specified_user_id = self.GetIntParam('u', default_value=0)
+    self.specified_logged_in_user_id = self.GetIntParam(
+        'logged_in_user_id', default_value=0)
+    self.specified_me_user_ids = self.GetIntListParam('me_user_ids')
+
+    # TODO(jrobbins): Phase this out after next deployment.  If an old
+    # version of the default GAE module sends a request with the old
+    # me_user_id= parameter, then accept it.
+    specified_me_user_id = self.GetIntParam(
+        'me_user_id', default_value=0)
+    if specified_me_user_id:
+      self.specified_me_user_ids = [specified_me_user_id]
+
+    self.specified_project = self.GetParam('project')
+    self.specified_project_id = self.GetIntParam('project_id')
+    self.query_project_names = self.GetListParam('projects', default_value=[])
+    self.template_name = self.GetParam('template')
+    self.component_path = self.GetParam('component')
+    self.field_name = self.GetParam('field')
+
+    # For image attachments
+    self.inline = bool(self.GetParam('inline'))
+    self.thumb = bool(self.GetParam('thumb'))
+
+    # For JS callbacks
+    self.token = self.GetParam('token')
+    self.starred = bool(self.GetIntParam('starred'))
+
+    # For issue reindexing utility servlet
+    self.auto_submit = self.GetParam('auto_submit')
+
+    # For issue dependency reranking servlet
+    self.parent_id = self.GetIntParam('parent_id')
+    self.target_id = self.GetIntParam('target_id')
+    self.moved_ids = self.GetIntListParam('moved_ids')
+    self.split_above = self.GetBoolParam('split_above')
+
+    # For adding issues to hotlists servlet
+    self.hotlist_ids_remove = self.GetIntListParam('hotlist_ids_remove')
+    self.hotlist_ids_add = self.GetIntListParam('hotlist_ids_add')
+    self.issue_refs = self.GetListParam('issue_refs')
+
+  def _ParseFormOverrides(self):
+    """Support deep linking by allowing the user to set form fields via QS."""
+    allowed_overrides = {
+        'template_name': self.GetParam('template_name'),
+        'initial_summary': self.GetParam('summary'),
+        'initial_description': (self.GetParam('description') or
+                                self.GetParam('comment')),
+        'initial_comment': self.GetParam('comment'),
+        'initial_status': self.GetParam('status'),
+        'initial_owner': self.GetParam('owner'),
+        'initial_cc': self.GetParam('cc'),
+        'initial_blocked_on': self.GetParam('blockedon'),
+        'initial_blocking': self.GetParam('blocking'),
+        'initial_merge_into': self.GetIntParam('mergeinto'),
+        'initial_components': self.GetParam('components'),
+        'initial_hotlists': self.GetParam('hotlists'),
+
+        # For the people pages
+        'initial_add_members': self.GetParam('add_members'),
+        'initially_expanded_form': ezt.boolean(self.GetParam('expand_form')),
+
+        # For user group admin pages
+        'initial_name': (self.GetParam('group_name') or
+                         self.GetParam('proposed_project_name')),
+        }
+
+    # Only keep the overrides that were actually provided in the query string.
+    self.form_overrides.update(
+        (k, v) for (k, v) in allowed_overrides.items()
+        if v is not None)
+
+  def _LookupViewedUser(self, services):
+    """Get information about the viewed user (if any) from the request."""
+    try:
+      with self.profiler.Phase('get viewed user, if any'):
+        self.viewed_user_auth = authdata.AuthData.FromEmail(
+            self.cnxn, self.viewed_username, services, autocreate=False)
+    except exceptions.NoSuchUserException:
+      logging.info('could not find user %r', self.viewed_username)
+      webapp2.abort(404, 'user not found')
+
+    if not self.viewed_user_auth.user_id:
+      webapp2.abort(404, 'user not found')
+
+  def _LookupProject(self, services):
+    """Get information about the current project (if any) from the request.
+
+    Raises:
+      NoSuchProjectException if there is no project with that name.
+    """
+    logging.info('project_name is %r', self.project_name)
+    if self.project_name:
+      self.project = services.project.GetProjectByName(
+          self.cnxn, self.project_name)
+      if not self.project:
+        raise exceptions.NoSuchProjectException()
+
+  def _LookupHotlist(self, services):
+    """Get information about the current hotlist (if any) from the request."""
+    with self.profiler.Phase('get current hotlist, if any'):
+      if self.hotlist_name:
+        hotlist_id_dict = services.features.LookupHotlistIDs(
+            self.cnxn, [self.hotlist_name], [self.viewed_user_auth.user_id])
+        try:
+          self.hotlist_id = hotlist_id_dict[(
+              self.hotlist_name, self.viewed_user_auth.user_id)]
+        except KeyError:
+          webapp2.abort(404, 'invalid hotlist')
+
+      if not self.hotlist_id:
+        logging.info('no hotlist_id or bad hotlist_name, so no hotlist')
+      else:
+        self.hotlist = services.features.GetHotlistByID(
+            self.cnxn, self.hotlist_id)
+        if not self.hotlist or (
+            self.viewed_user_auth.user_id and
+            self.viewed_user_auth.user_id not in self.hotlist.owner_ids):
+          webapp2.abort(404, 'invalid hotlist')
+
+  def _LookupLoggedInUser(self, services):
+    """Get information about the signed-in user (if any) from the request."""
+    self.auth = authdata.AuthData.FromRequest(self.cnxn, services)
+    self.me_user_id = (self.GetIntParam('me') or
+                       self.viewed_user_auth.user_id or self.auth.user_id)
+
+    self.LookupLoggedInUserPerms(self.project)
+
+  def ComputeColSpec(self, config):
+    """Set col_spec based on param, default in the config, or site default."""
+    if self.col_spec is not None:
+      return  # Already set.
+    default_col_spec = ''
+    if config:
+      default_col_spec = config.default_col_spec
+
+    col_spec = self.GetParam(
+        'colspec', default_value=default_col_spec,
+        antitamper_re=framework_constants.COLSPEC_RE)
+    cols_lower = col_spec.lower().split()
+    if self.project and any(
+        hotlist_col in cols_lower for hotlist_col in [
+            'rank', 'adder', 'added']):
+      # if the the list is a project list and the 'colspec' is a carry-over
+      # from hotlists, set col_spec to None so it will be set to default in
+      # in the next if statement
+      col_spec = None
+
+    if not col_spec:
+      # If col spec is still empty then default to the global col spec.
+      col_spec = tracker_constants.DEFAULT_COL_SPEC
+
+    self.col_spec = ' '.join(ParseColSpec(col_spec,
+                             max_parts=framework_constants.MAX_COL_PARTS))
+
+  def PrepareForReentry(self, echo_data):
+    """Expose the results of form processing as if it was a new GET.
+
+    This method is called only when the user submits a form with invalid
+    information which they are being asked to correct it.  Updating the MR
+    object allows the normal servlet get() method to populate the form with
+    the entered values and error messages.
+
+    Args:
+      echo_data: dict of {page_data_key: value_to_reoffer, ...} that will
+          override whatever HTML form values are nomally shown to the
+          user when they initially view the form.  This allows them to
+          fix user input that was not valid.
+    """
+    self.form_overrides.update(echo_data)
+
+  def GetParam(self, query_param_name, default_value=None,
+               antitamper_re=None):
+    """Get a query parameter from the URL as a utf8 string."""
+    value = self.request.params.get(query_param_name)
+    assert value is None or isinstance(value, six.text_type)
+    using_default = value is None
+    if using_default:
+      value = default_value
+
+    if antitamper_re and not antitamper_re.match(value):
+      if using_default:
+        logging.error('Default value fails antitamper for %s field: %s',
+                      query_param_name, value)
+      else:
+        logging.info('User seems to have tampered with %s field: %s',
+                     query_param_name, value)
+      raise exceptions.InputException()
+
+    return value
+
+  def GetIntParam(self, query_param_name, default_value=None):
+    """Get an integer param from the URL or default."""
+    value = self.request.params.get(query_param_name)
+    if value is None or value == '':
+      return default_value
+
+    try:
+      return int(value)
+    except (TypeError, ValueError):
+      raise exceptions.InputException(
+          'Invalid value for integer param: %r' % value)
+
+  def GetPositiveIntParam(self, query_param_name, default_value=None):
+    """Returns 0 if the user-provided value is less than 0."""
+    return max(self.GetIntParam(query_param_name, default_value=default_value),
+               0)
+
+  def GetListParam(self, query_param_name, default_value=None):
+    """Get a list of strings from the URL or default."""
+    params = self.request.params.get(query_param_name)
+    if params is None:
+      return default_value
+    if not params:
+      return []
+    return params.split(',')
+
+  def GetIntListParam(self, query_param_name, default_value=None):
+    """Get a list of ints from the URL or default."""
+    param_list = self.GetListParam(query_param_name)
+    if param_list is None:
+      return default_value
+
+    try:
+      return [int(p) for p in param_list]
+    except (TypeError, ValueError):
+      raise exceptions.InputException('Invalid value for integer list param')
+
+  def GetBoolParam(self, query_param_name, default_value=None):
+    """Get a boolean param from the URL or default."""
+    value = self.request.params.get(query_param_name)
+    if value is None:
+      return default_value
+
+    if (not value) or (value.lower() == 'false'):
+      return False
+    return True
+
+
+def _ParsePathIdentifiers(path):
+  """Parse out the workspace being requested (if any).
+
+  Args:
+    path: A string beginning with the request's path info.
+
+  Returns:
+    (viewed_user_val, project_name).
+  """
+  viewed_user_val = None
+  project_name = None
+  hotlist_id = None
+  hotlist_name = None
+
+  # Strip off any query params
+  split_path = path.lstrip('/').split('?')[0].split('/')
+  if len(split_path) >= 2:
+    if split_path[0] == 'hotlists':
+      if split_path[1].isdigit():
+        hotlist_id = int(split_path[1])
+    if split_path[0] == 'p':
+      project_name = split_path[1]
+    if split_path[0] == 'u' or split_path[0] == 'users':
+      viewed_user_val = urllib.unquote(split_path[1])
+      if len(split_path) >= 4 and split_path[2] == 'hotlists':
+        try:
+          hotlist_id = int(
+              urllib.unquote(split_path[3].split('.')[0]))
+        except ValueError:
+          raw_last_path = (split_path[3][:-3] if
+                        split_path[3].endswith('.do') else split_path[3])
+          last_path = urllib.unquote(raw_last_path)
+          match = framework_bizobj.RE_HOTLIST_NAME.match(
+              last_path)
+          if not match:
+            raise exceptions.InputException(
+                'Could not parse hotlist id or name')
+          else:
+            hotlist_name = last_path.lower()
+
+    if split_path[0] == 'g':
+      viewed_user_val = urllib.unquote(split_path[1])
+
+  return viewed_user_val, project_name, hotlist_id, hotlist_name
+
+
+def _GetViewedEmail(viewed_user_val, cnxn, services):
+  """Returns the viewed user's email.
+
+  Args:
+    viewed_user_val: Could be either int (user_id) or str (email).
+    cnxn: connection to the SQL database.
+    services: Interface to all persistence storage backends.
+
+  Returns:
+    viewed_email
+  """
+  if not viewed_user_val:
+    return None
+
+  try:
+    viewed_userid = int(viewed_user_val)
+    viewed_email = services.user.LookupUserEmail(cnxn, viewed_userid)
+    if not viewed_email:
+      logging.info('userID %s not found', viewed_userid)
+      webapp2.abort(404, 'user not found')
+  except ValueError:
+    viewed_email = viewed_user_val
+
+  return viewed_email
+
+
+def ParseColSpec(
+    col_spec, max_parts=framework_constants.MAX_SORT_PARTS,
+    ignore=None):
+  """Split a string column spec into a list of column names.
+
+  We dedup col parts because an attacker could try to DoS us or guess
+  zero or one result by measuring the time to process a request that
+  has a very long column list.
+
+  Args:
+    col_spec: a unicode string containing a list of labels.
+    max_parts: optional int maximum number of parts to consider.
+    ignore: optional list of column name parts to ignore.
+
+  Returns:
+    A list of the extracted labels. Non-alphanumeric
+    characters other than the period will be stripped from the text.
+  """
+  cols = framework_constants.COLSPEC_COL_RE.findall(col_spec)
+  result = []  # List of column headers with no duplicates.
+  # Set of column parts that we have processed so far.
+  seen = set()
+  if ignore:
+    seen = set(ignore_col.lower() for ignore_col in ignore)
+    max_parts += len(ignore)
+
+  for col in cols:
+    parts = []
+    for part in col.split('/'):
+      if (part.lower() not in seen and len(seen) < max_parts
+          and len(part) < framework_constants.MAX_COL_LEN):
+        parts.append(part)
+        seen.add(part.lower())
+    if parts:
+      result.append('/'.join(parts))
+  return result