diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml index f2b4303d6..84f228893 100644 --- a/.github/workflows/deploy-sandbox.yaml +++ b/.github/workflows/deploy-sandbox.yaml @@ -27,6 +27,7 @@ jobs: || startsWith(github.head_ref, 'cb/') || startsWith(github.head_ref, 'hotgov/') || startsWith(github.head_ref, 'litterbox/') + || startsWith(github.head_ref, 'ag/') outputs: environment: ${{ steps.var.outputs.environment}} runs-on: "ubuntu-latest" diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml index 283380236..81368f6e9 100644 --- a/.github/workflows/migrate.yaml +++ b/.github/workflows/migrate.yaml @@ -16,6 +16,7 @@ on: - stable - staging - development + - ag - litterbox - hotgov - cb diff --git a/.github/workflows/reset-db.yaml b/.github/workflows/reset-db.yaml index b9393415b..ad325c50a 100644 --- a/.github/workflows/reset-db.yaml +++ b/.github/workflows/reset-db.yaml @@ -16,6 +16,7 @@ on: options: - staging - development + - ag - litterbox - hotgov - cb 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/ops/manifests/manifest-ag.yaml b/ops/manifests/manifest-ag.yaml new file mode 100644 index 000000000..68d630f3e --- /dev/null +++ b/ops/manifests/manifest-ag.yaml @@ -0,0 +1,32 @@ +--- +applications: +- name: getgov-ag + buildpacks: + - python_buildpack + path: ../../src + instances: 1 + memory: 512M + stack: cflinuxfs4 + timeout: 180 + command: ./run.sh + health-check-type: http + health-check-http-endpoint: /health + health-check-invocation-timeout: 40 + env: + # Send stdout and stderr straight to the terminal without buffering + PYTHONUNBUFFERED: yup + # Tell Django where to find its configuration + DJANGO_SETTINGS_MODULE: registrar.config.settings + # Tell Django where it is being hosted + DJANGO_BASE_URL: https://getgov-ag.app.cloud.gov + # Tell Django how much stuff to log + DJANGO_LOG_LEVEL: INFO + # default public site location + GETGOV_PUBLIC_SITE_URL: https://get.gov + # Flag to disable/enable features in prod environments + IS_PRODUCTION: False + routes: + - route: getgov-ag.app.cloud.gov + services: + - getgov-credentials + - getgov-ag-database diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 5344d7059..215239d66 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 _ @@ -217,6 +218,7 @@ class DomainRequestAdminForm(forms.ModelForm): status = cleaned_data.get("status") investigator = cleaned_data.get("investigator") rejection_reason = cleaned_data.get("rejection_reason") + action_needed_reason = cleaned_data.get("action_needed_reason") # Get the old status initial_status = self.initial.get("status", None) @@ -240,6 +242,8 @@ class DomainRequestAdminForm(forms.ModelForm): # If the status is rejected, a rejection reason must exist if status == DomainRequest.DomainRequestStatus.REJECTED: self._check_for_valid_rejection_reason(rejection_reason) + elif status == DomainRequest.DomainRequestStatus.ACTION_NEEDED: + self._check_for_valid_action_needed_reason(action_needed_reason) return cleaned_data @@ -263,6 +267,18 @@ class DomainRequestAdminForm(forms.ModelForm): return is_valid + def _check_for_valid_action_needed_reason(self, action_needed_reason) -> bool: + """ + Checks if the action_needed_reason field is not none. + Adds form errors on failure. + """ + is_valid = action_needed_reason is not None and action_needed_reason != "" + if not is_valid: + error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.NO_ACTION_NEEDED_REASON) + self.add_error("action_needed_reason", error_message) + + return is_valid + def _check_for_valid_investigator(self, investigator) -> bool: """ Checks if the investigator field is not none, and is staff. @@ -1166,6 +1182,8 @@ class DomainInvitationAdmin(ListHeaderAdmin): # error. readonly_fields = ["status"] + autocomplete_fields = ["domain"] + change_form_template = "django/admin/email_clipboard_change_form.html" # Select domain invitations to change -> Domain invitations @@ -1466,6 +1484,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "fields": [ "status", "rejection_reason", + "action_needed_reason", "investigator", "creator", "submitter", @@ -1482,6 +1501,8 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "authorizing_official", "other_contacts", "no_other_contacts_rationale", + "cisa_representative_first_name", + "cisa_representative_last_name", "cisa_representative_email", ] }, @@ -1557,6 +1578,8 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "no_other_contacts_rationale", "anything_else", "is_policy_acknowledged", + "cisa_representative_first_name", + "cisa_representative_last_name", "cisa_representative_email", ] autocomplete_fields = [ @@ -1668,6 +1691,8 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # The opposite of this condition is acceptable (rejected -> other status and rejection_reason) # because we clean up the rejection reason in the transition in the model. error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.NO_REJECTION_REASON) + elif obj.status == models.DomainRequest.DomainRequestStatus.ACTION_NEEDED and not obj.action_needed_reason: + error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.NO_ACTION_NEEDED_REASON) else: # This is an fsm in model which will throw an error if the # transition condition is violated, so we roll back the @@ -1794,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.""" @@ -2463,6 +2571,34 @@ class VerifiedByStaffAdmin(ListHeaderAdmin): super().save_model(request, obj, form, change) +class PortfolioAdmin(ListHeaderAdmin): + # NOTE: these are just placeholders. Not part of ACs (haven't been defined yet). Update in future tickets. + list_display = ("organization_name", "federal_agency", "creator") + search_fields = ["organization_name"] + search_help_text = "Search by organization name." + # readonly_fields = [ + # "requestor", + # ] + + def save_model(self, request, obj, form, change): + + if obj.creator is not None: + # ---- update creator ---- + # Set the creator field to the current admin user + obj.creator = request.user if request.user.is_authenticated else None + + # ---- update organization name ---- + # org name will be the same as federal agency, if it is federal, + # otherwise it will be the actual org name. If nothing is entered for + # org name and it is a federal organization, have this field fill with + # the federal agency text name. + is_federal = obj.organization_type == DomainRequest.OrganizationChoices.FEDERAL + if is_federal and obj.organization_name is None: + obj.organization_name = obj.federal_agency.agency + + super().save_model(request, obj, form, change) + + class FederalAgencyResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file""" @@ -2542,6 +2678,7 @@ admin.site.register(models.PublicContact, PublicContactAdmin) admin.site.register(models.DomainRequest, DomainRequestAdmin) admin.site.register(models.TransitionDomain, TransitionDomainAdmin) admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin) +admin.site.register(models.Portfolio, PortfolioAdmin) # Register our custom waffle implementations admin.site.register(models.WaffleFlag, WaffleFlagAdmin) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 702364cba..524cfe594 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 (){ @@ -300,42 +341,90 @@ function initializeWidgetOnList(list, parentId) { */ (function (){ let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason') + let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason'); - if (rejectionReasonFormGroup) { + if (rejectionReasonFormGroup && actionNeededReasonFormGroup) { let statusSelect = document.getElementById('id_status') + let isRejected = statusSelect.value == "rejected" + let isActionNeeded = statusSelect.value == "action needed" // Initial handling of rejectionReasonFormGroup display - if (statusSelect.value != 'rejected') - rejectionReasonFormGroup.style.display = 'none'; + showOrHideObject(rejectionReasonFormGroup, show=isRejected) + showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded) // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage statusSelect.addEventListener('change', function() { - if (statusSelect.value == 'rejected') { - rejectionReasonFormGroup.style.display = 'block'; - sessionStorage.removeItem('hideRejectionReason'); - } else { - rejectionReasonFormGroup.style.display = 'none'; - sessionStorage.setItem('hideRejectionReason', 'true'); - } + // Show the rejection reason field if the status is rejected. + // Then track if its shown or hidden in our session cache. + isRejected = statusSelect.value == "rejected" + showOrHideObject(rejectionReasonFormGroup, show=isRejected) + addOrRemoveSessionBoolean("showRejectionReason", add=isRejected) + + isActionNeeded = statusSelect.value == "action needed" + showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded) + addOrRemoveSessionBoolean("showActionNeededReason", add=isActionNeeded) }); + + // Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage + + // When you navigate using forward/back after changing status but not saving, when you land back on the DA page the + // status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide + // accurately for this edge case, we use cache and test for the back/forward navigation. + const observer = new PerformanceObserver((list) => { + list.getEntries().forEach((entry) => { + if (entry.type === "back_forward") { + let showRejectionReason = sessionStorage.getItem("showRejectionReason") !== null + showOrHideObject(rejectionReasonFormGroup, show=showRejectionReason) + + let showActionNeededReason = sessionStorage.getItem("showActionNeededReason") !== null + showOrHideObject(actionNeededReasonFormGroup, show=showActionNeededReason) + } + }); + }); + observer.observe({ type: "navigation" }); } - // Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage + // Adds or removes the display-none class to object depending on the value of boolean show + function showOrHideObject(object, show){ + if (show){ + object.classList.remove("display-none"); + }else { + object.classList.add("display-none"); + } + } - // When you navigate using forward/back after changing status but not saving, when you land back on the DA page the - // status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide - // accurately for this edge case, we use cache and test for the back/forward navigation. - const observer = new PerformanceObserver((list) => { - list.getEntries().forEach((entry) => { - if (entry.type === "back_forward") { - if (sessionStorage.getItem('hideRejectionReason')) - document.querySelector('.field-rejection_reason').style.display = 'none'; - else - document.querySelector('.field-rejection_reason').style.display = 'block'; - } - }); + // Adds or removes a boolean from our session + function addOrRemoveSessionBoolean(name, add){ + if (add) { + sessionStorage.setItem(name, "true"); + }else { + sessionStorage.removeItem(name); + } + } + + document.addEventListener('DOMContentLoaded', function() { + let statusSelect = document.getElementById('id_status'); + + function moveStatusChangelog(actionNeededReasonFormGroup, statusSelect) { + let flexContainer = actionNeededReasonFormGroup.querySelector('.flex-container'); + let statusChangelog = document.getElementById('dja-status-changelog'); + if (statusSelect.value === "action needed") { + flexContainer.parentNode.insertBefore(statusChangelog, flexContainer.nextSibling); + } else { + // Move the changelog back to its original location + let statusFlexContainer = statusSelect.closest('.flex-container'); + statusFlexContainer.parentNode.insertBefore(statusChangelog, statusFlexContainer.nextSibling); + } + } + + // Call the function on page load + moveStatusChangelog(actionNeededReasonFormGroup, statusSelect); + + // Add event listener to handle changes to the selector itself + statusSelect.addEventListener('change', function() { + moveStatusChangelog(actionNeededReasonFormGroup, statusSelect); + }) }); - observer.observe({ type: "navigation" }); })(); /** An IIFE for toggling the submit bar on domain request forms diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 0d594b315..86bb1fe6e 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); } @@ -1301,6 +1604,18 @@ document.addEventListener('DOMContentLoaded', function() { +/** + * An IIFE that displays confirmation modal on the user profile page + */ +(function userProfileListener() { + + const showConfirmationModalTrigger = document.querySelector('.show-confirmation-modal'); + if (showConfirmationModalTrigger) { + showConfirmationModalTrigger.click(); + } +} +)(); + /** * An IIFE that hooks up the edit buttons on the finish-user-setup page */ diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 8ed702665..360055d91 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -773,3 +773,16 @@ div.dja__model-description{ .module caption, .inline-group h2 { text-transform: capitalize; } + +.wrapped-button-group { + // This button group has too many items + flex-wrap: wrap; + // Fix a weird spacing issue with USWDS a buttons in DJA + a.button { + padding: 6px 8px 10px 8px; + } +} + +.usa-button--dja-link-color { + color: var(--link-fg); +} diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index e88d75f4e..e5e8b89ee 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -70,7 +70,7 @@ body { top: 50%; left: 0; width: 0; /* No width since it's a border */ - height: 50%; + height: 40%; border-left: solid 1px color('base-light'); transform: translateY(-50%); } 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/config/settings.py b/src/registrar/config/settings.py index 9a6792dc7..8438812c4 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -659,6 +659,7 @@ ALLOWED_HOSTS = [ "getgov-stable.app.cloud.gov", "getgov-staging.app.cloud.gov", "getgov-development.app.cloud.gov", + "getgov-ag.app.cloud.gov", "getgov-litterbox.app.cloud.gov", "getgov-hotgov.app.cloud.gov", "getgov-cb.app.cloud.gov", diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index bf13b950e..dc6c8acb5 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -18,6 +18,7 @@ from registrar.views.admin_views import ( ExportDataType, ExportDataUnmanagedDomains, AnalyticsView, + ExportDomainRequestDataFull, ) from registrar.views.domain_request import Step @@ -66,6 +67,11 @@ urlpatterns = [ ExportDataType.as_view(), name="export_data_type", ), + path( + "admin/analytics/export_data_domain_requests_full/", + ExportDomainRequestDataFull.as_view(), + name="export_data_domain_requests_full", + ), path( "admin/analytics/export_data_full/", ExportDataFull.as_view(), diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py index c31acacfd..2aa9d224b 100644 --- a/src/registrar/fixtures_users.py +++ b/src/registrar/fixtures_users.py @@ -106,6 +106,12 @@ class UserFixture: "last_name": "Orr", "email": "riley+320@truss.works", }, + { + "username": "76612d84-66b0-4ae9-9870-81e98b9858b6", + "first_name": "Anna", + "last_name": "Gingle", + "email": "annagingle@truss.works", + }, ] STAFF = [ @@ -194,6 +200,12 @@ class UserFixture: "last_name": "Orr-Analyst", "email": "riley+321@truss.works", }, + { + "username": "e1e350b1-cfc1-4753-a6cb-3ae6d912f99c", + "first_name": "Anna-Analyst", + "last_name": "Gingle-Analyst", + "email": "annagingle+analyst@truss.works", + }, ] def load_users(cls, users, group_name, are_superusers=False): diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 0e9e87f9d..61afd6b57 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -16,6 +16,7 @@ from registrar.forms.utility.wizard_form_helper import ( from registrar.models import Contact, DomainRequest, DraftDomain, Domain, FederalAgency from registrar.templatetags.url_helpers import public_site_url from registrar.utility.enums import ValidationReturnType +from registrar.utility.constants import BranchChoices logger = logging.getLogger(__name__) @@ -67,7 +68,7 @@ class TribalGovernmentForm(RegistrarForm): class OrganizationFederalForm(RegistrarForm): federal_type = forms.ChoiceField( - choices=DomainRequest.BranchChoices.choices, + choices=BranchChoices.choices, widget=forms.RadioSelect, error_messages={"required": ("Select the part of the federal government your organization is in.")}, ) @@ -647,20 +648,27 @@ class NoOtherContactsForm(BaseDeletableRegistrarForm): class CisaRepresentativeForm(BaseDeletableRegistrarForm): + cisa_representative_first_name = forms.CharField( + label="First name / given name", + error_messages={"required": "Enter the first name / given name of the CISA regional representative."}, + ) + cisa_representative_last_name = forms.CharField( + label="Last name / family name", + error_messages={"required": "Enter the last name / family name of the CISA regional representative."}, + ) cisa_representative_email = forms.EmailField( - required=True, + label="Your representative’s email (optional)", max_length=None, - label="Your representative’s email", + required=False, + error_messages={ + "invalid": ("Enter your representative’s email address in the required format, like name@example.com."), + }, validators=[ MaxLengthValidator( 320, message="Response must be less than 320 characters.", ) ], - error_messages={ - "invalid": ("Enter your email address in the required format, like name@example.com."), - "required": ("Enter the email address of your CISA regional representative."), - }, ) diff --git a/src/registrar/forms/user_profile.py b/src/registrar/forms/user_profile.py index 557e34e0d..3dd8cbdce 100644 --- a/src/registrar/forms/user_profile.py +++ b/src/registrar/forms/user_profile.py @@ -47,7 +47,7 @@ class UserProfileForm(forms.ModelForm): self.fields["middle_name"].label = "Middle name (optional)" self.fields["last_name"].label = "Last name / family name" self.fields["title"].label = "Title or role in your organization" - self.fields["email"].label = "Organizational email" + self.fields["email"].label = "Organization email" # Set custom error messages self.fields["first_name"].error_messages = {"required": "Enter your first name / given name."} diff --git a/src/registrar/management/commands/transfer_transition_domains_to_domains.py b/src/registrar/management/commands/transfer_transition_domains_to_domains.py index 4e05235d4..4eaa2f367 100644 --- a/src/registrar/management/commands/transfer_transition_domains_to_domains.py +++ b/src/registrar/management/commands/transfer_transition_domains_to_domains.py @@ -19,6 +19,7 @@ from registrar.models.domain_request import DomainRequest from registrar.models.domain_information import DomainInformation from registrar.models.user import User from registrar.models.federal_agency import FederalAgency +from registrar.utility.constants import BranchChoices logger = logging.getLogger(__name__) @@ -819,7 +820,7 @@ class Command(BaseCommand): invitation.save() valid_org_choices = [(name, value) for name, value in DomainRequest.OrganizationChoices.choices] - valid_fed_choices = [value for name, value in DomainRequest.BranchChoices.choices] + valid_fed_choices = [value for name, value in BranchChoices.choices] valid_agency_choices = FederalAgency.objects.all() # ====================================================== # ================= DOMAIN INFORMATION ================= diff --git a/src/registrar/migrations/0099_federalagency_federal_type.py b/src/registrar/migrations/0099_federalagency_federal_type.py new file mode 100644 index 000000000..b4e955263 --- /dev/null +++ b/src/registrar/migrations/0099_federalagency_federal_type.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.10 on 2024-06-11 15:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0098_alter_domainrequest_status"), + ] + + operations = [ + migrations.AddField( + model_name="federalagency", + name="federal_type", + field=models.CharField( + blank=True, + choices=[("executive", "Executive"), ("judicial", "Judicial"), ("legislative", "Legislative")], + help_text="Federal agency type (executive, judicial, legislative, etc.)", + max_length=20, + null=True, + ), + ), + ] diff --git a/src/registrar/migrations/0100_domainrequest_action_needed_reason.py b/src/registrar/migrations/0100_domainrequest_action_needed_reason.py new file mode 100644 index 000000000..acd0ae2a2 --- /dev/null +++ b/src/registrar/migrations/0100_domainrequest_action_needed_reason.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.10 on 2024-06-12 14:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0099_federalagency_federal_type"), + ] + + operations = [ + migrations.AddField( + model_name="domainrequest", + name="action_needed_reason", + field=models.TextField( + blank=True, + choices=[ + ("eligibility_unclear", "Unclear organization eligibility"), + ("questionable_authorizing_official", "Questionable authorizing official"), + ("already_has_domains", "Already has domains"), + ("bad_name", "Doesn’t meet naming requirements"), + ("other", "Other (no auto-email sent)"), + ], + null=True, + ), + ), + ] diff --git a/src/registrar/migrations/0101_domaininformation_cisa_representative_first_name_and_more.py b/src/registrar/migrations/0101_domaininformation_cisa_representative_first_name_and_more.py new file mode 100644 index 000000000..89f24a5e1 --- /dev/null +++ b/src/registrar/migrations/0101_domaininformation_cisa_representative_first_name_and_more.py @@ -0,0 +1,69 @@ +# Generated by Django 4.2.10 on 2024-06-12 20:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0100_domainrequest_action_needed_reason"), + ] + + operations = [ + migrations.AddField( + model_name="domaininformation", + name="cisa_representative_first_name", + field=models.CharField( + blank=True, db_index=True, null=True, verbose_name="CISA regional representative first name" + ), + ), + migrations.AddField( + model_name="domaininformation", + name="cisa_representative_last_name", + field=models.CharField( + blank=True, db_index=True, null=True, verbose_name="CISA regional representative last name" + ), + ), + migrations.AddField( + model_name="domaininformation", + name="has_anything_else_text", + field=models.BooleanField( + blank=True, help_text="Determines if the user has a anything_else or not", null=True + ), + ), + migrations.AddField( + model_name="domaininformation", + name="has_cisa_representative", + field=models.BooleanField( + blank=True, help_text="Determines if the user has a representative email or not", null=True + ), + ), + migrations.AddField( + model_name="domainrequest", + name="cisa_representative_first_name", + field=models.CharField( + blank=True, db_index=True, null=True, verbose_name="CISA regional representative first name" + ), + ), + migrations.AddField( + model_name="domainrequest", + name="cisa_representative_last_name", + field=models.CharField( + blank=True, db_index=True, null=True, verbose_name="CISA regional representative last name" + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="cisa_representative_email", + field=models.EmailField( + blank=True, max_length=320, null=True, verbose_name="CISA regional representative email" + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="cisa_representative_email", + field=models.EmailField( + blank=True, max_length=320, null=True, verbose_name="CISA regional representative email" + ), + ), + ] 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/migrations/0103_portfolio_domaininformation_portfolio_and_more.py b/src/registrar/migrations/0103_portfolio_domaininformation_portfolio_and_more.py new file mode 100644 index 000000000..ac7a69074 --- /dev/null +++ b/src/registrar/migrations/0103_portfolio_domaininformation_portfolio_and_more.py @@ -0,0 +1,174 @@ +# Generated by Django 4.2.10 on 2024-06-18 17:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import registrar.models.federal_agency + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0102_domain_dsdata_last_change"), + ] + + operations = [ + migrations.CreateModel( + name="Portfolio", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("notes", models.TextField(blank=True, null=True)), + ( + "organization_type", + models.CharField( + blank=True, + choices=[ + ("federal", "Federal"), + ("interstate", "Interstate"), + ("state_or_territory", "State or territory"), + ("tribal", "Tribal"), + ("county", "County"), + ("city", "City"), + ("special_district", "Special district"), + ("school_district", "School district"), + ], + help_text="Type of organization", + max_length=255, + null=True, + ), + ), + ("organization_name", models.CharField(blank=True, null=True)), + ("address_line1", models.CharField(blank=True, null=True, verbose_name="address line 1")), + ("address_line2", models.CharField(blank=True, null=True, verbose_name="address line 2")), + ("city", models.CharField(blank=True, null=True)), + ( + "state_territory", + models.CharField( + blank=True, + choices=[ + ("AL", "Alabama (AL)"), + ("AK", "Alaska (AK)"), + ("AS", "American Samoa (AS)"), + ("AZ", "Arizona (AZ)"), + ("AR", "Arkansas (AR)"), + ("CA", "California (CA)"), + ("CO", "Colorado (CO)"), + ("CT", "Connecticut (CT)"), + ("DE", "Delaware (DE)"), + ("DC", "District of Columbia (DC)"), + ("FL", "Florida (FL)"), + ("GA", "Georgia (GA)"), + ("GU", "Guam (GU)"), + ("HI", "Hawaii (HI)"), + ("ID", "Idaho (ID)"), + ("IL", "Illinois (IL)"), + ("IN", "Indiana (IN)"), + ("IA", "Iowa (IA)"), + ("KS", "Kansas (KS)"), + ("KY", "Kentucky (KY)"), + ("LA", "Louisiana (LA)"), + ("ME", "Maine (ME)"), + ("MD", "Maryland (MD)"), + ("MA", "Massachusetts (MA)"), + ("MI", "Michigan (MI)"), + ("MN", "Minnesota (MN)"), + ("MS", "Mississippi (MS)"), + ("MO", "Missouri (MO)"), + ("MT", "Montana (MT)"), + ("NE", "Nebraska (NE)"), + ("NV", "Nevada (NV)"), + ("NH", "New Hampshire (NH)"), + ("NJ", "New Jersey (NJ)"), + ("NM", "New Mexico (NM)"), + ("NY", "New York (NY)"), + ("NC", "North Carolina (NC)"), + ("ND", "North Dakota (ND)"), + ("MP", "Northern Mariana Islands (MP)"), + ("OH", "Ohio (OH)"), + ("OK", "Oklahoma (OK)"), + ("OR", "Oregon (OR)"), + ("PA", "Pennsylvania (PA)"), + ("PR", "Puerto Rico (PR)"), + ("RI", "Rhode Island (RI)"), + ("SC", "South Carolina (SC)"), + ("SD", "South Dakota (SD)"), + ("TN", "Tennessee (TN)"), + ("TX", "Texas (TX)"), + ("UM", "United States Minor Outlying Islands (UM)"), + ("UT", "Utah (UT)"), + ("VT", "Vermont (VT)"), + ("VI", "Virgin Islands (VI)"), + ("VA", "Virginia (VA)"), + ("WA", "Washington (WA)"), + ("WV", "West Virginia (WV)"), + ("WI", "Wisconsin (WI)"), + ("WY", "Wyoming (WY)"), + ("AA", "Armed Forces Americas (AA)"), + ("AE", "Armed Forces Africa, Canada, Europe, Middle East (AE)"), + ("AP", "Armed Forces Pacific (AP)"), + ], + max_length=2, + null=True, + verbose_name="state / territory", + ), + ), + ("zipcode", models.CharField(blank=True, max_length=10, null=True, verbose_name="zip code")), + ( + "urbanization", + models.CharField( + blank=True, help_text="Required for Puerto Rico only", null=True, verbose_name="urbanization" + ), + ), + ( + "security_contact_email", + models.EmailField(blank=True, max_length=320, null=True, verbose_name="security contact e-mail"), + ), + ( + "creator", + models.ForeignKey( + help_text="Associated user", + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "federal_agency", + models.ForeignKey( + default=registrar.models.federal_agency.FederalAgency.get_non_federal_agency, + help_text="Associated federal agency", + on_delete=django.db.models.deletion.PROTECT, + to="registrar.federalagency", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="domaininformation", + name="portfolio", + field=models.ForeignKey( + blank=True, + help_text="Portfolio associated with this domain", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="DomainRequest_portfolio", + to="registrar.portfolio", + ), + ), + migrations.AddField( + model_name="domainrequest", + name="portfolio", + field=models.ForeignKey( + blank=True, + help_text="Portfolio associated with this domain request", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="DomainInformation_portfolio", + to="registrar.portfolio", + ), + ), + ] diff --git a/src/registrar/migrations/0104_create_groups_v13.py b/src/registrar/migrations/0104_create_groups_v13.py new file mode 100644 index 000000000..0ce3bafa5 --- /dev/null +++ b/src/registrar/migrations/0104_create_groups_v13.py @@ -0,0 +1,37 @@ +# This migration creates the create_full_access_group and create_cisa_analyst_group groups +# It is dependent on 0079 (which populates federal agencies) +# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS +# in the user_group model then: +# [NOT RECOMMENDED] +# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions +# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups +# step 3: fake run the latest migration in the migrations list +# [RECOMMENDED] +# Alternatively: +# step 1: duplicate the migration that loads data +# step 2: docker-compose exec app ./manage.py migrate + +from django.db import migrations +from registrar.models import UserGroup +from typing import Any + + +# For linting: RunPython expects a function reference, +# so let's give it one +def create_groups(apps, schema_editor) -> Any: + UserGroup.create_cisa_analyst_group(apps, schema_editor) + UserGroup.create_full_access_group(apps, schema_editor) + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0103_portfolio_domaininformation_portfolio_and_more"), + ] + + operations = [ + migrations.RunPython( + create_groups, + reverse_code=migrations.RunPython.noop, + atomic=True, + ), + ] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index f084a5d8b..b2cffaf32 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -16,6 +16,7 @@ from .website import Website from .transition_domain import TransitionDomain from .verified_by_staff import VerifiedByStaff from .waffle_flag import WaffleFlag +from .portfolio import Portfolio __all__ = [ @@ -36,6 +37,7 @@ __all__ = [ "TransitionDomain", "VerifiedByStaff", "WaffleFlag", + "Portfolio", ] auditlog.register(Contact) @@ -55,3 +57,4 @@ auditlog.register(Website) auditlog.register(TransitionDomain) auditlog.register(VerifiedByStaff) auditlog.register(WaffleFlag) +auditlog.register(Portfolio) 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_information.py b/src/registrar/models/domain_information.py index 23c9e4f32..b6f2dd9a7 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -3,6 +3,8 @@ from django.db import transaction from registrar.models.utility.domain_helper import DomainHelper from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper +from registrar.utility.constants import BranchChoices + from .domain_request import DomainRequest from .utility.time_stamped_model import TimeStampedModel @@ -37,8 +39,6 @@ class DomainInformation(TimeStampedModel): # use the short names in Django admin OrganizationChoices = DomainRequest.OrganizationChoices - BranchChoices = DomainRequest.BranchChoices - federal_agency = models.ForeignKey( "registrar.FederalAgency", on_delete=models.PROTECT, @@ -57,6 +57,16 @@ class DomainInformation(TimeStampedModel): help_text="Person who submitted the domain request", ) + # portfolio + portfolio = models.ForeignKey( + "registrar.Portfolio", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="DomainRequest_portfolio", + help_text="Portfolio associated with this domain", + ) + domain_request = models.OneToOneField( "registrar.DomainRequest", on_delete=models.PROTECT, @@ -214,13 +224,45 @@ class DomainInformation(TimeStampedModel): verbose_name="Additional details", ) + # This is a drop-in replacement for a has_anything_else_text() function. + # In order to track if the user has clicked the yes/no field (while keeping a none default), we need + # a tertiary state. We should not display this in /admin. + has_anything_else_text = models.BooleanField( + null=True, + blank=True, + help_text="Determines if the user has a anything_else or not", + ) + cisa_representative_email = models.EmailField( null=True, blank=True, - verbose_name="CISA regional representative", + verbose_name="CISA regional representative email", max_length=320, ) + cisa_representative_first_name = models.CharField( + null=True, + blank=True, + verbose_name="CISA regional representative first name", + db_index=True, + ) + + cisa_representative_last_name = models.CharField( + null=True, + blank=True, + verbose_name="CISA regional representative last name", + db_index=True, + ) + + # This is a drop-in replacement for an has_cisa_representative() function. + # In order to track if the user has clicked the yes/no field (while keeping a none default), we need + # a tertiary state. We should not display this in /admin. + has_cisa_representative = models.BooleanField( + null=True, + blank=True, + help_text="Determines if the user has a representative email or not", + ) + is_policy_acknowledged = models.BooleanField( null=True, blank=True, @@ -241,6 +283,30 @@ class DomainInformation(TimeStampedModel): except Exception: return "" + def sync_yes_no_form_fields(self): + """Some yes/no forms use a db field to track whether it was checked or not. + We handle that here for def save(). + """ + # This ensures that if we have prefilled data, the form is prepopulated + if self.cisa_representative_first_name is not None or self.cisa_representative_last_name is not None: + self.has_cisa_representative = ( + self.cisa_representative_first_name != "" and self.cisa_representative_last_name != "" + ) + + # This check is required to ensure that the form doesn't start out checked + if self.has_cisa_representative is not None: + self.has_cisa_representative = ( + self.cisa_representative_first_name != "" and self.cisa_representative_first_name is not None + ) and (self.cisa_representative_last_name != "" and self.cisa_representative_last_name is not None) + + # This ensures that if we have prefilled data, the form is prepopulated + if self.anything_else is not None: + self.has_anything_else_text = self.anything_else != "" + + # This check is required to ensure that the form doesn't start out checked. + if self.has_anything_else_text is not None: + self.has_anything_else_text = self.anything_else != "" and self.anything_else is not None + def sync_organization_type(self): """ Updates the organization_type (without saving) to match @@ -275,6 +341,7 @@ class DomainInformation(TimeStampedModel): def save(self, *args, **kwargs): """Save override for custom properties""" + self.sync_yes_no_form_fields() self.sync_organization_type() super().save(*args, **kwargs) diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index d00841041..1c4725be1 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -1,6 +1,5 @@ from __future__ import annotations from typing import Union - import logging from django.apps import apps @@ -12,13 +11,12 @@ from registrar.models.domain import Domain from registrar.models.federal_agency import FederalAgency from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes +from registrar.utility.constants import BranchChoices 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): @@ -52,6 +46,11 @@ class DomainRequest(TimeStampedModel): WITHDRAWN = "withdrawn", "Withdrawn" STARTED = "started", "Started" + @classmethod + def get_status_label(cls, status_name: str): + """Returns the associated label for a given status name""" + return cls(status_name).label if status_name else None + class StateTerritoryChoices(models.TextChoices): ALABAMA = "AL", "Alabama (AL)" ALASKA = "AK", "Alaska (AK)" @@ -133,6 +132,14 @@ class DomainRequest(TimeStampedModel): SPECIAL_DISTRICT = "special_district", "Special district" SCHOOL_DISTRICT = "school_district", "School district" + @classmethod + def get_org_label(cls, org_name: str): + """Returns the associated label for a given org name""" + org_names = org_name.split("_election") + if len(org_names) > 0: + org_name = org_names[0] + return cls(org_name).label if org_name else None + class OrgChoicesElectionOffice(models.TextChoices): """ Primary organization choices for Django admin: @@ -234,11 +241,6 @@ class DomainRequest(TimeStampedModel): "School district: a school district that is not part of a local government", ) - class BranchChoices(models.TextChoices): - EXECUTIVE = "executive", "Executive" - JUDICIAL = "judicial", "Judicial" - LEGISLATIVE = "legislative", "Legislative" - class RejectionReasons(models.TextChoices): DOMAIN_PURPOSE = "purpose_not_met", "Purpose requirements not met" REQUESTOR = "requestor_not_eligible", "Requestor not eligible to make request" @@ -254,6 +256,25 @@ 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""" + + ELIGIBILITY_UNCLEAR = ("eligibility_unclear", "Unclear organization eligibility") + QUESTIONABLE_AUTHORIZING_OFFICIAL = ("questionable_authorizing_official", "Questionable authorizing official") + ALREADY_HAS_DOMAINS = ("already_has_domains", "Already has domains") + 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 @@ -267,6 +288,12 @@ class DomainRequest(TimeStampedModel): blank=True, ) + action_needed_reason = models.TextField( + choices=ActionNeededReasons.choices, + null=True, + blank=True, + ) + federal_agency = models.ForeignKey( "registrar.FederalAgency", on_delete=models.PROTECT, @@ -276,6 +303,16 @@ class DomainRequest(TimeStampedModel): null=True, ) + # portfolio + portfolio = models.ForeignKey( + "registrar.Portfolio", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="DomainInformation_portfolio", + help_text="Portfolio associated with this domain request", + ) + # This is the domain request user who created this domain request. The contact # information that they gave is in the `submitter` field creator = models.ForeignKey( @@ -467,10 +504,24 @@ class DomainRequest(TimeStampedModel): cisa_representative_email = models.EmailField( null=True, blank=True, - verbose_name="CISA regional representative", + verbose_name="CISA regional representative email", max_length=320, ) + cisa_representative_first_name = models.CharField( + null=True, + blank=True, + verbose_name="CISA regional representative first name", + db_index=True, + ) + + cisa_representative_last_name = models.CharField( + null=True, + blank=True, + verbose_name="CISA regional representative last name", + db_index=True, + ) + # This is a drop-in replacement for an has_cisa_representative() function. # In order to track if the user has clicked the yes/no field (while keeping a none default), we need # a tertiary state. We should not display this in /admin. @@ -529,6 +580,16 @@ class DomainRequest(TimeStampedModel): # Actually updates the organization_type field org_type_helper.create_or_update_organization_type() + def _cache_status_and_action_needed_reason(self): + """Maintains a cache of properties so we can avoid a DB call""" + self._cached_action_needed_reason = self.action_needed_reason + self._cached_status = self.status + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Store original values for caching purposes. Used to compare them on save. + self._cache_status_and_action_needed_reason() + def save(self, *args, **kwargs): """Save override for custom properties""" self.sync_organization_type() @@ -536,20 +597,38 @@ class DomainRequest(TimeStampedModel): super().save(*args, **kwargs) + # Handle the action needed email. We send one when moving to action_needed, + # but we don't send one when we are _already_ in the state and change the reason. + self.sync_action_needed_reason() + + # Update the cached values after saving + self._cache_status_and_action_needed_reason() + + def sync_action_needed_reason(self): + """Checks if we need to send another action needed email""" + was_already_action_needed = self._cached_status == self.DomainRequestStatus.ACTION_NEEDED + reason_exists = self._cached_action_needed_reason is not None and self.action_needed_reason is not None + reason_changed = self._cached_action_needed_reason != self.action_needed_reason + if was_already_action_needed and (reason_exists and reason_changed): + # We don't send emails out in state "other" + if self.action_needed_reason != self.ActionNeededReasons.OTHER: + self._send_action_needed_reason_email() + def sync_yes_no_form_fields(self): """Some yes/no forms use a db field to track whether it was checked or not. We handle that here for def save(). """ - # This ensures that if we have prefilled data, the form is prepopulated - if self.cisa_representative_email is not None: - self.has_cisa_representative = self.cisa_representative_email != "" + if self.cisa_representative_first_name is not None or self.cisa_representative_last_name is not None: + self.has_cisa_representative = ( + self.cisa_representative_first_name != "" and self.cisa_representative_last_name != "" + ) # This check is required to ensure that the form doesn't start out checked if self.has_cisa_representative is not None: self.has_cisa_representative = ( - self.cisa_representative_email != "" and self.cisa_representative_email is not None - ) + self.cisa_representative_first_name != "" and self.cisa_representative_first_name is not None + ) and (self.cisa_representative_last_name != "" and self.cisa_representative_last_name is not None) # This ensures that if we have prefilled data, the form is prepopulated if self.anything_else is not None: @@ -587,7 +666,7 @@ class DomainRequest(TimeStampedModel): logger.error(f"Can't query an approved domain while attempting {called_from}") def _send_status_update_email( - self, new_status, email_template, email_template_subject, send_email=True, bcc_address="" + self, new_status, email_template, email_template_subject, send_email=True, bcc_address="", wrap_email=False ): """Send a status update email to the submitter. @@ -614,6 +693,7 @@ class DomainRequest(TimeStampedModel): self.submitter.email, context={"domain_request": self}, bcc_address=bcc_address, + wrap_email=wrap_email, ) logger.info(f"The {new_status} email sent to: {self.submitter.email}") except EmailSendingError: @@ -697,9 +777,10 @@ class DomainRequest(TimeStampedModel): if self.status == self.DomainRequestStatus.APPROVED: self.delete_and_clean_up_domain("in_review") - - if self.status == self.DomainRequestStatus.REJECTED: + elif self.status == self.DomainRequestStatus.REJECTED: self.rejection_reason = None + elif self.status == self.DomainRequestStatus.ACTION_NEEDED: + self.action_needed_reason = None literal = DomainRequest.DomainRequestStatus.IN_REVIEW # Check if the tuple exists, then grab its value @@ -717,7 +798,7 @@ class DomainRequest(TimeStampedModel): target=DomainRequestStatus.ACTION_NEEDED, conditions=[domain_is_not_active, investigator_exists_and_is_staff], ) - def action_needed(self): + def action_needed(self, send_email=True): """Send back an domain request that is under investigation or rejected. This action is logged. @@ -729,8 +810,7 @@ class DomainRequest(TimeStampedModel): if self.status == self.DomainRequestStatus.APPROVED: self.delete_and_clean_up_domain("reject_with_prejudice") - - if self.status == self.DomainRequestStatus.REJECTED: + elif self.status == self.DomainRequestStatus.REJECTED: self.rejection_reason = None literal = DomainRequest.DomainRequestStatus.ACTION_NEEDED @@ -738,6 +818,46 @@ class DomainRequest(TimeStampedModel): action_needed = literal if literal is not None else "Action Needed" logger.info(f"A status change occurred. {self} was changed to '{action_needed}'") + # Send out an email if an action needed reason exists + if self.action_needed_reason and self.action_needed_reason != self.ActionNeededReasons.OTHER: + self._send_action_needed_reason_email(send_email) + + def _send_action_needed_reason_email(self, send_email=True): + """Sends out an automatic email for each valid action needed reason provided""" + + # Store the filenames of the template and template subject + email_template_name: str = "" + email_template_subject_name: str = "" + + # Check for the "type" of action needed reason. + can_send_email = True + match self.action_needed_reason: + # Add to this match if you need to pass in a custom filename for these templates. + case self.ActionNeededReasons.OTHER, _: + # Unknown and other are default cases - do nothing + can_send_email = False + + # Assumes that the template name matches the action needed reason if nothing is specified. + # This is so you can override if you need, or have this taken care of for you. + if not email_template_name and not email_template_subject_name: + email_template_name = f"{self.action_needed_reason}.txt" + email_template_subject_name = f"{self.action_needed_reason}_subject.txt" + + bcc_address = "" + if settings.IS_PRODUCTION: + bcc_address = settings.DEFAULT_FROM_EMAIL + + # If we can, try to send out an email as long as send_email=True + if can_send_email: + self._send_status_update_email( + new_status="action needed", + email_template=f"emails/action_needed_reasons/{email_template_name}", + email_template_subject=f"emails/action_needed_reasons/{email_template_subject_name}", + send_email=send_email, + bcc_address=bcc_address, + wrap_email=True, + ) + @transition( field="status", source=[ @@ -786,6 +906,8 @@ class DomainRequest(TimeStampedModel): if self.status == self.DomainRequestStatus.REJECTED: self.rejection_reason = None + elif self.status == self.DomainRequestStatus.ACTION_NEEDED: + self.action_needed_reason = None # == Send out an email == # self._send_status_update_email( @@ -904,11 +1026,12 @@ class DomainRequest(TimeStampedModel): def has_additional_details(self) -> bool: """Combines the has_anything_else_text and has_cisa_representative fields, then returns if this domain request has either of them.""" - # Split out for linter - has_details = False - if self.has_anything_else_text or self.has_cisa_representative: - has_details = True + # Split out for linter + has_details = True + + if self.has_anything_else_text is None or self.has_cisa_representative is None: + has_details = False return has_details def is_federal(self) -> Union[bool, None]: @@ -1017,14 +1140,19 @@ class DomainRequest(TimeStampedModel): return True return False - def _cisa_rep_and_email_check(self): - # Has a CISA rep + email is NOT empty or NOT an empty string OR doesn't have CISA rep - return ( + def _cisa_rep_check(self): + # Either does not have a CISA rep, OR has a CISA rep + both first name + # and last name are NOT empty and are NOT an empty string + to_return = ( self.has_cisa_representative is True - and self.cisa_representative_email is not None - and self.cisa_representative_email != "" + and self.cisa_representative_first_name is not None + and self.cisa_representative_first_name != "" + and self.cisa_representative_last_name is not None + and self.cisa_representative_last_name != "" ) or self.has_cisa_representative is False + return to_return + def _anything_else_radio_button_and_text_field_check(self): # Anything else boolean is True + filled text field and it's not an empty string OR the boolean is No return ( @@ -1032,7 +1160,7 @@ class DomainRequest(TimeStampedModel): ) or self.has_anything_else_text is False def _is_additional_details_complete(self): - return self._cisa_rep_and_email_check() and self._anything_else_radio_button_and_text_field_check() + return self._cisa_rep_check() and self._anything_else_radio_button_and_text_field_check() def _is_policy_acknowledgement_complete(self): return self.is_policy_acknowledged is not None diff --git a/src/registrar/models/federal_agency.py b/src/registrar/models/federal_agency.py index 89b15ab56..8db415bbd 100644 --- a/src/registrar/models/federal_agency.py +++ b/src/registrar/models/federal_agency.py @@ -1,6 +1,7 @@ from .utility.time_stamped_model import TimeStampedModel from django.db import models import logging +from registrar.utility.constants import BranchChoices logger = logging.getLogger(__name__) @@ -16,6 +17,14 @@ class FederalAgency(TimeStampedModel): help_text="Federal agency", ) + federal_type = models.CharField( + max_length=20, + choices=BranchChoices.choices, + null=True, + blank=True, + help_text="Federal agency type (executive, judicial, legislative, etc.)", + ) + def __str__(self) -> str: return f"{self.agency}" @@ -221,3 +230,8 @@ class FederalAgency(TimeStampedModel): FederalAgency.objects.bulk_create(agencies) except Exception as e: logger.error(f"Error creating federal agencies: {e}") + + @classmethod + def get_non_federal_agency(cls): + """Returns the non-federal agency.""" + return FederalAgency.objects.filter(agency="Non-Federal Agency").first() diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py new file mode 100644 index 000000000..a05422960 --- /dev/null +++ b/src/registrar/models/portfolio.py @@ -0,0 +1,99 @@ +from django.db import models + +from registrar.models.domain_request import DomainRequest +from registrar.models.federal_agency import FederalAgency + +from .utility.time_stamped_model import TimeStampedModel + + +# def get_default_federal_agency(): +# """returns non-federal agency""" +# return FederalAgency.objects.filter(agency="Non-Federal Agency").first() + + +class Portfolio(TimeStampedModel): + """ + Portfolio is used for organizing domains/domain-requests into + manageable groups. + """ + + # use the short names in Django admin + OrganizationChoices = DomainRequest.OrganizationChoices + StateTerritoryChoices = DomainRequest.StateTerritoryChoices + + # Stores who created this model. If no creator is specified in DJA, + # then the creator will default to the current request user""" + creator = models.ForeignKey("registrar.User", on_delete=models.PROTECT, help_text="Associated user", unique=False) + + notes = models.TextField( + null=True, + blank=True, + ) + + federal_agency = models.ForeignKey( + "registrar.FederalAgency", + on_delete=models.PROTECT, + help_text="Associated federal agency", + unique=False, + default=FederalAgency.get_non_federal_agency, + ) + + organization_type = models.CharField( + max_length=255, + choices=OrganizationChoices.choices, + null=True, + blank=True, + help_text="Type of organization", + ) + + organization_name = models.CharField( + null=True, + blank=True, + ) + + address_line1 = models.CharField( + null=True, + blank=True, + verbose_name="address line 1", + ) + + address_line2 = models.CharField( + null=True, + blank=True, + verbose_name="address line 2", + ) + + city = models.CharField( + null=True, + blank=True, + ) + + # (imports enums from domain_request.py) + state_territory = models.CharField( + max_length=2, + choices=StateTerritoryChoices.choices, + null=True, + blank=True, + verbose_name="state / territory", + ) + + zipcode = models.CharField( + max_length=10, + null=True, + blank=True, + verbose_name="zip code", + ) + + urbanization = models.CharField( + null=True, + blank=True, + help_text="Required for Puerto Rico only", + verbose_name="urbanization", + ) + + security_contact_email = models.EmailField( + null=True, + blank=True, + verbose_name="security contact e-mail", + max_length=320, + ) diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py index ca6ce6c31..f9d4303c4 100644 --- a/src/registrar/models/utility/generic_helper.py +++ b/src/registrar/models/utility/generic_helper.py @@ -298,3 +298,26 @@ def replace_url_queryparams(url_to_modify: str, query_params, convert_list_to_cs new_url = urlunparse(url_parts) return new_url + + +def convert_queryset_to_dict(queryset, is_model=True, key="id"): + """ + Transforms a queryset into a dictionary keyed by a specified key (like "id"). + + Parameters: + requests (QuerySet or list of dicts): Input data. + is_model (bool): Indicates if each item in 'queryset' are model instances (True) or dictionaries (False). + key (str): Key or attribute to use for the resulting dictionary's keys. + + Returns: + dict: Dictionary with keys derived from 'key' and values corresponding to items in 'queryset'. + """ + + if is_model: + request_dict = {getattr(value, key): value for value in queryset} + else: + # Querysets sometimes contain sets of dictionaries. + # Calling .values is an example of this. + request_dict = {value[key]: value for value in queryset} + + return request_dict diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index f9921513b..79e3b7a11 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -5,6 +5,7 @@ Contains middleware used in settings.py from urllib.parse import parse_qs from django.urls import reverse from django.http import HttpResponseRedirect +from registrar.models.user import User from waffle.decorators import flag_is_active from registrar.models.utility.generic_helper import replace_url_queryparams @@ -38,10 +39,17 @@ class CheckUserProfileMiddleware: self.get_response = get_response self.setup_page = reverse("finish-user-profile-setup") + self.profile_page = reverse("user-profile") self.logout_page = reverse("logout") - self.excluded_pages = [ + self.regular_excluded_pages = [ self.setup_page, self.logout_page, + "/admin", + ] + self.other_excluded_pages = [ + self.profile_page, + self.logout_page, + "/admin", ] def __call__(self, request): @@ -61,12 +69,15 @@ class CheckUserProfileMiddleware: if request.user.is_authenticated: if hasattr(request.user, "finished_setup") and not request.user.finished_setup: - return self._handle_setup_not_finished(request) + if request.user.verification_type == User.VerificationTypeChoices.REGULAR: + return self._handle_regular_user_setup_not_finished(request) + else: + return self._handle_other_user_setup_not_finished(request) # Continue processing the view return None - def _handle_setup_not_finished(self, request): + def _handle_regular_user_setup_not_finished(self, request): """Redirects the given user to the finish setup page. We set the "redirect" query param equal to where the user wants to go. @@ -82,7 +93,7 @@ class CheckUserProfileMiddleware: custom_redirect = "domain-request:" if request.path == "/request/" else None # Don't redirect on excluded pages (such as the setup page itself) - if not any(request.path.startswith(page) for page in self.excluded_pages): + if not any(request.path.startswith(page) for page in self.regular_excluded_pages): # Preserve the original query parameters, and coerce them into a dict query_params = parse_qs(request.META["QUERY_STRING"]) @@ -98,3 +109,13 @@ class CheckUserProfileMiddleware: else: # Process the view as normal return None + + def _handle_other_user_setup_not_finished(self, request): + """Redirects the given user to the profile page to finish setup.""" + + # Don't redirect on excluded pages (such as the setup page itself) + if not any(request.path.startswith(page) for page in self.other_excluded_pages): + return HttpResponseRedirect(self.profile_page) + else: + # Process the view as normal + return None diff --git a/src/registrar/templates/admin/analytics.html b/src/registrar/templates/admin/analytics.html index e73f22ec5..13db3b60a 100644 --- a/src/registrar/templates/admin/analytics.html +++ b/src/registrar/templates/admin/analytics.html @@ -27,28 +27,35 @@

Current domains

-
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 5f23ac6c0..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/domain_request_additional_details.html b/src/registrar/templates/domain_request_additional_details.html index e13d3c7ee..c78bc8e96 100644 --- a/src/registrar/templates/domain_request_additional_details.html +++ b/src/registrar/templates/domain_request_additional_details.html @@ -1,16 +1,14 @@ {% extends 'domain_request_form.html' %} {% load static field_helpers %} -{% block form_instructions %} - These questions are required (*). -{% endblock %} + {% block form_required_fields_help_text %} -{# commented out so it does not appear at this point on this page #} +{% include "includes/required_fields.html" %} {% endblock %} - {% block form_fields %} +

Are you working with a CISA regional representative on your domain request?

@@ -18,38 +16,38 @@
+ Select one (*). {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} {% input_with_errors forms.0.has_cisa_representative %} {% endwith %} {# forms.0 is a small yes/no form that toggles the visibility of "cisa representative" formset #} -
-
+
+ {% input_with_errors forms.1.cisa_representative_first_name %} + {% input_with_errors forms.1.cisa_representative_last_name %} {% input_with_errors forms.1.cisa_representative_email %} {# forms.1 is a form for inputting the e-mail of a cisa representative #} -
-

Is there anything else you’d like us to know about your domain request?

+ Select one (*). {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} {% input_with_errors forms.2.has_anything_else_text %} {% endwith %} {# forms.2 is a small yes/no form that toggles the visibility of "cisa representative" formset #} -
-
+
+

Provide details below (*).

{% with attr_maxlength=2000 add_label_class="usa-sr-only" %} {% input_with_errors forms.3.anything_else %} {% endwith %} {# forms.3 is a form for inputting the e-mail of a cisa representative #} -
{% endblock %} diff --git a/src/registrar/templates/domain_request_review.html b/src/registrar/templates/domain_request_review.html index 1f21683a5..bb8ad498e 100644 --- a/src/registrar/templates/domain_request_review.html +++ b/src/registrar/templates/domain_request_review.html @@ -157,18 +157,33 @@ {% if step == Step.ADDITIONAL_DETAILS %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.requested_domain.name|default:"Incomplete"|safe %} - {% include "includes/summary_item.html" with title=title sub_header_text='CISA regional representative' value=domain_request.cisa_representative_email heading_level=heading_level editable=True edit_link=domain_request_url custom_text_for_value_none='No' %} - {% endwith %} + {% with title=form_titles|get_item:step %} + {% if domain_request.has_additional_details %} + {% include "includes/summary_item.html" with title="Additional Details" value=" " heading_level=heading_level editable=True edit_link=domain_request_url %} +

CISA Regional Representative

+ -

Anything else

- +

Anything else

+ + {% else %} + {% include "includes/summary_item.html" with title="Additional Details" value="Incomplete"|safe heading_level=heading_level editable=True edit_link=domain_request_url %} + {% endif %} + {% endwith %} {% endif %} diff --git a/src/registrar/templates/domain_request_status.html b/src/registrar/templates/domain_request_status.html index ac447476d..16ce26f4c 100644 --- a/src/registrar/templates/domain_request_status.html +++ b/src/registrar/templates/domain_request_status.html @@ -118,7 +118,15 @@ {# We always show this field even if None #} {% if DomainRequest %} - {% include "includes/summary_item.html" with title='Additional details' sub_header_text='CISA regional representative' value=DomainRequest.cisa_representative_email custom_text_for_value_none='No' heading_level=heading_level %} +

CISA Regional Representative

+ +

Anything else

{% endif %} - {% endwith %}
diff --git a/src/registrar/templates/emails/action_needed_reasons/already_has_domains.txt b/src/registrar/templates/emails/action_needed_reasons/already_has_domains.txt new file mode 100644 index 000000000..842b4ac4f --- /dev/null +++ b/src/registrar/templates/emails/action_needed_reasons/already_has_domains.txt @@ -0,0 +1,51 @@ +{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} +Hi, {{ domain_request.submitter.first_name }}. + +We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request. + +DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} +REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +STATUS: Action needed + +---------------------------------------------------------------- + +ORGANIZATION ALREADY HAS A .GOV DOMAIN +We've reviewed your domain request, but your organization already has at least one other .gov domain. We need more information about your rationale for registering another .gov domain. +In general, there are two reasons we will approve an additional domain: +- You determine a current .gov domain name will be replaced +- We determine an additional domain name is appropriate + + +WE LIMIT ADDITIONAL DOMAIN NAMES +Our practice is to only approve one domain per online service per government organization, evaluating additional requests on a case-by-case basis. +There are two core reasons we limit additional domains: +- We want to minimize your operational and security load, which increases with each additional domain. +- Fewer domains allow us to take protective, namespace-wide security actions faster and without undue dependencies. + +If you’re attempting to claim an additional domain to prevent others from obtaining it, that’s not necessary. .Gov domains are only available to U.S.-based government organizations, and we don’t operate on a first come, first served basis. We'll only assign a domain to the organization whose real name or services actually correspond to the domain name. + + +CONSIDER USING A SUBDOMAIN +Using a subdomain of an existing domain (e.g., service.domain.gov) is a common approach to logically divide your namespace while still maintaining an association with your existing domain name. Subdomains can also be delegated to allow an affiliated entity to manage their own DNS settings. + + +ACTION NEEDED +FOR A REPLACEMENT DOMAIN: If you’re requesting a new domain that will replace your current domain name, we can allow for a transition period where both are registered to your organization. Afterwards, we will reclaim and retire the legacy name. + +Reply to this email. Tell us how many months your organization needs to maintain your current .gov domain and conduct a transition to a new one. Detail why that period of time is needed. + +FOR AN ADDITIONAL DOMAIN: If you’re requesting an additional domain and not replacing your existing one, we’ll need more information to support that request. + +Reply to this email. Detail why you believe another domain is necessary for your organization, and why a subdomain won’t meet your needs. + + +If you have questions or comments, include those in your reply. + +---------------------------------------------------------------- + +The .gov team +Contact us: +Learn about .gov + +The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) +{% endautoescape %} \ No newline at end of file diff --git a/src/registrar/templates/emails/action_needed_reasons/already_has_domains_subject.txt b/src/registrar/templates/emails/action_needed_reasons/already_has_domains_subject.txt new file mode 100644 index 000000000..7ca332ddd --- /dev/null +++ b/src/registrar/templates/emails/action_needed_reasons/already_has_domains_subject.txt @@ -0,0 +1 @@ +Update on your .gov request: {{ domain_request.requested_domain.name }} \ No newline at end of file diff --git a/src/registrar/templates/emails/action_needed_reasons/bad_name.txt b/src/registrar/templates/emails/action_needed_reasons/bad_name.txt new file mode 100644 index 000000000..beba3521d --- /dev/null +++ b/src/registrar/templates/emails/action_needed_reasons/bad_name.txt @@ -0,0 +1,34 @@ +{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} +Hi, {{ domain_request.submitter.first_name }}. + +We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request. + +DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} +REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +STATUS: Action needed + +---------------------------------------------------------------- + +DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS +We've reviewed your domain request and, unfortunately, it does not meet our naming requirements. + +Domains should uniquely identify a government organization and be clear to the general public. Read more about naming requirements for your type of organization . + + +ACTION NEEDED +First, we need you to identify a new domain name that meets our naming requirements for your type of organization. Then, log in to the registrar and update the name in your domain request. Once you submit your updated request, we’ll resume the adjudication process. + +If you have questions or want to discuss potential domain names, reply to this email. + + +THANK YOU +.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain. + +---------------------------------------------------------------- + +The .gov team +Contact us: +Learn about .gov + +The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) +{% endautoescape %} \ No newline at end of file diff --git a/src/registrar/templates/emails/action_needed_reasons/bad_name_subject.txt b/src/registrar/templates/emails/action_needed_reasons/bad_name_subject.txt new file mode 100644 index 000000000..7ca332ddd --- /dev/null +++ b/src/registrar/templates/emails/action_needed_reasons/bad_name_subject.txt @@ -0,0 +1 @@ +Update on your .gov request: {{ domain_request.requested_domain.name }} \ No newline at end of file diff --git a/src/registrar/templates/emails/action_needed_reasons/eligibility_unclear.txt b/src/registrar/templates/emails/action_needed_reasons/eligibility_unclear.txt new file mode 100644 index 000000000..df8db313d --- /dev/null +++ b/src/registrar/templates/emails/action_needed_reasons/eligibility_unclear.txt @@ -0,0 +1,35 @@ +{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} +Hi, {{ domain_request.submitter.first_name }}. + +We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request. + +DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} +REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +STATUS: Action needed + +---------------------------------------------------------------- + +ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS +We've reviewed your domain request, but we need more information about the organization you represent: +- {{ domain_request.organization_name }} + +.Gov domains are only available to official US-based government organizations, not simply those that provide a public benefit. We lack clear documentation that demonstrates your organization is eligible for a .gov domain. + + +ACTION NEEDED +Reply to this email with links to (or copies of) your authorizing legislation, your founding charter or bylaws, recent election results, or other similar documentation. Without this, we can’t continue our review and your request will likely be rejected. + +If you have questions or comments, include those in your reply. + + +THANK YOU +.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain. + +---------------------------------------------------------------- + +The .gov team +Contact us: +Learn about .gov + +The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) +{% endautoescape %} \ No newline at end of file diff --git a/src/registrar/templates/emails/action_needed_reasons/eligibility_unclear_subject.txt b/src/registrar/templates/emails/action_needed_reasons/eligibility_unclear_subject.txt new file mode 100644 index 000000000..7ca332ddd --- /dev/null +++ b/src/registrar/templates/emails/action_needed_reasons/eligibility_unclear_subject.txt @@ -0,0 +1 @@ +Update on your .gov request: {{ domain_request.requested_domain.name }} \ No newline at end of file diff --git a/src/registrar/templates/emails/action_needed_reasons/questionable_authorizing_official.txt b/src/registrar/templates/emails/action_needed_reasons/questionable_authorizing_official.txt new file mode 100644 index 000000000..1c2c5d855 --- /dev/null +++ b/src/registrar/templates/emails/action_needed_reasons/questionable_authorizing_official.txt @@ -0,0 +1,36 @@ +{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} +Hi, {{ domain_request.submitter.first_name }}. + +We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request. + +DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} +REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +STATUS: Action needed + +---------------------------------------------------------------- + +AUTHORIZING OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS +We've reviewed your domain request, but we need more information about the authorizing official listed on the request: +- {{ domain_request.authorizing_official.get_formatted_name }} +- {{ domain_request.authorizing_official.title }} + +We expect an authorizing official to be someone in a role of significant, executive responsibility within the organization. Our guidelines are open-ended to accommodate the wide variety of government organizations that are eligible for .gov domains, but the person you listed does not meet our expectations for your type of organization. Read more about our guidelines for authorizing officials. + + +ACTION NEEDED +Reply to this email with a justification for naming {{ domain_request.authorizing_official.get_formatted_name }} as the authorizing official. If you have questions or comments, include those in your reply. + +Alternatively, you can log in to the registrar and enter a different authorizing official for this domain request. Once you submit your updated request, we’ll resume the adjudication process. + + +THANK YOU +.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain. + +---------------------------------------------------------------- + +The .gov team +Contact us: +Learn about .gov + +The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) +{% endautoescape %} \ No newline at end of file diff --git a/src/registrar/templates/emails/action_needed_reasons/questionable_authorizing_official_subject.txt b/src/registrar/templates/emails/action_needed_reasons/questionable_authorizing_official_subject.txt new file mode 100644 index 000000000..7ca332ddd --- /dev/null +++ b/src/registrar/templates/emails/action_needed_reasons/questionable_authorizing_official_subject.txt @@ -0,0 +1 @@ +Update on your .gov request: {{ domain_request.requested_domain.name }} \ No newline at end of file diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index fd54769a8..f93159f01 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -15,7 +15,11 @@ {% endblock %}

Manage your domains

- + {% comment %} + IMPORTANT: + If this button is added on any other page, make sure to update the + relevant view to reset request.session["new_request"] = True + {% endcomment %}

@@ -23,10 +27,39 @@

-
-

Domains

-