Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#2416: portfolio admin notification emails [BOB] #3441

Merged
merged 22 commits into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b4c697f
Merge branch 'hotgov/3402-invitation-updates' into bob/2416-portfolio…
dave-kennedy-ecs Jan 30, 2025
37f52aa
portfolio addition email send to admins on registrar invite
dave-kennedy-ecs Jan 30, 2025
1bd73b6
added support in forms and views for sending email on change from mem…
dave-kennedy-ecs Jan 30, 2025
04b375f
removal email template
dave-kennedy-ecs Jan 30, 2025
f62dd76
Merge branch 'hotgov/3402-invitation-updates' into bob/2416-portfolio…
dave-kennedy-ecs Jan 30, 2025
4ce9eff
updates to complete initial implementation of invitation emails to ad…
dave-kennedy-ecs Jan 30, 2025
f786d13
handle demoted member
dave-kennedy-ecs Jan 30, 2025
b1f528b
handle removals
dave-kennedy-ecs Jan 30, 2025
5996c4d
wip
dave-kennedy-ecs Jan 31, 2025
041df21
handle changes to PortfolioInvitation in DJA
dave-kennedy-ecs Feb 1, 2025
6671545
fixed existing tests and lint errors
dave-kennedy-ecs Feb 1, 2025
1e280ed
fix remaining broken test
dave-kennedy-ecs Feb 1, 2025
be0c55d
added tests for test_email_invitations
dave-kennedy-ecs Feb 1, 2025
c47122a
testing PortfolioInvitationAdmin
dave-kennedy-ecs Feb 1, 2025
ff9c402
additional portfolio member tests
dave-kennedy-ecs Feb 1, 2025
d29205f
tests for PortfolioMemberDeleteView PortfolioInvitedMemberDeleteView
dave-kennedy-ecs Feb 1, 2025
a089dbc
updated tests for PortfolioMemberEditView
dave-kennedy-ecs Feb 1, 2025
f405ae8
tests for PortfolioInvitedMemberEditView
dave-kennedy-ecs Feb 1, 2025
2ab3b04
lint
dave-kennedy-ecs Feb 1, 2025
c1c4ec0
Merge branch 'main' into bob/2416-portfolio-admin-emails
dave-kennedy-ecs Feb 4, 2025
cf51677
Merge branch 'main' into bob/2416-portfolio-admin-emails
dave-kennedy-ecs Feb 6, 2025
05aca45
Merge branch 'main' into bob/2416-portfolio-admin-emails
dave-kennedy-ecs Feb 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 48 additions & 15 deletions src/registrar/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@
from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email
from registrar.utility.email_invitations import (
send_domain_invitation_email,
send_portfolio_admin_addition_emails,
send_portfolio_invitation_email,
)
from registrar.views.utility.invitation_helper import (
get_org_membership,
get_requested_user,
Expand Down Expand Up @@ -1551,7 +1555,9 @@ def save_model(self, request, obj, form, change):
and not member_of_this_org
and not member_of_a_different_org
):
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=domain_org)
send_portfolio_invitation_email(
email=requested_email, requestor=requestor, portfolio=domain_org, is_admin_invitation=False
)
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
email=requested_email,
portfolio=domain_org,
Expand Down Expand Up @@ -1642,30 +1648,57 @@ def save_model(self, request, obj, form, change):
Emails sent to requested user / email.
When exceptions are raised, return without saving model.
"""
if not change: # Only send email if this is a new PortfolioInvitation (creation)
try:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the way you moved the try statement up

portfolio = obj.portfolio
requested_email = obj.email
requestor = request.user
# Look up a user with that email
requested_user = get_requested_user(requested_email)

permission_exists = UserPortfolioPermission.objects.filter(
user__email=requested_email, portfolio=portfolio, user__email__isnull=False
).exists()
try:
is_admin_invitation = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in obj.roles
if not change: # Only send email if this is a new PortfolioInvitation (creation)
# Look up a user with that email
requested_user = get_requested_user(requested_email)

permission_exists = UserPortfolioPermission.objects.filter(
user__email=requested_email, portfolio=portfolio, user__email__isnull=False
).exists()
if not permission_exists:
# if permission does not exist for a user with requested_email, send email
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio)
if not send_portfolio_invitation_email(
email=requested_email,
requestor=requestor,
portfolio=portfolio,
is_admin_invitation=is_admin_invitation,
):
messages.warning(
self.request, "Could not send email notification to existing organization admins."
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could be missing something, but don't we need an else after this if-statement? Or does the function somehow terminate before setting the following success message?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we don't need an else. In this case, we don't want to raise exceptions when email send attempts fail. But we want views to create messages. So, we are returning a pass/fail boolean from the send_email methods. If it fails, we want to raise a warning. Whether pass or fail, we want to continue after the method completes.

# if user exists for email, immediately retrieve portfolio invitation upon creation
if requested_user is not None:
obj.retrieve()
messages.success(request, f"{requested_email} has been invited.")
else:
messages.warning(request, "User is already a member of this portfolio.")
except Exception as e:
# when exception is raised, handle and do not save the model
handle_invitation_exceptions(request, e, requested_email)
return
else: # Handle the case when updating an existing PortfolioInvitation
# Retrieve the existing object from the database
existing_obj = PortfolioInvitation.objects.get(pk=obj.pk)

# Check if the previous roles did NOT include ORGANIZATION_ADMIN
# and the new roles DO include ORGANIZATION_ADMIN
was_not_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in existing_obj.roles
# Check also if status is INVITED, ignore role changes for other statuses
is_invited = obj.status == PortfolioInvitation.PortfolioInvitationStatus.INVITED

if was_not_admin and is_admin_invitation and is_invited:
# send email to existing portfolio admins if new admin
if not send_portfolio_admin_addition_emails(
email=requested_email,
requestor=requestor,
portfolio=portfolio,
):
messages.warning(request, "Could not send email notification to existing organization admins.")
except Exception as e:
# when exception is raised, handle and do not save the model
handle_invitation_exceptions(request, e, requested_email)
return
# Call the parent save method to save the object
super().save_model(request, obj, form, change)

Expand Down
26 changes: 26 additions & 0 deletions src/registrar/forms/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,32 @@ def map_instance_to_initial(self):
self.initial["domain_permissions"] = selected_domain_permission
self.initial["member_permissions"] = selected_member_permission

def is_change_from_member_to_admin(self) -> bool:
"""
Checks if the roles have changed from not containing ORGANIZATION_ADMIN
to containing ORGANIZATION_ADMIN.
"""
previous_roles = set(self.initial.get("roles", [])) # Initial roles before change
new_roles = set(self.cleaned_data.get("roles", [])) # New roles after change

return (
UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in previous_roles
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN in new_roles
)

def is_change_from_admin_to_member(self) -> bool:
"""
Checks if the roles have changed from containing ORGANIZATION_ADMIN
to not containing ORGANIZATION_ADMIN.
"""
previous_roles = set(self.initial.get("roles", [])) # Initial roles before change
new_roles = set(self.cleaned_data.get("roles", [])) # New roles after change

return (
UserPortfolioRoleChoices.ORGANIZATION_ADMIN in previous_roles
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in new_roles
)


class PortfolioMemberForm(BasePortfolioMemberForm):
"""
Expand Down
12 changes: 7 additions & 5 deletions src/registrar/models/utility/portfolio_helper.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from registrar.utility import StrEnum
from django.db import models
from django.db.models import Q
from django.apps import apps
from django.forms import ValidationError
from registrar.utility.waffle import flag_is_active_for_user
Expand Down Expand Up @@ -136,9 +137,10 @@ def validate_user_portfolio_permission(user_portfolio_permission):
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
)

existing_invitations = PortfolioInvitation.objects.exclude(
portfolio=user_portfolio_permission.portfolio
).filter(email=user_portfolio_permission.user.email)
existing_invitations = PortfolioInvitation.objects.filter(email=user_portfolio_permission.user.email).exclude(
Q(portfolio=user_portfolio_permission.portfolio)
| Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
)
Comment on lines -135 to +139
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this fixes an undocumented bug where retrieved invitations were blocking creation of new invitations

if existing_invitations.exists():
raise ValidationError(
"This user is already assigned to a portfolio invitation. "
Expand Down Expand Up @@ -195,8 +197,8 @@ def validate_portfolio_invitation(portfolio_invitation):
if not flag_is_active_for_user(user, "multiple_portfolios"):
existing_permissions = UserPortfolioPermission.objects.filter(user=user)

existing_invitations = PortfolioInvitation.objects.exclude(id=portfolio_invitation.id).filter(
email=portfolio_invitation.email
existing_invitations = PortfolioInvitation.objects.filter(email=portfolio_invitation.email).exclude(
Q(id=portfolio_invitation.id) | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
Comment on lines -194 to +197
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this fixes an undocumented bug where retrieved invitations were blocking creation of new invitations

)

if existing_permissions.exists():
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi,{% if portfolio_admin and portfolio_admin.first_name %} {{ portfolio_admin.first_name }}.{% endif %}

An admin was invited to your .gov organization.

ORGANIZATION: {{ portfolio.organization_name }}
INVITED BY: {{ requestor_email }}
INVITED ON: {{date}}
ADMIN INVITED: {{ invited_email_address }}

----------------------------------------------------------------

NEXT STEPS
The person who received the invitation will become an admin once they log in to the
.gov registrar. They'll need to access the registrar using a Login.gov account that's
associated with the invited email address.

If you need to cancel this invitation or remove the admin, you can do that by going to
the Members section for your organization <https://manage.get.gov/>.


WHY DID YOU RECEIVE THIS EMAIL?
You’re listed as an admin for {{ portfolio.organization_name }}. That means you'll receive a notification
whenever a new admin is invited to that organization.

If you have questions or concerns, reach out to the person who sent the invitation 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 %}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
An admin was invited to your .gov organization
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi,{% if portfolio_admin and portfolio_admin.first_name %} {{ portfolio_admin.first_name }}.{% endif %}

An admin was removed from your .gov organization.

ORGANIZATION: {{ portfolio.organization_name }}
REMOVED BY: {{ requestor_email }}
REMOVED ON: {{date}}
ADMIN REMOVED: {{ removed_email_address }}

You can view this update by going to the Members section for your .gov organization <https://manage.get.gov/>.

----------------------------------------------------------------

WHY DID YOU RECEIVE THIS EMAIL?
You’re listed as an admin for {{ portfolio.organization_name }}. That means you'll receive a notification
whenever an admin is removed from that organization.

If you have questions or concerns, reach out to the person who removed the admin 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 %}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
An admin was removed from your .gov organization
96 changes: 95 additions & 1 deletion src/registrar/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ def test_add_domain_invitation_success_when_user_not_portfolio_member(
email="test@example.com",
requestor=self.superuser,
portfolio=self.portfolio,
is_admin_invitation=False,
)

# Assert success message
Expand Down Expand Up @@ -504,6 +505,7 @@ def test_add_domain_invitation_when_user_not_portfolio_member_raises_exception_s
email="test@example.com",
requestor=self.superuser,
portfolio=self.portfolio,
is_admin_invitation=False,
)

# Assert retrieve on domain invite only was called
Expand Down Expand Up @@ -567,6 +569,7 @@ def test_add_domain_invitation_when_user_not_portfolio_member_raises_exception_s
email="test@example.com",
requestor=self.superuser,
portfolio=self.portfolio,
is_admin_invitation=False,
)

# Assert retrieve on domain invite only was called
Expand Down Expand Up @@ -693,6 +696,7 @@ def test_add_domain_invitation_success_when_email_not_portfolio_member(
email="nonexistent@example.com",
requestor=self.superuser,
portfolio=self.portfolio,
is_admin_invitation=False,
)

# Assert retrieve was not called
Expand Down Expand Up @@ -918,6 +922,7 @@ def test_add_domain_invitation_when_user_not_portfolio_email_raises_exception_se
email="nonexistent@example.com",
requestor=self.superuser,
portfolio=self.portfolio,
is_admin_invitation=False,
)

# Assert retrieve on domain invite only was called
Expand Down Expand Up @@ -979,6 +984,7 @@ def test_add_domain_invitation_when_user_not_portfolio_email_raises_exception_se
email="nonexistent@example.com",
requestor=self.superuser,
portfolio=self.portfolio,
is_admin_invitation=False,
)

# Assert retrieve on domain invite only was called
Expand Down Expand Up @@ -1204,7 +1210,7 @@ def test_get_filters(self):

@less_console_noise_decorator
@patch("registrar.admin.send_portfolio_invitation_email")
@patch("django.contrib.messages.success") # Mock the `messages.warning` call
@patch("django.contrib.messages.success") # Mock the `messages.success` call
def test_save_sends_email(self, mock_messages_success, mock_send_email):
"""On save_model, an email is sent if an invitation already exists."""

Expand Down Expand Up @@ -1455,6 +1461,94 @@ def test_save_exception_generic_error(self, mock_messages_error, mock_send_email
# Assert that messages.error was called with the correct message
mock_messages_error.assert_called_once_with(request, "Could not send email invitation.")

@less_console_noise_decorator
@patch("registrar.admin.send_portfolio_admin_addition_emails")
def test_save_existing_sends_email_notification(self, mock_send_email):
"""On save_model to an existing invitation, an email is set to notify existing
admins, if the invitation changes from member to admin."""

# Create an instance of the admin class
admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)

# Mock the response value of the email send
mock_send_email.return_value = True

# Create and save a PortfolioInvitation instance
portfolio_invitation = PortfolioInvitation.objects.create(
email="james.gordon@gotham.gov",
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], # Initially NOT an admin
status=PortfolioInvitation.PortfolioInvitationStatus.INVITED, # Must be "INVITED"
)

# Create a request object
request = self.factory.post(f"/admin/registrar/PortfolioInvitation/{portfolio_invitation.pk}/change/")
request.user = self.superuser

# Change roles from MEMBER to ADMIN
portfolio_invitation.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]

# Call the save_model method
admin_instance.save_model(request, portfolio_invitation, None, True)

# Assert that send_portfolio_admin_addition_emails is called
mock_send_email.assert_called_once()

# Get the arguments passed to send_portfolio_admin_addition_emails
_, called_kwargs = mock_send_email.call_args

# Assert the email content
self.assertEqual(called_kwargs["email"], "james.gordon@gotham.gov")
self.assertEqual(called_kwargs["requestor"], self.superuser)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)

@less_console_noise_decorator
@patch("registrar.admin.send_portfolio_admin_addition_emails")
@patch("django.contrib.messages.warning") # Mock the `messages.warning` call
def test_save_existing_email_notification_warning(self, mock_messages_warning, mock_send_email):
"""On save_model for an existing invitation, a warning is displayed if method to
send email to notify admins returns False."""

# Create an instance of the admin class
admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)

# Mock the response value of the email send
mock_send_email.return_value = False

# Create and save a PortfolioInvitation instance
portfolio_invitation = PortfolioInvitation.objects.create(
email="james.gordon@gotham.gov",
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], # Initially NOT an admin
status=PortfolioInvitation.PortfolioInvitationStatus.INVITED, # Must be "INVITED"
)

# Create a request object
request = self.factory.post(f"/admin/registrar/PortfolioInvitation/{portfolio_invitation.pk}/change/")
request.user = self.superuser

# Change roles from MEMBER to ADMIN
portfolio_invitation.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]

# Call the save_model method
admin_instance.save_model(request, portfolio_invitation, None, True)

# Assert that send_portfolio_admin_addition_emails is called
mock_send_email.assert_called_once()

# Get the arguments passed to send_portfolio_admin_addition_emails
_, called_kwargs = mock_send_email.call_args

# Assert the email content
self.assertEqual(called_kwargs["email"], "james.gordon@gotham.gov")
self.assertEqual(called_kwargs["requestor"], self.superuser)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)

# Assert that messages.error was called with the correct message
mock_messages_warning.assert_called_once_with(
request, "Could not send email notification to existing organization admins."
)


class TestHostAdmin(TestCase):
"""Tests for the HostAdmin class as super user
Expand Down
Loading