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 %} + + {{ 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 %} + + {{ 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 %} + + {{ 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 %} + + {{ 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"""