mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-05 09:21:54 +02:00
merge main
This commit is contained in:
commit
afbc1a6254
28 changed files with 800 additions and 213 deletions
|
@ -1,7 +1,8 @@
|
|||
from datetime import date
|
||||
import logging
|
||||
import copy
|
||||
|
||||
import json
|
||||
from django.template.loader import get_template
|
||||
from django import forms
|
||||
from django.db.models import Value, CharField, Q
|
||||
from django.db.models.functions import Concat, Coalesce
|
||||
|
@ -13,7 +14,6 @@ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
|||
from django.contrib.auth.models import Group
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from dateutil.relativedelta import relativedelta # type: ignore
|
||||
from epplibwrapper.errors import ErrorCode, RegistryError
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
from waffle.admin import FlagAdmin
|
||||
|
@ -1689,6 +1689,10 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
return super().save_model(request, obj, form, change)
|
||||
|
||||
# == Handle non-status changes == #
|
||||
# Change this in #1901. Add a check on "not self.action_needed_reason_email"
|
||||
if obj.action_needed_reason:
|
||||
self._handle_action_needed_reason_email(obj)
|
||||
should_save = True
|
||||
|
||||
# Get the original domain request from the database.
|
||||
original_obj = models.DomainRequest.objects.get(pk=obj.pk)
|
||||
|
@ -1697,14 +1701,17 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
return super().save_model(request, obj, form, change)
|
||||
|
||||
# == Handle status changes == #
|
||||
|
||||
# Run some checks on the current object for invalid status changes
|
||||
obj, should_save = self._handle_status_change(request, obj, original_obj)
|
||||
|
||||
# We should only save if we don't display any errors in the step above.
|
||||
# We should only save if we don't display any errors in the steps above.
|
||||
if should_save:
|
||||
return super().save_model(request, obj, form, change)
|
||||
|
||||
def _handle_action_needed_reason_email(self, obj):
|
||||
text = self._get_action_needed_reason_default_email_text(obj, obj.action_needed_reason)
|
||||
obj.action_needed_reason_email = text.get("email_body_text")
|
||||
|
||||
def _handle_status_change(self, request, obj, original_obj):
|
||||
"""
|
||||
Checks for various conditions when a status change is triggered.
|
||||
|
@ -1898,10 +1905,45 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
# Initialize extra_context and add filtered entries
|
||||
extra_context = extra_context or {}
|
||||
extra_context["filtered_audit_log_entries"] = filtered_audit_log_entries
|
||||
extra_context["action_needed_reason_emails"] = self.get_all_action_needed_reason_emails_as_json(obj)
|
||||
|
||||
# Call the superclass method with updated extra_context
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
|
||||
def get_all_action_needed_reason_emails_as_json(self, domain_request):
|
||||
"""Returns a json dictionary of every action needed reason and its associated email
|
||||
for this particular domain request."""
|
||||
emails = {}
|
||||
for action_needed_reason in domain_request.ActionNeededReasons:
|
||||
enum_value = action_needed_reason.value
|
||||
# Change this in #1901. Just add a check for the current value.
|
||||
emails[enum_value] = self._get_action_needed_reason_default_email_text(domain_request, enum_value)
|
||||
return json.dumps(emails)
|
||||
|
||||
def _get_action_needed_reason_default_email_text(self, domain_request, action_needed_reason: str):
|
||||
"""Returns the default email associated with the given action needed reason"""
|
||||
if action_needed_reason is None or action_needed_reason == domain_request.ActionNeededReasons.OTHER:
|
||||
return {
|
||||
"subject_text": None,
|
||||
"email_body_text": None,
|
||||
}
|
||||
|
||||
# Get the email body
|
||||
template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt"
|
||||
template = get_template(template_path)
|
||||
|
||||
# Get the email subject
|
||||
template_subject_path = f"emails/action_needed_reasons/{action_needed_reason}_subject.txt"
|
||||
subject_template = get_template(template_subject_path)
|
||||
|
||||
# Return the content of the rendered views
|
||||
context = {"domain_request": domain_request}
|
||||
|
||||
return {
|
||||
"subject_text": subject_template.render(context=context),
|
||||
"email_body_text": template.render(context=context),
|
||||
}
|
||||
|
||||
def process_log_entry(self, log_entry):
|
||||
"""Process a log entry and return filtered entry dictionary if applicable."""
|
||||
changes = log_entry.changes
|
||||
|
@ -2195,25 +2237,12 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
|
||||
extra_context["state_help_message"] = Domain.State.get_admin_help_text(domain.state)
|
||||
extra_context["domain_state"] = domain.get_state_display()
|
||||
|
||||
# Pass in what the an extended expiration date would be for the expiration date modal
|
||||
self._set_expiration_date_context(domain, extra_context)
|
||||
extra_context["curr_exp_date"] = (
|
||||
domain.expiration_date if domain.expiration_date is not None else self._get_current_date()
|
||||
)
|
||||
|
||||
return super().changeform_view(request, object_id, form_url, extra_context)
|
||||
|
||||
def _set_expiration_date_context(self, domain, extra_context):
|
||||
"""Given a domain, calculate the an extended expiration date
|
||||
from the current registry expiration date."""
|
||||
years_to_extend_by = self._get_calculated_years_for_exp_date(domain)
|
||||
try:
|
||||
curr_exp_date = domain.registry_expiration_date
|
||||
except KeyError:
|
||||
# No expiration date was found. Return none.
|
||||
extra_context["extended_expiration_date"] = None
|
||||
else:
|
||||
new_date = curr_exp_date + relativedelta(years=years_to_extend_by)
|
||||
extra_context["extended_expiration_date"] = new_date
|
||||
|
||||
def response_change(self, request, obj):
|
||||
# Create dictionary of action functions
|
||||
ACTION_FUNCTIONS = {
|
||||
|
@ -2241,11 +2270,9 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
self.message_user(request, "Object is not of type Domain.", messages.ERROR)
|
||||
return None
|
||||
|
||||
years = self._get_calculated_years_for_exp_date(obj)
|
||||
|
||||
# Renew the domain.
|
||||
try:
|
||||
obj.renew_domain(length=years)
|
||||
obj.renew_domain()
|
||||
self.message_user(
|
||||
request,
|
||||
"Successfully extended the expiration date.",
|
||||
|
@ -2270,37 +2297,6 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
|
||||
return HttpResponseRedirect(".")
|
||||
|
||||
def _get_calculated_years_for_exp_date(self, obj, extension_period: int = 1):
|
||||
"""Given the current date, an extension period, and a registry_expiration_date
|
||||
on the domain object, calculate the number of years needed to extend the
|
||||
current expiration date by the extension period.
|
||||
"""
|
||||
# Get the date we want to update to
|
||||
desired_date = self._get_current_date() + relativedelta(years=extension_period)
|
||||
|
||||
# Grab the current expiration date
|
||||
try:
|
||||
exp_date = obj.registry_expiration_date
|
||||
except KeyError:
|
||||
# if no expiration date from registry, set it to today
|
||||
logger.warning("current expiration date not set; setting to today")
|
||||
exp_date = self._get_current_date()
|
||||
|
||||
# If the expiration date is super old (2020, for example), we need to
|
||||
# "catch up" to the current year, so we add the difference.
|
||||
# If both years match, then lets just proceed as normal.
|
||||
calculated_exp_date = exp_date + relativedelta(years=extension_period)
|
||||
|
||||
year_difference = desired_date.year - exp_date.year
|
||||
|
||||
years = extension_period
|
||||
if desired_date > calculated_exp_date:
|
||||
# Max probably isn't needed here (no code flow), but it guards against negative and 0.
|
||||
# In both of those cases, we just want to extend by the extension_period.
|
||||
years = max(extension_period, year_difference)
|
||||
|
||||
return years
|
||||
|
||||
# Workaround for unit tests, as we cannot mock date directly.
|
||||
# it is immutable. Rather than dealing with a convoluted workaround,
|
||||
# lets wrap this in a function.
|
||||
|
|
|
@ -8,6 +8,25 @@
|
|||
|
||||
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
|
||||
// Helper functions.
|
||||
|
||||
/**
|
||||
* Hide element
|
||||
*
|
||||
*/
|
||||
const hideElement = (element) => {
|
||||
if (element && !element.classList.contains("display-none"))
|
||||
element.classList.add('display-none');
|
||||
};
|
||||
|
||||
/**
|
||||
* Show element
|
||||
*
|
||||
*/
|
||||
const showElement = (element) => {
|
||||
if (element && element.classList.contains("display-none"))
|
||||
element.classList.remove('display-none');
|
||||
};
|
||||
|
||||
/** Either sets attribute target="_blank" to a given element, or removes it */
|
||||
function openInNewTab(el, removeAttribute = false){
|
||||
if(removeAttribute){
|
||||
|
@ -57,6 +76,7 @@ function openInNewTab(el, removeAttribute = false){
|
|||
createPhantomModalFormButtons();
|
||||
})();
|
||||
|
||||
|
||||
/** An IIFE for DomainRequest to hook a modal to a dropdown option.
|
||||
* This intentionally does not interact with createPhantomModalFormButtons()
|
||||
*/
|
||||
|
@ -406,15 +426,27 @@ function initializeWidgetOnList(list, parentId) {
|
|||
let statusSelect = document.getElementById('id_status');
|
||||
|
||||
function moveStatusChangelog(actionNeededReasonFormGroup, statusSelect) {
|
||||
if (!actionNeededReasonFormGroup || !statusSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
let flexContainer = actionNeededReasonFormGroup.querySelector('.flex-container');
|
||||
let statusChangelog = document.getElementById('dja-status-changelog');
|
||||
|
||||
// On action needed, show the email that will be sent out
|
||||
let showReasonEmailContainer = document.querySelector("#action_needed_reason_email_readonly")
|
||||
|
||||
// Prepopulate values on page load.
|
||||
if (statusSelect.value === "action needed") {
|
||||
flexContainer.parentNode.insertBefore(statusChangelog, flexContainer.nextSibling);
|
||||
showElement(showReasonEmailContainer);
|
||||
} else {
|
||||
// Move the changelog back to its original location
|
||||
let statusFlexContainer = statusSelect.closest('.flex-container');
|
||||
statusFlexContainer.parentNode.insertBefore(statusChangelog, statusFlexContainer.nextSibling);
|
||||
hideElement(showReasonEmailContainer);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Call the function on page load
|
||||
|
@ -518,3 +550,60 @@ function initializeWidgetOnList(list, parentId) {
|
|||
handleShowMoreButton(toggleButton, descriptionDiv)
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
/** An IIFE that hooks up to the "show email" button.
|
||||
* which shows the auto generated email on action needed reason.
|
||||
*/
|
||||
(function () {
|
||||
let actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason");
|
||||
let actionNeededEmail = document.querySelector("#action_needed_reason_email_view_more");
|
||||
if(actionNeededReasonDropdown && actionNeededEmail && container) {
|
||||
// Add a change listener to the action needed reason dropdown
|
||||
handleChangeActionNeededEmail(actionNeededReasonDropdown, actionNeededEmail);
|
||||
}
|
||||
|
||||
function handleChangeActionNeededEmail(actionNeededReasonDropdown, actionNeededEmail) {
|
||||
actionNeededReasonDropdown.addEventListener("change", function() {
|
||||
let reason = actionNeededReasonDropdown.value;
|
||||
|
||||
// If a reason isn't specified, no email will be sent.
|
||||
// You also cannot save the model in this state.
|
||||
// This flow occurs if you switch back to the empty picker state.
|
||||
if(!reason) {
|
||||
showNoEmailMessage(actionNeededEmail);
|
||||
return;
|
||||
}
|
||||
|
||||
let actionNeededEmails = JSON.parse(document.getElementById('action-needed-emails-data').textContent)
|
||||
let emailData = actionNeededEmails[reason];
|
||||
if (emailData) {
|
||||
let emailBody = emailData.email_body_text
|
||||
if (emailBody) {
|
||||
actionNeededEmail.value = emailBody
|
||||
showActionNeededEmail(actionNeededEmail);
|
||||
}else {
|
||||
showNoEmailMessage(actionNeededEmail);
|
||||
}
|
||||
}else {
|
||||
showNoEmailMessage(actionNeededEmail);
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
// Show the text field. Hide the "no email" message.
|
||||
function showActionNeededEmail(actionNeededEmail){
|
||||
let noEmailMessage = document.getElementById("no-email-message");
|
||||
showElement(actionNeededEmail);
|
||||
hideElement(noEmailMessage);
|
||||
}
|
||||
|
||||
// Hide the text field. Show the "no email" message.
|
||||
function showNoEmailMessage(actionNeededEmail) {
|
||||
let noEmailMessage = document.getElementById("no-email-message");
|
||||
hideElement(actionNeededEmail);
|
||||
showElement(noEmailMessage);
|
||||
}
|
||||
|
||||
})();
|
||||
|
|
|
@ -786,3 +786,44 @@ div.dja__model-description{
|
|||
.usa-button--dja-link-color {
|
||||
color: var(--link-fg);
|
||||
}
|
||||
|
||||
.dja-readonly-textarea-container {
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-width: 610px;
|
||||
resize: none;
|
||||
cursor: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
// Style the scroll bar handle
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: var(--body-fg);
|
||||
border-radius: 99px;
|
||||
background-clip: content-box;
|
||||
border: 3px solid transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.max-full {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.thin-border {
|
||||
background-color: var(--selected-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
label {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.display-none {
|
||||
// Many elements in django admin try to override this, so we need !important.
|
||||
display: none !important;
|
||||
}
|
||||
|
|
|
@ -12,12 +12,11 @@ class Command(BaseCommand, PopulateScriptTemplate):
|
|||
def handle(self, **kwargs):
|
||||
"""Loops through each valid User object and updates its verification_type value"""
|
||||
filter_condition = {"verification_type__isnull": True}
|
||||
self.mass_populate_field(User, filter_condition, ["verification_type"])
|
||||
self.mass_update_records(User, filter_condition, ["verification_type"])
|
||||
|
||||
def populate_field(self, field_to_update):
|
||||
def update_record(self, record: User):
|
||||
"""Defines how we update the verification_type field"""
|
||||
field_to_update.set_user_verification_type()
|
||||
record.set_user_verification_type()
|
||||
logger.info(
|
||||
f"{TerminalColors.OKCYAN}Updating {field_to_update} => "
|
||||
f"{field_to_update.verification_type}{TerminalColors.OKCYAN}"
|
||||
f"{TerminalColors.OKCYAN}Updating {record} => " f"{record.verification_type}{TerminalColors.OKCYAN}"
|
||||
)
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
import logging
|
||||
from django.core.management import BaseCommand
|
||||
from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors
|
||||
from registrar.models import FederalAgency, DomainInformation
|
||||
from registrar.utility.constants import BranchChoices
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand, PopulateScriptTemplate):
|
||||
"""
|
||||
This command uses the PopulateScriptTemplate,
|
||||
which provides reusable logging and bulk updating functions for mass-updating fields.
|
||||
"""
|
||||
|
||||
help = "Loops through each valid User object and updates its verification_type value"
|
||||
prompt_title = "Do you wish to update all Federal Agencies?"
|
||||
|
||||
def handle(self, **kwargs):
|
||||
"""Loops through each valid User object and updates the value of its verification_type field"""
|
||||
|
||||
# These are federal agencies that we don't have any data on.
|
||||
# Independent agencies are considered "EXECUTIVE" here.
|
||||
self.missing_records = {
|
||||
"Christopher Columbus Fellowship Foundation": BranchChoices.EXECUTIVE,
|
||||
"Commission for the Preservation of America's Heritage Abroad": BranchChoices.EXECUTIVE,
|
||||
"Commission of Fine Arts": BranchChoices.EXECUTIVE,
|
||||
"Committee for Purchase From People Who Are Blind or Severely Disabled": BranchChoices.EXECUTIVE,
|
||||
"DC Court Services and Offender Supervision Agency": BranchChoices.EXECUTIVE,
|
||||
"DC Pre-trial Services": BranchChoices.EXECUTIVE,
|
||||
"Department of Agriculture": BranchChoices.EXECUTIVE,
|
||||
"Dwight D. Eisenhower Memorial Commission": BranchChoices.LEGISLATIVE,
|
||||
"Farm Credit System Insurance Corporation": BranchChoices.EXECUTIVE,
|
||||
"Federal Financial Institutions Examination Council": BranchChoices.EXECUTIVE,
|
||||
"Federal Judiciary": BranchChoices.JUDICIAL,
|
||||
"Institute of Peace": BranchChoices.EXECUTIVE,
|
||||
"International Boundary and Water Commission: United States and Mexico": BranchChoices.EXECUTIVE,
|
||||
"International Boundary Commission: United States and Canada": BranchChoices.EXECUTIVE,
|
||||
"International Joint Commission: United States and Canada": BranchChoices.EXECUTIVE,
|
||||
"Legislative Branch": BranchChoices.LEGISLATIVE,
|
||||
"National Foundation on the Arts and the Humanities": BranchChoices.EXECUTIVE,
|
||||
"Nuclear Safety Oversight Committee": BranchChoices.EXECUTIVE,
|
||||
"Office of Compliance": BranchChoices.LEGISLATIVE,
|
||||
"Overseas Private Investment Corporation": BranchChoices.EXECUTIVE,
|
||||
"Public Defender Service for the District of Columbia": BranchChoices.EXECUTIVE,
|
||||
"The Executive Office of the President": BranchChoices.EXECUTIVE,
|
||||
"U.S. Access Board": BranchChoices.EXECUTIVE,
|
||||
"U.S. Agency for Global Media": BranchChoices.EXECUTIVE,
|
||||
"U.S. China Economic and Security Review Commission": BranchChoices.LEGISLATIVE,
|
||||
"U.S. Interagency Council on Homelessness": BranchChoices.EXECUTIVE,
|
||||
"U.S. International Trade Commission": BranchChoices.EXECUTIVE,
|
||||
"U.S. Postal Service": BranchChoices.EXECUTIVE,
|
||||
"U.S. Trade and Development Agency": BranchChoices.EXECUTIVE,
|
||||
"Udall Foundation": BranchChoices.EXECUTIVE,
|
||||
"United States Arctic Research Commission": BranchChoices.EXECUTIVE,
|
||||
"Utah Reclamation Mitigation and Conservation Commission": BranchChoices.EXECUTIVE,
|
||||
"Vietnam Education Foundation": BranchChoices.EXECUTIVE,
|
||||
"Woodrow Wilson International Center for Scholars": BranchChoices.EXECUTIVE,
|
||||
"World War I Centennial Commission": BranchChoices.EXECUTIVE,
|
||||
}
|
||||
# Get all existing domain requests. Select_related allows us to skip doing db queries.
|
||||
self.all_domain_infos = DomainInformation.objects.select_related("federal_agency")
|
||||
self.mass_update_records(
|
||||
FederalAgency, filter_conditions={"agency__isnull": False}, fields_to_update=["federal_type"]
|
||||
)
|
||||
|
||||
def update_record(self, record: FederalAgency):
|
||||
"""Defines how we update the federal_type field on each record."""
|
||||
request = self.all_domain_infos.filter(federal_agency__agency=record.agency).first()
|
||||
if request:
|
||||
record.federal_type = request.federal_type
|
||||
elif not request and record.agency in self.missing_records:
|
||||
record.federal_type = self.missing_records.get(record.agency)
|
||||
logger.info(f"{TerminalColors.OKCYAN}Updating {str(record)} => {record.federal_type}{TerminalColors.ENDC}")
|
||||
|
||||
def should_skip_record(self, record) -> bool: # noqa
|
||||
"""Defines the conditions in which we should skip updating a record."""
|
||||
requests = self.all_domain_infos.filter(federal_agency__agency=record.agency, federal_type__isnull=False)
|
||||
# Check if all federal_type values are the same. Skip the record otherwise.
|
||||
distinct_federal_types = requests.values("federal_type").distinct()
|
||||
should_skip = distinct_federal_types.count() != 1
|
||||
if should_skip and record.agency not in self.missing_records:
|
||||
logger.info(
|
||||
f"{TerminalColors.YELLOW}Skipping update for {str(record)} => count is "
|
||||
f"{distinct_federal_types.count()} and records are {distinct_federal_types}{TerminalColors.ENDC}"
|
||||
)
|
||||
elif record.agency in self.missing_records:
|
||||
logger.info(
|
||||
f"{TerminalColors.MAGENTA}Missing data on {str(record)} - "
|
||||
f"swapping to manual mapping{TerminalColors.ENDC}"
|
||||
)
|
||||
should_skip = False
|
||||
return should_skip
|
|
@ -61,56 +61,96 @@ class ScriptDataHelper:
|
|||
|
||||
class PopulateScriptTemplate(ABC):
|
||||
"""
|
||||
Contains an ABC for generic populate scripts
|
||||
Contains an ABC for generic populate scripts.
|
||||
|
||||
This template provides reusable logging and bulk updating functions for
|
||||
mass-updating fields.
|
||||
"""
|
||||
|
||||
def mass_populate_field(self, sender, filter_conditions, fields_to_update):
|
||||
"""Loops through each valid "sender" object - specified by filter_conditions - and
|
||||
updates fields defined by fields_to_update using populate_function.
|
||||
# Optional script-global config variables. For the most part, you can leave these untouched.
|
||||
# Defines what prompt_for_execution displays as its header when you first start the script
|
||||
prompt_title: str = "Do you wish to proceed?"
|
||||
|
||||
You must define populate_field before you can use this function.
|
||||
# The header when printing the script run summary (after the script finishes)
|
||||
run_summary_header = None
|
||||
|
||||
@abstractmethod
|
||||
def update_record(self, record):
|
||||
"""Defines how we update each field. Must be defined before using mass_update_records."""
|
||||
raise NotImplementedError
|
||||
|
||||
def mass_update_records(self, object_class, filter_conditions, fields_to_update, debug=True):
|
||||
"""Loops through each valid "object_class" object - specified by filter_conditions - and
|
||||
updates fields defined by fields_to_update using update_record.
|
||||
|
||||
You must define update_record before you can use this function.
|
||||
"""
|
||||
|
||||
objects = sender.objects.filter(**filter_conditions)
|
||||
records = object_class.objects.filter(**filter_conditions)
|
||||
readable_class_name = self.get_class_name(object_class)
|
||||
|
||||
# Code execution will stop here if the user prompts "N"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
==Proposed Changes==
|
||||
Number of {sender} objects to change: {len(objects)}
|
||||
Number of {readable_class_name} objects to change: {len(records)}
|
||||
These fields will be updated on each record: {fields_to_update}
|
||||
""",
|
||||
prompt_title="Do you wish to patch this data?",
|
||||
prompt_title=self.prompt_title,
|
||||
)
|
||||
logger.info("Updating...")
|
||||
|
||||
to_update: List[sender] = []
|
||||
failed_to_update: List[sender] = []
|
||||
for updated_object in objects:
|
||||
to_update: List[object_class] = []
|
||||
to_skip: List[object_class] = []
|
||||
failed_to_update: List[object_class] = []
|
||||
for record in records:
|
||||
try:
|
||||
self.populate_field(updated_object)
|
||||
to_update.append(updated_object)
|
||||
if not self.should_skip_record(record):
|
||||
self.update_record(record)
|
||||
to_update.append(record)
|
||||
else:
|
||||
to_skip.append(record)
|
||||
except Exception as err:
|
||||
failed_to_update.append(updated_object)
|
||||
fail_message = self.get_failure_message(record)
|
||||
failed_to_update.append(record)
|
||||
logger.error(err)
|
||||
logger.error(f"{TerminalColors.FAIL}" f"Failed to update {updated_object}" f"{TerminalColors.ENDC}")
|
||||
logger.error(fail_message)
|
||||
|
||||
# Do a bulk update on the first_ready field
|
||||
ScriptDataHelper.bulk_update_fields(sender, to_update, fields_to_update)
|
||||
# Do a bulk update on the desired field
|
||||
ScriptDataHelper.bulk_update_fields(object_class, to_update, fields_to_update)
|
||||
|
||||
# Log what happened
|
||||
TerminalHelper.log_script_run_summary(to_update, failed_to_update, skipped=[], debug=True)
|
||||
TerminalHelper.log_script_run_summary(
|
||||
to_update,
|
||||
failed_to_update,
|
||||
to_skip,
|
||||
debug=debug,
|
||||
log_header=self.run_summary_header,
|
||||
display_as_str=True,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def populate_field(self, field_to_update):
|
||||
"""Defines how we update each field. Must be defined before using mass_populate_field."""
|
||||
raise NotImplementedError
|
||||
def get_class_name(self, sender) -> str:
|
||||
"""Returns the class name that we want to display for the terminal prompt.
|
||||
Example: DomainRequest => "Domain Request"
|
||||
"""
|
||||
return sender._meta.verbose_name if getattr(sender, "_meta") else sender
|
||||
|
||||
def get_failure_message(self, record) -> str:
|
||||
"""Returns the message that we will display if a record fails to update"""
|
||||
return f"{TerminalColors.FAIL}" f"Failed to update {record}" f"{TerminalColors.ENDC}"
|
||||
|
||||
def should_skip_record(self, record) -> bool: # noqa
|
||||
"""Defines the condition in which we should skip updating a record. Override as needed."""
|
||||
# By default - don't skip
|
||||
return False
|
||||
|
||||
|
||||
class TerminalHelper:
|
||||
@staticmethod
|
||||
def log_script_run_summary(to_update, failed_to_update, skipped, debug: bool, log_header=None):
|
||||
def log_script_run_summary(
|
||||
to_update, failed_to_update, skipped, debug: bool, log_header=None, display_as_str=False
|
||||
):
|
||||
"""Prints success, failed, and skipped counts, as well as
|
||||
all affected objects."""
|
||||
update_success_count = len(to_update)
|
||||
|
@ -121,20 +161,24 @@ class TerminalHelper:
|
|||
log_header = "============= FINISHED ==============="
|
||||
|
||||
# Prepare debug messages
|
||||
debug_messages = {
|
||||
"success": (f"{TerminalColors.OKCYAN}Updated: {to_update}{TerminalColors.ENDC}\n"),
|
||||
"skipped": (f"{TerminalColors.YELLOW}Skipped: {skipped}{TerminalColors.ENDC}\n"),
|
||||
"failed": (f"{TerminalColors.FAIL}Failed: {failed_to_update}{TerminalColors.ENDC}\n"),
|
||||
}
|
||||
if debug:
|
||||
updated_display = [str(u) for u in to_update] if display_as_str else to_update
|
||||
skipped_display = [str(s) for s in skipped] if display_as_str else skipped
|
||||
failed_display = [str(f) for f in failed_to_update] if display_as_str else failed_to_update
|
||||
debug_messages = {
|
||||
"success": (f"{TerminalColors.OKCYAN}Updated: {updated_display}{TerminalColors.ENDC}\n"),
|
||||
"skipped": (f"{TerminalColors.YELLOW}Skipped: {skipped_display}{TerminalColors.ENDC}\n"),
|
||||
"failed": (f"{TerminalColors.FAIL}Failed: {failed_display}{TerminalColors.ENDC}\n"),
|
||||
}
|
||||
|
||||
# Print out a list of everything that was changed, if we have any changes to log.
|
||||
# Otherwise, don't print anything.
|
||||
TerminalHelper.print_conditional(
|
||||
debug,
|
||||
f"{debug_messages.get('success') if update_success_count > 0 else ''}"
|
||||
f"{debug_messages.get('skipped') if update_skipped_count > 0 else ''}"
|
||||
f"{debug_messages.get('failed') if update_failed_count > 0 else ''}",
|
||||
)
|
||||
# Print out a list of everything that was changed, if we have any changes to log.
|
||||
# Otherwise, don't print anything.
|
||||
TerminalHelper.print_conditional(
|
||||
debug,
|
||||
f"{debug_messages.get('success') if update_success_count > 0 else ''}"
|
||||
f"{debug_messages.get('skipped') if update_skipped_count > 0 else ''}"
|
||||
f"{debug_messages.get('failed') if update_failed_count > 0 else ''}",
|
||||
)
|
||||
|
||||
if update_failed_count == 0 and update_skipped_count == 0:
|
||||
logger.info(
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 4.2.10 on 2024-06-26 14:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0106_create_groups_v14"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="domainrequest",
|
||||
name="action_needed_reason_email",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
|
@ -7,6 +7,7 @@ from django.conf import settings
|
|||
from django.db import models
|
||||
from django_fsm import FSMField, transition # type: ignore
|
||||
from django.utils import timezone
|
||||
from waffle import flag_is_active
|
||||
from registrar.models.domain import Domain
|
||||
from registrar.models.federal_agency import FederalAgency
|
||||
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
|
||||
|
@ -17,8 +18,6 @@ from .utility.time_stamped_model import TimeStampedModel
|
|||
from ..utility.email import send_templated_email, EmailSendingError
|
||||
from itertools import chain
|
||||
|
||||
from waffle.decorators import flag_is_active
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -296,6 +295,11 @@ class DomainRequest(TimeStampedModel):
|
|||
blank=True,
|
||||
)
|
||||
|
||||
action_needed_reason_email = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
federal_agency = models.ForeignKey(
|
||||
"registrar.FederalAgency",
|
||||
on_delete=models.PROTECT,
|
||||
|
@ -670,34 +674,50 @@ class DomainRequest(TimeStampedModel):
|
|||
def _send_status_update_email(
|
||||
self, new_status, email_template, email_template_subject, send_email=True, bcc_address="", wrap_email=False
|
||||
):
|
||||
"""Send a status update email to the submitter.
|
||||
"""Send a status update email to the creator.
|
||||
|
||||
The email goes to the email address that the submitter gave as their
|
||||
contact information. If there is not submitter information, then do
|
||||
The email goes to the email address that the creator gave as their
|
||||
contact information. If there is not creator information, then do
|
||||
nothing.
|
||||
|
||||
If the waffle flag "profile_feature" is active, then this email will be sent to the
|
||||
domain request creator rather than the submitter
|
||||
|
||||
send_email: bool -> Used to bypass the send_templated_email function, in the event
|
||||
we just want to log that an email would have been sent, rather than actually sending one.
|
||||
|
||||
wrap_email: bool -> Wraps emails using `wrap_text_and_preserve_paragraphs` if any given
|
||||
paragraph exceeds our desired max length (for prettier display).
|
||||
"""
|
||||
|
||||
if self.submitter is None or self.submitter.email is None:
|
||||
logger.warning(f"Cannot send {new_status} email, no submitter email address.")
|
||||
recipient = self.creator if flag_is_active(None, "profile_feature") else self.submitter
|
||||
if recipient is None or recipient.email is None:
|
||||
logger.warning(
|
||||
f"Cannot send {new_status} email, no creator email address for domain request with pk: {self.pk}."
|
||||
f" Name: {self.requested_domain.name}"
|
||||
if self.requested_domain
|
||||
else ""
|
||||
)
|
||||
return None
|
||||
|
||||
if not send_email:
|
||||
logger.info(f"Email was not sent. Would send {new_status} email: {self.submitter.email}")
|
||||
logger.info(f"Email was not sent. Would send {new_status} email to: {recipient.email}")
|
||||
return None
|
||||
|
||||
try:
|
||||
send_templated_email(
|
||||
email_template,
|
||||
email_template_subject,
|
||||
self.submitter.email,
|
||||
context={"domain_request": self},
|
||||
recipient.email,
|
||||
context={
|
||||
"domain_request": self,
|
||||
# This is the user that we refer to in the email
|
||||
"recipient": recipient,
|
||||
},
|
||||
bcc_address=bcc_address,
|
||||
wrap_email=wrap_email,
|
||||
)
|
||||
logger.info(f"The {new_status} email sent to: {self.submitter.email}")
|
||||
logger.info(f"The {new_status} email sent to: {recipient.email}")
|
||||
except EmailSendingError:
|
||||
logger.warning("Failed to send confirmation email", exc_info=True)
|
||||
|
||||
|
|
|
@ -69,7 +69,7 @@
|
|||
</h2>
|
||||
<div class="usa-prose">
|
||||
<p>
|
||||
This will extend the expiration date by one year.
|
||||
This will extend the expiration date by one year from today.
|
||||
{# Acts as a <br> #}
|
||||
<div class="display-inline"></div>
|
||||
This action cannot be undone.
|
||||
|
@ -78,7 +78,7 @@
|
|||
Domain: <b>{{ original.name }}</b>
|
||||
{# Acts as a <br> #}
|
||||
<div class="display-inline"></div>
|
||||
New expiration date: <b>{{ extended_expiration_date }}</b>
|
||||
Current expiration date: <b>{{ curr_exp_date }}</b>
|
||||
{{test}}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -5,7 +5,9 @@
|
|||
{% block field_sets %}
|
||||
{# Create an invisible <a> tag so that we can use a click event to toggle the modal. #}
|
||||
<a id="invisible-ineligible-modal-toggler" class="display-none" href="#toggle-set-ineligible" aria-controls="toggle-set-ineligible" data-open-modal></a>
|
||||
|
||||
{# Store the current object id so we can access it easier #}
|
||||
<input id="domain_request_id" class="display-none" value="{{original.id}}" />
|
||||
<input id="has_audit_logs" class="display-none" value="{%if filtered_audit_log_entries %}true{% else %}false{% endif %}"/>
|
||||
{% for fieldset in adminform %}
|
||||
{% comment %}
|
||||
TODO: this will eventually need to be changed to something like this
|
||||
|
|
|
@ -62,17 +62,18 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
{% endwith %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="readonly">{{ field.contents }}</div>
|
||||
<div class="readonly">{{ field.contents }}</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endblock field_readonly %}
|
||||
|
||||
{% block after_help_text %}
|
||||
{% if field.field.name == "status" and filtered_audit_log_entries %}
|
||||
{% if field.field.name == "status" %}
|
||||
<div class="flex-container" id="dja-status-changelog">
|
||||
<label aria-label="Status changelog"></label>
|
||||
<div>
|
||||
<div class="usa-table-container--scrollable collapse--dgsimple collapsed" tabindex="0">
|
||||
{% if filtered_audit_log_entries %}
|
||||
<table class="usa-table usa-table--borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -105,7 +106,34 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No changelog to display.</p>
|
||||
{% endif %}
|
||||
|
||||
{% comment %}
|
||||
Store the action needed reason emails in a json-based dictionary.
|
||||
This allows us to change the action_needed_reason_email field dynamically, depending on value.
|
||||
The alternative to this is an API endpoint.
|
||||
|
||||
Given that we have a limited number of emails, doing it this way makes sense.
|
||||
{% endcomment %}
|
||||
{% if action_needed_reason_emails %}
|
||||
<script id="action-needed-emails-data" type="application/json">
|
||||
{{ action_needed_reason_emails|safe }}
|
||||
</script>
|
||||
{% endif %}
|
||||
<div id="action_needed_reason_email_readonly" class="dja-readonly-textarea-container padding-1 margin-top-2 thin-border display-none">
|
||||
<label class="max-full" for="action_needed_reason_email_view_more">
|
||||
<strong>Auto-generated email (sent to submitter)</strong>
|
||||
</label>
|
||||
<textarea id="action_needed_reason_email_view_more" cols="40" rows="20" class="{% if not original_object.action_needed_reason %}display-none{% endif %}" readonly>
|
||||
{{ original_object.action_needed_reason_email }}
|
||||
</textarea>
|
||||
<p id="no-email-message" class="{% if original_object.action_needed_reason %}display-none{% endif %}">No email will be sent.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<button type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-2 margin-bottom-1 margin-left-1">
|
||||
<span>Show details</span>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||
Hi, {{ domain_request.submitter.first_name }}.
|
||||
Hi, {{ recipient.first_name }}.
|
||||
|
||||
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||
Hi, {{ domain_request.submitter.first_name }}.
|
||||
Hi, {{ recipient.first_name }}.
|
||||
|
||||
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||
Hi, {{ domain_request.submitter.first_name }}.
|
||||
Hi, {{ recipient.first_name }}.
|
||||
|
||||
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||
Hi, {{ domain_request.submitter.first_name }}.
|
||||
Hi, {{ recipient.first_name }}.
|
||||
|
||||
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||
Hi, {{ domain_request.submitter.first_name }}.
|
||||
Hi, {{ recipient.first_name }}.
|
||||
|
||||
Your .gov domain request has been withdrawn and will not be reviewed by our team.
|
||||
|
||||
|
|
|
@ -43,7 +43,7 @@ Purpose of your domain:
|
|||
{{ domain_request.purpose }}
|
||||
|
||||
Your contact information:
|
||||
{% spaceless %}{% include "emails/includes/contact.txt" with contact=domain_request.submitter %}{% endspaceless %}
|
||||
{% spaceless %}{% include "emails/includes/contact.txt" with contact=recipient %}{% endspaceless %}
|
||||
|
||||
Other employees from your organization:{% for other in domain_request.other_contacts.all %}
|
||||
{% spaceless %}{% include "emails/includes/contact.txt" with contact=other %}{% endspaceless %}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||
Hi, {{ domain_request.submitter.first_name }}.
|
||||
Hi, {{ recipient.first_name }}.
|
||||
|
||||
Congratulations! Your .gov domain request has been approved.
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||
Hi, {{ domain_request.submitter.first_name }}.
|
||||
Hi, {{ recipient.first_name }}.
|
||||
|
||||
Your .gov domain request has been rejected.
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||
Hi, {{ domain_request.submitter.first_name }}.
|
||||
Hi, {{ recipient.first_name }}.
|
||||
|
||||
We received your .gov domain request.
|
||||
|
||||
|
|
|
@ -858,6 +858,8 @@ def completed_domain_request( # noqa
|
|||
is_election_board=False,
|
||||
organization_type=None,
|
||||
federal_agency=None,
|
||||
federal_type=None,
|
||||
action_needed_reason=None,
|
||||
):
|
||||
"""A completed domain request."""
|
||||
if not user:
|
||||
|
@ -922,6 +924,12 @@ def completed_domain_request( # noqa
|
|||
if organization_type:
|
||||
domain_request_kwargs["organization_type"] = organization_type
|
||||
|
||||
if federal_type:
|
||||
domain_request_kwargs["federal_type"] = federal_type
|
||||
|
||||
if action_needed_reason:
|
||||
domain_request_kwargs["action_needed_reason"] = action_needed_reason
|
||||
|
||||
domain_request, _ = DomainRequest.objects.get_or_create(**domain_request_kwargs)
|
||||
|
||||
if has_other_contacts:
|
||||
|
@ -1543,8 +1551,6 @@ class MockEppLib(TestCase):
|
|||
def mockInfoDomainCommands(self, _request, cleaned):
|
||||
request_name = getattr(_request, "name", None).lower()
|
||||
|
||||
print(request_name)
|
||||
|
||||
# Define a dictionary to map request names to data and extension values
|
||||
request_mappings = {
|
||||
"security.gov": (self.infoDomainNoContact, None),
|
||||
|
|
|
@ -374,9 +374,9 @@ class TestDomainAdmin(MockEppLib, WebTest):
|
|||
|
||||
# Create a ready domain with a preset expiration date
|
||||
domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
|
||||
|
||||
response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk]))
|
||||
|
||||
# load expiration date into cache and registrar with below command
|
||||
domain.registry_expiration_date
|
||||
# Make sure the ex date is what we expect it to be
|
||||
domain_ex_date = Domain.objects.get(id=domain.id).expiration_date
|
||||
self.assertEqual(domain_ex_date, date(2023, 5, 25))
|
||||
|
@ -400,7 +400,6 @@ class TestDomainAdmin(MockEppLib, WebTest):
|
|||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, domain.name)
|
||||
self.assertContains(response, "Extend expiration date")
|
||||
self.assertContains(response, "New expiration date: <b>May 25, 2025</b>")
|
||||
|
||||
# Ensure the message we recieve is in line with what we expect
|
||||
expected_message = "Successfully extended the expiration date."
|
||||
|
@ -519,70 +518,10 @@ class TestDomainAdmin(MockEppLib, WebTest):
|
|||
# Follow the response
|
||||
response = response.follow()
|
||||
|
||||
# This value is based off of the current year - the expiration date.
|
||||
# We "freeze" time to 2024, so 2024 - 2023 will always result in an
|
||||
# "extension" of 2, as that will be one year of extension from that date.
|
||||
extension_length = 2
|
||||
|
||||
# Assert that it is calling the function with the right extension length.
|
||||
# Assert that it is calling the function with the default extension length.
|
||||
# We only need to test the value that EPP sends, as we can assume the other
|
||||
# test cases cover the "renew" function.
|
||||
renew_mock.assert_has_calls([call(length=extension_length)], any_order=False)
|
||||
|
||||
# We should not make duplicate calls
|
||||
self.assertEqual(renew_mock.call_count, 1)
|
||||
|
||||
# Assert that everything on the page looks correct
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, domain.name)
|
||||
self.assertContains(response, "Extend expiration date")
|
||||
|
||||
# Ensure the message we recieve is in line with what we expect
|
||||
expected_message = "Successfully extended the expiration date."
|
||||
expected_call = call(
|
||||
# The WGSI request doesn't need to be tested
|
||||
ANY,
|
||||
messages.INFO,
|
||||
expected_message,
|
||||
extra_tags="",
|
||||
fail_silently=False,
|
||||
)
|
||||
mock_add_message.assert_has_calls([expected_call], 1)
|
||||
|
||||
@patch("registrar.admin.DomainAdmin._get_current_date", return_value=date(2023, 1, 1))
|
||||
def test_extend_expiration_date_button_date_matches_epp(self, mock_date_today):
|
||||
"""
|
||||
Tests if extend_expiration_date button sends the right epp command
|
||||
when the current year matches the expiration date
|
||||
"""
|
||||
|
||||
# Create a ready domain with a preset expiration date
|
||||
domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
|
||||
|
||||
response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk]))
|
||||
|
||||
# Make sure that the page is loading as expected
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, domain.name)
|
||||
self.assertContains(response, "Extend expiration date")
|
||||
|
||||
# Grab the form to submit
|
||||
form = response.forms["domain_form"]
|
||||
|
||||
with patch("django.contrib.messages.add_message") as mock_add_message:
|
||||
with patch("registrar.models.Domain.renew_domain") as renew_mock:
|
||||
# Submit the form
|
||||
response = form.submit("_extend_expiration_date")
|
||||
|
||||
# Follow the response
|
||||
response = response.follow()
|
||||
|
||||
extension_length = 1
|
||||
|
||||
# Assert that it is calling the function with the right extension length.
|
||||
# We only need to test the value that EPP sends, as we can assume the other
|
||||
# test cases cover the "renew" function.
|
||||
renew_mock.assert_has_calls([call(length=extension_length)], any_order=False)
|
||||
renew_mock.assert_has_calls([call()], any_order=False)
|
||||
|
||||
# We should not make duplicate calls
|
||||
self.assertEqual(renew_mock.call_count, 1)
|
||||
|
@ -944,7 +883,7 @@ class TestDomainRequestAdminForm(TestCase):
|
|||
self.assertIn("rejection_reason", form.errors)
|
||||
|
||||
rejection_reason = form.errors.get("rejection_reason")
|
||||
self.assertEqual(rejection_reason, ["A rejection reason is required."])
|
||||
self.assertEqual(rejection_reason, ["A reason is required for this status."])
|
||||
|
||||
def test_form_choices_when_no_instance(self):
|
||||
with less_console_noise():
|
||||
|
@ -1596,6 +1535,24 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_model_displays_action_needed_email(self):
|
||||
"""Tests if the action needed email is visible for Domain Requests"""
|
||||
|
||||
_domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.ACTION_NEEDED,
|
||||
action_needed_reason=DomainRequest.ActionNeededReasons.BAD_NAME,
|
||||
)
|
||||
|
||||
p = "userpass"
|
||||
self.client.login(username="staffuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/domainrequest/{}/change/".format(_domain_request.pk),
|
||||
follow=True,
|
||||
)
|
||||
|
||||
self.assertContains(response, "DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS")
|
||||
|
||||
@override_settings(IS_PRODUCTION=True)
|
||||
def test_save_model_sends_submitted_email_with_bcc_on_prod(self):
|
||||
"""When transitioning to submitted from started or withdrawn on a domain request,
|
||||
|
@ -1911,7 +1868,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
|
||||
messages.error.assert_called_once_with(
|
||||
request,
|
||||
"A rejection reason is required.",
|
||||
"A reason is required for this status.",
|
||||
)
|
||||
|
||||
domain_request.refresh_from_db()
|
||||
|
@ -2161,15 +2118,15 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.assertContains(response, "testy@town.com", count=2)
|
||||
expected_ao_fields = [
|
||||
# Field, expected value
|
||||
("title", "Chief Tester"),
|
||||
("phone", "(555) 555 5555"),
|
||||
]
|
||||
self.test_helper.assert_response_contains_distinct_values(response, expected_ao_fields)
|
||||
self.assertContains(response, "Chief Tester")
|
||||
|
||||
self.assertContains(response, "Testy Tester", count=10)
|
||||
self.assertContains(response, "Testy Tester")
|
||||
|
||||
# == Test the other_employees field == #
|
||||
self.assertContains(response, "testy2@town.com", count=2)
|
||||
self.assertContains(response, "testy2@town.com")
|
||||
expected_other_employees_fields = [
|
||||
# Field, expected value
|
||||
("title", "Another Tester"),
|
||||
|
@ -2290,6 +2247,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"status",
|
||||
"rejection_reason",
|
||||
"action_needed_reason",
|
||||
"action_needed_reason_email",
|
||||
"federal_agency",
|
||||
"portfolio",
|
||||
"creator",
|
||||
|
|
|
@ -2,6 +2,7 @@ import copy
|
|||
from datetime import date, datetime, time
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase, override_settings
|
||||
from registrar.utility.constants import BranchChoices
|
||||
from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
import logging
|
||||
|
@ -1112,3 +1113,115 @@ class TestImportTables(TestCase):
|
|||
|
||||
# Check that logger.error was called with the correct message
|
||||
mock_logger.error.assert_called_once_with("Zip file tmp/exported_tables.zip does not exist.")
|
||||
|
||||
|
||||
class TestTransferFederalAgencyType(TestCase):
|
||||
"""Tests for the transfer_federal_agency_type script"""
|
||||
|
||||
def setUp(self):
|
||||
"""Creates a fake domain object"""
|
||||
super().setUp()
|
||||
|
||||
self.amtrak, _ = FederalAgency.objects.get_or_create(agency="AMTRAK")
|
||||
self.legislative_branch, _ = FederalAgency.objects.get_or_create(agency="Legislative Branch")
|
||||
self.library_of_congress, _ = FederalAgency.objects.get_or_create(agency="Library of Congress")
|
||||
self.gov_admin, _ = FederalAgency.objects.get_or_create(agency="gov Administration")
|
||||
|
||||
self.domain_request_1 = completed_domain_request(
|
||||
name="testgov.gov",
|
||||
federal_agency=self.amtrak,
|
||||
federal_type=BranchChoices.EXECUTIVE,
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||
)
|
||||
self.domain_request_2 = completed_domain_request(
|
||||
name="cheesefactory.gov",
|
||||
federal_agency=self.legislative_branch,
|
||||
federal_type=BranchChoices.LEGISLATIVE,
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||
)
|
||||
self.domain_request_3 = completed_domain_request(
|
||||
name="meowardslaw.gov",
|
||||
federal_agency=self.library_of_congress,
|
||||
federal_type=BranchChoices.JUDICIAL,
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||
)
|
||||
|
||||
# Duplicate fields with invalid data - we expect to skip updating these
|
||||
self.domain_request_4 = completed_domain_request(
|
||||
name="baddata.gov",
|
||||
federal_agency=self.gov_admin,
|
||||
federal_type=BranchChoices.EXECUTIVE,
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||
)
|
||||
self.domain_request_5 = completed_domain_request(
|
||||
name="worsedata.gov",
|
||||
federal_agency=self.gov_admin,
|
||||
federal_type=BranchChoices.JUDICIAL,
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||
)
|
||||
|
||||
self.domain_request_1.approve()
|
||||
self.domain_request_2.approve()
|
||||
self.domain_request_3.approve()
|
||||
self.domain_request_4.approve()
|
||||
self.domain_request_5.approve()
|
||||
|
||||
def tearDown(self):
|
||||
"""Deletes all DB objects related to migrations"""
|
||||
super().tearDown()
|
||||
|
||||
# Delete domains and related information
|
||||
Domain.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
DomainRequest.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
Contact.objects.all().delete()
|
||||
Website.objects.all().delete()
|
||||
FederalAgency.objects.all().delete()
|
||||
|
||||
def run_transfer_federal_agency_type(self):
|
||||
"""
|
||||
This method executes the transfer_federal_agency_type command.
|
||||
|
||||
The 'call_command' function from Django's management framework is then used to
|
||||
execute the populate_first_ready command with the specified arguments.
|
||||
"""
|
||||
with less_console_noise():
|
||||
with patch(
|
||||
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa
|
||||
return_value=True,
|
||||
):
|
||||
call_command("transfer_federal_agency_type")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_federal_agency_type_script(self):
|
||||
"""
|
||||
Tests that the transfer_federal_agency_type script updates what we expect, and skips what we expect
|
||||
"""
|
||||
|
||||
# Before proceeding, make sure we don't have any data contamination
|
||||
tested_agencies = [
|
||||
self.amtrak,
|
||||
self.legislative_branch,
|
||||
self.library_of_congress,
|
||||
self.gov_admin,
|
||||
]
|
||||
for agency in tested_agencies:
|
||||
self.assertEqual(agency.federal_type, None)
|
||||
|
||||
# Run the script
|
||||
self.run_transfer_federal_agency_type()
|
||||
|
||||
# Refresh the local db instance to reflect changes
|
||||
self.amtrak.refresh_from_db()
|
||||
self.legislative_branch.refresh_from_db()
|
||||
self.library_of_congress.refresh_from_db()
|
||||
self.gov_admin.refresh_from_db()
|
||||
|
||||
# Test the values that we expect to be updated
|
||||
self.assertEqual(self.amtrak.federal_type, BranchChoices.EXECUTIVE)
|
||||
self.assertEqual(self.legislative_branch.federal_type, BranchChoices.LEGISLATIVE)
|
||||
self.assertEqual(self.library_of_congress.federal_type, BranchChoices.JUDICIAL)
|
||||
|
||||
# We don't expect this field to be updated (as it has duplicate data)
|
||||
self.assertEqual(self.gov_admin.federal_type, None)
|
||||
|
|
|
@ -25,6 +25,7 @@ from registrar.utility.constants import BranchChoices
|
|||
|
||||
from .common import MockSESClient, less_console_noise, completed_domain_request, set_domain_request_investigators
|
||||
from django_fsm import TransitionNotAllowed
|
||||
from waffle.testutils import override_flag
|
||||
|
||||
|
||||
# Test comment for push -- will remove
|
||||
|
@ -33,29 +34,44 @@ from django_fsm import TransitionNotAllowed
|
|||
@boto3_mocking.patching
|
||||
class TestDomainRequest(TestCase):
|
||||
def setUp(self):
|
||||
|
||||
self.dummy_user, _ = Contact.objects.get_or_create(
|
||||
email="mayor@igorville.com", first_name="Hello", last_name="World"
|
||||
)
|
||||
self.dummy_user_2, _ = User.objects.get_or_create(
|
||||
username="intern@igorville.com", email="intern@igorville.com", first_name="Lava", last_name="World"
|
||||
)
|
||||
self.started_domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.STARTED, name="started.gov"
|
||||
status=DomainRequest.DomainRequestStatus.STARTED,
|
||||
name="started.gov",
|
||||
)
|
||||
self.submitted_domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.SUBMITTED, name="submitted.gov"
|
||||
status=DomainRequest.DomainRequestStatus.SUBMITTED,
|
||||
name="submitted.gov",
|
||||
)
|
||||
self.in_review_domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW, name="in-review.gov"
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||
name="in-review.gov",
|
||||
)
|
||||
self.action_needed_domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.ACTION_NEEDED, name="action-needed.gov"
|
||||
status=DomainRequest.DomainRequestStatus.ACTION_NEEDED,
|
||||
name="action-needed.gov",
|
||||
)
|
||||
self.approved_domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.APPROVED, name="approved.gov"
|
||||
status=DomainRequest.DomainRequestStatus.APPROVED,
|
||||
name="approved.gov",
|
||||
)
|
||||
self.withdrawn_domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.WITHDRAWN, name="withdrawn.gov"
|
||||
status=DomainRequest.DomainRequestStatus.WITHDRAWN,
|
||||
name="withdrawn.gov",
|
||||
)
|
||||
self.rejected_domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.REJECTED, name="rejected.gov"
|
||||
status=DomainRequest.DomainRequestStatus.REJECTED,
|
||||
name="rejected.gov",
|
||||
)
|
||||
self.ineligible_domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.INELIGIBLE, name="ineligible.gov"
|
||||
status=DomainRequest.DomainRequestStatus.INELIGIBLE,
|
||||
name="ineligible.gov",
|
||||
)
|
||||
|
||||
# Store all domain request statuses in a variable for ease of use
|
||||
|
@ -199,7 +215,9 @@ class TestDomainRequest(TestCase):
|
|||
domain_request.submit()
|
||||
self.assertEqual(domain_request.status, domain_request.DomainRequestStatus.SUBMITTED)
|
||||
|
||||
def check_email_sent(self, domain_request, msg, action, expected_count):
|
||||
def check_email_sent(
|
||||
self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com"
|
||||
):
|
||||
"""Check if an email was sent after performing an action."""
|
||||
|
||||
with self.subTest(msg=msg, action=action):
|
||||
|
@ -213,19 +231,35 @@ class TestDomainRequest(TestCase):
|
|||
sent_emails = [
|
||||
email
|
||||
for email in MockSESClient.EMAILS_SENT
|
||||
if "mayor@igorville.gov" in email["kwargs"]["Destination"]["ToAddresses"]
|
||||
if expected_email in email["kwargs"]["Destination"]["ToAddresses"]
|
||||
]
|
||||
self.assertEqual(len(sent_emails), expected_count)
|
||||
|
||||
if expected_content:
|
||||
email_content = sent_emails[0]["kwargs"]["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||
self.assertIn(expected_content, email_content)
|
||||
|
||||
@override_flag("profile_feature", active=False)
|
||||
def test_submit_from_started_sends_email(self):
|
||||
msg = "Create a domain request and submit it and see if email was sent."
|
||||
domain_request = completed_domain_request()
|
||||
self.check_email_sent(domain_request, msg, "submit", 1)
|
||||
domain_request = completed_domain_request(submitter=self.dummy_user, user=self.dummy_user_2)
|
||||
self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hello")
|
||||
|
||||
@override_flag("profile_feature", active=True)
|
||||
def test_submit_from_started_sends_email_to_creator(self):
|
||||
"""Tests if, when the profile feature flag is on, we send an email to the creator"""
|
||||
msg = "Create a domain request and submit it and see if email was sent when the feature flag is on."
|
||||
domain_request = completed_domain_request(submitter=self.dummy_user, user=self.dummy_user_2)
|
||||
self.check_email_sent(
|
||||
domain_request, msg, "submit", 1, expected_content="Lava", expected_email="intern@igorville.com"
|
||||
)
|
||||
|
||||
def test_submit_from_withdrawn_sends_email(self):
|
||||
msg = "Create a withdrawn domain request and submit it and see if email was sent."
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.WITHDRAWN)
|
||||
self.check_email_sent(domain_request, msg, "submit", 1)
|
||||
domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.WITHDRAWN, submitter=self.dummy_user
|
||||
)
|
||||
self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hello")
|
||||
|
||||
def test_submit_from_action_needed_does_not_send_email(self):
|
||||
msg = "Create a domain request with ACTION_NEEDED status and submit it, check if email was not sent."
|
||||
|
@ -239,18 +273,24 @@ class TestDomainRequest(TestCase):
|
|||
|
||||
def test_approve_sends_email(self):
|
||||
msg = "Create a domain request and approve it and see if email was sent."
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
|
||||
self.check_email_sent(domain_request, msg, "approve", 1)
|
||||
domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW, submitter=self.dummy_user
|
||||
)
|
||||
self.check_email_sent(domain_request, msg, "approve", 1, expected_content="Hello")
|
||||
|
||||
def test_withdraw_sends_email(self):
|
||||
msg = "Create a domain request and withdraw it and see if email was sent."
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
|
||||
self.check_email_sent(domain_request, msg, "withdraw", 1)
|
||||
domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW, submitter=self.dummy_user
|
||||
)
|
||||
self.check_email_sent(domain_request, msg, "withdraw", 1, expected_content="Hello")
|
||||
|
||||
def test_reject_sends_email(self):
|
||||
msg = "Create a domain request and reject it and see if email was sent."
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED)
|
||||
self.check_email_sent(domain_request, msg, "reject", 1)
|
||||
domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.APPROVED, submitter=self.dummy_user
|
||||
)
|
||||
self.check_email_sent(domain_request, msg, "reject", 1, expected_content="Hello")
|
||||
|
||||
def test_reject_with_prejudice_does_not_send_email(self):
|
||||
msg = "Create a domain request and reject it with prejudice and see if email was sent."
|
||||
|
|
|
@ -101,7 +101,7 @@ class FSMDomainRequestError(Exception):
|
|||
FSMErrorCodes.NO_INVESTIGATOR: ("Investigator is required for this status."),
|
||||
FSMErrorCodes.INVESTIGATOR_NOT_STAFF: ("Investigator is not a staff user."),
|
||||
FSMErrorCodes.INVESTIGATOR_NOT_SUBMITTER: ("Only the assigned investigator can make this change."),
|
||||
FSMErrorCodes.NO_REJECTION_REASON: ("A rejection reason is required."),
|
||||
FSMErrorCodes.NO_REJECTION_REASON: ("A reason is required for this status."),
|
||||
FSMErrorCodes.NO_ACTION_NEEDED_REASON: ("A reason is required for this status."),
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue