mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-22 02:36:02 +02:00
Merge pull request #2945 from cisagov/dk/2763-member-expand
#2763: Expanded row functionality
This commit is contained in:
commit
fd9eb0da58
11 changed files with 668 additions and 125 deletions
|
@ -1874,6 +1874,197 @@ 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* based on the supplied attributes.
|
||||
|
@ -1927,39 +2118,20 @@ class MembersTable extends LoadTableBase {
|
|||
const memberList = document.querySelector('.members__table tbody');
|
||||
memberList.innerHTML = '';
|
||||
|
||||
const UserPortfolioPermissionChoices = data.UserPortfolioPermissionChoices;
|
||||
const invited = 'Invited';
|
||||
const invalid_date = 'Invalid date';
|
||||
|
||||
data.members.forEach(member => {
|
||||
const member_id = member.source + member.id;
|
||||
const member_name = member.name;
|
||||
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
|
||||
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 last_active = this.handleLastActive(member.last_active);
|
||||
|
||||
const action_url = member.action_url;
|
||||
const action_label = member.action_label;
|
||||
|
@ -1971,14 +2143,42 @@ class MembersTable extends LoadTableBase {
|
|||
if (member.is_admin)
|
||||
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 = `
|
||||
<th scope="row" role="rowheader" data-label="member email">
|
||||
${member_display} ${admin_tagHTML}
|
||||
<th role="rowheader" headers="header-member" data-label="member email" id='row-header-${member_id}'>
|
||||
${member_display} ${admin_tagHTML} ${showMoreButton}
|
||||
</th>
|
||||
<td data-sort-value="${last_active_sort_value}" data-label="last_active">
|
||||
${last_active_formatted}
|
||||
<td headers="header-last-active row-header-${member_id}" data-sort-value="${last_active.sort_value}" data-label="last_active">
|
||||
${last_active.display_value}
|
||||
</td>
|
||||
<td>
|
||||
<td headers="header-action row-header-${member_id}">
|
||||
<a href="${action_url}">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#${svg_icon}"></use>
|
||||
|
@ -1988,8 +2188,13 @@ class MembersTable extends LoadTableBase {
|
|||
</td>
|
||||
`;
|
||||
memberList.appendChild(row);
|
||||
if (domainsHTML || permissionsHTML) {
|
||||
memberList.appendChild(showMoreRow);
|
||||
}
|
||||
});
|
||||
|
||||
this.initShowMoreButtons();
|
||||
|
||||
// Do not scroll on first page load
|
||||
if (scroll)
|
||||
ScrollToElement('class', 'members');
|
||||
|
|
|
@ -5508,6 +5508,8 @@ const SORT_BUTTON = `.${SORT_BUTTON_CLASS}`;
|
|||
const SORTABLE_HEADER = `th[data-sortable]`;
|
||||
const ANNOUNCEMENT_REGION = `.${PREFIX}-table__announcement-region[aria-live="polite"]`;
|
||||
|
||||
// ---- DOTGOV EDIT
|
||||
|
||||
/** Gets the data-sort-value attribute value, if provided — otherwise, gets
|
||||
* the innerText or textContent — of the child element (HTMLTableCellElement)
|
||||
* 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
|
||||
* @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
|
||||
|
@ -5528,7 +5542,6 @@ const compareFunction = (index, isAscending) => (thisRow, nextRow) => {
|
|||
// get values to compare from data attribute or cell content
|
||||
const value1 = getCellValue(isAscending ? thisRow : nextRow, index);
|
||||
const value2 = getCellValue(isAscending ? nextRow : thisRow, index);
|
||||
|
||||
// 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))) {
|
||||
return value1 - value2;
|
||||
|
@ -5603,7 +5616,16 @@ const sortRows = (header, isAscending) => {
|
|||
const thisHeaderIndex = allHeaders.indexOf(header);
|
||||
allRows.sort(compareFunction(thisHeaderIndex, !isAscending)).forEach(tr => {
|
||||
[].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);
|
||||
// DOTGOV added conditional to protect from tr.children[thisHeaderIndex] being absent
|
||||
|
||||
// ---- END DOTGOV EDIT
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
return true;
|
||||
|
|
|
@ -258,3 +258,11 @@ a.text-secondary,
|
|||
a.text-secondary:hover {
|
||||
color: $theme-color-error;
|
||||
}
|
||||
|
||||
.usa-button--show-more-button {
|
||||
font-size: size('ui', 'xs');
|
||||
text-decoration: none;
|
||||
.usa-icon {
|
||||
top: 6px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,9 +56,11 @@ th {
|
|||
border: none;
|
||||
}
|
||||
|
||||
tr:not(.hide-td-borders) {
|
||||
td, th {
|
||||
border-bottom: 1px solid color('base-lighter');
|
||||
}
|
||||
}
|
||||
|
||||
thead th {
|
||||
color: color('primary-darker');
|
||||
|
|
|
@ -28,3 +28,8 @@ h2 {
|
|||
.usa-form fieldset {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.p--blockquote {
|
||||
padding-left: units(1);
|
||||
border-left: 2px solid color('base-lighter');
|
||||
}
|
||||
|
|
|
@ -79,19 +79,8 @@ class PortfolioInvitation(TimeStampedModel):
|
|||
def get_portfolio_permissions(self):
|
||||
"""
|
||||
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
|
||||
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)
|
||||
return UserPortfolioPermission.get_portfolio_permissions(self.roles, self.additional_permissions)
|
||||
|
||||
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
|
||||
def retrieve(self):
|
||||
|
|
|
@ -92,16 +92,18 @@ class UserPortfolioPermission(TimeStampedModel):
|
|||
"""
|
||||
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
|
||||
portfolio_permissions = set()
|
||||
|
||||
if self.roles:
|
||||
for role in self.roles:
|
||||
portfolio_permissions.update(self.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
|
||||
|
||||
if self.additional_permissions:
|
||||
portfolio_permissions.update(self.additional_permissions)
|
||||
|
||||
if roles:
|
||||
for role in roles:
|
||||
portfolio_permissions.update(cls.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
|
||||
if additional_permissions:
|
||||
portfolio_permissions.update(additional_permissions)
|
||||
return list(portfolio_permissions)
|
||||
|
||||
def clean(self):
|
||||
|
|
|
@ -36,3 +36,7 @@ class UserPortfolioPermissionChoices(models.TextChoices):
|
|||
@classmethod
|
||||
def get_user_portfolio_permission_label(cls, user_portfolio_permission):
|
||||
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()}
|
||||
|
|
|
@ -39,16 +39,16 @@
|
|||
</div>
|
||||
|
||||
<!-- ---------- 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">
|
||||
<caption class="sr-only">Your registered members</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sortable="member" scope="col" role="columnheader">Member</th>
|
||||
<th data-sortable="last_active" scope="col" role="columnheader">Last Active</th>
|
||||
<th data-sortable="member" role="columnheader" id="header-member">Member</th>
|
||||
<th data-sortable="last_active" role="columnheader" id="header-last-active">Last Active</th>
|
||||
<th
|
||||
scope="col"
|
||||
role="columnheader"
|
||||
id="header-action"
|
||||
>
|
||||
<span class="usa-sr-only">Action</span>
|
||||
</th>
|
||||
|
|
|
@ -1,21 +1,25 @@
|
|||
from django.urls import reverse
|
||||
|
||||
from registrar.models.domain import Domain
|
||||
from registrar.models.domain_information import DomainInformation
|
||||
from registrar.models.domain_invitation import DomainInvitation
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.user import User
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from .test_views import TestWithUser
|
||||
from registrar.tests.common import MockEppLib, create_test_user
|
||||
from django_webtest import WebTest # type: ignore
|
||||
|
||||
|
||||
class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = create_test_user()
|
||||
|
||||
# Create additional users
|
||||
cls.user2 = User.objects.create(
|
||||
self.user2 = User.objects.create(
|
||||
username="test_user2",
|
||||
first_name="Second",
|
||||
last_name="User",
|
||||
|
@ -23,7 +27,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
phone="8003112345",
|
||||
title="Member",
|
||||
)
|
||||
cls.user3 = User.objects.create(
|
||||
self.user3 = User.objects.create(
|
||||
username="test_user3",
|
||||
first_name="Third",
|
||||
last_name="User",
|
||||
|
@ -31,7 +35,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
phone="8003113456",
|
||||
title="Member",
|
||||
)
|
||||
cls.user4 = User.objects.create(
|
||||
self.user4 = User.objects.create(
|
||||
username="test_user4",
|
||||
first_name="Fourth",
|
||||
last_name="User",
|
||||
|
@ -39,60 +43,66 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
phone="8003114567",
|
||||
title="Admin",
|
||||
)
|
||||
cls.email5 = "fifth@example.com"
|
||||
self.user5 = User.objects.create(
|
||||
username="test_user5",
|
||||
first_name="Fifth",
|
||||
last_name="User",
|
||||
email="fifth@example.com",
|
||||
phone="8003114568",
|
||||
title="Admin",
|
||||
)
|
||||
self.email6 = "fifth@example.com"
|
||||
|
||||
# Create Portfolio
|
||||
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
|
||||
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
|
||||
|
||||
# Assign permissions
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=cls.user,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=cls.user2,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=cls.user3,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=cls.user4,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
PortfolioInvitation.objects.create(
|
||||
email=cls.email5,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
self.app.set_user(self.user.username)
|
||||
|
||||
def tearDown(self):
|
||||
UserDomainRole.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
PortfolioInvitation.objects.all().delete()
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
super().tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.app.set_user(self.user.username)
|
||||
super().tearDown()
|
||||
|
||||
def test_get_portfolio_members_json_authenticated(self):
|
||||
"""Test that portfolio members are returned properly for an authenticated user."""
|
||||
"""Also tests that reposnse is 200 when no domains"""
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user2,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user3,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user4,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user5,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
|
||||
response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json
|
||||
|
@ -115,15 +125,228 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
self.user3.email,
|
||||
self.user4.email,
|
||||
self.user4.email,
|
||||
self.email5,
|
||||
self.user5.email,
|
||||
}
|
||||
actual_emails = {member["email"] for member in data["members"]}
|
||||
self.assertEqual(expected_emails, actual_emails)
|
||||
|
||||
expected_roles = {
|
||||
UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
|
||||
}
|
||||
# Convert each member's roles list to a frozenset
|
||||
actual_roles = {role for member in data["members"] for role in member["roles"]}
|
||||
self.assertEqual(expected_roles, actual_roles)
|
||||
|
||||
# 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):
|
||||
"""Test that pagination works properly when there are more members than page size."""
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user2,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user3,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user4,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
PortfolioInvitation.objects.create(
|
||||
email=self.email6,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Create additional members to exceed page size of 10
|
||||
for i in range(5, 15):
|
||||
for i in range(6, 16):
|
||||
user, _ = User.objects.get_or_create(
|
||||
username=f"test_user{i}",
|
||||
first_name=f"User{i}",
|
||||
|
@ -172,6 +395,40 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
|
||||
def test_search(self):
|
||||
"""Test search functionality for portfolio members."""
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user2,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user3,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user4,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
PortfolioInvitation.objects.create(
|
||||
email=self.email6,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Search by name
|
||||
response = self.app.get(
|
||||
reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id, "search_term": "Second"}
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
from django.http import JsonResponse
|
||||
from django.core.paginator import Paginator
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Value, F, CharField, TextField, Q, Case, When
|
||||
from django.db.models.functions import Concat, Coalesce
|
||||
from django.db.models import Value, F, CharField, TextField, Q, Case, When, OuterRef, Subquery
|
||||
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.db.models.functions import Cast
|
||||
|
||||
from registrar.models.domain_invitation import DomainInvitation
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
|
||||
|
||||
@login_required
|
||||
|
@ -40,6 +42,7 @@ def get_portfolio_members_json(request):
|
|||
return JsonResponse(
|
||||
{
|
||||
"members": members,
|
||||
"UserPortfolioPermissionChoices": UserPortfolioPermissionChoices.to_dict(),
|
||||
"page": page_obj.number,
|
||||
"num_pages": paginator.num_pages,
|
||||
"has_previous": page_obj.has_previous(),
|
||||
|
@ -59,8 +62,11 @@ def initial_permissions_search(portfolio):
|
|||
first_name=F("user__first_name"),
|
||||
last_name=F("user__last_name"),
|
||||
email_display=F("user__email"),
|
||||
last_active=Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text
|
||||
additional_permissions_display=F("additional_permissions"),
|
||||
last_active=Coalesce(
|
||||
Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text
|
||||
Value("Invalid date"),
|
||||
output_field=TextField(),
|
||||
),
|
||||
member_display=Case(
|
||||
# If email is present and not blank, use email
|
||||
When(Q(user__email__isnull=False) & ~Q(user__email=""), then=F("user__email")),
|
||||
|
@ -77,6 +83,19 @@ def initial_permissions_search(portfolio):
|
|||
default=Value(""),
|
||||
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()),
|
||||
)
|
||||
.values(
|
||||
|
@ -86,24 +105,43 @@ def initial_permissions_search(portfolio):
|
|||
"email_display",
|
||||
"last_active",
|
||||
"roles",
|
||||
"additional_permissions_display",
|
||||
"additional_permissions",
|
||||
"member_display",
|
||||
"domain_info",
|
||||
"source",
|
||||
)
|
||||
)
|
||||
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):
|
||||
"""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 = invitations.annotate(
|
||||
first_name=Value(None, output_field=CharField()),
|
||||
last_name=Value(None, output_field=CharField()),
|
||||
email_display=F("email"),
|
||||
last_active=Value("Invited", output_field=TextField()),
|
||||
additional_permissions_display=F("additional_permissions"),
|
||||
member_display=F("email"),
|
||||
# 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()),
|
||||
).values(
|
||||
"id",
|
||||
|
@ -112,8 +150,9 @@ def initial_invitations_search(portfolio):
|
|||
"email_display",
|
||||
"last_active",
|
||||
"roles",
|
||||
"additional_permissions_display",
|
||||
"additional_permissions",
|
||||
"member_display",
|
||||
"domain_info",
|
||||
"source",
|
||||
)
|
||||
return invitations
|
||||
|
@ -159,11 +198,21 @@ def serialize_members(request, portfolio, item, user):
|
|||
# Serialize member data
|
||||
member_json = {
|
||||
"id": item.get("id", ""),
|
||||
"source": item.get("source", ""),
|
||||
"name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
|
||||
"email": item.get("email_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,
|
||||
"last_active": item.get("last_active", ""),
|
||||
"last_active": item.get("last_active"),
|
||||
"action_url": action_url,
|
||||
"action_label": ("View" if view_only else "Manage"),
|
||||
"svg_icon": ("visibility" if view_only else "settings"),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue