Merge branch 'main' into ms/3316-automatically-add-portfolio-members

This commit is contained in:
Matt-Spence 2025-02-10 12:46:41 -05:00 committed by GitHub
commit edda071691
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 497 additions and 25 deletions

View file

@ -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

View file

@ -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/).

View file

@ -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)

View file

@ -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.

View file

@ -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.

View file

@ -0,0 +1,16 @@
{% extends 'admin/delete_confirmation.html' %}
{% load i18n static %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
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
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User Domain Roles table</a>
if you want to remove the user from a domain.
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -0,0 +1,16 @@
{% extends 'admin/delete_selected_confirmation.html' %}
{% load i18n static %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
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
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User Domain Roles table</a>
if you want to remove the user from a domain.
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends 'admin/delete_confirmation.html' %}
{% load i18n static %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you remove someone from a domain here, it won't trigger any emails when you click "save."
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends 'admin/delete_selected_confirmation.html' %}
{% load i18n static %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you remove someone from a domain here, it won't trigger any emails when you click "save."
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -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?
Youre listed as a domain manager for {{ domain.name }}, so youll 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: <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 @@
A domain manager was removed from {{ domain.name }}

View file

@ -113,10 +113,10 @@
</ul>
{% endif %}
{% endif %}
{% if value.invitations.all %}
{% if value.active_invitations.all %}
<h4 class="margin-bottom-05">Invited domain managers</h4>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% for item in value.invitations.all %}
{% for item in value.active_invitations.all %}
<li>{{ item.email }}</li>
{% endfor %}
</ul>

View file

@ -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</a></th>", 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

View file

@ -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

View file

@ -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):

View file

@ -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)

View file

@ -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)

View file

@ -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"""

View file

@ -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())
)