diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 928ead442..927af3621 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1381,9 +1381,13 @@ class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
change_form_template = "django/admin/user_domain_role_change_form.html"
+ # Override for the delete confirmation page on the domain table (bulk delete action)
+ delete_selected_confirmation_template = "django/admin/user_domain_role_delete_selected_confirmation.html"
+
# Fixes a bug where non-superusers are redirected to the main page
def delete_view(self, request, object_id, extra_context=None):
"""Custom delete_view implementation that specifies redirect behaviour"""
+ self.delete_confirmation_template = "django/admin/user_domain_role_delete_confirmation.html"
response = super().delete_view(request, object_id, extra_context)
if isinstance(response, HttpResponseRedirect) and not request.user.has_perm("registrar.full_access_permission"):
@@ -1518,6 +1522,8 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
autocomplete_fields = ["domain"]
change_form_template = "django/admin/domain_invitation_change_form.html"
+ # Override for the delete confirmation page on the domain table (bulk delete action)
+ delete_selected_confirmation_template = "django/admin/domain_invitation_delete_selected_confirmation.html"
# Select domain invitations to change -> Domain invitations
def changelist_view(self, request, extra_context=None):
@@ -1527,6 +1533,16 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
+ def delete_view(self, request, object_id, extra_context=None):
+ """
+ Custom delete_view to perform additional actions or customize the template.
+ """
+ # Set the delete template to a custom one
+ self.delete_confirmation_template = "django/admin/domain_invitation_delete_confirmation.html"
+ response = super().delete_view(request, object_id, extra_context=extra_context)
+
+ return response
+
def save_model(self, request, obj, form, change):
"""
Override the save_model method.
diff --git a/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html b/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html
new file mode 100644
index 000000000..215bf5ada
--- /dev/null
+++ b/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html
@@ -0,0 +1,16 @@
+{% extends 'admin/delete_confirmation.html' %}
+{% load i18n static %}
+
+{% block content_subtitle %}
+
+
+
+ If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
+ their domain management privileges if they already have that role assigned. Go to the
+ User Domain Roles table
+ if you want to remove the user from a domain.
+
+
+
+ {{ block.super }}
+{% endblock %}
diff --git a/src/registrar/templates/django/admin/domain_invitation_delete_selected_confirmation.html b/src/registrar/templates/django/admin/domain_invitation_delete_selected_confirmation.html
new file mode 100644
index 000000000..2e15347c1
--- /dev/null
+++ b/src/registrar/templates/django/admin/domain_invitation_delete_selected_confirmation.html
@@ -0,0 +1,16 @@
+{% extends 'admin/delete_selected_confirmation.html' %}
+{% load i18n static %}
+
+{% block content_subtitle %}
+
+
+
+ If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
+ their domain management privileges if they already have that role assigned. Go to the
+ User Domain Roles table
+ if you want to remove the user from a domain.
+
+
+
+ {{ block.super }}
+{% endblock %}
diff --git a/src/registrar/templates/django/admin/user_domain_role_delete_confirmation.html b/src/registrar/templates/django/admin/user_domain_role_delete_confirmation.html
new file mode 100644
index 000000000..171f4c3ea
--- /dev/null
+++ b/src/registrar/templates/django/admin/user_domain_role_delete_confirmation.html
@@ -0,0 +1,13 @@
+{% extends 'admin/delete_confirmation.html' %}
+{% load i18n static %}
+
+{% block content_subtitle %}
+
+
+
+ If you remove someone from a domain here, it won't trigger any emails when you click "save."
+
+
+
+ {{ block.super }}
+{% endblock %}
diff --git a/src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html b/src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html
new file mode 100644
index 000000000..392d1aebc
--- /dev/null
+++ b/src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html
@@ -0,0 +1,13 @@
+{% extends 'admin/delete_selected_confirmation.html' %}
+{% load i18n static %}
+
+{% block content_subtitle %}
+
+
+
+ If you remove someone from a domain here, it won't trigger any emails when you click "save."
+
+
+
+ {{ block.super }}
+{% endblock %}
diff --git a/src/registrar/templates/emails/domain_manager_deleted_notification.txt b/src/registrar/templates/emails/domain_manager_deleted_notification.txt
new file mode 100644
index 000000000..fbb1e47cc
--- /dev/null
+++ b/src/registrar/templates/emails/domain_manager_deleted_notification.txt
@@ -0,0 +1,27 @@
+{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
+Hi,{% if domain_manager and domain_manager.first_name %} {{ domain_manager.first_name }}.{% endif %}
+
+A domain manager was removed from {{ domain.name }}.
+
+REMOVED BY: {{ removed_by.email }}
+REMOVED ON: {{ date }}
+MANAGER REMOVED: {{ manager_removed.email }}
+
+----------------------------------------------------------------
+
+WHY DID YOU RECEIVE THIS EMAIL?
+You’re listed as a domain manager for {{ domain.name }}, so you’ll receive a notification whenever a domain manager is removed from that domain.
+If you have questions or concerns, reach out to the person who removed the domain manager or reply to this email.
+
+THANK YOU
+.Gov helps the public identify official, trusted information. Thank you for using a .gov domain.
+
+----------------------------------------------------------------
+
+The .gov team
+Contact us:
+Learn about .gov
+
+The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
+(CISA)
+{% endautoescape %}
diff --git a/src/registrar/templates/emails/domain_manager_deleted_notification_subject.txt b/src/registrar/templates/emails/domain_manager_deleted_notification_subject.txt
new file mode 100644
index 000000000..c84a20f18
--- /dev/null
+++ b/src/registrar/templates/emails/domain_manager_deleted_notification_subject.txt
@@ -0,0 +1 @@
+A domain manager was removed from {{ domain.name }}
\ No newline at end of file
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index 28a407036..9447d211f 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -120,7 +120,7 @@ class TestFsmModelResource(TestCase):
fsm_field_mock.save.assert_not_called()
-class TestDomainInvitationAdmin(TestCase):
+class TestDomainInvitationAdmin(WebTest):
"""Tests for the DomainInvitationAdmin class as super user
Notes:
@@ -128,15 +128,27 @@ class TestDomainInvitationAdmin(TestCase):
tests have available superuser, client, and admin
"""
- def setUp(self):
+ # csrf checks do not work with WebTest.
+ # We disable them here. TODO for another ticket.
+ csrf_checks = False
+
+ @classmethod
+ def setUpClass(self):
+ super().setUpClass()
+ self.site = AdminSite()
self.factory = RequestFactory()
- self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite())
self.superuser = create_superuser()
+
+ def setUp(self):
+ super().setUp()
+ self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite())
self.domain = Domain.objects.create(name="example.com")
self.portfolio = Portfolio.objects.create(organization_name="new portfolio", creator=self.superuser)
DomainInformation.objects.create(domain=self.domain, portfolio=self.portfolio, creator=self.superuser)
"""Create a client object"""
self.client = Client(HTTP_HOST="localhost:8080")
+ self.client.force_login(self.superuser)
+ self.app.set_user(self.superuser.username)
def tearDown(self):
"""Delete all DomainInvitation objects"""
@@ -1071,6 +1083,50 @@ class TestDomainInvitationAdmin(TestCase):
self.assertEqual(DomainInvitation.objects.count(), 0)
self.assertEqual(PortfolioInvitation.objects.count(), 1)
+ @less_console_noise_decorator
+ def test_custom_delete_confirmation_page(self):
+ """Tests if custom alerts display on Domain Invitation delete page"""
+ self.client.force_login(self.superuser)
+ self.app.set_user(self.superuser.username)
+ domain, _ = Domain.objects.get_or_create(name="domain-invitation-test.gov", state=Domain.State.READY)
+ domain_invitation, _ = DomainInvitation.objects.get_or_create(domain=domain)
+
+ domain_invitation_change_page = self.app.get(
+ reverse("admin:registrar_domaininvitation_change", args=[domain_invitation.pk])
+ )
+
+ self.assertContains(domain_invitation_change_page, "domain-invitation-test.gov")
+ # click the "Delete" link
+ confirmation_page = domain_invitation_change_page.click("Delete", index=0)
+
+ custom_alert_content = "If you cancel the domain invitation here"
+ self.assertContains(confirmation_page, custom_alert_content)
+
+ @less_console_noise_decorator
+ def test_custom_selected_delete_confirmation_page(self):
+ """Tests if custom alerts display on Domain Invitation selected delete page from Domain Invitation table"""
+ domain, _ = Domain.objects.get_or_create(name="domain-invitation-test.gov", state=Domain.State.READY)
+ domain_invitation, _ = DomainInvitation.objects.get_or_create(domain=domain)
+
+ # Get the index. The post expects the index to be encoded as a string
+ index = f"{domain_invitation.id}"
+
+ test_helper = GenericTestHelper(
+ factory=self.factory,
+ user=self.superuser,
+ admin=self.admin,
+ url=reverse("admin:registrar_domaininvitation_changelist"),
+ model=Domain,
+ client=self.client,
+ )
+
+ # Simulate selecting a single record, then clicking "Delete selected domains"
+ response = test_helper.get_table_delete_confirmation_page("0", index)
+
+ # Check for custom alert message
+ custom_alert_content = "If you cancel the domain invitation here"
+ self.assertContains(response, custom_alert_content)
+
class TestUserPortfolioPermissionAdmin(TestCase):
"""Tests for the PortfolioInivtationAdmin class"""
@@ -2016,7 +2072,7 @@ class TestDomainInformationAdmin(TestCase):
self.test_helper.assert_table_sorted("-4", ("-creator__first_name", "-creator__last_name"))
-class TestUserDomainRoleAdmin(TestCase):
+class TestUserDomainRoleAdmin(WebTest):
"""Tests for the UserDomainRoleAdmin class as super user
Notes:
@@ -2043,6 +2099,8 @@ class TestUserDomainRoleAdmin(TestCase):
"""Setup environment for a mock admin user"""
super().setUp()
self.client = Client(HTTP_HOST="localhost:8080")
+ self.client.force_login(self.superuser)
+ self.app.set_user(self.superuser.username)
def tearDown(self):
"""Delete all Users, Domains, and UserDomainRoles"""
@@ -2205,6 +2263,48 @@ class TestUserDomainRoleAdmin(TestCase):
# We only need to check for the end of the HTML string
self.assertContains(response, "Joe Jones AntarcticPolarBears@example.com", count=1)
+ @less_console_noise_decorator
+ def test_custom_delete_confirmation_page(self):
+ """Tests if custom alerts display on User Domain Role delete page"""
+ domain, _ = Domain.objects.get_or_create(name="user-domain-role-test.gov", state=Domain.State.READY)
+ domain_role, _ = UserDomainRole.objects.get_or_create(domain=domain, user=self.superuser)
+
+ domain_invitation_change_page = self.app.get(
+ reverse("admin:registrar_userdomainrole_change", args=[domain_role.pk])
+ )
+
+ self.assertContains(domain_invitation_change_page, "user-domain-role-test.gov")
+ # click the "Delete" link
+ confirmation_page = domain_invitation_change_page.click("Delete", index=0)
+
+ custom_alert_content = "If you remove someone from a domain here"
+ self.assertContains(confirmation_page, custom_alert_content)
+
+ @less_console_noise_decorator
+ def test_custom_selected_delete_confirmation_page(self):
+ """Tests if custom alerts display on selected delete page from User Domain Roles table"""
+ domain, _ = Domain.objects.get_or_create(name="domain-invitation-test.gov", state=Domain.State.READY)
+ domain_role, _ = UserDomainRole.objects.get_or_create(domain=domain, user=self.superuser)
+
+ # Get the index. The post expects the index to be encoded as a string
+ index = f"{domain_role.id}"
+
+ test_helper = GenericTestHelper(
+ factory=self.factory,
+ user=self.superuser,
+ admin=self.admin,
+ url=reverse("admin:registrar_userdomainrole_changelist"),
+ model=Domain,
+ client=self.client,
+ )
+
+ # Simulate selecting a single record, then clicking "Delete selected domains"
+ response = test_helper.get_table_delete_confirmation_page("0", index)
+
+ # Check for custom alert message
+ custom_alert_content = "If you remove someone from a domain here"
+ self.assertContains(response, custom_alert_content)
+
class TestListHeaderAdmin(TestCase):
"""Tests for the ListHeaderAdmin class as super user
diff --git a/src/registrar/tests/test_models_requests.py b/src/registrar/tests/test_models_requests.py
index c3528311d..b19b245e5 100644
--- a/src/registrar/tests/test_models_requests.py
+++ b/src/registrar/tests/test_models_requests.py
@@ -1106,7 +1106,7 @@ class TestDomainRequest(TestCase):
federal_agency=fed_agency,
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
)
- user_portfolio_permission = UserPortfolioPermission.objects.create( # noqa: F841
+ UserPortfolioPermission.objects.create(
user=self.dummy_user_3, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Adds cc'ed email in this test's allow list
diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py
index 27dff5e3a..b84d284d8 100644
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -1063,6 +1063,23 @@ class TestDomainManagers(TestDomainOverview):
success_page = success_result.follow()
self.assertContains(success_page, "Failed to send email.")
+ @boto3_mocking.patching
+ @less_console_noise_decorator
+ @patch("registrar.views.domain.send_templated_email")
+ def test_domain_remove_manager(self, mock_send_templated_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)
+ self.client.post(reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.manager.id}))
+
+ # 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,
+ )
+
@less_console_noise_decorator
@patch("registrar.views.domain.send_domain_invitation_email")
def test_domain_invitation_created(self, mock_send_domain_email):
diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py
index 24673ac4f..089bbe1a9 100644
--- a/src/registrar/views/domain.py
+++ b/src/registrar/views/domain.py
@@ -1348,10 +1348,49 @@ class DomainDeleteUserView(UserDomainRolePermissionDeleteView):
# Delete the object
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,
+ )
+
# 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"""