diff --git a/.github/ISSUE_TEMPLATE/design-issue.yml b/.github/ISSUE_TEMPLATE/design-issue.yml new file mode 100644 index 000000000..558c34c2d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/design-issue.yml @@ -0,0 +1,76 @@ +name: Design Issue / story +description: Specifically for design and content tickets +labels: design, content + +body: + - type: markdown + id: title-help + attributes: + value: | + > Titles should be short, descriptive, and compelling. Use sentence case: don't capitalize words unnecessarily. + - type: textarea + id: issue-description + attributes: + label: Issue description + description: | + Describe the issue so that someone who wasn't present for its discovery can understand why it matters. For stories, use the user story format (e.g., As a user, I want, so that). Use full sentences, plain language, and [good formatting](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax). + validations: + required: true + - type: markdown + id: acceptance-criteria-design + attributes: + value: | + ### Acceptance criteria for design updates + - [ ] Used components from the Figma design system wherein possible + - [ ] Merged draft design(s) into the official [registrar](https://www.figma.com/design/xFtGLHVrhp0lvwh0gYWnbx/.gov-registar?m=auto) or [get.gov](https://www.figma.com/design/qeWM03sfjXgHBHB23rsD6X/get.gov?m=auto&t=NevyFHpikXLWEwaL-6) Figma pages + - [ ] (If applicable) Updated components from the [Figma design system](https://www.figma.com/design/G2HANRHy8pnlY5ENvmpKY8/.gov-Design-System?m=auto) if there's any inconsistencies with production +
+ - type: markdown + id: acceptance-criteria-content + attributes: + value: | + ### Acceptance criteria for content updates + - [ ] **Followed the [content guide](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?usp=sharing) instructions, including:** + - [ ] Use official terms in the [word list](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.2et92p0) wherein possible, and avoiding synonyms or alternative terms + - [ ] Use [content blocks](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.23ckvvd) whenever possible (consider creating new content blocks for new content that will be referenced heavily) + - [ ] Check for readibility using the [Hemingway Editor](https://hemingwayapp.com/) + - [ ] Any external links have an [external link icon](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.4i7ojhp) and open in a new tab + + - [ ] **Instructions specific to get.gov ([instructions for how to update the public site](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.1pxezwc)):** + - [ ] Update content in the [get.gov design file](https://www.figma.com/design/qeWM03sfjXgHBHB23rsD6X/get.gov?m=auto&t=NevyFHpikXLWEwaL-6) in Figma + - [ ] Update the [relevant pages](https://drive.google.com/drive/u/1/folders/1kPsaM6wTli5yjTx1k1QZNlSzYZ-usW4x) in Google Drive's Content folder + - [ ] Links [open in a new window](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.g0jcbi63s6zm) if the user will need to reference text on the public site while viewing the link + + - [ ] **Instructions specific to the manage.get.gov ([instructions for how to update the registrar](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.1hmsyys)):** + - [ ] Update content in the [registrar design file](https://www.figma.com/design/xFtGLHVrhp0lvwh0gYWnbx/.gov-registar?m=auto) in Figma + - [ ] Update the [relevant pages](https://drive.google.com/drive/folders/1dlv_w9zT-W_TStG-7icag6lqcQi86WUs?usp=drive_link) in Google Drive's Content folder + - [ ] Links [open in a new window](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.g0jcbi63s6zm) + + - [ ] **Instructions specific to emails:** + - [ ] TBD +
+ - type: textarea + id: additional-acceptance-criteria + attributes: + label: Additional acceptance criteria + description: "If known, add more statements that would need to be true for this issue to be considered resolved. Use a [task list](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/about-task-lists#creating-task-lists) if appropriate." + placeholder: "- [ ]" + - type: textarea + id: additional-context + attributes: + label: Additional context + description: "Share any other thoughts, like how this might be implemented or fixed. Screenshots and links to documents/discussions are welcome." + - type: textarea + id: links-to-other-issues + attributes: + label: Links to other issues + description: | + "Use a dash (`-`) to start the line. Add an issue by typing "`#`" then the issue number. Add information to describe any dependancies, blockers, etc. (e.g., 🚧 [construction] Blocks, ⛔️ [no_entry] Is blocked by, 🔄 [arrows_counterclockwise] Relates to). If this is a parent issue, use sub-issues instead of linking other issues here." + placeholder: "- 🔄 Relates to..." + - type: markdown + id: note + attributes: + value: | + > We may edit the text in this issue to document our understanding and clarify the product work. + + diff --git a/.github/ISSUE_TEMPLATE/issue-default.yml b/.github/ISSUE_TEMPLATE/issue-default.yml index 292d0ebec..f9a2f6260 100644 --- a/.github/ISSUE_TEMPLATE/issue-default.yml +++ b/.github/ISSUE_TEMPLATE/issue-default.yml @@ -19,7 +19,7 @@ body: id: acceptance-criteria attributes: label: Acceptance criteria - description: "If known, share 1-3 statements that would need to be true for this issue to be considered resolved. Use a [task list](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/about-task-lists#creating-task-lists) if appropriate." + description: "If known, share 1-3 statements that would need to be true for this issue to be considered resolved. Use a [task list](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/about-task-lists#creating-task-lists) if appropriate. Designers: [Additional considerations](https://github.com/cisagov/manage.get.gov/wiki/Design-work-considerations) are listed in the wiki and can be adapted to ACs here." placeholder: "- [ ]" - type: textarea id: additional-context diff --git a/src/registrar/forms/feb.py b/src/registrar/forms/feb.py index 160df88f9..2dabbff0d 100644 --- a/src/registrar/forms/feb.py +++ b/src/registrar/forms/feb.py @@ -2,6 +2,7 @@ from django import forms from django.core.validators import MaxLengthValidator from registrar.forms.utility.wizard_form_helper import BaseDeletableRegistrarForm, BaseYesNoForm + class ExecutiveNamingRequirementsYesNoForm(BaseYesNoForm, BaseDeletableRegistrarForm): """ Form for verifying if the domain request meets the Federal Executive Branch domain naming requirements. diff --git a/src/registrar/migrations/0144_domainrequest_eop_stakeholder_email_and_more.py b/src/registrar/migrations/0144_domainrequest_eop_stakeholder_email_and_more.py index 051e9a6bd..b23b6c107 100644 --- a/src/registrar/migrations/0144_domainrequest_eop_stakeholder_email_and_more.py +++ b/src/registrar/migrations/0144_domainrequest_eop_stakeholder_email_and_more.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("registrar", "142_create_groups_v18"), + ("registrar", "0143_create_groups_v18"), ] operations = [ 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/domain_manager_deleted_notification.txt b/src/registrar/templates/emails/domain_manager_deleted_notification.txt index e2a566e42..9b8424608 100644 --- a/src/registrar/templates/emails/domain_manager_deleted_notification.txt +++ b/src/registrar/templates/emails/domain_manager_deleted_notification.txt @@ -5,7 +5,7 @@ A domain manager was removed from {{ domain.name }}. REMOVED BY: {{ removed_by.email }} REMOVED ON: {{ date }} -MANAGER REMOVED: {{ manager_removed.email }} +MANAGER REMOVED: {{ manager_removed_email }} ---------------------------------------------------------------- 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 2331d35e8..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 @@ -13,13 +13,15 @@ from registrar.utility.email_invitations import ( _send_portfolio_admin_addition_emails_to_portfolio_admins, _send_portfolio_admin_removal_emails_to_portfolio_admins, send_domain_invitation_email, - send_emails_to_domain_managers, + _send_domain_invitation_update_emails_to_domain_managers, + send_domain_manager_removal_emails_to_domain_managers, send_portfolio_admin_addition_emails, send_portfolio_admin_removal_emails, send_portfolio_invitation_email, 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 @@ -33,7 +35,7 @@ class DomainInvitationEmail(unittest.TestCase): @patch("registrar.utility.email_invitations.UserDomainRole.objects.filter") @patch("registrar.utility.email_invitations._validate_invitation") @patch("registrar.utility.email_invitations._get_requestor_email") - @patch("registrar.utility.email_invitations.send_invitation_email") + @patch("registrar.utility.email_invitations._send_domain_invitation_email") @patch("registrar.utility.email_invitations._normalize_domains") def test_send_domain_invitation_email( self, @@ -98,7 +100,7 @@ class DomainInvitationEmail(unittest.TestCase): @patch("registrar.utility.email_invitations.UserDomainRole.objects.filter") @patch("registrar.utility.email_invitations._validate_invitation") @patch("registrar.utility.email_invitations._get_requestor_email") - @patch("registrar.utility.email_invitations.send_invitation_email") + @patch("registrar.utility.email_invitations._send_domain_invitation_email") @patch("registrar.utility.email_invitations._normalize_domains") def test_send_domain_invitation_email_multiple_domains( self, @@ -234,7 +236,7 @@ class DomainInvitationEmail(unittest.TestCase): @less_console_noise_decorator @patch("registrar.utility.email_invitations._validate_invitation") @patch("registrar.utility.email_invitations._get_requestor_email") - @patch("registrar.utility.email_invitations.send_invitation_email") + @patch("registrar.utility.email_invitations._send_domain_invitation_email") @patch("registrar.utility.email_invitations._normalize_domains") def test_send_domain_invitation_email_raises_sending_email_exception( self, @@ -281,10 +283,10 @@ class DomainInvitationEmail(unittest.TestCase): self.assertEqual(str(context.exception), "Error sending email") @less_console_noise_decorator - @patch("registrar.utility.email_invitations.send_emails_to_domain_managers") + @patch("registrar.utility.email_invitations._send_domain_invitation_update_emails_to_domain_managers") @patch("registrar.utility.email_invitations._validate_invitation") @patch("registrar.utility.email_invitations._get_requestor_email") - @patch("registrar.utility.email_invitations.send_invitation_email") + @patch("registrar.utility.email_invitations._send_domain_invitation_email") @patch("registrar.utility.email_invitations._normalize_domains") def test_send_domain_invitation_email_manager_emails_send_mail_exception( self, @@ -295,7 +297,7 @@ class DomainInvitationEmail(unittest.TestCase): mock_send_domain_manager_emails, ): """Test sending domain invitation email for one domain and assert exception - when send_emails_to_domain_managers fails. + when _send_domain_invitation_update_emails_to_domain_managers fails. """ # Setup mock_domain = MagicMock(name="domain1") @@ -354,7 +356,7 @@ class DomainInvitationEmail(unittest.TestCase): mock_send_templated_email.return_value = None # No exception means success # Call function - result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain) + result = _send_domain_invitation_update_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain) # Assertions self.assertTrue(result) # All emails should be successfully sent @@ -394,7 +396,7 @@ class DomainInvitationEmail(unittest.TestCase): mock_send_templated_email.side_effect = EmailSendingError("Email sending failed") # Call function - result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain) + result = _send_domain_invitation_update_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain) # Assertions self.assertFalse(result) # The result should be False as email sending failed @@ -426,7 +428,7 @@ class DomainInvitationEmail(unittest.TestCase): mock_filter.return_value = [] # Call function - result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain) + result = _send_domain_invitation_update_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain) # Assertions self.assertTrue(result) # No emails to send, so it should return True @@ -457,7 +459,7 @@ class DomainInvitationEmail(unittest.TestCase): mock_send_templated_email.side_effect = [None, EmailSendingError("Failed to send email")] # Call function - result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain) + result = _send_domain_invitation_update_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain) # Assertions self.assertFalse(result) # One email failed, so result should be False @@ -1112,3 +1114,208 @@ class TestSendPortfolioInvitationRemoveEmail(unittest.TestCase): # Assertions mock_logger.warning.assert_not_called() # Function should fail before logging email failure + + +class SendDomainManagerRemovalEmailsToManagersTests(unittest.TestCase): + """Unit tests for send_domain_manager_removal_emails_to_domain_managers function.""" + + def setUp(self): + """Set up test data.""" + self.email = "removed.admin@example.com" + self.requestor_email = "requestor@example.com" + self.domain = MagicMock(spec=Domain) + self.domain.name = "Test Domain" + + # Mock domain manager users + self.manager_user1 = MagicMock(spec=User) + self.manager_user1.email = "manager1@example.com" + + self.manager_user2 = MagicMock(spec=User) + self.manager_user2.email = "manager2@example.com" + + self.domain_manager1 = MagicMock(spec=UserDomainRole) + self.domain_manager1.user = self.manager_user1 + + self.domain_manager2 = MagicMock(spec=UserDomainRole) + self.domain_manager2.user = self.manager_user2 + + @less_console_noise_decorator + @patch("registrar.utility.email_invitations.send_templated_email") + @patch("registrar.utility.email_invitations.UserDomainRole.objects.filter") + def test_send_email_success(self, mock_filter, mock_send_templated_email): + """Test successful sending of domain manager removal emails.""" + mock_filter.return_value.exclude.return_value = [self.domain_manager1] + mock_send_templated_email.return_value = None # No exception means success + + result = send_domain_manager_removal_emails_to_domain_managers( + removed_by_user=self.manager_user1, + manager_removed=self.manager_user2, + manager_removed_email=self.manager_user2.email, + domain=self.domain, + ) + + mock_filter.assert_called_once_with(domain=self.domain) + mock_send_templated_email.assert_any_call( + "emails/domain_manager_deleted_notification.txt", + "emails/domain_manager_deleted_notification_subject.txt", + to_address=self.manager_user1.email, + context={ + "domain": self.domain, + "removed_by": self.manager_user1, + "manager_removed_email": self.manager_user2.email, + "date": date.today(), + }, + ) + self.assertTrue(result) + + @less_console_noise_decorator + @patch("registrar.utility.email_invitations.send_templated_email") + @patch("registrar.utility.email_invitations.UserDomainRole.objects.filter") + def test_send_email_success_when_no_user(self, mock_filter, mock_send_templated_email): + """Test successful sending of domain manager removal emails.""" + mock_filter.return_value = [self.domain_manager1, self.domain_manager2] + mock_send_templated_email.return_value = None # No exception means success + + result = send_domain_manager_removal_emails_to_domain_managers( + removed_by_user=self.manager_user1, + manager_removed=None, + manager_removed_email=self.manager_user2.email, + domain=self.domain, + ) + + mock_filter.assert_called_once_with(domain=self.domain) + mock_send_templated_email.assert_any_call( + "emails/domain_manager_deleted_notification.txt", + "emails/domain_manager_deleted_notification_subject.txt", + to_address=self.manager_user1.email, + context={ + "domain": self.domain, + "removed_by": self.manager_user1, + "manager_removed_email": self.manager_user2.email, + "date": date.today(), + }, + ) + mock_send_templated_email.assert_any_call( + "emails/domain_manager_deleted_notification.txt", + "emails/domain_manager_deleted_notification_subject.txt", + to_address=self.manager_user2.email, + context={ + "domain": self.domain, + "removed_by": self.manager_user1, + "manager_removed_email": self.manager_user2.email, + "date": date.today(), + }, + ) + self.assertTrue(result) + + @less_console_noise_decorator + @patch("registrar.utility.email_invitations.send_templated_email", side_effect=EmailSendingError) + @patch("registrar.utility.email_invitations.UserDomainRole.objects.filter") + def test_send_email_failure(self, mock_filter, mock_send_templated_email): + """Test handling of failure in sending admin removal emails.""" + mock_filter.return_value.exclude.return_value = [self.domain_manager1, self.domain_manager2] + + result = send_domain_manager_removal_emails_to_domain_managers( + removed_by_user=self.manager_user1, + manager_removed=self.manager_user2, + manager_removed_email=self.manager_user2.email, + domain=self.domain, + ) + + self.assertFalse(result) + mock_filter.assert_called_once_with(domain=self.domain) + mock_send_templated_email.assert_any_call( + "emails/domain_manager_deleted_notification.txt", + "emails/domain_manager_deleted_notification_subject.txt", + to_address=self.manager_user1.email, + context={ + "domain": self.domain, + "removed_by": self.manager_user1, + "manager_removed_email": self.manager_user2.email, + "date": date.today(), + }, + ) + mock_send_templated_email.assert_any_call( + "emails/domain_manager_deleted_notification.txt", + "emails/domain_manager_deleted_notification_subject.txt", + to_address=self.manager_user2.email, + context={ + "domain": self.domain, + "removed_by": self.manager_user1, + "manager_removed_email": self.manager_user2.email, + "date": date.today(), + }, + ) + + @less_console_noise_decorator + @patch("registrar.utility.email_invitations.UserDomainRole.objects.filter") + def test_no_managers_to_notify(self, mock_filter): + """Test case where there are no domain managers to notify.""" + mock_filter.return_value.exclude.return_value = [] # No managers + + result = send_domain_manager_removal_emails_to_domain_managers( + removed_by_user=self.manager_user1, + manager_removed=self.manager_user2, + manager_removed_email=self.manager_user2.email, + domain=self.domain, + ) + + 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_domain.py b/src/registrar/tests/test_views_domain.py index c2120b58f..69c376fd4 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -1084,8 +1084,8 @@ class TestDomainManagers(TestDomainOverview): @boto3_mocking.patching @less_console_noise_decorator - @patch("registrar.views.domain.send_templated_email") - def test_domain_remove_manager(self, mock_send_templated_email): + @patch("registrar.views.domain.send_domain_manager_removal_emails_to_domain_managers") + def test_domain_remove_manager(self, mock_send_email): """Removing a domain manager sends notification email to other domain managers.""" self.manager, _ = User.objects.get_or_create(email="mayor@igorville.com", first_name="Hello", last_name="World") self.manager_domain_permission, _ = UserDomainRole.objects.get_or_create(user=self.manager, domain=self.domain) @@ -1094,11 +1094,11 @@ class TestDomainManagers(TestDomainOverview): ) # Verify that the notification emails were sent to domain manager - mock_send_templated_email.assert_called_once_with( - "emails/domain_manager_deleted_notification.txt", - "emails/domain_manager_deleted_notification_subject.txt", - to_address="info@example.com", - context=ANY, + mock_send_email.assert_called_once_with( + removed_by_user=self.user, + manager_removed=self.manager, + manager_removed_email=self.manager.email, + domain=self.domain, ) @less_console_noise_decorator diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 114c066b3..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, @@ -1657,16 +1725,19 @@ class TestPortfolioMemberDeleteView(WebTest): self.user = create_test_user() self.domain, _ = Domain.objects.get_or_create(name="igorville.gov") self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") + self.domain_information, _ = DomainInformation.objects.get_or_create( + creator=self.user, domain=self.domain, portfolio=self.portfolio + ) self.role, _ = UserDomainRole.objects.get_or_create( user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER ) def tearDown(self): UserPortfolioPermission.objects.all().delete() + DomainInformation.objects.all().delete() Portfolio.objects.all().delete() UserDomainRole.objects.all().delete() DomainRequest.objects.all().delete() - DomainInformation.objects.all().delete() Domain.objects.all().delete() User.objects.all().delete() super().tearDown() @@ -1676,7 +1747,10 @@ class TestPortfolioMemberDeleteView(WebTest): @override_flag("organization_members", active=True) @patch("registrar.views.portfolios.send_portfolio_admin_removal_emails") @patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email") - def test_portfolio_member_delete_view_members_table_active_requests(self, send_member_removal, send_removal_emails): + @patch("registrar.views.portfolios.send_domain_manager_removal_emails_to_domain_managers") + def test_portfolio_member_delete_view_members_table_active_requests( + self, send_domain_manager_removal_emails, send_member_removal, send_removal_emails + ): """Error state w/ deleting a member with active request on Members Table""" # I'm a user UserPortfolioPermission.objects.get_or_create( @@ -1718,13 +1792,18 @@ class TestPortfolioMemberDeleteView(WebTest): send_removal_emails.assert_not_called() # assert that send_portfolio_member_permission_remove_email is not called send_member_removal.assert_not_called() + # assert that send_domain_manager_removal_emails is not called + send_domain_manager_removal_emails.assert_not_called() @less_console_noise_decorator @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) @patch("registrar.views.portfolios.send_portfolio_admin_removal_emails") @patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email") - def test_portfolio_member_delete_view_members_table_only_admin(self, send_member_removal, send_removal_emails): + @patch("registrar.views.portfolios.send_domain_manager_removal_emails_to_domain_managers") + def test_portfolio_member_delete_view_members_table_only_admin( + self, send_domain_manager_removal_emails, send_member_removal, send_removal_emails + ): """Error state w/ deleting a member that's the only admin on Members Table""" # I'm a user with admin permission @@ -1757,13 +1836,18 @@ class TestPortfolioMemberDeleteView(WebTest): send_removal_emails.assert_not_called() # assert that send_portfolio_member_permission_remove_email is not called send_member_removal.assert_not_called() + # assert that send_domain_manager_removal_emails is not called + send_domain_manager_removal_emails.assert_not_called() @less_console_noise_decorator @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) @patch("registrar.views.portfolios.send_portfolio_admin_removal_emails") @patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email") - def test_portfolio_member_table_delete_member_success(self, send_member_removal, mock_send_removal_emails): + @patch("registrar.views.portfolios.send_domain_manager_removal_emails_to_domain_managers") + def test_portfolio_member_table_delete_member_success( + self, send_domain_manager_removal_emails, send_member_removal, mock_send_removal_emails + ): """Success state with deleting on Members Table page bc no active request AND not only admin""" # I'm a user @@ -1788,6 +1872,9 @@ class TestPortfolioMemberDeleteView(WebTest): roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], ) + # Set up the member as a domain manager + UserDomainRole.objects.get_or_create(user=member, domain=self.domain, role=UserDomainRole.Roles.MANAGER) + # Member removal email sent successfully send_member_removal.return_value = True @@ -1815,6 +1902,8 @@ class TestPortfolioMemberDeleteView(WebTest): mock_send_removal_emails.assert_not_called() # assert that send_portfolio_member_permission_remove_email is called send_member_removal.assert_called_once() + # assert that send_domain_manager_removal_emails_to_domain_managers + send_domain_manager_removal_emails.assert_called_once() # Get the arguments passed to send_portfolio_member_permission_remove_email _, called_kwargs = send_member_removal.call_args @@ -1824,6 +1913,15 @@ class TestPortfolioMemberDeleteView(WebTest): self.assertEqual(called_kwargs["permissions"].user, upp.user) self.assertEqual(called_kwargs["permissions"].portfolio, upp.portfolio) + # Get the arguments passed to send_domain_manager_removal_emails_to_domain_managers + _, called_kwargs = send_domain_manager_removal_emails.call_args + + # Assert the email content + self.assertEqual(called_kwargs["removed_by_user"], self.user) + self.assertEqual(called_kwargs["manager_removed"], upp.user) + self.assertEqual(called_kwargs["manager_removed_email"], upp.user.email) + self.assertEqual(called_kwargs["domain"], self.domain) + @less_console_noise_decorator @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) @@ -2639,7 +2737,8 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest): @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) @patch("registrar.views.portfolios.send_domain_invitation_email") - def test_post_with_valid_added_domains(self, mock_send_domain_email): + @patch("registrar.views.portfolios.send_domain_manager_removal_emails_to_domain_managers") + def test_post_with_valid_added_domains(self, send_domain_manager_removal_emails, mock_send_domain_email): """Test that domains can be successfully added.""" self.client.force_login(self.user) @@ -2658,6 +2757,8 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest): self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.") expected_domains = [self.domain1, self.domain2, self.domain3] + # assert that send_domain_manager_removal_emails_to_domain_managers is not called + send_domain_manager_removal_emails.assert_not_called() # Verify that the invitation email was sent mock_send_domain_email.assert_called_once() call_args = mock_send_domain_email.call_args.kwargs @@ -2670,7 +2771,8 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest): @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) @patch("registrar.views.portfolios.send_domain_invitation_email") - def test_post_with_valid_removed_domains(self, mock_send_domain_email): + @patch("registrar.views.portfolios.send_domain_manager_removal_emails_to_domain_managers") + def test_post_with_valid_removed_domains(self, send_domain_manager_removal_emails, mock_send_domain_email): """Test that domains can be successfully removed.""" self.client.force_login(self.user) @@ -2678,6 +2780,8 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest): domains = [self.domain1, self.domain2, self.domain3] UserDomainRole.objects.bulk_create([UserDomainRole(domain=domain, user=self.user) for domain in domains]) + send_domain_manager_removal_emails.return_value = True + data = { "removed_domains": json.dumps([self.domain1.id, self.domain2.id]), } @@ -2694,7 +2798,19 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest): self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.") # assert that send_domain_invitation_email is not called mock_send_domain_email.assert_not_called() - + # assert that send_domain_manager_removal_emails_to_domain_managers is called twice + send_domain_manager_removal_emails.assert_any_call( + removed_by_user=self.user, + manager_removed=self.portfolio_permission.user, + manager_removed_email=self.portfolio_permission.user.email, + domain=self.domain1, + ) + send_domain_manager_removal_emails.assert_any_call( + removed_by_user=self.user, + manager_removed=self.portfolio_permission.user, + manager_removed_email=self.portfolio_permission.user.email, + domain=self.domain2, + ) UserDomainRole.objects.all().delete() @less_console_noise_decorator 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 08ebb4d86..3c13f6aef 100644 --- a/src/registrar/utility/email_invitations.py +++ b/src/registrar/utility/email_invitations.py @@ -3,6 +3,7 @@ from django.conf import settings from registrar.models import Domain, DomainInvitation, UserDomainRole from registrar.models.portfolio import Portfolio from registrar.models.portfolio_invitation import PortfolioInvitation +from registrar.models.user import User from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from registrar.utility.errors import ( @@ -18,6 +19,88 @@ import logging logger = logging.getLogger(__name__) +def _normalize_domains(domains: Domain | list[Domain]) -> list[Domain]: + """Ensures domains is always a list.""" + return [domains] if isinstance(domains, Domain) else domains + + +def _get_requestor_email(requestor, domains=None, portfolio=None): + """Get the requestor's email or raise an error if it's missing. + + If the requestor is staff, default email is returned. + + Raises: + MissingEmailError + """ + if requestor.is_staff: + return settings.DEFAULT_FROM_EMAIL + + if not requestor.email or requestor.email.strip() == "": + domain_names = None + if domains: + domain_names = ", ".join([domain.name for domain in domains]) + raise MissingEmailError(email=requestor.email, domain=domain_names, portfolio=portfolio) + + return requestor.email + + +def _validate_invitation(email, user, domains, requestor, is_member_of_different_org): + """Validate the invitation conditions.""" + _check_outside_org_membership(email, requestor, is_member_of_different_org) + + for domain in domains: + _validate_existing_invitation(email, user, domain) + + # NOTE: should we also be validating against existing user_domain_roles + + +def _check_outside_org_membership(email, requestor, is_member_of_different_org): + """Raise an error if the email belongs to a different organization.""" + if ( + flag_is_active_for_user(requestor, "organization_feature") + and not flag_is_active_for_user(requestor, "multiple_portfolios") + and is_member_of_different_org + ): + raise OutsideOrgMemberError(email=email) + + +def _validate_existing_invitation(email, user, domain): + """Check for existing invitations and handle their status.""" + try: + invite = DomainInvitation.objects.get(email=email, domain=domain) + if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED: + raise AlreadyDomainManagerError(email) + elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED: + invite.update_cancellation_status() + invite.save() + else: + raise AlreadyDomainInvitedError(email) + except DomainInvitation.DoesNotExist: + pass + if user: + if UserDomainRole.objects.filter(user=user, domain=domain).exists(): + raise AlreadyDomainManagerError(email) + + +def _send_domain_invitation_email(email, requestor_email, domains, requested_user): + """Send the invitation email.""" + try: + send_templated_email( + "emails/domain_invitation.txt", + "emails/domain_invitation_subject.txt", + to_address=email, + context={ + "domains": domains, + "requestor_email": requestor_email, + "invitee_email_address": email, + "requested_user": requested_user, + }, + ) + except EmailSendingError as err: + domain_names = ", ".join([domain.name for domain in domains]) + raise EmailSendingError(f"Could not send email invitation to {email} for domains: {domain_names}") from err + + def send_domain_invitation_email( email: str, requestor, domains: Domain | list[Domain], is_member_of_different_org, requested_user=None ): @@ -46,12 +129,12 @@ def send_domain_invitation_email( _validate_invitation(email, requested_user, domains, requestor, is_member_of_different_org) - send_invitation_email(email, requestor_email, domains, requested_user) + _send_domain_invitation_email(email, requestor_email, domains, requested_user) all_manager_emails_sent = True # send emails to domain managers for domain in domains: - if not send_emails_to_domain_managers( + if not _send_domain_invitation_update_emails_to_domain_managers( email=email, requestor_email=requestor_email, domain=domain, @@ -62,7 +145,9 @@ def send_domain_invitation_email( return all_manager_emails_sent -def send_emails_to_domain_managers(email: str, requestor_email, domain: Domain, requested_user=None): +def _send_domain_invitation_update_emails_to_domain_managers( + email: str, requestor_email, domain: Domain, requested_user=None +): """ Notifies all domain managers of the provided domain of a change @@ -96,86 +181,54 @@ def send_emails_to_domain_managers(email: str, requestor_email, domain: Domain, return all_emails_sent -def _normalize_domains(domains: Domain | list[Domain]) -> list[Domain]: - """Ensures domains is always a list.""" - return [domains] if isinstance(domains, Domain) else domains - - -def _get_requestor_email(requestor, domains=None, portfolio=None): - """Get the requestor's email or raise an error if it's missing. - - If the requestor is staff, default email is returned. - - Raises: - MissingEmailError +def send_domain_manager_removal_emails_to_domain_managers( + removed_by_user: User, + manager_removed: User, + manager_removed_email: str, + domain: Domain, +): """ - if requestor.is_staff: - return settings.DEFAULT_FROM_EMAIL + Notifies all domain managers that a domain manager has been removed. - if not requestor.email or requestor.email.strip() == "": - domain_names = None - if domains: - domain_names = ", ".join([domain.name for domain in domains]) - raise MissingEmailError(email=requestor.email, domain=domain_names, portfolio=portfolio) + Args: + removed_by_user(User): The user who initiated the removal. + manager_removed(User): The user being removed. + manager_removed_email(str): The email of the user being removed (in case no User). + domain(Domain): The domain the user is being removed from. - return requestor.email + Returns: + Boolean indicating if all messages were sent successfully. - -def _validate_invitation(email, user, domains, requestor, is_member_of_different_org): - """Validate the invitation conditions.""" - check_outside_org_membership(email, requestor, is_member_of_different_org) - - for domain in domains: - _validate_existing_invitation(email, user, domain) - - # NOTE: should we also be validating against existing user_domain_roles - - -def check_outside_org_membership(email, requestor, is_member_of_different_org): - """Raise an error if the email belongs to a different organization.""" - if ( - flag_is_active_for_user(requestor, "organization_feature") - and not flag_is_active_for_user(requestor, "multiple_portfolios") - and is_member_of_different_org - ): - raise OutsideOrgMemberError(email=email) - - -def _validate_existing_invitation(email, user, domain): - """Check for existing invitations and handle their status.""" - try: - invite = DomainInvitation.objects.get(email=email, domain=domain) - if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED: - raise AlreadyDomainManagerError(email) - elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED: - invite.update_cancellation_status() - invite.save() - else: - raise AlreadyDomainInvitedError(email) - except DomainInvitation.DoesNotExist: - pass - if user: - if UserDomainRole.objects.filter(user=user, domain=domain).exists(): - raise AlreadyDomainManagerError(email) - - -def send_invitation_email(email, requestor_email, domains, requested_user): - """Send the invitation email.""" - try: - send_templated_email( - "emails/domain_invitation.txt", - "emails/domain_invitation_subject.txt", - to_address=email, - context={ - "domains": domains, - "requestor_email": requestor_email, - "invitee_email_address": email, - "requested_user": requested_user, - }, - ) - except EmailSendingError as err: - domain_names = ", ".join([domain.name for domain in domains]) - raise EmailSendingError(f"Could not send email invitation to {email} for domains: {domain_names}") from err + """ + all_emails_sent = True + # Get each domain manager from list + user_domain_roles = UserDomainRole.objects.filter(domain=domain) + if manager_removed: + user_domain_roles = user_domain_roles.exclude(user=manager_removed) + for user_domain_role in user_domain_roles: + # Send email to each domain manager + user = user_domain_role.user + try: + send_templated_email( + "emails/domain_manager_deleted_notification.txt", + "emails/domain_manager_deleted_notification_subject.txt", + to_address=user.email, + context={ + "domain": domain, + "removed_by": removed_by_user, + "manager_removed_email": manager_removed_email, + "date": date.today(), + }, + ) + except EmailSendingError: + logger.warning( + "Could not send notification email to %s for domain %s", + user.email, + domain.name, + exc_info=True, + ) + all_emails_sent = False + return all_emails_sent def send_portfolio_invitation_email(email: str, requestor, portfolio, is_admin_invitation): @@ -227,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 3a083393e..5cf296517 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -68,7 +68,11 @@ from epplibwrapper import ( ) from ..utility.email import send_templated_email, EmailSendingError -from ..utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email +from ..utility.email_invitations import ( + send_domain_invitation_email, + send_domain_manager_removal_emails_to_domain_managers, + send_portfolio_invitation_email, +) from django import forms logger = logging.getLogger(__name__) @@ -398,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 @@ -456,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 @@ -1166,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 @@ -1474,48 +1478,17 @@ class DomainDeleteUserView(DeleteView): super().form_valid(form) # Email all domain managers that domain manager has been removed - domain = self.object.domain - - context = { - "domain": domain, - "removed_by": self.request.user, - "manager_removed": self.object.user, - "date": date.today(), - "changes": "Domain Manager", - } - self.email_domain_managers( - domain, - "emails/domain_manager_deleted_notification.txt", - "emails/domain_manager_deleted_notification_subject.txt", - context, + send_domain_manager_removal_emails_to_domain_managers( + removed_by_user=self.request.user, + manager_removed=self.object.user, + manager_removed_email=self.object.user.email, + domain=self.object.domain, ) # Add a success message messages.success(self.request, self.get_success_message()) return redirect(self.get_success_url()) - def email_domain_managers(self, domain: Domain, template: str, subject_template: str, context={}): - manager_pks = UserDomainRole.objects.filter(domain=domain.pk, role=UserDomainRole.Roles.MANAGER).values_list( - "user", flat=True - ) - emails = list(User.objects.filter(pk__in=manager_pks).values_list("email", flat=True)) - - for email in emails: - try: - send_templated_email( - template, - subject_template, - to_address=email, - context=context, - ) - except EmailSendingError: - logger.warning( - "Could not send notification email to %s for domain %s", - email, - domain.name, - exc_info=True, - ) - def post(self, request, *args, **kwargs): """Custom post implementation to ensure last userdomainrole is not removed and to redirect to home in the event that the user deletes themselves""" diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 7fa421eaa..952793084 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -29,12 +29,14 @@ from registrar.models.utility.portfolio_helper import UserPortfolioPermissionCho from registrar.utility.email import EmailSendingError from registrar.utility.email_invitations import ( send_domain_invitation_email, + send_domain_manager_removal_emails_to_domain_managers, send_portfolio_admin_addition_emails, send_portfolio_admin_removal_emails, send_portfolio_invitation_email, 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 @@ -193,6 +195,31 @@ class PortfolioMemberDeleteView(View): messages.warning( request, f"Could not send email notification to {portfolio_member_permission.user.email}" ) + + # Notify domain managers for domains which the member is being removed from + # Get list of portfolio domains that the member is invited to: + invited_domains = Domain.objects.filter( + invitations__email=portfolio_member_permission.user.email, + domain_info__portfolio=portfolio_member_permission.portfolio, + invitations__status=DomainInvitation.DomainInvitationStatus.INVITED, + ).distinct() + # Get list of portfolio domains that the member is a manager of + domains = Domain.objects.filter( + permissions__user=portfolio_member_permission.user, + domain_info__portfolio=portfolio_member_permission.portfolio, + ).distinct() + # Combine both querysets while ensuring uniqueness + all_domains = domains.union(invited_domains) + for domain in all_domains: + if not send_domain_manager_removal_emails_to_domain_managers( + removed_by_user=request.user, + manager_removed=portfolio_member_permission.user, + manager_removed_email=portfolio_member_permission.user.email, + domain=domain, + ): + messages.warning( + request, "Could not send email notification to existing domain managers for %s", domain + ) except Exception as e: self._handle_exceptions(e) @@ -432,6 +459,20 @@ class PortfolioMemberDomainsEditView(DetailView, View): Processes removed domains by deleting corresponding UserDomainRole instances. """ if removed_domain_ids: + # Notify domain managers for domains which the member is being removed from + # Fetch Domain objects from removed_domain_ids + removed_domains = Domain.objects.filter(id__in=removed_domain_ids) + # need to get the domains from removed_domain_ids + for domain in removed_domains: + if not send_domain_manager_removal_emails_to_domain_managers( + removed_by_user=self.request.user, + manager_removed=member, + manager_removed_email=member.email, + domain=domain, + ): + messages.warning( + self.request, "Could not send email notification to existing domain managers for %s", domain + ) # Delete UserDomainRole instances for removed domains UserDomainRole.objects.filter(domain_id__in=removed_domain_ids, user=member).delete() @@ -502,6 +543,31 @@ class PortfolioInvitedMemberDeleteView(View): messages.warning(self.request, "Could not send email notification to existing organization admins.") if not send_portfolio_invitation_remove_email(requestor=request.user, invitation=portfolio_invitation): messages.warning(request, f"Could not send email notification to {portfolio_invitation.email}") + + # Notify domain managers for domains which the invited member is being removed from + # Get list of portfolio domains that the invited member is invited to: + invited_domains = Domain.objects.filter( + invitations__email=portfolio_invitation.email, + domain_info__portfolio=portfolio_invitation.portfolio, + invitations__status=DomainInvitation.DomainInvitationStatus.INVITED, + ).distinct() + # Get list of portfolio domains that the member is a manager of + domains = Domain.objects.filter( + permissions__user__email=portfolio_invitation.email, + domain_info__portfolio=portfolio_invitation.portfolio, + ).distinct() + # Combine both querysets while ensuring uniqueness + all_domains = domains.union(invited_domains) + for domain in all_domains: + if not send_domain_manager_removal_emails_to_domain_managers( + removed_by_user=request.user, + manager_removed=None, + manager_removed_email=portfolio_invitation.email, + domain=domain, + ): + messages.warning( + request, "Could not send email notification to existing domain managers for %s", domain + ) except Exception as e: self._handle_exceptions(e) @@ -740,6 +806,21 @@ class PortfolioInvitedMemberDomainsEditView(DetailView, View): if not removed_domain_ids: return + # Notify domain managers for domains which the member is being removed from + # Fetch Domain objects from removed_domain_ids + removed_domains = Domain.objects.filter(id__in=removed_domain_ids) + # need to get the domains from removed_domain_ids + for domain in removed_domains: + if not send_domain_manager_removal_emails_to_domain_managers( + removed_by_user=self.request.user, + manager_removed=None, + manager_removed_email=email, + domain=domain, + ): + messages.warning( + self.request, "Could not send email notification to existing domain managers for %s", domain + ) + # Update invitations from INVITED to CANCELED DomainInvitation.objects.filter( domain_id__in=removed_domain_ids, @@ -850,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) @@ -902,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):