diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 01ed246ac..b054afb5b 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -880,6 +880,7 @@ class Domain(TimeStampedModel, DomainHelper): which inturn call this function) Will throw error if contact type is not the same as expectType Raises ValueError if expected type doesn't match the contact type""" + if expectedType != contact.contact_type: raise ValueError("Cannot set a contact with a different contact type, expected type was %s" % expectedType) @@ -892,7 +893,6 @@ class Domain(TimeStampedModel, DomainHelper): duplicate_contacts = PublicContact.objects.exclude(registry_id=contact.registry_id).filter( domain=self, contact_type=contact.contact_type ) - # if no record exists with this contact type # make contact in registry, duplicate and errors handled there errorCode = self._make_contact_in_registry(contact) @@ -971,6 +971,24 @@ class Domain(TimeStampedModel, DomainHelper): logger.info("making technical contact") self._set_singleton_contact(contact, expectedType=contact.ContactTypeChoices.TECHNICAL) + def print_contact_info_epp(self, contact: PublicContact): + """Prints registry data for this PublicContact for easier debugging""" + results = self._request_contact_info(contact, get_result_as_dict=True) + logger.info("---------------------") + logger.info(f"EPP info for {contact.contact_type}:") + logger.info("---------------------") + for key, value in results.items(): + logger.info(f"{key}: {value}") + + def print_all_domain_contact_info_epp(self): + """Prints registry data for this domains security, registrant, technical, and administrative contacts.""" + logger.info(f"Contact info for {self}:") + logger.info("=====================") + contacts = [self.security_contact, self.registrant_contact, self.technical_contact, self.administrative_contact] + for contact in contacts: + if contact: + self.print_contact_info_epp(contact) + def is_active(self) -> bool: """Currently just returns if the state is created, because then it should be live, theoretically. @@ -1351,10 +1369,14 @@ class Domain(TimeStampedModel, DomainHelper): ) return street_dict - def _request_contact_info(self, contact: PublicContact): + def _request_contact_info(self, contact: PublicContact, get_result_as_dict=False): + """Grabs the resultant contact information in epp for this public contact + by using the InfoContact command. + Returns a commands.InfoContactResultData object, or a dict if get_result_as_dict is True.""" try: req = commands.InfoContact(id=contact.registry_id) - return registry.send(req, cleaned=True).res_data[0] + result = registry.send(req, cleaned=True).res_data[0] + return result if not get_result_as_dict else vars(result) except RegistryError as error: logger.error( "Registry threw error for contact id %s contact type is %s, error code is\n %s full error is %s", # noqa @@ -1674,22 +1696,26 @@ class Domain(TimeStampedModel, DomainHelper): return help_text def _disclose_fields(self, contact: PublicContact): - """creates a disclose object that can be added to a contact Create using + """creates a disclose object that can be added to a contact Create or Update using .disclose= on the command before sending. if item is security email then make sure email is visible""" - is_security = contact.contact_type == contact.ContactTypeChoices.SECURITY + # You can find each enum here: + # https://github.com/cisagov/epplib/blob/master/epplib/models/common.py#L32 DF = epp.DiscloseField - fields = {DF.EMAIL} + all_disclose_fields = {field for field in DF} + disclose_args = {"fields": all_disclose_fields, "flag": False, "types": {DF.ADDR: "loc"}} - hidden_security_emails = [DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, DefaultEmail.LEGACY_DEFAULT.value] - disclose = is_security and contact.email not in hidden_security_emails - # Delete after testing on other devices - logger.info("Updated domain contact %s to disclose: %s", contact.email, disclose) - # Will only disclose DF.EMAIL if its not the default - return epp.Disclose( - flag=disclose, - fields=fields, - ) + fields_to_remove = {DF.NOTIFY_EMAIL, DF.VAT, DF.IDENT} + if contact.contact_type == contact.ContactTypeChoices.SECURITY: + if contact.email not in DefaultEmail.get_all_emails(): + fields_to_remove.add(DF.EMAIL) + elif contact.contact_type == contact.ContactTypeChoices.ADMINISTRATIVE: + fields_to_remove.update({DF.EMAIL, DF.VOICE, DF.ADDR}) + + disclose_args["fields"].difference_update(fields_to_remove) # type: ignore + + logger.debug("Updated domain contact %s to disclose: %s", contact.email, disclose_args.get("flag")) + return epp.Disclose(**disclose_args) # type: ignore def _make_epp_contact_postal_info(self, contact: PublicContact): # type: ignore return epp.PostalInfo( # type: ignore diff --git a/src/registrar/models/public_contact.py b/src/registrar/models/public_contact.py index 71ed07de5..58ad5be92 100644 --- a/src/registrar/models/public_contact.py +++ b/src/registrar/models/public_contact.py @@ -1,3 +1,4 @@ +import logging from datetime import datetime from random import choices from string import ascii_uppercase, ascii_lowercase, digits @@ -9,6 +10,9 @@ from registrar.utility.enums import DefaultEmail from .utility.time_stamped_model import TimeStampedModel +logger = logging.getLogger(__name__) + + def get_id(): """Generate a 16 character registry ID with a low probability of collision.""" day = datetime.today().strftime("%A")[:2] @@ -92,15 +96,14 @@ class PublicContact(TimeStampedModel): return cls( contact_type=PublicContact.ContactTypeChoices.REGISTRANT, registry_id=get_id(), - name="CSD/CB – Attn: Cameron Dixon", + name="CSD/CB – Attn: .gov TLD", org="Cybersecurity and Infrastructure Security Agency", - street1="CISA – NGR STOP 0645", - street2="1110 N. Glebe Rd.", + street1="1110 N. Glebe Rd", city="Arlington", sp="VA", - pc="20598-0645", + pc="22201", cc="US", - email=DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, + email=DefaultEmail.PUBLIC_CONTACT_DEFAULT, voice="+1.8882820870", pw="thisisnotapassword", ) @@ -110,14 +113,14 @@ class PublicContact(TimeStampedModel): return cls( contact_type=PublicContact.ContactTypeChoices.ADMINISTRATIVE, registry_id=get_id(), - name="Program Manager", + name="CSD/CB – Attn: .gov TLD", org="Cybersecurity and Infrastructure Security Agency", - street1="4200 Wilson Blvd.", + street1="1110 N. Glebe Rd", city="Arlington", sp="VA", pc="22201", cc="US", - email=DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, + email=DefaultEmail.PUBLIC_CONTACT_DEFAULT, voice="+1.8882820870", pw="thisisnotapassword", ) @@ -127,14 +130,14 @@ class PublicContact(TimeStampedModel): return cls( contact_type=PublicContact.ContactTypeChoices.TECHNICAL, registry_id=get_id(), - name="Registry Customer Service", + name="CSD/CB – Attn: .gov TLD", org="Cybersecurity and Infrastructure Security Agency", - street1="4200 Wilson Blvd.", + street1="1110 N. Glebe Rd", city="Arlington", sp="VA", pc="22201", cc="US", - email=DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, + email=DefaultEmail.PUBLIC_CONTACT_DEFAULT, voice="+1.8882820870", pw="thisisnotapassword", ) @@ -144,14 +147,14 @@ class PublicContact(TimeStampedModel): return cls( contact_type=PublicContact.ContactTypeChoices.SECURITY, registry_id=get_id(), - name="Registry Customer Service", + name="CSD/CB – Attn: .gov TLD", org="Cybersecurity and Infrastructure Security Agency", - street1="4200 Wilson Blvd.", + street1="1110 N. Glebe Rd", city="Arlington", sp="VA", pc="22201", cc="US", - email=DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, + email=DefaultEmail.PUBLIC_CONTACT_DEFAULT, voice="+1.8882820870", pw="thisisnotapassword", ) diff --git a/src/registrar/templates/domain_security_email.html b/src/registrar/templates/domain_security_email.html index e74ecf709..66c9ae255 100644 --- a/src/registrar/templates/domain_security_email.html +++ b/src/registrar/templates/domain_security_email.html @@ -40,7 +40,7 @@ + >{% if form.security_email.value is None or form.security_email.value == "dotgov@cisa.dhs.gov" or form.security_email.value == "registrar@dotgov.gov" or form.security_email.value == "help@get.gov"%}Add security email{% else %}Save{% endif %} {% endblock %} {# domain_content #} diff --git a/src/registrar/templates/emails/portfolio_org_update_notification.txt b/src/registrar/templates/emails/portfolio_org_update_notification.txt new file mode 100644 index 000000000..1b9dbf2fc --- /dev/null +++ b/src/registrar/templates/emails/portfolio_org_update_notification.txt @@ -0,0 +1,34 @@ +{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} +Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first_name }}.{% endif %} + +An update was made to your .gov organization. + +ORGANIZATION: {{ portfolio }} +UPDATED BY: {{ editor.email }} +UPDATED ON: {{ date }} +INFORMATION UPDATED: {{ updated_info }} + +You can view this update in the .gov registrar . + +---------------------------------------------------------------- + +WHY DID YOU RECEIVE THIS EMAIL? +You're listed as an admin for {{ portfolio }}, so you'll receive a +notification whenever changes are made to that .gov organization. + +If you have questions or concerns, reach out to the person who made the change or reply +to this email. + +THANK YOU +.Gov helps the public identify official, trusted information. Thank you for using a .gov +domain. + +---------------------------------------------------------------- + +The .gov team +Contact us: +Learn about .gov + +The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency +(CISA) +{% endautoescape %} diff --git a/src/registrar/templates/emails/portfolio_org_update_notification_subject.txt b/src/registrar/templates/emails/portfolio_org_update_notification_subject.txt new file mode 100644 index 000000000..a30f72a54 --- /dev/null +++ b/src/registrar/templates/emails/portfolio_org_update_notification_subject.txt @@ -0,0 +1 @@ +An update was made to your .gov organization \ No newline at end of file diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index a3e2114f7..504b2b46d 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1444,10 +1444,8 @@ class MockEppLib(TestCase): ], ) - mockDefaultTechnicalContact = InfoDomainWithContacts.dummyInfoContactResultData( - "defaultTech", "dotgov@cisa.dhs.gov" - ) - mockDefaultSecurityContact = InfoDomainWithContacts.dummyInfoContactResultData("defaultSec", "dotgov@cisa.dhs.gov") + mockDefaultTechnicalContact = InfoDomainWithContacts.dummyInfoContactResultData("defaultTech", "help@get.gov") + mockDefaultSecurityContact = InfoDomainWithContacts.dummyInfoContactResultData("defaultSec", "help@get.gov") mockSecurityContact = InfoDomainWithContacts.dummyInfoContactResultData("securityContact", "security@mail.gov") mockTechnicalContact = InfoDomainWithContacts.dummyInfoContactResultData("technicalContact", "tech@mail.gov") mockAdministrativeContact = InfoDomainWithContacts.dummyInfoContactResultData("adminContact", "admin@mail.gov") @@ -1962,14 +1960,23 @@ class MockEppLib(TestCase): self.mockedSendFunction = self.mockSendPatch.start() self.mockedSendFunction.side_effect = self.mockSend - def _convertPublicContactToEpp(self, contact: PublicContact, disclose_email=False, createContact=True): + def _convertPublicContactToEpp( + self, + contact: PublicContact, + disclose=False, + createContact=True, + disclose_fields=None, + disclose_types=None, + ): DF = common.DiscloseField - fields = {DF.EMAIL} + if disclose_fields is None: + fields = {DF.NOTIFY_EMAIL, DF.VAT, DF.IDENT, DF.EMAIL} + disclose_fields = {field for field in DF} - fields - di = common.Disclose( - flag=disclose_email, - fields=fields, - ) + if disclose_types is None: + disclose_types = {DF.ADDR: "loc"} + + di = common.Disclose(flag=disclose, fields=disclose_fields, types=disclose_types) # check docs here looks like we may have more than one address field but addr = common.ContactAddr( diff --git a/src/registrar/tests/test_email_invitations.py b/src/registrar/tests/test_email_invitations.py index dfe587f08..d4ca5cdac 100644 --- a/src/registrar/tests/test_email_invitations.py +++ b/src/registrar/tests/test_email_invitations.py @@ -1,5 +1,5 @@ import unittest -from unittest.mock import patch, MagicMock +from unittest.mock import patch, MagicMock, ANY from datetime import date from registrar.models.domain import Domain from registrar.models.portfolio import Portfolio @@ -21,6 +21,7 @@ from registrar.utility.email_invitations import ( send_portfolio_invitation_remove_email, send_portfolio_member_permission_remove_email, send_portfolio_member_permission_update_email, + send_portfolio_update_emails_to_portfolio_admins, ) from api.tests.common import less_console_noise_decorator @@ -1261,3 +1262,60 @@ class SendDomainManagerRemovalEmailsToManagersTests(unittest.TestCase): self.assertTrue(result) # No emails sent, but also no failures mock_filter.assert_called_once_with(domain=self.domain) + + +class TestSendPortfolioOrganizationUpdateEmail(unittest.TestCase): + """Unit tests for send_portfolio_update_emails_to_portfolio_admins function.""" + + def setUp(self): + """Set up test data.""" + self.email = "removed.admin@example.com" + self.requestor_email = "requestor@example.com" + self.portfolio = MagicMock(spec=Portfolio, name="Portfolio") + self.portfolio.organization_name = "Test Organization" + + # Mock portfolio admin users + self.admin_user1 = MagicMock(spec=User) + self.admin_user1.email = "admin1@example.com" + + self.admin_user2 = MagicMock(spec=User) + self.admin_user2.email = "admin2@example.com" + + self.portfolio_admin1 = MagicMock(spec=UserPortfolioPermission) + self.portfolio_admin1.user = self.admin_user1 + self.portfolio_admin1.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + + self.portfolio_admin2 = MagicMock(spec=UserPortfolioPermission) + self.portfolio_admin2.user = self.admin_user2 + self.portfolio_admin2.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + + @patch("registrar.utility.email_invitations.send_templated_email") + @patch("registrar.utility.email_invitations.UserPortfolioPermission.objects.filter") + def test_send_portfolio_update_emails_to_portfolio_admins(self, mock_filter, mock_send_templated_email): + """Test send_portfolio_update_emails_to_portfolio_admins sends templated email.""" + # Mock data + editor = self.admin_user1 + updated_page = "Organization" + + mock_filter.return_value = [self.portfolio_admin1, self.portfolio_admin2] + mock_send_templated_email.return_value = None # No exception means success + + # Call function + result = send_portfolio_update_emails_to_portfolio_admins(editor, self.portfolio, updated_page) + + mock_filter.assert_called_once_with( + portfolio=self.portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + mock_send_templated_email.assert_any_call( + "emails/portfolio_org_update_notification.txt", + "emails/portfolio_org_update_notification_subject.txt", + to_address=self.admin_user1.email, + context=ANY, + ) + mock_send_templated_email.assert_any_call( + "emails/portfolio_org_update_notification.txt", + "emails/portfolio_org_update_notification_subject.txt", + to_address=self.admin_user2.email, + context=ANY, + ) + self.assertTrue(result) diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index 93072f93b..5ca32140c 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -722,6 +722,9 @@ class TestRegistrantContacts(MockEppLib): self.domain, _ = Domain.objects.get_or_create(name="security.gov") # Creates a domain with an associated contact self.domain_contact, _ = Domain.objects.get_or_create(name="freeman.gov") + 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): super().tearDown() @@ -758,7 +761,9 @@ class TestRegistrantContacts(MockEppLib): contact_type=PublicContact.ContactTypeChoices.SECURITY, ).registry_id expectedSecContact.registry_id = id - expectedCreateCommand = self._convertPublicContactToEpp(expectedSecContact, disclose_email=False) + expectedCreateCommand = self._convertPublicContactToEpp( + expectedSecContact, disclose=False, disclose_fields=self.all_disclose_fields + ) expectedUpdateDomain = commands.UpdateDomain( name=self.domain.name, add=[common.DomainContact(contact=expectedSecContact.registry_id, type="security")], @@ -788,7 +793,7 @@ class TestRegistrantContacts(MockEppLib): # self.domain.security_contact=expectedSecContact expectedSecContact.save() # no longer the default email it should be disclosed - expectedCreateCommand = self._convertPublicContactToEpp(expectedSecContact, disclose_email=True) + expectedCreateCommand = self._convertPublicContactToEpp(expectedSecContact, disclose=False) expectedUpdateDomain = commands.UpdateDomain( name=self.domain.name, add=[common.DomainContact(contact=expectedSecContact.registry_id, type="security")], @@ -813,7 +818,9 @@ class TestRegistrantContacts(MockEppLib): security_contact.registry_id = "fail" security_contact.save() self.domain.security_contact = security_contact - expectedCreateCommand = self._convertPublicContactToEpp(security_contact, disclose_email=False) + expectedCreateCommand = self._convertPublicContactToEpp( + security_contact, disclose=False, disclose_fields=self.all_disclose_fields + ) expectedUpdateDomain = commands.UpdateDomain( name=self.domain.name, add=[common.DomainContact(contact=security_contact.registry_id, type="security")], @@ -846,7 +853,7 @@ class TestRegistrantContacts(MockEppLib): new_contact.registry_id = "fail" new_contact.email = "" self.domain.security_contact = new_contact - firstCreateContactCall = self._convertPublicContactToEpp(old_contact, disclose_email=True) + firstCreateContactCall = self._convertPublicContactToEpp(old_contact, disclose=False) updateDomainAddCall = commands.UpdateDomain( name=self.domain.name, add=[common.DomainContact(contact=old_contact.registry_id, type="security")], @@ -856,7 +863,7 @@ class TestRegistrantContacts(MockEppLib): PublicContact.get_default_security().email, ) # this one triggers the fail - secondCreateContact = self._convertPublicContactToEpp(new_contact, disclose_email=True) + secondCreateContact = self._convertPublicContactToEpp(new_contact, disclose=False) updateDomainRemCall = commands.UpdateDomain( name=self.domain.name, rem=[common.DomainContact(contact=old_contact.registry_id, type="security")], @@ -864,7 +871,9 @@ class TestRegistrantContacts(MockEppLib): defaultSecID = PublicContact.objects.filter(domain=self.domain).get().registry_id default_security = PublicContact.get_default_security() default_security.registry_id = defaultSecID - createDefaultContact = self._convertPublicContactToEpp(default_security, disclose_email=False) + createDefaultContact = self._convertPublicContactToEpp( + default_security, disclose=False, disclose_fields=self.all_disclose_fields + ) updateDomainWDefault = commands.UpdateDomain( name=self.domain.name, add=[common.DomainContact(contact=defaultSecID, type="security")], @@ -892,15 +901,15 @@ class TestRegistrantContacts(MockEppLib): security_contact.email = "originalUserEmail@gmail.com" security_contact.registry_id = "fail" security_contact.save() - expectedCreateCommand = self._convertPublicContactToEpp(security_contact, disclose_email=True) + expectedCreateCommand = self._convertPublicContactToEpp(security_contact, disclose=False) expectedUpdateDomain = commands.UpdateDomain( name=self.domain.name, add=[common.DomainContact(contact=security_contact.registry_id, type="security")], ) security_contact.email = "changedEmail@email.com" security_contact.save() - expectedSecondCreateCommand = self._convertPublicContactToEpp(security_contact, disclose_email=True) - updateContact = self._convertPublicContactToEpp(security_contact, disclose_email=True, createContact=False) + expectedSecondCreateCommand = self._convertPublicContactToEpp(security_contact, disclose=False) + updateContact = self._convertPublicContactToEpp(security_contact, disclose=False, createContact=False) expected_calls = [ call(expectedCreateCommand, cleaned=True), call(expectedUpdateDomain, cleaned=True), @@ -988,9 +997,23 @@ class TestRegistrantContacts(MockEppLib): for contact in contacts: expected_contact = contact[0] actual_contact = contact[1] - is_security = expected_contact.contact_type == "security" - expectedCreateCommand = self._convertPublicContactToEpp(expected_contact, disclose_email=is_security) - # Should only be disclosed if the type is security, as the email is valid + if expected_contact.contact_type == PublicContact.ContactTypeChoices.SECURITY: + disclose_fields = self.all_disclose_fields - {"email"} + expectedCreateCommand = self._convertPublicContactToEpp( + expected_contact, disclose=False, disclose_fields=disclose_fields + ) + elif expected_contact.contact_type == PublicContact.ContactTypeChoices.ADMINISTRATIVE: + disclose_fields = self.all_disclose_fields - {"email", "voice", "addr"} + expectedCreateCommand = self._convertPublicContactToEpp( + expected_contact, + disclose=False, + disclose_fields=disclose_fields, + disclose_types={"addr": "loc"}, + ) + else: + expectedCreateCommand = self._convertPublicContactToEpp( + expected_contact, disclose=False, disclose_fields=self.all_disclose_fields + ) self.mockedSendFunction.assert_any_call(expectedCreateCommand, cleaned=True) # The emails should match on both items self.assertEqual(expected_contact.email, actual_contact.email) @@ -999,23 +1022,24 @@ class TestRegistrantContacts(MockEppLib): with less_console_noise(): domain, _ = Domain.objects.get_or_create(name="freeman.gov") dummy_contact = domain.get_default_security_contact() - test_disclose = self._convertPublicContactToEpp(dummy_contact, disclose_email=True).__dict__ - test_not_disclose = self._convertPublicContactToEpp(dummy_contact, disclose_email=False).__dict__ + test_disclose = self._convertPublicContactToEpp(dummy_contact, disclose=False).__dict__ + test_not_disclose = self._convertPublicContactToEpp(dummy_contact, disclose=False).__dict__ # Separated for linter - disclose_email_field = {common.DiscloseField.EMAIL} + disclose_email_field = self.all_disclose_fields - {common.DiscloseField.EMAIL} + DF = common.DiscloseField expected_disclose = { "auth_info": common.ContactAuthInfo(pw="2fooBAR123fooBaz"), - "disclose": common.Disclose(flag=True, fields=disclose_email_field, types=None), - "email": "dotgov@cisa.dhs.gov", + "disclose": common.Disclose(flag=False, fields=disclose_email_field, types={DF.ADDR: "loc"}), + "email": "help@get.gov", "extensions": [], "fax": None, "id": "ThIq2NcRIDN7PauO", "ident": None, "notify_email": None, "postal_info": common.PostalInfo( - name="Registry Customer Service", + name="CSD/CB – Attn: .gov TLD", addr=common.ContactAddr( - street=["4200 Wilson Blvd.", None, None], + street=["1110 N. Glebe Rd", None, None], city="Arlington", pc="22201", cc="US", @@ -1030,17 +1054,17 @@ class TestRegistrantContacts(MockEppLib): # Separated for linter expected_not_disclose = { "auth_info": common.ContactAuthInfo(pw="2fooBAR123fooBaz"), - "disclose": common.Disclose(flag=False, fields=disclose_email_field, types=None), - "email": "dotgov@cisa.dhs.gov", + "disclose": common.Disclose(flag=False, fields=disclose_email_field, types={DF.ADDR: "loc"}), + "email": "help@get.gov", "extensions": [], "fax": None, "id": "ThrECENCHI76PGLh", "ident": None, "notify_email": None, "postal_info": common.PostalInfo( - name="Registry Customer Service", + name="CSD/CB – Attn: .gov TLD", addr=common.ContactAddr( - street=["4200 Wilson Blvd.", None, None], + street=["1110 N. Glebe Rd", None, None], city="Arlington", pc="22201", cc="US", @@ -1058,6 +1082,39 @@ class TestRegistrantContacts(MockEppLib): self.assertEqual(test_disclose, expected_disclose) self.assertEqual(test_not_disclose, expected_not_disclose) + @less_console_noise_decorator + def test_convert_public_contact_with_custom_fields(self): + """Test converting a contact with custom disclosure fields.""" + domain, _ = Domain.objects.get_or_create(name="freeman.gov") + dummy_contact = domain.get_default_administrative_contact() + DF = common.DiscloseField + + # Create contact with multiple disclosure fields + result = self._convertPublicContactToEpp( + dummy_contact, + disclose=True, + disclose_fields={DF.EMAIL, DF.VOICE, DF.ADDR}, + disclose_types={}, + ) + self.assertEqual(result.disclose.flag, True) + self.assertEqual(result.disclose.fields, {DF.EMAIL, DF.VOICE, DF.ADDR}) + self.assertEqual(result.disclose.types, {}) + + @less_console_noise_decorator + def test_convert_public_contact_with_empty_fields(self): + """Test converting a contact with empty disclosure fields.""" + domain, _ = Domain.objects.get_or_create(name="freeman.gov") + dummy_contact = domain.get_default_security_contact() + + DF = common.DiscloseField + # Create contact with empty fields list + result = self._convertPublicContactToEpp(dummy_contact, disclose=True, disclose_fields={DF.EMAIL}) + + # Verify disclosure settings + self.assertEqual(result.disclose.flag, True) + self.assertEqual(result.disclose.fields, {DF.EMAIL}) + self.assertEqual(result.disclose.types, {DF.ADDR: "loc"}) + def test_not_disclosed_on_default_security_contact(self): """ Scenario: Registrant creates a new domain with no security email @@ -1071,7 +1128,9 @@ class TestRegistrantContacts(MockEppLib): expectedSecContact.domain = domain expectedSecContact.registry_id = "defaultSec" domain.security_contact = expectedSecContact - expectedCreateCommand = self._convertPublicContactToEpp(expectedSecContact, disclose_email=False) + expectedCreateCommand = self._convertPublicContactToEpp( + expectedSecContact, disclose=False, disclose_fields=self.all_disclose_fields + ) self.mockedSendFunction.assert_any_call(expectedCreateCommand, cleaned=True) # Confirm that we are getting a default email self.assertEqual(domain.security_contact.email, expectedSecContact.email) @@ -1089,7 +1148,9 @@ class TestRegistrantContacts(MockEppLib): expectedTechContact.domain = domain expectedTechContact.registry_id = "defaultTech" domain.technical_contact = expectedTechContact - expectedCreateCommand = self._convertPublicContactToEpp(expectedTechContact, disclose_email=False) + expectedCreateCommand = self._convertPublicContactToEpp( + expectedTechContact, disclose=False, disclose_fields=self.all_disclose_fields + ) self.mockedSendFunction.assert_any_call(expectedCreateCommand, cleaned=True) # Confirm that we are getting a default email self.assertEqual(domain.technical_contact.email, expectedTechContact.email) @@ -1108,7 +1169,7 @@ class TestRegistrantContacts(MockEppLib): expectedSecContact.domain = domain expectedSecContact.email = "security@mail.gov" domain.security_contact = expectedSecContact - expectedCreateCommand = self._convertPublicContactToEpp(expectedSecContact, disclose_email=True) + expectedCreateCommand = self._convertPublicContactToEpp(expectedSecContact, disclose=False) self.mockedSendFunction.assert_any_call(expectedCreateCommand, cleaned=True) # Confirm that we are getting the desired email self.assertEqual(domain.security_contact.email, expectedSecContact.email) diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 2dbbfb75e..f490f51ad 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -376,8 +376,8 @@ class TestPortfolio(WebTest): self.assertContains(page, "Non-Federal Agency") @less_console_noise_decorator - def test_domain_org_name_address_form(self): - """Submitting changes works on the org name address page.""" + def test_org_form_invalid_update(self): + """Organization form will not redirect on invalid formsets.""" with override_flag("organization_feature", active=True): self.app.set_user(self.user.username) portfolio_additional_permissions = [ @@ -398,11 +398,79 @@ class TestPortfolio(WebTest): self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) success_result_page = portfolio_org_name_page.form.submit() + # Form will not validate with missing required field (zipcode) self.assertEqual(success_result_page.status_code, 200) self.assertContains(success_result_page, "6 Downing st") self.assertContains(success_result_page, "London") + @less_console_noise_decorator + def test_org_form_valid_update(self): + """Organization form will redirect on valid formsets.""" + with override_flag("organization_feature", active=True): + self.app.set_user(self.user.username) + portfolio_additional_permissions = [ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.EDIT_PORTFOLIO, + ] + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=self.portfolio, additional_permissions=portfolio_additional_permissions + ) + + self.portfolio.address_line1 = "1600 Penn Ave" + self.portfolio.save() + portfolio_org_name_page = self.app.get(reverse("organization")) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + + # Form validates and redirects with all required fields + portfolio_org_name_page.form["address_line1"] = "6 Downing st" + portfolio_org_name_page.form["city"] = "London" + portfolio_org_name_page.form["zipcode"] = "11111" + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + success_result_page = portfolio_org_name_page.form.submit() + self.assertEqual(success_result_page.status_code, 302) + + @boto3_mocking.patching + @less_console_noise_decorator + @patch("registrar.views.portfolios.send_portfolio_update_emails_to_portfolio_admins") + def test_org_update_sends_admin_email(self, mock_send_organization_update_email): + """Updating organization information emails organization admin.""" + with override_flag("organization_feature", active=True): + self.app.set_user(self.user.username) + self.admin, _ = User.objects.get_or_create( + email="mayor@igorville.com", first_name="Hello", last_name="World" + ) + + portfolio_additional_permissions = [ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.EDIT_PORTFOLIO, + ] + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=self.portfolio, additional_permissions=portfolio_additional_permissions + ) + portfolio_permission_admin, _ = UserPortfolioPermission.objects.get_or_create( + user=self.admin, + portfolio=self.portfolio, + additional_permissions=portfolio_additional_permissions, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + ) + + self.portfolio.address_line1 = "1600 Penn Ave" + self.portfolio.save() + portfolio_org_name_page = self.app.get(reverse("organization")) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + portfolio_org_name_page.form["address_line1"] = "6 Downing st" + portfolio_org_name_page.form["city"] = "London" + portfolio_org_name_page.form["zipcode"] = "11111" + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + success_result_page = portfolio_org_name_page.form.submit() + self.assertEqual(success_result_page.status_code, 302) + + # Verify that the notification emails were sent to domain manager + mock_send_organization_update_email.assert_called_once() + @less_console_noise_decorator def test_portfolio_in_session_when_organization_feature_active(self): """When organization_feature flag is true and user has a portfolio, diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index cde91baca..5895a0c50 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -740,7 +740,7 @@ class DomainExport(BaseExport): domain_type = f"{human_readable_domain_org_type} - {human_readable_domain_federal_type}" security_contact_email = model.get("security_contact_email") - invalid_emails = {DefaultEmail.LEGACY_DEFAULT.value, DefaultEmail.PUBLIC_CONTACT_DEFAULT.value} + invalid_emails = DefaultEmail.get_all_emails() if ( not security_contact_email or not isinstance(security_contact_email, str) diff --git a/src/registrar/utility/email_invitations.py b/src/registrar/utility/email_invitations.py index 0dcfa5bf9..3c13f6aef 100644 --- a/src/registrar/utility/email_invitations.py +++ b/src/registrar/utility/email_invitations.py @@ -280,6 +280,56 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio, is_admin_i return all_admin_emails_sent +def send_portfolio_update_emails_to_portfolio_admins(editor, portfolio, updated_page): + """ + Sends an email notification to all portfolio admin when portfolio organization is updated. + + Raises exceptions for validation or email-sending issues. + + Args: + editor (User): The user editing the portfolio organization. + portfolio (Portfolio): The portfolio object whose organization information is changed. + + Returns: + Boolean indicating if all messages were sent successfully. + + Raises: + MissingEmailError: If the requestor has no email associated with their account. + EmailSendingError: If there is an error while sending the email. + """ + all_emails_sent = True + # Get each portfolio admin from list + user_portfolio_permissions = UserPortfolioPermission.objects.filter( + portfolio=portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + for user_portfolio_permission in user_portfolio_permissions: + # Send email to each portfolio_admin + user = user_portfolio_permission.user + try: + send_templated_email( + "emails/portfolio_org_update_notification.txt", + "emails/portfolio_org_update_notification_subject.txt", + to_address=user.email, + context={ + "requested_user": user, + "portfolio": portfolio, + "editor": editor, + "portfolio_admin": user, + "date": date.today(), + "updated_info": updated_page, + }, + ) + except EmailSendingError: + logger.warning( + "Could not send email organization admin notification to %s " "for portfolio: %s", + user.email, + portfolio, + exc_info=True, + ) + all_emails_sent = False + return all_emails_sent + + def send_portfolio_member_permission_update_email(requestor, permissions: UserPortfolioPermission): """ Sends an email notification to a portfolio member when their permissions are updated. diff --git a/src/registrar/utility/enums.py b/src/registrar/utility/enums.py index 47e6da47f..6c2649eec 100644 --- a/src/registrar/utility/enums.py +++ b/src/registrar/utility/enums.py @@ -29,18 +29,26 @@ class LogCode(Enum): DEFAULT = 5 -class DefaultEmail(Enum): +class DefaultEmail(StrEnum): """Stores the string values of default emails Overview of emails: - - PUBLIC_CONTACT_DEFAULT: "dotgov@cisa.dhs.gov" + - PUBLIC_CONTACT_DEFAULT: "help@get.gov" + - OLD_PUBLIC_CONTACT_DEFAULT: "dotgov@cisa.dhs.gov" - LEGACY_DEFAULT: "registrar@dotgov.gov" - - HELP_EMAIL: "help@get.gov" """ - PUBLIC_CONTACT_DEFAULT = "dotgov@cisa.dhs.gov" + PUBLIC_CONTACT_DEFAULT = "help@get.gov" + # We used to use this email for default public contacts. + # This is retained for data correctness, but it will be phased out. + # help@get.gov is the current email that we use for these now. + OLD_PUBLIC_CONTACT_DEFAULT = "dotgov@cisa.dhs.gov" LEGACY_DEFAULT = "registrar@dotgov.gov" + @classmethod + def get_all_emails(cls): + return [email for email in cls] + class DefaultUserValues(StrEnum): """Stores default values for a default user. diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 0e5350e93..5cf296517 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -402,7 +402,7 @@ class DomainView(DomainBaseView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - default_emails = [DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, DefaultEmail.LEGACY_DEFAULT.value] + default_emails = DefaultEmail.get_all_emails() context["hidden_security_emails"] = default_emails @@ -460,7 +460,7 @@ class DomainRenewalView(DomainBaseView): context = super().get_context_data(**kwargs) - default_emails = [DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, DefaultEmail.LEGACY_DEFAULT.value] + default_emails = DefaultEmail.get_all_emails() context["hidden_security_emails"] = default_emails @@ -1170,7 +1170,7 @@ class DomainSecurityEmailView(DomainFormBaseView): initial = super().get_initial() security_contact = self.object.security_contact - invalid_emails = [DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, DefaultEmail.LEGACY_DEFAULT.value] + invalid_emails = DefaultEmail.get_all_emails() if security_contact is None or security_contact.email in invalid_emails: initial["security_email"] = None return initial diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 225173b8d..952793084 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -36,6 +36,7 @@ from registrar.utility.email_invitations import ( send_portfolio_invitation_remove_email, send_portfolio_member_permission_remove_email, send_portfolio_member_permission_update_email, + send_portfolio_update_emails_to_portfolio_admins, ) from registrar.utility.errors import MissingEmailError from registrar.utility.enums import DefaultUserValues @@ -930,6 +931,20 @@ class PortfolioOrganizationView(DetailView, FormMixin): self.object = self.get_object() form = self.get_form() if form.is_valid(): + user = request.user + try: + if not send_portfolio_update_emails_to_portfolio_admins( + editor=user, portfolio=self.request.session.get("portfolio"), updated_page="Organization" + ): + messages.warning(self.request, "Could not send email notification to all organization admins.") + except Exception as e: + messages.error( + request, + f"An unexpected error occurred: {str(e)}. If the issue persists, " + f"please contact {DefaultUserValues.HELP_EMAIL}.", + ) + logger.error(f"An unexpected error occurred: {str(e)}.", exc_info=True) + return None return self.form_valid(form) else: return self.form_invalid(form) @@ -982,6 +997,45 @@ class PortfolioSeniorOfficialView(DetailView, FormMixin): form = self.get_form() return self.render_to_response(self.get_context_data(form=form)) + def post(self, request, *args, **kwargs): + """Handle POST requests to process form submission.""" + self.object = self.get_object() + form = self.get_form() + if form.is_valid(): + user = request.user + try: + if not send_portfolio_update_emails_to_portfolio_admins( + editor=user, portfolio=self.request.session.get("portfolio"), updated_page="Senior Official" + ): + messages.warning(self.request, "Could not send email notification to all organization admins.") + except Exception as e: + messages.error( + request, + f"An unexpected error occurred: {str(e)}. If the issue persists, " + f"please contact {DefaultUserValues.HELP_EMAIL}.", + ) + logger.error(f"An unexpected error occurred: {str(e)}.", exc_info=True) + return None + return self.form_valid(form) + else: + return self.form_invalid(form) + + def form_valid(self, form): + """Handle the case when the form is valid.""" + self.object = form.save(commit=False) + self.object.creator = self.request.user + self.object.save() + messages.success(self.request, "The senior official information for this portfolio has been updated.") + return super().form_valid(form) + + def form_invalid(self, form): + """Handle the case when the form is invalid.""" + return self.render_to_response(self.get_context_data(form=form)) + + def get_success_url(self): + """Redirect to the overview page for the portfolio.""" + return reverse("senior-official") + @grant_access(HAS_PORTFOLIO_MEMBERS_ANY_PERM) class PortfolioMembersView(View):