diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index af8108d92..c70a6dcf5 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -5,6 +5,11 @@ from django import forms
from django.db.models import Value, CharField, Q
from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect
+from registrar.utility.admin_helpers import (
+ get_action_needed_reason_default_email,
+ get_rejection_reason_default_email,
+ get_field_links_as_list,
+)
from django.conf import settings
from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField
@@ -20,11 +25,6 @@ from epplibwrapper.errors import ErrorCode, RegistryError
from registrar.models.user_domain_role import UserDomainRole
from waffle.admin import FlagAdmin
from waffle.models import Sample, Switch
-from registrar.utility.admin_helpers import (
- get_all_action_needed_reason_emails,
- get_action_needed_reason_default_email,
- get_field_links_as_list,
-)
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
from registrar.utility.constants import BranchChoices
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
@@ -237,6 +237,7 @@ class DomainRequestAdminForm(forms.ModelForm):
}
labels = {
"action_needed_reason_email": "Email",
+ "rejection_reason_email": "Email",
}
def __init__(self, *args, **kwargs):
@@ -1750,6 +1751,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"status_history",
"status",
"rejection_reason",
+ "rejection_reason_email",
"action_needed_reason",
"action_needed_reason_email",
"investigator",
@@ -1905,25 +1907,11 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Get the original domain request from the database.
original_obj = models.DomainRequest.objects.get(pk=obj.pk)
- # == Handle action_needed_reason == #
-
- reason_changed = obj.action_needed_reason != original_obj.action_needed_reason
- if reason_changed:
- # Track the fact that we sent out an email
- request.session["action_needed_email_sent"] = True
-
- # Set the action_needed_reason_email to the default if nothing exists.
- # Since this check occurs after save, if the user enters a value then we won't update.
-
- default_email = get_action_needed_reason_default_email(request, obj, obj.action_needed_reason)
- if obj.action_needed_reason_email:
- emails = get_all_action_needed_reason_emails(request, obj)
- is_custom_email = obj.action_needed_reason_email not in emails.values()
- if not is_custom_email:
- obj.action_needed_reason_email = default_email
- else:
- obj.action_needed_reason_email = default_email
+ # == Handle action needed and rejected emails == #
+ # Edge case: this logic is handled by javascript, so contexts outside that must be handled
+ obj = self._handle_custom_emails(obj)
+ # == Handle allowed emails == #
if obj.status in DomainRequest.get_statuses_that_send_emails() and not settings.IS_PRODUCTION:
self._check_for_valid_email(request, obj)
@@ -1939,6 +1927,15 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if should_save:
return super().save_model(request, obj, form, change)
+ def _handle_custom_emails(self, obj):
+ if obj.status == DomainRequest.DomainRequestStatus.ACTION_NEEDED:
+ if obj.action_needed_reason and not obj.action_needed_reason_email:
+ obj.action_needed_reason_email = get_action_needed_reason_default_email(obj, obj.action_needed_reason)
+ elif obj.status == DomainRequest.DomainRequestStatus.REJECTED:
+ if obj.rejection_reason and not obj.rejection_reason_email:
+ obj.rejection_reason_email = get_rejection_reason_default_email(obj, obj.rejection_reason)
+ return obj
+
def _check_for_valid_email(self, request, obj):
"""Certain emails are whitelisted in non-production environments,
so we should display that information using this function.
@@ -1976,18 +1973,30 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# If the status is not mapped properly, saving could cause
# weird issues down the line. Instead, we should block this.
+ # NEEDS A UNIT TEST
should_proceed = False
- return should_proceed
+ return (obj, should_proceed)
- request_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
- if request_is_not_approved and not obj.domain_is_not_active():
- # If an admin tried to set an approved domain request to
- # another status and the related domain is already
- # active, shortcut the action and throw a friendly
- # error message. This action would still not go through
- # shortcut or not as the rules are duplicated on the model,
- # but the error would be an ugly Django error screen.
+ obj_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
+ if obj_is_not_approved and not obj.domain_is_not_active():
+ # REDUNDANT CHECK / ERROR SCREEN AVOIDANCE:
+ # This action (moving a request from approved to
+ # another status) when the domain is already active (READY),
+ # would still not go through even without this check as the rules are
+ # duplicated in the model and the error is raised from the model.
+ # This avoids an ugly Django error screen.
error_message = "This action is not permitted. The domain is already active."
+ elif (
+ original_obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
+ and obj.status == models.DomainRequest.DomainRequestStatus.APPROVED
+ and original_obj.requested_domain is not None
+ and Domain.objects.filter(name=original_obj.requested_domain.name).exists()
+ ):
+ # REDUNDANT CHECK:
+ # This action (approving a request when the domain exists)
+ # would still not go through even without this check as the rules are
+ # duplicated in the model and the error is raised from the model.
+ error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.APPROVE_DOMAIN_IN_USE)
elif obj.status == models.DomainRequest.DomainRequestStatus.REJECTED and not obj.rejection_reason:
# This condition should never be triggered.
# The opposite of this condition is acceptable (rejected -> other status and rejection_reason)
diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js
index 73f3dded1..fd50fbb0c 100644
--- a/src/registrar/assets/js/get-gov-admin.js
+++ b/src/registrar/assets/js/get-gov-admin.js
@@ -344,69 +344,6 @@ function initializeWidgetOnList(list, parentId) {
}
}
-/** An IIFE for admin in DjangoAdmin to listen to changes on the domain request
- * status select and to show/hide the rejection reason
-*/
-(function (){
- let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
- // This is the "action needed reason" field
- let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason');
- // This is the "Email" field
- let actionNeededReasonEmailFormGroup = document.querySelector('.field-action_needed_reason_email')
-
- if (rejectionReasonFormGroup && actionNeededReasonFormGroup && actionNeededReasonEmailFormGroup) {
- let statusSelect = document.getElementById('id_status')
- let isRejected = statusSelect.value == "rejected"
- let isActionNeeded = statusSelect.value == "action needed"
-
- // Initial handling of rejectionReasonFormGroup display
- showOrHideObject(rejectionReasonFormGroup, show=isRejected)
- showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)
- showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
-
- // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
- statusSelect.addEventListener('change', function() {
- // 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)
- showOrHideObject(actionNeededReasonEmailFormGroup, 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)
- showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
- }
- });
- });
- observer.observe({ type: "navigation" });
- }
-
- // 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");
- }
- }
-})();
/** An IIFE for toggling the submit bar on domain request forms
*/
@@ -501,82 +438,110 @@ function initializeWidgetOnList(list, parentId) {
})();
-/** An IIFE that hooks to the show/hide button underneath action needed reason.
- * This shows the auto generated email on action needed reason.
-*/
-document.addEventListener('DOMContentLoaded', function() {
- const dropdown = document.getElementById("id_action_needed_reason");
- const textarea = document.getElementById("id_action_needed_reason_email")
- const domainRequestId = dropdown ? document.getElementById("domain_request_id").value : null
- const textareaPlaceholder = document.querySelector(".field-action_needed_reason_email__placeholder");
- const directEditButton = document.querySelector('.field-action_needed_reason_email__edit');
- const modalTrigger = document.querySelector('.field-action_needed_reason_email__modal-trigger');
- const modalConfirm = document.getElementById('confirm-edit-email');
- const formLabel = document.querySelector('label[for="id_action_needed_reason_email"]');
- let lastSentEmailContent = document.getElementById("last-sent-email-content");
- const initialDropdownValue = dropdown ? dropdown.value : null;
- const initialEmailValue = textarea.value;
+class CustomizableEmailBase {
+
+ /**
+ * @param {Object} config - must contain the following:
+ * @property {HTMLElement} dropdown - The dropdown element.
+ * @property {HTMLElement} textarea - The textarea element.
+ * @property {HTMLElement} lastSentEmailContent - The last sent email content element.
+ * @property {HTMLElement} textAreaFormGroup - The form group for the textarea.
+ * @property {HTMLElement} dropdownFormGroup - The form group for the dropdown.
+ * @property {HTMLElement} modalConfirm - The confirm button in the modal.
+ * @property {string} apiUrl - The API URL for fetching email content.
+ * @property {string} statusToCheck - The status to check against. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
+ * @property {string} sessionVariableName - The session variable name. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
+ * @property {string} apiErrorMessage - The error message that the ajax call returns.
+ */
+ constructor(config) {
+ this.config = config;
+ this.dropdown = config.dropdown;
+ this.textarea = config.textarea;
+ this.lastSentEmailContent = config.lastSentEmailContent;
+ this.apiUrl = config.apiUrl;
+ this.apiErrorMessage = config.apiErrorMessage;
+ this.modalConfirm = config.modalConfirm;
+
+ // These fields are hidden/shown on pageload depending on the current status
+ this.textAreaFormGroup = config.textAreaFormGroup;
+ this.dropdownFormGroup = config.dropdownFormGroup;
+ this.statusToCheck = config.statusToCheck;
+ this.sessionVariableName = config.sessionVariableName;
+
+ // Non-configurable variables
+ this.statusSelect = document.getElementById("id_status");
+ this.domainRequestId = this.dropdown ? document.getElementById("domain_request_id").value : null
+ this.initialDropdownValue = this.dropdown ? this.dropdown.value : null;
+ this.initialEmailValue = this.textarea ? this.textarea.value : null;
+
+ // Find other fields near the textarea
+ const parentDiv = this.textarea ? this.textarea.closest(".flex-container") : null;
+ this.directEditButton = parentDiv ? parentDiv.querySelector(".edit-email-button") : null;
+ this.modalTrigger = parentDiv ? parentDiv.querySelector(".edit-button-modal-trigger") : null;
+
+ this.textareaPlaceholder = parentDiv ? parentDiv.querySelector(".custom-email-placeholder") : null;
+ this.formLabel = this.textarea ? document.querySelector(`label[for="${this.textarea.id}"]`) : null;
+
+ this.isEmailAlreadySentConst;
+ if (this.lastSentEmailContent && this.textarea) {
+ this.isEmailAlreadySentConst = this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, '');
+ }
- // We will use the const to control the modal
- let isEmailAlreadySentConst = lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, '');
- // We will use the function to control the label and help
- function isEmailAlreadySent() {
- return lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, '');
}
- if (!dropdown || !textarea || !domainRequestId || !formLabel || !modalConfirm) return;
- const apiUrl = document.getElementById("get-action-needed-email-for-user-json").value;
+ // Handle showing/hiding the related fields on page load.
+ initializeFormGroups() {
+ let isStatus = this.statusSelect.value == this.statusToCheck;
- function updateUserInterface(reason) {
- if (!reason) {
- // No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text
- formLabel.innerHTML = "Email:";
- textareaPlaceholder.innerHTML = "Select an action needed reason to see email";
- showElement(textareaPlaceholder);
- hideElement(directEditButton);
- hideElement(modalTrigger);
- hideElement(textarea);
- } else if (reason === 'other') {
- // 'Other' selected, we will set the label to "Email", show the "No email will be sent" placeholder, hide the trigger, textarea, hide the help text
- formLabel.innerHTML = "Email:";
- textareaPlaceholder.innerHTML = "No email will be sent";
- showElement(textareaPlaceholder);
- hideElement(directEditButton);
- hideElement(modalTrigger);
- hideElement(textarea);
- } else {
- // A triggering selection is selected, all hands on board:
- textarea.setAttribute('readonly', true);
- showElement(textarea);
- hideElement(textareaPlaceholder);
+ // Initial handling of these groups.
+ this.updateFormGroupVisibility(isStatus);
- if (isEmailAlreadySentConst) {
- hideElement(directEditButton);
- showElement(modalTrigger);
- } else {
- showElement(directEditButton);
- hideElement(modalTrigger);
- }
- if (isEmailAlreadySent()) {
- formLabel.innerHTML = "Email sent to creator:";
- } else {
- formLabel.innerHTML = "Email:";
- }
+ // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
+ this.statusSelect.addEventListener('change', () => {
+ // Show the action needed field if the status is what we expect.
+ // Then track if its shown or hidden in our session cache.
+ isStatus = this.statusSelect.value == this.statusToCheck;
+ this.updateFormGroupVisibility(isStatus);
+ addOrRemoveSessionBoolean(this.sessionVariableName, isStatus);
+ });
+
+ // 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 showTextAreaFormGroup = sessionStorage.getItem(this.sessionVariableName) !== null;
+ this.updateFormGroupVisibility(showTextAreaFormGroup);
+ }
+ });
+ });
+ observer.observe({ type: "navigation" });
+ }
+
+ updateFormGroupVisibility(showFormGroups) {
+ if (showFormGroups) {
+ showElement(this.textAreaFormGroup);
+ showElement(this.dropdownFormGroup);
+ }else {
+ hideElement(this.textAreaFormGroup);
+ hideElement(this.dropdownFormGroup);
}
}
- // Initialize UI
- updateUserInterface(dropdown.value);
-
- dropdown.addEventListener("change", function() {
- const reason = dropdown.value;
- // Update the UI
- updateUserInterface(reason);
- if (reason && reason !== "other") {
- // If it's not the initial value
- if (initialDropdownValue !== dropdown.value || initialEmailValue !== textarea.value) {
+ initializeDropdown() {
+ this.dropdown.addEventListener("change", () => {
+ let reason = this.dropdown.value;
+ if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) {
+ let searchParams = new URLSearchParams(
+ {
+ "reason": reason,
+ "domain_request_id": this.domainRequestId,
+ }
+ );
// Replace the email content
- fetch(`${apiUrl}?reason=${reason}&domain_request_id=${domainRequestId}`)
+ fetch(`${this.apiUrl}?${searchParams.toString()}`)
.then(response => {
return response.json().then(data => data);
})
@@ -584,30 +549,213 @@ document.addEventListener('DOMContentLoaded', function() {
if (data.error) {
console.error("Error in AJAX call: " + data.error);
}else {
- textarea.value = data.action_needed_email;
+ this.textarea.value = data.email;
}
- updateUserInterface(reason);
+ this.updateUserInterface(reason);
})
.catch(error => {
- console.error("Error action needed email: ", error)
+ console.error(this.apiErrorMessage, error)
});
}
+ });
+ }
+
+ initializeModalConfirm() {
+ this.modalConfirm.addEventListener("click", () => {
+ this.textarea.removeAttribute('readonly');
+ this.textarea.focus();
+ hideElement(this.directEditButton);
+ hideElement(this.modalTrigger);
+ });
+ }
+
+ initializeDirectEditButton() {
+ this.directEditButton.addEventListener("click", () => {
+ this.textarea.removeAttribute('readonly');
+ this.textarea.focus();
+ hideElement(this.directEditButton);
+ hideElement(this.modalTrigger);
+ });
+ }
+
+ isEmailAlreadySent() {
+ return this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, '');
+ }
+
+ updateUserInterface(reason=this.dropdown.value, excluded_reasons=["other"]) {
+ if (!reason) {
+ // No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text
+ this.showPlaceholderNoReason();
+ } else if (excluded_reasons.includes(reason)) {
+ // 'Other' selected, we will set the label to "Email", show the "No email will be sent" placeholder, hide the trigger, textarea, hide the help text
+ this.showPlaceholderOtherReason();
+ } else {
+ this.showReadonlyTextarea();
+ }
+ }
+
+ // Helper function that makes overriding the readonly textarea easy
+ showReadonlyTextarea() {
+ // A triggering selection is selected, all hands on board:
+ this.textarea.setAttribute('readonly', true);
+ showElement(this.textarea);
+ hideElement(this.textareaPlaceholder);
+
+ if (this.isEmailAlreadySentConst) {
+ hideElement(this.directEditButton);
+ showElement(this.modalTrigger);
+ } else {
+ showElement(this.directEditButton);
+ hideElement(this.modalTrigger);
}
- });
+ if (this.isEmailAlreadySent()) {
+ this.formLabel.innerHTML = "Email sent to creator:";
+ } else {
+ this.formLabel.innerHTML = "Email:";
+ }
+ }
- modalConfirm.addEventListener("click", () => {
- textarea.removeAttribute('readonly');
- textarea.focus();
- hideElement(directEditButton);
- hideElement(modalTrigger);
- });
- directEditButton.addEventListener("click", () => {
- textarea.removeAttribute('readonly');
- textarea.focus();
- hideElement(directEditButton);
- hideElement(modalTrigger);
- });
+ // Helper function that makes overriding the placeholder reason easy
+ showPlaceholderNoReason() {
+ this.showPlaceholder("Email:", "Select a reason to see email");
+ }
+
+ // Helper function that makes overriding the placeholder reason easy
+ showPlaceholderOtherReason() {
+ this.showPlaceholder("Email:", "No email will be sent");
+ }
+
+ showPlaceholder(formLabelText, placeholderText) {
+ this.formLabel.innerHTML = formLabelText;
+ this.textareaPlaceholder.innerHTML = placeholderText;
+ showElement(this.textareaPlaceholder);
+ hideElement(this.directEditButton);
+ hideElement(this.modalTrigger);
+ hideElement(this.textarea);
+ }
+}
+
+
+
+class customActionNeededEmail extends CustomizableEmailBase {
+ constructor() {
+ const emailConfig = {
+ dropdown: document.getElementById("id_action_needed_reason"),
+ textarea: document.getElementById("id_action_needed_reason_email"),
+ lastSentEmailContent: document.getElementById("last-sent-action-needed-email-content"),
+ modalConfirm: document.getElementById("action-needed-reason__confirm-edit-email"),
+ apiUrl: document.getElementById("get-action-needed-email-for-user-json")?.value || null,
+ textAreaFormGroup: document.querySelector('.field-action_needed_reason'),
+ dropdownFormGroup: document.querySelector('.field-action_needed_reason_email'),
+ statusToCheck: "action needed",
+ sessionVariableName: "showActionNeededReason",
+ apiErrorMessage: "Error when attempting to grab action needed email: "
+ }
+ super(emailConfig);
+ }
+
+ loadActionNeededEmail() {
+ // Hide/show the email fields depending on the current status
+ this.initializeFormGroups();
+ // Setup the textarea, edit button, helper text
+ this.updateUserInterface();
+ this.initializeDropdown();
+ this.initializeModalConfirm();
+ this.initializeDirectEditButton();
+ }
+
+ // Overrides the placeholder text when no reason is selected
+ showPlaceholderNoReason() {
+ this.showPlaceholder("Email:", "Select an action needed reason to see email");
+ }
+
+ // Overrides the placeholder text when the reason other is selected
+ showPlaceholderOtherReason() {
+ this.showPlaceholder("Email:", "No email will be sent");
+ }
+}
+
+/** An IIFE that hooks to the show/hide button underneath action needed reason.
+ * This shows the auto generated email on action needed reason.
+*/
+document.addEventListener('DOMContentLoaded', function() {
+ const domainRequestForm = document.getElementById("domainrequest_form");
+ if (!domainRequestForm) {
+ return;
+ }
+
+ // Initialize UI
+ const customEmail = new customActionNeededEmail();
+
+ // Check that every variable was setup correctly
+ const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key);
+ if (nullItems.length > 0) {
+ console.error(`Failed to load customActionNeededEmail(). Some variables were null: ${nullItems.join(", ")}`)
+ return;
+ }
+ customEmail.loadActionNeededEmail()
+});
+
+
+class customRejectedEmail extends CustomizableEmailBase {
+ constructor() {
+ const emailConfig = {
+ dropdown: document.getElementById("id_rejection_reason"),
+ textarea: document.getElementById("id_rejection_reason_email"),
+ lastSentEmailContent: document.getElementById("last-sent-rejection-email-content"),
+ modalConfirm: document.getElementById("rejection-reason__confirm-edit-email"),
+ apiUrl: document.getElementById("get-rejection-email-for-user-json")?.value || null,
+ textAreaFormGroup: document.querySelector('.field-rejection_reason'),
+ dropdownFormGroup: document.querySelector('.field-rejection_reason_email'),
+ statusToCheck: "rejected",
+ sessionVariableName: "showRejectionReason",
+ errorMessage: "Error when attempting to grab rejected email: "
+ };
+ super(emailConfig);
+ }
+
+ loadRejectedEmail() {
+ this.initializeFormGroups();
+ this.updateUserInterface();
+ this.initializeDropdown();
+ this.initializeModalConfirm();
+ this.initializeDirectEditButton();
+ }
+
+ // Overrides the placeholder text when no reason is selected
+ showPlaceholderNoReason() {
+ this.showPlaceholder("Email:", "Select a rejection reason to see email");
+ }
+
+ updateUserInterface(reason=this.dropdown.value, excluded_reasons=[]) {
+ super.updateUserInterface(reason, excluded_reasons);
+ }
+ // Overrides the placeholder text when the reason other is selected
+ // showPlaceholderOtherReason() {
+ // this.showPlaceholder("Email:", "No email will be sent");
+ // }
+}
+
+
+/** An IIFE that hooks to the show/hide button underneath rejected reason.
+ * This shows the auto generated email on action needed reason.
+*/
+document.addEventListener('DOMContentLoaded', function() {
+ const domainRequestForm = document.getElementById("domainrequest_form");
+ if (!domainRequestForm) {
+ return;
+ }
+
+ // Initialize UI
+ const customEmail = new customRejectedEmail();
+ // Check that every variable was setup correctly
+ const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key);
+ if (nullItems.length > 0) {
+ console.error(`Failed to load customRejectedEmail(). Some variables were null: ${nullItems.join(", ")}`)
+ return;
+ }
+ customEmail.loadRejectedEmail()
});
@@ -706,18 +854,6 @@ document.addEventListener('DOMContentLoaded', function() {
}
return '';
}
- // Extract the submitter name, title, email, and phone number
- const submitterDiv = document.querySelector('.form-row.field-submitter');
- const submitterNameElement = document.getElementById('id_submitter');
- // We have to account for different superuser and analyst markups
- const submitterName = submitterNameElement
- ? submitterNameElement.options[submitterNameElement.selectedIndex].text
- : submitterDiv.querySelector('a').text;
- const submitterTitle = extractTextById('contact_info_title', submitterDiv);
- const submitterEmail = extractTextById('contact_info_email', submitterDiv);
- const submitterPhone = extractTextById('contact_info_phone', submitterDiv);
- let submitterInfo = `${submitterName}${submitterTitle}${submitterEmail}${submitterPhone}`;
-
//------ Senior Official
const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official');
@@ -734,7 +870,6 @@ document.addEventListener('DOMContentLoaded', function() {
`Current Websites: ${existingWebsites.join(', ')}` +
`Rationale:` +
`Alternative Domains: ${alternativeDomains.join(', ')}` +
- `Submitter: ${submitterInfo}` +
`Senior Official: ${seniorOfficialInfo}` +
`Other Employees: ${otherContactsSummary}`;
diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss
index 5cea72c4c..b6bc0d296 100644
--- a/src/registrar/assets/sass/_theme/_admin.scss
+++ b/src/registrar/assets/sass/_theme/_admin.scss
@@ -385,6 +385,7 @@ a.button,
font-kerning: auto;
font-family: inherit;
font-weight: normal;
+ text-decoration: none !important;
}
.button svg,
.button span,
@@ -392,6 +393,9 @@ a.button,
.usa-button--dja span {
vertical-align: middle;
}
+.usa-button--dja.usa-button--unstyled {
+ color: var(--link-fg);
+}
.usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary) {
background: var(--button-bg);
}
@@ -421,11 +425,34 @@ input[type=submit].button--dja-toolbar {
input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-toolbar:hover {
border-color: var(--body-quiet-color);
}
-// Targets the DJA buttom with a nested icon
-button .usa-icon,
-.button .usa-icon,
-.button--clipboard .usa-icon {
- vertical-align: middle;
+.admin-icon-group {
+ position: relative;
+ display: inline;
+ align-items: center;
+
+ input {
+ // Allow for padding around the copy button
+ padding-right: 35px !important;
+ }
+
+ button {
+ width: max-content;
+ }
+
+ @media (max-width: 1000px) {
+ button {
+ display: block;
+ }
+ }
+
+ span {
+ padding-left: 0.05rem;
+ }
+
+}
+.usa-button__small-text,
+.usa-button__small-text span {
+ font-size: 13px;
}
.module--custom {
@@ -673,71 +700,10 @@ address.dja-address-contact-list {
}
}
-// Make the clipboard button "float" inside of the input box
-.admin-icon-group {
- position: relative;
- display: inline;
- align-items: center;
-
- input {
- // Allow for padding around the copy button
- padding-right: 35px !important;
- // Match the height of other inputs
- min-height: 2.25rem !important;
- }
-
- button {
- line-height: 14px;
- width: max-content;
- font-size: unset;
- text-decoration: none !important;
- }
-
- @media (max-width: 1000px) {
- button {
- display: block;
- padding-top: 8px;
- }
- }
-
- span {
- padding-left: 0.1rem;
- }
-
-}
-
-.admin-icon-group.admin-icon-group__clipboard-link {
- position: relative;
- display: inline;
- align-items: center;
-
-
- .usa-button--icon {
- position: absolute;
- right: auto;
- left: 4px;
- height: 100%;
- top: -1px;
- }
- button {
- font-size: unset !important;
- display: inline-flex;
- padding-top: 4px;
- line-height: 14px;
- width: max-content;
- font-size: unset;
- text-decoration: none !important;
- }
-}
-
.no-outline-on-click:focus {
outline: none !important;
}
-.usa-button__small-text {
- font-size: small;
-}
-
// Get rid of padding on all help texts
form .aligned p.help, form .aligned div.help {
padding-left: 0px !important;
@@ -887,6 +853,9 @@ div.dja__model-description{
padding-top: 0 !important;
}
+.padding-bottom-0 {
+ padding-bottom: 0 !important;
+}
.flex-container {
@media screen and (min-width: 700px) and (max-width: 1150px) {
diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py
index 31e3a3d64..ee923aac6 100644
--- a/src/registrar/config/urls.py
+++ b/src/registrar/config/urls.py
@@ -31,9 +31,10 @@ from registrar.views.utility.api_views import (
get_senior_official_from_federal_agency_json,
get_federal_and_portfolio_types_from_federal_agency_json,
get_action_needed_email_for_user_json,
+ get_rejection_email_for_user_json,
)
-from registrar.views.domain_request import Step
+from registrar.views.domain_request import Step, PortfolioDomainRequestStep
from registrar.views.transfer_user import TransferUserView
from registrar.views.utility import always_404
from api.views import available, rdap, get_current_federal, get_current_full
@@ -61,6 +62,9 @@ for step, view in [
(Step.ADDITIONAL_DETAILS, views.AdditionalDetails),
(Step.REQUIREMENTS, views.Requirements),
(Step.REVIEW, views.Review),
+ # Portfolio steps
+ (PortfolioDomainRequestStep.REQUESTING_ENTITY, views.RequestingEntity),
+ (PortfolioDomainRequestStep.ADDITIONAL_DETAILS, views.PortfolioAdditionalDetails),
]:
domain_request_urls.append(path(f"{step}/", view.as_view(), name=step))
@@ -192,6 +196,11 @@ urlpatterns = [
get_action_needed_email_for_user_json,
name="get-action-needed-email-for-user-json",
),
+ path(
+ "admin/api/get-rejection-email-for-user-json/",
+ get_rejection_email_for_user_json,
+ name="get-rejection-email-for-user-json",
+ ),
path("admin/", admin.site.urls),
path(
"reports/export_data_type_user/",
@@ -204,7 +213,12 @@ urlpatterns = [
name="export_data_type_requests",
),
path(
- "domain-request//edit/",
+ "reports/export_data_type_requests/",
+ ExportDataTypeRequests.as_view(),
+ name="export_data_type_requests",
+ ),
+ path(
+ "domain-request//edit/",
views.DomainRequestWizard.as_view(),
name=views.DomainRequestWizard.EDIT_URL_NAME,
),
diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py
index f2fdd32bc..6b160b14d 100644
--- a/src/registrar/forms/domain_request_wizard.py
+++ b/src/registrar/forms/domain_request_wizard.py
@@ -21,6 +21,13 @@ from registrar.utility.constants import BranchChoices
logger = logging.getLogger(__name__)
+class RequestingEntityForm(RegistrarForm):
+ organization_name = forms.CharField(
+ label="Organization name",
+ error_messages={"required": "Enter the name of your organization."},
+ )
+
+
class OrganizationTypeForm(RegistrarForm):
generic_org_type = forms.ChoiceField(
# use the long names in the domain request form
diff --git a/src/registrar/forms/utility/wizard_form_helper.py b/src/registrar/forms/utility/wizard_form_helper.py
index ba3c37e1e..eedf5839b 100644
--- a/src/registrar/forms/utility/wizard_form_helper.py
+++ b/src/registrar/forms/utility/wizard_form_helper.py
@@ -279,11 +279,11 @@ class BaseYesNoForm(RegistrarForm):
return initial_value
-def request_step_list(request_wizard):
+def request_step_list(request_wizard, step_enum):
"""Dynamically generated list of steps in the form wizard."""
step_list = []
- for step in request_wizard.StepEnum:
- condition = request_wizard.WIZARD_CONDITIONS.get(step, True)
+ for step in step_enum:
+ condition = request_wizard.wizard_conditions.get(step, True)
if callable(condition):
condition = condition(request_wizard)
if condition:
diff --git a/src/registrar/migrations/0133_domainrequest_rejection_reason_email_and_more.py b/src/registrar/migrations/0133_domainrequest_rejection_reason_email_and_more.py
new file mode 100644
index 000000000..383c3ebfa
--- /dev/null
+++ b/src/registrar/migrations/0133_domainrequest_rejection_reason_email_and_more.py
@@ -0,0 +1,35 @@
+# Generated by Django 4.2.10 on 2024-10-08 18:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("registrar", "0132_alter_domaininformation_portfolio_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="domainrequest",
+ name="rejection_reason_email",
+ field=models.TextField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name="domainrequest",
+ name="rejection_reason",
+ field=models.TextField(
+ blank=True,
+ choices=[
+ ("domain_purpose", "Purpose requirements not met"),
+ ("requestor_not_eligible", "Requestor not eligible to make request"),
+ ("org_has_domain", "Org already has a .gov domain"),
+ ("contacts_not_verified", "Org contacts couldn't be verified"),
+ ("org_not_eligible", "Org not eligible for a .gov domain"),
+ ("naming_requirements", "Naming requirements not met"),
+ ("other", "Other/Unspecified"),
+ ],
+ null=True,
+ ),
+ ),
+ ]
diff --git a/src/registrar/migrations/0133_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more.py b/src/registrar/migrations/0134_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more.py
similarity index 79%
rename from src/registrar/migrations/0133_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more.py
rename to src/registrar/migrations/0134_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more.py
index 14b7ac22a..9a24438df 100644
--- a/src/registrar/migrations/0133_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more.py
+++ b/src/registrar/migrations/0134_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more.py
@@ -1,4 +1,4 @@
-# Generated by Django 4.2.10 on 2024-10-08 19:05
+# Generated by Django 4.2.10 on 2024-10-11 19:58
from django.db import migrations
@@ -6,7 +6,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ("registrar", "0132_alter_domaininformation_portfolio_and_more"),
+ ("registrar", "0133_domainrequest_rejection_reason_email_and_more"),
]
operations = [
diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py
index 79bc223e9..b9e3315d5 100644
--- a/src/registrar/models/domain_request.py
+++ b/src/registrar/models/domain_request.py
@@ -254,18 +254,18 @@ class DomainRequest(TimeStampedModel):
)
class RejectionReasons(models.TextChoices):
- DOMAIN_PURPOSE = "purpose_not_met", "Purpose requirements not met"
- REQUESTOR = "requestor_not_eligible", "Requestor not eligible to make request"
- SECOND_DOMAIN_REASONING = (
+ DOMAIN_PURPOSE = "domain_purpose", "Purpose requirements not met"
+ REQUESTOR_NOT_ELIGIBLE = "requestor_not_eligible", "Requestor not eligible to make request"
+ ORG_HAS_DOMAIN = (
"org_has_domain",
"Org already has a .gov domain",
)
- CONTACTS_OR_ORGANIZATION_LEGITIMACY = (
+ CONTACTS_NOT_VERIFIED = (
"contacts_not_verified",
"Org contacts couldn't be verified",
)
- ORGANIZATION_ELIGIBILITY = "org_not_eligible", "Org not eligible for a .gov domain"
- NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met"
+ ORG_NOT_ELIGIBLE = "org_not_eligible", "Org not eligible for a .gov domain"
+ NAMING_REQUIREMENTS = "naming_requirements", "Naming requirements not met"
OTHER = "other", "Other/Unspecified"
@classmethod
@@ -300,6 +300,11 @@ class DomainRequest(TimeStampedModel):
blank=True,
)
+ rejection_reason_email = models.TextField(
+ null=True,
+ blank=True,
+ )
+
action_needed_reason = models.TextField(
choices=ActionNeededReasons.choices,
null=True,
@@ -635,15 +640,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):
+ def _cache_status_and_status_reasons(self):
"""Maintains a cache of properties so we can avoid a DB call"""
self._cached_action_needed_reason = self.action_needed_reason
+ self._cached_rejection_reason = self.rejection_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()
+ self._cache_status_and_status_reasons()
def save(self, *args, **kwargs):
"""Save override for custom properties"""
@@ -655,23 +661,63 @@ class DomainRequest(TimeStampedModel):
super().save(*args, **kwargs)
- # Handle the action needed email.
- # An email is sent out when action_needed_reason is changed or added.
- if self.action_needed_reason and self.status == self.DomainRequestStatus.ACTION_NEEDED:
- self.sync_action_needed_reason()
+ # Handle custom status emails.
+ # An email is sent out when a, for example, action_needed_reason is changed or added.
+ statuses_that_send_custom_emails = [self.DomainRequestStatus.ACTION_NEEDED, self.DomainRequestStatus.REJECTED]
+ if self.status in statuses_that_send_custom_emails:
+ self.send_custom_status_update_email(self.status)
# Update the cached values after saving
- self._cache_status_and_action_needed_reason()
+ self._cache_status_and_status_reasons()
- 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(email_content=self.action_needed_reason_email)
+ def send_custom_status_update_email(self, status):
+ """Helper function to send out a second status email when the status remains the same,
+ but the reason has changed."""
+
+ # Currently, we store all this information in three variables.
+ # When adding new reasons, this can be a lot to manage so we store it here
+ # in a centralized location. However, this may need to change if this scales.
+ status_information = {
+ self.DomainRequestStatus.ACTION_NEEDED: {
+ "cached_reason": self._cached_action_needed_reason,
+ "reason": self.action_needed_reason,
+ "email": self.action_needed_reason_email,
+ "excluded_reasons": [DomainRequest.ActionNeededReasons.OTHER],
+ "wrap_email": True,
+ },
+ self.DomainRequestStatus.REJECTED: {
+ "cached_reason": self._cached_rejection_reason,
+ "reason": self.rejection_reason,
+ "email": self.rejection_reason_email,
+ "excluded_reasons": [],
+ # "excluded_reasons": [DomainRequest.RejectionReasons.OTHER],
+ "wrap_email": False,
+ },
+ }
+ status_info = status_information.get(status)
+
+ # Don't send an email if there is nothing to send.
+ if status_info.get("email") is None:
+ logger.warning("send_custom_status_update_email() => Tried sending an empty email.")
+ return
+
+ # We should never send an email if no reason was specified.
+ # Additionally, Don't send out emails for reasons that shouldn't send them.
+ if status_info.get("reason") is None or status_info.get("reason") in status_info.get("excluded_reasons"):
+ logger.warning("send_custom_status_update_email() => Tried sending a status email without a reason.")
+ return
+
+ # Only send out an email if the underlying reason itself changed or if no email was sent previously.
+ if status_info.get("cached_reason") != status_info.get("reason") or status_info.get("cached_reason") is None:
+ bcc_address = settings.DEFAULT_FROM_EMAIL if settings.IS_PRODUCTION else ""
+ self._send_status_update_email(
+ new_status=status,
+ email_template="emails/includes/custom_email.txt",
+ email_template_subject="emails/status_change_subject.txt",
+ bcc_address=bcc_address,
+ custom_email_content=status_info.get("email"),
+ wrap_email=status_information.get("wrap_email"),
+ )
def sync_yes_no_form_fields(self):
"""Some yes/no forms use a db field to track whether it was checked or not.
@@ -901,7 +947,7 @@ class DomainRequest(TimeStampedModel):
target=DomainRequestStatus.ACTION_NEEDED,
conditions=[domain_is_not_active, investigator_exists_and_is_staff],
)
- def action_needed(self, send_email=True):
+ def action_needed(self):
"""Send back an domain request that is under investigation or rejected.
This action is logged.
@@ -909,43 +955,23 @@ class DomainRequest(TimeStampedModel):
This action cleans up the rejection status if moving away from rejected.
As side effects this will delete the domain and domain_information
- (will cascade) when they exist."""
+ (will cascade) when they exist.
+
+ Afterwards, we send out an email for action_needed in def save().
+ See the function send_custom_status_update_email.
+ """
if self.status == self.DomainRequestStatus.APPROVED:
- self.delete_and_clean_up_domain("reject_with_prejudice")
+ self.delete_and_clean_up_domain("action_needed")
elif self.status == self.DomainRequestStatus.REJECTED:
self.rejection_reason = None
+ # Check if the tuple is setup correctly, then grab its value.
+
literal = DomainRequest.DomainRequestStatus.ACTION_NEEDED
- # Check if the tuple is setup correctly, then grab its value
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:
- email_content = self.action_needed_reason_email
- self._send_action_needed_reason_email(send_email, email_content)
-
- def _send_action_needed_reason_email(self, send_email=True, email_content=None):
- """Sends out an automatic email for each valid action needed reason provided"""
-
- email_template_name = "custom_email.txt"
- email_template_subject_name = f"{self.action_needed_reason}_subject.txt"
-
- bcc_address = ""
- if settings.IS_PRODUCTION:
- bcc_address = settings.DEFAULT_FROM_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,
- custom_email_content=email_content,
- wrap_email=True,
- )
-
@transition(
field="status",
source=[
@@ -1039,18 +1065,20 @@ class DomainRequest(TimeStampedModel):
def reject(self):
"""Reject an domain request that has been submitted.
+ This action is logged.
+
+ This action cleans up the action needed status if moving away from action needed.
+
As side effects this will delete the domain and domain_information
- (will cascade), and send an email notification."""
+ (will cascade) when they exist.
+
+ Afterwards, we send out an email for reject in def save().
+ See the function send_custom_status_update_email.
+ """
if self.status == self.DomainRequestStatus.APPROVED:
self.delete_and_clean_up_domain("reject")
- self._send_status_update_email(
- "action needed",
- "emails/status_change_rejected.txt",
- "emails/status_change_rejected_subject.txt",
- )
-
@transition(
field="status",
source=[
diff --git a/src/registrar/templates/admin/change_form_object_tools.html b/src/registrar/templates/admin/change_form_object_tools.html
index 198140c19..66011a3c4 100644
--- a/src/registrar/templates/admin/change_form_object_tools.html
+++ b/src/registrar/templates/admin/change_form_object_tools.html
@@ -20,10 +20,11 @@
{% if opts.model_name == 'domainrequest' %}
diff --git a/src/registrar/templates/admin/input_with_clipboard.html b/src/registrar/templates/admin/input_with_clipboard.html
index 5ad2b27f7..d6a016fd5 100644
--- a/src/registrar/templates/admin/input_with_clipboard.html
+++ b/src/registrar/templates/admin/input_with_clipboard.html
@@ -8,7 +8,7 @@ Template for an input field with a clipboard
{{ field }}
{% else %}
-
+
-
+
+
+ Copy
+
+ {% endif %}
-{% endif %}
\ No newline at end of file
+{% endif %}
diff --git a/src/registrar/templates/django/admin/domain_request_change_form.html b/src/registrar/templates/django/admin/domain_request_change_form.html
index afdd9e6c2..8d58bc696 100644
--- a/src/registrar/templates/django/admin/domain_request_change_form.html
+++ b/src/registrar/templates/django/admin/domain_request_change_form.html
@@ -10,6 +10,8 @@
{% url 'get-action-needed-email-for-user-json' as url %}
+ {% url 'get-rejection-email-for-user-json' as url_2 %}
+
{% for fieldset in adminform %}
{% comment %}
TODO: this will eventually need to be changed to something like this
diff --git a/src/registrar/templates/django/admin/includes/contact_detail_list.html b/src/registrar/templates/django/admin/includes/contact_detail_list.html
index 0a28a6532..84fb07f33 100644
--- a/src/registrar/templates/django/admin/includes/contact_detail_list.html
+++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html
@@ -26,7 +26,7 @@
{% if user.email %}
{{ user.email }}
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
-
+
{% else %}
None
{% endif %}
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 6b755724e..317604c5e 100644
--- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
+++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
@@ -137,29 +137,28 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% block field_other %}
{% if field.field.name == "action_needed_reason_email" %}
+ {{ field.field }}
-
+ If you edit this email's text, the system will send another email to
+ the creator after you “save” your changes. If you do not want to send another email, click “cancel” below.
+
+
+
+
+
+
+
+
+
+ {% if original_object.rejection_reason_email %}
+
+ {% else %}
+
+ {% endif %}
{% else %}
{{ field.field }}
{% endif %}
@@ -254,7 +341,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
-
Other contact information
+
Other contact information
@@ -267,18 +354,31 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
Names that uniquely apply to your organization are likely to be approved over names that could also apply to other organizations.
{% if not is_federal %}In most instances, this requires including your state’s two-letter abbreviation.{% endif %}
-
+
+ {% if not portfolio %}
Requests for your organization’s initials or an abbreviated name might not be approved, but we encourage you to request the name you want.
+ {% endif %}
Note that only federal agencies can request generic terms like
vote.gov.
You don’t have to complete the process in one session. You can save what you enter and come back to it when you’re ready.
+ {% if portfolio %}
+
We’ll use the information you provide to verify your domain request meets our guidelines.
+ {% else %}
We’ll use the information you provide to verify your organization’s eligibility for a .gov domain. We’ll also verify that the domain you request meets our guidelines.
+{% endblock %}
+
+{% block form_fields %}
+
+{% endblock %}
diff --git a/src/registrar/templates/domain_request_review.html b/src/registrar/templates/domain_request_review.html
index 03624d2ec..03cf31287 100644
--- a/src/registrar/templates/domain_request_review.html
+++ b/src/registrar/templates/domain_request_review.html
@@ -19,5 +19,9 @@
{% endblock %}
{% block form_fields %}
- {% include "includes/request_review_steps.html" with is_editable=True %}
+ {% if portfolio %}
+ {% include "includes/portfolio_request_review_steps.html" with is_editable=True %}
+ {% else %}
+ {% include "includes/request_review_steps.html" with is_editable=True %}
+ {% endif %}
{% endblock %}
diff --git a/src/registrar/templates/emails/action_needed_reasons/custom_email.txt b/src/registrar/templates/emails/includes/custom_email.txt
similarity index 100%
rename from src/registrar/templates/emails/action_needed_reasons/custom_email.txt
rename to src/registrar/templates/emails/includes/custom_email.txt
diff --git a/src/registrar/templates/emails/status_change_rejected.txt b/src/registrar/templates/emails/status_change_rejected.txt
index 4e5250162..b1d989bf1 100644
--- a/src/registrar/templates/emails/status_change_rejected.txt
+++ b/src/registrar/templates/emails/status_change_rejected.txt
@@ -8,8 +8,8 @@ REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
STATUS: Rejected
----------------------------------------------------------------
-{% if domain_request.rejection_reason != 'other' %}
-REJECTION REASON{% endif %}{% if domain_request.rejection_reason == 'purpose_not_met' %}
+{% if reason != domain_request.RejectionReasons.DOMAIN_PURPOSE.OTHER %}
+REJECTION REASON{% endif %}{% if reason == domain_request.RejectionReasons.DOMAIN_PURPOSE %}
Your domain request was rejected because the purpose you provided did not meet our
requirements. You didn’t provide enough information about how you intend to use the
domain.
@@ -18,7 +18,7 @@ Learn more about:
- Eligibility for a .gov domain
- What you can and can’t do with .gov domains
-If you have questions or comments, reply to this email.{% elif domain_request.rejection_reason == 'requestor_not_eligible' %}
+If you have questions or comments, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.REQUESTOR_NOT_ELIGIBLE %}
Your domain request was rejected because we don’t believe you’re eligible to request a
.gov domain on behalf of {{ domain_request.organization_name }}. You must be a government employee, or be
working on behalf of a government organization, to request a .gov domain.
@@ -26,7 +26,7 @@ working on behalf of a government organization, to request a .gov domain.
DEMONSTRATE ELIGIBILITY
If you can provide more information that demonstrates your eligibility, or you want to
-discuss further, reply to this email.{% elif domain_request.rejection_reason == 'org_has_domain' %}
+discuss further, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.ORG_HAS_DOMAIN %}
Your domain request was rejected because {{ domain_request.organization_name }} has a .gov domain. Our
practice is to approve one domain per online service per government organization. We
evaluate additional requests on a case-by-case basis. You did not provide sufficient
@@ -35,9 +35,9 @@ justification for an additional domain.
Read more about our practice of approving one domain per online service
.
-If you have questions or comments, reply to this email.{% elif domain_request.rejection_reason == 'contacts_not_verified' %}
+If you have questions or comments, reply to this email.{% elif reason == 'contacts_not_verified' %}
Your domain request was rejected because we could not verify the organizational
-contacts you provided. If you have questions or comments, reply to this email.{% elif domain_request.rejection_reason == 'org_not_eligible' %}
+contacts you provided. If you have questions or comments, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.ORG_NOT_ELIGIBLE %}
Your domain request was rejected because we determined that {{ domain_request.organization_name }} is not
eligible for a .gov domain. .Gov domains are only available to official U.S.-based
government organizations.
@@ -46,7 +46,7 @@ Learn more about eligibility for .gov domains
.
If you have questions or comments, reply to this email.
-{% elif domain_request.rejection_reason == 'naming_not_met' %}
+{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.NAMING_REQUIREMENTS %}
Your domain request was rejected because it does not meet our naming requirements.
Domains should uniquely identify a government organization and be clear to the
general public. Learn more about naming requirements for your type of organization
@@ -55,7 +55,7 @@ general public. Learn more about naming requirements for your type of organizati
YOU CAN SUBMIT A NEW REQUEST
We encourage you to request a domain that meets our requirements. If you have
-questions or want to discuss potential domain names, reply to this email.{% elif domain_request.rejection_reason == 'other' %}
+questions or want to discuss potential domain names, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.OTHER %}
YOU CAN SUBMIT A NEW REQUEST
If your organization is eligible for a .gov domain and you meet our other requirements, you can submit a new request.
diff --git a/src/registrar/templates/emails/status_change_rejected_subject.txt b/src/registrar/templates/emails/status_change_subject.txt
similarity index 100%
rename from src/registrar/templates/emails/status_change_rejected_subject.txt
rename to src/registrar/templates/emails/status_change_subject.txt
diff --git a/src/registrar/templates/includes/header_extended.html b/src/registrar/templates/includes/header_extended.html
index 79c6aacb7..23b7d1be3 100644
--- a/src/registrar/templates/includes/header_extended.html
+++ b/src/registrar/templates/includes/header_extended.html
@@ -34,6 +34,7 @@
+ {% if not hide_domains %}
{% if has_any_domains_portfolio_permission %}
{% url 'domains' as url %}
@@ -44,13 +45,14 @@
Domains
+ {% endif %}
-
- {% if has_organization_requests_flag %}
+
+ {% if has_organization_requests_flag and not hide_requests %}
{% if has_edit_request_portfolio_permission %}
diff --git a/src/registrar/templates/includes/portfolio_request_review_steps.html b/src/registrar/templates/includes/portfolio_request_review_steps.html
index 8f7dba9ac..9d3c5bdeb 100644
--- a/src/registrar/templates/includes/portfolio_request_review_steps.html
+++ b/src/registrar/templates/includes/portfolio_request_review_steps.html
@@ -8,7 +8,6 @@
{% endif %}
{% if step == Step.REQUESTING_ENTITY %}
-
{% if domain_request.organization_name %}
{% with title=form_titles|get_item:step value=domain_request %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url address='true' %}
@@ -54,32 +53,8 @@
{% endif %}
{% if step == Step.ADDITIONAL_DETAILS %}
- {% 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=is_editable edit_link=domain_request_url %}
-
CISA Regional Representative
-
- {% if domain_request.cisa_representative_first_name %}
-