Merge branch 'main' into za/2157-use-creator-email

This commit is contained in:
zandercymatics 2024-06-27 14:32:17 -06:00
commit f7f99a730a
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
18 changed files with 662 additions and 63 deletions

View file

@ -0,0 +1,65 @@
# Terminal Helper Functions
`terminal_helper.py` contains utility functions to assist with common terminal and script operations.
This file documents what they do and provides guidance on their usage.
## TerminalColors
`TerminalColors` provides ANSI color codes as variables to style terminal output.
## ScriptDataHelper
### bulk_update_fields
`bulk_update_fields` performs a memory-efficient bulk update on a Django model in batches using a Paginator.
## TerminalHelper
### log_script_run_summary
`log_script_run_summary` logs a summary of a script run, including counts of updated, skipped, and failed records.
### print_conditional
`print_conditional` conditionally logs a statement at a specified severity if a condition is met.
### prompt_for_execution
`prompt_for_execution` prompts the user to inspect a string and confirm if they wish to proceed. Returns True if proceeding, False if skipping, or exits the script.
### query_yes_no
`query_yes_no` prompts the user with a yes/no question and returns True for "yes" or False for "no".
### query_yes_no_exit
`query_yes_no_exit` is similar to `query_yes_no` but includes an "exit" option to terminate the script.
## PopulateScriptTemplate
`PopulateScriptTemplate` is an abstract base class that provides a template for creating generic populate scripts. It handles logging and bulk updating for repetitive scripts that update a few fields.
### **Disclaimer**
This template is intended as a shorthand for simple scripts. It is not recommended for complex operations. See `transfer_federal_agency.py` for a straightforward example of how to use this template.
### Step-by-step usage guide
To create a script using `PopulateScriptTemplate`:
1. Create a new class that inherits from `PopulateScriptTemplate`
2. Implement the `update_record` method to define how each record should be updated
3. Optionally, override the configuration variables and helper methods as needed
4. Call `mass_update_records` within `handle` and run the script
#### Template explanation
The main method provided by `PopulateScriptTemplate` is `mass_update_records`. This method loops through each valid object (specified by `filter_conditions`) and updates the fields defined in `fields_to_update` using the `update_record` method.
Before updating, `mass_update_records` prompts the user to confirm the proposed changes. If the user does not proceed, the script will exit.
After processing the records, `mass_update_records` performs a bulk update on the specified fields using `ScriptDataHelper.bulk_update_fields` and logs a summary of the script run using `TerminalHelper.log_script_run_summary`.
#### Config options
The class provides the following optional configuration variables:
- `prompt_title`: The header displayed by `prompt_for_execution` when the script starts (default: "Do you wish to proceed?")
- `display_run_summary_items_as_str`: If True, runs `str(item)` on each item when printing the run summary for prettier output (default: False)
- `run_summary_header`: The header for the script run summary printed after the script finishes (default: None)
The class also provides helper methods:
- `get_class_name`: Returns a display-friendly class name for the terminal prompt
- `get_failure_message`: Returns the message to display if a record fails to update
- `should_skip_record`: Defines the condition for skipping a record (by default, no records are skipped)

View file

@ -698,6 +698,33 @@ Example: `cf ssh getgov-za`
|:-:|:-------------------------- |:----------------------------------------------------------------------------| |:-:|:-------------------------- |:----------------------------------------------------------------------------|
| 1 | **debug** | Increases logging detail. Defaults to False. | | 1 | **debug** | Increases logging detail. Defaults to False. |
## Transfer federal agency script
The transfer federal agency script adds the "federal_type" field on each associated DomainRequest, and uses that to populate the "federal_type" field on each FederalAgency.
**Important:** When running this script, note that data generated by our fixtures will be inaccurate (since we assign random data to them). Use real data on this script.
Do note that there is a check on record uniqueness. If two or more records do NOT have the same value for federal_type for any given federal agency, then the record is skipped. This protects against fixtures data when loaded with real data.
### Running on sandboxes
#### Step 1: Login to CloudFoundry
```cf login -a api.fr.cloud.gov --sso```
#### Step 2: SSH into your environment
```cf ssh getgov-{space}```
Example: `cf ssh getgov-za`
#### Step 3: Create a shell instance
```/tmp/lifecycle/shell```
#### Step 4: Running the script
```./manage.py transfer_federal_agency_type```
### Running locally
#### Step 1: Running the script
```docker-compose exec app ./manage.py transfer_federal_agency_type```
## Email current metadata report ## Email current metadata report
### Running on sandboxes ### Running on sandboxes

View file

@ -1,7 +1,8 @@
from datetime import date from datetime import date
import logging import logging
import copy import copy
import json
from django.template.loader import get_template
from django import forms from django import forms
from django.db.models import Value, CharField, Q from django.db.models import Value, CharField, Q
from django.db.models.functions import Concat, Coalesce from django.db.models.functions import Concat, Coalesce
@ -1689,6 +1690,10 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return super().save_model(request, obj, form, change) return super().save_model(request, obj, form, change)
# == Handle non-status changes == # # == 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. # Get the original domain request from the database.
original_obj = models.DomainRequest.objects.get(pk=obj.pk) original_obj = models.DomainRequest.objects.get(pk=obj.pk)
@ -1697,14 +1702,17 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return super().save_model(request, obj, form, change) return super().save_model(request, obj, form, change)
# == Handle status changes == # # == Handle status changes == #
# Run some checks on the current object for invalid status changes # Run some checks on the current object for invalid status changes
obj, should_save = self._handle_status_change(request, obj, original_obj) 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: if should_save:
return super().save_model(request, obj, form, change) 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): def _handle_status_change(self, request, obj, original_obj):
""" """
Checks for various conditions when a status change is triggered. Checks for various conditions when a status change is triggered.
@ -1898,10 +1906,45 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Initialize extra_context and add filtered entries # Initialize extra_context and add filtered entries
extra_context = extra_context or {} extra_context = extra_context or {}
extra_context["filtered_audit_log_entries"] = filtered_audit_log_entries 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 # Call the superclass method with updated extra_context
return super().change_view(request, object_id, form_url, 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): def process_log_entry(self, log_entry):
"""Process a log entry and return filtered entry dictionary if applicable.""" """Process a log entry and return filtered entry dictionary if applicable."""
changes = log_entry.changes changes = log_entry.changes

View file

@ -8,6 +8,25 @@
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Helper functions. // 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 */ /** Either sets attribute target="_blank" to a given element, or removes it */
function openInNewTab(el, removeAttribute = false){ function openInNewTab(el, removeAttribute = false){
if(removeAttribute){ if(removeAttribute){
@ -57,6 +76,7 @@ function openInNewTab(el, removeAttribute = false){
createPhantomModalFormButtons(); createPhantomModalFormButtons();
})(); })();
/** An IIFE for DomainRequest to hook a modal to a dropdown option. /** An IIFE for DomainRequest to hook a modal to a dropdown option.
* This intentionally does not interact with createPhantomModalFormButtons() * This intentionally does not interact with createPhantomModalFormButtons()
*/ */
@ -408,13 +428,21 @@ function initializeWidgetOnList(list, parentId) {
function moveStatusChangelog(actionNeededReasonFormGroup, statusSelect) { function moveStatusChangelog(actionNeededReasonFormGroup, statusSelect) {
let flexContainer = actionNeededReasonFormGroup.querySelector('.flex-container'); let flexContainer = actionNeededReasonFormGroup.querySelector('.flex-container');
let statusChangelog = document.getElementById('dja-status-changelog'); 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") { if (statusSelect.value === "action needed") {
flexContainer.parentNode.insertBefore(statusChangelog, flexContainer.nextSibling); flexContainer.parentNode.insertBefore(statusChangelog, flexContainer.nextSibling);
showElement(showReasonEmailContainer);
} else { } else {
// Move the changelog back to its original location // Move the changelog back to its original location
let statusFlexContainer = statusSelect.closest('.flex-container'); let statusFlexContainer = statusSelect.closest('.flex-container');
statusFlexContainer.parentNode.insertBefore(statusChangelog, statusFlexContainer.nextSibling); statusFlexContainer.parentNode.insertBefore(statusChangelog, statusFlexContainer.nextSibling);
hideElement(showReasonEmailContainer);
} }
} }
// Call the function on page load // Call the function on page load
@ -518,3 +546,60 @@ function initializeWidgetOnList(list, parentId) {
handleShowMoreButton(toggleButton, descriptionDiv) 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);
}
})();

View file

@ -786,3 +786,44 @@ div.dja__model-description{
.usa-button--dja-link-color { .usa-button--dja-link-color {
color: var(--link-fg); 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;
}

View file

@ -12,12 +12,11 @@ class Command(BaseCommand, PopulateScriptTemplate):
def handle(self, **kwargs): def handle(self, **kwargs):
"""Loops through each valid User object and updates its verification_type value""" """Loops through each valid User object and updates its verification_type value"""
filter_condition = {"verification_type__isnull": True} 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""" """Defines how we update the verification_type field"""
field_to_update.set_user_verification_type() record.set_user_verification_type()
logger.info( logger.info(
f"{TerminalColors.OKCYAN}Updating {field_to_update} => " f"{TerminalColors.OKCYAN}Updating {record} => " f"{record.verification_type}{TerminalColors.OKCYAN}"
f"{field_to_update.verification_type}{TerminalColors.OKCYAN}"
) )

View file

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

View file

@ -61,56 +61,96 @@ class ScriptDataHelper:
class PopulateScriptTemplate(ABC): 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): # Optional script-global config variables. For the most part, you can leave these untouched.
"""Loops through each valid "sender" object - specified by filter_conditions - and # Defines what prompt_for_execution displays as its header when you first start the script
updates fields defined by fields_to_update using populate_function. 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" # Code execution will stop here if the user prompts "N"
TerminalHelper.prompt_for_execution( TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True, system_exit_on_terminate=True,
info_to_inspect=f""" info_to_inspect=f"""
==Proposed Changes== ==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} 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...") logger.info("Updating...")
to_update: List[sender] = [] to_update: List[object_class] = []
failed_to_update: List[sender] = [] to_skip: List[object_class] = []
for updated_object in objects: failed_to_update: List[object_class] = []
for record in records:
try: try:
self.populate_field(updated_object) if not self.should_skip_record(record):
to_update.append(updated_object) self.update_record(record)
to_update.append(record)
else:
to_skip.append(record)
except Exception as err: 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(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 # Do a bulk update on the desired field
ScriptDataHelper.bulk_update_fields(sender, to_update, fields_to_update) ScriptDataHelper.bulk_update_fields(object_class, to_update, fields_to_update)
# Log what happened # 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 get_class_name(self, sender) -> str:
def populate_field(self, field_to_update): """Returns the class name that we want to display for the terminal prompt.
"""Defines how we update each field. Must be defined before using mass_populate_field.""" Example: DomainRequest => "Domain Request"
raise NotImplementedError """
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: class TerminalHelper:
@staticmethod @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 """Prints success, failed, and skipped counts, as well as
all affected objects.""" all affected objects."""
update_success_count = len(to_update) update_success_count = len(to_update)
@ -121,20 +161,24 @@ class TerminalHelper:
log_header = "============= FINISHED ===============" log_header = "============= FINISHED ==============="
# Prepare debug messages # Prepare debug messages
debug_messages = { if debug:
"success": (f"{TerminalColors.OKCYAN}Updated: {to_update}{TerminalColors.ENDC}\n"), updated_display = [str(u) for u in to_update] if display_as_str else to_update
"skipped": (f"{TerminalColors.YELLOW}Skipped: {skipped}{TerminalColors.ENDC}\n"), skipped_display = [str(s) for s in skipped] if display_as_str else skipped
"failed": (f"{TerminalColors.FAIL}Failed: {failed_to_update}{TerminalColors.ENDC}\n"), 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. # Print out a list of everything that was changed, if we have any changes to log.
# Otherwise, don't print anything. # Otherwise, don't print anything.
TerminalHelper.print_conditional( TerminalHelper.print_conditional(
debug, debug,
f"{debug_messages.get('success') if update_success_count > 0 else ''}" 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('skipped') if update_skipped_count > 0 else ''}"
f"{debug_messages.get('failed') if update_failed_count > 0 else ''}", f"{debug_messages.get('failed') if update_failed_count > 0 else ''}",
) )
if update_failed_count == 0 and update_skipped_count == 0: if update_failed_count == 0 and update_skipped_count == 0:
logger.info( logger.info(

View file

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

View file

@ -18,6 +18,8 @@ from .utility.time_stamped_model import TimeStampedModel
from ..utility.email import send_templated_email, EmailSendingError from ..utility.email import send_templated_email, EmailSendingError
from itertools import chain from itertools import chain
from waffle.decorators import flag_is_active
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -295,6 +297,11 @@ class DomainRequest(TimeStampedModel):
blank=True, blank=True,
) )
action_needed_reason_email = models.TextField(
null=True,
blank=True,
)
federal_agency = models.ForeignKey( federal_agency = models.ForeignKey(
"registrar.FederalAgency", "registrar.FederalAgency",
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -1180,19 +1187,21 @@ class DomainRequest(TimeStampedModel):
def _is_policy_acknowledgement_complete(self): def _is_policy_acknowledgement_complete(self):
return self.is_policy_acknowledged is not None return self.is_policy_acknowledged is not None
def _is_general_form_complete(self): def _is_general_form_complete(self, request):
has_profile_feature_flag = flag_is_active(request, "profile_feature")
return ( return (
self._is_organization_name_and_address_complete() self._is_organization_name_and_address_complete()
and self._is_authorizing_official_complete() and self._is_authorizing_official_complete()
and self._is_requested_domain_complete() and self._is_requested_domain_complete()
and self._is_purpose_complete() and self._is_purpose_complete()
and self._is_submitter_complete() # NOTE: This flag leaves submitter as empty (request wont submit) hence preset to True
and (self._is_submitter_complete() if not has_profile_feature_flag else True)
and self._is_other_contacts_complete() and self._is_other_contacts_complete()
and self._is_additional_details_complete() and self._is_additional_details_complete()
and self._is_policy_acknowledgement_complete() and self._is_policy_acknowledgement_complete()
) )
def _form_complete(self): def _form_complete(self, request):
match self.generic_org_type: match self.generic_org_type:
case DomainRequest.OrganizationChoices.FEDERAL: case DomainRequest.OrganizationChoices.FEDERAL:
is_complete = self._is_federal_complete() is_complete = self._is_federal_complete()
@ -1213,8 +1222,6 @@ class DomainRequest(TimeStampedModel):
case _: case _:
# NOTE: Shouldn't happen, this is only if somehow they didn't choose an org type # NOTE: Shouldn't happen, this is only if somehow they didn't choose an org type
is_complete = False is_complete = False
if not is_complete or not self._is_general_form_complete(request):
if not is_complete or not self._is_general_form_complete():
return False return False
return True return True

View file

@ -5,7 +5,9 @@
{% block field_sets %} {% block field_sets %}
{# Create an invisible <a> tag so that we can use a click event to toggle the modal. #} {# 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> <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 %} {% for fieldset in adminform %}
{% comment %} {% comment %}
TODO: this will eventually need to be changed to something like this TODO: this will eventually need to be changed to something like this

View file

@ -62,17 +62,18 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endwith %} {% endwith %}
</div> </div>
{% else %} {% else %}
<div class="readonly">{{ field.contents }}</div> <div class="readonly">{{ field.contents }}</div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endblock field_readonly %} {% endblock field_readonly %}
{% block after_help_text %} {% 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"> <div class="flex-container" id="dja-status-changelog">
<label aria-label="Status changelog"></label> <label aria-label="Status changelog"></label>
<div> <div>
<div class="usa-table-container--scrollable collapse--dgsimple collapsed" tabindex="0"> <div class="usa-table-container--scrollable collapse--dgsimple collapsed" tabindex="0">
{% if filtered_audit_log_entries %}
<table class="usa-table usa-table--borderless"> <table class="usa-table usa-table--borderless">
<thead> <thead>
<tr> <tr>
@ -105,7 +106,34 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </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> </div>
<button type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-2 margin-bottom-1 margin-left-1"> <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> <span>Show details</span>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">

View file

@ -858,6 +858,8 @@ def completed_domain_request( # noqa
is_election_board=False, is_election_board=False,
organization_type=None, organization_type=None,
federal_agency=None, federal_agency=None,
federal_type=None,
action_needed_reason=None,
): ):
"""A completed domain request.""" """A completed domain request."""
if not user: if not user:
@ -922,6 +924,12 @@ def completed_domain_request( # noqa
if organization_type: if organization_type:
domain_request_kwargs["organization_type"] = 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) domain_request, _ = DomainRequest.objects.get_or_create(**domain_request_kwargs)
if has_other_contacts: if has_other_contacts:

View file

@ -944,7 +944,7 @@ class TestDomainRequestAdminForm(TestCase):
self.assertIn("rejection_reason", form.errors) self.assertIn("rejection_reason", form.errors)
rejection_reason = form.errors.get("rejection_reason") 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): def test_form_choices_when_no_instance(self):
with less_console_noise(): with less_console_noise():
@ -1596,6 +1596,24 @@ class TestDomainRequestAdmin(MockEppLib):
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) 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) @override_settings(IS_PRODUCTION=True)
def test_save_model_sends_submitted_email_with_bcc_on_prod(self): def test_save_model_sends_submitted_email_with_bcc_on_prod(self):
"""When transitioning to submitted from started or withdrawn on a domain request, """When transitioning to submitted from started or withdrawn on a domain request,
@ -1911,7 +1929,7 @@ class TestDomainRequestAdmin(MockEppLib):
messages.error.assert_called_once_with( messages.error.assert_called_once_with(
request, request,
"A rejection reason is required.", "A reason is required for this status.",
) )
domain_request.refresh_from_db() domain_request.refresh_from_db()
@ -2161,15 +2179,15 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertContains(response, "testy@town.com", count=2) self.assertContains(response, "testy@town.com", count=2)
expected_ao_fields = [ expected_ao_fields = [
# Field, expected value # Field, expected value
("title", "Chief Tester"),
("phone", "(555) 555 5555"), ("phone", "(555) 555 5555"),
] ]
self.test_helper.assert_response_contains_distinct_values(response, expected_ao_fields) 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 == # # == Test the other_employees field == #
self.assertContains(response, "testy2@town.com", count=2) self.assertContains(response, "testy2@town.com")
expected_other_employees_fields = [ expected_other_employees_fields = [
# Field, expected value # Field, expected value
("title", "Another Tester"), ("title", "Another Tester"),
@ -2290,6 +2308,7 @@ class TestDomainRequestAdmin(MockEppLib):
"status", "status",
"rejection_reason", "rejection_reason",
"action_needed_reason", "action_needed_reason",
"action_needed_reason_email",
"federal_agency", "federal_agency",
"portfolio", "portfolio",
"creator", "creator",

View file

@ -2,6 +2,7 @@ import copy
from datetime import date, datetime, time from datetime import date, datetime, time
from django.core.management import call_command from django.core.management import call_command
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from registrar.utility.constants import BranchChoices
from django.utils import timezone from django.utils import timezone
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
import logging import logging
@ -1112,3 +1113,115 @@ class TestImportTables(TestCase):
# Check that logger.error was called with the correct message # 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.") 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)

View file

@ -3,6 +3,8 @@ from django.db.utils import IntegrityError
from unittest.mock import patch from unittest.mock import patch
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import RequestFactory
from registrar.models import ( from registrar.models import (
Contact, Contact,
DomainRequest, DomainRequest,
@ -1650,6 +1652,7 @@ class TestDomainInformationCustomSave(TestCase):
class TestDomainRequestIncomplete(TestCase): class TestDomainRequestIncomplete(TestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.factory = RequestFactory()
username = "test_user" username = "test_user"
first_name = "First" first_name = "First"
last_name = "Last" last_name = "Last"
@ -2053,7 +2056,10 @@ class TestDomainRequestIncomplete(TestCase):
self.assertFalse(self.domain_request._is_policy_acknowledgement_complete()) self.assertFalse(self.domain_request._is_policy_acknowledgement_complete())
def test_form_complete(self): def test_form_complete(self):
self.assertTrue(self.domain_request._form_complete()) request = self.factory.get("/")
request.user = self.user
self.assertTrue(self.domain_request._form_complete(request))
self.domain_request.generic_org_type = None self.domain_request.generic_org_type = None
self.domain_request.save() self.domain_request.save()
self.assertFalse(self.domain_request._form_complete()) self.assertFalse(self.domain_request._form_complete(request))

View file

@ -101,7 +101,7 @@ class FSMDomainRequestError(Exception):
FSMErrorCodes.NO_INVESTIGATOR: ("Investigator is required for this status."), FSMErrorCodes.NO_INVESTIGATOR: ("Investigator is required for this status."),
FSMErrorCodes.INVESTIGATOR_NOT_STAFF: ("Investigator is not a staff user."), FSMErrorCodes.INVESTIGATOR_NOT_STAFF: ("Investigator is not a staff user."),
FSMErrorCodes.INVESTIGATOR_NOT_SUBMITTER: ("Only the assigned investigator can make this change."), 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."), FSMErrorCodes.NO_ACTION_NEEDED_REASON: ("A reason is required for this status."),
} }

View file

@ -383,7 +383,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
has_profile_flag = flag_is_active(self.request, "profile_feature") has_profile_flag = flag_is_active(self.request, "profile_feature")
context_stuff = {} context_stuff = {}
if DomainRequest._form_complete(self.domain_request): if DomainRequest._form_complete(self.domain_request, self.request):
modal_button = '<button type="submit" ' 'class="usa-button" ' ">Submit request</button>" modal_button = '<button type="submit" ' 'class="usa-button" ' ">Submit request</button>"
context_stuff = { context_stuff = {
"not_form": False, "not_form": False,
@ -695,7 +695,7 @@ class Review(DomainRequestWizard):
forms = [] # type: ignore forms = [] # type: ignore
def get_context_data(self): def get_context_data(self):
if DomainRequest._form_complete(self.domain_request) is False: if DomainRequest._form_complete(self.domain_request, self.request) is False:
logger.warning("User arrived at review page with an incomplete form.") logger.warning("User arrived at review page with an incomplete form.")
context = super().get_context_data() context = super().get_context_data()
context["Step"] = Step.__members__ context["Step"] = Step.__members__