mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-17 10:07:04 +02:00
Merge branch 'main' into gd/2365-add-suborg-to-domain-info
This commit is contained in:
commit
77acdc18e0
15 changed files with 641 additions and 50 deletions
65
docs/developer/management_script_helpers.md
Normal file
65
docs/developer/management_script_helpers.md
Normal 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)
|
|
@ -698,6 +698,33 @@ Example: `cf ssh getgov-za`
|
|||
|:-:|:-------------------------- |:----------------------------------------------------------------------------|
|
||||
| 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
|
||||
|
||||
### Running on sandboxes
|
||||
|
|
|
@ -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
|
||||
|
@ -1696,6 +1697,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)
|
||||
|
@ -1704,14 +1709,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.
|
||||
|
@ -1905,10 +1913,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
|
||||
|
|
|
@ -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()
|
||||
*/
|
||||
|
@ -408,13 +428,21 @@ function initializeWidgetOnList(list, parentId) {
|
|||
function moveStatusChangelog(actionNeededReasonFormGroup, statusSelect) {
|
||||
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 +546,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,10 +161,14 @@ class TerminalHelper:
|
|||
log_header = "============= FINISHED ==============="
|
||||
|
||||
# Prepare debug messages
|
||||
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: {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"),
|
||||
"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.
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -296,6 +296,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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -68,11 +68,12 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
{% 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">
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -1596,6 +1596,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,
|
||||
|
@ -2161,15 +2179,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 +2308,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"status",
|
||||
"rejection_reason",
|
||||
"action_needed_reason",
|
||||
"action_needed_reason_email",
|
||||
"federal_agency",
|
||||
"portfolio",
|
||||
"sub_organization",
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue