Merge remote-tracking branch 'origin/main' into nl/2248-add-org-and-portfolio-table

This commit is contained in:
CocoByte 2024-06-17 11:06:42 -06:00
commit c7f7898d04
No known key found for this signature in database
GPG key ID: BBFAA2526384C97F
20 changed files with 885 additions and 260 deletions

View file

@ -70,6 +70,6 @@ jobs:
- name: run pa11y - name: run pa11y
working-directory: ./src working-directory: ./src
run: | run: |
sleep 10; sleep 20;
npm i -g pa11y-ci npm i -g pa11y-ci
pa11y-ci pa11y-ci

View file

@ -33,6 +33,7 @@ from django.contrib.auth.forms import UserChangeForm, UsernameField
from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter
from import_export import resources from import_export import resources
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -1818,10 +1819,93 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return response return response
def change_view(self, request, object_id, form_url="", extra_context=None): def change_view(self, request, object_id, form_url="", extra_context=None):
"""Display restricted warning,
Setup the auditlog trail and pass it in extra context."""
obj = self.get_object(request, object_id) obj = self.get_object(request, object_id)
self.display_restricted_warning(request, obj) self.display_restricted_warning(request, obj)
# Initialize variables for tracking status changes and filtered entries
filtered_audit_log_entries = []
try:
# Retrieve and order audit log entries by timestamp in descending order
audit_log_entries = LogEntry.objects.filter(object_id=object_id).order_by("-timestamp")
# Process each log entry to filter based on the change criteria
for log_entry in audit_log_entries:
entry = self.process_log_entry(log_entry)
if entry:
filtered_audit_log_entries.append(entry)
except ObjectDoesNotExist as e:
logger.error(f"Object with object_id {object_id} does not exist: {e}")
except Exception as e:
logger.error(f"An error occurred during change_view: {e}")
# Initialize extra_context and add filtered entries
extra_context = extra_context or {}
extra_context["filtered_audit_log_entries"] = filtered_audit_log_entries
# Call the superclass method with updated extra_context
return super().change_view(request, object_id, form_url, extra_context) return super().change_view(request, object_id, form_url, extra_context)
def process_log_entry(self, log_entry):
"""Process a log entry and return filtered entry dictionary if applicable."""
changes = log_entry.changes
status_changed = "status" in changes
rejection_reason_changed = "rejection_reason" in changes
action_needed_reason_changed = "action_needed_reason" in changes
# Check if the log entry meets the filtering criteria
if status_changed or (not status_changed and (rejection_reason_changed or action_needed_reason_changed)):
entry = {}
# Handle status change
if status_changed:
_, status_value = changes.get("status")
if status_value:
entry["status"] = DomainRequest.DomainRequestStatus.get_status_label(status_value)
# Handle rejection reason change
if rejection_reason_changed:
_, rejection_reason_value = changes.get("rejection_reason")
if rejection_reason_value:
entry["rejection_reason"] = (
""
if rejection_reason_value == "None"
else DomainRequest.RejectionReasons.get_rejection_reason_label(rejection_reason_value)
)
# Handle case where rejection reason changed but not status
if not status_changed:
entry["status"] = DomainRequest.DomainRequestStatus.get_status_label(
DomainRequest.DomainRequestStatus.REJECTED
)
# Handle action needed reason change
if action_needed_reason_changed:
_, action_needed_reason_value = changes.get("action_needed_reason")
if action_needed_reason_value:
entry["action_needed_reason"] = (
""
if action_needed_reason_value == "None"
else DomainRequest.ActionNeededReasons.get_action_needed_reason_label(
action_needed_reason_value
)
)
# Handle case where action needed reason changed but not status
if not status_changed:
entry["status"] = DomainRequest.DomainRequestStatus.get_status_label(
DomainRequest.DomainRequestStatus.ACTION_NEEDED
)
# Add actor and timestamp information
entry["actor"] = log_entry.actor
entry["timestamp"] = log_entry.timestamp
return entry
return None
class TransitionDomainAdmin(ListHeaderAdmin): class TransitionDomainAdmin(ListHeaderAdmin):
"""Custom transition domain admin class.""" """Custom transition domain admin class."""

View file

@ -137,6 +137,47 @@ function openInNewTab(el, removeAttribute = false){
prepareDjangoAdmin(); prepareDjangoAdmin();
})(); })();
/** An IIFE for the "Assign to me" button under the investigator field in DomainRequests.
** This field uses the "select2" selector, rather than the default.
** To perform data operations on this - we need to use jQuery rather than vanilla js.
*/
(function (){
let selector = django.jQuery("#id_investigator")
let assignSelfButton = document.querySelector("#investigator__assign_self");
if (!selector || !assignSelfButton) {
return;
}
let currentUserId = assignSelfButton.getAttribute("data-user-id");
let currentUserName = assignSelfButton.getAttribute("data-user-name");
if (!currentUserId || !currentUserName){
console.error("Could not assign current user: no values found.")
return;
}
// Hook a click listener to the "Assign to me" button.
// Logic borrowed from here: https://select2.org/programmatic-control/add-select-clear-items#create-if-not-exists
assignSelfButton.addEventListener("click", function() {
if (selector.find(`option[value='${currentUserId}']`).length) {
// Select the value that is associated with the current user.
selector.val(currentUserId).trigger("change");
} else {
// Create a DOM Option that matches the desired user. Then append it and select it.
let userOption = new Option(currentUserName, currentUserId, true, true);
selector.append(userOption).trigger("change");
}
});
// Listen to any change events, and hide the parent container if investigator has a value.
selector.on('change', function() {
// The parent container has display type flex.
assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex";
});
})();
/** An IIFE for pages in DjangoAdmin that use a clipboard button /** An IIFE for pages in DjangoAdmin that use a clipboard button
*/ */
(function (){ (function (){

View file

@ -17,6 +17,22 @@ var SUCCESS = "success";
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Helper functions. // Helper functions.
/**
* Hide element
*
*/
const hideElement = (element) => {
element.classList.add('display-none');
};
/**
* Show element
*
*/
const showElement = (element) => {
element.classList.remove('display-none');
};
/** Makes an element invisible. */ /** Makes an element invisible. */
function makeHidden(el) { function makeHidden(el) {
el.style.position = "absolute"; el.style.position = "absolute";
@ -918,8 +934,9 @@ function ScrollToElement(attributeName, attributeValue) {
* @param {boolean} hasPrevious - Whether there is a page before the current page. * @param {boolean} hasPrevious - Whether there is a page before the current page.
* @param {boolean} hasNext - Whether there is a page after the current page. * @param {boolean} hasNext - Whether there is a page after the current page.
* @param {number} totalItems - The total number of items. * @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) { function updatePagination(itemName, paginationSelector, counterSelector, headerAnchor, loadPageFunction, currentPage, numPages, hasPrevious, hasNext, totalItems, searchTerm) {
const paginationContainer = document.querySelector(paginationSelector); const paginationContainer = document.querySelector(paginationSelector);
const paginationCounter = document.querySelector(counterSelector); const paginationCounter = document.querySelector(counterSelector);
const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`); const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`);
@ -932,7 +949,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
// Counter should only be displayed if there is more than 1 item // Counter should only be displayed if there is more than 1 item
paginationContainer.classList.toggle('display-none', totalItems < 1); paginationContainer.classList.toggle('display-none', totalItems < 1);
paginationCounter.innerHTML = `${totalItems} ${itemName}${totalItems > 1 ? 's' : ''}`; paginationCounter.innerHTML = `${totalItems} ${itemName}${totalItems > 1 ? 's' : ''}${searchTerm ? ' for ' + '"' + searchTerm + '"' : ''}`;
if (hasPrevious) { if (hasPrevious) {
const prevPageItem = document.createElement('li'); const prevPageItem = document.createElement('li');
@ -1018,6 +1035,47 @@ 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 { 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);
}
} else {
hideElement(dataWrapper);
hideElement(noSearchResultsWrapper);
showElement(noDataWrapper);
}
};
/**
* A helper that resets sortable table headers
*
*/
const unsetHeader = (header) => {
header.removeAttribute('aria-sort');
let headerName = header.innerText;
const headerLabel = `${headerName}, sortable column, currently unsorted"`;
const headerButtonLabel = `Click to sort by ascending order.`;
header.setAttribute("aria-label", headerLabel);
header.querySelector('.usa-table__header__button').setAttribute("title", headerButtonLabel);
};
/** /**
* An IIFE that listens for DOM Content to be loaded, then executes. This function * An IIFE that listens for DOM Content to be loaded, then executes. This function
@ -1025,13 +1083,21 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
* *
*/ */
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
let domainsWrapper = document.querySelector('.domains-wrapper'); const domainsWrapper = document.querySelector('.domains__table-wrapper');
if (domainsWrapper) { if (domainsWrapper) {
let currentSortBy = 'id'; let currentSortBy = 'id';
let currentOrder = 'asc'; let currentOrder = 'asc';
let noDomainsWrapper = document.querySelector('.no-domains-wrapper'); const noDomainsWrapper = document.querySelector('.domains__no-data');
const noSearchResultsWrapper = document.querySelector('.domains__no-search-results');
let hasLoaded = false; let hasLoaded = false;
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');
/** /**
* Loads rows in the domains list, as well as updates pagination around the domains list * Loads rows in the domains list, as well as updates pagination around the domains list
@ -1040,10 +1106,11 @@ document.addEventListener('DOMContentLoaded', function() {
* @param {*} sortBy - the sort column option * @param {*} sortBy - the sort column option
* @param {*} order - the sort order {asc, desc} * @param {*} order - the sort order {asc, desc}
* @param {*} loaded - control for the scrollToElement functionality * @param {*} loaded - control for the scrollToElement functionality
* @param {*} searchTerm - the search term
*/ */
function loadDomains(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded) { function loadDomains(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded, searchTerm = currentSearchTerm) {
//fetch json of page of domains, given page # and sort //fetch json of page of domains, given page # and sort
fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}`) fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.error) { if (data.error) {
@ -1051,23 +1118,17 @@ document.addEventListener('DOMContentLoaded', function() {
return; return;
} }
// handle the display of proper messaging in the event that no domains exist in the list // handle the display of proper messaging in the event that no domains exist in the list or search returns no results
if (data.domains.length) { updateDisplay(data, domainsWrapper, noDomainsWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm);
domainsWrapper.classList.remove('display-none');
noDomainsWrapper.classList.add('display-none');
} else {
domainsWrapper.classList.add('display-none');
noDomainsWrapper.classList.remove('display-none');
}
// identify the DOM element where the domain list will be inserted into the DOM // identify the DOM element where the domain list will be inserted into the DOM
const domainList = document.querySelector('.dotgov-table__registered-domains tbody'); const domainList = document.querySelector('.domains__table tbody');
domainList.innerHTML = ''; domainList.innerHTML = '';
data.domains.forEach(domain => { data.domains.forEach(domain => {
const options = { year: 'numeric', month: 'short', day: 'numeric' }; const options = { year: 'numeric', month: 'short', day: 'numeric' };
const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null; const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null;
const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : null; const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : '';
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
const actionUrl = domain.action_url; const actionUrl = domain.action_url;
@ -1106,9 +1167,10 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
// initialize tool tips immediately after the associated DOM elements are added // initialize tool tips immediately after the associated DOM elements are added
initializeTooltips(); initializeTooltips();
// Do not scroll on first page load
if (loaded) if (loaded)
ScrollToElement('id', 'domains-header'); ScrollToElement('id', 'domains-header');
hasLoaded = true; hasLoaded = true;
// update pagination // update pagination
@ -1122,18 +1184,18 @@ document.addEventListener('DOMContentLoaded', function() {
data.num_pages, data.num_pages,
data.has_previous, data.has_previous,
data.has_next, data.has_next,
data.total data.total,
currentSearchTerm
); );
currentSortBy = sortBy; currentSortBy = sortBy;
currentOrder = order; currentOrder = order;
currentSearchTerm = searchTerm;
}) })
.catch(error => console.error('Error fetching domains:', error)); .catch(error => console.error('Error fetching domains:', error));
} }
// Add event listeners to table headers for sorting // Add event listeners to table headers for sorting
document.querySelectorAll('.dotgov-table__registered-domains th[data-sortable]').forEach(header => { tableHeaders.forEach(header => {
header.addEventListener('click', function() { header.addEventListener('click', function() {
const sortBy = this.getAttribute('data-sortable'); const sortBy = this.getAttribute('data-sortable');
let order = 'asc'; let order = 'asc';
@ -1147,6 +1209,43 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
domainsSearchSubmit.addEventListener('click', function(e) {
e.preventDefault();
currentSearchTerm = domainsSearchInput.value;
// If the search is blank, we match the resetSearch functionality
if (currentSearchTerm) {
showElement(resetButton);
} else {
hideElement(resetButton);
}
loadDomains(1, 'id', 'asc');
resetHeaders();
})
// Reset UI and accessibility
function resetHeaders() {
tableHeaders.forEach(header => {
// Unset sort UI in headers
unsetHeader(header);
});
// Reset the announcement region
tableAnnouncementRegion.innerHTML = '';
}
function resetSearch() {
domainsSearchInput.value = '';
currentSearchTerm = '';
hideElement(resetButton);
loadDomains(1, 'id', 'asc', hasLoaded, '');
resetHeaders();
}
if (resetButton) {
resetButton.addEventListener('click', function() {
resetSearch();
});
}
// Load the first page initially // Load the first page initially
loadDomains(1); loadDomains(1);
} }
@ -1157,10 +1256,13 @@ const utcDateString = (dateString) => {
const utcYear = date.getUTCFullYear(); const utcYear = date.getUTCFullYear();
const utcMonth = date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' }); const utcMonth = date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
const utcDay = date.getUTCDate().toString().padStart(2, '0'); const utcDay = date.getUTCDate().toString().padStart(2, '0');
const utcHours = date.getUTCHours().toString().padStart(2, '0'); let utcHours = date.getUTCHours();
const utcMinutes = date.getUTCMinutes().toString().padStart(2, '0'); const utcMinutes = date.getUTCMinutes().toString().padStart(2, '0');
return `${utcMonth} ${utcDay}, ${utcYear}, ${utcHours}:${utcMinutes} UTC`; const ampm = utcHours >= 12 ? 'PM' : 'AM';
utcHours = utcHours % 12 || 12; // Convert to 12-hour format, '0' hours should be '12'
return `${utcMonth} ${utcDay}, ${utcYear}, ${utcHours}:${utcMinutes} ${ampm} UTC`;
}; };
/** /**
@ -1169,13 +1271,56 @@ const utcDateString = (dateString) => {
* *
*/ */
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
let domainRequestsWrapper = document.querySelector('.domain-requests-wrapper'); const domainRequestsSectionWrapper = document.querySelector('.domain-requests');
const domainRequestsWrapper = document.querySelector('.domain-requests__table-wrapper');
if (domainRequestsWrapper) { if (domainRequestsWrapper) {
let currentSortBy = 'id'; let currentSortBy = 'id';
let currentOrder = 'asc'; let currentOrder = 'asc';
let noDomainRequestsWrapper = document.querySelector('.no-domain-requests-wrapper'); const noDomainRequestsWrapper = document.querySelector('.domain-requests__no-data');
const noSearchResultsWrapper = document.querySelector('.domain-requests__no-search-results');
let hasLoaded = false; let hasLoaded = 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');
/**
* 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.
* @param {*} domainRequestPk - the identifier for the request that we're deleting
* @param {*} pageToDisplay - If we're deleting the last item on a page that is not page 1, we'll need to display the previous page
*/
function deleteDomainRequest(domainRequestPk,pageToDisplay) {
// Get csrf token
const csrfToken = getCsrfToken();
// Create FormData object and append the CSRF token
const formData = `csrfmiddlewaretoken=${encodeURIComponent(csrfToken)}&delete-domain-request=`;
fetch(`/domain-request/${domainRequestPk}/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken,
},
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Update data and UI
loadDomainRequests(pageToDisplay, currentSortBy, currentOrder, hasLoaded, currentSearchTerm);
})
.catch(error => console.error('Error fetching domain requests:', error));
}
// Helper function to get the CSRF token from the cookie
function getCsrfToken() {
return document.querySelector('input[name="csrfmiddlewaretoken"]').value;
}
/** /**
* Loads rows in the domain requests list, as well as updates pagination around the domain requests list * Loads rows in the domain requests list, as well as updates pagination around the domain requests list
@ -1184,10 +1329,11 @@ document.addEventListener('DOMContentLoaded', function() {
* @param {*} sortBy - the sort column option * @param {*} sortBy - the sort column option
* @param {*} order - the sort order {asc, desc} * @param {*} order - the sort order {asc, desc}
* @param {*} loaded - control for the scrollToElement functionality * @param {*} loaded - control for the scrollToElement functionality
* @param {*} searchTerm - the search term
*/ */
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded) { function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded, searchTerm = currentSearchTerm) {
//fetch json of page of domain requests, given page # and sort //fetch json of page of domain requests, given page # and sort
fetch(`/get-domain-requests-json/?page=${page}&sort_by=${sortBy}&order=${order}`) fetch(`/get-domain-requests-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.error) { if (data.error) {
@ -1195,29 +1341,68 @@ document.addEventListener('DOMContentLoaded', function() {
return; return;
} }
// handle the display of proper messaging in the event that no domain requests exist in the list // handle the display of proper messaging in the event that no requests exist in the list or search returns no results
if (data.domain_requests.length) { updateDisplay(data, domainRequestsWrapper, noDomainRequestsWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm);
domainRequestsWrapper.classList.remove('display-none');
noDomainRequestsWrapper.classList.add('display-none');
} else {
domainRequestsWrapper.classList.add('display-none');
noDomainRequestsWrapper.classList.remove('display-none');
}
// identify the DOM element where the domain request list will be inserted into the DOM // identify the DOM element where the domain request list will be inserted into the DOM
const tbody = document.querySelector('.dotgov-table__domain-requests tbody'); const tbody = document.querySelector('.domain-requests__table tbody');
tbody.innerHTML = ''; tbody.innerHTML = '';
// remove any existing modal elements from the DOM so they can be properly re-initialized // remove any existing modal elements from the DOM so they can be properly re-initialized
// after the DOM content changes and there are new delete modal buttons added // after the DOM content changes and there are new delete modal buttons added
unloadModals(); unloadModals();
let needsDeleteColumn = false;
needsDeleteColumn = data.domain_requests.some(request => request.is_deletable);
// Remove existing delete th and td if they exist
let existingDeleteTh = document.querySelector('.delete-header');
if (!needsDeleteColumn) {
if (existingDeleteTh)
existingDeleteTh.remove();
} else {
if (!existingDeleteTh) {
const delheader = document.createElement('th');
delheader.setAttribute('scope', 'col');
delheader.setAttribute('role', 'columnheader');
delheader.setAttribute('class', 'delete-header');
delheader.innerHTML = `
<span class="usa-sr-only">Delete Action</span>`;
let tableHeaderRow = document.querySelector('.domain-requests__table thead tr');
tableHeaderRow.appendChild(delheader);
}
}
data.domain_requests.forEach(request => { data.domain_requests.forEach(request => {
const options = { year: 'numeric', month: 'short', day: 'numeric' }; const options = { year: 'numeric', month: 'short', day: 'numeric' };
const domainName = request.requested_domain ? request.requested_domain : `New domain request <br><span class="text-base font-body-xs">(${utcDateString(request.created_at)})</span>`; const domainName = request.requested_domain ? request.requested_domain : `New domain request <br><span class="text-base font-body-xs">(${utcDateString(request.created_at)})</span>`;
const actionUrl = request.action_url; const actionUrl = request.action_url;
const actionLabel = request.action_label; const actionLabel = request.action_label;
const submissionDate = request.submission_date ? new Date(request.submission_date).toLocaleDateString('en-US', options) : `<span class="text-base">Not submitted</span>`; const submissionDate = request.submission_date ? new Date(request.submission_date).toLocaleDateString('en-US', options) : `<span class="text-base">Not submitted</span>`;
const deleteButton = request.is_deletable ? `
// Even if the request is not deletable, we may need this empty string for the td if the deletable column is displayed
let modalTrigger = '';
// If the request is deletable, create modal body and insert it
if (request.is_deletable) {
let modalHeading = '';
let modalDescription = '';
if (request.requested_domain) {
modalHeading = `Are you sure you want to delete ${request.requested_domain}?`;
modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.';
} else {
if (request.created_at) {
modalHeading = 'Are you sure you want to delete this domain request?';
modalDescription = `This will remove the domain request (created ${utcDateString(request.created_at)}) from the .gov registrar. This action cannot be undone`;
} else {
modalHeading = 'Are you sure you want to delete New domain request?';
modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.';
}
}
modalTrigger = `
<a <a
role="button" role="button"
id="button-toggle-delete-domain-alert-${request.id}" id="button-toggle-delete-domain-alert-${request.id}"
@ -1229,7 +1414,65 @@ document.addEventListener('DOMContentLoaded', function() {
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#delete"></use> <use xlink:href="/public/img/sprite.svg#delete"></use>
</svg> Delete <span class="usa-sr-only">${domainName}</span> </svg> Delete <span class="usa-sr-only">${domainName}</span>
</a>` : ''; </a>`
const modalSubmit = `
<button type="button"
class="usa-button usa-button--secondary usa-modal__submit"
data-pk = ${request.id}
name="delete-domain-request">Yes, delete request</button>
`
const modal = document.createElement('div');
modal.setAttribute('class', 'usa-modal');
modal.setAttribute('id', `toggle-delete-domain-alert-${request.id}`);
modal.setAttribute('aria-labelledby', 'Are you sure you want to continue?');
modal.setAttribute('aria-describedby', 'Domain will be removed');
modal.setAttribute('data-force-action', '');
modal.innerHTML = `
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
${modalHeading}
</h2>
<div class="usa-prose">
<p id="modal-1-description">
${modalDescription}
</p>
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
${modalSubmit}
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
data-close-modal
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="/public/img/sprite.svg#close"></use>
</svg>
</button>
</div>
`
domainRequestsSectionWrapper.appendChild(modal);
}
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = ` row.innerHTML = `
@ -1250,15 +1493,36 @@ document.addEventListener('DOMContentLoaded', function() {
${actionLabel} <span class="usa-sr-only">${request.requested_domain ? request.requested_domain : 'New domain request'}</span> ${actionLabel} <span class="usa-sr-only">${request.requested_domain ? request.requested_domain : 'New domain request'}</span>
</a> </a>
</td> </td>
<td>${deleteButton}</td> ${needsDeleteColumn ? '<td>'+modalTrigger+'</td>' : ''}
`; `;
tbody.appendChild(row); tbody.appendChild(row);
}); });
// initialize modals immediately after the DOM content is updated // initialize modals immediately after the DOM content is updated
initializeModals(); initializeModals();
// Now the DOM and modals are ready, add listeners to the submit buttons
const modals = document.querySelectorAll('.usa-modal__content');
modals.forEach(modal => {
const submitButton = modal.querySelector('.usa-modal__submit');
const closeButton = modal.querySelector('.usa-modal__close');
submitButton.addEventListener('click', function() {
pk = submitButton.getAttribute('data-pk');
// Close the modal to remove the USWDS UI local classes
closeButton.click();
// If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page
let pageToDisplay = data.page;
if (data.total == 1 && data.unfiltered_total > 1) {
pageToDisplay--;
}
deleteDomainRequest(pk, pageToDisplay);
});
});
// Do not scroll on first page load
if (loaded) if (loaded)
ScrollToElement('id', 'domain-requests-header'); ScrollToElement('id', 'domain-requests-header');
hasLoaded = true; hasLoaded = true;
// update the pagination after the domain requests list is updated // update the pagination after the domain requests list is updated
@ -1272,16 +1536,18 @@ document.addEventListener('DOMContentLoaded', function() {
data.num_pages, data.num_pages,
data.has_previous, data.has_previous,
data.has_next, data.has_next,
data.total data.total,
currentSearchTerm
); );
currentSortBy = sortBy; currentSortBy = sortBy;
currentOrder = order; currentOrder = order;
currentSearchTerm = searchTerm;
}) })
.catch(error => console.error('Error fetching domain requests:', error)); .catch(error => console.error('Error fetching domain requests:', error));
} }
// Add event listeners to table headers for sorting // Add event listeners to table headers for sorting
document.querySelectorAll('.dotgov-table__domain-requests th[data-sortable]').forEach(header => { tableHeaders.forEach(header => {
header.addEventListener('click', function() { header.addEventListener('click', function() {
const sortBy = this.getAttribute('data-sortable'); const sortBy = this.getAttribute('data-sortable');
let order = 'asc'; let order = 'asc';
@ -1294,6 +1560,43 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
domainRequestsSearchSubmit.addEventListener('click', function(e) {
e.preventDefault();
currentSearchTerm = domainRequestsSearchInput.value;
// If the search is blank, we match the resetSearch functionality
if (currentSearchTerm) {
showElement(resetButton);
} else {
hideElement(resetButton);
}
loadDomainRequests(1, 'id', 'asc');
resetHeaders();
})
// Reset UI and accessibility
function resetHeaders() {
tableHeaders.forEach(header => {
// unset sort UI in headers
unsetHeader(header);
});
// Reset the announcement region
tableAnnouncementRegion.innerHTML = '';
}
function resetSearch() {
domainRequestsSearchInput.value = '';
currentSearchTerm = '';
hideElement(resetButton);
loadDomainRequests(1, 'id', 'asc', hasLoaded, '');
resetHeaders();
}
if (resetButton) {
resetButton.addEventListener('click', function() {
resetSearch();
});
}
// Load the first page initially // Load the first page initially
loadDomainRequests(1); loadDomainRequests(1);
} }

View file

@ -782,3 +782,7 @@ div.dja__model-description{
padding: 6px 8px 10px 8px; padding: 6px 8px 10px 8px;
} }
} }
.usa-button--dja-link-color {
color: var(--link-fg);
}

View file

@ -98,7 +98,7 @@
} }
} }
@media (min-width: 1040px){ @media (min-width: 1040px){
.dotgov-table__domain-requests { .domain-requests__table {
th:nth-of-type(1) { th:nth-of-type(1) {
width: 200px; width: 200px;
} }
@ -122,7 +122,7 @@
} }
@media (min-width: 1040px){ @media (min-width: 1040px){
.dotgov-table__registered-domains { .domains__table {
th:nth-of-type(1) { th:nth-of-type(1) {
width: 200px; width: 200px;
} }

View file

@ -0,0 +1,18 @@
# Generated by Django 4.2.10 on 2024-06-14 19:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0101_domaininformation_cisa_representative_first_name_and_more"),
]
operations = [
migrations.AddField(
model_name="domain",
name="dsdata_last_change",
field=models.TextField(blank=True, help_text="Record of the last change event for ds data", null=True),
),
]

View file

@ -40,6 +40,8 @@ from .utility.time_stamped_model import TimeStampedModel
from .public_contact import PublicContact from .public_contact import PublicContact
from .user_domain_role import UserDomainRole
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -672,11 +674,29 @@ class Domain(TimeStampedModel, DomainHelper):
remRequest = commands.UpdateDomain(name=self.name) remRequest = commands.UpdateDomain(name=self.name)
remExtension = commands.UpdateDomainDNSSECExtension(**remParams) remExtension = commands.UpdateDomainDNSSECExtension(**remParams)
remRequest.add_extension(remExtension) remRequest.add_extension(remExtension)
dsdata_change_log = ""
# Get the user's email
user_domain_role = UserDomainRole.objects.filter(domain=self).first()
user_email = user_domain_role.user.email if user_domain_role else "unknown user"
try: try:
if "dsData" in _addDnssecdata and _addDnssecdata["dsData"] is not None: added_record = "dsData" in _addDnssecdata and _addDnssecdata["dsData"] is not None
deleted_record = "dsData" in _remDnssecdata and _remDnssecdata["dsData"] is not None
if added_record:
registry.send(addRequest, cleaned=True) registry.send(addRequest, cleaned=True)
if "dsData" in _remDnssecdata and _remDnssecdata["dsData"] is not None: dsdata_change_log = f"{user_email} added a DS data record"
if deleted_record:
registry.send(remRequest, cleaned=True) registry.send(remRequest, cleaned=True)
if dsdata_change_log != "": # if they add and remove a record at same time
dsdata_change_log = f"{user_email} added and deleted a DS data record"
else:
dsdata_change_log = f"{user_email} deleted a DS data record"
if dsdata_change_log != "":
self.dsdata_last_change = dsdata_change_log
self.save() # audit log will now record this as a change
except RegistryError as e: except RegistryError as e:
logger.error("Error updating DNSSEC, code was %s error was %s" % (e.code, e)) logger.error("Error updating DNSSEC, code was %s error was %s" % (e.code, e))
raise e raise e
@ -1057,6 +1077,12 @@ class Domain(TimeStampedModel, DomainHelper):
verbose_name="first ready on", verbose_name="first ready on",
) )
dsdata_last_change = TextField(
null=True,
blank=True,
help_text="Record of the last change event for ds data",
)
def isActive(self): def isActive(self):
return self.state == Domain.State.CREATED return self.state == Domain.State.CREATED

View file

@ -17,8 +17,6 @@ from .utility.time_stamped_model import TimeStampedModel
from ..utility.email import send_templated_email, EmailSendingError from ..utility.email import send_templated_email, EmailSendingError
from itertools import chain from itertools import chain
from auditlog.models import AuditlogHistoryField # type: ignore
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -35,11 +33,7 @@ class DomainRequest(TimeStampedModel):
] ]
# https://django-auditlog.readthedocs.io/en/latest/usage.html#object-history # https://django-auditlog.readthedocs.io/en/latest/usage.html#object-history
# If we note any performace degradation due to this addition, # history = AuditlogHistoryField()
# we can query the auditlogs table in admin.py and add the results to
# extra_context in the change_view method for DomainRequestAdmin.
# This is the more straightforward way so trying it first.
history = AuditlogHistoryField()
# Constants for choice fields # Constants for choice fields
class DomainRequestStatus(models.TextChoices): class DomainRequestStatus(models.TextChoices):
@ -262,6 +256,11 @@ class DomainRequest(TimeStampedModel):
NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met" NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met"
OTHER = "other", "Other/Unspecified" OTHER = "other", "Other/Unspecified"
@classmethod
def get_rejection_reason_label(cls, rejection_reason: str):
"""Returns the associated label for a given rejection reason"""
return cls(rejection_reason).label if rejection_reason else None
class ActionNeededReasons(models.TextChoices): class ActionNeededReasons(models.TextChoices):
"""Defines common action needed reasons for domain requests""" """Defines common action needed reasons for domain requests"""
@ -271,6 +270,11 @@ class DomainRequest(TimeStampedModel):
BAD_NAME = ("bad_name", "Doesnt meet naming requirements") BAD_NAME = ("bad_name", "Doesnt meet naming requirements")
OTHER = ("other", "Other (no auto-email sent)") OTHER = ("other", "Other (no auto-email sent)")
@classmethod
def get_action_needed_reason_label(cls, action_needed_reason: str):
"""Returns the associated label for a given action needed reason"""
return cls(action_needed_reason).label if action_needed_reason else None
# #### Internal fields about the domain request ##### # #### Internal fields about the domain request #####
status = FSMField( status = FSMField(
choices=DomainRequestStatus.choices, # possible states as an array of constants choices=DomainRequestStatus.choices, # possible states as an array of constants

View file

@ -68,11 +68,11 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endblock field_readonly %} {% endblock field_readonly %}
{% block after_help_text %} {% block after_help_text %}
{% if field.field.name == "status" and original_object.history.count > 0 %} {% if field.field.name == "status" and filtered_audit_log_entries %}
<div class="flex-container" id="dja-status-changelog"> <div class="flex-container" id="dja-status-changelog">
<label aria-label="Status changelog"></label> <label aria-label="Status changelog"></label>
<div> <div>
<div class="usa-table-container--scrollable collapse--dgsimple" tabindex="0"> <div class="usa-table-container--scrollable collapse--dgsimple collapsed" tabindex="0">
<table class="usa-table usa-table--borderless"> <table class="usa-table usa-table--borderless">
<thead> <thead>
<tr> <tr>
@ -82,24 +82,34 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for log_entry in original_object.history.all %} {% for entry in filtered_audit_log_entries %}
{% for key, value in log_entry.changes_display_dict.items %}
{% if key == "status" %}
<tr> <tr>
<td>{{ value.1|default:"None" }}</td> <td>
<td>{{ log_entry.actor|default:"None" }}</td> {% if entry.status %}
<td>{{ log_entry.timestamp|default:"None" }}</td> {{ entry.status|default:"Error" }}
</tr> {% else %}
Error
{% endif %} {% endif %}
{% endfor %}
{% if entry.rejection_reason %}
- {{ entry.rejection_reason|default:"Error" }}
{% endif %}
{% if entry.action_needed_reason %}
- {{ entry.action_needed_reason|default:"Error" }}
{% endif %}
</td>
<td>{{ entry.actor|default:"Error" }}</td>
<td>{{ entry.timestamp|date:"Y-m-d H:i:s" }}</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
<button type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-2 margin-bottom-1 margin-left-1"> <button type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-2 margin-bottom-1 margin-left-1">
<span>Hide details</span> <span>Show details</span>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="/public/img/sprite.svg#expand_less"></use> <use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg> </svg>
</button> </button>
</div> </div>
@ -174,5 +184,19 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endif %} {% endif %}
</span> </span>
</div> </div>
{% elif field.field.name == "investigator" and not field.is_readonly %}
<div class="flex-container">
<label aria-label="Assign yourself as the investigator"></label>
<button id="investigator__assign_self"
data-user-name="{{ request.user }}"
data-user-id="{{ request.user.id }}"
type="button"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-top-2 margin-bottom-1 margin-left-1">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="/public/img/sprite.svg#group_add"></use>
</svg>
<span>Assign to me</span>
</button>
</div>
{% endif %} {% endif %}
{% endblock after_help_text %} {% endblock after_help_text %}

View file

@ -23,10 +23,39 @@
</a> </a>
</p> </p>
<section class="section--outlined"> <section class="section--outlined domains">
<h2 id="domains-header">Domains</h2> <div class="grid-row">
<div class="domains-wrapper display-none"> <div class="mobile:grid-col-12 desktop:grid-col-6">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked dotgov-table__registered-domains"> <h2 id="domains-header" class="flex-6">Domains</h2>
</div>
<div class="mobile:grid-col-12 desktop:grid-col-6">
<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">
Reset
</button>
<label class="usa-sr-only" for="domains__search-field">Search</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>
</div>
<div class="domains__table-wrapper display-none">
<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> <caption class="sr-only">Your registered domains</caption>
<thead> <thead>
<tr> <tr>
@ -50,7 +79,7 @@
aria-live="polite" aria-live="polite"
></div> ></div>
</div> </div>
<div class="no-domains-wrapper display-none"> <div class="domains__no-data display-none">
<p>You don't have any registered domains.</p> <p>You don't have any registered domains.</p>
<p class="maxw-none clearfix"> <p class="maxw-none clearfix">
<a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet display-flex flex-align-start usa-link" target="_blank"> <a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet display-flex flex-align-start usa-link" target="_blank">
@ -61,6 +90,9 @@
</a> </a>
</p> </p>
</div> </div>
<div class="domains__no-search-results display-none">
<p>No results found for "<span class="domains__search-term"></span>"</p>
</div>
</section> </section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domains-pagination"> <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"> <span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
@ -71,10 +103,39 @@
</ul> </ul>
</nav> </nav>
<section class="section--outlined"> <section class="section--outlined domain-requests">
<h2 id="domain-requests-header">Domain requests</h2> <div class="grid-row">
<div class="domain-requests-wrapper display-none"> <div class="mobile:grid-col-12 desktop:grid-col-6">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked dotgov-table__domain-requests"> <h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
</div>
<div class="mobile:grid-col-12 desktop:grid-col-6">
<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">
Reset
</button>
<label class="usa-sr-only" for="domain-requests__search-field">Search</label>
<input
class="usa-input"
id="domain-requests__search-field"
type="search"
name="search"
placeholder="Search by domain name"
/>
<button class="usa-button" type="submit" id="domain-requests__search-field-submit">
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
</button>
</form>
</section>
</div>
</div>
<div class="domain-requests__table-wrapper display-none">
<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> <caption class="sr-only">Your domain requests</caption>
<thead> <thead>
<tr> <tr>
@ -82,7 +143,7 @@
<th data-sortable="submission_date" scope="col" role="columnheader">Date submitted</th> <th data-sortable="submission_date" scope="col" role="columnheader">Date submitted</th>
<th data-sortable="status" scope="col" role="columnheader">Status</th> <th data-sortable="status" scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th> <th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
<th scope="col" role="columnheader"><span class="usa-sr-only">Delete Action</span></th> <!-- AJAX will conditionally add a th for delete actions -->
</tr> </tr>
</thead> </thead>
<tbody id="domain-requests-tbody"> <tbody id="domain-requests-tbody">
@ -93,45 +154,13 @@
class="usa-sr-only usa-table__announcement-region" class="usa-sr-only usa-table__announcement-region"
aria-live="polite" aria-live="polite"
></div> ></div>
{% for domain_request in domain_requests %}
{% if has_deletable_domain_requests %}
{% if domain_request.status == domain_request.DomainRequestStatus.STARTED or domain_request.status == domain_request.DomainRequestStatus.WITHDRAWN %}
<div
class="usa-modal"
id="toggle-delete-domain-alert-{{ domain_request.id }}"
aria-labelledby="Are you sure you want to continue?"
aria-describedby="Domain will be removed"
data-force-action
>
<form method="POST" action="{% url "domain-request-delete" pk=domain_request.id %}">
{% if domain_request.requested_domain is None %}
{% if domain_request.created_at %}
{% with prefix="(created " %}
{% with formatted_date=domain_request.created_at|date:"DATETIME_FORMAT" %}
{% with modal_content=prefix|add:formatted_date|add:" UTC)" %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to delete this domain request?" modal_description="This will remove the domain request "|add:modal_content|add:" from the .gov registrar. This action cannot be undone." modal_button=modal_button|safe %}
{% endwith %}
{% endwith %}
{% endwith %}
{% else %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to delete New domain request?" modal_description="This will remove the domain request from the .gov registrar. This action cannot be undone." modal_button=modal_button|safe %}
{% endif %}
{% else %}
{% with modal_heading_value=domain_request.requested_domain.name|add:"?" %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to delete" heading_value=modal_heading_value modal_description="This will remove the domain request from the .gov registrar. This action cannot be undone." modal_button=modal_button|safe %}
{% endwith %}
{% endif %}
</form>
</div> </div>
{% endif %} <div class="domain-requests__no-data display-none">
{% endif %}
{% endfor %}
</div>
<div class="no-domain-requests-wrapper display-none">
<p>You haven't requested any domains.</p> <p>You haven't requested any domains.</p>
</div> </div>
<div class="domain-requests__no-search-results display-none">
<p>No results found for "<span class="domain-requests__search-term"></span>"</p>
</div>
</section> </section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination"> <nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1"> <span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">

View file

@ -1055,6 +1055,18 @@ class TestDomainRequestAdmin(MockEppLib):
accurately and in chronological order. accurately and in chronological order.
""" """
def assert_status_count(normalized_content, status, count):
"""Helper function to assert the count of a status in the HTML content."""
self.assertEqual(normalized_content.count(f"<td> {status} </td>"), count)
def assert_status_order(normalized_content, statuses):
"""Helper function to assert the order of statuses in the HTML content."""
start_index = 0
for status in statuses:
index = normalized_content.find(f"<td> {status} </td>", start_index)
self.assertNotEqual(index, -1, f"Status '{status}' not found in the expected order.")
start_index = index + len(status)
# Create a fake domain request and domain # Create a fake domain request and domain
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.STARTED) domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.STARTED)
@ -1069,48 +1081,23 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, domain_request.requested_domain.name) self.assertContains(response, domain_request.requested_domain.name)
# Table will contain one row for Started
self.assertContains(response, "<td>Started</td>", count=1)
self.assertNotContains(response, "<td>Submitted</td>")
domain_request.submit() domain_request.submit()
domain_request.save() domain_request.save()
response = self.client.get(
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
follow=True,
)
# Table will contain and extra row for Submitted
self.assertContains(response, "<td>Started</td>", count=1)
self.assertContains(response, "<td>Submitted</td>", count=1)
domain_request.in_review() domain_request.in_review()
domain_request.save() domain_request.save()
response = self.client.get(
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
follow=True,
)
# Table will contain and extra row for In review
self.assertContains(response, "<td>Started</td>", count=1)
self.assertContains(response, "<td>Submitted</td>", count=1)
self.assertContains(response, "<td>In review</td>", count=1)
domain_request.action_needed() domain_request.action_needed()
domain_request.action_needed_reason = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS
domain_request.save() domain_request.save()
response = self.client.get( # Let's just change the action needed reason
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), domain_request.action_needed_reason = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR
follow=True, domain_request.save()
)
# Table will contain and extra row for Action needed domain_request.reject()
self.assertContains(response, "<td>Started</td>", count=1) domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE
self.assertContains(response, "<td>Submitted</td>", count=1) domain_request.save()
self.assertContains(response, "<td>In review</td>", count=1)
self.assertContains(response, "<td>Action needed</td>", count=1)
domain_request.in_review() domain_request.in_review()
domain_request.save() domain_request.save()
@ -1120,24 +1107,28 @@ class TestDomainRequestAdmin(MockEppLib):
follow=True, follow=True,
) )
# Normalize the HTML response content
normalized_content = " ".join(response.content.decode("utf-8").split())
# Define the expected sequence of status changes # Define the expected sequence of status changes
expected_status_changes = [ expected_status_changes = [
"<td>In review</td>", "In review",
"<td>Action needed</td>", "Rejected - Purpose requirements not met",
"<td>In review</td>", "Action needed - Unclear organization eligibility",
"<td>Submitted</td>", "Action needed - Already has domains",
"<td>Started</td>", "In review",
"Submitted",
"Started",
] ]
# Test for the order of status changes assert_status_order(normalized_content, expected_status_changes)
for status_change in expected_status_changes:
self.assertContains(response, status_change, html=True)
# Table now contains 2 rows for Approved assert_status_count(normalized_content, "Started", 1)
self.assertContains(response, "<td>Started</td>", count=1) assert_status_count(normalized_content, "Submitted", 1)
self.assertContains(response, "<td>Submitted</td>", count=1) assert_status_count(normalized_content, "In review", 2)
self.assertContains(response, "<td>In review</td>", count=2) assert_status_count(normalized_content, "Action needed - Already has domains", 1)
self.assertContains(response, "<td>Action needed</td>", count=1) assert_status_count(normalized_content, "Action needed - Unclear organization eligibility", 1)
assert_status_count(normalized_content, "Rejected - Purpose requirements not met", 1)
def test_collaspe_toggle_button_markup(self): def test_collaspe_toggle_button_markup(self):
""" """

View file

@ -1901,12 +1901,8 @@ class TestRegistrantDNSSEC(MockEppLib):
3 - setter adds the UpdateDNSSECExtension extension to the command 3 - setter adds the UpdateDNSSECExtension extension to the command
4 - setter causes the getter to call info domain on next get from cache 4 - setter causes the getter to call info domain on next get from cache
5 - getter properly parses dnssecdata from InfoDomain response and sets to cache 5 - getter properly parses dnssecdata from InfoDomain response and sets to cache
""" """
# need to use a separate patcher and side_effect for this test, as
# response from InfoDomain must be different for different iterations
# of the same command
def side_effect(_request, cleaned): def side_effect(_request, cleaned):
if isinstance(_request, commands.InfoDomain): if isinstance(_request, commands.InfoDomain):
if mocked_send.call_count == 1: if mocked_send.call_count == 1:
@ -1924,17 +1920,30 @@ class TestRegistrantDNSSEC(MockEppLib):
mocked_send = patcher.start() mocked_send = patcher.start()
mocked_send.side_effect = side_effect mocked_send.side_effect = side_effect
domain, _ = Domain.objects.get_or_create(name="dnssec-dsdata.gov") domain, _ = Domain.objects.get_or_create(name="dnssec-dsdata.gov")
# Check initial dsdata_last_change value (should be None)
initial_change = domain.dsdata_last_change
# Adding dnssec data
domain.dnssecdata = self.dnssecExtensionWithDsData domain.dnssecdata = self.dnssecExtensionWithDsData
# get the DNS SEC extension added to the UpdateDomain command and
# Check dsdata_last_change is updated after adding data
domain = Domain.objects.get(name="dnssec-dsdata.gov")
self.assertIsNotNone(domain.dsdata_last_change)
self.assertNotEqual(domain.dsdata_last_change, initial_change)
# Get the DNS SEC extension added to the UpdateDomain command and
# verify that it is properly sent # verify that it is properly sent
# args[0] is the _request sent to registry # args[0] is the _request sent to registry
args, _ = mocked_send.call_args args, _ = mocked_send.call_args
# assert that the extension on the update matches # Assert that the extension on the update matches
self.assertEquals( self.assertEquals(
args[0].extensions[0], args[0].extensions[0],
self.createUpdateExtension(self.dnssecExtensionWithDsData), self.createUpdateExtension(self.dnssecExtensionWithDsData),
) )
# test that the dnssecdata getter is functioning properly
# Test that the dnssecdata getter is functioning properly
dnssecdata_get = domain.dnssecdata dnssecdata_get = domain.dnssecdata
mocked_send.assert_has_calls( mocked_send.assert_has_calls(
[ [
@ -2129,13 +2138,9 @@ class TestRegistrantDNSSEC(MockEppLib):
2 - first setter calls UpdateDomain command 2 - first setter calls UpdateDomain command
3 - second setter calls InfoDomain command again 3 - second setter calls InfoDomain command again
3 - setter then calls UpdateDomain command 3 - setter then calls UpdateDomain command
4 - setter adds the UpdateDNSSECExtension extension to the command with rem 4 - setter adds the UpdateDNSSExtension extension to the command with rem
""" """
# need to use a separate patcher and side_effect for this test, as
# response from InfoDomain must be different for different iterations
# of the same command
def side_effect(_request, cleaned): def side_effect(_request, cleaned):
if isinstance(_request, commands.InfoDomain): if isinstance(_request, commands.InfoDomain):
if mocked_send.call_count == 1: if mocked_send.call_count == 1:
@ -2153,10 +2158,25 @@ class TestRegistrantDNSSEC(MockEppLib):
mocked_send = patcher.start() mocked_send = patcher.start()
mocked_send.side_effect = side_effect mocked_send.side_effect = side_effect
domain, _ = Domain.objects.get_or_create(name="dnssec-dsdata.gov") domain, _ = Domain.objects.get_or_create(name="dnssec-dsdata.gov")
# dnssecdata_get_initial = domain.dnssecdata # call to force initial mock
# domain._invalidate_cache() # Initial setting of dnssec data
domain.dnssecdata = self.dnssecExtensionWithDsData domain.dnssecdata = self.dnssecExtensionWithDsData
# Check dsdata_last_change is updated
domain = Domain.objects.get(name="dnssec-dsdata.gov")
self.assertIsNotNone(domain.dsdata_last_change)
initial_change = domain.dsdata_last_change
# Remove dnssec data
domain.dnssecdata = self.dnssecExtensionRemovingDsData domain.dnssecdata = self.dnssecExtensionRemovingDsData
# Check that dsdata_last_change is updated again
domain = Domain.objects.get(name="dnssec-dsdata.gov")
self.assertIsNotNone(domain.dsdata_last_change)
self.assertNotEqual(domain.dsdata_last_change, initial_change)
# get the DNS SEC extension added to the UpdateDomain command and # get the DNS SEC extension added to the UpdateDomain command and
# verify that it is properly sent # verify that it is properly sent
# args[0] is the _request sent to registry # args[0] is the _request sent to registry

View file

@ -384,15 +384,15 @@ class HomeTests(TestWithUser):
) )
domain_request_2.other_contacts.set([contact_shared]) domain_request_2.other_contacts.set([contact_shared])
# Ensure that igorville.gov exists on the page igorville = DomainRequest.objects.filter(requested_domain__name="igorville.gov")
home_page = self.client.get("/") self.assertTrue(igorville.exists())
self.assertContains(home_page, "igorville.gov")
# Trigger the delete logic # Trigger the delete logic
response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True) self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}))
# igorville is now deleted # igorville is now deleted
self.assertNotContains(response, "igorville.gov") igorville = DomainRequest.objects.filter(requested_domain__name="igorville.gov")
self.assertFalse(igorville.exists())
# Check if the orphaned contact was deleted # Check if the orphaned contact was deleted
orphan = Contact.objects.filter(id=contact.id) orphan = Contact.objects.filter(id=contact.id)
@ -456,13 +456,14 @@ class HomeTests(TestWithUser):
) )
domain_request_2.other_contacts.set([contact_shared]) domain_request_2.other_contacts.set([contact_shared])
home_page = self.client.get("/") teaville = DomainRequest.objects.filter(requested_domain__name="teaville.gov")
self.assertContains(home_page, "teaville.gov") self.assertTrue(teaville.exists())
# Trigger the delete logic # Trigger the delete logic
response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request_2.pk}), follow=True) self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request_2.pk}))
self.assertNotContains(response, "teaville.gov") teaville = DomainRequest.objects.filter(requested_domain__name="teaville.gov")
self.assertFalse(teaville.exists())
# Check if the orphaned contact was deleted # Check if the orphaned contact was deleted
orphan = Contact.objects.filter(id=contact_shared.id) orphan = Contact.objects.filter(id=contact_shared.id)

View file

@ -102,6 +102,35 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
) )
self.assertEqual(svg_icon_expected, svg_icons[i]) self.assertEqual(svg_icon_expected, svg_icons[i])
def test_get_domains_json_search(self):
"""Test search."""
# Define your URL variables as a dictionary
url_vars = {"search_term": "e2"}
# Use the params parameter to include URL variables
response = self.app.get(reverse("get_domains_json"), params=url_vars)
self.assertEqual(response.status_code, 200)
data = response.json
# Check pagination info
self.assertEqual(data["page"], 1)
self.assertFalse(data["has_next"])
self.assertFalse(data["has_previous"])
self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 1)
self.assertEqual(data["unfiltered_total"], 3)
# Check the number of domain requests
self.assertEqual(len(data["domains"]), 1)
# Extract fields from response
domains = [request["name"] for request in data["domains"]]
self.assertEqual(
self.domain2.name,
domains[0],
)
def test_pagination(self): def test_pagination(self):
"""Test that pagination is correct in the response""" """Test that pagination is correct in the response"""
response = self.app.get(reverse("get_domains_json"), {"page": 1}) response = self.app.get(reverse("get_domains_json"), {"page": 1})

View file

@ -1,5 +1,7 @@
from registrar.models import DomainRequest from registrar.models import DomainRequest
from django.urls import reverse from django.urls import reverse
from registrar.models.draft_domain import DraftDomain
from .test_views import TestWithUser from .test_views import TestWithUser
from django_webtest import WebTest # type: ignore from django_webtest import WebTest # type: ignore
from django.utils.dateparse import parse_datetime from django.utils.dateparse import parse_datetime
@ -10,32 +12,37 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
super().setUp() super().setUp()
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
lamb_chops, _ = DraftDomain.objects.get_or_create(name="lamb-chops.gov")
short_ribs, _ = DraftDomain.objects.get_or_create(name="short-ribs.gov")
beef_chuck, _ = DraftDomain.objects.get_or_create(name="beef-chuck.gov")
stew_beef, _ = DraftDomain.objects.get_or_create(name="stew-beef.gov")
# Create domain requests for the user # Create domain requests for the user
self.domain_requests = [ self.domain_requests = [
DomainRequest.objects.create( DomainRequest.objects.create(
creator=self.user, creator=self.user,
requested_domain=None, requested_domain=lamb_chops,
submission_date="2024-01-01", submission_date="2024-01-01",
status=DomainRequest.DomainRequestStatus.STARTED, status=DomainRequest.DomainRequestStatus.STARTED,
created_at="2024-01-01", created_at="2024-01-01",
), ),
DomainRequest.objects.create( DomainRequest.objects.create(
creator=self.user, creator=self.user,
requested_domain=None, requested_domain=short_ribs,
submission_date="2024-02-01", submission_date="2024-02-01",
status=DomainRequest.DomainRequestStatus.WITHDRAWN, status=DomainRequest.DomainRequestStatus.WITHDRAWN,
created_at="2024-02-01", created_at="2024-02-01",
), ),
DomainRequest.objects.create( DomainRequest.objects.create(
creator=self.user, creator=self.user,
requested_domain=None, requested_domain=beef_chuck,
submission_date="2024-03-01", submission_date="2024-03-01",
status=DomainRequest.DomainRequestStatus.REJECTED, status=DomainRequest.DomainRequestStatus.REJECTED,
created_at="2024-03-01", created_at="2024-03-01",
), ),
DomainRequest.objects.create( DomainRequest.objects.create(
creator=self.user, creator=self.user,
requested_domain=None, requested_domain=stew_beef,
submission_date="2024-04-01", submission_date="2024-04-01",
status=DomainRequest.DomainRequestStatus.STARTED, status=DomainRequest.DomainRequestStatus.STARTED,
created_at="2024-04-01", created_at="2024-04-01",
@ -195,6 +202,61 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
) )
self.assertEqual(svg_icon_expected, svg_icons[i]) self.assertEqual(svg_icon_expected, svg_icons[i])
def test_get_domain_requests_json_search(self):
"""Test search."""
# Define your URL variables as a dictionary
url_vars = {"search_term": "lamb"}
# Use the params parameter to include URL variables
response = self.app.get(reverse("get_domain_requests_json"), params=url_vars)
self.assertEqual(response.status_code, 200)
data = response.json
# Check pagination info
self.assertEqual(data["page"], 1)
self.assertFalse(data["has_next"])
self.assertFalse(data["has_previous"])
self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 1)
self.assertEqual(data["unfiltered_total"], 12)
# Check the number of domain requests
self.assertEqual(len(data["domain_requests"]), 1)
# Extract fields from response
requested_domains = [request["requested_domain"] for request in data["domain_requests"]]
self.assertEqual(
self.domain_requests[0].requested_domain.name,
requested_domains[0],
)
def test_get_domain_requests_json_search_new_domains(self):
"""Test search when looking up New domain requests"""
# Define your URL variables as a dictionary
url_vars = {"search_term": "ew"}
# Use the params parameter to include URL variables
response = self.app.get(reverse("get_domain_requests_json"), params=url_vars)
self.assertEqual(response.status_code, 200)
data = response.json
# Check pagination info
pagination_fields = ["page", "has_next", "has_previous", "num_pages", "total", "unfiltered_total"]
expected_pagination_values = [1, False, False, 1, 9, 12]
for field, expected_value in zip(pagination_fields, expected_pagination_values):
self.assertEqual(data[field], expected_value)
# Check the number of domain requests
self.assertEqual(len(data["domain_requests"]), 9)
# Extract fields from response
requested_domains = [request.get("requested_domain") for request in data["domain_requests"]]
expected_domain_values = ["stew-beef.gov"] + [None] * 8
for expected_value, actual_value in zip(expected_domain_values, requested_domains):
self.assertEqual(expected_value, actual_value)
def test_pagination(self): def test_pagination(self):
"""Test that pagination works properly. There are 11 total non-approved requests and """Test that pagination works properly. There are 11 total non-approved requests and
a page size of 10""" a page size of 10"""

View file

@ -798,7 +798,8 @@ class DomainRequestDeleteView(DomainRequestPermissionDeleteView):
contacts_to_delete, duplicates = self._get_orphaned_contacts(domain_request) contacts_to_delete, duplicates = self._get_orphaned_contacts(domain_request)
# Delete the DomainRequest # Delete the DomainRequest
response = super().post(request, *args, **kwargs) self.object = self.get_object()
self.object.delete()
# Delete orphaned contacts - but only for if they are not associated with a user # Delete orphaned contacts - but only for if they are not associated with a user
Contact.objects.filter(id__in=contacts_to_delete, user=None).delete() Contact.objects.filter(id__in=contacts_to_delete, user=None).delete()
@ -810,7 +811,8 @@ class DomainRequestDeleteView(DomainRequestPermissionDeleteView):
duplicates_to_delete, _ = self._get_orphaned_contacts(domain_request, check_db=True) duplicates_to_delete, _ = self._get_orphaned_contacts(domain_request, check_db=True)
Contact.objects.filter(id__in=duplicates_to_delete, user=None).delete() Contact.objects.filter(id__in=duplicates_to_delete, user=None).delete()
return response # Return a 200 response with an empty body
return HttpResponse(status=200)
def _get_orphaned_contacts(self, domain_request: DomainRequest, check_db=False): def _get_orphaned_contacts(self, domain_request: DomainRequest, check_db=False):
""" """

View file

@ -4,6 +4,7 @@ from registrar.models import DomainRequest
from django.utils.dateformat import format from django.utils.dateformat import format
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.urls import reverse from django.urls import reverse
from django.db.models import Q
@login_required @login_required
@ -14,9 +15,27 @@ def get_domain_requests_json(request):
domain_requests = DomainRequest.objects.filter(creator=request.user).exclude( domain_requests = DomainRequest.objects.filter(creator=request.user).exclude(
status=DomainRequest.DomainRequestStatus.APPROVED status=DomainRequest.DomainRequestStatus.APPROVED
) )
unfiltered_total = domain_requests.count()
# Handle sorting # Handle sorting
sort_by = request.GET.get("sort_by", "id") # Default to 'id' sort_by = request.GET.get("sort_by", "id") # Default to 'id'
order = request.GET.get("order", "asc") # Default to 'asc' order = request.GET.get("order", "asc") # Default to 'asc'
search_term = request.GET.get("search_term")
if search_term:
search_term_lower = search_term.lower()
new_domain_request_text = "new domain request"
# Check if the search term is a substring of 'New domain request'
# If yes, we should return domain requests that do not have a
# requested_domain (those display as New domain request in the UI)
if search_term_lower in new_domain_request_text:
domain_requests = domain_requests.filter(
Q(requested_domain__name__icontains=search_term) | Q(requested_domain__isnull=True)
)
else:
domain_requests = domain_requests.filter(Q(requested_domain__name__icontains=search_term))
if order == "desc": if order == "desc":
sort_by = f"-{sort_by}" sort_by = f"-{sort_by}"
domain_requests = domain_requests.order_by(sort_by) domain_requests = domain_requests.order_by(sort_by)
@ -75,5 +94,6 @@ def get_domain_requests_json(request):
"page": page_obj.number, "page": page_obj.number,
"num_pages": paginator.num_pages, "num_pages": paginator.num_pages,
"total": paginator.count, "total": paginator.count,
"unfiltered_total": unfiltered_total,
} }
) )

View file

@ -3,6 +3,7 @@ from django.core.paginator import Paginator
from registrar.models import UserDomainRole, Domain from registrar.models import UserDomainRole, Domain
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.urls import reverse from django.urls import reverse
from django.db.models import Q
@login_required @login_required
@ -14,10 +15,15 @@ def get_domains_json(request):
domain_ids = user_domain_roles.values_list("domain_id", flat=True) domain_ids = user_domain_roles.values_list("domain_id", flat=True)
objects = Domain.objects.filter(id__in=domain_ids) objects = Domain.objects.filter(id__in=domain_ids)
unfiltered_total = objects.count()
# Handle sorting # Handle sorting
sort_by = request.GET.get("sort_by", "id") # Default to 'id' sort_by = request.GET.get("sort_by", "id") # Default to 'id'
order = request.GET.get("order", "asc") # Default to 'asc' order = request.GET.get("order", "asc") # Default to 'asc'
search_term = request.GET.get("search_term")
if search_term:
objects = objects.filter(Q(name__icontains=search_term))
if sort_by == "state_display": if sort_by == "state_display":
# Fetch the objects and sort them in Python # Fetch the objects and sort them in Python
@ -56,5 +62,6 @@ def get_domains_json(request):
"has_previous": page_obj.has_previous(), "has_previous": page_obj.has_previous(),
"has_next": page_obj.has_next(), "has_next": page_obj.has_next(),
"total": paginator.count, "total": paginator.count,
"unfiltered_total": unfiltered_total,
} }
) )

View file

@ -1,5 +1,4 @@
from django.shortcuts import render from django.shortcuts import render
from registrar.models import DomainRequest
from waffle.decorators import flag_is_active from waffle.decorators import flag_is_active
@ -8,46 +7,7 @@ def index(request):
context = {} context = {}
if request.user.is_authenticated: if request.user.is_authenticated:
# Get all domain requests the user has access to
domain_requests, deletable_domain_requests = _get_domain_requests(request)
context["domain_requests"] = domain_requests
# Determine if the user will see domain requests that they can delete
has_deletable_domain_requests = deletable_domain_requests.exists()
context["has_deletable_domain_requests"] = has_deletable_domain_requests
# This is a django waffle flag which toggles features based off of the "flag" table # This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature") context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
# If they can delete domain requests, add the delete button to the context
if has_deletable_domain_requests:
# Add the delete modal button to the context
modal_button = (
'<button type="submit" '
'class="usa-button usa-button--secondary" '
'name="delete-domain-request">Yes, delete request</button>'
)
context["modal_button"] = modal_button
return render(request, "home.html", context) return render(request, "home.html", context)
def _get_domain_requests(request):
"""Given the current request,
get all DomainRequests that are associated with the UserDomainRole object.
Returns a tuple of all domain requests, and those that are deletable by the user.
"""
# Let's exclude the approved domain requests since our
# domain_requests context will be used to populate
# the active domain requests table
domain_requests = DomainRequest.objects.filter(creator=request.user).exclude(
status=DomainRequest.DomainRequestStatus.APPROVED
)
# Create a placeholder DraftDomain for each incomplete draft
valid_statuses = [DomainRequest.DomainRequestStatus.STARTED, DomainRequest.DomainRequestStatus.WITHDRAWN]
deletable_domain_requests = domain_requests.filter(status__in=valid_statuses)
return (domain_requests, deletable_domain_requests)