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)