diff --git a/docs/developer/management_script_helpers.md b/docs/developer/management_script_helpers.md new file mode 100644 index 000000000..104e4dc13 --- /dev/null +++ b/docs/developer/management_script_helpers.md @@ -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) \ No newline at end of file diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index 17aa9c606..75f2f27a0 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -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 diff --git a/src/registrar/admin.py b/src/registrar/admin.py index d1cb287a3..eee2eda2f 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -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 @@ -1689,6 +1690,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 +1702,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 +1906,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 diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 524cfe594..140421db5 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -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); + } + +})(); diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 360055d91..aa8020ede 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -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; +} diff --git a/src/registrar/management/commands/populate_verification_type.py b/src/registrar/management/commands/populate_verification_type.py index b61521977..30cccfc15 100644 --- a/src/registrar/management/commands/populate_verification_type.py +++ b/src/registrar/management/commands/populate_verification_type.py @@ -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}" ) diff --git a/src/registrar/management/commands/transfer_federal_agency_type.py b/src/registrar/management/commands/transfer_federal_agency_type.py new file mode 100644 index 000000000..ca4360527 --- /dev/null +++ b/src/registrar/management/commands/transfer_federal_agency_type.py @@ -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 diff --git a/src/registrar/management/commands/utility/terminal_helper.py b/src/registrar/management/commands/utility/terminal_helper.py index db3e4a9d3..82821bd70 100644 --- a/src/registrar/management/commands/utility/terminal_helper.py +++ b/src/registrar/management/commands/utility/terminal_helper.py @@ -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( diff --git a/src/registrar/migrations/0107_domainrequest_action_needed_reason_email.py b/src/registrar/migrations/0107_domainrequest_action_needed_reason_email.py new file mode 100644 index 000000000..80caea245 --- /dev/null +++ b/src/registrar/migrations/0107_domainrequest_action_needed_reason_email.py @@ -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), + ), + ] diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index cb778c90a..5b01ae681 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -18,6 +18,8 @@ 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__) @@ -295,6 +297,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, @@ -1180,19 +1187,21 @@ class DomainRequest(TimeStampedModel): def _is_policy_acknowledgement_complete(self): 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 ( self._is_organization_name_and_address_complete() and self._is_authorizing_official_complete() and self._is_requested_domain_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_additional_details_complete() and self._is_policy_acknowledgement_complete() ) - def _form_complete(self): + def _form_complete(self, request): match self.generic_org_type: case DomainRequest.OrganizationChoices.FEDERAL: is_complete = self._is_federal_complete() @@ -1213,8 +1222,6 @@ class DomainRequest(TimeStampedModel): case _: # NOTE: Shouldn't happen, this is only if somehow they didn't choose an org type is_complete = False - - if not is_complete or not self._is_general_form_complete(): + if not is_complete or not self._is_general_form_complete(request): return False - return True diff --git a/src/registrar/templates/django/admin/domain_request_change_form.html b/src/registrar/templates/django/admin/domain_request_change_form.html index 1c8ce2633..a7d59d22c 100644 --- a/src/registrar/templates/django/admin/domain_request_change_form.html +++ b/src/registrar/templates/django/admin/domain_request_change_form.html @@ -5,7 +5,9 @@ {% block field_sets %} {# Create an invisible tag so that we can use a click event to toggle the modal. #} - + {# Store the current object id so we can access it easier #} + + {% for fieldset in adminform %} {% comment %} TODO: this will eventually need to be changed to something like this diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index 0f4274802..284dd9217 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -62,17 +62,18 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endwith %} {% else %} -
{{ field.contents }}
+
{{ field.contents }}
{% endif %} {% endwith %} {% endblock field_readonly %} {% block after_help_text %} - {% if field.field.name == "status" and filtered_audit_log_entries %} + {% if field.field.name == "status" %}
+ +