Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/project/__init__.py b/project/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/project/__init__.py
@@ -0,0 +1 @@
+
diff --git a/project/peopledetail.py b/project/peopledetail.py
new file mode 100644
index 0000000..3c4846b
--- /dev/null
+++ b/project/peopledetail.py
@@ -0,0 +1,271 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A class to display details about each project member."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from framework import exceptions
+from framework import framework_bizobj
+from framework import framework_helpers
+from framework import framework_views
+from framework import jsonfeed
+from framework import permissions
+from framework import servlet
+from framework import template_helpers
+from framework import urls
+from project import project_helpers
+from project import project_views
+
+CHECKBOX_PERMS = [
+    permissions.VIEW,
+    permissions.COMMIT,
+    permissions.CREATE_ISSUE,
+    permissions.ADD_ISSUE_COMMENT,
+    permissions.EDIT_ISSUE,
+    permissions.EDIT_ISSUE_OWNER,
+    permissions.EDIT_ISSUE_SUMMARY,
+    permissions.EDIT_ISSUE_STATUS,
+    permissions.EDIT_ISSUE_CC,
+    permissions.DELETE_ISSUE,
+    permissions.DELETE_OWN,
+    permissions.DELETE_ANY,
+    permissions.EDIT_ANY_MEMBER_NOTES,
+    permissions.MODERATE_SPAM,
+    ]
+
+
+class PeopleDetail(servlet.Servlet):
+  """People detail page documents one partipant's involvement in a project."""
+
+  _PAGE_TEMPLATE = 'project/people-detail-page.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PEOPLE
+
+  def AssertBasePermission(self, mr):
+    """Check that the user is allowed to access this servlet."""
+    super(PeopleDetail, self).AssertBasePermission(mr)
+    member_id = self.ValidateMemberID(mr.cnxn, mr.specified_user_id, mr.project)
+    # For now, contributors who cannot view other contributors are further
+    # restricted from viewing any part of the member list or detail pages.
+    if (not permissions.CanViewContributorList(mr, mr.project) and
+        member_id != mr.auth.user_id):
+      raise permissions.PermissionException(
+          'User is not allowed to view other people\'s details')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+
+    member_id = self.ValidateMemberID(mr.cnxn, mr.specified_user_id, mr.project)
+    group_ids = self.services.usergroup.DetermineWhichUserIDsAreGroups(
+        mr.cnxn, [member_id])
+    users_by_id = framework_views.MakeAllUserViews(
+        mr.cnxn, self.services.user, [member_id])
+    framework_views.RevealAllEmailsToMembers(
+        mr.cnxn, self.services, mr.auth, users_by_id, mr.project)
+
+    project_commitments = self.services.project.GetProjectCommitments(
+        mr.cnxn, mr.project_id)
+    (ac_exclusion_ids, no_expand_ids
+     ) = self.services.project.GetProjectAutocompleteExclusion(
+        mr.cnxn, mr.project_id)
+    member_view = project_views.MemberView(
+        mr.auth.user_id, member_id, users_by_id[member_id], mr.project,
+        project_commitments,
+        ac_exclusion=(member_id in ac_exclusion_ids),
+        no_expand=(member_id in no_expand_ids),
+        is_group=(member_id in group_ids))
+
+    member_user = self.services.user.GetUser(mr.cnxn, member_id)
+    # This ignores indirect memberships, which is ok because we are viewing
+    # the page for a member directly involved in the project
+    role_perms = permissions.GetPermissions(
+        member_user, {member_id}, mr.project)
+
+    # TODO(jrobbins): clarify in the UI which permissions are built-in to
+    # the user's direct role, vs. which are granted via a group membership,
+    # vs. which ones are extra_perms that have been added specifically for
+    # this user.
+    member_perms = template_helpers.EZTItem()
+    for perm in CHECKBOX_PERMS:
+      setattr(member_perms, perm,
+              ezt.boolean(role_perms.HasPerm(perm, member_id, mr.project)))
+
+    displayed_extra_perms = [perm for perm in member_view.extra_perms
+                             if perm not in CHECKBOX_PERMS]
+
+    viewing_self = mr.auth.user_id == member_id
+    warn_abandonment = (viewing_self and
+                        permissions.ShouldCheckForAbandonment(mr))
+
+    return {
+        'subtab_mode': None,
+        'member': member_view,
+        'role_perms': role_perms,
+        'member_perms': member_perms,
+        'displayed_extra_perms': displayed_extra_perms,
+        'offer_edit_perms': ezt.boolean(self.CanEditPerms(mr)),
+        'offer_edit_member_notes': ezt.boolean(
+            self.CanEditMemberNotes(mr, member_id)),
+        'offer_remove_role': ezt.boolean(self.CanRemoveRole(mr, member_id)),
+        'expand_perms': ezt.boolean(mr.auth.user_pb.keep_people_perms_open),
+        'warn_abandonment': ezt.boolean(warn_abandonment),
+        'total_num_owners': len(mr.project.owner_ids),
+        }
+
+  def ValidateMemberID(self, cnxn, member_id, project):
+    """Lookup a project member by user_id.
+
+    Args:
+      cnxn: connection to SQL database.
+      member_id: int user_id, same format as user profile page.
+      project: the current Project PB.
+
+    Returns:
+      The user ID of the project member. Raises an exception if the username
+      cannot be looked up, or if that user is not in the project.
+    """
+    if not member_id:
+      self.abort(404, 'project member not specified')
+
+    member_username = None
+    try:
+      member_username = self.services.user.LookupUserEmail(cnxn, member_id)
+    except exceptions.NoSuchUserException:
+      logging.info('user_id %s not found', member_id)
+
+    if not member_username:
+      logging.info('There is no such user id %r', member_id)
+      self.abort(404, 'project member not found')
+
+    if not framework_bizobj.UserIsInProject(project, {member_id}):
+      logging.info('User %r is not a member of %r',
+                   member_username, project.project_name)
+      self.abort(404, 'project member not found')
+
+    return member_id
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the posted form."""
+    # 1. Parse and validate user input.
+    user_id, role, extra_perms, notes, ac_exclusion, no_expand = (
+        self.ParsePersonData(mr, post_data))
+    member_id = self.ValidateMemberID(mr.cnxn, user_id, mr.project)
+
+    # 2. Call services layer to save changes.
+    if 'remove' in post_data:
+      self.ProcessRemove(mr, member_id)
+    else:
+      self.ProcessSave(
+          mr, role, extra_perms, notes, member_id, ac_exclusion, no_expand)
+
+    # 3. Determine the next page in the UI flow.
+    if 'remove' in post_data:
+      return framework_helpers.FormatAbsoluteURL(
+          mr, urls.PEOPLE_LIST, saved=1, ts=int(time.time()))
+    else:
+      return framework_helpers.FormatAbsoluteURL(
+          mr, urls.PEOPLE_DETAIL, u=user_id, saved=1, ts=int(time.time()))
+
+  def ProcessRemove(self, mr, member_id):
+    """Process the posted form when the user pressed 'Remove'."""
+    if not self.CanRemoveRole(mr, member_id):
+      raise permissions.PermissionException(
+          'User is not allowed to remove this member from the project')
+
+    self.RemoveRole(mr.cnxn, mr.project, member_id)
+
+  def ProcessSave(
+      self, mr, role, extra_perms, notes, member_id, ac_exclusion,
+      no_expand):
+    """Process the posted form when the user pressed 'Save'."""
+    if (not self.CanEditPerms(mr) and
+        not self.CanEditMemberNotes(mr, member_id)):
+      raise permissions.PermissionException(
+          'User is not allowed to edit people in this project')
+
+    if self.CanEditPerms(mr):
+      self.services.project.UpdateExtraPerms(
+          mr.cnxn, mr.project_id, member_id, extra_perms)
+      self.UpdateRole(mr.cnxn, mr.project, role, member_id)
+
+    if self.CanEditMemberNotes(mr, member_id):
+      self.services.project.UpdateCommitments(
+          mr.cnxn, mr.project_id, member_id, notes)
+
+    if self.CanEditPerms(mr):
+      self.services.project.UpdateProjectAutocompleteExclusion(
+          mr.cnxn, mr.project_id, member_id, ac_exclusion, no_expand)
+
+  def CanEditMemberNotes(self, mr, member_id):
+    """Return true if the logged in user can edit the current user's notes."""
+    return (self.CheckPerm(mr, permissions.EDIT_ANY_MEMBER_NOTES) or
+            member_id == mr.auth.user_id)
+
+  def CanEditPerms(self, mr):
+    """Return true if the logged in user can edit the current user's perms."""
+    return self.CheckPerm(mr, permissions.EDIT_PROJECT)
+
+  def CanRemoveRole(self, mr, member_id):
+    """Return true if the logged in user can remove the current user's role."""
+    return (self.CheckPerm(mr, permissions.EDIT_PROJECT) or
+            member_id == mr.auth.user_id)
+
+  def ParsePersonData(self, mr, post_data):
+    """Parse the POST data for a project member.
+
+    Args:
+      mr: common information parsed from the user's request.
+      post_data: dictionary of lists of values for each HTML
+          form field.
+
+    Returns:
+      A tuple with user_id, role, extra_perms, and notes.
+    """
+    if not mr.specified_user_id:
+      raise exceptions.InputException('Field user_id is missing')
+
+    role = post_data.get('role', '').lower()
+    extra_perms = []
+    for ep in post_data.getall('extra_perms'):
+      perm = framework_bizobj.CanonicalizeLabel(ep)
+      # Perms with leading underscores are reserved.
+      perm = perm.strip('_')
+      if perm:
+        extra_perms.append(perm)
+
+    notes = post_data.get('notes', '').strip()
+    ac_exclusion = not post_data.get('ac_include', False)
+    no_expand = not post_data.get('ac_expand', False)
+    return (mr.specified_user_id, role, extra_perms, notes, ac_exclusion,
+            no_expand)
+
+  def RemoveRole(self, cnxn, project, member_id):
+    """Remove the given member from the project."""
+    (owner_ids, committer_ids,
+     contributor_ids) = project_helpers.MembersWithoutGivenIDs(
+         project, {member_id})
+    self.services.project.UpdateProjectRoles(
+        cnxn, project.project_id, owner_ids, committer_ids, contributor_ids)
+
+  def UpdateRole(self, cnxn, project, role, member_id):
+    """If the user's role was changed, update that in the Project."""
+    if not role:
+      return  # Role was not in the form data
+
+    if role == framework_helpers.GetRoleName({member_id}, project).lower():
+      return  # No change needed
+
+    (owner_ids, committer_ids,
+     contributor_ids) = project_helpers.MembersWithGivenIDs(
+         project, {member_id}, role)
+
+    self.services.project.UpdateProjectRoles(
+        cnxn, project.project_id, owner_ids, committer_ids, contributor_ids)
diff --git a/project/peoplelist.py b/project/peoplelist.py
new file mode 100644
index 0000000..0db5ee6
--- /dev/null
+++ b/project/peoplelist.py
@@ -0,0 +1,234 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A class to display a paginated list of project members.
+
+This page lists owners, members, and contribtors.  For each
+member, we display their username, permission system role + extra
+perms, and notes on their involvement in the project.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from businesslogic import work_env
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import paginate
+from framework import permissions
+from framework import servlet
+from framework import urls
+from project import project_helpers
+from project import project_views
+
+MEMBERS_PER_PAGE = 50
+
+
+class PeopleList(servlet.Servlet):
+  """People list page shows a paginatied list of project members."""
+
+  _PAGE_TEMPLATE = 'project/people-list-page.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PEOPLE
+
+  def AssertBasePermission(self, mr):
+    super(PeopleList, self).AssertBasePermission(mr)
+    # For now, contributors who cannot view other contributors are further
+    # restricted from viewing any part of the member list or detail pages.
+    if not permissions.CanViewContributorList(mr, mr.project):
+      raise permissions.PermissionException(
+          'User is not allowed to view the project people list')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+    all_members = (mr.project.owner_ids +
+                   mr.project.committer_ids +
+                   mr.project.contributor_ids)
+
+    with mr.profiler.Phase('gathering members on this page'):
+      users_by_id = framework_views.MakeAllUserViews(
+          mr.cnxn, self.services.user, all_members)
+      framework_views.RevealAllEmailsToMembers(
+          mr.cnxn, self.services, mr.auth, users_by_id, mr.project)
+
+    # TODO(jrobbins): re-implement FindUntrustedGroups()
+    untrusted_user_group_proxies = []
+
+    with mr.profiler.Phase('gathering commitments (notes)'):
+      project_commitments = self.services.project.GetProjectCommitments(
+          mr.cnxn, mr.project_id)
+
+    with mr.profiler.Phase('gathering autocomple exclusion ids'):
+      group_ids = set(self.services.usergroup.DetermineWhichUserIDsAreGroups(
+        mr.cnxn, all_members))
+      (ac_exclusion_ids, no_expand_ids
+       ) = self.services.project.GetProjectAutocompleteExclusion(
+          mr.cnxn, mr.project_id)
+
+    with mr.profiler.Phase('making member views'):
+      owner_views = self._MakeMemberViews(
+          mr.auth.user_id, users_by_id, mr.project.owner_ids, mr.project,
+          project_commitments, ac_exclusion_ids, no_expand_ids, group_ids)
+      committer_views = self._MakeMemberViews(
+          mr.auth.user_id, users_by_id, mr.project.committer_ids, mr.project,
+          project_commitments, ac_exclusion_ids, no_expand_ids, group_ids)
+      contributor_views = self._MakeMemberViews(
+          mr.auth.user_id, users_by_id, mr.project.contributor_ids, mr.project,
+          project_commitments, ac_exclusion_ids, no_expand_ids, group_ids)
+      all_member_views = owner_views + committer_views + contributor_views
+
+    url_params = [(name, mr.GetParam(name)) for name in
+                  framework_helpers.RECOGNIZED_PARAMS]
+    pagination = paginate.ArtifactPagination(
+        all_member_views, mr.GetPositiveIntParam('num', MEMBERS_PER_PAGE),
+        mr.GetPositiveIntParam('start'), mr.project_name, urls.PEOPLE_LIST,
+        url_params=url_params)
+
+    offer_membership_editing = mr.perms.HasPerm(
+        permissions.EDIT_PROJECT, mr.auth.user_id, mr.project)
+
+    check_abandonment = permissions.ShouldCheckForAbandonment(mr)
+
+    newly_added_views = [mv for mv in all_member_views
+                         if str(mv.user.user_id) in mr.GetParam('new', [])]
+
+    return {
+        'pagination': pagination,
+        'subtab_mode': None,
+        'offer_membership_editing': ezt.boolean(offer_membership_editing),
+        'initial_add_members': '',
+        'initially_expand_form': ezt.boolean(False),
+        'untrusted_user_groups': untrusted_user_group_proxies,
+        'check_abandonment': ezt.boolean(check_abandonment),
+        'total_num_owners': len(mr.project.owner_ids),
+        'newly_added_views': newly_added_views,
+        'is_hotlist': ezt.boolean(False),
+        }
+
+  def GatherHelpData(self, mr, page_data):
+    """Return a dict of values to drive on-page user help.
+
+    Args:
+      mr: common information parsed from the HTTP request.
+      page_data: Dictionary of base and page template data.
+
+    Returns:
+      A dict of values to drive on-page user help, to be added to page_data.
+    """
+    help_data = super(PeopleList, self).GatherHelpData(mr, page_data)
+    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']
+    if (mr.auth.user_id and
+        not framework_bizobj.UserIsInProject(
+            mr.project, mr.auth.effective_ids) and
+        'how_to_join_project' not in dismissed):
+      help_data['cue'] = 'how_to_join_project'
+
+    return help_data
+
+  def _MakeMemberViews(
+      self, logged_in_user_id, users_by_id, member_ids, project,
+      project_commitments, ac_exclusion_ids, no_expand_ids, group_ids):
+    """Return a sorted list of MemberViews for display by EZT."""
+    member_views = [
+        project_views.MemberView(
+            logged_in_user_id, member_id, users_by_id[member_id], project,
+            project_commitments,
+            ac_exclusion=(member_id in ac_exclusion_ids),
+            no_expand=(member_id in no_expand_ids),
+            is_group=(member_id in group_ids))
+        for member_id in member_ids]
+    member_views.sort(key=lambda mv: mv.user.email)
+    return member_views
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the posted form."""
+    permit_edit = mr.perms.HasPerm(
+        permissions.EDIT_PROJECT, mr.auth.user_id, mr.project)
+    if not permit_edit:
+      raise permissions.PermissionException(
+          'User is not permitted to edit project membership')
+
+    if 'addbtn' in post_data:
+      return self.ProcessAddMembers(mr, post_data)
+    elif 'removebtn' in post_data:
+      return self.ProcessRemoveMembers(mr, post_data)
+
+  def ProcessAddMembers(self, mr, post_data):
+    """Process the user's request to add members.
+
+    Args:
+      mr: common information parsed from the HTTP request.
+      post_data: dictionary of form data.
+
+    Returns:
+      String URL to redirect the user to after processing.
+    """
+    # 1. Parse and validate user input.
+    new_member_ids = project_helpers.ParseUsernames(
+        mr.cnxn, self.services.user, post_data.get('addmembers'))
+    role = post_data['role']
+
+    (owner_ids, committer_ids,
+     contributor_ids) = project_helpers.MembersWithGivenIDs(
+        mr.project, new_member_ids, role)
+
+    total_people = len(owner_ids) + len(committer_ids) + len(contributor_ids)
+    if total_people > framework_constants.MAX_PROJECT_PEOPLE:
+      mr.errors.addmembers = (
+          'Too many project members.  The combined limit is %d.' %
+          framework_constants.MAX_PROJECT_PEOPLE)
+
+    # 2. Call services layer to save changes.
+    if not mr.errors.AnyErrors():
+      self.services.project.UpdateProjectRoles(
+          mr.cnxn, mr.project.project_id,
+          owner_ids, committer_ids, contributor_ids)
+
+    # 3. Determine the next page in the UI flow.
+    if mr.errors.AnyErrors():
+      add_members_str = post_data.get('addmembers', '')
+      self.PleaseCorrect(
+          mr, initial_add_members=add_members_str, initially_expand_form=True)
+    else:
+      return framework_helpers.FormatAbsoluteURL(
+          mr, urls.PEOPLE_LIST, saved=1, ts=int(time.time()),
+          new=','.join([str(u) for u in new_member_ids]))
+
+  def ProcessRemoveMembers(self, mr, post_data):
+    """Process the user's request to remove members.
+
+    Args:
+      mr: common information parsed from the HTTP request.
+      post_data: dictionary of form data.
+
+    Returns:
+      String URL to redirect the user to after processing.
+    """
+    # 1. Parse and validate user input.
+    remove_strs = post_data.getall('remove')
+    logging.info('remove_strs = %r', remove_strs)
+    remove_ids = set(
+        self.services.user.LookupUserIDs(mr.cnxn, remove_strs).values())
+    (owner_ids, committer_ids,
+     contributor_ids) = project_helpers.MembersWithoutGivenIDs(
+        mr.project, remove_ids)
+
+    # 2. Call services layer to save changes.
+    self.services.project.UpdateProjectRoles(
+        mr.cnxn, mr.project.project_id, owner_ids, committer_ids,
+        contributor_ids)
+
+    # 3. Determine the next page in the UI flow.
+    return framework_helpers.FormatAbsoluteURL(
+        mr, urls.PEOPLE_LIST, saved=1, ts=int(time.time()))
diff --git a/project/project_constants.py b/project/project_constants.py
new file mode 100644
index 0000000..f483b1f
--- /dev/null
+++ b/project/project_constants.py
@@ -0,0 +1,30 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Some constants used for managing Monorail Projects."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import re
+
+PROJECT_NAME_PATTERN = '[a-z0-9][-a-z0-9]*[a-z0-9]'
+
+MAX_PROJECT_NAME_LENGTH = 63
+
+# Pattern to match a valid project name.  Users of this pattern MUST use
+# the re.VERBOSE flag or the whitespace and comments we be considered
+# significant and the pattern will not work.  See "re" module documentation.
+_RE_PROJECT_NAME_PATTERN_VERBOSE = r"""
+  (?=[-a-z0-9]*[a-z][-a-z0-9]*)   # Lookahead to make sure there is at least
+                                  # one letter in the whole name.
+  [a-z0-9]                        # Start with a letter or digit.
+  [-a-z0-9]*                      # Follow with any number of valid characters.
+  [a-z0-9]                        # End with a letter or digit.
+"""
+
+# Compiled regexp to match the project name and nothing more before or after.
+RE_PROJECT_NAME = re.compile(
+    '^%s$' % _RE_PROJECT_NAME_PATTERN_VERBOSE, re.VERBOSE)
diff --git a/project/project_helpers.py b/project/project_helpers.py
new file mode 100644
index 0000000..23a2d46
--- /dev/null
+++ b/project/project_helpers.py
@@ -0,0 +1,236 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Helper functions and classes used by the project pages."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import re
+
+import settings
+
+from google.appengine.api import app_identity
+from framework import framework_bizobj
+from framework import framework_views
+from framework import gcs_helpers
+from framework import permissions
+from project import project_constants
+from project import project_views
+from proto import project_pb2
+
+
+_RE_EMAIL_SEPARATORS = re.compile(r'\s|,|;')
+
+
+def BuildProjectMembers(cnxn, project, user_service):
+  """Gather data for the members section of a project page.
+
+  Args:
+    cnxn: connection to SQL database.
+    project: Project PB of current project.
+    user_service: an instance of UserService for user persistence.
+
+  Returns:
+    A dictionary suitable for use with EZT.
+  """
+  # First, get all needed info on all users in one batch of requests.
+  users_by_id = framework_views.MakeAllUserViews(
+      cnxn, user_service, AllProjectMembers(project))
+
+  # Second, group the user proxies by role for display.
+  owner_proxies = [users_by_id[owner_id]
+                   for owner_id in project.owner_ids]
+  committer_proxies = [users_by_id[committer_id]
+                       for committer_id in project.committer_ids]
+  contributor_proxies = [users_by_id[contrib_id]
+                         for contrib_id in project.contributor_ids]
+
+  return {
+      'owners': owner_proxies,
+      'committers': committer_proxies,
+      'contributors': contributor_proxies,
+      'all_members': list(users_by_id.values()),
+      }
+
+
+def BuildProjectAccessOptions(project):
+  """Return a list of project access values for use in an HTML menu.
+
+  Args:
+    project: current Project PB, or None when creating a new project.
+
+  Returns:
+    A list of ProjectAccessView objects that can be used in EZT.
+  """
+  access_levels = [project_pb2.ProjectAccess.ANYONE,
+                   project_pb2.ProjectAccess.MEMBERS_ONLY]
+  access_views = []
+  for access in access_levels:
+    # Offer the allowed access levels.  When editing an existing project,
+    # its current access level may always be kept, even if it is no longer
+    # in the list of allowed access levels for new projects.
+    if (access in settings.allowed_access_levels or
+        (project and access == project.access)):
+      access_views.append(project_views.ProjectAccessView(access))
+
+  return access_views
+
+
+def ParseUsernames(cnxn, user_service, usernames_text):
+  """Parse all usernames from a text field and return a list of user IDs.
+
+  Args:
+    cnxn: connection to SQL database.
+    user_service: an instance of UserService for user persistence.
+    usernames_text: string that the user entered into a form field for a list
+        of email addresses.  Or, None if the browser did not send that value.
+
+  Returns:
+    A set of user IDs for the users named.  Or, an empty set if the
+    usernames_field was not in post_data.
+  """
+  if not usernames_text:  # The user did not enter any addresses.
+    return set()
+
+  email_list = _RE_EMAIL_SEPARATORS.split(usernames_text)
+  # skip empty strings between consecutive separators
+  email_list = [email for email in email_list if email]
+
+  id_dict = user_service.LookupUserIDs(cnxn, email_list, autocreate=True)
+  return set(id_dict.values())
+
+
+def ParseProjectAccess(project, access_num_str):
+  """Parse and validate the "access" field out of post_data.
+
+  Args:
+    project: Project PB for the project that was edited, or None if the
+        user is creating a new project.
+    access_num_str: string of digits from the users POST that identifies
+       the desired project access level.  Or, None if that widget was not
+       offered to the user.
+
+  Returns:
+    An enum project access level, or None if the user did not specify
+    any value or if the value specified was invalid.
+  """
+  access = None
+  if access_num_str:
+    access_number = int(access_num_str)
+    available_access_levels = BuildProjectAccessOptions(project)
+    allowed_access_choices = [access_view.key for access_view
+                              in available_access_levels]
+    if access_number in allowed_access_choices:
+      access = project_pb2.ProjectAccess(access_number)
+
+  return access
+
+
+def MembersWithoutGivenIDs(project, exclude_ids):
+  """Return three lists of member user IDs, with member_ids not in them."""
+  owner_ids = [user_id for user_id in project.owner_ids
+               if user_id not in exclude_ids]
+  committer_ids = [user_id for user_id in project.committer_ids
+                   if user_id not in exclude_ids]
+  contributor_ids = [user_id for user_id in project.contributor_ids
+                     if user_id not in exclude_ids]
+
+  return owner_ids, committer_ids, contributor_ids
+
+
+def MembersWithGivenIDs(project, new_member_ids, role):
+  """Return three lists of member IDs with the new IDs in the right one.
+
+  Args:
+    project: Project PB for the project to get current members from.
+    new_member_ids: set of user IDs for members being added.
+    role: string name of the role that new_member_ids should be granted.
+
+  Returns:
+    Three lists of member IDs with new_member_ids added to the appropriate
+    list and removed from any other role.
+
+  Raises:
+    ValueError: if the role is not one of owner, committer, or contributor.
+  """
+  owner_ids, committer_ids, contributor_ids = MembersWithoutGivenIDs(
+      project, new_member_ids)
+
+  if role == 'owner':
+    owner_ids.extend(new_member_ids)
+  elif role == 'committer':
+    committer_ids.extend(new_member_ids)
+  elif role == 'contributor':
+    contributor_ids.extend(new_member_ids)
+  else:
+    raise ValueError()
+
+  return owner_ids, committer_ids, contributor_ids
+
+
+def UsersInvolvedInProject(project):
+  """Return a set of all user IDs referenced in the Project."""
+  result = set()
+  result.update(project.owner_ids)
+  result.update(project.committer_ids)
+  result.update(project.contributor_ids)
+  result.update([perm.member_id for perm in project.extra_perms])
+  return result
+
+
+def UsersWithPermsInProject(project, perms_needed, users_by_id,
+                            effective_ids_by_user):
+  # Users that have the given permission are stored in direct_users_for_perm,
+  # users whose effective ids have the given permission are stored in
+  # indirect_users_for_perm.
+  direct_users_for_perm = {perm: set() for perm in perms_needed}
+  indirect_users_for_perm = {perm: set() for perm in perms_needed}
+
+  # Iterate only over users that have extra permissions, so we don't
+  # have to search the extra perms more than once for each user.
+  for extra_perm_pb in project.extra_perms:
+    extra_perms = set(perm.lower() for perm in extra_perm_pb.perms)
+    for perm, users in direct_users_for_perm.items():
+      if perm.lower() in extra_perms:
+        users.add(extra_perm_pb.member_id)
+
+  # Then, iterate over all users, but don't compute extra permissions.
+  for user_id, user_view in users_by_id.items():
+    effective_ids = effective_ids_by_user[user_id].union([user_id])
+    user_perms = permissions.GetPermissions(
+        user_view.user, effective_ids, project)
+    for perm, users in direct_users_for_perm.items():
+      if not effective_ids.isdisjoint(users):
+        indirect_users_for_perm[perm].add(user_id)
+      if user_perms.HasPerm(perm, None, None, []):
+        users.add(user_id)
+
+  for perm, users in direct_users_for_perm.items():
+    users.update(indirect_users_for_perm[perm])
+
+  return direct_users_for_perm
+
+
+def GetThumbnailUrl(gcs_id):
+  # type: (str) -> str
+  """Derive the thumbnail url for a given GCS object ID."""
+  bucket_name = app_identity.get_default_gcs_bucket_name()
+  return gcs_helpers.SignUrl(bucket_name, gcs_id + '-thumbnail')
+
+
+def IsValidProjectName(s):
+  # type: (string) -> bool
+  """Return true if the given string is a valid project name."""
+  return (
+      project_constants.RE_PROJECT_NAME.match(s) and
+      len(s) <= project_constants.MAX_PROJECT_NAME_LENGTH)
+
+
+def AllProjectMembers(project):
+  # type: (proto.project_pb2.Project) -> Sequence[int]
+  """Return a list of user IDs of all members in the given project."""
+  return project.owner_ids + project.committer_ids + project.contributor_ids
diff --git a/project/project_views.py b/project/project_views.py
new file mode 100644
index 0000000..e8698eb
--- /dev/null
+++ b/project/project_views.py
@@ -0,0 +1,125 @@
+# 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 projects in EZT."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from framework import framework_constants
+from framework import framework_helpers
+from framework import permissions
+from framework import template_helpers
+from framework import timestr
+from framework import urls
+from proto import project_pb2
+
+
+class ProjectAccessView(object):
+  """Object for project access information that can be easily used in EZT."""
+
+  ACCESS_NAMES = {
+      project_pb2.ProjectAccess.ANYONE: 'Anyone on the Internet',
+      project_pb2.ProjectAccess.MEMBERS_ONLY: 'Project Members',
+      }
+
+  def __init__(self, project_access_enum):
+    self.key = int(project_access_enum)
+    self.name = self.ACCESS_NAMES[project_access_enum]
+
+
+class ProjectView(template_helpers.PBProxy):
+  """View object to make it easy to display a search result in EZT."""
+
+  _MAX_SUMMARY_CHARS = 70
+  _LIMITED_DESCRIPTION_CHARS = 500
+
+  def __init__(self, pb, starred=False, now=None, num_stars=None,
+               membership_desc=None):
+    super(ProjectView, self).__init__(pb)
+
+    self.limited_summary = template_helpers.FitUnsafeText(
+        pb.summary, self._MAX_SUMMARY_CHARS)
+
+    self.limited_description = template_helpers.FitUnsafeText(
+        pb.description, self._LIMITED_DESCRIPTION_CHARS)
+
+    self.state_name = str(pb.state)  # Gives the enum name
+    self.relative_home_url = '/p/%s' % pb.project_name
+
+    if now is None:
+      now = time.time()
+
+    last_full_hour = now - (now % framework_constants.SECS_PER_HOUR)
+    self.cached_content_timestamp = max(
+        pb.cached_content_timestamp, last_full_hour)
+    self.last_updated_exists = ezt.boolean(pb.recent_activity)
+    course_grain, fine_grain = timestr.GetHumanScaleDate(pb.recent_activity)
+    if course_grain == 'Older':
+      self.recent_activity = fine_grain
+    else:
+      self.recent_activity = course_grain
+
+    self.starred = ezt.boolean(starred)
+
+    self.num_stars = num_stars
+    self.plural = '' if num_stars == 1 else 's'
+    self.membership_desc = membership_desc
+
+
+class MemberView(object):
+  """EZT-view of details of how a person is participating in a project."""
+
+  def __init__(
+    self, logged_in_user_id, member_id, user_view, project,
+    project_commitments, effective_ids=None, ac_exclusion=False,
+    no_expand=False, is_group=False):
+    """Initialize a MemberView with the given information.
+
+    Args:
+      logged_in_user_id: int user ID of the viewing user, or 0 for anon.
+      member_id: int user ID of the project member being viewed.
+      user_view: UserView object for this member.
+      project: Project PB for the currently viewed project.
+      project_commitments: ProjectCommitments PB for the currently viewed
+          project, or None if commitments are not to be displayed.
+      effective_ids: optional set of user IDs for this user, if supplied
+          we show the highest role that they have via any group membership.
+      ac_exclusion: True when this member should not be in autocomplete.
+      no_expand: True for user groups that should not expand when generating
+          autocomplete options.
+      is_group: True if this user is actually a user group.
+    """
+    self.viewing_self = ezt.boolean(logged_in_user_id == member_id)
+
+    self.user = user_view
+    member_qs_param = user_view.user_id
+    self.detail_url = '/p/%s%s?u=%s' % (
+        project.project_name, urls.PEOPLE_DETAIL, member_qs_param)
+    self.role = framework_helpers.GetRoleName(
+        effective_ids or {member_id}, project)
+    self.extra_perms = permissions.GetExtraPerms(project, member_id)
+    self.notes = None
+    if project_commitments is not None:
+      for commitment in project_commitments.commitments:
+        if commitment.member_id == member_id:
+          self.notes = commitment.notes
+          break
+
+    # Attributes needed by table_view_helpers.py
+    self.labels = []
+    self.derived_labels = []
+
+    self.ac_include = ezt.boolean(not ac_exclusion)
+    self.ac_expand = ezt.boolean(not no_expand)
+
+    self.is_group = ezt.boolean(is_group)
+    self.is_service_account = ezt.boolean(framework_helpers.IsServiceAccount(
+        self.user.email))
diff --git a/project/projectadmin.py b/project/projectadmin.py
new file mode 100644
index 0000000..887d3fc
--- /dev/null
+++ b/project/projectadmin.py
@@ -0,0 +1,192 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Servlets for project administration main subtab."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+from six import string_types
+from third_party import cloudstorage
+import ezt
+
+from businesslogic import work_env
+from framework import emailfmt
+from framework import framework_bizobj
+from framework import framework_constants
+from framework import framework_helpers
+from framework import gcs_helpers
+from framework import permissions
+from framework import servlet
+from framework import urls
+from framework import validate
+from project import project_helpers
+from project import project_views
+from tracker import tracker_views
+
+
+_MSG_INVALID_EMAIL_ADDRESS = 'Invalid email address'
+_MSG_DESCRIPTION_MISSING = 'Description is missing'
+_MSG_SUMMARY_MISSING = 'Summary is missing'
+
+
+class ProjectAdmin(servlet.Servlet):
+  """A page with project configuration options for the Project Owner(s)."""
+
+  _PAGE_TEMPLATE = 'project/project-admin-page.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ADMIN
+
+  def AssertBasePermission(self, mr):
+    super(ProjectAdmin, self).AssertBasePermission(mr)
+    if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+      raise permissions.PermissionException(
+          'User is not allowed to administer this project')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+    available_access_levels = project_helpers.BuildProjectAccessOptions(
+        mr.project)
+    offer_access_level = len(available_access_levels) > 1
+    access_view = project_views.ProjectAccessView(mr.project.access)
+
+    return {
+        'admin_tab_mode':
+            self.ADMIN_TAB_META,
+        'initial_summary':
+            mr.project.summary,
+        'initial_project_home':
+            mr.project.home_page,
+        'initial_docs_url':
+            mr.project.docs_url,
+        'initial_source_url':
+            mr.project.source_url,
+        'initial_logo_gcs_id':
+            mr.project.logo_gcs_id,
+        'initial_logo_file_name':
+            mr.project.logo_file_name,
+        'logo_view':
+            tracker_views.LogoView(mr.project),
+        'initial_description':
+            mr.project.description,
+        'issue_notify':
+            mr.project.issue_notify_address,
+        'process_inbound_email':
+            ezt.boolean(mr.project.process_inbound_email),
+        'email_from_addr':
+            emailfmt.FormatFromAddr(mr.project),
+        'only_owners_remove_restrictions':
+            ezt.boolean(mr.project.only_owners_remove_restrictions),
+        'only_owners_see_contributors':
+            ezt.boolean(mr.project.only_owners_see_contributors),
+        'offer_access_level':
+            ezt.boolean(offer_access_level),
+        'initial_access':
+            access_view,
+        'available_access_levels':
+            available_access_levels,
+        'issue_notify_always_detailed':
+            ezt.boolean(mr.project.issue_notify_always_detailed),
+    }
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the posted form."""
+    # 1. Parse and validate user input.
+    summary, description = self._ParseMeta(post_data, mr.errors)
+    access = project_helpers.ParseProjectAccess(
+        mr.project, post_data.get('access'))
+
+    only_owners_remove_restrictions = (
+        'only_owners_remove_restrictions' in post_data)
+    only_owners_see_contributors = 'only_owners_see_contributors' in post_data
+
+    issue_notify = post_data['issue_notify']
+    if issue_notify and not validate.IsValidEmail(issue_notify):
+      mr.errors.issue_notify = _MSG_INVALID_EMAIL_ADDRESS
+
+    process_inbound_email = 'process_inbound_email' in post_data
+    home_page = post_data.get('project_home')
+    if home_page and not (
+        home_page.startswith('http:') or home_page.startswith('https:')):
+      mr.errors.project_home = 'Home page link must start with http: or https:'
+    docs_url = post_data.get('docs_url')
+    if docs_url and not (
+        docs_url.startswith('http:') or docs_url.startswith('https:')):
+      mr.errors.docs_url = 'Documentation link must start with http: or https:'
+    source_url = post_data.get('source_url')
+    if source_url and not (
+        source_url.startswith('http:') or source_url.startswith('https:')):
+      mr.errors.source_url = 'Source link must start with http: or https:'
+
+    logo_gcs_id = ''
+    logo_file_name = ''
+    if 'logo' in post_data and not isinstance(post_data['logo'], string_types):
+      item = post_data['logo']
+      logo_file_name = item.filename
+      try:
+        logo_gcs_id = gcs_helpers.StoreLogoInGCS(
+            logo_file_name, item.value, mr.project.project_id)
+      except gcs_helpers.UnsupportedMimeType, e:
+        mr.errors.logo = e.message
+    elif mr.project.logo_gcs_id and mr.project.logo_file_name:
+      logo_gcs_id = mr.project.logo_gcs_id
+      logo_file_name = mr.project.logo_file_name
+      if post_data.get('delete_logo'):
+        try:
+          gcs_helpers.DeleteObjectFromGCS(logo_gcs_id)
+        except cloudstorage.NotFoundError:
+          pass
+        # Reset the GCS ID and file name.
+        logo_gcs_id = ''
+        logo_file_name = ''
+
+    issue_notify_always_detailed = 'issue_notify_always_detailed' in post_data
+
+    # 2. Call services layer to save changes.
+    if not mr.errors.AnyErrors():
+      with work_env.WorkEnv(mr, self.services) as we:
+        we.UpdateProject(
+            mr.project.project_id,
+            issue_notify_address=issue_notify,
+            summary=summary,
+            description=description,
+            only_owners_remove_restrictions=only_owners_remove_restrictions,
+            only_owners_see_contributors=only_owners_see_contributors,
+            process_inbound_email=process_inbound_email,
+            access=access,
+            home_page=home_page,
+            docs_url=docs_url,
+            source_url=source_url,
+            logo_gcs_id=logo_gcs_id,
+            logo_file_name=logo_file_name,
+            issue_notify_always_detailed=issue_notify_always_detailed)
+
+    # 3. Determine the next page in the UI flow.
+    if mr.errors.AnyErrors():
+      access_view = project_views.ProjectAccessView(access)
+      self.PleaseCorrect(
+          mr, initial_summary=summary, initial_description=description,
+          initial_access=access_view)
+    else:
+      return framework_helpers.FormatAbsoluteURL(
+          mr, urls.ADMIN_META, saved=1, ts=int(time.time()))
+
+  def _ParseMeta(self, post_data, errors):
+    """Process a POST on the project metadata section of the admin page."""
+    summary = None
+    description = None
+
+    if 'summary' in post_data:
+      summary = post_data['summary']
+      if not summary:
+        errors.summary = _MSG_SUMMARY_MISSING
+    if 'description' in post_data:
+      description = post_data['description']
+      if not description:
+        errors.description = _MSG_DESCRIPTION_MISSING
+
+    return summary, description
diff --git a/project/projectadminadvanced.py b/project/projectadminadvanced.py
new file mode 100644
index 0000000..9c5fc1b
--- /dev/null
+++ b/project/projectadminadvanced.py
@@ -0,0 +1,213 @@
+# 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
+
+"""Page and form handlers for project administration "advanced" subtab.
+
+The advanced subtab allows the project to be archived, unarchived, deleted, or
+marked as moved.  Site admins can use this page to "doom" a project, which is
+basically archiving it in a way that cannot be reversed by the project owners.
+
+The page also shows project data storage quota and usage values, and
+site admins can edit those quotas.
+"""
+
+from __future__ import division
+from __future__ import print_function
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from businesslogic import work_env
+from framework import framework_constants
+from framework import framework_helpers
+from framework import permissions
+from framework import servlet
+from framework import template_helpers
+from framework import urls
+from proto import project_pb2
+from tracker import tracker_constants
+
+
+class ProjectAdminAdvanced(servlet.Servlet):
+  """A page with project state options for the Project Owner(s)."""
+
+  _PAGE_TEMPLATE = 'project/project-admin-advanced-page.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ADMIN
+
+  def AssertBasePermission(self, mr):
+    """Make sure that the logged in user has permission to view this page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+    """
+    super(ProjectAdminAdvanced, self).AssertBasePermission(mr)
+    if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
+      raise permissions.PermissionException(
+          'User is not allowed to administer this project')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the "Advanced" subtab.
+    """
+    page_data = {
+        'admin_tab_mode': self.ADMIN_TAB_ADVANCED,
+        }
+    page_data.update(self._GatherPublishingOptions(mr))
+    page_data.update(self._GatherQuotaData(mr))
+
+    return page_data
+
+  def _GatherPublishingOptions(self, mr):
+    """Gather booleans to control the publishing buttons to show in EZT."""
+    state = mr.project.state
+    offer_archive = state != project_pb2.ProjectState.ARCHIVED
+    offer_delete = state == project_pb2.ProjectState.ARCHIVED
+    offer_publish = (
+        state == project_pb2.ProjectState.ARCHIVED and
+        (self.CheckPerm(mr, permissions.PUBLISH_PROJECT) or
+         not mr.project.state_reason))
+    offer_move = state == project_pb2.ProjectState.LIVE
+    offer_doom = self.CheckPerm(mr, permissions.ADMINISTER_SITE)
+    moved_to = mr.project.moved_to or 'http://'
+
+    publishing_data = {
+        'offer_archive': ezt.boolean(offer_archive),
+        'offer_publish': ezt.boolean(offer_publish),
+        'offer_delete': ezt.boolean(offer_delete),
+        'offer_move': ezt.boolean(offer_move),
+        'moved_to': moved_to,
+        'offer_doom': ezt.boolean(offer_doom),
+        'default_doom_reason': framework_constants.DEFAULT_DOOM_REASON,
+        }
+
+    return publishing_data
+
+  def _GatherQuotaData(self, mr):
+    """Gather quota info from backends so that it can be passed to EZT."""
+    offer_quota_editing = self.CheckPerm(mr, permissions.EDIT_QUOTA)
+
+    quota_data = {
+        'offer_quota_editing': ezt.boolean(offer_quota_editing),
+        'attachment_quota': self._BuildAttachmentQuotaData(mr.project),
+        }
+
+    return quota_data
+
+  def _BuildComponentQuota(self, used_bytes, quota_bytes, field_name):
+    """Return an object to easily display quota info in EZT."""
+    if quota_bytes:
+      used_percent = 100 * used_bytes // quota_bytes
+    else:
+      used_percent = 0
+
+    quota_mb = quota_bytes // 1024 // 1024
+
+    return template_helpers.EZTItem(
+        used=template_helpers.BytesKbOrMb(used_bytes),
+        quota_mb=quota_mb,
+        used_percent=used_percent,
+        avail_percent=100 - used_percent,
+        field_name=field_name)
+
+  def _BuildAttachmentQuotaData(self, project):
+    return self._BuildComponentQuota(
+      project.attachment_bytes_used,
+      project.attachment_quota or
+      tracker_constants.ISSUE_ATTACHMENTS_QUOTA_HARD,
+      'attachment_quota_mb')
+
+  def ProcessFormData(self, mr, post_data):
+    """Process the posted form.
+
+    Args:
+      mr: commonly used info parsed from the request.
+      post_data: dictionary of HTML form data.
+
+    Returns:
+      String URL to redirect to after processing is completed.
+    """
+    if 'savechanges' in post_data:
+      self._ProcessQuota(mr, post_data)
+    else:
+      self._ProcessPublishingOptions(mr, post_data)
+
+    if 'deletebtn' in post_data:
+      url = framework_helpers.FormatAbsoluteURL(
+          mr, urls.HOSTING_HOME, include_project=False)
+    else:
+      url = framework_helpers.FormatAbsoluteURL(
+          mr, urls.ADMIN_ADVANCED, saved=1, ts=int(time.time()))
+
+    return url
+
+  def _ProcessQuota(self, mr, post_data):
+    """Process form data to update project quotas."""
+    if not self.CheckPerm(mr, permissions.EDIT_QUOTA):
+      raise permissions.PermissionException(
+          'User is not allowed to change project quotas')
+
+    try:
+      new_attachment_quota = int(post_data['attachment_quota_mb'])
+      new_attachment_quota *= 1024 * 1024
+    except ValueError:
+      mr.errors.attachment_quota = 'Invalid value'
+      self.PleaseCorrect(mr)  # Don't echo back the bad input, just start over.
+      return
+
+    with work_env.WorkEnv(mr, self.services) as we:
+      we.UpdateProject(
+          mr.project.project_id, attachment_quota=new_attachment_quota)
+
+  def _ProcessPublishingOptions(self, mr, post_data):
+    """Process form data to update project state."""
+    # Note that EDIT_PROJECT is the base permission for this servlet, but
+    # dooming and undooming projects also requires PUBLISH_PROJECT.
+
+    state = mr.project.state
+
+    with work_env.WorkEnv(mr, self.services) as we:
+      if 'archivebtn' in post_data and not mr.project.delete_time:
+        we.UpdateProject(
+            mr.project.project_id, state=project_pb2.ProjectState.ARCHIVED)
+
+      elif 'deletebtn' in post_data:  # Mark the project for immediate deletion.
+        if state != project_pb2.ProjectState.ARCHIVED:
+          raise permissions.PermissionException(
+              'Projects must be archived before being deleted')
+        we.DeleteProject(mr.project_id)
+
+      elif 'doombtn' in post_data:  # Go from any state to forced ARCHIVED.
+        if not self.CheckPerm(mr, permissions.PUBLISH_PROJECT):
+          raise permissions.PermissionException(
+              'User is not allowed to doom projects')
+        reason = post_data.get('reason')
+        delete_time = time.time() + framework_constants.DEFAULT_DOOM_PERIOD
+        we.UpdateProject(
+            mr.project.project_id, state=project_pb2.ProjectState.ARCHIVED,
+            state_reason=reason, delete_time=delete_time)
+
+      elif 'publishbtn' in post_data:  # Go from any state to LIVE
+        if (mr.project.delete_time and
+            not self.CheckPerm(mr, permissions.PUBLISH_PROJECT)):
+          raise permissions.PermissionException(
+              'User is not allowed to unarchive doomed projects')
+        we.UpdateProject(
+            mr.project.project_id, state=project_pb2.ProjectState.LIVE,
+            state_reason='', delete_time=0, read_only_reason='')
+
+      elif 'movedbtn' in post_data:  # Record the moved_to location.
+        if state != project_pb2.ProjectState.LIVE:
+          raise permissions.PermissionException(
+              'This project is not live, no user can move it')
+        moved_to = post_data.get('moved_to', '')
+        we.UpdateProject(mr.project.project_id, moved_to=moved_to)
diff --git a/project/projectexport.py b/project/projectexport.py
new file mode 100644
index 0000000..e315442
--- /dev/null
+++ b/project/projectexport.py
@@ -0,0 +1,203 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Servlet to export a project's config in JSON format.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+import time
+
+import ezt
+
+from framework import permissions
+from framework import jsonfeed
+from framework import servlet
+from project import project_helpers
+from tracker import tracker_bizobj
+
+
+class ProjectExport(servlet.Servlet):
+  """Only site admins can export a project"""
+
+  _PAGE_TEMPLATE = 'project/project-export-page.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ADMIN
+
+  def AssertBasePermission(self, mr):
+    """Make sure that the logged in user has permission to view this page."""
+    super(ProjectExport, self).AssertBasePermission(mr)
+    if not mr.auth.user_pb.is_site_admin:
+      raise permissions.PermissionException(
+          'Only site admins may export project configuration')
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+
+    return {
+        'admin_tab_mode': None,
+        'page_perms': self.MakePagePerms(mr, None, permissions.CREATE_ISSUE),
+    }
+
+
+class ProjectExportJSON(jsonfeed.JsonFeed):
+  """ProjectExportJSON shows all configuration for a Project in JSON form."""
+
+  # Pretty-print the JSON output.
+  JSON_INDENT = 4
+
+  def AssertBasePermission(self, mr):
+    """Make sure that the logged in user has permission to view this page."""
+    super(ProjectExportJSON, self).AssertBasePermission(mr)
+    if not mr.auth.user_pb.is_site_admin:
+      raise permissions.PermissionException(
+          'Only site admins may export project configuration')
+
+  def HandleRequest(self, mr):
+    """Build up a dictionary of data values to use when rendering the page.
+
+    Args:
+      mr: commonly used info parsed from the request.
+
+    Returns:
+      Dict of values used by EZT for rendering the page.
+    """
+    project = self.services.project.GetProject(mr.cnxn, mr.project.project_id)
+    user_id_set = project_helpers.UsersInvolvedInProject(project)
+
+    config = self.services.config.GetProjectConfig(
+        mr.cnxn, mr.project.project_id)
+    templates = self.services.template.GetProjectTemplates(
+        mr.cnxn, config.project_id)
+    involved_users = self.services.config.UsersInvolvedInConfig(
+        config, templates)
+    user_id_set.update(involved_users)
+
+    # The value 0 indicates "no user", e.g., that an issue has no owner.
+    # We don't need to create a User row to represent that.
+    user_id_set.discard(0)
+    email_dict = self.services.user.LookupUserEmails(mr.cnxn, user_id_set)
+
+    project_json = self._MakeProjectJSON(project, email_dict)
+    config_json = self._MakeConfigJSON(config, email_dict, templates)
+
+    json_data = {
+        'metadata': {
+            'version': 1,
+            'when': int(time.time()),
+            'who': mr.auth.email,
+        },
+        'project': project_json,
+        'config': config_json,
+        # This list could be derived from the others, but we provide it for
+        # ease of processing.
+        'emails': list(email_dict.values()),
+    }
+    return json_data
+
+  def _MakeProjectJSON(self, project, email_dict):
+    project_json = {
+      'name': project.project_name,
+      'summary': project.summary,
+      'description': project.description,
+      'state': project.state.name,
+      'access': project.access.name,
+      'owners': [email_dict.get(user) for user in project.owner_ids],
+      'committers': [email_dict.get(user) for user in project.committer_ids],
+      'contributors': [
+          email_dict.get(user) for user in project.contributor_ids],
+      'perms': [self._MakePermJSON(perm, email_dict)
+                for perm in project.extra_perms],
+      'issue_notify_address': project.issue_notify_address,
+      'attachment_bytes': project.attachment_bytes_used,
+      'attachment_quota': project.attachment_quota,
+      'recent_activity': project.recent_activity,
+      'process_inbound_email': project.process_inbound_email,
+      'only_owners_remove_restrictions':
+          project.only_owners_remove_restrictions,
+      'only_owners_see_contributors': project.only_owners_see_contributors,
+      'revision_url_format': project.revision_url_format,
+      'read_only_reason': project.read_only_reason,
+    }
+    return project_json
+
+  def _MakePermJSON(self, perm, email_dict):
+    perm_json = {
+      'member': email_dict.get(perm.member_id),
+      'perms': [p for p in perm.perms],
+    }
+    return perm_json
+
+  def _MakeConfigJSON(self, config, email_dict, project_templates):
+    config_json = {
+      'statuses':
+          [self._MakeStatusJSON(status)
+           for status in config.well_known_statuses],
+      'statuses_offer_merge':
+          [status for status in config.statuses_offer_merge],
+      'labels':
+          [self._MakeLabelJSON(label) for label in config.well_known_labels],
+      'exclusive_label_prefixes':
+          [label for label in config.exclusive_label_prefixes],
+      # TODO(http://crbug.com/monorail/7217): Export the project's FieldDefs.
+      'components':
+          [self._MakeComponentJSON(component, email_dict)
+           for component in config.component_defs],
+      'templates':
+          [self._MakeTemplateJSON(template, email_dict)
+           for template in project_templates],
+      'developer_template': config.default_template_for_developers,
+      'user_template': config.default_template_for_users,
+      'list_cols': config.default_col_spec,
+      'list_spec': config.default_sort_spec,
+      'grid_x': config.default_x_attr,
+      'grid_y': config.default_y_attr,
+      'only_known_values': config.restrict_to_known,
+    }
+    if config.custom_issue_entry_url:
+      config_json.update({'issue_entry_url': config.custom_issue_entry_url})
+    return config_json
+
+  def _MakeTemplateJSON(self, template, email_dict):
+    template_json = {
+      'name': template.name,
+      'summary': template.summary,
+      'content': template.content,
+      'summary_must_be_edited': template.summary_must_be_edited,
+      'owner': email_dict.get(template.owner_id),
+      'status': template.status,
+      'labels': [label for label in template.labels],
+      # TODO(http://crbug.com/monorail/7217): Export the template's Fields.
+      'members_only': template.members_only,
+      'owner_defaults_to_member': template.owner_defaults_to_member,
+      'component_required': template.component_required,
+      'admins': [email_dict(user) for user in template.admin_ids],
+    }
+    return template_json
+
+  def _MakeStatusJSON(self, status):
+    status_json = {
+      'status': status.status,
+      'open': status.means_open,
+      'docstring': status.status_docstring,
+    }
+    return status_json
+
+  def _MakeLabelJSON(self, label):
+    label_json = {
+      'label': label.label,
+      'docstring': label.label_docstring,
+    }
+    return label_json
+
+  def _MakeComponentJSON(self, component, email_dict):
+    component_json = {
+      'path': component.path,
+      'docstring': component.docstring,
+      'admins': [email_dict.get(user) for user in component.admin_ids],
+      'ccs': [email_dict.get(user) for user in component.cc_ids],
+    }
+    return component_json
diff --git a/project/projectsummary.py b/project/projectsummary.py
new file mode 100644
index 0000000..a07bbe5
--- /dev/null
+++ b/project/projectsummary.py
@@ -0,0 +1,75 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A class to display the project summary page."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+from businesslogic import work_env
+from framework import permissions
+from framework import servlet
+from project import project_helpers
+from project import project_views
+
+from third_party import markdown
+
+
+class ProjectSummary(servlet.Servlet):
+  """Page to show brief project description and process documentation."""
+
+  _PAGE_TEMPLATE = 'project/project-summary-page.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PROCESS
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+
+    with mr.profiler.Phase('getting project star count'):
+      num_stars = self.services.project_star.CountItemStars(
+          mr.cnxn, mr.project_id)
+      plural = '' if num_stars == 1 else 's'
+
+    page_data = {
+        'admin_tab_mode': self.PROCESS_TAB_SUMMARY,
+        'formatted_project_description':
+            markdown.Markdown(mr.project.description),
+        'access_level': project_views.ProjectAccessView(mr.project.access),
+        'num_stars': num_stars,
+        'plural': plural,
+        'home_page': mr.project.home_page,
+        'docs_url': mr.project.docs_url,
+        'source_url': mr.project.source_url,
+        }
+
+    return page_data
+
+  def GatherHelpData(self, mr, page_data):
+    """Return a dict of values to drive on-page user help.
+
+    Args:
+      mr: common information parsed from the HTTP request.
+      page_data: Dictionary of base and page template data.
+
+    Returns:
+      A dict of values to drive on-page user help, to be added to page_data.
+    """
+    help_data = super(ProjectSummary, self).GatherHelpData(mr, page_data)
+    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']
+    project = mr.project
+
+    # Cue cards for project owners.
+    if self.CheckPerm(mr, permissions.EDIT_PROJECT):
+      if ('document_team_duties' not in dismissed and
+          len(project_helpers.AllProjectMembers(project)) > 1 and
+          not self.services.project.GetProjectCommitments(
+              mr.cnxn, mr.project_id).commitments):
+        help_data['cue'] = 'document_team_duties'
+
+    return help_data
diff --git a/project/projectupdates.py b/project/projectupdates.py
new file mode 100644
index 0000000..bd1e316
--- /dev/null
+++ b/project/projectupdates.py
@@ -0,0 +1,42 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A class to display a paginated list of activity stream updates."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+import ezt
+
+from features import activities
+from framework import servlet
+from framework import urls
+
+
+class ProjectUpdates(servlet.Servlet):
+  """ProjectUpdates page shows a list of past activities."""
+
+  _PAGE_TEMPLATE = 'project/project-updates-page.ezt'
+  _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_UPDATES
+
+  def GatherPageData(self, mr):
+    """Build up a dictionary of data values to use when rendering the page."""
+
+    page_data = self._GatherUpdates(mr)
+    page_data['subtab_mode'] = None
+    page_data['user_updates_tab_mode'] = None
+    logging.info('project updates data is %r', page_data)
+    return page_data
+
+  def _GatherUpdates(self, mr):
+    """Gathers and returns activity streams data."""
+
+    url = '/p/%s%s' % (mr.project_name, urls.UPDATES_LIST)
+    return activities.GatherUpdatesData(
+        self.services, mr, project_ids=[mr.project_id],
+        ending='by_user', updates_page_url=url,
+        autolink=self.services.autolink)
diff --git a/project/redirects.py b/project/redirects.py
new file mode 100644
index 0000000..7813a56
--- /dev/null
+++ b/project/redirects.py
@@ -0,0 +1,52 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""A class to forward requests to configured urls.
+
+This page handles the /wiki and /source urls which are forwarded from Codesite.
+If a project has defined appropriate urls, then the users are forwarded there.
+If not, they are redirected to adminIntro.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import httplib
+
+from framework import framework_helpers
+from framework import servlet
+from framework import urls
+
+
+class WikiRedirect(servlet.Servlet):
+  """Redirect to the wiki documentation, if provided."""
+
+  def get(self, **kwargs):
+    """Construct a 302 pointing at project.docs_url, or at adminIntro."""
+    if not self.mr.project:
+      self.response.status = httplib.NOT_FOUND
+      return
+    docs_url = self.mr.project.docs_url
+    if not docs_url:
+      docs_url = framework_helpers.FormatAbsoluteURL(
+          self.mr, urls.ADMIN_INTRO, include_project=True)
+    self.response.location = docs_url
+    self.response.status = httplib.MOVED_PERMANENTLY
+
+
+class SourceRedirect(servlet.Servlet):
+  """Redirect to the source browser, if provided."""
+
+  def get(self, **kwargs):
+    """Construct a 302 pointing at project.source_url, or at adminIntro."""
+    if not self.mr.project:
+      self.response.status = httplib.NOT_FOUND
+      return
+    source_url = self.mr.project.source_url
+    if not source_url:
+      source_url = framework_helpers.FormatAbsoluteURL(
+          self.mr, urls.ADMIN_INTRO, include_project=True)
+    self.response.location = source_url
+    self.response.status = httplib.MOVED_PERMANENTLY
diff --git a/project/test/__init__.py b/project/test/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/project/test/__init__.py
diff --git a/project/test/peopledetail_test.py b/project/test/peopledetail_test.py
new file mode 100644
index 0000000..547df80
--- /dev/null
+++ b/project/test/peopledetail_test.py
@@ -0,0 +1,262 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for the people detail page."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import logging
+
+import unittest
+
+import webapp2
+
+from framework import authdata
+from framework import exceptions
+from framework import permissions
+from project import peopledetail
+from proto import project_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class PeopleDetailTest(unittest.TestCase):
+
+  def setUp(self):
+    services = service_manager.Services(
+        project=fake.ProjectService(),
+        usergroup=fake.UserGroupService(),
+        user=fake.UserService())
+    services.user.TestAddUser('jrobbins', 111)
+    services.user.TestAddUser('jrobbins@jrobbins.org', 333)
+    services.user.TestAddUser('jrobbins@chromium.org', 555)
+    services.user.TestAddUser('imso31337@gmail.com', 999)
+    self.project = services.project.TestAddProject('proj')
+    self.project.owner_ids.extend([111, 222])
+    self.project.committer_ids.extend([333, 444])
+    self.project.contributor_ids.extend([555])
+    self.servlet = peopledetail.PeopleDetail('req', 'res', services=services)
+
+  def VerifyAccess(self, exception_expected):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.servlet.AssertBasePermission(mr)
+    # Owner never raises PermissionException.
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=333',
+        project=self.project,
+        perms=permissions.COMMITTER_ACTIVE_PERMISSIONSET)
+    self.servlet.AssertBasePermission(mr)
+    # Committer never raises PermissionException.
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=555',
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    if exception_expected:
+      self.assertRaises(permissions.PermissionException,
+                        self.servlet.AssertBasePermission, mr)
+    else:
+      self.servlet.AssertBasePermission(mr)
+      # No PermissionException raised
+
+    # Sign-out users
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=555',
+        project=self.project,
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    if exception_expected:
+      self.assertRaises(permissions.PermissionException,
+                        self.servlet.AssertBasePermission, mr)
+    else:
+      self.servlet.AssertBasePermission(mr)
+
+    # Non-membr users
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=555',
+        project=self.project,
+        perms=permissions.USER_PERMISSIONSET)
+    if exception_expected:
+      self.assertRaises(permissions.PermissionException,
+                        self.servlet.AssertBasePermission, mr)
+    else:
+      self.servlet.AssertBasePermission(mr)
+
+  def testAssertBasePermission_Normal(self):
+    self.VerifyAccess(False)
+
+  def testAssertBasePermission_HubSpoke(self):
+    self.project.only_owners_see_contributors = True
+    self.VerifyAccess(True)
+
+  def testAssertBasePermission_HubSpokeViewingSelf(self):
+    self.project.only_owners_see_contributors = True
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=333',
+        project=self.project,
+        perms=permissions.COMMITTER_ACTIVE_PERMISSIONSET)
+    mr.auth.user_id = 333
+    self.servlet.AssertBasePermission(mr)
+    # No PermissionException raised
+
+  def testGatherPageData(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    mr.auth = authdata.AuthData()
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertFalse(page_data['warn_abandonment'])
+    self.assertEqual(2, page_data['total_num_owners'])
+    # TODO(jrobbins): fill in tests for all other aspects.
+
+  def testValidateMemberID(self):
+    # We can validate owners
+    self.assertEqual(
+        111, self.servlet.ValidateMemberID('fake cnxn', 111, self.project))
+
+    # We can parse members
+    self.assertEqual(
+        333, self.servlet.ValidateMemberID('fake cnxn', 333, self.project))
+
+    # 404 for user that does not exist
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet.ValidateMemberID('fake cnxn', 8933, self.project)
+    self.assertEqual(404, cm.exception.code)
+
+    # 404 for valid user that is not in this project
+    with self.assertRaises(webapp2.HTTPException) as cm:
+      self.servlet.ValidateMemberID('fake cnxn', 999, self.project)
+    self.assertEqual(404, cm.exception.code)
+
+  def testParsePersonData_BadPost(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail',
+        project=self.project)
+    post_data = fake.PostData()
+    with self.assertRaises(exceptions.InputException):
+      _result = self.servlet.ParsePersonData(mr, post_data)
+
+  def testParsePersonData_NoDetails(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project)
+    post_data = fake.PostData(role=['owner'])
+    u, r, ac, n, _, _ = self.servlet.ParsePersonData(mr, post_data)
+    self.assertEqual(111, u)
+    self.assertEqual('owner', r)
+    self.assertEqual([], ac)
+    self.assertEqual('', n)
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=333',
+        project=self.project)
+    post_data = fake.PostData(role=['owner'])
+    u, r, ac, n, _, _ = self.servlet.ParsePersonData(mr, post_data)
+    self.assertEqual(333, u)
+
+  def testParsePersonData(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project)
+    post_data = fake.PostData(
+        role=['owner'], extra_perms=['ViewQuota', 'EditIssue'])
+    u, r, ac, n, _, _ = self.servlet.ParsePersonData(mr, post_data)
+    self.assertEqual(111, u)
+    self.assertEqual('owner', r)
+    self.assertEqual(['ViewQuota', 'EditIssue'], ac)
+    self.assertEqual('', n)
+
+    post_data = fake.PostData({
+        'role': ['owner'],
+        'extra_perms': [' ', '  \t'],
+        'notes': [''],
+        'ac_include': [123],
+        'ac_expand': [123],
+        })
+    (u, r, ac, n, ac_exclusion, no_expand
+     ) = self.servlet.ParsePersonData(mr, post_data)
+    self.assertEqual(111, u)
+    self.assertEqual('owner', r)
+    self.assertEqual([], ac)
+    self.assertEqual('', n)
+    self.assertFalse(ac_exclusion)
+    self.assertFalse(no_expand)
+
+    post_data = fake.PostData({
+        'username': ['jrobbins'],
+        'role': ['owner'],
+        'extra_perms': ['_ViewQuota', '  __EditIssue'],
+        'notes': [' Our local Python expert '],
+        })
+    (u, r, ac, n, ac_exclusion, no_expand
+     )= self.servlet.ParsePersonData(mr, post_data)
+    self.assertEqual(111, u)
+    self.assertEqual('owner', r)
+    self.assertEqual(['ViewQuota', 'EditIssue'], ac)
+    self.assertEqual('Our local Python expert', n)
+    self.assertTrue(ac_exclusion)
+    self.assertTrue(no_expand)
+
+  def testCanEditMemberNotes(self):
+    """Only owners can edit member notes."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    result = self.servlet.CanEditMemberNotes(mr, 222)
+    self.assertFalse(result)
+
+    mr.auth.user_id = 222
+    result = self.servlet.CanEditMemberNotes(mr, 222)
+    self.assertTrue(result)
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    result = self.servlet.CanEditMemberNotes(mr, 222)
+    self.assertTrue(result)
+
+  def testCanEditPerms(self):
+    """Only owners can edit member perms."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    result = self.servlet.CanEditPerms(mr)
+    self.assertFalse(result)
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    result = self.servlet.CanEditPerms(mr)
+    self.assertTrue(result)
+
+  def testCanRemoveRole(self):
+    """Owners can remove members. Users could also remove themselves."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    result = self.servlet.CanRemoveRole(mr, 222)
+    self.assertFalse(result)
+
+    mr.auth.user_id = 111
+    result = self.servlet.CanRemoveRole(mr, 111)
+    self.assertTrue(result)
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=111',
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    result = self.servlet.CanRemoveRole(mr, 222)
+    self.assertTrue(result)
diff --git a/project/test/peoplelist_test.py b/project/test/peoplelist_test.py
new file mode 100644
index 0000000..6620df9
--- /dev/null
+++ b/project/test/peoplelist_test.py
@@ -0,0 +1,158 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittest for People List servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import authdata
+from framework import permissions
+from project import peoplelist
+from proto import user_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class PeopleListTest(unittest.TestCase):
+  """Tests for the PeopleList servlet."""
+
+  def setUp(self):
+    services = service_manager.Services(
+        project=fake.ProjectService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService())
+    services.user.TestAddUser('jrobbins@gmail.com', 111)
+    services.user.TestAddUser('jrobbins@jrobbins.org', 222)
+    services.user.TestAddUser('jrobbins@chromium.org', 333)
+    services.user.TestAddUser('imso31337@gmail.com', 999)
+    self.project = services.project.TestAddProject('proj')
+    self.project.owner_ids.extend([111])
+    self.project.committer_ids.extend([222])
+    self.project.contributor_ids.extend([333])
+    self.servlet = peoplelist.PeopleList('req', 'res', services=services)
+
+  def VerifyAccess(self, exception_expected):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.servlet.AssertBasePermission(mr)
+    # Owner never raises PermissionException.
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project,
+        perms=permissions.COMMITTER_ACTIVE_PERMISSIONSET)
+    self.servlet.AssertBasePermission(mr)
+    # Committer never raises PermissionException.
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    if exception_expected:
+      self.assertRaises(permissions.PermissionException,
+                        self.servlet.AssertBasePermission, mr)
+    else:
+      self.servlet.AssertBasePermission(mr)
+      # No PermissionException raised
+
+    # Sign-out users
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=555',
+        project=self.project,
+        perms=permissions.READ_ONLY_PERMISSIONSET)
+    if exception_expected:
+      self.assertRaises(permissions.PermissionException,
+                        self.servlet.AssertBasePermission, mr)
+    else:
+      self.servlet.AssertBasePermission(mr)
+
+    # Non-membr users
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/detail?u=555',
+        project=self.project,
+        perms=permissions.USER_PERMISSIONSET)
+    if exception_expected:
+      self.assertRaises(permissions.PermissionException,
+                        self.servlet.AssertBasePermission, mr)
+    else:
+      self.servlet.AssertBasePermission(mr)
+
+  def testAssertBasePermission_Normal(self):
+    self.VerifyAccess(False)
+
+  def testAssertBasePermission_HideMembers(self):
+    self.project.only_owners_see_contributors = True
+    self.VerifyAccess(True)
+
+  def testGatherPageData(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    mr.auth = authdata.AuthData()
+    page_data = self.servlet.GatherPageData(mr)
+
+    self.assertEqual(1, page_data['total_num_owners'])
+    # TODO(jrobbins): fill in tests for all other aspects.
+
+  def testProcessFormData_Permission(self):
+    """Only owners could add/remove members."""
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.ProcessFormData, mr, {})
+
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.servlet.ProcessFormData(mr, {})
+
+  def testGatherHelpData_Anon(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project)
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(
+        {'account_cue': None, 'cue': None},
+        help_data)
+
+  def testGatherHelpData_Nonmember(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project)
+    mr.auth.user_id = 999
+    mr.auth.effective_ids = {999}
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(
+        {'account_cue': None, 'cue': 'how_to_join_project'},
+        help_data)
+
+    self.servlet.services.user.SetUserPrefs(
+        'cnxn', 999,
+        [user_pb2.UserPrefValue(name='how_to_join_project', value='true')])
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(
+        {'account_cue': None, 'cue': None},
+        help_data)
+
+  def testGatherHelpData_Member(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        path='/p/proj/people/list',
+        project=self.project)
+    mr.auth.user_id = 111
+    mr.auth.effective_ids = {111}
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(
+        {'account_cue': None, 'cue': None},
+        help_data)
diff --git a/project/test/project_helpers_test.py b/project/test/project_helpers_test.py
new file mode 100644
index 0000000..4732895
--- /dev/null
+++ b/project/test/project_helpers_test.py
@@ -0,0 +1,179 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for helpers module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from mock import patch
+
+from framework import framework_views
+from framework import permissions
+from project import project_constants
+from project import project_helpers
+from proto import project_pb2
+from services import service_manager
+from testing import fake
+
+
+class HelpersUnitTest(unittest.TestCase):
+
+  def setUp(self):
+    self.cnxn = 'fake sql connection'
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        user=fake.UserService())
+    self.services.user.TestAddUser('a@example.com', 111)
+    self.services.user.TestAddUser('b@example.com', 222)
+    self.services.user.TestAddUser('c@example.com', 333)
+    self.users_by_id = framework_views.MakeAllUserViews(
+        'cnxn', self.services.user, [111, 222, 333])
+    self.effective_ids_by_user = {user: set() for user in {111, 222, 333}}
+
+  def testBuildProjectMembers(self):
+    project = project_pb2.MakeProject(
+        'proj', owner_ids=[111], committer_ids=[222],
+        contributor_ids=[333])
+    page_data = project_helpers.BuildProjectMembers(
+        self.cnxn, project, self.services.user)
+    self.assertEqual(111, page_data['owners'][0].user_id)
+    self.assertEqual(222, page_data['committers'][0].user_id)
+    self.assertEqual(333, page_data['contributors'][0].user_id)
+    self.assertEqual(3, len(page_data['all_members']))
+
+  def testParseUsernames(self):
+    # Form field was not present in post data.
+    id_set = project_helpers.ParseUsernames(
+        self.cnxn, self.services.user, None)
+    self.assertEqual(set(), id_set)
+
+    # Form field was present, but empty.
+    id_set = project_helpers.ParseUsernames(
+        self.cnxn, self.services.user, '')
+    self.assertEqual(set(), id_set)
+
+    # Parsing valid user names.
+    id_set = project_helpers.ParseUsernames(
+        self.cnxn, self.services.user, 'a@example.com, c@example.com')
+    self.assertEqual({111, 333}, id_set)
+
+  def testParseProjectAccess_NotOffered(self):
+    project = project_pb2.MakeProject('proj')
+    access = project_helpers.ParseProjectAccess(project, None)
+    self.assertEqual(None, access)
+
+  def testParseProjectAccess_AllowedChoice(self):
+    project = project_pb2.MakeProject('proj')
+    access = project_helpers.ParseProjectAccess(project, '1')
+    self.assertEqual(project_pb2.ProjectAccess.ANYONE, access)
+
+    access = project_helpers.ParseProjectAccess(project, '3')
+    self.assertEqual(project_pb2.ProjectAccess.MEMBERS_ONLY, access)
+
+  def testParseProjectAccess_BogusChoice(self):
+    project = project_pb2.MakeProject('proj')
+    access = project_helpers.ParseProjectAccess(project, '9')
+    self.assertEqual(None, access)
+
+  def testUsersWithPermsInProject_StandardPermission(self):
+    project = project_pb2.MakeProject('proj', committer_ids=[111])
+    perms_needed = {permissions.VIEW, permissions.EDIT_ISSUE}
+    actual = project_helpers.UsersWithPermsInProject(
+        project, perms_needed, self.users_by_id, self.effective_ids_by_user)
+    self.assertEqual(
+        {permissions.VIEW: {111, 222, 333},
+         permissions.EDIT_ISSUE: {111}},
+        actual)
+
+  def testUsersWithPermsInProject_IndirectPermission(self):
+    perms_needed = {permissions.EDIT_ISSUE}
+    # User 111 has the EDIT_ISSUE permission.
+    project = project_pb2.MakeProject('proj', committer_ids=[111])
+    # User 222 has the EDIT_ISSUE permission, because 111 is included in its
+    # effective IDs.
+    self.effective_ids_by_user[222] = {111}
+    # User 333 doesn't have the EDIT_ISSUE permission, since only direct
+    # effective IDs are taken into account.
+    self.effective_ids_by_user[333] = {222}
+    actual = project_helpers.UsersWithPermsInProject(
+        project, perms_needed, self.users_by_id, self.effective_ids_by_user)
+    self.assertEqual(
+        {permissions.EDIT_ISSUE: {111, 222}},
+        actual)
+
+  def testUsersWithPermsInProject_CustomPermission(self):
+    project = project_pb2.MakeProject('proj')
+    project.extra_perms = [
+        project_pb2.Project.ExtraPerms(
+            member_id=111,
+            perms=['FooPerm', 'BarPerm']),
+        project_pb2.Project.ExtraPerms(
+            member_id=222,
+            perms=['BarPerm'])]
+    perms_needed = {'FooPerm', 'BarPerm'}
+    actual = project_helpers.UsersWithPermsInProject(
+        project, perms_needed, self.users_by_id, self.effective_ids_by_user)
+    self.assertEqual(
+        {'FooPerm': {111},
+         'BarPerm': {111, 222}},
+        actual)
+
+  @patch('google.appengine.api.app_identity.get_default_gcs_bucket_name')
+  @patch('framework.gcs_helpers.SignUrl')
+  def testGetThumbnailUrl(self, mock_SignUrl, mock_get_default_gcs_bucket_name):
+    bucket_name = 'testbucket'
+    expected_url = 'signed/url'
+
+    mock_get_default_gcs_bucket_name.return_value = bucket_name
+    mock_SignUrl.return_value = expected_url
+
+    self.assertEqual(expected_url, project_helpers.GetThumbnailUrl('xyz'))
+    mock_get_default_gcs_bucket_name.assert_called_once()
+    mock_SignUrl.assert_called_once_with(bucket_name, 'xyz' + '-thumbnail')
+
+  def testIsValidProjectName_BadChars(self):
+    self.assertFalse(project_helpers.IsValidProjectName('spa ce'))
+    self.assertFalse(project_helpers.IsValidProjectName('under_score'))
+    self.assertFalse(project_helpers.IsValidProjectName('name.dot'))
+    self.assertFalse(project_helpers.IsValidProjectName('pie#sign$'))
+    self.assertFalse(project_helpers.IsValidProjectName('(who?)'))
+
+  def testIsValidProjectName_BadHyphen(self):
+    self.assertFalse(project_helpers.IsValidProjectName('name-'))
+    self.assertFalse(project_helpers.IsValidProjectName('-name'))
+    self.assertTrue(project_helpers.IsValidProjectName('project-name'))
+
+  def testIsValidProjectName_MinimumLength(self):
+    self.assertFalse(project_helpers.IsValidProjectName('x'))
+    self.assertTrue(project_helpers.IsValidProjectName('xy'))
+
+  def testIsValidProjectName_MaximumLength(self):
+    self.assertFalse(
+        project_helpers.IsValidProjectName(
+            'x' * (project_constants.MAX_PROJECT_NAME_LENGTH + 1)))
+    self.assertTrue(
+        project_helpers.IsValidProjectName(
+            'x' * (project_constants.MAX_PROJECT_NAME_LENGTH)))
+
+  def testIsValidProjectName_InvalidName(self):
+    self.assertFalse(project_helpers.IsValidProjectName(''))
+    self.assertFalse(project_helpers.IsValidProjectName('000'))
+
+  def testIsValidProjectName_ValidName(self):
+    self.assertTrue(project_helpers.IsValidProjectName('098asd'))
+    self.assertTrue(project_helpers.IsValidProjectName('one-two-three'))
+
+  def testAllProjectMembers(self):
+    p = project_pb2.Project()
+    self.assertEqual(project_helpers.AllProjectMembers(p), [])
+
+    p.owner_ids.extend([1, 2, 3])
+    p.committer_ids.extend([4, 5, 6])
+    p.contributor_ids.extend([7, 8, 9])
+    self.assertEqual(
+        project_helpers.AllProjectMembers(p), [1, 2, 3, 4, 5, 6, 7, 8, 9])
diff --git a/project/test/project_views_test.py b/project/test/project_views_test.py
new file mode 100644
index 0000000..940116e
--- /dev/null
+++ b/project/test/project_views_test.py
@@ -0,0 +1,112 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for project_views module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import framework_views
+from project import project_views
+from proto import project_pb2
+from services import service_manager
+from testing import fake
+
+
+class ProjectAccessViewTest(unittest.TestCase):
+
+  def testAccessViews(self):
+    anyone_view = project_views.ProjectAccessView(
+        project_pb2.ProjectAccess.ANYONE)
+    self.assertEqual(anyone_view.key, int(project_pb2.ProjectAccess.ANYONE))
+
+    members_only_view = project_views.ProjectAccessView(
+        project_pb2.ProjectAccess.MEMBERS_ONLY)
+    self.assertEqual(members_only_view.key,
+                     int(project_pb2.ProjectAccess.MEMBERS_ONLY))
+
+
+class ProjectViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        project=fake.ProjectService(),
+        user=fake.UserService())
+    self.services.project.TestAddProject('test')
+
+  def testNormalProject(self):
+    project = self.services.project.GetProjectByName('fake cnxn', 'test')
+    project_view = project_views.ProjectView(project)
+    self.assertEqual('test', project_view.project_name)
+    self.assertEqual('/p/test', project_view.relative_home_url)
+    self.assertEqual('LIVE', project_view.state_name)
+
+  def testCachedContentTimestamp(self):
+    project = self.services.project.GetProjectByName('fake cnxn', 'test')
+
+    # Project was never updated since we added cached_content_timestamp.
+    project.cached_content_timestamp = 0
+    view = project_views.ProjectView(project, now=1 * 60 * 60 + 234)
+    self.assertEqual(1 * 60 * 60, view.cached_content_timestamp)
+
+    # Project was updated within the last hour, use that timestamp.
+    project.cached_content_timestamp = 1 * 60 * 60 + 123
+    view = project_views.ProjectView(project, now=1 * 60 * 60 + 234)
+    self.assertEqual(1 * 60 * 60 + 123, view.cached_content_timestamp)
+
+    # Project was not updated within the last hour, but user groups
+    # could have been updated on groups.google.com without any
+    # notification to us, so the client will ask for an updated feed
+    # at least once an hour.
+    project.cached_content_timestamp = 1 * 60 * 60 + 123
+    view = project_views.ProjectView(project, now=2 * 60 * 60 + 234)
+    self.assertEqual(2 * 60 * 60, view.cached_content_timestamp)
+
+
+class MemberViewTest(unittest.TestCase):
+
+  def setUp(self):
+    self.alice_view = framework_views.StuffUserView(111, 'alice', True)
+    self.bob_view = framework_views.StuffUserView(222, 'bob', True)
+    self.carol_view = framework_views.StuffUserView(333, 'carol', True)
+
+    self.project = project_pb2.Project()
+    self.project.project_name = 'proj'
+    self.project.owner_ids.append(111)
+    self.project.committer_ids.append(222)
+    self.project.contributor_ids.append(333)
+
+  def testViewingSelf(self):
+    member_view = project_views.MemberView(
+        0, 111, self.alice_view, self.project, None)
+    self.assertFalse(member_view.viewing_self)
+    member_view = project_views.MemberView(
+        222, 111, self.alice_view, self.project, None)
+    self.assertFalse(member_view.viewing_self)
+
+    member_view = project_views.MemberView(
+        111, 111, self.alice_view, self.project, None)
+    self.assertTrue(member_view.viewing_self)
+
+  def testRoles(self):
+    member_view = project_views.MemberView(
+        0, 111, self.alice_view, self.project, None)
+    self.assertEqual('Owner', member_view.role)
+    self.assertEqual('/p/proj/people/detail?u=111',
+                     member_view.detail_url)
+
+    member_view = project_views.MemberView(
+        0, 222, self.bob_view, self.project, None)
+    self.assertEqual('Committer', member_view.role)
+    self.assertEqual('/p/proj/people/detail?u=222',
+                     member_view.detail_url)
+
+    member_view = project_views.MemberView(
+        0, 333, self.carol_view, self.project, None)
+    self.assertEqual('Contributor', member_view.role)
+    self.assertEqual('/p/proj/people/detail?u=333',
+                     member_view.detail_url)
diff --git a/project/test/projectadmin_test.py b/project/test/projectadmin_test.py
new file mode 100644
index 0000000..0257cd0
--- /dev/null
+++ b/project/test/projectadmin_test.py
@@ -0,0 +1,78 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for projectadmin module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import permissions
+from project import projectadmin
+from proto import project_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class ProjectAdminTest(unittest.TestCase):
+  """Unit tests for the ProjectAdmin servlet class."""
+
+  def setUp(self):
+    services = service_manager.Services(
+        project=fake.ProjectService(),
+        user=fake.UserService())
+    self.servlet = projectadmin.ProjectAdmin('req', 'res', services=services)
+    self.project = services.project.TestAddProject(
+        'proj', summary='a summary', description='a description')
+    self.request, self.mr = testing_helpers.GetRequestObjects(
+        project=self.project)
+
+  def testAssertBasePermission(self):
+    # Contributors cannot edit the project
+    mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET)
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # Signed-out users cannot edit the project
+    mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # Non-member users cannot edit the project
+    mr.perms = permissions.USER_PERMISSIONSET
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+
+    # Owners can edit the project
+    mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(mr)
+
+  def testGatherPageData(self):
+    # Project has all default values.
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual('a summary', page_data['initial_summary'])
+    self.assertEqual('a description', page_data['initial_description'])
+    self.assertEqual(
+        int(project_pb2.ProjectAccess.ANYONE), page_data['initial_access'].key)
+
+    self.assertFalse(page_data['process_inbound_email'])
+    self.assertFalse(page_data['only_owners_remove_restrictions'])
+    self.assertFalse(page_data['only_owners_see_contributors'])
+    self.assertFalse(page_data['issue_notify_always_detailed'])
+
+    # Now try some alternate Project field values.
+    self.project.only_owners_remove_restrictions = True
+    self.project.only_owners_see_contributors = True
+    self.project.issue_notify_always_detailed = True
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertTrue(page_data['only_owners_remove_restrictions'])
+    self.assertTrue(page_data['only_owners_see_contributors'])
+    self.assertTrue(page_data['issue_notify_always_detailed'])
+
+    # TODO(jrobbins): many more tests needed.
diff --git a/project/test/projectadminadvanced_test.py b/project/test/projectadminadvanced_test.py
new file mode 100644
index 0000000..a654d98
--- /dev/null
+++ b/project/test/projectadminadvanced_test.py
@@ -0,0 +1,128 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unit tests for projectadminadvanced module."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import time
+import unittest
+from mock import patch
+
+from framework import permissions
+from project import projectadminadvanced
+from proto import project_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+NOW = 1277762224
+
+
+class ProjectAdminAdvancedTest(unittest.TestCase):
+  """Unit tests for the ProjectAdminAdvanced servlet class."""
+
+  def setUp(self):
+    services = service_manager.Services(
+        project=fake.ProjectService())
+    self.servlet = projectadminadvanced.ProjectAdminAdvanced(
+        'req', 'res', services=services)
+    self.project = services.project.TestAddProject('proj', owner_ids=[111])
+    self.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project,
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET,
+        user_info={'user_id': 111})
+
+  def testAssertBasePermission(self):
+    # Signed-out users cannot edit the project
+    self.mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, self.mr)
+
+    # Non-member users cannot edit the project
+    self.mr.perms = permissions.USER_PERMISSIONSET
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, self.mr)
+
+    # Contributors cannot edit the project
+    self.mr.perms = permissions.CONTRIBUTOR_ACTIVE_PERMISSIONSET
+    self.assertRaises(
+        permissions.PermissionException,
+        self.servlet.AssertBasePermission, self.mr)
+
+    self.mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    self.servlet.AssertBasePermission(self.mr)
+
+  def testGatherPageData(self):
+    page_data = self.servlet.GatherPageData(self.mr)
+    self.assertEqual(self.servlet.ADMIN_TAB_ADVANCED,
+                     page_data['admin_tab_mode'])
+
+  def testGatherPublishingOptions_Live(self):
+    pub_data = self.servlet._GatherPublishingOptions(self.mr)
+    self.assertTrue(pub_data['offer_archive'])
+    self.assertTrue(pub_data['offer_move'])
+    self.assertFalse(pub_data['offer_publish'])
+    self.assertFalse(pub_data['offer_delete'])
+    self.assertEqual('http://', pub_data['moved_to'])
+
+  def testGatherPublishingOptions_Moved(self):
+    self.project.moved_to = 'other location'
+    pub_data = self.servlet._GatherPublishingOptions(self.mr)
+    self.assertTrue(pub_data['offer_archive'])
+    self.assertTrue(pub_data['offer_move'])
+    self.assertFalse(pub_data['offer_publish'])
+    self.assertFalse(pub_data['offer_delete'])
+    self.assertEqual('other location', pub_data['moved_to'])
+
+  def testGatherPublishingOptions_Archived(self):
+    self.project.state = project_pb2.ProjectState.ARCHIVED
+    pub_data = self.servlet._GatherPublishingOptions(self.mr)
+    self.assertFalse(pub_data['offer_archive'])
+    self.assertFalse(pub_data['offer_move'])
+    self.assertTrue(pub_data['offer_publish'])
+    self.assertTrue(pub_data['offer_delete'])
+
+  def testGatherPublishingOptions_Doomed(self):
+    self.project.state = project_pb2.ProjectState.ARCHIVED
+    self.project.state_reason = 'you are a spammer'
+    pub_data = self.servlet._GatherPublishingOptions(self.mr)
+    self.assertFalse(pub_data['offer_archive'])
+    self.assertFalse(pub_data['offer_move'])
+    self.assertFalse(pub_data['offer_publish'])
+    self.assertTrue(pub_data['offer_delete'])
+
+  def testGatherQuotaData(self):
+    self.mr.perms = permissions.OWNER_ACTIVE_PERMISSIONSET
+    quota_data = self.servlet._GatherQuotaData(self.mr)
+    self.assertFalse(quota_data['offer_quota_editing'])
+
+    self.mr.perms = permissions.ADMIN_PERMISSIONSET
+    quota_data = self.servlet._GatherQuotaData(self.mr)
+    self.assertTrue(quota_data['offer_quota_editing'])
+
+  def testBuildComponentQuota(self):
+    ezt_item = self.servlet._BuildComponentQuota(
+        5000, 10000, 'attachments')
+    self.assertEqual(50, ezt_item.used_percent)
+    self.assertEqual('attachments', ezt_item.field_name)
+
+  @patch('time.time')
+  def testProcessFormData_NotDeleted(self, mock_time):
+    mock_time.return_value = NOW
+    self.mr.project_name = 'proj'
+    post_data = fake.PostData()
+    next_url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertEqual(
+        'http://127.0.0.1/p/proj/adminAdvanced?saved=1&ts=%s' % NOW,
+        next_url)
+
+  def testProcessFormData_AfterDeletion(self):
+    self.mr.project_name = 'proj'
+    self.project.state = project_pb2.ProjectState.ARCHIVED
+    post_data = fake.PostData(deletebtn='1')
+    next_url = self.servlet.ProcessFormData(self.mr, post_data)
+    self.assertEqual('http://127.0.0.1/hosting_old/', next_url)
diff --git a/project/test/projectexport_test.py b/project/test/projectexport_test.py
new file mode 100644
index 0000000..6dbe990
--- /dev/null
+++ b/project/test/projectexport_test.py
@@ -0,0 +1,148 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for the projectexport servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from mock import Mock, patch
+
+from framework import permissions
+from project import projectexport
+from proto import tracker_pb2
+from services import service_manager
+from services.template_svc import TemplateService
+from testing import fake
+from testing import testing_helpers
+
+
+class ProjectExportTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services()
+    self.servlet = projectexport.ProjectExport(
+        'req', 'res', services=self.services)
+
+  def testAssertBasePermission(self):
+    mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.assertRaises(permissions.PermissionException,
+                      self.servlet.AssertBasePermission, mr)
+    mr.auth.user_pb.is_site_admin = True
+    self.servlet.AssertBasePermission(mr)
+
+
+class ProjectExportJSONTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(
+        config=fake.ConfigService(),
+        project=fake.ProjectService(),
+        user=fake.UserService(),
+        template=Mock(spec=TemplateService))
+    self.services.user.TestAddUser('user1@example.com', 111)
+    self.servlet = projectexport.ProjectExportJSON(
+        'req', 'res', services=self.services)
+    self.project = fake.Project(project_id=789)
+    self.mr = testing_helpers.MakeMonorailRequest(
+        perms=permissions.OWNER_ACTIVE_PERMISSIONSET)
+    self.mr.auth.user_pb.is_site_admin = True
+    self.mr.project = self.project
+
+  @patch('time.time')
+  def testHandleRequest_Normal(self, mockTime):
+    mockTime.return_value = 123456789
+    self.services.project.GetProject = Mock(return_value=self.project)
+    test_config = fake.MakeTestConfig(project_id=789, labels=[], statuses=[])
+    self.services.config.GetProjectConfig = Mock(return_value=test_config)
+    test_templates = testing_helpers.DefaultTemplates()
+    self.services.template.GetProjectTemplates = Mock(
+        return_value=test_templates)
+    self.services.config.UsersInvolvedInConfig = Mock(return_value=[111])
+
+    json_data = self.servlet.HandleRequest(self.mr)
+
+    expected = {
+      'project': {
+        'committers': [],
+        'owners': [],
+        'recent_activity': 0,
+        'name': 'proj',
+        'contributors': [],
+        'perms': [],
+        'attachment_quota': None,
+        'process_inbound_email': False,
+        'revision_url_format': None,
+        'summary': '',
+        'access': 'ANYONE',
+        'state': 'LIVE',
+        'read_only_reason': None,
+        'only_owners_remove_restrictions': False,
+        'only_owners_see_contributors': False,
+        'attachment_bytes': 0,
+        'issue_notify_address': None,
+        'description': ''
+      },
+      'config': {
+        'templates': [{
+          'status': 'Accepted',
+          'members_only': True,
+          'labels': [],
+          'summary_must_be_edited': True,
+          'owner': None,
+          'owner_defaults_to_member': True,
+          'component_required': False,
+          'name': 'Defect report from developer',
+          'summary': 'Enter one-line summary',
+          'content': 'What steps will reproduce the problem?\n1. \n2. \n3. \n'
+            '\n'
+            'What is the expected output?\n\n\nWhat do you see instead?\n'
+            '\n\n'
+            'Please use labels and text to provide additional information.\n',
+          'admins': []
+        }, {
+          'status': 'New',
+          'members_only': False,
+          'labels': [],
+          'summary_must_be_edited': True,
+          'owner': None,
+          'owner_defaults_to_member': True,
+          'component_required': False,
+          'name': 'Defect report from user',
+          'summary': 'Enter one-line summary', 'content': 'What steps will '
+            'reproduce the problem?\n1. \n2. \n3. \n\nWhat is the expected '
+            'output?\n\n\nWhat do you see instead?\n\n\nWhat version of the '
+            'product are you using? On what operating system?\n\n\nPlease '
+            'provide any additional information below.\n',
+          'admins': []
+        }],
+        'labels': [],
+        'statuses_offer_merge': ['Duplicate'],
+        'exclusive_label_prefixes': ['Type', 'Priority', 'Milestone'],
+        'only_known_values': False,
+        'statuses': [],
+        'list_spec': '',
+        'developer_template': 0,
+        'user_template': 0,
+        'grid_y': '',
+        'grid_x': '',
+        'components': [],
+        'list_cols': 'ID Type Status Priority Milestone Owner Summary'
+      },
+      'emails': ['user1@example.com'],
+      'metadata': {
+        'version': 1,
+        'when': 123456789,
+        'who': None,
+      }
+    }
+    self.assertDictEqual(expected, json_data)
+    self.services.template.GetProjectTemplates.assert_called_once_with(
+        self.mr.cnxn, 789)
+    self.services.config.UsersInvolvedInConfig.assert_called_once_with(
+        test_config, test_templates)
diff --git a/project/test/projectsummary_test.py b/project/test/projectsummary_test.py
new file mode 100644
index 0000000..033664d
--- /dev/null
+++ b/project/test/projectsummary_test.py
@@ -0,0 +1,85 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for Project Summary servlet."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+from framework import permissions
+from project import projectsummary
+from proto import project_pb2
+from proto import user_pb2
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class ProjectSummaryTest(unittest.TestCase):
+
+  def setUp(self):
+    services = service_manager.Services(
+        project=fake.ProjectService(),
+        user=fake.UserService(),
+        usergroup=fake.UserGroupService(),
+        project_star=fake.ProjectStarService())
+    self.project = services.project.TestAddProject(
+        'proj', project_id=123, summary='sum',
+        description='desc')
+    self.servlet = projectsummary.ProjectSummary(
+        'req', 'res', services=services)
+
+  def testGatherPageData(self):
+    mr = testing_helpers.MakeMonorailRequest(project=self.project)
+    page_data = self.servlet.GatherPageData(mr)
+    self.assertEqual(
+        '<p>desc</p>', page_data['formatted_project_description'])
+    self.assertEqual(
+        int(project_pb2.ProjectAccess.ANYONE), page_data['access_level'].key)
+    self.assertEqual(0, page_data['num_stars'])
+    self.assertEqual('s', page_data['plural'])
+
+  def testGatherHelpData(self):
+    mr = testing_helpers.MakeMonorailRequest(project=self.project)
+
+    # Non-members cannot edit project, so cue is not relevant.
+    mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(None, help_data['cue'])
+
+    # Members (not owners) cannot edit project, so cue is not relevant.
+    mr.perms = permissions.READ_ONLY_PERMISSIONSET
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(None, help_data['cue'])
+
+    # This is a project member who has set up mailing lists and added
+    # members, but has not noted any duties.
+    mr = testing_helpers.MakeMonorailRequest(project=self.project)
+    self.project.issue_notify_address = 'example@domain.com'
+    self.project.committer_ids.extend([111, 222])
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual('document_team_duties', help_data['cue'])
+
+    # Now help set up notes too.
+    project_commitments = project_pb2.ProjectCommitments()
+    project_commitments.project_id = self.project.project_id
+    project_commitments.commitments.append(
+        project_pb2.ProjectCommitments.MemberCommitment())
+    self.servlet.services.project.TestStoreProjectCommitments(
+        project_commitments)
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(None, help_data['cue'])
+
+  def testGatherHelpData_Dismissed(self):
+    mr = testing_helpers.MakeMonorailRequest(project=self.project)
+    mr.auth.user_id = 111
+    self.project.committer_ids.extend([111, 222])
+    self.servlet.services.user.SetUserPrefs(
+        'cnxn', 111,
+        [user_pb2.UserPrefValue(name='document_team_duties', value='true')])
+    help_data = self.servlet.GatherHelpData(mr, {})
+    self.assertEqual(None, help_data['cue'])
diff --git a/project/test/projectupdates_test.py b/project/test/projectupdates_test.py
new file mode 100644
index 0000000..c2542e8
--- /dev/null
+++ b/project/test/projectupdates_test.py
@@ -0,0 +1,60 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Unittests for monorail.project.projectupdates."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import unittest
+
+import mox
+
+from features import activities
+from project import projectupdates
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class ProjectUpdatesTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services(project=fake.ProjectService())
+
+    self.project_name = 'proj'
+    self.project_id = 987
+    self.project = self.services.project.TestAddProject(
+        self.project_name, project_id=self.project_id,
+        process_inbound_email=True)
+
+    self.mr = testing_helpers.MakeMonorailRequest(
+        services=self.services, project=self.project)
+    self.mr.project_name = self.project_name
+    self.project_updates = projectupdates.ProjectUpdates(
+        None, None, self.services)
+    self.mox = mox.Mox()
+
+  def tearDown(self):
+    self.mox.UnsetStubs()
+    self.mox.ResetAll()
+
+  def testGatherPageData(self):
+    self.mox.StubOutWithMock(activities, 'GatherUpdatesData')
+    activities.GatherUpdatesData(
+        self.services, self.mr, project_ids=[self.project_id],
+        ending='by_user',
+        updates_page_url='/p/%s/updates/list' % self.project_name,
+        autolink=self.services.autolink).AndReturn({'test': 'testing'})
+    self.mox.ReplayAll()
+
+    page_data = self.project_updates.GatherPageData(self.mr)
+    self.mox.VerifyAll()
+    self.assertEqual(
+        {
+            'subtab_mode': None,
+            'user_updates_tab_mode': None,
+            'test': 'testing'
+        }, page_data)
diff --git a/project/test/redirects_test.py b/project/test/redirects_test.py
new file mode 100644
index 0000000..2f51495
--- /dev/null
+++ b/project/test/redirects_test.py
@@ -0,0 +1,90 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Tests for project handlers that redirect."""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import httplib
+import unittest
+
+import webapp2
+
+from framework import urls
+from project import redirects
+from services import service_manager
+from testing import fake
+from testing import testing_helpers
+
+
+class WikiRedirectTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services()
+    self.servlet = redirects.WikiRedirect(
+        webapp2.Request.blank('url'), webapp2.Response(),
+        services=self.services)
+    self.project = fake.Project()
+    self.servlet.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project)
+
+  def testRedirect_NoSuchProject(self):
+    """Visiting a project that we don't host is 404."""
+    self.servlet.mr.project = None
+    self.servlet.get()
+    self.assertEqual(
+        httplib.NOT_FOUND, self.servlet.response.status_code)
+
+  def testRedirect_NoDocsSpecified(self):
+    """Visiting any old wiki URL goes to admin intro by default."""
+    self.servlet.get()
+    self.assertEqual(
+        httplib.MOVED_PERMANENTLY, self.servlet.response.status_code)
+    self.assertTrue(
+        self.servlet.response.location.endswith(urls.ADMIN_INTRO))
+
+  def testRedirect_DocsSpecified(self):
+    """Visiting any old wiki URL goes to project docs URL."""
+    self.project.docs_url = 'some_url'
+    self.servlet.get()
+    self.assertEqual(
+        httplib.MOVED_PERMANENTLY, self.servlet.response.status_code)
+    self.assertEqual('some_url', self.servlet.response.location)
+
+
+class SourceRedirectTest(unittest.TestCase):
+
+  def setUp(self):
+    self.services = service_manager.Services()
+    self.servlet = redirects.SourceRedirect(
+        webapp2.Request.blank('url'), webapp2.Response(),
+        services=self.services)
+    self.project = fake.Project()
+    self.servlet.mr = testing_helpers.MakeMonorailRequest(
+        project=self.project)
+
+  def testRedirect_NoSuchProject(self):
+    """Visiting a project that we don't host is 404."""
+    self.servlet.mr.project = None
+    self.servlet.get()
+    self.assertEqual(
+        httplib.NOT_FOUND, self.servlet.response.status_code)
+
+  def testRedirect_NoSrcSpecified(self):
+    """Visiting any old source code URL goes to admin intro by default."""
+    self.servlet.get()
+    self.assertEqual(
+        httplib.MOVED_PERMANENTLY, self.servlet.response.status_code)
+    self.assertTrue(
+        self.servlet.response.location.endswith(urls.ADMIN_INTRO))
+
+  def testRedirect_SrcSpecified(self):
+    """Visiting any old source code URL goes to project source URL."""
+    self.project.source_url = 'some_url'
+    self.servlet.get()
+    self.assertEqual(
+        httplib.MOVED_PERMANENTLY, self.servlet.response.status_code)
+    self.assertEqual('some_url', self.servlet.response.location)