mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-18 18:39:21 +02:00
Merge remote-tracking branch 'origin/main' into nl/2248-add-org-and-portfolio-table
This commit is contained in:
commit
c7f7898d04
20 changed files with 885 additions and 260 deletions
2
.github/workflows/test.yaml
vendored
2
.github/workflows/test.yaml
vendored
|
@ -70,6 +70,6 @@ jobs:
|
|||
- name: run pa11y
|
||||
working-directory: ./src
|
||||
run: |
|
||||
sleep 10;
|
||||
sleep 20;
|
||||
npm i -g pa11y-ci
|
||||
pa11y-ci
|
||||
|
|
|
@ -33,6 +33,7 @@ from django.contrib.auth.forms import UserChangeForm, UsernameField
|
|||
from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter
|
||||
from import_export import resources
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
@ -1818,10 +1819,93 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
return response
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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):
|
||||
"""Custom transition domain admin class."""
|
||||
|
|
|
@ -137,6 +137,47 @@ function openInNewTab(el, removeAttribute = false){
|
|||
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
|
||||
*/
|
||||
(function (){
|
||||
|
|
|
@ -17,6 +17,22 @@ var SUCCESS = "success";
|
|||
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
|
||||
// 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. */
|
||||
function makeHidden(el) {
|
||||
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} hasNext - Whether there is a page after the current page.
|
||||
* @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 paginationCounter = document.querySelector(counterSelector);
|
||||
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
|
||||
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) {
|
||||
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
|
||||
|
@ -1025,13 +1083,21 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
|
|||
*
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let domainsWrapper = document.querySelector('.domains-wrapper');
|
||||
const domainsWrapper = document.querySelector('.domains__table-wrapper');
|
||||
|
||||
if (domainsWrapper) {
|
||||
let currentSortBy = 'id';
|
||||
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 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
|
||||
|
@ -1040,10 +1106,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
* @param {*} sortBy - the sort column option
|
||||
* @param {*} order - the sort order {asc, desc}
|
||||
* @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(`/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(data => {
|
||||
if (data.error) {
|
||||
|
@ -1051,23 +1118,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
return;
|
||||
}
|
||||
|
||||
// handle the display of proper messaging in the event that no domains exist in the list
|
||||
if (data.domains.length) {
|
||||
domainsWrapper.classList.remove('display-none');
|
||||
noDomainsWrapper.classList.add('display-none');
|
||||
} else {
|
||||
domainsWrapper.classList.add('display-none');
|
||||
noDomainsWrapper.classList.remove('display-none');
|
||||
}
|
||||
// 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);
|
||||
|
||||
// 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 = '';
|
||||
|
||||
data.domains.forEach(domain => {
|
||||
const options = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||
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 actionUrl = domain.action_url;
|
||||
|
||||
|
@ -1106,9 +1167,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
});
|
||||
// initialize tool tips immediately after the associated DOM elements are added
|
||||
initializeTooltips();
|
||||
|
||||
// Do not scroll on first page load
|
||||
if (loaded)
|
||||
ScrollToElement('id', 'domains-header');
|
||||
|
||||
hasLoaded = true;
|
||||
|
||||
// update pagination
|
||||
|
@ -1122,18 +1184,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
data.num_pages,
|
||||
data.has_previous,
|
||||
data.has_next,
|
||||
data.total
|
||||
data.total,
|
||||
currentSearchTerm
|
||||
);
|
||||
currentSortBy = sortBy;
|
||||
currentOrder = order;
|
||||
currentSearchTerm = searchTerm;
|
||||
})
|
||||
.catch(error => console.error('Error fetching domains:', error));
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 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() {
|
||||
const sortBy = this.getAttribute('data-sortable');
|
||||
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
|
||||
loadDomains(1);
|
||||
}
|
||||
|
@ -1157,25 +1256,71 @@ const utcDateString = (dateString) => {
|
|||
const utcYear = date.getUTCFullYear();
|
||||
const utcMonth = date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
|
||||
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');
|
||||
|
||||
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`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* initializes the domain requests list and associated functionality on the home page of the app.
|
||||
*
|
||||
*/
|
||||
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) {
|
||||
let currentSortBy = 'id';
|
||||
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 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
|
||||
|
@ -1184,10 +1329,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
* @param {*} sortBy - the sort column option
|
||||
* @param {*} order - the sort order {asc, desc}
|
||||
* @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(`/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(data => {
|
||||
if (data.error) {
|
||||
|
@ -1195,41 +1341,138 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
return;
|
||||
}
|
||||
|
||||
// handle the display of proper messaging in the event that no domain requests exist in the list
|
||||
if (data.domain_requests.length) {
|
||||
domainRequestsWrapper.classList.remove('display-none');
|
||||
noDomainRequestsWrapper.classList.add('display-none');
|
||||
} else {
|
||||
domainRequestsWrapper.classList.add('display-none');
|
||||
noDomainRequestsWrapper.classList.remove('display-none');
|
||||
}
|
||||
// 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);
|
||||
|
||||
// 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 = '';
|
||||
|
||||
// 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
|
||||
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 => {
|
||||
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 actionUrl = request.action_url;
|
||||
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 deleteButton = request.is_deletable ? `
|
||||
<a
|
||||
role="button"
|
||||
id="button-toggle-delete-domain-alert-${request.id}"
|
||||
href="#toggle-delete-domain-alert-${request.id}"
|
||||
class="usa-button--unstyled text-no-underline late-loading-modal-trigger"
|
||||
aria-controls="toggle-delete-domain-alert-${request.id}"
|
||||
data-open-modal
|
||||
>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#delete"></use>
|
||||
</svg> Delete <span class="usa-sr-only">${domainName}</span>
|
||||
</a>` : '';
|
||||
|
||||
// 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
|
||||
role="button"
|
||||
id="button-toggle-delete-domain-alert-${request.id}"
|
||||
href="#toggle-delete-domain-alert-${request.id}"
|
||||
class="usa-button--unstyled text-no-underline late-loading-modal-trigger"
|
||||
aria-controls="toggle-delete-domain-alert-${request.id}"
|
||||
data-open-modal
|
||||
>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#delete"></use>
|
||||
</svg> Delete <span class="usa-sr-only">${domainName}</span>
|
||||
</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');
|
||||
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>
|
||||
</a>
|
||||
</td>
|
||||
<td>${deleteButton}</td>
|
||||
${needsDeleteColumn ? '<td>'+modalTrigger+'</td>' : ''}
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
// initialize modals immediately after the DOM content is updated
|
||||
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)
|
||||
ScrollToElement('id', 'domain-requests-header');
|
||||
|
||||
hasLoaded = true;
|
||||
|
||||
// update the pagination after the domain requests list is updated
|
||||
|
@ -1272,16 +1536,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
data.num_pages,
|
||||
data.has_previous,
|
||||
data.has_next,
|
||||
data.total
|
||||
data.total,
|
||||
currentSearchTerm
|
||||
);
|
||||
currentSortBy = sortBy;
|
||||
currentOrder = order;
|
||||
currentSearchTerm = searchTerm;
|
||||
})
|
||||
.catch(error => console.error('Error fetching domain requests:', error));
|
||||
}
|
||||
|
||||
// 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() {
|
||||
const sortBy = this.getAttribute('data-sortable');
|
||||
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
|
||||
loadDomainRequests(1);
|
||||
}
|
||||
|
|
|
@ -782,3 +782,7 @@ div.dja__model-description{
|
|||
padding: 6px 8px 10px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.usa-button--dja-link-color {
|
||||
color: var(--link-fg);
|
||||
}
|
||||
|
|
|
@ -98,7 +98,7 @@
|
|||
}
|
||||
}
|
||||
@media (min-width: 1040px){
|
||||
.dotgov-table__domain-requests {
|
||||
.domain-requests__table {
|
||||
th:nth-of-type(1) {
|
||||
width: 200px;
|
||||
}
|
||||
|
@ -122,7 +122,7 @@
|
|||
}
|
||||
|
||||
@media (min-width: 1040px){
|
||||
.dotgov-table__registered-domains {
|
||||
.domains__table {
|
||||
th:nth-of-type(1) {
|
||||
width: 200px;
|
||||
}
|
||||
|
|
18
src/registrar/migrations/0102_domain_dsdata_last_change.py
Normal file
18
src/registrar/migrations/0102_domain_dsdata_last_change.py
Normal 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),
|
||||
),
|
||||
]
|
|
@ -40,6 +40,8 @@ from .utility.time_stamped_model import TimeStampedModel
|
|||
|
||||
from .public_contact import PublicContact
|
||||
|
||||
from .user_domain_role import UserDomainRole
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -672,11 +674,29 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
remRequest = commands.UpdateDomain(name=self.name)
|
||||
remExtension = commands.UpdateDomainDNSSECExtension(**remParams)
|
||||
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:
|
||||
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)
|
||||
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)
|
||||
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:
|
||||
logger.error("Error updating DNSSEC, code was %s error was %s" % (e.code, e))
|
||||
raise e
|
||||
|
@ -1057,6 +1077,12 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
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):
|
||||
return self.state == Domain.State.CREATED
|
||||
|
||||
|
|
|
@ -17,8 +17,6 @@ from .utility.time_stamped_model import TimeStampedModel
|
|||
from ..utility.email import send_templated_email, EmailSendingError
|
||||
from itertools import chain
|
||||
|
||||
from auditlog.models import AuditlogHistoryField # type: ignore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -35,11 +33,7 @@ class DomainRequest(TimeStampedModel):
|
|||
]
|
||||
|
||||
# https://django-auditlog.readthedocs.io/en/latest/usage.html#object-history
|
||||
# If we note any performace degradation due to this addition,
|
||||
# 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()
|
||||
# history = AuditlogHistoryField()
|
||||
|
||||
# Constants for choice fields
|
||||
class DomainRequestStatus(models.TextChoices):
|
||||
|
@ -262,6 +256,11 @@ class DomainRequest(TimeStampedModel):
|
|||
NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met"
|
||||
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):
|
||||
"""Defines common action needed reasons for domain requests"""
|
||||
|
||||
|
@ -271,6 +270,11 @@ class DomainRequest(TimeStampedModel):
|
|||
BAD_NAME = ("bad_name", "Doesn’t meet naming requirements")
|
||||
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 #####
|
||||
status = FSMField(
|
||||
choices=DomainRequestStatus.choices, # possible states as an array of constants
|
||||
|
|
|
@ -68,42 +68,52 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
{% endblock field_readonly %}
|
||||
|
||||
{% block after_help_text %}
|
||||
{% if field.field.name == "status" and original_object.history.count > 0 %}
|
||||
<div class="flex-container" id="dja-status-changelog">
|
||||
<label aria-label="Status changelog"></label>
|
||||
<div>
|
||||
<div class="usa-table-container--scrollable collapse--dgsimple" tabindex="0">
|
||||
<table class="usa-table usa-table--borderless">
|
||||
<thead>
|
||||
{% if field.field.name == "status" and filtered_audit_log_entries %}
|
||||
<div class="flex-container" id="dja-status-changelog">
|
||||
<label aria-label="Status changelog"></label>
|
||||
<div>
|
||||
<div class="usa-table-container--scrollable collapse--dgsimple collapsed" tabindex="0">
|
||||
<table class="usa-table usa-table--borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>User</th>
|
||||
<th>Changed at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in filtered_audit_log_entries %}
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>User</th>
|
||||
<th>Changed at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log_entry in original_object.history.all %}
|
||||
{% for key, value in log_entry.changes_display_dict.items %}
|
||||
{% if key == "status" %}
|
||||
<tr>
|
||||
<td>{{ value.1|default:"None" }}</td>
|
||||
<td>{{ log_entry.actor|default:"None" }}</td>
|
||||
<td>{{ log_entry.timestamp|default:"None" }}</td>
|
||||
</tr>
|
||||
<td>
|
||||
{% if entry.status %}
|
||||
{{ entry.status|default:"Error" }}
|
||||
{% else %}
|
||||
Error
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{% 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 %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-2 margin-bottom-1 margin-left-1">
|
||||
<span>Show details</span>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.name == "creator" %}
|
||||
<div class="flex-container tablet:margin-top-2">
|
||||
<label aria-label="Creator contact details"></label>
|
||||
|
@ -174,5 +184,19 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
{% endif %}
|
||||
</span>
|
||||
</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 %}
|
||||
{% endblock after_help_text %}
|
||||
|
|
|
@ -23,10 +23,39 @@
|
|||
</a>
|
||||
</p>
|
||||
|
||||
<section class="section--outlined">
|
||||
<h2 id="domains-header">Domains</h2>
|
||||
<div class="domains-wrapper display-none">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked dotgov-table__registered-domains">
|
||||
<section class="section--outlined domains">
|
||||
<div class="grid-row">
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<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>
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -50,7 +79,7 @@
|
|||
aria-live="polite"
|
||||
></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 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">
|
||||
|
@ -61,6 +90,9 @@
|
|||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="domains__no-search-results display-none">
|
||||
<p>No results found for "<span class="domains__search-term"></span>"</p>
|
||||
</div>
|
||||
</section>
|
||||
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domains-pagination">
|
||||
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
|
||||
|
@ -71,10 +103,39 @@
|
|||
</ul>
|
||||
</nav>
|
||||
|
||||
<section class="section--outlined">
|
||||
<h2 id="domain-requests-header">Domain requests</h2>
|
||||
<div class="domain-requests-wrapper display-none">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked dotgov-table__domain-requests">
|
||||
<section class="section--outlined domain-requests">
|
||||
<div class="grid-row">
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<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>
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -82,7 +143,7 @@
|
|||
<th data-sortable="submission_date" scope="col" role="columnheader">Date submitted</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">Delete Action</span></th>
|
||||
<!-- AJAX will conditionally add a th for delete actions -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="domain-requests-tbody">
|
||||
|
@ -93,45 +154,13 @@
|
|||
class="usa-sr-only usa-table__announcement-region"
|
||||
aria-live="polite"
|
||||
></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>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
<div class="no-domain-requests-wrapper display-none">
|
||||
<div class="domain-requests__no-data display-none">
|
||||
<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>
|
||||
<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">
|
||||
|
|
|
@ -1055,6 +1055,18 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
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
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.STARTED)
|
||||
|
||||
|
@ -1069,48 +1081,23 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.assertEqual(response.status_code, 200)
|
||||
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.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.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_reason = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS
|
||||
domain_request.save()
|
||||
|
||||
response = self.client.get(
|
||||
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
|
||||
follow=True,
|
||||
)
|
||||
# Let's just change the action needed reason
|
||||
domain_request.action_needed_reason = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR
|
||||
domain_request.save()
|
||||
|
||||
# Table will contain and extra row for Action needed
|
||||
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)
|
||||
self.assertContains(response, "<td>Action needed</td>", count=1)
|
||||
domain_request.reject()
|
||||
domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE
|
||||
domain_request.save()
|
||||
|
||||
domain_request.in_review()
|
||||
domain_request.save()
|
||||
|
@ -1120,24 +1107,28 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
follow=True,
|
||||
)
|
||||
|
||||
# Normalize the HTML response content
|
||||
normalized_content = " ".join(response.content.decode("utf-8").split())
|
||||
|
||||
# Define the expected sequence of status changes
|
||||
expected_status_changes = [
|
||||
"<td>In review</td>",
|
||||
"<td>Action needed</td>",
|
||||
"<td>In review</td>",
|
||||
"<td>Submitted</td>",
|
||||
"<td>Started</td>",
|
||||
"In review",
|
||||
"Rejected - Purpose requirements not met",
|
||||
"Action needed - Unclear organization eligibility",
|
||||
"Action needed - Already has domains",
|
||||
"In review",
|
||||
"Submitted",
|
||||
"Started",
|
||||
]
|
||||
|
||||
# Test for the order of status changes
|
||||
for status_change in expected_status_changes:
|
||||
self.assertContains(response, status_change, html=True)
|
||||
assert_status_order(normalized_content, expected_status_changes)
|
||||
|
||||
# Table now contains 2 rows for Approved
|
||||
self.assertContains(response, "<td>Started</td>", count=1)
|
||||
self.assertContains(response, "<td>Submitted</td>", count=1)
|
||||
self.assertContains(response, "<td>In review</td>", count=2)
|
||||
self.assertContains(response, "<td>Action needed</td>", count=1)
|
||||
assert_status_count(normalized_content, "Started", 1)
|
||||
assert_status_count(normalized_content, "Submitted", 1)
|
||||
assert_status_count(normalized_content, "In review", 2)
|
||||
assert_status_count(normalized_content, "Action needed - Already has domains", 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):
|
||||
"""
|
||||
|
|
|
@ -1901,12 +1901,8 @@ class TestRegistrantDNSSEC(MockEppLib):
|
|||
3 - setter adds the UpdateDNSSECExtension extension to the command
|
||||
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
|
||||
|
||||
"""
|
||||
|
||||
# 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):
|
||||
if isinstance(_request, commands.InfoDomain):
|
||||
if mocked_send.call_count == 1:
|
||||
|
@ -1924,17 +1920,30 @@ class TestRegistrantDNSSEC(MockEppLib):
|
|||
mocked_send = patcher.start()
|
||||
mocked_send.side_effect = side_effect
|
||||
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
|
||||
# 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
|
||||
# args[0] is the _request sent to registry
|
||||
args, _ = mocked_send.call_args
|
||||
# assert that the extension on the update matches
|
||||
# Assert that the extension on the update matches
|
||||
self.assertEquals(
|
||||
args[0].extensions[0],
|
||||
self.createUpdateExtension(self.dnssecExtensionWithDsData),
|
||||
)
|
||||
# test that the dnssecdata getter is functioning properly
|
||||
|
||||
# Test that the dnssecdata getter is functioning properly
|
||||
dnssecdata_get = domain.dnssecdata
|
||||
mocked_send.assert_has_calls(
|
||||
[
|
||||
|
@ -2129,13 +2138,9 @@ class TestRegistrantDNSSEC(MockEppLib):
|
|||
2 - first setter calls UpdateDomain command
|
||||
3 - second setter calls InfoDomain command again
|
||||
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):
|
||||
if isinstance(_request, commands.InfoDomain):
|
||||
if mocked_send.call_count == 1:
|
||||
|
@ -2153,10 +2158,25 @@ class TestRegistrantDNSSEC(MockEppLib):
|
|||
mocked_send = patcher.start()
|
||||
mocked_send.side_effect = side_effect
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# verify that it is properly sent
|
||||
# args[0] is the _request sent to registry
|
||||
|
|
|
@ -384,15 +384,15 @@ class HomeTests(TestWithUser):
|
|||
)
|
||||
domain_request_2.other_contacts.set([contact_shared])
|
||||
|
||||
# Ensure that igorville.gov exists on the page
|
||||
home_page = self.client.get("/")
|
||||
self.assertContains(home_page, "igorville.gov")
|
||||
igorville = DomainRequest.objects.filter(requested_domain__name="igorville.gov")
|
||||
self.assertTrue(igorville.exists())
|
||||
|
||||
# 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
|
||||
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
|
||||
orphan = Contact.objects.filter(id=contact.id)
|
||||
|
@ -456,13 +456,14 @@ class HomeTests(TestWithUser):
|
|||
)
|
||||
domain_request_2.other_contacts.set([contact_shared])
|
||||
|
||||
home_page = self.client.get("/")
|
||||
self.assertContains(home_page, "teaville.gov")
|
||||
teaville = DomainRequest.objects.filter(requested_domain__name="teaville.gov")
|
||||
self.assertTrue(teaville.exists())
|
||||
|
||||
# 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
|
||||
orphan = Contact.objects.filter(id=contact_shared.id)
|
||||
|
|
|
@ -102,6 +102,35 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
|
|||
)
|
||||
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):
|
||||
"""Test that pagination is correct in the response"""
|
||||
response = self.app.get(reverse("get_domains_json"), {"page": 1})
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
from registrar.models import DomainRequest
|
||||
from django.urls import reverse
|
||||
|
||||
from registrar.models.draft_domain import DraftDomain
|
||||
from .test_views import TestWithUser
|
||||
from django_webtest import WebTest # type: ignore
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
@ -10,32 +12,37 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
|
|||
super().setUp()
|
||||
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
|
||||
self.domain_requests = [
|
||||
DomainRequest.objects.create(
|
||||
creator=self.user,
|
||||
requested_domain=None,
|
||||
requested_domain=lamb_chops,
|
||||
submission_date="2024-01-01",
|
||||
status=DomainRequest.DomainRequestStatus.STARTED,
|
||||
created_at="2024-01-01",
|
||||
),
|
||||
DomainRequest.objects.create(
|
||||
creator=self.user,
|
||||
requested_domain=None,
|
||||
requested_domain=short_ribs,
|
||||
submission_date="2024-02-01",
|
||||
status=DomainRequest.DomainRequestStatus.WITHDRAWN,
|
||||
created_at="2024-02-01",
|
||||
),
|
||||
DomainRequest.objects.create(
|
||||
creator=self.user,
|
||||
requested_domain=None,
|
||||
requested_domain=beef_chuck,
|
||||
submission_date="2024-03-01",
|
||||
status=DomainRequest.DomainRequestStatus.REJECTED,
|
||||
created_at="2024-03-01",
|
||||
),
|
||||
DomainRequest.objects.create(
|
||||
creator=self.user,
|
||||
requested_domain=None,
|
||||
requested_domain=stew_beef,
|
||||
submission_date="2024-04-01",
|
||||
status=DomainRequest.DomainRequestStatus.STARTED,
|
||||
created_at="2024-04-01",
|
||||
|
@ -195,6 +202,61 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
|
|||
)
|
||||
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):
|
||||
"""Test that pagination works properly. There are 11 total non-approved requests and
|
||||
a page size of 10"""
|
||||
|
|
|
@ -798,7 +798,8 @@ class DomainRequestDeleteView(DomainRequestPermissionDeleteView):
|
|||
contacts_to_delete, duplicates = self._get_orphaned_contacts(domain_request)
|
||||
|
||||
# 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
|
||||
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)
|
||||
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):
|
||||
"""
|
||||
|
|
|
@ -4,6 +4,7 @@ from registrar.models import DomainRequest
|
|||
from django.utils.dateformat import format
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.urls import reverse
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
@login_required
|
||||
|
@ -14,9 +15,27 @@ def get_domain_requests_json(request):
|
|||
domain_requests = DomainRequest.objects.filter(creator=request.user).exclude(
|
||||
status=DomainRequest.DomainRequestStatus.APPROVED
|
||||
)
|
||||
unfiltered_total = domain_requests.count()
|
||||
|
||||
# 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")
|
||||
|
||||
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":
|
||||
sort_by = f"-{sort_by}"
|
||||
domain_requests = domain_requests.order_by(sort_by)
|
||||
|
@ -75,5 +94,6 @@ def get_domain_requests_json(request):
|
|||
"page": page_obj.number,
|
||||
"num_pages": paginator.num_pages,
|
||||
"total": paginator.count,
|
||||
"unfiltered_total": unfiltered_total,
|
||||
}
|
||||
)
|
||||
|
|
|
@ -3,6 +3,7 @@ from django.core.paginator import Paginator
|
|||
from registrar.models import UserDomainRole, Domain
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.urls import reverse
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
@login_required
|
||||
|
@ -14,10 +15,15 @@ def get_domains_json(request):
|
|||
domain_ids = user_domain_roles.values_list("domain_id", flat=True)
|
||||
|
||||
objects = Domain.objects.filter(id__in=domain_ids)
|
||||
unfiltered_total = objects.count()
|
||||
|
||||
# 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")
|
||||
|
||||
if search_term:
|
||||
objects = objects.filter(Q(name__icontains=search_term))
|
||||
|
||||
if sort_by == "state_display":
|
||||
# Fetch the objects and sort them in Python
|
||||
|
@ -56,5 +62,6 @@ def get_domains_json(request):
|
|||
"has_previous": page_obj.has_previous(),
|
||||
"has_next": page_obj.has_next(),
|
||||
"total": paginator.count,
|
||||
"unfiltered_total": unfiltered_total,
|
||||
}
|
||||
)
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
from django.shortcuts import render
|
||||
from registrar.models import DomainRequest
|
||||
from waffle.decorators import flag_is_active
|
||||
|
||||
|
||||
|
@ -8,46 +7,7 @@ def index(request):
|
|||
context = {}
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue