diff --git a/.github/workflows/delete-and-recreate-db.yaml b/.github/workflows/delete-and-recreate-db.yaml
new file mode 100644
index 000000000..ecdf54bbc
--- /dev/null
+++ b/.github/workflows/delete-and-recreate-db.yaml
@@ -0,0 +1,90 @@
+# This workflow can be run from the CLI
+# gh workflow run reset-db.yaml -f environment=ENVIRONMENT
+
+name: Reset database
+run-name: Reset database for ${{ github.event.inputs.environment }}
+
+on:
+ workflow_dispatch:
+ inputs:
+ environment:
+ type: choice
+ description: Which environment should we flush and re-load data for?
+ options:
+ - el
+ - ad
+ - ms
+ - ag
+ - litterbox
+ - hotgov
+ - cb
+ - bob
+ - meoward
+ - backup
+ - ky
+ - es
+ - nl
+ - rh
+ - za
+ - gd
+ - rb
+ - ko
+ - ab
+ - rjm
+ - dk
+
+jobs:
+ reset-db:
+ runs-on: ubuntu-latest
+ env:
+ CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME
+ CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
+ DESTINATION_ENVIRONMENT: ${{ github.event.inputs.environment}}
+ steps:
+ - name: Delete and Recreate Database
+ env:
+ cf_username: ${{ secrets[env.CF_USERNAME] }}
+ cf_password: ${{ secrets[env.CF_PASSWORD] }}
+ run: |
+ # install cf cli and other tools
+ wget -q -O - https://packages.cloudfoundry.org/debian/cli.cloudfoundry.org.key | sudo gpg --dearmor -o /usr/share/keyrings/cli.cloudfoundry.org.gpg
+ echo "deb [signed-by=/usr/share/keyrings/cli.cloudfoundry.org.gpg] https://packages.cloudfoundry.org/debian stable main" | sudo tee /etc/apt/sources.list.d/cloudfoundry-cli.list
+
+ sudo apt-get update
+ sudo apt-get install cf8-cli
+ cf api api.fr.cloud.gov
+ cf auth "$CF_USERNAME" "$CF_PASSWORD"
+ cf target -o cisa-dotgov -s $DESTINATION_ENVIRONMENT
+
+
+
+ # unbind the service
+ cf unbind-service getgov-$DESTINATION_ENVIRONMENT getgov-$DESTINATION_ENVIRONMENT-database
+ #delete the service key
+ yes Y | cf delete-service-key getgov-$DESTINATION_ENVIRONMENT-database SERVICE_CONNECT
+ # delete the service
+ yes Y | cf delete-service getgov-$DESTINATION_ENVIRONMENT-database
+ # create it again
+ cf create-service aws-rds micro-psql getgov-$DESTINATION_ENVIRONMENT-database
+ # wait for it be created (up to 5 mins)
+ # this checks the creation cf service getgov-$DESTINATION_ENVIRONMENT-database
+ # the below command with check “status” line using cf service command mentioned above. if it says “create in progress” it will keep waiting otherwise the next steps fail
+
+ timeout 480 bash -c "until cf service getgov-$DESTINATION_ENVIRONMENT-database | grep -q 'The service instance status is succeeded'
+ do
+ echo 'Database not up yet, waiting...'
+ sleep 30
+ done"
+
+ # rebind the service
+ cf bind-service getgov-$DESTINATION_ENVIRONMENT getgov-$DESTINATION_ENVIRONMENT-database
+ #restage the app or it will not connect to the database right for the next commands
+ cf restage getgov-$DESTINATION_ENVIRONMENT
+ # wait for the above command to finish
+ # if it is taking way to long and the annoying “instance starting” line that keeps repeating, then run following two commands in a separate window. This will interrupt the death loop where it keeps hitting an error with it failing health checks
+ # create the cache table and run migrations
+ cf run-task getgov-$DESTINATION_ENVIRONMENT --command 'python manage.py createcachetable' --name createcachetable
+ cf run-task getgov-$DESTINATION_ENVIRONMENT --wait --command 'python manage.py migrate' --name migrate
+
+ # load fixtures
+ cf run-task getgov-$DESTINATION_ENVIRONMENT --wait --command 'python manage.py load' --name loaddata
diff --git a/docs/developer/workflows/README.md b/docs/developer/workflows/README.md
new file mode 100644
index 000000000..6cff81add
--- /dev/null
+++ b/docs/developer/workflows/README.md
@@ -0,0 +1,7 @@
+# Workflows Docs
+
+========================
+
+This directory contains files related to workflows
+
+Delete And Recreate Database is in [docs/ops](../workflows/delete-and-recreate-db.md/).
\ No newline at end of file
diff --git a/docs/developer/workflows/delete-and-recreate-db.md b/docs/developer/workflows/delete-and-recreate-db.md
new file mode 100644
index 000000000..7b378ce47
--- /dev/null
+++ b/docs/developer/workflows/delete-and-recreate-db.md
@@ -0,0 +1,13 @@
+## Delete And Recreate Database
+
+This script destroys and recreates a database. This is another troubleshooting tool for issues with the database.
+
+1. unbinds the database
+2. deletes it
+3. recreates it
+4. binds it back to the sandbox
+5. runs migrations
+
+Addition Info in this slack thread:
+
+- [Slack thread](https://cisa-corp.slack.com/archives/C05BGB4L5NF/p1725495150772119)
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/models/domain.py b/src/registrar/models/domain.py
index 0f0b3f112..649b3f93d 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -9,6 +9,7 @@ from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignor
from django.db import models, IntegrityError
from django.utils import timezone
from typing import Any
+from registrar.models.domain_invitation import DomainInvitation
from registrar.models.host import Host
from registrar.models.host_ip import HostIP
from registrar.utility.enums import DefaultEmail
@@ -1177,6 +1178,10 @@ class Domain(TimeStampedModel, DomainHelper):
return "DNS needed"
return self.state.capitalize()
+ def active_invitations(self):
+ """Returns only the active invitations (those with status 'invited')."""
+ return self.invitations.filter(status=DomainInvitation.DomainInvitationStatus.INVITED)
+
def map_epp_contact_to_public_contact(self, contact: eppInfo.InfoContactResultData, contact_id, contact_type):
"""Maps the Epp contact representation to a PublicContact object.
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/templates/includes/summary_item.html b/src/registrar/templates/includes/summary_item.html
index 26e56fea7..d062a7b4e 100644
--- a/src/registrar/templates/includes/summary_item.html
+++ b/src/registrar/templates/includes/summary_item.html
@@ -113,10 +113,10 @@
{% endif %}
{% endif %}
- {% if value.invitations.all %}
+ {% if value.active_invitations.all %}
Invited domain managers
- {% for item in value.invitations.all %}
+ {% for item in value.active_invitations.all %}
- {{ item.email }}
{% endfor %}
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/tests/test_views_members_json.py b/src/registrar/tests/test_views_members_json.py
index ceae1e35f..c505421ec 100644
--- a/src/registrar/tests/test_views_members_json.py
+++ b/src/registrar/tests/test_views_members_json.py
@@ -372,6 +372,21 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
domain=domain3,
)
+ # create another domain in the portfolio
+ # but make sure the domain invitation is canceled
+ domain4 = Domain.objects.create(
+ name="somedomain4.com",
+ )
+ DomainInformation.objects.create(
+ creator=self.user,
+ domain=domain4,
+ )
+ DomainInvitation.objects.create(
+ email=self.email6,
+ domain=domain4,
+ status=DomainInvitation.DomainInvitationStatus.CANCELED,
+ )
+
response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
self.assertEqual(response.status_code, 200)
data = response.json
@@ -381,6 +396,7 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
self.assertIn("somedomain1.com", domain_names)
self.assertIn("thissecondinvitetestsasubqueryinjson@lets.notbreak", domain_names)
self.assertNotIn("somedomain3.com", domain_names)
+ self.assertNotIn("somedomain4.com", domain_names)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py
index 0c7c56e74..097aa1879 100644
--- a/src/registrar/tests/test_views_portfolio.py
+++ b/src/registrar/tests/test_views_portfolio.py
@@ -22,7 +22,7 @@ from registrar.models.utility.portfolio_helper import UserPortfolioPermissionCho
from registrar.tests.test_views import TestWithUser
from registrar.utility.email import EmailSendingError
from registrar.utility.errors import MissingEmailError
-from .common import MockSESClient, completed_domain_request, create_test_user, create_user
+from .common import MockEppLib, MockSESClient, completed_domain_request, create_test_user, create_user
from waffle.testutils import override_flag
from django.contrib.sessions.middleware import SessionMiddleware
import boto3_mocking # type: ignore
@@ -3365,34 +3365,35 @@ class TestRequestingEntity(WebTest):
self.assertContains(response, "kepler, AL")
-class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
+class TestPortfolioInviteNewMemberView(MockEppLib, WebTest):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
+ def setUp(self):
+ super().setUp()
+
+ self.user = create_test_user()
# Create Portfolio
- cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
+ self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
# Add an invited member who has been invited to manage domains
- cls.invited_member_email = "invited@example.com"
- cls.invitation = PortfolioInvitation.objects.create(
- email=cls.invited_member_email,
- portfolio=cls.portfolio,
+ self.invited_member_email = "invited@example.com"
+ self.invitation = PortfolioInvitation.objects.create(
+ email=self.invited_member_email,
+ portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
],
)
- cls.new_member_email = "newmember@example.com"
+ self.new_member_email = "newmember@example.com"
- AllowedEmail.objects.get_or_create(email=cls.new_member_email)
+ AllowedEmail.objects.get_or_create(email=self.new_member_email)
# Assign permissions to the user making requests
UserPortfolioPermission.objects.create(
- user=cls.user,
- portfolio=cls.portfolio,
+ user=self.user,
+ portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
@@ -3400,14 +3401,13 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
],
)
- @classmethod
- def tearDownClass(cls):
+ def tearDown(self):
PortfolioInvitation.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
AllowedEmail.objects.all().delete()
- super().tearDownClass()
+ super().tearDown()
@boto3_mocking.patching
@less_console_noise_decorator
@@ -3452,6 +3452,85 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
# Check that an email was sent
self.assertTrue(mock_client.send_email.called)
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ @patch("registrar.views.portfolios.send_portfolio_invitation_email")
+ def test_member_invite_for_previously_removed_user(self, mock_send_email):
+ """Tests the member invitation flow for an existing member which was previously removed."""
+ self.client.force_login(self.user)
+
+ # invite, then retrieve an existing user, then remove the user from the portfolio
+ retrieved_member_email = "retrieved@example.com"
+ retrieved_user = User.objects.create(
+ username="retrieved_user",
+ first_name="Retrieved",
+ last_name="User",
+ email=retrieved_member_email,
+ phone="8003111234",
+ title="retrieved",
+ )
+
+ retrieved_invitation = PortfolioInvitation.objects.create(
+ email=retrieved_member_email,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ ],
+ status=PortfolioInvitation.PortfolioInvitationStatus.INVITED,
+ )
+ retrieved_invitation.retrieve()
+ retrieved_invitation.save()
+ upp = UserPortfolioPermission.objects.filter(
+ user=retrieved_user,
+ portfolio=self.portfolio,
+ )
+ upp.delete()
+
+ # Simulate a session to ensure continuity
+ session_id = self.client.session.session_key
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+ # Simulate submission of member invite for previously retrieved/removed member
+ final_response = self.client.post(
+ reverse("new-member"),
+ {
+ "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
+ "domain_request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
+ "domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
+ "member_permissions": "no_access",
+ "email": retrieved_member_email,
+ },
+ )
+
+ # Ensure the final submission is successful
+ self.assertEqual(final_response.status_code, 302) # Redirects
+
+ # Validate Database Changes
+ # Validate that portfolio invitation was created and retrieved
+ self.assertFalse(
+ PortfolioInvitation.objects.filter(
+ email=retrieved_member_email,
+ portfolio=self.portfolio,
+ status=PortfolioInvitation.PortfolioInvitationStatus.INVITED,
+ ).exists()
+ )
+ # at least one retrieved invitation
+ self.assertTrue(
+ PortfolioInvitation.objects.filter(
+ email=retrieved_member_email,
+ portfolio=self.portfolio,
+ status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED,
+ ).exists()
+ )
+ # Ensure exactly one UserPortfolioPermission exists for the retrieved user
+ self.assertEqual(
+ UserPortfolioPermission.objects.filter(user=retrieved_user, portfolio=self.portfolio).count(),
+ 1,
+ "Expected exactly one UserPortfolioPermission for the retrieved user.",
+ )
+
@boto3_mocking.patching
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
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"""
diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py
index a45ad66e9..29dc6a71c 100644
--- a/src/registrar/views/portfolio_members_json.py
+++ b/src/registrar/views/portfolio_members_json.py
@@ -123,7 +123,11 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
# Subquery to get concatenated domain information for each email
domain_invitations = (
- DomainInvitation.objects.filter(email=OuterRef("email"), domain__domain_info__portfolio=portfolio)
+ DomainInvitation.objects.filter(
+ email=OuterRef("email"),
+ domain__domain_info__portfolio=portfolio,
+ status=DomainInvitation.DomainInvitationStatus.INVITED,
+ )
.annotate(
concatenated_info=Concat(F("domain__id"), Value(":"), F("domain__name"), output_field=CharField())
)