diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 61bb151d5..df87cfee3 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1261,6 +1261,13 @@ class HostIpAdmin(AuditedAdmin, ImportExportRegistrarModelAdmin): resource_classes = [HostIpResource] model = models.HostIP + search_fields = ["host__name", "address"] + search_help_text = "Search by host name or address." + list_display = ( + "host", + "address", + ) + class ContactResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -4595,6 +4602,10 @@ class PublicContactAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): change_form_template = "django/admin/email_clipboard_change_form.html" autocomplete_fields = ["domain"] + list_display = ("registry_id", "contact_type", "domain", "name") + search_fields = ["registry_id", "domain__name", "name"] + search_help_text = "Search by registry id, domain, or name." + list_filter = ("contact_type",) def changeform_view(self, request, object_id=None, form_url="", extra_context=None): if extra_context is None: diff --git a/src/registrar/management/commands/update_default_public_contacts.py b/src/registrar/management/commands/update_default_public_contacts.py new file mode 100644 index 000000000..ac8c542db --- /dev/null +++ b/src/registrar/management/commands/update_default_public_contacts.py @@ -0,0 +1,105 @@ +import logging +import argparse +from django.core.management import BaseCommand +from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors +from registrar.models import PublicContact +from registrar.models.utility.generic_helper import normalize_string +from registrar.utility.enums import DefaultEmail + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand, PopulateScriptTemplate): + help = "Loops through each default PublicContact and updates some values on each" + + def add_arguments(self, parser): + """Adds command line arguments""" + parser.add_argument( + "--overwrite_updated_contacts", + action=argparse.BooleanOptionalAction, + help=( + "Loops over PublicContacts with an email of 'help@get.gov' when enabled." + "Use this setting if the record was updated in the DB but not correctly in EPP." + ), + ) + + parser.add_argument( + "--target_domain", + help=( + "Updates the public contact on a given domain name (case insensitive). " + "Use this option to avoid doing a mass-update of every public contact record." + ), + ) + + def handle(self, **kwargs): + """Loops through each valid User object and updates its verification_type value""" + overwrite_updated_contacts = kwargs.get("overwrite_updated_contacts") + target_domain = kwargs.get("target_domain") + default_emails = {email for email in DefaultEmail} + + # Don't update records we've already updated + if not overwrite_updated_contacts: + default_emails.remove(DefaultEmail.PUBLIC_CONTACT_DEFAULT) + + # We should only update DEFAULT records. This means that if all values are not default, + # we should skip as this could lead to data corruption. + # Since we check for all fields, we don't account for casing differences. + self.old_and_new_default_contact_values = { + "name": { + "csd/cb – attn: .gov tld", + "csd/cb – attn: cameron dixon", + "program manager", + "registry customer service", + }, + "street1": {"1110 n. glebe rd", "cisa – ngr stop 0645", "4200 wilson blvd."}, + "pc": {"22201", "20598-0645"}, + "email": default_emails, + } + if not target_domain: + filter_condition = {"email__in": default_emails} + else: + filter_condition = {"email__in": default_emails, "domain__name__iexact": target_domain} + # This variable is decorative since we are skipping bulk update + fields_to_update = ["name", "street1", "pc", "email"] + self.mass_update_records(PublicContact, filter_condition, fields_to_update, show_record_count=True) + + def bulk_update_fields(self, *args, **kwargs): + """Skip bulk update since we need to manually save each field. + Our EPP logic is tied to an override of .save(), and this also associates + with our caching logic for this area of the code. + + Since bulk update does not trigger .save() for each field, we have to + call it manually. + """ + return None + + def update_record(self, record: PublicContact): + """Defines how we update the verification_type field""" + record.name = "CSD/CB – Attn: .gov TLD" + record.street1 = "1110 N. Glebe Rd" + record.pc = "22201" + record.email = DefaultEmail.PUBLIC_CONTACT_DEFAULT + record.save() + logger.info(f"{TerminalColors.OKCYAN}Updated '{record}' in EPP.{TerminalColors.ENDC}") + + def should_skip_record(self, record) -> bool: # noqa + """Skips updating a public contact if it contains different default info.""" + if record.registry_id and len(record.registry_id) < 16: + message = ( + f"Skipping legacy verisign contact '{record}'. " + f"The registry_id field has a length less than 16 characters." + ) + logger.warning(f"{TerminalColors.YELLOW}{message}{TerminalColors.ENDC}") + return True + + for key, expected_values in self.old_and_new_default_contact_values.items(): + record_field = normalize_string(getattr(record, key)) + if record_field not in expected_values: + message = ( + f"Skipping '{record}' to avoid potential data corruption. " + f"The field '{key}' does not match the default.\n" + f"Details: DB value - {record_field}, expected value(s) - {expected_values}" + ) + logger.warning(f"{TerminalColors.YELLOW}{message}{TerminalColors.ENDC}") + return True + return False diff --git a/src/registrar/management/commands/utility/terminal_helper.py b/src/registrar/management/commands/utility/terminal_helper.py index 87d9f12e5..1fdabac11 100644 --- a/src/registrar/management/commands/utility/terminal_helper.py +++ b/src/registrar/management/commands/utility/terminal_helper.py @@ -86,7 +86,9 @@ class PopulateScriptTemplate(ABC): """ raise NotImplementedError - def mass_update_records(self, object_class, filter_conditions, fields_to_update, debug=True, verbose=False): + def mass_update_records( + self, object_class, filter_conditions, fields_to_update, debug=True, verbose=False, show_record_count=False + ): """Loops through each valid "object_class" object - specified by filter_conditions - and updates fields defined by fields_to_update using update_record. @@ -106,6 +108,9 @@ class PopulateScriptTemplate(ABC): verbose: Whether to print a detailed run summary *before* run confirmation. Default: False. + show_record_count: Whether to show a 'Record 1/10' dialog when running update. + Default: False. + Raises: NotImplementedError: If you do not define update_record before using this function. TypeError: If custom_filter is not Callable. @@ -115,14 +120,16 @@ class PopulateScriptTemplate(ABC): # apply custom filter records = self.custom_filter(records) + records_length = len(records) readable_class_name = self.get_class_name(object_class) # for use in the execution prompt. - proposed_changes = f"""==Proposed Changes== - Number of {readable_class_name} objects to change: {len(records)} - These fields will be updated on each record: {fields_to_update} - """ + proposed_changes = ( + "==Proposed Changes==\n" + f"Number of {readable_class_name} objects to change: {records_length}\n" + f"These fields will be updated on each record: {fields_to_update}" + ) if verbose: proposed_changes = f"""{proposed_changes} @@ -140,7 +147,9 @@ class PopulateScriptTemplate(ABC): to_update: List[object_class] = [] to_skip: List[object_class] = [] failed_to_update: List[object_class] = [] - for record in records: + for i, record in enumerate(records, start=1): + if show_record_count: + logger.info(f"{TerminalColors.BOLD}Record {i}/{records_length}{TerminalColors.ENDC}") try: if not self.should_skip_record(record): self.update_record(record) @@ -154,7 +163,7 @@ class PopulateScriptTemplate(ABC): logger.error(fail_message) # Do a bulk update on the desired field - ScriptDataHelper.bulk_update_fields(object_class, to_update, fields_to_update) + self.bulk_update_fields(object_class, to_update, fields_to_update) # Log what happened TerminalHelper.log_script_run_summary( @@ -166,6 +175,10 @@ class PopulateScriptTemplate(ABC): display_as_str=True, ) + def bulk_update_fields(self, object_class, to_update, fields_to_update): + """Bulk updates the given fields""" + ScriptDataHelper.bulk_update_fields(object_class, to_update, fields_to_update) + def get_class_name(self, sender) -> str: """Returns the class name that we want to display for the terminal prompt. Example: DomainRequest => "Domain Request" @@ -463,4 +476,4 @@ class TerminalHelper: terminal_color = color colored_message = f"{terminal_color}{message}{TerminalColors.ENDC}" - log_method(colored_message, exc_info=exc_info) + return log_method(colored_message, exc_info=exc_info) diff --git a/src/registrar/models/public_contact.py b/src/registrar/models/public_contact.py index 58ad5be92..fc0130c54 100644 --- a/src/registrar/models/public_contact.py +++ b/src/registrar/models/public_contact.py @@ -164,4 +164,4 @@ class PublicContact(TimeStampedModel): return cls._meta.get_field("registry_id").max_length def __str__(self): - return f"{self.name} <{self.email}>" f"id: {self.registry_id} " f"type: {self.contact_type}" + return self.registry_id diff --git a/src/registrar/templates/domain_request_requirements.html b/src/registrar/templates/domain_request_requirements.html index 4d49b235e..4625162ea 100644 --- a/src/registrar/templates/domain_request_requirements.html +++ b/src/registrar/templates/domain_request_requirements.html @@ -49,7 +49,7 @@

Domain renewal

-

.Gov domains are registered for a one-year period. To renew your domain, you'll be asked to verify your organization’s eligibility and your contact information.

+

.Gov domains are registered for a one-year period. To renew the domain, you’ll be asked to verify your contact information and some details about the domain.

Though a domain may expire, it will not automatically be put on hold or deleted. We’ll make extensive efforts to contact your organization before holding or deleting a domain.

{% endblock %} diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 220dbb198..3d9eaa410 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1931,7 +1931,14 @@ class MockEppLib(TestCase): return MagicMock(res_data=[mocked_result]) def mockCreateContactCommands(self, _request, cleaned): - if getattr(_request, "id", None) == "fail" and self.mockedSendFunction.call_count == 3: + ids_to_throw_already_exists = [ + "failAdmin1234567", + "failTech12345678", + "failSec123456789", + "failReg123456789", + "fail", + ] + if getattr(_request, "id", None) in ids_to_throw_already_exists and self.mockedSendFunction.call_count == 3: # use this for when a contact is being updated # sets the second send() to fail raise RegistryError(code=ErrorCode.OBJECT_EXISTS) @@ -1946,7 +1953,14 @@ class MockEppLib(TestCase): return MagicMock(res_data=[self.mockDataInfoHosts]) def mockDeleteContactCommands(self, _request, cleaned): - if getattr(_request, "id", None) == "fail": + ids_to_throw_already_exists = [ + "failAdmin1234567", + "failTech12345678", + "failSec123456789", + "failReg123456789", + "fail", + ] + if getattr(_request, "id", None) in ids_to_throw_already_exists: raise RegistryError(code=ErrorCode.OBJECT_EXISTS) else: return MagicMock( diff --git a/src/registrar/tests/test_management_scripts.py b/src/registrar/tests/test_management_scripts.py index 110feea85..da74a8482 100644 --- a/src/registrar/tests/test_management_scripts.py +++ b/src/registrar/tests/test_management_scripts.py @@ -32,6 +32,7 @@ from registrar.models import ( Portfolio, Suborganization, ) +from registrar.utility.enums import DefaultEmail import tablib from unittest.mock import patch, call, MagicMock, mock_open from epplibwrapper import commands, common @@ -2506,3 +2507,189 @@ class TestRemovePortfolios(TestCase): # Check that the portfolio was deleted self.assertFalse(Portfolio.objects.filter(organization_name="Test with suborg").exists()) + + +class TestUpdateDefaultPublicContacts(MockEppLib): + """Tests for the update_default_public_contacts management command.""" + + @less_console_noise_decorator + def setUp(self): + """Setup test data with PublicContact records.""" + super().setUp() + self.domain_request = completed_domain_request( + name="testdomain.gov", + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + ) + self.domain_request.approve() + self.domain = self.domain_request.approved_domain + + # 1. PublicContact with all old default values + self.old_default_contact = self.domain.get_default_administrative_contact() + self.old_default_contact.registry_id = "failAdmin1234567" + self.old_default_contact.name = "CSD/CB – ATTN: Cameron Dixon" + self.old_default_contact.street1 = "CISA – NGR STOP 0645" + self.old_default_contact.pc = "20598-0645" + self.old_default_contact.email = DefaultEmail.OLD_PUBLIC_CONTACT_DEFAULT + self.old_default_contact.save() + + # 2. PublicContact with current default email but old values for other fields + self.mixed_default_contact = self.domain.get_default_technical_contact() + self.mixed_default_contact.registry_id = "failTech12345678" + self.mixed_default_contact.save(skip_epp_save=True) + self.mixed_default_contact.name = "registry customer service" + self.mixed_default_contact.street1 = "4200 Wilson Blvd." + self.mixed_default_contact.pc = "22201" + self.mixed_default_contact.email = DefaultEmail.PUBLIC_CONTACT_DEFAULT + self.mixed_default_contact.save() + + # 3. PublicContact with non-default values + self.non_default_contact = self.domain.get_default_security_contact() + self.non_default_contact.registry_id = "failSec123456789" + self.non_default_contact.domain = self.domain + self.non_default_contact.save(skip_epp_save=True) + self.non_default_contact.name = "Hotdogs" + self.non_default_contact.street1 = "123 hotdog town" + self.non_default_contact.pc = "22111" + self.non_default_contact.email = "thehotdogman@igorville.gov" + self.non_default_contact.save() + + # 4. Create a default contact but with an old email + self.default_registrant_old_email = self.domain.get_default_registrant_contact() + self.default_registrant_old_email.registry_id = "failReg123456789" + self.default_registrant_old_email.email = DefaultEmail.LEGACY_DEFAULT + self.default_registrant_old_email.save() + DF = common.DiscloseField + excluded_disclose_fields = {DF.NOTIFY_EMAIL, DF.VAT, DF.IDENT} + self.all_disclose_fields = {field for field in DF} - excluded_disclose_fields + + def tearDown(self): + """Clean up test data.""" + super().tearDown() + PublicContact.objects.all().delete() + Domain.objects.all().delete() + DomainRequest.objects.all().delete() + DomainInformation.objects.all().delete() + User.objects.all().delete() + + @patch("registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", return_value=True) + @less_console_noise_decorator + def run_update_default_public_contacts(self, mock_prompt, **kwargs): + """Execute the update_default_public_contacts command with options.""" + call_command("update_default_public_contacts", **kwargs) + + # @less_console_noise_decorator + def test_updates_old_default_contact(self): + """ + Test that contacts with old default values are updated to new default values. + Also tests for string normalization. + """ + self.run_update_default_public_contacts() + self.old_default_contact.refresh_from_db() + + # Verify updates occurred + self.assertEqual(self.old_default_contact.name, "CSD/CB – Attn: .gov TLD") + self.assertEqual(self.old_default_contact.street1, "1110 N. Glebe Rd") + self.assertEqual(self.old_default_contact.pc, "22201") + self.assertEqual(self.old_default_contact.email, DefaultEmail.PUBLIC_CONTACT_DEFAULT) + + # Verify EPP create/update calls were made + expected_update = self._convertPublicContactToEpp( + self.old_default_contact, + disclose=False, + disclose_fields=self.all_disclose_fields - {"name", "email", "voice", "addr"}, + ) + self.mockedSendFunction.assert_any_call(expected_update, cleaned=True) + + @less_console_noise_decorator + def test_updates_with_default_contact_values(self): + """ + Test that contacts created from the default helper function with old email are updated. + """ + self.run_update_default_public_contacts() + self.default_registrant_old_email.refresh_from_db() + + # Verify updates occurred + self.assertEqual(self.default_registrant_old_email.name, "CSD/CB – Attn: .gov TLD") + self.assertEqual(self.default_registrant_old_email.street1, "1110 N. Glebe Rd") + self.assertEqual(self.default_registrant_old_email.pc, "22201") + self.assertEqual(self.default_registrant_old_email.email, DefaultEmail.PUBLIC_CONTACT_DEFAULT) + + # Verify values match the default + default_reg = PublicContact.get_default_registrant() + self.assertEqual(self.default_registrant_old_email.name, default_reg.name) + self.assertEqual(self.default_registrant_old_email.street1, default_reg.street1) + self.assertEqual(self.default_registrant_old_email.pc, default_reg.pc) + self.assertEqual(self.default_registrant_old_email.email, default_reg.email) + + # Verify EPP create/update calls were made + expected_update = self._convertPublicContactToEpp( + self.default_registrant_old_email, disclose=False, disclose_fields=self.all_disclose_fields + ) + self.mockedSendFunction.assert_any_call(expected_update, cleaned=True) + + @less_console_noise_decorator + def test_skips_non_default_contacts(self): + """ + Test that contacts with non-default values are skipped. + """ + original_name = self.non_default_contact.name + original_street1 = self.non_default_contact.street1 + original_pc = self.non_default_contact.pc + original_email = self.non_default_contact.email + + self.run_update_default_public_contacts() + self.non_default_contact.refresh_from_db() + + # Verify no updates occurred + self.assertEqual(self.non_default_contact.name, original_name) + self.assertEqual(self.non_default_contact.street1, original_street1) + self.assertEqual(self.non_default_contact.pc, original_pc) + self.assertEqual(self.non_default_contact.email, original_email) + + # Ensure that the update is still skipped even with the override flag + self.run_update_default_public_contacts(overwrite_updated_contacts=True) + self.non_default_contact.refresh_from_db() + + # Verify no updates occurred + self.assertEqual(self.non_default_contact.name, original_name) + self.assertEqual(self.non_default_contact.street1, original_street1) + self.assertEqual(self.non_default_contact.pc, original_pc) + self.assertEqual(self.non_default_contact.email, original_email) + + @less_console_noise_decorator + def test_skips_contacts_with_current_default_email_by_default(self): + """ + Test that contacts with the current default email are skipped when not using the override flag. + """ + # Get original values + original_name = self.mixed_default_contact.name + original_street1 = self.mixed_default_contact.street1 + + self.run_update_default_public_contacts() + self.mixed_default_contact.refresh_from_db() + + # Verify no updates occurred + self.assertEqual(self.mixed_default_contact.name, original_name) + self.assertEqual(self.mixed_default_contact.street1, original_street1) + self.assertEqual(self.mixed_default_contact.email, DefaultEmail.PUBLIC_CONTACT_DEFAULT) + + @less_console_noise_decorator + def test_updates_with_overwrite_flag(self): + """ + Test that contacts with the current default email are updated when using the override flag. + """ + # Run the command with the override flag + self.run_update_default_public_contacts(overwrite_updated_contacts=True) + self.mixed_default_contact.refresh_from_db() + + # Verify updates occurred + self.assertEqual(self.mixed_default_contact.name, "CSD/CB – Attn: .gov TLD") + self.assertEqual(self.mixed_default_contact.street1, "1110 N. Glebe Rd") + self.assertEqual(self.mixed_default_contact.pc, "22201") + self.assertEqual(self.mixed_default_contact.email, DefaultEmail.PUBLIC_CONTACT_DEFAULT) + + # Verify EPP create/update calls were made + expected_update = self._convertPublicContactToEpp( + self.mixed_default_contact, disclose=False, disclose_fields=self.all_disclose_fields + ) + self.mockedSendFunction.assert_any_call(expected_update, cleaned=True)