+{% endif %}
\ No newline at end of file
diff --git a/src/registrar/templates/includes/summary_item.html b/src/registrar/templates/includes/summary_item.html
index d4c68395f..0600d7ea7 100644
--- a/src/registrar/templates/includes/summary_item.html
+++ b/src/registrar/templates/includes/summary_item.html
@@ -24,7 +24,11 @@
{% if sub_header_text %}
{{ sub_header_text }}
{% endif %}
- {% if address %}
+ {% if permissions %}
+ {% include "includes/member_permissions.html" with permissions=value %}
+ {% elif domain_mgmt %}
+ {% include "includes/member_domain_management.html" with domain_count=value %}
+ {% elif address %}
{% include "includes/organization_address.html" with organization=value %}
{% elif contact %}
{% if list %}
@@ -122,9 +126,9 @@
class="usa-link usa-link--icon font-sans-sm line-height-sans-5"
>
- Edit {{ title }}
+ {% if manage_button %}Manage{% elif view_button %}View{% else %}Edit{% endif %} {{ title }}
+ {% endif %}
{% include "includes/members_table.html" with portfolio=portfolio %}
diff --git a/src/registrar/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py
index a3f35ae8e..b29dccb08 100644
--- a/src/registrar/templatetags/custom_filters.py
+++ b/src/registrar/templatetags/custom_filters.py
@@ -246,9 +246,7 @@ def is_members_subpage(path):
"""Checks if the given page is a subpage of members.
Takes a path name, like '/organization/'."""
# Since our pages aren't unified under a common path, we need this approach for now.
- url_names = [
- "members",
- ]
+ url_names = ["members", "member", "member-permissions", "invitedmember", "invitedmember-permissions"]
return get_url_name(path) in url_names
diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py
index 33ae90da9..02902c1a9 100644
--- a/src/registrar/tests/test_models.py
+++ b/src/registrar/tests/test_models.py
@@ -152,12 +152,15 @@ class TestPortfolioInvitations(TestCase):
self.invitation, _ = PortfolioInvitation.objects.get_or_create(
email=self.email,
portfolio=self.portfolio,
- portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
- portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
+ roles=[self.portfolio_role_base, self.portfolio_role_admin],
+ additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
)
def tearDown(self):
super().tearDown()
+ DomainInvitation.objects.all().delete()
+ DomainInformation.objects.all().delete()
+ Domain.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete()
PortfolioInvitation.objects.all().delete()
@@ -209,8 +212,8 @@ class TestPortfolioInvitations(TestCase):
PortfolioInvitation.objects.get_or_create(
email=self.email,
portfolio=portfolio2,
- portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
- portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
+ roles=[self.portfolio_role_base, self.portfolio_role_admin],
+ additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
)
with override_flag("multiple_portfolios", active=True):
self.user.check_portfolio_invitations_on_login()
@@ -233,8 +236,8 @@ class TestPortfolioInvitations(TestCase):
PortfolioInvitation.objects.get_or_create(
email=self.email,
portfolio=portfolio2,
- portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
- portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
+ roles=[self.portfolio_role_base, self.portfolio_role_admin],
+ additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
)
self.user.check_portfolio_invitations_on_login()
self.user.refresh_from_db()
@@ -245,6 +248,52 @@ class TestPortfolioInvitations(TestCase):
updated_invitation2, _ = PortfolioInvitation.objects.get_or_create(email=self.email, portfolio=portfolio2)
self.assertEqual(updated_invitation2.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED)
+ @less_console_noise_decorator
+ def test_get_managed_domains_count(self):
+ """Test that the correct number of domains, which are associated with the portfolio and
+ have invited the email of the portfolio invitation, are returned."""
+ # Add three domains, one which is in the portfolio and email is invited to,
+ # one which is in the portfolio and email is not invited to,
+ # and one which is email is invited to and not in the portfolio.
+ # Arrange
+ # domain_in_portfolio should not be included in the count
+ domain_in_portfolio, _ = Domain.objects.get_or_create(name="domain_in_portfolio.gov", state=Domain.State.READY)
+ DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio, portfolio=self.portfolio)
+ # domain_in_portfolio_and_invited should be included in the count
+ domain_in_portfolio_and_invited, _ = Domain.objects.get_or_create(
+ name="domain_in_portfolio_and_invited.gov", state=Domain.State.READY
+ )
+ DomainInformation.objects.get_or_create(
+ creator=self.user, domain=domain_in_portfolio_and_invited, portfolio=self.portfolio
+ )
+ DomainInvitation.objects.get_or_create(email=self.email, domain=domain_in_portfolio_and_invited)
+ # domain_invited should not be included in the count
+ domain_invited, _ = Domain.objects.get_or_create(name="domain_invited.gov", state=Domain.State.READY)
+ DomainInformation.objects.get_or_create(creator=self.user, domain=domain_invited)
+ DomainInvitation.objects.get_or_create(email=self.email, domain=domain_invited)
+
+ # Assert
+ self.assertEqual(self.invitation.get_managed_domains_count(), 1)
+
+ @less_console_noise_decorator
+ def test_get_portfolio_permissions(self):
+ """Test that get_portfolio_permissions returns the expected list of permissions,
+ based on the roles and permissions assigned to the invitation."""
+ # Arrange
+ test_permission_list = set()
+ # add the arrays that are defined in UserPortfolioPermission for member and admin
+ test_permission_list.update(
+ UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [])
+ )
+ test_permission_list.update(
+ UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_ADMIN, [])
+ )
+ # add the permissions that are added to the invitation as additional_permissions
+ test_permission_list.update([self.portfolio_permission_1, self.portfolio_permission_2])
+ perm_list = list(test_permission_list)
+ # Verify
+ self.assertEquals(self.invitation.get_portfolio_permissions(), perm_list)
+
class TestUserPortfolioPermission(TestCase):
@less_console_noise_decorator
@@ -314,6 +363,40 @@ class TestUserPortfolioPermission(TestCase):
),
)
+ @less_console_noise_decorator
+ def test_get_managed_domains_count(self):
+ """Test that the correct number of managed domains associated with the portfolio
+ are returned."""
+ # Add three domains, one which is in the portfolio and managed by the user,
+ # one which is in the portfolio and not managed by the user,
+ # and one which is managed by the user and not in the portfolio.
+ # Arrange
+ portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
+ test_user = create_test_user()
+ portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
+ portfolio=portfolio, user=test_user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
+ )
+ # domain_in_portfolio should not be included in the count
+ domain_in_portfolio, _ = Domain.objects.get_or_create(name="domain_in_portfolio.gov", state=Domain.State.READY)
+ DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio, portfolio=portfolio)
+ # domain_in_portfolio_and_managed should be included in the count
+ domain_in_portfolio_and_managed, _ = Domain.objects.get_or_create(
+ name="domain_in_portfolio_and_managed.gov", state=Domain.State.READY
+ )
+ DomainInformation.objects.get_or_create(
+ creator=self.user, domain=domain_in_portfolio_and_managed, portfolio=portfolio
+ )
+ UserDomainRole.objects.get_or_create(
+ user=test_user, domain=domain_in_portfolio_and_managed, role=UserDomainRole.Roles.MANAGER
+ )
+ # domain_managed should not be included in the count
+ domain_managed, _ = Domain.objects.get_or_create(name="domain_managed.gov", state=Domain.State.READY)
+ DomainInformation.objects.get_or_create(creator=self.user, domain=domain_managed)
+ UserDomainRole.objects.get_or_create(user=test_user, domain=domain_managed, role=UserDomainRole.Roles.MANAGER)
+
+ # Assert
+ self.assertEqual(portfolio_permission.get_managed_domains_count(), 1)
+
class TestUser(TestCase):
"""Test actions that occur on user login,
diff --git a/src/registrar/tests/test_views_domains_json.py b/src/registrar/tests/test_views_domains_json.py
index 07799104b..c4e5832c0 100644
--- a/src/registrar/tests/test_views_domains_json.py
+++ b/src/registrar/tests/test_views_domains_json.py
@@ -37,6 +37,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
UserDomainRole.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
DomainInformation.objects.all().delete()
+ Domain.objects.all().delete()
Portfolio.objects.all().delete()
super().tearDown()
diff --git a/src/registrar/tests/test_views_members_json.py b/src/registrar/tests/test_views_members_json.py
index 75c3a3a66..9cd4e823c 100644
--- a/src/registrar/tests/test_views_members_json.py
+++ b/src/registrar/tests/test_views_members_json.py
@@ -1,6 +1,7 @@
from django.urls import reverse
from registrar.models.portfolio import Portfolio
+from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user import User
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
@@ -38,6 +39,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
phone="8003114567",
title="Admin",
)
+ cls.email5 = "fifth@example.com"
# Create Portfolio
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
@@ -67,6 +69,23 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
portfolio=cls.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
+ PortfolioInvitation.objects.create(
+ email=cls.email5,
+ portfolio=cls.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ ],
+ )
+
+ @classmethod
+ def tearDownClass(cls):
+ PortfolioInvitation.objects.all().delete()
+ UserPortfolioPermission.objects.all().delete()
+ Portfolio.objects.all().delete()
+ User.objects.all().delete()
+ super().tearDownClass()
def setUp(self):
super().setUp()
@@ -83,14 +102,21 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
self.assertFalse(data["has_previous"])
self.assertFalse(data["has_next"])
self.assertEqual(data["num_pages"], 1)
- self.assertEqual(data["total"], 4)
- self.assertEqual(data["unfiltered_total"], 4)
+ self.assertEqual(data["total"], 5)
+ self.assertEqual(data["unfiltered_total"], 5)
# Check the number of members
- self.assertEqual(len(data["members"]), 4)
+ self.assertEqual(len(data["members"]), 5)
# Check member fields
- expected_emails = {self.user.email, self.user2.email, self.user3.email, self.user4.email}
+ expected_emails = {
+ self.user.email,
+ self.user2.email,
+ self.user3.email,
+ self.user4.email,
+ self.user4.email,
+ self.email5,
+ }
actual_emails = {member["email"] for member in data["members"]}
self.assertEqual(expected_emails, actual_emails)
@@ -123,8 +149,8 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
self.assertTrue(data["has_next"])
self.assertFalse(data["has_previous"])
self.assertEqual(data["num_pages"], 2)
- self.assertEqual(data["total"], 14)
- self.assertEqual(data["unfiltered_total"], 14)
+ self.assertEqual(data["total"], 15)
+ self.assertEqual(data["unfiltered_total"], 15)
# Check the number of members on page 1
self.assertEqual(len(data["members"]), 10)
@@ -142,7 +168,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
self.assertEqual(data["num_pages"], 2)
# Check the number of members on page 2
- self.assertEqual(len(data["members"]), 4)
+ self.assertEqual(len(data["members"]), 5)
def test_search(self):
"""Test search functionality for portfolio members."""
diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py
index dfb0469d0..13173565c 100644
--- a/src/registrar/tests/test_views_portfolio.py
+++ b/src/registrar/tests/test_views_portfolio.py
@@ -10,6 +10,7 @@ from registrar.models import (
UserDomainRole,
User,
)
+from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user_group import UserGroup
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
@@ -288,9 +289,9 @@ class TestPortfolio(WebTest):
def test_accessible_pages_when_user_does_not_have_role(self):
"""Test that admin / memmber roles are associated with the right access"""
self.app.set_user(self.user.username)
- portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
+ roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
- user=self.user, portfolio=self.portfolio, roles=portfolio_roles
+ user=self.user, portfolio=self.portfolio, roles=roles
)
with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page.
@@ -398,8 +399,8 @@ class TestPortfolio(WebTest):
"""When organization_feature flag is true and user has a portfolio,
the portfolio should be set in session."""
self.client.force_login(self.user)
- portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
- UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
+ roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
+ UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
with override_flag("organization_feature", active=True):
response = self.client.get(reverse("home"))
# Ensure that middleware processes the session
@@ -420,8 +421,8 @@ class TestPortfolio(WebTest):
This test also satisfies the condition when multiple_portfolios flag
is false and user has a portfolio, so won't add a redundant test for that."""
self.client.force_login(self.user)
- portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
- UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
+ roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
+ UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
response = self.client.get(reverse("home"))
# Ensure that middleware processes the session
session_middleware = SessionMiddleware(lambda request: None)
@@ -457,8 +458,8 @@ class TestPortfolio(WebTest):
"""When multiple_portfolios flag is true and user has a portfolio,
the portfolio should be set in session."""
self.client.force_login(self.user)
- portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
- UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
+ roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
+ UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
with override_flag("organization_feature", active=True), override_flag("multiple_portfolios", active=True):
response = self.client.get(reverse("home"))
# Ensure that middleware processes the session
@@ -817,7 +818,6 @@ class TestPortfolio(WebTest):
# Verify that view-only settings are sent in the dynamic HTML
response = self.client.get(reverse("get_portfolio_members_json") + f"?portfolio={self.portfolio.pk}")
- print(response.content)
self.assertContains(response, '"action_label": "View"')
self.assertContains(response, '"svg_icon": "visibility"')
@@ -856,6 +856,230 @@ class TestPortfolio(WebTest):
# TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"{response.content}")
self.assertContains(response, '"is_admin": true')
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ def test_cannot_view_member_page_when_flag_is_off(self):
+ """Test that user cannot access the member page when waffle flag is off"""
+
+ # Verify that the user cannot access the member page
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("member", kwargs={"pk": 1}), follow=True)
+ # Make sure the page is denied
+ self.assertEqual(response.status_code, 403)
+
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ def test_cannot_view_member_page_when_user_has_no_permission(self):
+ """Test that user cannot access the member page without proper permission"""
+
+ # give user base permissions
+ UserPortfolioPermission.objects.get_or_create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ )
+
+ # Verify that the user cannot access the member page
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("member", kwargs={"pk": 1}), follow=True)
+ # Make sure the page is denied
+ self.assertEqual(response.status_code, 403)
+
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ def test_can_view_member_page_when_user_has_view_members(self):
+ """Test that user can access the member page with view_members permission"""
+
+ # Arrange
+ # give user permissions to view members
+ permission_obj, _ = UserPortfolioPermission.objects.get_or_create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ ],
+ )
+
+ # Verify the page can be accessed
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("member", kwargs={"pk": permission_obj.pk}), follow=True)
+ self.assertEqual(response.status_code, 200)
+
+ # Assert text within the page is correct
+ self.assertContains(response, "First Last")
+ self.assertContains(response, self.user.email)
+ self.assertContains(response, "Basic access")
+ self.assertContains(response, "No access")
+ self.assertContains(response, "View all members")
+ self.assertContains(response, "This member does not manage any domains.")
+
+ # Assert buttons and links within the page are correct
+ self.assertNotContains(response, "usa-button--more-actions") # test that 3 dot is not present
+ self.assertNotContains(response, "sprite.svg#edit") # test that Edit link is not present
+ self.assertNotContains(response, "sprite.svg#settings") # test that Manage link is not present
+ self.assertContains(response, "sprite.svg#visibility") # test that View link is present
+
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ def test_can_view_member_page_when_user_has_edit_members(self):
+ """Test that user can access the member page with edit_members permission"""
+
+ # Arrange
+ # give user permissions to view AND manage members
+ permission_obj, _ = UserPortfolioPermission.objects.get_or_create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ ],
+ )
+
+ # Verify the page can be accessed
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("member", kwargs={"pk": permission_obj.pk}), follow=True)
+ self.assertEqual(response.status_code, 200)
+
+ # Assert text within the page is correct
+ self.assertContains(response, "First Last")
+ self.assertContains(response, self.user.email)
+ self.assertContains(response, "Admin access")
+ self.assertContains(response, "View all requests plus create requests")
+ self.assertContains(response, "View all members plus manage members")
+ self.assertContains(
+ response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
+ )
+
+ # Assert buttons and links within the page are correct
+ self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
+ self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
+ self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
+ self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
+
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ def test_cannot_view_invitedmember_page_when_flag_is_off(self):
+ """Test that user cannot access the invitedmember page when waffle flag is off"""
+
+ # Verify that the user cannot access the member page
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("invitedmember", kwargs={"pk": 1}), follow=True)
+ # Make sure the page is denied
+ self.assertEqual(response.status_code, 403)
+
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ def test_cannot_view_invitedmember_page_when_user_has_no_permission(self):
+ """Test that user cannot access the invitedmember page without proper permission"""
+
+ # give user base permissions
+ UserPortfolioPermission.objects.get_or_create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ )
+
+ # Verify that the user cannot access the member page
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("invitedmember", kwargs={"pk": 1}), follow=True)
+ # Make sure the page is denied
+ self.assertEqual(response.status_code, 403)
+
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ def test_can_view_invitedmember_page_when_user_has_view_members(self):
+ """Test that user can access the invitedmember page with view_members permission"""
+
+ # Arrange
+ # give user permissions to view members
+ UserPortfolioPermission.objects.get_or_create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ ],
+ )
+ portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
+ email="info@example.com",
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ ],
+ )
+
+ # Verify the page can be accessed
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("invitedmember", kwargs={"pk": portfolio_invitation.pk}), follow=True)
+ self.assertEqual(response.status_code, 200)
+
+ # Assert text within the page is correct
+ self.assertContains(response, "Invited")
+ self.assertContains(response, portfolio_invitation.email)
+ self.assertContains(response, "Basic access")
+ self.assertContains(response, "No access")
+ self.assertContains(response, "View all members")
+ self.assertContains(response, "This member does not manage any domains.")
+
+ # Assert buttons and links within the page are correct
+ self.assertNotContains(response, "usa-button--more-actions") # test that 3 dot is not present
+ self.assertNotContains(response, "sprite.svg#edit") # test that Edit link is not present
+ self.assertNotContains(response, "sprite.svg#settings") # test that Manage link is not present
+ self.assertContains(response, "sprite.svg#visibility") # test that View link is present
+
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ def test_can_view_invitedmember_page_when_user_has_edit_members(self):
+ """Test that user can access the invitedmember page with edit_members permission"""
+
+ # Arrange
+ # give user permissions to view AND manage members
+ permission_obj, _ = UserPortfolioPermission.objects.get_or_create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ ],
+ )
+ portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
+ email="info@example.com",
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ ],
+ )
+
+ # Verify the page can be accessed
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("invitedmember", kwargs={"pk": portfolio_invitation.pk}), follow=True)
+ self.assertEqual(response.status_code, 200)
+
+ # Assert text within the page is correct
+ self.assertContains(response, "Invited")
+ self.assertContains(response, portfolio_invitation.email)
+ self.assertContains(response, "Admin access")
+ self.assertContains(response, "View all requests plus create requests")
+ self.assertContains(response, "View all members plus manage members")
+ self.assertContains(
+ response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
+ )
+
+ # Assert buttons and links within the page are correct
+ self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
+ self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
+ self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
+ self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
+
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_portfolio_domain_requests_page_when_user_has_no_permissions(self):
@@ -1015,8 +1239,8 @@ class TestPortfolio(WebTest):
def test_portfolio_cache_updates_when_modified(self):
"""Test that the portfolio in session updates when the portfolio is modified"""
self.client.force_login(self.user)
- portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
- UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
+ roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
+ UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
with override_flag("organization_feature", active=True):
# Initial request to set the portfolio in session
@@ -1044,8 +1268,8 @@ class TestPortfolio(WebTest):
def test_portfolio_cache_updates_when_flag_disabled_while_logged_in(self):
"""Test that the portfolio in session is set to None when the organization_feature flag is disabled"""
self.client.force_login(self.user)
- portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
- UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
+ roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
+ UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
with override_flag("organization_feature", active=True):
# Initial request to set the portfolio in session
diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py
index 133e6750e..d2f2276cf 100644
--- a/src/registrar/views/portfolio_members_json.py
+++ b/src/registrar/views/portfolio_members_json.py
@@ -1,45 +1,41 @@
from django.http import JsonResponse
from django.core.paginator import Paginator
from django.contrib.auth.decorators import login_required
-from django.db.models import Q
+from django.db.models import Value, F, CharField, TextField, Q, Case, When
+from django.db.models.functions import Concat, Coalesce
+from django.urls import reverse
+from django.db.models.functions import Cast
from registrar.models.portfolio_invitation import PortfolioInvitation
-from registrar.models.user import User
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
@login_required
def get_portfolio_members_json(request):
- """Given the current request,
- get all members that are associated with the given portfolio"""
+ """Fetch members (permissions and invitations) for the given portfolio."""
+
portfolio = request.GET.get("portfolio")
- member_ids = get_member_ids_from_request(request, portfolio)
- objects = User.objects.filter(id__in=member_ids)
- admin_ids = UserPortfolioPermission.objects.filter(
- portfolio=portfolio,
- roles__overlap=[
- UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
- ],
- ).values_list("user__id", flat=True)
- portfolio_invitation_emails = PortfolioInvitation.objects.filter(portfolio=portfolio).values_list(
- "email", flat=True
- )
+ # Two initial querysets which will be combined
+ permissions = initial_permissions_search(portfolio)
+ invitations = initial_invitations_search(portfolio)
- unfiltered_total = objects.count()
+ # Get total across both querysets before applying filters
+ unfiltered_total = permissions.count() + invitations.count()
- objects = apply_search(objects, request)
- # objects = apply_status_filter(objects, request)
+ permissions = apply_search_term(permissions, request)
+ invitations = apply_search_term(invitations, request)
+
+ # Union the two querysets
+ objects = permissions.union(invitations)
objects = apply_sorting(objects, request)
paginator = Paginator(objects, 10)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
- members = [
- serialize_members(request, portfolio, member, request.user, admin_ids, portfolio_invitation_emails)
- for member in page_obj.object_list
- ]
+
+ members = [serialize_members(request, portfolio, item, request.user) for item in page_obj.object_list]
return JsonResponse(
{
@@ -54,71 +50,121 @@ def get_portfolio_members_json(request):
)
-def get_member_ids_from_request(request, portfolio):
- """Given the current request,
- get all members that are associated with the given portfolio"""
- member_ids = []
- if portfolio:
- member_ids = UserPortfolioPermission.objects.filter(portfolio=portfolio).values_list("user__id", flat=True)
- return member_ids
+def initial_permissions_search(portfolio):
+ """Perform initial search for permissions before applying any filters."""
+ permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio)
+ permissions = (
+ permissions.select_related("user")
+ .annotate(
+ first_name=F("user__first_name"),
+ last_name=F("user__last_name"),
+ email_display=F("user__email"),
+ last_active=Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text
+ additional_permissions_display=F("additional_permissions"),
+ member_display=Case(
+ # If email is present and not blank, use email
+ When(Q(user__email__isnull=False) & ~Q(user__email=""), then=F("user__email")),
+ # If first name or last name is present, use concatenation of first_name + " " + last_name
+ When(
+ Q(user__first_name__isnull=False) | Q(user__last_name__isnull=False),
+ then=Concat(
+ Coalesce(F("user__first_name"), Value("")),
+ Value(" "),
+ Coalesce(F("user__last_name"), Value("")),
+ ),
+ ),
+ # If neither, use an empty string
+ default=Value(""),
+ output_field=CharField(),
+ ),
+ source=Value("permission", output_field=CharField()),
+ )
+ .values(
+ "id",
+ "first_name",
+ "last_name",
+ "email_display",
+ "last_active",
+ "roles",
+ "additional_permissions_display",
+ "member_display",
+ "source",
+ )
+ )
+ return permissions
-def apply_search(queryset, request):
- search_term = request.GET.get("search_term")
+def initial_invitations_search(portfolio):
+ """Perform initial invitations search before applying any filters."""
+ invitations = PortfolioInvitation.objects.filter(portfolio=portfolio)
+ invitations = invitations.annotate(
+ first_name=Value(None, output_field=CharField()),
+ last_name=Value(None, output_field=CharField()),
+ email_display=F("email"),
+ last_active=Value("Invited", output_field=TextField()),
+ additional_permissions_display=F("additional_permissions"),
+ member_display=F("email"),
+ source=Value("invitation", output_field=CharField()),
+ ).values(
+ "id",
+ "first_name",
+ "last_name",
+ "email_display",
+ "last_active",
+ "roles",
+ "additional_permissions_display",
+ "member_display",
+ "source",
+ )
+ return invitations
+
+def apply_search_term(queryset, request):
+ """Apply search term to the queryset."""
+ search_term = request.GET.get("search_term", "").lower()
if search_term:
queryset = queryset.filter(
- Q(username__icontains=search_term)
- | Q(first_name__icontains=search_term)
+ Q(first_name__icontains=search_term)
| Q(last_name__icontains=search_term)
- | Q(email__icontains=search_term)
+ | Q(email_display__icontains=search_term)
)
return queryset
def apply_sorting(queryset, request):
+ """Apply sorting to the queryset."""
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
order = request.GET.get("order", "asc") # Default to 'asc'
-
+ # Adjust sort_by to match the annotated fields in the unioned queryset
if sort_by == "member":
- sort_by = ["email", "first_name", "middle_name", "last_name"]
- else:
- sort_by = [sort_by]
-
+ sort_by = "member_display"
if order == "desc":
- sort_by = [f"-{field}" for field in sort_by]
-
- return queryset.order_by(*sort_by)
+ queryset = queryset.order_by(F(sort_by).desc())
+ else:
+ queryset = queryset.order_by(sort_by)
+ return queryset
-def serialize_members(request, portfolio, member, user, admin_ids, portfolio_invitation_emails):
- # ------- VIEW ONLY
- # If not view_only (the user has permissions to edit/manage users), show the gear icon with "Manage" link.
- # If view_only (the user only has view user permissions), show the "View" link (no gear icon).
- # We check on user_group_permision to account for the upcoming "Manage portfolio" button on admin.
- user_can_edit_other_users = False
- for user_group_permission in ["registrar.full_access_permission", "registrar.change_user"]:
- if user.has_perm(user_group_permission):
- user_can_edit_other_users = True
- break
+def serialize_members(request, portfolio, item, user):
+ # Check if the user can edit other users
+ user_can_edit_other_users = any(
+ user.has_perm(perm) for perm in ["registrar.full_access_permission", "registrar.change_user"]
+ )
view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users
- # ------- USER STATUSES
- is_invited = member.email in portfolio_invitation_emails
- last_active = "Invited" if is_invited else "Unknown"
- if member.last_login:
- last_active = member.last_login.strftime("%b. %d, %Y")
- is_admin = member.id in admin_ids
+ is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or [])
+ action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]})
- # ------- SERIALIZE
+ # Serialize member data
member_json = {
- "id": member.id,
- "name": member.get_formatted_name(),
- "email": member.email,
+ "id": item.get("id", ""),
+ "name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
+ "email": item.get("email_display", ""),
+ "member_display": item.get("member_display", ""),
"is_admin": is_admin,
- "last_active": last_active,
- "action_url": "#", # reverse("members", kwargs={"pk": member.id}), # TODO: Future ticket?
+ "last_active": item.get("last_active", ""),
+ "action_url": action_url,
"action_label": ("View" if view_only else "Manage"),
"svg_icon": ("visibility" if view_only else "settings"),
}
diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py
index 552fdb6ff..cc1a09b25 100644
--- a/src/registrar/views/portfolios.py
+++ b/src/registrar/views/portfolios.py
@@ -3,20 +3,30 @@ from django.http import Http404
from django.shortcuts import render
from django.urls import reverse
from django.contrib import messages
-from registrar.forms.portfolio import PortfolioOrgAddressForm, PortfolioSeniorOfficialForm
+from registrar.forms.portfolio import (
+ PortfolioInvitedMemberForm,
+ PortfolioMemberForm,
+ PortfolioOrgAddressForm,
+ PortfolioSeniorOfficialForm,
+)
from registrar.models import Portfolio, User
+from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user_portfolio_permission import UserPortfolioPermission
-from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
+from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.views.utility.permission_views import (
PortfolioDomainRequestsPermissionView,
PortfolioDomainsPermissionView,
PortfolioBasePermissionView,
NoPortfolioDomainsPermissionView,
+ PortfolioInvitedMemberEditPermissionView,
+ PortfolioInvitedMemberPermissionView,
+ PortfolioMemberEditPermissionView,
+ PortfolioMemberPermissionView,
PortfolioMembersPermissionView,
)
from django.views.generic import View
from django.views.generic.edit import FormMixin
-
+from django.shortcuts import get_object_or_404, redirect
logger = logging.getLogger(__name__)
@@ -51,6 +61,155 @@ class PortfolioMembersView(PortfolioMembersPermissionView, View):
return render(request, "portfolio_members.html")
+class PortfolioMemberView(PortfolioMemberPermissionView, View):
+
+ template_name = "portfolio_member.html"
+
+ def get(self, request, pk):
+ portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
+ member = portfolio_permission.user
+
+ # We have to explicitely name these with member_ otherwise we'll have conflicts with context preprocessors
+ member_has_view_all_requests_portfolio_permission = member.has_view_all_requests_portfolio_permission(
+ portfolio_permission.portfolio
+ )
+ member_has_edit_request_portfolio_permission = member.has_edit_request_portfolio_permission(
+ portfolio_permission.portfolio
+ )
+ member_has_view_members_portfolio_permission = member.has_view_members_portfolio_permission(
+ portfolio_permission.portfolio
+ )
+ member_has_edit_members_portfolio_permission = member.has_edit_members_portfolio_permission(
+ portfolio_permission.portfolio
+ )
+
+ return render(
+ request,
+ self.template_name,
+ {
+ "edit_url": reverse("member-permissions", args=[pk]),
+ "portfolio_permission": portfolio_permission,
+ "member": member,
+ "member_has_view_all_requests_portfolio_permission": member_has_view_all_requests_portfolio_permission,
+ "member_has_edit_request_portfolio_permission": member_has_edit_request_portfolio_permission,
+ "member_has_view_members_portfolio_permission": member_has_view_members_portfolio_permission,
+ "member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission,
+ },
+ )
+
+
+class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
+
+ template_name = "portfolio_member_permissions.html"
+ form_class = PortfolioMemberForm
+
+ def get(self, request, pk):
+ portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
+ user = portfolio_permission.user
+
+ form = self.form_class(instance=portfolio_permission)
+
+ return render(
+ request,
+ self.template_name,
+ {
+ "form": form,
+ "member": user,
+ },
+ )
+
+ def post(self, request, pk):
+ portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
+ user = portfolio_permission.user
+
+ form = self.form_class(request.POST, instance=portfolio_permission)
+
+ if form.is_valid():
+ form.save()
+ return redirect("member", pk=pk)
+
+ return render(
+ request,
+ self.template_name,
+ {
+ "form": form,
+ "member": user, # Pass the user object again to the template
+ },
+ )
+
+
+class PortfolioInvitedMemberView(PortfolioInvitedMemberPermissionView, View):
+
+ template_name = "portfolio_member.html"
+ # form_class = PortfolioInvitedMemberForm
+
+ def get(self, request, pk):
+ portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
+ # form = self.form_class(instance=portfolio_invitation)
+
+ # We have to explicitely name these with member_ otherwise we'll have conflicts with context preprocessors
+ member_has_view_all_requests_portfolio_permission = (
+ UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in portfolio_invitation.get_portfolio_permissions()
+ )
+ member_has_edit_request_portfolio_permission = (
+ UserPortfolioPermissionChoices.EDIT_REQUESTS in portfolio_invitation.get_portfolio_permissions()
+ )
+ member_has_view_members_portfolio_permission = (
+ UserPortfolioPermissionChoices.VIEW_MEMBERS in portfolio_invitation.get_portfolio_permissions()
+ )
+ member_has_edit_members_portfolio_permission = (
+ UserPortfolioPermissionChoices.EDIT_MEMBERS in portfolio_invitation.get_portfolio_permissions()
+ )
+
+ return render(
+ request,
+ self.template_name,
+ {
+ "edit_url": reverse("invitedmember-permissions", args=[pk]),
+ "portfolio_invitation": portfolio_invitation,
+ "member_has_view_all_requests_portfolio_permission": member_has_view_all_requests_portfolio_permission,
+ "member_has_edit_request_portfolio_permission": member_has_edit_request_portfolio_permission,
+ "member_has_view_members_portfolio_permission": member_has_view_members_portfolio_permission,
+ "member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission,
+ },
+ )
+
+
+class PortfolioInvitedMemberEditView(PortfolioInvitedMemberEditPermissionView, View):
+
+ template_name = "portfolio_member_permissions.html"
+ form_class = PortfolioInvitedMemberForm
+
+ def get(self, request, pk):
+ portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
+ form = self.form_class(instance=portfolio_invitation)
+
+ return render(
+ request,
+ self.template_name,
+ {
+ "form": form,
+ "invitation": portfolio_invitation,
+ },
+ )
+
+ def post(self, request, pk):
+ portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
+ form = self.form_class(request.POST, instance=portfolio_invitation)
+ if form.is_valid():
+ form.save()
+ return redirect("invitedmember", pk=pk)
+
+ return render(
+ request,
+ self.template_name,
+ {
+ "form": form,
+ "invitation": portfolio_invitation, # Pass the user object again to the template
+ },
+ )
+
+
class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View):
"""Some users have access to the underlying portfolio, but not any domains.
This is a custom view which explains that to the user - and denotes who to contact.
diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py
index f400e7f4b..9cee2f61a 100644
--- a/src/registrar/views/utility/mixins.py
+++ b/src/registrar/views/utility/mixins.py
@@ -512,7 +512,81 @@ class PortfolioMembersPermission(PortfolioBasePermission):
up from the portfolio's primary key in self.kwargs["pk"]"""
portfolio = self.request.session.get("portfolio")
- if not self.request.user.has_view_members_portfolio_permission(portfolio):
+ if not self.request.user.has_view_members_portfolio_permission(
+ portfolio
+ ) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
+ return False
+
+ return super().has_permission()
+
+
+class PortfolioMemberPermission(PortfolioBasePermission):
+ """Permission mixin that allows access to portfolio member pages if user
+ has access, otherwise 403"""
+
+ def has_permission(self):
+ """Check if this user has access to members for this portfolio.
+
+ The user is in self.request.user and the portfolio can be looked
+ up from the portfolio's primary key in self.kwargs["pk"]"""
+
+ portfolio = self.request.session.get("portfolio")
+ if not self.request.user.has_view_members_portfolio_permission(
+ portfolio
+ ) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
+ return False
+
+ return super().has_permission()
+
+
+class PortfolioMemberEditPermission(PortfolioBasePermission):
+ """Permission mixin that allows access to portfolio member pages if user
+ has access to edit, otherwise 403"""
+
+ def has_permission(self):
+ """Check if this user has access to members for this portfolio.
+
+ The user is in self.request.user and the portfolio can be looked
+ up from the portfolio's primary key in self.kwargs["pk"]"""
+
+ portfolio = self.request.session.get("portfolio")
+ if not self.request.user.has_edit_members_portfolio_permission(portfolio):
+ return False
+
+ return super().has_permission()
+
+
+class PortfolioInvitedMemberPermission(PortfolioBasePermission):
+ """Permission mixin that allows access to portfolio invited member pages if user
+ has access, otherwise 403"""
+
+ def has_permission(self):
+ """Check if this user has access to members for this portfolio.
+
+ The user is in self.request.user and the portfolio can be looked
+ up from the portfolio's primary key in self.kwargs["pk"]"""
+
+ portfolio = self.request.session.get("portfolio")
+ if not self.request.user.has_view_members_portfolio_permission(
+ portfolio
+ ) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
+ return False
+
+ return super().has_permission()
+
+
+class PortfolioInvitedMemberEditPermission(PortfolioBasePermission):
+ """Permission mixin that allows access to portfolio invited member pages if user
+ has access to edit, otherwise 403"""
+
+ def has_permission(self):
+ """Check if this user has access to members for this portfolio.
+
+ The user is in self.request.user and the portfolio can be looked
+ up from the portfolio's primary key in self.kwargs["pk"]"""
+
+ portfolio = self.request.session.get("portfolio")
+ if not self.request.user.has_edit_members_portfolio_permission(portfolio):
return False
return super().has_permission()
diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py
index 414e58275..c1d25d691 100644
--- a/src/registrar/views/utility/permission_views.py
+++ b/src/registrar/views/utility/permission_views.py
@@ -15,10 +15,14 @@ from .mixins import (
DomainRequestWizardPermission,
PortfolioDomainRequestsPermission,
PortfolioDomainsPermission,
+ PortfolioInvitedMemberEditPermission,
+ PortfolioInvitedMemberPermission,
+ PortfolioMemberEditPermission,
UserDeleteDomainRolePermission,
UserProfilePermission,
PortfolioBasePermission,
PortfolioMembersPermission,
+ PortfolioMemberPermission,
DomainRequestPortfolioViewonlyPermission,
)
import logging
@@ -253,7 +257,41 @@ class PortfolioDomainRequestsPermissionView(PortfolioDomainRequestsPermission, P
class PortfolioMembersPermissionView(PortfolioMembersPermission, PortfolioBasePermissionView, abc.ABC):
- """Abstract base view for portfolio domain request views that enforces permissions.
+ """Abstract base view for portfolio members views that enforces permissions.
+
+ This abstract view cannot be instantiated. Actual views must specify
+ `template_name`.
+ """
+
+
+class PortfolioMemberPermissionView(PortfolioMemberPermission, PortfolioBasePermissionView, abc.ABC):
+ """Abstract base view for portfolio member views that enforces permissions.
+
+ This abstract view cannot be instantiated. Actual views must specify
+ `template_name`.
+ """
+
+
+class PortfolioMemberEditPermissionView(PortfolioMemberEditPermission, PortfolioBasePermissionView, abc.ABC):
+ """Abstract base view for portfolio member edit views that enforces permissions.
+
+ This abstract view cannot be instantiated. Actual views must specify
+ `template_name`.
+ """
+
+
+class PortfolioInvitedMemberPermissionView(PortfolioInvitedMemberPermission, PortfolioBasePermissionView, abc.ABC):
+ """Abstract base view for portfolio member views that enforces permissions.
+
+ This abstract view cannot be instantiated. Actual views must specify
+ `template_name`.
+ """
+
+
+class PortfolioInvitedMemberEditPermissionView(
+ PortfolioInvitedMemberEditPermission, PortfolioBasePermissionView, abc.ABC
+):
+ """Abstract base view for portfolio member edit views that enforces permissions.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
diff --git a/src/zap.conf b/src/zap.conf
index dd9ae1565..1f0548f2d 100644
--- a/src/zap.conf
+++ b/src/zap.conf
@@ -71,6 +71,7 @@
10038 OUTOFSCOPE http://app:8080/domain_requests/
10038 OUTOFSCOPE http://app:8080/domains/
10038 OUTOFSCOPE http://app:8080/organization/
+10038 OUTOFSCOPE http://app:8080/permissions
10038 OUTOFSCOPE http://app:8080/suborganization/
10038 OUTOFSCOPE http://app:8080/transfer/
# This URL always returns 404, so include it as well.