Merge branch 'main' into avm99963-monorail

Merged commit 34d8229ae2b51fb1a15bd208e6fe6185c94f6266

GitOrigin-RevId: 7ee0917f93a577e475f8e09526dd144d245593f4
diff --git a/framework/servlet.py b/framework/servlet.py
index b363095..15530a9 100644
--- a/framework/servlet.py
+++ b/framework/servlet.py
@@ -1,105 +1,64 @@
-# 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
+# Copyright 2022 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""Base classes for Monorail Flask servlets.
 
-"""Base classes for Monorail servlets.
-
-This base class provides HTTP get() and post() methods that
-conveniently drive the process of parsing the request, checking base
-permissions, gathering common page information, gathering
-page-specific information, and adding on-page debugging information
-(when appropriate).  Subclasses can simply implement the page-specific
-logic.
+This is derived from  servlet.py
+This base class provides handler methods that conveniently drive
+the process of parsing the request, checking base permisssion,
+gathering common page information, gathering page-specific information,
+and adding on-page debugging information (when appropriate).
+Subclasses can simply implement the page-specific logic.
 
 Summary of page classes:
-  Servlet: abstract base class for all Monorail servlets.
-  _ContextDebugItem: displays page_data elements for on-page debugging.
+  Servlet: abstract base class for all Monorail flask servlets.
 """
-from __future__ import print_function
-from __future__ import division
-from __future__ import absolute_import
 
 import gc
-from six.moves import http_client
-import json
-import logging
 import os
-import random
+import logging
+from six.moves import http_client
 import time
-from six.moves import urllib
+from businesslogic import work_env
 
 import ezt
+from features import features_bizobj, hotlist_views
+import flask
 import httpagentparser
+from project import project_constants
+from mrproto import project_pb2
+from search import query2ast
+
+import settings
+from framework import alerts, exceptions, framework_helpers, urls
+from framework import framework_views, servlet_helpers
+from framework import framework_constants
+from framework import monorailrequest
+from framework import permissions
+from framework import ratelimiter
+from framework import template_helpers
+from framework import xsrf
 
 from google.appengine.api import app_identity
 from google.appengine.api import modules
 from google.appengine.api import users
-from oauth2client.client import GoogleCredentials
-
-import webapp2
-
-import settings
-from businesslogic import work_env
-from features import savedqueries_helpers
-from features import features_bizobj
-from features import hotlist_views
-from framework import alerts
-from framework import exceptions
-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 ratelimiter
-from framework import servlet_helpers
-from framework import template_helpers
-from framework import urls
-from framework import xsrf
-from project import project_constants
-from proto import project_pb2
-from search import query2ast
 from tracker import tracker_views
-
-from infra_libs import ts_mon
+from werkzeug import datastructures
 
 NONCE_LENGTH = 32
 
 if not settings.unit_test_mode:
   import MySQLdb
 
-GC_COUNT = ts_mon.NonCumulativeDistributionMetric(
-    'monorail/servlet/gc_count',
-    'Count of objects in each generation tracked by the GC',
-    [ts_mon.IntegerField('generation')])
-
-GC_EVENT_REQUEST = ts_mon.CounterMetric(
-    'monorail/servlet/gc_event_request',
-    'Counts of requests that triggered at least one GC event',
-    [])
-
-# TODO(crbug/monorail:7084): Find a better home for this code.
-trace_service = None
-# TOD0(crbug/monorail:7082): Re-enable this once we have a solution that doesn't
-# inur clatency, or when we're actively using Cloud Tracing data.
-# if app_identity.get_application_id() != 'testing-app':
-#   logging.warning('app id: %s', app_identity.get_application_id())
-#   try:
-#     credentials = GoogleCredentials.get_application_default()
-#     trace_service = discovery.build(
-#         'cloudtrace', 'v1', credentials=credentials)
-#   except Exception as e:
-#     logging.warning('could not get trace service: %s', e)
-class Servlet(webapp2.RequestHandler):
-  """Base class for all Monorail servlets.
+class Servlet(object):
+  """Base class for all Monorail flask servlets.
 
   Defines a framework of methods that build up parts of the EZT page data.
 
   Subclasses should override GatherPageData and/or ProcessFormData to
   handle requests.
   """
-
-  _MAIN_TAB_MODE = None  # Normally overriden in subclasses to be one of these:
+  _MAIN_TAB_MODE = None  # Normally overridden in subclasses to be one of these:
 
   MAIN_TAB_NONE = 't0'
   MAIN_TAB_DASHBOARD = 't1'
@@ -132,26 +91,16 @@
   # we can allow an xhr-scoped XSRF token to be used to post to the page.
   ALLOW_XHR = False
 
-  # Most forms just ignore fields that have value "".  Subclasses can override
-  # if needed.
-  KEEP_BLANK_FORM_VALUES = False
-
-  # Most forms use regular forms, but subclasses that accept attached files can
-  # override this to be True.
-  MULTIPART_POST_BODY = False
-
   # This value should not typically be overridden.
   _TEMPLATE_PATH = framework_constants.TEMPLATE_PATH
 
-  _PAGE_TEMPLATE = None  # Normally overriden in subclasses.
+  _PAGE_TEMPLATE = None  # Normally overridden in subclasses.
   _ELIMINATE_BLANK_LINES = False
 
   _MISSING_PERMISSIONS_TEMPLATE = 'sitewide/403-page.ezt'
 
-  def __init__(self, request, response, services=None,
-               content_type='text/html; charset=UTF-8'):
+  def __init__(self, services=None, content_type='text/html; charset=UTF-8'):
     """Load and parse the template, saving it for later use."""
-    super(Servlet, self).__init__(request, response)
     if self._PAGE_TEMPLATE:  # specified in subclasses
       template_path = self._TEMPLATE_PATH + self._PAGE_TEMPLATE
       self.template = template_helpers.GetTemplate(
@@ -161,37 +110,40 @@
 
     self._missing_permissions_template = template_helpers.MonorailTemplate(
         self._TEMPLATE_PATH + self._MISSING_PERMISSIONS_TEMPLATE)
-    self.services = services or self.app.config.get('services')
+    self.services = services or flask.current_app.config['services']
     self.content_type = content_type
     self.mr = None
+    # TODO: convert it to use self.request.path when we merge all flask together
+    self.request = flask.request
+    self.request_path = None
+    self.response = None
     self.ratelimiter = ratelimiter.RateLimiter()
 
-  def dispatch(self):
+  # pylint: disable=unused-argument
+  def handler(self, **kwargs):
     """Do common stuff then dispatch the request to get() or put() methods."""
+    self.response = flask.make_response()
     handler_start_time = time.time()
+    logging.info('\n\n\n Flask Request handler: %r', self)
 
-    logging.info('\n\n\nRequest handler: %r', self)
-    count0, count1, count2 = gc.get_count()
-    logging.info('gc counts: %d %d %d', count0, count1, count2)
-    GC_COUNT.add(count0, {'generation': 0})
-    GC_COUNT.add(count1, {'generation': 1})
-    GC_COUNT.add(count2, {'generation': 2})
+    #TODO: add the ts_mon.NonCumulativeDistributionMetric
+    # count0, count1, count2 = gc.get_count()
+    # logging.info('gc counts: %d %d %d', count0, count1, count2)
+    # GC_COUNT.add(count0, {'generation': 0})
+    # GC_COUNT.add(count1, {'generation': 1})
+    # GC_COUNT.add(count2, {'generation': 2})
 
     self.mr = monorailrequest.MonorailRequest(self.services)
-
-    self.response.headers.add('Strict-Transport-Security',
-        'max-age=31536000; includeSubDomains')
+    # TODO: convert it to use self.request.path when we merge all flask together
+    self.request_path = self.request.base_url[len(self.request.host_url) - 1:]
+    self.response.headers.add(
+        'Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
 
     if 'X-Cloud-Trace-Context' in self.request.headers:
       self.mr.profiler.trace_context = (
           self.request.headers.get('X-Cloud-Trace-Context'))
-    # TOD0(crbug/monorail:7082): Re-enable tracing.
-    # if trace_service is not None:
-    #   self.mr.profiler.trace_service = trace_service
 
     if self.services.cache_manager:
-      # TODO(jrobbins): don't do this step if invalidation_timestep was
-      # passed via the request and matches our last timestep
       try:
         with self.mr.profiler.Phase('distributed invalidation'):
           self.services.cache_manager.DoDistributedInvalidation(self.mr.cnxn)
@@ -206,8 +158,8 @@
             'templates/framework/database-maintenance.ezt',
             eliminate_blank_lines=self._ELIMINATE_BLANK_LINES)
         self.template.WriteResponse(
-          self.response, page_data, content_type='text/html')
-        return
+            self.response, page_data, content_type='text/html')
+        return self.response
 
     try:
       self.ratelimiter.CheckStart(self.request)
@@ -216,27 +168,33 @@
         self.mr.ParseRequest(self.request, self.services)
 
       self.response.headers['X-Frame-Options'] = 'SAMEORIGIN'
-      webapp2.RequestHandler.dispatch(self)
 
+      if self.request.method == 'POST':
+        self.post()
+      elif self.request.method == 'GET':
+        self.get()
+
+    except exceptions.RedirectException as e:
+      return self.redirect(str(e))
     except exceptions.NoSuchUserException as e:
-      logging.warning('Trapped NoSuchUserException %s', e)
-      self.abort(404, 'user not found')
+      logging.info('Trapped NoSuchUserException %s', e)
+      flask.abort(404, 'user not found')
 
     except exceptions.NoSuchGroupException as e:
       logging.warning('Trapped NoSuchGroupException %s', e)
-      self.abort(404, 'user group not found')
+      flask.abort(404, 'user group not found')
 
     except exceptions.InputException as e:
       logging.info('Rejecting invalid input: %r', e)
-      self.response.status = http_client.BAD_REQUEST
+      self.response.status_code = http_client.BAD_REQUEST
 
     except exceptions.NoSuchProjectException as e:
       logging.info('Rejecting invalid request: %r', e)
-      self.response.status = http_client.NOT_FOUND
+      self.response.status_code = http_client.NOT_FOUND
 
     except xsrf.TokenIncorrect as e:
-      logging.info('Bad XSRF token: %r', e.message)
-      self.response.status = http_client.BAD_REQUEST
+      logging.info('Bad XSRF token: %r', str(e))
+      self.response.status_code = http_client.BAD_REQUEST
 
     except permissions.BannedUserException as e:
       logging.warning('The user has been banned')
@@ -246,8 +204,8 @@
 
     except ratelimiter.RateLimitExceeded as e:
       logging.info('RateLimitExceeded Exception %s', e)
-      self.response.status = http_client.BAD_REQUEST
-      self.response.body = 'Slow your roll.'
+      self.response.status_code = http_client.BAD_REQUEST
+      self.response.set_data('Slow your roll.')
 
     finally:
       self.mr.CleanUp()
@@ -259,34 +217,17 @@
 
     end_count0, end_count1, end_count2 = gc.get_count()
     logging.info('gc counts: %d %d %d', end_count0, end_count1, end_count2)
-    if (end_count0 < count0) or (end_count1 < count1) or (end_count2 < count2):
-      GC_EVENT_REQUEST.increment()
+    # TODO: get the GC event back
+    # if (end_count0 < count0) or (end_count1 < count1) or(end_count2 < count2):
+    #   GC_EVENT_REQUEST.increment()
 
     if settings.enable_profiler_logging:
       self.mr.profiler.LogStats()
 
-    # TOD0(crbug/monorail:7082, crbug/monorail:7088): Re-enable this when we
-    # have solved the latency, or when we really need the profiler data.
-    # if self.mr.profiler.trace_context is not None:
-    #   try:
-    #     self.mr.profiler.ReportTrace()
-    #   except Exception as ex:
-    #     # We never want Cloud Tracing to cause a user-facing error.
-    #     logging.warning('Ignoring exception reporting Cloud Trace %s', ex)
+    return self.response
 
-  def _AddHelpDebugPageData(self, page_data):
-    with self.mr.profiler.Phase('help and debug data'):
-      page_data.update(self.GatherHelpData(self.mr, page_data))
-      page_data.update(self.GatherDebugData(self.mr, page_data))
-
-  # pylint: disable=unused-argument
-  def get(self, **kwargs):
-    """Collect page-specific and generic info, then render the page.
-
-    Args:
-      Any path components parsed by webapp2 will be in kwargs, but we do
-        our own parsing later anyway, so igore them for now.
-    """
+  def get(self):
+    """Collect page-specific and generic info, then render the page."""
     page_data = {}
     nonce = framework_helpers.MakeRandomKey(length=NONCE_LENGTH)
     try:
@@ -298,44 +239,52 @@
       user_agent_str = self.mr.request.headers.get('User-Agent', '')
       ua = httpagentparser.detect(user_agent_str)
       browser, browser_major_version = 'Unknown browser', 0
-      if ua.has_key('browser'):
+      if 'browser' in ua:
         browser = ua['browser']['name']
         try:
           browser_major_version = int(ua['browser']['version'].split('.')[0])
         except ValueError:
-          logging.warn('Could not parse version: %r', ua['browser']['version'])
+          logging.warning(
+              'Could not parse version: %r', ua['browser']['version'])
         except KeyError:
-          logging.warn('No browser version defined in user agent.')
+          logging.warning('No browser version defined in user agent.')
       csp_supports_report_sample = (
-        (browser == 'Chrome' and browser_major_version >= 59) or
-        (browser == 'Opera' and browser_major_version >= 46))
+          (browser == 'Chrome' and browser_major_version >= 59) or
+          (browser == 'Opera' and browser_major_version >= 46))
       version_base = servlet_helpers.VersionBaseURL(self.mr.request)
-      self.response.headers.add(csp_header,
-           ("default-src %(scheme)s ; "
-            "script-src"
-            " %(rep_samp)s"  # Report 40 chars of any inline violation.
-            " 'unsafe-inline'"  # Only counts in browsers that lack CSP2.
-            " 'strict-dynamic'"  # Allows <script nonce> to load more.
-            " %(version_base)s/static/dist/"
-            " 'self' 'nonce-%(nonce)s'; "
-            "child-src 'none'; "
-            "frame-src accounts.google.com" # All used by gapi.js auth.
-            " content-issuetracker.corp.googleapis.com"
-            " login.corp.google.com up.corp.googleapis.com"
-            # Used by Google Feedback
-            " feedback.googleusercontent.com"
-            " www.google.com; "
-            "img-src %(scheme)s data: blob: ; "
-            "style-src %(scheme)s 'unsafe-inline'; "
-            "object-src 'none'; "
-            "base-uri 'self'; " # Used by Google Feedback
-            "report-uri /csp.do" % {
-            'nonce': nonce,
-            'scheme': csp_scheme,
-            'rep_samp': "'report-sample'" if csp_supports_report_sample else '',
-            'version_base': version_base,
-            }))
+      self.response.headers.add(
+          csp_header,
+          (
+              "default-src %(scheme)s ; "
+              "script-src"
+              " %(rep_samp)s"  # Report 40 chars of any inline violation.
+              " 'unsafe-inline'"  # Only counts in browsers that lack CSP2.
+              " 'strict-dynamic'"  # Allows <script nonce> to load more.
+              " %(version_base)s/static/dist/"
+              " 'self' 'nonce-%(nonce)s'; "
+              "child-src 'none'; "
+              "frame-src accounts.google.com"  # All used by gapi.js auth.
+              " content-issuetracker.corp.googleapis.com"
+              " login.corp.google.com up.corp.googleapis.com"
+              # Used by Google Feedback
+              " feedback.googleusercontent.com"
+              " www.google.com; "
+              "img-src %(scheme)s data: blob: ; "
+              "style-src %(scheme)s 'unsafe-inline'; "
+              "object-src 'none'; "
+              "base-uri 'self'; "  # Used by Google Feedback
+              "report-uri /csp.do" % {
+                  'nonce':
+                      nonce,
+                  'scheme':
+                      csp_scheme,
+                  'rep_samp':
+                      "'report-sample'" if csp_supports_report_sample else '',
+                  'version_base':
+                      version_base,
+              }))
 
+      # add the function to get data and render page
       page_data.update(self._GatherFlagData(self.mr))
 
       # Page-specific work happens in this call.
@@ -352,12 +301,12 @@
       # meaningful during fuzzing. For more context see
       # https://bugs.chromium.org/p/monorail/issues/detail?id=659
       logging.warning('Trapped NotImplementedError %s', e)
-      self.abort(404, 'invalid page')
+      flask.abort(404, 'invalid page')
     except query2ast.InvalidQueryError as e:
       logging.warning('Trapped InvalidQueryError: %s', e)
       logging.exception(e)
-      msg = e.message if e.message else 'invalid query'
-      self.abort(400, msg)
+      msg = str(e) if str(e) else 'invalid query'
+      flask.abort(400, msg)
     except permissions.PermissionException as e:
       logging.warning('Trapped PermissionException %s', e)
       logging.warning('mr.auth.user_id is %s', self.mr.auth.user_id)
@@ -365,12 +314,12 @@
       logging.warning('mr.perms is %s', self.mr.perms)
       if not self.mr.auth.user_id:
         # If not logged in, let them log in
-        url = servlet_helpers.SafeCreateLoginURL(self.mr)
-        self.redirect(url, abort=True)
+        login_url = servlet_helpers.SafeCreateLoginURL(self.mr)
+        raise exceptions.RedirectException(login_url)
       else:
         # Display the missing permissions template.
         page_data = {
-            'reason': e.message,
+            'reason': str(e),
             'http_response_code': http_client.FORBIDDEN,
         }
         with self.mr.profiler.Phase('gather base data'):
@@ -379,61 +328,34 @@
         self._missing_permissions_template.WriteResponse(
             self.response, page_data, content_type=self.content_type)
 
-  def GetTemplate(self, _page_data):
-    """Get the template to use for writing the http response.
-
-    Defaults to self.template.  This method can be overwritten in subclasses
-    to allow dynamic template selection based on page_data.
-
-    Args:
-      _page_data: A dict of data for ezt rendering, containing base ezt
-      data, page data, and debug data.
-
-    Returns:
-      The template to be used for writing the http response.
-    """
-    return self.template
-
-  def _GatherFlagData(self, mr):
-    page_data = {
-        'project_stars_enabled': ezt.boolean(
-            settings.enable_project_stars),
-        'user_stars_enabled': ezt.boolean(settings.enable_user_stars),
-        'can_create_project': ezt.boolean(
-            permissions.CanCreateProject(mr.perms)),
-        'can_create_group': ezt.boolean(
-            permissions.CanCreateGroup(mr.perms)),
-        }
-
-    return page_data
-
-  def _RenderResponse(self, page_data):
-    logging.info('rendering response len(page_data) is %r', len(page_data))
-    self.GetTemplate(page_data).WriteResponse(
-        self.response, page_data, content_type=self.content_type)
-
-  def ProcessFormData(self, mr, post_data):
-    """Handle form data and redirect appropriately.
-
-    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.
-    """
-    raise servlet_helpers.MethodNotSupportedError()
-
-  def post(self, **kwargs):
-    """Parse the request, check base perms, and call form-specific code."""
+  def post(self):
+    logging.info('process post request')
     try:
       # Page-specific work happens in this call.
       self._DoFormProcessing(self.request, self.mr)
 
     except permissions.PermissionException as e:
       logging.warning('Trapped permission-related exception "%s".', e)
-      # TODO(jrobbins): can we do better than an error page? not much.
-      self.response.status = http_client.BAD_REQUEST
+      self.response.status_code = http_client.BAD_REQUEST
+
+  def _RenderResponse(self, page_data):
+    logging.info('rendering response len(page_data) is %r', len(page_data))
+    self.template.WriteResponse(
+        self.response, page_data, content_type=self.content_type)
+
+  def _GatherFlagData(self, mr):
+    page_data = {
+        'project_stars_enabled':
+            ezt.boolean(settings.enable_project_stars),
+        'user_stars_enabled':
+            ezt.boolean(settings.enable_user_stars),
+        'can_create_project':
+            ezt.boolean(permissions.CanCreateProject(mr.perms)),
+        'can_create_group':
+            ezt.boolean(permissions.CanCreateGroup(mr.perms)),
+    }
+
+    return page_data
 
   def _DoCommonRequestProcessing(self, request, mr):
     """Do common processing dependent on having the user and project pbs."""
@@ -441,11 +363,15 @@
       self._CheckForMovedProject(mr, request)
       self.AssertBasePermission(mr)
 
+  # pylint: disable=unused-argument
   def _DoPageProcessing(self, mr, nonce):
     """Do user lookups and gather page-specific ezt data."""
     with mr.profiler.Phase('common request data'):
+
       self._DoCommonRequestProcessing(self.request, mr)
+
       self._MaybeRedirectToBrandedDomain(self.request, mr.project_name)
+
       page_data = self.GatherBaseData(mr, nonce)
 
     with mr.profiler.Phase('page processing'):
@@ -463,123 +389,52 @@
     if self.CHECK_SECURITY_TOKEN:
       try:
         xsrf.ValidateToken(
-            request.POST.get('token'), mr.auth.user_id, request.path)
+            request.values.get('token'), mr.auth.user_id, self.request_path)
       except xsrf.TokenIncorrect as err:
         if self.ALLOW_XHR:
-          xsrf.ValidateToken(request.POST.get('token'), mr.auth.user_id, 'xhr')
+          xsrf.ValidateToken(
+              request.values.get('token'), mr.auth.user_id, 'xhr')
         else:
           raise err
 
-    redirect_url = self.ProcessFormData(mr, request.POST)
+    form_values = datastructures.MultiDict(request.values)
+    form_values.update(request.files)
+    redirect_url = self.ProcessFormData(mr, form_values)
 
     # Most forms redirect the user to a new URL on success.  If no
     # redirect_url was returned, the form handler must have already
     # sent a response.  E.g., bounced the user back to the form with
-    # invalid form fields higlighted.
+    # invalid form fields highlighted.
     if redirect_url:
-      self.redirect(redirect_url, abort=True)
+      raise exceptions.RedirectException(redirect_url)
     else:
-      assert self.response.body
+      assert self.response.response
 
-  def _CheckForMovedProject(self, mr, request):
-    """If the project moved, redirect there or to an informational page."""
-    if not mr.project:
-      return  # We are on a site-wide or user page.
-    if not mr.project.moved_to:
-      return  # This project has not moved.
-    admin_url = '/p/%s%s' % (mr.project_name, urls.ADMIN_META)
-    if request.path.startswith(admin_url):
-      return  # It moved, but we are near the page that can un-move it.
-
-    logging.info('project %s has moved: %s', mr.project.project_name,
-                 mr.project.moved_to)
-
-    moved_to = mr.project.moved_to
-    if project_constants.RE_PROJECT_NAME.match(moved_to):
-      # Use the redir query parameter to avoid redirect loops.
-      if mr.redir is None:
-        url = framework_helpers.FormatMovedProjectURL(mr, moved_to)
-        if '?' in url:
-          url += '&redir=1'
-        else:
-          url += '?redir=1'
-        logging.info('trusted move to a new project on our site')
-        self.redirect(url, abort=True)
-
-    logging.info('not a trusted move, will display link to user to click')
-    # Attach the project name as a url param instead of generating a /p/
-    # link to the destination project.
-    url = framework_helpers.FormatAbsoluteURL(
-        mr, urls.PROJECT_MOVED,
-        include_project=False, copy_params=False, project=mr.project_name)
-    self.redirect(url, abort=True)
-
-  def _MaybeRedirectToBrandedDomain(self, request, project_name):
-    """If we are live and the project should be branded, check request host."""
-    if request.params.get('redir'):
-      return  # Avoid any chance of a redirect loop.
-    if not project_name:
-      return
-    needed_domain = framework_helpers.GetNeededDomain(
-        project_name, request.host)
-    if not needed_domain:
-      return
-
-    url = 'https://%s%s' % (needed_domain, request.path_qs)
-    if '?' in url:
-      url += '&redir=1'
-    else:
-      url += '?redir=1'
-    logging.info('branding redirect to url %r', url)
-    self.redirect(url, abort=True)
-
-  def CheckPerm(self, mr, perm, art=None, granted_perms=None):
-    """Return True if the user can use the requested permission."""
-    return servlet_helpers.CheckPerm(
-        mr, perm, art=art, granted_perms=granted_perms)
-
-  def MakePagePerms(self, mr, art, *perm_list, **kwargs):
-    """Make an EZTItem with a set of permissions needed in a given template.
+  def ProcessFormData(self, mr, post_data):
+    """Handle form data and redirect appropriately.
 
     Args:
       mr: commonly used info parsed from the request.
-      art: a project artifact, such as an issue.
-      *perm_list: any number of permission names that are referenced
-          in the EZT template.
-      **kwargs: dictionary that may include 'granted_perms' list of permissions
-          granted to the current user specifically on the current page.
+      post_data: HTML form data from the request.
 
     Returns:
-      An EZTItem with one attribute for each permission and the value
-      of each attribute being an ezt.boolean().  True if the user
-      is permitted to do that action on the given artifact, or
-      False if not.
+      String URL to redirect the user to, or None if response was already sent.
     """
-    granted_perms = kwargs.get('granted_perms')
-    page_perms = template_helpers.EZTItem()
-    for perm in perm_list:
-      setattr(
-          page_perms, perm,
-          ezt.boolean(self.CheckPerm(
-              mr, perm, art=art, granted_perms=granted_perms)))
+    raise servlet_helpers.MethodNotSupportedError()
 
-    return page_perms
+  def _FormHandlerURL(self, path):
+    """Return the form handler for the main form on a page."""
+    if path.endswith('/'):
+      return path + 'edit.do'
+    elif path.endswith('.do'):
+      return path  # This happens as part of PleaseCorrect().
+    else:
+      return path + '.do'
 
-  def AssertBasePermission(self, mr):
-    """Make sure that the logged in user has permission to view this page.
-
-    Subclasses should call super, then check additional permissions
-    and raise a PermissionException if the user is not authorized to
-    do something.
-
-    Args:
-      mr: commonly used info parsed from the request.
-
-    Raises:
-      PermissionException: If the user does not have permisssion to view
-        the current page.
-    """
-    servlet_helpers.AssertBasePermission(mr)
+  # pylint: disable=unused-argument
+  def GatherPageData(self, mr):
+    """Return a dict of page-specific ezt data."""
+    raise servlet_helpers.MethodNotSupportedError()
 
   def GatherBaseData(self, mr, nonce):
     """Return a dict of info used on almost all pages."""
@@ -601,10 +456,9 @@
       is_project_starred = False
       project_view = None
       if mr.project:
-        if permissions.UserCanViewProject(
-            mr.auth.user_pb, mr.auth.effective_ids, mr.project):
+        if permissions.UserCanViewProject(mr.auth.user_pb,
+                                          mr.auth.effective_ids, mr.project):
           is_project_starred = we.IsProjectStarred(mr.project_id)
-          # TODO(jrobbins): should this be a ProjectView?
           project_view = template_helpers.PBProxy(mr.project)
 
     grid_x_attr = None
@@ -616,8 +470,9 @@
           features_bizobj.UsersInvolvedInHotlists([mr.hotlist]))
       hotlist_view = hotlist_views.HotlistView(
           mr.hotlist, mr.perms, mr.auth, mr.viewed_user_auth.user_id,
-          users_by_id, self.services.hotlist_star.IsItemStarredBy(
-            mr.cnxn, mr.hotlist.hotlist_id, mr.auth.user_id))
+          users_by_id,
+          self.services.hotlist_star.IsItemStarredBy(
+              mr.cnxn, mr.hotlist.hotlist_id, mr.auth.user_id))
       grid_x_attr = mr.x.lower()
       grid_y_attr = mr.y.lower()
 
@@ -790,10 +645,6 @@
             None,  # First part of page title
         'title_summary':
             None,  # Appended to title on artifact detail pages
-
-        # TODO(jrobbins): make sure that the templates use
-        # project_read_only for project-mutative actions and if any
-        # uses of read_only remain.
         'project_read_only':
             ezt.boolean(project_read_only),
         'site_read_only':
@@ -836,30 +687,22 @@
         mr.auth.user_id, xsrf.XHR_SERVLET_PATH)
     # Always add other anti-xsrf tokens when the user is logged in.
     if mr.auth.user_id:
-      form_token_path = self._FormHandlerURL(mr.request.path)
+      form_token_path = self._FormHandlerURL(mr.request_path)
       base_data['form_token'] = xsrf.GenerateToken(
-        mr.auth.user_id, form_token_path)
+          mr.auth.user_id, form_token_path)
       base_data['form_token_path'] = form_token_path
 
     return base_data
 
-  def _FormHandlerURL(self, path):
-    """Return the form handler for the main form on a page."""
-    if path.endswith('/'):
-      return path + 'edit.do'
-    elif path.endswith('.do'):
-      return path  # This happens as part of PleaseCorrect().
-    else:
-      return path + '.do'
-
-  def GatherPageData(self, mr):
-    """Return a dict of page-specific ezt data."""
-    raise servlet_helpers.MethodNotSupportedError()
+  def _AddHelpDebugPageData(self, page_data):
+    with self.mr.profiler.Phase('help and debug data'):
+      page_data.update(self.GatherHelpData(self.mr, page_data))
+      page_data.update(self.GatherDebugData(self.mr, page_data))
 
   # pylint: disable=unused-argument
   def GatherHelpData(self, mr, page_data):
     """Return a dict of values to drive on-page user help.
-
+       Subclasses can override this function
     Args:
       mr: common information parsed from the HTTP request.
       page_data: Dictionary of base and page template data.
@@ -870,13 +713,12 @@
     help_data = {
         'cue': None,  # for cues.ezt
         'account_cue': None,  # for cues.ezt
-        }
+    }
     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']
+      dismissed = [pv.name for pv in userprefs.prefs if pv.value == 'true']
       if (mr.auth.user_pb.vacation_message and
           'you_are_on_vacation' not in dismissed):
         help_data['cue'] = 'you_are_on_vacation'
@@ -896,11 +738,12 @@
     """Return debugging info for display at the very bottom of the page."""
     if mr.debug_enabled:
       debug = [servlet_helpers.ContextDebugCollection('Page data', page_data)]
+      debug = [('none', 'recorded')]
       return {
           'dbg': 'on',
           'debug': debug,
           'profiler': mr.profiler,
-          }
+      }
     else:
       if '?' in mr.current_page_url:
         debug_url = mr.current_page_url + '&debug=1'
@@ -911,7 +754,117 @@
           'debug_uri': debug_url,
           'dbg': 'off',
           'debug': [('none', 'recorded')],
-          }
+      }
+
+  def _CheckForMovedProject(self, mr, request):
+    """If the project moved, redirect there or to an informational page."""
+    if not mr.project:
+      return  # We are on a site-wide or user page.
+    if not mr.project.moved_to:
+      return  # This project has not moved.
+    admin_url = '/p/%s%s' % (mr.project_name, urls.ADMIN_META)
+    if self.request_path.startswith(admin_url):
+      return  # It moved, but we are near the page that can un-move it.
+
+    logging.info(
+        'project %s has moved: %s', mr.project.project_name,
+        mr.project.moved_to)
+
+    moved_to = mr.project.moved_to
+    if project_constants.RE_PROJECT_NAME.match(moved_to):
+      # Use the redir query parameter to avoid redirect loops.
+      if mr.redir is None:
+        url = framework_helpers.FormatMovedProjectURL(mr, moved_to)
+        if '?' in url:
+          url += '&redir=1'
+        else:
+          url += '?redir=1'
+        logging.info('trusted move to a new project on our site')
+        self.redirect(url, abort=True)
+
+    logging.info('not a trusted move, will display link to user to click')
+    # Attach the project name as a url param instead of generating a /p/
+    # link to the destination project.
+    url = framework_helpers.FormatAbsoluteURL(
+        mr,
+        urls.PROJECT_MOVED,
+        include_project=False,
+        copy_params=False,
+        project=mr.project_name)
+    self.redirect(url, abort=True)
+
+  def _MaybeRedirectToBrandedDomain(self, request, project_name):
+    """If we are live and the project should be branded, check request host."""
+    if request.values.get('redir'):
+      return  # Avoid any chance of a redirect loop.
+    if not project_name:
+      return
+    needed_domain = framework_helpers.GetNeededDomain(
+        project_name, request.host)
+    if not needed_domain:
+      return
+
+    url = 'https://%s%s' % (needed_domain, request.full_path)
+    if '?' in url:
+      url += '&redir=1'
+    else:
+      url += '?redir=1'
+    logging.info('branding redirect to url %r', url)
+    self.redirect(url, abort=True)
+
+  def AssertBasePermission(self, mr):
+    """Make sure that the logged in user has permission to view this page.
+
+    Subclasses should call super, then check additional permissions
+    and raise a PermissionException if the user is not authorized to
+    do something.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Raises:
+      PermissionException: If the user does not have permisssion to view
+        the current page.
+    """
+    servlet_helpers.AssertBasePermission(mr)
+
+  def CheckPerm(self, mr, perm, art=None, granted_perms=None):
+    """Return True if the user can use the requested permission."""
+    return servlet_helpers.CheckPerm(
+        mr, perm, art=art, granted_perms=granted_perms)
+
+  def MakePagePerms(self, mr, art, *perm_list, **kwargs):
+    """Make an EZTItem with a set of permissions needed in a given template.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      art: a project artifact, such as an issue.
+      *perm_list: any number of permission names that are referenced
+          in the EZT template.
+      **kwargs: dictionary that may include 'granted_perms' list of permissions
+          granted to the current user specifically on the current page.
+
+    Returns:
+      An EZTItem with one attribute for each permission and the value
+      of each attribute being an ezt.boolean().  True if the user
+      is permitted to do that action on the given artifact, or
+      False if not.
+    """
+    granted_perms = kwargs.get('granted_perms')
+    page_perms = template_helpers.EZTItem()
+    for perm in perm_list:
+      setattr(
+          page_perms, perm,
+          ezt.boolean(
+              self.CheckPerm(mr, perm, art=art, granted_perms=granted_perms)))
+
+    return page_perms
+
+  def redirect(self, url, abort=False):
+    if abort:
+      return flask.redirect(url, code=302)
+    else:
+      return flask.redirect(url)
 
   def PleaseCorrect(self, mr, **echo_data):
     """Show the same form again so that the user can correct their input."""
@@ -927,3 +880,6 @@
           now - framework_constants.VISIT_RESOLUTION):
         user_pb.last_visit_timestamp = now
         self.services.user.UpdateUser(mr.cnxn, user_pb.user_id, user_pb)
+
+  def abort(self, code, context=""):
+    return flask.abort(code, context)