Merge remote-tracking branch 'origin/main' into nl/2300-Senior-Official-Table

This commit is contained in:
CocoByte 2024-07-02 15:03:20 -06:00
commit ae4e435e49
No known key found for this signature in database
GPG key ID: BBFAA2526384C97F
22 changed files with 593 additions and 103 deletions

View file

@ -67,8 +67,8 @@ services:
# command: "python"
command: >
bash -c " python manage.py migrate &&
python manage.py load &&
python manage.py createcachetable &&
python manage.py load &&
python manage.py runserver 0.0.0.0:8080"
db:

View file

@ -598,6 +598,27 @@ class UserContactInline(admin.StackedInline):
model = models.Contact
# Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [
"user",
"email",
]
def get_readonly_fields(self, request, obj=None):
"""Set the read-only state on form elements.
We have 1 conditions that determine which fields are read-only:
admin user permissions.
"""
readonly_fields = list(self.readonly_fields)
if request.user.has_perm("registrar.full_access_permission"):
return readonly_fields
# Return restrictive Read-only fields for analysts and
# users who might not belong to groups
readonly_fields.extend([field for field in self.analyst_readonly_fields])
return readonly_fields # Read-only fields for analysts
class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"""Custom user admin class to use our inlines."""
@ -645,7 +666,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
None,
{"fields": ("username", "password", "status", "verification_type")},
),
("Personal info", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
("User profile", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
(
"Permissions",
{
@ -676,7 +697,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
)
},
),
("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
("User profile", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
(
"Permissions",
{
@ -700,7 +721,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
# NOT all fields are readonly for admin, otherwise we would have
# set this at the permissions level. The exception is 'status'
analyst_readonly_fields = [
"Personal Info",
"User profile",
"first_name",
"middle_name",
"last_name",
@ -937,6 +958,7 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [
"user",
"email",
]
def get_readonly_fields(self, request, obj=None):
@ -1246,7 +1268,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
search_help_text = "Search by domain."
fieldsets = [
(None, {"fields": ["portfolio", "creator", "submitter", "domain_request", "notes"]}),
(None, {"fields": ["portfolio", "sub_organization", "creator", "submitter", "domain_request", "notes"]}),
(".gov domain", {"fields": ["domain"]}),
("Contacts", {"fields": ["senior_official", "other_contacts", "no_other_contacts_rationale"]}),
("Background info", {"fields": ["anything_else"]}),
@ -1325,6 +1347,8 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"senior_official",
"domain",
"submitter",
"portfolio",
"sub_organization",
]
# Table ordering
@ -1334,6 +1358,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
superuser_only_fields = [
"portfolio",
"sub_organization",
]
# DEVELOPER's NOTE:
@ -1522,6 +1547,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
{
"fields": [
"portfolio",
"sub_organization",
"status",
"rejection_reason",
"action_needed_reason",
@ -1629,11 +1655,14 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"creator",
"senior_official",
"investigator",
"portfolio",
"sub_organization",
]
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
superuser_only_fields = [
"portfolio",
"sub_organization",
]
# DEVELOPER's NOTE:
@ -2048,14 +2077,7 @@ class DomainInformationInline(admin.StackedInline):
fieldsets = DomainInformationAdmin.fieldsets
readonly_fields = DomainInformationAdmin.readonly_fields
analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields
autocomplete_fields = [
"creator",
"domain_request",
"senior_official",
"domain",
"submitter",
]
autocomplete_fields = DomainInformationAdmin.autocomplete_fields
def has_change_permission(self, request, obj=None):
"""Custom has_change_permission override so that we can specify that
@ -2167,8 +2189,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
),
)
# this ordering effects the ordering of results
# in autocomplete_fields for domain
# this ordering effects the ordering of results in autocomplete_fields for domain
ordering = ["name"]
def generic_org_type(self, obj):
@ -2652,6 +2673,11 @@ class PortfolioAdmin(ListHeaderAdmin):
# readonly_fields = [
# "requestor",
# ]
# Creates select2 fields (with search bars)
autocomplete_fields = [
"creator",
"federal_agency",
]
def save_model(self, request, obj, form, change):
@ -2735,6 +2761,10 @@ class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin):
class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
list_display = ["name", "portfolio"]
autocomplete_fields = [
"portfolio",
]
search_fields = ["name"]
admin.site.unregister(LogEntry) # Unregister the default registration

View file

@ -46,7 +46,7 @@ function ScrollToElement(attributeName, attributeValue) {
} else if (attributeName === 'id') {
targetEl = document.getElementById(attributeValue);
} else {
console.log('Error: unknown attribute name provided.');
console.error('Error: unknown attribute name provided.');
return; // Exit the function if an invalid attributeName is provided
}
@ -78,6 +78,50 @@ function makeVisible(el) {
el.style.visibility = "visible";
}
/**
* Toggles expand_more / expand_more svgs in buttons or anchors
* @param {Element} element - DOM element
*/
function toggleCaret(element) {
// Get a reference to the use element inside the button
const useElement = element.querySelector('use');
// Check if the span element text is 'Hide'
if (useElement.getAttribute('xlink:href') === '/public/img/sprite.svg#expand_more') {
// Update the xlink:href attribute to expand_more
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less');
} else {
// Update the xlink:href attribute to expand_less
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more');
}
}
/**
* Helper function that scrolls to an element
* @param {string} attributeName - The string "class" or "id"
* @param {string} attributeValue - The class or id name
*/
function ScrollToElement(attributeName, attributeValue) {
let targetEl = null;
if (attributeName === 'class') {
targetEl = document.getElementsByClassName(attributeValue)[0];
} else if (attributeName === 'id') {
targetEl = document.getElementById(attributeValue);
} else {
console.error('Error: unknown attribute name provided.');
return; // Exit the function if an invalid attributeName is provided
}
if (targetEl) {
const rect = targetEl.getBoundingClientRect();
const scrollTop = window.scrollY || document.documentElement.scrollTop;
window.scrollTo({
top: rect.top + scrollTop,
behavior: 'smooth' // Optional: for smooth scrolling
});
}
}
/** Creates and returns a live region element. */
function createLiveRegion(id) {
const liveRegion = document.createElement("div");
@ -927,7 +971,7 @@ function unloadModals() {
* @param {string} itemName - The name displayed in the counter
* @param {string} paginationSelector - CSS selector for the pagination container.
* @param {string} counterSelector - CSS selector for the pagination counter.
* @param {string} headerAnchor - CSS selector for the header element to anchor the links to.
* @param {string} linkAnchor - CSS selector for the header element to anchor the links to.
* @param {Function} loadPageFunction - Function to call when a page link is clicked.
* @param {number} currentPage - The current page number (starting with 1).
* @param {number} numPages - The total number of pages.
@ -936,7 +980,7 @@ function unloadModals() {
* @param {number} totalItems - The total number of items.
* @param {string} searchTerm - The search term
*/
function updatePagination(itemName, paginationSelector, counterSelector, headerAnchor, loadPageFunction, currentPage, numPages, hasPrevious, hasNext, totalItems, searchTerm) {
function updatePagination(itemName, paginationSelector, counterSelector, linkAnchor, loadPageFunction, currentPage, numPages, hasPrevious, hasNext, totalItems, searchTerm) {
const paginationContainer = document.querySelector(paginationSelector);
const paginationCounter = document.querySelector(counterSelector);
const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`);
@ -955,7 +999,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
const prevPageItem = document.createElement('li');
prevPageItem.className = 'usa-pagination__item usa-pagination__arrow';
prevPageItem.innerHTML = `
<a href="${headerAnchor}" class="usa-pagination__link usa-pagination__previous-page" aria-label="Previous page">
<a href="${linkAnchor}" class="usa-pagination__link usa-pagination__previous-page" aria-label="Previous page">
<svg class="usa-icon" aria-hidden="true" role="img">
<use xlink:href="/public/img/sprite.svg#navigate_before"></use>
</svg>
@ -974,7 +1018,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
const pageItem = document.createElement('li');
pageItem.className = 'usa-pagination__item usa-pagination__page-no';
pageItem.innerHTML = `
<a href="${headerAnchor}" class="usa-pagination__button" aria-label="Page ${page}">${page}</a>
<a href="${linkAnchor}" class="usa-pagination__button" aria-label="Page ${page}">${page}</a>
`;
if (page === currentPage) {
pageItem.querySelector('a').classList.add('usa-current');
@ -1020,7 +1064,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
const nextPageItem = document.createElement('li');
nextPageItem.className = 'usa-pagination__item usa-pagination__arrow';
nextPageItem.innerHTML = `
<a href="${headerAnchor}" class="usa-pagination__link usa-pagination__next-page" aria-label="Next page">
<a href="${linkAnchor}" class="usa-pagination__link usa-pagination__next-page" aria-label="Next page">
<span class="usa-pagination__link-text">Next</span>
<svg class="usa-icon" aria-hidden="true" role="img">
<use xlink:href="/public/img/sprite.svg#navigate_next"></use>
@ -1039,20 +1083,14 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
* A helper that toggles content/ no content/ no search results
*
*/
const updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm) => {
const updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => {
const { unfiltered_total, total } = data;
if (searchTermHolder)
searchTermHolder.innerHTML = '';
if (unfiltered_total) {
if (total) {
showElement(dataWrapper);
hideElement(noSearchResultsWrapper);
hideElement(noDataWrapper);
} else {
if (searchTermHolder)
searchTermHolder.innerHTML = currentSearchTerm;
hideElement(dataWrapper);
showElement(noSearchResultsWrapper);
hideElement(noDataWrapper);
@ -1090,14 +1128,18 @@ document.addEventListener('DOMContentLoaded', function() {
let currentOrder = 'asc';
const noDomainsWrapper = document.querySelector('.domains__no-data');
const noSearchResultsWrapper = document.querySelector('.domains__no-search-results');
let hasLoaded = false;
let currentSearchTerm = ''
let scrollToTable = false;
let currentStatus = [];
let currentSearchTerm = '';
const domainsSearchInput = document.getElementById('domains__search-field');
const domainsSearchSubmit = document.getElementById('domains__search-field-submit');
const tableHeaders = document.querySelectorAll('.domains__table th[data-sortable]');
const tableAnnouncementRegion = document.querySelector('.domains__table-wrapper .usa-table__announcement-region');
const searchTermHolder = document.querySelector('.domains__search-term');
const resetButton = document.querySelector('.domains__reset-button');
const resetSearchButton = document.querySelector('.domains__reset-search');
const resetFiltersButton = document.querySelector('.domains__reset-filters');
const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
const statusIndicator = document.querySelector('.domain__filter-indicator');
const statusToggle = document.querySelector('.usa-button--filter');
/**
* Loads rows in the domains list, as well as updates pagination around the domains list
@ -1105,21 +1147,21 @@ document.addEventListener('DOMContentLoaded', function() {
* @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 {*} loaded - control for the scrollToElement functionality
* @param {*} scroll - control for the scrollToElement functionality
* @param {*} searchTerm - the search term
*/
function loadDomains(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded, searchTerm = currentSearchTerm) {
//fetch json of page of domains, given page # and sort
fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`)
function loadDomains(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, status = currentStatus, searchTerm = currentSearchTerm) {
// fetch json of page of domains, given params
fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}&status=${status}&search_term=${searchTerm}`)
.then(response => response.json())
.then(data => {
if (data.error) {
console.log('Error in AJAX call: ' + data.error);
console.error('Error in AJAX call: ' + data.error);
return;
}
// handle the display of proper messaging in the event that no domains exist in the list or search returns no results
updateDisplay(data, domainsWrapper, noDomainsWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm);
updateDisplay(data, domainsWrapper, noDomainsWrapper, noSearchResultsWrapper, currentSearchTerm);
// identify the DOM element where the domain list will be inserted into the DOM
const domainList = document.querySelector('.domains__table tbody');
@ -1132,7 +1174,6 @@ document.addEventListener('DOMContentLoaded', function() {
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
const actionUrl = domain.action_url;
const row = document.createElement('tr');
row.innerHTML = `
<th scope="row" role="rowheader" data-label="Domain name">
@ -1148,7 +1189,7 @@ document.addEventListener('DOMContentLoaded', function() {
data-position="top"
title="${domain.get_state_help_text}"
focusable="true"
aria-label="Status Information"
aria-label="${domain.get_state_help_text}"
role="tooltip"
>
<use aria-hidden="true" xlink:href="/public/img/sprite.svg#info_outline"></use>
@ -1169,16 +1210,16 @@ document.addEventListener('DOMContentLoaded', function() {
initializeTooltips();
// Do not scroll on first page load
if (loaded)
ScrollToElement('id', 'domains-header');
hasLoaded = true;
if (scroll)
ScrollToElement('class', 'domains');
scrollToTable = true;
// update pagination
updatePagination(
'domain',
'#domains-pagination',
'#domains-pagination .usa-pagination__counter',
'#domains-header',
'#domains',
loadDomains,
data.page,
data.num_pages,
@ -1214,13 +1255,51 @@ document.addEventListener('DOMContentLoaded', function() {
currentSearchTerm = domainsSearchInput.value;
// If the search is blank, we match the resetSearch functionality
if (currentSearchTerm) {
showElement(resetButton);
showElement(resetSearchButton);
} else {
hideElement(resetButton);
hideElement(resetSearchButton);
}
loadDomains(1, 'id', 'asc');
resetHeaders();
})
});
if (statusToggle) {
statusToggle.addEventListener('click', function() {
toggleCaret(statusToggle);
});
}
// Add event listeners to status filter checkboxes
statusCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
const checkboxValue = this.value;
// Update currentStatus array based on checkbox state
if (this.checked) {
currentStatus.push(checkboxValue);
} else {
const index = currentStatus.indexOf(checkboxValue);
if (index > -1) {
currentStatus.splice(index, 1);
}
}
// Manage visibility of reset filters button
if (currentStatus.length == 0) {
hideElement(resetFiltersButton);
} else {
showElement(resetFiltersButton);
}
// Disable the auto scroll
scrollToTable = false;
// Call loadDomains with updated status
loadDomains(1, 'id', 'asc');
resetHeaders();
updateStatusIndicator();
});
});
// Reset UI and accessibility
function resetHeaders() {
@ -1235,18 +1314,78 @@ document.addEventListener('DOMContentLoaded', function() {
function resetSearch() {
domainsSearchInput.value = '';
currentSearchTerm = '';
hideElement(resetButton);
loadDomains(1, 'id', 'asc', hasLoaded, '');
hideElement(resetSearchButton);
loadDomains(1, 'id', 'asc');
resetHeaders();
}
if (resetButton) {
resetButton.addEventListener('click', function() {
if (resetSearchButton) {
resetSearchButton.addEventListener('click', function() {
resetSearch();
});
}
// Load the first page initially
function resetFilters() {
currentStatus = [];
statusCheckboxes.forEach(checkbox => {
checkbox.checked = false;
});
hideElement(resetFiltersButton);
// Disable the auto scroll
scrollToTable = false;
loadDomains(1, 'id', 'asc');
resetHeaders();
updateStatusIndicator();
// No need to toggle close the filters. The focus shift will trigger that for us.
}
if (resetFiltersButton) {
resetFiltersButton.addEventListener('click', function() {
resetFilters();
});
}
function updateStatusIndicator() {
statusIndicator.innerHTML = '';
// Even if the element is empty, it'll mess up the flex layout unless we set display none
statusIndicator.hideElement();
if (currentStatus.length)
statusIndicator.innerHTML = '(' + currentStatus.length + ')';
statusIndicator.showElement();
}
function closeFilters() {
if (statusToggle.getAttribute("aria-expanded") === "true") {
statusToggle.click();
}
}
// Instead of managing the toggle/close on the filter buttons in all edge cases (user clicks on search, user clicks on ANOTHER filter,
// user clicks on main nav...) we add a listener and close the filters whenever the focus shifts out of the dropdown menu/filter button.
// NOTE: We may need to evolve this as we add more filters.
document.addEventListener('focusin', function(event) {
const accordion = document.querySelector('.usa-accordion--select');
const accordionIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
if (accordionIsOpen && !accordion.contains(event.target)) {
closeFilters();
}
});
// Close when user clicks outside
// NOTE: We may need to evolve this as we add more filters.
document.addEventListener('click', function(event) {
const accordion = document.querySelector('.usa-accordion--select');
const accordionIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
if (accordionIsOpen && !accordion.contains(event.target)) {
closeFilters();
}
});
// Initial load
loadDomains(1);
}
});
@ -1279,14 +1418,13 @@ document.addEventListener('DOMContentLoaded', function() {
let currentOrder = 'asc';
const noDomainRequestsWrapper = document.querySelector('.domain-requests__no-data');
const noSearchResultsWrapper = document.querySelector('.domain-requests__no-search-results');
let hasLoaded = false;
let currentSearchTerm = ''
let scrollToTable = false;
let currentSearchTerm = '';
const domainRequestsSearchInput = document.getElementById('domain-requests__search-field');
const domainRequestsSearchSubmit = document.getElementById('domain-requests__search-field-submit');
const tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]');
const tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region');
const searchTermHolder = document.querySelector('.domain-requests__search-term');
const resetButton = document.querySelector('.domain-requests__reset-button');
const resetSearchButton = document.querySelector('.domain-requests__reset-search');
/**
* Delete is actually a POST API that requires a csrf token. The token will be waiting for us in the template as a hidden input.
@ -1316,7 +1454,7 @@ document.addEventListener('DOMContentLoaded', function() {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Update data and UI
loadDomainRequests(pageToDisplay, currentSortBy, currentOrder, hasLoaded, currentSearchTerm);
loadDomainRequests(pageToDisplay, currentSortBy, currentOrder, scrollToTable, currentSearchTerm);
})
.catch(error => console.error('Error fetching domain requests:', error));
}
@ -1332,21 +1470,21 @@ document.addEventListener('DOMContentLoaded', function() {
* @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 {*} loaded - control for the scrollToElement functionality
* @param {*} scroll - control for the scrollToElement functionality
* @param {*} searchTerm - the search term
*/
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded, searchTerm = currentSearchTerm) {
//fetch json of page of domain requests, given page # and sort
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, searchTerm = currentSearchTerm) {
// fetch json of page of domain requests, given params
fetch(`/get-domain-requests-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`)
.then(response => response.json())
.then(data => {
if (data.error) {
console.log('Error in AJAX call: ' + data.error);
console.error('Error in AJAX call: ' + data.error);
return;
}
// handle the display of proper messaging in the event that no requests exist in the list or search returns no results
updateDisplay(data, domainRequestsWrapper, noDomainRequestsWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm);
updateDisplay(data, domainRequestsWrapper, noDomainRequestsWrapper, noSearchResultsWrapper, currentSearchTerm);
// identify the DOM element where the domain request list will be inserted into the DOM
const tbody = document.querySelector('.domain-requests__table tbody');
@ -1533,16 +1671,16 @@ document.addEventListener('DOMContentLoaded', function() {
});
// Do not scroll on first page load
if (loaded)
ScrollToElement('id', 'domain-requests-header');
hasLoaded = true;
if (scroll)
ScrollToElement('class', 'domain-requests');
scrollToTable = true;
// update the pagination after the domain requests list is updated
updatePagination(
'domain request',
'#domain-requests-pagination',
'#domain-requests-pagination .usa-pagination__counter',
'#domain-requests-header',
'#domain-requests',
loadDomainRequests,
data.page,
data.num_pages,
@ -1577,13 +1715,13 @@ document.addEventListener('DOMContentLoaded', function() {
currentSearchTerm = domainRequestsSearchInput.value;
// If the search is blank, we match the resetSearch functionality
if (currentSearchTerm) {
showElement(resetButton);
showElement(resetSearchButton);
} else {
hideElement(resetButton);
hideElement(resetSearchButton);
}
loadDomainRequests(1, 'id', 'asc');
resetHeaders();
})
});
// Reset UI and accessibility
function resetHeaders() {
@ -1598,24 +1736,23 @@ document.addEventListener('DOMContentLoaded', function() {
function resetSearch() {
domainRequestsSearchInput.value = '';
currentSearchTerm = '';
hideElement(resetButton);
loadDomainRequests(1, 'id', 'asc', hasLoaded, '');
hideElement(resetSearchButton);
loadDomainRequests(1, 'id', 'asc');
resetHeaders();
}
if (resetButton) {
resetButton.addEventListener('click', function() {
if (resetSearchButton) {
resetSearchButton.addEventListener('click', function() {
resetSearch();
});
}
// Load the first page initially
// Initial load
loadDomainRequests(1);
}
});
/**
* An IIFE that displays confirmation modal on the user profile page
*/

View file

@ -0,0 +1,33 @@
@use "uswds-core" as *;
.usa-accordion--select {
display: inline-block;
width: auto;
position: relative;
.usa-accordion__button[aria-expanded=false],
.usa-accordion__button[aria-expanded=false]:hover,
.usa-accordion__button[aria-expanded=true],
.usa-accordion__button[aria-expanded=true]:hover {
background-image: none;
}
.usa-accordion__content {
// Note, width is determined by a custom width class on one of the children
position: absolute;
z-index: 1;
top: 33.88px;
left: 0;
border-radius: 4px;
border: solid 1px color('base-lighter');
padding: units(2) units(2) units(3) units(2);
width: max-content;
}
h2 {
font-size: size('body', 'sm');
}
.usa-button {
width: 100%;
}
.margin-top-0 {
margin-top: 0 !important;
}
}

View file

@ -83,6 +83,10 @@ body {
padding: 0 units(2) units(3);
margin-top: units(3);
&.margin-top-0 {
margin-top: 0;
}
h2 {
color: color('primary-dark');
margin-top: units(2);
@ -96,6 +100,10 @@ body {
@include at-media(mobile-lg) {
margin-top: units(5);
&.margin-top-0 {
margin-top: 0;
}
h2 {
margin-bottom: 0;
}
@ -211,3 +219,7 @@ abbr[title] {
.usa-logo button.usa-button--unstyled.disabled-button:hover{
color: #{$dhs-dark-gray-85};
}
.padding--8-8-9 {
padding: 8px 8px 9px !important;
}

View file

@ -161,3 +161,19 @@ a.usa-button--unstyled:visited {
margin-left: units(2);
}
}
.usa-button--filter {
width: auto;
// For mobile stacking
margin-bottom: units(1);
border: solid 1px color('base-light') !important;
padding: units(1);
color: color('primary-darker') !important;
font-weight: font-weight('normal');
font-size: size('ui', 'xs');
box-shadow: none;
&:hover {
box-shadow: none;
}
}

View file

@ -27,7 +27,6 @@
}
td .no-click-outline-and-cursor-help {
outline: none;
cursor: help;
use {
// USWDS has weird interactions with SVGs regarding tooltips,

View file

@ -12,6 +12,7 @@
@forward "typography";
@forward "links";
@forward "lists";
@forward "accordions";
@forward "buttons";
@forward "pagination";
@forward "forms";

View file

@ -0,0 +1,85 @@
# Generated by Django 4.2.10 on 2024-07-02 16:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("registrar", "0108_domaininformation_authorizing_official_and_more"),
]
operations = [
migrations.AddField(
model_name="domaininformation",
name="sub_organization",
field=models.ForeignKey(
blank=True,
help_text="The suborganization that this domain is included under",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="information_sub_organization",
to="registrar.suborganization",
),
),
migrations.AddField(
model_name="domainrequest",
name="sub_organization",
field=models.ForeignKey(
blank=True,
help_text="The suborganization that this domain request is included under",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="request_sub_organization",
to="registrar.suborganization",
),
),
migrations.AlterField(
model_name="domaininformation",
name="portfolio",
field=models.ForeignKey(
blank=True,
help_text="Portfolio associated with this domain",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="information_portfolio",
to="registrar.portfolio",
),
),
migrations.AlterField(
model_name="domainrequest",
name="approved_domain",
field=models.OneToOneField(
blank=True,
help_text="Domain associated with this request; will be blank until request is approved",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="domain_request_approved_domain",
to="registrar.domain",
),
),
migrations.AlterField(
model_name="domainrequest",
name="portfolio",
field=models.ForeignKey(
blank=True,
help_text="Portfolio associated with this domain request",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="DomainRequest_portfolio",
to="registrar.portfolio",
),
),
migrations.AlterField(
model_name="domainrequest",
name="requested_domain",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="domain_request_requested_domain",
to="registrar.draftdomain",
),
),
]

View file

@ -63,10 +63,19 @@ class DomainInformation(TimeStampedModel):
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="DomainRequest_portfolio",
related_name="information_portfolio",
help_text="Portfolio associated with this domain",
)
sub_organization = models.ForeignKey(
"registrar.Suborganization",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="information_sub_organization",
help_text="The suborganization that this domain is included under",
)
domain_request = models.OneToOneField(
"registrar.DomainRequest",
on_delete=models.PROTECT,
@ -361,6 +370,10 @@ class DomainInformation(TimeStampedModel):
# domain_request, if so short circuit the create
existing_domain_info = cls.objects.filter(domain_request__id=domain_request.id).first()
if existing_domain_info:
logger.info(
f"create_from_da() -> Shortcircuting create on {existing_domain_info}. "
"This record already exists. No values updated!"
)
return existing_domain_info
# Get the fields that exist on both DomainRequest and DomainInformation

View file

@ -315,10 +315,19 @@ class DomainRequest(TimeStampedModel):
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="DomainInformation_portfolio",
related_name="DomainRequest_portfolio",
help_text="Portfolio associated with this domain request",
)
sub_organization = models.ForeignKey(
"registrar.Suborganization",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="request_sub_organization",
help_text="The suborganization that this domain request is included under",
)
# This is the domain request user who created this domain request. The contact
# information that they gave is in the `submitter` field
creator = models.ForeignKey(
@ -444,7 +453,7 @@ class DomainRequest(TimeStampedModel):
null=True,
blank=True,
help_text="Domain associated with this request; will be blank until request is approved",
related_name="domain_request",
related_name="domain_request_approved_domain",
on_delete=models.SET_NULL,
)
@ -452,7 +461,7 @@ class DomainRequest(TimeStampedModel):
"DraftDomain",
null=True,
blank=True,
related_name="domain_request",
related_name="domain_request_requested_domain",
on_delete=models.PROTECT,
)

View file

@ -1,6 +1,6 @@
{% load static %}
<section class="section--outlined domain-requests">
<section class="section--outlined domain-requests" id="domain-requests">
<div class="grid-row">
{% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
@ -11,10 +11,10 @@
<section aria-label="Domain requests search component" class="flex-6 margin-y-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-button display-none" type="button">
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-search display-none" type="button">
Reset
</button>
<label class="usa-sr-only" for="domain-requests__search-field">Search</label>
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label>
<input
class="usa-input"
id="domain-requests__search-field"
@ -33,7 +33,7 @@
</section>
</div>
</div>
<div class="domain-requests__table-wrapper display-none">
<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">
<caption class="sr-only">Your domain requests</caption>
<thead>
@ -58,7 +58,7 @@
<p>You haven't requested any domains.</p>
</div>
<div class="domain-requests__no-search-results display-none">
<p>No results found for "<span class="domain-requests__search-term"></span>"</p>
<p>No results found</p>
</div>
</section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">

View file

@ -1,6 +1,6 @@
{% load static %}
<section class="section--outlined domains">
<section class="section--outlined domains{% if portfolio is not None %} margin-top-0{% endif %}" id="domains">
<div class="grid-row">
{% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
@ -11,10 +11,10 @@
<section aria-label="Domains search component" class="flex-6 margin-y-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domains__reset-button display-none" type="button">
<button class="usa-button usa-button--unstyled margin-right-2 domains__reset-search display-none" type="button">
Reset
</button>
<label class="usa-sr-only" for="domains__search-field">Search</label>
<label class="usa-sr-only" for="domains__search-field">Search by domain name</label>
<input
class="usa-input"
id="domains__search-field"
@ -33,7 +33,102 @@
</section>
</div>
</div>
<div class="domains__table-wrapper display-none">
{% if portfolio %}
<div class="display-flex flex-align-center margin-top-1">
<span class="margin-right-2 margin-top-neg-1 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="domain__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>
</svg>
</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>
</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>
@ -70,7 +165,7 @@
</p>
</div>
<div class="domains__no-search-results display-none">
<p>No results found for "<span class="domains__search-term"></span>"</p>
<p>No results found</p>
</div>
</section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domains-pagination">

View file

@ -3,6 +3,6 @@
{% load static %}
{% block portfolio_content %}
<h1>Domains</h1>
<h1 id="domains-header">Domains</h1>
{% include "includes/domains_table.html" with portfolio=portfolio %}
{% endblock %}

View file

@ -3,7 +3,7 @@
{% load static %}
{% block portfolio_content %}
<h1>Domain requests</h1>
<h1 id="domain-requests-header">Domain requests</h1>
{% comment %}
IMPORTANT:

View file

@ -3,7 +3,7 @@
<div class="margin-bottom-4 tablet:margin-bottom-0">
<nav aria-label="">
<h2 class="margin-top-0 text-semibold">{{ portfolio.organization_name }}</h2>
<ul class="usa-sidenav">
<ul class="usa-sidenav usa-sidenav--portfolio">
<li class="usa-sidenav__item">
{% url 'portfolio-domains' portfolio.id as url %}
<a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}>

View file

@ -2249,6 +2249,7 @@ class TestDomainRequestAdmin(MockEppLib):
"action_needed_reason_email",
"federal_agency",
"portfolio",
"sub_organization",
"creator",
"investigator",
"generic_org_type",
@ -3557,7 +3558,7 @@ class TestMyUserAdmin(MockDb):
)
},
),
("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
("User profile", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
("Permissions", {"fields": ("is_active", "groups")}),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
@ -4048,9 +4049,7 @@ class TestContactAdmin(TestCase):
readonly_fields = self.admin.get_readonly_fields(request)
expected_fields = [
"user",
]
expected_fields = ["user", "email"]
self.assertEqual(readonly_fields, expected_fields)

View file

@ -735,7 +735,6 @@ class ExportDataTest(MockDb, MockEppLib):
csv_file.seek(0)
# Read the content into a variable
csv_content = csv_file.read()
self.maxDiff = None
expected_content = (
# Header
"Domain request,Submitted at,Status,Domain type,Federal type,"

View file

@ -965,7 +965,7 @@ class PortfoliosTests(TestWithUser, WebTest):
# Assert that we're on the right page
self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertContains(portfolio_page, "<h1>Domains</h1>")
self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
@less_console_noise_decorator
def test_no_redirect_when_org_flag_false(self):

View file

@ -3,6 +3,7 @@ from django.urls import reverse
from .test_views import TestWithUser
from django_webtest import WebTest # type: ignore
from django.utils.dateparse import parse_date
from api.tests.common import less_console_noise_decorator
class GetDomainsJsonTest(TestWithUser, WebTest):
@ -11,9 +12,9 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
self.app.set_user(self.user.username)
# Create test domains
self.domain1 = Domain.objects.create(name="example1.com", expiration_date="2024-01-01", state="active")
self.domain2 = Domain.objects.create(name="example2.com", expiration_date="2024-02-01", state="inactive")
self.domain3 = Domain.objects.create(name="example3.com", expiration_date="2024-03-01", state="active")
self.domain1 = Domain.objects.create(name="example1.com", expiration_date="2024-01-01", state="unknown")
self.domain2 = Domain.objects.create(name="example2.com", expiration_date="2024-02-01", state="dns needed")
self.domain3 = Domain.objects.create(name="example3.com", expiration_date="2024-03-01", state="ready")
# Create UserDomainRoles
UserDomainRole.objects.create(user=self.user, domain=self.domain1)
@ -25,6 +26,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
UserDomainRole.objects.all().delete()
UserDomainRole.objects.all().delete()
@less_console_noise_decorator
def test_get_domains_json_unauthenticated(self):
"""for an unauthenticated user, test that the user is redirected for auth"""
self.app.reset()
@ -32,6 +34,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
response = self.client.get(reverse("get_domains_json"))
self.assertEqual(response.status_code, 302)
@less_console_noise_decorator
def test_get_domains_json_authenticated(self):
"""Test that an authenticated user gets the list of 3 domains."""
response = self.app.get(reverse("get_domains_json"))
@ -102,6 +105,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
)
self.assertEqual(svg_icon_expected, svg_icons[i])
@less_console_noise_decorator
def test_get_domains_json_search(self):
"""Test search."""
# Define your URL variables as a dictionary
@ -131,6 +135,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
domains[0],
)
@less_console_noise_decorator
def test_pagination(self):
"""Test that pagination is correct in the response"""
response = self.app.get(reverse("get_domains_json"), {"page": 1})
@ -143,6 +148,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
self.assertFalse(data["has_previous"])
self.assertEqual(data["num_pages"], 1)
@less_console_noise_decorator
def test_sorting(self):
"""test that sorting works properly in the response"""
response = self.app.get(reverse("get_domains_json"), {"sort_by": "expiration_date", "order": "desc"})
@ -161,6 +167,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
expiration_dates = [domain["expiration_date"] for domain in data["domains"]]
self.assertEqual(expiration_dates, sorted(expiration_dates))
@less_console_noise_decorator
def test_sorting_by_state_display(self):
"""test that the state_display sorting works properly"""
response = self.app.get(reverse("get_domains_json"), {"sort_by": "state_display", "order": "asc"})
@ -178,3 +185,21 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
# Check if sorted by state_display in descending order
states = [domain["state_display"] for domain in data["domains"]]
self.assertEqual(states, sorted(states, reverse=True))
@less_console_noise_decorator
def test_state_filtering(self):
"""Test that different states in request get expected responses."""
expected_values = [
("unknown", 1),
("ready", 0),
("expired", 2),
("ready,expired", 2),
("unknown,expired", 3),
]
for state, num_domains in expected_values:
with self.subTest(state=state, num_domains=num_domains):
response = self.app.get(reverse("get_domains_json"), {"status": state})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertEqual(len(data["domains"]), num_domains)

View file

@ -20,11 +20,46 @@ def get_domains_json(request):
# Handle sorting
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
order = request.GET.get("order", "asc") # Default to 'asc'
search_term = request.GET.get("search_term")
# Handle search term
search_term = request.GET.get("search_term")
if search_term:
objects = objects.filter(Q(name__icontains=search_term))
# Handle state
status_param = request.GET.get("status")
if status_param:
status_list = status_param.split(",")
# if unknown is in status_list, append 'dns needed' since both
# unknown and dns needed display as DNS Needed, and both are
# searchable via state parameter of 'unknown'
if "unknown" in status_list:
status_list.append("dns needed")
# Split the status list into normal states and custom states
normal_states = [state for state in status_list if state in Domain.State.values]
custom_states = [state for state in status_list if state == "expired"]
# Construct Q objects for normal states that can be queried through ORM
state_query = Q()
if normal_states:
state_query |= Q(state__in=normal_states)
# Handle custom states in Python, as expired can not be queried through ORM
if "expired" in custom_states:
expired_domain_ids = [domain.id for domain in objects if domain.state_display() == "Expired"]
state_query |= Q(id__in=expired_domain_ids)
# Apply the combined query
objects = objects.filter(state_query)
# If there are filtered states, and expired is not one of them, domains with
# state_display of 'Expired' must be removed
if "expired" not in custom_states:
expired_domain_ids = [domain.id for domain in objects if domain.state_display() == "Expired"]
objects = objects.exclude(id__in=expired_domain_ids)
if sort_by == "state_display":
# Fetch the objects and sort them in Python
objects = list(objects) # Evaluate queryset to a list

View file

@ -68,6 +68,8 @@
10038 OUTOFSCOPE http://app:8080/dns/dnssec
10038 OUTOFSCOPE http://app:8080/dns/dnssec/dsdata
10038 OUTOFSCOPE http://app:8080/org-name-address
10038 OUTOFSCOPE http://app:8080/domain_requests/
10038 OUTOFSCOPE http://app:8080/domains/
# This URL always returns 404, so include it as well.
10038 OUTOFSCOPE http://app:8080/todo
# OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers