This commit is contained in:
asaki222 2024-10-30 09:27:29 -04:00
commit 5c15d7e172
No known key found for this signature in database
GPG key ID: 2C4F802060E06EA4
27 changed files with 1648 additions and 510 deletions

View file

@ -12,41 +12,41 @@ on:
env:
DESTINATION_ENVIRONMENT: ms
SOURCE_ENVIRONMENT: staging
jobs:
clone-database:
runs-on: ubuntu-latest
env:
CF_USERNAME: CF_MS_USERNAME
CF_PASSWORD: CF_MS_PASSWORD
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Install Cloud Foundry CLI
- name: Share DB Service
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ env.DESTINATION_ENVIRONMENT }}
cf_command: share-service getgov-${{ env.DESTINATION_ENVIRONMENT }}-database -s ${{ env.SOURCE_ENVIRONMENT }}
- 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 }}
- name: Clone Database
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets.CF_MS_USERNAM }}
cf_password: ${{ secrets.CF_MS_PASSWORD }}
cf_org: cisa-dotgov
cf_space: ${{ env.SOURCE_ENVIRONMENT }}
command: cg-manage-rds clone getgov-${{ env.SOURCE_ENVIRONMENT }}-database getgov-${{ env.DESTINATION_ENVIRONMENT }}-database
- name: Unshare DB Service
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets.CF_MS_USERNAM }}
cf_password: ${{ secrets.CF_MS_PASSWORD }}
cf_org: cisa-dotgov
cf_space: ${{ env.SOURCE_ENVIRONMENT }}
cf_command: unshare-service getgov-${{ env.DESTINATION_ENVIRONMENT }}-database -s ${{ env.SOURCE_ENVIRONMENT }}

View file

@ -1003,25 +1003,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();
@ -1363,7 +1362,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
@ -1415,7 +1414,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 => {
@ -1501,7 +1500,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) {
@ -1567,7 +1566,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
@ -1599,7 +1598,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);
}
}
@ -1872,7 +1871,7 @@ 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');
}
/**
@ -1973,7 +1972,7 @@ class MembersTable extends LoadTableBase {
* @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) {
generateDomainsHTML(num_domains, domain_names, domain_urls, action_url) {
// Initialize an empty string for the HTML
let domainsHTML = '';
@ -1993,7 +1992,7 @@ class MembersTable extends LoadTableBase {
// If there are more than 6 domains, display a "View assigned domains" link
if (num_domains >= 6) {
domainsHTML += "<p><a href='#'>View assigned domains</a></p>";
domainsHTML += `<p><a href="${action_url}/domains">View assigned domains</a></p>`;
}
domainsHTML += "</div>";
@ -2091,7 +2090,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;
@ -2115,7 +2114,7 @@ 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;
@ -2144,7 +2143,7 @@ class MembersTable extends LoadTableBase {
admin_tagHTML = `<span class="usa-tag margin-left-1 bg-primary">Admin</span>`
// generate html blocks for domains and permissions for the member
let domainsHTML = this.generateDomainsHTML(num_domains, domain_names, domain_urls);
let 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
@ -2220,14 +2219,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) {
@ -2239,11 +2345,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) {
@ -2296,11 +2402,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) {
@ -2310,6 +2416,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
*/

View 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;
}
}

View file

@ -15,6 +15,7 @@
@forward "buttons";
@forward "pagination";
@forward "forms";
@forward "search";
@forward "tooltips";
@forward "fieldsets";
@forward "alerts";

View file

@ -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

View file

@ -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)

View file

@ -0,0 +1,30 @@
# Generated by Django 4.2.10 on 2024-10-28 16:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"registrar",
"0134_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more",
),
]
operations = [
migrations.AlterField(
model_name="federalagency",
name="agency",
field=models.CharField(null=True, verbose_name="Federal agency"),
),
migrations.AlterField(
model_name="federalagency",
name="federal_type",
field=models.CharField(
choices=[("executive", "Executive"), ("judicial", "Judicial"), ("legislative", "Legislative")],
max_length=20,
null=True,
),
),
]

View file

@ -13,15 +13,15 @@ class FederalAgency(TimeStampedModel):
agency = models.CharField(
null=True,
blank=True,
help_text="Federal agency",
blank=False,
verbose_name="Federal agency",
)
federal_type = models.CharField(
max_length=20,
choices=BranchChoices.choices,
null=True,
blank=True,
blank=False,
)
acronym = models.CharField(

View file

@ -6,7 +6,11 @@
<ul class="usa-list">
<li>Be available </li>
<li>Relate to your organizations name, location, and/or services </li>
{% if portfolio %}
<li>Be clear to the general public. Your domain name must not be easily confused with other organizations.</li>
{% else %}
<li>Be unlikely to mislead or confuse the general public (even if your domain is only intended for a specific audience) </li>
{% endif %}
</ul>
</p>
@ -19,11 +23,12 @@
<p>Note that <strong>only federal agencies can request generic terms</strong> like
vote.gov.</p>
{% if not portfolio %}
<h2 class="margin-top-3">Domain examples for your type of organization</h2>
<div class="domain_example">
{% include "includes/domain_example.html" %}
</div>
{% endif %}
{% endblock %}

View file

@ -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>

View file

@ -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>

View 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>

View file

@ -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,8 +39,8 @@
</div>
<!-- ---------- MAIN TABLE ---------- -->
<div class="members__table-wrapper display-none margin-top-0">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked members__table">
<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>
@ -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>

View file

@ -7,14 +7,13 @@
{% block form_fields %}
<fieldset class="usa-fieldset margin-top-2">
<legend>
<fieldset class="usa-fieldset margin-top-2">
<h2>Is there anything else youd like us to know about your domain request?</h2>
</legend>
</fieldset>
<div class="margin-top-3" id="anything-else">
<p>Provide details below. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></p>
<p><em>Provide details below. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em></p>
{% with attr_maxlength=2000 add_label_class="usa-sr-only" %}
{% input_with_errors forms.0.anything_else %}
{% endwith %}

View file

@ -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>

View 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 %}

View file

@ -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,

View 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)

View file

@ -1,5 +1,6 @@
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
@ -9,6 +10,7 @@ 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 registrar.tests.common import MockEppLib, create_test_user
from django_webtest import WebTest # type: ignore
@ -70,6 +72,9 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
User.objects.all().delete()
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"""
@ -147,9 +152,22 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
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,
@ -167,14 +185,14 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
# Check pagination info
self.assertEqual(data["page"], 1)
self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 1)
self.assertEqual(data["unfiltered_total"], 1)
self.assertEqual(data["total"], 2)
self.assertEqual(data["unfiltered_total"], 2)
# Check the number of members
self.assertEqual(len(data["members"]), 1)
self.assertEqual(len(data["members"]), 2)
# Check member fields
expected_emails = {self.email6}
expected_emails = {self.user.email, self.email6}
actual_emails = {member["email"] for member in data["members"]}
self.assertEqual(expected_emails, actual_emails)
@ -194,6 +212,9 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
}
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.."""
@ -260,9 +281,19 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
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,
@ -309,6 +340,9 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
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(
@ -393,6 +427,9 @@ class GetPortfolioMembersJsonTest(MockEppLib, 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(

View file

@ -14,6 +14,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
from waffle.testutils import override_flag
from django.contrib.sessions.middleware import SessionMiddleware
@ -1387,3 +1388,207 @@ class TestPortfolio(WebTest):
# Check that the domain request still exists
self.assertTrue(DomainRequest.objects.filter(pk=domain_request.pk).exists())
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)

View file

@ -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,
],
)

View file

@ -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

View 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,
}

View file

@ -1,220 +1,221 @@
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, 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.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 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,
"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,
}
)
members = [self.serialize_members(request, portfolio, item, request.user) for item in page_obj.object_list]
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,
}
)
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=Coalesce(
Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text
Value("Invalid date"),
output_field=TextField(),
),
member_display=Case(
# If email is present and not blank, use email
When(Q(user__email__isnull=False) & ~Q(user__email=""), then=F("user__email")),
# 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("")),
),
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(),
),
# 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
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(),
),
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()),
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",
)
)
.values(
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",
"email_display",
"last_active",
"roles",
"additional_permissions",
"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 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 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"]
)
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
# Custom Func to use array_remove to remove null values
class ArrayRemove(Func):
function = "array_remove"
template = "%(function)s(%(expressions)s, NULL)"
def initial_invitations_search(portfolio):
"""Perform initial invitations search 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()),
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",
"email_display",
"last_active",
"roles",
"additional_permissions",
"member_display",
"domain_info",
"source",
)
return invitations
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)
)
return queryset
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", ""),
"source": item.get("source", ""),
"name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
"email": item.get("email_display", ""),
"member_display": item.get("member_display", ""),
"roles": (item.get("roles") or []),
"permissions": UserPortfolioPermission.get_portfolio_permissions(
item.get("roles"), item.get("additional_permissions")
),
# split domain_info array values into ids to form urls, and names
"domain_urls": [
reverse("domain", kwargs={"pk": domain_info.split(":")[0]}) for domain_info in item.get("domain_info")
],
"domain_names": [domain_info.split(":")[1] for domain_info in item.get("domain_info")],
"is_admin": is_admin,
"last_active": item.get("last_active"),
"action_url": action_url,
"action_label": ("View" if view_only else "Manage"),
"svg_icon": ("visibility" if view_only else "settings"),
}
return member_json

View file

@ -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.

View file

@ -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()

View file

@ -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`.