From f10cfe2c9bd143029af27270807619eab41be630 Mon Sep 17 00:00:00 2001
From: matthewswspence
Date: Tue, 4 Feb 2025 14:45:08 -0600
Subject: [PATCH 001/125] delete ds data
---
src/registrar/models/domain.py | 15 +++++++++-
src/registrar/tests/test_models_domain.py | 36 +++++++++++++++++++++++
2 files changed, 50 insertions(+), 1 deletion(-)
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 0f0b3f112..134da7ead 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -1053,13 +1053,15 @@ class Domain(TimeStampedModel, DomainHelper):
note=f"Host {host.name} is in use by {host.domain}",
)
+ # set hosts to empty list so nameservers are deleted
(
deleted_values,
updated_values,
new_values,
oldNameservers,
) = self.getNameserverChanges(hosts=[])
-
+
+ # update the hosts
_ = self._update_host_values(updated_values, oldNameservers) # returns nothing, just need to be run and errors
addToDomainList, _ = self.createNewHostList(new_values)
deleteHostList, _ = self.createDeleteHostList(deleted_values)
@@ -1073,6 +1075,7 @@ class Domain(TimeStampedModel, DomainHelper):
# but we still need to delete the object themselves
self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
+ # delete the non-registrant contacts
logger.debug("Deleting non-registrant contacts for %s", self.name)
contacts = PublicContact.objects.filter(domain=self)
for contact in contacts:
@@ -1081,6 +1084,16 @@ class Domain(TimeStampedModel, DomainHelper):
request = commands.DeleteContact(contact.registry_id)
registry.send(request, cleaned=True)
+ # delete ds data if it exists
+ if self.dnssecdata:
+ logger.debug("Deleting ds data for %s", self.name)
+ try:
+ self.dnssecdata = None
+ except RegistryError as e:
+ logger.error("Error deleting ds data for %s: %s", self.name, e)
+ e.note = "Error deleting ds data for %s" % self.name
+ raise e
+
logger.info("Deleting domain %s", self.name)
request = commands.DeleteDomain(name=self.name)
registry.send(request, cleaned=True)
diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py
index 083725a55..5bb783fd0 100644
--- a/src/registrar/tests/test_models_domain.py
+++ b/src/registrar/tests/test_models_domain.py
@@ -2863,6 +2863,42 @@ class TestAnalystDelete(MockEppLib):
# State should have changed
self.assertEqual(self.domain_with_contacts.state, Domain.State.DELETED)
+ def test_analyst_deletes_domain_with_ds_data(self):
+ """
+ Scenario: Domain with DS data is deleted
+ When `domain.deletedInEpp()` is called
+ Then `commands.DeleteDomain` is sent to the registry
+ And `state` is set to `DELETED`
+ """
+ # Create a domain with DS data
+ domain, _ = Domain.objects.get_or_create(name="dsdomain.gov", state=Domain.State.READY)
+ domain.dnssecdata = extensions.DNSSECExtension(
+ dsdata=[extensions.DSData(keytag=1, algorithm=1, digest_type=1, digest="1234567890")],
+ keydata=[extensions.DNSSECKeyData(keytag=1, algorithm=1, digest_type=1, digest="1234567890")],
+ )
+ domain.save()
+
+ # Delete the domain
+ domain.deletedInEpp()
+ domain.save()
+
+ # Check that dsdata is None
+ self.assertEqual(domain.dnssecdata, None)
+
+ # Check that the UpdateDomain command was sent to the registry with the correct extension
+ self.mockedSendFunction.assert_has_calls(
+ [
+ call(
+ commands.UpdateDomain(name="dsdomain.gov", add=[], rem=[], nsset=None, keyset=None, registrant=None, auth_info=None),
+ cleaned=True,
+ ),
+ ]
+ )
+
+ # Check that the domain was deleted
+ self.assertEqual(domain.state, Domain.State.DELETED)
+
+
@less_console_noise_decorator
def test_deletion_ready_fsm_failure(self):
"""
From 59c22643316dd87b7ed7910eb62de07962f8e6fe Mon Sep 17 00:00:00 2001
From: matthewswspence
Date: Tue, 4 Feb 2025 14:55:19 -0600
Subject: [PATCH 002/125] remove raise error on delete_hosts_if_not_used
---
src/registrar/models/domain.py | 8 +-------
1 file changed, 1 insertion(+), 7 deletions(-)
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 134da7ead..44eb1cde0 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -750,11 +750,7 @@ class Domain(TimeStampedModel, DomainHelper):
successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount
- try:
- self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
- except Exception as e:
- # we don't need this part to succeed in order to continue.
- logger.error("Failed to delete nameserver hosts: %s", e)
+ self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
if successTotalNameservers < 2:
try:
@@ -1849,8 +1845,6 @@ class Domain(TimeStampedModel, DomainHelper):
else:
logger.error("Error _delete_hosts_if_not_used, code was %s error was %s" % (e.code, e))
- raise e
-
def _fix_unknown_state(self, cleaned):
"""
_fix_unknown_state: Calls _add_missing_contacts_if_unknown
From 041ca70a8b9fa0dc8c1d924a1940278c39f6d005 Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Thu, 6 Feb 2025 08:04:32 -0500
Subject: [PATCH 003/125] portfolio removed email and associated tests
---
.../templates/emails/portfolio_removal.txt | 24 ++++++
.../emails/portfolio_removal_subject.txt | 1 +
src/registrar/tests/test_email_invitations.py | 74 +++++++++++++++++++
src/registrar/tests/test_views_portfolio.py | 58 +++++++++++++--
src/registrar/utility/email_invitations.py | 41 ++++++++++
src/registrar/views/portfolios.py | 18 +++--
6 files changed, 204 insertions(+), 12 deletions(-)
create mode 100644 src/registrar/templates/emails/portfolio_removal.txt
create mode 100644 src/registrar/templates/emails/portfolio_removal_subject.txt
diff --git a/src/registrar/templates/emails/portfolio_removal.txt b/src/registrar/templates/emails/portfolio_removal.txt
new file mode 100644
index 000000000..6de2190ae
--- /dev/null
+++ b/src/registrar/templates/emails/portfolio_removal.txt
@@ -0,0 +1,24 @@
+{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
+Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first_name }}.{% endif %}
+
+{{ requestor_email }} has removed you from {{ portfolio.organization_name }}.
+
+You can no longer view this organization or its related domains within the .gov registrar.
+
+
+SOMETHING WRONG?
+If you have questions or concerns, reach out to the person who removed you from the
+organization, 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/portfolio_removal_subject.txt b/src/registrar/templates/emails/portfolio_removal_subject.txt
new file mode 100644
index 000000000..d60ef9859
--- /dev/null
+++ b/src/registrar/templates/emails/portfolio_removal_subject.txt
@@ -0,0 +1 @@
+You've been removed from a .gov organization
\ No newline at end of file
diff --git a/src/registrar/tests/test_email_invitations.py b/src/registrar/tests/test_email_invitations.py
index 20ac4a565..f07e2f2a7 100644
--- a/src/registrar/tests/test_email_invitations.py
+++ b/src/registrar/tests/test_email_invitations.py
@@ -16,6 +16,7 @@ from registrar.utility.email_invitations import (
send_portfolio_admin_addition_emails,
send_portfolio_admin_removal_emails,
send_portfolio_invitation_email,
+ send_portfolio_member_permission_remove_email,
send_portfolio_member_permission_update_email,
)
@@ -962,3 +963,76 @@ class TestSendPortfolioMemberPermissionUpdateEmail(unittest.TestCase):
# Assertions
mock_logger.warning.assert_not_called() # Function should fail before logging email failure
+
+
+class TestSendPortfolioMemberPermissionRemoveEmail(unittest.TestCase):
+ """Unit tests for send_portfolio_member_permission_remove_email function."""
+
+ @patch("registrar.utility.email_invitations.send_templated_email")
+ @patch("registrar.utility.email_invitations._get_requestor_email")
+ def test_send_email_success(self, mock_get_requestor_email, mock_send_email):
+ """Test that the email is sent successfully when there are no errors."""
+ # Mock data
+ requestor = MagicMock()
+ permissions = MagicMock(spec=UserPortfolioPermission)
+ permissions.user.email = "user@example.com"
+ permissions.portfolio.organization_name = "Test Portfolio"
+
+ mock_get_requestor_email.return_value = "requestor@example.com"
+
+ # Call function
+ result = send_portfolio_member_permission_remove_email(requestor, permissions)
+
+ # Assertions
+ mock_get_requestor_email.assert_called_once_with(requestor, portfolio=permissions.portfolio)
+ mock_send_email.assert_called_once_with(
+ "emails/portfolio_removal.txt",
+ "emails/portfolio_removal_subject.txt",
+ to_address="user@example.com",
+ context={
+ "requested_user": permissions.user,
+ "portfolio": permissions.portfolio,
+ "requestor_email": "requestor@example.com",
+ },
+ )
+ self.assertTrue(result)
+
+ @patch("registrar.utility.email_invitations.send_templated_email", side_effect=EmailSendingError("Email failed"))
+ @patch("registrar.utility.email_invitations._get_requestor_email")
+ @patch("registrar.utility.email_invitations.logger")
+ def test_send_email_failure(self, mock_logger, mock_get_requestor_email, mock_send_email):
+ """Test that the function returns False and logs an error when email sending fails."""
+ # Mock data
+ requestor = MagicMock()
+ permissions = MagicMock(spec=UserPortfolioPermission)
+ permissions.user.email = "user@example.com"
+ permissions.portfolio.organization_name = "Test Portfolio"
+
+ mock_get_requestor_email.return_value = "requestor@example.com"
+
+ # Call function
+ result = send_portfolio_member_permission_remove_email(requestor, permissions)
+
+ # Assertions
+ mock_logger.warning.assert_called_once_with(
+ "Could not send email organization member removal notification to %s for portfolio: %s",
+ permissions.user.email,
+ permissions.portfolio.organization_name,
+ exc_info=True,
+ )
+ self.assertFalse(result)
+
+ @patch("registrar.utility.email_invitations._get_requestor_email", side_effect=Exception("Unexpected error"))
+ @patch("registrar.utility.email_invitations.logger")
+ def test_requestor_email_retrieval_failure(self, mock_logger, mock_get_requestor_email):
+ """Test that an exception in retrieving requestor email is logged."""
+ # Mock data
+ requestor = MagicMock()
+ permissions = MagicMock(spec=UserPortfolioPermission)
+
+ # Call function
+ with self.assertRaises(Exception):
+ send_portfolio_member_permission_remove_email(requestor, permissions)
+
+ # Assertions
+ mock_logger.warning.assert_not_called() # Function should fail before logging email failure
diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py
index 65e0350ee..329e8e9f1 100644
--- a/src/registrar/tests/test_views_portfolio.py
+++ b/src/registrar/tests/test_views_portfolio.py
@@ -1669,7 +1669,8 @@ class TestPortfolioMemberDeleteView(WebTest):
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
- def test_portfolio_member_delete_view_members_table_active_requests(self, send_removal_emails):
+ @patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
+ def test_portfolio_member_delete_view_members_table_active_requests(self, send_member_removal, send_removal_emails):
"""Error state w/ deleting a member with active request on Members Table"""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
@@ -1709,12 +1710,15 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is not called
send_removal_emails.assert_not_called()
+ # assert that send_portfolio_member_permission_remove_email is not called
+ send_member_removal.assert_not_called()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
- def test_portfolio_member_delete_view_members_table_only_admin(self, send_removal_emails):
+ @patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
+ def test_portfolio_member_delete_view_members_table_only_admin(self, send_member_removal, send_removal_emails):
"""Error state w/ deleting a member that's the only admin on Members Table"""
# I'm a user with admin permission
@@ -1744,12 +1748,15 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is not called
send_removal_emails.assert_not_called()
+ # assert that send_portfolio_member_permission_remove_email is not called
+ send_member_removal.assert_not_called()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
- def test_portfolio_member_table_delete_member_success(self, mock_send_removal_emails):
+ @patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
+ def test_portfolio_member_table_delete_member_success(self, send_member_removal, mock_send_removal_emails):
"""Success state with deleting on Members Table page bc no active request AND not only admin"""
# I'm a user
@@ -1774,6 +1781,9 @@ class TestPortfolioMemberDeleteView(WebTest):
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
+ # Member removal email sent successfully
+ send_member_removal.return_value = True
+
# And set that the member has no active requests AND it's not the only admin
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object(
User, "is_only_admin_of_portfolio", return_value=False
@@ -1796,12 +1806,23 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is not called
# because member being removed is not an admin
mock_send_removal_emails.assert_not_called()
+ # assert that send_portfolio_member_permission_remove_email is called
+ send_member_removal.assert_called_once()
+
+ # Get the arguments passed to send_portfolio_member_permission_remove_email
+ _, called_kwargs = send_member_removal.call_args
+
+ # Assert the email content
+ self.assertEqual(called_kwargs["requestor"], self.user)
+ self.assertEqual(called_kwargs["permissions"].user, upp.user)
+ self.assertEqual(called_kwargs["permissions"].portfolio, upp.portfolio)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
- def test_portfolio_member_table_delete_admin_success(self, mock_send_removal_emails):
+ @patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
+ def test_portfolio_member_table_delete_admin_success(self, send_member_removal, mock_send_removal_emails):
"""Success state with deleting on Members Table page bc no active request AND
not only admin. Because admin, removal emails are sent."""
@@ -1828,6 +1849,7 @@ class TestPortfolioMemberDeleteView(WebTest):
)
mock_send_removal_emails.return_value = True
+ send_member_removal.return_value = True
# And set that the member has no active requests AND it's not the only admin
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object(
@@ -1850,6 +1872,8 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is called
mock_send_removal_emails.assert_called_once()
+ # assert that send_portfolio_member_permission_remove_email is called
+ send_member_removal.assert_called_once()
# Get the arguments passed to send_portfolio_admin_addition_emails
_, called_kwargs = mock_send_removal_emails.call_args
@@ -1859,13 +1883,23 @@ class TestPortfolioMemberDeleteView(WebTest):
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
+ # Get the arguments passed to send_portfolio_member_permission_remove_email
+ _, called_kwargs = send_member_removal.call_args
+
+ # Assert the email content
+ self.assertEqual(called_kwargs["requestor"], self.user)
+ self.assertEqual(called_kwargs["permissions"].user, upp.user)
+ self.assertEqual(called_kwargs["permissions"].portfolio, upp.portfolio)
+
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
- def test_portfolio_member_table_delete_admin_success_removal_email_fail(self, mock_send_removal_emails):
+ @patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
+ def test_portfolio_member_table_delete_admin_success_removal_email_fail(self, send_member_removal, mock_send_removal_emails):
"""Success state with deleting on Members Table page bc no active request AND
- not only admin. Because admin, removal emails are sent, but fail to send."""
+ not only admin. Because admin, removal emails are sent, but fail to send.
+ Email to removed member also fails to send."""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
@@ -1890,6 +1924,7 @@ class TestPortfolioMemberDeleteView(WebTest):
)
mock_send_removal_emails.return_value = False
+ send_member_removal.return_value = False
# And set that the member has no active requests AND it's not the only admin
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object(
@@ -1912,6 +1947,8 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is called
mock_send_removal_emails.assert_called_once()
+ # assert that send_portfolio_member_permission_remove_email is called
+ send_member_removal.assert_called_once()
# Get the arguments passed to send_portfolio_admin_addition_emails
_, called_kwargs = mock_send_removal_emails.call_args
@@ -1920,6 +1957,15 @@ class TestPortfolioMemberDeleteView(WebTest):
self.assertEqual(called_kwargs["email"], member_email)
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
+
+ # Get the arguments passed to send_portfolio_member_permission_remove_email
+ _, called_kwargs = send_member_removal.call_args
+
+ # Assert the email content
+ self.assertEqual(called_kwargs["requestor"], self.user)
+ self.assertEqual(called_kwargs["permissions"].user, upp.user)
+ self.assertEqual(called_kwargs["permissions"].portfolio, upp.portfolio)
+
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
diff --git a/src/registrar/utility/email_invitations.py b/src/registrar/utility/email_invitations.py
index d206bf279..2ddf74cc0 100644
--- a/src/registrar/utility/email_invitations.py
+++ b/src/registrar/utility/email_invitations.py
@@ -268,6 +268,47 @@ def send_portfolio_member_permission_update_email(requestor, permissions: UserPo
return True
+def send_portfolio_member_permission_remove_email(requestor, permissions: UserPortfolioPermission):
+ """
+ Sends an email notification to a portfolio member when their permissions are deleted.
+
+ This function retrieves the requestor's email and sends a templated email to the affected user,
+ notifying them of the removal of their portfolio permissions.
+
+ Args:
+ requestor (User): The user initiating the permission update.
+ permissions (UserPortfolioPermission): The updated permissions object containing the affected user
+ and the portfolio details.
+
+ Returns:
+ bool: True if the email was sent successfully, False if an EmailSendingError occurred.
+
+ Raises:
+ MissingEmailError: If the requestor has no email associated with their account.
+ """
+ requestor_email = _get_requestor_email(requestor, portfolio=permissions.portfolio)
+ try:
+ send_templated_email(
+ "emails/portfolio_removal.txt",
+ "emails/portfolio_removal_subject.txt",
+ to_address=permissions.user.email,
+ context={
+ "requested_user": permissions.user,
+ "portfolio": permissions.portfolio,
+ "requestor_email": requestor_email,
+ },
+ )
+ except EmailSendingError:
+ logger.warning(
+ "Could not send email organization member removal notification to %s " "for portfolio: %s",
+ permissions.user.email,
+ permissions.portfolio.organization_name,
+ exc_info=True,
+ )
+ return False
+ return True
+
+
def send_portfolio_admin_addition_emails(email: str, requestor, portfolio: Portfolio):
"""
Notifies all portfolio admins of the provided portfolio of a newly invited portfolio admin
diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py
index d63b5964e..3a1124898 100644
--- a/src/registrar/views/portfolios.py
+++ b/src/registrar/views/portfolios.py
@@ -20,6 +20,7 @@ from registrar.utility.email_invitations import (
send_portfolio_admin_addition_emails,
send_portfolio_admin_removal_emails,
send_portfolio_invitation_email,
+ send_portfolio_member_permission_remove_email,
send_portfolio_member_permission_update_email,
)
from registrar.utility.errors import MissingEmailError
@@ -149,18 +150,23 @@ class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
messages.error(request, error_message)
return redirect(reverse("member", kwargs={"pk": pk}))
- # if member being removed is an admin
- if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_member_permission.roles:
- try:
+ try:
+ # if member being removed is an admin
+ if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_member_permission.roles:
# attempt to send notification emails of the removal to other portfolio admins
if not send_portfolio_admin_removal_emails(
email=portfolio_member_permission.user.email,
requestor=request.user,
portfolio=portfolio_member_permission.portfolio,
):
- messages.warning(self.request, "Could not send email notification to existing organization admins.")
- except Exception as e:
- self._handle_exceptions(e)
+ messages.warning(request, "Could not send email notification to existing organization admins.")
+ # send notification email to member being removed
+ if not send_portfolio_member_permission_remove_email(
+ requestor=request.user, permissions=portfolio_member_permission
+ ):
+ messages.warning(request, f"Could not send email notification to {member.email}")
+ except Exception as e:
+ self._handle_exceptions(e)
# passed all error conditions
portfolio_member_permission.delete()
From a7f08aa5868005d27bfb688694b3a40d8656a4ba Mon Sep 17 00:00:00 2001
From: matthewswspence
Date: Thu, 6 Feb 2025 11:09:23 -0600
Subject: [PATCH 004/125] fix test
---
src/registrar/tests/test_models_domain.py | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py
index 5bb783fd0..973a5ad39 100644
--- a/src/registrar/tests/test_models_domain.py
+++ b/src/registrar/tests/test_models_domain.py
@@ -2863,6 +2863,7 @@ class TestAnalystDelete(MockEppLib):
# State should have changed
self.assertEqual(self.domain_with_contacts.state, Domain.State.DELETED)
+ # @less_console_noise
def test_analyst_deletes_domain_with_ds_data(self):
"""
Scenario: Domain with DS data is deleted
@@ -2872,9 +2873,10 @@ class TestAnalystDelete(MockEppLib):
"""
# Create a domain with DS data
domain, _ = Domain.objects.get_or_create(name="dsdomain.gov", state=Domain.State.READY)
+ # set domain to be on hold
+ domain.place_client_hold()
domain.dnssecdata = extensions.DNSSECExtension(
- dsdata=[extensions.DSData(keytag=1, algorithm=1, digest_type=1, digest="1234567890")],
- keydata=[extensions.DNSSECKeyData(keytag=1, algorithm=1, digest_type=1, digest="1234567890")],
+ dsData=[extensions.DSData(keyTag=1, alg=1, digestType=1, digest="1234567890")],
)
domain.save()
@@ -2885,6 +2887,11 @@ class TestAnalystDelete(MockEppLib):
# Check that dsdata is None
self.assertEqual(domain.dnssecdata, None)
+ # Print out all calls from the mockedSendFunction
+ print("\nAll calls to mockedSendFunction:")
+ for call in self.mockedSendFunction.call_args_list:
+ print(f"- {call}")
+
# Check that the UpdateDomain command was sent to the registry with the correct extension
self.mockedSendFunction.assert_has_calls(
[
From e3e001fe0d0efd9d09abef7ee20a6492879775e6 Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Thu, 6 Feb 2025 13:25:54 -0500
Subject: [PATCH 005/125] add static messages to admin confirmation messages
---
src/registrar/admin.py | 2 ++
...rtfolio_invitation_delete_confirmation.html | 17 +++++++++++++++++
...rtfolio_permission_delete_confirmation.html | 12 ++++++++++++
src/registrar/tests/test_admin.py | 18 ++++++++++++++++++
4 files changed, 49 insertions(+)
create mode 100644 src/registrar/templates/django/admin/portfolio_invitation_delete_confirmation.html
create mode 100644 src/registrar/templates/django/admin/user_portfolio_permission_delete_confirmation.html
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 928ead442..d27a4849c 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1326,6 +1326,7 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
search_help_text = "Search by first name, last name, email, or portfolio."
change_form_template = "django/admin/user_portfolio_permission_change_form.html"
+ delete_confirmation_template = "django/admin/user_portfolio_permission_delete_confirmation.html"
def get_roles(self, obj):
readable_roles = obj.get_readable_roles()
@@ -1631,6 +1632,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin):
autocomplete_fields = ["portfolio"]
change_form_template = "django/admin/portfolio_invitation_change_form.html"
+ delete_confirmation_template = "django/admin/portfolio_invitation_delete_confirmation.html"
# Select portfolio invitations to change -> Portfolio invitations
def changelist_view(self, request, extra_context=None):
diff --git a/src/registrar/templates/django/admin/portfolio_invitation_delete_confirmation.html b/src/registrar/templates/django/admin/portfolio_invitation_delete_confirmation.html
new file mode 100644
index 000000000..5aa01c33f
--- /dev/null
+++ b/src/registrar/templates/django/admin/portfolio_invitation_delete_confirmation.html
@@ -0,0 +1,17 @@
+{% extends "admin/delete_confirmation.html" %}
+
+{% block content_subtitle %}
+
+
+
+ If you cancel the portfolio invitation here, it won't trigger any emails. It also won't remove the user's
+ portfolio access if they already logged in. Go to the
+
+ User Portfolio Permissions
+
+ table if you want to remove the user from a portfolio.
+
+
+
+ {{ block.super }}
+{% endblock %}
\ No newline at end of file
diff --git a/src/registrar/templates/django/admin/user_portfolio_permission_delete_confirmation.html b/src/registrar/templates/django/admin/user_portfolio_permission_delete_confirmation.html
new file mode 100644
index 000000000..71c789a63
--- /dev/null
+++ b/src/registrar/templates/django/admin/user_portfolio_permission_delete_confirmation.html
@@ -0,0 +1,12 @@
+{% extends "admin/delete_confirmation.html" %}
+
+{% block content_subtitle %}
+
+
+
+ If you remove someone from a portfolio here, it will not send any emails when you click "Save".
+
+
+
+ {{ block.super }}
+{% endblock %}
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index 28a407036..e237f9509 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -1549,6 +1549,24 @@ class TestPortfolioInvitationAdmin(TestCase):
request, "Could not send email notification to existing organization admins."
)
+ @less_console_noise_decorator
+ def test_delete_confirmation_page_contains_static_message(self):
+ """Ensure the custom message appears in the delete confirmation page."""
+ self.client.force_login(self.superuser)
+ # Create a test portfolio invitation
+ self.invitation = PortfolioInvitation.objects.create(
+ email="testuser@example.com",
+ portfolio=self.portfolio,
+ roles=["organization_member"]
+ )
+ delete_url = reverse(
+ "admin:registrar_portfolioinvitation_delete", args=[self.invitation.pk]
+ )
+ response = self.client.get(delete_url)
+
+ # Check if the response contains the expected static message
+ expected_message = "If you cancel the portfolio invitation here"
+ self.assertIn(expected_message, response.content.decode("utf-8"))
class TestHostAdmin(TestCase):
"""Tests for the HostAdmin class as super user
From 6d0b9d1a9e2f995c37fce5b0f27245534def08dd Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Thu, 6 Feb 2025 13:39:04 -0500
Subject: [PATCH 006/125] last test for messages in dja
---
src/registrar/tests/test_admin.py | 26 ++++++++++++++++-----
src/registrar/tests/test_views_portfolio.py | 7 +++---
2 files changed, 24 insertions(+), 9 deletions(-)
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index e237f9509..8e04899cd 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -55,6 +55,7 @@ from .common import (
MockDbForSharedTests,
AuditedAdminMockData,
completed_domain_request,
+ create_test_user,
generic_domain_object,
less_console_noise,
mock_user,
@@ -1079,6 +1080,7 @@ class TestUserPortfolioPermissionAdmin(TestCase):
"""Create a client object"""
self.client = Client(HTTP_HOST="localhost:8080")
self.superuser = create_superuser()
+ self.testuser = create_test_user()
self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser)
def tearDown(self):
@@ -1111,6 +1113,21 @@ class TestUserPortfolioPermissionAdmin(TestCase):
"If you add someone to a portfolio here, it will not trigger an invitation email.",
)
+ @less_console_noise_decorator
+ def test_delete_confirmation_page_contains_static_message(self):
+ """Ensure the custom message appears in the delete confirmation page."""
+ self.client.force_login(self.superuser)
+ # Create a test portfolio permission
+ self.permission = UserPortfolioPermission.objects.create(
+ user=self.testuser, portfolio=self.portfolio, roles=["organization_member"]
+ )
+ delete_url = reverse("admin:registrar_userportfoliopermission_delete", args=[self.permission.pk])
+ response = self.client.get(delete_url)
+
+ # Check if the response contains the expected static message
+ expected_message = "If you remove someone from a portfolio here, it will not send any emails"
+ self.assertIn(expected_message, response.content.decode("utf-8"))
+
class TestPortfolioInvitationAdmin(TestCase):
"""Tests for the PortfolioInvitationAdmin class as super user
@@ -1555,19 +1572,16 @@ class TestPortfolioInvitationAdmin(TestCase):
self.client.force_login(self.superuser)
# Create a test portfolio invitation
self.invitation = PortfolioInvitation.objects.create(
- email="testuser@example.com",
- portfolio=self.portfolio,
- roles=["organization_member"]
- )
- delete_url = reverse(
- "admin:registrar_portfolioinvitation_delete", args=[self.invitation.pk]
+ email="testuser@example.com", portfolio=self.portfolio, roles=["organization_member"]
)
+ delete_url = reverse("admin:registrar_portfolioinvitation_delete", args=[self.invitation.pk])
response = self.client.get(delete_url)
# Check if the response contains the expected static message
expected_message = "If you cancel the portfolio invitation here"
self.assertIn(expected_message, response.content.decode("utf-8"))
+
class TestHostAdmin(TestCase):
"""Tests for the HostAdmin class as super user
diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py
index 329e8e9f1..76290ff3c 100644
--- a/src/registrar/tests/test_views_portfolio.py
+++ b/src/registrar/tests/test_views_portfolio.py
@@ -1896,7 +1896,9 @@ class TestPortfolioMemberDeleteView(WebTest):
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
@patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
- def test_portfolio_member_table_delete_admin_success_removal_email_fail(self, send_member_removal, mock_send_removal_emails):
+ def test_portfolio_member_table_delete_admin_success_removal_email_fail(
+ self, send_member_removal, mock_send_removal_emails
+ ):
"""Success state with deleting on Members Table page bc no active request AND
not only admin. Because admin, removal emails are sent, but fail to send.
Email to removed member also fails to send."""
@@ -1957,7 +1959,7 @@ class TestPortfolioMemberDeleteView(WebTest):
self.assertEqual(called_kwargs["email"], member_email)
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
-
+
# Get the arguments passed to send_portfolio_member_permission_remove_email
_, called_kwargs = send_member_removal.call_args
@@ -1966,7 +1968,6 @@ class TestPortfolioMemberDeleteView(WebTest):
self.assertEqual(called_kwargs["permissions"].user, upp.user)
self.assertEqual(called_kwargs["permissions"].portfolio, upp.portfolio)
-
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
From 9dd357c81f90bd7c0d16cd86ac22c87c841a2f2a Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Thu, 6 Feb 2025 15:02:59 -0500
Subject: [PATCH 007/125] refactored method to make more readable
---
src/registrar/views/portfolios.py | 77 +++++++++++++++++++------------
1 file changed, 47 insertions(+), 30 deletions(-)
diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py
index 3a1124898..f85e8ebea 100644
--- a/src/registrar/views/portfolios.py
+++ b/src/registrar/views/portfolios.py
@@ -119,65 +119,82 @@ class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
"""
portfolio_member_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
member = portfolio_member_permission.user
+ portfolio = portfolio_member_permission.portfolio
+ # Validate if the member can be removed
+ error_message = self._validate_member_removal(request, member, portfolio)
+ if error_message:
+ return self._handle_error_response(request, error_message, pk)
+
+ # Attempt to send notification emails
+ self._send_removal_notifications(request, portfolio_member_permission)
+
+ # Passed all error conditions, proceed with deletion
+ portfolio_member_permission.delete()
+
+ # Return success response
+ return self._handle_success_response(request, member.email)
+
+ def _validate_member_removal(self, request, member, portfolio):
+ """
+ Check whether the member can be removed from the portfolio.
+ Returns an error message if removal is not allowed; otherwise, returns None.
+ """
active_requests_count = member.get_active_requests_count_in_portfolio(request)
-
support_url = "https://get.gov/contact/"
- error_message = ""
-
if active_requests_count > 0:
- # If they have any in progress requests
- error_message = mark_safe( # nosec
+ return mark_safe( # nosec
"This member can't be removed from the organization because they have an active domain request. "
f"Please contact us to remove this member."
)
- elif member.is_only_admin_of_portfolio(portfolio_member_permission.portfolio):
- # If they are the last manager of a domain
- error_message = (
+ if member.is_only_admin_of_portfolio(portfolio):
+ return (
"There must be at least one admin in your organization. Give another member admin "
"permissions, make sure they log into the registrar, and then remove this member."
)
+ return None
- # From the Members Table page Else the Member Page
- if error_message:
- if request.headers.get("X-Requested-With") == "XMLHttpRequest":
- return JsonResponse(
- {"error": error_message},
- status=400,
- )
- else:
- messages.error(request, error_message)
- return redirect(reverse("member", kwargs={"pk": pk}))
+ def _handle_error_response(self, request, error_message, pk):
+ """
+ Return an error response (JSON or redirect with messages).
+ """
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
+ return JsonResponse({"error": error_message}, status=400)
+ messages.error(request, error_message)
+ return redirect(reverse("member", kwargs={"pk": pk}))
+ def _send_removal_notifications(self, request, portfolio_member_permission):
+ """
+ Attempt to send notification emails about the member's removal.
+ """
try:
- # if member being removed is an admin
+ # Notify other portfolio admins if removing an admin
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_member_permission.roles:
- # attempt to send notification emails of the removal to other portfolio admins
if not send_portfolio_admin_removal_emails(
email=portfolio_member_permission.user.email,
requestor=request.user,
portfolio=portfolio_member_permission.portfolio,
):
messages.warning(request, "Could not send email notification to existing organization admins.")
- # send notification email to member being removed
+
+ # Notify the member being removed
if not send_portfolio_member_permission_remove_email(
requestor=request.user, permissions=portfolio_member_permission
):
- messages.warning(request, f"Could not send email notification to {member.email}")
+ messages.warning(request, f"Could not send email notification to {portfolio_member_permission.user.email}")
except Exception as e:
self._handle_exceptions(e)
- # passed all error conditions
- portfolio_member_permission.delete()
-
- # From the Members Table page Else the Member Page
- success_message = f"You've removed {member.email} from the organization."
+ def _handle_success_response(self, request, member_email):
+ """
+ Return a success response (JSON or redirect with messages).
+ """
+ success_message = f"You've removed {member_email} from the organization."
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return JsonResponse({"success": success_message}, status=200)
- else:
- messages.success(request, success_message)
- return redirect(reverse("members"))
+ messages.success(request, success_message)
+ return redirect(reverse("members"))
def _handle_exceptions(self, exception):
"""Handle exceptions raised during the process."""
From 5de149394932407d49a0002c0592fed22ac3bfa6 Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Thu, 6 Feb 2025 17:10:53 -0500
Subject: [PATCH 008/125] Change sort icon and sort header ui
---
src/registrar/assets/js/uswds-edited.js | 20 +++++++++-
.../assets/src/js/getgov/formset-forms.js | 1 +
.../assets/src/sass/_theme/_tables.scss | 39 ++++++++++++++-----
3 files changed, 49 insertions(+), 11 deletions(-)
diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js
index 9d4dd2e51..ae246b05c 100644
--- a/src/registrar/assets/js/uswds-edited.js
+++ b/src/registrar/assets/js/uswds-edited.js
@@ -5695,19 +5695,35 @@ const createHeaderButton = (header, headerName) => {
buttonEl.setAttribute("tabindex", "0");
buttonEl.classList.add(SORT_BUTTON_CLASS);
// ICON_SOURCE
+ // ---- END DOTGOV EDIT
+ // Change icons on sort, use source from arro_upward and arrow_downward
+ // buttonEl.innerHTML = Sanitizer.escapeHTML`
+ //
+ // `;
buttonEl.innerHTML = Sanitizer.escapeHTML`
`;
+ // ---- END DOTGOV EDIT
header.appendChild(buttonEl);
updateSortLabel(header, headerName);
};
diff --git a/src/registrar/assets/src/js/getgov/formset-forms.js b/src/registrar/assets/src/js/getgov/formset-forms.js
index 27b85212e..faad54639 100644
--- a/src/registrar/assets/src/js/getgov/formset-forms.js
+++ b/src/registrar/assets/src/js/getgov/formset-forms.js
@@ -208,6 +208,7 @@ function hideDeletedForms() {
* it everywhere.
*/
export function initFormsetsForms() {
+ console.log('init formsets');
let formIdentifier = "form"
let repeatableForm = document.querySelectorAll(".repeatable-form");
let container = document.querySelector("#form-container");
diff --git a/src/registrar/assets/src/sass/_theme/_tables.scss b/src/registrar/assets/src/sass/_theme/_tables.scss
index 37ae22b1b..d13cb8b0a 100644
--- a/src/registrar/assets/src/sass/_theme/_tables.scss
+++ b/src/registrar/assets/src/sass/_theme/_tables.scss
@@ -1,4 +1,5 @@
@use "uswds-core" as *;
+@use "cisa_colors" as *;
td,
th {
@@ -68,7 +69,9 @@ th {
border-bottom: 1px solid color('base-lighter');
}
- thead th {
+ thead th,
+ thead th[aria-sort],
+ thead th[aria-sort] {
color: color('primary-darker');
border-bottom: 2px solid color('base-light');
}
@@ -93,17 +96,35 @@ th {
}
}
- @include at-media(tablet-lg) {
- th[data-sortable] .usa-table__header__button {
- right: auto;
-
- &[aria-sort=ascending],
- &[aria-sort=descending],
- &:not([aria-sort]) {
- right: auto;
+ th[aria-sort],
+ th[data-sortable][aria-sort=ascending],
+ th[data-sortable][aria-sort=descending] {
+ background-color: transparent;
+ .usa-table__header__button {
+ background-color: rgba(214, 233, 242, 0.6);
+ background-color: $theme-color-accent-cool-lightest;
+ border-radius: 4px;
+ // position: relative;
+ // left: 4px;
+ // top: 16px;
+ color: color('primary-darker');
+
+ &:hover {
+ background-color: rgba(214, 233, 242, 0.6);
}
}
}
+
+ @include at-media(tablet-lg) {
+ th[data-sortable] .usa-table__header__button,
+ th[data-sortable] .usa-table__header__button:not([aria-sort]),
+ th[data-sortable][aria-sort=ascending] .usa-table__header__button,
+ th[data-sortable][aria-sort=descending] .usa-table__header__button {
+ right: auto;
+ top: 12px;
+ transform: translateX(10px);
+ }
+ }
}
.dotgov-table--cell-padding-2 {
From 9c1e3bbc9419fe618c0100a03aebdbae49074e7a Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Thu, 6 Feb 2025 17:36:04 -0500
Subject: [PATCH 009/125] lint
---
src/registrar/views/portfolios.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py
index c0a0a442f..a52a757ec 100644
--- a/src/registrar/views/portfolios.py
+++ b/src/registrar/views/portfolios.py
@@ -182,7 +182,9 @@ class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
if not send_portfolio_member_permission_remove_email(
requestor=request.user, permissions=portfolio_member_permission
):
- messages.warning(request, f"Could not send email notification to {portfolio_member_permission.user.email}")
+ messages.warning(
+ request, f"Could not send email notification to {portfolio_member_permission.user.email}"
+ )
except Exception as e:
self._handle_exceptions(e)
From ec9df83154565e8d1ae74e4cbeed4ba6594b0454 Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Thu, 6 Feb 2025 20:01:15 -0500
Subject: [PATCH 010/125] Finish tweaking the UI
---
.../assets/src/sass/_theme/_tables.scss | 49 ++++++++++---------
1 file changed, 26 insertions(+), 23 deletions(-)
diff --git a/src/registrar/assets/src/sass/_theme/_tables.scss b/src/registrar/assets/src/sass/_theme/_tables.scss
index d13cb8b0a..4b9ccbb8a 100644
--- a/src/registrar/assets/src/sass/_theme/_tables.scss
+++ b/src/registrar/assets/src/sass/_theme/_tables.scss
@@ -1,5 +1,4 @@
@use "uswds-core" as *;
-@use "cisa_colors" as *;
td,
th {
@@ -42,13 +41,8 @@ th {
}
}
-// The member table has an extra "expand" row, which looks like a single row.
-// But the DOM disagrees - so we basically need to hide the border on both rows.
-#members__table-wrapper .dotgov-table tr:nth-last-child(2) td,
-#members__table-wrapper .dotgov-table tr:nth-last-child(2) th {
- border-bottom: none;
-}
-
+// .dotgov-table allows us to customize .usa-table on the user-facing pages,
+// while leaving the default styles for use on the admin pages
.dotgov-table {
width: 100%;
@@ -96,35 +90,44 @@ th {
}
}
- th[aria-sort],
+ // Sortable headers
th[data-sortable][aria-sort=ascending],
th[data-sortable][aria-sort=descending] {
background-color: transparent;
.usa-table__header__button {
- background-color: rgba(214, 233, 242, 0.6);
- background-color: $theme-color-accent-cool-lightest;
- border-radius: 4px;
- // position: relative;
- // left: 4px;
- // top: 16px;
+ background-color: color('accent-cool-lightest');
+ border-radius: units(.5);
color: color('primary-darker');
-
&:hover {
- background-color: rgba(214, 233, 242, 0.6);
+ background-color: color('accent-cool-lightest');
}
}
}
-
@include at-media(tablet-lg) {
th[data-sortable] .usa-table__header__button,
- th[data-sortable] .usa-table__header__button:not([aria-sort]),
- th[data-sortable][aria-sort=ascending] .usa-table__header__button,
- th[data-sortable][aria-sort=descending] .usa-table__header__button {
+ th[data-sortable] .usa-table__header__button:not([aria-sort]) {
+ // position next to the copy
right: auto;
- top: 12px;
- transform: translateX(10px);
+ // slide left to mock a margin between the copy and the icon
+ transform: translateX(units(1));
+ // fix vertical alignment
+ top: units(1.5);
}
}
+
+ // Currently the 'flash' when sort is clicked,
+ // this will become persistent if the double-sort bug is fixed
+ td[data-sort-active],
+ th[data-sort-active] {
+ background-color: color('primary-lightest');
+ }
+}
+
+// The member table has an extra "expand" row, which looks like a single row.
+// But the DOM disagrees - so we basically need to hide the border on both rows.
+#members__table-wrapper .dotgov-table tr:nth-last-child(2) td,
+#members__table-wrapper .dotgov-table tr:nth-last-child(2) th {
+ border-bottom: none;
}
.dotgov-table--cell-padding-2 {
From 1ca3fba2f74f1065e40c7ad8145978595bdc9763 Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Thu, 6 Feb 2025 20:04:29 -0500
Subject: [PATCH 011/125] cleanup
---
src/registrar/assets/src/js/getgov/formset-forms.js | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/registrar/assets/src/js/getgov/formset-forms.js b/src/registrar/assets/src/js/getgov/formset-forms.js
index faad54639..27b85212e 100644
--- a/src/registrar/assets/src/js/getgov/formset-forms.js
+++ b/src/registrar/assets/src/js/getgov/formset-forms.js
@@ -208,7 +208,6 @@ function hideDeletedForms() {
* it everywhere.
*/
export function initFormsetsForms() {
- console.log('init formsets');
let formIdentifier = "form"
let repeatableForm = document.querySelectorAll(".repeatable-form");
let container = document.querySelector("#form-container");
From f28ec7cf74d132eab38f328a179f9858b7706091 Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Thu, 6 Feb 2025 20:06:54 -0500
Subject: [PATCH 012/125] cleanup
---
src/registrar/assets/src/sass/_theme/_tables.scss | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/registrar/assets/src/sass/_theme/_tables.scss b/src/registrar/assets/src/sass/_theme/_tables.scss
index 4b9ccbb8a..d67b08041 100644
--- a/src/registrar/assets/src/sass/_theme/_tables.scss
+++ b/src/registrar/assets/src/sass/_theme/_tables.scss
@@ -64,7 +64,6 @@ th {
}
thead th,
- thead th[aria-sort],
thead th[aria-sort] {
color: color('primary-darker');
border-bottom: 2px solid color('base-light');
From dea13ec27b58406a812db3464942a96523e461e9 Mon Sep 17 00:00:00 2001
From: Matthew Spence
Date: Fri, 7 Feb 2025 10:37:52 -0600
Subject: [PATCH 013/125] bug fixes for deletion
---
src/registrar/models/domain.py | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 44eb1cde0..032cac8b4 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -1074,17 +1074,24 @@ class Domain(TimeStampedModel, DomainHelper):
# delete the non-registrant contacts
logger.debug("Deleting non-registrant contacts for %s", self.name)
contacts = PublicContact.objects.filter(domain=self)
+
for contact in contacts:
- if contact.contact_type != PublicContact.ContactTypeChoices.REGISTRANT:
- self._update_domain_with_contact(contact, rem=True)
- request = commands.DeleteContact(contact.registry_id)
- registry.send(request, cleaned=True)
+ try:
+ if contact.contact_type != PublicContact.ContactTypeChoices.REGISTRANT:
+ self._update_domain_with_contact(contact, rem=True)
+ request = commands.DeleteContact(contact.registry_id)
+ registry.send(request, cleaned=True)
+ except RegistryError as e:
+ logger.error(f"Error deleting contact: {contact}, {e}", exec_info=True)
# delete ds data if it exists
if self.dnssecdata:
logger.debug("Deleting ds data for %s", self.name)
try:
+ # set and unset client hold to be able to change ds data
+ self._remove_client_hold()
self.dnssecdata = None
+ self._place_client_hold()
except RegistryError as e:
logger.error("Error deleting ds data for %s: %s", self.name, e)
e.note = "Error deleting ds data for %s" % self.name
From d4a5e3e40081bd12d5b6d93e0d1f1147eb5483a1 Mon Sep 17 00:00:00 2001
From: Matthew Spence
Date: Fri, 7 Feb 2025 10:45:21 -0600
Subject: [PATCH 014/125] fix deletion regex
---
src/registrar/models/domain.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 032cac8b4..4a3edcd4a 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -1040,7 +1040,7 @@ class Domain(TimeStampedModel, DomainHelper):
logger.info("Deleting subdomains for %s", self.name)
# check if any subdomains are in use by another domain
- hosts = Host.objects.filter(name__regex=r".+{}".format(self.name))
+ hosts = Host.objects.filter(name__regex=r".+\.{}".format(self.name))
for host in hosts:
if host.domain != self:
logger.error("Unable to delete host: %s is in use by another domain: %s", host.name, host.domain)
From 826adc575355f26024a4954316c6e9af01800ef6 Mon Sep 17 00:00:00 2001
From: Matthew Spence
Date: Fri, 7 Feb 2025 11:02:35 -0600
Subject: [PATCH 015/125] add more logging
---
src/registrar/models/domain.py | 45 ++++++++++++++++++++--------------
1 file changed, 27 insertions(+), 18 deletions(-)
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 4a3edcd4a..18a4442f4 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -1048,21 +1048,23 @@ class Domain(TimeStampedModel, DomainHelper):
code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION,
note=f"Host {host.name} is in use by {host.domain}",
)
-
- # set hosts to empty list so nameservers are deleted
- (
- deleted_values,
- updated_values,
- new_values,
- oldNameservers,
- ) = self.getNameserverChanges(hosts=[])
-
- # update the hosts
- _ = self._update_host_values(updated_values, oldNameservers) # returns nothing, just need to be run and errors
- addToDomainList, _ = self.createNewHostList(new_values)
- deleteHostList, _ = self.createDeleteHostList(deleted_values)
- responseCode = self.addAndRemoveHostsFromDomain(hostsToAdd=addToDomainList, hostsToDelete=deleteHostList)
-
+ try:
+ # set hosts to empty list so nameservers are deleted
+ (
+ deleted_values,
+ updated_values,
+ new_values,
+ oldNameservers,
+ ) = self.getNameserverChanges(hosts=[])
+
+ # update the hosts
+ _ = self._update_host_values(updated_values, oldNameservers) # returns nothing, just need to be run and errors
+ addToDomainList, _ = self.createNewHostList(new_values)
+ deleteHostList, _ = self.createDeleteHostList(deleted_values)
+ responseCode = self.addAndRemoveHostsFromDomain(hostsToAdd=addToDomainList, hostsToDelete=deleteHostList)
+ except RegistryError as e:
+ logger.error(f"Error trying to delete hosts from domain {self}: {e}")
+ raise e
# if unable to update domain raise error and stop
if responseCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY:
raise NameserverError(code=nsErrorCodes.BAD_DATA)
@@ -1070,6 +1072,7 @@ class Domain(TimeStampedModel, DomainHelper):
# addAndRemoveHostsFromDomain removes the hosts from the domain object,
# but we still need to delete the object themselves
self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
+ logger.info("Finished _delete_host_if_not_used inside _delete_domain()")
# delete the non-registrant contacts
logger.debug("Deleting non-registrant contacts for %s", self.name)
@@ -1083,6 +1086,8 @@ class Domain(TimeStampedModel, DomainHelper):
registry.send(request, cleaned=True)
except RegistryError as e:
logger.error(f"Error deleting contact: {contact}, {e}", exec_info=True)
+
+ logger.info("Finished deleting contacts")
# delete ds data if it exists
if self.dnssecdata:
@@ -1097,9 +1102,13 @@ class Domain(TimeStampedModel, DomainHelper):
e.note = "Error deleting ds data for %s" % self.name
raise e
- logger.info("Deleting domain %s", self.name)
- request = commands.DeleteDomain(name=self.name)
- registry.send(request, cleaned=True)
+ try:
+ logger.info("Deleting domain %s", self.name)
+ request = commands.DeleteDomain(name=self.name)
+ registry.send(request, cleaned=True)
+ except RegistryError as e:
+ logger.error(f"Error deleting domain {self}: {e}")
+ raise e
def __str__(self) -> str:
return self.name
From 68732c7895d2ca0217b4bd481bd0cfdf5a2bc4c8 Mon Sep 17 00:00:00 2001
From: Matthew Spence
Date: Fri, 7 Feb 2025 11:22:06 -0600
Subject: [PATCH 016/125] add MOAR LOGS
---
src/registrar/models/domain.py | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 18a4442f4..a77641872 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -1068,6 +1068,8 @@ class Domain(TimeStampedModel, DomainHelper):
# if unable to update domain raise error and stop
if responseCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY:
raise NameserverError(code=nsErrorCodes.BAD_DATA)
+
+ logger.info("Finished removing nameservers from domain")
# addAndRemoveHostsFromDomain removes the hosts from the domain object,
# but we still need to delete the object themselves
@@ -1077,13 +1079,19 @@ class Domain(TimeStampedModel, DomainHelper):
# delete the non-registrant contacts
logger.debug("Deleting non-registrant contacts for %s", self.name)
contacts = PublicContact.objects.filter(domain=self)
+ logger.info(f"retrieved contacts for domain: {contacts}")
for contact in contacts:
try:
if contact.contact_type != PublicContact.ContactTypeChoices.REGISTRANT:
- self._update_domain_with_contact(contact, rem=True)
+ logger.info(f"Deleting contact: {contact}")
+ try:
+ self._update_domain_with_contact(contact, rem=True)
+ except Exception as e:
+ logger.error(f"Error while updateing domain with contact: {contact}, e: {e}", exc_info=True)
request = commands.DeleteContact(contact.registry_id)
registry.send(request, cleaned=True)
+ logger.info(f"sent DeleteContact for {contact}")
except RegistryError as e:
logger.error(f"Error deleting contact: {contact}, {e}", exec_info=True)
@@ -1094,8 +1102,10 @@ class Domain(TimeStampedModel, DomainHelper):
logger.debug("Deleting ds data for %s", self.name)
try:
# set and unset client hold to be able to change ds data
+ logger.info("removing client hold")
self._remove_client_hold()
self.dnssecdata = None
+ logger.info("placing client hold")
self._place_client_hold()
except RegistryError as e:
logger.error("Error deleting ds data for %s: %s", self.name, e)
From 0f50fd62e9c238f54f2bfeb509813576c921d2f9 Mon Sep 17 00:00:00 2001
From: Matthew Spence
Date: Fri, 7 Feb 2025 11:38:32 -0600
Subject: [PATCH 017/125] fix typo
---
src/registrar/models/domain.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index a77641872..adaa8703b 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -1088,12 +1088,12 @@ class Domain(TimeStampedModel, DomainHelper):
try:
self._update_domain_with_contact(contact, rem=True)
except Exception as e:
- logger.error(f"Error while updateing domain with contact: {contact}, e: {e}", exc_info=True)
+ logger.error(f"Error while updating domain with contact: {contact}, e: {e}", exc_info=True)
request = commands.DeleteContact(contact.registry_id)
registry.send(request, cleaned=True)
logger.info(f"sent DeleteContact for {contact}")
except RegistryError as e:
- logger.error(f"Error deleting contact: {contact}, {e}", exec_info=True)
+ logger.error(f"Error deleting contact: {contact}, {e}", exc_info=True)
logger.info("Finished deleting contacts")
From 6fc5a76e923a98a6180bf94771d22e3d94eca557 Mon Sep 17 00:00:00 2001
From: CocoByte
Date: Sun, 9 Feb 2025 18:20:38 -0700
Subject: [PATCH 018/125] Removed header markup for "clear all" filters link
---
.../templates/admin/change_list.html | 24 ++++++++++++++++++-
1 file changed, 23 insertions(+), 1 deletion(-)
diff --git a/src/registrar/templates/admin/change_list.html b/src/registrar/templates/admin/change_list.html
index 43abf0861..43e6fa5b5 100644
--- a/src/registrar/templates/admin/change_list.html
+++ b/src/registrar/templates/admin/change_list.html
@@ -1,4 +1,5 @@
{% extends "admin/change_list.html" %}
+{% load i18n admin_urls static admin_list %}
{% block content_title %}
{{ title }}
@@ -46,4 +47,25 @@
{{ block.super }}
{% endblock %}
-{% endblock %}
\ No newline at end of file
+{% endblock %}
+
+
+{% comment %} Replace the Django header markup for clearing all filters with a div. {% endcomment %}
+{% block filters %}
+{% if cl.has_filters %}
+
+{% endif %}
+{% endblock %}
+
From f90fe6c4629696616d50cee8bcc87064a18a7c39 Mon Sep 17 00:00:00 2001
From: CocoByte
Date: Sun, 9 Feb 2025 18:20:51 -0700
Subject: [PATCH 019/125] Removed header markup for "By Status" multiple choice
filter
---
.../admin/multiple_choice_list_filter.html | 70 ++++++++++---------
1 file changed, 37 insertions(+), 33 deletions(-)
diff --git a/src/registrar/templates/django/admin/multiple_choice_list_filter.html b/src/registrar/templates/django/admin/multiple_choice_list_filter.html
index 167059594..2a61fee93 100644
--- a/src/registrar/templates/django/admin/multiple_choice_list_filter.html
+++ b/src/registrar/templates/django/admin/multiple_choice_list_filter.html
@@ -1,37 +1,41 @@
{% load i18n %}
{% load static field_helpers url_helpers %}
+
+
+ {% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}
+
+
+ {% for choice in choices %}
+ {% if choice.reset %}
+
";
+
return domainsHTML;
}
@@ -387,40 +390,51 @@ export class MembersTable extends BaseTable {
* - If no relevant permissions are found, the function returns a message stating that the user has no additional permissions.
* - The resulting HTML always includes a header "Additional permissions for this member" and appends the relevant permission descriptions.
*/
- generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices) {
+ generatePermissionsHTML(is_admin, member_permissions, UserPortfolioPermissionChoices) {
let permissionsHTML = '';
// Define shared classes across elements for easier refactoring
- let sharedParagraphClasses = "font-body-xs text-base-dark margin-top-1 p--blockquote";
+ let sharedParagraphClasses = "font-body-xs text-base-darker margin-top-1 p--blockquote";
+
+ // Member access
+ if (is_admin) {
+ permissionsHTML += `
{% endif %}
\ 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 d062a7b4e..a091e5ab7 100644
--- a/src/registrar/templates/includes/summary_item.html
+++ b/src/registrar/templates/includes/summary_item.html
@@ -134,18 +134,26 @@
{% endif %}
- {% if editable and edit_link %}
+ {% if editable and edit_link or view_button %}
{% endif %}
\ No newline at end of file
diff --git a/src/registrar/templates/portfolio_member_permissions.html b/src/registrar/templates/portfolio_member_permissions.html
index 2bef5b7af..6d187084b 100644
--- a/src/registrar/templates/portfolio_member_permissions.html
+++ b/src/registrar/templates/portfolio_member_permissions.html
@@ -105,7 +105,7 @@
>
Cancel
-
+
From 77711894b3061e36ba63867f8c99d150b075304a Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Mon, 10 Feb 2025 16:29:40 -0500
Subject: [PATCH 028/125] Add a new member page
---
src/registrar/templates/portfolio_members_add_new.html | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/registrar/templates/portfolio_members_add_new.html b/src/registrar/templates/portfolio_members_add_new.html
index 464eaefce..c3a648bdc 100644
--- a/src/registrar/templates/portfolio_members_add_new.html
+++ b/src/registrar/templates/portfolio_members_add_new.html
@@ -55,7 +55,7 @@
What level of access would you like to grant this member?
-
Select one *
+
Select the level of access for this member. *
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors form.role %}
@@ -88,7 +88,7 @@
aria-controls="invite-member-modal"
data-open-modal
>Trigger invite member modal
-
+
From 586d83adc172a058e288c1bba7207123ee734cbe Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Mon, 10 Feb 2025 16:44:57 -0500
Subject: [PATCH 029/125] Fix unit tests
---
src/registrar/tests/test_views_portfolio.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py
index 7a664a8c6..7d56e1650 100644
--- a/src/registrar/tests/test_views_portfolio.py
+++ b/src/registrar/tests/test_views_portfolio.py
@@ -952,7 +952,7 @@ class TestPortfolio(WebTest):
self.assertContains(response, "Creator")
self.assertContains(response, "Manager")
self.assertContains(
- response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
+ response, 'This member does not manage any domains. To assign this member a domain, click "Edit"'
)
# Assert buttons and links within the page are correct
@@ -1067,7 +1067,7 @@ class TestPortfolio(WebTest):
self.assertContains(response, "Creator")
self.assertContains(response, "Manager")
self.assertContains(
- response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
+ response, 'This member does not manage any domains. To assign this member a domain, click "Edit"'
)
# Assert buttons and links within the page are correct
self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present
From 1d54fe6b43e1716a20c39ee269408b86c13437a8 Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Mon, 10 Feb 2025 16:50:54 -0500
Subject: [PATCH 030/125] fix unit tests
---
src/registrar/tests/test_views_portfolio.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py
index 7d56e1650..c00892eca 100644
--- a/src/registrar/tests/test_views_portfolio.py
+++ b/src/registrar/tests/test_views_portfolio.py
@@ -952,7 +952,7 @@ class TestPortfolio(WebTest):
self.assertContains(response, "Creator")
self.assertContains(response, "Manager")
self.assertContains(
- response, 'This member does not manage any domains. To assign this member a domain, click "Edit"'
+ response, 'This member does not manage any domains.'
)
# Assert buttons and links within the page are correct
@@ -1067,7 +1067,7 @@ class TestPortfolio(WebTest):
self.assertContains(response, "Creator")
self.assertContains(response, "Manager")
self.assertContains(
- response, 'This member does not manage any domains. To assign this member a domain, click "Edit"'
+ response, 'This member does not manage any domains.'
)
# Assert buttons and links within the page are correct
self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present
From c44c66ae96eebbff82ac1883b7fb79c69f4daf1a Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Mon, 10 Feb 2025 17:01:43 -0500
Subject: [PATCH 031/125] wip
---
src/registrar/assets/src/js/getgov/table-members.js | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/registrar/assets/src/js/getgov/table-members.js b/src/registrar/assets/src/js/getgov/table-members.js
index 5e93ecab8..e876ce0f9 100644
--- a/src/registrar/assets/src/js/getgov/table-members.js
+++ b/src/registrar/assets/src/js/getgov/table-members.js
@@ -108,7 +108,7 @@ export class MembersTable extends BaseTable {
`;
- showMoreRow.innerHTML = `
${domainsHTML} ${permissionsHTML}
`;
+ showMoreRow.innerHTML = `
${domainsHTML} ${permissionsHTML}
`;
showMoreRow.classList.add('show-more-content');
showMoreRow.classList.add('display-none');
showMoreRow.id = unique_id;
@@ -258,7 +258,7 @@ export class MembersTable extends BaseTable {
// Only generate HTML if the member has one or more assigned domains
- domainsHTML += "
";
+ domainsHTML += "
";
domainsHTML += "
Domains assigned
";
if (num_domains > 0) {
domainsHTML += `
This member is assigned to ${num_domains} domain${num_domains > 1 ? 's' : ''}:
`;
@@ -275,7 +275,7 @@ export class MembersTable extends BaseTable {
}
// If there are more than 6 domains, display a "View assigned domains" link
- domainsHTML += `
";
@@ -434,7 +434,7 @@ export class MembersTable extends BaseTable {
}
// Add a permissions header and wrap the entire output in a container
- permissionsHTML = `
Member access and permissions
${permissionsHTML}
`;
+ permissionsHTML = `
Member access and permissions
${permissionsHTML}
`;
return permissionsHTML;
}
From d7c76b6d20e612c661a413eace0f150ec25550bd Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Mon, 10 Feb 2025 17:03:34 -0500
Subject: [PATCH 032/125] Domain assignments with s
---
src/registrar/templates/portfolio_member.html | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html
index 477599d49..46e0cced6 100644
--- a/src/registrar/templates/portfolio_member.html
+++ b/src/registrar/templates/portfolio_member.html
@@ -103,9 +103,9 @@ Organization member
{% comment %}view_button is passed below as true in all cases. This is because editable logic will trump view_button logic; ie. if editable is true, view_button will never be looked at{% endcomment %}
{% if portfolio_permission %}
- {% include "includes/summary_item.html" with title='Domain assignment' domain_mgmt=True value=portfolio_permission.get_managed_domains_count edit_link=domains_url editable=has_edit_members_portfolio_permission view_button=True %}
+ {% include "includes/summary_item.html" with title='Domain assignments' domain_mgmt=True value=portfolio_permission.get_managed_domains_count edit_link=domains_url editable=has_edit_members_portfolio_permission view_button=True %}
{% elif portfolio_invitation %}
- {% include "includes/summary_item.html" with title='Domain assignment' domain_mgmt=True value=portfolio_invitation.get_managed_domains_count edit_link=domains_url editable=has_edit_members_portfolio_permission view_button=True %}
+ {% include "includes/summary_item.html" with title='Domain assignments' domain_mgmt=True value=portfolio_invitation.get_managed_domains_count edit_link=domains_url editable=has_edit_members_portfolio_permission view_button=True %}
{% endif %}
From fe1780fbb921138e88a2faea6a734b5a2681f30c Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Mon, 10 Feb 2025 17:07:56 -0500
Subject: [PATCH 033/125] wip
---
src/registrar/templates/portfolio_member.html | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html
index 46e0cced6..b270e03fb 100644
--- a/src/registrar/templates/portfolio_member.html
+++ b/src/registrar/templates/portfolio_member.html
@@ -65,7 +65,7 @@ Organization member
- Last active:
+ Last active:
{% if member and member.last_login %}
{{ member.last_login }}
{% elif portfolio_invitation %}
@@ -75,7 +75,7 @@ Organization member
{% endif %}
- Full name:
+ Full name:
{% if member %}
{% if member.first_name or member.last_name %}
{{ member.get_formatted_name }}
@@ -87,7 +87,7 @@ Organization member
{% endif %}
- Title or organization role:
+ Title or organization role:
{% if member and member.title %}
{{ member.title }}
{% else %}
From 5216cb4a53ca9e8dc4e4b1aeaed01c60f12ff4ac Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Mon, 10 Feb 2025 17:15:28 -0500
Subject: [PATCH 034/125] wip
---
src/registrar/templates/portfolio_members_add_new.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/registrar/templates/portfolio_members_add_new.html b/src/registrar/templates/portfolio_members_add_new.html
index c3a648bdc..715ad53ab 100644
--- a/src/registrar/templates/portfolio_members_add_new.html
+++ b/src/registrar/templates/portfolio_members_add_new.html
@@ -67,7 +67,7 @@
{% include "includes/member_basic_permissions.html" %}
-
Domain management
+
Domain assignments
After you invite this person to your organization, you can assign domain management permissions on their member profile.
From e031d04fa5cdb9559a318dcce806b412b35ced6a Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Mon, 10 Feb 2025 17:23:18 -0500
Subject: [PATCH 035/125] lint
---
src/registrar/tests/test_views_portfolio.py | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py
index c00892eca..e45f65f6f 100644
--- a/src/registrar/tests/test_views_portfolio.py
+++ b/src/registrar/tests/test_views_portfolio.py
@@ -951,9 +951,7 @@ class TestPortfolio(WebTest):
self.assertContains(response, "Admin")
self.assertContains(response, "Creator")
self.assertContains(response, "Manager")
- self.assertContains(
- response, 'This member does not manage any domains.'
- )
+ self.assertContains(response, "This member does not manage any domains.")
# Assert buttons and links within the page are correct
self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present
@@ -1066,9 +1064,7 @@ class TestPortfolio(WebTest):
self.assertContains(response, "Viewer")
self.assertContains(response, "Creator")
self.assertContains(response, "Manager")
- self.assertContains(
- response, 'This member does not manage any domains.'
- )
+ self.assertContains(response, "This member does not manage any domains.")
# Assert buttons and links within the page are correct
self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
From 819fd35e591f2db7af19191e792d0c73e1a9780e Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Mon, 10 Feb 2025 18:04:17 -0500
Subject: [PATCH 036/125] remove last active on member page
---
src/registrar/templates/portfolio_member.html | 10 ----------
1 file changed, 10 deletions(-)
diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html
index b270e03fb..b4b54291b 100644
--- a/src/registrar/templates/portfolio_member.html
+++ b/src/registrar/templates/portfolio_member.html
@@ -65,16 +65,6 @@ Organization member
- Last active:
- {% if member and member.last_login %}
- {{ member.last_login }}
- {% elif portfolio_invitation %}
- Invited
- {% else %}
- ⎯
- {% endif %}
-
-
Full name:
{% if member %}
{% if member.first_name or member.last_name %}
From aa253a85dad9602c82e84786a4b3efb2c560c4d1 Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Mon, 10 Feb 2025 18:09:13 -0500
Subject: [PATCH 037/125] wip
---
src/registrar/assets/src/js/getgov/table-members.js | 2 +-
src/registrar/tests/test_views_portfolio.py | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/registrar/assets/src/js/getgov/table-members.js b/src/registrar/assets/src/js/getgov/table-members.js
index e876ce0f9..bb2d8c185 100644
--- a/src/registrar/assets/src/js/getgov/table-members.js
+++ b/src/registrar/assets/src/js/getgov/table-members.js
@@ -108,7 +108,7 @@ export class MembersTable extends BaseTable {
`;
- showMoreRow.innerHTML = `
${domainsHTML} ${permissionsHTML}
`;
+ showMoreRow.innerHTML = `
${domainsHTML} ${permissionsHTML}
`;
showMoreRow.classList.add('show-more-content');
showMoreRow.classList.add('display-none');
showMoreRow.id = unique_id;
diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py
index e45f65f6f..052155bb0 100644
--- a/src/registrar/tests/test_views_portfolio.py
+++ b/src/registrar/tests/test_views_portfolio.py
@@ -956,7 +956,7 @@ class TestPortfolio(WebTest):
# Assert buttons and links within the page are correct
self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
- self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
+ self.assertContains(response, "sprite.svg#edit") # test that Manage link is present
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
@less_console_noise_decorator
@@ -1068,7 +1068,7 @@ class TestPortfolio(WebTest):
# Assert buttons and links within the page are correct
self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
- self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
+ self.assertContains(response, "sprite.svg#edit") # test that Manage link is present
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
@less_console_noise_decorator
From d84aa421d90ba2d3a26bb998f428f3643ecf1841 Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Mon, 10 Feb 2025 19:23:50 -0500
Subject: [PATCH 038/125] wip
---
src/djangooidc/views.py | 2 +
src/registrar/config/settings.py | 2 +
src/registrar/decorators.py | 72 ++++++++++++++++++++
src/registrar/registrar_middleware.py | 39 +++++++++++
src/registrar/utility/domain_cache_helper.py | 0
src/registrar/views/domain_requests_json.py | 3 +-
src/registrar/views/domains_json.py | 3 +-
src/registrar/views/index.py | 2 +
8 files changed, 121 insertions(+), 2 deletions(-)
create mode 100644 src/registrar/decorators.py
create mode 100644 src/registrar/utility/domain_cache_helper.py
diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py
index 815df4ecf..984936a4c 100644
--- a/src/djangooidc/views.py
+++ b/src/djangooidc/views.py
@@ -5,12 +5,14 @@ import logging
from django.conf import settings
from django.contrib.auth import logout as auth_logout
from django.contrib.auth import authenticate, login
+from login_required import login_not_required
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
from urllib.parse import parse_qs, urlencode
from djangooidc.oidc import Client
from djangooidc import exceptions as o_e
+from registrar.decorators import grant_access
from registrar.models import User
from registrar.views.utility.error_views import custom_500_error_view, custom_401_error_view
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
index 78439188e..9b51383f3 100644
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -200,6 +200,8 @@ MIDDLEWARE = [
"waffle.middleware.WaffleMiddleware",
"registrar.registrar_middleware.CheckUserProfileMiddleware",
"registrar.registrar_middleware.CheckPortfolioMiddleware",
+ # Restrict access using Opt-Out approach
+ "registrar.registrar_middleware.RestrictAccessMiddleware",
]
# application object used by Django's built-in servers (e.g. `runserver`)
diff --git a/src/registrar/decorators.py b/src/registrar/decorators.py
new file mode 100644
index 000000000..08214f89c
--- /dev/null
+++ b/src/registrar/decorators.py
@@ -0,0 +1,72 @@
+from functools import wraps
+from django.http import JsonResponse
+from django.core.exceptions import ObjectDoesNotExist
+
+from registrar.models.domain import Domain
+from registrar.models.user_domain_role import UserDomainRole
+
+# Constants for clarity
+ALL = "all"
+IS_SUPERUSER = "is_superuser"
+IS_STAFF = "is_staff"
+IS_DOMAIN_MANAGER = "is_domain_manager"
+
+def grant_access(*rules):
+ """
+ Allows multiple rules in a single decorator call:
+ @grant_access(IS_STAFF, IS_SUPERUSER, IS_DOMAIN_MANAGER)
+ or multiple stacked decorators:
+ @grant_access(IS_SUPERUSER)
+ @grant_access(IS_DOMAIN_MANAGER)
+ """
+
+ def decorator(view_func):
+ view_func.has_explicit_access = True # Mark as explicitly access-controlled
+ existing_rules = getattr(view_func, "_access_rules", set())
+ existing_rules.update(rules) # Support multiple rules in one call
+ view_func._access_rules = existing_rules # Store rules on the function
+
+ @wraps(view_func)
+ def wrapper(request, *args, **kwargs):
+ user = request.user
+
+ # Skip authentication if @login_not_required is applied
+ if getattr(view_func, "login_not_required", False):
+ return view_func(request, *args, **kwargs)
+
+ # Allow everyone if `ALL` is in rules
+ if ALL in view_func._access_rules:
+ return view_func(request, *args, **kwargs)
+
+ # Ensure user is authenticated
+ if not user.is_authenticated:
+ return JsonResponse({"error": "Authentication required"}, status=403)
+
+ conditions_met = []
+
+ if IS_STAFF in view_func._access_rules:
+ conditions_met.append(user.is_staff)
+
+ if not any(conditions_met) and IS_SUPERUSER in view_func._access_rules:
+ conditions_met.append(user.is_superuser)
+
+ if not any(conditions_met) and IS_DOMAIN_MANAGER in view_func._access_rules:
+ domain_id = kwargs.get('pk') or kwargs.get('domain_id')
+ if not domain_id:
+ return JsonResponse({"error": "Domain ID missing"}, status=400)
+ try:
+ domain = Domain.objects.get(pk=domain_id)
+ has_permission = UserDomainRole.objects.filter(
+ user=user, domain=domain
+ ).exists()
+ conditions_met.append(has_permission)
+ except ObjectDoesNotExist:
+ return JsonResponse({"error": "Invalid Domain"}, status=404)
+
+ if not any(conditions_met):
+ return JsonResponse({"error": "Access Denied"}, status=403)
+
+ return view_func(request, *args, **kwargs)
+
+ return wrapper
+ return decorator
diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py
index ed7a4dffc..c23fb0515 100644
--- a/src/registrar/registrar_middleware.py
+++ b/src/registrar/registrar_middleware.py
@@ -3,9 +3,13 @@ Contains middleware used in settings.py
"""
import logging
+import re
from urllib.parse import parse_qs
+from django.conf import settings
from django.urls import reverse
from django.http import HttpResponseRedirect
+from django.http import JsonResponse
+from django.urls import resolve
from registrar.models import User
from waffle.decorators import flag_is_active
@@ -170,3 +174,38 @@ class CheckPortfolioMiddleware:
request.session["portfolio"] = request.user.get_first_portfolio()
else:
request.session["portfolio"] = request.user.get_first_portfolio()
+
+
+class RestrictAccessMiddleware:
+ """ Middleware that blocks all views unless explicitly permitted """
+
+ def __init__(self, get_response):
+ self.get_response = get_response
+ self.ignored_paths = [re.compile(pattern) for pattern in getattr(settings, "LOGIN_REQUIRED_IGNORE_PATHS", [])]
+
+ def __call__(self, request):
+ # Allow requests that match LOGIN_REQUIRED_IGNORE_PATHS
+ if any(pattern.match(request.path) for pattern in self.ignored_paths):
+ return self.get_response(request)
+
+ # Try to resolve the view function
+ try:
+ resolver_match = resolve(request.path_info)
+ view_func = resolver_match.func
+ app_name = resolver_match.app_name # Get app name of resolved view
+ except Exception:
+ return JsonResponse({"error": "Not Found"}, status=404)
+
+ # Auto-allow Django's built-in admin views (but NOT custom /admin/* views)
+ if app_name == "admin":
+ return self.get_response(request)
+
+ # Skip access restriction if the view explicitly allows unauthenticated access
+ if getattr(view_func, "login_required", True) is False:
+ return self.get_response(request)
+
+ # Enforce explicit access fules for other views
+ if not getattr(view_func, "has_explicit_access", False):
+ return JsonResponse({"error": "Access Denied"}, status=403)
+
+ return self.get_response(request)
\ No newline at end of file
diff --git a/src/registrar/utility/domain_cache_helper.py b/src/registrar/utility/domain_cache_helper.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/registrar/views/domain_requests_json.py b/src/registrar/views/domain_requests_json.py
index 88590010e..b6a9d1072 100644
--- a/src/registrar/views/domain_requests_json.py
+++ b/src/registrar/views/domain_requests_json.py
@@ -1,5 +1,6 @@
from django.http import JsonResponse
from django.core.paginator import Paginator
+from registrar.decorators import grant_access, ALL
from registrar.models import DomainRequest
from django.utils.dateformat import format
from django.contrib.auth.decorators import login_required
@@ -7,7 +8,7 @@ from django.urls import reverse
from django.db.models import Q
-@login_required
+@grant_access(ALL)
def get_domain_requests_json(request):
"""Given the current request,
get all domain requests that are associated with the request user and exclude the APPROVED ones.
diff --git a/src/registrar/views/domains_json.py b/src/registrar/views/domains_json.py
index 8734ef89c..e0081450d 100644
--- a/src/registrar/views/domains_json.py
+++ b/src/registrar/views/domains_json.py
@@ -1,6 +1,7 @@
import logging
from django.http import JsonResponse
from django.core.paginator import Paginator
+from registrar.decorators import grant_access, ALL
from registrar.models import UserDomainRole, Domain, DomainInformation, User
from django.contrib.auth.decorators import login_required
from django.urls import reverse
@@ -9,7 +10,7 @@ from django.db.models import Q
logger = logging.getLogger(__name__)
-@login_required
+@grant_access(ALL)
def get_domains_json(request):
"""Given the current request,
get all domains that are associated with the UserDomainRole object"""
diff --git a/src/registrar/views/index.py b/src/registrar/views/index.py
index be7149018..d9ff9b209 100644
--- a/src/registrar/views/index.py
+++ b/src/registrar/views/index.py
@@ -1,6 +1,8 @@
from django.shortcuts import render
+from registrar.decorators import grant_access, ALL
+@grant_access(ALL)
def index(request):
"""This page is available to anyone without logging in."""
context = {}
From a334f7dc3ef02c2aaa2a8bdb7f9fa43d5ac9e60d Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Tue, 11 Feb 2025 10:06:24 -0500
Subject: [PATCH 039/125] wip
---
src/registrar/config/urls.py | 29 +--
src/registrar/decorators.py | 176 +++++++++++++-----
src/registrar/registrar_middleware.py | 8 +-
src/registrar/templates/domain_add_user.html | 6 +-
src/registrar/templates/domain_detail.html | 20 +-
src/registrar/templates/domain_dns.html | 8 +-
src/registrar/templates/domain_dnssec.html | 6 +-
src/registrar/templates/domain_dsdata.html | 6 +-
.../templates/domain_nameservers.html | 4 +-
src/registrar/templates/domain_renewal.html | 8 +-
.../templates/domain_security_email.html | 2 +-
src/registrar/templates/domain_sidebar.html | 8 +-
.../templates/domain_suborganization.html | 2 +-
src/registrar/templates/domain_users.html | 8 +-
src/registrar/views/domain.py | 41 ++--
src/registrar/views/domains_json.py | 2 +-
src/registrar/views/utility/error_views.py | 8 +
src/registrar/views/utility/mixins.py | 4 +-
.../views/utility/permission_views.py | 1 +
19 files changed, 216 insertions(+), 131 deletions(-)
diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py
index eb095c5ca..27ad01dc8 100644
--- a/src/registrar/config/urls.py
+++ b/src/registrar/config/urls.py
@@ -296,56 +296,56 @@ urlpatterns = [
lambda r: always_404(r, "We forgot to include this link, sorry."),
name="todo",
),
- path("domain/", views.DomainView.as_view(), name="domain"),
- path("domain//prototype-dns", views.PrototypeDomainDNSRecordView.as_view(), name="prototype-domain-dns"),
- path("domain//users", views.DomainUsersView.as_view(), name="domain-users"),
+ path("domain/", views.DomainView.as_view(), name="domain"),
+ path("domain//prototype-dns", views.PrototypeDomainDNSRecordView.as_view(), name="prototype-domain-dns"),
+ path("domain//users", views.DomainUsersView.as_view(), name="domain-users"),
path(
- "domain//dns",
+ "domain//dns",
views.DomainDNSView.as_view(),
name="domain-dns",
),
path(
- "domain//dns/nameservers",
+ "domain//dns/nameservers",
views.DomainNameserversView.as_view(),
name="domain-dns-nameservers",
),
path(
- "domain//dns/dnssec",
+ "domain//dns/dnssec",
views.DomainDNSSECView.as_view(),
name="domain-dns-dnssec",
),
path(
- "domain//dns/dnssec/dsdata",
+ "domain//dns/dnssec/dsdata",
views.DomainDsDataView.as_view(),
name="domain-dns-dnssec-dsdata",
),
path(
- "domain//org-name-address",
+ "domain//org-name-address",
views.DomainOrgNameAddressView.as_view(),
name="domain-org-name-address",
),
path(
- "domain//suborganization",
+ "domain//suborganization",
views.DomainSubOrganizationView.as_view(),
name="domain-suborganization",
),
path(
- "domain//senior-official",
+ "domain//senior-official",
views.DomainSeniorOfficialView.as_view(),
name="domain-senior-official",
),
path(
- "domain//security-email",
+ "domain//security-email",
views.DomainSecurityEmailView.as_view(),
name="domain-security-email",
),
path(
- "domain//renewal",
+ "domain//renewal",
views.DomainRenewalView.as_view(),
name="domain-renewal",
),
path(
- "domain//users/add",
+ "domain//users/add",
views.DomainAddUserView.as_view(),
name="domain-users-add",
),
@@ -370,7 +370,7 @@ urlpatterns = [
name="domain-request-delete",
),
path(
- "domain//users//delete",
+ "domain//users//delete",
views.DomainDeleteUserView.as_view(http_method_names=["post"]),
name="domain-user-delete",
),
@@ -392,6 +392,7 @@ urlpatterns = [
# This way, we can share a view for djangooidc, and other pages as we see fit.
handler500 = "registrar.views.utility.error_views.custom_500_error_view"
handler403 = "registrar.views.utility.error_views.custom_403_error_view"
+handler404 = "registrar.views.utility.error_views.custom_404_error_view"
# we normally would guard these with `if settings.DEBUG` but tests run with
# DEBUG = False even when these apps have been loaded because settings.DEBUG
diff --git a/src/registrar/decorators.py b/src/registrar/decorators.py
index 08214f89c..177dfab62 100644
--- a/src/registrar/decorators.py
+++ b/src/registrar/decorators.py
@@ -1,15 +1,14 @@
-from functools import wraps
-from django.http import JsonResponse
-from django.core.exceptions import ObjectDoesNotExist
-
-from registrar.models.domain import Domain
-from registrar.models.user_domain_role import UserDomainRole
+import functools
+from django.core.exceptions import PermissionDenied
+from django.utils.decorators import method_decorator
+from registrar.models import DomainInformation, DomainRequest, UserDomainRole
# Constants for clarity
ALL = "all"
IS_SUPERUSER = "is_superuser"
IS_STAFF = "is_staff"
IS_DOMAIN_MANAGER = "is_domain_manager"
+IS_STAFF_MANAGING_DOMAIN = "is_staff_managing_domain"
def grant_access(*rules):
"""
@@ -20,53 +19,128 @@ def grant_access(*rules):
@grant_access(IS_DOMAIN_MANAGER)
"""
- def decorator(view_func):
- view_func.has_explicit_access = True # Mark as explicitly access-controlled
- existing_rules = getattr(view_func, "_access_rules", set())
- existing_rules.update(rules) # Support multiple rules in one call
- view_func._access_rules = existing_rules # Store rules on the function
+ def decorator(view):
+ if isinstance(view, type): # If decorating a class-based view (CBV)
+ original_dispatch = view.dispatch # save original dispatch method
- @wraps(view_func)
- def wrapper(request, *args, **kwargs):
- user = request.user
-
- # Skip authentication if @login_not_required is applied
- if getattr(view_func, "login_not_required", False):
- return view_func(request, *args, **kwargs)
-
- # Allow everyone if `ALL` is in rules
- if ALL in view_func._access_rules:
- return view_func(request, *args, **kwargs)
+ @method_decorator(grant_access(*rules)) # apply the decorator to dispatch
+ def wrapped_dispatch(self, request, *args, **kwargs):
+ if not _user_has_permission(request.user, request, rules, **kwargs):
+ raise PermissionDenied
+ return original_dispatch(self, request, *args, **kwargs)
- # Ensure user is authenticated
- if not user.is_authenticated:
- return JsonResponse({"error": "Authentication required"}, status=403)
+ view.dispatch = wrapped_dispatch # replace dispatch with wrapped version
+ return view
- conditions_met = []
+ else: # If decorating a function-based view (FBV)
+ view.has_explicit_access = True
+ existing_rules = getattr(view, "_access_rules", set())
+ existing_rules.update(rules)
+ view._access_rules = existing_rules
- if IS_STAFF in view_func._access_rules:
- conditions_met.append(user.is_staff)
-
- if not any(conditions_met) and IS_SUPERUSER in view_func._access_rules:
- conditions_met.append(user.is_superuser)
-
- if not any(conditions_met) and IS_DOMAIN_MANAGER in view_func._access_rules:
- domain_id = kwargs.get('pk') or kwargs.get('domain_id')
- if not domain_id:
- return JsonResponse({"error": "Domain ID missing"}, status=400)
- try:
- domain = Domain.objects.get(pk=domain_id)
- has_permission = UserDomainRole.objects.filter(
- user=user, domain=domain
- ).exists()
- conditions_met.append(has_permission)
- except ObjectDoesNotExist:
- return JsonResponse({"error": "Invalid Domain"}, status=404)
-
- if not any(conditions_met):
- return JsonResponse({"error": "Access Denied"}, status=403)
-
- return view_func(request, *args, **kwargs)
-
- return wrapper
+ @functools.wraps(view)
+ def wrapper(request, *args, **kwargs):
+ if not _user_has_permission(request.user, request, rules, **kwargs):
+ raise PermissionDenied
+ return view(request, *args, **kwargs)
+
+ return wrapper
+
return decorator
+
+
+def _user_has_permission(user, request, rules, **kwargs):
+ """
+ Checks if the user meets the permission requirements.
+ """
+
+ # Skip authentication if @login_not_required is applied
+ if getattr(request, "login_not_required", False):
+ return True
+
+ # Allow everyone if `ALL` is in rules
+ if ALL in rules:
+ return True
+
+ # Ensure user is authenticated
+ if not user.is_authenticated:
+ return False
+
+ conditions_met = []
+
+ if IS_STAFF in rules:
+ conditions_met.append(user.is_staff)
+
+ if not any(conditions_met) and IS_SUPERUSER in rules:
+ conditions_met.append(user.is_superuser)
+
+ if not any(conditions_met) and IS_DOMAIN_MANAGER in rules:
+ domain_id = kwargs.get('domain_pk')
+ # Check UserDomainRole directly instead of fetching Domain
+ has_permission = UserDomainRole.objects.filter(user=user, domain_id=domain_id).exists()
+ conditions_met.append(has_permission)
+
+ if not any(conditions_met) and IS_STAFF_MANAGING_DOMAIN in rules:
+ domain_id = kwargs.get('domain_pk')
+ has_permission = _can_access_other_user_domains(request, domain_id)
+ conditions_met.append(has_permission)
+
+ return any(conditions_met)
+
+
+def _can_access_other_user_domains(request, domain_pk):
+ """Checks to see if an authorized user (staff or superuser)
+ can access a domain that they did not create or were invited to.
+ """
+
+ # Check if the request user is permissioned...
+ user_is_analyst_or_superuser = request.user.has_perm(
+ "registrar.analyst_access_permission"
+ ) or request.user.has_perm("registrar.full_access_permission")
+
+ if not user_is_analyst_or_superuser:
+ return False
+
+ # Check if the user is attempting a valid edit action.
+ # In other words, if the analyst/admin did not click
+ # the 'Manage Domain' button in /admin,
+ # then they cannot access this page.
+ session = request.session
+ can_do_action = (
+ "analyst_action" in session
+ and "analyst_action_location" in session
+ and session["analyst_action_location"] == domain_pk
+ )
+
+ if not can_do_action:
+ return False
+
+ # Analysts may manage domains, when they are in these statuses:
+ valid_domain_statuses = [
+ DomainRequest.DomainRequestStatus.APPROVED,
+ DomainRequest.DomainRequestStatus.IN_REVIEW,
+ DomainRequest.DomainRequestStatus.REJECTED,
+ DomainRequest.DomainRequestStatus.ACTION_NEEDED,
+ # Edge case - some domains do not have
+ # a status or DomainInformation... aka a status of 'None'.
+ # It is necessary to access those to correct errors.
+ None,
+ ]
+
+ requested_domain = DomainInformation.objects.filter(domain_id=domain_pk).first()
+
+ # if no domain information or domain request exist, the user
+ # should be able to manage the domain; however, if domain information
+ # and domain request exist, and domain request is not in valid status,
+ # user should not be able to manage domain
+ if (
+ requested_domain
+ and requested_domain.domain_request
+ and requested_domain.domain_request.status not in valid_domain_statuses
+ ):
+ return False
+
+ # Valid session keys exist,
+ # the user is permissioned,
+ # and it is in a valid status
+ return True
diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py
index c23fb0515..e3dcbe788 100644
--- a/src/registrar/registrar_middleware.py
+++ b/src/registrar/registrar_middleware.py
@@ -6,9 +6,9 @@ import logging
import re
from urllib.parse import parse_qs
from django.conf import settings
+from django.core.exceptions import PermissionDenied
from django.urls import reverse
from django.http import HttpResponseRedirect
-from django.http import JsonResponse
from django.urls import resolve
from registrar.models import User
from waffle.decorators import flag_is_active
@@ -182,7 +182,7 @@ class RestrictAccessMiddleware:
def __init__(self, get_response):
self.get_response = get_response
self.ignored_paths = [re.compile(pattern) for pattern in getattr(settings, "LOGIN_REQUIRED_IGNORE_PATHS", [])]
-
+
def __call__(self, request):
# Allow requests that match LOGIN_REQUIRED_IGNORE_PATHS
if any(pattern.match(request.path) for pattern in self.ignored_paths):
@@ -194,7 +194,7 @@ class RestrictAccessMiddleware:
view_func = resolver_match.func
app_name = resolver_match.app_name # Get app name of resolved view
except Exception:
- return JsonResponse({"error": "Not Found"}, status=404)
+ return self.get_response(request)
# Auto-allow Django's built-in admin views (but NOT custom /admin/* views)
if app_name == "admin":
@@ -206,6 +206,6 @@ class RestrictAccessMiddleware:
# Enforce explicit access fules for other views
if not getattr(view_func, "has_explicit_access", False):
- return JsonResponse({"error": "Access Denied"}, status=403)
+ raise PermissionDenied
return self.get_response(request)
\ No newline at end of file
diff --git a/src/registrar/templates/domain_add_user.html b/src/registrar/templates/domain_add_user.html
index 04565f61e..abc549a82 100644
--- a/src/registrar/templates/domain_add_user.html
+++ b/src/registrar/templates/domain_add_user.html
@@ -16,10 +16,10 @@
Domains
{% endif %}
diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py
index 3248c1368..bcefbbc77 100644
--- a/src/registrar/views/domain_request.py
+++ b/src/registrar/views/domain_request.py
@@ -5,28 +5,27 @@ from django.shortcuts import redirect, render
from django.urls import resolve, reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
-from django.views.generic import TemplateView
+from django.views.generic import DeleteView, DetailView, TemplateView
from django.contrib import messages
+from registrar.decorators import (
+ HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT,
+ HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL,
+ IS_DOMAIN_REQUEST_CREATOR,
+ grant_access,
+)
from registrar.forms import domain_request_wizard as forms
from registrar.forms.utility.wizard_form_helper import request_step_list
from registrar.models import DomainRequest
from registrar.models.contact import Contact
from registrar.models.user import User
from registrar.views.utility import StepsHelper
-from registrar.views.utility.permission_views import DomainRequestPermissionDeleteView
from registrar.utility.enums import Step, PortfolioDomainRequestStep
-from .utility import (
- DomainRequestPermissionView,
- DomainRequestPermissionWithdrawView,
- DomainRequestWizardPermissionView,
- DomainRequestPortfolioViewonlyView,
-)
-
logger = logging.getLogger(__name__)
-class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
+@grant_access(IS_DOMAIN_REQUEST_CREATOR, HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT)
+class DomainRequestWizard(TemplateView):
"""
A common set of methods and configuration.
@@ -51,7 +50,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# NB: this is included here for reference. Do not change it without
# also changing the many places it is hardcoded in the HTML templates
URL_NAMESPACE = "domain-request"
- # name for accessing /domain-request//edit
+ # name for accessing /domain-request//edit
EDIT_URL_NAME = "edit-domain-request"
NEW_URL_NAME = "start"
FINISHED_URL_NAME = "finished"
@@ -174,7 +173,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
def has_pk(self):
"""Does this wizard know about a DomainRequest database record?"""
- return bool(self.kwargs.get("id") is not None)
+ return bool(self.kwargs.get("domain_request_pk") is not None)
def get_step_enum(self):
"""Determines which step enum we should use for the wizard"""
@@ -209,11 +208,11 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
try:
self._domain_request = DomainRequest.objects.get(
creator=creator,
- pk=self.kwargs.get("id"),
+ pk=self.kwargs.get("domain_request_pk"),
)
return self._domain_request
except DomainRequest.DoesNotExist:
- logger.debug("DomainRequest id %s did not have a DomainRequest" % id)
+ logger.debug("DomainRequest id %s did not have a DomainRequest" % self.kwargs.get("domain_request_pk"))
# If a user is creating a request, we assume that perms are handled upstream
if self.request.user.is_org_user(self.request):
@@ -292,10 +291,10 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
current_url = resolve(request.path_info).url_name
- # if user visited via an "edit" url, associate the id of the
+ # if user visited via an "edit" url, associate the pk of the
# domain request they are trying to edit to this wizard instance
# and remove any prior wizard data from their session
- if current_url == self.EDIT_URL_NAME and "id" in kwargs:
+ if current_url == self.EDIT_URL_NAME and "domain_request_pk" in kwargs:
del self.storage
# if accessing this class directly, redirect to either to an acknowledgement
@@ -474,7 +473,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
def goto(self, step):
self.steps.current = step
- return redirect(reverse(f"{self.URL_NAMESPACE}:{step}", kwargs={"id": self.domain_request.id}))
+ return redirect(reverse(f"{self.URL_NAMESPACE}:{step}", kwargs={"domain_request_pk": self.domain_request.id}))
def goto_next_step(self):
"""Redirects to the next step."""
@@ -823,23 +822,12 @@ class Finished(DomainRequestWizard):
return render(self.request, self.template_name, context)
-class DomainRequestStatus(DomainRequestPermissionView):
+@grant_access(IS_DOMAIN_REQUEST_CREATOR, HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT)
+class DomainRequestStatus(DetailView):
template_name = "domain_request_status.html"
-
- def has_permission(self):
- """
- Override of the base has_permission class to account for portfolio permissions
- """
- has_base_perms = super().has_permission()
- if not has_base_perms:
- return False
-
- if self.request.user.is_org_user(self.request):
- portfolio = self.request.session.get("portfolio")
- if not self.request.user.has_edit_request_portfolio_permission(portfolio):
- return False
-
- return True
+ model = DomainRequest
+ pk_url_kwarg = "domain_request_pk"
+ context_object_name = "DomainRequest"
def get_context_data(self, **kwargs):
"""Context override to add a step list to the context"""
@@ -854,19 +842,27 @@ class DomainRequestStatus(DomainRequestPermissionView):
return context
-class DomainRequestWithdrawConfirmation(DomainRequestPermissionWithdrawView):
+@grant_access(IS_DOMAIN_REQUEST_CREATOR)
+class DomainRequestWithdrawConfirmation(DetailView):
"""This page will ask user to confirm if they want to withdraw
- The DomainRequestPermissionView restricts access so that only the
+ Access is restricted so that only the
`creator` of the domain request may withdraw it.
"""
- template_name = "domain_request_withdraw_confirmation.html"
+ template_name = "domain_request_withdraw_confirmation.html" # DetailView property for what model this is viewing
+ model = DomainRequest
+ pk_url_kwarg = "domain_request_pk"
+ context_object_name = "DomainRequest"
-class DomainRequestWithdrawn(DomainRequestPermissionWithdrawView):
+@grant_access(IS_DOMAIN_REQUEST_CREATOR)
+class DomainRequestWithdrawn(DetailView):
# this view renders no template
template_name = ""
+ model = DomainRequest
+ pk_url_kwarg = "domain_request_pk"
+ context_object_name = "DomainRequest"
def get(self, *args, **kwargs):
"""View class that does the actual withdrawing.
@@ -874,7 +870,7 @@ class DomainRequestWithdrawn(DomainRequestPermissionWithdrawView):
If user click on withdraw confirm button, this view updates the status
to withdraw and send back to homepage.
"""
- domain_request = DomainRequest.objects.get(id=self.kwargs["pk"])
+ domain_request = DomainRequest.objects.get(id=self.kwargs["domain_request_pk"])
domain_request.withdraw()
domain_request.save()
if self.request.user.is_org_user(self.request):
@@ -883,28 +879,22 @@ class DomainRequestWithdrawn(DomainRequestPermissionWithdrawView):
return HttpResponseRedirect(reverse("home"))
-class DomainRequestDeleteView(DomainRequestPermissionDeleteView):
+@grant_access(IS_DOMAIN_REQUEST_CREATOR, HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT)
+class DomainRequestDeleteView(DeleteView):
"""Delete view for home that allows the end user to delete DomainRequests"""
object: DomainRequest # workaround for type mismatch in DeleteView
+ model = DomainRequest
+ pk_url_kwarg = "domain_request_pk"
def has_permission(self):
"""Custom override for has_permission to exclude all statuses, except WITHDRAWN and STARTED"""
- has_perm = super().has_permission()
- if not has_perm:
- return False
status = self.get_object().status
valid_statuses = [DomainRequest.DomainRequestStatus.WITHDRAWN, DomainRequest.DomainRequestStatus.STARTED]
if status not in valid_statuses:
return False
- # Portfolio users cannot delete their requests if they aren't permissioned to do so
- if self.request.user.is_org_user(self.request):
- portfolio = self.request.session.get("portfolio")
- if not self.request.user.has_edit_request_portfolio_permission(portfolio):
- return False
-
return True
def get_success_url(self):
@@ -989,8 +979,12 @@ class DomainRequestDeleteView(DomainRequestPermissionDeleteView):
# region Portfolio views
-class PortfolioDomainRequestStatusViewOnly(DomainRequestPortfolioViewonlyView):
+@grant_access(HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL)
+class PortfolioDomainRequestStatusViewOnly(DetailView):
template_name = "portfolio_domain_request_status_viewonly.html"
+ model = DomainRequest
+ pk_url_kwarg = "domain_request_pk"
+ context_object_name = "DomainRequest"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
diff --git a/src/registrar/views/domain_requests_json.py b/src/registrar/views/domain_requests_json.py
index b6a9d1072..4e475321c 100644
--- a/src/registrar/views/domain_requests_json.py
+++ b/src/registrar/views/domain_requests_json.py
@@ -155,9 +155,9 @@ def serialize_domain_request(request, domain_request, user):
# Map the action label to corresponding URLs and icons
action_url_map = {
- "Edit": reverse("edit-domain-request", kwargs={"id": domain_request.id}),
- "Manage": reverse("domain-request-status", kwargs={"pk": domain_request.id}),
- "View": reverse("domain-request-status-viewonly", kwargs={"pk": domain_request.id}),
+ "Edit": reverse("edit-domain-request", kwargs={"domain_request_pk": domain_request.id}),
+ "Manage": reverse("domain-request-status", kwargs={"domain_request_pk": domain_request.id}),
+ "View": reverse("domain-request-status-viewonly", kwargs={"domain_request_pk": domain_request.id}),
}
svg_icon_map = {"Edit": "edit", "Manage": "settings", "View": "visibility"}
diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py
index 0f93ec8e1..9f864324f 100644
--- a/src/registrar/views/portfolios.py
+++ b/src/registrar/views/portfolios.py
@@ -6,6 +6,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.contrib import messages
+from registrar.decorators import HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM, grant_access
from registrar.forms import portfolio as portfolioForms
from registrar.models import Portfolio, User
from registrar.models.domain import Domain
@@ -25,7 +26,6 @@ from registrar.utility.errors import MissingEmailError
from registrar.utility.enums import DefaultUserValues
from registrar.views.utility.mixins import PortfolioMemberPermission
from registrar.views.utility.permission_views import (
- PortfolioDomainRequestsPermissionView,
PortfolioDomainsPermissionView,
PortfolioBasePermissionView,
NoPortfolioDomainsPermissionView,
@@ -58,7 +58,8 @@ class PortfolioDomainsView(PortfolioDomainsPermissionView, View):
return render(request, "portfolio_domains.html", context)
-class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View):
+@grant_access(HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM)
+class PortfolioDomainRequestsView(View):
template_name = "portfolio_requests.html"
diff --git a/src/registrar/views/utility/__init__.py b/src/registrar/views/utility/__init__.py
index 6798eb4ee..4a33c9944 100644
--- a/src/registrar/views/utility/__init__.py
+++ b/src/registrar/views/utility/__init__.py
@@ -3,11 +3,7 @@ from .always_404 import always_404
from .permission_views import (
DomainPermissionView,
- DomainRequestPermissionView,
- DomainRequestPermissionWithdrawView,
- DomainRequestWizardPermissionView,
PortfolioMembersPermission,
- DomainRequestPortfolioViewonlyView,
DomainInvitationPermissionCancelView,
)
from .api_views import get_senior_official_from_federal_agency_json
diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py
index 23bcff162..682d9ffcb 100644
--- a/src/registrar/views/utility/mixins.py
+++ b/src/registrar/views/utility/mixins.py
@@ -286,51 +286,6 @@ class DomainPermission(PermissionsLoginMixin):
return True
-class DomainRequestPermission(PermissionsLoginMixin):
- """Permission mixin that redirects to domain request if user
- has access, otherwise 403"""
-
- def has_permission(self):
- """Check if this user has access to this domain request.
-
- The user is in self.request.user and the domain needs to be looked
- up from the domain's primary key in self.kwargs["pk"]
- """
- if not self.request.user.is_authenticated:
- return False
-
- # user needs to be the creator of the domain request
- # this query is empty if there isn't a domain request with this
- # id and this user as creator
- if not DomainRequest.objects.filter(creator=self.request.user, id=self.kwargs["pk"]).exists():
- return False
-
- return True
-
-
-class DomainRequestPortfolioViewonlyPermission(PermissionsLoginMixin):
- """Permission mixin that redirects to domain request if user
- has access, otherwise 403"""
-
- def has_permission(self):
- """Check if this user has access to this domain request.
-
- The user is in self.request.user and the domain needs to be looked
- up from the domain's primary key in self.kwargs["pk"]
- """
- if not self.request.user.is_authenticated:
- return False
-
- if not self.request.user.is_org_user(self.request):
- return False
-
- portfolio = self.request.session.get("portfolio")
- if not self.request.user.has_view_all_requests_portfolio_permission(portfolio):
- return False
-
- return True
-
-
class UserDeleteDomainRolePermission(PermissionsLoginMixin):
"""Permission mixin for UserDomainRole if user
has access, otherwise 403"""
@@ -365,67 +320,6 @@ class UserDeleteDomainRolePermission(PermissionsLoginMixin):
return True
-class DomainRequestPermissionWithdraw(PermissionsLoginMixin):
- """Permission mixin that redirects to withdraw action on domain request
- if user has access, otherwise 403"""
-
- def has_permission(self):
- """Check if this user has access to withdraw this domain request."""
- if not self.request.user.is_authenticated:
- return False
-
- # user needs to be the creator of the domain request
- # this query is empty if there isn't a domain request with this
- # id and this user as creator
- if not DomainRequest.objects.filter(creator=self.request.user, id=self.kwargs["pk"]).exists():
- return False
-
- # Restricted users should not be able to withdraw domain requests
- if self.request.user.is_restricted():
- return False
-
- return True
-
-
-class DomainRequestWizardPermission(PermissionsLoginMixin):
- """Permission mixin that redirects to start or edit domain request if
- user has access, otherwise 403"""
-
- def has_permission(self):
- """Check if this user has permission to start or edit a domain request.
-
- The user is in self.request.user
- """
-
- if not self.request.user.is_authenticated:
- return False
-
- # The user has an ineligible flag
- if self.request.user.is_restricted():
- return False
-
- # If the user is an org user and doesn't have add/edit perms, forbid this
- if self.request.user.is_org_user(self.request):
- portfolio = self.request.session.get("portfolio")
- if not self.request.user.has_edit_request_portfolio_permission(portfolio):
- return False
-
- # user needs to be the creator of the domain request to edit it.
- id = self.kwargs.get("id") if hasattr(self, "kwargs") else None
- if not id:
- domain_request_wizard = self.request.session.get("wizard_domain_request")
- if domain_request_wizard:
- id = domain_request_wizard.get("domain_request_id")
-
- # If no id is provided, we can assume that the user is starting a new request.
- # If one IS provided, check that they are the original creator of it.
- if id:
- if not DomainRequest.objects.filter(creator=self.request.user, id=id).exists():
- return False
-
- return True
-
-
class DomainInvitationPermission(PermissionsLoginMixin):
"""Permission mixin that redirects to domain invitation if user has
access, otherwise 403"
@@ -496,23 +390,6 @@ class PortfolioDomainsPermission(PortfolioBasePermission):
return super().has_permission()
-class PortfolioDomainRequestsPermission(PortfolioBasePermission):
- """Permission mixin that allows access to portfolio domain request pages if user
- has access, otherwise 403"""
-
- def has_permission(self):
- """Check if this user has access to domain requests for this portfolio.
-
- The user is in self.request.user and the portfolio can be looked
- up from the portfolio's primary key in self.kwargs["pk"]"""
-
- portfolio = self.request.session.get("portfolio")
- if not self.request.user.has_any_requests_portfolio_permission(portfolio):
- return False
-
- return super().has_permission()
-
-
class PortfolioMembersPermission(PortfolioBasePermission):
"""Permission mixin that allows access to portfolio members pages if user
has access, otherwise 403"""
diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py
index b430845f4..41d620d42 100644
--- a/src/registrar/views/utility/permission_views.py
+++ b/src/registrar/views/utility/permission_views.py
@@ -2,18 +2,14 @@
import abc # abstract base class
-from django.views.generic import DetailView, DeleteView, TemplateView, UpdateView
-from registrar.models import Domain, DomainRequest, DomainInvitation, Portfolio
+from django.views.generic import DetailView, DeleteView, UpdateView
+from registrar.models import Domain, DomainInvitation, Portfolio
from registrar.models.user import User
from registrar.models.user_domain_role import UserDomainRole
from .mixins import (
DomainPermission,
- DomainRequestPermission,
- DomainRequestPermissionWithdraw,
DomainInvitationPermission,
- DomainRequestWizardPermission,
- PortfolioDomainRequestsPermission,
PortfolioDomainsPermission,
PortfolioMemberDomainsPermission,
PortfolioMemberDomainsEditPermission,
@@ -23,7 +19,6 @@ from .mixins import (
PortfolioBasePermission,
PortfolioMembersPermission,
PortfolioMemberPermission,
- DomainRequestPortfolioViewonlyPermission,
)
import logging
@@ -86,77 +81,6 @@ class DomainPermissionView(DomainPermission, DetailView, abc.ABC):
raise NotImplementedError
-class DomainRequestPermissionView(DomainRequestPermission, DetailView, abc.ABC):
- """Abstract base view for domain requests that enforces permissions
-
- This abstract view cannot be instantiated. Actual views must specify
- `template_name`.
- """
-
- # DetailView property for what model this is viewing
- model = DomainRequest
- # variable name in template context for the model object
- context_object_name = "DomainRequest"
-
- # Abstract property enforces NotImplementedError on an attribute.
- @property
- @abc.abstractmethod
- def template_name(self):
- raise NotImplementedError
-
-
-class DomainRequestPortfolioViewonlyView(DomainRequestPortfolioViewonlyPermission, DetailView, abc.ABC):
- """Abstract base view for domain requests that enforces permissions
-
- This abstract view cannot be instantiated. Actual views must specify
- `template_name`.
- """
-
- # DetailView property for what model this is viewing
- model = DomainRequest
- # variable name in template context for the model object
- context_object_name = "DomainRequest"
-
- # Abstract property enforces NotImplementedError on an attribute.
- @property
- @abc.abstractmethod
- def template_name(self):
- raise NotImplementedError
-
-
-class DomainRequestPermissionWithdrawView(DomainRequestPermissionWithdraw, DetailView, abc.ABC):
- """Abstract base view for domain request withdraw function
-
- This abstract view cannot be instantiated. Actual views must specify
- `template_name`.
- """
-
- # DetailView property for what model this is viewing
- model = DomainRequest
- # variable name in template context for the model object
- context_object_name = "DomainRequest"
-
- # Abstract property enforces NotImplementedError on an attribute.
- @property
- @abc.abstractmethod
- def template_name(self):
- raise NotImplementedError
-
-
-class DomainRequestWizardPermissionView(DomainRequestWizardPermission, TemplateView, abc.ABC):
- """Abstract base view for the domain request form that enforces permissions
-
- This abstract view cannot be instantiated. Actual views must specify
- `template_name`.
- """
-
- # Abstract property enforces NotImplementedError on an attribute.
- @property
- @abc.abstractmethod
- def template_name(self):
- raise NotImplementedError
-
-
class DomainInvitationPermissionCancelView(DomainInvitationPermission, UpdateView, abc.ABC):
"""Abstract view for cancelling a DomainInvitation."""
@@ -164,13 +88,6 @@ class DomainInvitationPermissionCancelView(DomainInvitationPermission, UpdateVie
object: DomainInvitation
-class DomainRequestPermissionDeleteView(DomainRequestPermission, DeleteView, abc.ABC):
- """Abstract view for deleting a DomainRequest."""
-
- model = DomainRequest
- object: DomainRequest
-
-
class UserDomainRolePermissionDeleteView(UserDeleteDomainRolePermission, DeleteView, abc.ABC):
"""Abstract base view for deleting a UserDomainRole.
@@ -242,14 +159,6 @@ class NoPortfolioDomainsPermissionView(PortfolioBasePermissionView, abc.ABC):
"""
-class PortfolioDomainRequestsPermissionView(PortfolioDomainRequestsPermission, PortfolioBasePermissionView, abc.ABC):
- """Abstract base view for portfolio domain request views that enforces permissions.
-
- This abstract view cannot be instantiated. Actual views must specify
- `template_name`.
- """
-
-
class PortfolioMembersPermissionView(PortfolioMembersPermission, PortfolioBasePermissionView, abc.ABC):
"""Abstract base view for portfolio members views that enforces permissions.
From 40737cbcf7beb4e0d1af167e0e2898c0410eb7cb Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Tue, 11 Feb 2025 23:47:55 -0500
Subject: [PATCH 048/125] wip
---
src/registrar/decorators.py | 14 ++++++++++-
.../includes/domain_sidenav_item.html | 2 +-
src/registrar/views/domain.py | 7 ++++--
src/registrar/views/domain_request.py | 2 +-
src/registrar/views/domain_requests_json.py | 20 +++++++--------
src/registrar/views/portfolios.py | 18 ++++++++-----
src/registrar/views/utility/mixins.py | 17 -------------
.../views/utility/permission_views.py | 25 ++++++-------------
8 files changed, 49 insertions(+), 56 deletions(-)
diff --git a/src/registrar/decorators.py b/src/registrar/decorators.py
index cd19bcc95..fe71342cc 100644
--- a/src/registrar/decorators.py
+++ b/src/registrar/decorators.py
@@ -10,8 +10,10 @@ IS_STAFF = "is_staff"
IS_DOMAIN_MANAGER = "is_domain_manager"
IS_DOMAIN_REQUEST_CREATOR = "is_domain_request_creator"
IS_STAFF_MANAGING_DOMAIN = "is_staff_managing_domain"
+IS_PORTFOLIO_MEMBER = "is_portfolio_member"
IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER = "is_portfolio_member_and_domain_manager"
IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER = "is_domain_manager_and_not_portfolio_member"
+HAS_PORTFOLIO_DOMAINS_ANY_PERM = "has_portfolio_domains_any_perm"
HAS_PORTFOLIO_DOMAINS_VIEW_ALL = "has_portfolio_domains_view_all"
HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM = "has_portfolio_domain_requests_any_perm"
HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL = "has_portfolio_domain_requests_view_all"
@@ -97,11 +99,21 @@ def _user_has_permission(user, request, rules, **kwargs):
has_permission = _can_access_other_user_domains(request, domain_id)
conditions_met.append(has_permission)
+ if not any(conditions_met) and IS_PORTFOLIO_MEMBER in rules:
+ has_permission = user.is_org_user(request)
+ conditions_met.append(has_permission)
+
if not any(conditions_met) and HAS_PORTFOLIO_DOMAINS_VIEW_ALL in rules:
domain_id = kwargs.get("domain_pk")
has_permission = _can_access_domain_via_portfolio_view_all_domains(request, domain_id)
conditions_met.append(has_permission)
+ if not any(conditions_met) and HAS_PORTFOLIO_DOMAINS_ANY_PERM in rules:
+ has_permission = user.is_org_user(request) and user.has_any_domains_portfolio_permission(
+ request.session.get("portfolio")
+ )
+ conditions_met.append(has_permission)
+
if not any(conditions_met) and IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER in rules:
domain_id = kwargs.get("domain_pk")
has_permission = _is_domain_manager(user, domain_id) and _is_portfolio_member(request)
@@ -142,7 +154,7 @@ def _has_portfolio_domain_requests_edit(user, request, domain_request_id):
if domain_request_id and not _is_domain_request_creator(user, domain_request_id):
return False
return user.is_org_user(request) and user.has_edit_request_portfolio_permission(request.session.get("portfolio"))
-
+
def _is_domain_manager(user, domain_pk):
"""Checks to see if the user is a domain manager of the
diff --git a/src/registrar/templates/includes/domain_sidenav_item.html b/src/registrar/templates/includes/domain_sidenav_item.html
index 206e49c05..715f76865 100644
--- a/src/registrar/templates/includes/domain_sidenav_item.html
+++ b/src/registrar/templates/includes/domain_sidenav_item.html
@@ -1,6 +1,6 @@
- Oct 16, 24:00 UTC: We're investigating an outage on the .gov registrar. The .gov zone and individual domains remain online. However, you can’t request a new domain or manage an existing one at this time.
+ Oct 16, 24:00 UTC: We're investigating an outage on the .gov registrar. The .gov zone and individual domains remain online. However, you can't request a new domain or manage an existing one at this time.
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index cfdc866d0..9447d211f 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -526,7 +526,7 @@ class TestDomainInvitationAdmin(WebTest):
# Assert error message
mock_messages_error.assert_called_once_with(
- request, "Can’t send invitation email. No email is associated with your user account."
+ request, "Can't send invitation email. No email is associated with your user account."
)
# Assert the invitations were saved
@@ -595,7 +595,7 @@ class TestDomainInvitationAdmin(WebTest):
# Assert error message
mock_messages_error.assert_called_once_with(
- request, "Can’t send invitation email. No email is associated with your user account."
+ request, "Can't send invitation email. No email is associated with your user account."
)
# Assert the invitations were saved
@@ -661,7 +661,7 @@ class TestDomainInvitationAdmin(WebTest):
# Assert error message
mock_messages_error.assert_called_once_with(
- request, "Can’t send invitation email. No email is associated with your user account."
+ request, "Can't send invitation email. No email is associated with your user account."
)
# Assert the invitations were saved
@@ -943,7 +943,7 @@ class TestDomainInvitationAdmin(WebTest):
# Assert error message
mock_messages_error.assert_called_once_with(
- request, "Can’t send invitation email. No email is associated with your user account."
+ request, "Can't send invitation email. No email is associated with your user account."
)
# Assert the invitations were saved
@@ -1010,7 +1010,7 @@ class TestDomainInvitationAdmin(WebTest):
# Assert error message
mock_messages_error.assert_called_once_with(
- request, "Can’t send invitation email. No email is associated with your user account."
+ request, "Can't send invitation email. No email is associated with your user account."
)
# Assert the invitations were saved
@@ -1076,7 +1076,7 @@ class TestDomainInvitationAdmin(WebTest):
# Assert error message
mock_messages_error.assert_called_once_with(
- request, "Can’t send invitation email. No email is associated with your user account."
+ request, "Can't send invitation email. No email is associated with your user account."
)
# Assert the invitations were saved
@@ -1484,7 +1484,7 @@ class TestPortfolioInvitationAdmin(TestCase):
# Assert that messages.error was called with the correct message
mock_messages_error.assert_called_once_with(
request,
- "Can’t send invitation email. No email is associated with your user account.",
+ "Can't send invitation email. No email is associated with your user account.",
)
@less_console_noise_decorator
diff --git a/src/registrar/tests/test_email_invitations.py b/src/registrar/tests/test_email_invitations.py
index 4d9e286aa..77a8c402f 100644
--- a/src/registrar/tests/test_email_invitations.py
+++ b/src/registrar/tests/test_email_invitations.py
@@ -531,7 +531,7 @@ class PortfolioInvitationEmailTests(unittest.TestCase):
send_portfolio_invitation_email(self.email, self.requestor, self.portfolio, is_admin_invitation)
self.assertIn(
- "Can’t send invitation email. No email is associated with your user account.", str(context.exception)
+ "Can't send invitation email. No email is associated with your user account.", str(context.exception)
)
@less_console_noise_decorator
@@ -869,7 +869,7 @@ class SendPortfolioAdminRemovalEmailsTests(unittest.TestCase):
mock_get_requestor_email.assert_called_once_with(self.requestor, portfolio=self.portfolio)
mock_send_removal_emails.assert_not_called() # Should not proceed if email retrieval fails
self.assertEqual(
- str(context.exception), "Can’t send invitation email. No email is associated with your user account."
+ str(context.exception), "Can't send invitation email. No email is associated with your user account."
)
@less_console_noise_decorator
diff --git a/src/registrar/tests/test_nameserver_error.py b/src/registrar/tests/test_nameserver_error.py
index 35e218fd2..be9a26f6f 100644
--- a/src/registrar/tests/test_nameserver_error.py
+++ b/src/registrar/tests/test_nameserver_error.py
@@ -20,7 +20,7 @@ class TestNameserverError(TestCase):
"""Test NameserverError when no ip address
and no nameserver is passed"""
nameserver = "nameserver val"
- expected = "You can’t have more than 13 nameservers."
+ expected = "You can't have more than 13 nameservers."
nsException = NameserverError(code=nsErrorCodes.TOO_MANY_HOSTS, nameserver=nameserver)
self.assertEqual(nsException.message, expected)
diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py
index de1d53ebb..b84d284d8 100644
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -1331,7 +1331,7 @@ class TestDomainManagers(TestDomainOverview):
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit()
- expected_message_content = "Can’t send invitation email. No email is associated with your user account."
+ expected_message_content = "Can't send invitation email. No email is associated with your user account."
# Assert that the error message was called with the correct argument
mock_error.assert_called_once_with(
diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py
index 176437b5c..052155bb0 100644
--- a/src/registrar/tests/test_views_portfolio.py
+++ b/src/registrar/tests/test_views_portfolio.py
@@ -1696,16 +1696,12 @@ class TestPortfolioMemberDeleteView(WebTest):
self.assertEqual(response.status_code, 400) # Bad request due to active requests
support_url = "https://get.gov/contact/"
expected_error_message = (
- "This member can\u2019t be removed from the organization because they have an active domain request. "
+ "This member can't be removed from the organization because they have an active domain request. "
f"Please contact us "
"to remove this member."
)
- # The curly apostrophe \u2019 requires us to do a bit more work before we can assert
- response_json = json.loads(response.content.decode("utf-8"))
- self.assertEqual(response.status_code, 400) # Ensure it's a bad request
- self.assertIn("error", response_json) # Ensure the "error" key exists
- self.assertEqual(response_json["error"], expected_error_message) # Compare actual vs expected message
+ self.assertContains(response, expected_error_message, status_code=400)
# assert that send_portfolio_admin_removal_emails is not called
send_removal_emails.assert_not_called()
@@ -1960,7 +1956,7 @@ class TestPortfolioMemberDeleteView(WebTest):
support_url = "https://get.gov/contact/"
expected_error_message = (
- "This member can’t be removed from the organization because they have an active domain request. "
+ "This member can't be removed from the organization because they have an active domain request. "
f"Please contact us "
"to remove this member."
)
diff --git a/src/registrar/utility/errors.py b/src/registrar/utility/errors.py
index c7a353f67..0a6f00c36 100644
--- a/src/registrar/utility/errors.py
+++ b/src/registrar/utility/errors.py
@@ -48,13 +48,13 @@ class MissingEmailError(InvitationError):
def __init__(self, email=None, domain=None, portfolio=None):
# Default message if no additional info is provided
- message = "Can’t send invitation email. No email is associated with your user account."
+ message = "Can't send invitation email. No email is associated with your user account."
# Customize message based on provided arguments
if email and domain:
- message = f"Can’t send email to '{email}' on domain '{domain}'. No email exists for the requestor."
+ message = f"Can't send email to '{email}' on domain '{domain}'. No email exists for the requestor."
elif email and portfolio:
- message = f"Can’t send email to '{email}' for portfolio '{portfolio}'. No email exists for the requestor."
+ message = f"Can't send email to '{email}' for portfolio '{portfolio}'. No email exists for the requestor."
super().__init__(message)
@@ -201,7 +201,7 @@ class NameserverError(Exception):
NameserverErrorCodes.MISSING_IP: ("Using your domain for a name server requires an IP address."),
NameserverErrorCodes.GLUE_RECORD_NOT_ALLOWED: ("Name server address does not match domain name"),
NameserverErrorCodes.INVALID_IP: ("{}: Enter an IP address in the required format."),
- NameserverErrorCodes.TOO_MANY_HOSTS: ("You can’t have more than 13 nameservers."),
+ NameserverErrorCodes.TOO_MANY_HOSTS: ("You can't have more than 13 nameservers."),
NameserverErrorCodes.MISSING_HOST: ("You must provide a name server to enter an IP address."),
NameserverErrorCodes.INVALID_HOST: ("Enter a name server in the required format, like ns1.example.com"),
NameserverErrorCodes.DUPLICATE_HOST: (
diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py
index be91cc06a..2fdafe9ca 100644
--- a/src/registrar/views/portfolios.py
+++ b/src/registrar/views/portfolios.py
@@ -935,7 +935,7 @@ class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin):
elif isinstance(exception, MissingEmailError):
messages.error(self.request, str(exception))
logger.error(
- f"Can’t send email to '{email}' for portfolio '{portfolio}'. No email exists for the requestor.",
+ f"Can't send email to '{email}' for portfolio '{portfolio}'. No email exists for the requestor.",
exc_info=True,
)
else:
From 34914b4da4ae6249828bb64da585c7598488f05f Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Thu, 13 Feb 2025 14:08:48 -0500
Subject: [PATCH 073/125] revert apostrophe work
---
src/registrar/views/portfolios.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py
index 2fdafe9ca..0f93ec8e1 100644
--- a/src/registrar/views/portfolios.py
+++ b/src/registrar/views/portfolios.py
@@ -127,7 +127,7 @@ class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
if active_requests_count > 0:
# If they have any in progress requests
error_message = mark_safe( # nosec
- "This member can\u2019t be removed from the organization because they have an active domain request. "
+ "This member can't be removed from the organization because they have an active domain request. "
f"Please contact us to remove this member."
)
elif member.is_only_admin_of_portfolio(portfolio_member_permission.portfolio):
From 1b4f09ec767ec11b134a73e0527d019f70249e24 Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Thu, 13 Feb 2025 14:36:47 -0500
Subject: [PATCH 074/125] Domain managers page
---
src/registrar/templates/domain_users.html | 19 ++++++++++++-------
src/registrar/views/domain.py | 15 ---------------
2 files changed, 12 insertions(+), 22 deletions(-)
diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html
index c6e6baa93..aa39a994e 100644
--- a/src/registrar/templates/domain_users.html
+++ b/src/registrar/templates/domain_users.html
@@ -33,21 +33,26 @@
{% else %}
- Domain managers can update all information related to a domain within the
- .gov registrar, including contact details, senior official, security email, and DNS name servers.
+ Domain managers can update all information related to a domain, including contact details, senior official, security email, and DNS name servers.
{% endif %}
-
There is no limit to the number of domain managers you can add.
-
After adding a domain manager, an email invitation will be sent to that user with
- instructions on how to set up an account.
+
There is no limit on the number of domain managers you can add.
All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.
- {% if not portfolio %}
All domain managers will be notified when updates are made to this domain.
{% endif %}
-
Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain.
+
All domain managers will be notified when updates are made to this domain and when managers are added or removed.
+
Domains must have at least one manager. You can’t remove yourself if you’re the only one assigned to this domain.
{% if portfolio %} Add another domain manager before you remove yourself from this domain.{% endif %}
+ {% if domain_manager_roles and domain_manager_roles|length == 1 %}
+
+
+ This domain has only one manager. Consider adding another manager to ensure the domain has continuous oversight and support.
+
+
+ {% endif %}
+
{% if domain_manager_roles %}
diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py
index 089bbe1a9..34a4957c2 100644
--- a/src/registrar/views/domain.py
+++ b/src/registrar/views/domain.py
@@ -1116,21 +1116,6 @@ class DomainUsersView(DomainBaseView):
return context
- def get(self, request, *args, **kwargs):
- """Get method for DomainUsersView."""
- # Call the parent class's `get` method to get the response and context
- response = super().get(request, *args, **kwargs)
-
- # Ensure context is available after the parent call
- context = response.context_data if hasattr(response, "context_data") else {}
-
- # Check if context contains `domain_managers_roles` and its length is 1
- if context.get("domain_manager_roles") and len(context["domain_manager_roles"]) == 1:
- # Add an info message
- messages.info(request, "This domain has one manager. Adding more can prevent issues.")
-
- return response
-
def _add_domain_manager_roles_to_context(self, context, portfolio):
"""Add domain_manager_roles to context separately, as roles need admin indicator."""
From 95a3fd157b3d1379ad3b5950888521ffb55eb465 Mon Sep 17 00:00:00 2001
From: Rachid Mrad
Date: Thu, 13 Feb 2025 14:43:29 -0500
Subject: [PATCH 075/125] Add a manager
---
src/registrar/templates/domain_add_user.html | 19 ++++++++++---------
src/registrar/templates/domain_users.html | 3 +--
2 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/src/registrar/templates/domain_add_user.html b/src/registrar/templates/domain_add_user.html
index 04565f61e..5c350f720 100644
--- a/src/registrar/templates/domain_add_user.html
+++ b/src/registrar/templates/domain_add_user.html
@@ -42,17 +42,18 @@
{% endblock breadcrumb %}
Add a domain manager
-{% if has_organization_feature_flag %}
+ {% if portfolio %}
+
+ Provide an email address for the domain manager you’d like to add.
+ They’ll need to access the registrar using a Login.gov account that’s associated with this email address.
+ Domain managers can be a member of only one .gov organization.
+
+ {% else %}
- You can add another user to help manage your domain. Users can only be a member of one .gov organization,
- and they'll need to sign in with their Login.gov account.
+ Provide an email address for the domain manager you’d like to add.
+ They’ll need to access the registrar using a Login.gov account that’s associated with this email address.
-{% else %}
-
- You can add another user to help manage your domain. They will need to sign in to the .gov registrar with
- their Login.gov account.
-