portfolio removed email and associated tests

This commit is contained in:
David Kennedy 2025-02-06 08:04:32 -05:00
parent f5ef200216
commit 041ca70a8b
No known key found for this signature in database
GPG key ID: 6528A5386E66B96B
6 changed files with 204 additions and 12 deletions

View file

@ -0,0 +1,24 @@
{% 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 %}
{{ requestor_email }} has removed you from {{ portfolio.organization_name }}.
You can no longer view this organization or its related domains within the .gov registrar.
SOMETHING WRONG?
If you have questions or concerns, reach out to the person who removed you from the
organization, 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 @@
You've been removed from a .gov organization

View file

@ -16,6 +16,7 @@ from registrar.utility.email_invitations import (
send_portfolio_admin_addition_emails,
send_portfolio_admin_removal_emails,
send_portfolio_invitation_email,
send_portfolio_member_permission_remove_email,
send_portfolio_member_permission_update_email,
)
@ -962,3 +963,76 @@ class TestSendPortfolioMemberPermissionUpdateEmail(unittest.TestCase):
# Assertions
mock_logger.warning.assert_not_called() # Function should fail before logging email failure
class TestSendPortfolioMemberPermissionRemoveEmail(unittest.TestCase):
"""Unit tests for send_portfolio_member_permission_remove_email function."""
@patch("registrar.utility.email_invitations.send_templated_email")
@patch("registrar.utility.email_invitations._get_requestor_email")
def test_send_email_success(self, mock_get_requestor_email, mock_send_email):
"""Test that the email is sent successfully when there are no errors."""
# Mock data
requestor = MagicMock()
permissions = MagicMock(spec=UserPortfolioPermission)
permissions.user.email = "user@example.com"
permissions.portfolio.organization_name = "Test Portfolio"
mock_get_requestor_email.return_value = "requestor@example.com"
# Call function
result = send_portfolio_member_permission_remove_email(requestor, permissions)
# Assertions
mock_get_requestor_email.assert_called_once_with(requestor, portfolio=permissions.portfolio)
mock_send_email.assert_called_once_with(
"emails/portfolio_removal.txt",
"emails/portfolio_removal_subject.txt",
to_address="user@example.com",
context={
"requested_user": permissions.user,
"portfolio": permissions.portfolio,
"requestor_email": "requestor@example.com",
},
)
self.assertTrue(result)
@patch("registrar.utility.email_invitations.send_templated_email", side_effect=EmailSendingError("Email failed"))
@patch("registrar.utility.email_invitations._get_requestor_email")
@patch("registrar.utility.email_invitations.logger")
def test_send_email_failure(self, mock_logger, mock_get_requestor_email, mock_send_email):
"""Test that the function returns False and logs an error when email sending fails."""
# Mock data
requestor = MagicMock()
permissions = MagicMock(spec=UserPortfolioPermission)
permissions.user.email = "user@example.com"
permissions.portfolio.organization_name = "Test Portfolio"
mock_get_requestor_email.return_value = "requestor@example.com"
# Call function
result = send_portfolio_member_permission_remove_email(requestor, permissions)
# Assertions
mock_logger.warning.assert_called_once_with(
"Could not send email organization member removal notification to %s for portfolio: %s",
permissions.user.email,
permissions.portfolio.organization_name,
exc_info=True,
)
self.assertFalse(result)
@patch("registrar.utility.email_invitations._get_requestor_email", side_effect=Exception("Unexpected error"))
@patch("registrar.utility.email_invitations.logger")
def test_requestor_email_retrieval_failure(self, mock_logger, mock_get_requestor_email):
"""Test that an exception in retrieving requestor email is logged."""
# Mock data
requestor = MagicMock()
permissions = MagicMock(spec=UserPortfolioPermission)
# Call function
with self.assertRaises(Exception):
send_portfolio_member_permission_remove_email(requestor, permissions)
# Assertions
mock_logger.warning.assert_not_called() # Function should fail before logging email failure

View file

@ -1669,7 +1669,8 @@ class TestPortfolioMemberDeleteView(WebTest):
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
def test_portfolio_member_delete_view_members_table_active_requests(self, send_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):
"""Error state w/ deleting a member with active request on Members Table"""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
@ -1709,12 +1710,15 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is not called
send_removal_emails.assert_not_called()
# assert that send_portfolio_member_permission_remove_email is not called
send_member_removal.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")
def test_portfolio_member_delete_view_members_table_only_admin(self, send_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):
"""Error state w/ deleting a member that's the only admin on Members Table"""
# I'm a user with admin permission
@ -1744,12 +1748,15 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is not called
send_removal_emails.assert_not_called()
# assert that send_portfolio_member_permission_remove_email is not called
send_member_removal.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")
def test_portfolio_member_table_delete_member_success(self, mock_send_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):
"""Success state with deleting on Members Table page bc no active request AND not only admin"""
# I'm a user
@ -1774,6 +1781,9 @@ class TestPortfolioMemberDeleteView(WebTest):
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# Member removal email sent successfully
send_member_removal.return_value = True
# And set that the member has no active requests AND it's not the only admin
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object(
User, "is_only_admin_of_portfolio", return_value=False
@ -1796,12 +1806,23 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is not called
# because member being removed is not an admin
mock_send_removal_emails.assert_not_called()
# assert that send_portfolio_member_permission_remove_email is called
send_member_removal.assert_called_once()
# Get the arguments passed to send_portfolio_member_permission_remove_email
_, called_kwargs = send_member_removal.call_args
# Assert the email content
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["permissions"].user, upp.user)
self.assertEqual(called_kwargs["permissions"].portfolio, upp.portfolio)
@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")
def test_portfolio_member_table_delete_admin_success(self, mock_send_removal_emails):
@patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
def test_portfolio_member_table_delete_admin_success(self, send_member_removal, mock_send_removal_emails):
"""Success state with deleting on Members Table page bc no active request AND
not only admin. Because admin, removal emails are sent."""
@ -1828,6 +1849,7 @@ class TestPortfolioMemberDeleteView(WebTest):
)
mock_send_removal_emails.return_value = True
send_member_removal.return_value = True
# And set that the member has no active requests AND it's not the only admin
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object(
@ -1850,6 +1872,8 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is called
mock_send_removal_emails.assert_called_once()
# assert that send_portfolio_member_permission_remove_email is called
send_member_removal.assert_called_once()
# Get the arguments passed to send_portfolio_admin_addition_emails
_, called_kwargs = mock_send_removal_emails.call_args
@ -1859,13 +1883,23 @@ class TestPortfolioMemberDeleteView(WebTest):
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
# Get the arguments passed to send_portfolio_member_permission_remove_email
_, called_kwargs = send_member_removal.call_args
# Assert the email content
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["permissions"].user, upp.user)
self.assertEqual(called_kwargs["permissions"].portfolio, upp.portfolio)
@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")
def test_portfolio_member_table_delete_admin_success_removal_email_fail(self, mock_send_removal_emails):
@patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
def test_portfolio_member_table_delete_admin_success_removal_email_fail(self, send_member_removal, mock_send_removal_emails):
"""Success state with deleting on Members Table page bc no active request AND
not only admin. Because admin, removal emails are sent, but fail to send."""
not only admin. Because admin, removal emails are sent, but fail to send.
Email to removed member also fails to send."""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
@ -1890,6 +1924,7 @@ class TestPortfolioMemberDeleteView(WebTest):
)
mock_send_removal_emails.return_value = False
send_member_removal.return_value = False
# And set that the member has no active requests AND it's not the only admin
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object(
@ -1912,6 +1947,8 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is called
mock_send_removal_emails.assert_called_once()
# assert that send_portfolio_member_permission_remove_email is called
send_member_removal.assert_called_once()
# Get the arguments passed to send_portfolio_admin_addition_emails
_, called_kwargs = mock_send_removal_emails.call_args
@ -1920,6 +1957,15 @@ class TestPortfolioMemberDeleteView(WebTest):
self.assertEqual(called_kwargs["email"], member_email)
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
# Get the arguments passed to send_portfolio_member_permission_remove_email
_, called_kwargs = send_member_removal.call_args
# Assert the email content
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["permissions"].user, upp.user)
self.assertEqual(called_kwargs["permissions"].portfolio, upp.portfolio)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)

View file

@ -268,6 +268,47 @@ def send_portfolio_member_permission_update_email(requestor, permissions: UserPo
return True
def send_portfolio_member_permission_remove_email(requestor, permissions: UserPortfolioPermission):
"""
Sends an email notification to a portfolio member when their permissions are deleted.
This function retrieves the requestor's email and sends a templated email to the affected user,
notifying them of the removal of their portfolio permissions.
Args:
requestor (User): The user initiating the permission update.
permissions (UserPortfolioPermission): The updated permissions object containing the affected user
and the portfolio details.
Returns:
bool: True if the email was sent successfully, False if an EmailSendingError occurred.
Raises:
MissingEmailError: If the requestor has no email associated with their account.
"""
requestor_email = _get_requestor_email(requestor, portfolio=permissions.portfolio)
try:
send_templated_email(
"emails/portfolio_removal.txt",
"emails/portfolio_removal_subject.txt",
to_address=permissions.user.email,
context={
"requested_user": permissions.user,
"portfolio": permissions.portfolio,
"requestor_email": requestor_email,
},
)
except EmailSendingError:
logger.warning(
"Could not send email organization member removal notification to %s " "for portfolio: %s",
permissions.user.email,
permissions.portfolio.organization_name,
exc_info=True,
)
return False
return True
def send_portfolio_admin_addition_emails(email: str, requestor, portfolio: Portfolio):
"""
Notifies all portfolio admins of the provided portfolio of a newly invited portfolio admin

View file

@ -20,6 +20,7 @@ from registrar.utility.email_invitations import (
send_portfolio_admin_addition_emails,
send_portfolio_admin_removal_emails,
send_portfolio_invitation_email,
send_portfolio_member_permission_remove_email,
send_portfolio_member_permission_update_email,
)
from registrar.utility.errors import MissingEmailError
@ -149,18 +150,23 @@ class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
messages.error(request, error_message)
return redirect(reverse("member", kwargs={"pk": pk}))
# if member being removed is an admin
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_member_permission.roles:
try:
try:
# if member being removed is an admin
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_member_permission.roles:
# attempt to send notification emails of the removal to other portfolio admins
if not send_portfolio_admin_removal_emails(
email=portfolio_member_permission.user.email,
requestor=request.user,
portfolio=portfolio_member_permission.portfolio,
):
messages.warning(self.request, "Could not send email notification to existing organization admins.")
except Exception as e:
self._handle_exceptions(e)
messages.warning(request, "Could not send email notification to existing organization admins.")
# send notification email to member being removed
if not send_portfolio_member_permission_remove_email(
requestor=request.user, permissions=portfolio_member_permission
):
messages.warning(request, f"Could not send email notification to {member.email}")
except Exception as e:
self._handle_exceptions(e)
# passed all error conditions
portfolio_member_permission.delete()