Merge branch 'ms/3212-FEB-review' into ms/3457-FEB-emails

This commit is contained in:
matthewswspence 2025-03-24 10:45:06 -05:00
commit a6741cf05b
No known key found for this signature in database
GPG key ID: FB458202A7852BA4
20 changed files with 971 additions and 221 deletions

76
.github/ISSUE_TEMPLATE/design-issue.yml vendored Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -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= <this function> 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

View file

@ -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",
)

View file

@ -40,7 +40,7 @@
<button
type="submit"
class="usa-button"
>{% if form.security_email.value is None or form.security_email.value == "dotgov@cisa.dhs.gov" or form.security_email.value == "registrar@dotgov.gov"%}Add security email{% else %}Save{% endif %}</button>
>{% 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 %}</button>
</form>
{% endblock %} {# domain_content #}

View file

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

View file

@ -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 <https://manage.get.gov>.
----------------------------------------------------------------
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: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
(CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -0,0 +1 @@
An update was made to your .gov organization

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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