mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-01 15:34:53 +02:00
Merge branch 'main' into za/2771-create-requesting-entity-page
This commit is contained in:
commit
7e2e75d105
30 changed files with 2241 additions and 542 deletions
52
.github/workflows/clone-staging.yaml
vendored
Normal file
52
.github/workflows/clone-staging.yaml
vendored
Normal file
|
@ -0,0 +1,52 @@
|
|||
name: Clone Staging Database
|
||||
|
||||
on:
|
||||
# these will be uncommented after testing
|
||||
# ----
|
||||
# schedule:
|
||||
# # Run daily at 2:00 PM EST
|
||||
# - cron: '0 * * * *'
|
||||
# Allow manual triggering
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DESTINATION_ENVIRONMENT: ms
|
||||
SOURCE_ENVIRONMENT: staging
|
||||
|
||||
jobs:
|
||||
clone-database:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Cloud Foundry CLI
|
||||
uses: cloud-gov/cg-cli-tools@main
|
||||
|
||||
- name: Clone
|
||||
env:
|
||||
CF_USERNAME: CF_${{ env.DESTINATION_ENVIRONMENT }}_USERNAME
|
||||
CF_PASSWORD: CF_${{ env.DESTINATION_ENVIRONMENT }}_PASSWORD
|
||||
run: |
|
||||
# login to cf cli
|
||||
cf login -a api.fr.cloud.gov -u $CF_USERNAME -p $CF_PASSWORD -o cisa-dotgov -s ${{ env.DESTINATION_ENVIRONMENT }}
|
||||
|
||||
# install cg-manage-rds tool
|
||||
pip install git+https://github.com/cloud-gov/cg-manage-rds.git
|
||||
|
||||
# share the sandbox db with the Staging space
|
||||
cf share-service getgov-${{ env.DESTINATION_ENVIRONMENT }}-database -s ${{ env.SOURCE_ENVIRONMENT }}
|
||||
|
||||
# target the Staging space
|
||||
cf target -s ${{ env.SOURCE_ENVIRONMENT }}
|
||||
|
||||
# clone from staging to the sandbox
|
||||
cg-manage-rds clone getgov-${{ env.SOURCE_ENVIRONMENT }}-database getgov-${{ env.DESTINATION_ENVIRONMENT }}-database
|
||||
|
||||
rm db_backup.sql
|
||||
|
||||
# switch to the target sandbox space
|
||||
cf target -s ${{ env.DESTINATION_ENVIRONMENT }}
|
||||
|
||||
# un-share the sandbox from Staging
|
||||
cf unshare-service getgov-${{ env.DESTINATION_ENVIRONMENT }}-database -s ${{ env.SOURCE_ENVIRONMENT }}
|
|
@ -1005,25 +1005,24 @@ function unloadModals() {
|
|||
}
|
||||
|
||||
class LoadTableBase {
|
||||
constructor(tableSelector, tableWrapperSelector, searchFieldId, searchSubmitId, resetSearchBtn, resetFiltersBtn, noDataDisplay, noSearchresultsDisplay) {
|
||||
this.tableWrapper = document.querySelector(tableWrapperSelector);
|
||||
this.tableHeaders = document.querySelectorAll(`${tableSelector} th[data-sortable]`);
|
||||
constructor(sectionSelector) {
|
||||
this.tableWrapper = document.getElementById(`${sectionSelector}__table-wrapper`);
|
||||
this.tableHeaders = document.querySelectorAll(`#${sectionSelector} th[data-sortable]`);
|
||||
this.currentSortBy = 'id';
|
||||
this.currentOrder = 'asc';
|
||||
this.currentStatus = [];
|
||||
this.currentSearchTerm = '';
|
||||
this.scrollToTable = false;
|
||||
this.searchInput = document.querySelector(searchFieldId);
|
||||
this.searchSubmit = document.querySelector(searchSubmitId);
|
||||
this.tableAnnouncementRegion = document.querySelector(`${tableWrapperSelector} .usa-table__announcement-region`);
|
||||
this.resetSearchButton = document.querySelector(resetSearchBtn);
|
||||
this.resetFiltersButton = document.querySelector(resetFiltersBtn);
|
||||
// NOTE: these 3 can't be used if filters are active on a page with more than 1 table
|
||||
this.statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
|
||||
this.statusIndicator = document.querySelector('.filter-indicator');
|
||||
this.statusToggle = document.querySelector('.usa-button--filter');
|
||||
this.noTableWrapper = document.querySelector(noDataDisplay);
|
||||
this.noSearchResultsWrapper = document.querySelector(noSearchresultsDisplay);
|
||||
this.searchInput = document.getElementById(`${sectionSelector}__search-field`);
|
||||
this.searchSubmit = document.getElementById(`${sectionSelector}__search-field-submit`);
|
||||
this.tableAnnouncementRegion = document.getElementById(`${sectionSelector}__usa-table__announcement-region`);
|
||||
this.resetSearchButton = document.getElementById(`${sectionSelector}__reset-search`);
|
||||
this.resetFiltersButton = document.getElementById(`${sectionSelector}__reset-filters`);
|
||||
this.statusCheckboxes = document.querySelectorAll(`.${sectionSelector} input[name="filter-status"]`);
|
||||
this.statusIndicator = document.getElementById(`${sectionSelector}__filter-indicator`);
|
||||
this.statusToggle = document.getElementById(`${sectionSelector}__usa-button--filter`);
|
||||
this.noTableWrapper = document.getElementById(`${sectionSelector}__no-data`);
|
||||
this.noSearchResultsWrapper = document.getElementById(`${sectionSelector}__no-search-results`);
|
||||
this.portfolioElement = document.getElementById('portfolio-js-value');
|
||||
this.portfolioValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-portfolio') : null;
|
||||
this.initializeTableHeaders();
|
||||
|
@ -1365,7 +1364,7 @@ class LoadTableBase {
|
|||
class DomainsTable extends LoadTableBase {
|
||||
|
||||
constructor() {
|
||||
super('.domains__table', '.domains__table-wrapper', '#domains__search-field', '#domains__search-field-submit', '.domains__reset-search', '.domains__reset-filters', '.domains__no-data', '.domains__no-search-results');
|
||||
super('domains');
|
||||
}
|
||||
/**
|
||||
* Loads rows in the domains list, as well as updates pagination around the domains list
|
||||
|
@ -1417,7 +1416,7 @@ class DomainsTable extends LoadTableBase {
|
|||
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 domainList = document.querySelector('.domains__table tbody');
|
||||
const domainList = document.querySelector('#domains tbody');
|
||||
domainList.innerHTML = '';
|
||||
|
||||
data.domains.forEach(domain => {
|
||||
|
@ -1503,7 +1502,7 @@ class DomainsTable extends LoadTableBase {
|
|||
class DomainRequestsTable extends LoadTableBase {
|
||||
|
||||
constructor() {
|
||||
super('.domain-requests__table', '.domain-requests__table-wrapper', '#domain-requests__search-field', '#domain-requests__search-field-submit', '.domain-requests__reset-search', '.domain-requests__reset-filters', '.domain-requests__no-data', '.domain-requests__no-search-results');
|
||||
super('domain-requests');
|
||||
}
|
||||
|
||||
toggleExportButton(requests) {
|
||||
|
@ -1569,7 +1568,7 @@ class DomainRequestsTable extends LoadTableBase {
|
|||
this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
|
||||
|
||||
// identify the DOM element where the domain request list will be inserted into the DOM
|
||||
const tbody = document.querySelector('.domain-requests__table tbody');
|
||||
const tbody = document.querySelector('#domain-requests tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
// Unload modals will re-inject the DOM with the initial placeholders to allow for .on() in regular use cases
|
||||
|
@ -1601,7 +1600,7 @@ class DomainRequestsTable extends LoadTableBase {
|
|||
delheader.setAttribute('class', 'delete-header');
|
||||
delheader.innerHTML = `
|
||||
<span class="usa-sr-only">Delete Action</span>`;
|
||||
let tableHeaderRow = document.querySelector('.domain-requests__table thead tr');
|
||||
let tableHeaderRow = document.querySelector('#domain-requests thead tr');
|
||||
tableHeaderRow.appendChild(delheader);
|
||||
}
|
||||
}
|
||||
|
@ -1874,8 +1873,199 @@ 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');
|
||||
super('members');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, action_url) {
|
||||
// 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="${action_url}/domains">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.
|
||||
|
@ -1902,7 +2092,7 @@ class MembersTable extends LoadTableBase {
|
|||
|
||||
|
||||
// --------- FETCH DATA
|
||||
// fetch json of page of domais, given params
|
||||
// fetch json of page of domains, given params
|
||||
let baseUrl = document.getElementById("get_members_json_url");
|
||||
if (!baseUrl) {
|
||||
return;
|
||||
|
@ -1926,42 +2116,23 @@ class MembersTable extends LoadTableBase {
|
|||
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');
|
||||
const memberList = document.querySelector('#members 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;
|
||||
|
@ -1973,14 +2144,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, action_url);
|
||||
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>
|
||||
|
@ -1990,8 +2189,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');
|
||||
|
@ -2017,14 +2221,121 @@ class MembersTable extends LoadTableBase {
|
|||
}
|
||||
}
|
||||
|
||||
class MemberDomainsTable extends LoadTableBase {
|
||||
|
||||
constructor() {
|
||||
super('member-domains');
|
||||
this.currentSortBy = 'name';
|
||||
}
|
||||
/**
|
||||
* 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 {*} searchTerm - the search term
|
||||
* @param {*} portfolio - the portfolio id
|
||||
*/
|
||||
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {
|
||||
|
||||
// --------- SEARCH
|
||||
let searchParams = new URLSearchParams(
|
||||
{
|
||||
"page": page,
|
||||
"sort_by": sortBy,
|
||||
"order": order,
|
||||
"search_term": searchTerm,
|
||||
}
|
||||
);
|
||||
|
||||
let emailValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-email') : null;
|
||||
let memberIdValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-id') : null;
|
||||
let memberOnly = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-only') : null;
|
||||
|
||||
if (portfolio)
|
||||
searchParams.append("portfolio", portfolio)
|
||||
if (emailValue)
|
||||
searchParams.append("email", emailValue)
|
||||
if (memberIdValue)
|
||||
searchParams.append("member_id", memberIdValue)
|
||||
if (memberOnly)
|
||||
searchParams.append("member_only", memberOnly)
|
||||
|
||||
|
||||
// --------- FETCH DATA
|
||||
// fetch json of page of domais, given params
|
||||
let baseUrl = document.getElementById("get_member_domains_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 memberDomainsList = document.querySelector('#member-domains tbody');
|
||||
memberDomainsList.innerHTML = '';
|
||||
|
||||
|
||||
data.domains.forEach(domain => {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
row.innerHTML = `
|
||||
<td scope="row" data-label="Domain name">
|
||||
${domain.name}
|
||||
</td>
|
||||
`;
|
||||
memberDomainsList.appendChild(row);
|
||||
});
|
||||
|
||||
// Do not scroll on first page load
|
||||
if (scroll)
|
||||
ScrollToElement('class', 'member-domains');
|
||||
this.scrollToTable = true;
|
||||
|
||||
// update pagination
|
||||
this.updatePagination(
|
||||
'member domain',
|
||||
'#member-domains-pagination',
|
||||
'#member-domains-pagination .usa-pagination__counter',
|
||||
'#member-domains',
|
||||
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 domains:', error));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* initializes the domains list and associated functionality.
|
||||
*
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const isDomainsPage = document.querySelector("#domains")
|
||||
const isDomainsPage = document.getElementById("domains")
|
||||
if (isDomainsPage){
|
||||
const domainsTable = new DomainsTable();
|
||||
if (domainsTable.tableWrapper) {
|
||||
|
@ -2036,11 +2347,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
|
||||
/**
|
||||
* An IIFE that listens for DOM Content to be loaded, then executes. This function
|
||||
* initializes the domain requests list and associated functionality on the home page of the app.
|
||||
* initializes the domain requests list and associated functionality.
|
||||
*
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const domainRequestsSectionWrapper = document.querySelector('.domain-requests');
|
||||
const domainRequestsSectionWrapper = document.getElementById('domain-requests');
|
||||
if (domainRequestsSectionWrapper) {
|
||||
const domainRequestsTable = new DomainRequestsTable();
|
||||
if (domainRequestsTable.tableWrapper) {
|
||||
|
@ -2093,11 +2404,11 @@ 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.
|
||||
* initializes the members list and associated functionality.
|
||||
*
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const isMembersPage = document.querySelector("#members")
|
||||
const isMembersPage = document.getElementById("members")
|
||||
if (isMembersPage){
|
||||
const membersTable = new MembersTable();
|
||||
if (membersTable.tableWrapper) {
|
||||
|
@ -2107,6 +2418,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* An IIFE that listens for DOM Content to be loaded, then executes. This function
|
||||
* initializes the member domains list and associated functionality.
|
||||
*
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const isMemberDomainsPage = document.getElementById("member-domains")
|
||||
if (isMemberDomainsPage){
|
||||
const memberDomainsTable = new MemberDomainsTable();
|
||||
if (memberDomainsTable.tableWrapper) {
|
||||
// Initial load
|
||||
memberDomainsTable.loadTable(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* An IIFE that displays confirmation modal on the user profile page
|
||||
*/
|
||||
|
|
|
@ -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"));
|
||||
tr.children[thisHeaderIndex].setAttribute("data-sort-active", true);
|
||||
|
||||
// ---- 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;
|
||||
}
|
||||
}
|
||||
|
|
11
src/registrar/assets/sass/_theme/_search.scss
Normal file
11
src/registrar/assets/sass/_theme/_search.scss
Normal file
|
@ -0,0 +1,11 @@
|
|||
@use "base" as *;
|
||||
|
||||
.usa-search--show-label {
|
||||
flex-wrap: wrap;
|
||||
label {
|
||||
width: 100%;
|
||||
}
|
||||
.usa-search--show-label__input-wrapper {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
|
@ -56,8 +56,10 @@ th {
|
|||
border: none;
|
||||
}
|
||||
|
||||
td, th {
|
||||
border-bottom: 1px solid color('base-lighter');
|
||||
tr:not(.hide-td-borders) {
|
||||
td, th {
|
||||
border-bottom: 1px solid color('base-lighter');
|
||||
}
|
||||
}
|
||||
|
||||
thead th {
|
||||
|
|
|
@ -28,3 +28,8 @@ h2 {
|
|||
.usa-form fieldset {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.p--blockquote {
|
||||
padding-left: units(1);
|
||||
border-left: 2px solid color('base-lighter');
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
@forward "buttons";
|
||||
@forward "pagination";
|
||||
@forward "forms";
|
||||
@forward "search";
|
||||
@forward "tooltips";
|
||||
@forward "fieldsets";
|
||||
@forward "alerts";
|
||||
|
|
|
@ -26,7 +26,6 @@ from registrar.views.report_views import (
|
|||
# --jsons
|
||||
from registrar.views.domain_requests_json import get_domain_requests_json
|
||||
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,
|
||||
|
@ -96,6 +95,11 @@ urlpatterns = [
|
|||
views.PortfolioMemberEditView.as_view(),
|
||||
name="member-permissions",
|
||||
),
|
||||
path(
|
||||
"member/<int:pk>/domains",
|
||||
views.PortfolioMemberDomainsView.as_view(),
|
||||
name="member-domains",
|
||||
),
|
||||
path(
|
||||
"invitedmember/<int:pk>",
|
||||
views.PortfolioInvitedMemberView.as_view(),
|
||||
|
@ -106,6 +110,11 @@ urlpatterns = [
|
|||
views.PortfolioInvitedMemberEditView.as_view(),
|
||||
name="invitedmember-permissions",
|
||||
),
|
||||
path(
|
||||
"invitedmember/<int:pk>/domains",
|
||||
views.PortfolioInvitedMemberDomainsView.as_view(),
|
||||
name="invitedmember-domains",
|
||||
),
|
||||
# path(
|
||||
# "no-organization-members/",
|
||||
# views.PortfolioNoMembersView.as_view(),
|
||||
|
@ -328,7 +337,8 @@ 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"),
|
||||
path("get-portfolio-members-json/", views.PortfolioMembersJson.as_view(), name="get_portfolio_members_json"),
|
||||
path("get-member-domains-json/", views.PortfolioMemberDomainsJson.as_view(), name="get_member_domains_json"),
|
||||
]
|
||||
|
||||
# Djangooidc strips out context data from that context, so we define a custom error
|
||||
|
|
|
@ -89,12 +89,18 @@ class DomainFixture(DomainRequestFixture):
|
|||
|
||||
# Approve the current domain request
|
||||
if domain_request:
|
||||
cls._approve_request(domain_request, users)
|
||||
try:
|
||||
cls._approve_request(domain_request, users)
|
||||
except Exception as err:
|
||||
logger.warning(f"Cannot approve domain request in fixtures: {err}")
|
||||
domain_requests_to_update.append(domain_request)
|
||||
|
||||
# Approve the expired domain request
|
||||
if domain_request_expired:
|
||||
cls._approve_request(domain_request_expired, users)
|
||||
try:
|
||||
cls._approve_request(domain_request_expired, users)
|
||||
except Exception as err:
|
||||
logger.warning(f"Cannot approve domain request (expired) in fixtures: {err}")
|
||||
domain_requests_to_update.append(domain_request_expired)
|
||||
expired_requests.append(domain_request_expired)
|
||||
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -17,22 +17,24 @@
|
|||
<section aria-label="Domain requests search component" class="margin-top-2">
|
||||
<form class="usa-search usa-search--small" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<button class="usa-button usa-button--unstyled margin-right-3 domain-requests__reset-search display-none" type="button">
|
||||
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="domain-requests__reset-search" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
Reset
|
||||
</button>
|
||||
{% if portfolio %}
|
||||
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name or creator</label>
|
||||
{% else %}
|
||||
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label>
|
||||
{% endif %}
|
||||
<label class="usa-sr-only" for="domain-requests__search-field">
|
||||
{% if portfolio %}
|
||||
Search by domain name or creator
|
||||
{% else %}
|
||||
Search by domain name
|
||||
{% endif %}
|
||||
</label>
|
||||
<input
|
||||
class="usa-input"
|
||||
id="domain-requests__search-field"
|
||||
type="search"
|
||||
name="search"
|
||||
name="domain-requests-search"
|
||||
{% if portfolio %}
|
||||
placeholder="Search by domain name or creator"
|
||||
{% else %}
|
||||
|
@ -70,10 +72,11 @@
|
|||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--small padding--8-8-9 usa-button--outline usa-button--filter usa-accordion__button"
|
||||
id="domain-requests__usa-button--filter"
|
||||
aria-expanded="false"
|
||||
aria-controls="filter-status"
|
||||
>
|
||||
<span class="filter-indicator text-bold display-none"></span> Status
|
||||
<span class="text-bold display-none" id="domain-requests__filter-indicator"></span> Status
|
||||
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
|
||||
</svg>
|
||||
|
@ -158,7 +161,8 @@
|
|||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--small padding--8-12-9-12 usa-button--outline usa-button--filter domain-requests__reset-filters display-none"
|
||||
class="usa-button usa-button--small padding--8-12-9-12 usa-button--outline usa-button--filter display-none"
|
||||
id="domain-requests__reset-filters"
|
||||
>
|
||||
Clear filters
|
||||
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
|
@ -168,8 +172,8 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="domain-requests__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table">
|
||||
<div class="display-none usa-table-container--scrollable margin-top-0" tabindex="0" id="domain-requests__table-wrapper">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
|
||||
<caption class="sr-only">Your domain requests</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -187,14 +191,14 @@
|
|||
<!-- AJAX will populate this tbody -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="usa-sr-only usa-table__announcement-region" aria-live="polite"></div>
|
||||
<div class="usa-sr-only usa-table__announcement-region" aria-live="polite" id="domain-requests__usa-table__announcement-region"></div>
|
||||
</div>
|
||||
|
||||
<div class="domain-requests__no-data display-none">
|
||||
<div class="display-none" id="domain-requests__no-data">
|
||||
<p>You haven't requested any domains.</p>
|
||||
</div>
|
||||
|
||||
<div class="domain-requests__no-search-results display-none">
|
||||
<div class="display-none" id="domain-requests__no-search-results">
|
||||
<p>No results found</p>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
@ -7,195 +7,197 @@
|
|||
<span id="get_domains_json_url" class="display-none">{{url}}</span>
|
||||
<section class="section-outlined domains margin-top-0{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domains">
|
||||
<div class="section-outlined__header margin-bottom-3 {% if not portfolio %} section-outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
|
||||
{% if not portfolio %}
|
||||
<h2 id="domains-header" class="display-inline-block">Domains</h2>
|
||||
{% else %}
|
||||
<!-- Embedding the portfolio value in a data attribute -->
|
||||
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
|
||||
{% endif %}
|
||||
<div class="section-outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %} {% if is_widescreen_mode %} section-outlined__search--widescreen {% endif %}">
|
||||
<section aria-label="Domains search component" class="margin-top-2">
|
||||
<form class="usa-search usa-search--small" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<button class="usa-button usa-button--unstyled margin-right-3 domains__reset-search display-none" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
Reset
|
||||
</button>
|
||||
<label class="usa-sr-only" for="domains__search-field">Search by domain name</label>
|
||||
<input
|
||||
class="usa-input"
|
||||
id="domains__search-field"
|
||||
type="search"
|
||||
name="search"
|
||||
placeholder="Search by domain name"
|
||||
/>
|
||||
<button class="usa-button" type="submit" id="domains__search-field-submit">
|
||||
<img
|
||||
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||
class="usa-search__submit-icon"
|
||||
alt="Search"
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% if user_domain_count and user_domain_count > 0 %}
|
||||
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
|
||||
<section aria-label="Domains report component" class="margin-top-205">
|
||||
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" role="button">
|
||||
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg>Export as CSV
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if portfolio %}
|
||||
<div class="display-flex flex-align-center">
|
||||
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
|
||||
<div class="usa-accordion usa-accordion--select margin-right-2">
|
||||
<div class="usa-accordion__heading">
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--small padding--8-8-9 usa-button--outline usa-button--filter usa-accordion__button"
|
||||
aria-expanded="false"
|
||||
aria-controls="filter-status"
|
||||
>
|
||||
<span class="filter-indicator text-bold display-none"></span> Status
|
||||
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
|
||||
{% if not portfolio %}
|
||||
<h2 id="domains-header" class="display-inline-block">Domains</h2>
|
||||
{% else %}
|
||||
<!-- Embedding the portfolio value in a data attribute -->
|
||||
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
|
||||
{% endif %}
|
||||
<div class="section-outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %} {% if is_widescreen_mode %} section-outlined__search--widescreen {% endif %}">
|
||||
<section aria-label="Domains search component" class="margin-top-2">
|
||||
<form class="usa-search usa-search--small" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="domains__reset-search" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<div id="filter-status" class="usa-accordion__content usa-prose shadow-1">
|
||||
<h2>Status</h2>
|
||||
<fieldset class="usa-fieldset margin-top-0">
|
||||
<legend class="usa-legend">Select to apply <span class="sr-only">status</span> filter</legend>
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
class="usa-checkbox__input"
|
||||
id="filter-status-dns-needed"
|
||||
type="checkbox"
|
||||
name="filter-status"
|
||||
value="unknown"
|
||||
/>
|
||||
<label class="usa-checkbox__label" for="filter-status-dns-needed"
|
||||
>DNS Needed</label
|
||||
>
|
||||
</div>
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
class="usa-checkbox__input"
|
||||
id="filter-status-ready"
|
||||
type="checkbox"
|
||||
name="filter-status"
|
||||
value="ready"
|
||||
/>
|
||||
<label class="usa-checkbox__label" for="filter-status-ready"
|
||||
>Ready</label
|
||||
>
|
||||
</div>
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
class="usa-checkbox__input"
|
||||
id="filter-status-on-hold"
|
||||
type="checkbox"
|
||||
name="filter-status"
|
||||
value="on hold"
|
||||
/>
|
||||
<label class="usa-checkbox__label" for="filter-status-on-hold"
|
||||
>On hold</label
|
||||
>
|
||||
</div>
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
class="usa-checkbox__input"
|
||||
id="filter-status-expired"
|
||||
type="checkbox"
|
||||
name="filter-status"
|
||||
value="expired"
|
||||
/>
|
||||
<label class="usa-checkbox__label" for="filter-status-expired"
|
||||
>Expired</label
|
||||
>
|
||||
</div>
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
class="usa-checkbox__input"
|
||||
id="filter-status-deleted"
|
||||
type="checkbox"
|
||||
name="filter-status"
|
||||
value="deleted"
|
||||
/>
|
||||
<label class="usa-checkbox__label" for="filter-status-deleted"
|
||||
>Deleted</label
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--small padding--8-12-9-12 usa-button--outline usa-button--filter domains__reset-filters display-none"
|
||||
>
|
||||
Clear filters
|
||||
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#close"></use>
|
||||
</svg>
|
||||
</button>
|
||||
<label class="usa-sr-only" for="domains__search-field">Search by domain name</label>
|
||||
<input
|
||||
class="usa-input"
|
||||
id="domains__search-field"
|
||||
type="search"
|
||||
name="domains-search"
|
||||
placeholder="Search by domain name"
|
||||
/>
|
||||
<button class="usa-button" type="submit" id="domains__search-field-submit">
|
||||
<img
|
||||
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||
class="usa-search__submit-icon"
|
||||
alt="Search"
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% if user_domain_count and user_domain_count > 0 %}
|
||||
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
|
||||
<section aria-label="Domains report component" class="margin-top-205">
|
||||
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" role="button">
|
||||
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg>Export as CSV
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="domains__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domains__table">
|
||||
<caption class="sr-only">Your registered domains</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sortable="name" scope="col" role="columnheader">Domain name</th>
|
||||
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
|
||||
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
|
||||
{% if portfolio and has_view_suborganization_portfolio_permission %}
|
||||
<th data-sortable="domain_info__sub_organization" scope="col" role="columnheader">Suborganization</th>
|
||||
{% endif %}
|
||||
<th
|
||||
scope="col"
|
||||
role="columnheader"
|
||||
>
|
||||
<span class="usa-sr-only">Action</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- AJAX will populate this tbody -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="usa-sr-only usa-table__announcement-region"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</div>
|
||||
<div class="domains__no-data display-none">
|
||||
<p>You don't have any registered domains.</p>
|
||||
<p class="maxw-none clearfix">
|
||||
<a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet usa-link usa-link--icon" target="_blank">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#help_outline"></use>
|
||||
</div>
|
||||
{% if portfolio %}
|
||||
<div class="display-flex flex-align-center">
|
||||
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
|
||||
<div class="usa-accordion usa-accordion--select margin-right-2">
|
||||
<div class="usa-accordion__heading">
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--small padding--8-8-9 usa-button--outline usa-button--filter usa-accordion__button"
|
||||
id="domains__usa-button--filter"
|
||||
aria-expanded="false"
|
||||
aria-controls="filter-status"
|
||||
>
|
||||
<span class="text-bold display-none" id="domains__filter-indicator"></span> Status
|
||||
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
|
||||
</svg>
|
||||
Why don't I see my domain when I sign in to the registrar?
|
||||
</a>
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
<div id="filter-status" class="usa-accordion__content usa-prose shadow-1">
|
||||
<h2>Status</h2>
|
||||
<fieldset class="usa-fieldset margin-top-0">
|
||||
<legend class="usa-legend">Select to apply <span class="sr-only">status</span> filter</legend>
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
class="usa-checkbox__input"
|
||||
id="filter-status-dns-needed"
|
||||
type="checkbox"
|
||||
name="filter-status"
|
||||
value="unknown"
|
||||
/>
|
||||
<label class="usa-checkbox__label" for="filter-status-dns-needed"
|
||||
>DNS Needed</label
|
||||
>
|
||||
</div>
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
class="usa-checkbox__input"
|
||||
id="filter-status-ready"
|
||||
type="checkbox"
|
||||
name="filter-status"
|
||||
value="ready"
|
||||
/>
|
||||
<label class="usa-checkbox__label" for="filter-status-ready"
|
||||
>Ready</label
|
||||
>
|
||||
</div>
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
class="usa-checkbox__input"
|
||||
id="filter-status-on-hold"
|
||||
type="checkbox"
|
||||
name="filter-status"
|
||||
value="on hold"
|
||||
/>
|
||||
<label class="usa-checkbox__label" for="filter-status-on-hold"
|
||||
>On hold</label
|
||||
>
|
||||
</div>
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
class="usa-checkbox__input"
|
||||
id="filter-status-expired"
|
||||
type="checkbox"
|
||||
name="filter-status"
|
||||
value="expired"
|
||||
/>
|
||||
<label class="usa-checkbox__label" for="filter-status-expired"
|
||||
>Expired</label
|
||||
>
|
||||
</div>
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
class="usa-checkbox__input"
|
||||
id="filter-status-deleted"
|
||||
type="checkbox"
|
||||
name="filter-status"
|
||||
value="deleted"
|
||||
/>
|
||||
<label class="usa-checkbox__label" for="filter-status-deleted"
|
||||
>Deleted</label
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
<div class="domains__no-search-results display-none">
|
||||
<p>No results found</p>
|
||||
</div>
|
||||
</section>
|
||||
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domains-pagination">
|
||||
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
|
||||
<!-- Count will be dynamically populated by JS -->
|
||||
</span>
|
||||
<ul class="usa-pagination__list">
|
||||
<!-- Pagination links will be dynamically populated by JS -->
|
||||
</ul>
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--small padding--8-12-9-12 usa-button--outline usa-button--filter display-none"
|
||||
id="domains__reset-filters"
|
||||
>
|
||||
Clear filters
|
||||
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#close"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="display-none usa-table-container--scrollable margin-top-0" tabindex="0" id="domains__table-wrapper">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
|
||||
<caption class="sr-only">Your registered domains</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sortable="name" scope="col" role="columnheader">Domain name</th>
|
||||
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
|
||||
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
|
||||
{% if portfolio and has_view_suborganization_portfolio_permission %}
|
||||
<th data-sortable="domain_info__sub_organization" scope="col" role="columnheader">Suborganization</th>
|
||||
{% endif %}
|
||||
<th
|
||||
scope="col"
|
||||
role="columnheader"
|
||||
>
|
||||
<span class="usa-sr-only">Action</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- AJAX will populate this tbody -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="usa-sr-only usa-table__announcement-region" id="domains__usa-table__announcement-region"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</div>
|
||||
<div class="display-none" id="domains__no-data">
|
||||
<p>You don't have any registered domains.</p>
|
||||
<p class="maxw-none clearfix">
|
||||
<a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet usa-link usa-link--icon" target="_blank">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#help_outline"></use>
|
||||
</svg>
|
||||
Why don't I see my domain when I sign in to the registrar?
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="display-none" id="domains__no-search-results">
|
||||
<p>No results found</p>
|
||||
</div>
|
||||
</section>
|
||||
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domains-pagination">
|
||||
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
|
||||
<!-- Count will be dynamically populated by JS -->
|
||||
</span>
|
||||
<ul class="usa-pagination__list">
|
||||
<!-- Pagination links will be dynamically populated by JS -->
|
||||
</ul>
|
||||
</nav>
|
||||
|
|
115
src/registrar/templates/includes/member_domains_table.html
Normal file
115
src/registrar/templates/includes/member_domains_table.html
Normal file
|
@ -0,0 +1,115 @@
|
|||
{% load static %}
|
||||
|
||||
{% if member %}
|
||||
<span
|
||||
id="portfolio-js-value"
|
||||
class="display-none"
|
||||
data-portfolio="{{ portfolio.id }}"
|
||||
data-email=""
|
||||
data-member-id="{{ member.id }}"
|
||||
data-member-only="true"
|
||||
></span>
|
||||
{% else %}
|
||||
<span
|
||||
id="portfolio-js-value"
|
||||
class="display-none"
|
||||
data-portfolio="{{ portfolio.id }}"
|
||||
data-email="{{ portfolio_invitation.email }}"
|
||||
data-member-id=""
|
||||
data-member-only="true"
|
||||
></span>
|
||||
{% endif %}
|
||||
|
||||
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||
{% url 'get_member_domains_json' as url %}
|
||||
<span id="get_member_domains_json_url" class="display-none">{{url}}</span>
|
||||
<section class="section-outlined member-domains margin-top-0 section-outlined--border-base-light" id="member-domains">
|
||||
|
||||
<h2>
|
||||
Domains assigned to
|
||||
{% if member %}
|
||||
{{ member.email }}
|
||||
{% else %}
|
||||
{{ portfolio_invitation.email }}
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
<div class="section-outlined__header margin-bottom-3 grid-row">
|
||||
<!-- ---------- SEARCH ---------- -->
|
||||
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-6">
|
||||
<section aria-label="Members search component" class="margin-top-2">
|
||||
<form class="usa-search usa-search--show-label" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<label class="usa-label display-block margin-bottom-05" for="member-domains__search-field">
|
||||
{% if has_edit_members_portfolio_permission %}
|
||||
Search all domains
|
||||
{% else %}
|
||||
Search domains assigned to
|
||||
{% if member %}
|
||||
{{ member.email }}
|
||||
{% else %}
|
||||
{{ portfolio_invitation.email }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</label>
|
||||
<div class="usa-search--show-label__input-wrapper">
|
||||
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="member-domains__reset-search" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
Reset
|
||||
</button>
|
||||
<input
|
||||
class="usa-input"
|
||||
id="member-domains__search-field"
|
||||
type="search"
|
||||
name="member-domains-search"
|
||||
/>
|
||||
<button class="usa-button" type="submit" id="member-domains__search-field-submit">
|
||||
<span class="usa-search__submit-text">Search </span>
|
||||
<img
|
||||
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||
class="usa-search__submit-icon"
|
||||
alt="Search"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ---------- MAIN TABLE ---------- -->
|
||||
<div class="display-none margin-top-0" id="member-domains__table-wrapper">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
|
||||
<caption class="sr-only">member domains</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<!-- We override default sort to be name/ascending in the JSON endpoint. We add the correct aria-sort attribute here to reflect that in the UI -->
|
||||
<th data-sortable="name" scope="col" role="columnheader" aria-sort="descending">Domains</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- AJAX will populate this tbody -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="usa-sr-only usa-table__announcement-region" id="member-domains__usa-table__announcement-region"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</div>
|
||||
<div class="display-none" id="member-domains__no-data">
|
||||
<p>This member does not manage any domains. Click the Edit domain assignments buttons to assign domains.</p>
|
||||
</div>
|
||||
<div class="display-none" id="member-domains__no-search-results">
|
||||
<p>No results found</p>
|
||||
</div>
|
||||
</section>
|
||||
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="member-domains-pagination">
|
||||
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
|
||||
<!-- Count will be dynamically populated by JS -->
|
||||
</span>
|
||||
<ul class="usa-pagination__list">
|
||||
<!-- Pagination links will be dynamically populated by JS -->
|
||||
</ul>
|
||||
</nav>
|
|
@ -12,7 +12,7 @@
|
|||
<section aria-label="Members search component" class="margin-top-2">
|
||||
<form class="usa-search usa-search--small" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<button class="usa-button usa-button--unstyled margin-right-3 members__reset-search display-none" type="button">
|
||||
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="members__reset-search" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
|
@ -23,7 +23,7 @@
|
|||
class="usa-input"
|
||||
id="members__search-field"
|
||||
type="search"
|
||||
name="search"
|
||||
name="members-search"
|
||||
placeholder="Search by member name"
|
||||
/>
|
||||
<button class="usa-button" type="submit" id="members__search-field-submit">
|
||||
|
@ -39,16 +39,16 @@
|
|||
</div>
|
||||
|
||||
<!-- ---------- MAIN TABLE ---------- -->
|
||||
<div class="members__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked members__table">
|
||||
<div class="display-none margin-top-0" id="members__table-wrapper">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
|
||||
<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"
|
||||
role="columnheader"
|
||||
id="header-action"
|
||||
>
|
||||
<span class="usa-sr-only">Action</span>
|
||||
</th>
|
||||
|
@ -59,14 +59,14 @@
|
|||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="usa-sr-only usa-table__announcement-region"
|
||||
class="usa-sr-only usa-table__announcement-region" id="members__usa-table__announcement-region"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</div>
|
||||
<div class="members__no-data display-none">
|
||||
<div class="display-none" id="members__no-data">
|
||||
<p>You don't have any members.</p>
|
||||
</div>
|
||||
<div class="members__no-search-results display-none">
|
||||
<div class="display-none" id="members__no-search-results">
|
||||
<p>No results found</p>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
@ -126,11 +126,9 @@
|
|||
|
||||
{% comment %}view_button is passed below as true in all cases. This is because manage_button logic will trump view_button logic; ie. if manage_button is true, view_button will never be looked at{% endcomment %}
|
||||
{% if portfolio_permission %}
|
||||
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=portfolio_permission.get_managed_domains_count edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
|
||||
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=portfolio_permission.get_managed_domains_count edit_link=domains_url editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
|
||||
{% elif portfolio_invitation %}
|
||||
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=portfolio_invitation.get_managed_domains_count edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
|
||||
{% else %}
|
||||
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=0 edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
|
||||
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=portfolio_invitation.get_managed_domains_count edit_link=domains_url editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
|
54
src/registrar/templates/portfolio_member_domains.html
Normal file
54
src/registrar/templates/portfolio_member_domains.html
Normal file
|
@ -0,0 +1,54 @@
|
|||
{% extends 'portfolio_base.html' %}
|
||||
{% load static field_helpers%}
|
||||
|
||||
{% block title %}Organization member domains {% endblock %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
<div id="main-content">
|
||||
|
||||
{% url 'members' as url %}
|
||||
{% if portfolio_permission %}
|
||||
{% url 'member' pk=portfolio_permission.id as url2 %}
|
||||
{% else %}
|
||||
{% url 'invitedmember' pk=portfolio_invitation.id as url2 %}
|
||||
{% endif %}
|
||||
<nav class="usa-breadcrumb padding-top-0 margin-bottom-3" aria-label="Portfolio member breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Members</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{{ url2 }}" class="usa-breadcrumb__link"><span>Manage member</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||
<span>Manage member</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="grid-row grid-gap">
|
||||
<div class="mobile:grid-col-12 tablet:grid-col-7">
|
||||
<h1 class="margin-bottom-3">Domain assignments</h1>
|
||||
</div>
|
||||
{% if has_edit_members_portfolio_permission %}
|
||||
<div class="mobile:grid-col-12 tablet:grid-col-5">
|
||||
<p class="float-right-tablet tablet:margin-y-0">
|
||||
<a href="#" class="usa-button"
|
||||
>
|
||||
Edit domain assignments
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p>
|
||||
A domain manager can be assigned to any domain across the organization. Domain managers can change domain information, adjust DNS settings, and invite or assign other domain managers to their assigned domains.
|
||||
</p>
|
||||
|
||||
{% include "includes/member_domains_table.html" %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -89,7 +89,6 @@ class CsvReportsTest(MockDbForSharedTests):
|
|||
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
|
||||
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
|
||||
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
|
||||
call("adomain2.gov,Interstate,,,,,(blank)\r\n"),
|
||||
call("zdomain12.gov,Interstate,,,,,(blank)\r\n"),
|
||||
]
|
||||
# We don't actually want to write anything for a test case,
|
||||
|
@ -470,8 +469,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
|
|||
# Invoke setter
|
||||
self.domain_1.security_contact
|
||||
# Invoke setter
|
||||
self.domain_2.security_contact
|
||||
# Invoke setter
|
||||
self.domain_3.security_contact
|
||||
# Add a first ready date on the first domain. Leaving the others blank.
|
||||
self.domain_1.first_ready = get_default_start_date()
|
||||
|
@ -492,7 +489,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
|
|||
"defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
|
||||
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n"
|
||||
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n"
|
||||
"adomain2.gov,Interstate,,,,,(blank)\n"
|
||||
"zdomain12.gov,Interstate,,,,,(blank)\n"
|
||||
)
|
||||
# Normalize line endings and remove commas,
|
||||
|
|
414
src/registrar/tests/test_views_member_domains_json.py
Normal file
414
src/registrar/tests/test_views_member_domains_json.py
Normal file
|
@ -0,0 +1,414 @@
|
|||
from django.urls import reverse
|
||||
|
||||
from api.tests.common import less_console_noise_decorator
|
||||
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 waffle.testutils import override_flag
|
||||
from .test_views import TestWithUser
|
||||
from django_webtest import WebTest # type: ignore
|
||||
|
||||
|
||||
class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
# Create test member
|
||||
cls.user_member = User.objects.create(
|
||||
username="test_member",
|
||||
first_name="Second",
|
||||
last_name="User",
|
||||
email="second@example.com",
|
||||
phone="8003112345",
|
||||
title="Member",
|
||||
)
|
||||
|
||||
# Create test user with no perms
|
||||
cls.user_no_perms = User.objects.create(
|
||||
username="test_user_no_perms",
|
||||
first_name="No",
|
||||
last_name="Permissions",
|
||||
email="user_no_perms@example.com",
|
||||
phone="8003112345",
|
||||
title="No Permissions",
|
||||
)
|
||||
|
||||
# Create Portfolio
|
||||
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
|
||||
|
||||
# Assign permissions to the user making requests
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=cls.user,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Assign some domains
|
||||
cls.domain1 = Domain.objects.create(name="example1.com", expiration_date="2024-03-01", state="ready")
|
||||
cls.domain2 = Domain.objects.create(name="example2.com", expiration_date="2024-03-01", state="ready")
|
||||
cls.domain3 = Domain.objects.create(name="example3.com", expiration_date="2024-03-01", state="ready")
|
||||
# Add domain1 and domain2 to portfolio
|
||||
DomainInformation.objects.create(creator=cls.user, domain=cls.domain1, portfolio=cls.portfolio)
|
||||
DomainInformation.objects.create(creator=cls.user, domain=cls.domain2, portfolio=cls.portfolio)
|
||||
DomainInformation.objects.create(creator=cls.user, domain=cls.domain3, portfolio=cls.portfolio)
|
||||
|
||||
# Assign user_member to view all domains
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=cls.user_member,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
# Add user_member as manager of domains
|
||||
UserDomainRole.objects.create(user=cls.user_member, domain=cls.domain1)
|
||||
UserDomainRole.objects.create(user=cls.user_member, domain=cls.domain2)
|
||||
|
||||
# Add an invited member who has been invited to manage domains
|
||||
cls.invited_member_email = "invited@example.com"
|
||||
PortfolioInvitation.objects.create(
|
||||
email=cls.invited_member_email,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
],
|
||||
)
|
||||
DomainInvitation.objects.create(
|
||||
email=cls.invited_member_email, domain=cls.domain1, status=DomainInvitation.DomainInvitationStatus.INVITED
|
||||
)
|
||||
DomainInvitation.objects.create(
|
||||
email=cls.invited_member_email, domain=cls.domain2, status=DomainInvitation.DomainInvitationStatus.INVITED
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
PortfolioInvitation.objects.all().delete()
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
UserDomainRole.objects.all().delete()
|
||||
DomainInvitation.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
Domain.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)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_get_portfolio_member_domains_json_authenticated(self):
|
||||
"""Test that portfolio member's domains are returned properly for an authenticated user."""
|
||||
response = self.app.get(
|
||||
reverse("get_member_domains_json"),
|
||||
params={"portfolio": self.portfolio.id, "member_id": self.user_member.id, "member_only": "true"},
|
||||
)
|
||||
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"], 2)
|
||||
self.assertEqual(data["unfiltered_total"], 2)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 2)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_get_portfolio_invitedmember_domains_json_authenticated(self):
|
||||
"""Test that portfolio invitedmember's domains are returned properly for an authenticated user."""
|
||||
response = self.app.get(
|
||||
reverse("get_member_domains_json"),
|
||||
params={"portfolio": self.portfolio.id, "email": self.invited_member_email, "member_only": "true"},
|
||||
)
|
||||
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"], 2)
|
||||
self.assertEqual(data["unfiltered_total"], 2)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 2)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_get_portfolio_member_domains_json_authenticated_include_all_domains(self):
|
||||
"""Test that all portfolio domains are returned properly for an authenticated user."""
|
||||
response = self.app.get(
|
||||
reverse("get_member_domains_json"),
|
||||
params={"portfolio": self.portfolio.id, "member_id": self.user_member.id, "member_only": "false"},
|
||||
)
|
||||
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"], 3)
|
||||
self.assertEqual(data["unfiltered_total"], 3)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 3)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_get_portfolio_invitedmember_domains_json_authenticated_include_all_domains(self):
|
||||
"""Test that all portfolio domains are returned properly for an authenticated user."""
|
||||
response = self.app.get(
|
||||
reverse("get_member_domains_json"),
|
||||
params={"portfolio": self.portfolio.id, "email": self.invited_member_email, "member_only": "false"},
|
||||
)
|
||||
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"], 3)
|
||||
self.assertEqual(data["unfiltered_total"], 3)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 3)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_get_portfolio_member_domains_json_authenticated_search(self):
|
||||
"""Test that search_term yields correct domain."""
|
||||
response = self.app.get(
|
||||
reverse("get_member_domains_json"),
|
||||
params={
|
||||
"portfolio": self.portfolio.id,
|
||||
"member_id": self.user_member.id,
|
||||
"member_only": "false",
|
||||
"search_term": "example1",
|
||||
},
|
||||
)
|
||||
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"], 1)
|
||||
self.assertEqual(data["unfiltered_total"], 3)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 1)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_get_portfolio_invitedmember_domains_json_authenticated_search(self):
|
||||
"""Test that search_term yields correct domain."""
|
||||
response = self.app.get(
|
||||
reverse("get_member_domains_json"),
|
||||
params={
|
||||
"portfolio": self.portfolio.id,
|
||||
"email": self.invited_member_email,
|
||||
"member_only": "false",
|
||||
"search_term": "example1",
|
||||
},
|
||||
)
|
||||
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"], 1)
|
||||
self.assertEqual(data["unfiltered_total"], 3)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 1)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_get_portfolio_member_domains_json_authenticated_sort(self):
|
||||
"""Test that sort returns results in correct order."""
|
||||
# Test by name in ascending order
|
||||
response = self.app.get(
|
||||
reverse("get_member_domains_json"),
|
||||
params={
|
||||
"portfolio": self.portfolio.id,
|
||||
"member_id": self.user_member.id,
|
||||
"member_only": "false",
|
||||
"sort_by": "name",
|
||||
"order": "asc",
|
||||
},
|
||||
)
|
||||
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"], 3)
|
||||
self.assertEqual(data["unfiltered_total"], 3)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 3)
|
||||
|
||||
# Check the name of the first domain is example1.com
|
||||
self.assertEqual(data["domains"][0]["name"], "example1.com")
|
||||
|
||||
# Test by name in descending order
|
||||
response = self.app.get(
|
||||
reverse("get_member_domains_json"),
|
||||
params={
|
||||
"portfolio": self.portfolio.id,
|
||||
"member_id": self.user_member.id,
|
||||
"member_only": "false",
|
||||
"sort_by": "name",
|
||||
"order": "desc",
|
||||
},
|
||||
)
|
||||
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"], 3)
|
||||
self.assertEqual(data["unfiltered_total"], 3)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 3)
|
||||
|
||||
# Check the name of the first domain is example1.com
|
||||
self.assertEqual(data["domains"][0]["name"], "example3.com")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_get_portfolio_invitedmember_domains_json_authenticated_sort(self):
|
||||
"""Test that sort returns results in correct order."""
|
||||
# Test by name in ascending order
|
||||
response = self.app.get(
|
||||
reverse("get_member_domains_json"),
|
||||
params={
|
||||
"portfolio": self.portfolio.id,
|
||||
"email": self.invited_member_email,
|
||||
"member_only": "false",
|
||||
"sort_by": "name",
|
||||
"order": "asc",
|
||||
},
|
||||
)
|
||||
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"], 3)
|
||||
self.assertEqual(data["unfiltered_total"], 3)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 3)
|
||||
|
||||
# Check the name of the first domain is example1.com
|
||||
self.assertEqual(data["domains"][0]["name"], "example1.com")
|
||||
|
||||
# Test by name in descending order
|
||||
response = self.app.get(
|
||||
reverse("get_member_domains_json"),
|
||||
params={
|
||||
"portfolio": self.portfolio.id,
|
||||
"email": self.invited_member_email,
|
||||
"member_only": "false",
|
||||
"sort_by": "name",
|
||||
"order": "desc",
|
||||
},
|
||||
)
|
||||
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"], 3)
|
||||
self.assertEqual(data["unfiltered_total"], 3)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 3)
|
||||
|
||||
# Check the name of the first domain is example1.com
|
||||
self.assertEqual(data["domains"][0]["name"], "example3.com")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_get_portfolio_members_json_restricted_user(self):
|
||||
"""Test that an restricted user is denied access."""
|
||||
# set user to a user with no permissions
|
||||
self.app.set_user(self.user_no_perms)
|
||||
|
||||
# Try to access the portfolio members without being authenticated
|
||||
response = self.app.get(
|
||||
reverse("get_member_domains_json"),
|
||||
params={"portfolio": self.portfolio.id, "member_id": self.user_member.id, "member_only": "true"},
|
||||
expect_errors=True,
|
||||
)
|
||||
|
||||
# Assert that the response is a 403
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_get_portfolio_members_json_unauthenticated(self):
|
||||
"""Test that an unauthenticated user is redirected to login."""
|
||||
# set app to unauthenticated
|
||||
self.app.set_user(None)
|
||||
|
||||
# Try to access the portfolio members without being authenticated
|
||||
response = self.app.get(
|
||||
reverse("get_member_domains_json"),
|
||||
params={"portfolio": self.portfolio.id, "member_id": self.user_member.id, "member_only": "true"},
|
||||
expect_errors=True,
|
||||
)
|
||||
|
||||
# Assert that the response is a redirect to openid login
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/openid/login", response.location)
|
|
@ -1,21 +1,27 @@
|
|||
from django.urls import reverse
|
||||
|
||||
from api.tests.common import less_console_noise_decorator
|
||||
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 waffle.testutils import override_flag
|
||||
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 +29,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 +37,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 +45,69 @@ 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()
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
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 +130,257 @@ 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))
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
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"""
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
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"], 2)
|
||||
self.assertEqual(data["unfiltered_total"], 2)
|
||||
|
||||
# Check the number of members
|
||||
self.assertEqual(len(data["members"]), 2)
|
||||
|
||||
# Check member fields
|
||||
expected_emails = {self.user.email, 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))
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
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)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
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.."""
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
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}",
|
||||
|
@ -170,8 +427,45 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
# Check the number of members on page 2
|
||||
self.assertEqual(len(data["members"]), 5)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
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"}
|
||||
|
|
|
@ -17,6 +17,7 @@ from registrar.models.portfolio_invitation import PortfolioInvitation
|
|||
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 registrar.tests.test_views import TestWithUser
|
||||
from .common import MockSESClient, completed_domain_request, create_test_user, create_user
|
||||
from waffle.testutils import override_flag
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
|
@ -1392,6 +1393,210 @@ class TestPortfolio(WebTest):
|
|||
domain_request.delete()
|
||||
|
||||
|
||||
class TestPortfolioMemberDomainsView(TestWithUser, WebTest):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
# Create test member
|
||||
cls.user_member = User.objects.create(
|
||||
username="test_member",
|
||||
first_name="Second",
|
||||
last_name="User",
|
||||
email="second@example.com",
|
||||
phone="8003112345",
|
||||
title="Member",
|
||||
)
|
||||
|
||||
# Create test user with no perms
|
||||
cls.user_no_perms = User.objects.create(
|
||||
username="test_user_no_perms",
|
||||
first_name="No",
|
||||
last_name="Permissions",
|
||||
email="user_no_perms@example.com",
|
||||
phone="8003112345",
|
||||
title="No Permissions",
|
||||
)
|
||||
|
||||
# Create Portfolio
|
||||
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
|
||||
|
||||
# Assign permissions to the user making requests
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=cls.user,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
cls.permission = UserPortfolioPermission.objects.create(
|
||||
user=cls.user_member,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
super().tearDownClass()
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_member_domains_authenticated(self):
|
||||
"""Tests that the portfolio member domains view is accessible."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(reverse("member-domains", kwargs={"pk": self.permission.id}))
|
||||
|
||||
# Make sure the page loaded, and that we're on the right page
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.user_member.email)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_member_domains_no_perms(self):
|
||||
"""Tests that the portfolio member domains view is not accessible to user with no perms."""
|
||||
self.client.force_login(self.user_no_perms)
|
||||
|
||||
response = self.client.get(reverse("member-domains", kwargs={"pk": self.permission.id}))
|
||||
|
||||
# Make sure the request returns 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_member_domains_unauthenticated(self):
|
||||
"""Tests that the portfolio member domains view is not accessible when no authenticated user."""
|
||||
self.client.logout()
|
||||
|
||||
response = self.client.get(reverse("member-domains", kwargs={"pk": self.permission.id}))
|
||||
|
||||
# Make sure the request returns redirect to openid login
|
||||
self.assertEqual(response.status_code, 302) # Redirect to openid login
|
||||
self.assertIn("/openid/login", response.url)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_member_domains_not_found(self):
|
||||
"""Tests that the portfolio member domains view returns not found if user portfolio permission not found."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(reverse("member-domains", kwargs={"pk": "0"}))
|
||||
|
||||
# Make sure the response is not found
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class TestPortfolioInvitedMemberDomainsView(TestWithUser, WebTest):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
cls.user_no_perms = User.objects.create(
|
||||
username="test_user_no_perms",
|
||||
first_name="No",
|
||||
last_name="Permissions",
|
||||
email="user_no_perms@example.com",
|
||||
phone="8003112345",
|
||||
title="No Permissions",
|
||||
)
|
||||
|
||||
# Create Portfolio
|
||||
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
|
||||
|
||||
# Add an invited member who has been invited to manage domains
|
||||
cls.invited_member_email = "invited@example.com"
|
||||
cls.invitation = PortfolioInvitation.objects.create(
|
||||
email=cls.invited_member_email,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Assign permissions to the user making requests
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=cls.user,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
PortfolioInvitation.objects.all().delete()
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
super().tearDownClass()
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_invitedmember_domains_authenticated(self):
|
||||
"""Tests that the portfolio invited member domains view is accessible."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(reverse("invitedmember-domains", kwargs={"pk": self.invitation.id}))
|
||||
|
||||
# Make sure the page loaded, and that we're on the right page
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.invited_member_email)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_invitedmember_domains_no_perms(self):
|
||||
"""Tests that the portfolio invited member domains view is not accessible to user with no perms."""
|
||||
self.client.force_login(self.user_no_perms)
|
||||
|
||||
response = self.client.get(reverse("invitedmember-domains", kwargs={"pk": self.invitation.id}))
|
||||
|
||||
# Make sure the request returns 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_invitedmember_domains_unauthenticated(self):
|
||||
"""Tests that the portfolio invited member domains view is not accessible when no authenticated user."""
|
||||
self.client.logout()
|
||||
|
||||
response = self.client.get(reverse("invitedmember-domains", kwargs={"pk": self.invitation.id}))
|
||||
|
||||
# Make sure the request returns redirect to openid login
|
||||
self.assertEqual(response.status_code, 302) # Redirect to openid login
|
||||
self.assertIn("/openid/login", response.url)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_member_domains_not_found(self):
|
||||
"""Tests that the portfolio invited member domains view returns not found if user is not a member."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(reverse("invitedmember-domains", kwargs={"pk": "0"}))
|
||||
|
||||
# Make sure the response is not found
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class TestRequestingEntity(WebTest):
|
||||
"""The requesting entity page is a domain request form that only exists
|
||||
within the context of a portfolio."""
|
||||
|
|
|
@ -746,7 +746,6 @@ class DomainDataFull(DomainExport):
|
|||
return Q(
|
||||
domain__state__in=[
|
||||
Domain.State.READY,
|
||||
Domain.State.DNS_NEEDED,
|
||||
Domain.State.ON_HOLD,
|
||||
],
|
||||
)
|
||||
|
@ -842,7 +841,6 @@ class DomainDataFederal(DomainExport):
|
|||
organization_type__icontains="federal",
|
||||
domain__state__in=[
|
||||
Domain.State.READY,
|
||||
Domain.State.DNS_NEEDED,
|
||||
Domain.State.ON_HOLD,
|
||||
],
|
||||
)
|
||||
|
|
|
@ -19,3 +19,5 @@ from .health import *
|
|||
from .index import *
|
||||
from .portfolios import *
|
||||
from .transfer_user import TransferUserView
|
||||
from .member_domains_json import PortfolioMemberDomainsJson
|
||||
from .portfolio_members_json import PortfolioMembersJson
|
||||
|
|
121
src/registrar/views/member_domains_json.py
Normal file
121
src/registrar/views/member_domains_json.py
Normal file
|
@ -0,0 +1,121 @@
|
|||
import logging
|
||||
from django.http import JsonResponse
|
||||
from django.core.paginator import Paginator
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views import View
|
||||
from registrar.models import UserDomainRole, Domain, DomainInformation, User
|
||||
from django.urls import reverse
|
||||
from django.db.models import Q
|
||||
|
||||
from registrar.models.domain_invitation import DomainInvitation
|
||||
from registrar.views.utility.mixins import PortfolioMemberDomainsPermission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PortfolioMemberDomainsJson(PortfolioMemberDomainsPermission, View):
|
||||
|
||||
def get(self, request):
|
||||
"""Given the current request,
|
||||
get all domains that are associated with the portfolio, or
|
||||
associated with the member/invited member"""
|
||||
|
||||
domain_ids = self.get_domain_ids_from_request(request)
|
||||
|
||||
objects = Domain.objects.filter(id__in=domain_ids).select_related("domain_info__sub_organization")
|
||||
unfiltered_total = objects.count()
|
||||
|
||||
objects = self.apply_search(objects, request)
|
||||
objects = self.apply_sorting(objects, request)
|
||||
|
||||
paginator = Paginator(objects, 10)
|
||||
page_number = request.GET.get("page")
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
domains = [self.serialize_domain(domain, request.user) for domain in page_obj.object_list]
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"domains": domains,
|
||||
"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_domain_ids_from_request(self, request):
|
||||
"""Get domain ids from request.
|
||||
|
||||
request.get.email - email address of invited member
|
||||
request.get.member_id - member id of member
|
||||
request.get.portfolio - portfolio id of portfolio
|
||||
request.get.member_only - whether to return only domains associated with member
|
||||
or to return all domains in the portfolio
|
||||
"""
|
||||
portfolio = request.GET.get("portfolio")
|
||||
email = request.GET.get("email")
|
||||
member_id = request.GET.get("member_id")
|
||||
member_only = request.GET.get("member_only", "false").lower() in ["true", "1"]
|
||||
if member_only:
|
||||
if member_id:
|
||||
member = get_object_or_404(User, pk=member_id)
|
||||
domain_info_ids = DomainInformation.objects.filter(portfolio=portfolio).values_list(
|
||||
"domain_id", flat=True
|
||||
)
|
||||
user_domain_roles = UserDomainRole.objects.filter(user=member).values_list("domain_id", flat=True)
|
||||
return domain_info_ids.intersection(user_domain_roles)
|
||||
elif email:
|
||||
domain_info_ids = DomainInformation.objects.filter(portfolio=portfolio).values_list(
|
||||
"domain_id", flat=True
|
||||
)
|
||||
domain_invitations = DomainInvitation.objects.filter(email=email).values_list("domain_id", flat=True)
|
||||
return domain_info_ids.intersection(domain_invitations)
|
||||
else:
|
||||
domain_infos = DomainInformation.objects.filter(portfolio=portfolio)
|
||||
return domain_infos.values_list("domain_id", flat=True)
|
||||
logger.warning("Invalid search criteria, returning empty results list")
|
||||
return []
|
||||
|
||||
def apply_search(self, queryset, request):
|
||||
search_term = request.GET.get("search_term")
|
||||
if search_term:
|
||||
queryset = queryset.filter(Q(name__icontains=search_term))
|
||||
return queryset
|
||||
|
||||
def apply_sorting(self, queryset, request):
|
||||
sort_by = request.GET.get("sort_by", "name")
|
||||
order = request.GET.get("order", "asc")
|
||||
if order == "desc":
|
||||
sort_by = f"-{sort_by}"
|
||||
return queryset.order_by(sort_by)
|
||||
|
||||
def serialize_domain(self, domain, user):
|
||||
suborganization_name = None
|
||||
try:
|
||||
domain_info = domain.domain_info
|
||||
if domain_info:
|
||||
suborganization = domain_info.sub_organization
|
||||
if suborganization:
|
||||
suborganization_name = suborganization.name
|
||||
except Domain.domain_info.RelatedObjectDoesNotExist:
|
||||
domain_info = None
|
||||
logger.debug(f"Issue in domains_json: We could not find domain_info for {domain}")
|
||||
|
||||
# Check if there is a UserDomainRole for this domain and user
|
||||
user_domain_role_exists = UserDomainRole.objects.filter(domain_id=domain.id, user=user).exists()
|
||||
view_only = not user_domain_role_exists or domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD]
|
||||
return {
|
||||
"id": domain.id,
|
||||
"name": domain.name,
|
||||
"expiration_date": domain.expiration_date,
|
||||
"state": domain.state,
|
||||
"state_display": domain.state_display(),
|
||||
"get_state_help_text": domain.get_state_help_text(),
|
||||
"action_url": reverse("domain", kwargs={"pk": domain.id}),
|
||||
"action_label": ("View" if view_only else "Manage"),
|
||||
"svg_icon": ("visibility" if view_only else "settings"),
|
||||
"domain_info__sub_organization": suborganization_name,
|
||||
}
|
|
@ -1,85 +1,147 @@
|
|||
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 django.views import View
|
||||
|
||||
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
|
||||
from registrar.views.utility.mixins import PortfolioMembersPermission
|
||||
|
||||
|
||||
@login_required
|
||||
def get_portfolio_members_json(request):
|
||||
"""Fetch members (permissions and invitations) for the given portfolio."""
|
||||
class PortfolioMembersJson(PortfolioMembersPermission, View):
|
||||
|
||||
portfolio = request.GET.get("portfolio")
|
||||
def get(self, request):
|
||||
"""Fetch members (permissions and invitations) for the given portfolio."""
|
||||
|
||||
# Two initial querysets which will be combined
|
||||
permissions = initial_permissions_search(portfolio)
|
||||
invitations = initial_invitations_search(portfolio)
|
||||
portfolio = request.GET.get("portfolio")
|
||||
|
||||
# Get total across both querysets before applying filters
|
||||
unfiltered_total = permissions.count() + invitations.count()
|
||||
# Two initial querysets which will be combined
|
||||
permissions = self.initial_permissions_search(portfolio)
|
||||
invitations = self.initial_invitations_search(portfolio)
|
||||
|
||||
permissions = apply_search_term(permissions, request)
|
||||
invitations = apply_search_term(invitations, request)
|
||||
# Get total across both querysets before applying filters
|
||||
unfiltered_total = permissions.count() + invitations.count()
|
||||
|
||||
# Union the two querysets
|
||||
objects = permissions.union(invitations)
|
||||
objects = apply_sorting(objects, request)
|
||||
permissions = self.apply_search_term(permissions, request)
|
||||
invitations = self.apply_search_term(invitations, request)
|
||||
|
||||
paginator = Paginator(objects, 10)
|
||||
page_number = request.GET.get("page", 1)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
# Union the two querysets
|
||||
objects = permissions.union(invitations)
|
||||
objects = self.apply_sorting(objects, request)
|
||||
|
||||
members = [serialize_members(request, portfolio, item, request.user) for item in page_obj.object_list]
|
||||
paginator = Paginator(objects, 10)
|
||||
page_number = request.GET.get("page", 1)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
members = [self.serialize_members(request, portfolio, item, request.user) for item in page_obj.object_list]
|
||||
|
||||
|
||||
def initial_permissions_search(portfolio):
|
||||
"""Perform initial search for permissions before applying any filters."""
|
||||
permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio)
|
||||
permissions = (
|
||||
permissions.select_related("user")
|
||||
.annotate(
|
||||
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"),
|
||||
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")),
|
||||
# If first name or last name is present, use concatenation of first_name + " " + last_name
|
||||
When(
|
||||
Q(user__first_name__isnull=False) | Q(user__last_name__isnull=False),
|
||||
then=Concat(
|
||||
Coalesce(F("user__first_name"), Value("")),
|
||||
Value(" "),
|
||||
Coalesce(F("user__last_name"), Value("")),
|
||||
),
|
||||
),
|
||||
# If neither, use an empty string
|
||||
default=Value(""),
|
||||
output_field=CharField(),
|
||||
),
|
||||
source=Value("permission", output_field=CharField()),
|
||||
return JsonResponse(
|
||||
{
|
||||
"members": members,
|
||||
"UserPortfolioPermissionChoices": UserPortfolioPermissionChoices.to_dict(),
|
||||
"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,
|
||||
}
|
||||
)
|
||||
.values(
|
||||
|
||||
def initial_permissions_search(self, portfolio):
|
||||
"""Perform initial search for permissions before applying any filters."""
|
||||
permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio)
|
||||
permissions = (
|
||||
permissions.select_related("user")
|
||||
.annotate(
|
||||
first_name=F("user__first_name"),
|
||||
last_name=F("user__last_name"),
|
||||
email_display=F("user__email"),
|
||||
last_active=Coalesce(
|
||||
Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text
|
||||
Value("Invalid date"),
|
||||
output_field=TextField(),
|
||||
),
|
||||
additional_permissions_display=F("additional_permissions"),
|
||||
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")),
|
||||
# If first name or last name is present, use concatenation of first_name + " " + last_name
|
||||
When(
|
||||
Q(user__first_name__isnull=False) | Q(user__last_name__isnull=False),
|
||||
then=Concat(
|
||||
Coalesce(F("user__first_name"), Value("")),
|
||||
Value(" "),
|
||||
Coalesce(F("user__last_name"), Value("")),
|
||||
),
|
||||
),
|
||||
# If neither, use an empty string
|
||||
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(
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email_display",
|
||||
"last_active",
|
||||
"roles",
|
||||
"additional_permissions_display",
|
||||
"member_display",
|
||||
"domain_info",
|
||||
"source",
|
||||
)
|
||||
)
|
||||
return permissions
|
||||
|
||||
def initial_invitations_search(self, portfolio):
|
||||
"""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",
|
||||
"first_name",
|
||||
"last_name",
|
||||
|
@ -88,84 +150,72 @@ def initial_permissions_search(portfolio):
|
|||
"roles",
|
||||
"additional_permissions_display",
|
||||
"member_display",
|
||||
"domain_info",
|
||||
"source",
|
||||
)
|
||||
)
|
||||
return permissions
|
||||
return invitations
|
||||
|
||||
def apply_search_term(self, queryset, request):
|
||||
"""Apply search term to the queryset."""
|
||||
search_term = request.GET.get("search_term", "").lower()
|
||||
if search_term:
|
||||
queryset = queryset.filter(
|
||||
Q(first_name__icontains=search_term)
|
||||
| Q(last_name__icontains=search_term)
|
||||
| Q(email_display__icontains=search_term)
|
||||
)
|
||||
return queryset
|
||||
|
||||
def initial_invitations_search(portfolio):
|
||||
"""Perform initial invitations search before applying any filters."""
|
||||
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"),
|
||||
source=Value("invitation", output_field=CharField()),
|
||||
).values(
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email_display",
|
||||
"last_active",
|
||||
"roles",
|
||||
"additional_permissions_display",
|
||||
"member_display",
|
||||
"source",
|
||||
)
|
||||
return invitations
|
||||
def apply_sorting(self, queryset, request):
|
||||
"""Apply sorting to the queryset."""
|
||||
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
|
||||
order = request.GET.get("order", "asc") # Default to 'asc'
|
||||
# Adjust sort_by to match the annotated fields in the unioned queryset
|
||||
if sort_by == "member":
|
||||
sort_by = "member_display"
|
||||
if order == "desc":
|
||||
queryset = queryset.order_by(F(sort_by).desc())
|
||||
else:
|
||||
queryset = queryset.order_by(sort_by)
|
||||
return queryset
|
||||
|
||||
|
||||
def apply_search_term(queryset, request):
|
||||
"""Apply search term to the queryset."""
|
||||
search_term = request.GET.get("search_term", "").lower()
|
||||
if search_term:
|
||||
queryset = queryset.filter(
|
||||
Q(first_name__icontains=search_term)
|
||||
| Q(last_name__icontains=search_term)
|
||||
| Q(email_display__icontains=search_term)
|
||||
def serialize_members(self, request, portfolio, item, user):
|
||||
# Check if the user can edit other users
|
||||
user_can_edit_other_users = any(
|
||||
user.has_perm(perm) for perm in ["registrar.full_access_permission", "registrar.change_user"]
|
||||
)
|
||||
return queryset
|
||||
|
||||
view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users
|
||||
|
||||
is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or [])
|
||||
action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]})
|
||||
|
||||
# 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_display")
|
||||
),
|
||||
# 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"),
|
||||
"action_url": action_url,
|
||||
"action_label": ("View" if view_only else "Manage"),
|
||||
"svg_icon": ("visibility" if view_only else "settings"),
|
||||
}
|
||||
return member_json
|
||||
|
||||
|
||||
def apply_sorting(queryset, request):
|
||||
"""Apply sorting to the queryset."""
|
||||
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
|
||||
order = request.GET.get("order", "asc") # Default to 'asc'
|
||||
# Adjust sort_by to match the annotated fields in the unioned queryset
|
||||
if sort_by == "member":
|
||||
sort_by = "member_display"
|
||||
if order == "desc":
|
||||
queryset = queryset.order_by(F(sort_by).desc())
|
||||
else:
|
||||
queryset = queryset.order_by(sort_by)
|
||||
return queryset
|
||||
|
||||
|
||||
def serialize_members(request, portfolio, item, user):
|
||||
# Check if the user can edit other users
|
||||
user_can_edit_other_users = any(
|
||||
user.has_perm(perm) for perm in ["registrar.full_access_permission", "registrar.change_user"]
|
||||
)
|
||||
|
||||
view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users
|
||||
|
||||
is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or [])
|
||||
action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]})
|
||||
|
||||
# Serialize member data
|
||||
member_json = {
|
||||
"id": item.get("id", ""),
|
||||
"name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
|
||||
"email": item.get("email_display", ""),
|
||||
"member_display": item.get("member_display", ""),
|
||||
"is_admin": is_admin,
|
||||
"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"),
|
||||
}
|
||||
return member_json
|
||||
# Custom Func to use array_remove to remove null values
|
||||
class ArrayRemove(Func):
|
||||
function = "array_remove"
|
||||
template = "%(function)s(%(expressions)s, NULL)"
|
||||
|
|
|
@ -18,8 +18,7 @@ from registrar.views.utility.permission_views import (
|
|||
PortfolioDomainsPermissionView,
|
||||
PortfolioBasePermissionView,
|
||||
NoPortfolioDomainsPermissionView,
|
||||
PortfolioInvitedMemberEditPermissionView,
|
||||
PortfolioInvitedMemberPermissionView,
|
||||
PortfolioMemberDomainsPermissionView,
|
||||
PortfolioMemberEditPermissionView,
|
||||
PortfolioMemberPermissionView,
|
||||
PortfolioMembersPermissionView,
|
||||
|
@ -88,6 +87,7 @@ class PortfolioMemberView(PortfolioMemberPermissionView, View):
|
|||
self.template_name,
|
||||
{
|
||||
"edit_url": reverse("member-permissions", args=[pk]),
|
||||
"domains_url": reverse("member-domains", args=[pk]),
|
||||
"portfolio_permission": portfolio_permission,
|
||||
"member": member,
|
||||
"member_has_view_all_requests_portfolio_permission": member_has_view_all_requests_portfolio_permission,
|
||||
|
@ -138,7 +138,25 @@ class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
|
|||
)
|
||||
|
||||
|
||||
class PortfolioInvitedMemberView(PortfolioInvitedMemberPermissionView, View):
|
||||
class PortfolioMemberDomainsView(PortfolioMemberDomainsPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member_domains.html"
|
||||
|
||||
def get(self, request, pk):
|
||||
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
|
||||
member = portfolio_permission.user
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"portfolio_permission": portfolio_permission,
|
||||
"member": member,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member.html"
|
||||
# form_class = PortfolioInvitedMemberForm
|
||||
|
@ -166,6 +184,7 @@ class PortfolioInvitedMemberView(PortfolioInvitedMemberPermissionView, View):
|
|||
self.template_name,
|
||||
{
|
||||
"edit_url": reverse("invitedmember-permissions", args=[pk]),
|
||||
"domains_url": reverse("invitedmember-domains", args=[pk]),
|
||||
"portfolio_invitation": portfolio_invitation,
|
||||
"member_has_view_all_requests_portfolio_permission": member_has_view_all_requests_portfolio_permission,
|
||||
"member_has_edit_request_portfolio_permission": member_has_edit_request_portfolio_permission,
|
||||
|
@ -175,7 +194,7 @@ class PortfolioInvitedMemberView(PortfolioInvitedMemberPermissionView, View):
|
|||
)
|
||||
|
||||
|
||||
class PortfolioInvitedMemberEditView(PortfolioInvitedMemberEditPermissionView, View):
|
||||
class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member_permissions.html"
|
||||
form_class = PortfolioInvitedMemberForm
|
||||
|
@ -210,6 +229,22 @@ class PortfolioInvitedMemberEditView(PortfolioInvitedMemberEditPermissionView, V
|
|||
)
|
||||
|
||||
|
||||
class PortfolioInvitedMemberDomainsView(PortfolioMemberDomainsPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member_domains.html"
|
||||
|
||||
def get(self, request, pk):
|
||||
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"portfolio_invitation": portfolio_invitation,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
|
|
@ -521,11 +521,11 @@ class PortfolioMembersPermission(PortfolioBasePermission):
|
|||
|
||||
|
||||
class PortfolioMemberPermission(PortfolioBasePermission):
|
||||
"""Permission mixin that allows access to portfolio member pages if user
|
||||
"""Permission mixin that allows access to portfolio member or invited member pages if user
|
||||
has access, otherwise 403"""
|
||||
|
||||
def has_permission(self):
|
||||
"""Check if this user has access to members for this portfolio.
|
||||
"""Check if this user has access to members or invited members for this portfolio.
|
||||
|
||||
The user is in self.request.user and the portfolio can be looked
|
||||
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||
|
@ -540,11 +540,11 @@ class PortfolioMemberPermission(PortfolioBasePermission):
|
|||
|
||||
|
||||
class PortfolioMemberEditPermission(PortfolioBasePermission):
|
||||
"""Permission mixin that allows access to portfolio member pages if user
|
||||
"""Permission mixin that allows access to portfolio member or invited member pages if user
|
||||
has access to edit, otherwise 403"""
|
||||
|
||||
def has_permission(self):
|
||||
"""Check if this user has access to members for this portfolio.
|
||||
"""Check if this user has access to members or invited members for this portfolio.
|
||||
|
||||
The user is in self.request.user and the portfolio can be looked
|
||||
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||
|
@ -556,12 +556,12 @@ class PortfolioMemberEditPermission(PortfolioBasePermission):
|
|||
return super().has_permission()
|
||||
|
||||
|
||||
class PortfolioInvitedMemberPermission(PortfolioBasePermission):
|
||||
"""Permission mixin that allows access to portfolio invited member pages if user
|
||||
has access, otherwise 403"""
|
||||
class PortfolioMemberDomainsPermission(PortfolioBasePermission):
|
||||
"""Permission mixin that allows access to portfolio member or invited member domains pages if user
|
||||
has access to edit, otherwise 403"""
|
||||
|
||||
def has_permission(self):
|
||||
"""Check if this user has access to members for this portfolio.
|
||||
"""Check if this user has access to member or invited member domains for this portfolio.
|
||||
|
||||
The user is in self.request.user and the portfolio can be looked
|
||||
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||
|
@ -573,20 +573,3 @@ class PortfolioInvitedMemberPermission(PortfolioBasePermission):
|
|||
return False
|
||||
|
||||
return super().has_permission()
|
||||
|
||||
|
||||
class PortfolioInvitedMemberEditPermission(PortfolioBasePermission):
|
||||
"""Permission mixin that allows access to portfolio invited member pages if user
|
||||
has access to edit, otherwise 403"""
|
||||
|
||||
def has_permission(self):
|
||||
"""Check if this user has access to members for this portfolio.
|
||||
|
||||
The user is in self.request.user and the portfolio can be looked
|
||||
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||
|
||||
portfolio = self.request.session.get("portfolio")
|
||||
if not self.request.user.has_edit_members_portfolio_permission(portfolio):
|
||||
return False
|
||||
|
||||
return super().has_permission()
|
||||
|
|
|
@ -15,8 +15,7 @@ from .mixins import (
|
|||
DomainRequestWizardPermission,
|
||||
PortfolioDomainRequestsPermission,
|
||||
PortfolioDomainsPermission,
|
||||
PortfolioInvitedMemberEditPermission,
|
||||
PortfolioInvitedMemberPermission,
|
||||
PortfolioMemberDomainsPermission,
|
||||
PortfolioMemberEditPermission,
|
||||
UserDeleteDomainRolePermission,
|
||||
UserProfilePermission,
|
||||
|
@ -280,18 +279,8 @@ class PortfolioMemberEditPermissionView(PortfolioMemberEditPermission, Portfolio
|
|||
"""
|
||||
|
||||
|
||||
class PortfolioInvitedMemberPermissionView(PortfolioInvitedMemberPermission, PortfolioBasePermissionView, abc.ABC):
|
||||
"""Abstract base view for portfolio member views that enforces permissions.
|
||||
|
||||
This abstract view cannot be instantiated. Actual views must specify
|
||||
`template_name`.
|
||||
"""
|
||||
|
||||
|
||||
class PortfolioInvitedMemberEditPermissionView(
|
||||
PortfolioInvitedMemberEditPermission, PortfolioBasePermissionView, abc.ABC
|
||||
):
|
||||
"""Abstract base view for portfolio member edit views that enforces permissions.
|
||||
class PortfolioMemberDomainsPermissionView(PortfolioMemberDomainsPermission, PortfolioBasePermissionView, abc.ABC):
|
||||
"""Abstract base view for portfolio member domains views that enforces permissions.
|
||||
|
||||
This abstract view cannot be instantiated. Actual views must specify
|
||||
`template_name`.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue