diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js
index f3457649c..cd2e251ab 100644
--- a/src/registrar/assets/js/get-gov.js
+++ b/src/registrar/assets/js/get-gov.js
@@ -2043,11 +2043,11 @@ class MembersTable extends LoadTableBase {
if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_MEMBERS)) {
permissionsHTML += "
Members: Can manage members including inviting new members, removing current members, and assigning domains to members.";
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MEMBERS)) {
- permissionsHTML += "
class='margin-top-1 p--blockquote'>Members (view-only): Can view all organizational members. Can't manage any members.";
+ permissionsHTML += "
Members (view-only): Can view all organizational members. Can't manage any members.";
}
// if there are no additional permissions, display a no additional permissions message
if (!permissionsHTML) {
- permissionsHTML += "
No additional permissions: There are no additional permissions for this member.
";
+ permissionsHTML += "No additional permissions: There are no additional permissions for this member.
";
}
// add permissions header in all cases
permissionsHTML = "
Additional permissions for this member " + permissionsHTML + "";
@@ -2058,7 +2058,7 @@ class MembersTable extends LoadTableBase {
showMoreButton = `
Expand
diff --git a/src/registrar/tests/test_views_members_json.py b/src/registrar/tests/test_views_members_json.py
index 9cd4e823c..77a7a5566 100644
--- a/src/registrar/tests/test_views_members_json.py
+++ b/src/registrar/tests/test_views_members_json.py
@@ -1,21 +1,25 @@
from django.urls import reverse
+from registrar.models.domain import Domain
+from registrar.models.domain_information import DomainInformation
+from registrar.models.domain_invitation import DomainInvitation
from registrar.models.portfolio import Portfolio
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user import User
+from registrar.models.user_domain_role import UserDomainRole
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
-from .test_views import TestWithUser
+from registrar.tests.common import MockEppLib, create_test_user
from django_webtest import WebTest # type: ignore
-class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
+class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
+ def setUp(self):
+ super().setUp()
+ self.user = create_test_user()
# Create additional users
- cls.user2 = User.objects.create(
+ self.user2 = User.objects.create(
username="test_user2",
first_name="Second",
last_name="User",
@@ -23,7 +27,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
phone="8003112345",
title="Member",
)
- cls.user3 = User.objects.create(
+ self.user3 = User.objects.create(
username="test_user3",
first_name="Third",
last_name="User",
@@ -31,7 +35,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
phone="8003113456",
title="Member",
)
- cls.user4 = User.objects.create(
+ self.user4 = User.objects.create(
username="test_user4",
first_name="Fourth",
last_name="User",
@@ -39,60 +43,66 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
phone="8003114567",
title="Admin",
)
- cls.email5 = "fifth@example.com"
+ self.user5 = User.objects.create(
+ username="test_user5",
+ first_name="Fifth",
+ last_name="User",
+ email="fifth@example.com",
+ phone="8003114568",
+ title="Admin",
+ )
+ self.email6 = "fifth@example.com"
# Create Portfolio
- cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
+ self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
# Assign permissions
- UserPortfolioPermission.objects.create(
- user=cls.user,
- portfolio=cls.portfolio,
- roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
- additional_permissions=[
- UserPortfolioPermissionChoices.VIEW_MEMBERS,
- UserPortfolioPermissionChoices.EDIT_MEMBERS,
- ],
- )
- UserPortfolioPermission.objects.create(
- user=cls.user2,
- portfolio=cls.portfolio,
- roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
- )
- UserPortfolioPermission.objects.create(
- user=cls.user3,
- portfolio=cls.portfolio,
- roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
- )
- UserPortfolioPermission.objects.create(
- user=cls.user4,
- 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):
+ self.app.set_user(self.user.username)
+
+ def tearDown(self):
+ UserDomainRole.objects.all().delete()
+ DomainInformation.objects.all().delete()
+ Domain.objects.all().delete()
PortfolioInvitation.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
- super().tearDownClass()
-
- def setUp(self):
- super().setUp()
- self.app.set_user(self.user.username)
+ super().tearDown()
def test_get_portfolio_members_json_authenticated(self):
"""Test that portfolio members are returned properly for an authenticated user."""
+ """Also tests that reposnse is 200 when no domains"""
+ UserPortfolioPermission.objects.create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ ],
+ )
+ UserPortfolioPermission.objects.create(
+ user=self.user2,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ )
+ UserPortfolioPermission.objects.create(
+ user=self.user3,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ )
+ UserPortfolioPermission.objects.create(
+ user=self.user4,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ )
+ UserPortfolioPermission.objects.create(
+ user=self.user5,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ )
+
response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
self.assertEqual(response.status_code, 200)
data = response.json
@@ -115,15 +125,207 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
self.user3.email,
self.user4.email,
self.user4.email,
- self.email5,
+ self.user5.email,
}
actual_emails = {member["email"] for member in data["members"]}
self.assertEqual(expected_emails, actual_emails)
+ expected_roles = {
+ UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
+ UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
+ }
+ # Convert each member's roles list to a frozenset
+ actual_roles = {role for member in data["members"] for role in member["roles"]}
+ self.assertEqual(expected_roles, actual_roles)
+
+ expected_additional_permissions = {
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ }
+ actual_additional_permissions = {permission for member in data["members"] for permission in member["permissions"]}
+ self.assertTrue(expected_additional_permissions.issubset(actual_additional_permissions))
+
+ def test_get_portfolio_invited_json_authenticated(self):
+ """Test that portfolio invitees are returned properly for an authenticated user."""
+ """Also tests that reposnse is 200 when no domains"""
+ PortfolioInvitation.objects.create(
+ email=self.email6,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ ],
+ )
+
+ response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
+ self.assertEqual(response.status_code, 200)
+ data = response.json
+
+ # Check pagination info
+ self.assertEqual(data["page"], 1)
+ self.assertEqual(data["num_pages"], 1)
+ self.assertEqual(data["total"], 1)
+ self.assertEqual(data["unfiltered_total"], 1)
+
+ # Check the number of members
+ self.assertEqual(len(data["members"]), 1)
+
+ # Check member fields
+ expected_emails = {
+ self.email6
+ }
+ actual_emails = {member["email"] for member in data["members"]}
+ self.assertEqual(expected_emails, actual_emails)
+
+ expected_roles = {
+ UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
+ }
+ # Convert each member's roles list to a frozenset
+ actual_roles = {role for member in data["members"] for role in member["roles"]}
+ self.assertEqual(expected_roles, actual_roles)
+
+ expected_additional_permissions = {
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ }
+ actual_additional_permissions = {permission for member in data["members"] for permission in member["permissions"]}
+ self.assertTrue(expected_additional_permissions.issubset(actual_additional_permissions))
+
+ def test_get_portfolio_members_json_with_domains(self):
+ """Test that portfolio members are returned properly for an authenticated user and the response includes
+ the domains that the member manages.."""
+ UserPortfolioPermission.objects.create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ ],
+ )
+ UserPortfolioPermission.objects.create(
+ user=self.user2,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ )
+ UserPortfolioPermission.objects.create(
+ user=self.user3,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ )
+ UserPortfolioPermission.objects.create(
+ user=self.user4,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ )
+
+ domain = Domain.objects.create(
+ name="somedomain1.com",
+ )
+
+ DomainInformation.objects.create(
+ creator=self.user,
+ domain=domain,
+ portfolio=self.portfolio,
+ )
+
+ UserDomainRole.objects.create(
+ user=self.user,
+ domain=domain,
+ role=UserDomainRole.Roles.MANAGER,
+ )
+
+ response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
+ self.assertEqual(response.status_code, 200)
+ data = response.json
+
+ # Check if the domain appears in the response JSON
+ domain_names = [
+ domain_name
+ for member in data["members"]
+ for domain_name in member.get("domain_names", [])
+ ]
+ self.assertIn("somedomain1.com", domain_names)
+
+ def test_get_portfolio_invited_json_with_domains(self):
+ """Test that portfolio invited members are returned properly for an authenticated user and the response includes
+ the domains that the member manages.."""
+ PortfolioInvitation.objects.create(
+ email=self.email6,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ ],
+ )
+
+ domain = Domain.objects.create(
+ name="somedomain1.com",
+ )
+
+ DomainInformation.objects.create(
+ creator=self.user,
+ domain=domain,
+ portfolio=self.portfolio,
+ )
+
+ DomainInvitation.objects.create(
+ email=self.email6,
+ domain=domain,
+ )
+
+ response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
+ self.assertEqual(response.status_code, 200)
+ data = response.json
+
+ # Check if the domain appears in the response JSON
+ domain_names = [
+ domain_name
+ for member in data["members"]
+ for domain_name in member.get("domain_names", [])
+ ]
+ self.assertIn("somedomain1.com", domain_names)
+
def test_pagination(self):
"""Test that pagination works properly when there are more members than page size."""
+ UserPortfolioPermission.objects.create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ ],
+ )
+ UserPortfolioPermission.objects.create(
+ user=self.user2,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ )
+ UserPortfolioPermission.objects.create(
+ user=self.user3,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ )
+ UserPortfolioPermission.objects.create(
+ user=self.user4,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ )
+ PortfolioInvitation.objects.create(
+ email=self.email6,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ ],
+ )
+
# Create additional members to exceed page size of 10
- for i in range(5, 15):
+ for i in range(6, 16):
user, _ = User.objects.get_or_create(
username=f"test_user{i}",
first_name=f"User{i}",
@@ -172,6 +374,40 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
def test_search(self):
"""Test search functionality for portfolio members."""
+ UserPortfolioPermission.objects.create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ ],
+ )
+ UserPortfolioPermission.objects.create(
+ user=self.user2,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ )
+ UserPortfolioPermission.objects.create(
+ user=self.user3,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ )
+ UserPortfolioPermission.objects.create(
+ user=self.user4,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ )
+ PortfolioInvitation.objects.create(
+ email=self.email6,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ ],
+ )
+
# Search by name
response = self.app.get(
reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id, "search_term": "Second"}
diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py
index b76d185f5..e888feda5 100644
--- a/src/registrar/views/portfolio_members_json.py
+++ b/src/registrar/views/portfolio_members_json.py
@@ -1,13 +1,14 @@
from django.http import JsonResponse
from django.core.paginator import Paginator
from django.contrib.auth.decorators import login_required
-from django.db.models import Value, F, CharField, TextField, Q, Case, When
+from django.db.models import Value, F, CharField, TextField, Q, Case, When, OuterRef, Subquery
from django.db.models.functions import Concat, Coalesce
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.aggregates import ArrayAgg
from django.urls import reverse
from django.db.models.functions import Cast
+from registrar.models.domain_invitation import DomainInvitation
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.user_portfolio_permission import UserPortfolioPermission
@@ -89,7 +90,8 @@ def initial_permissions_search(portfolio):
# specify the output_field to ensure union has same column types
output_field=CharField()
),
- distinct=True
+ distinct=True,
+ filter=Q(user__permissions__domain__isnull=False)
),
source=Value("permission", output_field=CharField()),
)
@@ -110,7 +112,22 @@ def initial_permissions_search(portfolio):
def initial_invitations_search(portfolio):
- """Perform initial invitations search before applying any filters."""
+ """Perform initial invitations search and get related DomainInvitation data based on the email."""
+
+
+ # Get DomainInvitation query for matching email
+ domain_invitations = DomainInvitation.objects.filter(
+ email=OuterRef('email'),
+ domain__isnull=False
+ ).annotate(
+ domain_info=Concat(
+ F('domain__id'),
+ Value(':'),
+ F('domain__name'),
+ output_field=CharField()
+ )
+ )
+
invitations = PortfolioInvitation.objects.filter(portfolio=portfolio)
invitations = invitations.annotate(
first_name=Value(None, output_field=CharField()),
@@ -118,7 +135,14 @@ def initial_invitations_search(portfolio):
email_display=F("email"),
last_active=Value("Invited", output_field=TextField()),
member_display=F("email"),
- domain_info=Value([], output_field=ArrayField(TextField())),
+ # ArrayAgg for multiple domain_invitations matched by email, filtered to exclude nulls
+ domain_info=Coalesce( # Use Coalesce to return an empty list if no domain invitations exist
+ ArrayAgg(
+ Subquery(domain_invitations.values('domain_info')),
+ distinct=True,
+ ),
+ Value([], output_field=ArrayField(CharField())) # Ensure we return an empty list
+ ),
source=Value("invitation", output_field=CharField()),
).values(
"id",
@@ -181,9 +205,15 @@ def serialize_members(request, portfolio, item, user):
"member_display": item.get("member_display", ""),
"roles": (item.get("roles") or []),
"permissions": UserPortfolioPermission.get_portfolio_permissions(item.get("roles"), item.get("additional_permissions")),
- # split domain_info array values into ids to form urls, and names
- "domain_urls": [reverse("domain", kwargs={"pk": domain_info.split(":")[0]}) for domain_info in item.get("domain_info")],
- "domain_names": [domain_info.split(":")[1] for domain_info in item.get("domain_info")],
+ "domain_names": [
+ domain_info.split(":")[1] for domain_info in (item.get("domain_info") or [])
+ if domain_info is not None # Prevent splitting None
+ ],
+ "domain_urls": [
+ reverse("domain", kwargs={"pk": domain_info.split(":")[0]})
+ for domain_info in (item.get("domain_info") or [])
+ if domain_info is not None # Prevent splitting None
+ ],
"is_admin": is_admin,
"last_active": item.get("last_active", ""),
"action_url": action_url,