diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index fd9e31b91..642e9dc30 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -70,6 +70,6 @@ jobs: - name: run pa11y working-directory: ./src run: | - sleep 10; + sleep 20; npm i -g pa11y-ci pa11y-ci diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 9abb909d2..bb256f742 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -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.""" diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 0f3d8b2ad..9c75d9856 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -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 (){ diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 0d594b315..cc11f1336 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -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 = ` + Delete Action`; + 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
(${utcDateString(request.created_at)})`; const actionUrl = request.action_url; const actionLabel = request.action_label; const submissionDate = request.submission_date ? new Date(request.submission_date).toLocaleDateString('en-US', options) : `Not submitted`; - const deleteButton = request.is_deletable ? ` - - Delete ${domainName} - ` : ''; + + // 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 = ` + + Delete ${domainName} + ` + + const modalSubmit = ` + + ` + + 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 = ` +
+
+ +
+ +
+ +
+ +
+ ` + + domainRequestsSectionWrapper.appendChild(modal); + } const row = document.createElement('tr'); row.innerHTML = ` @@ -1250,15 +1493,36 @@ document.addEventListener('DOMContentLoaded', function() { ${actionLabel} ${request.requested_domain ? request.requested_domain : 'New domain request'} - ${deleteButton} + ${needsDeleteColumn ? ''+modalTrigger+'' : ''} `; 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); } diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 701b239ca..360055d91 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -782,3 +782,7 @@ div.dja__model-description{ padding: 6px 8px 10px 8px; } } + +.usa-button--dja-link-color { + color: var(--link-fg); +} diff --git a/src/registrar/assets/sass/_theme/_tables.scss b/src/registrar/assets/sass/_theme/_tables.scss index 26d90d291..a5eb5a4e0 100644 --- a/src/registrar/assets/sass/_theme/_tables.scss +++ b/src/registrar/assets/sass/_theme/_tables.scss @@ -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; } diff --git a/src/registrar/migrations/0102_domain_dsdata_last_change.py b/src/registrar/migrations/0102_domain_dsdata_last_change.py new file mode 100644 index 000000000..6ea631b6a --- /dev/null +++ b/src/registrar/migrations/0102_domain_dsdata_last_change.py @@ -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), + ), + ] diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 26dcb89a7..767227499 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -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 diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 3e616f202..cffe5f86c 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -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 diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index b6c926f8b..0f4274802 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -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 %} -
- -
-
- - + {% if field.field.name == "status" and filtered_audit_log_entries %} +
+ +
+
+ + + + + + + + + {% for entry in filtered_audit_log_entries %} - - - - - - - {% for log_entry in original_object.history.all %} - {% for key, value in log_entry.changes_display_dict.items %} - {% if key == "status" %} - - - - - + -
StatusUserChanged at
StatusUserChanged at
{{ value.1|default:"None" }}{{ log_entry.actor|default:"None" }}{{ log_entry.timestamp|default:"None" }}
+ {% if entry.status %} + {{ entry.status|default:"Error" }} + {% else %} + Error {% endif %} - {% endfor %} - {% endfor %} -
-
- + + {% if entry.rejection_reason %} + - {{ entry.rejection_reason|default:"Error" }} + {% endif %} + + {% if entry.action_needed_reason %} + - {{ entry.action_needed_reason|default:"Error" }} + {% endif %} + + {{ entry.actor|default:"Error" }} + {{ entry.timestamp|date:"Y-m-d H:i:s" }} + + {% endfor %} + +
+
+ {% elif field.field.name == "creator" %}
@@ -174,5 +184,19 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endif %}
+ {% elif field.field.name == "investigator" and not field.is_readonly %} +
+ + +
{% endif %} {% endblock after_help_text %} diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index fd54769a8..15261440d 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -23,10 +23,39 @@

-
-

Domains

-