Merge branch 'main' into cb/3212-subissues

This commit is contained in:
Matt-Spence 2025-03-26 13:57:36 -04:00 committed by GitHub
commit 3c2e6f5c04
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 342 additions and 12 deletions

View file

@ -1261,6 +1261,13 @@ class HostIpAdmin(AuditedAdmin, ImportExportRegistrarModelAdmin):
resource_classes = [HostIpResource] resource_classes = [HostIpResource]
model = models.HostIP 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): class ContactResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the """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" change_form_template = "django/admin/email_clipboard_change_form.html"
autocomplete_fields = ["domain"] 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): def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
if extra_context is None: if extra_context is None:

View file

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

View file

@ -86,7 +86,9 @@ class PopulateScriptTemplate(ABC):
""" """
raise NotImplementedError 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 """Loops through each valid "object_class" object - specified by filter_conditions - and
updates fields defined by fields_to_update using update_record. 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. verbose: Whether to print a detailed run summary *before* run confirmation.
Default: False. Default: False.
show_record_count: Whether to show a 'Record 1/10' dialog when running update.
Default: False.
Raises: Raises:
NotImplementedError: If you do not define update_record before using this function. NotImplementedError: If you do not define update_record before using this function.
TypeError: If custom_filter is not Callable. TypeError: If custom_filter is not Callable.
@ -115,14 +120,16 @@ class PopulateScriptTemplate(ABC):
# apply custom filter # apply custom filter
records = self.custom_filter(records) records = self.custom_filter(records)
records_length = len(records)
readable_class_name = self.get_class_name(object_class) readable_class_name = self.get_class_name(object_class)
# for use in the execution prompt. # for use in the execution prompt.
proposed_changes = f"""==Proposed Changes== proposed_changes = (
Number of {readable_class_name} objects to change: {len(records)} "==Proposed Changes==\n"
These fields will be updated on each record: {fields_to_update} 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: if verbose:
proposed_changes = f"""{proposed_changes} proposed_changes = f"""{proposed_changes}
@ -140,7 +147,9 @@ class PopulateScriptTemplate(ABC):
to_update: List[object_class] = [] to_update: List[object_class] = []
to_skip: List[object_class] = [] to_skip: List[object_class] = []
failed_to_update: 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: try:
if not self.should_skip_record(record): if not self.should_skip_record(record):
self.update_record(record) self.update_record(record)
@ -154,7 +163,7 @@ class PopulateScriptTemplate(ABC):
logger.error(fail_message) logger.error(fail_message)
# Do a bulk update on the desired field # 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 # Log what happened
TerminalHelper.log_script_run_summary( TerminalHelper.log_script_run_summary(
@ -166,6 +175,10 @@ class PopulateScriptTemplate(ABC):
display_as_str=True, 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: def get_class_name(self, sender) -> str:
"""Returns the class name that we want to display for the terminal prompt. """Returns the class name that we want to display for the terminal prompt.
Example: DomainRequest => "Domain Request" Example: DomainRequest => "Domain Request"
@ -463,4 +476,4 @@ class TerminalHelper:
terminal_color = color terminal_color = color
colored_message = f"{terminal_color}{message}{TerminalColors.ENDC}" 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)

View file

@ -164,4 +164,4 @@ class PublicContact(TimeStampedModel):
return cls._meta.get_field("registry_id").max_length return cls._meta.get_field("registry_id").max_length
def __str__(self): def __str__(self):
return f"{self.name} <{self.email}>" f"id: {self.registry_id} " f"type: {self.contact_type}" return self.registry_id

View file

@ -49,7 +49,7 @@
<h2>Domain renewal</h2> <h2>Domain renewal</h2>
<p>.Gov domains are registered for a one-year period. To renew your domain, you'll be asked to verify your organizations eligibility and your contact information. </p> <p>.Gov domains are registered for a one-year period. To renew the domain, youll be asked to verify your contact information and some details about the domain.</p>
<p>Though a domain may expire, it will not automatically be put on hold or deleted. Well make extensive efforts to contact your organization before holding or deleting a domain.</p> <p>Though a domain may expire, it will not automatically be put on hold or deleted. Well make extensive efforts to contact your organization before holding or deleting a domain.</p>
{% endblock %} {% endblock %}

View file

@ -1931,7 +1931,14 @@ class MockEppLib(TestCase):
return MagicMock(res_data=[mocked_result]) return MagicMock(res_data=[mocked_result])
def mockCreateContactCommands(self, _request, cleaned): 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 # use this for when a contact is being updated
# sets the second send() to fail # sets the second send() to fail
raise RegistryError(code=ErrorCode.OBJECT_EXISTS) raise RegistryError(code=ErrorCode.OBJECT_EXISTS)
@ -1946,7 +1953,14 @@ class MockEppLib(TestCase):
return MagicMock(res_data=[self.mockDataInfoHosts]) return MagicMock(res_data=[self.mockDataInfoHosts])
def mockDeleteContactCommands(self, _request, cleaned): 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) raise RegistryError(code=ErrorCode.OBJECT_EXISTS)
else: else:
return MagicMock( return MagicMock(

View file

@ -32,6 +32,7 @@ from registrar.models import (
Portfolio, Portfolio,
Suborganization, Suborganization,
) )
from registrar.utility.enums import DefaultEmail
import tablib import tablib
from unittest.mock import patch, call, MagicMock, mock_open from unittest.mock import patch, call, MagicMock, mock_open
from epplibwrapper import commands, common from epplibwrapper import commands, common
@ -2506,3 +2507,189 @@ class TestRemovePortfolios(TestCase):
# Check that the portfolio was deleted # Check that the portfolio was deleted
self.assertFalse(Portfolio.objects.filter(organization_name="Test with suborg").exists()) 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)