diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js
index 99de55427..8a07b3f27 100644
--- a/src/registrar/assets/js/get-gov.js
+++ b/src/registrar/assets/js/get-gov.js
@@ -1868,6 +1868,125 @@ class DomainRequestsTable extends LoadTableBase {
}
}
+class MembersTable extends LoadTableBase {
+
+ constructor() {
+ super('.members__table', '.members__table-wrapper', '#members__search-field', '#members__search-field-submit', '.members__reset-search', '.members__reset-filters', '.members__no-data', '.members__no-search-results');
+ }
+ /**
+ * Loads rows in the members list, as well as updates pagination around the members list
+ * based on the supplied attributes.
+ * @param {*} page - the page number of the results (starts with 1)
+ * @param {*} sortBy - the sort column option
+ * @param {*} order - the sort order {asc, desc}
+ * @param {*} scroll - control for the scrollToElement functionality
+ * @param {*} status - control for the status filter
+ * @param {*} searchTerm - the search term
+ * @param {*} portfolio - the portfolio id
+ */
+ loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {
+
+ // --------- SEARCH
+ let searchParams = new URLSearchParams(
+ {
+ "page": page,
+ "sort_by": sortBy,
+ "order": order,
+ "status": status,
+ "search_term": searchTerm
+ }
+ );
+ if (portfolio)
+ searchParams.append("portfolio", portfolio)
+
+
+ // --------- FETCH DATA
+ // fetch json of page of domais, given params
+ let baseUrl = document.getElementById("get_members_json_url");
+ if (!baseUrl) {
+ return;
+ }
+
+ let baseUrlValue = baseUrl.innerHTML;
+ if (!baseUrlValue) {
+ return;
+ }
+
+ let url = `${baseUrlValue}?${searchParams.toString()}` //TODO: uncomment for search function
+ fetch(url)
+ .then(response => response.json())
+ .then(data => {
+ if (data.error) {
+ console.error('Error in AJAX call: ' + data.error);
+ return;
+ }
+
+ // handle the display of proper messaging in the event that no members exist in the list or search returns no results
+ this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
+
+ // identify the DOM element where the domain list will be inserted into the DOM
+ const memberList = document.querySelector('.members__table tbody');
+ memberList.innerHTML = '';
+
+ data.members.forEach(member => {
+ // const actionUrl = domain.action_url;
+ const member_name = member.name;
+ const member_email = member.email;
+ const last_active = member.last_active;
+ const action_url = member.action_url;
+ const action_label = member.action_label;
+ const svg_icon = member.svg_icon;
+
+ const row = document.createElement('tr');
+
+ let admin_tagHTML = ``;
+ if (member.is_admin)
+ admin_tagHTML = `Admin`
+
+ row.innerHTML = `
+
+ ${member_email ? member_email : member_name} ${admin_tagHTML}
+ |
+
+ ${last_active}
+ |
+
+
+
+ ${action_label} ${member_name}
+
+ |
+ `;
+ memberList.appendChild(row);
+ });
+
+ // Do not scroll on first page load
+ if (scroll)
+ ScrollToElement('class', 'members');
+ this.scrollToTable = true;
+
+ // update pagination
+ this.updatePagination(
+ 'member',
+ '#members-pagination',
+ '#members-pagination .usa-pagination__counter',
+ '#members',
+ data.page,
+ data.num_pages,
+ data.has_previous,
+ data.has_next,
+ data.total,
+ );
+ this.currentSortBy = sortBy;
+ this.currentOrder = order;
+ this.currentSearchTerm = searchTerm;
+ })
+ .catch(error => console.error('Error fetching members:', error));
+ }
+}
+
/**
* An IIFE that listens for DOM Content to be loaded, then executes. This function
@@ -1941,6 +2060,23 @@ const utcDateString = (dateString) => {
};
+
+/**
+ * An IIFE that listens for DOM Content to be loaded, then executes. This function
+ * initializes the domains list and associated functionality on the home page of the app.
+ *
+ */
+document.addEventListener('DOMContentLoaded', function() {
+ const isMembersPage = document.querySelector("#members")
+ if (isMembersPage){
+ const membersTable = new MembersTable();
+ if (membersTable.tableWrapper) {
+ // Initial load
+ membersTable.loadTable(1);
+ }
+ }
+});
+
/**
* An IIFE that displays confirmation modal on the user profile page
*/
diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py
index d0fef3b34..436ca3ae0 100644
--- a/src/registrar/config/urls.py
+++ b/src/registrar/config/urls.py
@@ -23,19 +23,21 @@ from registrar.views.report_views import (
ExportDataTypeRequests,
)
-from registrar.views.domain_request import Step
+# --jsons
from registrar.views.domain_requests_json import get_domain_requests_json
-from registrar.views.transfer_user import TransferUserView
+from registrar.views.domains_json import get_domains_json
+from registrar.views.portfolio_members_json import get_portfolio_members_json
from registrar.views.utility.api_views import (
get_senior_official_from_federal_agency_json,
get_federal_and_portfolio_types_from_federal_agency_json,
get_action_needed_email_for_user_json,
)
-from registrar.views.domains_json import get_domains_json
+
+from registrar.views.domain_request import Step
+from registrar.views.transfer_user import TransferUserView
from registrar.views.utility import always_404
from api.views import available, rdap, get_current_federal, get_current_full
-
DOMAIN_REQUEST_NAMESPACE = views.DomainRequestWizard.URL_NAMESPACE
domain_request_urls = [
path("", views.DomainRequestWizard.as_view(), name=""),
@@ -75,6 +77,16 @@ urlpatterns = [
views.PortfolioNoDomainsView.as_view(),
name="no-portfolio-domains",
),
+ path(
+ "members/",
+ views.PortfolioMembersView.as_view(),
+ name="members",
+ ),
+ # path(
+ # "no-organization-members/",
+ # views.PortfolioNoMembersView.as_view(),
+ # name="no-portfolio-members",
+ # ),
path(
"requests/",
views.PortfolioDomainRequestsView.as_view(),
@@ -282,6 +294,7 @@ urlpatterns = [
),
path("get-domains-json/", get_domains_json, name="get_domains_json"),
path("get-domain-requests-json/", get_domain_requests_json, name="get_domain_requests_json"),
+ path("get-portfolio-members-json/", get_portfolio_members_json, name="get_portfolio_members_json"),
]
# Djangooidc strips out context data from that context, so we define a custom error
diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py
index c62c9a7c4..53f6e8ae7 100644
--- a/src/registrar/context_processors.py
+++ b/src/registrar/context_processors.py
@@ -97,5 +97,5 @@ def portfolio_permissions(request):
def is_widescreen_mode(request):
- widescreen_paths = ["/domains/", "/requests/"]
+ widescreen_paths = ["/domains/", "/requests/", "/members/"]
return {"is_widescreen_mode": any(path in request.path for path in widescreen_paths) or request.path == "/"}
diff --git a/src/registrar/migrations/0129_alter_portfolioinvitation_portfolio_roles_and_more.py b/src/registrar/migrations/0129_alter_portfolioinvitation_portfolio_roles_and_more.py
new file mode 100644
index 000000000..dae994f8e
--- /dev/null
+++ b/src/registrar/migrations/0129_alter_portfolioinvitation_portfolio_roles_and_more.py
@@ -0,0 +1,40 @@
+# Generated by Django 4.2.10 on 2024-09-25 00:49
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("registrar", "0128_alter_domaininformation_state_territory_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="portfolioinvitation",
+ name="portfolio_roles",
+ field=django.contrib.postgres.fields.ArrayField(
+ base_field=models.CharField(
+ choices=[("organization_admin", "Admin"), ("organization_member", "Member")], max_length=50
+ ),
+ blank=True,
+ help_text="Select one or more roles.",
+ null=True,
+ size=None,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userportfoliopermission",
+ name="roles",
+ field=django.contrib.postgres.fields.ArrayField(
+ base_field=models.CharField(
+ choices=[("organization_admin", "Admin"), ("organization_member", "Member")], max_length=50
+ ),
+ blank=True,
+ help_text="Select one or more roles.",
+ null=True,
+ size=None,
+ ),
+ ),
+ ]
diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py
index f7bafc8c6..6acd651db 100644
--- a/src/registrar/models/user_portfolio_permission.py
+++ b/src/registrar/models/user_portfolio_permission.py
@@ -15,8 +15,6 @@ class UserPortfolioPermission(TimeStampedModel):
PORTFOLIO_ROLE_PERMISSIONS = {
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
- UserPortfolioPermissionChoices.VIEW_MEMBERS,
- UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
@@ -25,14 +23,6 @@ class UserPortfolioPermission(TimeStampedModel):
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
],
- UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [
- UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
- UserPortfolioPermissionChoices.VIEW_MEMBERS,
- UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
- UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
- # Domain: field specific permissions
- UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
- ],
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
],
diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py
index 7f34221fd..29e2a377c 100644
--- a/src/registrar/models/utility/portfolio_helper.py
+++ b/src/registrar/models/utility/portfolio_helper.py
@@ -7,7 +7,6 @@ class UserPortfolioRoleChoices(models.TextChoices):
"""
ORGANIZATION_ADMIN = "organization_admin", "Admin"
- ORGANIZATION_ADMIN_READ_ONLY = "organization_admin_read_only", "Admin read only"
ORGANIZATION_MEMBER = "organization_member", "Member"
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..2cf056858 100644
--- a/src/registrar/templatetags/custom_filters.py
+++ b/src/registrar/templatetags/custom_filters.py
@@ -239,3 +239,14 @@ 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
diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py
index e26b077fd..e1c1178b3 100644
--- a/src/registrar/tests/test_reports.py
+++ b/src/registrar/tests/test_reports.py
@@ -358,11 +358,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/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/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()