diff --git a/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html b/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html
new file mode 100644
index 000000000..1249a486c
--- /dev/null
+++ b/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html
@@ -0,0 +1,16 @@
+{% extends 'django/admin/email_clipboard_change_form.html' %}
+{% load custom_filters %}
+{% load i18n static %}
+
+{% block field_sets %}
+ {% for fieldset in adminform %}
+ {% comment %}
+ This is a placeholder for now.
+
+ Disclaimer:
+ When extending the fieldset view consider whether you need to make a new one that extends from detail_table_fieldset.
+ detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences.
+ {% endcomment %}
+ {% include "django/admin/includes/user_portfolio_permission_fieldset.html" with original_object=original %}
+ {% endfor %}
+{% endblock %}
\ No newline at end of file
diff --git a/src/registrar/templates/includes/header_extended.html b/src/registrar/templates/includes/header_extended.html
index ab53dd5cf..a3b2364a9 100644
--- a/src/registrar/templates/includes/header_extended.html
+++ b/src/registrar/templates/includes/header_extended.html
@@ -91,9 +91,9 @@
{% endif %}
- {% if has_organization_members_flag %}
+ {% if has_organization_members_flag and has_view_members_portfolio_permission %}
-
+
Members
diff --git a/src/registrar/templates/includes/members_table.html b/src/registrar/templates/includes/members_table.html
new file mode 100644
index 000000000..529d2629d
--- /dev/null
+++ b/src/registrar/templates/includes/members_table.html
@@ -0,0 +1,80 @@
+{% load static %}
+
+
+
+{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
+{% url 'get_portfolio_members_json' as url %}
+
{{url}}
+
+
+
+
+
+
+
You don't have any members.
+
+
+
+
diff --git a/src/registrar/templates/portfolio_members.html b/src/registrar/templates/portfolio_members.html
new file mode 100644
index 000000000..82e06c808
--- /dev/null
+++ b/src/registrar/templates/portfolio_members.html
@@ -0,0 +1,33 @@
+{% extends 'portfolio_base.html' %}
+
+{% load static %}
+
+{% block title %} Members | {% endblock %}
+
+{% block wrapper_class %}
+ {{ block.super }} dashboard--grey-1
+{% endblock %}
+
+{% block portfolio_content %}
+{% block messages %}
+ {% include "includes/form_messages.html" %}
+{% endblock %}
+
+
+
+
+ {% include "includes/members_table.html" with portfolio=portfolio %}
+
+{% endblock %}
diff --git a/src/registrar/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py
index c6c7c97d1..a3f35ae8e 100644
--- a/src/registrar/templatetags/custom_filters.py
+++ b/src/registrar/templatetags/custom_filters.py
@@ -239,3 +239,23 @@ def is_portfolio_subpage(path):
"senior-official",
]
return get_url_name(path) in url_names
+
+
+@register.filter(name="is_members_subpage")
+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",
+ ]
+ return get_url_name(path) in url_names
+
+
+@register.filter(name="portfolio_role_summary")
+def portfolio_role_summary(user, portfolio):
+ """Returns the value of user.portfolio_role_summary"""
+ if user and portfolio:
+ return user.portfolio_role_summary(portfolio)
+ else:
+ return []
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index 88482e4db..cdc3c97de 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -2097,36 +2097,11 @@ class TestPortfolioAdmin(TestCase):
)
display_admins = self.admin.display_admins(self.portfolio)
-
- self.assertIn(
- f'
Gerald Meoward meaoward@gov.gov',
- display_admins,
- )
- self.assertIn("Captain", display_admins)
- self.assertIn(
- f'
Arnold Poopy poopy@gov.gov', display_admins
- )
- self.assertIn("Major", display_admins)
-
- display_members_summary = self.admin.display_members_summary(self.portfolio)
-
- self.assertIn(
- f'
Mad Max madmax@gov.gov',
- display_members_summary,
- )
- self.assertIn(
- f'
Agent Smith thematrix@gov.gov',
- display_members_summary,
- )
+ url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={self.portfolio.id}"
+ self.assertIn(f'
2 administrators', display_admins)
display_members = self.admin.display_members(self.portfolio)
-
- self.assertIn("Mad Max", display_members)
- self.assertIn("
Member", display_members)
- self.assertIn("Road warrior", display_members)
- self.assertIn("Agent Smith", display_members)
- self.assertIn("
Domain requestor", display_members)
- self.assertIn("Program", display_members)
+ self.assertIn(f'
2 members', display_members)
class TestTransferUser(WebTest):
diff --git a/src/registrar/tests/test_api.py b/src/registrar/tests/test_api.py
index ef5385d72..218c63d4f 100644
--- a/src/registrar/tests/test_api.py
+++ b/src/registrar/tests/test_api.py
@@ -100,7 +100,6 @@ class GetFederalPortfolioTypeJsonTest(TestCase):
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["federal_type"], "Judicial")
- self.assertEqual(data["portfolio_type"], "Federal - Judicial")
@less_console_noise_decorator
def test_get_federal_and_portfolio_types_json_authenticated_regularuser(self):
diff --git a/src/registrar/tests/test_management_scripts.py b/src/registrar/tests/test_management_scripts.py
index cbdc2c034..9fcd261f7 100644
--- a/src/registrar/tests/test_management_scripts.py
+++ b/src/registrar/tests/test_management_scripts.py
@@ -1387,18 +1387,18 @@ class TestPopulateFederalAgencyInitialsAndFceb(TestCase):
self.agency4.refresh_from_db()
# Check if FederalAgency objects were updated correctly
- self.assertEqual(self.agency1.initials, "ABMC")
+ self.assertEqual(self.agency1.acronym, "ABMC")
self.assertTrue(self.agency1.is_fceb)
- self.assertEqual(self.agency2.initials, "ACHP")
+ self.assertEqual(self.agency2.acronym, "ACHP")
self.assertTrue(self.agency2.is_fceb)
# We expect that this field doesn't have any data,
# as none is specified in the CSV
- self.assertIsNone(self.agency3.initials)
+ self.assertIsNone(self.agency3.acronym)
self.assertIsNone(self.agency3.is_fceb)
- self.assertEqual(self.agency4.initials, "KC")
+ self.assertEqual(self.agency4.acronym, "KC")
self.assertFalse(self.agency4.is_fceb)
@less_console_noise_decorator
@@ -1411,7 +1411,7 @@ class TestPopulateFederalAgencyInitialsAndFceb(TestCase):
# Verify that the missing agency was not updated
missing_agency.refresh_from_db()
- self.assertIsNone(missing_agency.initials)
+ self.assertIsNone(missing_agency.acronym)
self.assertIsNone(missing_agency.is_fceb)
diff --git a/src/registrar/tests/test_migrations.py b/src/registrar/tests/test_migrations.py
index 6d8ff7151..eaaae8727 100644
--- a/src/registrar/tests/test_migrations.py
+++ b/src/registrar/tests/test_migrations.py
@@ -40,10 +40,22 @@ class TestGroups(TestCase):
"add_federalagency",
"change_federalagency",
"delete_federalagency",
+ "add_portfolio",
+ "change_portfolio",
+ "delete_portfolio",
+ "add_seniorofficial",
+ "change_seniorofficial",
+ "delete_seniorofficial",
+ "add_suborganization",
+ "change_suborganization",
+ "delete_suborganization",
"analyst_access_permission",
"change_user",
"delete_userdomainrole",
"view_userdomainrole",
+ "add_userportfoliopermission",
+ "change_userportfoliopermission",
+ "delete_userportfoliopermission",
"add_verifiedbystaff",
"change_verifiedbystaff",
"delete_verifiedbystaff",
@@ -51,6 +63,7 @@ class TestGroups(TestCase):
# Get the codenames of actual permissions associated with the group
actual_permissions = [p.codename for p in cisa_analysts_group.permissions.all()]
+ self.maxDiff = None
# Assert that the actual permissions match the expected permissions
self.assertListEqual(actual_permissions, expected_permissions)
diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py
index a6cac1389..015c67dab 100644
--- a/src/registrar/tests/test_models.py
+++ b/src/registrar/tests/test_models.py
@@ -1332,7 +1332,10 @@ class TestUserPortfolioPermission(TestCase):
self.assertEqual(
cm.exception.message,
- "Only one portfolio permission is allowed per user when multiple portfolios are disabled.",
+ (
+ "This user is already assigned to a portfolio. "
+ "Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
+ ),
)
diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py
index ed2b75791..797592806 100644
--- a/src/registrar/tests/test_reports.py
+++ b/src/registrar/tests/test_reports.py
@@ -356,11 +356,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
self.assertIn(self.domain_3.name, csv_content)
self.assertNotIn(self.domain_2.name, csv_content)
- # Test the output for readonly admin
- portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY]
- portfolio_permission.save()
- portfolio_permission.refresh_from_db()
-
# Get the csv content
csv_content = self._run_domain_data_type_user_export(request)
self.assertIn(self.domain_1.name, csv_content)
diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py
index 8fb92df72..127b78a4a 100644
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -1568,7 +1568,7 @@ class TestDomainSuborganization(TestDomainOverview):
# Add portfolio perms to the user object
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
- user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY]
+ user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
self.assertEqual(self.domain_information.sub_organization, suborg)
diff --git a/src/registrar/tests/test_views_members_json.py b/src/registrar/tests/test_views_members_json.py
new file mode 100644
index 000000000..75c3a3a66
--- /dev/null
+++ b/src/registrar/tests/test_views_members_json.py
@@ -0,0 +1,175 @@
+from django.urls import reverse
+
+from registrar.models.portfolio import Portfolio
+from registrar.models.user import User
+from registrar.models.user_portfolio_permission import UserPortfolioPermission
+from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
+from .test_views import TestWithUser
+from django_webtest import WebTest # type: ignore
+
+
+class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ # Create additional users
+ cls.user2 = User.objects.create(
+ username="test_user2",
+ first_name="Second",
+ last_name="User",
+ email="second@example.com",
+ phone="8003112345",
+ title="Member",
+ )
+ cls.user3 = User.objects.create(
+ username="test_user3",
+ first_name="Third",
+ last_name="User",
+ email="third@example.com",
+ phone="8003113456",
+ title="Member",
+ )
+ cls.user4 = User.objects.create(
+ username="test_user4",
+ first_name="Fourth",
+ last_name="User",
+ email="fourth@example.com",
+ phone="8003114567",
+ title="Admin",
+ )
+
+ # Create Portfolio
+ cls.portfolio = Portfolio.objects.create(creator=cls.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],
+ )
+
+ def setUp(self):
+ super().setUp()
+ self.app.set_user(self.user.username)
+
+ def test_get_portfolio_members_json_authenticated(self):
+ """Test that portfolio members are returned properly for an authenticated user."""
+ 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.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)
+
+ # Check the number of members
+ self.assertEqual(len(data["members"]), 4)
+
+ # Check member fields
+ expected_emails = {self.user.email, self.user2.email, self.user3.email, self.user4.email}
+ actual_emails = {member["email"] for member in data["members"]}
+ self.assertEqual(expected_emails, actual_emails)
+
+ def test_pagination(self):
+ """Test that pagination works properly when there are more members than page size."""
+ # Create additional members to exceed page size of 10
+ for i in range(5, 15):
+ user, _ = User.objects.get_or_create(
+ username=f"test_user{i}",
+ first_name=f"User{i}",
+ last_name=f"Last{i}",
+ email=f"user{i}@example.com",
+ phone=f"80031156{i}",
+ title="Member",
+ )
+ UserPortfolioPermission.objects.create(
+ user=user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ )
+
+ response = self.app.get(
+ reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id, "page": 1}
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json
+
+ # Check pagination info
+ self.assertEqual(data["page"], 1)
+ 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)
+
+ # Check the number of members on page 1
+ self.assertEqual(len(data["members"]), 10)
+
+ response = self.app.get(
+ reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id, "page": 2}
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json
+
+ # Check pagination info for page 2
+ self.assertEqual(data["page"], 2)
+ self.assertFalse(data["has_next"])
+ self.assertTrue(data["has_previous"])
+ self.assertEqual(data["num_pages"], 2)
+
+ # Check the number of members on page 2
+ self.assertEqual(len(data["members"]), 4)
+
+ def test_search(self):
+ """Test search functionality for portfolio members."""
+ # Search by name
+ response = self.app.get(
+ reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id, "search_term": "Second"}
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json
+ self.assertEqual(len(data["members"]), 1)
+ self.assertEqual(data["members"][0]["name"], "Second User")
+ self.assertEqual(data["members"][0]["email"], "second@example.com")
+
+ # Search by email
+ response = self.app.get(
+ reverse("get_portfolio_members_json"),
+ params={"portfolio": self.portfolio.id, "search_term": "fourth@example.com"},
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json
+ self.assertEqual(len(data["members"]), 1)
+ self.assertEqual(data["members"][0]["email"], "fourth@example.com")
+
+ # Search with no matching results
+ response = self.app.get(
+ reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id, "search_term": "NonExistent"}
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json
+ self.assertEqual(len(data["members"]), 0)
diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py
index e7c593a45..dfb0469d0 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.user_group import UserGroup
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .common import MockSESClient, completed_domain_request, create_test_user
@@ -666,6 +667,195 @@ class TestPortfolio(WebTest):
self.assertContains(home, "Hotel California")
self.assertContains(home, "Members")
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ def test_cannot_view_members_table(self):
+ """Test that user without proper permission is denied access to members view"""
+
+ # Users can only view the members table if they have
+ # Portfolio Permission "view_members" selected.
+ # NOTE: Admins, by default, do NOT have permission
+ # to view/edit members. This must be enabled explicitly
+ # in the "additional permissions" section for a portfolio
+ # permission.
+ #
+ # Scenarios to test include;
+ # (1) - User is not admin and can view portfolio, but not the members table
+ # (1) - User is admin and can view portfolio, but not the members table
+
+ # --- non-admin
+ self.app.set_user(self.user.username)
+
+ UserPortfolioPermission.objects.get_or_create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
+ ],
+ )
+ # Verify that the user cannot access the members page
+ # This will redirect the user to the members page.
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("members"), follow=True)
+ # Assert the response is a 403 Forbidden
+ self.assertEqual(response.status_code, 403)
+
+ # --- admin
+ UserPortfolioPermission.objects.filter(user=self.user, portfolio=self.portfolio).update(
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ )
+
+ # Verify that the user cannot access the members page
+ # This will redirect the user to the members page.
+ response = self.client.get(reverse("members"), follow=True)
+ # Assert the response is a 403 Forbidden
+ 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_members_table(self):
+ """Test that user with proper permission is able to access members view"""
+
+ self.app.set_user(self.user.username)
+
+ UserPortfolioPermission.objects.get_or_create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ ],
+ )
+
+ # Verify that the user can access the members page
+ # This will redirect the user to the members page.
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("members"), follow=True)
+ # Make sure the page loaded
+ self.assertEqual(response.status_code, 200)
+
+ # ---- Useful debugging stub to see what "assertContains" is finding
+ # pattern = r'Members'
+ # matches = re.findall(pattern, response.content.decode('utf-8'))
+ # for match in matches:
+ # TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"{match}")
+
+ # Make sure the page loaded
+ self.assertContains(response, "Members")
+
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ def test_can_manage_members(self):
+ """Test that user with proper permission is able to manage members"""
+ user = self.user
+ self.app.set_user(user.username)
+
+ # give user permissions to view AND manage members
+ UserPortfolioPermission.objects.get_or_create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ ],
+ )
+
+ # Give user permissions to modify user objects in the DB
+ group, _ = UserGroup.objects.get_or_create(name="full_access_group")
+ # Add the user to the group
+ user.groups.set([group])
+
+ # Verify that the user can access the members page
+ # This will redirect the user to the members page.
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("members"), follow=True)
+ # Make sure the page loaded
+ self.assertEqual(response.status_code, 200)
+
+ # Verify that manage settings are sent in the dynamic HTML
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("get_portfolio_members_json") + f"?portfolio={self.portfolio.pk}")
+ self.assertContains(response, '"action_label": "Manage"')
+ self.assertContains(response, '"svg_icon": "settings"')
+
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ def test_view_only_members(self):
+ """Test that user with view only permission settings can only
+ view members (not manage them)"""
+ user = self.user
+ self.app.set_user(user.username)
+
+ # give user permissions to view AND manage members
+ UserPortfolioPermission.objects.get_or_create(
+ user=self.user,
+ portfolio=self.portfolio,
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ ],
+ )
+ # Give user permissions to modify user objects in the DB
+ group, _ = UserGroup.objects.get_or_create(name="full_access_group")
+ # Add the user to the group
+ user.groups.set([group])
+
+ # Verify that the user can access the members page
+ # This will redirect the user to the members page.
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("members"), follow=True)
+ # Make sure the page loaded
+ self.assertEqual(response.status_code, 200)
+
+ # 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"')
+
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ def test_members_admin_detection(self):
+ """Test that user with proper permission is able to manage members"""
+ user = self.user
+ self.app.set_user(user.username)
+
+ # give user permissions to view AND manage members
+ UserPortfolioPermission.objects.get_or_create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ ],
+ )
+
+ # Give user permissions to modify user objects in the DB
+ group, _ = UserGroup.objects.get_or_create(name="full_access_group")
+ # Add the user to the group
+ user.groups.set([group])
+
+ # Verify that the user can access the members page
+ # This will redirect the user to the members page.
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("members"), follow=True)
+ # Make sure the page loaded
+ self.assertEqual(response.status_code, 200)
+ # Verify that admin info is sent in the dynamic HTML
+ response = self.client.get(reverse("get_portfolio_members_json") + f"?portfolio={self.portfolio.pk}")
+ # 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_portfolio_domain_requests_page_when_user_has_no_permissions(self):
diff --git a/src/registrar/utility/admin_helpers.py b/src/registrar/utility/admin_helpers.py
index 0b99bba13..2af9d0b3c 100644
--- a/src/registrar/utility/admin_helpers.py
+++ b/src/registrar/utility/admin_helpers.py
@@ -1,5 +1,9 @@
from registrar.models.domain_request import DomainRequest
from django.template.loader import get_template
+from django.utils.html import format_html
+from django.urls import reverse
+from django.utils.html import escape
+from registrar.models.utility.generic_helper import value_of_attribute
def get_all_action_needed_reason_emails(request, domain_request):
@@ -34,3 +38,56 @@ def get_action_needed_reason_default_email(request, domain_request, action_neede
email_body_text_cleaned = email_body_text.strip().lstrip("\n")
return email_body_text_cleaned
+
+
+def get_field_links_as_list(
+ queryset,
+ model_name,
+ attribute_name=None,
+ link_info_attribute=None,
+ separator=None,
+ msg_for_none="-",
+):
+ """
+ Generate HTML links for items in a queryset, using a specified attribute for link text.
+
+ Args:
+ queryset: The queryset of items to generate links for.
+ model_name: The model name used to construct the admin change URL.
+ attribute_name: The attribute or method name to use for link text. If None, the item itself is used.
+ link_info_attribute: Appends f"({value_of_attribute})" to the end of the link.
+ separator: The separator to use between links in the resulting HTML.
+ If none, an unordered list is returned.
+ msg_for_none: What to return when the field would otherwise display None.
+ Defaults to `-`.
+
+ Returns:
+ A formatted HTML string with links to the admin change pages for each item.
+ """
+ links = []
+ for item in queryset:
+
+ # This allows you to pass in attribute_name="get_full_name" for instance.
+ if attribute_name:
+ item_display_value = value_of_attribute(item, attribute_name)
+ else:
+ item_display_value = item
+
+ if item_display_value:
+ change_url = reverse(f"admin:registrar_{model_name}_change", args=[item.pk])
+
+ link = f'
{escape(item_display_value)}'
+ if link_info_attribute:
+ link += f" ({value_of_attribute(item, link_info_attribute)})"
+
+ if separator:
+ links.append(link)
+ else:
+ links.append(f"
{link}")
+
+ # If no separator is specified, just return an unordered list.
+ if separator:
+ return format_html(separator.join(links)) if links else msg_for_none
+ else:
+ links = "".join(links)
+ return format_html(f'
') if links else msg_for_none
diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py
new file mode 100644
index 000000000..133e6750e
--- /dev/null
+++ b/src/registrar/views/portfolio_members_json.py
@@ -0,0 +1,125 @@
+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 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"""
+ 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
+ )
+
+ unfiltered_total = objects.count()
+
+ objects = apply_search(objects, request)
+ # objects = apply_status_filter(objects, request)
+ 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
+ ]
+
+ return JsonResponse(
+ {
+ "members": members,
+ "page": page_obj.number,
+ "num_pages": paginator.num_pages,
+ "has_previous": page_obj.has_previous(),
+ "has_next": page_obj.has_next(),
+ "total": paginator.count,
+ "unfiltered_total": unfiltered_total,
+ }
+ )
+
+
+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 apply_search(queryset, request):
+ search_term = request.GET.get("search_term")
+
+ if search_term:
+ queryset = queryset.filter(
+ Q(username__icontains=search_term)
+ | Q(first_name__icontains=search_term)
+ | Q(last_name__icontains=search_term)
+ | Q(email__icontains=search_term)
+ )
+ return queryset
+
+
+def apply_sorting(queryset, request):
+ sort_by = request.GET.get("sort_by", "id") # Default to 'id'
+ order = request.GET.get("order", "asc") # Default to 'asc'
+
+ if sort_by == "member":
+ sort_by = ["email", "first_name", "middle_name", "last_name"]
+ else:
+ sort_by = [sort_by]
+
+ if order == "desc":
+ sort_by = [f"-{field}" for field in sort_by]
+
+ return queryset.order_by(*sort_by)
+
+
+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
+
+ 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
+
+ # ------- SERIALIZE
+ member_json = {
+ "id": member.id,
+ "name": member.get_formatted_name(),
+ "email": member.email,
+ "is_admin": is_admin,
+ "last_active": last_active,
+ "action_url": "#", # reverse("members", kwargs={"pk": member.id}), # TODO: Future ticket?
+ "action_label": ("View" if view_only else "Manage"),
+ "svg_icon": ("visibility" if view_only else "settings"),
+ }
+ return member_json
diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py
index 885dca636..552fdb6ff 100644
--- a/src/registrar/views/portfolios.py
+++ b/src/registrar/views/portfolios.py
@@ -12,6 +12,7 @@ from registrar.views.utility.permission_views import (
PortfolioDomainsPermissionView,
PortfolioBasePermissionView,
NoPortfolioDomainsPermissionView,
+ PortfolioMembersPermissionView,
)
from django.views.generic import View
from django.views.generic.edit import FormMixin
@@ -41,6 +42,15 @@ class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View):
return render(request, "portfolio_requests.html")
+class PortfolioMembersView(PortfolioMembersPermissionView, View):
+
+ template_name = "portfolio_members.html"
+
+ def get(self, request):
+ """Add additional context data to the template."""
+ return render(request, "portfolio_members.html")
+
+
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/api_views.py b/src/registrar/views/utility/api_views.py
index 6a6269baa..f9522e2e9 100644
--- a/src/registrar/views/utility/api_views.py
+++ b/src/registrar/views/utility/api_views.py
@@ -55,11 +55,9 @@ def get_federal_and_portfolio_types_from_federal_agency_json(request):
portfolio_type = None
agency_name = request.GET.get("agency_name")
- organization_type = request.GET.get("organization_type")
agency = FederalAgency.objects.filter(agency=agency_name).first()
if agency:
federal_type = Portfolio.get_federal_type(agency)
- portfolio_type = Portfolio.get_portfolio_type(organization_type, federal_type)
federal_type = BranchChoices.get_branch_label(federal_type) if federal_type else "-"
response_data = {
diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py
index d8c48e01e..2cb2a599b 100644
--- a/src/registrar/views/utility/mixins.py
+++ b/src/registrar/views/utility/mixins.py
@@ -490,7 +490,7 @@ 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):
+ if not self.request.user.has_view_members_portfolio_permission(portfolio):
return False
return super().has_permission()