mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-28 13:36:30 +02:00
merge main and fix migrations
This commit is contained in:
commit
7cd2c6fef3
36 changed files with 2389 additions and 1622 deletions
|
@ -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)
|
||||
|
|
|
@ -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>`;
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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 = [
|
|
@ -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=[
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
|
@ -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
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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">
|
||||
–
|
||||
</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">
|
||||
–
|
||||
</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 %}
|
||||
|
|
|
@ -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 state’s two-letter abbreviation.{% endif %}</p>
|
||||
|
||||
{% if not portfolio %}
|
||||
<p>Requests for your organization’s 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>
|
||||
|
|
|
@ -12,7 +12,11 @@
|
|||
|
||||
<h1>You’re about to start your .gov domain request.</h1>
|
||||
<p>You don’t have to complete the process in one session. You can save what you enter and come back to it when you’re ready.</p>
|
||||
{% if portfolio %}
|
||||
<p>We’ll use the information you provide to verify your domain request meets our guidelines.</p>
|
||||
{% else %}
|
||||
<p>We’ll use the information you provide to verify your organization’s eligibility for a .gov domain. We’ll also verify that the domain you request meets our guidelines.</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>
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -8,8 +8,8 @@ REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
|||
STATUS: Rejected
|
||||
|
||||
----------------------------------------------------------------
|
||||
{% if domain_request.rejection_reason != 'other' %}
|
||||
REJECTION REASON{% endif %}{% if domain_request.rejection_reason == 'purpose_not_met' %}
|
||||
{% if reason != domain_request.RejectionReasons.DOMAIN_PURPOSE.OTHER %}
|
||||
REJECTION REASON{% endif %}{% if reason == domain_request.RejectionReasons.DOMAIN_PURPOSE %}
|
||||
Your domain request was rejected because the purpose you provided did not meet our
|
||||
requirements. You didn’t provide enough information about how you intend to use the
|
||||
domain.
|
||||
|
@ -18,7 +18,7 @@ Learn more about:
|
|||
- Eligibility for a .gov domain <https://get.gov/domains/eligibility>
|
||||
- What you can and can’t 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 don’t believe you’re eligible to request a
|
||||
.gov domain on behalf of {{ domain_request.organization_name }}. You must be a government employee, or be
|
||||
working on behalf of a government organization, to request a .gov domain.
|
||||
|
@ -26,7 +26,7 @@ working on behalf of a government organization, to request a .gov domain.
|
|||
|
||||
DEMONSTRATE ELIGIBILITY
|
||||
If you can provide more information that demonstrates your eligibility, or you want to
|
||||
discuss further, reply to this email.{% elif domain_request.rejection_reason == 'org_has_domain' %}
|
||||
discuss further, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.ORG_HAS_DOMAIN %}
|
||||
Your domain request was rejected because {{ domain_request.organization_name }} has a .gov domain. Our
|
||||
practice is to approve one domain per online service per government organization. We
|
||||
evaluate additional requests on a case-by-case basis. You did not provide sufficient
|
||||
|
@ -35,9 +35,9 @@ justification for an additional domain.
|
|||
Read more about our practice of approving one domain per online service
|
||||
<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.
|
||||
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
|
@ -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 you’d 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 %}
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 didn’t 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 don’t believe you’re 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.
|
||||
|
|
|
@ -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
1047
src/registrar/tests/test_models_requests.py
Normal file
1047
src/registrar/tests/test_models_requests.py
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue