merge main and fix migrations

This commit is contained in:
Rachid Mrad 2024-10-11 15:58:27 -04:00
commit 7cd2c6fef3
No known key found for this signature in database
36 changed files with 2389 additions and 1622 deletions

View file

@ -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)

View file

@ -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.
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.
*/
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;
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;
// 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, '');
// 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, '');
}
if (!dropdown || !textarea || !domainRequestId || !formLabel || !modalConfirm) return;
const apiUrl = document.getElementById("get-action-needed-email-for-user-json").value;
}
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);
// Handle showing/hiding the related fields on page load.
initializeFormGroups() {
let isStatus = this.statusSelect.value == this.statusToCheck;
// Initial handling of these groups.
this.updateFormGroupVisibility(isStatus);
// 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 {
// A triggering selection is selected, all hands on board:
textarea.setAttribute('readonly', true);
showElement(textarea);
hideElement(textareaPlaceholder);
if (isEmailAlreadySentConst) {
hideElement(directEditButton);
showElement(modalTrigger);
} else {
showElement(directEditButton);
hideElement(modalTrigger);
}
if (isEmailAlreadySent()) {
formLabel.innerHTML = "Email sent to creator:";
} else {
formLabel.innerHTML = "Email:";
}
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:";
}
}
// 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()
});
modalConfirm.addEventListener("click", () => {
textarea.removeAttribute('readonly');
textarea.focus();
hideElement(directEditButton);
hideElement(modalTrigger);
});
directEditButton.addEventListener("click", () => {
textarea.removeAttribute('readonly');
textarea.focus();
hideElement(directEditButton);
hideElement(modalTrigger);
});
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() {
`<strong>Current Websites:</strong> ${existingWebsites.join(', ')}</br>` +
`<strong>Rationale:</strong></br>` +
`<strong>Alternative Domains:</strong> ${alternativeDomains.join(', ')}</br>` +
`<strong>Submitter:</strong> ${submitterInfo}</br>` +
`<strong>Senior Official:</strong> ${seniorOfficialInfo}</br>` +
`<strong>Other Employees:</strong> ${otherContactsSummary}</br>`;

View file

@ -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) {

View file

@ -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/<id>/edit/",
"reports/export_data_type_requests/",
ExportDataTypeRequests.as_view(),
name="export_data_type_requests",
),
path(
"domain-request/<int:id>/edit/",
views.DomainRequestWizard.as_view(),
name=views.DomainRequestWizard.EDIT_URL_NAME,
),

View file

@ -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

View file

@ -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:

View file

@ -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,
),
),
]

View file

@ -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 = [

View file

@ -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=[

View file

@ -20,10 +20,11 @@
</li>
{% if opts.model_name == 'domainrequest' %}
<li>
<a id="id-copy-to-clipboard-summary" class="button--clipboard" type="button" href="#">
<a id="id-copy-to-clipboard-summary" class="usa-button--dja" type="button" href="#">
<svg class="usa-icon" >
<use xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<!-- the span is targeted in JS, do not remove -->
<span>{% translate "Copy request summary" %}</span>
</a>
</li>

View file

@ -8,7 +8,7 @@ Template for an input field with a clipboard
<div class="admin-icon-group">
{{ field }}
<button
class="usa-button usa-button--unstyled padding-left-1 usa-button--icon button--clipboard copy-to-clipboard"
class="usa-button--dja usa-button usa-button__small-text usa-button--unstyled padding-left-1 usa-button--icon copy-to-clipboard"
type="button"
>
<div class="no-outline-on-click">
@ -17,15 +17,17 @@ Template for an input field with a clipboard
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
Copy
<!-- the span is targeted in JS, do not remove -->
<span>Copy</span>
</div>
</button>
</div>
{% else %}
<div class="admin-icon-group admin-icon-group__clipboard-link">
<div class="admin-icon-group">
<input aria-hidden="true" class="display-none" value="{{ field.email }}" />
{% if field.email is not None %}
<button
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard text-no-underline padding-left-05"
class="usa-button--dja usa-button usa-button__small-text usa-button--unstyled padding-right-1 usa-button--icon copy-to-clipboard text-no-underline padding-left-05"
type="button"
>
<svg
@ -33,7 +35,9 @@ Template for an input field with a clipboard
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
Copy
<!-- the span is targeted in JS, do not remove -->
<span>Copy</span>
</button>
{% endif %}
</div>
{% endif %}

View file

@ -10,6 +10,8 @@
<input id="has_audit_logs" class="display-none" value="{%if filtered_audit_log_entries %}true{% else %}false{% endif %}"/>
{% url 'get-action-needed-email-for-user-json' as url %}
<input id="get-action-needed-email-for-user-json" class="display-none" value="{{ url }}" />
{% url 'get-rejection-email-for-user-json' as url_2 %}
<input id="get-rejection-email-for-user-json" class="display-none" value="{{ url_2 }}" />
{% for fieldset in adminform %}
{% comment %}
TODO: this will eventually need to be changed to something like this

View file

@ -26,7 +26,7 @@
{% if user.email %}
<span id="contact_info_email">{{ user.email }}</span>
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
<br class="admin-icon-group__br">
<br>
{% else %}
None<br>
{% endif %}

View file

@ -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 }}
<div class="margin-top-05 text-faded field-action_needed_reason_email__placeholder">
<div class="margin-top-05 text-faded custom-email-placeholder">
&ndash;
</div>
{{ field.field }}
<button
aria-label="Edit email in textarea"
type="button"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text margin-left-1 text-no-underline field-action_needed_reason_email__edit flex-align-self-start"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text margin-left-1 text-no-underline flex-align-self-start edit-email-button"
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</button
>
<a
href="#email-already-sent-modal"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1 field-action_needed_reason_email__modal-trigger flex-align-self-start"
aria-controls="email-already-sent-modal"
href="#action-needed-email-already-sent-modal"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1 edit-button-modal-trigger flex-align-self-start"
aria-controls="action-needed-email-already-sent-modal"
data-open-modal
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</a
>
<div
class="usa-modal"
id="email-already-sent-modal"
id="action-needed-email-already-sent-modal"
aria-labelledby="Are you sure you want to edit this email?"
aria-describedby="The creator of this request already received an email"
>
@ -187,8 +186,8 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<li class="usa-button-group__item">
<button
type="submit"
id="action-needed-reason__confirm-edit-email"
class="usa-button"
id="confirm-edit-email"
data-close-modal
>
Yes, continue editing
@ -221,11 +220,99 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
</div>
{% if original_object.action_needed_reason_email %}
<input id="last-sent-email-content" class="display-none" value="{{original_object.action_needed_reason_email}}">
<input id="last-sent-action-needed-email-content" class="display-none" value="{{original_object.action_needed_reason_email}}">
{% else %}
<input id="last-sent-email-content" class="display-none" value="None">
<input id="last-sent-action-needed-email-content" class="display-none" value="None">
{% endif %}
{% elif field.field.name == "rejection_reason_email" %}
{{ field.field }}
<div class="margin-top-05 text-faded custom-email-placeholder">
&ndash;
</div>
<button
aria-label="Edit email in textarea"
type="button"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text margin-left-1 text-no-underline flex-align-self-start edit-email-button"
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</button
>
<a
href="#rejection-reason-email-already-sent-modal"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1 edit-button-modal-trigger flex-align-self-start"
aria-controls="rejection-reason-email-already-sent-modal"
data-open-modal
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</a
>
<div
class="usa-modal"
id="rejection-reason-email-already-sent-modal"
aria-labelledby="Are you sure you want to edit this email?"
aria-describedby="The creator of this request already received an email"
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading">
Are you sure you want to edit this email?
</h2>
<div class="usa-prose">
<p>
The creator of this request already received an email for this status/reason:
</p>
<ul>
<li class="font-body-sm">Status: <b>Rejected</b></li>
<li class="font-body-sm">Reason: <b>{{ original_object.get_rejection_reason_display }}</b></li>
</ul>
<p>
If you edit this email's text, <b>the system will send another email</b> to
the creator after you “save” your changes. If you do not want to send another email, click “cancel” below.
</p>
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button
type="submit"
id="rejection-reason__confirm-edit-email"
class="usa-button"
data-close-modal
>
Yes, continue editing
</button>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
name="_cancel_edit_email"
data-close-modal
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
</button>
</div>
</div>
{% if original_object.rejection_reason_email %}
<input id="last-sent-rejection-email-content" class="display-none" value="{{original_object.rejection_reason_email}}">
{% else %}
<input id="last-sent-rejection-email-content" class="display-none" value="None">
{% endif %}
{% else %}
{{ field.field }}
{% endif %}
@ -254,7 +341,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<table>
<thead>
<tr>
<th colspan="4">Other contact information</th>
<th colspan="5">Other contact information</th>
<tr>
</thead>
<tbody>
@ -267,9 +354,20 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
</td>
<td class="padding-left-1">{{ contact.phone }}</td>
<td class="padding-left-1 text-size-small">
{% if contact.email %}
<input aria-hidden="true" class="display-none" value="{{ contact.email }}" />
<button
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard usa-button__small-text text-no-underline"
class="
usa-button--dja
usa-button
usa-button__small-text
usa-button--unstyled
padding-right-1
padding-top-0
padding-bottom-0
usa-button--icon
copy-to-clipboard
text-no-underline"
type="button"
>
<svg
@ -277,8 +375,10 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<!-- the span is targeted in JS, do not remove -->
<span>Copy email</span>
</button>
{% endif %}
</td>
</tr>
{% endfor %}

View file

@ -13,7 +13,9 @@
<p>Names that <em>uniquely apply to your organization</em> 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 states two-letter abbreviation.{% endif %}</p>
{% if not portfolio %}
<p>Requests for your organizations initials or an abbreviated name might not be approved, but we encourage you to request the name you want.</p>
{% endif %}
<p>Note that <strong>only federal agencies can request generic terms</strong> like
vote.gov.</p>

View file

@ -12,7 +12,11 @@
<h1>Youre about to start your .gov domain request.</h1>
<p>You dont have to complete the process in one session. You can save what you enter and come back to it when youre ready.</p>
{% if portfolio %}
<p>Well use the information you provide to verify your domain request meets our guidelines.</p>
{% else %}
<p>Well use the information you provide to verify your organizations eligibility for a .gov domain. Well also verify that the domain you request meets our guidelines.</p>
{% endif %}
<h2>Time to complete the form</h2>
<p>If you have <a href="{% public_site_url 'domains/before/#information-you%E2%80%99ll-need-to-complete-the-domain-request-form' %}" target="_blank" class="usa-link">all the information you need</a>,
completing your domain request might take around 15 minutes.</p>

View file

@ -0,0 +1,16 @@
{% extends 'domain_request_form.html' %}
{% load field_helpers url_helpers %}
{% block form_instructions %}
<p>🛸🛸🛸🛸 Placeholder content 🛸🛸🛸🛸</p>
{% endblock %}
{% block form_fields %}
<fieldset class="usa-fieldset">
<legend>
<h2>What is the name of your space vessel?</h2>
</legend>
{% input_with_errors forms.0.organization_name %}
</fieldset>
{% endblock %}

View file

@ -19,5 +19,9 @@
{% endblock %}
{% block form_fields %}
{% 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 %}

View file

@ -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 didnt provide enough information about how you intend to use the
domain.
@ -18,7 +18,7 @@ Learn more about:
- Eligibility for a .gov domain <https://get.gov/domains/eligibility>
- What you can and cant do with .gov domains <https://get.gov/domains/requirements/>
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 dont believe youre 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
<https://get.gov/domains/before/#one-domain-per-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
<https://get.gov/domains/eligibility/>.
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.

View file

@ -34,6 +34,7 @@
</ul>
</div>
<ul class="usa-nav__primary usa-accordion">
{% if not hide_domains %}
<li class="usa-nav__primary-item">
{% if has_any_domains_portfolio_permission %}
{% url 'domains' as url %}
@ -44,13 +45,14 @@
Domains
</a>
</li>
{% endif %}
<!-- <li class="usa-nav__primary-item">
<a href="#" class="usa-nav-link">
Domain groups
</a>
</li> -->
{% if has_organization_requests_flag %}
{% if has_organization_requests_flag and not hide_requests %}
<li class="usa-nav__primary-item">
<!-- user has one of the view permissions plus the edit permission, show the dropdown -->
{% if has_edit_request_portfolio_permission %}

View file

@ -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 %}
<h3 class="register-form-review-header">CISA Regional Representative</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if domain_request.cisa_representative_first_name %}
<li>{{domain_request.cisa_representative_first_name}} {{domain_request.cisa_representative_last_name}}</li>
{% if domain_request.cisa_representative_email %}
<li>{{domain_request.cisa_representative_email}}</li>
{% endif %}
{% else %}
No
{% endif %}
</ul>
<h3 class="register-form-review-header">Anything else</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if domain_request.anything_else %}
{{domain_request.anything_else}}
{% else %}
No
{% endif %}
</ul>
{% else %}
{% include "includes/summary_item.html" with title="Additional Details" value="<span class='text-bold text-secondary-dark'>Incomplete</span>"|safe heading_level=heading_level editable=is_editable edit_link=domain_request_url %}
{% endif %}
{% with title=form_titles|get_item:step value=domain_request.anything_else|default:"<span class='text-bold text-secondary-dark'>Incomplete</span>"|safe %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url %}
{% endwith %}
{% endif %}

View file

@ -0,0 +1,22 @@
{% extends 'domain_request_form.html' %}
{% load static field_helpers %}
{% block form_required_fields_help_text %}
{% include "includes/required_fields.html" %}
{% endblock %}
{% block form_fields %}
<fieldset class="usa-fieldset margin-top-2">
<legend>
<h2>Is there anything else youd like us to know about your domain request?</h2>
</legend>
</fieldset>
<div class="margin-top-3" id="anything-else">
<p>Provide details below. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></p>
{% with attr_maxlength=2000 add_label_class="usa-sr-only" %}
{% input_with_errors forms.0.anything_else %}
{% endwith %}
</div>
{% endblock %}

View file

@ -654,7 +654,7 @@ class TestDomainInformationAdmin(TestCase):
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# Test for the copy link
self.assertContains(response, "button--clipboard", count=3)
self.assertContains(response, "copy-to-clipboard", count=3)
# cleanup this test
domain_info.delete()

View file

@ -535,7 +535,7 @@ class TestDomainAdminWithClient(TestCase):
self.assertContains(response, "Testy Tester")
# Test for the copy link
self.assertContains(response, "button--clipboard")
self.assertContains(response, "copy-to-clipboard")
# cleanup from this test
domain.delete()

View file

@ -595,7 +595,12 @@ class TestDomainRequestAdmin(MockEppLib):
@less_console_noise_decorator
def transition_state_and_send_email(
self, domain_request, status, rejection_reason=None, action_needed_reason=None, action_needed_reason_email=None
self,
domain_request,
status,
rejection_reason=None,
action_needed_reason=None,
action_needed_reason_email=None,
):
"""Helper method for the email test cases."""
@ -687,6 +692,10 @@ class TestDomainRequestAdmin(MockEppLib):
self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
# We use javascript to reset the content of this. It is only automatically set
# if the email itself is somehow None.
self._reset_action_needed_email(domain_request)
# Test the email sent out for bad_name
bad_name = DomainRequest.ActionNeededReasons.BAD_NAME
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name)
@ -694,6 +703,7 @@ class TestDomainRequestAdmin(MockEppLib):
"DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL
)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
self._reset_action_needed_email(domain_request)
# Test the email sent out for eligibility_unclear
eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR
@ -702,6 +712,7 @@ class TestDomainRequestAdmin(MockEppLib):
"ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL
)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
self._reset_action_needed_email(domain_request)
# Test that a custom email is sent out for questionable_so
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
@ -710,6 +721,7 @@ class TestDomainRequestAdmin(MockEppLib):
"SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, _creator.email, bcc_email_address=BCC_EMAIL
)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
self._reset_action_needed_email(domain_request)
# Assert that no other emails are sent on OTHER
other = DomainRequest.ActionNeededReasons.OTHER
@ -717,6 +729,7 @@ class TestDomainRequestAdmin(MockEppLib):
# Should be unchanged from before
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
self._reset_action_needed_email(domain_request)
# Tests if an analyst can override existing email content
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
@ -730,6 +743,7 @@ class TestDomainRequestAdmin(MockEppLib):
domain_request.refresh_from_db()
self.assert_email_is_accurate("custom email content", 4, _creator.email, bcc_email_address=BCC_EMAIL)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 5)
self._reset_action_needed_email(domain_request)
# Tests if a new email gets sent when just the email is changed.
# An email should NOT be sent out if we just modify the email content.
@ -741,6 +755,7 @@ class TestDomainRequestAdmin(MockEppLib):
)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 5)
self._reset_action_needed_email(domain_request)
# Set the request back to in review
domain_request.in_review()
@ -757,55 +772,53 @@ class TestDomainRequestAdmin(MockEppLib):
)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 6)
# def test_action_needed_sends_reason_email_prod_bcc(self):
# """When an action needed reason is set, an email is sent out and help@get.gov
# is BCC'd in production"""
# # Ensure there is no user with this email
# EMAIL = "mayor@igorville.gov"
# BCC_EMAIL = settings.DEFAULT_FROM_EMAIL
# User.objects.filter(email=EMAIL).delete()
# in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
# action_needed = DomainRequest.DomainRequestStatus.ACTION_NEEDED
def _reset_action_needed_email(self, domain_request):
"""Sets the given action needed email back to none"""
domain_request.action_needed_reason_email = None
domain_request.save()
domain_request.refresh_from_db()
# # Create a sample domain request
# domain_request = completed_domain_request(status=in_review)
@override_settings(IS_PRODUCTION=True)
@less_console_noise_decorator
def test_rejected_sends_reason_email_prod_bcc(self):
"""When a rejection reason is set, an email is sent out and help@get.gov
is BCC'd in production"""
# Create fake creator
EMAIL = "meoward.jones@igorville.gov"
# # Test the email sent out for already_has_domains
# already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains)
# self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL)
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
_creator = User.objects.create(
username="MrMeoward",
first_name="Meoward",
last_name="Jones",
email=EMAIL,
phone="(555) 123 12345",
title="Treat inspector",
)
# # Test the email sent out for bad_name
# bad_name = DomainRequest.ActionNeededReasons.BAD_NAME
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name)
# self.assert_email_is_accurate(
# "DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL
# )
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
BCC_EMAIL = settings.DEFAULT_FROM_EMAIL
in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
rejected = DomainRequest.DomainRequestStatus.REJECTED
# # Test the email sent out for eligibility_unclear
# eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=eligibility_unclear)
# self.assert_email_is_accurate(
# "ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL
# )
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Create a sample domain request
domain_request = completed_domain_request(status=in_review, user=_creator)
# # Test the email sent out for questionable_so
# questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_so)
# self.assert_email_is_accurate(
# "SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, EMAIL, bcc_email_address=BCC_EMAIL
# )
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
# # Assert that no other emails are sent on OTHER
# other = DomainRequest.ActionNeededReasons.OTHER
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=other)
# # Should be unchanged from before
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
expected_emails = {
DomainRequest.RejectionReasons.DOMAIN_PURPOSE: "You didnt provide enough information about how",
DomainRequest.RejectionReasons.REQUESTOR_NOT_ELIGIBLE: "You must be a government employee, or be",
DomainRequest.RejectionReasons.ORG_HAS_DOMAIN: "practice is to approve one domain",
DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED: "we could not verify the organizational",
DomainRequest.RejectionReasons.ORG_NOT_ELIGIBLE: ".Gov domains are only available to official U.S.-based",
DomainRequest.RejectionReasons.NAMING_REQUIREMENTS: "does not meet our naming requirements",
DomainRequest.RejectionReasons.OTHER: "YOU CAN SUBMIT A NEW REQUEST",
}
for i, (reason, email_content) in enumerate(expected_emails.items()):
with self.subTest(reason=reason):
self.transition_state_and_send_email(domain_request, status=rejected, rejection_reason=reason)
self.assert_email_is_accurate(email_content, i, EMAIL, bcc_email_address=BCC_EMAIL)
self.assertEqual(len(self.mock_client.EMAILS_SENT), i + 1)
domain_request.rejection_reason_email = None
domain_request.save()
domain_request.refresh_from_db()
@less_console_noise_decorator
def test_save_model_sends_submitted_email(self):
@ -1034,7 +1047,9 @@ class TestDomainRequestAdmin(MockEppLib):
# Reject for reason REQUESTOR and test email including dynamic organization name
self.transition_state_and_send_email(
domain_request, DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.RejectionReasons.REQUESTOR
domain_request,
DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.RejectionReasons.REQUESTOR_NOT_ELIGIBLE,
)
self.assert_email_is_accurate(
"Your domain request was rejected because we dont believe youre eligible to request a \n.gov "
@ -1072,7 +1087,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.transition_state_and_send_email(
domain_request,
DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.RejectionReasons.SECOND_DOMAIN_REASONING,
DomainRequest.RejectionReasons.ORG_HAS_DOMAIN,
)
self.assert_email_is_accurate(
"Your domain request was rejected because Testorg has a .gov domain.", 0, _creator.email
@ -1108,7 +1123,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.transition_state_and_send_email(
domain_request,
DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY,
DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
)
self.assert_email_is_accurate(
"Your domain request was rejected because we could not verify the organizational \n"
@ -1146,7 +1161,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.transition_state_and_send_email(
domain_request,
DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.RejectionReasons.ORGANIZATION_ELIGIBILITY,
DomainRequest.RejectionReasons.ORG_NOT_ELIGIBLE,
)
self.assert_email_is_accurate(
"Your domain request was rejected because we determined that Testorg is not \neligible for "
@ -1275,7 +1290,7 @@ class TestDomainRequestAdmin(MockEppLib):
stack.enter_context(patch.object(messages, "error"))
stack.enter_context(patch.object(messages, "warning"))
domain_request.status = DomainRequest.DomainRequestStatus.REJECTED
domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY
domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED
self.admin.save_model(request, domain_request, None, True)
@ -1511,7 +1526,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# Test for the copy link
self.assertContains(response, "button--clipboard", count=4)
self.assertContains(response, "copy-to-clipboard", count=4)
# Test that Creator counts display properly
self.assertNotContains(response, "Approved domains")
@ -1621,6 +1636,7 @@ class TestDomainRequestAdmin(MockEppLib):
"updated_at",
"status",
"rejection_reason",
"rejection_reason_email",
"action_needed_reason",
"action_needed_reason_email",
"federal_agency",
@ -1840,12 +1856,64 @@ class TestDomainRequestAdmin(MockEppLib):
self.trigger_saving_approved_to_another_state(
False,
DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY,
DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
)
def test_side_effects_when_saving_approved_to_ineligible(self):
self.trigger_saving_approved_to_another_state(False, DomainRequest.DomainRequestStatus.INELIGIBLE)
@less_console_noise
def test_error_when_saving_to_approved_and_domain_exists(self):
"""Redundant admin check on model transition not allowed."""
Domain.objects.create(name="wabbitseason.gov")
new_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.SUBMITTED, name="wabbitseason.gov"
)
# Create a request object with a superuser
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(new_request.pk))
request.user = self.superuser
request.session = {}
# Use ExitStack to combine patch contexts
with ExitStack() as stack:
# Patch django.contrib.messages.error
stack.enter_context(patch.object(messages, "error"))
new_request.status = DomainRequest.DomainRequestStatus.APPROVED
self.admin.save_model(request, new_request, None, True)
messages.error.assert_called_once_with(
request,
"Cannot approve. Requested domain is already in use.",
)
@less_console_noise
def test_no_error_when_saving_to_approved_and_domain_exists(self):
"""The negative of the redundant admin check on model transition not allowed."""
new_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED)
# Create a request object with a superuser
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(new_request.pk))
request.user = self.superuser
request.session = {}
# Use ExitStack to combine patch contexts
with ExitStack() as stack:
# Patch Domain.is_active and django.contrib.messages.error simultaneously
stack.enter_context(patch.object(messages, "error"))
new_request.status = DomainRequest.DomainRequestStatus.APPROVED
self.admin.save_model(request, new_request, None, True)
# Assert that the error message was never called
messages.error.assert_not_called()
def test_has_correct_filters(self):
"""
This test verifies that DomainRequestAdmin has the correct filters set up.

View file

@ -143,8 +143,8 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("action_needed_email", data)
self.assertIn("ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", data["action_needed_email"])
self.assertIn("email", data)
self.assertIn("ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", data["email"])
@less_console_noise_decorator
def test_get_action_needed_email_for_user_json_analyst(self):
@ -160,8 +160,8 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("action_needed_email", data)
self.assertIn("SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", data["action_needed_email"])
self.assertIn("email", data)
self.assertIn("SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", data["email"])
@less_console_noise_decorator
def test_get_action_needed_email_for_user_json_regular(self):
@ -176,3 +176,71 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
},
)
self.assertEqual(response.status_code, 302)
class GetRejectionEmailForUserJsonTest(TestCase):
def setUp(self):
self.client = Client()
self.superuser = create_superuser()
self.analyst_user = create_user()
self.agency = FederalAgency.objects.create(agency="Test Agency")
self.domain_request = completed_domain_request(
federal_agency=self.agency,
name="test.gov",
status=DomainRequest.DomainRequestStatus.REJECTED,
)
self.api_url = reverse("get-rejection-email-for-user-json")
def tearDown(self):
DomainRequest.objects.all().delete()
User.objects.all().delete()
FederalAgency.objects.all().delete()
@less_console_noise_decorator
def test_get_rejected_email_for_user_json_superuser(self):
"""Test that a superuser can fetch the action needed email."""
self.client.force_login(self.superuser)
response = self.client.get(
self.api_url,
{
"reason": DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
"domain_request_id": self.domain_request.id,
},
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("email", data)
self.assertIn("we could not verify the organizational", data["email"])
@less_console_noise_decorator
def test_get_rejected_email_for_user_json_analyst(self):
"""Test that an analyst can fetch the action needed email."""
self.client.force_login(self.analyst_user)
response = self.client.get(
self.api_url,
{
"reason": DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
"domain_request_id": self.domain_request.id,
},
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("email", data)
self.assertIn("we could not verify the organizational", data["email"])
@less_console_noise_decorator
def test_get_rejected_email_for_user_json_regular(self):
"""Test that a regular user receives a 403 with an error message."""
p = "password"
self.client.login(username="testuser", password=p)
response = self.client.get(
self.api_url,
{
"reason": DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
"domain_request_id": self.domain_request.id,
},
)
self.assertEqual(response.status_code, 302)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -43,7 +43,7 @@ class DomainRequestTests(TestWithUser, WebTest):
super().setUp()
self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration")
self.app.set_user(self.user.username)
self.TITLES = DomainRequestWizard.TITLES
self.TITLES = DomainRequestWizard.REGULAR_TITLES
def tearDown(self):
super().tearDown()
@ -82,7 +82,6 @@ class DomainRequestTests(TestWithUser, WebTest):
response = self.app.get(f"/domain-request/{domain_request.id}")
# Ensure that the date is still set to None
self.assertIsNone(domain_request.last_status_update)
print(response)
# We should still grab a date for this field in this event - but it should come from the audit log instead
self.assertContains(response, "Started on:")
self.assertContains(response, fixed_date.strftime("%B %-d, %Y"))
@ -2742,6 +2741,66 @@ class DomainRequestTests(TestWithUser, WebTest):
self.assertContains(review_page, "toggle-submit-domain-request")
self.assertContains(review_page, "Your request form is incomplete")
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_portfolio_user_missing_edit_permissions(self):
"""Tests that a portfolio user without edit request permissions cannot edit or add new requests"""
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Test Portfolio")
portfolio_perm, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
# This user should be forbidden from creating new domain requests
intro_page = self.app.get(reverse("domain-request:"), expect_errors=True)
self.assertEqual(intro_page.status_code, 403)
# This user should also be forbidden from editing existing ones
domain_request = completed_domain_request(user=self.user)
edit_page = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk}), expect_errors=True)
self.assertEqual(edit_page.status_code, 403)
# Cleanup
portfolio_perm.delete()
portfolio.delete()
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_portfolio_user_with_edit_permissions(self):
"""Tests that a portfolio user with edit request permissions can edit and add new requests"""
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Test Portfolio")
portfolio_perm, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# This user should be allowed to create new domain requests
intro_page = self.app.get(reverse("domain-request:"))
self.assertEqual(intro_page.status_code, 200)
# This user should also be allowed to edit existing ones
domain_request = completed_domain_request(user=self.user)
edit_page = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})).follow()
self.assertEqual(edit_page.status_code, 200)
# Cleanup
DomainRequest.objects.all().delete()
portfolio_perm.delete()
portfolio.delete()
def test_non_creator_access(self):
"""Tests that a user cannot edit a domain request they didn't create"""
p = "password"
other_user = User.objects.create_user(username="other_user", password=p)
domain_request = completed_domain_request(user=other_user)
edit_page = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk}), expect_errors=True)
self.assertEqual(edit_page.status_code, 403)
def test_creator_access(self):
"""Tests that a user can edit a domain request they created"""
domain_request = completed_domain_request(user=self.user)
edit_page = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})).follow()
self.assertEqual(edit_page.status_code, 200)
class DomainRequestTestDifferentStatuses(TestWithUser, WebTest):
def setUp(self):
@ -2904,7 +2963,7 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest):
self.assertNotContains(home_page, "city.gov")
class TestWizardUnlockingSteps(TestWithUser, WebTest):
class TestDomainRequestWizard(TestWithUser, WebTest):
def setUp(self):
super().setUp()
self.app.set_user(self.user.username)
@ -3026,6 +3085,94 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest):
else:
self.fail(f"Expected a redirect, but got a different response: {response}")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_wizard_steps_portfolio(self):
"""
Tests the behavior of the domain request wizard for portfolios.
Ensures that:
- The user can access the organization page.
- The expected number of steps are locked/unlocked (implicit test for expected steps).
- The user lands on the "Requesting entity" page
- The user does not see the Domain and Domain requests buttons
"""
# This should unlock 4 steps by default.
# Purpose, .gov domain, current websites, and requirements for operating
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
user=self.user,
)
domain_request.anything_else = None
domain_request.save()
federal_agency = FederalAgency.objects.get(agency="Non-Federal Agency")
# Add a portfolio
portfolio = Portfolio.objects.create(
creator=self.user,
organization_name="test portfolio",
federal_agency=federal_agency,
)
user_portfolio_permission = UserPortfolioPermission.objects.create(
user=self.user,
portfolio=portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
response = self.app.get(f"/domain-request/{domain_request.id}/edit/")
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
# and then setting the cookie on each request.
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Check if the response is a redirect
if response.status_code == 302:
# Follow the redirect manually
try:
detail_page = response.follow()
self.wizard.get_context_data()
except Exception as err:
# Handle any potential errors while following the redirect
self.fail(f"Error following the redirect {err}")
# Now 'detail_page' contains the response after following the redirect
self.assertEqual(detail_page.status_code, 200)
# Assert that we're on the organization page
self.assertContains(detail_page, portfolio.organization_name)
# We should only see one unlocked step
self.assertContains(detail_page, "#check_circle", count=4)
# One pages should still be locked (additional details)
self.assertContains(detail_page, "#lock", 1)
# The current option should be selected
self.assertContains(detail_page, "usa-current", count=1)
# We default to the requesting entity page
expected_url = reverse("domain-request:portfolio_requesting_entity")
# This returns the entire url, thus "in"
self.assertIn(expected_url, detail_page.request.url)
# We shouldn't show the "domains" and "domain requests" buttons
# on this page.
self.assertNotContains(detail_page, "Domains")
self.assertNotContains(detail_page, "Domain requests")
else:
self.fail(f"Expected a redirect, but got a different response: {response}")
# Data cleanup
user_portfolio_permission.delete()
portfolio.delete()
federal_agency.delete()
domain_request.delete()
class TestPortfolioDomainRequestViewonly(TestWithUser, WebTest):
@ -3037,7 +3184,7 @@ class TestPortfolioDomainRequestViewonly(TestWithUser, WebTest):
super().setUp()
self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration")
self.app.set_user(self.user.username)
self.TITLES = DomainRequestWizard.TITLES
self.TITLES = DomainRequestWizard.REGULAR_TITLES
def tearDown(self):
super().tearDown()

View file

@ -6,36 +6,39 @@ from django.utils.html import escape
from registrar.models.utility.generic_helper import value_of_attribute
def get_all_action_needed_reason_emails(request, domain_request):
"""Returns a dictionary of every action needed reason and its associated email
for this particular domain request."""
emails = {}
for action_needed_reason in domain_request.ActionNeededReasons:
# Map the action_needed_reason to its default email
emails[action_needed_reason.value] = get_action_needed_reason_default_email(
request, domain_request, action_needed_reason.value
def get_action_needed_reason_default_email(domain_request, action_needed_reason):
"""Returns the default email associated with the given action needed reason"""
return _get_default_email(
domain_request,
file_path=f"emails/action_needed_reasons/{action_needed_reason}.txt",
reason=action_needed_reason,
excluded_reasons=[DomainRequest.ActionNeededReasons.OTHER],
)
return emails
def get_rejection_reason_default_email(domain_request, rejection_reason):
"""Returns the default email associated with the given rejection reason"""
return _get_default_email(
domain_request,
file_path="emails/status_change_rejected.txt",
reason=rejection_reason,
# excluded_reasons=[DomainRequest.RejectionReasons.OTHER]
)
def get_action_needed_reason_default_email(request, domain_request, action_needed_reason):
"""Returns the default email associated with the given action needed reason"""
if not action_needed_reason or action_needed_reason == DomainRequest.ActionNeededReasons.OTHER:
def _get_default_email(domain_request, file_path, reason, excluded_reasons=None):
if not reason:
return None
if excluded_reasons and reason in excluded_reasons:
return None
recipient = domain_request.creator
# Return the context of the rendered views
context = {"domain_request": domain_request, "recipient": recipient}
context = {"domain_request": domain_request, "recipient": recipient, "reason": reason}
# Get the email body
template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt"
email_body_text = get_template(template_path).render(context=context)
email_body_text_cleaned = None
if email_body_text:
email_body_text_cleaned = email_body_text.strip().lstrip("\n")
email_body_text = get_template(file_path).render(context=context)
email_body_text_cleaned = email_body_text.strip().lstrip("\n") if email_body_text else None
return email_body_text_cleaned

View file

@ -74,10 +74,13 @@ class PortfolioDomainRequestStep(StrEnum):
appear in the order they are defined. (Order matters.)
"""
# Portfolio
REQUESTING_ENTITY = "organization_name"
# NOTE: Append portfolio_ when customizing a view for portfolio.
# By default, these will redirect to the normal request flow views.
# After creating a new view, you will need to add this to urls.py.
REQUESTING_ENTITY = "portfolio_requesting_entity"
CURRENT_SITES = "current_sites"
DOTGOV_DOMAIN = "dotgov_domain"
PURPOSE = "purpose"
ADDITIONAL_DETAILS = "additional_details"
ADDITIONAL_DETAILS = "portfolio_additional_details"
REQUIREMENTS = "requirements"
REVIEW = "review"

View file

@ -43,8 +43,8 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
although not without consulting the base implementation, first.
"""
StepEnum: Step = Step # type: ignore
template_name = ""
is_portfolio = False
# uniquely namespace the wizard in urls.py
# (this is not seen _in_ urls, only for Django's internal naming)
@ -54,42 +54,140 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# name for accessing /domain-request/<id>/edit
EDIT_URL_NAME = "edit-domain-request"
NEW_URL_NAME = "/request/"
# region: Titles
# We need to pass our human-readable step titles as context to the templates.
TITLES = {
StepEnum.ORGANIZATION_TYPE: _("Type of organization"),
StepEnum.TRIBAL_GOVERNMENT: _("Tribal government"),
StepEnum.ORGANIZATION_FEDERAL: _("Federal government branch"),
StepEnum.ORGANIZATION_ELECTION: _("Election office"),
StepEnum.ORGANIZATION_CONTACT: _("Organization"),
StepEnum.ABOUT_YOUR_ORGANIZATION: _("About your organization"),
StepEnum.SENIOR_OFFICIAL: _("Senior official"),
StepEnum.CURRENT_SITES: _("Current websites"),
StepEnum.DOTGOV_DOMAIN: _(".gov domain"),
StepEnum.PURPOSE: _("Purpose of your domain"),
StepEnum.OTHER_CONTACTS: _("Other employees from your organization"),
StepEnum.ADDITIONAL_DETAILS: _("Additional details"),
StepEnum.REQUIREMENTS: _("Requirements for operating a .gov domain"),
StepEnum.REVIEW: _("Review and submit your domain request"),
REGULAR_TITLES = {
Step.ORGANIZATION_TYPE: _("Type of organization"),
Step.TRIBAL_GOVERNMENT: _("Tribal government"),
Step.ORGANIZATION_FEDERAL: _("Federal government branch"),
Step.ORGANIZATION_ELECTION: _("Election office"),
Step.ORGANIZATION_CONTACT: _("Organization"),
Step.ABOUT_YOUR_ORGANIZATION: _("About your organization"),
Step.SENIOR_OFFICIAL: _("Senior official"),
Step.CURRENT_SITES: _("Current websites"),
Step.DOTGOV_DOMAIN: _(".gov domain"),
Step.PURPOSE: _("Purpose of your domain"),
Step.OTHER_CONTACTS: _("Other employees from your organization"),
Step.ADDITIONAL_DETAILS: _("Additional details"),
Step.REQUIREMENTS: _("Requirements for operating a .gov domain"),
Step.REVIEW: _("Review and submit your domain request"),
}
# Titles for the portfolio context
PORTFOLIO_TITLES = {
PortfolioDomainRequestStep.REQUESTING_ENTITY: _("Requesting entity"),
PortfolioDomainRequestStep.CURRENT_SITES: _("Current websites"),
PortfolioDomainRequestStep.DOTGOV_DOMAIN: _(".gov domain"),
PortfolioDomainRequestStep.PURPOSE: _("Purpose of your domain"),
PortfolioDomainRequestStep.ADDITIONAL_DETAILS: _("Additional details"),
PortfolioDomainRequestStep.REQUIREMENTS: _("Requirements for operating a .gov domain"),
PortfolioDomainRequestStep.REVIEW: _("Review and submit your domain request"),
}
# endregion
# region: Wizard conditions
# We can use a dictionary with step names and callables that return booleans
# to show or hide particular steps based on the state of the process.
WIZARD_CONDITIONS = {
StepEnum.ORGANIZATION_FEDERAL: lambda w: w.from_model("show_organization_federal", False),
StepEnum.TRIBAL_GOVERNMENT: lambda w: w.from_model("show_tribal_government", False),
StepEnum.ORGANIZATION_ELECTION: lambda w: w.from_model("show_organization_election", False),
StepEnum.ABOUT_YOUR_ORGANIZATION: lambda w: w.from_model("show_about_your_organization", False),
REGULAR_WIZARD_CONDITIONS = {
Step.ORGANIZATION_FEDERAL: lambda w: w.from_model("show_organization_federal", False),
Step.TRIBAL_GOVERNMENT: lambda w: w.from_model("show_tribal_government", False),
Step.ORGANIZATION_ELECTION: lambda w: w.from_model("show_organization_election", False),
Step.ABOUT_YOUR_ORGANIZATION: lambda w: w.from_model("show_about_your_organization", False),
}
PORTFOLIO_WIZARD_CONDITIONS = {} # type: ignore
# endregion
# region: Unlocking steps
# The conditions by which each step is "unlocked" or "locked"
REGULAR_UNLOCKING_STEPS = {
Step.ORGANIZATION_TYPE: lambda self: self.domain_request.generic_org_type is not None,
Step.TRIBAL_GOVERNMENT: lambda self: self.domain_request.tribe_name is not None,
Step.ORGANIZATION_FEDERAL: lambda self: self.domain_request.federal_type is not None,
Step.ORGANIZATION_ELECTION: lambda self: self.domain_request.is_election_board is not None,
Step.ORGANIZATION_CONTACT: lambda self: (
self.domain_request.federal_agency is not None
or self.domain_request.organization_name is not None
or self.domain_request.address_line1 is not None
or self.domain_request.city is not None
or self.domain_request.state_territory is not None
or self.domain_request.zipcode is not None
or self.domain_request.urbanization is not None
),
Step.ABOUT_YOUR_ORGANIZATION: lambda self: self.domain_request.about_your_organization is not None,
Step.SENIOR_OFFICIAL: lambda self: self.domain_request.senior_official is not None,
Step.CURRENT_SITES: lambda self: (
self.domain_request.current_websites.exists() or self.domain_request.requested_domain is not None
),
Step.DOTGOV_DOMAIN: lambda self: self.domain_request.requested_domain is not None,
Step.PURPOSE: lambda self: self.domain_request.purpose is not None,
Step.OTHER_CONTACTS: lambda self: (
self.domain_request.other_contacts.exists() or self.domain_request.no_other_contacts_rationale is not None
),
Step.ADDITIONAL_DETAILS: lambda self: (
# Additional details is complete as long as "has anything else" and "has cisa rep" are not None
(
self.domain_request.has_anything_else_text is not None
and self.domain_request.has_cisa_representative is not None
)
),
Step.REQUIREMENTS: lambda self: self.domain_request.is_policy_acknowledged is not None,
Step.REVIEW: lambda self: self.domain_request.is_policy_acknowledged is not None,
}
PORTFOLIO_UNLOCKING_STEPS = {
PortfolioDomainRequestStep.REQUESTING_ENTITY: lambda self: self.domain_request.organization_name is not None,
PortfolioDomainRequestStep.CURRENT_SITES: lambda self: (
self.domain_request.current_websites.exists() or self.domain_request.requested_domain is not None
),
PortfolioDomainRequestStep.DOTGOV_DOMAIN: lambda self: self.domain_request.requested_domain is not None,
PortfolioDomainRequestStep.PURPOSE: lambda self: self.domain_request.purpose is not None,
PortfolioDomainRequestStep.ADDITIONAL_DETAILS: lambda self: self.domain_request.anything_else is not None,
PortfolioDomainRequestStep.REQUIREMENTS: lambda self: self.domain_request.is_policy_acknowledged is not None,
PortfolioDomainRequestStep.REVIEW: lambda self: self.domain_request.is_policy_acknowledged is not None,
}
# endregion
def __init__(self):
super().__init__()
self.steps = StepsHelper(self)
self.titles = {}
self.wizard_conditions = {}
self.unlocking_steps = {}
self.steps = None
# Configure titles, wizard_conditions, unlocking_steps, and steps
self.configure_step_options()
self._domain_request = None # for caching
def configure_step_options(self):
"""Changes which steps are available to the user based on self.is_portfolio.
This may change on the fly, so we need to evaluate it on the fly.
Using this information, we then set three configuration variables.
- self.titles => Returns the page titles for each step
- self.wizard_conditions => Conditionally shows / hides certain steps
- self.unlocking_steps => Determines what steps are locked/unlocked
Then, we create self.steps.
"""
if self.is_portfolio:
self.titles = self.PORTFOLIO_TITLES
self.wizard_conditions = self.PORTFOLIO_WIZARD_CONDITIONS
self.unlocking_steps = self.PORTFOLIO_UNLOCKING_STEPS
else:
self.titles = self.REGULAR_TITLES
self.wizard_conditions = self.REGULAR_WIZARD_CONDITIONS
self.unlocking_steps = self.REGULAR_UNLOCKING_STEPS
self.steps = StepsHelper(self)
def has_pk(self):
"""Does this wizard know about a DomainRequest database record?"""
return "domain_request_id" in self.storage
def get_step_enum(self):
"""Determines which step enum we should use for the wizard"""
return PortfolioDomainRequestStep if self.is_portfolio else Step
@property
def prefix(self):
"""Namespace the wizard to avoid clashes in session variable names."""
@ -128,10 +226,16 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# If a user is creating a request, we assume that perms are handled upstream
if self.request.user.is_org_user(self.request):
portfolio = self.request.session.get("portfolio")
self._domain_request = DomainRequest.objects.create(
creator=self.request.user,
portfolio=self.request.session.get("portfolio"),
portfolio=portfolio,
)
# Question for reviewers: we should probably be doing this right?
if portfolio and not self._domain_request.generic_org_type:
self._domain_request.generic_org_type = portfolio.organization_type
self._domain_request.save()
else:
self._domain_request = DomainRequest.objects.create(creator=self.request.user)
@ -190,6 +294,12 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
def get(self, request, *args, **kwargs):
"""This method handles GET requests."""
if not self.is_portfolio and self.request.user.is_org_user(request):
self.is_portfolio = True
# Configure titles, wizard_conditions, unlocking_steps, and steps
self.configure_step_options()
current_url = resolve(request.path_info).url_name
# if user visited via an "edit" url, associate the id of the
@ -209,7 +319,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# Clear context so the prop getter won't create a request here.
# Creating a request will be handled in the post method for the
# intro page.
return render(request, "domain_request_intro.html", {})
return render(request, "domain_request_intro.html", {"hide_requests": True, "hide_domains": True})
else:
return self.goto(self.steps.first)
@ -321,56 +431,9 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
return DomainRequest.objects.filter(creator=self.request.user, status__in=check_statuses)
def db_check_for_unlocking_steps(self):
"""Helper for get_context_data
"""Helper for get_context_data.
Queries the DB for a domain request and returns a list of unlocked steps."""
# The way this works is as follows:
# Each step is assigned a true/false value to determine if it is
# "unlocked" or not. This dictionary of values is looped through
# at the end of this function and any step with a "true" value is
# added to a simple array that is returned at the end of this function.
# This array is eventually passed to the frontend context (eg. domain_request_sidebar.html),
# and is used to determine how steps appear in the side nav.
# It is worth noting that any step assigned "false" here will be EXCLUDED
# from the list of "unlocked" steps.
history_dict = {
"generic_org_type": self.domain_request.generic_org_type is not None,
"tribal_government": self.domain_request.tribe_name is not None,
"organization_federal": self.domain_request.federal_type is not None,
"organization_election": self.domain_request.is_election_board is not None,
"organization_contact": (
self.domain_request.federal_agency is not None
or self.domain_request.organization_name is not None
or self.domain_request.address_line1 is not None
or self.domain_request.city is not None
or self.domain_request.state_territory is not None
or self.domain_request.zipcode is not None
or self.domain_request.urbanization is not None
),
"about_your_organization": self.domain_request.about_your_organization is not None,
"senior_official": self.domain_request.senior_official is not None,
"current_sites": (
self.domain_request.current_websites.exists() or self.domain_request.requested_domain is not None
),
"dotgov_domain": self.domain_request.requested_domain is not None,
"purpose": self.domain_request.purpose is not None,
"other_contacts": (
self.domain_request.other_contacts.exists()
or self.domain_request.no_other_contacts_rationale is not None
),
"additional_details": (
# Additional details is complete as long as "has anything else" and "has cisa rep" are not None
(
self.domain_request.has_anything_else_text is not None
and self.domain_request.has_cisa_representative is not None
)
),
"requirements": self.domain_request.is_policy_acknowledged is not None,
"review": self.domain_request.is_policy_acknowledged is not None,
}
return [key for key, value in history_dict.items() if value]
return [key for key, is_unlocked_checker in self.unlocking_steps.items() if is_unlocked_checker(self)]
def get_context_data(self):
"""Define context for access on all wizard pages."""
@ -380,11 +443,16 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
requested_domain_name = self.domain_request.requested_domain.name
context_stuff = {}
if DomainRequest._form_complete(self.domain_request, self.request):
# Note: we will want to consolidate the non_org_steps_complete check into the same check that
# org_steps_complete is using at some point.
non_org_steps_complete = DomainRequest._form_complete(self.domain_request, self.request)
org_steps_complete = len(self.db_check_for_unlocking_steps()) == len(self.steps)
if (not self.is_portfolio and non_org_steps_complete) or (self.is_portfolio and org_steps_complete):
modal_button = '<button type="submit" ' 'class="usa-button" ' ">Submit request</button>"
context_stuff = {
"not_form": False,
"form_titles": self.TITLES,
"form_titles": self.titles,
"steps": self.steps,
"visited": self.storage.get("step_history", []),
"is_federal": self.domain_request.is_federal(),
@ -401,7 +469,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
modal_button = '<button type="button" class="usa-button" data-close-modal>Return to request</button>'
context_stuff = {
"not_form": True,
"form_titles": self.TITLES,
"form_titles": self.titles,
"steps": self.steps,
"visited": self.storage.get("step_history", []),
"is_federal": self.domain_request.is_federal(),
@ -413,14 +481,19 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
"user": self.request.user,
"requested_domain__name": requested_domain_name,
}
# Hides the requests and domains buttons in the navbar
context_stuff["hide_requests"] = self.is_portfolio
context_stuff["hide_domains"] = self.is_portfolio
return context_stuff
def get_step_list(self) -> list:
"""Dynamically generated list of steps in the form wizard."""
return request_step_list(self)
return request_step_list(self, self.get_step_enum())
def goto(self, step):
if step == "generic_org_type":
if step == "generic_org_type" or step == "portfolio_requesting_entity":
# We need to avoid creating a new domain request if the user
# clicks the back button
self.request.session["new_request"] = False
@ -443,21 +516,21 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
def post(self, request, *args, **kwargs) -> HttpResponse:
"""This method handles POST requests."""
if not self.is_portfolio and self.request.user.is_org_user(request): # type: ignore
self.is_portfolio = True
# Configure titles, wizard_conditions, unlocking_steps, and steps
self.configure_step_options()
# which button did the user press?
button: str = request.POST.get("submit_button", "")
# If a user hits the new request url directly
if "new_request" not in request.session:
request.session["new_request"] = True
# if user has acknowledged the intro message
if button == "intro_acknowledge":
if request.path_info == self.NEW_URL_NAME:
if self.request.session["new_request"] is True:
# This will trigger the domain_request getter into creating a new DomainRequest
del self.storage
return self.goto(self.steps.first)
# Split into a function: C901 'DomainRequestWizard.post' is too complex (11)
self.handle_intro_acknowledge(request)
# if accessing this class directly, redirect to the first step
if self.__class__ == DomainRequestWizard:
@ -488,6 +561,14 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# otherwise, proceed as normal
return self.goto_next_step()
def handle_intro_acknowledge(self, request):
"""If we are starting a new request, clear storage
and redirect to the first step"""
if request.path_info == self.NEW_URL_NAME:
if self.request.session["new_request"] is True:
del self.storage
return self.goto(self.steps.first)
def save(self, forms: list):
"""
Unpack the form responses onto the model object properties.
@ -501,24 +582,22 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# TODO - this is a WIP until the domain request experience for portfolios is complete
class PortfolioDomainRequestWizard(DomainRequestWizard):
StepEnum: PortfolioDomainRequestStep = PortfolioDomainRequestStep # type: ignore
TITLES = {
StepEnum.REQUESTING_ENTITY: _("Requesting entity"),
StepEnum.CURRENT_SITES: _("Current websites"),
StepEnum.DOTGOV_DOMAIN: _(".gov domain"),
StepEnum.PURPOSE: _("Purpose of your domain"),
StepEnum.ADDITIONAL_DETAILS: _("Additional details"),
StepEnum.REQUIREMENTS: _("Requirements for operating a .gov domain"),
# Step.REVIEW: _("Review and submit your domain request"),
}
def __init__(self):
super().__init__()
self.steps = StepsHelper(self)
self._domain_request = None # for caching
is_portfolio = True
# Portfolio pages
class RequestingEntity(DomainRequestWizard):
template_name = "domain_request_requesting_entity.html"
forms = [forms.RequestingEntityForm]
class PortfolioAdditionalDetails(DomainRequestWizard):
template_name = "portfolio_domain_request_additional_details.html"
forms = [forms.AnythingElseForm]
# Non-portfolio pages
class OrganizationType(DomainRequestWizard):
template_name = "domain_request_org_type.html"
forms = [forms.OrganizationTypeForm]
@ -698,7 +777,7 @@ class Review(DomainRequestWizard):
if DomainRequest._form_complete(self.domain_request, self.request) is False:
logger.warning("User arrived at review page with an incomplete form.")
context = super().get_context_data()
context["Step"] = Step.__members__
context["Step"] = self.get_step_enum().__members__
context["domain_request"] = self.domain_request
return context
@ -899,9 +978,9 @@ class PortfolioDomainRequestStatusViewOnly(DomainRequestPortfolioViewonlyView):
# Create a temp wizard object to grab the step list
wizard = PortfolioDomainRequestWizard()
wizard.request = self.request
context["Step"] = wizard.StepEnum.__members__
context["steps"] = request_step_list(wizard)
context["form_titles"] = wizard.TITLES
context["Step"] = PortfolioDomainRequestStep.__members__
context["steps"] = request_step_list(wizard, PortfolioDomainRequestStep)
context["form_titles"] = wizard.titles
return context

View file

@ -4,7 +4,7 @@ from django.forms.models import model_to_dict
from registrar.models import FederalAgency, SeniorOfficial, DomainRequest
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.decorators import login_required
from registrar.utility.admin_helpers import get_all_action_needed_reason_emails
from registrar.utility.admin_helpers import get_action_needed_reason_default_email, get_rejection_reason_default_email
from registrar.models.portfolio import Portfolio
from registrar.utility.constants import BranchChoices
@ -88,5 +88,30 @@ def get_action_needed_email_for_user_json(request):
return JsonResponse({"error": "No domain_request_id specified"}, status=404)
domain_request = DomainRequest.objects.filter(id=domain_request_id).first()
emails = get_all_action_needed_reason_emails(request, domain_request)
return JsonResponse({"action_needed_email": emails.get(reason)}, status=200)
email = get_action_needed_reason_default_email(domain_request, reason)
return JsonResponse({"email": email}, status=200)
@login_required
@staff_member_required
def get_rejection_email_for_user_json(request):
"""Returns a default rejection email for a given user"""
# This API is only accessible to admins and analysts
superuser_perm = request.user.has_perm("registrar.full_access_permission")
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]):
return JsonResponse({"error": "You do not have access to this resource"}, status=403)
reason = request.GET.get("reason")
domain_request_id = request.GET.get("domain_request_id")
if not reason:
return JsonResponse({"error": "No reason specified"}, status=404)
if not domain_request_id:
return JsonResponse({"error": "No domain_request_id specified"}, status=404)
domain_request = DomainRequest.objects.filter(id=domain_request_id).first()
email = get_rejection_reason_default_email(domain_request, reason)
return JsonResponse({"email": email}, status=200)

View file

@ -384,10 +384,32 @@ class DomainRequestWizardPermission(PermissionsLoginMixin):
The user is in self.request.user
"""
if not self.request.user.is_authenticated:
return False
# The user has an ineligible flag
if self.request.user.is_restricted():
return False
# If the user is an org user and doesn't have add/edit perms, forbid this
if self.request.user.is_org_user(self.request):
portfolio = self.request.session.get("portfolio")
if not self.request.user.has_edit_request_portfolio_permission(portfolio):
return False
# user needs to be the creator of the domain request to edit it.
id = self.kwargs.get("id") if hasattr(self, "kwargs") else None
if not id:
domain_request_wizard = self.request.session.get("wizard_domain_request")
if domain_request_wizard:
id = domain_request_wizard.get("domain_request_id")
# If no id is provided, we can assume that the user is starting a new request.
# If one IS provided, check that they are the original creator of it.
if id:
if not DomainRequest.objects.filter(creator=self.request.user, id=id).exists():
return False
return True