Merge pull request #2945 from cisagov/dk/2763-member-expand

#2763: Expanded row functionality
This commit is contained in:
dave-kennedy-ecs 2024-10-23 16:23:21 -04:00 committed by GitHub
commit fd9eb0da58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 668 additions and 125 deletions

View file

@ -1874,6 +1874,197 @@ class MembersTable extends LoadTableBase {
constructor() { 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'); 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');
} }
/**
* Initializes "Show More" buttons on the page, enabling toggle functionality to show or hide content.
*
* The function finds elements with "Show More" buttons and sets up a click event listener to toggle the visibility
* of a corresponding content div. When clicked, the button updates its visual state (e.g., text/icon change),
* and the associated content is shown or hidden based on its current visibility status.
*
* @function initShowMoreButtons
*/
initShowMoreButtons() {
/**
* Toggles the visibility of a content section when the "Show More" button is clicked.
* Updates the button text/icon based on whether the content is shown or hidden.
*
* @param {HTMLElement} toggleButton - The button that toggles the content visibility.
* @param {HTMLElement} contentDiv - The content div whose visibility is toggled.
* @param {HTMLElement} buttonParentRow - The parent row element containing the button.
*/
function toggleShowMoreButton(toggleButton, contentDiv, buttonParentRow) {
const spanElement = toggleButton.querySelector('span');
const useElement = toggleButton.querySelector('use');
if (contentDiv.classList.contains('display-none')) {
showElement(contentDiv);
spanElement.textContent = 'Close';
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less');
buttonParentRow.classList.add('hide-td-borders');
toggleButton.setAttribute('aria-label', 'Close additional information');
} else {
hideElement(contentDiv);
spanElement.textContent = 'Expand';
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more');
buttonParentRow.classList.remove('hide-td-borders');
toggleButton.setAttribute('aria-label', 'Expand for additional information');
}
}
let toggleButtons = document.querySelectorAll('.usa-button--show-more-button');
toggleButtons.forEach((toggleButton) => {
// get contentDiv for element specified in data-for attribute of toggleButton
let dataFor = toggleButton.dataset.for;
let contentDiv = document.getElementById(dataFor);
let buttonParentRow = toggleButton.parentElement.parentElement;
if (contentDiv && contentDiv.tagName.toLowerCase() === 'tr' && contentDiv.classList.contains('show-more-content') && buttonParentRow && buttonParentRow.tagName.toLowerCase() === 'tr') {
toggleButton.addEventListener('click', function() {
toggleShowMoreButton(toggleButton, contentDiv, buttonParentRow);
});
} else {
console.warn('Found a toggle button with no associated toggleable content or parent row');
}
});
}
/**
* Converts a given `last_active` value into a display value and a numeric sort value.
* The input can be a UTC date, the strings "Invited", "Invalid date", or null/undefined.
*
* @param {string} last_active - UTC date string or special status like "Invited" or "Invalid date".
* @returns {Object} - An object containing `display_value` (formatted date or status string)
* and `sort_value` (numeric value for sorting).
*/
handleLastActive(last_active) {
const invited = 'Invited';
const invalid_date = 'Invalid date';
const options = { year: 'numeric', month: 'long', day: 'numeric' }; // Date display format
let display_value = invalid_date; // Default display value for invalid or null dates
let sort_value = -1; // Default sort value for invalid or null dates
if (last_active === invited) {
// Handle "Invited" status: special case with 0 sort value
display_value = invited;
sort_value = 0;
} else if (last_active && last_active !== invalid_date) {
// Parse and format valid UTC date strings
const parsedDate = new Date(last_active);
if (!isNaN(parsedDate.getTime())) {
// Valid date
display_value = parsedDate.toLocaleDateString('en-US', options);
sort_value = parsedDate.getTime(); // Use timestamp for sorting
} else {
console.error(`Error: Invalid date string provided: ${last_active}`);
}
}
return { display_value, sort_value };
}
/**
* Generates HTML for the list of domains assigned to a member.
*
* @param {number} num_domains - The number of domains the member is assigned to.
* @param {Array} domain_names - An array of domain names.
* @param {Array} domain_urls - An array of corresponding domain URLs.
* @returns {string} - A string of HTML displaying the domains assigned to the member.
*/
generateDomainsHTML(num_domains, domain_names, domain_urls) {
// Initialize an empty string for the HTML
let domainsHTML = '';
// Only generate HTML if the member has one or more assigned domains
if (num_domains > 0) {
domainsHTML += "<div class='desktop:grid-col-5 margin-bottom-2 desktop:margin-bottom-0'>";
domainsHTML += "<h4 class='margin-y-0 text-primary'>Domains assigned</h4>";
domainsHTML += `<p class='margin-y-0'>This member is assigned to ${num_domains} domains:</p>`;
domainsHTML += "<ul class='usa-list usa-list--unstyled margin-y-0'>";
// Display up to 6 domains with their URLs
for (let i = 0; i < num_domains && i < 6; i++) {
domainsHTML += `<li><a href="${domain_urls[i]}">${domain_names[i]}</a></li>`;
}
domainsHTML += "</ul>";
// If there are more than 6 domains, display a "View assigned domains" link
if (num_domains >= 6) {
domainsHTML += "<p><a href='#'>View assigned domains</a></p>";
}
domainsHTML += "</div>";
}
return domainsHTML;
}
/**
* Generates an HTML string summarizing a user's additional permissions within a portfolio,
* based on the user's permissions and predefined permission choices.
*
* @param {Array} member_permissions - An array of permission strings that the member has.
* @param {Object} UserPortfolioPermissionChoices - An object containing predefined permission choice constants.
* Expected keys include:
* - VIEW_ALL_DOMAINS
* - VIEW_MANAGED_DOMAINS
* - EDIT_REQUESTS
* - VIEW_ALL_REQUESTS
* - EDIT_MEMBERS
* - VIEW_MEMBERS
*
* @returns {string} - A string of HTML representing the user's additional permissions.
* If the user has no specific permissions, it returns a default message
* indicating no additional permissions.
*
* Behavior:
* - The function checks the user's permissions (`member_permissions`) and generates
* corresponding HTML sections based on the permission choices defined in `UserPortfolioPermissionChoices`.
* - Permissions are categorized into domains, requests, and members:
* - Domains: Determines whether the user can view or manage all or assigned domains.
* - Requests: Differentiates between users who can edit requests, view all requests, or have no request privileges.
* - Members: Distinguishes between members who can manage or only view other members.
* - If no relevant permissions are found, the function returns a message stating that the user has no additional permissions.
* - The resulting HTML always includes a header "Additional permissions for this member" and appends the relevant permission descriptions.
*/
generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices) {
let permissionsHTML = '';
// Check domain-related permissions
if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Domains:</strong> Can view all organization domains. Can manage domains they are assigned to and edit information about the domain (including DNS settings).</p>";
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Domains:</strong> Can manage domains they are assigned to and edit information about the domain (including DNS settings).</p>";
}
// Check request-related permissions
if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_REQUESTS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Domain requests:</strong> Can view all organization domain requests. Can create domain requests and modify their own requests.</p>";
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Domain requests (view-only):</strong> Can view all organization domain requests. Can't create or modify any domain requests.</p>";
}
// Check member-related permissions
if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_MEMBERS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Members:</strong> Can manage members including inviting new members, removing current members, and assigning domains to members.</p>";
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MEMBERS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Members (view-only):</strong> Can view all organizational members. Can't manage any members.</p>";
}
// If no specific permissions are assigned, display a message indicating no additional permissions
if (!permissionsHTML) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><b>No additional permissions:</b> There are no additional permissions for this member.</p>";
}
// Add a permissions header and wrap the entire output in a container
permissionsHTML = "<div class='desktop:grid-col-7'><h4 class='margin-y-0 text-primary'>Additional permissions for this member</h4>" + permissionsHTML + "</div>";
return permissionsHTML;
}
/** /**
* Loads rows in the members list, as well as updates pagination around the members list * Loads rows in the members list, as well as updates pagination around the members list
* based on the supplied attributes. * based on the supplied attributes.
@ -1927,39 +2118,20 @@ class MembersTable extends LoadTableBase {
const memberList = document.querySelector('.members__table tbody'); const memberList = document.querySelector('.members__table tbody');
memberList.innerHTML = ''; memberList.innerHTML = '';
const UserPortfolioPermissionChoices = data.UserPortfolioPermissionChoices;
const invited = 'Invited'; const invited = 'Invited';
const invalid_date = 'Invalid date';
data.members.forEach(member => { data.members.forEach(member => {
const member_id = member.source + member.id;
const member_name = member.name; const member_name = member.name;
const member_display = member.member_display; const member_display = member.member_display;
const options = { year: 'numeric', month: 'short', day: 'numeric' }; const member_permissions = member.permissions;
const domain_urls = member.domain_urls;
const domain_names = member.domain_names;
const num_domains = domain_urls.length;
// Handle last_active values const last_active = this.handleLastActive(member.last_active);
let last_active = member.last_active;
let last_active_formatted = '';
let last_active_sort_value = '';
// Handle 'Invited' or null/empty values differently from valid dates
if (last_active && last_active !== invited) {
try {
// Try to parse the last_active as a valid date
last_active = new Date(last_active);
if (!isNaN(last_active)) {
last_active_formatted = last_active.toLocaleDateString('en-US', options);
last_active_sort_value = last_active.getTime(); // For sorting purposes
} else {
last_active_formatted='Invalid date'
}
} catch (e) {
console.error(`Error parsing date: ${last_active}. Error: ${e}`);
last_active_formatted='Invalid date'
}
} else {
// Handle 'Invited' or null
last_active = invited;
last_active_formatted = invited;
last_active_sort_value = invited; // Keep 'Invited' as a sortable string
}
const action_url = member.action_url; const action_url = member.action_url;
const action_label = member.action_label; const action_label = member.action_label;
@ -1971,14 +2143,42 @@ class MembersTable extends LoadTableBase {
if (member.is_admin) if (member.is_admin)
admin_tagHTML = `<span class="usa-tag margin-left-1 bg-primary">Admin</span>` admin_tagHTML = `<span class="usa-tag margin-left-1 bg-primary">Admin</span>`
// generate html blocks for domains and permissions for the member
let domainsHTML = this.generateDomainsHTML(num_domains, domain_names, domain_urls);
let permissionsHTML = this.generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices);
// domainsHTML block and permissionsHTML block need to be wrapped with hide/show toggle, Expand
let showMoreButton = '';
const showMoreRow = document.createElement('tr');
if (domainsHTML || permissionsHTML) {
showMoreButton = `
<button
type="button"
class="usa-button--show-more-button usa-button usa-button--unstyled display-block margin-top-1"
data-for=${member_id}
aria-label="Expand for additional information"
>
<span>Expand</span>
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
`;
showMoreRow.innerHTML = `<td colspan='3' headers="header-member row-header-${member_id}" class="padding-top-0"><div class='grid-row'>${domainsHTML} ${permissionsHTML}</div></td>`;
showMoreRow.classList.add('show-more-content');
showMoreRow.classList.add('display-none');
showMoreRow.id = member_id;
}
row.innerHTML = ` row.innerHTML = `
<th scope="row" role="rowheader" data-label="member email"> <th role="rowheader" headers="header-member" data-label="member email" id='row-header-${member_id}'>
${member_display} ${admin_tagHTML} ${member_display} ${admin_tagHTML} ${showMoreButton}
</th> </th>
<td data-sort-value="${last_active_sort_value}" data-label="last_active"> <td headers="header-last-active row-header-${member_id}" data-sort-value="${last_active.sort_value}" data-label="last_active">
${last_active_formatted} ${last_active.display_value}
</td> </td>
<td> <td headers="header-action row-header-${member_id}">
<a href="${action_url}"> <a href="${action_url}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#${svg_icon}"></use> <use xlink:href="/public/img/sprite.svg#${svg_icon}"></use>
@ -1988,8 +2188,13 @@ class MembersTable extends LoadTableBase {
</td> </td>
`; `;
memberList.appendChild(row); memberList.appendChild(row);
if (domainsHTML || permissionsHTML) {
memberList.appendChild(showMoreRow);
}
}); });
this.initShowMoreButtons();
// Do not scroll on first page load // Do not scroll on first page load
if (scroll) if (scroll)
ScrollToElement('class', 'members'); ScrollToElement('class', 'members');

View file

@ -5508,6 +5508,8 @@ const SORT_BUTTON = `.${SORT_BUTTON_CLASS}`;
const SORTABLE_HEADER = `th[data-sortable]`; const SORTABLE_HEADER = `th[data-sortable]`;
const ANNOUNCEMENT_REGION = `.${PREFIX}-table__announcement-region[aria-live="polite"]`; const ANNOUNCEMENT_REGION = `.${PREFIX}-table__announcement-region[aria-live="polite"]`;
// ---- DOTGOV EDIT
/** Gets the data-sort-value attribute value, if provided otherwise, gets /** Gets the data-sort-value attribute value, if provided otherwise, gets
* the innerText or textContent of the child element (HTMLTableCellElement) * the innerText or textContent of the child element (HTMLTableCellElement)
* at the specified index of the given table row * at the specified index of the given table row
@ -5516,7 +5518,19 @@ const ANNOUNCEMENT_REGION = `.${PREFIX}-table__announcement-region[aria-live="po
* @param {array<HTMLTableRowElement>} tr * @param {array<HTMLTableRowElement>} tr
* @return {boolean} * @return {boolean}
*/ */
const getCellValue = (tr, index) => tr.children[index].getAttribute(SORT_OVERRIDE) || tr.children[index].innerText || tr.children[index].textContent; const getCellValue = (tr, index) => {
if (tr.children[index])
return tr.children[index].getAttribute(SORT_OVERRIDE) || tr.children[index].innerText || tr.children[index].textContent;
return "";
}
// const getCellValue = (tr, index) => tr.children[index].getAttribute(SORT_OVERRIDE) || tr.children[index].innerText || tr.children[index].textContent;
// DOTGOV: added check for tr.children[index] to protect from absent cells
// ---- END DOTGOV EDIT
/** /**
* Compares the values of two row array items at the given index, then sorts by the given direction * Compares the values of two row array items at the given index, then sorts by the given direction
@ -5528,7 +5542,6 @@ const compareFunction = (index, isAscending) => (thisRow, nextRow) => {
// get values to compare from data attribute or cell content // get values to compare from data attribute or cell content
const value1 = getCellValue(isAscending ? thisRow : nextRow, index); const value1 = getCellValue(isAscending ? thisRow : nextRow, index);
const value2 = getCellValue(isAscending ? nextRow : thisRow, index); const value2 = getCellValue(isAscending ? nextRow : thisRow, index);
// if neither value is empty, and if both values are already numbers, compare numerically // if neither value is empty, and if both values are already numbers, compare numerically
if (value1 && value2 && !Number.isNaN(Number(value1)) && !Number.isNaN(Number(value2))) { if (value1 && value2 && !Number.isNaN(Number(value1)) && !Number.isNaN(Number(value2))) {
return value1 - value2; return value1 - value2;
@ -5603,7 +5616,16 @@ const sortRows = (header, isAscending) => {
const thisHeaderIndex = allHeaders.indexOf(header); const thisHeaderIndex = allHeaders.indexOf(header);
allRows.sort(compareFunction(thisHeaderIndex, !isAscending)).forEach(tr => { allRows.sort(compareFunction(thisHeaderIndex, !isAscending)).forEach(tr => {
[].slice.call(tr.children).forEach(td => td.removeAttribute("data-sort-active")); [].slice.call(tr.children).forEach(td => td.removeAttribute("data-sort-active"));
// ---- DOTGOV EDIT
// tr.children[thisHeaderIndex].setAttribute("data-sort-active", true);
if (tr.children[thisHeaderIndex])
tr.children[thisHeaderIndex].setAttribute("data-sort-active", true); tr.children[thisHeaderIndex].setAttribute("data-sort-active", true);
// DOTGOV added conditional to protect from tr.children[thisHeaderIndex] being absent
// ---- END DOTGOV EDIT
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
return true; return true;

View file

@ -258,3 +258,11 @@ a.text-secondary,
a.text-secondary:hover { a.text-secondary:hover {
color: $theme-color-error; color: $theme-color-error;
} }
.usa-button--show-more-button {
font-size: size('ui', 'xs');
text-decoration: none;
.usa-icon {
top: 6px;
}
}

View file

@ -56,9 +56,11 @@ th {
border: none; border: none;
} }
tr:not(.hide-td-borders) {
td, th { td, th {
border-bottom: 1px solid color('base-lighter'); border-bottom: 1px solid color('base-lighter');
} }
}
thead th { thead th {
color: color('primary-darker'); color: color('primary-darker');

View file

@ -28,3 +28,8 @@ h2 {
.usa-form fieldset { .usa-form fieldset {
font-size: 1rem; font-size: 1rem;
} }
.p--blockquote {
padding-left: units(1);
border-left: 2px solid color('base-lighter');
}

View file

@ -79,19 +79,8 @@ class PortfolioInvitation(TimeStampedModel):
def get_portfolio_permissions(self): def get_portfolio_permissions(self):
""" """
Retrieve the permissions for the user's portfolio roles from the invite. Retrieve the permissions for the user's portfolio roles from the invite.
This is similar logic to _get_portfolio_permissions in user_portfolio_permission
""" """
# Use a set to avoid duplicate permissions return UserPortfolioPermission.get_portfolio_permissions(self.roles, self.additional_permissions)
portfolio_permissions = set()
if self.roles:
for role in self.roles:
portfolio_permissions.update(UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
if self.additional_permissions:
portfolio_permissions.update(self.additional_permissions)
return list(portfolio_permissions)
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED) @transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
def retrieve(self): def retrieve(self):

View file

@ -92,16 +92,18 @@ class UserPortfolioPermission(TimeStampedModel):
""" """
Retrieve the permissions for the user's portfolio roles. Retrieve the permissions for the user's portfolio roles.
""" """
return self.get_portfolio_permissions(self.roles, self.additional_permissions)
@classmethod
def get_portfolio_permissions(cls, roles, additional_permissions):
"""Class method to return a list of permissions based on roles and addtl permissions"""
# Use a set to avoid duplicate permissions # Use a set to avoid duplicate permissions
portfolio_permissions = set() portfolio_permissions = set()
if roles:
if self.roles: for role in roles:
for role in self.roles: portfolio_permissions.update(cls.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
portfolio_permissions.update(self.PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) if additional_permissions:
portfolio_permissions.update(additional_permissions)
if self.additional_permissions:
portfolio_permissions.update(self.additional_permissions)
return list(portfolio_permissions) return list(portfolio_permissions)
def clean(self): def clean(self):

View file

@ -36,3 +36,7 @@ class UserPortfolioPermissionChoices(models.TextChoices):
@classmethod @classmethod
def get_user_portfolio_permission_label(cls, user_portfolio_permission): def get_user_portfolio_permission_label(cls, user_portfolio_permission):
return cls(user_portfolio_permission).label if user_portfolio_permission else None return cls(user_portfolio_permission).label if user_portfolio_permission else None
@classmethod
def to_dict(cls):
return {key: value.value for key, value in cls.__members__.items()}

View file

@ -39,16 +39,16 @@
</div> </div>
<!-- ---------- MAIN TABLE ---------- --> <!-- ---------- MAIN TABLE ---------- -->
<div class="members__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0"> <div class="members__table-wrapper display-none margin-top-0">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked members__table"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked members__table">
<caption class="sr-only">Your registered members</caption> <caption class="sr-only">Your registered members</caption>
<thead> <thead>
<tr> <tr>
<th data-sortable="member" scope="col" role="columnheader">Member</th> <th data-sortable="member" role="columnheader" id="header-member">Member</th>
<th data-sortable="last_active" scope="col" role="columnheader">Last Active</th> <th data-sortable="last_active" role="columnheader" id="header-last-active">Last Active</th>
<th <th
scope="col"
role="columnheader" role="columnheader"
id="header-action"
> >
<span class="usa-sr-only">Action</span> <span class="usa-sr-only">Action</span>
</th> </th>

View file

@ -1,21 +1,25 @@
from django.urls import reverse 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 import Portfolio
from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user import User 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.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices 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 from django_webtest import WebTest # type: ignore
class GetPortfolioMembersJsonTest(TestWithUser, WebTest): class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
@classmethod def setUp(self):
def setUpClass(cls): super().setUp()
super().setUpClass() self.user = create_test_user()
# Create additional users # Create additional users
cls.user2 = User.objects.create( self.user2 = User.objects.create(
username="test_user2", username="test_user2",
first_name="Second", first_name="Second",
last_name="User", last_name="User",
@ -23,7 +27,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
phone="8003112345", phone="8003112345",
title="Member", title="Member",
) )
cls.user3 = User.objects.create( self.user3 = User.objects.create(
username="test_user3", username="test_user3",
first_name="Third", first_name="Third",
last_name="User", last_name="User",
@ -31,7 +35,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
phone="8003113456", phone="8003113456",
title="Member", title="Member",
) )
cls.user4 = User.objects.create( self.user4 = User.objects.create(
username="test_user4", username="test_user4",
first_name="Fourth", first_name="Fourth",
last_name="User", last_name="User",
@ -39,60 +43,66 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
phone="8003114567", phone="8003114567",
title="Admin", 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 # 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 # 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 self.app.set_user(self.user.username)
def tearDownClass(cls):
def tearDown(self):
UserDomainRole.objects.all().delete()
DomainInformation.objects.all().delete()
Domain.objects.all().delete()
PortfolioInvitation.objects.all().delete() PortfolioInvitation.objects.all().delete()
UserPortfolioPermission.objects.all().delete() UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete() Portfolio.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
super().tearDownClass() super().tearDown()
def setUp(self):
super().setUp()
self.app.set_user(self.user.username)
def test_get_portfolio_members_json_authenticated(self): def test_get_portfolio_members_json_authenticated(self):
"""Test that portfolio members are returned properly for an authenticated user.""" """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}) response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = response.json data = response.json
@ -115,15 +125,228 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
self.user3.email, self.user3.email,
self.user4.email, self.user4.email,
self.user4.email, self.user4.email,
self.email5, self.user5.email,
} }
actual_emails = {member["email"] for member in data["members"]} actual_emails = {member["email"] for member in data["members"]}
self.assertEqual(expected_emails, actual_emails) 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)
# Assert that the expected additional permissions are in the actual entire permissions list
expected_additional_permissions = {
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
}
# actual_permissions includes additional permissions as well as permissions from roles
actual_permissions = {permission for member in data["members"] for permission in member["permissions"]}
self.assertTrue(expected_additional_permissions.issubset(actual_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],
)
# create domain for which user is manager and domain in portfolio
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,
)
# create domain for which user is manager and domain not in portfolio
domain2 = Domain.objects.create(
name="somedomain2.com",
)
DomainInformation.objects.create(
creator=self.user,
domain=domain2,
)
UserDomainRole.objects.create(
user=self.user,
domain=domain2,
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 and that domain2 does not
domain_names = [domain_name for member in data["members"] for domain_name in member.get("domain_names", [])]
self.assertIn("somedomain1.com", domain_names)
self.assertNotIn("somedomain2.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,
],
)
# create a domain in the portfolio
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,
)
# create a domain not in the portfolio
domain2 = Domain.objects.create(
name="somedomain2.com",
)
DomainInformation.objects.create(
creator=self.user,
domain=domain2,
)
DomainInvitation.objects.create(
email=self.email6,
domain=domain2,
)
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 and domain2 does not
domain_names = [domain_name for member in data["members"] for domain_name in member.get("domain_names", [])]
self.assertIn("somedomain1.com", domain_names)
self.assertNotIn("somedomain2.com", domain_names)
def test_pagination(self): def test_pagination(self):
"""Test that pagination works properly when there are more members than page size.""" """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 # 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( user, _ = User.objects.get_or_create(
username=f"test_user{i}", username=f"test_user{i}",
first_name=f"User{i}", first_name=f"User{i}",
@ -172,6 +395,40 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
def test_search(self): def test_search(self):
"""Test search functionality for portfolio members.""" """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 # Search by name
response = self.app.get( response = self.app.get(
reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id, "search_term": "Second"} reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id, "search_term": "Second"}

View file

@ -1,14 +1,16 @@
from django.http import JsonResponse from django.http import JsonResponse
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.contrib.auth.decorators import login_required 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.db.models.expressions import Func
from django.db.models.functions import Cast, Coalesce, Concat
from django.contrib.postgres.aggregates import ArrayAgg
from django.urls import reverse 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.portfolio_invitation import PortfolioInvitation
from registrar.models.user_portfolio_permission import UserPortfolioPermission 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
@login_required @login_required
@ -40,6 +42,7 @@ def get_portfolio_members_json(request):
return JsonResponse( return JsonResponse(
{ {
"members": members, "members": members,
"UserPortfolioPermissionChoices": UserPortfolioPermissionChoices.to_dict(),
"page": page_obj.number, "page": page_obj.number,
"num_pages": paginator.num_pages, "num_pages": paginator.num_pages,
"has_previous": page_obj.has_previous(), "has_previous": page_obj.has_previous(),
@ -59,8 +62,11 @@ def initial_permissions_search(portfolio):
first_name=F("user__first_name"), first_name=F("user__first_name"),
last_name=F("user__last_name"), last_name=F("user__last_name"),
email_display=F("user__email"), email_display=F("user__email"),
last_active=Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text last_active=Coalesce(
additional_permissions_display=F("additional_permissions"), Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text
Value("Invalid date"),
output_field=TextField(),
),
member_display=Case( member_display=Case(
# If email is present and not blank, use email # If email is present and not blank, use email
When(Q(user__email__isnull=False) & ~Q(user__email=""), then=F("user__email")), When(Q(user__email__isnull=False) & ~Q(user__email=""), then=F("user__email")),
@ -77,6 +83,19 @@ def initial_permissions_search(portfolio):
default=Value(""), default=Value(""),
output_field=CharField(), output_field=CharField(),
), ),
domain_info=ArrayAgg(
# an array of domains, with id and name, colon separated
Concat(
F("user__permissions__domain_id"),
Value(":"),
F("user__permissions__domain__name"),
# specify the output_field to ensure union has same column types
output_field=CharField(),
),
distinct=True,
filter=Q(user__permissions__domain__isnull=False) # filter out null values
& Q(user__permissions__domain__domain_info__portfolio=portfolio), # only include domains in portfolio
),
source=Value("permission", output_field=CharField()), source=Value("permission", output_field=CharField()),
) )
.values( .values(
@ -86,24 +105,43 @@ def initial_permissions_search(portfolio):
"email_display", "email_display",
"last_active", "last_active",
"roles", "roles",
"additional_permissions_display", "additional_permissions",
"member_display", "member_display",
"domain_info",
"source", "source",
) )
) )
return permissions return permissions
# Custom Func to use array_remove to remove null values
class ArrayRemove(Func):
function = "array_remove"
template = "%(function)s(%(expressions)s, NULL)"
def initial_invitations_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 and for the portfolio
domain_invitations = DomainInvitation.objects.filter(
email=OuterRef("email"), # Check if email matches the OuterRef("email")
domain__domain_info__portfolio=portfolio, # Check if the domain's portfolio matches the given portfolio
).annotate(domain_info=Concat(F("domain__id"), Value(":"), F("domain__name"), output_field=CharField()))
# PortfolioInvitation query
invitations = PortfolioInvitation.objects.filter(portfolio=portfolio) invitations = PortfolioInvitation.objects.filter(portfolio=portfolio)
invitations = invitations.annotate( invitations = invitations.annotate(
first_name=Value(None, output_field=CharField()), first_name=Value(None, output_field=CharField()),
last_name=Value(None, output_field=CharField()), last_name=Value(None, output_field=CharField()),
email_display=F("email"), email_display=F("email"),
last_active=Value("Invited", output_field=TextField()), last_active=Value("Invited", output_field=TextField()),
additional_permissions_display=F("additional_permissions"),
member_display=F("email"), member_display=F("email"),
# Use ArrayRemove to return an empty list when no domain invitations are found
domain_info=ArrayRemove(
ArrayAgg(
Subquery(domain_invitations.values("domain_info")),
distinct=True,
)
),
source=Value("invitation", output_field=CharField()), source=Value("invitation", output_field=CharField()),
).values( ).values(
"id", "id",
@ -112,8 +150,9 @@ def initial_invitations_search(portfolio):
"email_display", "email_display",
"last_active", "last_active",
"roles", "roles",
"additional_permissions_display", "additional_permissions",
"member_display", "member_display",
"domain_info",
"source", "source",
) )
return invitations return invitations
@ -159,11 +198,21 @@ def serialize_members(request, portfolio, item, user):
# Serialize member data # Serialize member data
member_json = { member_json = {
"id": item.get("id", ""), "id": item.get("id", ""),
"source": item.get("source", ""),
"name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])), "name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
"email": item.get("email_display", ""), "email": item.get("email_display", ""),
"member_display": item.get("member_display", ""), "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")],
"is_admin": is_admin, "is_admin": is_admin,
"last_active": item.get("last_active", ""), "last_active": item.get("last_active"),
"action_url": action_url, "action_url": action_url,
"action_label": ("View" if view_only else "Manage"), "action_label": ("View" if view_only else "Manage"),
"svg_icon": ("visibility" if view_only else "settings"), "svg_icon": ("visibility" if view_only else "settings"),