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):